234 lines
10 KiB
Python
234 lines
10 KiB
Python
"""Integration tests for the Kanban application-tracker API (real isolated DB)."""
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.main import app
|
|
from app.schemas.applications import APPLICATION_STATUS_ORDER
|
|
|
|
|
|
def _client():
|
|
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
|
|
|
|
|
|
async def _seed_card(isolated_db, **kwargs):
|
|
"""Create a card directly on the DB (bypassing the LLM manual-add path)."""
|
|
defaults = dict(job_id="job-1", resume_id="res-1", status="applied")
|
|
defaults.update(kwargs)
|
|
return await isolated_db.create_application(**defaults)
|
|
|
|
|
|
class TestListAndGroup:
|
|
async def test_empty_board_has_all_seven_columns(self, isolated_db):
|
|
async with _client() as client:
|
|
resp = await client.get("/api/v1/applications")
|
|
assert resp.status_code == 200
|
|
columns = resp.json()["columns"]
|
|
assert set(columns.keys()) == set(APPLICATION_STATUS_ORDER)
|
|
assert all(columns[s] == [] for s in APPLICATION_STATUS_ORDER)
|
|
|
|
async def test_cards_grouped_by_status(self, isolated_db):
|
|
await _seed_card(isolated_db, job_id="j1", resume_id="r1", status="applied")
|
|
await _seed_card(isolated_db, job_id="j2", resume_id="r2", status="interview")
|
|
async with _client() as client:
|
|
resp = await client.get("/api/v1/applications")
|
|
columns = resp.json()["columns"]
|
|
assert len(columns["applied"]) == 1
|
|
assert len(columns["interview"]) == 1
|
|
assert columns["applied"][0]["resume_id"] == "r1"
|
|
|
|
|
|
class TestManualAdd:
|
|
async def test_manual_add_extracts_company_role(self, isolated_db):
|
|
with patch(
|
|
"app.routers.applications.extract_job_keywords",
|
|
new_callable=AsyncMock,
|
|
return_value={"company": "Acme Corp", "role": "Staff Engineer"},
|
|
):
|
|
async with _client() as client:
|
|
resp = await client.post(
|
|
"/api/v1/applications",
|
|
json={
|
|
"resume_id": "res-1",
|
|
"job_description": "We are Acme Corp hiring a Staff Engineer...",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["company"] == "Acme Corp"
|
|
assert body["role"] == "Staff Engineer"
|
|
assert body["status"] == "applied"
|
|
|
|
async def test_manual_add_respects_explicit_fields_without_llm(self, isolated_db):
|
|
with patch(
|
|
"app.routers.applications.extract_job_keywords",
|
|
new_callable=AsyncMock,
|
|
) as mock_extract:
|
|
async with _client() as client:
|
|
resp = await client.post(
|
|
"/api/v1/applications",
|
|
json={
|
|
"resume_id": "res-1",
|
|
"job_description": "JD text",
|
|
"company": "Given Co",
|
|
"role": "Given Role",
|
|
"status": "saved",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
# Both fields supplied → no extraction call.
|
|
mock_extract.assert_not_called()
|
|
body = resp.json()
|
|
assert body["company"] == "Given Co"
|
|
assert body["status"] == "saved"
|
|
assert body["applied_at"] is None # saved is not applied yet
|
|
|
|
|
|
class TestDetail:
|
|
async def test_detail_embeds_job_and_resume(self, isolated_db):
|
|
resume = await isolated_db.create_resume(content="# Resume")
|
|
job = await isolated_db.create_job(content="JD body text")
|
|
card = await _seed_card(isolated_db, job_id=job["job_id"], resume_id=resume["resume_id"])
|
|
async with _client() as client:
|
|
resp = await client.get(f"/api/v1/applications/{card['application_id']}")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["job_content"] == "JD body text"
|
|
assert body["resume"]["resume_id"] == resume["resume_id"]
|
|
|
|
async def test_detail_tolerates_deleted_resume(self, isolated_db):
|
|
job = await isolated_db.create_job(content="JD")
|
|
card = await _seed_card(isolated_db, job_id=job["job_id"], resume_id="ghost-resume")
|
|
async with _client() as client:
|
|
resp = await client.get(f"/api/v1/applications/{card['application_id']}")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["resume"] is None # never 500
|
|
|
|
async def test_detail_unknown_returns_404(self, isolated_db):
|
|
async with _client() as client:
|
|
resp = await client.get("/api/v1/applications/does-not-exist")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestUpdateAndMove:
|
|
async def test_patch_moves_card_across_columns(self, isolated_db):
|
|
a = await _seed_card(isolated_db, job_id="j1", resume_id="r1", status="applied")
|
|
b = await _seed_card(isolated_db, job_id="j2", resume_id="r2", status="applied")
|
|
async with _client() as client:
|
|
resp = await client.patch(
|
|
f"/api/v1/applications/{a['application_id']}",
|
|
json={"status": "interview", "position": 0},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "interview"
|
|
# The applied column renumbered so b is now position 0.
|
|
async with _client() as client:
|
|
board = (await client.get("/api/v1/applications")).json()["columns"]
|
|
assert board["applied"][0]["application_id"] == b["application_id"]
|
|
assert board["applied"][0]["position"] == 0
|
|
|
|
async def test_patch_notes_and_company(self, isolated_db):
|
|
card = await _seed_card(isolated_db)
|
|
async with _client() as client:
|
|
resp = await client.patch(
|
|
f"/api/v1/applications/{card['application_id']}",
|
|
json={"notes": "Recruiter call Friday", "company": "NewCo"},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["notes"] == "Recruiter call Friday"
|
|
assert body["company"] == "NewCo"
|
|
|
|
async def test_patch_unknown_returns_404(self, isolated_db):
|
|
async with _client() as client:
|
|
resp = await client.patch("/api/v1/applications/nope", json={"notes": "x"})
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestBulkAndDelete:
|
|
async def test_bulk_move(self, isolated_db):
|
|
a = await _seed_card(isolated_db, job_id="j1", resume_id="r1")
|
|
b = await _seed_card(isolated_db, job_id="j2", resume_id="r2")
|
|
async with _client() as client:
|
|
resp = await client.patch(
|
|
"/api/v1/applications/bulk",
|
|
json={"application_ids": [a["application_id"], b["application_id"]], "status": "rejected"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["affected"] == 2
|
|
async with _client() as client:
|
|
board = (await client.get("/api/v1/applications")).json()["columns"]
|
|
assert len(board["rejected"]) == 2
|
|
assert board["applied"] == []
|
|
|
|
async def test_delete_single(self, isolated_db):
|
|
card = await _seed_card(isolated_db)
|
|
async with _client() as client:
|
|
resp = await client.delete(f"/api/v1/applications/{card['application_id']}")
|
|
assert resp.status_code == 200
|
|
async with _client() as client:
|
|
board = (await client.get("/api/v1/applications")).json()["columns"]
|
|
assert board["applied"] == []
|
|
|
|
async def test_bulk_delete(self, isolated_db):
|
|
a = await _seed_card(isolated_db, job_id="j1", resume_id="r1")
|
|
b = await _seed_card(isolated_db, job_id="j2", resume_id="r2")
|
|
async with _client() as client:
|
|
resp = await client.post(
|
|
"/api/v1/applications/bulk-delete",
|
|
json={"application_ids": [a["application_id"], b["application_id"]]},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["affected"] == 2
|
|
async with _client() as client:
|
|
board = (await client.get("/api/v1/applications")).json()["columns"]
|
|
assert board["applied"] == []
|
|
|
|
|
|
class TestRobustnessFixes:
|
|
async def test_create_dedupes_same_job_resume(self, isolated_db):
|
|
"""A second card for the same (job, resume) returns the existing one."""
|
|
first = await _seed_card(isolated_db, job_id="dup-j", resume_id="dup-r", status="applied")
|
|
second = await isolated_db.create_application(
|
|
job_id="dup-j", resume_id="dup-r", status="applied"
|
|
)
|
|
assert second["application_id"] == first["application_id"]
|
|
async with _client() as client:
|
|
board = (await client.get("/api/v1/applications")).json()["columns"]
|
|
assert len(board["applied"]) == 1
|
|
|
|
async def test_unknown_status_is_skipped_not_500(self, isolated_db):
|
|
"""A row whose status is outside the enum must not 500 the board."""
|
|
await isolated_db.create_application(job_id="j1", resume_id="r1", status="bogus_status")
|
|
await _seed_card(isolated_db, job_id="j2", resume_id="r2", status="applied")
|
|
async with _client() as client:
|
|
resp = await client.get("/api/v1/applications")
|
|
assert resp.status_code == 200
|
|
columns = resp.json()["columns"]
|
|
assert "bogus_status" not in columns
|
|
assert len(columns["applied"]) == 1 # the valid card still renders
|
|
|
|
async def test_manual_add_cleans_up_orphan_job_on_failure(self, isolated_db):
|
|
"""If application creation fails, the just-created job is removed."""
|
|
with patch.object(
|
|
isolated_db,
|
|
"create_application",
|
|
new_callable=AsyncMock,
|
|
side_effect=RuntimeError("boom"),
|
|
):
|
|
async with _client() as client:
|
|
resp = await client.post(
|
|
"/api/v1/applications",
|
|
json={
|
|
"resume_id": "res-1",
|
|
"job_description": "JD text",
|
|
"company": "Given Co",
|
|
"role": "Given Role",
|
|
},
|
|
)
|
|
assert resp.status_code == 500
|
|
stats = await isolated_db.get_stats()
|
|
assert stats["total_jobs"] == 0 # no orphan job left behind
|