"""Safety decisions, the NSFW→analysis privacy gate, and one-mutating-job policy (US02-06). The privacy invariant is the headline: the vision provider is called ONLY for confirmed-SFW assets. A call-recording fake provider proves nsfw/undecided assets never produce a request, with no network or API key. """ import shutil import uuid from datetime import datetime, timezone import pytest from fastapi.testclient import TestClient from PIL import Image 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 Asset from photo_pipeline.services.analysis import AnalysisService from photo_pipeline.services.safety import SafetyService def _factory(tmp_path): (tmp_path / "data").mkdir() config = Config.from_env({"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data")}) run_migrations(config.database_url) return config, create_session_factory(create_db_engine(config.database_url)) def _seed_assets(sf, paths): now = datetime.now(timezone.utc) ids = [] with sf() as session: for path in paths: asset = Asset( id=str(uuid.uuid4()), original_path=str(path), current_path=str(path), discovered_at=now, hash_version=1, ) session.add(asset) ids.append(asset.id) session.commit() return ids class RecordingProvider: def __init__(self): self.calls = [] def analyze(self, path, *, album_hint): self.calls.append(path) return {"description": "a photo", "tags": ["alpha", "beta"], "people_count": 1} def test_provider_called_only_for_confirmed_sfw(tmp_path): _, sf = _factory(tmp_path) sfw, nsfw, undecided = _seed_assets(sf, ["/lib/a.jpg", "/lib/b.jpg", "/lib/c.jpg"]) safety = SafetyService(sf) safety.decide(sfw, "sfw", write_exif=False) safety.decide(nsfw, "nsfw", write_exif=False) # `undecided` gets no decision at all. provider = RecordingProvider() result = AnalysisService(sf, provider=provider).run([sfw, nsfw, undecided]) assert provider.calls == ["/lib/a.jpg"], "provider must see only the SFW asset" assert result == {"analyzed": 1, "skipped": 2, "errors": 0} def test_flipping_sfw_to_nsfw_removes_analysis_eligibility(tmp_path): _, sf = _factory(tmp_path) (asset,) = _seed_assets(sf, ["/lib/a.jpg"]) safety = SafetyService(sf) analysis = AnalysisService(sf, provider=RecordingProvider()) safety.decide(asset, "sfw", write_exif=False) assert analysis.eligible_asset_ids() == [asset] safety.decide(asset, "nsfw", write_exif=False) assert analysis.eligible_asset_ids() == [] # latest decision wins def test_decision_persists_and_shows_in_queue(tmp_path): config, sf = _factory(tmp_path) (asset,) = _seed_assets(sf, ["/lib/a.jpg"]) app = create_app(config) with TestClient(app) as client: ok = client.post("/api/v1/safety/decisions", json={"asset_id": asset, "decision": "nsfw"}) assert ok.status_code == 200 and ok.json()["decision"] == "nsfw" nsfw_queue = client.get("/api/v1/safety/queue", params={"state": "nsfw"}).json() assert [row["asset_id"] for row in nsfw_queue["items"]] == [asset] assert client.get("/api/v1/safety/counts").json()["nsfw"] == 1 unknown = TestClient(app) with unknown as client: bad = client.post("/api/v1/safety/decisions", json={"asset_id": "nope", "decision": "sfw"}) assert bad.status_code == 404 def test_one_mutating_job_at_a_time(tmp_path): config, sf = _factory(tmp_path) ids = _seed_assets(sf, ["/lib/a.jpg", "/lib/b.jpg"]) SafetyService(sf).decide(ids[0], "sfw", write_exif=False) # make analysis eligible app = create_app(config) with TestClient(app) as client: first = client.post("/api/v1/safety/jobs") assert first.status_code == 200 # Both scoring and analysis take the library_write lock — the second is refused. second = client.post("/api/v1/analysis/jobs") assert second.status_code == 409 workflow = client.get("/api/v1/workflow").json() assert workflow["active_job"] is not None assert workflow["active_job"]["lock"] == "library_write" def test_workflow_counts_reflect_decisions(tmp_path): config, sf = _factory(tmp_path) ids = _seed_assets(sf, ["/lib/a.jpg", "/lib/b.jpg", "/lib/c.jpg"]) SafetyService(sf).decide(ids[0], "sfw", write_exif=False) app = create_app(config) with TestClient(app) as client: stages = {s["key"]: s for s in client.get("/api/v1/workflow").json()["stages"]} assert stages["safety"]["counts"] == {"sfw": 1, "nsfw": 0, "deferred": 0, "undecided": 2, "scored": 0} assert stages["safety"]["status"] == "attention" assert stages["analysis"]["counts"]["eligible"] == 1 @pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool not installed") def test_safety_decision_writes_and_verifies_exif(tmp_path): _, sf = _factory(tmp_path) image = tmp_path / "photo.jpg" Image.new("RGB", (32, 32), (120, 60, 30)).save(image) (asset,) = _seed_assets(sf, [image]) result = SafetyService(sf).decide(asset, "nsfw") assert result["exif_verified"] is True from photo_pipeline.integrations import exiftool keywords = exiftool.read_keyword_sets([str(image)]).get(str(image), set()) assert "nsfw" in keywords and "sfw" not in keywords