284 lines
13 KiB
Python
284 lines
13 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 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")
|