diff --git a/backend/app/services/intelligence/github_analysis_service.py b/backend/app/services/intelligence/github_analysis_service.py index f4db8ac2..52e06fea 100644 --- a/backend/app/services/intelligence/github_analysis_service.py +++ b/backend/app/services/intelligence/github_analysis_service.py @@ -32,13 +32,20 @@ def _now() -> datetime: async def run_github_analysis(db: Session, payload: dict) -> None: """GitHub 分析パイプラインを実行し、AI 学習アドバイスまで一括生成してキャッシュに保存する。""" - user_id = payload["user_id"] + user_id = payload.get("user_id") + # 必須キー欠落・キャッシュ不在はいずれもディスパッチ側のバグであり、 + # リトライしても回復しないため NonRetryableError で worker に dead_letter を委ねる。 + if not user_id: + message = "GitHub 分析タスクのペイロードに user_id がありません" + logger.error(message, extra={"payload_keys": list(payload.keys())}) + raise NonRetryableError(f"{message} (payload_keys={list(payload.keys())})") task_id = user_id cache = db.query(GitHubAnalysisCache).filter_by(user_id=user_id).first() if not cache: - logger.error("GitHub 分析キャッシュが見つかりません", extra={"user_id": user_id}) - raise RuntimeError(f"GitHub analysis cache not found: user_id={user_id}") + message = "GitHub 分析キャッシュが見つかりません" + logger.error(message, extra={"user_id": user_id}) + raise NonRetryableError(f"{message} (user_id={user_id})") cache.status = "processing" cache.started_at = _now() diff --git a/backend/app/services/tasks/handlers/career_analysis.py b/backend/app/services/tasks/handlers/career_analysis.py index 5d0217a7..fecdc9f2 100644 --- a/backend/app/services/tasks/handlers/career_analysis.py +++ b/backend/app/services/tasks/handlers/career_analysis.py @@ -7,6 +7,7 @@ from ....core.logging_utils import get_logger from ....models.career_analysis import CareerAnalysis +from ..exceptions import NonRetryableError from .base import TaskHandler logger = get_logger(__name__) @@ -30,14 +31,28 @@ async def run(self, db: Session, payload: dict) -> None: from ...career_analysis.builder import build_career_analysis from ...intelligence.llm import get_llm_client - user_id = payload["user_id"] - record_id = payload["record_id"] - target_position = payload["target_position"] + user_id = payload.get("user_id") + record_id = payload.get("record_id") + target_position = payload.get("target_position") + # 必須キー欠落はディスパッチ側のバグであり、リトライしても回復しない。 + # ``dead_letter`` への遷移を worker に委ねるため NonRetryableError を raise する。 + missing = [ + name for name, value in ( + ("user_id", user_id), + ("record_id", record_id), + ("target_position", target_position), + ) if not value + ] + if missing: + message = "キャリア分析タスクのペイロードに必須キーがありません" + logger.error(message, extra={"missing_keys": missing, "payload_keys": list(payload.keys())}) + raise NonRetryableError(f"{message} (missing={missing})") analysis = db.query(CareerAnalysis).filter_by(id=record_id, user_id=user_id).first() if not analysis: - logger.error("キャリア分析レコードが見つかりません", extra={"record_id": record_id}) - return + message = "キャリア分析レコードが見つかりません" + logger.error(message, extra={"record_id": record_id, "user_id": user_id}) + raise NonRetryableError(f"{message} (record_id={record_id}, user_id={user_id})") analysis.status = "processing" analysis.started_at = _now() diff --git a/backend/tests/services/tasks/test_handlers_failure.py b/backend/tests/services/tasks/test_handlers_failure.py new file mode 100644 index 00000000..3674f918 --- /dev/null +++ b/backend/tests/services/tasks/test_handlers_failure.py @@ -0,0 +1,132 @@ +""" +タスクハンドラの失敗パスを固定化するテスト。 + +CLAUDE.md の「タスクハンドラの『黙って return』は禁止」原則に基づき、 +3 ハンドラ(github_analysis / blog_summarize / career_analysis)の以下分岐で +``NonRetryableError`` が必ず raise されることを assert する。 + +- payload に必須キー(user_id 等)が無い +- DB に対応するレコード(キャッシュ / 分析)が無い + +worker は ``NonRetryableError`` を捕捉して ``dead_letter`` に遷移させるため、 +silent return / RuntimeError では「completed」として観測される回帰バグを直接検知できる。 +""" + +from __future__ import annotations + +import asyncio + +import pytest +from app.repositories import UserRepository +from app.services.tasks.exceptions import NonRetryableError +from app.services.tasks.handlers.blog_summarize import BlogSummarizeHandler +from app.services.tasks.handlers.career_analysis import CareerAnalysisHandler +from app.services.tasks.handlers.github_analysis import GitHubAnalysisHandler +from sqlalchemy.orm import Session + + +def _run(coro): + """async 関数を同期的に実行するヘルパー。""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _make_user(db: Session, username: str): + """テスト用ユーザーを作成する。""" + return UserRepository(db).create( + username, + hashed_password=None, + email=f"{username}@test.com", + ) + + +# ── BlogSummarizeHandler ────────────────────────────────────── + + +class TestBlogSummarizeHandlerFailures: + """ブログサマリハンドラの失敗パス。""" + + def test_missing_user_id_raises_non_retryable(self, db_session: Session) -> None: + """payload に user_id が無い → NonRetryableError。""" + handler = BlogSummarizeHandler() + with pytest.raises(NonRetryableError): + _run(handler.run(db_session, payload={})) + + def test_missing_cache_raises_non_retryable(self, db_session: Session) -> None: + """user_id はあるが BlogSummaryCache が無い → NonRetryableError。""" + user = _make_user(db_session, "blog-handler-no-cache") + handler = BlogSummarizeHandler() + with pytest.raises(NonRetryableError): + _run(handler.run(db_session, payload={"user_id": user.id})) + + +# ── CareerAnalysisHandler ───────────────────────────────────── + + +class TestCareerAnalysisHandlerFailures: + """キャリア分析ハンドラの失敗パス。""" + + def test_missing_user_id_raises_non_retryable(self, db_session: Session) -> None: + """payload に user_id が無い → NonRetryableError。""" + handler = CareerAnalysisHandler() + with pytest.raises(NonRetryableError): + _run(handler.run(db_session, payload={})) + + def test_missing_record_id_raises_non_retryable(self, db_session: Session) -> None: + """user_id だけで record_id が無い → NonRetryableError。""" + user = _make_user(db_session, "career-handler-no-record-id") + handler = CareerAnalysisHandler() + with pytest.raises(NonRetryableError): + _run(handler.run(db_session, payload={"user_id": user.id})) + + def test_missing_record_raises_non_retryable(self, db_session: Session) -> None: + """user_id / record_id はあるが CareerAnalysis が DB に無い → NonRetryableError。""" + user = _make_user(db_session, "career-handler-no-record") + handler = CareerAnalysisHandler() + with pytest.raises(NonRetryableError): + _run( + handler.run( + db_session, + payload={ + "user_id": user.id, + "record_id": 999999, + "target_position": "Backend", + }, + ) + ) + + +# ── GitHubAnalysisHandler ───────────────────────────────────── + + +class TestGithubAnalysisHandlerFailures: + """GitHub 分析ハンドラの失敗パス。""" + + def test_missing_user_id_raises_non_retryable(self, db_session: Session) -> None: + """payload に user_id が無い → NonRetryableError。""" + handler = GitHubAnalysisHandler() + with pytest.raises(NonRetryableError): + _run(handler.run(db_session, payload={})) + + def test_missing_cache_raises_non_retryable(self, db_session: Session) -> None: + """user_id はあるが GitHubAnalysisCache が無い → NonRetryableError。 + + 現状は RuntimeError を raise しており worker のリトライ対象になってしまうため、 + テストとしては失敗するはず(fix 後に通過)。 + """ + user = _make_user(db_session, "gh-handler-no-cache") + handler = GitHubAnalysisHandler() + with pytest.raises(NonRetryableError): + _run( + handler.run( + db_session, + payload={ + "user_id": user.id, + "github_username": "ghuser", + "include_forks": False, + }, + ) + ) diff --git a/backend/tests/test_worker_extended.py b/backend/tests/test_worker_extended.py index 9c525883..d78d135e 100644 --- a/backend/tests/test_worker_extended.py +++ b/backend/tests/test_worker_extended.py @@ -194,9 +194,14 @@ def test_github_user_not_found_sets_dead_letter(self, db_session: Session): db_session.refresh(cache) assert cache.status == "dead_letter" - def test_no_cache_raises_runtime_error(self, db_session: Session): - """キャッシュが見つからない場合、RuntimeError が送出されること。""" - with pytest.raises(RuntimeError, match="GitHub analysis cache not found"): + def test_no_cache_raises_non_retryable(self, db_session: Session): + """キャッシュが見つからない場合、NonRetryableError が送出されること。 + + worker 側で ``dead_letter`` 遷移と通知発行を行わせる契約。 + """ + from app.services.tasks.exceptions import NonRetryableError + + with pytest.raises(NonRetryableError): _run( _run_github_analysis( db_session, @@ -463,18 +468,25 @@ def test_value_error_sets_dead_letter(self, db_session: Session): assert analysis.error_message is not None assert "不足" in analysis.error_message - def test_no_record_returns_early(self, db_session: Session): - """レコードが見つからない場合、例外なく早期リターンすること。""" - _run( - _run_career_analysis( - db_session, - { - "user_id": "ghost", - "record_id": 99999, - "target_position": "test", - }, + def test_no_record_raises_non_retryable(self, db_session: Session): + """レコードが見つからない場合、NonRetryableError が送出されること。 + + 旧契約(silent return)は worker から completed と誤って観測される回帰を招くため、 + ``dead_letter`` への遷移を強制する。 + """ + from app.services.tasks.exceptions import NonRetryableError + + with pytest.raises(NonRetryableError): + _run( + _run_career_analysis( + db_session, + { + "user_id": "ghost", + "record_id": 99999, + "target_position": "test", + }, + ) ) - ) # ── _generate_advice_if_available ───────────────────────────────────────── diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6c0a7af1..9b870471 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,108 +1,16 @@ -import { useEffect, useRef, useState } from "react"; -import { useNavigate, useLocation } from "react-router-dom"; - -import { getCurrentUser, logout, setOnUnauthorized } from "./api"; import ErrorBoundary from "./components/ErrorBoundary"; +import { useAuthSession } from "./hooks/useAuthSession"; import { useTheme } from "./hooks/useTheme"; -import { AppRoutes, type AuthUser } from "./router"; - -// 認証チェック不要なパス(未認証が正常な画面) -const PUBLIC_PATHS = new Set(["/", "/login", "/github/callback"]); +import { AppRoutes } from "./router"; /** * アプリケーションのメインエントリーポイントコンポーネント。 - * 認証状態を管理し、AppRoutes にルーティングを委譲する。 + * 認証ライフサイクルとテーマは個別フックに委譲し、本コンポーネントは wiring に専念する。 */ export default function App() { - const navigate = useNavigate(); - const location = useLocation(); const { theme, toggleTheme } = useTheme(); - const [user, setUser] = useState(() => { - const saved = sessionStorage.getItem("auth_user"); - if (saved) { - try { - return JSON.parse(saved) as AuthUser; - } catch { - return null; - } - } - return null; - }); - const [authLoading, setAuthLoading] = useState( - user === null && !PUBLIC_PATHS.has(location.pathname), - ); - const [githubError, setGithubError] = useState(null); - // ログアウト直後の /auth/me 呼び出しを防ぐフラグ。 - // setUser(null) 前にセットし、effect が pathname より先に発火してもスキップできるようにする。 - const justLoggedOut = useRef(false); - - useEffect(() => { - setOnUnauthorized(() => { - sessionStorage.removeItem("auth_user"); - justLoggedOut.current = true; - setUser(null); - }); - }, []); - - useEffect(() => { - const params = new URLSearchParams(location.search); - const error = params.get("github_error"); - if (error) { - navigate(location.pathname, { replace: true }); - setGithubError(error); - } - }, [location.search, location.pathname, navigate]); - - useEffect(() => { - let active = true; - - if (user || PUBLIC_PATHS.has(location.pathname) || justLoggedOut.current) { - justLoggedOut.current = false; - setAuthLoading(false); - return () => { - active = false; - }; - } - - (async () => { - try { - const currentUser = await getCurrentUser(); - if (!active || !currentUser) return; - const authUser: AuthUser = { - username: currentUser.username, - isGitHubUser: currentUser.is_github_user, - }; - sessionStorage.setItem("auth_user", JSON.stringify(authUser)); - setUser(authUser); - } catch { - if (!active) return; - } finally { - if (active) { - setAuthLoading(false); - } - } - })(); - - return () => { - active = false; - }; - }, [user, location.pathname]); - - const handleLogout = async () => { - await logout(); - sessionStorage.removeItem("auth_user"); - justLoggedOut.current = true; - setUser(null); - }; - - const handleLoginSuccess = (rawUser: { username: string; is_github_user: boolean }) => { - const authUser: AuthUser = { - username: rawUser.username, - isGitHubUser: rawUser.is_github_user, - }; - sessionStorage.setItem("auth_user", JSON.stringify(authUser)); - setUser(authUser); - }; + const { user, authLoading, githubError, handleLogout, handleLoginSuccess } = + useAuthSession(); return ( diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ed93f8f4..2242a74e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -64,6 +64,19 @@ function getLegacyDetail(body: ErrorResponseBody | null): string | null { return typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail); } +/** + * 401 認証失敗時に共通で行う後処理。 + * onUnauthorized コールバックを発火し、AUTH_REQUIRED の ApiError を返す。 + */ +function buildUnauthorizedError(): ApiError { + _onUnauthorized?.(); + return new ApiError({ + code: "AUTH_REQUIRED", + message: "認証が必要です。再度ログインしてください。", + action: "ログインし直してください", + }); +} + function buildApiError(response: Response, body: ErrorResponseBody | null, fallbackMessage: string): ApiError { const code = body?.code ?? @@ -129,21 +142,11 @@ export async function request( if (refreshed) { return request(path, options, true); } - _onUnauthorized?.(); - throw new ApiError({ - code: "AUTH_REQUIRED", - message: "認証が必要です。再度ログインしてください。", - action: "ログインし直してください", - }); + throw buildUnauthorizedError(); } if (response.status === 401) { - _onUnauthorized?.(); - throw new ApiError({ - code: "AUTH_REQUIRED", - message: "認証が必要です。再度ログインしてください。", - action: "ログインし直してください", - }); + throw buildUnauthorizedError(); } if (!response.ok) { diff --git a/frontend/src/components/analysis/FrameworkList.test.tsx b/frontend/src/components/analysis/TechBar.test.tsx similarity index 81% rename from frontend/src/components/analysis/FrameworkList.test.tsx rename to frontend/src/components/analysis/TechBar.test.tsx index 3e54e512..c6f91f49 100644 --- a/frontend/src/components/analysis/FrameworkList.test.tsx +++ b/frontend/src/components/analysis/TechBar.test.tsx @@ -22,16 +22,10 @@ describe("TechBar", () => { it("リポジトリ数の多い順に並ぶ", () => { render(); - const items = screen.getAllByRole("generic").filter((el) => - ["React", "FastAPI", "Vue"].includes(el.textContent ?? ""), - ); - // React(3) > FastAPI(2) > Vue(1) の順 const texts = screen .getAllByText(/^(React|FastAPI|Vue)$/) .map((el) => el.textContent); expect(texts.indexOf("React")).toBeLessThan(texts.indexOf("FastAPI")); expect(texts.indexOf("FastAPI")).toBeLessThan(texts.indexOf("Vue")); - // items を使ってESLint の unused variable を回避 - expect(items).toBeDefined(); }); }); diff --git a/frontend/src/components/forms/ProjectModal.tsx b/frontend/src/components/forms/ProjectModal.tsx index 3573be7c..91154987 100644 --- a/frontend/src/components/forms/ProjectModal.tsx +++ b/frontend/src/components/forms/ProjectModal.tsx @@ -1,17 +1,11 @@ -import { useState } from "react"; - import type { CareerProjectForm } from "../../payloadBuilders"; -import { validateDateRange } from "../../payloadBuilders"; -import type { CareerTechnologyStack, CareerTechnologyStackCategory } from "../../types"; -import type { CareerProjectFieldKey } from "../../formTypes"; import { - blankCareerTechnologyStack, - blankTeamMember, careerTechnologyStackCategories, careerTechnologyStackCategoryLabels, phaseOptions, teamRoleOptions, } from "../../constants"; +import { useProjectModalForm } from "../../hooks/useProjectModalForm"; import { Combobox } from "./Combobox"; import { MarkdownTextarea } from "./MarkdownTextarea"; import styles from "./ProjectModal.module.css"; @@ -27,122 +21,25 @@ type ProjectModalProps = { techStackNamesByCategory: Map; }; -/** プロジェクト情報を初期化する */ -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: [], - }; -} - export function ProjectModal({ project, onSave, onClose, techStackNamesByCategory, }: ProjectModalProps) { - const [local, setLocal] = useState(() => initProject(project)); - - const updateField = (key: CareerProjectFieldKey, value: string | boolean) => { - setLocal((prev) => { - if (key === "is_current") { - const isCurrent = Boolean(value); - return { ...prev, is_current: isCurrent, end_date: isCurrent ? "" : prev.end_date }; - } - return { ...prev, [key]: value }; - }); - }; - - const updateTechStack = ( - stackIndex: number, - key: keyof CareerTechnologyStack, - value: string, - ) => { - setLocal((prev) => ({ - ...prev, - technology_stacks: prev.technology_stacks.map((stack, si) => { - if (si !== stackIndex) return stack; - if (key === "category") { - return { ...stack, category: value as CareerTechnologyStackCategory, name: "" }; - } - return { ...stack, name: value }; - }), - })); - }; - - const addTechStack = () => { - setLocal((prev) => ({ - ...prev, - technology_stacks: [...prev.technology_stacks, { ...blankCareerTechnologyStack }], - })); - }; - - const removeTechStack = (stackIndex: number) => { - setLocal((prev) => ({ - ...prev, - technology_stacks: - prev.technology_stacks.length === 1 - ? [{ ...blankCareerTechnologyStack }] - : prev.technology_stacks.filter((_, si) => si !== stackIndex), - })); - }; - - const updateTeamTotal = (value: string) => { - setLocal((prev) => ({ ...prev, team: { ...prev.team, total: value } })); - }; - - const addTeamMember = () => { - setLocal((prev) => ({ - ...prev, - team: { ...prev.team, members: [...prev.team.members, { ...blankTeamMember }] }, - })); - }; - - const removeTeamMember = (memberIndex: number) => { - setLocal((prev) => ({ - ...prev, - team: { - ...prev.team, - members: prev.team.members.filter((_, mi) => mi !== memberIndex), - }, - })); - }; - - const updateTeamMember = (memberIndex: number, key: "role" | "count", value: string) => { - setLocal((prev) => ({ - ...prev, - team: { - ...prev.team, - members: prev.team.members.map((m, mi) => - mi === memberIndex ? { ...m, [key]: value } : m, - ), - }, - })); - }; - - const togglePhase = (phase: string) => { - setLocal((prev) => { - const phases = prev.phases.includes(phase) - ? prev.phases.filter((p) => p !== phase) - : [...prev.phases, phase]; - return { ...prev, phases }; - }); - }; - - const dateError = validateDateRange(local.start_date, local.end_date, local.is_current); + const { + local, + dateError, + updateField, + updateTechStack, + addTechStack, + removeTechStack, + updateTeamTotal, + addTeamMember, + removeTeamMember, + updateTeamMember, + togglePhase, + } = useProjectModalForm(project); return (
diff --git a/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts b/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts index cbeeceae..094904c3 100644 --- a/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts +++ b/frontend/src/hooks/analysis/useAsyncAnalysisPage.test.ts @@ -160,6 +160,36 @@ describe("useAsyncAnalysisPage", () => { expect(result.current.result).toBeNull(); }); + /** + * fetchProgress が reject しても polling は継続し、progress は null のまま。 + * Redis 障害等の進捗取得失敗で hook 全体が壊れないことを守る。 + */ + it("fetchProgress が reject しても polling フェーズが維持される", async () => { + mockLoadCache.mockResolvedValue({ result: null, status: "pending" }); + mockCheckStatus.mockResolvedValue({ status: "pending" }); + const mockFetchProgress = vi.fn().mockRejectedValue(new Error("Redis down")); + + const { result } = renderHook(() => + useAsyncAnalysisPage({ + loadCache: mockLoadCache, + checkStatus: mockCheckStatus, + fetchProgress: mockFetchProgress, + }), + ); + + await waitFor(() => { + expect(result.current.phase).toBe("polling"); + }); + + await waitFor(() => { + expect(mockFetchProgress).toHaveBeenCalled(); + }); + + // fetchProgress が reject しても polling 本体は続行し、progress は null のまま + expect(result.current.phase).toBe("polling"); + expect(result.current.progress).toBeNull(); + }); + /** loadCache でエラーが発生した場合、input フェーズに遷移すること */ it("loadCache でエラーが発生した場合 input フェーズに遷移する", async () => { mockLoadCache.mockRejectedValue(new Error("ネットワークエラー")); diff --git a/frontend/src/hooks/analysis/useAsyncAnalysisPage.ts b/frontend/src/hooks/analysis/useAsyncAnalysisPage.ts index 222d7801..53d98f88 100644 --- a/frontend/src/hooks/analysis/useAsyncAnalysisPage.ts +++ b/frontend/src/hooks/analysis/useAsyncAnalysisPage.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { useTaskPolling } from "../useTaskPolling"; import type { AppErrorState } from "../../utils/appError"; +import { isInProgressStatus } from "../../utils/taskStatus"; import type { TaskProgress } from "../../api/intelligence"; /** 分析ページのフェーズ型 */ @@ -150,11 +151,7 @@ export function useAsyncAnalysisPage({ loadCache() .then((cached) => { if (cancelled) return; - if ( - cached.status === "pending" || - cached.status === "processing" || - cached.status === "retrying" - ) { + if (isInProgressStatus(cached.status)) { setPhase("polling"); return; } diff --git a/frontend/src/hooks/useAuthSession.test.ts b/frontend/src/hooks/useAuthSession.test.ts new file mode 100644 index 00000000..e51cab8a --- /dev/null +++ b/frontend/src/hooks/useAuthSession.test.ts @@ -0,0 +1,144 @@ +import { renderHook, act, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import React from "react"; +import { MemoryRouter } from "react-router-dom"; + +import { useAuthSession } from "./useAuthSession"; + +/** ../api 全体をモック */ +vi.mock("../api", () => ({ + getCurrentUser: vi.fn(), + logout: vi.fn(), + setOnUnauthorized: vi.fn(), +})); + +/** MemoryRouter でラップする wrapper を生成する */ +function makeWrapper(initialPath: string) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(MemoryRouter, { initialEntries: [initialPath] }, children); + }; +} + +describe("useAuthSession", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let api: Record; + + beforeEach(async () => { + vi.clearAllMocks(); + sessionStorage.clear(); + api = await import("../api"); + }); + + /** sessionStorage に保存された user が初期値として復元されること */ + it("sessionStorage に user があれば初期値として復元する", () => { + sessionStorage.setItem( + "auth_user", + JSON.stringify({ username: "saved-user", isGitHubUser: true }), + ); + + const { result } = renderHook(() => useAuthSession(), { + wrapper: makeWrapper("/career"), + }); + + expect(result.current.user).toEqual({ username: "saved-user", isGitHubUser: true }); + // 既に user があるので getCurrentUser は呼ばれない + expect(api.getCurrentUser).not.toHaveBeenCalled(); + }); + + /** 公開パス(/login)では user=null でも authLoading=false が初期値であること */ + it("公開パスでは user=null でも authLoading=false で getCurrentUser を呼ばない", async () => { + const { result } = renderHook(() => useAuthSession(), { + wrapper: makeWrapper("/login"), + }); + + await waitFor(() => { + expect(result.current.authLoading).toBe(false); + }); + expect(api.getCurrentUser).not.toHaveBeenCalled(); + }); + + /** 保護パスで user=null なら getCurrentUser でセッション復元を試みる */ + it("保護パスで user=null なら getCurrentUser を呼んでセッション復元する", async () => { + api.getCurrentUser.mockResolvedValue({ + username: "fetched-user", + is_github_user: false, + }); + + const { result } = renderHook(() => useAuthSession(), { + wrapper: makeWrapper("/career"), + }); + + await waitFor(() => { + expect(result.current.user).toEqual({ + username: "fetched-user", + isGitHubUser: false, + }); + }); + + // sessionStorage にも書き戻されること + const stored = sessionStorage.getItem("auth_user"); + expect(stored).toBeTruthy(); + const parsed = JSON.parse(stored as string); + expect(parsed).toEqual({ username: "fetched-user", isGitHubUser: false }); + }); + + /** + * handleLogout 後は justLoggedOut ref が立ち、後続の effect で getCurrentUser を呼ばないこと。 + * 「ログアウト直後にセッション復元が走って一瞬ログイン状態が復活する」race を直接守る。 + */ + it("handleLogout 後に getCurrentUser が呼ばれない(race 防止)", async () => { + sessionStorage.setItem( + "auth_user", + JSON.stringify({ username: "active-user", isGitHubUser: true }), + ); + api.logout.mockResolvedValue(undefined); + api.getCurrentUser.mockResolvedValue({ + username: "should-not-be-restored", + is_github_user: true, + }); + + const { result } = renderHook(() => useAuthSession(), { + wrapper: makeWrapper("/career"), + }); + + await waitFor(() => { + expect(result.current.user).not.toBeNull(); + }); + + await act(async () => { + await result.current.handleLogout(); + }); + + // user は null に + expect(result.current.user).toBeNull(); + // sessionStorage もクリア + expect(sessionStorage.getItem("auth_user")).toBeNull(); + // ログアウト後の effect で getCurrentUser が走らないこと(race 防止) + await waitFor(() => { + expect(result.current.authLoading).toBe(false); + }); + expect(api.getCurrentUser).not.toHaveBeenCalled(); + }); + + /** handleLoginSuccess で user state と sessionStorage が両方更新されること */ + it("handleLoginSuccess で user と sessionStorage が更新される", async () => { + const { result } = renderHook(() => useAuthSession(), { + wrapper: makeWrapper("/login"), + }); + + await waitFor(() => { + expect(result.current.authLoading).toBe(false); + }); + + act(() => { + result.current.handleLoginSuccess({ + username: "new-user", + is_github_user: true, + }); + }); + + expect(result.current.user).toEqual({ username: "new-user", isGitHubUser: true }); + const stored = JSON.parse(sessionStorage.getItem("auth_user") as string); + expect(stored).toEqual({ username: "new-user", isGitHubUser: true }); + }); +}); diff --git a/frontend/src/hooks/useAuthSession.ts b/frontend/src/hooks/useAuthSession.ts new file mode 100644 index 00000000..db536aa4 --- /dev/null +++ b/frontend/src/hooks/useAuthSession.ts @@ -0,0 +1,121 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; + +import { getCurrentUser, logout, setOnUnauthorized } from "../api"; +import type { AuthUser } from "../router"; + +// 認証チェック不要なパス(未認証が正常な画面) +const PUBLIC_PATHS = new Set(["/", "/login", "/github/callback"]); + +/** + * アプリケーション全体の認証セッションを管理するカスタムフック。 + * + * 責務: + * - sessionStorage との同期(ページリロード時の復元) + * - 401 を捕捉してログアウト状態へ遷移する onUnauthorized 登録 + * - URL クエリの ``github_error`` を取り込み、フックの利用者に渡す + * - 初回マウント時に /auth/me で現在のユーザーを復元 + * - ログアウト直後の race condition 防止(``justLoggedOut`` フラグ) + * + * App.tsx を wiring のみに痩せさせ、認証ライフサイクルを単独でテスト可能にする。 + */ +export function useAuthSession() { + const navigate = useNavigate(); + const location = useLocation(); + + const [user, setUser] = useState(() => { + const saved = sessionStorage.getItem("auth_user"); + if (saved) { + try { + return JSON.parse(saved) as AuthUser; + } catch { + return null; + } + } + return null; + }); + const [authLoading, setAuthLoading] = useState( + user === null && !PUBLIC_PATHS.has(location.pathname), + ); + const [githubError, setGithubError] = useState(null); + + // ログアウト直後の /auth/me 呼び出しを防ぐフラグ。 + // setUser(null) 前にセットし、effect が pathname より先に発火してもスキップできるようにする。 + const justLoggedOut = useRef(false); + + useEffect(() => { + setOnUnauthorized(() => { + sessionStorage.removeItem("auth_user"); + justLoggedOut.current = true; + setUser(null); + }); + }, []); + + useEffect(() => { + const params = new URLSearchParams(location.search); + const error = params.get("github_error"); + if (error) { + navigate(location.pathname, { replace: true }); + setGithubError(error); + } + }, [location.search, location.pathname, navigate]); + + useEffect(() => { + let active = true; + + if (user || PUBLIC_PATHS.has(location.pathname) || justLoggedOut.current) { + justLoggedOut.current = false; + setAuthLoading(false); + return () => { + active = false; + }; + } + + (async () => { + try { + const currentUser = await getCurrentUser(); + if (!active || !currentUser) return; + const authUser: AuthUser = { + username: currentUser.username, + isGitHubUser: currentUser.is_github_user, + }; + sessionStorage.setItem("auth_user", JSON.stringify(authUser)); + setUser(authUser); + } catch { + if (!active) return; + } finally { + if (active) { + setAuthLoading(false); + } + } + })(); + + return () => { + active = false; + }; + }, [user, location.pathname]); + + const handleLogout = async () => { + await logout(); + sessionStorage.removeItem("auth_user"); + justLoggedOut.current = true; + setUser(null); + }; + + const handleLoginSuccess = (rawUser: { username: string; is_github_user: boolean }) => { + const authUser: AuthUser = { + username: rawUser.username, + isGitHubUser: rawUser.is_github_user, + }; + sessionStorage.setItem("auth_user", JSON.stringify(authUser)); + setUser(authUser); + }; + + return { + user, + authLoading, + githubError, + handleLogout, + handleLoginSuccess, + }; +} diff --git a/frontend/src/hooks/useBlogAccountManager.ts b/frontend/src/hooks/useBlogAccountManager.ts index 6e77ac8e..a7c3201c 100644 --- a/frontend/src/hooks/useBlogAccountManager.ts +++ b/frontend/src/hooks/useBlogAccountManager.ts @@ -13,8 +13,17 @@ import { useBlogSummaryPolling } from "./useBlogSummaryPolling"; export type PlatformKey = "zenn" | "note" | "qiita"; +type PlatformAction = "saving" | "syncing" | "updating" | "deleting"; + +/** プラットフォーム別の進行中アクション集合。値が無いキーは「アイドル」を意味する。 */ +type PlatformActionMap = Partial>; + /** * BlogPage のブログアカウント管理・同期・AI分析ロジックを提供するカスタムフック。 + * + * 4 種類の per-platform lifecycle(saving / syncing / updating / deleting)は + * 単一の ``PlatformActionMap`` で集約管理する。外部には従来通り + * ``savingPlatform`` / ``syncingPlatform`` 等の派生値で公開する。 */ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") { const [accounts, setAccounts] = useState([]); @@ -30,14 +39,31 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") qiita: "", }); - /** 保存中のプラットフォーム */ - const [savingPlatform, setSavingPlatform] = useState(null); - /** 同期中のプラットフォーム */ - const [syncingPlatform, setSyncingPlatform] = useState(null); - /** 更新中のプラットフォーム */ - const [updatingPlatform, setUpdatingPlatform] = useState(null); - /** 解除中のプラットフォーム */ - const [deletingPlatform, setDeletingPlatform] = useState(null); + /** プラットフォーム別の進行中アクション。同時に複数プラットフォームを操作する余地を残す。 */ + const [actions, setActions] = useState({}); + + /** 指定プラットフォームのアクションをセット/解除する。 */ + const setAction = useCallback( + (platform: PlatformKey, action: PlatformAction | null) => { + setActions((prev) => { + if (action == null) { + const next = { ...prev }; + delete next[platform]; + return next; + } + return { ...prev, [platform]: action }; + }); + }, + [], + ); + + /** 指定アクションを実行中のプラットフォームを返す(最初の 1 件、なければ null)。 */ + const findPlatformWithAction = (target: PlatformAction): PlatformKey | null => { + for (const [platform, action] of Object.entries(actions)) { + if (action === target) return platform as PlatformKey; + } + return null; + }; /** アカウント map(platform → account) */ const accountMap = new Map(accounts.map((a) => [a.platform, a])); @@ -69,38 +95,51 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") const { summary, summaryLoading, summaryError, handleSummarize } = useBlogSummaryPolling(articles); + /** + * 保存/更新後の自動同期を試みる。成功時は formatSuccess の文言、 + * 失敗時は fallbackMessage を success にセットしつつ同期エラーを accountError に出す。 + */ + const attemptAutoSync = async ( + accountId: string, + formatSuccess: (synced: number, total: number) => string, + fallbackMessage: string, + ) => { + try { + const result = await syncBlogAccount(accountId); + await loadData(); + setSuccess(formatSuccess(result.synced_count, result.total_count)); + } catch (syncErr) { + setSuccess(fallbackMessage); + setAccountError( + syncErr instanceof Error + ? syncErr.message + : "記事の同期に失敗しました。「同期」ボタンで再試行してください。", + ); + } + }; + /** * ユーザー名を保存(連携)し、自動で記事を同期する。 */ const handleSave = async (platform: PlatformKey) => { const username = draftUsernames[platform]?.trim(); if (!username) return; - setSavingPlatform(platform); + setAction(platform, "saving"); setAccountError(null); setSuccess(null); try { const account = await addBlogAccount(platform, username); setDraftUsernames((prev) => ({ ...prev, [platform]: "" })); await loadData(); - // 連携直後に自動同期 - try { - const result = await syncBlogAccount(account.id); - await loadData(); - setSuccess( - `${result.synced_count}件の記事を取得しました(合計: ${result.total_count}件)`, - ); - } catch (syncErr) { - setSuccess("アカウントを連携しました"); - setAccountError( - syncErr instanceof Error - ? syncErr.message - : "記事の同期に失敗しました。「同期」ボタンで再試行してください。", - ); - } + await attemptAutoSync( + account.id, + (synced, total) => `${synced}件の記事を取得しました(合計: ${total}件)`, + "アカウントを連携しました", + ); } catch (e) { setAccountError(e instanceof Error ? e.message : "アカウントの連携に失敗しました"); } finally { - setSavingPlatform(null); + setAction(platform, null); } }; @@ -110,7 +149,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") const handleSync = async (platform: PlatformKey) => { const account = accountMap.get(platform); if (!account) return; - setSyncingPlatform(platform); + setAction(platform, "syncing"); setAccountError(null); setSuccess(null); try { @@ -122,7 +161,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") } catch (e) { setAccountError(e instanceof Error ? e.message : "同期に失敗しました"); } finally { - setSyncingPlatform(null); + setAction(platform, null); } }; @@ -132,7 +171,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") const handleDelete = async (platform: PlatformKey) => { const account = accountMap.get(platform); if (!account) return; - setDeletingPlatform(platform); + setAction(platform, "deleting"); setAccountError(null); setSuccess(null); try { @@ -142,7 +181,7 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") } catch (e) { setAccountError(e instanceof Error ? e.message : "アカウントの解除に失敗しました"); } finally { - setDeletingPlatform(null); + setAction(platform, null); } }; @@ -154,34 +193,25 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") if (!account) return false; const trimmedUsername = username.trim(); if (!trimmedUsername) return false; - setUpdatingPlatform(platform); + setAction(platform, "updating"); setAccountError(null); setSuccess(null); try { await updateBlogAccount(platform, trimmedUsername); setDraftUsernames((prev) => ({ ...prev, [platform]: "" })); await loadData(); - // 更新後に自動同期 - try { - const result = await syncBlogAccount(account.id); - await loadData(); - setSuccess( - `usernameを更新し、${result.synced_count}件の記事を取得しました(合計: ${result.total_count}件)`, - ); - } catch (syncErr) { - setSuccess("usernameを更新しました。再同期してください。"); - setAccountError( - syncErr instanceof Error - ? syncErr.message - : "記事の同期に失敗しました。「同期」ボタンで再試行してください。", - ); - } + await attemptAutoSync( + account.id, + (synced, total) => + `usernameを更新し、${synced}件の記事を取得しました(合計: ${total}件)`, + "usernameを更新しました。再同期してください。", + ); return true; } catch (e) { setAccountError(e instanceof Error ? e.message : "usernameの更新に失敗しました"); return false; } finally { - setUpdatingPlatform(null); + setAction(platform, null); } }; @@ -194,10 +224,10 @@ export function useBlogAccountManager(filter: "all" | "zenn" | "note" | "qiita") success, draftUsernames, setDraftUsernames, - savingPlatform, - syncingPlatform, - updatingPlatform, - deletingPlatform, + savingPlatform: findPlatformWithAction("saving"), + syncingPlatform: findPlatformWithAction("syncing"), + updatingPlatform: findPlatformWithAction("updating"), + deletingPlatform: findPlatformWithAction("deleting"), summary, summaryLoading, accountMap, diff --git a/frontend/src/hooks/useBlogSummaryPolling.ts b/frontend/src/hooks/useBlogSummaryPolling.ts index e2cd9e9d..b6732b5e 100644 --- a/frontend/src/hooks/useBlogSummaryPolling.ts +++ b/frontend/src/hooks/useBlogSummaryPolling.ts @@ -5,6 +5,7 @@ import { getBlogSummaryCacheStatus, } from "../api"; import type { BlogArticle } from "../types"; +import { isInProgressStatus } from "../utils/taskStatus"; import { useTaskPolling } from "./useTaskPolling"; /** @@ -35,11 +36,7 @@ export function useBlogSummaryPolling(articles: BlogArticle[]) { useEffect(() => { getBlogSummaryCache() .then((cached) => { - if ( - cached.status === "pending" || - cached.status === "processing" || - cached.status === "retrying" - ) { + if (isInProgressStatus(cached.status)) { setSummaryLoading(true); startPolling(); return; diff --git a/frontend/src/hooks/useCareerAnalysisPage.ts b/frontend/src/hooks/useCareerAnalysisPage.ts index 277de6c5..6df07ce5 100644 --- a/frontend/src/hooks/useCareerAnalysisPage.ts +++ b/frontend/src/hooks/useCareerAnalysisPage.ts @@ -9,6 +9,7 @@ import { type CareerAnalysisResponse, } 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"; @@ -57,12 +58,7 @@ export function useCareerAnalysisPage() { if (!active) return; setAnalyses(data); - const pending = data.find( - (a) => - a.status === "pending" || - a.status === "processing" || - a.status === "retrying", - ); + const pending = data.find((a) => isInProgressStatus(a.status)); if (pending) { setPollingId(pending.id); setPhase("polling"); diff --git a/frontend/src/hooks/useDocumentForm.test.ts b/frontend/src/hooks/useDocumentForm.test.ts index 3c0189a1..7e8eefc8 100644 --- a/frontend/src/hooks/useDocumentForm.test.ts +++ b/frontend/src/hooks/useDocumentForm.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import React from "react"; import { Provider } from "react-redux"; import { configureStore } from "@reduxjs/toolkit"; -import formCacheReducer from "../store/formCacheSlice"; +import formCacheReducer, { setCache } from "../store/formCacheSlice"; import { useDocumentForm } from "./useDocumentForm"; import { ApiError } from "../utils/appError"; @@ -143,6 +143,46 @@ describe("useDocumentForm", () => { expect(result.current.success).toBeNull(); }); + /** + * Redux キャッシュにデータがある場合、loadLatest を呼ばずに + * キャッシュ値を初期 form として使うこと。 + * ページ遷移で戻ってきたときの再 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) }, + ); + + // キャッシュがあるので最初から loading=false + expect(result.current.loading).toBe(false); + // loadLatest は呼ばれない(二重 fetch 防止) + expect(mockLoadLatest).not.toHaveBeenCalled(); + // キャッシュ値が form の初期値になる + expect(result.current.form).toEqual({ title: "cached title" }); + expect(result.current.documentId).toBe("doc-cached"); + }); + /** beforeSave でエラーがスローされた場合、API が呼ばれずエラーが表示されること */ it("beforeSave でエラーがスローされた場合 API が呼ばれずエラーがセットされる", async () => { mockLoadLatest.mockRejectedValue(new Error("Not found")); diff --git a/frontend/src/hooks/useProjectModalForm.test.ts b/frontend/src/hooks/useProjectModalForm.test.ts new file mode 100644 index 00000000..41bb308d --- /dev/null +++ b/frontend/src/hooks/useProjectModalForm.test.ts @@ -0,0 +1,108 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; + +import { useProjectModalForm } from "./useProjectModalForm"; +import type { CareerProjectForm } from "../payloadBuilders"; + +const sampleProject: CareerProjectForm = { + name: "テスト", + start_date: "2024-01", + end_date: "2024-12", + is_current: false, + role: "Backend", + description: "", + challenge: "", + action: "", + result: "", + team: { total: "5", members: [{ role: "SE", count: "3" }] }, + technology_stacks: [{ category: "language", name: "Python" }], + phases: ["設計"], +}; + +describe("useProjectModalForm", () => { + it("project=null で初期化すると空の state が返る", () => { + const { result } = renderHook(() => useProjectModalForm(null)); + expect(result.current.local.name).toBe(""); + expect(result.current.local.is_current).toBe(false); + expect(result.current.local.technology_stacks).toHaveLength(1); + expect(result.current.local.team.members).toHaveLength(0); + }); + + it("既存 project は structuredClone され元データを変更しない", () => { + const { result } = renderHook(() => useProjectModalForm(sampleProject)); + act(() => result.current.updateField("name", "差し替え")); + expect(result.current.local.name).toBe("差し替え"); + // 元データは破壊されない + expect(sampleProject.name).toBe("テスト"); + }); + + it("is_current=true に切り替えると end_date が空にリセットされる", () => { + const { result } = renderHook(() => useProjectModalForm(sampleProject)); + act(() => result.current.updateField("is_current", true)); + expect(result.current.local.is_current).toBe(true); + expect(result.current.local.end_date).toBe(""); + }); + + it("技術スタックのカテゴリを変えると name が空にリセットされる", () => { + const { result } = renderHook(() => useProjectModalForm(sampleProject)); + act(() => result.current.updateTechStack(0, "category", "framework")); + expect(result.current.local.technology_stacks[0]).toEqual({ + category: "framework", + name: "", + }); + }); + + it("技術スタックの追加/削除が動作する", () => { + const { result } = renderHook(() => useProjectModalForm(sampleProject)); + act(() => result.current.addTechStack()); + expect(result.current.local.technology_stacks).toHaveLength(2); + act(() => result.current.removeTechStack(0)); + expect(result.current.local.technology_stacks).toHaveLength(1); + }); + + it("技術スタックを 1 件のみ残して削除すると空チップが再生成される", () => { + const { result } = renderHook(() => + useProjectModalForm({ ...sampleProject, technology_stacks: [{ category: "language", name: "Go" }] }), + ); + act(() => result.current.removeTechStack(0)); + expect(result.current.local.technology_stacks).toHaveLength(1); + expect(result.current.local.technology_stacks[0]).toEqual({ category: "language", name: "" }); + }); + + it("チームメンバーの追加・削除・更新が動作する", () => { + const { result } = renderHook(() => useProjectModalForm(sampleProject)); + act(() => result.current.addTeamMember()); + expect(result.current.local.team.members).toHaveLength(2); + act(() => result.current.updateTeamMember(1, "role", "PM")); + expect(result.current.local.team.members[1]).toEqual({ role: "PM", count: "" }); + act(() => result.current.removeTeamMember(0)); + expect(result.current.local.team.members).toHaveLength(1); + }); + + it("phase をトグルできる(追加→削除)", () => { + const { result } = renderHook(() => useProjectModalForm(sampleProject)); + act(() => result.current.togglePhase("実装")); + expect(result.current.local.phases).toContain("実装"); + act(() => result.current.togglePhase("設計")); + expect(result.current.local.phases).not.toContain("設計"); + }); + + it("開始日 > 終了日 のとき dateError が生成される", () => { + const { result } = renderHook(() => + useProjectModalForm({ ...sampleProject, start_date: "2024-12", end_date: "2024-01" }), + ); + expect(result.current.dateError).not.toBeNull(); + }); + + it("is_current=true なら dateError は発生しない", () => { + const { result } = renderHook(() => + useProjectModalForm({ + ...sampleProject, + start_date: "2024-12", + end_date: "2024-01", + is_current: true, + }), + ); + expect(result.current.dateError).toBeNull(); + }); +}); diff --git a/frontend/src/hooks/useProjectModalForm.ts b/frontend/src/hooks/useProjectModalForm.ts new file mode 100644 index 00000000..50d46222 --- /dev/null +++ b/frontend/src/hooks/useProjectModalForm.ts @@ -0,0 +1,146 @@ +import { useState } from "react"; + +import { + blankCareerTechnologyStack, + blankTeamMember, +} from "../constants"; +import type { CareerProjectFieldKey } from "../formTypes"; +import { + validateDateRange, + type CareerProjectForm, +} from "../payloadBuilders"; +import type { CareerTechnologyStack, CareerTechnologyStackCategory } from "../types"; + +/** + * 編集対象が無い(新規追加)場合の初期プロジェクトを生成する。 + * 既存プロジェクトを編集する場合は structuredClone で副作用を切る。 + */ +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: [], + }; +} + +/** + * プロジェクト編集モーダルの state と nested update ハンドラを提供するフック。 + * ProjectModal の責務を JSX に絞るために、データ操作ロジックを切り出している。 + */ +export function useProjectModalForm(project: CareerProjectForm | null) { + const [local, setLocal] = useState(() => initProject(project)); + + const updateField = (key: CareerProjectFieldKey, value: string | boolean) => { + setLocal((prev) => { + if (key === "is_current") { + const isCurrent = Boolean(value); + return { ...prev, is_current: isCurrent, end_date: isCurrent ? "" : prev.end_date }; + } + return { ...prev, [key]: value }; + }); + }; + + const updateTechStack = ( + stackIndex: number, + key: keyof CareerTechnologyStack, + value: string, + ) => { + setLocal((prev) => ({ + ...prev, + technology_stacks: prev.technology_stacks.map((stack, si) => { + if (si !== stackIndex) return stack; + if (key === "category") { + return { ...stack, category: value as CareerTechnologyStackCategory, name: "" }; + } + return { ...stack, name: value }; + }), + })); + }; + + const addTechStack = () => { + setLocal((prev) => ({ + ...prev, + technology_stacks: [...prev.technology_stacks, { ...blankCareerTechnologyStack }], + })); + }; + + const removeTechStack = (stackIndex: number) => { + setLocal((prev) => ({ + ...prev, + technology_stacks: + prev.technology_stacks.length === 1 + ? [{ ...blankCareerTechnologyStack }] + : prev.technology_stacks.filter((_, si) => si !== stackIndex), + })); + }; + + const updateTeamTotal = (value: string) => { + setLocal((prev) => ({ ...prev, team: { ...prev.team, total: value } })); + }; + + const addTeamMember = () => { + setLocal((prev) => ({ + ...prev, + team: { ...prev.team, members: [...prev.team.members, { ...blankTeamMember }] }, + })); + }; + + const removeTeamMember = (memberIndex: number) => { + setLocal((prev) => ({ + ...prev, + team: { + ...prev.team, + members: prev.team.members.filter((_, mi) => mi !== memberIndex), + }, + })); + }; + + const updateTeamMember = (memberIndex: number, key: "role" | "count", value: string) => { + setLocal((prev) => ({ + ...prev, + team: { + ...prev.team, + members: prev.team.members.map((m, mi) => + mi === memberIndex ? { ...m, [key]: value } : m, + ), + }, + })); + }; + + const togglePhase = (phase: string) => { + setLocal((prev) => { + const phases = prev.phases.includes(phase) + ? prev.phases.filter((p) => p !== phase) + : [...prev.phases, phase]; + return { ...prev, phases }; + }); + }; + + const dateError = validateDateRange(local.start_date, local.end_date, local.is_current); + + return { + local, + dateError, + updateField, + updateTechStack, + addTechStack, + removeTechStack, + updateTeamTotal, + addTeamMember, + removeTeamMember, + updateTeamMember, + togglePhase, + }; +} diff --git a/frontend/src/utils/taskStatus.ts b/frontend/src/utils/taskStatus.ts new file mode 100644 index 00000000..fbc2dc2f --- /dev/null +++ b/frontend/src/utils/taskStatus.ts @@ -0,0 +1,23 @@ +/** + * 非同期タスクのステータス契約をフロント側で 1 か所に集約するモジュール。 + * + * backend (``app/services/tasks/base.py``) と同じ判定式を採用する。 + * 文字列リテラルの直接比較を hook 内に散らさないことで、契約変更時の + * 修正箇所を最小化する。 + */ + +/** バックエンドが返すタスクステータス文字列。 */ +export type TaskStatus = + | "pending" + | "processing" + | "retrying" + | "completed" + | "dead_letter"; + +/** + * タスクが「進行中」(pending / processing / retrying)かを判定する。 + * UI 側ではポーリング継続や入力フォーム表示制御の判定に使う。 + */ +export function isInProgressStatus(status: string | null | undefined): boolean { + return status === "pending" || status === "processing" || status === "retrying"; +}