WB1: add >=20 tests for cluster, tailor-cv, deadline integration
This commit is contained in:
parent
b3a1f588ef
commit
d8967f29f0
3 changed files with 740 additions and 15 deletions
739
apps/api/tests/test_v11b_wb1.py
Normal file
739
apps/api/tests/test_v11b_wb1.py
Normal file
|
|
@ -0,0 +1,739 @@
|
|||
"""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):
|
||||
"""When mock returns bullet with no source mapping, return 502."""
|
||||
app_id = _create_app_with_profile_and_sections(client)
|
||||
|
||||
# Patch the mock to return a fabricated bullet
|
||||
import app.llm as llm_mod
|
||||
original = llm_mod.MOCK_OUTPUTS.get("cv_tailor", {}).copy()
|
||||
try:
|
||||
llm_mod.MOCK_OUTPUTS["cv_tailor"] = {
|
||||
"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"}],
|
||||
}
|
||||
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)
|
||||
finally:
|
||||
llm_mod.MOCK_OUTPUTS["cv_tailor"] = original
|
||||
|
||||
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):
|
||||
"""After a 502 hallucination rejection, a subsequent valid call works."""
|
||||
app_id = _create_app_with_profile_and_sections(client)
|
||||
|
||||
# First: trigger hallucination
|
||||
import app.llm as llm_mod
|
||||
original = llm_mod.MOCK_OUTPUTS.get("cv_tailor", {}).copy()
|
||||
try:
|
||||
llm_mod.MOCK_OUTPUTS["cv_tailor"] = {
|
||||
"tailored_cv": {
|
||||
"summary": "Dev",
|
||||
"skills": ["Python"],
|
||||
"experience": [
|
||||
{
|
||||
"company": "X",
|
||||
"role": "X",
|
||||
"bullets": ["Fabricated xyzqwerty zzz new content"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"change_log": [],
|
||||
}
|
||||
resp1 = client.post(f"/api/applications/{app_id}/tailor-cv")
|
||||
assert resp1.status_code == 502
|
||||
|
||||
# Restore and retry
|
||||
llm_mod.MOCK_OUTPUTS["cv_tailor"] = original
|
||||
resp2 = client.post(f"/api/applications/{app_id}/tailor-cv")
|
||||
assert resp2.status_code == 200
|
||||
finally:
|
||||
llm_mod.MOCK_OUTPUTS["cv_tailor"] = original
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# 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]
|
||||
|
|
@ -33,20 +33,6 @@ const groupedPostings = computed(() => {
|
|||
return Array.from(map.entries()).map(([cluster_id, items]) => ({ cluster_id, items }))
|
||||
})
|
||||
|
||||
// Best score per cluster (from cluster endpoint or from scoreMap)
|
||||
function clusterBestScore(clusterId: string): number | null {
|
||||
const cluster = clusters.value.find((c) => c.cluster_id === clusterId)
|
||||
if (cluster && cluster.postings.length > 0) {
|
||||
return Math.max(...cluster.postings.map((p) => p.score))
|
||||
}
|
||||
const items = groupedPostings.value.find((g) => g.cluster_id === clusterId)?.items
|
||||
if (items) {
|
||||
const scores = items.map((p) => scoreMap.value[p.id]?.score).filter((s): s is number => s != null)
|
||||
return scores.length > 0 ? Math.max(...scores) : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Alternates for a cluster (from GET /clusters endpoint)
|
||||
function clusterAlternatives(clusterId: string): Cluster['postings'] {
|
||||
const cluster = clusters.value.find((c) => c.cluster_id === clusterId)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type { TodayResponse, TodayDeadline, EmailSuggestion, NotificationLogEntr
|
|||
const toast = useToastStore()
|
||||
const router = useRouter()
|
||||
|
||||
const today = ref<TodayResponse | null>(null)
|
||||
const today = ref<(TodayResponse & { deadlines?: TodayDeadline[] }) | null>(null)
|
||||
const loading = ref(true)
|
||||
const deadlines = ref<TodayDeadline[]>([])
|
||||
const suggestions = ref<EmailSuggestion[]>([])
|
||||
|
|
|
|||
Loading…
Reference in a new issue