377 lines
12 KiB
Python
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"
|