from __future__ import annotations import json from typing import Any from fastapi.testclient import TestClient BASE = "/ai-api/resume-agent" def active_component(body: dict[str, Any]) -> dict[str, Any]: turns = body.get("turns") or ([body["turn"]] if body.get("turn") else []) for turn in reversed(turns): for block in reversed(turn["blocks"]): if block["type"] == "component" and block["lifecycle"] == "active": return block raise AssertionError("response has no active component") def event( client: TestClient, session_id: str, body: dict[str, Any], event_name: str, payload: dict[str, Any] | None = None, ): block = active_component(body) response = client.post( f"{BASE}/sessions/{session_id}/component-events", json={ "component_id": block["id"], "event": event_name, "payload": payload or {}, }, ) return response def start_manual_profile(client: TestClient, *, job_type: str) -> tuple[str, dict[str, Any]]: response = client.post(f"{BASE}/sessions", json={}) assert response.status_code == 201 body = response.json() session_id = body["session_id"] assert body["stage"] == "PRIVACY_CONSENT" response = event(client, session_id, body, "accept", {"accepted": True}) assert response.status_code == 200 assert response.json()["stage"] == "PHONE_SELECTION" response = event(client, session_id, response.json(), "select", {"source": "other"}) assert response.status_code == 200 assert response.json()["stage"] == "MANUAL_PHONE_INPUT" response = event( client, session_id, response.json(), "submit", {"phone": "13800138000"}, ) assert response.status_code == 200 assert response.json()["stage"] == "NAME_CAPTURE" response = event(client, session_id, response.json(), "submit", {"name": "测试用户"}) assert response.status_code == 200 assert response.json()["stage"] == "JOB_TYPE_SELECT" response = event( client, session_id, response.json(), "select", {"job_type": job_type}, ) assert response.status_code == 200 return session_id, response.json() def fill_anchor( client: TestClient, session_id: str, body: dict[str, Any], values: dict[str, str], ) -> dict[str, Any]: while body["stage"] == "ANCHOR_COLLECTING": block = active_component(body) data = block["data"] component = data["component"] if component == "date_range_selector": payload = { "start_date": values["start_date"], "end_date_or_present": values["end_date_or_present"], } response = event(client, session_id, body, "submit", payload) elif component == "degree_selector": response = event( client, session_id, body, "select", {"degree": values["degree"]}, ) else: field = data["field"] response = event( client, session_id, body, "submit", {"field": field, "value": values[field]}, ) assert response.status_code == 200, response.text body = response.json() return body def campus_ready(client: TestClient) -> tuple[str, dict[str, Any]]: session_id, body = start_manual_profile(client, job_type="campus") assert body["stage"] == "ANCHOR_COLLECTING" assert body["gate"]["anchor_type"] == "education" assert body["missing_fields"] == [ "school", "major", "degree", "start_date", "end_date_or_present", ] described = client.post( f"{BASE}/sessions/{session_id}/messages", json={ "content": "我就读于示例大学,专业是计算机科学,本科,2021年9月至2025年6月。" }, ) assert described.status_code == 200, described.text body = described.json() assert body["stage"] == "ANCHOR_CONFIRM" response = event(client, session_id, body, "confirm", {"confirmed": True}) assert response.status_code == 200 body = response.json() assert body["stage"] == "MINIMUM_READY" assert body["draft_id"].startswith("draft_") assert body["gate"]["allowed"] is True return session_id, body def test_full_campus_flow_is_idempotent_and_masks_phone(client: TestClient) -> None: session_id, ready = campus_ready(client) assert active_component(ready)["data"]["component"] == "create_resume_card" first = client.post( f"{BASE}/sessions/{session_id}/create", json={"idempotency_key": "create-once"}, ) assert first.status_code == 200, first.text result = first.json() assert result["created"] is True assert result["stage"] == "RESUME_ENRICHING" assert result["resume_id"] == result["resume"]["id"] assert result["resume"]["content"]["basics"]["masked_phone"] == "138****8000" assert "13800138000" not in json.dumps(result, ensure_ascii=False) second = client.post( f"{BASE}/sessions/{session_id}/create", json={"idempotency_key": "another-key"}, ) assert second.status_code == 200 assert second.json()["created"] is False assert second.json()["resume_id"] == result["resume_id"] timeline = client.get(f"{BASE}/sessions/{session_id}/timeline") assert timeline.status_code == 200 timeline_body = timeline.json() assert timeline_body["session"]["masked_phone"] == "138****8000" assert timeline_body["session"]["phone_source"] == "manual" assert "phone" not in timeline_body["session"] assert timeline_body["turns"][0]["blocks"][1]["lifecycle"] == "submitted" def test_manual_phone_is_strict_and_failed_event_is_retryable(client: TestClient) -> None: created = client.post(f"{BASE}/sessions", json={}).json() session_id = created["session_id"] accepted = event(client, session_id, created, "accept_privacy").json() manual = event(client, session_id, accepted, "use_other_phone").json() invalid = event( client, session_id, manual, "submit_manual_phone", {"phone": "+8613800138000"}, ) assert invalid.status_code == 422 assert invalid.json()["error"]["code"] == "invalid_phone" valid = event( client, session_id, manual, "submit_manual_phone", {"phone": "13900139000"}, ) assert valid.status_code == 200 assert valid.json()["stage"] == "NAME_CAPTURE" def test_account_phone_is_normalized_but_never_exposed(client: TestClient) -> None: created_response = client.post( f"{BASE}/sessions", json={"account_phone": "+86 137-0013-7000"} ) assert created_response.status_code == 201 created = created_response.json() assert "13700137000" not in json.dumps(created) session_id = created["session_id"] selector = event(client, session_id, created, "accept", {"accepted": True}).json() named = event(client, session_id, selector, "select", {"source": "account"}) assert named.status_code == 200 assert "13700137000" not in named.text timeline = client.get(f"{BASE}/sessions/{session_id}/timeline").json() assert timeline["session"]["masked_phone"] == "137****7000" assert timeline["session"]["phone_source"] == "account" def test_social_and_other_job_types_enforce_their_first_anchor(client: TestClient) -> None: social_id, social = start_manual_profile(client, job_type="experienced") assert social["gate"]["anchor_type"] == "work_experience" assert social["missing_fields"] == [ "company", "position", "start_date", "end_date_or_present", ] other_id, other = start_manual_profile(client, job_type="other") assert other["stage"] == "ANCHOR_TYPE_SELECT" selected = event( client, other_id, other, "select_anchor_type", {"anchor_type": "internship_experience"}, ) assert selected.status_code == 200 assert selected.json()["gate"]["anchor_type"] == "internship_experience" assert selected.json()["missing_fields"][0:2] == ["company", "position"] assert social_id != other_id def test_anchor_chat_extracts_known_facts_and_renders_only_the_next_gap( client: TestClient, ) -> None: session_id, body = start_manual_profile(client, job_type="social") response = client.post( f"{BASE}/sessions/{session_id}/messages", json={"content": "我在星河科技有限公司担任产品经理。"}, ) assert response.status_code == 200, response.text body = response.json() assert body["stage"] == "ANCHOR_COLLECTING" assert body["missing_fields"] == ["start_date", "end_date_or_present"] block = active_component(body) assert block["data"]["component"] == "date_range_selector" assert body["turn"]["composer_mode"] == "hybrid" def test_messages_rewrite_resume_and_short_text_requests_clarification( client: TestClient, ) -> None: session_id, _ready = campus_ready(client) created = client.post(f"{BASE}/sessions/{session_id}/create", json={}).json() ready_component = active_component(created) enriching = event( client, session_id, created, "continue_enriching", ) assert enriching.status_code == 200 assert enriching.json()["stage"] == "RESUME_ENRICHING" short = client.post( f"{BASE}/sessions/{session_id}/messages", json={"content": "做项目"} ) assert short.status_code == 200 assert short.json()["stage"] == "CONTENT_DISAMBIGUATION" detailed = client.post( f"{BASE}/sessions/{session_id}/messages", json={"content": "在星河科技担任后端工程师,优化接口后延迟降低30%。"}, ) assert detailed.status_code == 200 body = detailed.json() assert body["stage"] == "CONTENT_READY" assert body["gate"]["formal_content_ready"] is False assert active_component(body)["data"]["component"] == "experience_confirm_card" confirmed = event(client, session_id, body, "confirm", {"confirmed": True}) assert confirmed.status_code == 200, confirmed.text body = confirmed.json() patches = [block for block in body["turn"]["blocks"] if block["type"] == "resume_patch"] assert patches[0]["data"]["revision"] == 2 assert body["gate"]["formal_content_ready"] is True assert ready_component["data"]["component"] == "content_ready_card" def test_delete_removes_session_and_cors_is_configured(client: TestClient) -> None: session_id = client.post(f"{BASE}/sessions", json={}).json()["session_id"] preflight = client.options( f"{BASE}/sessions/{session_id}/timeline", headers={ "Origin": "http://localhost:5173", "Access-Control-Request-Method": "GET", }, ) assert preflight.status_code == 200 assert preflight.headers["access-control-allow-origin"] == "http://localhost:5173" deleted = client.delete(f"{BASE}/sessions/{session_id}") assert deleted.status_code == 204 missing = client.get(f"{BASE}/sessions/{session_id}/timeline") assert missing.status_code == 404 assert missing.json()["error"]["code"] == "session_not_found"