Files
photoanalyzer/tests/integration/test_album_proposals.py

377 lines
12 KiB
Python

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