"""Repository tests for AlbumService.aggregate_evidence (US03-01): folder grouping, the documented exclusion/conflict rules, ordering, paging, and — the heart of the story — that each folder's version is stable across reads and invalidated by exactly the relevant asset changes, before and after those changes. """ import uuid from datetime import datetime, timedelta, timezone from pathlib import Path 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.albums import AlbumService NOW = datetime(2026, 1, 1, tzinfo=timezone.utc) LIB = (Path("/lib"),) # library root, so album labels are library-relative def _service(sf): return AlbumService(sf, library_roots=LIB) 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 create_session_factory(create_db_engine(config.database_url)) def _asset(sf, path, *, canonical_of=None, availability="active", asset_id=None): asset_id = asset_id or 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, availability_state=availability, canonical_asset_id=canonical_of, ) ) session.commit() return asset_id def _decide(sf, asset_id, decision, *, at=NOW): with sf() as session: session.add( SafetyReview(id=str(uuid.uuid4()), asset_id=asset_id, decision=decision, created_at=at) ) session.commit() def _analyze(sf, asset_id, **fields): with sf() as session: row = session.get(AnalysisResult, asset_id) or AnalysisResult(asset_id=asset_id) row.status = fields.pop("status", "analyzed") for key, value in fields.items(): setattr(row, key, value) session.add(row) session.commit() def _sfw_analyzed(sf, path, **fields): asset_id = _asset(sf, path) _decide(sf, asset_id, "sfw") _analyze(sf, asset_id, **fields) return asset_id def _folders(sf): return {f["album"]: f for f in _service(sf).aggregate_evidence()["folders"]} def test_groups_by_full_folder_path_and_summarises_sfw_evidence(tmp_path): sf = _factory(tmp_path) _sfw_analyzed( sf, "/lib/rome/a.jpg", description="Colosseum", tags='["ruins", "city"]', setting="outdoor", approx_year=2019, people_count=2, location_hint="Rome", ) _sfw_analyzed( sf, "/lib/rome/b.jpg", description="Forum", tags='["ruins", "history"]', setting="outdoor", approx_year=2019, people_count=4, location_hint="Rome", ) rome = _folders(sf)["rome"] assert rome["folder_name"] == "rome" and rome["parent_path"] == "" assert rome["asset_count"] == 2 and rome["analyzed_count"] == 2 assert rome["descriptions"] == ["Colosseum", "Forum"] # sorted assert rome["tags"][0] == {"value": "ruins", "count": 2} # most frequent first assert rome["locations"] == [{"value": "Rome", "count": 2}] assert rome["people_counts"] == [{"value": "2", "count": 1}, {"value": "3+", "count": 1}] assert rome["dominant_year"] == 2019 and rome["year_conflict"] is False def test_exclusion_rules_for_dup_nsfw_deferred_and_missing(tmp_path): sf = _factory(tmp_path) canonical = _sfw_analyzed(sf, "/lib/mix/keep.jpg", description="kept", tags='["a"]') # Duplicate variant of the canonical: excluded entirely. _asset(sf, "/lib/mix/dup.jpg", canonical_of=canonical) # NSFW: counted, but no descriptive evidence. nsfw = _asset(sf, "/lib/mix/private.jpg") _decide(sf, nsfw, "nsfw") # Deferred and undecided: counted as pending, no evidence. deferred = _asset(sf, "/lib/mix/later.jpg") _decide(sf, deferred, "deferred") _asset(sf, "/lib/mix/unknown.jpg") # no decision at all # Archived: not in an active folder, excluded entirely. _asset(sf, "/lib/mix/gone.jpg", availability="archived_offline") mix = _folders(sf)["mix"] # Only canonical, active assets are counted: keep + nsfw + deferred + unknown. # The duplicate variant and the archived asset are excluded entirely. assert mix["asset_count"] == 4 assert mix["nsfw_count"] == 1 assert mix["pending_count"] == 2 # deferred + unknown assert mix["analyzed_count"] == 1 assert mix["descriptions"] == ["kept"] and mix["tags"] == [{"value": "a", "count": 1}] def test_conflicting_year_reports_distribution_and_no_dominant(tmp_path): sf = _factory(tmp_path) _sfw_analyzed(sf, "/lib/trip/a.jpg", approx_year=2018) _sfw_analyzed(sf, "/lib/trip/b.jpg", approx_year=2019) trip = _folders(sf)["trip"] assert trip["years"] == [{"value": 2018, "count": 1}, {"value": 2019, "count": 1}] assert trip["dominant_year"] is None and trip["year_conflict"] is True def test_folders_are_ordered_and_pageable(tmp_path): sf = _factory(tmp_path) for folder in ("/lib/c", "/lib/a", "/lib/b"): _sfw_analyzed(sf, f"{folder}/x.jpg", description="x") service = _service(sf) assert service.folder_count() == 3 full = service.aggregate_evidence() assert [f["album"] for f in full["folders"]] == ["a", "b", "c"] page = service.aggregate_evidence(offset=1, limit=1) assert page["total"] == 3 and page["offset"] == 1 assert [f["album"] for f in page["folders"]] == ["b"] def test_version_is_stable_across_repeated_reads(tmp_path): sf = _factory(tmp_path) _sfw_analyzed(sf, "/lib/rome/a.jpg", description="d", approx_year=2019) first = _folders(sf)["rome"]["version"] second = _folders(sf)["rome"]["version"] assert first == second def test_version_invalidates_on_new_analysis(tmp_path): sf = _factory(tmp_path) _sfw_analyzed(sf, "/lib/rome/a.jpg", description="d") before = _folders(sf)["rome"]["version"] added = _asset(sf, "/lib/rome/b.jpg") _decide(sf, added, "sfw") _analyze(sf, added, description="new") after = _folders(sf)["rome"] assert after["version"] != before and after["analyzed_count"] == 2 def test_version_invalidates_when_a_decision_changes_to_nsfw(tmp_path): sf = _factory(tmp_path) asset = _sfw_analyzed(sf, "/lib/rome/a.jpg", description="d") before = _folders(sf)["rome"]["version"] _decide(sf, asset, "nsfw", at=NOW + timedelta(hours=1)) # newer decision wins after = _folders(sf)["rome"] assert after["version"] != before assert after["nsfw_count"] == 1 and after["analyzed_count"] == 0 def test_moving_an_asset_invalidates_both_folders_and_not_a_third(tmp_path): sf = _factory(tmp_path) mover = _sfw_analyzed(sf, "/lib/src/a.jpg", description="d") _sfw_analyzed(sf, "/lib/dst/keep.jpg", description="k") _sfw_analyzed(sf, "/lib/other/z.jpg", description="z") before = _folders(sf) other_before = before["other"]["version"] with sf() as session: asset = session.get(Asset, mover) asset.current_path = "/lib/dst/a.jpg" session.commit() after = _folders(sf) assert "src" not in after # source folder is now empty assert after["dst"]["asset_count"] == 2 assert after["dst"]["version"] != before["dst"]["version"] assert after["other"]["version"] == other_before # untouched folder is stable def test_version_invalidates_when_an_asset_becomes_a_duplicate(tmp_path): sf = _factory(tmp_path) canonical = _sfw_analyzed(sf, "/lib/rome/a.jpg", description="a") variant = _sfw_analyzed(sf, "/lib/rome/b.jpg", description="b") before = _folders(sf)["rome"]["version"] with sf() as session: session.get(Asset, variant).canonical_asset_id = canonical session.commit() after = _folders(sf)["rome"] assert after["version"] != before assert after["asset_count"] == 1 # the variant no longer contributes