Files
alfred/tests/infrastructure/persistence/dot_alfred/v2/test_library_index.py
T
francwa cc334a7951 feat(dot_alfred/v2): Phase 4 Step 4 — settings + anchor warning
Two small additions that close out Phase 4's loose ends.

Settings — tmdb_cache_ttl_days

    class Settings(BaseSettings):
        # --- DOT_ALFRED ---
        tmdb_cache_ttl_days: int = 14

Default 14 days, matching the dot_alfred_v2 master spec. Will drive
the Phase 5 TTL policy on TVShowLibraryIndexSidecar /
MovieLibraryIndexSidecar (decide when a TMDB-cached entry is stale
and triggers a refresh sync).

Anchor-mismatch warning

DotAlfredTVShowLibraryIndex._load_or_heal and DotAlfredMovieLibraryIndex
._load_or_heal now cross-check each indexed entry's metadata.path
against the on-disk folder layout right after a successful parse.
Drift (sidecar says folder X, X no longer exists under library_root)
is surfaced as a WARNING log — one per missing folder, with the
tmdb_id for cross-reference. No auto-heal on drift; the caller
decides (the heal path remains opt-in via index.heal()).

The warning fires only on the parsed-index path. The heal path
always synthesizes entries from real folder names, so it can never
drift — silent by construction.

Tests

* TestTVShowLibraryIndexAnchorWarning — 3 scenarios:
  warn-on-drift / no-warn-on-match / no-warn-on-heal.
* TestMovieLibraryIndexAnchorWarning — symmetric coverage.

Full suite: 1237 passed / 8 skipped / 4 xfailed.
2026-05-25 21:14:18 +02:00

345 lines
14 KiB
Python

