331 lines
13 KiB
Python
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}"
|