import threading import pytest from fastapi.testclient import TestClient import backend.jobs as jobs from backend.main import app class _SyncThread: """Drop-in threading.Thread replacement that runs the target inline.""" def __init__(self, target=None, args=(), kwargs=None, **_ignored): self._target = target self._args = args self._kwargs = kwargs or {} def start(self): self._target(*self._args, **self._kwargs) @pytest.fixture def job_env(monkeypatch, tmp_path): monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path)) monkeypatch.setattr(threading, "Thread", _SyncThread) monkeypatch.setattr("backend.jobs.send_conflict_report", lambda *a, **k: True) pdf = tmp_path / "set.pdf" pdf.write_bytes(b"%PDF-1.4\n") yield tmp_path jobs._jobs.clear() def test_job_log_captures_pipeline_output(job_env, monkeypatch): def fake_runner(pdf_path, **kwargs): print("STAGE banner: fake wave ran") return {"source": "set.pdf", "summary": {"conflicts_found": 0}} monkeypatch.setattr("backend.jobs.run_pipeline", fake_runner) job_id = jobs.create_job(str(job_env / "set.pdf"), "set.pdf", pipeline_mode="classic") log_path = job_env / job_id / "job.log" assert log_path.is_file() content = log_path.read_text() assert "STAGE banner: fake wave ran" in content assert job_id in content # header line def test_job_log_endpoint_serves_log_and_404s(job_env, monkeypatch): monkeypatch.setattr( "backend.jobs.run_pipeline", lambda pdf_path, **kw: {"source": "s", "summary": {}}, ) job_id = jobs.create_job(str(job_env / "set.pdf"), "set.pdf", pipeline_mode="classic") client = TestClient(app) ok = client.get(f"/jobs/{job_id}/log") assert ok.status_code == 200 assert ok.headers["content-type"].startswith("text/plain") assert "Job " + job_id in ok.text assert client.get("/jobs/nope/log").status_code == 404 def test_model_overrides_passed_to_classic_runner(job_env, monkeypatch): """Classic mode: per-run picks travel as run_pipeline kwargs (the runner sets and clears llm.set_model_overrides itself).""" seen = {} def fake_runner(pdf_path, **kwargs): seen.update(kwargs) return {"source": "set.pdf", "summary": {}} monkeypatch.setattr("backend.jobs.run_pipeline", fake_runner) jobs.create_job(str(job_env / "set.pdf"), "set.pdf", pipeline_mode="classic", vision_model="openai/gpt-4o", text_model="openai/gpt-4o-mini") assert seen["vision_model"] == "openai/gpt-4o" assert seen["text_model"] == "openai/gpt-4o-mini" def test_model_overrides_set_and_cleared_around_agent_run(job_env, monkeypatch): """Agent mode: the agent runner has no override params, so jobs.py sets them module-level for the duration of the run.""" from backend import llm seen = {} def fake_agent_runner(pdf_path, **kwargs): seen["vision"] = llm._vision_model_override seen["text"] = llm._text_model_override return {"source": "set.pdf", "summary": {}} monkeypatch.setattr("backend.jobs.run_agent_pipeline", fake_agent_runner) jobs.create_job(str(job_env / "set.pdf"), "set.pdf", pipeline_mode="agent", vision_model="openai/gpt-4o", text_model="openai/gpt-4o-mini") assert seen["vision"] == "openai/gpt-4o" assert seen["text"] == "openai/gpt-4o-mini" assert llm._vision_model_override is None # cleared after the run assert llm._text_model_override is None def test_failed_run_logs_traceback(job_env, monkeypatch): """A crashed job must leave the traceback in job.log, not just str(e).""" def boom(pdf_path, **kwargs): raise RuntimeError("kaboom-stage-failure") monkeypatch.setattr("backend.jobs.run_pipeline", boom) job_id = jobs.create_job(str(job_env / "set.pdf"), "set.pdf", pipeline_mode="classic") assert jobs._jobs[job_id]["status"] == "error" content = (job_env / job_id / "job.log").read_text() assert "Traceback (most recent call last)" in content assert "RuntimeError: kaboom-stage-failure" in content