diff --git a/.claude/rules/common/duplication.md b/.claude/rules/common/duplication.md index a42c0acc..325f19ed 100644 --- a/.claude/rules/common/duplication.md +++ b/.claude/rules/common/duplication.md @@ -92,6 +92,12 @@ PR 前に 1 度走らせて、新規重複が増えていないか確認する - `minTokens: 50` / `minLines: 5` — これ未満の小片は無視 - `threshold: 0` — Phase 1 は fail させない(baseline 確定後に引き上げ) +### symlink 採用に伴う計測の前提(infra) + +`infra/environments/{dev,stg,prod}/{variables,moved,outputs}.tf` は `../shared/.tf` への symlink で 1 ファイルに物理統合してある。jscpd は symlink 経由で同一ファイルを 3 回スキャンすると 100% 重複として誤検出するため、`.jscpd.json` の `ignore` で 9 件(3 環境 × 3 ファイル)を明示除外している。 + +そのため `make dupe-check` の HCL 重複率は「真の重複ゼロ」ではなく「symlink 除外後の重複ゼロ」を意味する。`infra/environments/shared/` 配下の正本だけがスキャン対象になっている前提で読むこと。新規に環境別 symlink を増やす場合は同様に `.jscpd.json` に追記する。 + ### AI レビュー (refacter skill) - 領域内: `BE_refacter` / `FE_refacter` / `INFRA_refacter` diff --git a/.claude/rules/infra/opentofu.md b/.claude/rules/infra/opentofu.md index a690dc8a..d2a90c7d 100644 --- a/.claude/rules/infra/opentofu.md +++ b/.claude/rules/infra/opentofu.md @@ -20,3 +20,8 @@ DB は Turso (libSQL) を使用。Terraform 対象外で `turso CLI` 手動管 - 重複検知 / DRY ポリシーは `.claude/rules/common/duplication.md` を参照 - `environments/{dev,stg,prod}` で同じ resource block をコピペしている場合は `modules/` 化を検討する(環境差分は `variable` で吸収) +- `environments/{dev,stg,prod}/{variables,moved,outputs}.tf` は `../shared/.tf` への symlink で物理統合済み。新規ファイルを 3 環境で揃える場合も同じパターンで shared 化し、`.jscpd.json` の ignore に追記する + +## monitoring の責務分割 + +`infra/modules/monitoring/` は責務別にファイル分割している(`notification_channels.tf` / `uptime.tf` / `auth_failures.tf` / `rate_limits.tf` / `task_failures.tf`)。**新規 alert を追加するときは既存ファイルに混ぜず、責務に対応するファイルへ追加するか、新しい責務であれば `monitoring/<新規責務>.tf` を新設する**。1 ファイルに alert を集約すると「監視増→ファイル肥大→責務不明瞭」が再発する。 diff --git a/backend/app/schemas/career_analysis.py b/backend/app/schemas/career_analysis.py index 125f6ed3..64013dd8 100644 --- a/backend/app/schemas/career_analysis.py +++ b/backend/app/schemas/career_analysis.py @@ -4,9 +4,6 @@ from pydantic import BaseModel, ConfigDict, Field -# 互換のため再エクスポート。新規実装は schemas.shared から import すること。 -from .shared import TaskStatusResponse # noqa: F401 - class TechStackItem(BaseModel): """技術スタック1件。""" diff --git a/backend/app/services/pdf/generators/intelligence_generator.py b/backend/app/services/pdf/generators/intelligence_generator.py index 2909d85e..0ddcae37 100644 --- a/backend/app/services/pdf/generators/intelligence_generator.py +++ b/backend/app/services/pdf/generators/intelligence_generator.py @@ -30,6 +30,24 @@ def _add_page_number(canvas_obj, doc): canvas_obj.restoreState() +def _table_style( + header_range: tuple[tuple[int, int], tuple[int, int]], + valign: str, +) -> TableStyle: + """グリッド・ヘッダ背景・パディングをまとめた共通 TableStyle を返す。""" + return TableStyle( + [ + ("GRID", (0, 0), (-1, -1), 0.5, TABLE_BORDER), + ("BACKGROUND", header_range[0], header_range[1], HEADER_BG), + ("VALIGN", (0, 0), (-1, -1), valign), + ("TOPPADDING", (0, 0), (-1, -1), 3), + ("BOTTOMPADDING", (0, 0), (-1, -1), 3), + ("LEFTPADDING", (0, 0), (-1, -1), 4), + ("RIGHTPADDING", (0, 0), (-1, -1), 4), + ] + ) + + def build_intelligence_pdf(payload: dict) -> bytes: buffer = BytesIO() doc = SimpleDocTemplate( @@ -68,19 +86,7 @@ def build_intelligence_pdf(payload: dict) -> bytes: [Paragraph("分析日時", s["body"]), Paragraph(analyzed_at, s["body"])], ] overview_table = Table(overview_data, colWidths=[40 * mm, content_width - 40 * mm]) - overview_table.setStyle( - TableStyle( - [ - ("GRID", (0, 0), (-1, -1), 0.5, TABLE_BORDER), - ("BACKGROUND", (0, 0), (0, -1), HEADER_BG), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("TOPPADDING", (0, 0), (-1, -1), 3), - ("BOTTOMPADDING", (0, 0), (-1, -1), 3), - ("LEFTPADDING", (0, 0), (-1, -1), 4), - ("RIGHTPADDING", (0, 0), (-1, -1), 4), - ] - ) - ) + overview_table.setStyle(_table_style(((0, 0), (0, -1)), "MIDDLE")) elements.append(overview_table) elements.append(Spacer(1, 4 * mm)) @@ -138,19 +144,7 @@ def build_intelligence_pdf(payload: dict) -> bytes: ] ) role_table = Table(role_data, colWidths=[45 * mm, 20 * mm, content_width - 65 * mm]) - role_table.setStyle( - TableStyle( - [ - ("GRID", (0, 0), (-1, -1), 0.5, TABLE_BORDER), - ("BACKGROUND", (0, 0), (-1, 0), HEADER_BG), - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ("TOPPADDING", (0, 0), (-1, -1), 3), - ("BOTTOMPADDING", (0, 0), (-1, -1), 3), - ("LEFTPADDING", (0, 0), (-1, -1), 4), - ("RIGHTPADDING", (0, 0), (-1, -1), 4), - ] - ) - ) + role_table.setStyle(_table_style(((0, 0), (-1, 0)), "TOP")) elements.append(role_table) elements.append(Spacer(1, 2 * mm)) diff --git a/backend/tests/test_blog_collector.py b/backend/tests/test_blog_collector.py index 8f53620c..36ae3d2b 100644 --- a/backend/tests/test_blog_collector.py +++ b/backend/tests/test_blog_collector.py @@ -25,6 +25,40 @@ def _run(coro): loop.close() +def _mock_async_client_returning_json(api_json: dict | list) -> AsyncMock: + """raise_for_status() を通り json() で `api_json` を返す AsyncClient モックを生成する。""" + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.json = MagicMock(return_value=api_json) + + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_resp) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + return mock_client + + +def _mock_async_client_with_status(status_code: int) -> AsyncMock: + """get() が status_code だけを持つレスポンスを返す AsyncClient モックを生成する。""" + mock_resp = MagicMock() + mock_resp.status_code = status_code + + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_resp) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + return mock_client + + +def _mock_async_client_raising(exc: BaseException) -> AsyncMock: + """get() が `exc` を送出する AsyncClient モックを生成する。""" + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=exc) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + return mock_client + + # ── fetch_note_articles テスト ─────────────────────────────────────────── @@ -68,15 +102,7 @@ def test_fetch_note_articles_no_hashtags_returns_empty_tags() -> None: } ] ) - - mock_resp = MagicMock() - mock_resp.raise_for_status = MagicMock() - mock_resp.json = MagicMock(return_value=api_json) - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_resp) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client = _mock_async_client_returning_json(api_json) with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client): articles = _run(fetch_note_articles("user")) @@ -105,15 +131,7 @@ def test_fetch_note_articles_with_hashtags() -> None: } ] ) - - mock_resp = MagicMock() - mock_resp.raise_for_status = MagicMock() - mock_resp.json = MagicMock(return_value=api_json) - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_resp) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client = _mock_async_client_returning_json(api_json) with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client): articles = _run(fetch_note_articles("user")) @@ -137,15 +155,7 @@ def test_fetch_qiita_articles_success() -> None: "tags": [{"name": "TypeScript"}, {"name": "React"}], } ] - - mock_resp = MagicMock() - mock_resp.raise_for_status = MagicMock() - mock_resp.json = MagicMock(return_value=api_json) - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_resp) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client = _mock_async_client_returning_json(api_json) with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client): articles = _run(fetch_qiita_articles("user")) @@ -164,10 +174,7 @@ def test_fetch_qiita_articles_success() -> None: def test_fetch_zenn_articles_timeout_raises() -> None: """httpx.TimeoutException 発生時は例外が伝播すること。""" - mock_client = AsyncMock() - mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout")) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client = _mock_async_client_raising(httpx.TimeoutException("timeout")) with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client): with pytest.raises(httpx.TimeoutException): @@ -185,13 +192,7 @@ def test_verify_user_exists_unsupported_platform_raises_error() -> None: def test_verify_user_exists_zenn_user_found() -> None: """Zenn ユーザーが存在する場合は True を返すこと。""" - mock_resp = MagicMock() - mock_resp.status_code = 200 - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_resp) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client = _mock_async_client_with_status(200) with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client): result = _run(verify_user_exists("zenn", "testuser")) @@ -201,13 +202,7 @@ def test_verify_user_exists_zenn_user_found() -> None: def test_verify_user_exists_normalizes_before_request() -> None: """URL 入力でも正規化された username で存在確認すること。""" - mock_resp = MagicMock() - mock_resp.status_code = 200 - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_resp) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client = _mock_async_client_with_status(200) with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client): result = _run(verify_user_exists("zenn", "https://zenn.dev/testuser/articles/test")) @@ -218,13 +213,7 @@ def test_verify_user_exists_normalizes_before_request() -> None: def test_verify_user_exists_zenn_user_not_found() -> None: """Zenn ユーザーが存在しない場合は False を返すこと。""" - mock_resp = MagicMock() - mock_resp.status_code = 404 - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_resp) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client = _mock_async_client_with_status(404) with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client): result = _run(verify_user_exists("zenn", "nonexistent")) @@ -239,10 +228,7 @@ def test_verify_user_exists_invalid_url_returns_false() -> None: def test_verify_user_exists_timeout_raises_blog_platform_request_error() -> None: """接続タイムアウト時は BlogPlatformRequestError が送出されること。""" - mock_client = AsyncMock() - mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout")) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client = _mock_async_client_raising(httpx.TimeoutException("timeout")) with patch("app.services.blog.collector.httpx.AsyncClient", return_value=mock_client): with pytest.raises(BlogPlatformRequestError): diff --git a/docs/api.md b/docs/api.md index 882de04f..4271ea3c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -145,6 +145,7 @@ REST API エンドポイント一覧と、バックエンド/フロントエ | `APP_VERSION` | アプリケーションバージョン(ログ・メトリクス用) | | `LOG_LEVEL` | ログレベル(`DEBUG` / `INFO` / `WARNING` / `ERROR`) | | `LOG_FORMAT` | ログフォーマット(`json` / `text`) | +| `APP_BOOTSTRAPPED` | `1` 指定で起動時 bootstrap(DB / 鍵検証)をスキップ。マイグレーションを別途流す環境で使用 | ### フロントエンド diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index fee59ea9..a347011d 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -4,7 +4,7 @@ import { PATHS } from "./paths"; type AuthResponse = { username: string; is_github_user: boolean }; export async function getCurrentUser(): Promise { - const response = await fetch(`${API_BASE_URL}/auth/me`, { + const response = await fetch(`${API_BASE_URL}${PATHS.auth.me}`, { credentials: "include", }); if (response.status === 401) { @@ -31,7 +31,7 @@ export const GITHUB_OAUTH_STATE_STORAGE_KEY = "github_oauth_state"; * state は sessionStorage で管理し、コールバック時に CSRF 検証する。 */ export async function initiateGitHubLogin(returnTo: string): Promise { const params = new URLSearchParams({ return_to: returnTo }); - const response = await fetch(`${API_BASE_URL}/auth/github/login-url?${params.toString()}`, { + const response = await fetch(`${API_BASE_URL}${PATHS.auth.githubLoginUrl}?${params.toString()}`, { credentials: "include", }); if (!response.ok) throw new Error("GitHub OAuth の開始に失敗しました"); @@ -44,7 +44,7 @@ export async function initiateGitHubLogin(returnTo: string): Promise { /** サーバー側で refresh_jti を無効化し Cookie を削除する。 * 401 ハンドラのループを避けるため request ラッパーではなく fetch を直接使う。 */ export async function logout(): Promise { - await fetch(`${API_BASE_URL}/auth/logout`, { + await fetch(`${API_BASE_URL}${PATHS.auth.logout}`, { method: "POST", credentials: "include", }); diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index 8facc9f8..fe7eb288 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -106,6 +106,37 @@ describe("api/client request", () => { expect(refreshCallCount).toBe(1); }); + /** 並行 401 でリフレッシュが失敗した場合、両方の元リクエストがエラーで rejected になること */ + it("複数の同時 401 はリフレッシュ失敗時に 1 回のリフレッシュを共有して両方失敗する", async () => { + let refreshCallCount = 0; + let resolveRefresh!: (response: Response) => void; + const refreshPromise = new Promise((resolve) => { + resolveRefresh = resolve; + }); + + const fetchMock = vi.fn().mockImplementation((url: string) => { + if ((url as string).includes("/auth/refresh")) { + refreshCallCount++; + return refreshPromise; + } + return Promise.resolve(makeResponse(401)); + }); + + vi.stubGlobal("fetch", fetchMock); + + const req1 = request("/api/test1"); + const req2 = request("/api/test2"); + + await Promise.resolve(); + expect(refreshCallCount).toBe(1); + + resolveRefresh(makeResponse(401)); + + await expect(req1).rejects.toThrow("認証が必要です"); + await expect(req2).rejects.toThrow("認証が必要です"); + expect(refreshCallCount).toBe(1); + }); + /** 500 系のレスポンスでエラーがスローされること */ it("500 レスポンスの場合エラーがスローされる", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(makeResponse(500))); diff --git a/frontend/src/api/paths.ts b/frontend/src/api/paths.ts index d095fc69..dad4f512 100644 --- a/frontend/src/api/paths.ts +++ b/frontend/src/api/paths.ts @@ -23,6 +23,9 @@ export const PATHS = { auth: { githubCallback: "/auth/github/callback", + me: "/auth/me", + githubLoginUrl: "/auth/github/login-url", + logout: "/auth/logout", }, resumes: { base: "/api/resumes", diff --git a/frontend/src/components/blog/BlogPlatformList.tsx b/frontend/src/components/blog/BlogPlatformList.tsx index 66843ccd..37607535 100644 --- a/frontend/src/components/blog/BlogPlatformList.tsx +++ b/frontend/src/components/blog/BlogPlatformList.tsx @@ -1,3 +1,5 @@ +import type { Dispatch, SetStateAction } from "react"; + import type { BlogAccount } from "../../types"; import type { PlatformKey } from "../../hooks/blog/useBlogAccountManager"; import { ZennIcon } from "../icons/ZennIcon"; @@ -30,7 +32,7 @@ const PLATFORMS = [ type BlogPlatformListProps = { accountMap: Map; draftUsernames: Record; - setDraftUsernames: React.Dispatch>>; + setDraftUsernames: Dispatch>>; savingPlatform: string | null; syncingPlatform: string | null; onSave: (platform: PlatformKey) => Promise; diff --git a/frontend/src/components/forms/sections/CareerExperienceSection.tsx b/frontend/src/components/forms/sections/CareerExperienceSection.tsx index 99914fec..4251da68 100644 --- a/frontend/src/components/forms/sections/CareerExperienceSection.tsx +++ b/frontend/src/components/forms/sections/CareerExperienceSection.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import type { Dispatch, SetStateAction } from "react"; import type { CareerExperienceForm, CareerFormState, CareerProjectForm } from "../../../payloadBuilders"; import type { TechStackMasterItem } from "../../../types"; @@ -13,7 +14,7 @@ type CareerExperienceSectionProps = { /** 職務経歴データの配列 */ experiences: CareerExperienceForm[]; /** フォーム状態更新ディスパッチャ */ - setForm: React.Dispatch>; + setForm: Dispatch>; /** 技術スタックのマスタデータ */ techStackOptions: TechStackMasterItem[]; }; diff --git a/frontend/src/components/forms/sections/CareerQualificationsSection.tsx b/frontend/src/components/forms/sections/CareerQualificationsSection.tsx index ce44c440..6d0818cd 100644 --- a/frontend/src/components/forms/sections/CareerQualificationsSection.tsx +++ b/frontend/src/components/forms/sections/CareerQualificationsSection.tsx @@ -1,3 +1,4 @@ +import type { Dispatch, SetStateAction } from "react"; import { blankResumeQualification } from "../../../constants"; import type { CareerFormState } from "../../../payloadBuilders"; import type { ResumeQualification } from "../../../types"; @@ -14,7 +15,7 @@ type Props = { /** ローディング中(Skeleton 表示) */ loading: boolean; /** フォーム状態更新ディスパッチャ */ - setForm: React.Dispatch>; + setForm: Dispatch>; }; /** diff --git a/frontend/src/hooks/analysis/useAsyncAnalysisPage.ts b/frontend/src/hooks/analysis/useAsyncAnalysisPage.ts index b342b479..e729b6b9 100644 --- a/frontend/src/hooks/analysis/useAsyncAnalysisPage.ts +++ b/frontend/src/hooks/analysis/useAsyncAnalysisPage.ts @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from "react"; +import type { Dispatch, SetStateAction } from "react"; import { useTaskPolling } from "../useTaskPolling"; import type { AppErrorState } from "../../utils/appError"; import { isInProgressStatus } from "../../utils/taskStatus"; @@ -52,15 +53,15 @@ type UseAsyncAnalysisPageReturn = { /** 現在のフェーズ */ phase: AsyncAnalysisPhase; /** フェーズを手動で更新する関数 */ - setPhase: React.Dispatch>; + setPhase: Dispatch>; /** 分析結果(result フェーズ時のみ非 null) */ result: TResult | null; /** 結果を更新する関数 */ - setResult: React.Dispatch>; + setResult: Dispatch>; /** エラーメッセージ */ error: AppErrorState | null; /** エラーを更新する関数 */ - setError: React.Dispatch>; + setError: Dispatch>; /** ポーリング開始関数 */ startPolling: () => void; /** ポーリング中フラグ */ diff --git a/frontend/src/hooks/career/useCareerExperienceMutators.ts b/frontend/src/hooks/career/useCareerExperienceMutators.ts index c2338671..a64700f2 100644 --- a/frontend/src/hooks/career/useCareerExperienceMutators.ts +++ b/frontend/src/hooks/career/useCareerExperienceMutators.ts @@ -1,3 +1,5 @@ +import type { Dispatch, SetStateAction } from "react"; + import { blankCareerClient, blankCareerExperience, @@ -20,7 +22,7 @@ import type { */ export function useCareerExperienceMutators( experiences: CareerExperienceForm[], - setForm: React.Dispatch>, + setForm: Dispatch>, ) { /** experience フィールド変更ハンドラ */ const updateExperienceField = ( diff --git a/frontend/src/hooks/useDocumentForm.test.ts b/frontend/src/hooks/useDocumentForm.test.ts index 9ace5267..408a9023 100644 --- a/frontend/src/hooks/useDocumentForm.test.ts +++ b/frontend/src/hooks/useDocumentForm.test.ts @@ -57,7 +57,7 @@ describe("useDocumentForm", () => { if (storeOverrides.presetCache) { store.dispatch( setCache({ - key: "career", + key: overrides.cacheKey ?? "career", form: storeOverrides.presetCache.form, documentId: storeOverrides.presetCache.documentId, }), diff --git a/frontend/src/hooks/useDocumentForm.ts b/frontend/src/hooks/useDocumentForm.ts index c60e9511..36d0635d 100644 --- a/frontend/src/hooks/useDocumentForm.ts +++ b/frontend/src/hooks/useDocumentForm.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; import { useAppDispatch, useAppSelector } from "../store"; import { clearCache, setCache, type FormCacheKey } from "../store/formCacheSlice"; @@ -52,7 +53,7 @@ export function useDocumentForm> = useCallback( + const setForm: Dispatch> = useCallback( (action) => { setFormRaw((prev) => { const next = typeof action === "function" ? (action as (prev: FormState) => FormState)(prev) : action; diff --git a/frontend/src/router/guards.test.tsx b/frontend/src/router/guards.test.tsx index e301b6de..685b55d6 100644 --- a/frontend/src/router/guards.test.tsx +++ b/frontend/src/router/guards.test.tsx @@ -62,6 +62,20 @@ describe("PrivateRoute", () => { expect(screen.getByText("読み込み中...")).toBeInTheDocument(); expect(screen.queryByText("Career")).not.toBeInTheDocument(); }); + + it("authLoading=true は user 有無に関わらずローディングを優先する", () => { + render( + + + }> + Career} /> + + + , + ); + expect(screen.getByText("読み込み中...")).toBeInTheDocument(); + expect(screen.queryByText("Career")).not.toBeInTheDocument(); + }); }); describe("PublicRoute", () => { @@ -75,4 +89,18 @@ describe("PublicRoute", () => { renderWithRoutes(null, "/login"); expect(screen.getByText("Login")).toBeInTheDocument(); }); + + it("authLoading=true はリダイレクトせずローディングを表示する", () => { + render( + + + }> + Login} /> + + + , + ); + expect(screen.getByText("読み込み中...")).toBeInTheDocument(); + expect(screen.queryByText("Login")).not.toBeInTheDocument(); + }); });