303 lines
12 KiB
Python
303 lines
12 KiB
Python
"""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"]
|