US03-03: Generate and Persist Versioned Proposals (#63)
This commit was merged in pull request #63.
This commit is contained in:
376
tests/integration/test_album_proposals.py
Normal file
376
tests/integration/test_album_proposals.py
Normal file
@@ -0,0 +1,376 @@
|
||||
"""Album naming proposals: generation, provider failures, versioning, edits,
|
||||
approval, staleness, and durability (US03-03).
|
||||
|
||||
Deterministic fakes stand in for the naming provider — success, malformed output,
|
||||
rate limiting, and hard failure — so every branch is exercised with no network.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from photo_pipeline.api.app import create_app
|
||||
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.proposals import (
|
||||
ConflictError,
|
||||
ProposalError,
|
||||
ProposalService,
|
||||
ProviderError,
|
||||
RateLimited,
|
||||
evidence_summary,
|
||||
validate_response,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
LIB = (Path("/lib"),)
|
||||
|
||||
|
||||
def _factory(tmp_path):
|
||||
(tmp_path / "data").mkdir()
|
||||
config = Config.from_env(
|
||||
{"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data"), "PHOTO_PIPELINE_LIBRARY_ROOTS": "/lib"}
|
||||
)
|
||||
run_migrations(config.database_url)
|
||||
return config, create_session_factory(create_db_engine(config.database_url))
|
||||
|
||||
|
||||
def _sfw_analyzed(sf, path, **fields):
|
||||
asset_id = str(uuid.uuid4())
|
||||
with sf() as session:
|
||||
session.add(
|
||||
Asset(
|
||||
id=asset_id,
|
||||
original_path=path,
|
||||
current_path=path,
|
||||
discovered_at=NOW,
|
||||
hash_version=1,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
SafetyReview(id=str(uuid.uuid4()), asset_id=asset_id, decision="sfw", created_at=NOW)
|
||||
)
|
||||
row = AnalysisResult(asset_id=asset_id, status="analyzed")
|
||||
for key, value in fields.items():
|
||||
setattr(row, key, value)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return asset_id
|
||||
|
||||
|
||||
def _seed_rome(sf):
|
||||
_sfw_analyzed(
|
||||
sf,
|
||||
"/lib/rome/a.jpg",
|
||||
description="Colosseum",
|
||||
tags='["ruins"]',
|
||||
approx_year=2019,
|
||||
location_hint="Rome",
|
||||
)
|
||||
_sfw_analyzed(
|
||||
sf,
|
||||
"/lib/rome/b.jpg",
|
||||
description="Forum",
|
||||
tags='["ruins"]',
|
||||
approx_year=2019,
|
||||
location_hint="Rome",
|
||||
)
|
||||
|
||||
|
||||
class GoodProvider:
|
||||
"""Records what it was shown and returns a valid structured proposal."""
|
||||
|
||||
def __init__(self, name="2019 — Rome — City Trip"):
|
||||
self.seen = []
|
||||
self._name = name
|
||||
|
||||
def propose(self, summary):
|
||||
self.seen.append(summary)
|
||||
return {"name": self._name, "rationale": "Year and place dominate.", "confidence": 0.82}
|
||||
|
||||
|
||||
class MalformedProvider:
|
||||
def propose(self, summary):
|
||||
return {"rationale": "no name at all"}
|
||||
|
||||
|
||||
class RateLimitedProvider:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def propose(self, summary):
|
||||
self.calls += 1
|
||||
raise RateLimited()
|
||||
|
||||
|
||||
class BrokenProvider:
|
||||
def propose(self, summary):
|
||||
raise ProviderError("provider_error", "upstream exploded")
|
||||
|
||||
|
||||
def _service(sf, provider=None):
|
||||
return ProposalService(sf, provider=provider, library_roots=LIB)
|
||||
|
||||
|
||||
# ── provider contract: minimal evidence, validated response ──────────────────
|
||||
|
||||
|
||||
def test_provider_sees_only_minimal_evidence_without_ids_or_paths(tmp_path):
|
||||
_, sf = _factory(tmp_path)
|
||||
_seed_rome(sf)
|
||||
provider = GoodProvider()
|
||||
_service(sf, provider).generate()
|
||||
|
||||
assert len(provider.seen) == 1
|
||||
summary = provider.seen[0]
|
||||
assert summary["folder_name"] == "rome" and summary["dominant_year"] == 2019
|
||||
assert summary["locations"] == ["Rome"]
|
||||
flat = repr(summary)
|
||||
assert "/lib/" not in flat, "file paths must never reach the naming provider"
|
||||
assert "asset_id" not in summary and "version" not in summary
|
||||
|
||||
|
||||
def test_evidence_summary_is_bounded():
|
||||
evidence = {
|
||||
"folder_name": "big",
|
||||
"tags": [{"value": f"t{i}", "count": 1} for i in range(50)],
|
||||
"locations": [{"value": f"l{i}", "count": 1} for i in range(20)],
|
||||
"descriptions": [f"d{i}" for i in range(50)],
|
||||
}
|
||||
summary = evidence_summary(evidence)
|
||||
assert len(summary["top_tags"]) == 12
|
||||
assert len(summary["locations"]) == 3
|
||||
assert len(summary["descriptions"]) == 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"not a dict",
|
||||
{},
|
||||
{"name": ""},
|
||||
{"name": "ok", "confidence": 1.5},
|
||||
{"name": "ok", "confidence": "high"},
|
||||
{"name": "ok", "rationale": 42},
|
||||
],
|
||||
)
|
||||
def test_validate_response_rejects_malformed_payloads(raw):
|
||||
with pytest.raises(ProviderError) as caught:
|
||||
validate_response(raw)
|
||||
assert caught.value.code == "malformed_response"
|
||||
|
||||
|
||||
def test_validate_response_accepts_a_minimal_valid_payload():
|
||||
assert validate_response({"name": " Rome "})["name"] == "Rome"
|
||||
|
||||
|
||||
# ── generation, persistence, and failures ────────────────────────────────────
|
||||
|
||||
|
||||
def test_generate_persists_proposal_with_rationale_confidence_and_versions(tmp_path):
|
||||
_, sf = _factory(tmp_path)
|
||||
_seed_rome(sf)
|
||||
service = _service(sf, GoodProvider())
|
||||
assert service.generate() == {"proposed": 1, "errors": 0, "skipped": 0}
|
||||
|
||||
proposal = service.get("rome")
|
||||
assert proposal["name"] == "2019 — Rome — City Trip"
|
||||
assert proposal["rationale"] and proposal["confidence"] == 0.82
|
||||
assert proposal["status"] == "proposed" and proposal["version"] == 1
|
||||
assert proposal["evidence_version"] and proposal["stale"] is False
|
||||
assert proposal["model"] and proposal["prompt_version"]
|
||||
|
||||
|
||||
def test_generated_name_is_forced_through_the_naming_policy(tmp_path):
|
||||
_, sf = _factory(tmp_path)
|
||||
_seed_rome(sf)
|
||||
# A model returning a path separator must never yield a nested name.
|
||||
_service(sf, GoodProvider(name="2019/Rome")).generate()
|
||||
assert _service(sf).get("rome")["name"] == "2019 Rome"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider,code",
|
||||
[
|
||||
(MalformedProvider(), "malformed_response"),
|
||||
(RateLimitedProvider(), "rate_limited"),
|
||||
(BrokenProvider(), "provider_error"),
|
||||
],
|
||||
)
|
||||
def test_provider_failures_are_explicit_and_retryable(tmp_path, provider, code):
|
||||
_, sf = _factory(tmp_path)
|
||||
_seed_rome(sf)
|
||||
assert _service(sf, provider).generate() == {"proposed": 0, "errors": 1, "skipped": 0}
|
||||
|
||||
failed = _service(sf).get("rome")
|
||||
assert failed["status"] == "error" and failed["error_code"] == code
|
||||
|
||||
# Retry with a working provider recovers in place — no duplicate row.
|
||||
_service(sf, GoodProvider()).generate()
|
||||
recovered = _service(sf).list()
|
||||
assert recovered["total"] == 1
|
||||
assert recovered["items"][0]["status"] == "proposed"
|
||||
assert recovered["items"][0]["error_code"] is None
|
||||
|
||||
|
||||
def test_generate_without_a_provider_falls_back_to_the_policy_name(tmp_path):
|
||||
_, sf = _factory(tmp_path)
|
||||
_seed_rome(sf)
|
||||
_service(sf).generate()
|
||||
proposal = _service(sf).get("rome")
|
||||
assert proposal["status"] == "proposed" and proposal["model"] == "policy"
|
||||
assert proposal["name"] == "2019 — Rome — rome"
|
||||
|
||||
|
||||
def test_generate_skips_albums_without_evidence(tmp_path):
|
||||
_, sf = _factory(tmp_path)
|
||||
_seed_rome(sf)
|
||||
assert _service(sf, GoodProvider()).generate(["nope"]) == {
|
||||
"proposed": 0,
|
||||
"errors": 0,
|
||||
"skipped": 1,
|
||||
}
|
||||
|
||||
|
||||
# ── edit, approve, conflicts, staleness ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_edit_sets_the_final_name_and_bumps_the_version(tmp_path):
|
||||
_, sf = _factory(tmp_path)
|
||||
_seed_rome(sf)
|
||||
service = _service(sf, GoodProvider())
|
||||
service.generate()
|
||||
|
||||
edited = service.edit("rome", "2019 Rome Holiday", expected_version=1)
|
||||
assert edited["final_name"] == "2019 Rome Holiday"
|
||||
assert edited["name"] == "2019 Rome Holiday"
|
||||
assert edited["status"] == "edited" and edited["version"] == 2
|
||||
|
||||
|
||||
def test_edit_with_a_stale_version_conflicts_without_mutating(tmp_path):
|
||||
_, sf = _factory(tmp_path)
|
||||
_seed_rome(sf)
|
||||
service = _service(sf, GoodProvider())
|
||||
service.generate()
|
||||
|
||||
with pytest.raises(ConflictError):
|
||||
service.edit("rome", "Something Else", expected_version=99)
|
||||
assert service.get("rome")["final_name"] is None
|
||||
|
||||
|
||||
def test_edit_rejects_a_name_that_sanitizes_to_nothing(tmp_path):
|
||||
_, sf = _factory(tmp_path)
|
||||
_seed_rome(sf)
|
||||
service = _service(sf, GoodProvider())
|
||||
service.generate()
|
||||
with pytest.raises(ProposalError):
|
||||
service.edit("rome", "///", expected_version=1)
|
||||
|
||||
|
||||
def test_approve_validates_the_latest_version_and_renames_nothing(tmp_path):
|
||||
_, sf = _factory(tmp_path)
|
||||
_seed_rome(sf)
|
||||
service = _service(sf, GoodProvider())
|
||||
service.generate()
|
||||
|
||||
approved = service.approve("rome", expected_version=1)
|
||||
assert approved["status"] == "approved" and approved["approved_at"]
|
||||
# No rename happened: the assets still live at their original paths.
|
||||
with sf() as session:
|
||||
paths = {a.current_path for a in session.scalars(select(Asset))}
|
||||
assert paths == {"/lib/rome/a.jpg", "/lib/rome/b.jpg"}
|
||||
|
||||
|
||||
def test_approve_with_a_stale_version_conflicts(tmp_path):
|
||||
_, sf = _factory(tmp_path)
|
||||
_seed_rome(sf)
|
||||
service = _service(sf, GoodProvider())
|
||||
service.generate()
|
||||
with pytest.raises(ConflictError):
|
||||
service.approve("rome", expected_version=42)
|
||||
assert service.get("rome")["status"] == "proposed"
|
||||
|
||||
|
||||
def test_proposal_becomes_stale_when_evidence_changes_and_approval_is_refused(tmp_path):
|
||||
_, sf = _factory(tmp_path)
|
||||
_seed_rome(sf)
|
||||
service = _service(sf, GoodProvider())
|
||||
service.generate()
|
||||
assert service.get("rome")["stale"] is False
|
||||
|
||||
_sfw_analyzed(sf, "/lib/rome/c.jpg", description="Pantheon", approx_year=2019)
|
||||
stale = service.get("rome")
|
||||
assert stale["stale"] is True
|
||||
with pytest.raises(ProposalError):
|
||||
service.approve("rome", expected_version=stale["version"])
|
||||
|
||||
# Regenerating re-syncs it to the current evidence and clears the staleness.
|
||||
service.generate()
|
||||
assert service.get("rome")["stale"] is False
|
||||
|
||||
|
||||
def test_regeneration_clears_a_previous_edit(tmp_path):
|
||||
_, sf = _factory(tmp_path)
|
||||
_seed_rome(sf)
|
||||
service = _service(sf, GoodProvider())
|
||||
service.generate()
|
||||
service.edit("rome", "My Own Name", expected_version=1)
|
||||
service.generate()
|
||||
assert service.get("rome")["final_name"] is None
|
||||
|
||||
|
||||
# ── API surface ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_api_generate_edit_conflict_approve_and_restart(tmp_path):
|
||||
config, sf = _factory(tmp_path)
|
||||
_seed_rome(sf)
|
||||
|
||||
with TestClient(create_app(config)) as client:
|
||||
assert client.get("/api/v1/albums/evidence").json()["total"] == 1
|
||||
|
||||
generated = client.post("/api/v1/albums/proposals", json={}).json()
|
||||
assert generated["proposed"] == 1
|
||||
|
||||
listed = client.get("/api/v1/albums/proposals").json()
|
||||
assert listed["total"] == 1
|
||||
proposal = listed["items"][0]
|
||||
assert proposal["album"] == "rome" and proposal["status"] == "proposed"
|
||||
|
||||
missing = client.get("/api/v1/albums/proposals/nope")
|
||||
assert missing.status_code == 404
|
||||
|
||||
stale = client.post(
|
||||
"/api/v1/albums/proposals/rome/edit",
|
||||
json={"name": "New Name", "expected_version": 99},
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["error"]["code"] == "version_conflict"
|
||||
|
||||
edited = client.post(
|
||||
"/api/v1/albums/proposals/rome/edit",
|
||||
json={"name": "2019 Rome Holiday", "expected_version": proposal["version"]},
|
||||
).json()
|
||||
assert edited["name"] == "2019 Rome Holiday"
|
||||
|
||||
invalid = client.post(
|
||||
"/api/v1/albums/proposals/rome/edit",
|
||||
json={"name": "///", "expected_version": edited["version"]},
|
||||
)
|
||||
assert invalid.status_code == 422
|
||||
|
||||
approved = client.post(
|
||||
"/api/v1/albums/proposals/rome/approve",
|
||||
json={"expected_version": edited["version"]},
|
||||
).json()
|
||||
assert approved["status"] == "approved"
|
||||
|
||||
# Restart against the same database: the approval is durable.
|
||||
with TestClient(create_app(config)) as client:
|
||||
after = client.get("/api/v1/albums/proposals/rome").json()
|
||||
assert after["status"] == "approved" and after["name"] == "2019 Rome Holiday"
|
||||
Reference in New Issue
Block a user