Files
srbhr--resume-matcher/apps/backend/tests/integration/test_resume_wizard_api.py
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

242 lines
8.7 KiB
Python

"""Integration tests for the adaptive resume wizard endpoints."""
import json
from unittest.mock import AsyncMock, patch
from httpx import ASGITransport, AsyncClient
from app.main import app
from app.schemas.resume_wizard import ResumeWizardHistoryEntry, ResumeWizardQuestion
from app.services.resume_wizard import (
RESUME_WIZARD_MAX_QUESTIONS,
build_initial_wizard_state,
)
_AI_RESULT = {
"resume_data": {
"personalInfo": {"name": "James"},
"summary": "",
"workExperience": [],
"education": [],
"personalProjects": [],
"additional": {
"technicalSkills": ["Python"],
"languages": [],
"certificationsTraining": [],
"awards": [],
},
"sectionMeta": [],
"customSections": {},
},
"next_question": {"text": "What tools do you use most?", "section": "skills"},
"inferred_skills": ["FastAPI"],
"is_complete": False,
}
async def test_turn_answer_runs_ai_and_returns_next_question(isolated_db) -> None:
transport = ASGITransport(app=app)
state = build_initial_wizard_state()
state.step = "question"
state.current_question.section = "skills"
with patch(
"app.services.resume_wizard.complete_json",
new_callable=AsyncMock,
return_value=_AI_RESULT,
):
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/resume-wizard/turn",
json={
"state": state.model_dump(mode="json"),
"action": "answer",
"answer": {"text": "I use Python and FastAPI."},
},
)
assert response.status_code == 200
payload = response.json()["state"]
assert payload["current_question"]["text"] == "What tools do you use most?"
assert payload["resume_data"]["additional"]["technicalSkills"] == ["Python", "FastAPI"]
assert payload["asked_count"] == 1
async def test_turn_review_needs_no_llm(isolated_db) -> None:
transport = ASGITransport(app=app)
state = build_initial_wizard_state()
state.step = "question"
state.resume_data.personalInfo.name = "James"
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/resume-wizard/turn",
json={"state": state.model_dump(mode="json"), "action": "review"},
)
assert response.status_code == 200
payload = response.json()["state"]
assert payload["step"] == "review"
assert payload["warnings"]
async def test_turn_answer_without_answer_is_422(isolated_db) -> None:
transport = ASGITransport(app=app)
state = build_initial_wizard_state()
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/resume-wizard/turn",
json={"state": state.model_dump(mode="json"), "action": "answer"},
)
assert response.status_code == 422
async def test_finalize_creates_ready_master_resume(isolated_db) -> None:
transport = ASGITransport(app=app)
state = build_initial_wizard_state()
state.resume_data.personalInfo.name = "James"
state.resume_data.personalInfo.email = "james@example.com"
state.resume_data.additional.technicalSkills = ["Python"]
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/resume-wizard/finalize",
json={"state": state.model_dump(mode="json")},
)
assert response.status_code == 200
payload = response.json()
assert payload["processing_status"] == "ready"
assert payload["is_master"] is True
stored = await isolated_db.get_resume(payload["resume_id"])
assert stored is not None
assert stored["is_master"] is True
assert stored["content_type"] == "json"
assert json.loads(stored["content"])["personalInfo"]["name"] == "James"
async def test_finalize_rejects_when_master_exists(isolated_db, sample_resume) -> None:
await isolated_db.create_resume(
content=json.dumps(sample_resume),
content_type="json",
filename="existing.json",
is_master=True,
processed_data=sample_resume,
processing_status="ready",
)
state = build_initial_wizard_state()
state.resume_data.personalInfo.name = "James"
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/resume-wizard/finalize",
json={"state": state.model_dump(mode="json")},
)
assert response.status_code == 409
assert "already exists" in response.json()["detail"].lower()
async def test_turn_start_returns_initial_state(isolated_db) -> None:
transport = ASGITransport(app=app)
state = build_initial_wizard_state()
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/resume-wizard/turn",
json={"state": state.model_dump(mode="json"), "action": "start"},
)
assert response.status_code == 200
payload = response.json()["state"]
assert payload["step"] == "intro"
assert payload["current_question"]["section"] == "intro"
assert payload["asked_count"] == 0
async def test_turn_back_restores_previous_question(isolated_db) -> None:
transport = ASGITransport(app=app)
state = build_initial_wizard_state()
state.step = "question"
state.asked_count = 1
state.current_question = ResumeWizardQuestion(text="Skills?", section="skills")
state.resume_data.additional.technicalSkills = ["Python"]
state.history = [
ResumeWizardHistoryEntry(
question="Where have you worked?",
answer="Acme",
section="workExperience",
resume_data_before=build_initial_wizard_state().resume_data,
)
]
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/resume-wizard/turn",
json={"state": state.model_dump(mode="json"), "action": "back"},
)
assert response.status_code == 200
payload = response.json()["state"]
assert payload["asked_count"] == 0
assert payload["current_question"]["section"] == "workExperience"
# The pre-answer snapshot is restored, dropping the later skills edit.
assert payload["resume_data"]["additional"]["technicalSkills"] == []
async def test_turn_skip_advances_without_modifying_resume_data(isolated_db) -> None:
transport = ASGITransport(app=app)
state = build_initial_wizard_state()
state.step = "question"
state.current_question = ResumeWizardQuestion(text="Education?", section="education")
skip_result = {
"resume_data": {"education": [{"id": 1, "institution": "MIT"}]},
"next_question": {"text": "What skills?", "section": "skills"},
"inferred_skills": [],
"is_complete": False,
}
with patch(
"app.services.resume_wizard.complete_json",
new_callable=AsyncMock,
return_value=skip_result,
):
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/resume-wizard/turn",
json={"state": state.model_dump(mode="json"), "action": "skip"},
)
assert response.status_code == 200
payload = response.json()["state"]
assert payload["current_question"]["section"] == "skills"
assert payload["resume_data"]["education"] == [] # skip must not apply the model's data
assert payload["asked_count"] == 1
async def test_turn_answer_past_cap_routes_to_review_without_llm(isolated_db) -> None:
transport = ASGITransport(app=app)
state = build_initial_wizard_state()
state.step = "question"
state.current_question.section = "skills"
state.asked_count = RESUME_WIZARD_MAX_QUESTIONS # at the cap
with patch(
"app.services.resume_wizard.complete_json",
new_callable=AsyncMock,
) as mock_complete:
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/resume-wizard/turn",
json={
"state": state.model_dump(mode="json"),
"action": "answer",
"answer": {"text": "one more thing"},
},
)
assert response.status_code == 200
assert response.json()["state"]["step"] == "review"
mock_complete.assert_not_awaited() # cap guard must skip the LLM call