diff --git a/.claude/rules/backend/architecture.md b/.claude/rules/backend/architecture.md index 227e47c9..9bae8aaa 100644 --- a/.claude/rules/backend/architecture.md +++ b/.claude/rules/backend/architecture.md @@ -11,6 +11,7 @@ backend/app/ ├── messages.json # ユーザー向けメッセージ・通知文言の定義 ├── core/ # 設定・メッセージ・認証・暗号化などの横断基盤 │ ├── settings.py +│ ├── env_keys.py # 環境変数名の定数定義(正本) │ ├── messages.py │ ├── logging_utils.py │ ├── date_utils.py @@ -33,8 +34,7 @@ backend/app/ │ └── seeds/ ├── routers/ # FastAPI エンドポイント │ ├── auth/ # 認証関連(endpoints, github_auth, oauth_flow, token_manager) -│ ├── blog.py -│ ├── career_analysis.py +│ ├── blog/ # ブログ連携(accounts, score, sync) │ ├── download_utils.py │ ├── health.py │ ├── github_link.py @@ -43,13 +43,13 @@ backend/app/ │ ├── notifications.py │ └── resumes.py ├── models/ # SQLAlchemy 2.0 宣言的マッピング -│ ├── user.py / blog.py / cache.py / career_analysis.py +│ ├── user.py / blog.py / cache.py │ ├── master_data.py / notification.py / resume.py ├── schemas/ # Pydantic リクエスト/レスポンススキーマ -│ ├── auth.py / blog.py / career_analysis.py / github_link.py +│ ├── auth.py / blog.py / github_link.py │ ├── master_data.py / resume.py / shared.py ├── repositories/ # データアクセス層 -│ ├── base.py / user.py / blog.py / career_analysis.py +│ ├── base.py / user.py / blog.py │ ├── master_data.py / notification.py / resume.py ├── services/ │ ├── blog/ # ブログ収集・技術記事判定・スコア算出 @@ -58,10 +58,6 @@ backend/app/ │ │ ├── scorer.py │ │ ├── sync_service.py │ │ └── tech_keywords.json -│ ├── career_analysis/ # キャリア分析(プロンプト組み立て・テックスタックマージ) -│ │ ├── builder.py -│ │ ├── prompt_builder.py -│ │ └── tech_stack_merger.py │ ├── intelligence/ # GitHub 連携パイプラインと LLM 連携 │ │ ├── pipeline.py │ │ ├── github_collector.py @@ -69,39 +65,34 @@ backend/app/ │ │ ├── github/ # GitHub API クライアント・リポジトリ解析 │ │ │ ├── api_client.py │ │ │ └── repo_analyzer.py -│ │ ├── llm_summarizer.py -│ │ ├── llm_advice_service.py │ │ ├── response_mapper.py -│ │ ├── position_scorer.py -│ │ ├── position_weights.json │ │ ├── skill_extractor.py -│ │ ├── skill_taxonomy/ # スキル分類(言語・トピック・所有権マップ) -│ │ └── llm/ # LLM クライアント実装 +│ │ ├── skill_taxonomy/ # スキル分類(言語・トピック・キーワードマップ) +│ │ └── llm/ # LLM クライアント実装(休眠インフラ・温存) │ │ ├── base.py │ │ ├── factory.py │ │ ├── ollama_client.py │ │ └── vertex_client.py -│ ├── llm/ # LLM 入出力サニタイザ等(intelligence/llm とは別) +│ ├── llm/ # LLM 入出力サニタイザ(intelligence/llm とは別・休眠) │ │ └── sanitizer.py │ ├── tasks/ # 非同期タスク基盤(Cloud Tasks / ローカル) -│ │ ├── base.py # TaskType 定義 +│ │ ├── base.py # TaskType 定義(現状 GITHUB_LINK のみ) │ │ ├── exceptions.py # RetryableError / NonRetryableError │ │ ├── worker.py # execute_task(状態遷移・通知) -│ │ ├── dispatch_service.py +│ │ ├── dispatch_service.py # AsyncTaskCacheService(状態遷移 + ディスパッチ) │ │ ├── factory.py │ │ ├── cloud_tasks.py # Cloud Tasks エンキュー │ │ ├── local.py # BackgroundTasks 直接実行 │ │ └── handlers/ # タスク種別ごとのハンドラ │ │ ├── base.py # TaskHandler 抽象基底クラス -│ │ ├── blog_summarize.py -│ │ ├── career_analysis.py │ │ └── github_link.py -│ ├── markdown/ # Markdown テンプレート生成 -│ ├── pdf/ # WeasyPrint による PDF 生成 +│ ├── markdown/ # Markdown 生成(generators / templates / utils) +│ ├── pdf/ # WeasyPrint による PDF 生成(generators / utils) │ ├── progress_service.py # 進捗状態管理 │ └── shared/ # ドメイン横断の service util +│ ├── resume_format.py # レジュメ整形の共通ロジック(md/pdf 共有) │ └── sort_utils.py -├── prompts/ # LLM プロンプトテンプレート +├── prompts/ # LLM プロンプトテンプレート(MD) ├── fonts/ # PDF 生成用フォント └── utils/ └── prompt_loader.py # プロンプトファイルローダ @@ -109,7 +100,7 @@ backend/app/ ## 主要モジュールのポイント -- **routers/auth/**: パッケージ化されており、`endpoints` / `github_auth` / `oauth_flow` / `token_manager` に責務分割 -- **services/tasks/**: Cloud Tasks(本番)と BackgroundTasks(ローカル)を共通の `execute_task` でディスパッチ。状態遷移(`processing` / `completed` / `dead_letter` / `retrying`)は worker が担う -- **services/intelligence/**: GitHub 連携 → スキル集計パイプライン。Ollama / Vertex AI を `LLMClient` 抽象で切替 -- **services/llm/ と services/intelligence/llm/**: 別物。前者は入出力サニタイザ等の横断 util、後者は LLM プロバイダクライアントの実装 +- **routers/auth/ と routers/blog/**: いずれもパッケージ化されている。auth は `endpoints` / `github_auth` / `oauth_flow` / `token_manager`、blog は `accounts` / `score` / `sync` に責務分割 +- **services/tasks/**: Cloud Tasks(本番)と BackgroundTasks(ローカル)を共通の `execute_task` でディスパッチ。状態遷移(`processing` / `completed` / `dead_letter` / `retrying`)は worker が担う。現在登録されているタスクは `GITHUB_LINK` の 1 種類のみだが、`AsyncTaskCacheService` / `TaskHandler` は新規タスク追加の拡張ポイントとして汎用化してある(インライン化しない) +- **services/intelligence/**: GitHub 連携 → スキル集計パイプライン。`github_link_service` → `pipeline` → `github_collector` → `skill_extractor` が live 経路 +- **services/llm/ と services/intelligence/llm/**: 別物。前者は入出力サニタイザ等の横断 util、後者は LLM プロバイダクライアント(Ollama / Vertex AI を `LLMClient` 抽象で切替)の実装。**両者とも現状どの live エンドポイントからも到達しない休眠インフラ**で、将来用に温存している。デッドコードとして削除しないこと diff --git a/backend/app/main.py b/backend/app/main.py index 44752802..dbcf8172 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -86,7 +86,7 @@ async def _validation_exception_handler(request: Request, exc: RequestValidation status_code=422, content=build_app_error_response( code=ErrorCode.VALIDATION_ERROR, - message="入力内容を確認してください。", + message=get_error("validation.invalid_input"), action="入力内容を見直して再試行してください", error_id=error_id, ).model_dump(exclude_none=True), diff --git a/backend/app/messages.json b/backend/app/messages.json index 98f2550c..506dfd36 100644 --- a/backend/app/messages.json +++ b/backend/app/messages.json @@ -38,11 +38,13 @@ "github_login_required": "GitHub連携にはGitHubアカウントでのログインが必要です。", "github_user_not_found": "GitHubユーザーが見つかりません: {username}", "profile_fetch_failed": "GitHubプロフィールの取得に失敗しました。しばらくしてから再度お試しください。", - "no_link_cache": "連携データがありません。先にGitHub連携を実行してください。" + "no_link_cache": "連携データがありません。先にGitHub連携を実行してください。", + "not_retryable": "このタスクはリトライできない状態です(現在: {status})。" }, "validation": { "required_field": "{field}は必須です。", "invalid_format": "{field}の形式が正しくありません。", + "invalid_input": "入力内容を確認してください。", "date_range_invalid": "開始日は終了日より前に設定してください。", "start_date_required": "開始年月を入力してください。", "end_date_required": "在職中でない場合は終了年月を入力してください。" @@ -59,7 +61,9 @@ "task": { "already_in_progress": "処理が進行中です。完了までお待ちください。", "dispatch_failed": "タスクの開始に失敗しました。しばらくしてから再度お試しください。", - "internal_unauthorized": "内部タスクエンドポイントへのアクセスが拒否されました。" + "internal_unauthorized": "内部タスクエンドポイントへのアクセスが拒否されました。", + "unknown_task_type": "不明なタスク種別: {task_type}", + "execution_failed": "タスク実行中に予期しないエラーが発生しました。" } }, "notification": { diff --git a/backend/app/routers/github_link.py b/backend/app/routers/github_link.py index c2053f03..d31de433 100644 --- a/backend/app/routers/github_link.py +++ b/backend/app/routers/github_link.py @@ -175,7 +175,7 @@ async def retry_github_link( raise_app_error( status_code=409, code=ErrorCode.VALIDATION_ERROR, - message=f"このタスクはリトライできない状態です(現在: {cache.status})", + message=get_error("github_link.not_retryable", status=cache.status), action="タスクの完了または失敗を待ってから再試行してください", ) @@ -187,7 +187,7 @@ async def retry_github_link( raise_app_error( status_code=409, code=ErrorCode.VALIDATION_ERROR, - message=f"このタスクはリトライできない状態です(現在: {cache.status})", + message=get_error("github_link.not_retryable", status=cache.status), action="タスクの完了または失敗を待ってから再試行してください", ) diff --git a/backend/app/routers/internal.py b/backend/app/routers/internal.py index c7d8bdfc..7f1b7a6b 100644 --- a/backend/app/routers/internal.py +++ b/backend/app/routers/internal.py @@ -59,7 +59,10 @@ async def handle_task(task_type: str, request: Request): try: task_type_enum = TaskType(task_type) except ValueError: - raise HTTPException(status_code=400, detail=f"不明なタスク種別: {task_type}") + raise HTTPException( + status_code=400, + detail=get_error("task.unknown_task_type", task_type=task_type), + ) payload = await request.json() @@ -91,15 +94,16 @@ async def handle_task(task_type: str, request: Request): headers["Retry-After"] = str(int(exc.retry_after)) status_code = 429 raise HTTPException(status_code=status_code, detail=str(exc), headers=headers) - except Exception as exc: + except Exception: # 予期しないエラーは 500 を返し Cloud Tasks のリトライに任せる logger.exception( "タスク実行で予期しないエラー", extra={"task_id": task_type, "retry_count": retry_count}, ) + # 例外詳細は上の logger.exception でのみ残し、クライアント応答へは補間しない(info leak 防止) raise HTTPException( status_code=500, - detail=f"タスク実行中に予期しないエラーが発生しました: {exc}", + detail=get_error("task.execution_failed"), ) return {"status": "ok"} diff --git a/backend/app/services/intelligence/github_collector.py b/backend/app/services/intelligence/github_collector.py index 9677d73e..5710283c 100644 --- a/backend/app/services/intelligence/github_collector.py +++ b/backend/app/services/intelligence/github_collector.py @@ -3,8 +3,6 @@ GitHub REST API を介してパブリックリポジトリのデータを取得します。 実際の API 呼び出しは github.api_client、解析処理は github.repo_analyzer に委譲します。 - -後方互換性のため、このモジュールから直接インポートできるシンボルを再エクスポートします。 """ import logging @@ -30,9 +28,6 @@ from .github.repo_analyzer import ( DEPENDENCY_FILES as _DEPENDENCY_FILES, ) -from .github.repo_analyzer import ( - DEPENDENCY_TO_FRAMEWORK, -) from .github.repo_analyzer import ( detect_devtools_from_root_files as _detect_devtools_from_root_files, ) @@ -63,36 +58,15 @@ logger = logging.getLogger(__name__) -# 後方互換性のため公開シンボルとして再エクスポート +# このモジュールの公開 API。``GitHubUserNotFoundError`` は github_link_service が +# ``from .github_collector import GitHubUserNotFoundError`` で参照するため再エクスポートする。 __all__ = [ "RepoData", "collect_repos", "GitHubUserNotFoundError", - "DEPENDENCY_TO_FRAMEWORK", - "_detect_from_root_files", - "_detect_devtools_from_root_files", - "_detect_infras_from_root_files", - "_detect_infras_from_dependencies", - "_parse_requirements_txt", - "_parse_pyproject_toml", - "_parse_package_json", - "_parse_pom_xml", - "_parse_go_mod", - "GITHUB_API", ] -def _detect_from_root_files(root_files: List[str]) -> List[str]: - """ルートファイルからdevtools・インフラを重複なく検出して返す。""" - seen: set[str] = set() - result: List[str] = [] - for item in _detect_devtools_from_root_files(root_files) + _detect_infras_from_root_files(root_files): - if item not in seen: - seen.add(item) - result.append(item) - return result - - @dataclass class RepoData: """リポジトリデータを保持するデータクラス。""" diff --git a/backend/app/services/markdown/generators/resume_generator.py b/backend/app/services/markdown/generators/resume_generator.py index 0daa31de..9962da03 100644 --- a/backend/app/services/markdown/generators/resume_generator.py +++ b/backend/app/services/markdown/generators/resume_generator.py @@ -1,6 +1,11 @@ from typing import Any -from ...shared.resume_format import CATEGORY_LABELS +from ...shared.resume_format import ( + CATEGORY_LABELS, + group_stacks_by_category, + normalize_clients, + normalize_team, +) from ...shared.resume_format import attr as _a from ..templates import resume_template as tpl from ..utils.markdown_utils import field_line, format_period @@ -57,10 +62,8 @@ def build_resume_markdown(payload: dict[str, Any]) -> str: lines.append(field_line("資本金", f"{cap}千万円")) lines.append("") - # clients → projects(後方互換: clients がなく projects がある場合) - clients = _a(exp, "clients", []) - if not clients and _a(exp, "projects", None): - clients = [{"name": "", "projects": _a(exp, "projects", [])}] + # clients → projects(後方互換含む正規化は shared に集約) + clients = normalize_clients(exp) for client in clients: client_name = _a(client, "name") if client_name: @@ -90,10 +93,8 @@ def build_resume_markdown(payload: dict[str, Any]) -> str: result = _a(proj, "result") if result: lines.append(field_line("成果", result)) - # 体制(後方互換: 旧 scale → team に変換) - team = _a(proj, "team", None) - if not team and _a(proj, "scale", None): - team = {"total": _a(proj, "scale"), "members": []} + # 体制(後方互換: 旧 scale → team の正規化は shared に集約) + team = normalize_team(proj) if team: total = _a(team, "total") members = _a(team, "members", []) @@ -114,12 +115,7 @@ def build_resume_markdown(payload: dict[str, Any]) -> str: lines.append(field_line("工程", ", ".join(phases))) stacks = _a(proj, "technology_stacks", []) if stacks: - grouped: dict[str, list[str]] = {} - for st in stacks: - cat = _a(st, "category") - if cat not in grouped: - grouped[cat] = [] - grouped[cat].append(_a(st, "name")) + grouped = group_stacks_by_category(stacks) parts = [ f"{CATEGORY_LABELS.get(c, c)}: {', '.join(ns)}" for c, ns in grouped.items() diff --git a/backend/app/services/pdf/generators/resume_generator.py b/backend/app/services/pdf/generators/resume_generator.py index 1f2fcc67..8a7e1a42 100644 --- a/backend/app/services/pdf/generators/resume_generator.py +++ b/backend/app/services/pdf/generators/resume_generator.py @@ -7,7 +7,14 @@ from ....core.date_utils import JST from ...shared.resume_format import CATEGORY_LABELS as _CATEGORY_LABELS -from ...shared.resume_format import attr as _a +from ...shared.resume_format import ( + attr as _a, +) +from ...shared.resume_format import ( + group_stacks_by_category, + normalize_clients, + normalize_team, +) _CSS_PATH = Path(__file__).resolve().parent.parent / "templates" / "resume.css" _FONT_PATH = ( @@ -84,13 +91,7 @@ def _build_project_html(project) -> str: # 右カラム: 開発環境(技術スタック) stacks = _a(project, "technology_stacks", []) - grouped: dict[str, list[str]] = {} - for st in stacks: - cat = _a(st, "category") - n = _a(st, "name") - if cat not in grouped: - grouped[cat] = [] - grouped[cat].append(n) + grouped = group_stacks_by_category(stacks) right_parts: list[str] = [] for cat, names in grouped.items(): label = _CATEGORY_LABELS.get(cat, cat) @@ -99,10 +100,8 @@ def _build_project_html(project) -> str: ) right_content = "
".join(right_parts) if right_parts else "-" - # 体制(後方互換: 旧 scale → team) - team = _a(project, "team", None) - if not team and _a(project, "scale", None): - team = {"total": _a(project, "scale"), "members": []} + # 体制(後方互換: 旧 scale → team の正規化は shared に集約) + team = normalize_team(project) team_parts: list[str] = [] if team: total = _a(team, "total") @@ -187,10 +186,8 @@ def _build_html(resume: dict) -> str: ) parts.append('
') - # 取引先 → プロジェクト - clients = _a(exp, "clients", []) - if not clients and _a(exp, "projects", None): - clients = [{"name": "", "projects": _a(exp, "projects", [])}] + # 取引先 → プロジェクト(後方互換含む正規化は shared に集約) + clients = normalize_clients(exp) for client in clients: client_name = _a(client, "name") if client_name: diff --git a/backend/app/services/shared/resume_format.py b/backend/app/services/shared/resume_format.py index 41a1327c..7f2d38fd 100644 --- a/backend/app/services/shared/resume_format.py +++ b/backend/app/services/shared/resume_format.py @@ -4,6 +4,7 @@ PDF(HTML 経由)と Markdown の両ジェネレータで重複していた - 技術スタックカテゴリの日本語ラベル - dict / ORM 両対応の属性アクセス +- プレゼンテーション非依存の入力正規化(旧スキーマ後方互換・スタックのカテゴリ別グルーピング) を集約する。 @@ -11,8 +12,11 @@ - 期間表示の `format_period` は PDF(「YYYY 年 MM 月〜現在」)と Markdown(「YYYY-MM - 現在」)で 意図的に出力フォーマットが異なる。共通化すると出力崩れが起きるためここには置かない。 - HTML エスケープと Markdown エスケープも別物のためここでは扱わない。 +- ここに置くのは「表示形式に依存しない正規化」だけ。ラベル付けや HTML/Markdown 組み立ては + 各ジェネレータ側に残す(同じ正規化を両者で別々に持つと片方だけ乖離する事故を防ぐ)。 """ +from collections.abc import Iterable from typing import Any #: 技術スタックカテゴリの日本語ラベル。PDF / Markdown で共通利用する。 @@ -38,3 +42,39 @@ def attr(obj: Any, key: str, default: Any = "") -> Any: if isinstance(obj, dict): return obj.get(key, default) return getattr(obj, key, default) + + +def normalize_clients(experience: Any) -> list[Any]: + """職歴から取引先(clients)リストを取り出す(dict / ORM 両対応)。 + + 旧スキーマ後方互換: ``clients`` が無く ``projects`` だけがある場合は、 + 無名取引先 1 件(``{"name": "", "projects": [...]}``)に畳んで返す。 + """ + clients = attr(experience, "clients", []) + if not clients and attr(experience, "projects", None): + clients = [{"name": "", "projects": attr(experience, "projects", [])}] + return clients + + +def normalize_team(project: Any) -> Any | None: + """プロジェクトから体制(team)情報を取り出す(dict / ORM 両対応)。 + + 旧スキーマ後方互換: ``team`` が無く ``scale`` だけがある場合は + ``{"total": scale, "members": []}`` に変換して返す。 + team も scale も無ければ ``None`` を返す。 + """ + team = attr(project, "team", None) + if not team and attr(project, "scale", None): + team = {"total": attr(project, "scale"), "members": []} + return team + + +def group_stacks_by_category(stacks: Iterable[Any]) -> dict[str, list[str]]: + """technology_stacks を ``{category: [name, ...]}`` にグルーピングする(dict / ORM 両対応)。 + + 最初に出現した category の順序を保持する。表示用ラベル付けは呼び出し側が行う。 + """ + grouped: dict[str, list[str]] = {} + for st in stacks: + grouped.setdefault(attr(st, "category"), []).append(attr(st, "name")) + return grouped diff --git a/backend/app/services/tasks/dispatch_service.py b/backend/app/services/tasks/dispatch_service.py index e4f5bfa8..c96c9c1c 100644 --- a/backend/app/services/tasks/dispatch_service.py +++ b/backend/app/services/tasks/dispatch_service.py @@ -1,6 +1,6 @@ """非同期タスクのキャッシュレコード操作とディスパッチを共通化するサービス。 -3つの非同期タスク(GitHub 連携 / ブログサマリ / キャリア分析)はいずれも +現在の非同期タスクは GitHub 連携(``TaskType.GITHUB_LINK``)の 1 種類のみ。 ``status`` / ``error_message`` / ``retry_count`` / ``started_at`` / ``completed_at`` を持つキャッシュレコードに紐づき、以下のフローを取る: @@ -9,8 +9,9 @@ 3. ディスパッチ 4. ディスパッチ失敗時に ``dead_letter`` へ遷移 -この共通フローを ``AsyncTaskCacheService`` に集約することで、ルーター層から -キャッシュ状態管理の重複コードを排除する。 +``AsyncTaskCacheService`` は現状単一タスク専用だが、``_AsyncTaskRecord`` の構造的型と +``TaskType`` 引数により**新規タスク追加時の拡張ポイントとして意図的に汎用化**してある。 +タスクが 1 種類だからといってルーター層へインライン化しない(再拡張時の手戻りになる)。 """ import logging diff --git a/backend/tests/test_retry_flow.py b/backend/tests/test_retry_flow.py index dd4a1e73..0fc52ee7 100644 --- a/backend/tests/test_retry_flow.py +++ b/backend/tests/test_retry_flow.py @@ -346,6 +346,50 @@ def test_intelligence_retry_resets_cache(self, client: TestClient): assert cache.retry_count == 0 assert cache.error_message is None + def test_retry_returns_404_when_no_cache(self, client: TestClient): + """連携キャッシュが未作成なら 404(先に連携を実行させる)。""" + headers = auth_header(client, "github:retry-nocache-user") + resp = client.post("/api/github-link/run/retry", headers=headers) + assert resp.status_code == 404 + + @pytest.mark.parametrize("status", ["completed", "processing", "pending", "retrying"]) + def test_retry_returns_409_when_not_dead_letter(self, client: TestClient, status: str): + """dead_letter 以外(completed / processing 等)の状態では再実行できず 409。""" + username = f"github:retry-409-{status}" + headers = auth_header(client, username) + db = client._db_session + user = UserRepository(db).get_by_username(username) + + cache = GitHubLinkCache(user_id=user.id, status=status) + db.add(cache) + db.commit() + + resp = client.post("/api/github-link/run/retry", headers=headers) + assert resp.status_code == 409 + + # 状態は変更されないこと(再実行ガードが副作用を起こさない) + db.refresh(cache) + assert cache.status == status + + def test_retry_returns_409_on_concurrent_reset_race(self, client: TestClient): + """is_retryable_terminal は通過しても、並列競合で try_reset_to_pending が + False を返したら 409 にすること(TOCTOU ガード)。""" + headers = auth_header(client, "github:retry-race-user") + db = client._db_session + user = UserRepository(db).get_by_username("github:retry-race-user") + + cache = GitHubLinkCache(user_id=user.id, status="dead_letter", retry_count=2) + db.add(cache) + db.commit() + + # 1つ目のガード(is_retryable_terminal)は通すが、アトミック遷移で競合負け + with patch( + "app.services.tasks.dispatch_service.AsyncTaskCacheService.try_reset_to_pending", + return_value=False, + ): + resp = client.post("/api/github-link/run/retry", headers=headers) + assert resp.status_code == 409 + # ══════════════════════════════════════════════════════════════════════ # 終端状態の判定(固定値ではなく境界条件を確認) diff --git a/backend/tests/test_worker/_helpers.py b/backend/tests/test_worker/_helpers.py index 4feb9c07..72056437 100644 --- a/backend/tests/test_worker/_helpers.py +++ b/backend/tests/test_worker/_helpers.py @@ -2,6 +2,8 @@ import asyncio +from sqlalchemy.orm import Session + def run_sync(coro): """async 関数を同期的に実行するヘルパー。""" @@ -10,3 +12,24 @@ def run_sync(coro): return loop.run_until_complete(coro) finally: loop.close() + + +def keep_open_session(db: Session): + """worker が finally で呼ぶ ``db.close()`` を no-op 化するプロキシを返す。 + + worker は終端処理ごとに ``SessionLocal()`` を開いて finally で close するが、 + テストでは同じ ``db_session`` を検証側でも使い続けたいため close を握りつぶす。 + close 時は ``expire_all()`` のみ行い、次回 refresh で最新の DB 状態を読めるようにする。 + """ + + class _Proxy: + def __init__(self, real: Session) -> None: + self._real = real + + def __getattr__(self, name): + return getattr(self._real, name) + + def close(self) -> None: + self._real.expire_all() + + return _Proxy(db) diff --git a/backend/tests/test_worker/test_execute_task.py b/backend/tests/test_worker/test_execute_task.py index 5f411a73..e65317d7 100644 --- a/backend/tests/test_worker/test_execute_task.py +++ b/backend/tests/test_worker/test_execute_task.py @@ -13,6 +13,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from ._helpers import keep_open_session from ._helpers import run_sync as _run @@ -54,37 +55,42 @@ def test_all_task_types_have_dispatch_branch(self): ) def test_execute_task_marks_dead_letter_on_error(self, db_session: Session): - """予期しない例外が発生した場合(max_attempts=1)、_mark_dead_letter が - 呼ばれ例外が再 raise されること。""" - mock_db = MagicMock() - mock_session_local = MagicMock(return_value=mock_db) + """予期しない例外が発生した場合(max_attempts=1)、例外が再 raise され、 + キャッシュが dead_letter へ遷移し error_message が永続化されること。 + + 内部関数 _mark_dead_letter の呼び出し引数ではなく結果 DB state を検証する + (test_retry_flow.py と同じ契約を、実装詳細に結合しない形で守る)。""" + user = UserRepository(db_session).create( + "github:dead-letter-user", hashed_password=None, email="dl@test.com", + ) + cache = GitHubLinkCache(user_id=user.id, status="processing") + db_session.add(cache) + db_session.commit() with ( - patch("app.services.tasks.worker.SessionLocal", mock_session_local), + patch( + "app.services.tasks.worker.SessionLocal", + return_value=keep_open_session(db_session), + ), patch( "app.services.tasks.worker._run_github_link", new_callable=AsyncMock, side_effect=RuntimeError("予期しないクラッシュ"), ), - patch("app.services.tasks.worker._mark_dead_letter") as mock_mark_dead_letter, patch("app.services.tasks.worker._create_notification"), ): with pytest.raises(RuntimeError, match="予期しないクラッシュ"): _run( execute_task( TaskType.GITHUB_LINK, - {"user_id": "test-user-id", "github_username": "u"}, + {"user_id": user.id, "github_username": "u"}, ) ) - mock_mark_dead_letter.assert_called_once() - call_args = mock_mark_dead_letter.call_args - assert call_args.args == ( - mock_db, - TaskType.GITHUB_LINK, - {"user_id": "test-user-id", "github_username": "u"}, - ) - assert isinstance(call_args.kwargs.get("error"), RuntimeError) + db_session.refresh(cache) + assert cache.status == "dead_letter" + assert "予期しないクラッシュ" in (cache.error_message or "") + assert cache.completed_at is not None def test_execute_task_creates_notification_on_success(self, db_session: Session): """タスク成功時に _create_notification が呼ばれること。""" diff --git a/backend/tests/test_worker/test_github_link.py b/backend/tests/test_worker/test_github_link.py index 0273fd87..66f68558 100644 --- a/backend/tests/test_worker/test_github_link.py +++ b/backend/tests/test_worker/test_github_link.py @@ -1,6 +1,6 @@ """_run_github_link の単体テスト。""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.models import GitHubLinkCache @@ -83,6 +83,67 @@ def test_status_transitions_to_completed(self, db_session: Session, session_fact assert cache.result is not None assert cache.completed_at is not None + def test_completed_persists_mapped_result_content( + self, db_session: Session, session_factory + ): + """フェーズC: 成功時に map_pipeline_result の出力が cache.result へ + そのまま永続化され、error/warning がクリアされること(内容まで検証)。""" + user, cache = self._make_user_and_cache(db_session, "github:content-user") + # 前回失敗の痕跡が成功時にクリアされることも併せて確認する + cache.error_message = "前回の失敗" + cache.warning_message = "前回の警告" + db_session.commit() + + repos = self._sample_repos() + sentinel_result = { + "skills": [{"name": "Python", "score": 80}], + "summary": "集計結果", + } + mapped = MagicMock() + mapped.model_dump.return_value = sentinel_result + + with ( + patch( + "app.services.intelligence.github_link_service.collect_repos", + new_callable=AsyncMock, + return_value=repos, + ), + patch("app.services.progress_service.set_progress", new_callable=AsyncMock), + patch( + "app.services.intelligence.github_link_service.decrypt_field", + return_value="token123", + ), + patch( + "app.services.intelligence.github_link_service.aggregate_intelligence", + return_value=MagicMock(), + ), + patch( + "app.services.intelligence.github_link_service.map_pipeline_result", + return_value=mapped, + ), + ): + _run( + _run_github_link( + session_factory, + { + "user_id": user.id, + "github_username": "gh-user", + "github_token": "encrypted_token", + "include_forks": False, + }, + ) + ) + + db_session.refresh(cache) + assert cache.status == "completed" + # フェーズCで mapper の出力がそのまま書き戻されていること + assert cache.result == sentinel_result + mapped.model_dump.assert_called_once() + # 成功時は前回の error/warning がクリアされること + assert cache.error_message is None + assert cache.warning_message is None + assert cache.completed_at is not None + def test_status_transitions_to_processing_at_start( self, db_session: Session, session_factory ):