378 lines
15 KiB
Python
378 lines
15 KiB
Python
"""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()
|