146 lines
5.3 KiB
Python
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
|