diff --git a/frontend/css/app.css b/frontend/css/app.css index 8480aad..01b1ab1 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -165,6 +165,11 @@ a.link:hover { text-decoration: underline; } .badge.approved { color: var(--ok); border-color: var(--ok); } .badge.error { color: var(--danger); border-color: var(--danger); } +/* Renames: the preview table must show whole paths, so let them wrap rather than + truncate — a hidden path segment is exactly what makes a move a surprise. */ +.grid th, .grid td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; } +.path { font-family: ui-monospace, monospace; font-size: 0.85em; word-break: break-all; } + /* Narrow screens: stack the panes so blockers and actions stay reachable. */ @media (max-width: 720px) { .two-pane { grid-template-columns: 1fr; } diff --git a/frontend/index.html b/frontend/index.html index 0cde89c..47c23bb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -17,6 +17,7 @@ Library Analyze Albums + Renames Stats diff --git a/frontend/js/api.js b/frontend/js/api.js index cadd2a6..edaa1f0 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -99,4 +99,19 @@ export const api = { body: JSON.stringify(payload), ...opts, }), + + // ── Renames: plan, confirm, apply, recover ────────────────────────────── + listPlans: (opts = {}) => request("/rename-plans", opts), + buildPlan: (opts = {}) => request("/rename-plans", { method: "POST", ...opts }), + getPlan: (id, opts = {}) => request(`/rename-plans/${encodeURIComponent(id)}`, opts), + applyPlan: (id, payload, opts = {}) => + request(`/rename-plans/${encodeURIComponent(id)}/apply`, { + method: "POST", + body: JSON.stringify(payload), + ...opts, + }), + rollbackPlan: (id, opts = {}) => + request(`/rename-plans/${encodeURIComponent(id)}/rollback`, { method: "POST", ...opts }), + renameRecovery: (opts = {}) => request("/rename-recovery", opts), + resolveRecovery: (opts = {}) => request("/rename-recovery/resolve", { method: "POST", ...opts }), }; diff --git a/frontend/js/app.js b/frontend/js/app.js index 0a2ea4e..0d11180 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1,5 +1,6 @@ import { api } from "./api.js"; import { navigate, onRouteChange, parseHash } from "./router.js"; +import { renderRenames, setRenamesRender } from "./renames.js"; import { renderAlbums, renderAnalyze, @@ -362,11 +363,13 @@ function render() { else if (path === "/library") renderLibrary(root, params); else if (path === "/analyze") renderAnalyze(root, params); else if (path === "/albums") renderAlbums(root, params); + else if (path === "/renames") renderRenames(root, params); else if (path === "/stats") renderStats(root, params); else show(errorBanner("Unknown view")); } // Let views re-render the current route after a mutation. setRender(render); +setRenamesRender(render); onRouteChange(render); render(); diff --git a/frontend/js/renames.js b/frontend/js/renames.js new file mode 100644 index 0000000..28438df --- /dev/null +++ b/frontend/js/renames.js @@ -0,0 +1,357 @@ +// Renames view (US04-05): preview a rename plan, confirm it with the server-issued +// token, watch it run, and resolve anything an interruption left behind. +// +// Nothing here decides what is safe. Blocking issues, plan applicability, and +// recovery classifications all come from the server; the view only shows them and +// refuses to offer an action the server would reject. +import { api } from "./api.js"; +import { el, errorBanner, setActiveNav } from "./dom.js"; + +// What the last confirm did, for this tab only. Deliberately not persisted: after a +// reload the page must show what the journal says, not what this page remembers. +let outcome = null; + +let render = () => {}; +export function setRenamesRender(fn) { + render = fn; +} + +const BLOCKED_TITLE = "an unresolved rename must be resolved first"; + +export async function renderRenames(root, params = {}) { + setActiveNav("renames"); + let plans, recovery; + try { + [plans, recovery] = await Promise.all([api.listPlans(), api.renameRecovery()]); + } catch (error) { + root.replaceChildren(errorBanner(`Failed to load renames: ${error.message}`)); + return; + } + + const selectedId = params.plan || (plans.items[0] && plans.items[0].id) || null; + let plan = null; + if (selectedId) { + try { + plan = await api.getPlan(selectedId); + } catch (error) { + root.replaceChildren(errorBanner(`Failed to load plan: ${error.message}`)); + return; + } + } + + const blocked = recovery.blocks_mutation; + root.replaceChildren( + el("h1", {}, "Renames"), + recoveryPanel(recovery), + el( + "div", + { class: "toolbar" }, + el( + "button", + { + class: "primary", + "data-testid": "build-plan", + disabled: blocked ? "disabled" : false, + title: blocked ? BLOCKED_TITLE : false, + onclick: () => run(() => api.buildPlan(), "Planning…"), + }, + "Build plan from approved names" + ) + ), + outcomeBanner(), + plan + ? planSection(plan, blocked) + : el( + "p", + { class: "muted", "data-testid": "no-plan" }, + "No rename plan yet — approve album names, then build a plan to preview the moves." + ) + ); +} + +// ── preview ────────────────────────────────────────────────────────────────── +function planSection(plan, blocked) { + const blockingIssues = plan.operations.flatMap((operation) => + operation.issues.filter((issue) => issue.blocking) + ); + + return el( + "div", + { "data-testid": "plan" }, + el( + "div", + { class: "decision-bar" }, + el("span", { class: `badge ${plan.state}`, "data-testid": "plan-state" }, plan.state), + el( + "span", + { class: "badge", "data-testid": "operation-count" }, + `${plan.operation_count} folder${plan.operation_count === 1 ? "" : "s"}` + ), + el( + "span", + { class: "badge", "data-testid": "affected-assets" }, + `${plan.operations.reduce((sum, o) => sum + o.asset_count, 0)} photos` + ), + blockingIssues.length + ? el( + "span", + { class: "badge attention", "data-testid": "blocking-count" }, + `${blockingIssues.length} blocking issue${blockingIssues.length === 1 ? "" : "s"}` + ) + : null + ), + plan.blockers.length + ? el( + "div", + { class: "alert", role: "alert", "data-testid": "plan-blockers" }, + `This plan cannot run: ${plan.blockers.join("; ")}` + ) + : null, + operationsTable(plan), + confirmBlock(plan, blocked) + ); +} + +function operationType(operation) { + if (operation.case_only) return "case-only rename"; + if (!operation.same_filesystem) return "cross-filesystem move"; + return "rename"; +} + +function operationsTable(plan) { + if (!plan.operations.length) { + return el("p", { class: "muted", "data-testid": "no-operations" }, "This plan moves nothing."); + } + return el( + "table", + { class: "grid", "data-testid": "operations" }, + el( + "thead", + {}, + el( + "tr", + {}, + ...["#", "Album", "From", "To", "Type", "Photos", "Issues", "State"].map((label) => + el("th", { scope: "col" }, label) + ) + ) + ), + el( + "tbody", + {}, + ...plan.operations.map((operation) => + el( + "tr", + { "data-testid": "operation-row", "data-album": operation.album }, + el("td", {}, String(operation.sequence)), + el("td", { "data-testid": "op-album" }, operation.album), + // Full paths, not basenames: the whole point of the preview is that no + // move is a surprise. + el("td", { class: "path", "data-testid": "op-source" }, operation.source_path), + el("td", { class: "path", "data-testid": "op-destination" }, operation.destination_path), + el("td", { "data-testid": "op-type" }, operationType(operation)), + el("td", { "data-testid": "op-count" }, String(operation.asset_count)), + el( + "td", + { "data-testid": "op-issues" }, + operation.issues.length + ? operation.issues.map((issue) => + el( + "span", + { + class: `badge ${issue.blocking ? "attention" : ""}`, + "data-testid": "op-issue", + "data-code": issue.code, + title: issue.message, + }, + `${issue.blocking ? "blocks" : "note"}: ${issue.code}` + ) + ) + : el("span", { class: "muted" }, "—") + ), + el( + "td", + {}, + el("span", { "data-testid": "op-state" }, operation.journal_state), + operation.verified_at + ? el("span", { class: "badge complete", "data-testid": "op-verified" }, "verified") + : null, + operation.error_code + ? el( + "div", + { class: "blocker", "data-testid": "op-error" }, + `${operation.error_code}: ${operation.error_message || ""}` + ) + : null + ) + ) + ) + ) + ); +} + +// ── confirmation ───────────────────────────────────────────────────────────── +// Journal states whose content can still be moved back; `complete` is not one of +// them (photo_pipeline/services/rename_journal.py: complete is terminal). +const REVERSIBLE = new Set(["moved", "database_updated", "verified", "rollback_required"]); + +function rollbackable(plan) { + return plan.operations.some((operation) => REVERSIBLE.has(operation.journal_state)); +} + +function confirmBlock(plan, blocked) { + const applyable = plan.applicable && !blocked; + return el( + "div", + { class: "card", "data-testid": "confirm" }, + el("h2", {}, "Confirm"), + // The token is shown, not just sent: a confirmation the user cannot see is a + // confirmation they cannot check against what the preview claimed. + el( + "p", + { class: "muted", "data-testid": "confirm-token" }, + `Plan ${plan.id} · version ${plan.version} · checksum ${plan.checksum.slice(0, 12)}` + ), + el( + "p", + { "data-testid": "cancel-note" }, + "Once confirmed, the run cannot be cancelled part-way: each folder is moved, " + + "verified, and recorded before the next one starts. An interruption is " + + "recoverable from the journal — it is never left half-applied." + ), + el( + "div", + { class: "toolbar" }, + el( + "button", + { + class: "primary", + "data-testid": "apply-plan", + disabled: applyable ? false : "disabled", + title: blocked + ? BLOCKED_TITLE + : plan.applicable + ? false + : `plan is ${plan.state} — it cannot be applied`, + onclick: () => + run( + () => + api.applyPlan(plan.id, { + expected_version: plan.version, + expected_checksum: plan.checksum, + }), + `Applying ${plan.operation_count} rename(s)…` + ), + }, + `Apply ${plan.operation_count} rename(s)` + ), + // Rollback is recovery, not undo: a completed rename is terminal and stays + // that way. The button appears only while operations are still reversible. + rollbackable(plan) + ? el( + "button", + { + "data-testid": "rollback-plan", + onclick: () => run(() => api.rollbackPlan(plan.id), "Rolling back…"), + }, + "Roll back the unfinished moves" + ) + : null + ), + el("p", { role: "status", "aria-live": "polite", "data-testid": "apply-progress" }, "") + ); +} + +// Runs a mutating call, showing progress while it is in flight and leaving the +// result — including a stale-confirmation conflict — visible afterwards. +async function run(action, progressText) { + const progress = document.querySelector('[data-testid="apply-progress"]'); + if (progress) progress.textContent = progressText; + try { + outcome = { kind: "ok", result: await action() }; + } catch (error) { + outcome = error.status === 409 ? { kind: "conflict" } : { kind: "error", error }; + } + render(); +} + +function outcomeBanner() { + if (!outcome) return null; + if (outcome.kind === "conflict") { + return el( + "div", + { class: "alert", role: "alert", "data-testid": "conflict" }, + "This plan changed on the server since it was loaded. Nothing was applied; " + + "the preview below is the current state — review it and confirm again." + ); + } + if (outcome.kind === "error") { + return el( + "div", + { class: "alert", role: "alert", "data-testid": "apply-error" }, + `Failed: ${outcome.error.message}` + ); + } + const result = outcome.result; + if (result.applied === undefined) return null; // a build or recovery call + return el( + "div", + { class: "alert", role: "status", "data-testid": "apply-result" }, + `Applied ${result.applied}, failed ${result.failed}, skipped ${result.skipped}. ` + + `The plan is now ${result.state}.` + ); +} + +// ── recovery ───────────────────────────────────────────────────────────────── +function recoveryPanel(recovery) { + if (!recovery.items.length) return null; + const recoverable = recovery.items.filter((item) => item.classification !== "manual"); + return el( + "div", + { class: "alert", role: "alert", "data-testid": "recovery-banner" }, + el( + "strong", + {}, + recovery.blocks_mutation + ? "A rename was interrupted — other changes are paused" + : "A rename is unresolved" + ), + el( + "p", + { "data-testid": "recovery-explanation" }, + recovery.blocks_mutation + ? "Some folders on disk may disagree with the database. Planning, applying, and " + + "album-name changes stay blocked until this is resolved; browsing is unaffected." + : "These operations never reached a terminal state." + ), + el( + "ul", + {}, + ...recovery.items.map((item) => + el( + "li", + { "data-testid": "recovery-item", "data-classification": item.classification }, + `${item.album}: ${item.journal_state} — ${item.classification} — ${item.reason} ` + + `(${item.source_path} → ${item.destination_path})` + ) + ) + ), + // Only evidence-safe work is offered. Ambiguous operations get no button at + // all — an action the server would refuse must not look available. + recoverable.length + ? el( + "button", + { + "data-testid": "resolve-recovery", + onclick: () => run(() => api.resolveRecovery(), "Resolving…"), + }, + `Resolve ${recoverable.length} recoverable rename(s) from evidence` + ) + : el( + "p", + { "data-testid": "manual-only" }, + "No automatic action is safe here: source and destination both exist, or neither " + + "does. Inspect the paths above and resolve them by hand." + ) + ); +} diff --git a/frontend/js/views.js b/frontend/js/views.js index 9049b67..4ed32e4 100644 --- a/frontend/js/views.js +++ b/frontend/js/views.js @@ -327,11 +327,15 @@ export async function renderStats(root) { // Approving never renames anything — it records the approved name only. export async function renderAlbums(root, params = {}) { setActiveNav("albums"); - let evidence, proposals; + let evidence, proposals, recovery; try { - [evidence, proposals] = await Promise.all([ + [evidence, proposals, recovery] = await Promise.all([ api.albumEvidence({ limit: 500 }), api.albumProposals(), + // An unresolved rename means folder paths on disk may disagree with the + // database, so the server refuses name changes until it is resolved. Ask up + // front rather than offering a button that is guaranteed to fail. + api.renameRecovery(), ]); } catch (error) { root.replaceChildren(errorBanner(`Failed to load albums: ${error.message}`)); @@ -370,17 +374,27 @@ export async function renderAlbums(root, params = {}) { }) ); + const renameBlocked = recovery.blocks_mutation; const detail = selected ? albumDetail( root, folders.find((folder) => folder.album === selected), byAlbum.get(selected), - params + params, + renameBlocked ) : el("p", { class: "muted" }, "No albums with evidence yet."); root.replaceChildren( el("h1", {}, "Albums"), + renameBlocked + ? el( + "div", + { class: "alert", role: "alert", "data-testid": "rename-blocked" }, + "An interrupted rename is unresolved. Name changes are paused until it is " + + "resolved on the Renames page; browsing stays available." + ) + : null, el( "div", { class: "toolbar" }, @@ -389,6 +403,8 @@ export async function renderAlbums(root, params = {}) { { class: "primary", "data-testid": "generate-proposals", + disabled: renameBlocked ? "disabled" : false, + title: renameBlocked ? "an unresolved rename must be resolved first" : false, onclick: async () => { try { await api.generateProposals({}); @@ -405,7 +421,7 @@ export async function renderAlbums(root, params = {}) { ); } -function albumDetail(root, folder, proposal, params) { +function albumDetail(root, folder, proposal, params, renameBlocked = false) { if (!folder) return el("p", { class: "muted" }, "Album not found."); const evidenceBlock = el( @@ -473,8 +489,13 @@ function albumDetail(root, folder, proposal, params) { { class: "primary", "data-testid": "approve", - disabled: proposal.stale || proposal.status === "error" ? "disabled" : false, - title: proposal.stale ? "evidence changed — regenerate first" : false, + disabled: + renameBlocked || proposal.stale || proposal.status === "error" ? "disabled" : false, + title: renameBlocked + ? "an unresolved rename must be resolved first" + : proposal.stale + ? "evidence changed — regenerate first" + : false, onclick: () => mutate(root, () => api.approveProposal(proposal.album, { expected_version: proposal.version }), params), }, proposal.status === "approved" ? "Approved" : "Approve name" diff --git a/photo_pipeline/api/routes/albums.py b/photo_pipeline/api/routes/albums.py index e7d3dbd..3d029ae 100644 --- a/photo_pipeline/api/routes/albums.py +++ b/photo_pipeline/api/routes/albums.py @@ -17,6 +17,7 @@ from photo_pipeline.schemas import ( ) from photo_pipeline.services.albums import AlbumService from photo_pipeline.services.proposals import ConflictError, ProposalError, ProposalService +from photo_pipeline.services.rename_journal import RenameJournal router = APIRouter(tags=["albums"]) @@ -37,6 +38,22 @@ def _error(status: int, code: str, message: str) -> JSONResponse: return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}}) +def _unresolved_rename(request: Request) -> JSONResponse | None: + """Refuse work that feeds renaming while the library may be half-renamed. + + An unresolved rename means some folder paths on disk disagree with the database, + so evidence gathered now — and any approval based on it — could describe a + location that no longer exists. Reading stays available; only mutation stops. + """ + if RenameJournal(request.app.state.session_factory).blocks_mutation(): + return _error( + 409, + "rename_recovery_required", + "an unresolved rename is in progress; resolve it before changing album names", + ) + return None + + @router.get("/albums/evidence") def evidence( request: Request, @@ -47,8 +64,8 @@ def evidence( @router.post("/albums/proposals") -def generate(body: GenerateProposalsRequest, request: Request) -> dict: - return _proposals(request).generate(body.albums) +def generate(body: GenerateProposalsRequest, request: Request): + return _unresolved_rename(request) or _proposals(request).generate(body.albums) @router.get("/albums/proposals") @@ -76,6 +93,9 @@ def edit_proposal(album: str, body: EditProposalRequest, request: Request): @router.post("/albums/proposals/{album:path}/approve") def approve_proposal(album: str, body: ApproveProposalRequest, request: Request): + blocked = _unresolved_rename(request) + if blocked is not None: + return blocked try: return _proposals(request).approve(album, expected_version=body.expected_version) except ConflictError as error: diff --git a/photo_pipeline/services/rename_journal.py b/photo_pipeline/services/rename_journal.py index d5e90b7..bf29bb1 100644 --- a/photo_pipeline/services/rename_journal.py +++ b/photo_pipeline/services/rename_journal.py @@ -229,6 +229,12 @@ class RenameJournal: classification, reason = _classify(row["journal_state"], source_exists, destination_exists) return { "operation_id": operation_id, + "plan_id": row["plan_id"], + "album": row["album"], + # The paths travel with the verdict so a reviewer can see which folder is + # unresolved without correlating an opaque operation id by hand. + "source_path": row["source_path"], + "destination_path": row["destination_path"], "journal_state": row["journal_state"], "classification": classification, "reason": reason, diff --git a/photo_pipeline/services/renames.py b/photo_pipeline/services/renames.py index 53d08b0..dd86ffe 100644 --- a/photo_pipeline/services/renames.py +++ b/photo_pipeline/services/renames.py @@ -418,8 +418,17 @@ def _plan_dict(plan: RenamePlan, operations: list[RenameOperation]) -> dict: "asset_count": operation.asset_count, "same_filesystem": operation.same_filesystem, "case_only": operation.case_only, - "issues": json.loads(operation.issues or "[]"), + # Severity is decided here, not in the browser: a client that has to + # keep its own copy of BLOCKING_CODES will eventually disagree with + # the validator about whether a plan can run. + "issues": [ + {**issue, "blocking": issue["code"] in BLOCKING_CODES} + for issue in json.loads(operation.issues or "[]") + ], "journal_state": operation.journal_state, + "verified_at": operation.verified_at.isoformat() if operation.verified_at else None, + "error_code": operation.error_code, + "error_message": operation.error_message, } for operation in operations ], diff --git a/pyproject.toml b/pyproject.toml index 2e7f3b8..6acbc12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,4 +30,5 @@ testpaths = ["tests"] markers = [ "phase_b: Phase B end-to-end acceptance (US02-07) — API, worker-recovery, and browser journeys", "phase_c: Phase C end-to-end acceptance (US03-05) — album proposal API and browser journeys", + "phase_d: Phase D end-to-end acceptance (US04-06) — guarded rename API, fault, and browser journeys", ] diff --git a/tests/e2e/test_renames_ui.py b/tests/e2e/test_renames_ui.py new file mode 100644 index 0000000..dddc888 --- /dev/null +++ b/tests/e2e/test_renames_ui.py @@ -0,0 +1,359 @@ +"""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 uuid +from datetime import datetime, timezone + +import httpx +import pytest +from playwright.sync_api import expect + +from tests.e2e._pipeline_harness import Server, image, seed_library + +# Part of the Phase D acceptance command (US04-06); mapped to US04-05 for traceability. +pytestmark = pytest.mark.phase_d + +NOW = datetime(2026, 1, 1, tzinfo=timezone.utc) +TIMEOUT = 10 +APPROVED = "2019 Rome" + + +def _seed(tmp_path): + """A library with one album ("rome") whose two photos are SFW and analysed.""" + seeded = seed_library(tmp_path, {}, {}) + album = seeded.lib / "rome" + album.mkdir() + image(album / "a.jpg", 1) + image(album / "b.jpg", 2) + + from sqlalchemy import select + + from photo_pipeline.models import AnalysisResult, Asset, SafetyReview + from photo_pipeline.services.inventory import InventoryService + + with _factory(seeded) as sf: + InventoryService(sf).scan(seeded.lib) + with sf() as session: + rows = list(session.execute(select(Asset.id, Asset.current_path)).all()) + for asset_id, path in rows: + session.add( + SafetyReview( + id=str(uuid.uuid4()), asset_id=asset_id, decision="sfw", created_at=NOW + ) + ) + session.add( + AnalysisResult( + asset_id=asset_id, + status="analyzed", + description=f"a view of {path}", + tags='["ruins", "city"]', + approx_year=2019, + location_hint="Rome", + ) + ) + session.commit() + return seeded + + +class _factory: + """Session factory against the seeded database, for the few things a test has to + set up or inspect below the API (journal states, mainly).""" + + def __init__(self, seeded): + self._seeded = seeded + + def __enter__(self): + from photo_pipeline.config import Config + from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations + + config = Config.from_env( + { + "PHOTO_PIPELINE_DATA_DIR": str(self._seeded.data), + "PHOTO_PIPELINE_LIBRARY_ROOTS": str(self._seeded.lib), + } + ) + run_migrations(config.database_url) + self._engine = create_db_engine(config.database_url) + return create_session_factory(self._engine) + + def __exit__(self, *_): + self._engine.dispose() + return False + + +@pytest.fixture +def server(tmp_path): + seeded = _seed(tmp_path) + running = Server(seeded).start() + running.seeded = seeded + try: + yield running + finally: + running.stop() + + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _approve(base, *, album="rome", name=APPROVED): + """Generate a proposal, set its final name, and approve it — the state a rename + plan is built from.""" + httpx.post(f"{base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT).raise_for_status() + current = httpx.get(f"{base}/api/v1/albums/proposals/{album}", timeout=TIMEOUT).json() + httpx.post( + f"{base}/api/v1/albums/proposals/{album}/edit", + json={"name": name, "expected_version": current["version"]}, + timeout=TIMEOUT, + ).raise_for_status() + current = httpx.get(f"{base}/api/v1/albums/proposals/{album}", timeout=TIMEOUT).json() + httpx.post( + f"{base}/api/v1/albums/proposals/{album}/approve", + json={"expected_version": current["version"]}, + timeout=TIMEOUT, + ).raise_for_status() + + +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 _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_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 _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" diff --git a/tests/story_traceability.json b/tests/story_traceability.json index 3abdf46..eb8fb8b 100644 --- a/tests/story_traceability.json +++ b/tests/story_traceability.json @@ -97,6 +97,9 @@ ], "US04-04": [ "tests/integration/test_rename_recovery.py" + ], + "US04-05": [ + "tests/e2e/test_renames_ui.py" ] } }