US04-06: Automate Phase D End-to-End Acceptance
This commit is contained in:
34
README.md
34
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=<journal state>` 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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
377
tests/e2e/test_phase_d_pipeline.py
Normal file
377
tests/e2e/test_phase_d_pipeline.py
Normal file
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -100,6 +100,9 @@
|
||||
],
|
||||
"US04-05": [
|
||||
"tests/e2e/test_renames_ui.py"
|
||||
],
|
||||
"US04-06": [
|
||||
"tests/e2e/test_phase_d_pipeline.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user