"""Browser journeys for the archive view (US06-05). Archiving is the only stage that removes originals, so these journeys check the two things a browser must never get wrong about it: that the preview names exactly what would leave and where it would go, and that nothing offers an action the server would refuse. The medium is a real directory whose marker makes it identifiable; unmounting it is what an unplugged disk looks like from here. Nothing is mocked inside the browser: the transfer runs in the real worker process and the assertions read the filesystem afterwards. """ from __future__ import annotations import pytest from playwright.sync_api import expect from tests.e2e._pipeline_harness import ( ArchiveStack, mark_uploaded, seed_album, session_factory, wait_until, ) pytestmark = pytest.mark.phase_f # part of the Phase F acceptance gate (US06-06) TIMEOUT = 10 RUN_TIMEOUT = 30_000 @pytest.fixture def stack(tmp_path): seeded = seed_album(tmp_path) mark_uploaded(seeded) running = ArchiveStack(tmp_path, seeded) try: yield running finally: running.stop() def _open(page, stack) -> None: page.goto(f"{stack.base}/app/#/archive") page.get_by_test_id("archive-locations").wait_for() def _archive(page, stack) -> None: """Confirm the archive and wait for the worker to finish the run.""" _open(page, stack) page.get_by_test_id("start-archive").click() expect(page.get_by_test_id("detail-state")).to_have_text("complete", timeout=RUN_TIMEOUT) def _archived_paths(stack) -> list[str]: return sorted(p.name for p in (stack.archive / "rome").glob("*.jpg")) # ── preview and confirmation ───────────────────────────────────────────────── def test_the_preview_names_the_scope_destination_and_reclaimable_bytes(page, stack): errors = [] page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None) stack.start(worker=False) location = stack.register() _open(page, stack) row = page.get_by_test_id("location-row").first expect(row.get_by_test_id("location-label")).to_have_text("external") expect(row.get_by_test_id("location-media")).to_have_text(location["media_id"]) expect(row.get_by_test_id("location-state")).to_have_text("online") album = page.get_by_test_id("archive-album-row").first expect(album.get_by_test_id("album-name")).to_have_text("rome") expect(album.get_by_test_id("album-destination")).to_have_text(str(stack.archive / "rome")) expect(album.get_by_test_id("album-assets")).to_have_text("2") expect(album.get_by_test_id("album-state")).to_have_text("ready") # Same filesystem here, so the transfer method is the move path — and it is # named, because copy-verify-remove and move fail differently. expect(album.get_by_test_id("album-method")).to_contain_text("move") expect(page.get_by_test_id("destination-identity")).to_contain_text(location["media_id"]) expect(page.get_by_test_id("capacity")).to_contain_text("reserve") expect(page.get_by_test_id("start-archive")).to_contain_text("Archive 1 album(s) · reclaim") expect(page.get_by_test_id("start-archive")).to_be_enabled() assert stack.plans() == [], "previewing may not create anything" assert errors == [], f"console errors: {errors}" def test_an_offline_medium_blocks_the_confirmation_and_says_what_to_mount(page, stack): stack.start(worker=False) location = stack.register() stack.unmount() _open(page, stack) expect(page.get_by_test_id("location-state")).to_have_text("offline") expect(page.get_by_test_id("preflight-blocker").first).to_have_attribute( "data-code", "location_offline" ) instruction = page.get_by_test_id("mount-instruction") expect(instruction).to_contain_text("external") expect(instruction).to_contain_text(location["media_id"]) expect(page.get_by_test_id("start-archive")).to_be_disabled() def test_an_unuploaded_album_is_blocked_with_its_reason(page, stack): # No upload evidence at all: archiving would remove the only copy. seeded = stack.seeded with session_factory(seeded) as sf: from sqlalchemy import delete from photo_pipeline.models import UploadItem with sf() as session: session.execute(delete(UploadItem)) session.commit() stack.start(worker=False) stack.register() _open(page, stack) expect(page.get_by_test_id("album-state")).to_have_text("blocked") expect(page.get_by_test_id("album-blocker").first).to_have_attribute( "data-code", "partial_scope" ) expect(page.get_by_test_id("start-archive")).to_be_disabled() # ── running, progress, reload ──────────────────────────────────────────────── def test_a_confirmed_archive_runs_and_separates_transfer_verify_and_removal(page, stack): stack.start() stack.register() _archive(page, stack) expect(page.get_by_test_id("count-complete")).to_have_text("complete: 2") expect(page.get_by_test_id("count-failed")).to_have_text("failed: 0") expect(page.get_by_test_id("count-transfer")).to_have_text("transfer: 0") expect(page.get_by_test_id("count-verified")).to_have_text("verified: 0") expect(page.get_by_test_id("count-removing")).to_have_text("removing: 0") expect(page.get_by_test_id("operation-phase").first).to_have_text("complete") # The library really lost the originals and the medium really holds them. assert _archived_paths(stack) == ["a.jpg", "b.jpg"] assert not (stack.seeded.lib / "rome" / "a.jpg").exists() assert {a["availability_state"] for a in stack.assets()} == {"archived_online"} def test_the_run_survives_a_reload_because_the_state_is_the_servers(page, stack): stack.start() stack.register() _archive(page, stack) page.reload() page.get_by_test_id("plan-detail").wait_for() expect(page.get_by_test_id("detail-state")).to_have_text("complete") expect(page.get_by_test_id("count-complete")).to_have_text("complete: 2") # And the run stays addressable by its own URL. plan_id = stack.plans()[0]["id"] page.goto(f"{stack.base}/app/#/archive?plan={plan_id}") expect(page.get_by_test_id("plan-detail")).to_have_attribute("data-plan", plan_id) # ── recovery ───────────────────────────────────────────────────────────────── def test_an_interrupted_transfer_is_shown_with_evidence_and_a_safe_action(page, stack): stack.start(worker=False) stack.register() _open(page, stack) page.get_by_test_id("start-archive").click() expect(page.get_by_test_id("archive-result")).to_be_visible() # No worker ran, so every item is still planned; leave one mid-transfer as a # crash would: intent recorded, nothing published, source intact. _interrupt(stack) page.reload() page.get_by_test_id("archive-recovery").wait_for() row = page.get_by_test_id("recovery-row").first expect(row).to_have_attribute("data-classification", "resumable") expect(row.get_by_test_id("recovery-reason")).to_contain_text("source present") page.get_by_test_id("resolve-recovery").click() expect(page.get_by_test_id("archive-recovery")).to_have_count(0) assert (stack.seeded.lib / "rome" / "a.jpg").exists(), "recovery may not move anything" def test_ambiguous_evidence_offers_no_action_at_all(page, stack): stack.start(worker=False) stack.register() _open(page, stack) page.get_by_test_id("start-archive").click() expect(page.get_by_test_id("archive-result")).to_be_visible() # Journal says the copy is verified, but the medium holds nothing: no evidence # supports either finishing or retrying this item. _interrupt(stack, state="verified") page.reload() page.get_by_test_id("archive-recovery").wait_for() expect(page.get_by_test_id("recovery-row").first).to_have_attribute( "data-classification", "manual" ) expect(page.get_by_test_id("recovery-manual")).to_be_visible() expect(page.get_by_test_id("resolve-recovery")).to_have_count(0) expect(page.get_by_test_id("no-safe-recovery")).to_be_visible() def _interrupt(stack, *, state: str = "transferring") -> None: """Leave the plan's first operation in a non-terminal journal state.""" with session_factory(stack.seeded) as sf: from photo_pipeline.services.archive_journal import ArchiveJournal journal = ArchiveJournal(sf) plan_id = stack.plans()[0]["id"] operation = journal.operations(plan_id)[0] journal.begin(operation["id"], worker_id="crashed", fencing_token=1) if state != "transferring": journal.transition(operation["id"], state, fencing_token=1) # ── offline browsing and restore ───────────────────────────────────────────── def test_archived_photos_stay_browsable_while_the_medium_is_away(page, stack): stack.start() stack.register() _archive(page, stack) stack.unmount() page.reload() page.get_by_test_id("archived-assets").wait_for() row = page.get_by_test_id("archived-row").first expect(row.get_by_test_id("archived-availability")).to_have_text("archived · medium away") expect(row.get_by_test_id("archived-path")).to_contain_text("rome/") expect(row.get_by_test_id("archived-medium")).to_have_text("external") expect(page.get_by_test_id("mount-instruction")).to_contain_text("external") # The retained protected preview is served even though the original is gone. preview = row.get_by_test_id("archived-preview") assert preview.evaluate("img => img.complete && img.naturalWidth > 0") # Restoring is impossible right now, and says so instead of failing later. expect(page.get_by_test_id("start-restore")).to_be_disabled() def test_restoring_brings_the_photos_back_without_losing_identity(page, stack): stack.start() stack.register() _archive(page, stack) before = {asset["id"] for asset in stack.assets()} page.reload() page.get_by_test_id("restore").wait_for() expect(page.get_by_test_id("restore-row").first.get_by_test_id("restore-destination")).to_contain_text( str(stack.seeded.lib / "rome") ) page.get_by_test_id("start-restore").click() wait_until( lambda: all(a["availability_state"] == "active" for a in stack.assets()), timeout=30, ) assert {asset["id"] for asset in stack.assets()} == before # same identities assert (stack.seeded.lib / "rome" / "a.jpg").exists() assert _archived_paths(stack) == ["a.jpg", "b.jpg"] # the archive copy stays def test_a_taken_name_is_restored_beside_it_never_over_it(page, stack): stack.start() stack.register() _archive(page, stack) squatter = stack.seeded.lib / "rome" / "a.jpg" squatter.parent.mkdir(parents=True, exist_ok=True) squatter.write_bytes(b"a different photo now lives here") page.reload() page.get_by_test_id("restore").wait_for() destinations = page.get_by_test_id("restore-destination").all_inner_texts() assert any("(restored)" in text for text in destinations), destinations page.get_by_test_id("start-restore").click() wait_until( lambda: all(a["availability_state"] == "active" for a in stack.assets()), timeout=30, ) assert squatter.read_bytes() == b"a different photo now lives here" assert (stack.seeded.lib / "rome" / "a (restored).jpg").exists() # ── keyboard ───────────────────────────────────────────────────────────────── def test_the_whole_archive_can_be_confirmed_from_the_keyboard(page, stack): stack.start() stack.register() _open(page, stack) button = page.get_by_test_id("start-archive") button.focus() expect(button).to_be_focused() page.keyboard.press("Enter") expect(page.get_by_test_id("detail-state")).to_have_text("complete", timeout=RUN_TIMEOUT) assert _archived_paths(stack) == ["a.jpg", "b.jpg"]