"""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 httpx import pytest from playwright.sync_api import expect from tests.e2e._pipeline_harness import ( SENTINEL_KEY, SILENT_UPLOADER, UPLOADER_VERSION, UploadStack, mark_upload_ready, seed_album, session_factory, wait_until, ) pytestmark = pytest.mark.phase_e TIMEOUT = 10 @pytest.fixture def stack(tmp_path): seeded = seed_album(tmp_path) mark_upload_ready(seeded) running = UploadStack(tmp_path, seeded) 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.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") 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")