"""US07-03: EXIF checkpoints, asserted with before/after metadata snapshots. Every test here reads the complete metadata of a real file through exiftool before the stage runs and again afterwards, then compares the two. That is the only way to prove the property the concept actually asks for: a stage owns a few fields and must leave literally everything else — dates, GPS, camera, artist, rating, other people's keywords — exactly as it found them. The other half is divergence. When something outside the stage's ownership does move, the checkpoint must say so, refuse to call itself verified, and change nothing back: a silent repair is how a library quietly loses the user's metadata. """ from __future__ import annotations import json import shutil import subprocess import uuid from datetime import datetime, timezone from types import SimpleNamespace import pytest from photo_pipeline.config import Config from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations from photo_pipeline.integrations import exiftool from photo_pipeline.models import Asset, ExifProjection from photo_pipeline.services import exif_checkpoint from photo_pipeline.services.analysis import AnalysisService from photo_pipeline.services.safety import SafetyService from tests.fixtures.media_corpus import CASES_BY_ID, build_corpus pytestmark = pytest.mark.skipif( shutil.which("exiftool") is None, reason="exiftool not installed" ) @pytest.fixture def env(tmp_path): data = tmp_path / "data" data.mkdir() lib = tmp_path / "lib" files = build_corpus( lib, ids=( "user_exif", "prior_safety_keyword", "prior_analysis_keywords", "conflicting_safety_keywords", "malformed_metadata", "no_exif", ), ) config = Config.from_env( {"PHOTO_PIPELINE_DATA_DIR": str(data), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib)} ) run_migrations(config.database_url) engine = create_db_engine(config.database_url) sf = create_session_factory(engine) assets = {} with sf() as session: for case_id, path in files.items(): asset = Asset( id=str(uuid.uuid4()), original_path=str(path), current_path=str(path), discovered_at=datetime.now(timezone.utc), hash_version=1, ) session.add(asset) assets[case_id] = asset.id session.commit() yield SimpleNamespace(config=config, lib=lib, sf=sf, files=files, assets=assets) engine.dispose() class Provider: """A vision provider whose tags are fixed, so the EXIF assertion is the test.""" def __init__(self, tags): self.tags = list(tags) def analyze(self, path, *, album_hint): return {"description": "a photo", "tags": self.tags} def snapshot(path): return exiftool.read_all(str(path)) def stage_fields(before, after): """Everything that changed except this application's owned and volatile tags.""" return exif_checkpoint.compare(before, after) # ── preservation ────────────────────────────────────────────────────────────── def test_a_safety_decision_preserves_every_user_field(env): case = CASES_BY_ID["user_exif"] path = env.files["user_exif"] before = snapshot(path) assert before, "the fixture must actually carry user metadata" result = SafetyService(env.sf).decide(env.assets["user_exif"], "nsfw") after = snapshot(path) assert result["exif_verified"] is True assert stage_fields(before, after) == () for field in case.preserved_fields: assert after[field] == before[field], field assert "nsfw" in exif_checkpoint.owned_values(after) assert "sfw" not in exif_checkpoint.owned_values(after) def test_analysis_keywords_are_additive_and_keep_the_safety_decision(env): """The two stages share the Keywords field; the second must merge, not replace.""" asset_id = env.assets["prior_analysis_keywords"] path = env.files["prior_analysis_keywords"] SafetyService(env.sf).decide(asset_id, "sfw") before = snapshot(path) AnalysisService( env.sf, provider=Provider(["harbour", "boats"]), library_roots=(env.lib,) ).run([asset_id]) after = snapshot(path) keywords = exif_checkpoint.owned_values(after) assert {"sfw", "beach", "sunset", "harbour", "boats"} <= keywords assert "nsfw" not in keywords assert stage_fields(before, after) == () def test_flipping_a_safety_decision_removes_only_the_opposite_keyword(env): asset_id = env.assets["prior_analysis_keywords"] path = env.files["prior_analysis_keywords"] safety = SafetyService(env.sf) safety.decide(asset_id, "nsfw") before = snapshot(path) safety.decide(asset_id, "sfw") after = snapshot(path) keywords = exif_checkpoint.owned_values(after) assert "sfw" in keywords and "nsfw" not in keywords assert {"beach", "sunset"} <= keywords, "analysis keywords are not safety's to remove" assert stage_fields(before, after) == () def test_conflicting_safety_keywords_are_resolved_to_one(env): asset_id = env.assets["conflicting_safety_keywords"] SafetyService(env.sf).decide(asset_id, "sfw") keywords = exif_checkpoint.owned_values(snapshot(env.files["conflicting_safety_keywords"])) assert keywords & {"sfw", "nsfw"} == {"sfw"} def test_malformed_metadata_does_not_block_the_checkpoint(env): result = SafetyService(env.sf).decide(env.assets["malformed_metadata"], "nsfw") assert result["exif_verified"] is True # ── verification and the refreshed hash ─────────────────────────────────────── def test_a_verified_checkpoint_refreshes_the_recorded_bytes(env): """exiftool rewrites the container, so the stored SHA-256 must be the new one — upload compares against exactly these bytes.""" asset_id = env.assets["no_exif"] path = env.files["no_exif"] SafetyService(env.sf).decide(asset_id, "sfw") with env.sf() as session: asset = session.get(Asset, asset_id) row = session.get(ExifProjection, (asset_id, "safety")) import hashlib on_disk = hashlib.sha256(path.read_bytes()).hexdigest() assert asset.current_sha256 == on_disk assert row.state == "verified" and row.result_file_sha256 == on_disk assert json.loads(row.desired_json) == {"add": ["sfw"], "remove": ["nsfw"]} def test_the_projection_survives_a_restart(env): asset_id = env.assets["user_exif"] SafetyService(env.sf).decide(asset_id, "nsfw") engine = create_db_engine(env.config.database_url) # a fresh connection, as a restart is try: with create_session_factory(engine)() as session: row = session.get(ExifProjection, (asset_id, "safety")) assert row.state == "verified" and row.verified_at is not None finally: engine.dispose() # ── divergence ──────────────────────────────────────────────────────────────── def test_a_field_the_stage_does_not_own_changing_is_divergent(env, monkeypatch): """Something rewrote the artist while the safety keyword was being written. The stage must not call that verified, must record what moved, and must not put the old value back — the file is now a question for a human. """ asset_id = env.assets["user_exif"] path = env.files["user_exif"] real_apply = exiftool.apply_keywords def sabotage(target, *, add=(), remove=()): ok = real_apply(target, add=add, remove=remove) subprocess.run( ["exiftool", "-m", "-overwrite_original", "-Artist=Someone Else", str(target)], capture_output=True, check=False, ) return ok monkeypatch.setattr(exiftool, "apply_keywords", sabotage) result = SafetyService(env.sf).decide(asset_id, "nsfw") assert result["exif_verified"] is False, "a divergent checkpoint is not verified" with env.sf() as session: row = session.get(ExifProjection, (asset_id, "safety")) asset = session.get(Asset, asset_id) assert row.state == "divergent" assert "EXIF:IFD0:Artist" in json.loads(row.divergent_fields) assert row.verified_at is None # Not repaired, and not silently accepted as the current verified bytes. assert snapshot(path)["EXIF:IFD0:Artist"] == "Someone Else" assert asset.current_sha256 is None def test_a_divergent_asset_shows_up_in_the_review_queue(env, monkeypatch): asset_id = env.assets["user_exif"] monkeypatch.setattr( exif_checkpoint, "run", lambda *args, **kwargs: exif_checkpoint.CheckpointResult( exif_checkpoint.DIVERGENT, changed_fields=("EXIF:IFD0:Artist",), sha256="abc" ), ) SafetyService(env.sf).decide(asset_id, "sfw") rows = SafetyService(env.sf).review_queue()["items"] row = next(item for item in rows if item["asset_id"] == asset_id) assert row["exif_state"] == "divergent" assert row["exif_verified"] is False def test_a_write_that_does_not_take_is_a_failure_not_a_verification(env, monkeypatch): monkeypatch.setattr(exiftool, "apply_keywords", lambda *a, **k: False) result = SafetyService(env.sf).decide(env.assets["no_exif"], "sfw") assert result["exif_verified"] is False assert exif_checkpoint.state_for(env.sf, env.assets["no_exif"], "safety") == "failed" def test_unreadable_metadata_is_a_failure_not_an_empty_snapshot(env, monkeypatch): """``None`` from exiftool means "cannot answer"; treating it as "nothing there" would make every field look preserved.""" monkeypatch.setattr(exiftool, "read_all", lambda path: None) result = exif_checkpoint.run(str(env.files["no_exif"]), add=("sfw",)) assert result.state == "failed" and result.reason == "metadata_unreadable" def test_analysis_records_its_own_projection_separately(env): asset_id = env.assets["user_exif"] SafetyService(env.sf).decide(asset_id, "sfw") AnalysisService(env.sf, provider=Provider(["pier"]), library_roots=(env.lib,)).run([asset_id]) assert exif_checkpoint.state_for(env.sf, asset_id, "safety") == "verified" assert exif_checkpoint.state_for(env.sf, asset_id, "analysis") == "verified" # ── the comparison rules themselves ─────────────────────────────────────────── def test_compare_ignores_owned_and_volatile_fields_only(): before = { "EXIF:IFD0:Artist": "Ada", "IPTC:Keywords": ["holiday"], "File:System:FileSize": "3.8 kB", "File:CurrentIPTCDigest": "aaa", "XMP:XMP-x:XMPToolkit": "old", } after = { "EXIF:IFD0:Artist": "Ada", "IPTC:Keywords": ["holiday", "sfw"], "File:System:FileSize": "3.9 kB", "File:CurrentIPTCDigest": "bbb", "XMP:XMP-x:XMPToolkit": "new", } assert exif_checkpoint.compare(before, after) == () after["EXIF:GPS:GPSLatitude"] = "48.1" # an addition counts as much as a loss del after["EXIF:IFD0:Artist"] assert exif_checkpoint.compare(before, after) == ("EXIF:GPS:GPSLatitude", "EXIF:IFD0:Artist")