US06-05: Operate Archive and Restore in the Browser (#81)
This commit was merged in pull request #81.
This commit is contained in:
@@ -393,6 +393,115 @@ def mark_upload_ready(seeded: Seeded, *, unverified: tuple[str, ...] = ()) -> No
|
||||
session.commit()
|
||||
|
||||
|
||||
# ── Phase F: an archivable album and a mountable fake medium ─────────────────
|
||||
|
||||
|
||||
def mark_uploaded(seeded: Seeded, *, album: str = "rome") -> None:
|
||||
"""Give every seeded photo the verified upload evidence archiving requires.
|
||||
|
||||
Archiving refuses anything Immich is not proven to hold, and that proof is an
|
||||
upload batch — recorded here as fixture state so the archive journeys do not
|
||||
have to re-run an upload they are not testing.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from photo_pipeline.models import Asset, UploadBatch, UploadItem
|
||||
from photo_pipeline.services.hashing import sha256_file
|
||||
|
||||
with session_factory(seeded) as sf:
|
||||
with sf() as session:
|
||||
batch_id = str(uuid.uuid4())
|
||||
session.add(
|
||||
UploadBatch(
|
||||
id=batch_id,
|
||||
album=album,
|
||||
folder=str(seeded.lib / album),
|
||||
album_name=album,
|
||||
state="succeeded",
|
||||
preflight_token="v1:e2e",
|
||||
outcome_state="verified",
|
||||
created_at=NOW,
|
||||
)
|
||||
)
|
||||
for asset in session.scalars(select(Asset)):
|
||||
if not asset.current_path:
|
||||
continue
|
||||
session.add(
|
||||
UploadItem(
|
||||
batch_id=batch_id,
|
||||
asset_id=asset.id,
|
||||
path=asset.current_path,
|
||||
sha256=sha256_file(asset.current_path),
|
||||
sha1="0" * 40,
|
||||
state="sent",
|
||||
outcome="uploaded",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
class ArchiveStack:
|
||||
"""A seeded, archivable library plus the server, the worker, and a fake medium.
|
||||
|
||||
The medium is an ordinary directory whose marker file makes it identifiable;
|
||||
``unmount()`` takes that marker away, which is exactly what the application sees
|
||||
when an external disk is unplugged.
|
||||
"""
|
||||
|
||||
MARKER = ".photo-pipeline-archive.json"
|
||||
|
||||
def __init__(self, tmp_path: Path, seeded: Seeded) -> None:
|
||||
self.tmp_path = tmp_path
|
||||
self.seeded = seeded
|
||||
self.archive = tmp_path / "archive"
|
||||
self.archive.mkdir(exist_ok=True)
|
||||
self.server: Server | None = None
|
||||
self.worker: subprocess.Popen | None = None
|
||||
self.base = ""
|
||||
|
||||
def start(self, *, worker: bool = True, extra_env: dict[str, str] | None = None) -> "ArchiveStack":
|
||||
env = {"PHOTO_PIPELINE_ARCHIVE_FREE_SPACE_RESERVE_BYTES": "0", **(extra_env or {})}
|
||||
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 register(self, name: str = "external") -> dict:
|
||||
response = httpx.post(
|
||||
f"{self.base}/api/v1/archive-locations",
|
||||
json={"name": name, "root": str(self.archive)},
|
||||
timeout=20,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def unmount(self) -> None:
|
||||
(self.archive / self.MARKER).rename(self.archive / f"{self.MARKER}.away")
|
||||
|
||||
def remount(self) -> None:
|
||||
(self.archive / f"{self.MARKER}.away").rename(self.archive / self.MARKER)
|
||||
|
||||
def plans(self) -> list[dict]:
|
||||
return httpx.get(f"{self.base}/api/v1/archive-plans", timeout=20).json()["plans"]
|
||||
|
||||
def assets(self) -> list[dict]:
|
||||
return httpx.get(
|
||||
f"{self.base}/api/v1/inventory/assets", params={"limit": 200}, timeout=20
|
||||
).json()["items"]
|
||||
|
||||
def restart_server(self) -> None:
|
||||
self.server.stop()
|
||||
self.server.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.worker is not None:
|
||||
self.worker.kill()
|
||||
self.worker.wait(timeout=10)
|
||||
if self.server is not None:
|
||||
self.server.stop()
|
||||
|
||||
|
||||
class UploadStack:
|
||||
"""A seeded, upload-ready library plus the server, worker, and fake Immich."""
|
||||
|
||||
|
||||
302
tests/e2e/test_archive_ui.py
Normal file
302
tests/e2e/test_archive_ui.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user