US05-06: Automate Phase E End-to-End Acceptance (#76)

This commit was merged in pull request #76.
This commit is contained in:
2026-08-16 17:05:13 +02:00
parent ac9943884d
commit 3ccef58796
13 changed files with 844 additions and 161 deletions

View File

@@ -13,180 +13,31 @@ API key is a sentinel string, so the last test can prove it never reached the pa
from __future__ import annotations
import json
import stat
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import httpx
import pytest
from playwright.sync_api import expect
from tests.e2e._pipeline_harness import (
Server,
SENTINEL_KEY,
SILENT_UPLOADER,
UPLOADER_VERSION,
UploadStack,
mark_upload_ready,
seed_album,
session_factory,
start_worker,
wait_until,
)
pytestmark = pytest.mark.phase_e
TIMEOUT = 10
SENTINEL_KEY = "immich-sentinel-9f3a2b"
UPLOADER_VERSION = "immich-go 0.21.0" # a pinned family, so reports are parsable
# A report the pinned text-v1 grammar understands: one new file, one the server
# already holds. ``$6`` is the folder argument of ``upload from-folder``.
REPORTING_UPLOADER = (
'echo "INFO uploaded $6/a.jpg"\n'
'echo "INFO server has the same file $6/b.jpg"\n'
'echo "Uploaded 1, duplicates 1"\n'
"exit 0\n"
)
# Exits cleanly but says nothing about any file: the process succeeded, the
# per-file outcome is unknown.
SILENT_UPLOADER = "exit 0\n"
# ── fake Immich ──────────────────────────────────────────────────────────────
def _handler(state: dict):
class Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 (BaseHTTPRequestHandler API)
self._json(200, {"res": "pong"})
def do_POST(self): # noqa: N802
length = int(self.headers.get("Content-Length", 0))
payload = json.loads(self.rfile.read(length) or b"{}")
if state["mode"] == "broken":
self.send_error(500, "bulk-upload-check is unavailable")
return
reject = state["mode"] == "present"
self._json(
200,
{
"results": [
{
"id": asset["id"],
"action": "reject" if reject else "accept",
"reason": "duplicate" if reject else None,
}
for asset in payload.get("assets", [])
]
},
)
def _json(self, code: int, body: dict) -> None:
raw = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def log_message(self, *args):
pass
return Handler
class FakeImmich:
"""An Immich that answers ping, and says whether it holds the exact bytes.
``mode`` is what the next verification will find: ``present`` (the server
deduplicates them, so it has them), ``absent`` (it would accept them, so it does
not), or ``broken`` (no usable answer at all).
"""
def __init__(self) -> None:
self.state = {"mode": "present"}
self._server = HTTPServer(("127.0.0.1", 0), _handler(self.state))
threading.Thread(target=self._server.serve_forever, daemon=True).start()
self.url = f"http://127.0.0.1:{self._server.server_port}"
def mode(self, mode: str) -> None:
self.state["mode"] = mode
def stop(self) -> None:
self._server.shutdown()
self._server.server_close()
# ── stack ────────────────────────────────────────────────────────────────────
def _uploader(tmp_path: Path, body: str) -> Path:
path = tmp_path / "immich-go"
path.write_text(
f'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "{UPLOADER_VERSION}"; exit 0; fi\n{body}'
)
path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return path
def _mark_upload_ready(seeded, *, unverified: tuple[str, ...] = ()) -> None:
"""Give every seeded photo the verified EXIF checkpoints upload requires.
``unverified`` names stems whose analysis checkpoint stays incomplete, which is
what makes an album partially blocked.
"""
from datetime import datetime, timezone
from sqlalchemy import select
from photo_pipeline.models import AnalysisResult, SafetyReview
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
blocked = {seeded.asset_ids[stem] for stem in unverified}
with session_factory(seeded) as sf:
with sf() as session:
for review in session.scalars(select(SafetyReview)):
review.exif_verified_at = now
for analysis in session.scalars(select(AnalysisResult)):
analysis.exif_written_at = None if analysis.asset_id in blocked else now
session.commit()
class Stack:
"""A seeded, upload-ready library plus the server, worker, and fake Immich."""
def __init__(self, tmp_path: Path, seeded, immich: FakeImmich) -> None:
self.tmp_path = tmp_path
self.seeded = seeded
self.immich = immich
self.server: Server | None = None
self.worker = None
def start(self, *, uploader: str = REPORTING_UPLOADER, worker: bool = True) -> "Stack":
env = {
"PHOTO_PIPELINE_IMMICH_SERVER_URL": self.immich.url,
"PHOTO_PIPELINE_IMMICH_API_KEY": SENTINEL_KEY,
"PHOTO_PIPELINE_IMMICH_GO_BINARY": str(_uploader(self.tmp_path, uploader)),
}
self.server = Server(self.seeded, extra_env=env).start()
self.base = self.server.base
if worker:
self.worker = start_worker(self.seeded, extra_env=env)
return self
def batches(self) -> list[dict]:
return httpx.get(f"{self.base}/api/v1/upload-batches", timeout=TIMEOUT).json()["batches"]
def stop(self) -> None:
if self.worker is not None:
self.worker.terminate()
self.worker.wait(timeout=10)
if self.server is not None:
self.server.stop()
self.immich.stop()
@pytest.fixture
def stack(tmp_path):
seeded = seed_album(tmp_path)
_mark_upload_ready(seeded)
running = Stack(tmp_path, seeded, FakeImmich())
mark_upload_ready(seeded)
running = UploadStack(tmp_path, seeded)
try:
yield running
finally:
@@ -236,7 +87,7 @@ def test_the_preview_shows_scope_configuration_and_an_exact_confirmation(page, s
def test_an_unfinished_photo_blocks_its_album_and_the_confirmation(page, stack):
_mark_upload_ready(stack.seeded, unverified=("b",))
mark_upload_ready(stack.seeded, unverified=("b",))
stack.start(worker=False)
_open(page, stack)
@@ -422,8 +273,7 @@ def test_an_interrupted_attempt_is_shown_as_uncertain_after_a_restart(page, stac
session.get(UploadBatch, created["id"]).state = "running"
session.commit()
stack.server.stop()
stack.server.start() # the same port, so recovery runs in a genuinely fresh process
stack.restart_server() # the same port, so recovery runs in a genuinely fresh process
page.reload()
expect(page.get_by_test_id("detail-state")).to_have_text("unknown_requires_verification")