Skip to content
Merged

Dev #253

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions backend/app/services/intelligence/github_analysis_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
25 changes: 20 additions & 5 deletions backend/app/services/tasks/handlers/career_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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()
Expand Down
132 changes: 132 additions & 0 deletions backend/tests/services/tasks/test_handlers_failure.py
Original file line number Diff line number Diff line change
@@ -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,
},
)
)
40 changes: 26 additions & 14 deletions backend/tests/test_worker_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ─────────────────────────────────────────
Expand Down
102 changes: 5 additions & 97 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<AuthUser | null>(() => {
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<string | null>(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 (
<ErrorBoundary>
Expand Down
Loading
Loading