"""Job API contract: typed commands, status codes, error envelope, idempotency, cancellation, blockers, and OpenAPI schema.""" import pytest from fastapi.testclient import TestClient from photo_pipeline.api.app import create_app from photo_pipeline.config import Config from photo_pipeline.db import run_migrations from photo_pipeline.services.jobs import ItemState, JobService, JobState @pytest.fixture def client(tmp_path): (tmp_path / "data").mkdir() config = Config.from_env({"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data")}) run_migrations(config.database_url) app = create_app(config) with TestClient(app) as test_client: test_client.app = app yield test_client def _drive_to_success(app, job_id, items): service = JobService(app.state.session_factory) service.claim(["scan"], "w1") for item in items: service.set_item(job_id, item, ItemState.RUNNING) service.set_item(job_id, item, ItemState.SUCCEEDED) service.transition(job_id, JobState.SUCCEEDED, worker_id="w1") def test_start_status_and_progress(client): start = client.post("/api/v1/jobs", json={"job_type": "scan", "items": ["a", "b"]}) assert start.status_code == 200 job = start.json() assert job["state"] == "queued" status = client.get(f"/api/v1/jobs/{job['id']}") assert status.status_code == 200 body = status.json() assert body["progress"] == {"total": 2, "done": 0, "by_state": {"queued": 2}} def test_idempotent_start_returns_same_job(client): first = client.post("/api/v1/jobs", json={"job_type": "scan", "idempotency_key": "k"}).json() second = client.post("/api/v1/jobs", json={"job_type": "scan", "idempotency_key": "k"}).json() assert first["id"] == second["id"] def test_lock_conflict_returns_409(client): client.post("/api/v1/jobs", json={"job_type": "scan", "lock": "library_write"}) conflict = client.post("/api/v1/jobs", json={"job_type": "rename", "lock": "library_write"}) assert conflict.status_code == 409 assert conflict.json()["error"]["code"] == "lock_held" def test_cancel_queued_then_terminal(client): job = client.post("/api/v1/jobs", json={"job_type": "scan"}).json() cancelled = client.post(f"/api/v1/jobs/{job['id']}/cancel") assert cancelled.status_code == 200 assert cancelled.json()["state"] == "cancelled" # Cancelling a terminal job conflicts. again = client.post(f"/api/v1/jobs/{job['id']}/cancel") assert again.status_code == 409 def test_blockers_endpoint(client): client.post("/api/v1/jobs", json={"job_type": "scan", "lock": "library_write"}) job = client.post("/api/v1/jobs", json={"job_type": "scan"}).json() blockers = client.get(f"/api/v1/jobs/{job['id']}/blockers?lock=library_write").json() assert len(blockers["blockers"]) == 1 def test_unknown_job_uses_error_envelope(client): for resp in ( client.get("/api/v1/jobs/ghost"), client.post("/api/v1/jobs/ghost/cancel"), client.get("/api/v1/jobs/ghost/events"), ): assert resp.status_code == 404 assert resp.json()["error"]["code"] == "not_found" def test_polling_events_with_cursor(client): job = client.post("/api/v1/jobs", json={"job_type": "scan", "items": ["a"]}).json() _drive_to_success(client.app, job["id"], ["a"]) all_events = client.get(f"/api/v1/jobs/{job['id']}/events").json()["events"] seqs = [e["seq"] for e in all_events] assert seqs == sorted(seqs) and len(seqs) >= 3 after_first = client.get(f"/api/v1/jobs/{job['id']}/events?after={seqs[0]}").json()["events"] assert [e["seq"] for e in after_first] == seqs[1:] def test_openapi_exposes_job_contracts(client): paths = client.get("/openapi.json").json()["paths"] assert "/api/v1/jobs" in paths assert "post" in paths["/api/v1/jobs"] assert "/api/v1/jobs/{job_id}" in paths assert "/api/v1/jobs/{job_id}/cancel" in paths assert "/api/v1/jobs/{job_id}/events/stream" in paths def test_status_content_type_is_json(client): job = client.post("/api/v1/jobs", json={"job_type": "scan"}).json() assert client.get(f"/api/v1/jobs/{job['id']}").headers["content-type"].startswith( "application/json" )