"""Review API contract: inventory paging/filtering, cluster list/detail, and the decision endpoint's success and error (404/409/422) behavior. Fixtures inlined to avoid shadowing the characterization suite's ``conftest``. """ import shutil from types import SimpleNamespace import numpy as np import pytest from fastapi.testclient import TestClient from PIL import Image 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.duplicates import DuplicateService, Method from photo_pipeline.services.inventory import InventoryService def structured(path, seed, size=(256, 192)): path.parent.mkdir(parents=True, exist_ok=True) rng = np.random.default_rng(seed) w, h = size base = np.zeros((h, w, 3), dtype=np.uint8) for _ in range(6): x0, y0 = int(rng.integers(0, w - 60)), int(rng.integers(0, h - 60)) base[y0 : y0 + 60, x0 : x0 + 60] = rng.integers(0, 256, 3) grad = np.linspace(0, 120, w, dtype=np.uint8) base[:, :, 0] = np.clip(base[:, :, 0].astype(int) + grad[None, :], 0, 255) Image.fromarray(base).save(path, quality=95) return path def resized_copy(src, dst, scale=0.5): with Image.open(src) as image: image.resize( (int(image.width * scale), int(image.height * scale)), Image.LANCZOS ).save(dst, quality=95) return dst @pytest.fixture def seeded(tmp_path): data = tmp_path / "data" data.mkdir() lib = tmp_path / "lib" lib.mkdir() # exact pair + a perceptual pair a = structured(lib / "a.jpg", 1) shutil.copy2(a, lib / "a_copy.jpg") b = structured(lib / "b.jpg", 2) resized_copy(b, lib / "b_small.jpg") config = Config.from_env( {"PHOTO_PIPELINE_DATA_DIR": str(data), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib)} ) run_migrations(config.database_url) app = create_app(config) client = TestClient(app) client.__enter__() InventoryService(app.state.session_factory).scan(lib) DuplicateService(app.state.session_factory).detect() yield SimpleNamespace(client=client, app=app) client.__exit__(None, None, None) def test_inventory_lists_and_pages(seeded): client = seeded.client full = client.get("/api/v1/inventory/assets").json() assert full["total"] == 4 page = client.get("/api/v1/inventory/assets?limit=2&offset=0").json() assert len(page["items"]) == 2 assert page["limit"] == 2 page2 = client.get("/api/v1/inventory/assets?limit=2&offset=2").json() assert page2["items"][0]["id"] != page["items"][0]["id"] def test_inventory_filters_by_path(seeded): result = seeded.client.get("/api/v1/inventory/assets?q=a_copy").json() assert result["total"] == 1 assert "a_copy" in result["items"][0]["current_path"] def test_clusters_list_and_detail(seeded): clusters = seeded.client.get("/api/v1/duplicates/clusters").json() methods = {c["method"] for c in clusters["items"]} assert {Method.EXACT.value, Method.PERCEPTUAL.value} <= methods perceptual = next(c for c in clusters["items"] if c["method"] == Method.PERCEPTUAL.value) detail = seeded.client.get(f"/api/v1/duplicates/clusters/{perceptual['id']}").json() assert detail["requires_confirmation"] is True assert len(detail["members"]) == 2 assert "evidence" in detail["members"][0] def test_decision_success_and_persists(seeded): clusters = seeded.client.get("/api/v1/duplicates/clusters?state=open").json() cluster = clusters["items"][0] resp = seeded.client.post( f"/api/v1/duplicates/clusters/{cluster['id']}/decision", json={"decision": "deferred", "expected_version": cluster["version"]}, ) assert resp.status_code == 200 assert resp.json()["state"] == "deferred" again = seeded.client.get(f"/api/v1/duplicates/clusters/{cluster['id']}").json() assert again["state"] == "deferred" def test_decision_stale_version_conflicts_without_mutation(seeded): clusters = seeded.client.get("/api/v1/duplicates/clusters?state=open").json() cluster = clusters["items"][0] stale = seeded.client.post( f"/api/v1/duplicates/clusters/{cluster['id']}/decision", json={"decision": "deferred", "expected_version": cluster["version"] + 99}, ) assert stale.status_code == 409 assert stale.json()["error"]["code"] == "version_conflict" unchanged = seeded.client.get(f"/api/v1/duplicates/clusters/{cluster['id']}").json() assert unchanged["state"] == cluster["state"] # nothing mutated def test_decision_unknown_and_invalid(seeded): assert ( seeded.client.post( "/api/v1/duplicates/clusters/ghost/decision", json={"decision": "deferred", "expected_version": 1}, ).status_code == 404 ) clusters = seeded.client.get("/api/v1/duplicates/clusters?state=open").json() cluster = clusters["items"][0] invalid = seeded.client.post( f"/api/v1/duplicates/clusters/{cluster['id']}/decision", json={ "decision": "canonical", "expected_version": cluster["version"], "canonical_asset_id": "not-a-member", }, ) assert invalid.status_code == 422 def test_cluster_detail_unknown_is_404(seeded): assert seeded.client.get("/api/v1/duplicates/clusters/ghost").status_code == 404