"""Tests for v1.1 wave B WB1: dedupe + tailor + deadline integration. Covers: - Cluster assignment on posting creation (manual POST /postings) - Cluster stability across re-imports (same posting URL -> same cluster_id) - GET /clusters endpoint shape (cluster_id, postings with id/title/company/source/url/score) - Tailor CV happy path (artifact created, change_log, keyword_coverage) - Tailor CV hallucination rejection (fabricated mock returning unmapped bullet -> 502) - Keyword coverage numbers vs fixture - Deadline persisted on scoring (single + batch) - /today deadlines filter window (next 7 days) Total: >= 20 new tests. """ from __future__ import annotations import os from datetime import date, datetime, timedelta, timezone from unittest.mock import patch import pytest from fastapi.testclient import TestClient from app.db import execute, fetch_one, repo_app, repo_profile @pytest.fixture() def client(): from app.main import app return TestClient(app) # --- Helpers --- def _create_posting_direct( company: str = "TechCorp", title: str = "Senior Python Developer", url: str = "https://example.com/1", description: str = "We need a Python developer with FastAPI experience.", source: str = "manual_url", ) -> dict: """Create a posting directly via repo.""" posting = repo_app.create_job_posting( source=source, url=url, company=company, title=title, location="Malmo", description=description, raw={}, ) repo_app.create_application(posting["id"]) return posting def _create_app_with_profile_and_sections( client, company: str = "TechCorp", title: str = "Senior Python Developer", url: str = "https://example.com/tc1", description: str = "Python FastAPI PostgreSQL Docker Kubernetes AWS", ) -> str: """Create a profile with sections + posting + application. Returns app_id.""" # Create profile client.get("/api/profile") client.put("/api/profile", json={ "full_name": "Test User", "email": "test@test.com", "headline": "Backend Developer", "summary": "Experienced backend developer.", }) # Create sections client.post("/api/profile/sections", json={ "kind": "experience", "title": "Backend Developer", "org": "TechCorp", "bullets": [ "Led migration of monolith to microservices using Fast API", "Reduced API latency by 40% through query optimization and caching", ], "tags": ["python", "fastapi"], }) client.post("/api/profile/sections", json={ "kind": "skills", "title": "Technical Skills", "bullets": ["Python", "PostgreSQL", "Docker", "FastAPI"], "tags": ["python", "docker"], }) # Create posting + application posting = _create_posting_direct(company=company, title=title, url=url, description=description) # Re-fetch to get the app apps = repo_app.list_applications() for a in apps: if a["job_posting_id"] == posting["id"]: return a["id"] raise RuntimeError("Application not found") def _make_posting_dict(posting_id: str, company: str, title: str, description: str) -> dict: """Build a posting dict suitable for cluster().""" return { "id": posting_id, "employer": company, "title": title, "description": description, } # ======================================================================== # Cluster assignment on create (4 tests) # ======================================================================== class TestClusterAssignmentOnCreate: def test_single_posting_gets_cluster_id(self, client): """A posting created via POST /postings gets a cluster_id assigned.""" resp = client.post("/api/postings", json={"url": "https://example.com/cluster/1"}) assert resp.status_code == 201 postings = client.get("/api/postings").json() assert len(postings) >= 1 # The first posting might get c1 or no cluster (if it's the only one, cluster() gives it c1) # With matching available, even 1 posting gets cluster c1 if len(postings) == 1: # Single posting: cluster() returns {"c1": [id]} assert postings[0]["cluster_id"] is not None def test_duplicate_postings_same_cluster(self, client): """Two identical postings (same company, title, description) get same cluster_id.""" _create_posting_direct( company="Acme Corp", title="Software Engineer", url="https://example.com/dup/1", description="We need a Python developer with Docker experience.", ) _create_posting_direct( company="Acme Corp", title="Software Engineer", url="https://example.com/dup/2", description="We need a Python developer with Docker experience.", ) # Run cluster assignment manually from app.main import _assign_cluster_id from app.db import repo_app all_postings = repo_app.list_postings() for p in all_postings: _assign_cluster_id(p["id"]) postings = repo_app.list_postings() cluster_ids = [p["cluster_id"] for p in postings if p["cluster_id"]] # Both should have the same cluster_id assert len(cluster_ids) >= 2 assert len(set(cluster_ids)) == 1 def test_different_postings_different_clusters(self, client): """Completely different postings get different cluster_ids.""" _create_posting_direct( company="CompanyA", title="Chef", url="https://example.com/diff/1", description="Looking for an experienced chef.", ) _create_posting_direct( company="CompanyB", title="Pilot", url="https://example.com/diff/2", description="Commercial airline pilot needed.", ) from app.main import _assign_cluster_id from app.db import repo_app all_postings = repo_app.list_postings() for p in all_postings: _assign_cluster_id(p["id"]) postings = repo_app.list_postings() cluster_ids = [p["cluster_id"] for p in postings if p["cluster_id"]] if len(cluster_ids) >= 2: assert len(set(cluster_ids)) >= 2 def test_cluster_id_in_get_postings(self, client): """GET /postings returns cluster_id field.""" _create_posting_direct( company="TestCo", title="Dev", url="https://example.com/field/1", description="Test description", ) resp = client.get("/api/postings") assert resp.status_code == 200 data = resp.json() assert len(data) >= 1 assert "cluster_id" in data[0] # ======================================================================== # Cluster stability across re-imports (3 tests) # ======================================================================== class TestClusterStability: def test_reimport_preserves_cluster_id(self, client): """Re-importing a posting (same URL) keeps the cluster_id stable.""" # First import posting1 = _create_posting_direct( company="StableCorp", title="Engineer", url="https://example.com/stable/1", description="Stable description for engineer role.", ) from app.main import _assign_cluster_id _assign_cluster_id(posting1["id"]) p1 = repo_app.get_job_posting(posting1["id"]) original_cluster_id = p1.get("cluster_id") # Re-import: same URL -> ON CONFLICT DO UPDATE, returns same row posting2 = repo_app.create_job_posting( source="manual_url", url="https://example.com/stable/1", company="StableCorp", title="Engineer", description="Stable description for engineer role.", raw={}, ) assert posting2["id"] == posting1["id"] p2 = repo_app.get_job_posting(posting2["id"]) assert p2.get("cluster_id") == original_cluster_id def test_new_duplicate_joins_existing_cluster(self, client): """A new posting that's a duplicate of an existing one joins its cluster_id.""" # First posting p1 = _create_posting_direct( company="JoinCorp", title="Backend Developer", url="https://example.com/join/1", description="Python developer with PostgreSQL and Docker.", ) from app.main import _assign_cluster_id _assign_cluster_id(p1["id"]) p1_row = repo_app.get_job_posting(p1["id"]) original_cluster = p1_row.get("cluster_id") # Second posting (duplicate) p2 = _create_posting_direct( company="JoinCorp", title="Backend Developer", url="https://example.com/join/2", description="Python developer with PostgreSQL and Docker.", ) _assign_cluster_id(p2["id"]) p2_row = repo_app.get_job_posting(p2["id"]) assert p2_row.get("cluster_id") == original_cluster def test_third_duplicate_extends_cluster(self, client): """Third duplicate posting joins the same cluster as the first two.""" desc = "Senior Python developer with FastAPI and PostgreSQL experience." p1 = _create_posting_direct( company="ExtCorp", title="Senior Python Developer", url="https://example.com/ext/1", description=desc, ) from app.main import _assign_cluster_id _assign_cluster_id(p1["id"]) c1 = repo_app.get_job_posting(p1["id"]).get("cluster_id") p2 = _create_posting_direct( company="ExtCorp", title="Senior Python Developer", url="https://example.com/ext/2", description=desc, ) _assign_cluster_id(p2["id"]) p3 = _create_posting_direct( company="ExtCorp", title="Senior Python Developer", url="https://example.com/ext/3", description=desc, ) _assign_cluster_id(p3["id"]) c2 = repo_app.get_job_posting(p2["id"]).get("cluster_id") c3 = repo_app.get_job_posting(p3["id"]).get("cluster_id") assert c1 == c2 == c3 # ======================================================================== # GET /clusters endpoint shape (3 tests) # ======================================================================== class TestClustersEndpoint: def test_clusters_returns_list(self, client): """GET /clusters returns a list of cluster objects.""" resp = client.get("/api/clusters") assert resp.status_code == 200 assert isinstance(resp.json(), list) def test_clusters_shape(self, client): """Each cluster has cluster_id and postings with required fields.""" # Create two duplicate postings desc = "Full stack developer with React and Node.js experience needed." p1 = _create_posting_direct( company="ShapeCorp", title="Full Stack Developer", url="https://example.com/shape/1", description=desc, ) from app.main import _assign_cluster_id _assign_cluster_id(p1["id"]) p2 = _create_posting_direct( company="ShapeCorp", title="Full Stack Developer", url="https://example.com/shape/2", description=desc, ) _assign_cluster_id(p2["id"]) resp = client.get("/api/clusters") assert resp.status_code == 200 data = resp.json() assert len(data) >= 1 cluster = data[0] assert "cluster_id" in cluster assert "postings" in cluster assert isinstance(cluster["postings"], list) assert len(cluster["postings"]) >= 2 p = cluster["postings"][0] assert "id" in p assert "title" in p assert "company" in p assert "source" in p assert "url" in p assert "score" in p def test_clusters_empty_when_no_cluster_ids(self, client): """GET /clusters returns empty list when no postings have cluster_ids.""" # Create a posting but don't assign cluster_id _create_posting_direct( company="NoCluster", title="Dev", url="https://example.com/nocluster/1", description="Something unique.", ) # Don't call _assign_cluster_id resp = client.get("/api/clusters") assert resp.status_code == 200 data = resp.json() # Should be empty since no cluster_ids assigned assert len(data) == 0 # ======================================================================== # Tailor CV happy path (3 tests) # ======================================================================== class TestTailorCvHappyPath: def test_tailor_cv_returns_artifact_and_coverage(self, client): """POST /applications/{id}/tailor-cv returns artifact_id, change_log, keyword_coverage.""" app_id = _create_app_with_profile_and_sections(client) resp = client.post(f"/api/applications/{app_id}/tailor-cv") assert resp.status_code == 200 data = resp.json() assert "artifact_id" in data assert data["artifact_id"] is not None assert "change_log" in data assert isinstance(data["change_log"], list) assert len(data["change_log"]) >= 1 assert "keyword_coverage" in data kc = data["keyword_coverage"] assert "matched" in kc assert "missing" in kc assert "ratio" in kc def test_tailor_cv_artifact_stored(self, client): """The tailored CV artifact appears in the application's artifacts list.""" app_id = _create_app_with_profile_and_sections(client) resp = client.post(f"/api/applications/{app_id}/tailor-cv") assert resp.status_code == 200 artifact_id = resp.json()["artifact_id"] artifacts = client.get(f"/api/applications/{app_id}/artifacts").json() cv_artifacts = [a for a in artifacts if a["kind"] == "cv"] assert len(cv_artifacts) >= 1 assert any(a["id"] == artifact_id for a in cv_artifacts) ai_artifact = [a for a in cv_artifacts if a["id"] == artifact_id][0] assert ai_artifact["origin"] == "ai_drafted" def test_tailor_cv_404_nonexistent(self, client): """Tailor CV on nonexistent application returns 404.""" resp = client.post("/api/applications/00000000-0000-0000-0000-000000000000/tailor-cv") assert resp.status_code == 404 # ======================================================================== # Tailor CV hallucination rejection (3 tests) # ======================================================================== class TestTailorCvHallucinationGuard: def test_hallucination_rejection_502(self, client, monkeypatch): """When the tailor output has bullets with no source mapping, return 502.""" import app.llm as llm_mod app_id = _create_app_with_profile_and_sections(client) fabricated = { "tailored_cv": { "summary": "Developer", "skills": ["Python"], "experience": [ { "company": "FakeCorp", "role": "Fake Role", "bullets": [ "Completely fabricated achievement that has no overlap with any source bullet xyzqwerty", ], }, ], }, "change_log": [{"action": "invented", "detail": "Made up a bullet"}], } monkeypatch.setattr( llm_mod, "run_task", lambda task, prompt, *a, **k: fabricated, ) resp = client.post(f"/api/applications/{app_id}/tailor-cv") assert resp.status_code == 502 detail = resp.json()["detail"] assert "hallucination_guard" in str(detail) def test_hallucination_rejection_with_empty_source_bullets(self, client): """When there are no source bullets, hallucination guard is not triggered (no source to map to).""" # Create profile with no sections client.get("/api/profile") client.put("/api/profile", json={ "full_name": "Test User", "email": "test@test.com", }) # Create posting + app posting = _create_posting_direct( company="NoSourceCo", title="Dev", url="https://example.com/nosource/1", description="Python developer", ) apps = repo_app.list_applications() app_id = None for a in apps: if a["job_posting_id"] == posting["id"]: app_id = a["id"] break # When no source bullets exist, the guard doesn't trigger (source_bullets is empty) resp = client.post(f"/api/applications/{app_id}/tailor-cv") # Should succeed since source_bullets is empty -> guard not triggered assert resp.status_code == 200 def test_hallucination_rejection_preserves_existing_output(self, client, monkeypatch): """After a 502 hallucination rejection, a subsequent valid call works.""" import app.llm as llm_mod app_id = _create_app_with_profile_and_sections(client) fabricated = { "tailored_cv": { "summary": "Dev", "skills": ["Python"], "experience": [ { "company": "X", "role": "X", "bullets": ["Fabricated xyzqwerty zzz new content"], }, ], }, "change_log": [], } with monkeypatch.context() as mp: mp.setattr( llm_mod, "run_task", lambda task, prompt, *a, **k: fabricated, ) resp1 = client.post(f"/api/applications/{app_id}/tailor-cv") assert resp1.status_code == 502 # Default prompt-aware mock is guard-safe -> succeeds resp2 = client.post(f"/api/applications/{app_id}/tailor-cv") assert resp2.status_code == 200 # ======================================================================== # Keyword coverage numbers vs fixture (2 tests) # ======================================================================== class TestKeywordCoverage: def test_keyword_coverage_has_matched_and_missing(self, client): """Keyword coverage from tailor-cv contains matched and missing keywords.""" app_id = _create_app_with_profile_and_sections( client, description="Python FastAPI PostgreSQL Docker Kubernetes AWS Java Spring", ) resp = client.post(f"/api/applications/{app_id}/tailor-cv") assert resp.status_code == 200 kc = resp.json()["keyword_coverage"] assert "matched" in kc assert "missing" in kc assert "ratio" in kc assert isinstance(kc["matched"], list) assert isinstance(kc["missing"], list) assert isinstance(kc["ratio"], (int, float)) assert 0.0 <= kc["ratio"] <= 1.0 def test_keyword_coverage_ratio_is_reasonable(self, client): """With matching CV keywords, coverage ratio should be > 0.""" app_id = _create_app_with_profile_and_sections( client, description="Python FastAPI PostgreSQL Docker Kubernetes AWS", ) resp = client.post(f"/api/applications/{app_id}/tailor-cv") assert resp.status_code == 200 kc = resp.json()["keyword_coverage"] # The mock CV has Python, Fast API, PostgreSQL, Docker, Kubernetes, AWS # which should match most posting keywords assert kc["ratio"] > 0.0 # ======================================================================== # Deadline persisted on scoring (3 tests) # ======================================================================== class TestDeadlinePersisted: def test_deadline_extracted_on_single_score(self, client): """Scoring a posting also runs deadline_extract and persists apply_by.""" posting = _create_posting_direct( company="DeadlineCo", title="Dev", url="https://example.com/deadline/1", description="Apply by 2026-12-31.", ) apps = repo_app.list_applications() app_id = None for a in apps: if a["job_posting_id"] == posting["id"]: app_id = a["id"] break # Mock deadline_extract to return a date import app.llm as llm_mod original = llm_mod.MOCK_OUTPUTS.get("deadline_extract", {}).copy() try: llm_mod.MOCK_OUTPUTS["deadline_extract"] = {"apply_by": "2026-12-31"} resp = client.post(f"/api/postings/{posting['id']}/score") assert resp.status_code == 200 # Verify apply_by was persisted p = repo_app.get_job_posting(posting["id"]) assert p["apply_by"] == "2026-12-31" finally: llm_mod.MOCK_OUTPUTS["deadline_extract"] = original def test_deadline_null_does_not_persist(self, client): """When deadline_extract returns null apply_by, nothing is persisted.""" posting = _create_posting_direct( company="NoDeadlineCo", title="Dev", url="https://example.com/nodeadline/1", description="No deadline mentioned.", ) # Default mock returns apply_by=None resp = client.post(f"/api/postings/{posting['id']}/score") assert resp.status_code == 200 p = repo_app.get_job_posting(posting["id"]) assert p["apply_by"] is None def test_deadline_extracted_on_batch_score(self, client): """Batch scoring also runs deadline_extract and persists apply_by.""" posting = _create_posting_direct( company="BatchDeadlineCo", title="Dev", url="https://example.com/batchdeadline/1", description="Apply by 2026-11-15.", ) apps = repo_app.list_applications() app_id = None for a in apps: if a["job_posting_id"] == posting["id"]: app_id = a["id"] break import app.llm as llm_mod original = llm_mod.MOCK_OUTPUTS.get("deadline_extract", {}).copy() try: llm_mod.MOCK_OUTPUTS["deadline_extract"] = {"apply_by": "2026-11-15"} resp = client.post("/api/scoring/batch", json={"application_ids": [app_id]}) assert resp.status_code == 200 p = repo_app.get_job_posting(posting["id"]) assert p["apply_by"] == "2026-11-15" finally: llm_mod.MOCK_OUTPUTS["deadline_extract"] = original # ======================================================================== # /today deadlines filter window (3 tests) # ======================================================================== class TestTodayDeadlines: def test_today_returns_deadlines_field(self, client): """/today response includes deadlines field.""" resp = client.get("/api/today") assert resp.status_code == 200 data = resp.json() assert "deadlines" in data assert isinstance(data["deadlines"], list) def test_today_deadlines_within_7_days(self, client): """Deadlines within next 7 days appear in /today.""" # Create posting with apply_by in 3 days posting = _create_posting_direct( company="WeekCo", title="Dev", url="https://example.com/week/1", description="Dev role.", ) apps = repo_app.list_applications() app_id = None for a in apps: if a["job_posting_id"] == posting["id"]: app_id = a["id"] break future_date = date.today() + timedelta(days=3) repo_app.update_posting_apply_by(posting["id"], future_date) resp = client.get("/api/today") assert resp.status_code == 200 deadlines = resp.json()["deadlines"] assert len(deadlines) >= 1 dl = [d for d in deadlines if d["application_id"] == app_id] assert len(dl) == 1 assert dl[0]["title"] == "Dev" assert dl[0]["company"] == "WeekCo" assert dl[0]["apply_by"] == future_date.isoformat() def test_today_deadlines_excludes_beyond_7_days(self, client): """Deadlines beyond 7 days do NOT appear in /today.""" posting = _create_posting_direct( company="FarCo", title="Dev", url="https://example.com/far/1", description="Dev role.", ) apps = repo_app.list_applications() app_id = None for a in apps: if a["job_posting_id"] == posting["id"]: app_id = a["id"] break far_date = date.today() + timedelta(days=30) repo_app.update_posting_apply_by(posting["id"], far_date) resp = client.get("/api/today") assert resp.status_code == 200 deadlines = resp.json()["deadlines"] far_deadlines = [d for d in deadlines if d["application_id"] == app_id] assert len(far_deadlines) == 0 # ======================================================================== # Extra integration tests (2 tests) # ======================================================================== class TestExtraIntegration: def test_get_postings_has_apply_by_field(self, client): """GET /postings includes apply_by field.""" posting = _create_posting_direct( company="ApplyByCo", title="Dev", url="https://example.com/applyby/1", description="Dev role.", ) repo_app.update_posting_apply_by(posting["id"], date.today() + timedelta(days=5)) resp = client.get("/api/postings") assert resp.status_code == 200 data = resp.json() p = [x for x in data if x["id"] == posting["id"]][0] assert p["apply_by"] is not None def test_clusters_sorted_by_best_score_desc(self, client): """Clusters are sorted by best score descending.""" # Create cluster 1 with a high-score posting desc1 = "Python developer with PostgreSQL and Docker experience needed." p1 = _create_posting_direct( company="HighScoreCo", title="Python Developer", url="https://example.com/sort/1", description=desc1, ) from app.main import _assign_cluster_id _assign_cluster_id(p1["id"]) # Score p1 apps = repo_app.list_applications() for a in apps: if a["job_posting_id"] == p1["id"]: repo_app.update_application_score(a["id"], 90, {"factors": {}}) break # Create cluster 2 with a low-score posting desc2 = "Marketing specialist for social media campaigns and content creation." p2 = _create_posting_direct( company="LowScoreCo", title="Marketing Specialist", url="https://example.com/sort/2", description=desc2, ) _assign_cluster_id(p2["id"]) for a in apps: if a["job_posting_id"] == p2["id"]: repo_app.update_application_score(a["id"], 30, {"factors": {}}) break resp = client.get("/api/clusters") assert resp.status_code == 200 data = resp.json() if len(data) >= 2: # Best scores should be descending best_scores = [] for c in data: scores = [p.get("score") or 0 for p in c["postings"]] best_scores.append(max(scores) if scores else 0) assert best_scores[0] >= best_scores[1]