packages/artifacts: - render_cv_pdf(profile, sections) -> bytes: data-driven CV PDF generation using fpdf2 with bundled DejaVuSans TTF for unicode (Swedish chars tested). Jinja2 template for layout data prep, adapted from build_cv.py approach. - render_cover_letter(text, profile) -> bytes: simple cover letter PDF. - hash_bytes(b) -> str: sha256 hex digest. - next_version(existing) -> int: version numbering helper. - 16 tests, all passing: PDF validity, Swedish characters, hash stability, cover letter rendering, hash correctness, version logic. packages/llm-gateway: - Async-first Gateway class with provider config from env. - Mock mode default when no API key env present (deterministic canned outputs per task name, defined in mock.py). - Telemetry sink injectable (async or sync callable, receives TelemetryRow). - Budget guard raises BudgetExceeded BEFORE any provider call is made. - Retry policy: max 2 retries on 429/5xx, then fallback provider for STRONG tasks only. CHEAP tasks never use fallback (paid provider protection). - Schema validation via jsonschema; SchemaValidationError on mismatch. - Task class routing: CHEAP (score, extract, cv_assist) vs STRONG (critique, cl_critique, research). Model routing per task class. - Paid provider detection heuristic; warns on paid fallback config. - 31 tests, all passing: mock determinism, schema pass/fail, budget guard (mock + real mode), telemetry sink (async/sync/none), config from env, provider calls with mocked HTTP (retry, fallback, no-fallback-for-cheap). docker-compose.yml: - postgres:16 service, user/pass/db = jobhunt, host port 5433->5432, named volume jobhunt_pgdata, healthcheck. .env.example: - DATABASE_URL, LLM provider config (primary + fallback), task budgets, API and web settings.
146 lines
No EOL
4.8 KiB
Python
146 lines
No EOL
4.8 KiB
Python
"""Tests for the artifacts package."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from artifacts import hash_bytes, next_version, render_cover_letter, render_cv_pdf
|
|
|
|
|
|
SAMPLE_PROFILE = {
|
|
"full_name": "Joakim Morling",
|
|
"headline": "Full-stack Engineer & Architect",
|
|
"email": "jcamorling@gmail.com",
|
|
"phone": "+46 76 006 7335",
|
|
"location": "Malmo, Sweden",
|
|
"summary": (
|
|
"Full-stack engineer with a passion for building robust systems. "
|
|
"Experienced in government platforms and embedded ML. "
|
|
"Swedish characters: å ä ö Å Ä Ö are important."
|
|
),
|
|
}
|
|
|
|
SAMPLE_SECTIONS = [
|
|
{
|
|
"kind": "experience",
|
|
"title": "Co-founder & Partner",
|
|
"org": "Pro Firmitas ApS",
|
|
"location": "Copenhagen, DK",
|
|
"start_date": "2026-02",
|
|
"end_date": None,
|
|
"bullets": [
|
|
"Co-founded a consulting company building AI-enhanced software",
|
|
"Responsible for technical architecture and client delivery",
|
|
],
|
|
},
|
|
{
|
|
"kind": "education",
|
|
"title": "M.Sc. Computer Science & Engineering",
|
|
"org": "Lund University (LTH)",
|
|
"location": "Lund, SE",
|
|
"start_date": "2018",
|
|
"end_date": "2023",
|
|
"bullets": [
|
|
"Master Thesis: optimized DBSCAN clustering for radar processing",
|
|
],
|
|
},
|
|
]
|
|
|
|
SWEDISH_PROFILE = {
|
|
"full_name": "Åke Öberg",
|
|
"headline": "Mjukvaruingenjör - Full Stack",
|
|
"email": "ake.oberg@example.se",
|
|
"phone": "+46 70 123 4567",
|
|
"location": "Malmö, Sverige",
|
|
"summary": "Erfaren utvecklare med fokus på å ä ö Å Ä Ö i alla texter.",
|
|
}
|
|
|
|
SWEDISH_SECTIONS = [
|
|
{
|
|
"kind": "experience",
|
|
"title": "Senior Utvecklare",
|
|
"org": "Företag AB",
|
|
"location": "Göteborg",
|
|
"start_date": "2020",
|
|
"end_date": None,
|
|
"bullets": [
|
|
"Byggde system med ångervektor och översättningsmotor",
|
|
"Ansvarig för säkerhetsgranskning av ärendehantering",
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
class TestRenderCvPdf:
|
|
def test_returns_valid_pdf_bytes(self) -> None:
|
|
result = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
|
assert isinstance(result, (bytes, bytearray))
|
|
assert bytes(result).startswith(b"%PDF")
|
|
|
|
def test_swedish_characters_render(self) -> None:
|
|
"""Swedish characters must not raise an encoding error."""
|
|
result = render_cv_pdf(SWEDISH_PROFILE, SWEDISH_SECTIONS)
|
|
assert bytes(result).startswith(b"%PDF")
|
|
|
|
def test_content_hash_stable(self) -> None:
|
|
"""Same input should produce the same content hash."""
|
|
result_a = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
|
result_b = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
|
assert hash_bytes(bytes(result_a)) == hash_bytes(bytes(result_b))
|
|
|
|
def test_different_input_different_hash(self) -> None:
|
|
result_a = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
|
result_b = render_cv_pdf(SWEDISH_PROFILE, SWEDISH_SECTIONS)
|
|
assert hash_bytes(bytes(result_a)) != hash_bytes(bytes(result_b))
|
|
|
|
def test_empty_sections(self) -> None:
|
|
result = render_cv_pdf(SAMPLE_PROFILE, [])
|
|
assert bytes(result).startswith(b"%PDF")
|
|
|
|
def test_minimal_profile(self) -> None:
|
|
result = render_cv_pdf({"full_name": "Test Person"}, [])
|
|
assert bytes(result).startswith(b"%PDF")
|
|
|
|
|
|
class TestRenderCoverLetter:
|
|
def test_returns_valid_pdf_bytes(self) -> None:
|
|
result = render_cover_letter("Dear Hiring Manager,\n\nI am applying...", SAMPLE_PROFILE)
|
|
assert bytes(result).startswith(b"%PDF")
|
|
|
|
def test_swedish_text(self) -> None:
|
|
text = "Hej! Jag söker jobbet. Mvh Åke Öberg."
|
|
result = render_cover_letter(text, SWEDISH_PROFILE)
|
|
assert bytes(result).startswith(b"%PDF")
|
|
|
|
def test_content_hash_stable(self) -> None:
|
|
text = "Cover letter body text."
|
|
result_a = render_cover_letter(text, SAMPLE_PROFILE)
|
|
result_b = render_cover_letter(text, SAMPLE_PROFILE)
|
|
assert hash_bytes(bytes(result_a)) == hash_bytes(bytes(result_b))
|
|
|
|
|
|
class TestHashBytes:
|
|
def test_known_value(self) -> None:
|
|
assert hash_bytes(b"hello") == (
|
|
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
|
)
|
|
|
|
def test_empty_bytes(self) -> None:
|
|
assert hash_bytes(b"") == (
|
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
)
|
|
|
|
def test_different_input_different_hash(self) -> None:
|
|
assert hash_bytes(b"a") != hash_bytes(b"b")
|
|
|
|
|
|
class TestNextVersion:
|
|
def test_empty_list(self) -> None:
|
|
assert next_version([]) == 1
|
|
|
|
def test_sequential(self) -> None:
|
|
assert next_version([1, 2, 3]) == 4
|
|
|
|
def test_gaps(self) -> None:
|
|
assert next_version([1, 3]) == 4
|
|
|
|
def test_single(self) -> None:
|
|
assert next_version([5]) == 6 |