"""Phase A end-to-end acceptance gate. Drives the whole Phase A journey against a real API process over HTTP, plus one browser reload check, then restarts the process and asserts durable API + database state survived. Everything is offline: deterministic synthetic fixtures, no network. Note: Phase A has no separate worker yet (durable jobs arrive in Phase B/US02); the harness launches the real API process, which is the production boundary for this phase. Scan and detection run synchronously via their endpoints. """ import shutil import socket import subprocess import sys import time from io import BytesIO from pathlib import Path import httpx import numpy as np import pytest from PIL import Image REPO = Path(__file__).resolve().parents[2] def _structured(path, seed, size=(320, 240)): 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(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) def _oriented(path, w, h, orientation=6, seed=7): arr = np.random.default_rng(seed).integers(0, 256, (h, w, 3), dtype=np.uint8) img = Image.fromarray(arr) exif = img.getexif() exif[274] = orientation img.save(path, exif=exif, quality=95) def _free_port(): with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) return sock.getsockname()[1] class ServerController: def __init__(self, data, lib): self.data = data self.lib = lib self.proc = None self.base = None self.client = None def start(self): port = _free_port() env = { "PATH": __import__("os").environ.get("PATH", ""), "PHOTO_PIPELINE_DATA_DIR": str(self.data), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(self.lib), "PHOTO_PIPELINE_HOST": "127.0.0.1", "PHOTO_PIPELINE_PORT": str(port), } self.proc = subprocess.Popen( [sys.executable, "-m", "photo_pipeline", "serve"], cwd=str(REPO), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) self.base = f"http://127.0.0.1:{port}" deadline = time.monotonic() + 30 while time.monotonic() < deadline: if self.proc.poll() is not None: _, err = self.proc.communicate() pytest.fail(f"server exited: {err.decode(errors='replace')}") try: if httpx.get(f"{self.base}/api/v1/health/ready", timeout=1).status_code == 200: self.client = httpx.Client(base_url=self.base, timeout=10) return except httpx.HTTPError: time.sleep(0.2) self.stop() pytest.fail("server never became ready") def restart(self): self.stop() self.start() def stop(self): if self.client: self.client.close() self.client = None if self.proc: self.proc.terminate() try: self.proc.wait(timeout=10) except subprocess.TimeoutExpired: self.proc.kill() self.proc = None @pytest.fixture def pipeline(tmp_path): data = tmp_path / "data" data.mkdir() lib = tmp_path / "lib" lib.mkdir() # Deterministic corpus exercising every Phase A journey. photo = _structured(lib / "album" / "photo.jpg", 1) shutil.copy2(photo, lib / "album" / "photo_copy.jpg") # exact duplicate scene = _structured(lib / "album" / "scene.jpg", 2) _resized(scene, lib / "album" / "scene_small.jpg") # perceptual near-duplicate _oriented(lib / "rotated.jpg", 800, 480, orientation=6) # orientation fixture _structured(lib / "movable.jpg", 5) # moved between scans _structured(lib / "_IGNORE" / "secret.jpg", 9) # exclusion sentinel controller = ServerController(data, lib) controller.start() yield controller controller.stop() def _assets(client): return client.get("/api/v1/inventory/assets?limit=200").json()["items"] def _id_for(assets, suffix): return next(a["id"] for a in assets if (a["current_path"] or "").endswith(suffix)) def test_phase_a_full_pipeline_and_restart(page, pipeline): client = pipeline.client # 1. Scan + detect through the real API. assert client.post("/api/v1/inventory/scan").status_code == 200 detect = client.post("/api/v1/duplicates/detect").json() assert detect["clusters"] >= 2 assets = _assets(client) paths = [a["current_path"] for a in assets] # 2. Exclusion: the _IGNORE sentinel is never discovered. assert not any("_IGNORE" in p or p.endswith("secret.jpg") for p in paths) # 5 discovered files (photo, photo_copy, scene, scene_small, rotated, movable = 6) assert len(assets) == 6 movable_id = _id_for(assets, "movable.jpg") rotated_id = _id_for(assets, "rotated.jpg") # 3. Move reconciliation: identity survives a move across a rescan. shutil.move(str(pipeline.lib / "movable.jpg"), str(pipeline.lib / "album" / "moved.jpg")) assert client.post("/api/v1/inventory/scan").status_code == 200 assert _id_for(_assets(client), "moved.jpg") == movable_id # 4. Exact + fuzzy clusters exist with the right confidence handling. clusters = client.get("/api/v1/duplicates/clusters").json()["items"] exact = next(c for c in clusters if c["method"] == "exact") perceptual = next(c for c in clusters if c["method"] == "perceptual") assert exact["state"] == "decided" # exact auto-resolved assert perceptual["state"] == "open" # fuzzy left for review # 5. Thumbnail orientation: a rotated source serves an upright preview. thumb = client.get(f"/api/v1/assets/{rotated_id}/thumbnail?size=256") assert thumb.status_code == 200 and thumb.headers["content-type"] == "image/webp" with Image.open(BytesIO(thumb.content)) as image: assert image.height > image.width # 800x480 landscape shown upright as portrait # 6. Canonical selection on the fuzzy cluster persists with variant links. detail = client.get(f"/api/v1/duplicates/clusters/{perceptual['id']}").json() chosen = detail["members"][0]["asset_id"] decided = client.post( f"/api/v1/duplicates/clusters/{perceptual['id']}/decision", json={ "decision": "canonical", "expected_version": detail["version"], "canonical_asset_id": chosen, }, ) assert decided.status_code == 200 and decided.json()["state"] == "decided" variant_ids = {m["asset_id"] for m in detail["members"]} - {chosen} after = {a["id"]: a for a in _assets(client)} assert all(after[v]["canonical_asset_id"] == chosen for v in variant_ids) # 7. Reload in the browser: the decision is restored, not re-fetched fresh state. page.goto(f"{pipeline.base}/app/#/duplicates/{perceptual['id']}") page.get_by_test_id("cluster-state").wait_for() page.reload() page.get_by_test_id("cluster-state").wait_for() assert "decided" in page.get_by_test_id("cluster-state").inner_text() # 8. Full process restart: durable API + DB state survives. pipeline.restart() client = pipeline.client assert client.get("/api/v1/health/ready").status_code == 200 restored = client.get("/api/v1/duplicates/clusters").json()["items"] restored_perceptual = next(c for c in restored if c["id"] == perceptual["id"]) assert restored_perceptual["state"] == "decided" # decision durable restored_assets = {a["id"]: a for a in _assets(client)} assert _id_for(list(restored_assets.values()), "moved.jpg") == movable_id # identity durable assert all(restored_assets[v]["canonical_asset_id"] == chosen for v in variant_ids) restored_paths = [a["current_path"] for a in restored_assets.values()] assert not any("_IGNORE" in p for p in restored_paths) # exclusion still holds