Files
srbhr--resume-matcher/apps/backend/tests/unit/test_database.py
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

289 lines
12 KiB
Python

"""Tests for the real SQLAlchemy/SQLite layer (app.database.Database).
Every integration test mocks `db`, so the actual persistence layer was barely
exercised. These run a real SQLite database against a temp file, so CRUD,
master-resume assignment, the jobs ``metadata_json`` round-trip, applications,
and stats are verified end-to-end on the storage.
"""
import pytest
from app.database import Database
from app.db_engine import init_models_sync, make_sync_engine
@pytest.fixture
async def db(tmp_path):
database = Database(db_path=tmp_path / "test_db.db")
yield database
await database.close()
class TestResumeCrud:
async def test_create_and_get(self, db):
created = await db.create_resume(content="# Resume", filename="r.pdf")
assert created["resume_id"]
fetched = await db.get_resume(created["resume_id"])
assert fetched is not None
assert fetched["content"] == "# Resume"
assert fetched["filename"] == "r.pdf"
async def test_get_missing_returns_none(self, db):
assert await db.get_resume("does-not-exist") is None
async def test_list_resumes(self, db):
await db.create_resume(content="a")
await db.create_resume(content="b")
assert len(await db.list_resumes()) == 2
async def test_update_resume_changes_field_and_timestamp(self, db):
created = await db.create_resume(content="x")
updated = await db.update_resume(created["resume_id"], {"title": "New Title"})
assert updated["title"] == "New Title"
assert updated["updated_at"] >= created["updated_at"]
async def test_update_missing_raises(self, db):
with pytest.raises(ValueError):
await db.update_resume("missing", {"title": "X"})
async def test_delete_resume(self, db):
created = await db.create_resume(content="x")
assert await db.delete_resume(created["resume_id"]) is True
assert await db.get_resume(created["resume_id"]) is None
async def test_delete_missing_returns_false(self, db):
assert await db.delete_resume("missing") is False
async def test_original_markdown_absence_semantics(self, db):
# Omitted when None (preserve TinyDB behavior); present when supplied.
without = await db.create_resume(content="x")
assert "original_markdown" not in without
with_md = await db.create_resume(content="x", original_markdown="# raw")
fetched = await db.get_resume(with_md["resume_id"])
assert fetched["original_markdown"] == "# raw"
async def test_interview_prep_round_trips_as_text(self, db):
created = await db.create_resume(
content="x",
interview_prep='{"role_fit_analysis":["fit"]}',
)
fetched = await db.get_resume(created["resume_id"])
assert fetched["interview_prep"] == '{"role_fit_analysis":["fit"]}'
def test_interview_prep_migration_is_idempotent(self, tmp_path):
engine = make_sync_engine(tmp_path / "old.db")
try:
with engine.begin() as conn:
conn.exec_driver_sql(
"""
CREATE TABLE resumes (
resume_id TEXT PRIMARY KEY,
content TEXT NOT NULL,
content_type TEXT DEFAULT 'md'
)
"""
)
init_models_sync(engine)
init_models_sync(engine)
with engine.begin() as conn:
columns = conn.exec_driver_sql("PRAGMA table_info(resumes)").mappings().all()
names = [column["name"] for column in columns]
assert names.count("interview_prep") == 1
finally:
engine.dispose()
class TestMasterResume:
async def test_no_master_initially(self, db):
assert await db.get_master_resume() is None
async def test_set_master_unsets_previous(self, db):
r1 = await db.create_resume(content="1")
r2 = await db.create_resume(content="2")
assert await db.set_master_resume(r1["resume_id"]) is True
assert (await db.get_master_resume())["resume_id"] == r1["resume_id"]
assert await db.set_master_resume(r2["resume_id"]) is True
master = await db.get_master_resume()
assert master["resume_id"] == r2["resume_id"]
# Only one master at a time.
assert sum(1 for r in await db.list_resumes() if r["is_master"]) == 1
async def test_set_master_missing_returns_false(self, db):
assert await db.set_master_resume("missing") is False
async def test_atomic_first_upload_becomes_master(self, db):
created = await db.create_resume_atomic_master(content="first", processing_status="ready")
assert created["is_master"] is True
async def test_atomic_second_upload_not_master(self, db):
await db.create_resume_atomic_master(content="first", processing_status="ready")
second = await db.create_resume_atomic_master(content="second", processing_status="ready")
assert second["is_master"] is False
async def test_atomic_recovers_when_master_stuck(self, db):
# Master stuck in "failed" → next upload is promoted to master.
first = await db.create_resume_atomic_master(content="first", processing_status="failed")
assert first["is_master"] is True
second = await db.create_resume_atomic_master(content="second", processing_status="ready")
assert second["is_master"] is True
assert (await db.get_master_resume())["resume_id"] == second["resume_id"]
class TestJobs:
async def test_create_and_get_job(self, db):
created = await db.create_job(content="Engineer role", resume_id="r1")
fetched = await db.get_job(created["job_id"])
assert fetched["content"] == "Engineer role"
assert fetched["resume_id"] == "r1"
async def test_get_missing_job_returns_none(self, db):
assert await db.get_job("missing") is None
async def test_update_job(self, db):
created = await db.create_job(content="old")
updated = await db.update_job(created["job_id"], {"content": "new"})
assert updated["content"] == "new"
async def test_update_missing_job_returns_none(self, db):
assert await db.update_job("missing", {"content": "x"}) is None
async def test_dynamic_fields_round_trip_as_top_level(self, db):
"""Dynamic pipeline fields must survive write→read as top-level keys.
This is the highest-risk migration detail: ``/improve/confirm`` rejects
with 400 if ``preview_hash``/``preview_hashes`` don't round-trip.
"""
created = await db.create_job(content="jd")
await db.update_job(
created["job_id"],
{
"job_keywords": {"required_skills": ["Python", "AWS"]},
"job_keywords_hash": "deadbeef",
"preview_hash": "abc123",
"preview_hashes": {"keywords": "abc123", "nudge": "def456"},
"preview_prompt_id": "keywords",
"company": "Acme Corp",
"role": "Staff Engineer",
},
)
fetched = await db.get_job(created["job_id"])
# Core fields preserved.
assert fetched["content"] == "jd"
# Dynamic fields flattened to the top level.
assert fetched["preview_hash"] == "abc123"
assert fetched["preview_hashes"] == {"keywords": "abc123", "nudge": "def456"}
assert fetched["job_keywords_hash"] == "deadbeef"
assert fetched["job_keywords"]["required_skills"] == ["Python", "AWS"]
assert fetched["company"] == "Acme Corp"
assert fetched["role"] == "Staff Engineer"
async def test_update_job_merges_metadata(self, db):
created = await db.create_job(content="jd")
await db.update_job(created["job_id"], {"preview_hash": "h1"})
await db.update_job(created["job_id"], {"company": "Acme"})
fetched = await db.get_job(created["job_id"])
# The second update must not wipe the first dynamic field.
assert fetched["preview_hash"] == "h1"
assert fetched["company"] == "Acme"
class TestImprovements:
async def test_create_and_lookup_by_tailored_resume(self, db):
await db.create_improvement(
original_resume_id="orig",
tailored_resume_id="tailored-1",
job_id="job-1",
improvements=[{"path": "summary"}],
)
found = await db.get_improvement_by_tailored_resume("tailored-1")
assert found is not None
assert found["job_id"] == "job-1"
async def test_lookup_missing_returns_none(self, db):
assert await db.get_improvement_by_tailored_resume("nope") is None
class TestApplications:
async def test_create_defaults_and_position(self, db):
a = await db.create_application(job_id="j1", resume_id="r1")
assert a["status"] == "applied"
assert a["position"] == 0
assert a["applied_at"] is not None # applied → stamped
b = await db.create_application(job_id="j2", resume_id="r2")
assert b["position"] == 1 # appended to the column
async def test_saved_status_has_no_applied_at(self, db):
a = await db.create_application(job_id="j1", resume_id="r1", status="saved")
assert a["applied_at"] is None
async def test_create_dedupes_on_job_and_resume(self, db):
a = await db.create_application(job_id="j1", resume_id="r1")
again = await db.create_application(job_id="j1", resume_id="r1")
assert again["application_id"] == a["application_id"]
assert len(await db.list_applications()) == 1
async def test_move_renumbers_columns(self, db):
a = await db.create_application(job_id="j1", resume_id="r1")
b = await db.create_application(job_id="j2", resume_id="r2")
# Move a to the front of "interview".
moved = await db.update_application(a["application_id"], {"status": "interview", "position": 0})
assert moved["status"] == "interview"
assert moved["position"] == 0
# The "applied" column renumbered: b is now position 0.
applied = await db.list_applications(status="applied")
assert [x["application_id"] for x in applied] == [b["application_id"]]
assert applied[0]["position"] == 0
async def test_bulk_update_and_delete(self, db):
a = await db.create_application(job_id="j1", resume_id="r1")
b = await db.create_application(job_id="j2", resume_id="r2")
moved = await db.bulk_update_applications([a["application_id"], b["application_id"]], "rejected")
assert moved == 2
rejected = await db.list_applications(status="rejected")
assert {x["position"] for x in rejected} == {0, 1}
deleted = await db.bulk_delete_applications([a["application_id"]])
assert deleted == 1
remaining = await db.list_applications(status="rejected")
assert len(remaining) == 1
assert remaining[0]["position"] == 0 # renumbered after delete
class TestApiKeyStore:
async def test_set_get_delete_ciphertext(self, db):
db.set_api_key_ciphertext("openai", "ct-openai")
db.set_api_key_ciphertext("anthropic", "ct-anthropic")
assert db.get_api_key_ciphertexts() == {"openai": "ct-openai", "anthropic": "ct-anthropic"}
db.delete_api_key("openai")
assert db.get_api_key_ciphertexts() == {"anthropic": "ct-anthropic"}
db.clear_api_keys()
assert db.get_api_key_ciphertexts() == {}
class TestStatsAndReset:
async def test_get_stats(self, db):
await db.create_resume(content="a")
await db.set_master_resume((await db.list_resumes())[0]["resume_id"])
await db.create_job(content="jd")
stats = await db.get_stats()
assert stats["total_resumes"] == 1
assert stats["total_jobs"] == 1
assert stats["has_master_resume"] is True
async def test_reset_database_truncates(self, db, tmp_path, monkeypatch):
# reset_database also clears settings.data_dir/uploads — isolate it to tmp.
monkeypatch.setattr("app.database.settings.data_dir", tmp_path)
await db.create_resume(content="a")
await db.create_job(content="jd")
await db.create_application(job_id="j1", resume_id="r1")
await db.reset_database()
stats = await db.get_stats()
assert stats["total_resumes"] == 0
assert stats["total_jobs"] == 0
assert stats["has_master_resume"] is False
# Applications are cleared too (no orphans after a full reset).
assert await db.list_applications() == []