109 lines
4.1 KiB
Python
109 lines
4.1 KiB
Python
"""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"
|
|
)
|