Files
photoanalyzer/tests/integration/test_review_api.py

146 lines
5.3 KiB
Python

"""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