Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 189 additions & 17 deletions frontend/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import { test, expect } from "@playwright/test";

import { setupAuth, waitForAuthenticatedLayout } from "./helpers/auth";

/**
* 認証フロー E2E テスト
*
* 動線方針: 未ログインでも職務経歴書を入力でき(お試し入力)、保存時にログインを促す。
* `/career` はログイン強制せず、`/login` は明示ログイン・OAuth エラー着地用に残す。
*/

test.describe("未認証ユーザー", () => {
test("ルートへのアクセスは /login へリダイレクトされる", async ({ page }) => {
/** localhost:8000 への全リクエストを 404 で返すキャッチオール(master-data 等を解決させる)。 */
async function routeBackendNotFound(page: import("@playwright/test").Page) {
await page.route("http://localhost:8000/**", (route) =>
route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ code: "NOT_FOUND", message: "not found" }),
}),
);
}

test.describe("未認証ユーザー(お試し入力)", () => {
test("ルート / は /login ではなく職務経歴書のお試し入力画面を表示する", async ({ page }) => {
await routeBackendNotFound(page);
await page.route("**/auth/me", (route) =>
route.fulfill({
status: 401,
Expand All @@ -15,13 +32,23 @@ test.describe("未認証ユーザー", () => {
);

await page.goto("/");
await page.waitForURL("**/login", { timeout: 5_000 });

// ログインボタンが表示されることを確認する
await expect(page.getByRole("button", { name: "GitHubでログイン" })).toBeVisible();
// /login へリダイレクトされず、職務経歴書フォームが表示される
await expect(page.getByRole("heading", { name: "職務経歴書" })).toBeVisible();
await expect(page).toHaveURL(/\/career/);
// 氏名を入力できる(お試し入力)
await page.getByPlaceholder("例: 山田 太郎").fill("山田太郎");
await expect(page.getByPlaceholder("例: 山田 太郎")).toHaveValue("山田太郎");

// サイドバー: ユーザーメニュー位置にログイントリガーが表示される
await expect(page.getByRole("button", { name: "ログイン" })).toBeVisible();
// GitHub連携 / ブログ連携 は表示され、押下でログイン導線になる(非活性ではない)
await expect(page.getByRole("button", { name: "GitHub連携" })).toBeEnabled();
await expect(page.getByRole("button", { name: "ブログ連携" })).toBeEnabled();
});

test("/career へ直接アクセスすると /login にリダイレクトされる", async ({ page }) => {
test("未ログインで連携メニューを押すとログイン促進モーダルが出る", async ({ page }) => {
await routeBackendNotFound(page);
await page.route("**/auth/me", (route) =>
route.fulfill({
status: 401,
Expand All @@ -31,22 +58,40 @@ test.describe("未認証ユーザー", () => {
);

await page.goto("/career");
await page.waitForURL("**/login", { timeout: 5_000 });
await page.getByRole("button", { name: "GitHub連携" }).click();
await expect(
page.getByRole("heading", { name: "ログインして保存しましょう" }),
).toBeVisible();
});

test("未ログインでプレビューを押すとログイン促進モーダルが出る", async ({ page }) => {
await routeBackendNotFound(page);
await page.route("**/auth/me", (route) =>
route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "Unauthorized" }),
}),
);

await expect(page.getByRole("button", { name: "GitHubでログイン" })).toBeVisible();
await page.goto("/career");
await page.getByRole("button", { name: "プレビュー" }).click();
await expect(
page.getByRole("heading", { name: "ログインして保存しましょう" }),
).toBeVisible();
});

test("GitHubでログインボタンを押すと /auth/github/login-url が呼ばれる", async ({ page }) => {
test("未ログインで保存しようとするとログイン促進モーダルが出る", async ({ page }) => {
let loginUrlRequest: string | null = null;

await routeBackendNotFound(page);
await page.route("**/auth/me", (route) =>
route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "Unauthorized" }),
}),
);

await page.route("**/auth/github/login-url*", (route) => {
loginUrlRequest = route.request().url();
return route.fulfill({
Expand All @@ -58,19 +103,146 @@ test.describe("未認証ユーザー", () => {
}),
});
});

// mock OAuth URL への遷移を止める
await page.route("**/mock-github-oauth*", (route) =>
route.fulfill({ status: 200, contentType: "text/html", body: "<html></html>" }),
);

await page.goto("/career");
await page.getByPlaceholder("例: 山田 太郎").fill("山田太郎");
await page.getByRole("button", { name: "保存する" }).click();

// ログイン促進モーダルが表示される
await expect(
page.getByRole("heading", { name: "ログインして保存しましょう" }),
).toBeVisible();

// 入力ドラフトが sessionStorage に退避される(ログイン往復で失わないため)
expect(
await page.evaluate(() => sessionStorage.getItem("career_draft")),
).toContain("山田太郎");

// モーダルの GitHub ログインで OAuth 開始 URL が呼ばれる
await page.getByRole("button", { name: "GitHubでログイン" }).click();
await expect.poll(() => loginUrlRequest).not.toBeNull();
expect(loginUrlRequest).toContain("/auth/github/login-url");
expect(loginUrlRequest).toContain("return_to=");
});

test("退避ドラフトはログイン後に自動保存され失われない(新規ユーザー)", async ({ page }) => {
// 認証済み(サイドバー付き)状態をセットアップ。
await setupAuth(page);
// API はホスト名非依存(Vite プロキシ経由のため)でモックする。
// 未モックの /api/* が実バックエンドに到達して 401 を返すと _onUnauthorized で
// 匿名へ戻ってしまうため、必要なエンドポイントをすべて塞ぐ。
await page.route("**/api/master-data/qualification", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
);
await page.route("**/api/master-data/technology-stack", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
);
// 新規ユーザー: 既存経歴書なし(404)。
await page.route("**/api/resumes/latest", (route) =>
route.fulfill({
status: 200,
contentType: "text/html",
body: "<html><body>mock oauth</body></html>",
status: 404,
contentType: "application/json",
body: JSON.stringify({ code: "NOT_FOUND", message: "not found" }),
}),
);
// 自動保存の POST を捕捉する。
let createdName: string | null = null;
await page.route("**/api/resumes", (route) => {
if (route.request().method() === "POST") {
createdName = route.request().postDataJSON()?.full_name ?? null;
return route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
id: "new-resume-id",
full_name: createdName ?? "",
career_summary: "",
self_pr: "",
experiences: [],
qualifications: [],
}),
});
}
return route.fulfill({ status: 404, contentType: "application/json", body: "{}" });
});

// ログイン往復から復帰した状態を再現するため、アプリ起動前にドラフトを sessionStorage へ仕込む。
await page.addInitScript(() => {
sessionStorage.setItem(
"career_draft",
JSON.stringify({
full_name: "山田太郎",
career_summary: "テスト要約",
self_pr: "テスト自己PR",
experiences: [
{
company: "株式会社テスト",
business_description: "受託開発",
start_date: "2020-04",
end_date: null,
is_current: true,
is_it_company: true,
description: "",
employee_count: "100",
capital: "1",
capital_unit: "万円",
clients: [],
},
],
qualifications: [],
}),
);
});

await page.goto("/career");
await waitForAuthenticatedLayout(page);
// サイドバーのナビゲーション(認証済みレイアウト専用)が出ていることを確認する。
await expect(page.getByRole("link", { name: "職務経歴書" })).toBeVisible();

// 退避ドラフトが自動保存(POST)され、入力した氏名が送られる。
await expect.poll(() => createdName).toBe("山田太郎");
// フォームにも氏名が反映され、入力は失われていない。
await expect(page.getByPlaceholder("例: 山田 太郎")).toHaveValue("山田太郎");
// 退避領域は消費後に破棄される。
await expect
.poll(() => page.evaluate(() => sessionStorage.getItem("career_draft")))
.toBeNull();
});
});

