generated from kgod/ai-review-template
feat: start resume sessions in new flow
This commit is contained in:
+82
-14
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@@ -46,11 +48,7 @@ def start_manual_profile(
|
||||
session_id = body["session_id"]
|
||||
assert body["stage"] == "PRIVACY_CONSENT"
|
||||
|
||||
source = event(client, session_id, body, "accept", {"accepted": True})
|
||||
assert source.status_code == 200
|
||||
assert source.json()["stage"] == "RESUME_SOURCE_SELECT"
|
||||
|
||||
phone_selector = event(client, session_id, source.json(), "select", {"value": "manual"})
|
||||
phone_selector = event(client, session_id, body, "accept", {"accepted": True})
|
||||
assert phone_selector.status_code == 200
|
||||
assert phone_selector.json()["stage"] == "PHONE_SELECTION"
|
||||
|
||||
@@ -109,22 +107,92 @@ def campus_ready(client: TestClient) -> tuple[str, dict[str, Any]]:
|
||||
return start_manual_profile(client, job_type="campus")
|
||||
|
||||
|
||||
def test_privacy_precedes_resume_source_selection(client: TestClient) -> None:
|
||||
def test_privacy_continues_directly_into_new_resume_flow(client: TestClient) -> None:
|
||||
created = client.post(f"{BASE}/sessions", json={})
|
||||
session_id = created.json()["session_id"]
|
||||
source = event(client, session_id, created.json(), "accept", {"accepted": True})
|
||||
assert source.status_code == 200
|
||||
body = source.json()
|
||||
assert body["stage"] == "RESUME_SOURCE_SELECT"
|
||||
options = active_component(body)["data"]["options"]
|
||||
assert {option["value"] for option in options} == {"import", "manual"}
|
||||
phone_selector = event(client, session_id, created.json(), "accept", {"accepted": True})
|
||||
assert phone_selector.status_code == 200
|
||||
body = phone_selector.json()
|
||||
assert body["stage"] == "PHONE_SELECTION"
|
||||
assert active_component(body)["data"]["component"] == "resume_phone_selector"
|
||||
assert "导入已有简历" not in json.dumps(body, ensure_ascii=False)
|
||||
|
||||
with client.app.state.database.transaction() as connection:
|
||||
session = client.app.state.database.fetch_session(connection, session_id)
|
||||
assert session is not None
|
||||
assert session["profile"]["resume_source"] == "manual"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy_stage", ["RESUME_SOURCE_SELECT", "RESUME_IMPORT_UPLOAD"])
|
||||
def test_legacy_source_stages_advance_to_new_resume_flow(
|
||||
client: TestClient, legacy_stage: str
|
||||
) -> None:
|
||||
created = client.post(f"{BASE}/sessions", json={}).json()
|
||||
session_id = created["session_id"]
|
||||
accepted = event(client, session_id, created, "accept", {"accepted": True})
|
||||
assert accepted.status_code == 200
|
||||
|
||||
with client.app.state.database.transaction(immediate=True) as connection:
|
||||
session = client.app.state.database.fetch_session(connection, session_id)
|
||||
assert session is not None
|
||||
profile = dict(session["profile"])
|
||||
profile["resume_source"] = "import"
|
||||
client.app.state.database.update_session(
|
||||
connection,
|
||||
session_id,
|
||||
stage=legacy_stage,
|
||||
profile=profile,
|
||||
)
|
||||
|
||||
migrated = client.get(f"{BASE}/sessions/{session_id}/timeline")
|
||||
assert migrated.status_code == 200
|
||||
body = migrated.json()
|
||||
assert body["stage"] == "PHONE_SELECTION"
|
||||
assert active_component(body)["data"]["component"] == "resume_phone_selector"
|
||||
turn_count = len(body["turns"])
|
||||
|
||||
repeated = client.get(f"{BASE}/sessions/{session_id}/timeline").json()
|
||||
assert len(repeated["turns"]) == turn_count
|
||||
with client.app.state.database.transaction() as connection:
|
||||
session = client.app.state.database.fetch_session(connection, session_id)
|
||||
assert session is not None
|
||||
assert session["profile"]["resume_source"] == "manual"
|
||||
|
||||
|
||||
def test_concurrent_legacy_stage_refresh_advances_only_once(client: TestClient) -> None:
|
||||
created = client.post(f"{BASE}/sessions", json={}).json()
|
||||
session_id = created["session_id"]
|
||||
accepted = event(client, session_id, created, "accept", {"accepted": True})
|
||||
assert accepted.status_code == 200
|
||||
baseline_turn_count = len(
|
||||
client.get(f"{BASE}/sessions/{session_id}/timeline").json()["turns"]
|
||||
)
|
||||
|
||||
with client.app.state.database.transaction(immediate=True) as connection:
|
||||
session = client.app.state.database.fetch_session(connection, session_id)
|
||||
assert session is not None
|
||||
profile = dict(session["profile"])
|
||||
profile["resume_source"] = "import"
|
||||
client.app.state.database.update_session(
|
||||
connection,
|
||||
session_id,
|
||||
stage="RESUME_SOURCE_SELECT",
|
||||
profile=profile,
|
||||
)
|
||||
|
||||
agent = client.app.state.resume_agent
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
responses = list(executor.map(lambda _: agent.timeline(session_id), range(4)))
|
||||
|
||||
assert all(response.stage == "PHONE_SELECTION" for response in responses)
|
||||
timeline = client.get(f"{BASE}/sessions/{session_id}/timeline").json()
|
||||
assert len(timeline["turns"]) == baseline_turn_count + 1
|
||||
|
||||
|
||||
def test_manual_phone_is_strict_and_retryable(client: TestClient) -> None:
|
||||
created = client.post(f"{BASE}/sessions", json={}).json()
|
||||
session_id = created["session_id"]
|
||||
source = event(client, session_id, created, "accept", {"accepted": True}).json()
|
||||
phone_selector = event(client, session_id, source, "select", {"value": "manual"}).json()
|
||||
phone_selector = event(client, session_id, created, "accept", {"accepted": True}).json()
|
||||
phone_input = event(client, session_id, phone_selector, "select", {"source": "other"}).json()
|
||||
|
||||
invalid = event(client, session_id, phone_input, "submit", {"phone": "+8613800138000"})
|
||||
|
||||
@@ -146,7 +146,7 @@ def test_session_creation_authenticates_and_defaults_account_phone(tmp_path: Pat
|
||||
assert TOKEN not in json.dumps(profile, ensure_ascii=False)
|
||||
|
||||
auth_headers = {"Authorization": f"Bearer {TOKEN}"}
|
||||
source = event(
|
||||
phone_selector = event(
|
||||
client,
|
||||
session_id,
|
||||
created,
|
||||
@@ -154,14 +154,6 @@ def test_session_creation_authenticates_and_defaults_account_phone(tmp_path: Pat
|
||||
{"accepted": True},
|
||||
headers=auth_headers,
|
||||
).json()
|
||||
phone_selector = event(
|
||||
client,
|
||||
session_id,
|
||||
source,
|
||||
"select",
|
||||
{"value": "manual"},
|
||||
headers=auth_headers,
|
||||
).json()
|
||||
data = active_component(phone_selector)["data"]
|
||||
assert data["has_account_phone"] is True
|
||||
assert data["masked_phone"] == "134****2384"
|
||||
@@ -182,7 +174,7 @@ def test_session_creation_authenticates_and_defaults_account_phone(tmp_path: Pat
|
||||
assert updated["profile"]["phone"] == "13421012384"
|
||||
assert updated["profile"]["phone_source"] == "account"
|
||||
|
||||
assert provider.tokens == [TOKEN, TOKEN, TOKEN, TOKEN]
|
||||
assert provider.tokens == [TOKEN, TOKEN, TOKEN]
|
||||
|
||||
|
||||
def test_invalid_external_token_does_not_create_session(tmp_path: Path) -> None:
|
||||
|
||||
@@ -264,14 +264,6 @@ def _complete_initial_collection(
|
||||
response = event(
|
||||
client, session_id, body, "accept", {"accepted": True}, headers=headers
|
||||
)
|
||||
response = event(
|
||||
client,
|
||||
session_id,
|
||||
response.json(),
|
||||
"select",
|
||||
{"value": "manual"},
|
||||
headers=headers,
|
||||
)
|
||||
response = event(
|
||||
client,
|
||||
session_id,
|
||||
|
||||
@@ -1,171 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from docx import Document
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
from app.resume_import_models import ParsedResumeDraft
|
||||
from app.resume_import_service import ResumeImportService
|
||||
from app.services import RuleBasedEntryExpander, RuleBasedExperienceExtractor, RuleBasedResumeRewriter
|
||||
from app.settings import Settings
|
||||
from test_api import BASE
|
||||
|
||||
|
||||
BASE = "/ai-api/resume-agent"
|
||||
def test_resume_import_routes_are_removed(client: TestClient) -> None:
|
||||
created = client.post(f"{BASE}/sessions", json={}).json()
|
||||
session_id = created["session_id"]
|
||||
|
||||
|
||||
class FakeResumeImportParser:
|
||||
def parse(self, *, text: str, source_name: str) -> ParsedResumeDraft:
|
||||
return ParsedResumeDraft(
|
||||
document={
|
||||
"schema_version": 3,
|
||||
"basics": {"name": "Imported Name", "phone": "13800138000", "email": "import@example.com"},
|
||||
"target": {"job_type": "campus", "position": "Backend Engineer"},
|
||||
"sections": [{"kind": "education", "heading": "Education", "items": [{"school": "Example University", "major": "Computer Science"}]}],
|
||||
"skill_groups": [{"category": "Programming Languages", "skills": ["Python"]}],
|
||||
},
|
||||
field_reviews=[],
|
||||
)
|
||||
|
||||
|
||||
def docx_bytes(text: str) -> bytes:
|
||||
document = Document()
|
||||
document.add_paragraph(text)
|
||||
buffer = BytesIO()
|
||||
document.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def client_for_import(tmp_path) -> TestClient:
|
||||
application = create_app(
|
||||
database_path=tmp_path / "test.db",
|
||||
cors_origins=["http://localhost:5173"],
|
||||
extractor=RuleBasedExperienceExtractor(),
|
||||
rewriter=RuleBasedResumeRewriter(),
|
||||
expander=RuleBasedEntryExpander(),
|
||||
settings=Settings(llm_provider="rule", offerpai_auth_required=False),
|
||||
resume_import_service=ResumeImportService(storage_root=tmp_path / "imports", parser=FakeResumeImportParser()),
|
||||
)
|
||||
return TestClient(application)
|
||||
|
||||
|
||||
def _active_component(body: dict) -> dict:
|
||||
turns = body.get("turns") or [body["turn"]]
|
||||
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, name: str, payload: dict | None = None):
|
||||
return client.post(
|
||||
f"{BASE}/sessions/{session_id}/component-events",
|
||||
json={"component_id": _active_component(body)["id"], "event": name, "payload": payload or {}},
|
||||
)
|
||||
|
||||
|
||||
def import_session(client: TestClient) -> str:
|
||||
created = client.post(f"{BASE}/sessions", json={})
|
||||
session_id = created.json()["session_id"]
|
||||
source = _event(client, session_id, created.json(), "accept", {"accepted": True})
|
||||
selected = _event(client, session_id, source.json(), "select", {"value": "import"})
|
||||
assert selected.status_code == 200
|
||||
assert selected.json()["stage"] == "RESUME_IMPORT_UPLOAD"
|
||||
return session_id
|
||||
|
||||
|
||||
def upload(client: TestClient, session_id: str, name: str = "resume.docx"):
|
||||
return client.post(
|
||||
upload = client.post(
|
||||
f"{BASE}/sessions/{session_id}/resume-imports",
|
||||
files={"file": (name, docx_bytes("Imported Name\nExample University"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
|
||||
files={"file": ("resume.docx", b"unused")},
|
||||
)
|
||||
assert upload.status_code == 404
|
||||
|
||||
read = client.get(f"{BASE}/sessions/{session_id}/resume-imports/import_legacy")
|
||||
assert read.status_code == 404
|
||||
|
||||
def test_import_requires_privacy_consent_and_import_selection(tmp_path) -> None:
|
||||
with client_for_import(tmp_path) as client:
|
||||
session_id = client.post(f"{BASE}/sessions", json={}).json()["session_id"]
|
||||
before_consent = upload(client, session_id)
|
||||
assert before_consent.status_code == 409
|
||||
assert before_consent.json()["error"]["code"] == "privacy_consent_required"
|
||||
|
||||
timeline = client.get(f"{BASE}/sessions/{session_id}/timeline").json()
|
||||
source = _event(client, session_id, timeline, "accept", {"accepted": True})
|
||||
without_choice = upload(client, session_id)
|
||||
assert without_choice.status_code == 409
|
||||
assert without_choice.json()["error"]["code"] == "resume_import_not_selected"
|
||||
|
||||
manual = _event(client, session_id, source.json(), "select", {"value": "manual"})
|
||||
assert manual.status_code == 200
|
||||
after_manual_choice = upload(client, session_id)
|
||||
assert after_manual_choice.status_code == 409
|
||||
assert after_manual_choice.json()["error"]["code"] == "resume_import_not_selected"
|
||||
|
||||
|
||||
def test_docx_import_is_reviewable_and_apply_updates_live_resume(tmp_path) -> None:
|
||||
with client_for_import(tmp_path) as client:
|
||||
session_id = import_session(client)
|
||||
imported = upload(client, session_id)
|
||||
assert imported.status_code == 201, imported.text
|
||||
view = imported.json()
|
||||
assert view["status"] == "awaiting_review"
|
||||
|
||||
applied = client.post(
|
||||
f"{BASE}/sessions/{session_id}/resume-imports/{view['id']}/apply",
|
||||
json={"expected_revision": 0},
|
||||
)
|
||||
assert applied.status_code == 200, applied.text
|
||||
body = applied.json()
|
||||
assert body["stage"] == "RESUME_ENRICHING"
|
||||
content = body["resume"]["content"]
|
||||
assert content["basics"]["name"] == "Imported Name"
|
||||
assert content["basics"]["masked_phone"] == "138****8000"
|
||||
assert "phone" not in content["basics"]
|
||||
assert content["basics"]["email"] == "import@example.com"
|
||||
assert content["sections"][0]["items"][0]["school"] == "Example University"
|
||||
|
||||
|
||||
def test_import_is_blocked_after_an_imported_resume_is_applied(tmp_path) -> None:
|
||||
with client_for_import(tmp_path) as client:
|
||||
session_id = import_session(client)
|
||||
first_upload = upload(client, session_id)
|
||||
imported = first_upload.json()
|
||||
applied = client.post(
|
||||
f"{BASE}/sessions/{session_id}/resume-imports/{imported['id']}/apply",
|
||||
json={"expected_revision": 0},
|
||||
)
|
||||
assert applied.status_code == 200
|
||||
blocked = upload(client, session_id, "second.docx")
|
||||
assert blocked.status_code == 409
|
||||
assert blocked.json()["error"]["code"] == "resume_import_not_allowed"
|
||||
|
||||
|
||||
def test_legacy_doc_and_scanned_pdf_return_stable_errors(tmp_path) -> None:
|
||||
with client_for_import(tmp_path) as client:
|
||||
session_id = import_session(client)
|
||||
legacy = client.post(f"{BASE}/sessions/{session_id}/resume-imports", files={"file": ("resume.doc", b"not-a-docx", "application/msword")})
|
||||
assert legacy.status_code == 422
|
||||
assert legacy.json()["error"]["code"] == "legacy_doc_unsupported"
|
||||
scanned = client.post(f"{BASE}/sessions/{session_id}/resume-imports", files={"file": ("scan.pdf", b"%PDF-1.7\n", "application/pdf")})
|
||||
assert scanned.status_code == 422
|
||||
assert scanned.json()["error"]["code"] == "ocr_required"
|
||||
|
||||
|
||||
def test_imported_resume_continue_enriching_keeps_imported_content(tmp_path) -> None:
|
||||
with client_for_import(tmp_path) as client:
|
||||
session_id = import_session(client)
|
||||
imported = upload(client, session_id)
|
||||
applied = client.post(
|
||||
f"{BASE}/sessions/{session_id}/resume-imports/{imported.json()['id']}/apply",
|
||||
json={"expected_revision": 0},
|
||||
)
|
||||
assert applied.status_code == 200, applied.text
|
||||
before = applied.json()["resume"]["content"]
|
||||
|
||||
continued = _event(client, session_id, applied.json(), "continue_enriching")
|
||||
assert continued.status_code == 200, continued.text
|
||||
body = continued.json()
|
||||
assert body["stage"] == "RESUME_ENRICHING"
|
||||
assert _active_component(body)["data"]["component"] == "custom_card_picker"
|
||||
assert body["resume"]["content"] == before
|
||||
apply = client.post(
|
||||
f"{BASE}/sessions/{session_id}/resume-imports/import_legacy/apply",
|
||||
json={"expected_revision": 0},
|
||||
)
|
||||
assert apply.status_code == 404
|
||||
|
||||
@@ -14,7 +14,6 @@ def _reach_job_type(client: TestClient) -> tuple[str, dict[str, Any]]:
|
||||
body = client.post(f"{BASE}/sessions", json={}).json()
|
||||
session_id = body["session_id"]
|
||||
body = event(client, session_id, body, "accept", {"accepted": True}).json()
|
||||
body = event(client, session_id, body, "select", {"value": "manual"}).json()
|
||||
body = event(client, session_id, body, "select", {"source": "other"}).json()
|
||||
body = event(client, session_id, body, "submit", {"phone": "13800138000"}).json()
|
||||
body = event(
|
||||
|
||||
Reference in New Issue
Block a user