"""Verifying, retrying, and resolving uncertain uploads (US05-04). The Immich boundary is a real HTTP server here: it answers ``/api/server/ping`` and ``/api/assets/bulk-upload-check`` exactly as the app's own adapter parses them, and the set of checksums it "holds" is what each fault scenario controls. The uploader stays a real executable driven through the real batch service, so every state under test is reached the way production reaches it. The invariant these tests defend is one-directional: uncertainty may only become success when something authoritative said so — the server, or an operator who recorded what they checked. """ import json import stat import threading import uuid from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path import pytest from fastapi.testclient import TestClient from photo_pipeline.api.app import create_app from photo_pipeline.config import Config from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations from photo_pipeline.models import AnalysisResult, Asset, SafetyReview, UploadBatch from photo_pipeline.services.hashing import sha256_file from photo_pipeline.services.upload_batches import BatchError, BatchState, UploadBatchService from photo_pipeline.services.upload_reports import REQUIRES_VERIFICATION, VERIFIED from photo_pipeline.services.upload_verification import ( ABSENT, INCONCLUSIVE, MANUAL, PRESENT, UploadVerificationService, VerificationError, retry_blockers, ) from photo_pipeline.services.uploads import UploadService pytestmark = pytest.mark.phase_e # part of the Phase E acceptance gate (US05-06) NOW = datetime(2026, 1, 1, tzinfo=timezone.utc) SENTINEL_KEY = "immich-sentinel-9f3a2b" UPLOADER_VERSION = "immich-go 0.21.0" # ── fake Immich ────────────────────────────────────────────────────────────── class _FakeImmich: """The server's view of the world: which checksums it holds, and whether it is willing to answer at all.""" def __init__(self) -> None: self.held: set[str] = set() self.available = True self.checked: list[str] = [] def _handler(state: _FakeImmich): class Handler(BaseHTTPRequestHandler): def do_GET(self): # noqa: N802 (BaseHTTPRequestHandler API) self._json(200, {"res": "pong"}) def do_POST(self): # noqa: N802 body = json.loads(self.rfile.read(int(self.headers["Content-Length"] or 0)) or "{}") if not state.available: self._json(503, {"error": "unavailable"}) return if self.headers.get("x-api-key") != SENTINEL_KEY: self._json(401, {"error": "unauthorized"}) return results = [] for asset in body.get("assets", []): state.checked.append(asset["checksum"]) results.append( {"id": asset["id"], "action": "reject", "reason": "duplicate"} if asset["checksum"] in state.held else {"id": asset["id"], "action": "accept"} ) self._json(200, {"results": results}) def _json(self, status: int, payload: dict) -> None: body = json.dumps(payload).encode() self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *args): pass return Handler @pytest.fixture def immich(): state = _FakeImmich() server = HTTPServer(("127.0.0.1", 0), _handler(state)) threading.Thread(target=server.serve_forever, daemon=True).start() state.url = f"http://127.0.0.1:{server.server_port}" yield state server.shutdown() server.server_close() # ── environment ────────────────────────────────────────────────────────────── def _uploader(tmp_path, report_body: str = "", *, exit_code: int = 0, version=UPLOADER_VERSION): path = tmp_path / "immich-go" path.write_text( "#!/bin/sh\n" f'if [ "$1" = "--version" ]; then echo "{version}"; exit 0; fi\n' f"cat <<'REPORT'\n{report_body}\nREPORT\n" f"exit {exit_code}\n" ) path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) return path def _env(tmp_path, immich, uploader=None): (tmp_path / "data").mkdir(exist_ok=True) lib = tmp_path / "lib" lib.mkdir(exist_ok=True) config = Config.from_env( { "PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data"), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib), "PHOTO_PIPELINE_IMMICH_SERVER_URL": immich.url, "PHOTO_PIPELINE_IMMICH_API_KEY": SENTINEL_KEY, "PHOTO_PIPELINE_IMMICH_GO_BINARY": str( uploader if uploader is not None else _uploader(tmp_path) ), } ) run_migrations(config.database_url) return config, create_session_factory(create_db_engine(config.database_url)), lib def _album(sf, lib, album="rome", names=("a.jpg", "b.jpg")): folder = lib / album folder.mkdir(parents=True, exist_ok=True) with sf() as session: for name in names: path = folder / name path.write_bytes(f"{album}/{name}".encode() * 16) asset_id = str(uuid.uuid4()) session.add( Asset( id=asset_id, original_path=str(path), current_path=str(path), discovered_at=NOW, hash_version=1, byte_size=path.stat().st_size, current_sha256=sha256_file(path), ) ) session.add( SafetyReview( id=str(uuid.uuid4()), asset_id=asset_id, decision="sfw", exif_verified_at=NOW ) ) session.add(AnalysisResult(asset_id=asset_id, status="analyzed", exif_written_at=NOW)) session.commit() return folder def _batch(sf, config, albums=None): report = UploadService(sf, config=config).preflight(albums) assert report["state"] == "ready", report["blockers"] (batch,) = UploadBatchService(sf, config=config).create(albums, token=report["token"]) return batch def _interrupt(sf, config, batch_id): """Leave behind exactly what a killed worker leaves: a mid-flight attempt.""" with sf() as session: session.get(UploadBatch, batch_id).state = BatchState.RUNNING session.commit() UploadBatchService(sf, config=config).recover() def _server_holds(immich, batch): immich.held.update(item["sha1"] for item in batch["items"]) def _by_name(batch) -> dict: return {Path(item["path"]).name: item for item in batch["items"]} def _uncertain_batch(tmp_path, immich, *, hold: bool): """A batch whose attempt died mid-upload, with the server holding the bytes or not.""" config, sf, lib = _env(tmp_path, immich) _album(sf, lib) batch = _batch(sf, config) if hold: _server_holds(immich, batch) _interrupt(sf, config, batch["id"]) return config, sf, lib, batch # ── accepted-but-unknown ───────────────────────────────────────────────────── def test_acceptance_then_lost_response_is_confirmed_by_the_server(tmp_path, immich): """The classic lost response: Immich took the files, the app never saw a report.""" config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True) result = UploadVerificationService(sf, config=config).verify(batch["id"]) assert result["state"] == BatchState.SUCCEEDED, "server evidence resolves the uncertainty" assert result["outcome_state"] == VERIFIED assert result["counts"] == {PRESENT: 2} verified = UploadBatchService(sf, config=config).get(batch["id"]) for item in verified["items"]: assert item["outcome"] == "uploaded" assert item["verification"] == PRESENT assert item["sha1"] in item["evidence"], "the exact bytes are named in the evidence" assert immich.checked, "verification actually asked the server" def test_timeout_before_acceptance_leaves_a_safe_retry(tmp_path, immich): """Nothing arrived, so the batch becomes a plain failure and may run again.""" config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=False) service = UploadBatchService(sf, config=config) result = UploadVerificationService(sf, config=config).verify(batch["id"]) assert result["state"] == BatchState.FAILED assert result["counts"] == {ABSENT: 2} assert {item["outcome"] for item in service.get(batch["id"])["items"]} == {"failed"} assert retry_blockers(service.get(batch["id"])) == [] assert service.run(batch["id"])["state"] == BatchState.SUCCEEDED def test_an_uncertain_batch_cannot_be_retried_before_verification(tmp_path, immich): config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True) service = UploadBatchService(sf, config=config) with pytest.raises(BatchError) as error: service.run(batch["id"]) assert error.value.code == "requires_verification" with TestClient(create_app(config)) as client: response = client.post(f"/api/v1/upload-batches/{batch['id']}/start") assert response.status_code == 409 assert response.json()["error"]["code"] == "requires_verification" def test_a_partly_arrived_batch_is_a_failure_not_a_success(tmp_path, immich): config, sf, lib = _env(tmp_path, immich) _album(sf, lib) batch = _batch(sf, config) immich.held.add(batch["items"][0]["sha1"]) # only one file made it _interrupt(sf, config, batch["id"]) result = UploadVerificationService(sf, config=config).verify(batch["id"]) assert result["state"] == BatchState.FAILED assert result["counts"] == {PRESENT: 1, ABSENT: 1} assert result["outcome_state"] == VERIFIED, "every file is accounted for" # ── parser uncertainty ─────────────────────────────────────────────────────── def test_parser_uncertainty_is_settled_by_server_evidence(tmp_path, immich): """An unpinned uploader version leaves every item unknown; the server decides.""" config, sf, lib = _env(tmp_path, immich, _uploader(tmp_path, "done", version="immich-go 9.9.9")) _album(sf, lib) batch = _batch(sf, config) service = UploadBatchService(sf, config=config) ran = service.run(batch["id"]) assert ran["state"] == BatchState.SUCCEEDED and ran["outcome_state"] == REQUIRES_VERIFICATION _server_holds(immich, ran) result = UploadVerificationService(sf, config=config).verify(batch["id"]) assert result["outcome_state"] == VERIFIED assert {item["outcome"] for item in service.get(batch["id"])["items"]} == {"uploaded"} def test_an_unreachable_server_never_turns_uncertainty_into_success(tmp_path, immich): config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True) immich.available = False service = UploadBatchService(sf, config=config) result = UploadVerificationService(sf, config=config).verify(batch["id"]) assert result["server_reachable"] is False assert result["detail"], "the reason the server could not answer is reported" assert result["counts"] == {INCONCLUSIVE: 2} assert result["state"] == BatchState.UNKNOWN, "still uncertain, not succeeded" assert result["outcome_state"] == REQUIRES_VERIFICATION assert [b["code"] for b in retry_blockers(service.get(batch["id"]))] == [ "requires_verification" ] # ── safe failures ──────────────────────────────────────────────────────────── def test_a_plain_uploader_failure_is_retryable_without_verification(tmp_path, immich): """Nothing uncertain happened: the process failed before/while reporting an error.""" config, sf, lib = _env(tmp_path, immich, _uploader(tmp_path, "boom", exit_code=1)) _album(sf, lib) batch = _batch(sf, config) service = UploadBatchService(sf, config=config) assert service.run(batch["id"])["state"] == BatchState.FAILED assert retry_blockers(service.get(batch["id"])) == [] with TestClient(create_app(config)) as client: assert client.post(f"/api/v1/upload-batches/{batch['id']}/start").status_code == 200 # ── changed bytes ──────────────────────────────────────────────────────────── def test_bytes_changed_after_upload_warn_and_block_a_rerun(tmp_path, immich): config, sf, lib = _env(tmp_path, immich) folder = _album(sf, lib) batch = _batch(sf, config) service = UploadBatchService(sf, config=config) ran = service.run(batch["id"]) _server_holds(immich, ran) (folder / "a.jpg").write_bytes(b"edited after the upload") result = UploadVerificationService(sf, config=config).verify(batch["id"]) assert result["stale_bytes"] is True changed = _by_name(result)["a.jpg"] assert changed["changed_after_upload"] is True assert _by_name(result)["b.jpg"]["changed_after_upload"] is False stored = service.get(batch["id"]) assert stored["stale_bytes"] is True, "the warning is durable, not only in the response" assert _by_name(stored)["a.jpg"]["observed_sha256"] != _by_name(stored)["a.jpg"]["sha256"] with pytest.raises(BatchError) as error: service.run(batch["id"]) assert error.value.code == "changed_after_upload" with TestClient(create_app(config)) as client: response = client.post(f"/api/v1/upload-batches/{batch['id']}/start") assert response.status_code == 409 assert response.json()["error"]["code"] == "changed_after_upload" def test_a_deleted_file_counts_as_changed_after_upload(tmp_path, immich): config, sf, lib = _env(tmp_path, immich) folder = _album(sf, lib) batch = _batch(sf, config) ran = UploadBatchService(sf, config=config).run(batch["id"]) _server_holds(immich, ran) (folder / "a.jpg").unlink() result = UploadVerificationService(sf, config=config).verify(batch["id"]) assert _by_name(result)["a.jpg"]["changed_after_upload"] is True assert result["stale_bytes"] is True # The bytes are still on the server: verification is about the upload, not the # local file's continued existence. assert _by_name(result)["a.jpg"]["verification"] == PRESENT # ── repeated verification and history ──────────────────────────────────────── def test_repeated_verification_converges_and_keeps_every_answer(tmp_path, immich): config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True) service = UploadVerificationService(sf, config=config) first = service.verify(batch["id"]) second = service.verify(batch["id"]) assert first["counts"] == second["counts"] == {PRESENT: 2} assert first["state"] == second["state"] == BatchState.SUCCEEDED history = service.history(batch["id"]) assert len(history) == 4, "the audit trail appends, it never overwrites" assert {entry["source"] for entry in history} == {"immich_api"} assert all(entry["action"] == "verify" for entry in history) def test_verification_that_changes_its_mind_keeps_both_answers(tmp_path, immich): """A file that was absent and later present must show both, in order.""" config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=False) service = UploadVerificationService(sf, config=config) service.verify(batch["id"]) _server_holds(immich, batch) service.verify(batch["id"]) asset_id = batch["items"][0]["asset_id"] results = [e["result"] for e in service.history(batch["id"]) if e["asset_id"] == asset_id] assert results == [ABSENT, PRESENT] # ── manual resolution ──────────────────────────────────────────────────────── def test_manual_resolution_requires_evidence_and_an_author(tmp_path, immich): config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True) service = UploadVerificationService(sf, config=config) asset_id = batch["items"][0]["asset_id"] for kwargs, code in ( ({"evidence": " ", "actor": "dom"}, "evidence_required"), ({"evidence": "checked in Immich", "actor": ""}, "actor_required"), ({"evidence": "checked", "actor": "dom", "outcome": "definitely-fine"}, "invalid_outcome"), ): with pytest.raises(VerificationError) as error: service.resolve(batch["id"], asset_id, **{"outcome": "uploaded", **kwargs}) assert error.value.code == code assert service.history(batch["id"]) == [], "a refused resolution records nothing" assert UploadBatchService(sf, config=config).get(batch["id"])["state"] == BatchState.UNKNOWN def test_manual_resolution_is_recorded_as_operator_evidence(tmp_path, immich): """An operator may settle what the server cannot — but never anonymously.""" config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True) immich.available = False service = UploadVerificationService(sf, config=config) service.verify(batch["id"]) # inconclusive: the server is down for item in batch["items"]: result = service.resolve( batch["id"], item["asset_id"], outcome="uploaded", evidence="found in Immich by checksum in the web UI", actor="dom", ) assert result["state"] == BatchState.SUCCEEDED assert result["outcome_state"] == VERIFIED resolutions = [e for e in service.history(batch["id"]) if e["action"] == "resolve"] assert len(resolutions) == 2 assert {e["actor"] for e in resolutions} == {"dom"} assert {e["source"] for e in resolutions} == {"operator"} assert all("web UI" in e["evidence"] for e in resolutions) stored = UploadBatchService(sf, config=config).get(batch["id"]) assert {item["verification"] for item in stored["items"]} == {MANUAL}, ( "a manual answer stays distinguishable from server evidence" ) def test_resolving_one_item_leaves_the_batch_uncertain(tmp_path, immich): config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True) immich.available = False service = UploadVerificationService(sf, config=config) service.verify(batch["id"]) result = service.resolve( batch["id"], batch["items"][0]["asset_id"], outcome="uploaded", evidence="visible in Immich", actor="dom", ) assert result["state"] == BatchState.UNKNOWN assert result["outcome_state"] == REQUIRES_VERIFICATION def test_resolving_an_unknown_item_or_batch_is_refused(tmp_path, immich): config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True) service = UploadVerificationService(sf, config=config) for batch_id, asset_id in ((batch["id"], "not-in-this-batch"), ("no-such-batch", "x")): with pytest.raises(VerificationError) as error: service.resolve(batch_id, asset_id, outcome="uploaded", evidence="checked", actor="dom") assert error.value.code == "not_found" def test_verifying_an_unknown_batch_is_refused(tmp_path, immich): config, sf, lib = _env(tmp_path, immich) with pytest.raises(VerificationError) as error: UploadVerificationService(sf, config=config).verify("does-not-exist") assert error.value.code == "not_found" # ── API surface and durability ─────────────────────────────────────────────── def test_the_api_verifies_resolves_and_lists_history_without_secrets(tmp_path, immich): config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=False) with TestClient(create_app(config)) as client: verified = client.post(f"/api/v1/upload-batches/{batch['id']}/verify") resolved = client.post( f"/api/v1/upload-batches/{batch['id']}/resolve", json={ "asset_id": batch["items"][0]["asset_id"], "outcome": "skipped", "evidence": "the file was withdrawn from the album", "actor": "dom", }, ) refused = client.post( f"/api/v1/upload-batches/{batch['id']}/resolve", json={ "asset_id": batch["items"][0]["asset_id"], "outcome": "uploaded", "evidence": "", "actor": "dom", }, ) history = client.get(f"/api/v1/upload-batches/{batch['id']}/verifications") missing = client.post("/api/v1/upload-batches/does-not-exist/verify") assert verified.status_code == 200 and verified.json()["counts"] == {ABSENT: 2} assert resolved.status_code == 200 assert refused.status_code == 422 and refused.json()["error"]["code"] == "evidence_required" assert len(history.json()["verifications"]) == 3 assert missing.status_code == 404 assert SENTINEL_KEY not in verified.text + resolved.text + history.text def test_verification_survives_a_restart(tmp_path, immich): config, sf, lib, batch = _uncertain_batch(tmp_path, immich, hold=True) UploadVerificationService(sf, config=config).verify(batch["id"]) with TestClient(create_app(config)) as client: # a fresh application process fetched = client.get(f"/api/v1/upload-batches/{batch['id']}").json() history = client.get(f"/api/v1/upload-batches/{batch['id']}/verifications").json() assert fetched["state"] == BatchState.SUCCEEDED assert fetched["verified_at"] assert {item["verification"] for item in fetched["items"]} == {PRESENT} assert len(history["verifications"]) == 2 assert json.dumps(fetched) # the record stays JSON-serialisable for the UI