diff --git a/backend/tests/auth/test_endpoints.py b/backend/tests/auth/test_endpoints.py index e59e2f7f..41050703 100644 --- a/backend/tests/auth/test_endpoints.py +++ b/backend/tests/auth/test_endpoints.py @@ -189,8 +189,12 @@ def test_get_request_skips_csrf_check(client) -> None: def _auth_failed_records(records: list[logging.LogRecord]) -> list[logging.LogRecord]: - """auth_failed イベント (devforge ロガー WARNING) のみ抽出する。""" - return [r for r in records if r.name == "devforge" and r.message == "auth_failed"] + """auth_failed イベント (devforge ロガー WARNING) のみ抽出する。 + + `LogRecord.message` はフォーマット後にのみ設定される派生属性のため、 + 常に値を返す `getMessage()` を使う方が堅牢(caplog 等のフォーマッタ設定差異に強い)。 + """ + return [r for r in records if r.name == "devforge" and r.getMessage() == "auth_failed"] def test_auth_failed_logged_when_cookie_missing(client, caplog) -> None: diff --git a/backend/tests/auth/test_oauth_flow.py b/backend/tests/auth/test_oauth_flow.py index 6dfafd75..81a6368d 100644 --- a/backend/tests/auth/test_oauth_flow.py +++ b/backend/tests/auth/test_oauth_flow.py @@ -174,8 +174,15 @@ def test_github_login_url_uses_frontend_origin_when_callback_base_url_unset(clie def test_github_login_url_uses_callback_base_url_when_set(client) -> None: - """CALLBACK_BASE_URL が設定されている場合、x-forwarded-host より優先されることを確認する。""" - with patch.dict(os.environ, {"CALLBACK_BASE_URL": "devforge-dev-XXXXX-an.a.run.app"}): + """CALLBACK_BASE_URL が設定されている場合、x-forwarded-host より優先されることを確認する。 + + settings.py のドキュメントに従い、scheme 付きの URL(``https://``)を期待値とする。 + GitHub OAuth は scheme 付きの redirect_uri しか受け付けないため、現実的な値で検証する。 + """ + with patch.dict( + os.environ, + {"CALLBACK_BASE_URL": "https://devforge-dev-XXXXX-an.a.run.app"}, + ): response = client.get( "/auth/github/login-url", headers={ @@ -188,7 +195,7 @@ def test_github_login_url_uses_callback_base_url_when_set(client) -> None: assert response.status_code == 200 parsed = urlparse(response.json()["authorization_url"]) redirect_uri = parse_qs(parsed.query)["redirect_uri"][0] - assert redirect_uri == "devforge-dev-XXXXX-an.a.run.app/github/callback" + assert redirect_uri == "https://devforge-dev-XXXXX-an.a.run.app/github/callback" def test_github_login_redirect_to_github(client) -> None: diff --git a/backend/tests/test_worker/test_execute_task.py b/backend/tests/test_worker/test_execute_task.py index f50839d7..0a28ba54 100644 --- a/backend/tests/test_worker/test_execute_task.py +++ b/backend/tests/test_worker/test_execute_task.py @@ -10,6 +10,7 @@ _safe_rollback, execute_task, ) +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from ._helpers import run_sync as _run @@ -107,26 +108,37 @@ def test_execute_task_creates_notification_on_success(self, db_session: Session) class TestSafeRollback: def test_rollback_after_failed_commit_restores_session(self, db_session: Session): - """DB commit 失敗後に _safe_rollback を呼ぶと、セッションが再利用可能になること。""" + """DB commit 失敗で実際にエラー状態に陥ったあと、_safe_rollback で + セッションが再利用可能になること。 + + `BlogSummaryCache.user_id` の unique 制約に違反させて IntegrityError を起こし、 + その後 `_safe_rollback` を呼ぶことで、ロールバックが効いて以降の commit が成功する + という回復経路を実際に踏ませる。元実装は手動 `rollback()` のみで失敗状態を作らず、 + テストとして空回りしていた。 + """ user = UserRepository(db_session).create( "rollback-test-user", hashed_password=None, email="rollback@test.com" ) - cache = BlogSummaryCache(user_id=user.id, status="processing") - db_session.add(cache) + first_cache = BlogSummaryCache(user_id=user.id, status="processing") + db_session.add(first_cache) db_session.commit() - # コミット失敗をシミュレートしてセッションを PendingRollback 状態にする - db_session.execute.__self__ if hasattr(db_session.execute, "__self__") else None - db_session.rollback() # まず手動でロールバックして dirty 状態を作る + # 同じ user_id で 2 件目を追加 → unique 制約違反で commit が失敗し、 + # セッションは「次の操作で PendingRollbackError を投げる」状態になる + duplicate = BlogSummaryCache(user_id=user.id, status="processing") + db_session.add(duplicate) + with pytest.raises(IntegrityError): + db_session.commit() - # _safe_rollback は例外を上げないこと + # _safe_rollback は例外を外に漏らさないこと(dirty な状態でも安全に呼べる) _safe_rollback(db_session) - # ロールバック後にセッションが再利用可能であること - cache.status = "dead_letter" + # ロールバック後にセッションが再利用可能であること。 + # 元の cache に対する更新 commit が通れば回復経路 OK と判断する。 + first_cache.status = "dead_letter" db_session.commit() - db_session.refresh(cache) - assert cache.status == "dead_letter" + db_session.refresh(first_cache) + assert first_cache.status == "dead_letter" def test_safe_rollback_suppresses_exception(self): """rollback() が例外を送出しても _safe_rollback は例外を外に漏らさないこと。""" diff --git a/frontend/src/components/analysis/GitHubAnalysisPage.test.tsx b/frontend/src/components/analysis/GitHubAnalysisPage.test.tsx index 02844fba..f78b3414 100644 --- a/frontend/src/components/analysis/GitHubAnalysisPage.test.tsx +++ b/frontend/src/components/analysis/GitHubAnalysisPage.test.tsx @@ -11,17 +11,25 @@ function renderPage() { return renderWithProviders(); } +/** + * `GET /api/intelligence/cache` をキャッシュ未保存(入力画面表示)レスポンスに差し替える。 + * 2 箇所でコピペされていた server.use ブロックを集約する。 + */ +function mockEmptyCache() { + server.use( + http.get("*/api/intelligence/cache", () => + HttpResponse.json({ + analysis_result: null, + position_advice: null, + status: null, + }), + ), + ); +} + describe("GitHubAnalysisPage", () => { it("キャッシュなしの場合、入力画面が表示される", async () => { - server.use( - http.get("*/api/intelligence/cache", () => - HttpResponse.json({ - analysis_result: null, - position_advice: null, - status: null, - }), - ), - ); + mockEmptyCache(); renderPage(); @@ -106,14 +114,8 @@ describe("GitHubAnalysisPage", () => { it("分析開始ボタン押下後、ポーリング画面に遷移する", async () => { const user = userEvent.setup(); + mockEmptyCache(); server.use( - http.get("*/api/intelligence/cache", () => - HttpResponse.json({ - analysis_result: null, - position_advice: null, - status: null, - }), - ), http.get("*/api/intelligence/cache/status", () => HttpResponse.json({ status: "pending" }), ), @@ -137,14 +139,8 @@ describe("GitHubAnalysisPage", () => { it("API 500 エラー時にエラーメッセージが表示される", async () => { const user = userEvent.setup(); + mockEmptyCache(); server.use( - http.get("*/api/intelligence/cache", () => - HttpResponse.json({ - analysis_result: null, - position_advice: null, - status: null, - }), - ), http.post("*/api/intelligence/analyze", () => HttpResponse.json( { diff --git a/frontend/src/components/blog/BlogPage.tsx b/frontend/src/components/blog/BlogPage.tsx index 88586a32..7db290ad 100644 --- a/frontend/src/components/blog/BlogPage.tsx +++ b/frontend/src/components/blog/BlogPage.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import { useBlogAccountManager } from "../../hooks/useBlogAccountManager"; +import { useBlogAccountManager } from "../../hooks/blog/useBlogAccountManager"; import { BlogScoreCard } from "./BlogScoreCard"; import { BlogAnalysisSection } from "./BlogAnalysisSection"; import { BlogPlatformList } from "./BlogPlatformList"; diff --git a/frontend/src/components/blog/BlogPlatformList.tsx b/frontend/src/components/blog/BlogPlatformList.tsx index 6cb5f3d2..66843ccd 100644 --- a/frontend/src/components/blog/BlogPlatformList.tsx +++ b/frontend/src/components/blog/BlogPlatformList.tsx @@ -1,5 +1,5 @@ import type { BlogAccount } from "../../types"; -import type { PlatformKey } from "../../hooks/useBlogAccountManager"; +import type { PlatformKey } from "../../hooks/blog/useBlogAccountManager"; import { ZennIcon } from "../icons/ZennIcon"; import { NoteIcon } from "../icons/NoteIcon"; import { QiitaIcon } from "../icons/QiitaIcon"; diff --git a/frontend/src/components/career-analysis/CareerAnalysisPage.tsx b/frontend/src/components/career-analysis/CareerAnalysisPage.tsx index 687f62da..2de979d6 100644 --- a/frontend/src/components/career-analysis/CareerAnalysisPage.tsx +++ b/frontend/src/components/career-analysis/CareerAnalysisPage.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import type { CareerAnalysisResponse } from "../../api"; -import { useCareerAnalysisPage } from "../../hooks/useCareerAnalysisPage"; +import { useCareerAnalysisPage } from "../../hooks/career/useCareerAnalysisPage"; import { CareerAnalysisResultView } from "./result/CareerAnalysisResultView"; import { ErrorToast } from "../ui/ErrorToast"; import { InlineSpinner } from "../ui/InlineSpinner"; diff --git a/frontend/src/components/forms/CareerResumeForm.tsx b/frontend/src/components/forms/CareerResumeForm.tsx index bc4e4adc..70228e02 100644 --- a/frontend/src/components/forms/CareerResumeForm.tsx +++ b/frontend/src/components/forms/CareerResumeForm.tsx @@ -15,15 +15,14 @@ import { buildCareerPayload } from "../../payloadBuilders"; import type { CareerTextFieldKey } from "../../formTypes"; import { useQualifications, useTechnologyStacks } from "../../hooks/useMasterData"; import { usePdfActions } from "../../hooks/usePdfActions"; -import type { ResumeQualification } from "../../types"; -import { blankResumeQualification } from "../../constants"; import shared from "../../styles/shared.module.css"; import { ConfirmDialog } from "../ConfirmDialog"; import { Skeleton } from "../ui/Skeleton"; -import { Combobox } from "./Combobox"; -import { MarkdownTextarea } from "./MarkdownTextarea"; import { PdfPreviewModal } from "./PdfPreviewModal"; +import { CareerBasicInfoSection } from "./sections/CareerBasicInfoSection"; import { CareerExperienceSection } from "./sections/CareerExperienceSection"; +import { CareerQualificationsSection } from "./sections/CareerQualificationsSection"; +import { CareerSelfPrSection } from "./sections/CareerSelfPrSection"; export function CareerResumeForm() { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -81,39 +80,6 @@ export function CareerResumeForm() { setForm((prev) => ({ ...prev, [key]: value })); }; - /** 資格フィールド変更ハンドラ */ - const updateQualificationField = ( - index: number, - key: keyof ResumeQualification, - value: string, - ) => { - setForm((prev) => ({ - ...prev, - qualifications: prev.qualifications.map((qualification, i) => - i === index ? { ...qualification, [key]: value } : qualification, - ), - })); - }; - - /** 資格追加ハンドラ */ - const addQualification = () => { - setForm((prev) => ({ - ...prev, - qualifications: [...prev.qualifications, { ...blankResumeQualification }], - })); - }; - - /** 資格削除ハンドラ */ - const removeQualification = (index: number) => { - setForm((prev) => ({ - ...prev, - qualifications: - prev.qualifications.length === 1 - ? [{ ...blankResumeQualification }] - : prev.qualifications.filter((_, i) => i !== index), - })); - }; - const onSubmit = async (event: FormEvent) => { event.preventDefault(); await save(); @@ -184,35 +150,12 @@ export function CareerResumeForm() { {success &&

{success}

} {/* 基本情報: 氏名・職務要約 */} -
- - {loading ? ( - - ) : ( - onChangeField("career_summary", v)} - rows={4} - required - /> - )} -
+ {/* 職務経歴セクション */} {loading ? ( @@ -234,62 +177,19 @@ export function CareerResumeForm() { )} {/* 資格セクション */} -
-

資格

- {loading ? ( -
- -
- ) : ( - <> - {form.qualifications.map((qualification, index) => ( -
-
- - -
- -
- ))} - - - )} -
+ {/* 自己PR */} -
- {loading ? ( - - ) : ( - onChangeField("self_pr", v)} - rows={4} - required - /> - )} -
+ onChangeField("self_pr", v)} + /> diff --git a/frontend/src/components/forms/ProjectModal.tsx b/frontend/src/components/forms/ProjectModal.tsx index 91154987..d34ebee7 100644 --- a/frontend/src/components/forms/ProjectModal.tsx +++ b/frontend/src/components/forms/ProjectModal.tsx @@ -5,7 +5,7 @@ import { phaseOptions, teamRoleOptions, } from "../../constants"; -import { useProjectModalForm } from "../../hooks/useProjectModalForm"; +import { useProjectModalForm } from "../../hooks/career/useProjectModalForm"; import { Combobox } from "./Combobox"; import { MarkdownTextarea } from "./MarkdownTextarea"; import styles from "./ProjectModal.module.css"; diff --git a/frontend/src/components/forms/sections/CareerBasicInfoSection.tsx b/frontend/src/components/forms/sections/CareerBasicInfoSection.tsx new file mode 100644 index 00000000..941e5046 --- /dev/null +++ b/frontend/src/components/forms/sections/CareerBasicInfoSection.tsx @@ -0,0 +1,53 @@ +import shared from "../../../styles/shared.module.css"; +import { Skeleton } from "../../ui/Skeleton"; +import { MarkdownTextarea } from "../MarkdownTextarea"; + +/** CareerBasicInfoSection のプロパティ型 */ +type Props = { + /** 氏名 */ + fullName: string; + /** 職務要約(Markdown) */ + careerSummary: string; + /** ローディング中(Skeleton 表示) */ + loading: boolean; + /** フィールド変更ハンドラ */ + onChange: (key: "full_name" | "career_summary", value: string) => void; +}; + +/** + * 職務経歴書の「基本情報」セクション。氏名と職務要約を表示する。 + * CareerResumeForm の JSX をセクション単位で読みやすくするための切り出し。 + */ +export function CareerBasicInfoSection({ fullName, careerSummary, loading, onChange }: Props) { + return ( +
+ + {loading ? ( + + ) : ( + onChange("career_summary", v)} + rows={4} + required + /> + )} +
+ ); +} diff --git a/frontend/src/components/forms/sections/CareerExperienceSection.tsx b/frontend/src/components/forms/sections/CareerExperienceSection.tsx index ffc5e1ba..99914fec 100644 --- a/frontend/src/components/forms/sections/CareerExperienceSection.tsx +++ b/frontend/src/components/forms/sections/CareerExperienceSection.tsx @@ -2,8 +2,8 @@ import { useMemo } from "react"; import type { CareerExperienceForm, CareerFormState, CareerProjectForm } from "../../../payloadBuilders"; import type { TechStackMasterItem } from "../../../types"; -import { useCareerExperienceMutators } from "../../../hooks/useCareerExperienceMutators"; -import { useProjectModalState } from "../../../hooks/useProjectModalState"; +import { useCareerExperienceMutators } from "../../../hooks/career/useCareerExperienceMutators"; +import { useProjectModalState } from "../../../hooks/career/useProjectModalState"; import shared from "../../../styles/shared.module.css"; import { CareerExperienceEditor } from "../CareerFormEditors/CareerExperienceEditor"; import { ProjectModal } from "../ProjectModal"; diff --git a/frontend/src/components/forms/sections/CareerQualificationsSection.tsx b/frontend/src/components/forms/sections/CareerQualificationsSection.tsx new file mode 100644 index 00000000..ce44c440 --- /dev/null +++ b/frontend/src/components/forms/sections/CareerQualificationsSection.tsx @@ -0,0 +1,102 @@ +import { blankResumeQualification } from "../../../constants"; +import type { CareerFormState } from "../../../payloadBuilders"; +import type { ResumeQualification } from "../../../types"; +import shared from "../../../styles/shared.module.css"; +import { Skeleton } from "../../ui/Skeleton"; +import { Combobox } from "../Combobox"; + +/** CareerQualificationsSection のプロパティ型 */ +type Props = { + /** 資格データ配列 */ + qualifications: ResumeQualification[]; + /** マスタから取得した資格名候補 */ + qualificationNames: string[]; + /** ローディング中(Skeleton 表示) */ + loading: boolean; + /** フォーム状態更新ディスパッチャ */ + setForm: React.Dispatch>; +}; + +/** + * 職務経歴書の「資格」セクション。資格の追加・削除・編集ハンドラを内包する。 + * 元 CareerResumeForm の JSX をセクション単位で読みやすくするための切り出し。 + */ +export function CareerQualificationsSection({ + qualifications, + qualificationNames, + loading, + setForm, +}: Props) { + /** 資格フィールド変更ハンドラ */ + const updateField = (index: number, key: keyof ResumeQualification, value: string) => { + setForm((prev) => ({ + ...prev, + qualifications: prev.qualifications.map((qualification, i) => + i === index ? { ...qualification, [key]: value } : qualification, + ), + })); + }; + + /** 資格追加ハンドラ */ + const addRow = () => { + setForm((prev) => ({ + ...prev, + qualifications: [...prev.qualifications, { ...blankResumeQualification }], + })); + }; + + /** 資格削除ハンドラ(最後の 1 件は blank に戻すことで「項目ゼロ」を避ける) */ + const removeRow = (index: number) => { + setForm((prev) => ({ + ...prev, + qualifications: + prev.qualifications.length === 1 + ? [{ ...blankResumeQualification }] + : prev.qualifications.filter((_, i) => i !== index), + })); + }; + + return ( +
+

資格

+ {loading ? ( +
+ +
+ ) : ( + <> + {qualifications.map((qualification, index) => ( +
+
+ + +
+ +
+ ))} + + + )} +
+ ); +} diff --git a/frontend/src/components/forms/sections/CareerSelfPrSection.tsx b/frontend/src/components/forms/sections/CareerSelfPrSection.tsx new file mode 100644 index 00000000..42ff0e06 --- /dev/null +++ b/frontend/src/components/forms/sections/CareerSelfPrSection.tsx @@ -0,0 +1,35 @@ +import shared from "../../../styles/shared.module.css"; +import { Skeleton } from "../../ui/Skeleton"; +import { MarkdownTextarea } from "../MarkdownTextarea"; + +/** CareerSelfPrSection のプロパティ型 */ +type Props = { + /** 自己PR(Markdown) */ + selfPr: string; + /** ローディング中(Skeleton 表示) */ + loading: boolean; + /** 値変更ハンドラ */ + onChange: (value: string) => void; +}; + +/** + * 職務経歴書の「自己PR」セクション。 + * 元 CareerResumeForm の JSX をセクション単位で読みやすくするための切り出し。 + */ +export function CareerSelfPrSection({ selfPr, loading, onChange }: Props) { + return ( +
+ {loading ? ( + + ) : ( + + )} +
+ ); +} diff --git a/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts b/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts index 094904c3..b733f068 100644 --- a/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts +++ b/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts @@ -1,6 +1,9 @@ import { renderHook, act, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { useAsyncAnalysisPage } from "./useAsyncAnalysisPage"; +import { + useAsyncAnalysisPage, + type UseAsyncAnalysisPageOptions, +} from "./useAsyncAnalysisPage"; describe("useAsyncAnalysisPage", () => { const mockLoadCache = vi.fn(); @@ -10,17 +13,29 @@ describe("useAsyncAnalysisPage", () => { vi.clearAllMocks(); }); + /** + * 4 箇所の renderHook + props 標準セット (loadCache / checkStatus) のコピペを集約する setup ファクトリ。 + * `overrides` で fetchProgress 等の追加 props を渡せる。 + */ + type TestResult = { id: string }; + function setup( + overrides: Partial> = {}, + ) { + return renderHook(() => + useAsyncAnalysisPage({ + loadCache: mockLoadCache, + checkStatus: mockCheckStatus, + ...overrides, + }), + ); + } + /** 初回マウント時にキャッシュが存在する場合、result フェーズに遷移すること */ it("キャッシュが存在する場合 result フェーズに遷移する", async () => { mockLoadCache.mockResolvedValue({ result: { id: "test-result" } }); mockCheckStatus.mockResolvedValue({ status: "completed" }); - const { result } = renderHook(() => - useAsyncAnalysisPage({ - loadCache: mockLoadCache, - checkStatus: mockCheckStatus, - }), - ); + const { result } = setup(); await waitFor(() => { expect(result.current.phase).toBe("result"); @@ -34,12 +49,7 @@ describe("useAsyncAnalysisPage", () => { mockLoadCache.mockResolvedValue({ result: null }); mockCheckStatus.mockResolvedValue({ status: "idle" }); - const { result } = renderHook(() => - useAsyncAnalysisPage({ - loadCache: mockLoadCache, - checkStatus: mockCheckStatus, - }), - ); + const { result } = setup(); await waitFor(() => { expect(result.current.phase).toBe("input"); @@ -53,12 +63,7 @@ describe("useAsyncAnalysisPage", () => { mockLoadCache.mockResolvedValue({ result: null, status: "retrying" }); mockCheckStatus.mockResolvedValue({ status: "retrying" }); - const { result } = renderHook(() => - useAsyncAnalysisPage({ - loadCache: mockLoadCache, - checkStatus: mockCheckStatus, - }), - ); + const { result } = setup(); await waitFor(() => { expect(result.current.phase).toBe("polling"); @@ -71,12 +76,7 @@ describe("useAsyncAnalysisPage", () => { // ポーリングが止まらないよう pending を返し続ける mockCheckStatus.mockResolvedValue({ status: "pending" }); - const { result } = renderHook(() => - useAsyncAnalysisPage({ - loadCache: mockLoadCache, - checkStatus: mockCheckStatus, - }), - ); + const { result } = setup(); await waitFor(() => { expect(result.current.phase).toBe("polling"); @@ -93,12 +93,7 @@ describe("useAsyncAnalysisPage", () => { error_message: "分析に失敗しました", }); - const { result } = renderHook(() => - useAsyncAnalysisPage({ - loadCache: mockLoadCache, - checkStatus: mockCheckStatus, - }), - ); + const { result } = setup(); await waitFor(() => { expect(result.current.phase).toBe("input"); @@ -117,12 +112,7 @@ describe("useAsyncAnalysisPage", () => { mockLoadCache.mockResolvedValue({ result: null }); mockCheckStatus.mockResolvedValue({ status: "pending" }); - const { result } = renderHook(() => - useAsyncAnalysisPage({ - loadCache: mockLoadCache, - checkStatus: mockCheckStatus, - }), - ); + const { result } = setup(); // input フェーズになるまで待つ await waitFor(() => { @@ -141,12 +131,7 @@ describe("useAsyncAnalysisPage", () => { mockLoadCache.mockResolvedValue({ result: { id: "existing" } }); mockCheckStatus.mockResolvedValue({ status: "completed" }); - const { result } = renderHook(() => - useAsyncAnalysisPage({ - loadCache: mockLoadCache, - checkStatus: mockCheckStatus, - }), - ); + const { result } = setup(); await waitFor(() => { expect(result.current.phase).toBe("result"); @@ -169,13 +154,7 @@ describe("useAsyncAnalysisPage", () => { mockCheckStatus.mockResolvedValue({ status: "pending" }); const mockFetchProgress = vi.fn().mockRejectedValue(new Error("Redis down")); - const { result } = renderHook(() => - useAsyncAnalysisPage({ - loadCache: mockLoadCache, - checkStatus: mockCheckStatus, - fetchProgress: mockFetchProgress, - }), - ); + const { result } = setup({ fetchProgress: mockFetchProgress }); await waitFor(() => { expect(result.current.phase).toBe("polling"); @@ -195,12 +174,7 @@ describe("useAsyncAnalysisPage", () => { mockLoadCache.mockRejectedValue(new Error("ネットワークエラー")); mockCheckStatus.mockResolvedValue({ status: "idle" }); - const { result } = renderHook(() => - useAsyncAnalysisPage({ - loadCache: mockLoadCache, - checkStatus: mockCheckStatus, - }), - ); + const { result } = setup(); await waitFor(() => { expect(result.current.phase).toBe("input"); diff --git a/frontend/src/hooks/analysis/useAsyncAnalysisPage.ts b/frontend/src/hooks/analysis/useAsyncAnalysisPage.ts index 53d98f88..b342b479 100644 --- a/frontend/src/hooks/analysis/useAsyncAnalysisPage.ts +++ b/frontend/src/hooks/analysis/useAsyncAnalysisPage.ts @@ -24,7 +24,7 @@ const getNextInterval = (current: number): number => export { getNextInterval }; /** useAsyncAnalysisPage のオプション型 */ -type UseAsyncAnalysisPageOptions = { +export type UseAsyncAnalysisPageOptions = { /** * キャッシュ読み込み関数。 * - 結果が存在する場合は result を返す diff --git a/frontend/src/hooks/useBlogAccountManager.test.ts b/frontend/src/hooks/blog/useBlogAccountManager.test.ts similarity index 77% rename from frontend/src/hooks/useBlogAccountManager.test.ts rename to frontend/src/hooks/blog/useBlogAccountManager.test.ts index cb56b809..13e9ac5a 100644 --- a/frontend/src/hooks/useBlogAccountManager.test.ts +++ b/frontend/src/hooks/blog/useBlogAccountManager.test.ts @@ -1,7 +1,7 @@ import { renderHook, act, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { useBlogAccountManager } from "./useBlogAccountManager"; -import type { BlogAccount, BlogArticle } from "../types"; +import { reduceActions, useBlogAccountManager } from "./useBlogAccountManager"; +import type { BlogAccount, BlogArticle } from "../../types"; /** テスト用のダミーアカウントデータ */ const dummyAccounts: BlogAccount[] = [ @@ -29,7 +29,7 @@ const dummyArticles: BlogArticle[] = [ ]; /** ../api モジュール全体をモック */ -vi.mock("../api", () => ({ +vi.mock("../../api", () => ({ getBlogAccounts: vi.fn(), getBlogArticles: vi.fn(), addBlogAccount: vi.fn(), @@ -41,6 +41,48 @@ vi.mock("../api", () => ({ getBlogSummaryCacheStatus: vi.fn(), })); +// ── reduceActions の単体テスト ──────────────────────────────── +// +// setAction の expectedAction ガード(先発アクションの finally が後発アクションを +// clobber しないこと)を locked-down するために、reduceActions を直接検証する。 +// hook の useState から純粋関数として切り出してある。 +describe("reduceActions", () => { + it("action を指定すると platform にアクションをセットする", () => { + const next = reduceActions({}, "zenn", "saving"); + expect(next).toEqual({ zenn: "saving" }); + }); + + it("既存のアクションは新しいアクションで上書きされる", () => { + const next = reduceActions({ zenn: "saving" }, "zenn", "syncing"); + expect(next).toEqual({ zenn: "syncing" }); + }); + + it("action=null + expectedAction なしでは無条件で削除される", () => { + const next = reduceActions({ zenn: "saving", note: "syncing" }, "zenn", null); + expect(next).toEqual({ note: "syncing" }); + }); + + it("expectedAction が現在の値と一致するとき削除される", () => { + const next = reduceActions({ zenn: "saving" }, "zenn", null, "saving"); + expect(next).toEqual({}); + }); + + it("expectedAction が現在の値と異なるときは prev のまま返す(後発を clobber しない)", () => { + // 先発 saving の finally が、後発 syncing が走った後に呼ばれたケース + const prev = { zenn: "syncing" } as const; + const next = reduceActions(prev, "zenn", null, "saving"); + // 同一参照で返ることで、保持する syncing がクリアされない + expect(next).toBe(prev); + expect(next).toEqual({ zenn: "syncing" }); + }); + + it("expectedAction 指定で対象プラットフォーム未登録なら何もしない", () => { + const prev = { note: "saving" } as const; + const next = reduceActions(prev, "zenn", null, "saving"); + expect(next).toBe(prev); + }); +}); + describe("useBlogAccountManager", () => { // モック関数への参照を取得するために動的 import を使う // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -48,7 +90,7 @@ describe("useBlogAccountManager", () => { beforeEach(async () => { vi.clearAllMocks(); - api = await import("../api"); + api = await import("../../api"); // デフォルトのモック戻り値を設定 api.getBlogSummaryCache.mockResolvedValue({ available: false, summary: null, status: "idle" }); api.getBlogSummaryCacheStatus.mockResolvedValue({ status: "idle" }); diff --git a/frontend/src/hooks/useBlogAccountManager.ts b/frontend/src/hooks/blog/useBlogAccountManager.ts similarity index 84% rename from frontend/src/hooks/useBlogAccountManager.ts rename to frontend/src/hooks/blog/useBlogAccountManager.ts index e574c526..37b27889 100644 --- a/frontend/src/hooks/useBlogAccountManager.ts +++ b/frontend/src/hooks/blog/useBlogAccountManager.ts @@ -7,16 +7,43 @@ import { deleteBlogAccount, getBlogArticles, syncBlogAccount, -} from "../api"; -import type { BlogAccount, BlogArticle } from "../types"; +} from "../../api"; +import type { BlogAccount, BlogArticle } from "../../types"; import { useBlogSummaryPolling } from "./useBlogSummaryPolling"; export type PlatformKey = "zenn" | "note" | "qiita"; -type PlatformAction = "saving" | "syncing" | "updating" | "deleting"; +export type PlatformAction = "saving" | "syncing" | "updating" | "deleting"; /** プラットフォーム別の進行中アクション集合。値が無いキーは「アイドル」を意味する。 */ -type PlatformActionMap = Partial>; +export type PlatformActionMap = Partial>; + +/** + * PlatformActionMap への純粋な更新関数。 + * + * クリア(``action === null``)時に ``expectedAction`` を指定すると、 + * 現在のアクションが ``expectedAction`` と一致する場合のみ削除する。 + * これにより、同一プラットフォームで先発アクションの finally が + * 後発アクションを clobber することを防ぐ(race condition ガード)。 + * + * 単体テストのため、フック内のクロージャから切り出して export している。 + */ +export function reduceActions( + prev: PlatformActionMap, + platform: PlatformKey, + action: PlatformAction | null, + expectedAction?: PlatformAction, +): PlatformActionMap { + if (action == null) { + if (expectedAction !== undefined && prev[platform] !== expectedAction) { + return prev; + } + const next = { ...prev }; + delete next[platform]; + return next; + } + return { ...prev, [platform]: action }; +} /** * BlogPage のブログアカウント管理・同期・AI分析ロジックを提供するカスタムフック。 @@ -42,31 +69,14 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") /** プラットフォーム別の進行中アクション。同時に複数プラットフォームを操作する余地を残す。 */ const [actions, setActions] = useState({}); - /** - * 指定プラットフォームのアクションをセット/解除する。 - * - * クリア(``action === null``)時に ``expectedAction`` を指定すると、 - * 現在のアクションが ``expectedAction`` と一致する場合のみ削除する。 - * これにより、同一プラットフォームで先発アクションの finally が - * 後発アクションを clobber することを防ぐ。 - */ + /** 指定プラットフォームのアクションをセット/解除する(reduceActions の薄いラッパ)。 */ const setAction = useCallback( ( platform: PlatformKey, action: PlatformAction | null, expectedAction?: PlatformAction, ) => { - setActions((prev) => { - if (action == null) { - if (expectedAction !== undefined && prev[platform] !== expectedAction) { - return prev; - } - const next = { ...prev }; - delete next[platform]; - return next; - } - return { ...prev, [platform]: action }; - }); + setActions((prev) => reduceActions(prev, platform, action, expectedAction)); }, [], ); diff --git a/frontend/src/hooks/useBlogSummaryPolling.ts b/frontend/src/hooks/blog/useBlogSummaryPolling.ts similarity index 92% rename from frontend/src/hooks/useBlogSummaryPolling.ts rename to frontend/src/hooks/blog/useBlogSummaryPolling.ts index b6732b5e..4c077c31 100644 --- a/frontend/src/hooks/useBlogSummaryPolling.ts +++ b/frontend/src/hooks/blog/useBlogSummaryPolling.ts @@ -3,10 +3,10 @@ import { summarizeBlogArticles, getBlogSummaryCache, getBlogSummaryCacheStatus, -} from "../api"; -import type { BlogArticle } from "../types"; -import { isInProgressStatus } from "../utils/taskStatus"; -import { useTaskPolling } from "./useTaskPolling"; +} from "../../api"; +import type { BlogArticle } from "../../types"; +import { isInProgressStatus } from "../../utils/taskStatus"; +import { useTaskPolling } from "../useTaskPolling"; /** * ブログ記事の AI サマリのポーリングを管理するフック。 diff --git a/frontend/src/hooks/useCareerAnalysisPage.test.ts b/frontend/src/hooks/career/useCareerAnalysisPage.test.ts similarity index 78% rename from frontend/src/hooks/useCareerAnalysisPage.test.ts rename to frontend/src/hooks/career/useCareerAnalysisPage.test.ts index be9ff5ba..a4dcd8a6 100644 --- a/frontend/src/hooks/useCareerAnalysisPage.test.ts +++ b/frontend/src/hooks/career/useCareerAnalysisPage.test.ts @@ -1,7 +1,7 @@ import { renderHook, waitFor, act } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { useCareerAnalysisPage } from "./useCareerAnalysisPage"; -import type { CareerAnalysisResponse } from "../api"; +import type { CareerAnalysisResponse } from "../../api"; /** テスト用のダミー分析データ */ const dummyCompleted: CareerAnalysisResponse = { @@ -28,8 +28,8 @@ const dummyPending: CareerAnalysisResponse = { created_at: "2024-01-02T00:00:00", }; -/** ../api モジュール全体をモック */ -vi.mock("../api", () => ({ +/** ../../api モジュール全体をモック */ +vi.mock("../../api", () => ({ listAnalyses: vi.fn(), generateAnalysis: vi.fn(), deleteAnalysis: vi.fn(), @@ -49,15 +49,34 @@ describe("useCareerAnalysisPage", () => { beforeEach(async () => { vi.clearAllMocks(); - api = await import("../api"); + api = await import("../../api"); api.listAnalyses.mockResolvedValue([]); }); + /** + * setup ファクトリ: 各テストで使う初期一覧と、API モックの挙動セットを差分指定可能にする。 + * 7 箇所でコピペされていた `renderHook(() => useCareerAnalysisPage())` 直前の API setup を集約する。 + */ + function setup( + initialAnalyses: CareerAnalysisResponse[] = [], + mockOverrides: { + generateAnalysis?: ReturnType; + deleteAnalysis?: ReturnType; + } = {}, + ) { + api.listAnalyses.mockResolvedValue(initialAnalyses); + if (mockOverrides.generateAnalysis) { + api.generateAnalysis = mockOverrides.generateAnalysis; + } + if (mockOverrides.deleteAnalysis) { + api.deleteAnalysis = mockOverrides.deleteAnalysis; + } + return renderHook(() => useCareerAnalysisPage()); + } + /** マウント時に listAnalyses が呼ばれ、データがなければ input フェーズになること */ it("マウント時にデータなしの場合 input フェーズになる", async () => { - api.listAnalyses.mockResolvedValue([]); - - const { result } = renderHook(() => useCareerAnalysisPage()); + const { result } = setup([]); await waitFor(() => { expect(result.current.phase).toBe("input"); @@ -69,9 +88,7 @@ describe("useCareerAnalysisPage", () => { /** マウント時にデータがあれば list フェーズになること */ it("マウント時にデータありの場合 list フェーズになる", async () => { - api.listAnalyses.mockResolvedValue([dummyCompleted]); - - const { result } = renderHook(() => useCareerAnalysisPage()); + const { result } = setup([dummyCompleted]); await waitFor(() => { expect(result.current.phase).toBe("list"); @@ -82,9 +99,7 @@ describe("useCareerAnalysisPage", () => { /** マウント時に pending レコードがあればポーリングフェーズになること */ it("マウント時に pending レコードがある場合 polling フェーズになる", async () => { - api.listAnalyses.mockResolvedValue([dummyPending]); - - const { result } = renderHook(() => useCareerAnalysisPage()); + const { result } = setup([dummyPending]); await waitFor(() => { expect(result.current.phase).toBe("polling"); @@ -93,10 +108,8 @@ describe("useCareerAnalysisPage", () => { /** handleGenerate が失敗した場合、error がセットされ input フェーズになること */ it("handleGenerate が失敗した場合 error がセットされ input フェーズになる", async () => { - api.listAnalyses.mockResolvedValue([]); api.generateAnalysis.mockRejectedValue(new Error("生成に失敗しました")); - - const { result } = renderHook(() => useCareerAnalysisPage()); + const { result } = setup([]); await waitFor(() => { expect(result.current.phase).toBe("input"); @@ -113,10 +126,8 @@ describe("useCareerAnalysisPage", () => { /** handleGenerate が成功した場合、polling フェーズに遷移すること */ it("handleGenerate が成功した場合 polling フェーズに遷移する", async () => { - api.listAnalyses.mockResolvedValue([]); api.generateAnalysis.mockResolvedValue({ id: 10, status: "pending" }); - - const { result } = renderHook(() => useCareerAnalysisPage()); + const { result } = setup([]); await waitFor(() => { expect(result.current.phase).toBe("input"); @@ -131,10 +142,8 @@ describe("useCareerAnalysisPage", () => { /** handleDelete が成功した場合、削除後の一覧を返すこと */ it("handleDelete が成功した場合 更新後の一覧を返す", async () => { - api.listAnalyses.mockResolvedValue([dummyCompleted]); api.deleteAnalysis.mockResolvedValue(undefined); - - const { result } = renderHook(() => useCareerAnalysisPage()); + const { result } = setup([dummyCompleted]); await waitFor(() => { expect(result.current.phase).toBe("list"); @@ -153,10 +162,8 @@ describe("useCareerAnalysisPage", () => { /** handleDelete が失敗した場合、null を返し error がセットされること */ it("handleDelete が失敗した場合 null を返し error がセットされる", async () => { - api.listAnalyses.mockResolvedValue([dummyCompleted]); api.deleteAnalysis.mockRejectedValue(new Error("削除に失敗しました")); - - const { result } = renderHook(() => useCareerAnalysisPage()); + const { result } = setup([dummyCompleted]); await waitFor(() => { expect(result.current.phase).toBe("list"); diff --git a/frontend/src/hooks/useCareerAnalysisPage.ts b/frontend/src/hooks/career/useCareerAnalysisPage.ts similarity index 95% rename from frontend/src/hooks/useCareerAnalysisPage.ts rename to frontend/src/hooks/career/useCareerAnalysisPage.ts index 6df07ce5..60315547 100644 --- a/frontend/src/hooks/useCareerAnalysisPage.ts +++ b/frontend/src/hooks/career/useCareerAnalysisPage.ts @@ -7,10 +7,10 @@ import { retryAnalysis, toAppError, type CareerAnalysisResponse, -} from "../api"; -import type { AppErrorState } from "../utils/appError"; -import { isInProgressStatus } from "../utils/taskStatus"; -import { useTaskPolling } from "./useTaskPolling"; +} from "../../api"; +import type { AppErrorState } from "../../utils/appError"; +import { isInProgressStatus } from "../../utils/taskStatus"; +import { useTaskPolling } from "../useTaskPolling"; export type CareerAnalysisPhase = "loading" | "input" | "polling" | "list" | "detail"; diff --git a/frontend/src/hooks/useCareerExperienceMutators.ts b/frontend/src/hooks/career/useCareerExperienceMutators.ts similarity index 98% rename from frontend/src/hooks/useCareerExperienceMutators.ts rename to frontend/src/hooks/career/useCareerExperienceMutators.ts index b7d8b8cb..c2338671 100644 --- a/frontend/src/hooks/useCareerExperienceMutators.ts +++ b/frontend/src/hooks/career/useCareerExperienceMutators.ts @@ -2,16 +2,16 @@ import { blankCareerClient, blankCareerExperience, blankCareerProject, -} from "../constants"; +} from "../../constants"; import type { CareerClientFieldKey, CareerExperienceFieldKey, -} from "../formTypes"; +} from "../../formTypes"; import type { CareerExperienceForm, CareerFormState, CareerProjectForm, -} from "../payloadBuilders"; +} from "../../payloadBuilders"; /** * 職務経歴フォームの experience / client / project 三階層に対する diff --git a/frontend/src/hooks/usePhotoUpload.test.ts b/frontend/src/hooks/career/usePhotoUpload.test.ts similarity index 100% rename from frontend/src/hooks/usePhotoUpload.test.ts rename to frontend/src/hooks/career/usePhotoUpload.test.ts diff --git a/frontend/src/hooks/usePhotoUpload.ts b/frontend/src/hooks/career/usePhotoUpload.ts similarity index 100% rename from frontend/src/hooks/usePhotoUpload.ts rename to frontend/src/hooks/career/usePhotoUpload.ts diff --git a/frontend/src/hooks/useProjectModalForm.test.ts b/frontend/src/hooks/career/useProjectModalForm.test.ts similarity index 98% rename from frontend/src/hooks/useProjectModalForm.test.ts rename to frontend/src/hooks/career/useProjectModalForm.test.ts index 41bb308d..b182b904 100644 --- a/frontend/src/hooks/useProjectModalForm.test.ts +++ b/frontend/src/hooks/career/useProjectModalForm.test.ts @@ -2,7 +2,7 @@ import { renderHook, act } from "@testing-library/react"; import { describe, it, expect } from "vitest"; import { useProjectModalForm } from "./useProjectModalForm"; -import type { CareerProjectForm } from "../payloadBuilders"; +import type { CareerProjectForm } from "../../payloadBuilders"; const sampleProject: CareerProjectForm = { name: "テスト", diff --git a/frontend/src/hooks/useProjectModalForm.ts b/frontend/src/hooks/career/useProjectModalForm.ts similarity index 89% rename from frontend/src/hooks/useProjectModalForm.ts rename to frontend/src/hooks/career/useProjectModalForm.ts index 50d46222..c3725eec 100644 --- a/frontend/src/hooks/useProjectModalForm.ts +++ b/frontend/src/hooks/career/useProjectModalForm.ts @@ -1,38 +1,29 @@ import { useState } from "react"; import { + blankCareerProject, blankCareerTechnologyStack, blankTeamMember, -} from "../constants"; -import type { CareerProjectFieldKey } from "../formTypes"; +} from "../../constants"; +import type { CareerProjectFieldKey } from "../../formTypes"; import { validateDateRange, type CareerProjectForm, -} from "../payloadBuilders"; -import type { CareerTechnologyStack, CareerTechnologyStackCategory } from "../types"; +} from "../../payloadBuilders"; +import type { CareerTechnologyStack, CareerTechnologyStackCategory } from "../../types"; /** * 編集対象が無い(新規追加)場合の初期プロジェクトを生成する。 * 既存プロジェクトを編集する場合は structuredClone で副作用を切る。 + * + * 初期値の唯一の SSoT は `constants.ts:blankCareerProject`。フィールド追加・改名は + * そちらで行えば本フックも自動追従する。 */ export function initProject(project: CareerProjectForm | null): CareerProjectForm { if (project) { return structuredClone(project); } - return { - name: "", - start_date: "", - end_date: "", - is_current: false, - role: "", - description: "", - challenge: "", - action: "", - result: "", - team: { total: "", members: [] }, - technology_stacks: [{ ...blankCareerTechnologyStack }], - phases: [], - }; + return structuredClone(blankCareerProject); } /** diff --git a/frontend/src/hooks/useProjectModalState.test.ts b/frontend/src/hooks/career/useProjectModalState.test.ts similarity index 95% rename from frontend/src/hooks/useProjectModalState.test.ts rename to frontend/src/hooks/career/useProjectModalState.test.ts index f84d5bb8..c2047829 100644 --- a/frontend/src/hooks/useProjectModalState.test.ts +++ b/frontend/src/hooks/career/useProjectModalState.test.ts @@ -1,7 +1,7 @@ import { renderHook, act } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; import { useProjectModalState } from "./useProjectModalState"; -import type { CareerProjectForm } from "../payloadBuilders"; +import type { CareerProjectForm } from "../../payloadBuilders"; const dummyProject: CareerProjectForm = { name: "テストプロジェクト", diff --git a/frontend/src/hooks/useProjectModalState.ts b/frontend/src/hooks/career/useProjectModalState.ts similarity index 96% rename from frontend/src/hooks/useProjectModalState.ts rename to frontend/src/hooks/career/useProjectModalState.ts index 916d958f..23b300c3 100644 --- a/frontend/src/hooks/useProjectModalState.ts +++ b/frontend/src/hooks/career/useProjectModalState.ts @@ -1,6 +1,6 @@ import { useState } from "react"; -import type { CareerProjectForm } from "../payloadBuilders"; +import type { CareerProjectForm } from "../../payloadBuilders"; /** プロジェクトモーダルの対象を表す型 */ export type ProjectModalTarget = { diff --git a/frontend/src/hooks/useDocumentForm.test.ts b/frontend/src/hooks/useDocumentForm.test.ts index 7e8eefc8..9ace5267 100644 --- a/frontend/src/hooks/useDocumentForm.test.ts +++ b/frontend/src/hooks/useDocumentForm.test.ts @@ -4,7 +4,7 @@ import React from "react"; import { Provider } from "react-redux"; import { configureStore } from "@reduxjs/toolkit"; import formCacheReducer, { setCache } from "../store/formCacheSlice"; -import { useDocumentForm } from "./useDocumentForm"; +import { useDocumentForm, type UseDocumentFormOptions } from "./useDocumentForm"; import { ApiError } from "../utils/appError"; /** テスト用の Redux Store を生成するヘルパー */ @@ -17,6 +17,9 @@ function createTestStore() { /** テスト用のフォーム状態型 */ type TestForm = { title: string }; +/** テスト用のペイロード型 */ +type TestPayload = { title: string }; + /** テスト用のレスポンス型 */ type TestResponse = { id: string; title: string }; @@ -42,14 +45,27 @@ describe("useDocumentForm", () => { })); }); - /** save() が API エラー(500)を返した場合、error メッセージが表示されること */ - it("save() で API エラーが発生した場合 error がセットされる", async () => { - mockLoadLatest.mockRejectedValue(new Error("Not found")); - + /** + * テスト共通の setup 関数。各 it では `overrides` で差分だけ指定する。 + * 5 箇所でコピペされていた renderHook + props の標準セットを集約する。 + */ + function setup( + overrides: Partial> = {}, + storeOverrides: { presetCache?: { form: TestForm; documentId: string | null } } = {}, + ) { const store = createTestStore(); - const { result } = renderHook( + if (storeOverrides.presetCache) { + store.dispatch( + setCache({ + key: "career", + form: storeOverrides.presetCache.form, + documentId: storeOverrides.presetCache.documentId, + }), + ); + } + const hook = renderHook( () => - useDocumentForm({ + useDocumentForm({ createInitialForm: () => ({ title: "" }), loadLatest: mockLoadLatest, createDocument: mockCreateDocument, @@ -57,9 +73,17 @@ describe("useDocumentForm", () => { buildPayload: mockBuildPayload, mapResponseToForm: mockMapResponseToForm, successMessage: "保存しました", + ...overrides, }), { wrapper: makeWrapper(store) }, ); + return { ...hook, store }; + } + + /** save() が API エラー(500)を返した場合、error メッセージが表示されること */ + it("save() で API エラーが発生した場合 error がセットされる", async () => { + mockLoadLatest.mockRejectedValue(new Error("Not found")); + const { result } = setup(); // ローディング完了を待つ await waitFor(() => { @@ -82,20 +106,7 @@ describe("useDocumentForm", () => { mockLoadLatest.mockRejectedValue(new Error("Not found")); mockCreateDocument.mockResolvedValueOnce({ id: "new-id", title: "テスト" }); - const store = createTestStore(); - const { result } = renderHook( - () => - useDocumentForm({ - createInitialForm: () => ({ title: "" }), - loadLatest: mockLoadLatest, - createDocument: mockCreateDocument, - updateDocument: mockUpdateDocument, - buildPayload: mockBuildPayload, - mapResponseToForm: mockMapResponseToForm, - successMessage: "保存しました", - }), - { wrapper: makeWrapper(store) }, - ); + const { result } = setup(); await waitFor(() => { expect(result.current.loading).toBe(false); @@ -116,20 +127,7 @@ describe("useDocumentForm", () => { new ApiError({ code: "UNAUTHORIZED", message: "認証が必要です" }), ); - const store = createTestStore(); - const { result } = renderHook( - () => - useDocumentForm({ - createInitialForm: () => ({ title: "" }), - loadLatest: mockLoadLatest, - createDocument: mockCreateDocument, - updateDocument: mockUpdateDocument, - buildPayload: mockBuildPayload, - mapResponseToForm: mockMapResponseToForm, - successMessage: "保存しました", - }), - { wrapper: makeWrapper(store) }, - ); + const { result } = setup(); await waitFor(() => { expect(result.current.loading).toBe(false); @@ -149,29 +147,9 @@ describe("useDocumentForm", () => { * ページ遷移で戻ってきたときの再 fetch チラつき/二重 API 呼び出しを直接守るテスト。 */ it("Redux キャッシュ存在時は loadLatest を呼ばずキャッシュ値を form の初期値にする", async () => { - const store = createTestStore(); - // 事前にキャッシュをセット - store.dispatch( - setCache({ - key: "career", - form: { title: "cached title" }, - documentId: "doc-cached", - }), - ); - - const { result } = renderHook( - () => - useDocumentForm({ - createInitialForm: () => ({ title: "" }), - loadLatest: mockLoadLatest, - createDocument: mockCreateDocument, - updateDocument: mockUpdateDocument, - buildPayload: mockBuildPayload, - mapResponseToForm: mockMapResponseToForm, - successMessage: "保存しました", - cacheKey: "career", - }), - { wrapper: makeWrapper(store) }, + const { result } = setup( + { cacheKey: "career" }, + { presetCache: { form: { title: "cached title" }, documentId: "doc-cached" } }, ); // キャッシュがあるので最初から loading=false @@ -186,24 +164,11 @@ describe("useDocumentForm", () => { /** beforeSave でエラーがスローされた場合、API が呼ばれずエラーが表示されること */ it("beforeSave でエラーがスローされた場合 API が呼ばれずエラーがセットされる", async () => { mockLoadLatest.mockRejectedValue(new Error("Not found")); - - const store = createTestStore(); - const { result } = renderHook( - () => - useDocumentForm({ - createInitialForm: () => ({ title: "" }), - loadLatest: mockLoadLatest, - createDocument: mockCreateDocument, - updateDocument: mockUpdateDocument, - buildPayload: mockBuildPayload, - mapResponseToForm: mockMapResponseToForm, - successMessage: "保存しました", - beforeSave: async () => { - throw new Error("基本情報が未入力です"); - }, - }), - { wrapper: makeWrapper(store) }, - ); + const { result } = setup({ + beforeSave: async () => { + throw new Error("基本情報が未入力です"); + }, + }); await waitFor(() => { expect(result.current.loading).toBe(false); diff --git a/frontend/src/hooks/useDocumentForm.ts b/frontend/src/hooks/useDocumentForm.ts index 0920c911..c60e9511 100644 --- a/frontend/src/hooks/useDocumentForm.ts +++ b/frontend/src/hooks/useDocumentForm.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAppDispatch, useAppSelector } from "../store"; import { clearCache, setCache, type FormCacheKey } from "../store/formCacheSlice"; -type UseDocumentFormOptions = { +export type UseDocumentFormOptions = { createInitialForm: () => FormState; loadLatest: () => Promise; createDocument: (payload: Payload) => Promise; diff --git a/frontend/src/hooks/useTaskPolling.test.ts b/frontend/src/hooks/useTaskPolling.test.ts index 4fc14737..f2156bf5 100644 --- a/frontend/src/hooks/useTaskPolling.test.ts +++ b/frontend/src/hooks/useTaskPolling.test.ts @@ -1,26 +1,40 @@ import { renderHook, act, waitFor } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, type Mock } from "vitest"; import { useTaskPolling } from "./useTaskPolling"; /** ポーリング間隔を短くしてテストを高速化する */ const FAST_INTERVAL = 50; describe("useTaskPolling", () => { - it("completed になったとき onCompleted が呼ばれる", async () => { - const onCompleted = vi.fn(); - const onFailed = vi.fn(); - const checkStatus = vi - .fn() - .mockResolvedValue({ status: "completed" }); - - const { result } = renderHook(() => + /** + * テスト共通の renderHook + コールバック作成を集約する setup ファクトリ。 + * checkStatus は呼び出し側で挙動を組み立てる(mockResolvedValue / mockRejectedValue 等)。 + * onCompleted / onFailed は省略時は新規 vi.fn() で生成し、overrides で差し替え可能。 + */ + function setup( + checkStatus: Mock, + overrides: { + onCompleted?: Mock; + onFailed?: Mock; + intervalMs?: number; + } = {}, + ) { + const onCompleted = overrides.onCompleted ?? vi.fn(); + const onFailed = overrides.onFailed ?? vi.fn(); + const hook = renderHook(() => useTaskPolling({ checkStatus, onCompleted, onFailed, - intervalMs: FAST_INTERVAL, + intervalMs: overrides.intervalMs ?? FAST_INTERVAL, }), ); + return { ...hook, onCompleted, onFailed }; + } + + it("completed になったとき onCompleted が呼ばれる", async () => { + const checkStatus = vi.fn().mockResolvedValue({ status: "completed" }); + const { result, onCompleted, onFailed } = setup(checkStatus); act(() => { result.current.startPolling(); @@ -34,21 +48,11 @@ describe("useTaskPolling", () => { }); it("dead_letter になったとき onFailed が呼ばれる", async () => { - const onCompleted = vi.fn(); - const onFailed = vi.fn(); const checkStatus = vi.fn().mockResolvedValue({ status: "dead_letter", error_message: "LLM タイムアウト", }); - - const { result } = renderHook(() => - useTaskPolling({ - checkStatus, - onCompleted, - onFailed, - intervalMs: FAST_INTERVAL, - }), - ); + const { result, onCompleted, onFailed } = setup(checkStatus); act(() => { result.current.startPolling(); @@ -67,19 +71,8 @@ describe("useTaskPolling", () => { }); it("dead_letter で error_message がない場合デフォルトメッセージが渡される", async () => { - const onFailed = vi.fn(); - const checkStatus = vi - .fn() - .mockResolvedValue({ status: "dead_letter" }); - - const { result } = renderHook(() => - useTaskPolling({ - checkStatus, - onCompleted: vi.fn(), - onFailed, - intervalMs: FAST_INTERVAL, - }), - ); + const checkStatus = vi.fn().mockResolvedValue({ status: "dead_letter" }); + const { result, onFailed } = setup(checkStatus); act(() => { result.current.startPolling(); @@ -96,21 +89,12 @@ describe("useTaskPolling", () => { }); it("pending 中はポーリングが継続し completed で停止する", async () => { - const onCompleted = vi.fn(); const checkStatus = vi .fn() .mockResolvedValueOnce({ status: "pending" }) .mockResolvedValueOnce({ status: "pending" }) .mockResolvedValue({ status: "completed" }); - - const { result } = renderHook(() => - useTaskPolling({ - checkStatus, - onCompleted, - onFailed: vi.fn(), - intervalMs: FAST_INTERVAL, - }), - ); + const { result, onCompleted } = setup(checkStatus); act(() => { result.current.startPolling(); @@ -124,19 +108,8 @@ describe("useTaskPolling", () => { }); it("アンマウント時にポーリングが停止する", async () => { - const checkStatus = vi - .fn() - .mockResolvedValue({ status: "pending" }); - const onCompleted = vi.fn(); - - const { result, unmount } = renderHook(() => - useTaskPolling({ - checkStatus, - onCompleted, - onFailed: vi.fn(), - intervalMs: FAST_INTERVAL, - }), - ); + const checkStatus = vi.fn().mockResolvedValue({ status: "pending" }); + const { result, unmount } = setup(checkStatus); act(() => { result.current.startPolling(); @@ -156,20 +129,11 @@ describe("useTaskPolling", () => { }); it("ネットワークエラー時はポーリングが継続する", async () => { - const onCompleted = vi.fn(); const checkStatus = vi .fn() .mockRejectedValueOnce(new Error("Network error")) .mockResolvedValue({ status: "completed" }); - - const { result } = renderHook(() => - useTaskPolling({ - checkStatus, - onCompleted, - onFailed: vi.fn(), - intervalMs: FAST_INTERVAL, - }), - ); + const { result, onCompleted } = setup(checkStatus); act(() => { result.current.startPolling(); diff --git a/frontend/src/payloadBuilders.test.ts b/frontend/src/payloadBuilders.test.ts index ca070e27..609df953 100644 --- a/frontend/src/payloadBuilders.test.ts +++ b/frontend/src/payloadBuilders.test.ts @@ -1,5 +1,54 @@ import { describe, it, expect } from "vitest"; -import { validateDateRange } from "./payloadBuilders"; + +import { + buildCareerPayload, + hasAnyText, + validateDateRange, + type CareerExperienceForm, + type CareerFormState, + type CareerProjectForm, +} from "./payloadBuilders"; + +// ── 共通 fixture ──────────────────────────────────────────────── + +const blankProject = (overrides: Partial = {}): CareerProjectForm => ({ + name: "P", + start_date: "2024-01", + end_date: "2024-06", + is_current: false, + role: "", + description: "業務内容", + challenge: "", + action: "", + result: "", + team: { total: "", members: [] }, + technology_stacks: [], + phases: [], + ...overrides, +}); + +const blankExperience = (overrides: Partial = {}): CareerExperienceForm => ({ + company: "Acme", + business_description: "Web", + start_date: "2023-01", + end_date: "2024-01", + is_current: false, + employee_count: "", + capital: "", + clients: [], + ...overrides, +}); + +const baseState = (overrides: Partial = {}): CareerFormState => ({ + full_name: "山田 太郎", + career_summary: "要約", + self_pr: "自己PR", + experiences: [], + qualifications: [], + ...overrides, +}); + +// ── validateDateRange ──────────────────────────────────────────── describe("validateDateRange", () => { it("開始日が終了日より後の場合にエラーメッセージが返される", () => { @@ -25,3 +74,309 @@ describe("validateDateRange", () => { expect(validateDateRange("2024-01", "", false)).toBeNull(); }); }); + +// ── hasAnyText ────────────────────────────────────────────────── + +describe("hasAnyText", () => { + it("すべて空 / null / undefined / 空白のみなら false を返す", () => { + expect(hasAnyText([])).toBe(false); + expect(hasAnyText([""])).toBe(false); + expect(hasAnyText([null, undefined])).toBe(false); + expect(hasAnyText([" ", "\t", "\n"])).toBe(false); + }); + + it("1 つでも非空白文字を含めば true を返す", () => { + expect(hasAnyText(["", " x "])).toBe(true); + expect(hasAnyText([null, "a"])).toBe(true); + expect(hasAnyText(["foo"])).toBe(true); + }); +}); + +// ── buildCareerPayload: 基本 ─────────────────────────────────── + +describe("buildCareerPayload (basic validation)", () => { + it("氏名が空ならエラー", () => { + expect(() => buildCareerPayload(baseState({ full_name: " " }))).toThrow(/氏名/); + }); + + it("職務要約が空ならエラー", () => { + expect(() => buildCareerPayload(baseState({ career_summary: "" }))).toThrow(/職務要約/); + }); + + it("自己PR が空ならエラー", () => { + expect(() => buildCareerPayload(baseState({ self_pr: "" }))).toThrow(/自己PR/); + }); + + it("必須項目が揃えば experiences/qualifications 空でも payload を返す", () => { + const payload = buildCareerPayload(baseState()); + expect(payload.full_name).toBe("山田 太郎"); + expect(payload.experiences).toEqual([]); + expect(payload.qualifications).toEqual([]); + }); + + it("前後の空白は trim される", () => { + const payload = buildCareerPayload( + baseState({ full_name: " 山田 ", career_summary: " 要約 ", self_pr: " PR " }), + ); + expect(payload.full_name).toBe("山田"); + expect(payload.career_summary).toBe("要約"); + expect(payload.self_pr).toBe("PR"); + }); +}); + +// ── experiences の境界 ──────────────────────────────────────── + +describe("buildCareerPayload (experiences)", () => { + it("is_current=true の experience は end_date が null に正規化される", () => { + const payload = buildCareerPayload( + baseState({ + experiences: [ + blankExperience({ is_current: true, end_date: "2024-12" }), + ], + }), + ); + expect(payload.experiences[0].end_date).toBeNull(); + expect(payload.experiences[0].is_current).toBe(true); + }); + + it("is_current=false で end_date 空ならエラー", () => { + expect(() => + buildCareerPayload( + baseState({ + experiences: [ + blankExperience({ is_current: false, end_date: " " }), + ], + }), + ), + ).toThrow(/離職年月/); + }); + + it("start_date より end_date が前ならエラー", () => { + expect(() => + buildCareerPayload( + baseState({ + experiences: [ + blankExperience({ start_date: "2024-06", end_date: "2024-01" }), + ], + }), + ), + ).toThrow(/開始日/); + }); + + it("会社名や事業内容が空ならエラー", () => { + expect(() => + buildCareerPayload( + baseState({ + experiences: [blankExperience({ company: "", business_description: "" })], + }), + ), + ).toThrow(/会社名/); + }); + + it("空欄だけの experience は filter で除外され、エラーにならない", () => { + const payload = buildCareerPayload( + baseState({ + experiences: [ + blankExperience({ + company: "", + business_description: "", + start_date: "", + end_date: "", + }), + ], + }), + ); + expect(payload.experiences).toEqual([]); + }); +}); + +// ── projects / clients / team の境界 ───────────────────────── + +describe("buildCareerPayload (projects/clients/team)", () => { + it("project.is_current=true なら end_date が空文字に正規化される", () => { + const payload = buildCareerPayload( + baseState({ + experiences: [ + blankExperience({ + clients: [ + { + name: "顧客A", + has_client: true, + projects: [ + blankProject({ + is_current: true, + end_date: "2024-12", + }), + ], + }, + ], + }), + ], + }), + ); + const proj = payload.experiences[0].clients[0].projects[0]; + expect(proj.end_date).toBe(""); + expect(proj.is_current).toBe(true); + }); + + it("client.has_client=false なら name が空文字に正規化される", () => { + const payload = buildCareerPayload( + baseState({ + experiences: [ + blankExperience({ + clients: [ + { + name: "捨てられる", + has_client: false, + projects: [blankProject({ description: "内容" })], + }, + ], + }), + ], + }), + ); + expect(payload.experiences[0].clients[0].name).toBe(""); + expect(payload.experiences[0].clients[0].has_client).toBe(false); + }); + + it("client.has_client=true で name 空かつ projects が中身なしなら除外される", () => { + const payload = buildCareerPayload( + baseState({ + experiences: [ + blankExperience({ + clients: [ + { + name: "", + has_client: true, + projects: [], + }, + ], + }), + ], + }), + ); + expect(payload.experiences[0].clients).toEqual([]); + }); + + it("team.members の空配列は payload でも空配列のまま", () => { + const payload = buildCareerPayload( + baseState({ + experiences: [ + blankExperience({ + clients: [ + { + name: "C", + has_client: true, + projects: [ + blankProject({ team: { total: "3", members: [] } }), + ], + }, + ], + }), + ], + }), + ); + expect(payload.experiences[0].clients[0].projects[0].team.members).toEqual([]); + expect(payload.experiences[0].clients[0].projects[0].team.total).toBe("3"); + }); + + it("team.members は role と count が両方 truthy のものだけ残り、count は number 化される", () => { + const payload = buildCareerPayload( + baseState({ + experiences: [ + blankExperience({ + clients: [ + { + name: "C", + has_client: true, + projects: [ + blankProject({ + team: { + total: "", + members: [ + { role: "PM", count: "1" }, + { role: "", count: "2" }, // role 空 → 除外 + { role: "SE", count: "" }, // count 空 → 除外 + { role: "QA", count: "3" }, + ], + }, + }), + ], + }, + ], + }), + ], + }), + ); + const members = payload.experiences[0].clients[0].projects[0].team.members; + expect(members).toEqual([ + { role: "PM", count: 1 }, + { role: "QA", count: 3 }, + ]); + }); + + it("technology_stacks は name が空のものを除外する", () => { + const payload = buildCareerPayload( + baseState({ + experiences: [ + blankExperience({ + clients: [ + { + name: "C", + has_client: true, + projects: [ + blankProject({ + technology_stacks: [ + { category: "language", name: "TypeScript" }, + { category: "framework", name: " " }, + { category: "db", name: "PostgreSQL" }, + ], + }), + ], + }, + ], + }), + ], + }), + ); + const stacks = payload.experiences[0].clients[0].projects[0].technology_stacks; + expect(stacks).toEqual([ + { category: "language", name: "TypeScript" }, + { category: "db", name: "PostgreSQL" }, + ]); + }); +}); + +// ── qualifications の境界 ──────────────────────────────────── + +describe("buildCareerPayload (qualifications)", () => { + it("空欄の qualification は除外される", () => { + const payload = buildCareerPayload( + baseState({ + qualifications: [{ acquired_date: "", name: "" }], + }), + ); + expect(payload.qualifications).toEqual([]); + }); + + it("片方だけ埋まった qualification はエラー", () => { + expect(() => + buildCareerPayload( + baseState({ + qualifications: [{ acquired_date: "2024-01-01", name: "" }], + }), + ), + ).toThrow(/資格/); + }); + + it("両方埋まった qualification は trim されて残る", () => { + const payload = buildCareerPayload( + baseState({ + qualifications: [{ acquired_date: " 2024-01-01 ", name: " 基本情報 " }], + }), + ); + expect(payload.qualifications).toEqual([ + { acquired_date: "2024-01-01", name: "基本情報" }, + ]); + }); +});