"""Merge endpoints over HTTP against a real gallery on a temp database. The gallery here is real (SQLite + numpy index, no models) because the point of these tests is the refusal contract the UI depends on: a refused merge must come back as a 409 carrying the measured similarity, so the operator can be shown what they are being asked to override. """ import threading import numpy as np import pytest pytest.importorskip("httpx") from fastapi.testclient import TestClient # noqa: E402 from behavision.api import create_app # noqa: E402 from behavision.cameras import CameraStore # noqa: E402 from behavision.config import Config # noqa: E402 from behavision.engine import Engine # noqa: E402 from behavision.events import EventBus # noqa: E402 from behavision.gallery import Gallery, IdentityStore, VectorIndex # noqa: E402 DIM = 16 def _unit(seed): rng = np.random.default_rng(seed) v = rng.normal(size=DIM).astype(np.float32) return v / np.linalg.norm(v) def _at_similarity(base, target, seed=99): other = _unit(seed) other -= (other @ base) * base other /= np.linalg.norm(other) v = target * base + np.sqrt(1 - target ** 2) * other return (v / np.linalg.norm(v)).astype(np.float32) @pytest.fixture def client(tmp_path): eng = object.__new__(Engine) eng.cfg = Config() eng.cfg.app.data_dir = tmp_path eng.cfg.api.username = eng.cfg.api.password = "" eng.cfg.recognition.sighting_cooldown_seconds = 0.0 eng.store = IdentityStore(tmp_path / "api.db") eng.gallery = Gallery(eng.store, VectorIndex(DIM), eng.cfg.recognition) eng.bus = EventBus() eng.encoder = eng.attributes = None eng._lock = threading.RLock() eng.workers, eng.detectors = {}, {} eng.started_at, eng._running = 1.0, True eng.camera_store = CameraStore(tmp_path / "cameras.json") yield TestClient(create_app(eng)), eng eng.store.close() def test_merge_succeeds_and_emits_an_audit_event(client): c, eng = client base = _unit(1) a = eng.gallery.enroll("Alice", [base]) b = eng.gallery.enroll("Visitor 2", [_at_similarity(base, 0.50)]) r = c.post(f"/api/identities/{b}/merge", json={"into": a}) assert r.status_code == 200 assert r.json()["ok"] is True assert r.json()["similarity"] == pytest.approx(0.50, abs=0.01) assert len(c.get("/api/identities").json()) == 1 # Merging is destructive and irreversible; it has to leave a trace. types = [e["type"] for e in eng.bus.recent] assert "identity.merged" in types def test_refusal_is_409_and_reports_the_similarity(client): """The UI needs the number, not just a failure — it is what justifies offering the force override to a human.""" c, eng = client a = eng.gallery.enroll("Alice", [_unit(1)]) b = eng.gallery.enroll("Bob", [_at_similarity(_unit(1), 0.05)]) r = c.post(f"/api/identities/{b}/merge", json={"into": a}) assert r.status_code == 409 detail = r.json()["detail"] assert detail["ok"] is False assert detail["similarity"] == pytest.approx(0.05, abs=0.01) assert detail["threshold"] == eng.cfg.recognition.enroll_threshold assert len(c.get("/api/identities").json()) == 2 def test_force_is_not_the_default(client): c, eng = client a = eng.gallery.enroll("Alice", [_unit(1)]) b = eng.gallery.enroll("Bob", [_at_similarity(_unit(1), 0.05)]) assert c.post(f"/api/identities/{b}/merge", json={"into": a}).status_code == 409 r = c.post(f"/api/identities/{b}/merge", json={"into": a, "force": True}) assert r.status_code == 200 assert r.json()["forced"] is True def test_missing_identity_is_404(client): c, eng = client a = eng.gallery.enroll("Alice", [_unit(1)]) r = c.post(f"/api/identities/9999/merge", json={"into": a}) assert r.status_code == 404 def test_self_merge_is_409(client): c, eng = client a = eng.gallery.enroll("Alice", [_unit(1)]) assert c.post(f"/api/identities/{a}/merge", json={"into": a}).status_code == 409 def test_duplicates_endpoint_lists_candidates(client): c, eng = client base = _unit(1) a = eng.gallery.enroll("Alice", [base]) b = eng.gallery.enroll("Visitor 2", [_at_similarity(base, 0.50)]) pairs = c.get("/api/identities/duplicates").json() assert len(pairs) == 1 assert {pairs[0]["a"]["id"], pairs[0]["b"]["id"]} == {a, b} assert pairs[0]["confident"] is True # 0.50 clears match_threshold def test_duplicates_route_is_not_shadowed_by_the_id_routes(client): """`/api/identities/duplicates` sits under a path whose siblings take an int id; if it ever gets matched by one of those it returns 422, not a list.""" c, _ = client r = c.get("/api/identities/duplicates") assert r.status_code == 200 assert isinstance(r.json(), list) def test_embedding_endpoint_returns_the_best_view(client): """The server needs a vector to match across sites, and the event bus deliberately does not carry one.""" c, eng = client base = _unit(1) poor = _at_similarity(base, 0.60, seed=5) # enroll() defaults to quality 1.0, which would win regardless and make # the assertion below vacuous. ident = eng.gallery.enroll("Alice", [base], quality=0.50) eng.store.add_embedding(ident, poor, 0.20, eng.gallery.model_name) eng.store.add_embedding(ident, base, 0.91, eng.gallery.model_name) r = c.get(f"/api/identities/{ident}/embedding") assert r.status_code == 200 body = r.json() assert body["quality"] == 0.91, "did not pick the best view" assert len(body["embedding"]) == DIM assert body["model"] == eng.gallery.model_name def test_embedding_endpoint_404s_for_an_unknown_identity(client): c, _ = client assert c.get("/api/identities/9999/embedding").status_code == 404 def test_embedding_endpoint_404s_when_the_model_differs(client): """Vectors from another encoder are not comparable, so returning one would hand the server a template it must not use.""" c, eng = client ident = eng.store.create_identity("Bob") eng.store.add_embedding(ident, _unit(3), 0.9, "some_other_model") assert c.get(f"/api/identities/{ident}/embedding").status_code == 404