444 lines
15 KiB
Python
444 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from resume_agent.agent import save_json
|
|
from resume_agent.models import CareerProfile, TailoringPackage
|
|
from resume_agent.render import render_ohmycv_resume
|
|
from resume_agent.webapp import create_app
|
|
|
|
|
|
def sample_profile() -> CareerProfile:
|
|
return CareerProfile.model_validate(
|
|
{
|
|
"contact": {
|
|
"full_name": "Ada Example",
|
|
"email": "ada@example.com",
|
|
"phone": None,
|
|
"location": "London",
|
|
"linkedin": None,
|
|
"website": None,
|
|
},
|
|
"professional_identity": "Backend engineer",
|
|
"differentiators": ["Reliable distributed systems"],
|
|
"target_roles": ["Senior Backend Engineer"],
|
|
"facts": [
|
|
{
|
|
"id": "F001",
|
|
"category": "experience",
|
|
"statement": "Reduced API latency by 30%.",
|
|
"source_name": "resume",
|
|
"source_excerpt": "Reduced API latency by 30%.",
|
|
}
|
|
],
|
|
"skills": ["Python"],
|
|
"unanswered_questions": [],
|
|
}
|
|
)
|
|
|
|
|
|
def sample_package() -> TailoringPackage:
|
|
contact = sample_profile().contact.model_dump()
|
|
item = {"text": "Reduced API latency by 30%. [F013]", "evidence_ids": ["F001"]}
|
|
return TailoringPackage.model_validate(
|
|
{
|
|
"job": {
|
|
"company": "Example Co",
|
|
"role_title": "Backend Engineer",
|
|
"mission": None,
|
|
"requirements": [],
|
|
"responsibilities": [],
|
|
"culture_signals": [],
|
|
"ats_keywords": ["Python"],
|
|
},
|
|
"resume": {
|
|
"contact": contact,
|
|
"headline": "Backend Engineer",
|
|
"summary": [item],
|
|
"sections": [{"title": "Experience", "items": [item]}],
|
|
},
|
|
"match": {
|
|
"strong_matches": ["API performance"],
|
|
"partial_matches": [],
|
|
"genuine_gaps": [],
|
|
"keywords_used": ["Python"],
|
|
},
|
|
"changes_made": ["Prioritized relevant impact."],
|
|
"questions_for_candidate": [],
|
|
"warnings": [],
|
|
}
|
|
)
|
|
|
|
|
|
class FakeLLM:
|
|
def __init__(self, responses: list[Any]) -> None:
|
|
self.responses = responses
|
|
|
|
def parse(self, schema: type[Any], instructions: str, input_text: str) -> Any:
|
|
response = self.responses.pop(0)
|
|
assert isinstance(response, schema)
|
|
return response
|
|
|
|
def list_models(self) -> list[str]:
|
|
return ["test-model"]
|
|
|
|
|
|
def wait_for_task(client: TestClient, task_id: str) -> dict[str, Any]:
|
|
for _ in range(100):
|
|
response = client.get(f"/api/tasks/{task_id}")
|
|
assert response.status_code == 200
|
|
task = response.json()
|
|
if task["status"] != "running":
|
|
return task
|
|
time.sleep(0.01)
|
|
raise AssertionError("Background task did not finish in time.")
|
|
|
|
|
|
def test_root_and_status(tmp_path: Path, monkeypatch: Any) -> None:
|
|
monkeypatch.delenv("RESUME_AGENT_API_KEY", raising=False)
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
app = create_app(
|
|
profile_path=tmp_path / "profile.json",
|
|
output_dir=tmp_path / "output",
|
|
llm_factory=lambda: FakeLLM([]), # type: ignore[arg-type]
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
assert client.get("/").status_code == 200
|
|
favicon = client.get("/favicon.ico")
|
|
assert favicon.status_code == 200
|
|
assert favicon.headers["content-type"] == "image/x-icon"
|
|
status = client.get("/api/status").json()
|
|
assert status["configured"] is False
|
|
assert status["profile_exists"] is False
|
|
|
|
|
|
def test_build_profile_from_upload(tmp_path: Path) -> None:
|
|
expected = sample_profile()
|
|
app = create_app(
|
|
profile_path=tmp_path / "profile.json",
|
|
output_dir=tmp_path / "output",
|
|
llm_factory=lambda: FakeLLM([expected]), # type: ignore[arg-type]
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/api/profile",
|
|
files={"resume": ("resume.md", b"# Ada\nReduced API latency by 30%.")},
|
|
data={"about": ""},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["facts"][0]["id"] == "F001"
|
|
assert (tmp_path / "profile.json").is_file()
|
|
|
|
|
|
def test_background_profile_upload_returns_pollable_task(tmp_path: Path) -> None:
|
|
expected = sample_profile()
|
|
app = create_app(
|
|
profile_path=tmp_path / "profile.json",
|
|
output_dir=tmp_path / "output",
|
|
llm_factory=lambda: FakeLLM([expected]), # type: ignore[arg-type]
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
started = client.post(
|
|
"/api/profile/start",
|
|
files={"resume": ("resume.md", b"# Ada\nReduced API latency by 30%.")},
|
|
data={"about": ""},
|
|
)
|
|
task = wait_for_task(client, started.json()["task_id"])
|
|
|
|
assert started.status_code == 200
|
|
assert task["status"] == "succeeded"
|
|
assert task["result"]["facts"][0]["id"] == "F001"
|
|
|
|
|
|
def test_tailor_creates_downloads(tmp_path: Path) -> None:
|
|
profile_path = tmp_path / "profile.json"
|
|
output_dir = tmp_path / "output"
|
|
save_json(sample_profile(), profile_path)
|
|
expected = sample_package()
|
|
app = create_app(
|
|
profile_path=profile_path,
|
|
output_dir=output_dir,
|
|
llm_factory=lambda: FakeLLM([expected, expected]), # type: ignore[arg-type]
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/api/tailor",
|
|
json={
|
|
"job_url": None,
|
|
"job_text": "Backend engineer role requiring Python and API performance. " * 3,
|
|
},
|
|
)
|
|
download = client.get("/api/download/resume.md")
|
|
ohmycv_download = client.get("/api/download/resume-ohmycv.md")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["job"]["company"] == "Example Co"
|
|
assert download.status_code == 200
|
|
assert "Ada Example" in download.text
|
|
assert "[F013]" not in download.text
|
|
assert ohmycv_download.status_code == 200
|
|
assert ohmycv_download.text.startswith('---\nname: "Ada Example"')
|
|
assert "[F013]" not in ohmycv_download.text
|
|
|
|
|
|
def test_background_tailoring_returns_pollable_task(tmp_path: Path) -> None:
|
|
profile_path = tmp_path / "profile.json"
|
|
output_dir = tmp_path / "output"
|
|
save_json(sample_profile(), profile_path)
|
|
expected = sample_package()
|
|
app = create_app(
|
|
profile_path=profile_path,
|
|
output_dir=output_dir,
|
|
llm_factory=lambda: FakeLLM([expected, expected]), # type: ignore[arg-type]
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
started = client.post(
|
|
"/api/tailor/start",
|
|
json={
|
|
"job_url": None,
|
|
"job_text": "Backend engineer role requiring Python and API performance. " * 3,
|
|
},
|
|
)
|
|
task = wait_for_task(client, started.json()["task_id"])
|
|
|
|
assert started.status_code == 200
|
|
assert task["status"] == "succeeded"
|
|
assert task["result"]["job"]["company"] == "Example Co"
|
|
assert (output_dir / "resume-ohmycv.md").is_file()
|
|
|
|
|
|
def test_profile_based_tailoring_allows_empty_evidence_ids(tmp_path: Path) -> None:
|
|
profile_path = tmp_path / "profile.json"
|
|
output_dir = tmp_path / "output"
|
|
save_json(sample_profile(), profile_path)
|
|
flexible = sample_package()
|
|
for item in flexible.resume.summary:
|
|
item.evidence_ids = []
|
|
for section in flexible.resume.sections:
|
|
for item in section.items:
|
|
item.evidence_ids = []
|
|
app = create_app(
|
|
profile_path=profile_path,
|
|
output_dir=output_dir,
|
|
llm_factory=lambda: FakeLLM([flexible, flexible]), # type: ignore[arg-type]
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/api/tailor",
|
|
json={
|
|
"job_url": None,
|
|
"job_text": "Backend engineer role requiring Python and API performance. " * 3,
|
|
"evidence_mode": "profile",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["resume"]["summary"][0]["evidence_ids"] == []
|
|
assert (output_dir / "evidence-mode.txt").read_text() == "profile"
|
|
assert "<!-- evidence:" not in (output_dir / "resume-audited.md").read_text()
|
|
|
|
|
|
def test_app_wide_guard_off_forces_profile_mode_and_persists(tmp_path: Path) -> None:
|
|
profile_path = tmp_path / "profile.json"
|
|
output_dir = tmp_path / "output"
|
|
settings_path = tmp_path / "app-settings.json"
|
|
save_json(sample_profile(), profile_path)
|
|
flexible = sample_package()
|
|
for item in flexible.resume.summary:
|
|
item.evidence_ids = []
|
|
for section in flexible.resume.sections:
|
|
for item in section.items:
|
|
item.evidence_ids = []
|
|
app = create_app(
|
|
profile_path=profile_path,
|
|
output_dir=output_dir,
|
|
settings_path=settings_path,
|
|
llm_factory=lambda: FakeLLM([flexible, flexible]), # type: ignore[arg-type]
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
updated = client.put("/api/settings", json={"evidence_guard": False})
|
|
status = client.get("/api/status")
|
|
response = client.post(
|
|
"/api/tailor",
|
|
json={
|
|
"job_url": None,
|
|
"job_text": "Backend engineer role requiring Python and API performance. " * 3,
|
|
"evidence_mode": "strict",
|
|
},
|
|
)
|
|
|
|
assert updated.status_code == 200
|
|
assert updated.json() == {"evidence_guard": False}
|
|
assert status.json()["evidence_guard"] is False
|
|
assert response.status_code == 200
|
|
assert response.json()["resume"]["summary"][0]["evidence_ids"] == []
|
|
assert (output_dir / "evidence-mode.txt").read_text() == "profile"
|
|
assert '"evidence_guard": false' in settings_path.read_text()
|
|
|
|
|
|
def test_tailor_requires_exactly_one_job_source(tmp_path: Path) -> None:
|
|
profile_path = tmp_path / "profile.json"
|
|
save_json(sample_profile(), profile_path)
|
|
app = create_app(
|
|
profile_path=profile_path,
|
|
output_dir=tmp_path / "output",
|
|
llm_factory=lambda: FakeLLM([]), # type: ignore[arg-type]
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/api/tailor",
|
|
json={"job_url": "https://example.com/job", "job_text": "Also pasted"},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_tailor_rejects_out_of_range_strength(tmp_path: Path) -> None:
|
|
profile_path = tmp_path / "profile.json"
|
|
save_json(sample_profile(), profile_path)
|
|
app = create_app(
|
|
profile_path=profile_path,
|
|
output_dir=tmp_path / "output",
|
|
llm_factory=lambda: FakeLLM([]), # type: ignore[arg-type]
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/api/tailor",
|
|
json={
|
|
"job_url": None,
|
|
"job_text": "Backend engineer role requiring Python. " * 3,
|
|
"tailoring_strength": 101,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_revision_chat_updates_tailored_outputs(tmp_path: Path) -> None:
|
|
profile_path = tmp_path / "profile.json"
|
|
output_dir = tmp_path / "output"
|
|
current = sample_package()
|
|
revised = sample_package()
|
|
revised.changes_made = ["Made the summary more direct."]
|
|
save_json(sample_profile(), profile_path)
|
|
save_json(current, output_dir / "tailoring.json")
|
|
app = create_app(
|
|
profile_path=profile_path,
|
|
output_dir=output_dir,
|
|
llm_factory=lambda: FakeLLM([revised, revised]), # type: ignore[arg-type]
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/api/revise",
|
|
json={"message": "Make the summary more direct."},
|
|
)
|
|
download = client.get("/api/download/resume-ohmycv.md")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["package"]["changes_made"] == [
|
|
"Made the summary more direct."
|
|
]
|
|
assert "Updated and re-audited" in response.json()["reply"]
|
|
assert download.status_code == 200
|
|
assert "[F013]" not in download.text
|
|
|
|
|
|
def test_revision_chat_requires_a_tailored_resume(tmp_path: Path) -> None:
|
|
app = create_app(
|
|
profile_path=tmp_path / "profile.json",
|
|
output_dir=tmp_path / "output",
|
|
llm_factory=lambda: FakeLLM([]), # type: ignore[arg-type]
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post("/api/revise", json={"message": "Make it shorter."})
|
|
|
|
assert response.status_code == 409
|
|
|
|
|
|
def test_markdown_tailor_preserves_ohmycv_front_matter(tmp_path: Path) -> None:
|
|
expected_profile = sample_profile()
|
|
expected_package = sample_package()
|
|
app = create_app(
|
|
profile_path=tmp_path / "profile.json",
|
|
output_dir=tmp_path / "output",
|
|
cv_dist=tmp_path / "missing-cv",
|
|
llm_factory=lambda: FakeLLM([expected_profile, expected_package, expected_package]), # type: ignore[arg-type]
|
|
)
|
|
source = """---
|
|
name: Ada Example
|
|
header:
|
|
- text: ada@example.com
|
|
---
|
|
|
|
## Experience
|
|
|
|
- Reduced API latency by 30%.
|
|
"""
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/api/markdown/tailor",
|
|
json={
|
|
"markdown": source,
|
|
"about": "",
|
|
"job_url": None,
|
|
"job_text": "Backend engineer role requiring Python and API performance. " * 3,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
markdown = response.json()["markdown"]
|
|
assert markdown.startswith("---\nname: Ada Example\nheader:")
|
|
assert "## Professional Summary\n\nReduced API latency" in markdown
|
|
assert "Reduced API latency by 30%." in markdown
|
|
assert "[F013]" not in markdown
|
|
assert (tmp_path / "output/resume-ohmycv.md").read_text() == markdown
|
|
|
|
|
|
def test_ohmycv_render_creates_native_header_when_source_has_none() -> None:
|
|
markdown = render_ohmycv_resume(sample_package(), "# Old resume")
|
|
|
|
assert markdown.startswith('---\nname: "Ada Example"\nheader:')
|
|
assert 'data-icon=\\"tabler:mail\\"' in markdown
|
|
assert 'link: "mailto:ada@example.com"' in markdown
|
|
assert "[F013]" not in markdown
|
|
|
|
|
|
def test_status_reports_built_cv_editor(tmp_path: Path) -> None:
|
|
cv_dist = tmp_path / "cv"
|
|
cv_dist.mkdir()
|
|
(cv_dist / "index.html").write_text("<h1>Oh My CV</h1>", encoding="utf-8")
|
|
(cv_dist / "200.html").write_text("<h1>Editor fallback</h1>", encoding="utf-8")
|
|
app = create_app(
|
|
profile_path=tmp_path / "profile.json",
|
|
output_dir=tmp_path / "output",
|
|
cv_dist=cv_dist,
|
|
llm_factory=lambda: FakeLLM([]), # type: ignore[arg-type]
|
|
)
|
|
|
|
with TestClient(app) as client:
|
|
assert client.get("/api/status").json()["editor_available"] is True
|
|
editor = client.get("/cv/")
|
|
dynamic_editor = client.get("/cv/editor/42")
|
|
|
|
assert editor.status_code == 200
|
|
assert "Oh My CV" in editor.text
|
|
assert dynamic_editor.status_code == 200
|
|
assert "Editor fallback" in dynamic_editor.text
|