Files
JobSourceAgent/tests/test_db.py
2026-06-18 00:02:58 -04:00

551 lines
21 KiB
Python

"""Tests for jobsource/db.py and jobsource/pipeline.py.
All tests are offline — no network calls, no real job source.
The DB uses tmp_path (file-backed) or ":memory:" via path override.
"""
from __future__ import annotations
import csv
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from jobsource.db import Database, _company_key
from jobsource.models import CSV_COLUMNS, JobResult, JobStatus, RawJob
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _db(tmp_path: Path) -> Database:
"""Open a fresh file-backed Database in tmp_path."""
return Database(path=tmp_path / "test.db")
def _result(
job_id: str = "1",
company_name: str = "Acme",
website: str | None = "https://acme.com",
career_url: str | None = "https://acme.com/careers",
position_url: str | None = "https://acme.com/careers/jobs/42",
status: JobStatus = JobStatus.position_found,
company_key: str | None = None,
) -> JobResult:
return JobResult(
job_id=job_id,
company_name=company_name,
company_key=company_key or _company_key(company_name, website),
website=website,
career_page_url=career_url,
open_position_url=position_url,
status=status,
)
# ---------------------------------------------------------------------------
# _company_key (pure function)
# ---------------------------------------------------------------------------
class TestCompanyKey:
def test_domain_stripped_of_www(self) -> None:
assert _company_key("Acme", "https://www.acme.com/about") == "acme.com"
def test_domain_lowercase(self) -> None:
assert _company_key("Foo", "https://Foo.IO") == "foo.io"
def test_no_website_uses_name(self) -> None:
assert _company_key("Globex Corp", None) == "globex corp"
def test_no_website_lowercased(self) -> None:
assert _company_key("Initech LLC", None) == "initech llc"
def test_empty_host_falls_back_to_name(self) -> None:
# Malformed URL with empty hostname
result = _company_key("BadCo", "://no-host")
assert result == "badco"
def test_empty_name_and_no_website_returns_unknown(self) -> None:
assert _company_key("", None) == "unknown"
def test_subpath_ignored(self) -> None:
assert _company_key("X", "https://x.example.com/jobs/42") == "x.example.com"
# ---------------------------------------------------------------------------
# Database.init_schema / open
# ---------------------------------------------------------------------------
class TestDatabaseSchema:
def test_creates_tables(self, tmp_path: Path) -> None:
db = _db(tmp_path)
try:
cur = db._conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
)
tables = {r[0] for r in cur.fetchall()}
assert "companies" in tables
assert "jobs" in tables
finally:
db.close()
def test_idempotent_schema(self, tmp_path: Path) -> None:
"""Opening the same DB twice must not raise."""
db1 = Database(path=tmp_path / "idempotent.db")
db1.close()
db2 = Database(path=tmp_path / "idempotent.db")
db2.close()
def test_parent_dirs_created(self, tmp_path: Path) -> None:
nested = tmp_path / "a" / "b" / "c" / "test.db"
db = Database(path=nested)
try:
assert nested.exists()
finally:
db.close()
def test_context_manager(self, tmp_path: Path) -> None:
with Database(path=tmp_path / "cm.db") as db:
assert not db.is_seen("x")
# ---------------------------------------------------------------------------
# is_seen / dedup
# ---------------------------------------------------------------------------
class TestIsSeeen:
def test_unseen_job_returns_false(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
assert not db.is_seen("99999")
def test_seen_after_persist(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
r = _result(job_id="42")
db.persist_result(r)
assert db.is_seen("42")
def test_different_job_ids_independent(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
db.persist_result(_result(job_id="1"))
assert db.is_seen("1")
assert not db.is_seen("2")
# ---------------------------------------------------------------------------
# company_key_for
# ---------------------------------------------------------------------------
class TestCompanyKeyFor:
def test_delegates_to_pure_fn(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
assert db.company_key_for("Acme", "https://acme.com") == "acme.com"
assert db.company_key_for("Foo LLC", None) == "foo llc"
# ---------------------------------------------------------------------------
# get_cached_career_url
# ---------------------------------------------------------------------------
class TestCachedCareerUrl:
def test_miss_returns_none(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
assert db.get_cached_career_url("acme.com") is None
def test_hit_after_persist(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
r = _result(job_id="1", website="https://acme.com", career_url="https://acme.com/careers")
db.persist_result(r)
assert db.get_cached_career_url("acme.com") == "https://acme.com/careers"
def test_null_career_url_not_cached(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
r = _result(job_id="1", career_url=None, status=JobStatus.needs_review)
db.persist_result(r)
assert db.get_cached_career_url("acme.com") is None
# ---------------------------------------------------------------------------
# upsert_company COALESCE / first_seen preservation
# ---------------------------------------------------------------------------
class TestUpsertCompany:
def test_insert_then_null_fill(self, tmp_path: Path) -> None:
"""A NULL value in the second upsert fills the existing NULL without error."""
with _db(tmp_path) as db:
db.upsert_company("x.com", "X", None, None)
db.upsert_company("x.com", "X", "https://x.com", "https://x.com/careers")
row = db._conn.execute(
"SELECT website, career_url FROM companies WHERE company_key='x.com'"
).fetchone()
assert row["website"] == "https://x.com"
assert row["career_url"] == "https://x.com/careers"
def test_existing_value_not_clobbered(self, tmp_path: Path) -> None:
"""A second upsert with NULL must not overwrite an existing value."""
with _db(tmp_path) as db:
db.upsert_company("y.com", "Y", "https://y.com", "https://y.com/jobs")
db.upsert_company("y.com", "Y", None, None)
row = db._conn.execute(
"SELECT website, career_url FROM companies WHERE company_key='y.com'"
).fetchone()
assert row["website"] == "https://y.com"
assert row["career_url"] == "https://y.com/jobs"
def test_first_seen_preserved(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
db.upsert_company("z.com", "Z", None, None)
row1 = db._conn.execute(
"SELECT first_seen FROM companies WHERE company_key='z.com'"
).fetchone()
db.upsert_company("z.com", "Z", "https://z.com", "https://z.com/careers")
row2 = db._conn.execute(
"SELECT first_seen FROM companies WHERE company_key='z.com'"
).fetchone()
assert row1["first_seen"] == row2["first_seen"]
# ---------------------------------------------------------------------------
# persist_result / upsert_job
# ---------------------------------------------------------------------------
class TestPersistResult:
def test_both_tables_populated(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
r = _result(job_id="10", company_name="Initech", website="https://initech.com")
db.persist_result(r)
job = db._conn.execute(
"SELECT * FROM jobs WHERE job_id='10'"
).fetchone()
assert job is not None
assert job["status"] == "position_found"
company = db._conn.execute(
"SELECT * FROM companies WHERE company_key='initech.com'"
).fetchone()
assert company is not None
assert company["name"] == "Initech"
def test_listed_at_null_stored_as_null(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
r = _result(job_id="20")
assert r.listed_at is None
db.persist_result(r)
row = db._conn.execute(
"SELECT listed_at FROM jobs WHERE job_id='20'"
).fetchone()
assert row["listed_at"] is None
def test_listed_at_serialized(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
r = _result(job_id="30")
r.listed_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
db.persist_result(r)
row = db._conn.execute(
"SELECT listed_at FROM jobs WHERE job_id='30'"
).fetchone()
assert row["listed_at"] is not None
assert "2026" in row["listed_at"]
def test_upsert_fills_null_position(self, tmp_path: Path) -> None:
"""A second persist with position_url fills a prior NULL."""
with _db(tmp_path) as db:
r1 = _result(job_id="50", position_url=None, status=JobStatus.needs_review)
db.persist_result(r1)
r2 = _result(job_id="50", position_url="https://acme.com/jobs/1", status=JobStatus.position_found)
db.persist_result(r2)
row = db._conn.execute(
"SELECT position_url, status FROM jobs WHERE job_id='50'"
).fetchone()
assert row["position_url"] == "https://acme.com/jobs/1"
assert row["status"] == "position_found"
def test_job_first_seen_preserved(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
r = _result(job_id="60", position_url=None, status=JobStatus.needs_review)
db.persist_result(r)
row1 = db._conn.execute(
"SELECT first_seen FROM jobs WHERE job_id='60'"
).fetchone()
r2 = _result(job_id="60", position_url="https://acme.com/jobs/1", status=JobStatus.position_found)
db.persist_result(r2)
row2 = db._conn.execute(
"SELECT first_seen FROM jobs WHERE job_id='60'"
).fetchone()
assert row1["first_seen"] == row2["first_seen"]
# ---------------------------------------------------------------------------
# counts
# ---------------------------------------------------------------------------
class TestCounts:
def test_empty_db(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
assert db.counts() == {}
def test_counts_grouped_by_status(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
db.persist_result(_result(job_id="1", status=JobStatus.position_found))
db.persist_result(_result(job_id="2", position_url=None, status=JobStatus.needs_review))
db.persist_result(_result(job_id="3", position_url=None, status=JobStatus.needs_review))
c = db.counts()
assert c["position_found"] == 1
assert c["needs_review"] == 2
# ---------------------------------------------------------------------------
# export_csv
# ---------------------------------------------------------------------------
class TestExportCsv:
def test_header_matches_csv_columns(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
db.persist_result(_result(job_id="1"))
out = tmp_path / "out.csv"
db.export_csv(path=out)
with out.open() as f:
reader = csv.DictReader(f)
assert tuple(reader.fieldnames or []) == CSV_COLUMNS
def test_null_fields_become_empty_string(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
r = _result(job_id="1", career_url=None, position_url=None, status=JobStatus.needs_review)
db.persist_result(r)
out = tmp_path / "out.csv"
db.export_csv(path=out)
with out.open() as f:
rows = list(csv.DictReader(f))
assert rows[0]["career_page_url"] == ""
assert rows[0]["open_position_url"] == ""
def test_complete_rows_sorted_first(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
# Insert incomplete row first, complete row second
db.persist_result(_result(
job_id="1", company_name="ZZZ Corp",
position_url=None, status=JobStatus.needs_review,
))
db.persist_result(_result(
job_id="2", company_name="AAA Inc",
position_url="https://aaa.com/jobs/1", status=JobStatus.position_found,
))
out = tmp_path / "out.csv"
db.export_csv(path=out)
with out.open() as f:
rows = list(csv.DictReader(f))
assert len(rows) == 2
# complete row (AAA Inc) must come first
assert rows[0]["open_position_url"] != ""
assert rows[1]["open_position_url"] == ""
def test_creates_parent_dirs(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
db.persist_result(_result())
nested = tmp_path / "deep" / "dir" / "results.csv"
db.export_csv(path=nested)
assert nested.exists()
def test_returns_path(self, tmp_path: Path) -> None:
with _db(tmp_path) as db:
db.persist_result(_result())
out = tmp_path / "r.csv"
returned = db.export_csv(path=out)
assert returned == out
# ---------------------------------------------------------------------------
# pipeline.run_batch — offline unit tests via monkeypatch
# ---------------------------------------------------------------------------
def _make_raw(job_id: str, company: str = "Acme") -> RawJob:
return RawJob(
job_id=job_id,
company=company,
linkedin_url=f"https://www.linkedin.com/jobs/view/{job_id}",
website=None,
)
class TestRunBatch:
"""Monkeypatched run_batch tests — no network, no real providers."""
def _patch_all(self, monkeypatch, raw_jobs: list[RawJob]) -> None:
"""Patch all external I/O in pipeline so tests run offline."""
from jobsource.careers.cascade import CareersResult
# Fake job source
fake_source = MagicMock()
fake_source.fetch_recent_jobs.return_value = raw_jobs
monkeypatch.setattr("jobsource.pipeline._make_source", lambda _: fake_source)
# Fake resolve: always succeeds
monkeypatch.setattr(
"jobsource.pipeline.resolve_website",
lambda name, website=None, *, client=None: f"https://{name.lower().replace(' ', '')}.com",
)
# Fake cascade: returns a careers URL + free position shortcut
def _fake_careers(website, *, company_name=None, client=None):
return CareersResult(
careers_url=f"{website}/careers",
confidence=0.95,
method="ats:greenhouse",
position_url=f"{website}/careers/jobs/1",
)
monkeypatch.setattr("jobsource.pipeline.find_careers_page", _fake_careers)
# extract_open_position shouldn't be called (shortcut covers it),
# but patch defensively to detect if it is.
monkeypatch.setattr(
"jobsource.pipeline.extract_open_position",
lambda url, *, client=None: (None, "none"),
)
# build_client — return a context-manager stub
cm = MagicMock()
cm.__enter__ = lambda s: MagicMock()
cm.__exit__ = MagicMock(return_value=False)
monkeypatch.setattr("jobsource.pipeline.build_client", lambda: cm)
def test_new_jobs_processed(self, tmp_path: Path, monkeypatch) -> None:
from jobsource.pipeline import run_batch
# Use distinct company names so each job gets its own company_key (no cache sharing).
raw = [_make_raw("1", company="Alpha"), _make_raw("2", company="Beta")]
self._patch_all(monkeypatch, raw)
db = Database(path=tmp_path / "t.db")
summary = run_batch(batch_size=5, search_terms=["eng"], db=db)
assert summary.fetched == 2
assert summary.new == 2
assert summary.already_seen == 0
assert summary.website_resolved == 2
assert summary.careers_found == 2
assert summary.position_found == 2
assert summary.failed == 0
assert summary.coverage == 1.0
def test_cross_run_dedup(self, tmp_path: Path, monkeypatch) -> None:
"""Re-running with the same DB yields new == 0 (dedup proven)."""
from jobsource.pipeline import run_batch
raw = [_make_raw("10", company="Gamma"), _make_raw("11", company="Delta")]
self._patch_all(monkeypatch, raw)
db = Database(path=tmp_path / "dedup.db")
# First run
s1 = run_batch(batch_size=5, search_terms=["eng"], db=db)
assert s1.new == 2
# Second run — same raw jobs returned by source, but DB already has them
s2 = run_batch(batch_size=5, search_terms=["eng"], db=db)
assert s2.new == 0
assert s2.already_seen == 2
def test_batch_size_cap(self, tmp_path: Path, monkeypatch) -> None:
"""Only batch_size new jobs are processed even if more are available."""
from jobsource.pipeline import run_batch
# Distinct companies to avoid career-URL cache sharing across jobs.
raw = [_make_raw(str(i), company=f"Co{i}") for i in range(10)]
self._patch_all(monkeypatch, raw)
db = Database(path=tmp_path / "cap.db")
summary = run_batch(batch_size=3, search_terms=["eng"], db=db)
assert summary.new == 3
assert summary.position_found == 3
def test_resolve_failure_marks_needs_review(self, tmp_path: Path, monkeypatch) -> None:
"""Jobs where resolve_website returns None get needs_review, not failed."""
from jobsource.pipeline import run_batch
raw = [_make_raw("99")]
self._patch_all(monkeypatch, raw)
# Override resolve to return None (unresolvable)
monkeypatch.setattr(
"jobsource.pipeline.resolve_website",
lambda *a, **kw: None,
)
db = Database(path=tmp_path / "nr.db")
summary = run_batch(batch_size=5, search_terms=["eng"], db=db)
assert summary.needs_review == 1
assert summary.failed == 0
assert summary.website_resolved == 0
def test_csv_written_after_batch(self, tmp_path: Path, monkeypatch) -> None:
"""Data is persisted by run_batch and export_csv produces the right columns."""
from jobsource.pipeline import run_batch
raw = [_make_raw("7", company="SevenCo")]
self._patch_all(monkeypatch, raw)
csv_path = tmp_path / "out.csv"
# Pass db explicitly so own_db=False → database is NOT closed by run_batch.
db = Database(path=tmp_path / "t.db")
run_batch(batch_size=5, search_terms=["eng"], db=db)
# Export to a controlled path and verify the header/contract.
db.export_csv(path=csv_path)
assert csv_path.exists()
with csv_path.open() as f:
reader = csv.DictReader(f)
assert tuple(reader.fieldnames or []) == CSV_COLUMNS
def test_one_failure_does_not_abort_batch(self, tmp_path: Path, monkeypatch) -> None:
"""A cascade exception for one company doesn't abort others.
Cascade errors land in needs_review (not failed — they are recoverable/transient).
Each job has a distinct company so cache cannot mask the flaky call.
"""
from jobsource.pipeline import run_batch
from jobsource.careers.cascade import CareersResult
# Distinct companies → distinct company_keys → no cache sharing.
raw = [
_make_raw("A", company="AlphaCo"),
_make_raw("B", company="BetaCo"),
_make_raw("C", company="GammaCo"),
]
self._patch_all(monkeypatch, raw)
call_count = {"n": 0}
def _flaky_careers(website, *, company_name=None, client=None):
call_count["n"] += 1
if call_count["n"] == 2:
raise RuntimeError("simulated cascade crash")
return CareersResult(
careers_url=f"{website}/careers",
confidence=0.9,
method="url_pattern",
position_url=f"{website}/careers/jobs/1",
)
monkeypatch.setattr("jobsource.pipeline.find_careers_page", _flaky_careers)
db = Database(path=tmp_path / "fault.db")
summary = run_batch(batch_size=5, search_terms=["eng"], db=db)
# All 3 attempted; cascade exception → needs_review (not failed); 2 fully resolved.
assert summary.new == 3
assert summary.needs_review == 1
assert summary.failed == 0
assert summary.position_found == 2