Skip to content
Closed
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
12 changes: 12 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ async def _rate_limit_handler(request: Request, exc: RateLimitExceeded):

@app.exception_handler(RequestValidationError)
async def _validation_exception_handler(request: Request, exc: RequestValidationError):
"""
Handle request validation errors and produce a standardized 422 error response.

Parameters:
request (Request): The incoming request that triggered validation.
exc (RequestValidationError): The validation error details.

Returns:
JSONResponse: HTTP 422 response containing an application error payload with
`code` set to `ErrorCode.VALIDATION_ERROR`, a localized `message`, an `action`
prompting input review, and an `error_id`.
"""
error_id = generate_error_id()
logger.warning(
"リクエストバリデーションエラー",
Expand Down
20 changes: 16 additions & 4 deletions backend/app/routers/github_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,22 @@ async def retry_github_link(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""失敗した GitHub 連携タスクを手動で再実行する。

``dead_letter`` 状態のキャッシュのみ再実行可能。
``retry_count`` を 0 にリセットし、ステータスを ``pending`` に戻して再ディスパッチする。
"""
Manually re-dispatch a previously failed GitHub link task for the current user.

Only a cache in the "dead_letter" terminal state can be retried. This resets the cache's retry count to 0, sets its status to "pending", and dispatches the background task to re-run the GitHub linking process.

Parameters:
payload (GitHubLinkRequest | None): Optional request body. If provided, its `include_forks` value is used; otherwise `include_forks` defaults to False.

Returns:
dict: {"status": "pending"} when the task has been queued.

Raises:
HTTPException: 403 if the current user is not authenticated via GitHub.
HTTPException: 404 if there is no existing GitHub link cache for the user.
HTTPException: 409 if the cache is not in a retryable terminal state or a concurrent retry prevented resetting to pending.
HTTPException: 500 if dispatching the background task fails.
"""
if not user.username.startswith("github:"):
raise_app_error(
Expand Down
18 changes: 17 additions & 1 deletion backend/app/routers/internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,23 @@ def _get_max_attempts() -> int:

@router.post("/{task_type}")
async def handle_task(task_type: str, request: Request):
"""Cloud Tasks コールバックまたはローカルテスト用エンドポイント。"""
"""
Handle a task callback request from Cloud Tasks or a local/test invocation and dispatch execution for the specified task type.

Parameters:
task_type (str): The task type identifier extracted from the URL path.
request (Request): The incoming FastAPI request containing headers and JSON payload.

Returns:
dict: JSON-serializable response. On success returns {"status": "ok"}; for a non-retryable failure returns {"status": "non_retryable", "error": "<message>"}.

Raises:
HTTPException:
- 403 when the request is not authorized to invoke internal tasks.
- 400 when the provided task_type is unrecognized.
- 429 or 503 for retryable task failures (429 is used when a `Retry-After` value is provided).
- 500 for unexpected execution errors (generic error detail to avoid leaking internals).
"""
if not _verify_request(request):
raise HTTPException(
status_code=403,
Expand Down
13 changes: 13 additions & 0 deletions backend/app/services/markdown/generators/resume_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@


def build_resume_markdown(payload: dict[str, Any]) -> str:
"""
Builds a Japanese resume as a single Markdown-formatted string from the provided payload.

The payload may include keys such as:
- full_name: applicant's full name
- qualifications: list of {name, acquired_date}
- career_summary: free-form career summary text
- experiences: list of experience objects; each experience may contain company, start_date, end_date, is_current, business_description, employee_count, capital, and normalized client/project data (projects include project-level fields, team, phases, technology_stacks)
- self_pr: free-form self-promotion text

Returns:
A single string containing the resume formatted in Markdown (Japanese labels and section structure).
"""
lines: list[str] = []
lines.append(tpl.TITLE)
lines.append("")
Expand Down
27 changes: 25 additions & 2 deletions backend/app/services/pdf/generators/resume_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,20 @@ def _format_period(


def _build_project_html(project) -> str:
"""プロジェクト1件分のHTMLを組み立てる"""
"""
Builds the HTML fragment for a single project section of the resume.

Parameters:
project (dict): Project data following the resume schema. Expected keys include
'name', 'start_date', 'end_date', 'is_current', 'role', 'phases',
'challenge', 'action', 'result', 'technology_stacks' and fields used by
team normalization helpers.

Returns:
str: HTML string representing the project, containing a header (period/name/role/phases),
a left column for work details (challenge/action/result), a right column for grouped
technology stacks, and a team column (total and members).
"""
# ヘッダー(3行構成: 期間/プロジェクト名、役割、工程)
name = _a(project, "name")
start = _a(project, "start_date")
Expand Down Expand Up @@ -127,7 +140,17 @@ def _build_project_html(project) -> str:


def _build_html(resume: dict) -> str:
"""職務経歴書データからHTML文字列を組み立てる"""
"""
Assemble an HTML document body representing the provided resume data.

Builds the HTML for name, current date (rendered in Japan Standard Time), career summary, experiences (companies, clients, projects), qualifications, and self-promotion using the module's escaping, Markdown rendering, and normalization helpers.

Parameters:
resume (dict): Structured resume data expected to contain keys like "full_name", "career_summary", "experiences", "qualifications", and "self_pr".

Returns:
html (str): The assembled HTML string for the resume.
"""
parts: list[str] = []

# タイトル
Expand Down
47 changes: 34 additions & 13 deletions backend/app/services/shared/resume_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,32 @@


def attr(obj: Any, key: str, default: Any = "") -> Any:
"""dict / ORM オブジェクト両対応の属性アクセスヘルパ。"""
"""
Access a value from a mapping or an attribute from an object using a single call.

If `obj` is a dict-like mapping, returns `obj.get(key, default)`; otherwise returns `getattr(obj, key, default)`.

Parameters:
obj (Any): A mapping (e.g., dict) or an object with attributes.
key (str): The key or attribute name to retrieve.
default (Any): Value returned when the key/attribute is missing (defaults to empty string).

Returns:
Any: The retrieved value if present, otherwise `default`.
"""
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": [...]}``)に畳んで返す。
"""
Extract the list of clients from an experience object, supporting both dict and ORM-style access.

If the experience has no `clients` but has `projects`, returns a single unnamed client `{"name": "", "projects": [...]}` for backward compatibility with the old schema.

Returns:
list[Any]: A list of client objects (possibly a single synthesized unnamed client).
"""
clients = attr(experience, "clients", [])
if not clients and attr(experience, "projects", None):
Expand All @@ -57,11 +72,13 @@ def normalize_clients(experience: Any) -> list[Any]:


def normalize_team(project: Any) -> Any | None:
"""プロジェクトから体制(team)情報を取り出す(dict / ORM 両対応)。

旧スキーマ後方互換: ``team`` が無く ``scale`` だけがある場合は
``{"total": scale, "members": []}`` に変換して返す。
team も scale も無ければ ``None`` を返す。
"""
Extract team information from a project object, supporting both dict and attribute-style access.

If `team` is missing but `scale` exists, a compatible team object `{"total": scale, "members": []}` is returned. If neither `team` nor `scale` is present, `None` is returned.

Returns:
dict | None: A team object with keys `"total"` (number) and `"members"` (list) when available, otherwise `None`.
"""
team = attr(project, "team", None)
if not team and attr(project, "scale", None):
Expand All @@ -70,9 +87,13 @@ def normalize_team(project: Any) -> Any | None:


def group_stacks_by_category(stacks: Iterable[Any]) -> dict[str, list[str]]:
"""technology_stacks を ``{category: [name, ...]}`` にグルーピングする(dict / ORM 両対応)。

最初に出現した category の順序を保持する。表示用ラベル付けは呼び出し側が行う。
"""
Group technology stack entries into a mapping from category to a list of names.

Preserves the order in which categories first appear; display labeling is the caller's responsibility.

Returns:
dict[str, list[str]]: Mapping from each category to a list of stack names. Categories appear in insertion order (first-seen first).
"""
grouped: dict[str, list[str]] = {}
for st in stacks:
Expand Down
48 changes: 42 additions & 6 deletions backend/tests/test_worker/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,18 @@


def run_sync(coro):
"""async 関数を同期的に実行するヘルパー。"""
"""
Run a coroutine to completion on a new event loop and return its result.

Parameters:
coro: A coroutine or awaitable to execute.

Returns:
The value produced by the coroutine.

Notes:
The function creates a fresh event loop and always closes it after completion or if an exception occurs.
"""
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
Expand All @@ -15,21 +26,46 @@ def run_sync(coro):


def keep_open_session(db: Session):
"""worker が finally で呼ぶ ``db.close()`` を no-op 化するプロキシを返す。

worker は終端処理ごとに ``SessionLocal()`` を開いて finally で close するが、
テストでは同じ ``db_session`` を検証側でも使い続けたいため close を握りつぶす。
close 時は ``expire_all()`` のみ行い、次回 refresh で最新の DB 状態を読めるようにする。
"""
Provide a Session proxy whose close() does not close the underlying session.

Used in tests to prevent worker code from closing a shared Session: attribute access is delegated to the original Session, and calling close() calls `expire_all()` on the real session instead of closing it so the test can continue to use the same Session.

Parameters:
db (Session): The real SQLAlchemy Session to wrap.

Returns:
A proxy object exposing the same attributes as `db`; its `close()` method calls `db.expire_all()` rather than closing `db`.
"""

class _Proxy:
def __init__(self, real: Session) -> None:
"""
Initialize the proxy with the underlying SQLAlchemy Session.

Parameters:
real (Session): The actual Session instance to which attribute access and operations are delegated.
"""
self._real = real

def __getattr__(self, name):
"""
Delegate attribute access to the underlying real Session.

Parameters:
name (str): Attribute name being accessed on the proxy.

Returns:
The attribute or method with the given name from the proxied `self._real`.
"""
return getattr(self._real, name)

def close(self) -> None:
"""
Mark all ORM objects in the proxied Session as expired without closing the underlying Session.

Invokes `expire_all()` on the wrapped `Session` to expire loaded instances while keeping the session open for reuse by callers.
"""
self._real.expire_all()

return _Proxy(db)
24 changes: 14 additions & 10 deletions backend/tests/test_worker/test_execute_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ def test_known_task_type_routes_to_correct_handler(self, db_session: Session):
mock_gh.assert_called_once()

def test_all_task_types_have_dispatch_branch(self):
"""TaskType に列挙された全種別に execute_task のディスパッチ分岐があること。

新しい TaskType を追加した際に worker.execute_task への分岐追加を
忘れて「黙って completed になる」事故を防ぐためのガード。
"""
Ensure every TaskType has a corresponding dispatch branch in worker.execute_task.

Acts as a guard so adding a new TaskType without updating execute_task does not silently treat the task as completed; the test fails listing missing TaskType members.
"""
import inspect

Expand All @@ -55,11 +55,11 @@ 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)、例外が再 raise され、
キャッシュが dead_letter へ遷移し error_message が永続化されること。

内部関数 _mark_dead_letter の呼び出し引数ではなく結果 DB state を検証する
(test_retry_flow.py と同じ契約を、実装詳細に結合しない形で守る)。"""
"""
Verify that when a task handler raises an unexpected exception (max_attempts=1), the exception is re-raised and the corresponding GitHubLinkCache row transitions to the "dead_letter" state with the error message persisted and completed_at set.
Asserts the post-failure database state (cache.status == "dead_letter", cache.error_message contains the exception text, and cache.completed_at is not None) rather than relying on internal helper call observations.
"""
user = UserRepository(db_session).create(
"github:dead-letter-user", hashed_password=None, email="dl@test.com",
)
Expand Down Expand Up @@ -93,7 +93,11 @@ def test_execute_task_marks_dead_letter_on_error(self, db_session: Session):
assert cache.completed_at is not None

def test_execute_task_creates_notification_on_success(self, db_session: Session):
"""タスク成功時に _create_notification が呼ばれること。"""
"""
Verifies that a notification is created when a GitHub link task completes successfully.

Ensures `_create_notification` is invoked once with the database session provided by `SessionLocal`, the `TaskType.GITHUB_LINK`, the task's `user_id`, and the `"completed"` status.
"""
mock_db = MagicMock()
mock_session_local = MagicMock(return_value=mock_db)

Expand Down
6 changes: 5 additions & 1 deletion backend/tests/test_worker/test_github_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ def _sample_repos(self):
]

def test_status_transitions_to_completed(self, db_session: Session, session_factory):
"""正常系: status が completed に遷移すること。"""
"""
Verify that a pending GitHub link cache transitions to completed on successful processing.

This test mocks repository collection and token decryption, runs the GitHub link worker, and asserts the cache is marked as "completed", a result is persisted, and `completed_at` is populated.
"""
user, cache = self._make_user_and_cache(db_session)
repos = self._sample_repos()

Expand Down
Loading