Files
srbhr--resume-matcher/apps/backend/tests/integration/test_pipeline_e2e.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

521 lines
22 KiB
Python

"""End-to-end pipeline test through the REAL routers + a REAL (isolated) TinyDB.
Every existing integration test mocks the database away (``patch("...db")``), so
nothing proves that the core user journey actually persists through the routers.
This module fills that gap: it drives the genuine FastAPI app against the
disposable ``isolated_db`` fixture (a temp-file ``Database`` swapped into every
router module) and asserts real persisted state via the yielded db — not just
status codes. Only the LLM boundaries are mocked; the routers, schemas,
validation, and TinyDB persistence are all real.
Pipeline stages covered:
upload -> POST /api/v1/resumes/upload (parse_document + parse_resume_to_json mocked)
jobs -> POST /api/v1/jobs/upload
fetch -> GET /api/v1/resumes?resume_id=...
improve -> POST /api/v1/resumes/improve/preview then /improve/confirm
(every LLM-touching service in the diff flow mocked)
The real LLM is NEVER called: parse_resume_to_json, extract_job_keywords,
generate_skill_target_plan, generate_resume_diffs, refine_resume, and the
auxiliary cover-letter/outreach/title generators are all replaced with
Mock/AsyncMock returning canned data.
"""
import copy
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
from app.schemas.models import ResumeData
def _new_client():
"""A fresh ASGI-in-process client.
httpx.AsyncClient cannot be reopened once its ``async with`` block exits, so
multi-request flows construct one client per request rather than reusing a
single fixture instance.
"""
transport = ASGITransport(app=app)
return AsyncClient(transport=transport, base_url="http://test")
@pytest.fixture
def client():
"""Async HTTP client bound to the real FastAPI app (ASGI in-process)."""
return _new_client()
async def _upload_resume(isolated_db, sample_resume):
"""Upload a fake PDF through the real router with the parse boundary mocked.
Returns the upload response. parse_document yields markdown and
parse_resume_to_json yields the structured ``sample_resume`` dict, so the
upload should end in ``processing_status == "ready"`` with ``sample_resume``
persisted as ``processed_data``.
"""
markdown = "# Jane Doe\nSenior Backend Engineer\njane@example.com\n"
with (
patch(
"app.routers.resumes.parse_document",
new_callable=AsyncMock,
return_value=markdown,
),
patch(
"app.routers.resumes.parse_resume_to_json",
new_callable=AsyncMock,
return_value=copy.deepcopy(sample_resume),
),
):
async with _new_client() as client:
resp = await client.post(
"/api/v1/resumes/upload",
files={"file": ("resume.pdf", b"%PDF-1.4 fake", "application/pdf")},
)
return resp
class TestPipelineCore:
"""The minimum bar: upload -> store -> jobs -> fetch, end to end."""
async def test_upload_persists_master_resume_through_router(
self, isolated_db, sample_resume
):
"""Upload a fake PDF; assert the resume is the persisted master, marked
``ready``, with ``processed_data`` round-tripped through real TinyDB.
This proves the upload handler actually wires parse_document ->
parse_resume_to_json -> create_resume_atomic_master -> update_resume
against a real database, which the DB-mocking tests cannot.
"""
resp = await _upload_resume(isolated_db, sample_resume)
assert resp.status_code == 200
body = resp.json()
assert body["processing_status"] == "ready"
assert body["is_master"] is True
resume_id = body["resume_id"]
assert resume_id
# Inspect REAL persisted state via the yielded isolated db.
master = await isolated_db.get_master_resume()
assert master is not None
assert master["resume_id"] == resume_id
assert master["processing_status"] == "ready"
assert master["is_master"] is True
# processed_data round-tripped through TinyDB JSON storage.
assert master["processed_data"] is not None
assert master["processed_data"]["personalInfo"]["name"] == "Jane Doe"
assert (
master["processed_data"]["summary"] == sample_resume["summary"]
)
# Exactly one resume exists, and it is the master.
assert len(await isolated_db.list_resumes()) == 1
async def test_jobs_upload_persists_job_through_router(
self, isolated_db, client
):
"""POST /jobs/upload stores the JD text and returns a job_id that is
actually retrievable from the real db; stats reflect one job.
Fixture safety: ``isolated_db`` is listed before ``client`` (pytest
resolves same-scope fixtures left-to-right), and the routers look up the
module-global ``db`` at REQUEST time — so the monkeypatched isolated db
is always the one the ASGI app reads, independent of when ``client`` was
constructed.
"""
async with client:
resp = await client.post(
"/api/v1/jobs/upload",
json={
"job_descriptions": [
"Senior Python role building scalable FastAPI services. "
"Docker and AWS required."
]
},
)
assert resp.status_code == 200
body = resp.json()
job_ids = body["job_id"]
assert isinstance(job_ids, list)
assert len(job_ids) == 1
job_id = job_ids[0]
# Real persistence check via the yielded db.
stored_job = await isolated_db.get_job(job_id)
assert stored_job is not None
assert "FastAPI" in stored_job["content"]
assert (await isolated_db.get_stats())["total_jobs"] == 1
async def test_full_journey_upload_jobs_then_fetch(
self, isolated_db, sample_resume
):
"""The cohesive core journey in one flow: upload a resume, upload a job,
then fetch the resume back by id — all through real routers + real db.
Asserts the fetched ``processed_resume`` (a fully validated ResumeData)
carries the expected summary, proving the stored data survives the
round trip out through the GET handler. Also confirms both the resume
and the job coexist in the same isolated database.
"""
# 1. Upload.
upload_resp = await _upload_resume(isolated_db, sample_resume)
assert upload_resp.status_code == 200
resume_id = upload_resp.json()["resume_id"]
# The resume_id is recoverable from the db too (anti-theater).
listed = await isolated_db.list_resumes()
assert resume_id in {r["resume_id"] for r in listed}
# 2. Jobs upload.
async with _new_client() as client:
jobs_resp = await client.post(
"/api/v1/jobs/upload",
json={"job_descriptions": ["Senior Python role at TechCorp."]},
)
assert jobs_resp.status_code == 200
job_id = jobs_resp.json()["job_id"][0]
assert await isolated_db.get_job(job_id) is not None
# 3. Fetch the resume back through the real GET handler.
async with _new_client() as client:
fetch_resp = await client.get(
"/api/v1/resumes", params={"resume_id": resume_id}
)
assert fetch_resp.status_code == 200
data = fetch_resp.json()["data"]
assert data["resume_id"] == resume_id
assert data["raw_resume"]["processing_status"] == "ready"
processed = data["processed_resume"]
assert processed is not None
assert processed["personalInfo"]["name"] == "Jane Doe"
assert processed["summary"] == sample_resume["summary"]
# Both resume and job live in the same isolated db.
stats = await isolated_db.get_stats()
assert stats["total_resumes"] == 1
assert stats["total_jobs"] == 1
assert stats["has_master_resume"] is True
async def test_fetch_unknown_resume_returns_404(self, isolated_db, client):
"""Fetching a non-existent id is a real 404 from the GET handler against
an empty isolated db (sanity guard that the fixture starts clean)."""
async with client:
resp = await client.get(
"/api/v1/resumes", params={"resume_id": "does-not-exist"}
)
assert resp.status_code == 404
assert await isolated_db.list_resumes() == []
class TestTailoringPipeline:
"""Stretch: the preview -> confirm tailoring handshake, end to end.
The diff-based improve flow calls many LLM services. We mock every
LLM-touching boundary imported into ``app.routers.resumes`` so the flow is
deterministic, drive a real ``/improve/preview``, then echo the returned
``resume_preview`` back into ``/improve/confirm`` (exactly as the real
client does). Confirm re-hashes the payload and validates the preview_hash
persisted on the job, so a self-consistent round trip is what proves the
handshake + tailored-resume persistence actually wire together.
Refinement (``refine_resume``) is mocked to raise: the router explicitly
catches refinement failures and falls back to the unrefined result, so the
preview_hash is computed on our canned ``improved_data`` and the
brittle ``RefinementResult`` attribute surface never has to be faked.
"""
async def test_preview_then_confirm_persists_tailored_resume(
self, isolated_db, sample_resume
):
# Seed a master resume (with processed_data so the diff path runs) and a
# job, both through the real upload routers.
upload_resp = await _upload_resume(isolated_db, sample_resume)
assert upload_resp.status_code == 200
resume_id = upload_resp.json()["resume_id"]
async with _new_client() as client:
jobs_resp = await client.post(
"/api/v1/jobs/upload",
json={
"job_descriptions": [
"Senior Backend Engineer: Python, FastAPI, Docker, AWS."
]
},
)
assert jobs_resp.status_code == 200
job_id = jobs_resp.json()["job_id"][0]
# Canned tailored resume: identical personalInfo (required by the confirm
# invariant) with a tweaked summary so we can prove the *tailored* copy
# is what gets stored.
#
# Run it through ResumeData first so it carries the full, default-filled
# key set the real ``parse_resume_to_json``/``apply_diffs`` pipeline
# produces. The preview hashes this raw dict, while the preview *response*
# serializes ``ResumeData.model_validate(...)``; canonicalizing here makes
# the two byte-identical (mirroring production, where stored processed_data
# is already Pydantic-normalized) so the confirm preview_hash matches.
improved = ResumeData.model_validate(copy.deepcopy(sample_resume)).model_dump()
improved["summary"] = (
"Senior backend engineer with 6 years building scalable Python and "
"FastAPI services on AWS and Docker."
)
diff_result = SimpleNamespace(changes=[])
with (
patch(
"app.routers.resumes.extract_job_keywords",
new_callable=AsyncMock,
return_value={"keywords": ["Python", "FastAPI"], "required_skills": []},
),
patch(
"app.routers.resumes.generate_skill_target_plan",
new_callable=AsyncMock,
return_value={"accepted": [], "rejected": []},
),
patch(
"app.routers.resumes.verify_skill_target_plan",
return_value={"accepted": [], "rejected": []},
),
patch(
"app.routers.resumes.generate_resume_diffs",
new_callable=AsyncMock,
return_value=diff_result,
),
patch(
"app.routers.resumes.apply_diffs",
return_value=(copy.deepcopy(improved), [], []),
),
patch("app.routers.resumes.verify_diff_result", return_value=[]),
# Force the unrefined fallback path (handled gracefully by the router)
# so we don't have to fake the RefinementResult object.
patch(
"app.routers.resumes.refine_resume",
new_callable=AsyncMock,
side_effect=RuntimeError("refinement disabled for test"),
),
# Auxiliary generators are awaited in confirm; keep them cheap.
patch(
"app.routers.resumes.generate_resume_title",
new_callable=AsyncMock,
return_value="Senior Backend Engineer - TechCorp",
),
):
# --- Preview (no persistence; resume_id stays null) ---
async with _new_client() as client:
preview_resp = await client.post(
"/api/v1/resumes/improve/preview",
json={"resume_id": resume_id, "job_id": job_id},
)
assert preview_resp.status_code == 200, preview_resp.text
preview_data = preview_resp.json()["data"]
assert preview_data["resume_id"] is None
assert preview_data["job_id"] == job_id
preview_resume = preview_data["resume_preview"]
assert preview_resume["summary"] == improved["summary"]
# Preview must NOT have persisted a tailored resume.
assert (await isolated_db.get_stats())["total_resumes"] == 1
# The preview_hash was persisted on the job for the confirm handshake.
job_after_preview = await isolated_db.get_job(job_id)
assert job_after_preview.get("preview_hash")
# --- Confirm (echo the preview back, exactly as the client does) ---
async with _new_client() as client:
confirm_resp = await client.post(
"/api/v1/resumes/improve/confirm",
json={
"resume_id": resume_id,
"job_id": job_id,
"improved_data": preview_resume,
"improvements": preview_data["improvements"],
},
)
assert confirm_resp.status_code == 200, confirm_resp.text
confirm_data = confirm_resp.json()["data"]
tailored_id = confirm_data["resume_id"]
assert tailored_id is not None
assert tailored_id != resume_id
# The tailored resume is REALLY persisted, as a non-master child of the
# original, carrying the tailored summary.
stored_tailored = await isolated_db.get_resume(tailored_id)
assert stored_tailored is not None
assert stored_tailored["is_master"] is False
assert stored_tailored["parent_id"] == resume_id
assert stored_tailored["processing_status"] == "ready"
assert stored_tailored["processed_data"]["summary"] == improved["summary"]
# personalInfo preserved from the master (the confirm invariant).
assert (
stored_tailored["processed_data"]["personalInfo"]
== sample_resume["personalInfo"]
)
# An improvements record links original -> tailored for this job.
improvement = await isolated_db.get_improvement_by_tailored_resume(tailored_id)
assert improvement is not None
assert improvement["original_resume_id"] == resume_id
assert improvement["job_id"] == job_id
# Master + tailored both present; master unchanged.
stats = await isolated_db.get_stats()
assert stats["total_resumes"] == 2
assert stats["total_improvements"] == 1
master = await isolated_db.get_master_resume()
assert master["resume_id"] == resume_id
assert master["processed_data"]["summary"] == sample_resume["summary"]
async def test_preview_confirm_succeeds_for_non_canonical_stored_resume(
self, isolated_db, sample_resume
):
"""A stored resume whose ``processed_data`` OMITS optional schema fields
must still tailor + confirm successfully (regression for the confirm 400).
``improve/preview`` hashes the raw ``improved_data`` — here a project that
omits the optional ``github``/``website`` keys ``ResumeData`` defaults to
``None`` — while ``improve/confirm`` hashes the schema-defaulted
``ResumeData`` round-trip. Before ``_hash_improved_data`` canonicalized
both sides these diverged and confirm returned 400 ("preview hash
mismatch"). NO canonicalize workaround is applied to ``improved`` here —
that is exactly the point.
"""
upload_resp = await _upload_resume(isolated_db, sample_resume)
assert upload_resp.status_code == 200
resume_id = upload_resp.json()["resume_id"]
async with _new_client() as client:
jobs_resp = await client.post(
"/api/v1/jobs/upload",
json={"job_descriptions": ["Senior Backend Engineer: Python, FastAPI."]},
)
assert jobs_resp.status_code == 200
job_id = jobs_resp.json()["job_id"][0]
# Non-canonical tailored data: a project missing the optional
# github/website fields. personalInfo stays canonical/unchanged so the
# confirm identity invariant holds; only personalProjects is non-canonical.
improved = ResumeData.model_validate(copy.deepcopy(sample_resume)).model_dump()
improved["summary"] = (
"Senior backend engineer building Python and FastAPI services."
)
improved["personalProjects"] = [
{
"id": 1,
"name": "Sidecar",
"role": "Author",
"years": "2022",
"description": ["Shipped a CLI"],
} # deliberately NO github/website keys
]
assert "github" not in improved["personalProjects"][0]
with (
patch(
"app.routers.resumes.extract_job_keywords",
new_callable=AsyncMock,
return_value={"keywords": ["Python", "FastAPI"], "required_skills": []},
),
patch(
"app.routers.resumes.generate_skill_target_plan",
new_callable=AsyncMock,
return_value={"accepted": [], "rejected": []},
),
patch(
"app.routers.resumes.verify_skill_target_plan",
return_value={"accepted": [], "rejected": []},
),
patch(
"app.routers.resumes.generate_resume_diffs",
new_callable=AsyncMock,
return_value=SimpleNamespace(changes=[]),
),
patch(
"app.routers.resumes.apply_diffs",
return_value=(copy.deepcopy(improved), [], []),
),
patch("app.routers.resumes.verify_diff_result", return_value=[]),
patch(
"app.routers.resumes.refine_resume",
new_callable=AsyncMock,
side_effect=RuntimeError("refinement disabled for test"),
),
patch(
"app.routers.resumes.generate_resume_title",
new_callable=AsyncMock,
return_value="Senior Backend Engineer",
),
):
async with _new_client() as client:
preview_resp = await client.post(
"/api/v1/resumes/improve/preview",
json={"resume_id": resume_id, "job_id": job_id},
)
assert preview_resp.status_code == 200, preview_resp.text
preview_data = preview_resp.json()["data"]
preview_resume = preview_data["resume_preview"]
# The preview RESPONSE is schema-complete even though the stored
# improved_data wasn't — this asymmetry is what used to break confirm.
assert "github" in preview_resume["personalProjects"][0]
async with _new_client() as client:
confirm_resp = await client.post(
"/api/v1/resumes/improve/confirm",
json={
"resume_id": resume_id,
"job_id": job_id,
"improved_data": preview_resume,
"improvements": preview_data["improvements"],
},
)
assert confirm_resp.status_code == 200, confirm_resp.text
# The tailored resume really persisted as a child of the master.
tailored_id = confirm_resp.json()["data"]["resume_id"]
assert tailored_id is not None and tailored_id != resume_id
assert await isolated_db.get_resume(tailored_id) is not None
class TestConfigurableImproveTimeout:
"""Issue #776: the improve timeout is driven by settings.request_timeout_seconds
(not a hardcoded 240), so a configured smaller value actually causes a 504."""
async def test_preview_times_out_per_configured_setting(
self, isolated_db, sample_resume, monkeypatch
):
import asyncio
from app.config import settings
# Seed a master resume + a job through the real routers.
resume_id = (await _upload_resume(isolated_db, sample_resume)).json()["resume_id"]
async with _new_client() as client:
jr = await client.post(
"/api/v1/jobs/upload",
json={"job_descriptions": ["Senior Python / FastAPI role."]},
)
job_id = jr.json()["job_id"][0]
# Configure a tiny timeout and make the inner flow exceed it. The flow
# body never really runs (it is cancelled), so no LLM mocking is needed.
monkeypatch.setattr(settings, "request_timeout_seconds", 0.05)
async def _slow_flow(**_kwargs):
await asyncio.sleep(1.0)
monkeypatch.setattr("app.routers.resumes._improve_preview_flow", _slow_flow)
async with _new_client() as client:
resp = await client.post(
"/api/v1/resumes/improve/preview",
json={"resume_id": resume_id, "job_id": job_id},
)
assert resp.status_code == 504, resp.text
assert "timed out" in resp.json()["detail"].lower()