Files
photoanalyzer/tests/integration/test_album_evidence_repo.py

226 lines
8.1 KiB
Python

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