test.describe("ログイン画面", () => {
test("/login の GitHubでログインボタンを押すと /auth/github/login-url が呼ばれる", async ({
page,
}) => {
let loginUrlRequest: string | null = null;

await routeBackendNotFound(page);
await page.route("**/auth/me", (route) =>
route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ detail: "Unauthorized" }),
}),
);
await page.route("**/auth/github/login-url*", (route) => {
loginUrlRequest = route.request().url();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
authorization_url: "http://localhost:5173/mock-github-oauth",
state: "mock-state",
}),
});
});
await page.route("**/mock-github-oauth*", (route) =>
route.fulfill({ status: 200, contentType: "text/html", body: "<html></html>" }),
);

await page.goto("/login");
await page.waitForURL("**/login", { timeout: 5_000 });

await page.getByRole("button", { name: "GitHubでログイン" }).click();
await expect.poll(() => loginUrlRequest).not.toBeNull();
expect(loginUrlRequest).toContain("/auth/github/login-url");
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@
padding-left: calc(1.2rem - 3px);
}


/* ── GitHub連携: 展開グループ(フォーク含むオプション)──────────── */

.sidebarItemGroup {
Expand Down Expand Up @@ -195,6 +196,7 @@
border-top: 1px solid var(--sidebar-border);
}


.mainContent {
flex: 1;
margin-left: var(--sidebar-width);
Expand Down
25 changes: 14 additions & 11 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ErrorBoundary from "./components/ErrorBoundary";
import { LoginPromptProvider } from "./components/auth/LoginPromptProvider";
import { useAuthSession } from "./hooks/useAuthSession";
import { useTheme } from "./hooks/useTheme";
import { AppRoutes } from "./router";
Expand All @@ -14,17 +15,19 @@ export default function App() {

return (
<ErrorBoundary>
<AppRoutes
user={user}
authLoading={authLoading}
theme={theme}
onToggleTheme={toggleTheme}
githubError={githubError}
onLogout={() => {
void handleLogout();
}}
onLoginSuccess={handleLoginSuccess}
/>
<LoginPromptProvider>
<AppRoutes
user={user}
authLoading={authLoading}
theme={theme}
onToggleTheme={toggleTheme}
githubError={githubError}
onLogout={() => {
void handleLogout();
}}
onLoginSuccess={handleLoginSuccess}
/>
</LoginPromptProvider>
</ErrorBoundary>
);
}
Loading
Loading