From b9b5ab7f51f2c1d96f8556eccf58815cbc6c8c5f Mon Sep 17 00:00:00 2001 From: domverse Date: Sun, 16 Aug 2026 12:05:32 +0200 Subject: [PATCH] US04-06: Automate Phase D End-to-End Acceptance --- README.md | 34 +++ tests/e2e/_pipeline_harness.py | 87 +++++++ tests/e2e/test_phase_d_pipeline.py | 377 +++++++++++++++++++++++++++++ tests/e2e/test_renames_ui.py | 109 ++------- tests/e2e/test_traceability.py | 7 + tests/story_traceability.json | 3 + 6 files changed, 530 insertions(+), 87 deletions(-) create mode 100644 tests/e2e/test_phase_d_pipeline.py diff --git a/README.md b/README.md index 8f7ea29..6a4fd7e 100644 --- a/README.md +++ b/README.md @@ -102,3 +102,37 @@ work_item/scripts/python -m pytest tests/e2e -m phase_c -q The deterministic naming provider is enabled only by test configuration (`PHOTO_PIPELINE_FAKE_NAMING_LOG`); without it the application falls back to the offline naming-policy name. Phase A and B suites remain green in the full run above. + +### Phase D acceptance gate + +Phase D (Epic E04: guarded renaming) is the first phase that changes the library on +disk, so its gate is the strictest. One command runs the rename API journeys, the +filesystem fault injection, and the browser suite: + +```bash +work_item/scripts/python -m pytest tests/e2e -m phase_d -q +``` + +- `tests/e2e/test_phase_d_pipeline.py` drives a real server over HTTP: plan and + export, confirmation with the plan version and checksum (a stale token is refused + without touching disk), a valid apply, the case-only rename procedure, a collision + whose occupant survives, a source that changed after planning, and durability + across a full restart. +- **Fault injection is real.** `PHOTO_PIPELINE_FAULT_AFTER=` kills the + server process the instant that state is persisted. The suite crashes it at every + journal transition in turn (`moving`, `moved`, `database_updated`, `verified`), + starts a fresh process against the same database and library, and requires recovery + to converge from journal and disk evidence alone — with the asset set, the stable + IDs, and every content hash unchanged. Ambiguous evidence is never guessed: it stays + classified `manual` and keeps blocking. An unresolved rename is the cancellation + boundary — there is no cancel once a run starts, and unrelated mutations (album + proposal generation and approval) are refused with 409 `rename_recovery_required` + until it is resolved, while reads stay available. +- `tests/e2e/test_renames_ui.py` covers the browser journeys: preview of every + affected path, confirmation carrying the server-issued token, apply with progress + and terminal verification, stale confirmation, collision, interruption, recovery, + rollback, keyboard confirmation, and the view still matching the journal after a + server restart. + +The fault barrier is test-only configuration; without `PHOTO_PIPELINE_FAULT_AFTER` +the apply path has no crash points. Phases A–C remain green in the full run above. diff --git a/tests/e2e/_pipeline_harness.py b/tests/e2e/_pipeline_harness.py index 39de202..144ad26 100644 --- a/tests/e2e/_pipeline_harness.py +++ b/tests/e2e/_pipeline_harness.py @@ -14,7 +14,9 @@ import socket import subprocess import sys import time +import uuid from dataclasses import dataclass +from datetime import datetime, timezone from pathlib import Path import httpx @@ -159,3 +161,88 @@ def wait_until(predicate, *, timeout: float = 20, interval: float = 0.1): return value time.sleep(interval) raise AssertionError("condition not met before timeout") + + +# ── Phase D: an analysed album, ready to be named and renamed ──────────────── + +NOW = datetime(2026, 1, 1, tzinfo=timezone.utc) + + +class session_factory: + """Session factory against a seeded database, for the few things a test has to + set up or inspect below the API — journal states, mainly.""" + + def __init__(self, seeded: Seeded) -> None: + 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 + + +def seed_album(tmp_path: Path, album: str = "rome", names: tuple[str, ...] = ("a.jpg", "b.jpg")): + """A library holding one album folder whose photos are confirmed SFW and analysed + — the state a naming proposal, and therefore a rename plan, is built from.""" + from sqlalchemy import select + + from photo_pipeline.models import AnalysisResult, Asset, SafetyReview + from photo_pipeline.services.inventory import InventoryService + + seeded = seed_library(tmp_path, {}, {}) + folder = seeded.lib / album + folder.mkdir(parents=True) + for index, name in enumerate(names): + image(folder / name, index + 1) + + with session_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() + seeded.asset_ids.update({Path(path).stem: aid for aid, path in rows}) + return seeded + + +def approve_album(base: str, *, album: str = "rome", name: str) -> None: + """Generate a proposal, set its final name, and approve it over HTTP.""" + httpx.post(f"{base}/api/v1/albums/proposals", json={}, timeout=10).raise_for_status() + for payload, route in ( + ({"name": name}, "edit"), + ({}, "approve"), + ): + current = httpx.get(f"{base}/api/v1/albums/proposals/{album}", timeout=10).json() + httpx.post( + f"{base}/api/v1/albums/proposals/{album}/{route}", + json={**payload, "expected_version": current["version"]}, + timeout=10, + ).raise_for_status() diff --git a/tests/e2e/test_phase_d_pipeline.py b/tests/e2e/test_phase_d_pipeline.py new file mode 100644 index 0000000..0f838e6 --- /dev/null +++ b/tests/e2e/test_phase_d_pipeline.py @@ -0,0 +1,377 @@ +"""Phase D end-to-end acceptance (US04-06): guarded renaming, black box. + +Every journey here drives a real ``photo_pipeline serve`` child process over HTTP — +plan, export, confirm, apply, collide, go stale, crash, recover, roll back. The +crashes are real: the server is killed by the ``PHOTO_PIPELINE_FAULT_AFTER`` barrier +at each persisted journal transition in turn, then a fresh process is started against +the same database and library and has to reconcile the wreckage from evidence alone. + +Photos really move. After every journey the assertions read the filesystem and the +inventory back: the asset set, the stable IDs, and the content hashes must be exactly +what they were before, only at new paths. +""" + +from __future__ import annotations + +import httpx +import pytest + +from tests.e2e._pipeline_harness import Server, approve_album, seed_album, session_factory + +pytestmark = pytest.mark.phase_d + +TIMEOUT = 10 +APPROVED = "2019 Rome" +CRASH_POINTS = ["moving", "moved", "database_updated", "verified"] + + +@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 _plan(base) -> dict: + response = httpx.post(f"{base}/api/v1/rename-plans", timeout=TIMEOUT) + response.raise_for_status() + return response.json() + + +def _get_plan(base, plan_id) -> dict: + return httpx.get(f"{base}/api/v1/rename-plans/{plan_id}", timeout=TIMEOUT).json() + + +def _apply(base, plan, **body): + payload = {"expected_version": plan["version"], **body} + return httpx.post( + f"{base}/api/v1/rename-plans/{plan['id']}/apply", json=payload, timeout=TIMEOUT + ) + + +def _inventory(base) -> dict[str, dict]: + """Every asset by stable ID, so identity can be compared across a rename.""" + items = httpx.get( + f"{base}/api/v1/inventory/assets", params={"limit": 200}, timeout=TIMEOUT + ).json()["items"] + return {item["id"]: item for item in items} + + +def _content(root) -> dict[str, bytes]: + return { + str(path.relative_to(root)): path.read_bytes() + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +def _recovery(base) -> dict: + return httpx.get(f"{base}/api/v1/rename-recovery", timeout=TIMEOUT).json() + + +def _journal_states(seeded, plan_id) -> list[str]: + from photo_pipeline.services.rename_journal import RenameJournal + + with session_factory(seeded) as sf: + return [row["journal_state"] for row in RenameJournal(sf).operations(plan_id)] + + +# ── US04-01: plan and export ───────────────────────────────────────────────── + + +def test_plan_and_export_describe_every_move_without_touching_the_library(server): + before = _content(server.seeded.lib) + approve_album(server.base, name=APPROVED) + plan = _plan(server.base) + + assert plan["state"] == "validated" and plan["operation_count"] == 1 + operation = plan["operations"][0] + assert operation["source_path"].endswith("/rome") + assert operation["destination_path"].endswith(f"/{APPROVED}") + assert operation["asset_count"] == 2 + assert set(operation["asset_ids"]) == set(_inventory(server.base)) + + export = httpx.get( + f"{server.base}/api/v1/rename-plans/{plan['id']}/export", timeout=TIMEOUT + ).json() + assert export["schema_version"] == 1 + assert export["checksum"] == plan["checksum"] + assert [op["source_path"] for op in export["operations"]] == [operation["source_path"]] + # Portable evidence must not smuggle out anything sensitive. + assert "token" not in str(export).lower() and "key" not in str(export).lower() + + # Planning is a preview: not one byte moved. + assert _content(server.seeded.lib) == before + + +# ── US04-03: confirmation and apply ────────────────────────────────────────── + + +def test_apply_requires_the_current_confirmation_token(server): + approve_album(server.base, name=APPROVED) + plan = _plan(server.base) + + stale = _apply(server.base, {**plan, "version": plan["version"] + 7}) + assert stale.status_code == 409 and stale.json()["error"]["code"] == "version_conflict" + + wrong_checksum = _apply(server.base, plan, expected_checksum="0" * 64) + assert wrong_checksum.status_code == 409 + + assert (server.seeded.lib / "rome").is_dir(), "a refused confirmation moves nothing" + + +def test_a_valid_apply_preserves_ids_hashes_and_the_asset_set(server): + approve_album(server.base, name=APPROVED) + before = _inventory(server.base) + before_content = _content(server.seeded.lib) + plan = _plan(server.base) + + applied = _apply(server.base, plan, expected_checksum=plan["checksum"]).json() + assert applied["applied"] == 1 and applied["failed"] == 0 and applied["state"] == "applied" + + after = _inventory(server.base) + assert set(after) == set(before), "renaming must not change asset identity" + assert {item["current_sha256"] for item in after.values()} == { + item["current_sha256"] for item in before.values() + } + assert all(APPROVED in item["current_path"] for item in after.values()) + # Same bytes, new folder — nothing was rewritten in the move. + assert _content(server.seeded.lib) == { + key.replace("rome/", f"{APPROVED}/"): value for key, value in before_content.items() + } + assert _journal_states(server.seeded, plan["id"]) == ["complete"] + + +def test_a_case_only_rename_applies_on_a_case_insensitive_filesystem(tmp_path): + seeded = seed_album(tmp_path, album="rome") + running = Server(seeded).start() + try: + approve_album(running.base, name="Rome") + plan = _plan(running.base) + assert plan["operations"][0]["case_only"] is True + + assert _apply(running.base, plan).json()["applied"] == 1 + entries = {path.name for path in seeded.lib.iterdir()} + assert "Rome" in entries + # The staged intermediate name must not survive the procedure. + assert not any(name.startswith(".rename-") for name in entries) + assert all("/Rome/" in item["current_path"] for item in _inventory(running.base).values()) + finally: + running.stop() + + +def test_a_collision_is_refused_and_the_occupant_survives(server): + approve_album(server.base, name=APPROVED) + occupied = server.seeded.lib / APPROVED + occupied.mkdir() + (occupied / "precious.jpg").write_bytes(b"do not lose me") + + plan = _plan(server.base) + assert plan["state"] == "invalid" and "destination_exists" in plan["blockers"] + + refused = _apply(server.base, plan) + assert refused.status_code == 422 and refused.json()["error"]["code"] == "cannot_apply" + assert (occupied / "precious.jpg").read_bytes() == b"do not lose me" + assert (server.seeded.lib / "rome").is_dir() + + +def test_a_source_that_changed_after_planning_is_refused(server): + approve_album(server.base, name=APPROVED) + plan = _plan(server.base) + # The plan recorded per-asset hashes; the file changes before confirmation. + (server.seeded.lib / "rome" / "a.jpg").write_bytes(b"tampered") + + result = _apply(server.base, plan).json() + assert result["failed"] == 1 and result["applied"] == 0 + assert (server.seeded.lib / "rome").is_dir(), "a failed precondition leaves the source alone" + operation = _get_plan(server.base, plan["id"])["operations"][0] + assert operation["journal_state"] == "failed" + assert operation["error_code"] == "source_changed" + + +# ── US04-04: crash points, recovery, rollback ──────────────────────────────── + + +def _crash_during_apply(seeded, plan, state): + """Apply with the fault barrier armed: the server dies at ``state``, mid-move.""" + crashing = Server(seeded, extra_env={"PHOTO_PIPELINE_FAULT_AFTER": state}).start() + try: + with pytest.raises(httpx.HTTPError): + _apply(crashing.base, plan) + finally: + crashing.stop() + assert crashing.proc is None + + +@pytest.mark.parametrize("crash_point", CRASH_POINTS) +def test_every_journal_crash_point_recovers_without_losing_content(tmp_path, crash_point): + seeded = seed_album(tmp_path) + first = Server(seeded).start() + try: + approve_album(first.base, name=APPROVED) + before = _inventory(first.base) + before_bytes = sorted(_content(seeded.lib).values()) + plan = _plan(first.base) + finally: + first.stop() + + _crash_during_apply(seeded, plan, crash_point) + + # A brand-new process, no in-memory state: everything comes from the journal. + restarted = Server(seeded).start() + try: + recovery = _recovery(restarted.base) + assert recovery["items"], f"a crash at {crash_point} must leave visible evidence" + assert recovery["items"][0]["journal_state"] == crash_point + # Only a crash that could have left the library half-renamed blocks other + # work. `verified` is past every filesystem and database change — the move + # is done and checked, just not flagged complete — so it blocks nothing. + assert recovery["blocks_mutation"] is (crash_point != "verified") + + resolved = httpx.post( + f"{restarted.base}/api/v1/rename-recovery/resolve", timeout=TIMEOUT + ).json() + assert resolved["manual"] == 0, "an interrupted rename must be decidable from evidence" + + # Recovery is idempotent: running it again changes nothing. + assert _recovery(restarted.base)["blocks_mutation"] is False + httpx.post(f"{restarted.base}/api/v1/rename-recovery/resolve", timeout=TIMEOUT) + + # A resumable crash is left ready to run again; finish it so every crash + # point converges on the same observable end state. + current = _get_plan(restarted.base, plan["id"]) + if current["state"] != "applied": + _apply(restarted.base, current) + + after = _inventory(restarted.base) + assert set(after) == set(before), "no asset may be lost or invented by a crash" + assert {item["current_sha256"] for item in after.values()} == { + item["current_sha256"] for item in before.values() + } + assert sorted(_content(seeded.lib).values()) == before_bytes + assert (seeded.lib / APPROVED).is_dir() and not (seeded.lib / "rome").exists() + assert all(APPROVED in item["current_path"] for item in after.values()) + assert _journal_states(seeded, plan["id"]) == ["complete"] + finally: + restarted.stop() + + +def test_ambiguous_evidence_is_kept_for_a_human_and_keeps_blocking(tmp_path): + seeded = seed_album(tmp_path) + first = Server(seeded).start() + try: + approve_album(first.base, name=APPROVED) + plan = _plan(first.base) + finally: + first.stop() + + _crash_during_apply(seeded, plan, "moving") + # Someone creates the destination while the operation is unresolved: now both + # paths exist and nothing can tell which one holds the truth. + (seeded.lib / APPROVED).mkdir(exist_ok=True) + + restarted = Server(seeded).start() + try: + assert _recovery(restarted.base)["items"][0]["classification"] == "manual" + resolved = httpx.post( + f"{restarted.base}/api/v1/rename-recovery/resolve", timeout=TIMEOUT + ).json() + assert resolved["manual"] == 1 and resolved["resumed"] == 0 and resolved["completed"] == 0 + # Still blocking, and still nothing guessed. + assert _recovery(restarted.base)["blocks_mutation"] is True + assert (seeded.lib / "rome").is_dir() + finally: + restarted.stop() + + +def test_an_unresolved_rename_is_the_cancellation_boundary(tmp_path): + """There is no cancel button once a rename starts. The boundary is that nothing + else may mutate the library until the interrupted work is resolved.""" + seeded = seed_album(tmp_path) + first = Server(seeded).start() + try: + approve_album(first.base, name=APPROVED) + plan = _plan(first.base) + finally: + first.stop() + + _crash_during_apply(seeded, plan, "moving") + + restarted = Server(seeded).start() + try: + assert _recovery(restarted.base)["blocks_mutation"] is True + + refused = httpx.post(f"{restarted.base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT) + assert refused.status_code == 409 + assert refused.json()["error"]["code"] == "rename_recovery_required" + + # Reading stays available throughout — only mutation is paused. + assert httpx.get(f"{restarted.base}/api/v1/albums/evidence", timeout=TIMEOUT).status_code + assert len(_inventory(restarted.base)) == 2 + finally: + restarted.stop() + + +def test_rollback_returns_an_interrupted_move_to_its_source(tmp_path): + seeded = seed_album(tmp_path) + first = Server(seeded).start() + try: + approve_album(first.base, name=APPROVED) + before = _inventory(first.base) + plan = _plan(first.base) + finally: + first.stop() + + # Crash after the content moved but before the database caught up: the operation + # is still reversible, which is exactly when rollback is defined. + _crash_during_apply(seeded, plan, "moved") + + restarted = Server(seeded).start() + try: + rolled = httpx.post( + f"{restarted.base}/api/v1/rename-plans/{plan['id']}/rollback", timeout=TIMEOUT + ).json() + assert rolled["rolled_back"] == 1 and rolled["state"] == "rolled_back" + + assert (seeded.lib / "rome" / "a.jpg").exists() + assert not (seeded.lib / APPROVED).exists() + after = _inventory(restarted.base) + assert set(after) == set(before) + assert all(item["current_path"].endswith(".jpg") for item in after.values()) + assert _recovery(restarted.base)["blocks_mutation"] is False + finally: + restarted.stop() + + +# ── durability ─────────────────────────────────────────────────────────────── + + +def test_the_applied_state_survives_a_full_restart(tmp_path): + seeded = seed_album(tmp_path) + first = Server(seeded).start() + try: + approve_album(first.base, name=APPROVED) + plan = _plan(first.base) + _apply(first.base, plan, expected_checksum=plan["checksum"]).raise_for_status() + expected = _inventory(first.base) + finally: + first.stop() + + restarted = Server(seeded).start() + try: + assert _inventory(restarted.base) == expected + after = _get_plan(restarted.base, plan["id"]) + assert after["state"] == "applied" + assert after["checksum"] == plan["checksum"], "the plan's evidence is immutable" + assert [op["journal_state"] for op in after["operations"]] == ["complete"] + assert all(op["verified_at"] for op in after["operations"]) + assert _recovery(restarted.base) == {"blocks_mutation": False, "items": []} + finally: + restarted.stop() diff --git a/tests/e2e/test_renames_ui.py b/tests/e2e/test_renames_ui.py index dddc888..13f9b4e 100644 --- a/tests/e2e/test_renames_ui.py +++ b/tests/e2e/test_renames_ui.py @@ -11,89 +11,22 @@ 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 +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 -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) + seeded = seed_album(tmp_path) running = Server(seeded).start() running.seeded = seeded try: @@ -105,22 +38,8 @@ def server(tmp_path): # ── 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 _approve(base, *, name=APPROVED): + approve_album(base, name=name) def _build(base): @@ -145,7 +64,7 @@ def _interrupt(seeded, plan, *, state="moving", make_destination=False): """ from photo_pipeline.services.rename_journal import RenameJournal - with _factory(seeded) as sf: + 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) @@ -263,6 +182,22 @@ def test_the_applied_plan_survives_a_reload(page, server): 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() @@ -287,7 +222,7 @@ def test_rollback_returns_an_unfinished_move_to_its_source(page, server): # 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: + with session_factory(server.seeded) as sf: from photo_pipeline.services.rename_journal import RenameJournal journal = RenameJournal(sf) diff --git a/tests/e2e/test_traceability.py b/tests/e2e/test_traceability.py index 62b72dc..14b0b30 100644 --- a/tests/e2e/test_traceability.py +++ b/tests/e2e/test_traceability.py @@ -12,12 +12,19 @@ from pathlib import Path REPO = Path(__file__).resolve().parents[2] MAP = json.loads((REPO / "tests" / "story_traceability.json").read_text())["stories"] PHASE_A_STORIES = {f"US01-0{n}" for n in range(1, 8)} +PHASE_D_STORIES = {f"US04-0{n}" for n in range(1, 7)} def test_all_phase_a_stories_are_mapped(): assert PHASE_A_STORIES <= set(MAP) +def test_all_phase_d_stories_are_mapped(): + """US04-06 acceptance: every guarded-rename story, plan through browser, is tied + to automated tests — renaming is the first thing that mutates the real library.""" + assert PHASE_D_STORIES <= set(MAP) + + def test_every_mapped_test_file_exists_and_is_nonempty(): for story, files in MAP.items(): assert files, f"{story} maps to no tests" diff --git a/tests/story_traceability.json b/tests/story_traceability.json index eb8fb8b..2706256 100644 --- a/tests/story_traceability.json +++ b/tests/story_traceability.json @@ -100,6 +100,9 @@ ], "US04-05": [ "tests/e2e/test_renames_ui.py" + ], + "US04-06": [ + "tests/e2e/test_phase_d_pipeline.py" ] } }