"""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"