360 lines
15 KiB
Python
360 lines
15 KiB
Python
"""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"
|