Files
photoanalyzer/tests/e2e/test_uploads_ui.py

434 lines
18 KiB
Python

"""Browser journeys for the upload view (US05-05).
Covers the preflight preview (scope, redacted configuration, blockers, exact
confirmation), a real upload through the real worker with per-outcome progress,
stopping a running album, and the two ways out of an uncertain outcome —
verification against Immich and a manual resolution that records its evidence.
Nothing external is mocked inside the browser: the uploader is a real executable
driven by the real worker process, and Immich is a real HTTP server answering the
same ``ping``/``bulk-upload-check`` endpoints the adapter calls in production. The
API key is a sentinel string, so the last test can prove it never reached the page.
"""
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,
seed_album,
session_factory,
start_worker,
wait_until,
)
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())
try:
yield running
finally:
running.stop()
def _open(page, stack) -> None:
page.goto(f"{stack.base}/app/#/uploads")
page.get_by_test_id("upload-scope").wait_for()
def _upload(page, stack) -> None:
"""Confirm the upload and wait for the worker to finish the album."""
_open(page, stack)
page.get_by_test_id("start-upload").click()
expect(page.get_by_test_id("detail-state")).not_to_have_text("planned", timeout=30_000)
# ── preflight ────────────────────────────────────────────────────────────────
def test_the_preview_shows_scope_configuration_and_an_exact_confirmation(page, stack):
errors = []
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
stack.start(worker=False)
_open(page, stack)
expect(page.get_by_test_id("config-server")).to_have_text(stack.immich.url)
expect(page.get_by_test_id("config-key")).to_have_text("configured (never shown)")
expect(page.get_by_test_id("config-reachable")).to_have_text("yes")
expect(page.get_by_test_id("config-uploader")).to_have_text(UPLOADER_VERSION)
row = page.get_by_test_id("album-row").first
expect(row.get_by_test_id("album-name")).to_have_text("rome")
expect(row.get_by_test_id("album-immich-name")).to_have_text("rome")
expect(row.get_by_test_id("album-eligible")).to_have_text("2")
expect(row.get_by_test_id("album-state")).to_have_text("ready")
# The exact invocation is previewed, with the key masked at the source.
command = row.get_by_test_id("album-command").inner_text()
assert "upload from-folder" in command and "--album-name=rome" in command
assert "--api-key=***" in command
# The confirmation names the scope it is about to send, not just "Upload".
expect(page.get_by_test_id("start-upload")).to_have_text("Upload 1 album(s) · 2 photo(s)")
expect(page.get_by_test_id("start-upload")).to_be_enabled()
assert errors == [], f"console errors: {errors}"
def test_an_unfinished_photo_blocks_its_album_and_the_confirmation(page, stack):
_mark_upload_ready(stack.seeded, unverified=("b",))
stack.start(worker=False)
_open(page, stack)
expect(page.get_by_test_id("album-state")).to_have_text("blocked")
expect(page.get_by_test_id("album-blocker")).to_have_attribute("data-code", "partial_scope")
expect(page.get_by_test_id("album-eligible")).to_have_text("1")
expect(page.get_by_test_id("album-blocked")).to_have_text("1")
expect(page.get_by_test_id("start-upload")).to_be_disabled()
# Partial upload exists, but only as a deliberate act: ticking it re-runs the
# preflight under that policy and the confirmation then names the smaller scope.
page.get_by_test_id("allow-partial").check()
expect(page.get_by_test_id("album-state")).to_have_text("ready")
expect(page.get_by_test_id("start-upload")).to_have_text("Upload 1 album(s) · 1 photo(s)")
assert stack.batches() == [], "nothing may be created by previewing"
# ── uploading ────────────────────────────────────────────────────────────────
def test_a_confirmed_upload_runs_and_reports_each_outcome(page, stack):
stack.start()
_upload(page, stack)
expect(page.get_by_test_id("detail-state")).to_have_text("succeeded")
expect(page.get_by_test_id("count-new")).to_have_text("new: 1")
expect(page.get_by_test_id("count-duplicate")).to_have_text("duplicate: 1")
expect(page.get_by_test_id("count-uncertain")).to_have_text("uncertain: 0")
expect(page.get_by_test_id("count-failed")).to_have_text("failed: 0")
expect(page.get_by_test_id("batch-outcome-state")).to_have_text("verified")
outcomes = sorted(page.get_by_test_id("item-outcome").all_inner_texts())
assert outcomes == ["duplicate", "new"]
# A finished album offers no restart: the server would refuse one.
expect(page.get_by_test_id("retry-blocked")).to_contain_text("not_runnable")
expect(page.get_by_test_id("start-batch")).to_have_count(0)
def test_a_running_album_can_be_stopped(page, stack):
stack.start(uploader='echo "INFO starting"; sleep 20; exit 0\n')
_open(page, stack)
page.get_by_test_id("start-upload").click()
stop = page.get_by_test_id("cancel-batch")
expect(stop).to_have_text("Stop after the current file", timeout=30_000)
stop.click()
expect(page.get_by_test_id("detail-state")).to_have_text("cancelled", timeout=30_000)
# A stopped album is a clean boundary, not an uncertain one: it can run again.
expect(page.get_by_test_id("start-batch")).to_be_visible()
def test_the_finished_upload_survives_a_reload(page, stack):
stack.start()
_upload(page, stack)
expect(page.get_by_test_id("detail-state")).to_have_text("succeeded")
page.reload()
expect(page.get_by_test_id("detail-state")).to_have_text("succeeded")
expect(page.get_by_test_id("count-new")).to_have_text("new: 1")
# The result banner is this tab's memory, not server state, so it stays gone.
expect(page.get_by_test_id("upload-result")).to_have_count(0)
# ── uncertainty ──────────────────────────────────────────────────────────────
def test_an_uncertain_outcome_offers_verification_and_no_retry(page, stack):
stack.start(uploader=SILENT_UPLOADER)
_upload(page, stack)
expect(page.get_by_test_id("detail-state")).to_have_text("succeeded")
expect(page.get_by_test_id("batch-outcome-state")).to_have_text("requires_verification")
expect(page.get_by_test_id("count-uncertain")).to_have_text("uncertain: 2")
expect(page.get_by_test_id("uncertain")).to_be_visible()
# The point of the story: verification is offered, a retry is not.
expect(page.get_by_test_id("verify-batch")).to_be_visible()
expect(page.get_by_test_id("start-batch")).to_have_count(0)
def test_verification_asks_immich_and_resolves_the_uncertain_items(page, stack):
stack.start(uploader=SILENT_UPLOADER)
stack.immich.mode("present") # Immich holds exactly the bytes that were sent
_upload(page, stack)
page.get_by_test_id("verify-batch").click()
expect(page.get_by_test_id("batch-outcome-state")).to_have_text("verified")
expect(page.get_by_test_id("count-new")).to_have_text("new: 2")
expect(page.get_by_test_id("count-uncertain")).to_have_text("uncertain: 0")
expect(page.get_by_test_id("item-verification").first).to_have_text("present")
expect(page.get_by_test_id("history-entry").first).to_have_attribute(
"data-source", "immich_api"
)
expect(page.get_by_test_id("uncertain")).to_have_count(0)
def test_an_unusable_answer_stays_uncertain_until_someone_records_evidence(page, stack):
stack.start(uploader=SILENT_UPLOADER)
stack.immich.mode("broken") # answers, but nothing this adapter will interpret
_upload(page, stack)
page.get_by_test_id("verify-batch").click()
# No answer is never "no": the items stay uncertain rather than being called failed.
expect(page.get_by_test_id("item-verification").first).to_have_text("inconclusive")
expect(page.get_by_test_id("uncertain")).to_be_visible()
expect(page.get_by_test_id("start-batch")).to_have_count(0)
row = page.get_by_test_id("item-row").first
row.get_by_test_id("resolve-outcome").select_option("uploaded")
row.get_by_test_id("resolve-evidence").fill("found it in Immich by checksum")
row.get_by_test_id("resolve-actor").fill("dom")
row.get_by_test_id("resolve-item").click()
expect(page.get_by_test_id("item-row").first.get_by_test_id("item-outcome")).to_have_text("new")
manual = page.get_by_test_id("history-entry").last
expect(manual).to_have_attribute("data-source", "operator")
expect(manual).to_contain_text("found it in Immich by checksum")
expect(manual).to_contain_text("dom")
def test_bytes_changed_after_upload_are_flagged_and_block_another_run(page, stack):
stack.start()
_upload(page, stack)
expect(page.get_by_test_id("detail-state")).to_have_text("succeeded")
# The user edits a photo after it was uploaded; Immich still holds the old bytes.
(stack.seeded.lib / "rome" / "a.jpg").write_bytes(b"edited after the upload")
page.get_by_test_id("verify-batch").click()
expect(page.get_by_test_id("stale-bytes")).to_be_visible()
expect(page.get_by_test_id("item-changed")).to_have_count(1)
expect(page.get_by_test_id("retry-blocked")).to_contain_text("changed_after_upload")
expect(page.get_by_test_id("start-batch")).to_have_count(0)
# The preflight agrees: those bytes are no longer approved for any new upload.
expect(page.get_by_test_id("album-state")).to_have_text("blocked")
expect(page.get_by_test_id("album-blocked")).to_have_text("1")
# ── privacy ──────────────────────────────────────────────────────────────────
def test_the_api_key_never_reaches_the_browser(page, stack):
logs = []
page.on("console", lambda message: logs.append(message.text))
stack.start()
_upload(page, stack)
page.get_by_test_id("verify-batch").click()
expect(page.get_by_test_id("item-verification").first).to_have_text("present")
storage = page.evaluate(
"() => JSON.stringify([{...localStorage}, {...sessionStorage}, document.cookie])"
)
assert SENTINEL_KEY not in page.content()
assert SENTINEL_KEY not in page.url
assert SENTINEL_KEY not in storage
assert SENTINEL_KEY not in "\n".join(logs)
# The uploader was given the real key even though nothing on the page shows it.
report = wait_until(lambda: sorted((stack.seeded.data / "uploads").glob("*.log")))[0]
assert SENTINEL_KEY not in report.read_text()
# ── recovery ─────────────────────────────────────────────────────────────────
def test_an_interrupted_attempt_is_shown_as_uncertain_after_a_restart(page, stack):
"""A worker that vanished mid-upload leaves a batch whose outcome nobody knows.
Startup recovery marks it uncertain, and the view must not offer to retry it."""
from photo_pipeline.models import UploadBatch
stack.start(worker=False)
_open(page, stack)
token = httpx.post(f"{stack.base}/api/v1/upload-preflight", json={}, timeout=TIMEOUT).json()[
"token"
]
created = httpx.post(
f"{stack.base}/api/v1/upload-batches", json={"token": token}, timeout=TIMEOUT
).json()["batches"][0]
with session_factory(stack.seeded) as sf: # what a killed worker leaves behind
with sf() as session:
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
page.reload()
expect(page.get_by_test_id("detail-state")).to_have_text("unknown_requires_verification")
expect(page.get_by_test_id("batch-error")).to_contain_text("interrupted")
expect(page.get_by_test_id("uncertain")).to_be_visible()
expect(page.get_by_test_id("start-batch")).to_have_count(0)
expect(page.get_by_test_id("retry-blocked")).to_contain_text("requires_verification")