"""Integration tests for the library-root index repositories.
Cover upsert / delete / find_by_* and the auto-heal behavior on
missing / corrupt index files. Auto-heal must produce a valid
sidecar with TMDB-cached fields left as documented placeholders
(``status="unknown"``, ``seasons=()``).
"""
from __future__ import annotations
import logging
from alfred.domain.shared.value_objects import ImdbId, TmdbId
from alfred.infrastructure.persistence.dot_alfred.v2.repository import (
DotAlfredMovieLibraryIndex,
DotAlfredMovieReleaseRepository,
DotAlfredSeriesReleaseRepository,
DotAlfredTVShowLibraryIndex,
)
# ════════════════════════════════════════════════════════════════════════════
# TV — upsert / find / delete
# ════════════════════════════════════════════════════════════════════════════
class TestTVShowLibraryIndexUpsert:
def test_upsert_creates_index_file(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
assert (tv_library / ".alfred.index").is_file()
def test_upsert_then_find_by_tmdb_id_returns_entry(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
entry = index.find_by_tmdb_id(TmdbId(84958))
assert entry is not None
assert entry.name == "Foundation"
assert entry.status == "Returning Series"
assert entry.metadata.path == "Foundation"
assert entry.metadata.fetched_at == now_utc
def test_upsert_flattens_multi_episode_file_across_slots(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
entry = index.find_by_tmdb_id(TmdbId(84958))
s02 = next(s for s in entry.seasons if s.number == 2)
# E02 and E03 must point to the SAME multi-episode file.
assert s02.episodes["E02"] == s02.episodes["E03"]
assert "E02-E03" in s02.episodes["E02"]
def test_upsert_twice_replaces_entry_does_not_duplicate(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
all_entries = index.find_all()
assert len(all_entries) == 1
def test_find_by_imdb_id_returns_entry(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
entry = index.find_by_imdb_id(ImdbId("tt0804484"))
assert entry is not None
assert entry.tmdb_id == 84958
def test_find_by_path_returns_entry(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
entry = index.find_by_path("Foundation")
assert entry is not None
def test_delete_removes_entry(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
assert index.delete(TmdbId(84958)) is True
assert index.find_by_tmdb_id(TmdbId(84958)) is None
def test_delete_unknown_id_returns_false(self, tv_library):
index = DotAlfredTVShowLibraryIndex(tv_library)
assert index.delete(TmdbId(999)) is False
# ════════════════════════════════════════════════════════════════════════════
# TV — auto-heal
# ════════════════════════════════════════════════════════════════════════════
class TestTVShowLibraryIndexAutoHeal:
def test_missing_index_is_silently_healed_from_per_show_sidecars(
self, tv_library, foundation_release, caplog
):
# Write a per-show sidecar but no index.
release_repo = DotAlfredSeriesReleaseRepository(tv_library)
release_repo.save(foundation_release, show_folder="Foundation")
assert not (tv_library / ".alfred.index").exists()
index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=release_repo)
with caplog.at_level(logging.INFO):
entry = index.find_by_tmdb_id(TmdbId(84958))
assert entry is not None
assert entry.tmdb_id == 84958
# Healed entries carry placeholders (no TMDB sync yet).
assert entry.status == "unknown"
assert entry.seasons == ()
assert (tv_library / ".alfred.index").is_file()
assert any("healing" in r.message for r in caplog.records)
def test_corrupt_index_is_healed(
self, tv_library, foundation_release, caplog
):
release_repo = DotAlfredSeriesReleaseRepository(tv_library)
release_repo.save(foundation_release, show_folder="Foundation")
# Plant a corrupt index.
(tv_library / ".alfred.index").write_text("not: [valid yaml")
index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=release_repo)
with caplog.at_level(logging.WARNING):
entries = index.find_all()
assert len(entries) == 1
assert any("corrupt" in r.message for r in caplog.records)
def test_schema_version_mismatch_in_index_is_healed(
self, tv_library, foundation_release
):
release_repo = DotAlfredSeriesReleaseRepository(tv_library)
release_repo.save(foundation_release, show_folder="Foundation")
(tv_library / ".alfred.index").write_text(
"schema_version: 999\nshows: []\n"
)
index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=release_repo)
entries = index.find_all()
# After heal, only Foundation (the only valid per-show sidecar) appears.
assert len(entries) == 1
assert entries[0].tmdb_id == 84958
def test_heal_is_idempotent(
self, tv_library, foundation_release
):
release_repo = DotAlfredSeriesReleaseRepository(tv_library)
release_repo.save(foundation_release, show_folder="Foundation")
index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=release_repo)
first = index.heal()
second = index.heal()
# Compare model state minus the ``fetched_at`` (timestamps differ).
assert len(first.shows) == len(second.shows) == 1
assert first.shows[0].tmdb_id == second.shows[0].tmdb_id
def test_heal_with_empty_library_writes_empty_index(self, tv_library):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.heal()
assert (tv_library / ".alfred.index").is_file()
assert index.find_all() == ()
# ════════════════════════════════════════════════════════════════════════════
# TV — atomicity
# ════════════════════════════════════════════════════════════════════════════
class TestTVShowLibraryIndexAtomicity:
def test_upsert_leaves_no_tmp_file(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
tmps = list(tv_library.glob("*.tmp"))
assert tmps == []
# ════════════════════════════════════════════════════════════════════════════
# Movies
# ════════════════════════════════════════════════════════════════════════════
class TestMovieLibraryIndex:
def test_upsert_and_find(
self, movie_library, inception_release, now_utc
):
index = DotAlfredMovieLibraryIndex(movie_library)
index.upsert(
inception_release,
name="Inception",
release_year=2010,
path=inception_release.folder,
fetched_at=now_utc,
)
entry = index.find_by_tmdb_id(TmdbId(27205))
assert entry is not None
assert entry.name == "Inception"
assert entry.release_year == 2010
def test_missing_index_heals_from_movie_sidecars(
self, movie_library, inception_release, caplog
):
release_repo = DotAlfredMovieReleaseRepository(movie_library)
release_repo.save(inception_release)
index = DotAlfredMovieLibraryIndex(movie_library, release_repo=release_repo)
with caplog.at_level(logging.INFO):
entry = index.find_by_tmdb_id(TmdbId(27205))
assert entry is not None
assert entry.tmdb_id == 27205
# Placeholder until TMDB sync.
assert entry.release_year is None
assert any("healing" in r.message for r in caplog.records)
# ════════════════════════════════════════════════════════════════════════════
# Anchor-mismatch warnings (parsed-index path only — heal path is silent)
# ════════════════════════════════════════════════════════════════════════════
class TestTVShowLibraryIndexAnchorWarning:
def test_warns_when_indexed_folder_missing(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc, caplog
):
# Seed the index with a path that exists ("Foundation"), then
# delete the folder so the next read detects the drift.
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
(tv_library / "Foundation").rmdir()
with caplog.at_level(logging.WARNING):
index.find_by_tmdb_id(TmdbId(84958))
assert any(
"anchor mismatch" in r.message and "Foundation" in r.message
for r in caplog.records
)
def test_no_warning_when_indexed_folder_present(
self, tv_library, foundation_release, foundation_tmdb_info, now_utc, caplog
):
index = DotAlfredTVShowLibraryIndex(tv_library)
index.upsert(
foundation_tmdb_info,
foundation_release,
path="Foundation",
fetched_at=now_utc,
)
with caplog.at_level(logging.WARNING):
index.find_by_tmdb_id(TmdbId(84958))
assert not any(
"anchor mismatch" in r.message for r in caplog.records
)
def test_no_warning_on_heal_path(
self, tv_library, foundation_release, caplog
):
# The heal path always synthesizes entries from real folder
# names, so it can never drift — no anchor warning should fire.
release_repo = DotAlfredSeriesReleaseRepository(tv_library)
release_repo.save(foundation_release, show_folder="Foundation")
index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=release_repo)
with caplog.at_level(logging.WARNING):
index.find_by_tmdb_id(TmdbId(84958))
assert not any(
"anchor mismatch" in r.message for r in caplog.records
)
class TestMovieLibraryIndexAnchorWarning:
def test_warns_when_indexed_folder_missing(
self, movie_library, inception_release, now_utc, caplog
):
index = DotAlfredMovieLibraryIndex(movie_library)
index.upsert(
inception_release,
name="Inception",
release_year=2010,
path=inception_release.folder,
fetched_at=now_utc,
)
(movie_library / inception_release.folder).rmdir()
with caplog.at_level(logging.WARNING):
index.find_by_tmdb_id(TmdbId(27205))
assert any(
"anchor mismatch" in r.message and inception_release.folder in r.message
for r in caplog.records
)