Files
photoanalyzer/tests/e2e/test_phase_c_pipeline.py

331 lines
13 KiB
Python

"""Phase C black-box end-to-end acceptance (US03-05).
Drives a real server process over HTTP only: evidence aggregation, proposal
generation through the deterministic naming fake, provider failure, invalid names,
collisions, editing, stale approval, valid approval, and durability across a full
restart. Asserts the provider's exact inputs and that no fixture path ever changes —
Phase C proposes names, it never renames.
"""
from __future__ import annotations
import json
import httpx
import pytest
from tests.e2e._pipeline_harness import Server, image, seed_library
pytestmark = pytest.mark.phase_c
TIMEOUT = 10
def seed_albums(tmp_path, albums: dict[str, int]):
"""Build a library of ``{album_name: photo_count}`` where every photo is a
confirmed-SFW, analysed asset — the state Phase C consumes."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.models import AnalysisResult, Asset, SafetyReview
from photo_pipeline.services.inventory import InventoryService
seeded = seed_library(tmp_path, {}, {})
seed = 0
for album, count in albums.items():
folder = seeded.lib / album
folder.mkdir(parents=True, exist_ok=True)
for index in range(count):
seed += 1
image(folder / f"{index}.jpg", seed)
config = Config.from_env(
{
"PHOTO_PIPELINE_DATA_DIR": str(seeded.data),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(seeded.lib),
}
)
run_migrations(config.database_url)
engine = create_db_engine(config.database_url)
sf = create_session_factory(engine)
InventoryService(sf).scan(seeded.lib)
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
with sf() as session:
rows = session.execute(select(Asset.id, Asset.current_path)).all()
for index, (asset_id, _path) in enumerate(sorted(rows, key=lambda row: row[1])):
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",
# Realistic model output: a caption never contains a file path,
# which is what lets the provider-input assertions be meaningful.
description=f"a stone archway at golden hour ({index})",
tags='["ruins", "city"]',
approx_year=2019,
location_hint="Rome",
)
)
session.commit()
engine.dispose()
return seeded
def start(seeded, naming_log):
return Server(seeded, extra_env={"PHOTO_PIPELINE_FAKE_NAMING_LOG": str(naming_log)}).start()
def paths(base):
return {
row["current_path"]
for row in httpx.get(f"{base}/api/v1/inventory/assets", timeout=TIMEOUT).json()["items"]
}
def proposal(base, album):
return httpx.get(f"{base}/api/v1/albums/proposals/{album}", timeout=TIMEOUT).json()
# ── evidence aggregation ─────────────────────────────────────────────────────
def test_evidence_aggregates_per_album_over_http(tmp_path):
seeded = seed_albums(tmp_path, {"rome": 2, "paris": 1})
server = start(seeded, tmp_path / "naming.log")
try:
data = httpx.get(f"{server.base}/api/v1/albums/evidence", timeout=TIMEOUT).json()
assert data["total"] == 2
albums = {folder["album"]: folder for folder in data["folders"]}
assert albums["rome"]["asset_count"] == 2 and albums["rome"]["analyzed_count"] == 2
assert albums["rome"]["dominant_year"] == 2019
assert albums["paris"]["asset_count"] == 1
finally:
server.stop()
# ── generation: provider inputs, success, and failure ────────────────────────
def test_generation_shows_the_provider_only_minimal_evidence(tmp_path):
seeded = seed_albums(tmp_path, {"rome": 2})
log = tmp_path / "naming.log"
server = start(seeded, log)
try:
httpx.post(f"{server.base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT).json()
seen = [json.loads(line) for line in log.read_text().splitlines() if line.strip()]
assert len(seen) == 1
summary = seen[0]
assert summary["folder_name"] == "rome" and summary["analyzed_count"] == 2
assert summary["dominant_year"] == 2019 and summary["locations"] == ["Rome"]
# Identity must never reach the naming provider.
flat = json.dumps(summary)
assert str(seeded.lib) not in flat and ".jpg" not in flat
assert "asset_id" not in summary
finally:
server.stop()
def test_successful_generation_persists_name_rationale_confidence_and_version(tmp_path):
seeded = seed_albums(tmp_path, {"rome": 2})
server = start(seeded, tmp_path / "naming.log")
try:
assert httpx.post(
f"{server.base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT
).json() == {"proposed": 1, "errors": 0, "skipped": 0}
current = proposal(server.base, "rome")
assert current["name"] == "2019 — Rome — Rome"
assert current["rationale"] and current["confidence"] == 0.9
assert current["status"] == "proposed" and current["version"] == 1
assert current["model"] == "fake-naming" and current["stale"] is False
finally:
server.stop()
def test_provider_failure_is_explicit_and_retryable(tmp_path):
# The fake returns a malformed payload for an album named "boom".
seeded = seed_albums(tmp_path, {"boomtown": 1, "rome": 1})
server = start(seeded, tmp_path / "naming.log")
try:
result = httpx.post(
f"{server.base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT
).json()
assert result == {"proposed": 1, "errors": 1, "skipped": 0}
failed = proposal(server.base, "boomtown")
assert failed["status"] == "error" and failed["error_code"] == "malformed_response"
# The healthy album is unaffected by its neighbour's failure.
assert proposal(server.base, "rome")["status"] == "proposed"
# Retrying is safe: still exactly one row per album.
httpx.post(f"{server.base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT)
listed = httpx.get(f"{server.base}/api/v1/albums/proposals", timeout=TIMEOUT).json()
assert listed["total"] == 2
finally:
server.stop()
# ── editing: invalid names and collisions ────────────────────────────────────
def test_invalid_name_is_rejected_with_a_structured_error(tmp_path):
seeded = seed_albums(tmp_path, {"rome": 1})
server = start(seeded, tmp_path / "naming.log")
try:
httpx.post(f"{server.base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT)
current = proposal(server.base, "rome")
response = httpx.post(
f"{server.base}/api/v1/albums/proposals/rome/edit",
json={"name": "///", "expected_version": current["version"]},
timeout=TIMEOUT,
)
assert response.status_code == 422
assert response.json()["error"]["code"] == "invalid_proposal"
# Nothing changed.
assert proposal(server.base, "rome")["version"] == current["version"]
finally:
server.stop()
def test_a_name_colliding_with_a_path_separator_is_sanitized_not_nested(tmp_path):
seeded = seed_albums(tmp_path, {"rome": 1})
server = start(seeded, tmp_path / "naming.log")
try:
httpx.post(f"{server.base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT)
current = proposal(server.base, "rome")
edited = httpx.post(
f"{server.base}/api/v1/albums/proposals/rome/edit",
json={"name": "2019/Rome", "expected_version": current["version"]},
timeout=TIMEOUT,
).json()
assert edited["name"] == "2019 Rome", "a proposal can never introduce a path level"
finally:
server.stop()
def test_edit_persists_and_bumps_the_version(tmp_path):
seeded = seed_albums(tmp_path, {"rome": 1})
server = start(seeded, tmp_path / "naming.log")
try:
httpx.post(f"{server.base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT)
first = proposal(server.base, "rome")
edited = httpx.post(
f"{server.base}/api/v1/albums/proposals/rome/edit",
json={"name": "2019 Rome Holiday", "expected_version": first["version"]},
timeout=TIMEOUT,
).json()
assert edited["status"] == "edited" and edited["version"] == first["version"] + 1
stale = httpx.post(
f"{server.base}/api/v1/albums/proposals/rome/edit",
json={"name": "Too Late", "expected_version": first["version"]},
timeout=TIMEOUT,
)
assert stale.status_code == 409
assert proposal(server.base, "rome")["name"] == "2019 Rome Holiday"
finally:
server.stop()
# ── approval: stale refusal, valid approval, no renames ──────────────────────
def test_stale_evidence_blocks_approval_until_regenerated(tmp_path):
seeded = seed_albums(tmp_path, {"rome": 1})
server = start(seeded, tmp_path / "naming.log")
try:
httpx.post(f"{server.base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT)
# A new photo in the album moves its evidence version on.
image(seeded.lib / "rome" / "extra.jpg", 99)
httpx.post(f"{server.base}/api/v1/inventory/scan", timeout=TIMEOUT).raise_for_status()
stale = proposal(server.base, "rome")
assert stale["stale"] is True
refused = httpx.post(
f"{server.base}/api/v1/albums/proposals/rome/approve",
json={"expected_version": stale["version"]},
timeout=TIMEOUT,
)
assert refused.status_code == 422
assert proposal(server.base, "rome")["status"] != "approved"
# Regenerating re-syncs the proposal, and approval then succeeds.
httpx.post(f"{server.base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT)
fresh = proposal(server.base, "rome")
assert fresh["stale"] is False
approved = httpx.post(
f"{server.base}/api/v1/albums/proposals/rome/approve",
json={"expected_version": fresh["version"]},
timeout=TIMEOUT,
).json()
assert approved["status"] == "approved"
finally:
server.stop()
def test_approval_changes_no_fixture_path_and_survives_restart(tmp_path):
seeded = seed_albums(tmp_path, {"rome": 2, "paris": 1})
log = tmp_path / "naming.log"
server = start(seeded, log)
before = None
try:
before = paths(server.base)
httpx.post(f"{server.base}/api/v1/albums/proposals", json={}, timeout=TIMEOUT)
current = proposal(server.base, "rome")
httpx.post(
f"{server.base}/api/v1/albums/proposals/rome/edit",
json={"name": "2019 Rome Holiday", "expected_version": current["version"]},
timeout=TIMEOUT,
).raise_for_status()
edited = proposal(server.base, "rome")
approved = httpx.post(
f"{server.base}/api/v1/albums/proposals/rome/approve",
json={"expected_version": edited["version"]},
timeout=TIMEOUT,
).json()
assert approved["status"] == "approved"
assert paths(server.base) == before, "Phase C must never rename a file"
finally:
server.stop()
# Restart: the approved name and version are durable, and the files are still
# exactly where they were.
restarted = start(seeded, log)
try:
after = proposal(restarted.base, "rome")
assert after["status"] == "approved" and after["name"] == "2019 Rome Holiday"
assert paths(restarted.base) == before
# The untouched album kept its own state.
assert proposal(restarted.base, "paris")["status"] == "proposed"
finally:
restarted.stop()
# ── story traceability ───────────────────────────────────────────────────────
def test_phase_c_stories_map_to_tests():
import json as _json
from pathlib import Path
repo = Path(__file__).resolve().parents[2]
mapping = _json.loads((repo / "tests" / "story_traceability.json").read_text())["stories"]
for story in ("US03-01", "US03-02", "US03-03", "US03-04", "US03-05"):
assert mapping.get(story), f"{story} maps to no tests"
for rel in mapping[story]:
assert (repo / rel).is_file(), f"{story}: missing {rel}"