"""Browser journeys for the rename view (US04-05). Covers previewing every affected path, confirming with the server-issued token, applying with visible progress and terminal verification, refusing a stale confirmation, previewing a collision as blocking, and resolving an interrupted rename — including the check that an unresolved rename really does block unrelated mutations at the API, not just in the UI. Renames really happen here: the assertions read the filesystem afterwards. """ from __future__ import annotations import httpx import pytest from playwright.sync_api import expect from tests.e2e._pipeline_harness import Server, approve_album, seed_album, session_factory # Part of the Phase D acceptance command (US04-06); mapped to US04-05 for traceability. pytestmark = pytest.mark.phase_d TIMEOUT = 10 APPROVED = "2019 Rome" @pytest.fixture def server(tmp_path): seeded = seed_album(tmp_path) running = Server(seeded).start() running.seeded = seeded try: yield running finally: running.stop() # ── helpers ────────────────────────────────────────────────────────────────── def _approve(base, *, name=APPROVED): approve_album(base, name=name) def _build(base): response = httpx.post(f"{base}/api/v1/rename-plans", timeout=TIMEOUT) response.raise_for_status() return response.json() def _open_plan(page, server): """Approve a name, open the view, and build the plan through the UI.""" _approve(server.base) page.goto(f"{server.base}/app/#/renames") page.get_by_test_id("build-plan").click() page.get_by_test_id("operations").wait_for() def _interrupt(seeded, plan, *, state="moving", make_destination=False): """Leave an operation in a non-terminal journal state, as a crash mid-apply would. ``make_destination`` also creates the destination folder, so source and destination both exist and the evidence becomes ambiguous (``manual``). """ from photo_pipeline.services.rename_journal import RenameJournal with session_factory(seeded) as sf: journal = RenameJournal(sf) operation = journal.operations(plan["id"])[0] journal.begin(operation["id"], worker_id="crashed", fencing_token=1) if state != "moving": journal.transition(operation["id"], state, fencing_token=1) if make_destination: (seeded.lib / APPROVED).mkdir(exist_ok=True) # ── preview ────────────────────────────────────────────────────────────────── def test_preview_shows_every_path_operation_type_and_count(page, server): errors = [] page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None) _open_plan(page, server) expect(page.get_by_test_id("plan-state")).to_have_text("validated") expect(page.get_by_test_id("operation-count")).to_have_text("1 folder") expect(page.get_by_test_id("affected-assets")).to_have_text("2 photos") row = page.get_by_test_id("operation-row").first # Whole paths, both sides — nothing about the move is left to be inferred. assert row.get_by_test_id("op-source").inner_text().endswith("/rome") assert row.get_by_test_id("op-destination").inner_text().endswith(f"/{APPROVED}") expect(row.get_by_test_id("op-type")).to_have_text("rename") expect(row.get_by_test_id("op-count")).to_have_text("2") expect(row.get_by_test_id("op-state")).to_have_text("planned") assert errors == [], f"console errors: {errors}" def test_confirmation_shows_the_server_token_and_the_cancellation_limit(page, server): _open_plan(page, server) plan = httpx.get(f"{server.base}/api/v1/rename-plans", timeout=TIMEOUT).json()["items"][0] token = page.get_by_test_id("confirm-token").inner_text() assert plan["id"] in token and f"version {plan['version']}" in token assert plan["checksum"][:12] in token # The limit on cancellation is stated before the button, not discovered after. assert "cannot be cancelled" in page.get_by_test_id("cancel-note").inner_text() def test_a_collision_is_previewed_as_blocking_and_cannot_be_applied(page, server): _approve(server.base) # Someone else already owns the destination name, with content worth keeping. (server.seeded.lib / APPROVED).mkdir() (server.seeded.lib / APPROVED / "precious.jpg").write_bytes(b"do not lose me") page.goto(f"{server.base}/app/#/renames") page.get_by_test_id("build-plan").click() page.get_by_test_id("operations").wait_for() expect(page.get_by_test_id("plan-state")).to_have_text("invalid") expect(page.get_by_test_id("plan-blockers")).to_contain_text("destination_exists") issue = page.get_by_test_id("op-issue").first expect(issue).to_have_attribute("data-code", "destination_exists") assert issue.inner_text().startswith("blocks") expect(page.get_by_test_id("apply-plan")).to_be_disabled() assert (server.seeded.lib / APPROVED / "precious.jpg").read_bytes() == b"do not lose me" assert (server.seeded.lib / "rome").is_dir() # ── apply ──────────────────────────────────────────────────────────────────── def test_apply_reports_progress_terminal_verification_and_moves_the_folder(page, server): _open_plan(page, server) # The progress region is live before anything runs, so the status it announces # during the run reaches assistive technology. progress = page.get_by_test_id("apply-progress") expect(progress).to_have_attribute("aria-live", "polite") page.get_by_test_id("apply-plan").click() expect(page.get_by_test_id("apply-result")).to_contain_text("Applied 1, failed 0, skipped 0") expect(page.get_by_test_id("plan-state")).to_have_text("applied") row = page.get_by_test_id("operation-row").first expect(row.get_by_test_id("op-state")).to_have_text("complete") expect(row.get_by_test_id("op-verified")).to_be_visible() assert (server.seeded.lib / APPROVED / "a.jpg").exists() assert not (server.seeded.lib / "rome").exists() def test_a_stale_confirmation_is_refused_and_explained(page, server): _open_plan(page, server) # Another client applies first, which bumps the plan version this page is holding. plan = httpx.get(f"{server.base}/api/v1/rename-plans", timeout=TIMEOUT).json()["items"][0] httpx.post( f"{server.base}/api/v1/rename-plans/{plan['id']}/apply", json={"expected_version": plan["version"]}, timeout=TIMEOUT, ).raise_for_status() page.get_by_test_id("apply-plan").click() expect(page.get_by_test_id("conflict")).to_be_visible() # The refusal is not a second run: the page now shows the server's real state. expect(page.get_by_test_id("plan-state")).to_have_text("applied") expect(page.get_by_test_id("apply-plan")).to_be_disabled() def test_the_applied_plan_survives_a_reload(page, server): _open_plan(page, server) page.get_by_test_id("apply-plan").click() expect(page.get_by_test_id("plan-state")).to_have_text("applied") page.reload() expect(page.get_by_test_id("plan-state")).to_have_text("applied") expect(page.get_by_test_id("operation-row").first.get_by_test_id("op-state")).to_have_text( "complete" ) # The result banner is tab state, not server state, so it correctly does not return. expect(page.get_by_test_id("apply-result")).to_have_count(0) def test_the_view_matches_the_journal_after_a_server_restart(page, server): _open_plan(page, server) page.get_by_test_id("apply-plan").click() expect(page.get_by_test_id("plan-state")).to_have_text("applied") server.stop() server.start() # same port, so the page reconnects to a genuinely fresh process page.reload() expect(page.get_by_test_id("plan-state")).to_have_text("applied") expect(page.get_by_test_id("operation-row").first.get_by_test_id("op-state")).to_have_text( "complete" ) expect(page.get_by_test_id("op-verified")).to_be_visible() def test_apply_can_be_confirmed_from_the_keyboard(page, server): _open_plan(page, server) page.get_by_test_id("apply-plan").focus() page.keyboard.press("Enter") expect(page.get_by_test_id("plan-state")).to_have_text("applied") assert (server.seeded.lib / APPROVED).is_dir() def test_a_finished_rename_offers_no_rollback(page, server): """Rollback is recovery, not undo: once an operation is `complete` the journal treats it as terminal, so the view must not suggest it can be reversed.""" _open_plan(page, server) page.get_by_test_id("apply-plan").click() expect(page.get_by_test_id("plan-state")).to_have_text("applied") expect(page.get_by_test_id("rollback-plan")).to_have_count(0) def test_rollback_returns_an_unfinished_move_to_its_source(page, server): _approve(server.base) plan = _build(server.base) # A crash after the move but before the database caught up: the content sits at # the destination and the operation is still reversible. _interrupt(server.seeded, plan, state="moving") (server.seeded.lib / "rome").rename(server.seeded.lib / APPROVED) with session_factory(server.seeded) as sf: from photo_pipeline.services.rename_journal import RenameJournal journal = RenameJournal(sf) journal.transition(journal.operations(plan["id"])[0]["id"], "moved", fencing_token=1) page.goto(f"{server.base}/app/#/renames") page.get_by_test_id("rollback-plan").click() expect(page.get_by_test_id("plan-state")).to_have_text("rolled_back") assert (server.seeded.lib / "rome" / "a.jpg").exists() assert not (server.seeded.lib / APPROVED).exists() # ── recovery ───────────────────────────────────────────────────────────────── def test_an_interrupted_rename_is_shown_and_offers_the_safe_action(page, server): _approve(server.base) plan = _build(server.base) _interrupt(server.seeded, plan) # source intact, destination free → resumable page.goto(f"{server.base}/app/#/renames") banner = page.get_by_test_id("recovery-banner") banner.wait_for() expect(page.get_by_test_id("recovery-item")).to_have_attribute( "data-classification", "resumable" ) # While it is unresolved nothing else may mutate the library. expect(page.get_by_test_id("build-plan")).to_be_disabled() expect(page.get_by_test_id("apply-plan")).to_be_disabled() page.get_by_test_id("resolve-recovery").click() expect(banner).to_have_count(0) expect(page.get_by_test_id("build-plan")).to_be_enabled() def test_ambiguous_evidence_offers_no_automatic_action(page, server): _approve(server.base) plan = _build(server.base) # Both paths exist: nothing can tell which side holds the truth. _interrupt(server.seeded, plan, make_destination=True) page.goto(f"{server.base}/app/#/renames") page.get_by_test_id("recovery-banner").wait_for() expect(page.get_by_test_id("recovery-item")).to_have_attribute("data-classification", "manual") expect(page.get_by_test_id("manual-only")).to_be_visible() # No button is offered for work the server would refuse to do. expect(page.get_by_test_id("resolve-recovery")).to_have_count(0) def test_an_unresolved_rename_blocks_unrelated_name_changes(page, server): _approve(server.base) plan = _build(server.base) _interrupt(server.seeded, plan, make_destination=True) page.goto(f"{server.base}/app/#/albums?album=rome") expect(page.get_by_test_id("rename-blocked")).to_be_visible() expect(page.get_by_test_id("approve")).to_be_disabled() expect(page.get_by_test_id("generate-proposals")).to_be_disabled() # The disabled buttons are not the only guard: the API refuses too. current = httpx.get(f"{server.base}/api/v1/albums/proposals/rome", timeout=TIMEOUT).json() refused = httpx.post( f"{server.base}/api/v1/albums/proposals/rome/approve", json={"expected_version": current["version"]}, timeout=TIMEOUT, ) assert refused.status_code == 409 assert refused.json()["error"]["code"] == "rename_recovery_required"