"""Tests for ``sync_show`` — TMDB sync orchestrator for TV shows. Cover the four interesting paths the spec calls out: * placeholder entry (auto-healed index) — always refreshes regardless of TTL; * fresh entry within TTL — no-op, no TMDB call; * stale entry past TTL — refreshes; * ``force=True`` — refreshes regardless of TTL; * missing on-disk sidecar but indexed — warns + upserts empty slots; * not in index and no sidecar — raises :class:`ShowNotFoundInLibrary`. """ from __future__ import annotations import logging from datetime import UTC, datetime, timedelta import pytest from alfred.application.exceptions import ShowNotFoundInLibrary from alfred.application.tv_shows_TO_CHECK.sync import sync_show from alfred.domain.shared_TO_CHECK.value_objects import TmdbId from alfred.infrastructure.api_TO_CHECK.tmdb.dto import TmdbSeasonInfo, TmdbShowInfo from alfred.infrastructure.persistence_TO_CHECK.dot_alfred.v2.repository import ( DotAlfredSeriesReleaseRepository, DotAlfredTVShowLibraryIndex, ) class _StubTMDBClient: """Minimal stand-in implementing only ``get_tv_show_info``. Records call count so tests can assert no-op behavior on fresh entries. Raising stubs are passed inline when needed. """ def __init__(self, info: TmdbShowInfo) -> None: self._info = info self.calls: list[int] = [] def get_tv_show_info(self, tmdb_id: int) -> TmdbShowInfo: self.calls.append(tmdb_id) return self._info @pytest.fixture def tmdb_info_fresh() -> TmdbShowInfo: """TMDB payload returned by the stub — distinct from any placeholder.""" return TmdbShowInfo( tmdb_id=84958, imdb_id="tt0804484", name="Foundation", status="Returning Series", seasons=( TmdbSeasonInfo(number=1, episode_count=10, aired=True), TmdbSeasonInfo(number=2, episode_count=10, aired=True), ), ) @pytest.fixture def now_clock(now_utc): """A ``now()`` callable that returns the fixed ``now_utc``.""" return lambda: now_utc def _make_index_and_repos(tv_library): repo = DotAlfredSeriesReleaseRepository(tv_library) index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=repo) return repo, index # ════════════════════════════════════════════════════════════════════════════ # Happy paths # ════════════════════════════════════════════════════════════════════════════ class TestSyncShowRefreshesPlaceholder: def test_placeholder_entry_refreshes_regardless_of_ttl( self, tv_library, foundation_release, tmdb_info_fresh, now_clock ): # Persist the per-show sidecar; the index auto-heal will then # produce a placeholder entry (status="unknown") on first read. repo, index = _make_index_and_repos(tv_library) repo.save(foundation_release, show_folder="Foundation") client = _StubTMDBClient(tmdb_info_fresh) # TTL=365 days — without the placeholder rule, this entry would # be considered fresh (auto-heal stamps fetched_at=now). result = sync_show( tv_library, tmdb_id=TmdbId(84958), index=index, release_repo=repo, tmdb_client=client, ttl_days=365, now=now_clock, ) assert client.calls == [84958] assert result.name == "Foundation" assert result.status == "Returning Series" # Index entry was upserted with the on-disk slot map. assert result.seasons[0].episodes # season 1 has files class TestSyncShowTTLGate: def test_fresh_entry_within_ttl_is_noop( self, tv_library, foundation_release, tmdb_info_fresh, now_utc ): repo, index = _make_index_and_repos(tv_library) repo.save(foundation_release, show_folder="Foundation") # First sync — populates the entry with real status. client = _StubTMDBClient(tmdb_info_fresh) sync_show( tv_library, tmdb_id=TmdbId(84958), index=index, release_repo=repo, tmdb_client=client, ttl_days=14, now=lambda: now_utc, ) first_count = len(client.calls) # Second sync, ttl=14d, called 3 days later — should be no-op. later = now_utc + timedelta(days=3) result = sync_show( tv_library, tmdb_id=TmdbId(84958), index=index, release_repo=repo, tmdb_client=client, ttl_days=14, now=lambda: later, ) assert len(client.calls) == first_count # no extra call assert result.status == "Returning Series" def test_stale_entry_past_ttl_refreshes( self, tv_library, foundation_release, tmdb_info_fresh, now_utc ): repo, index = _make_index_and_repos(tv_library) repo.save(foundation_release, show_folder="Foundation") client = _StubTMDBClient(tmdb_info_fresh) sync_show( tv_library, tmdb_id=TmdbId(84958), index=index, release_repo=repo, tmdb_client=client, ttl_days=14, now=lambda: now_utc, ) first_count = len(client.calls) # 20 days later — past the 14d TTL. later = now_utc + timedelta(days=20) sync_show( tv_library, tmdb_id=TmdbId(84958), index=index, release_repo=repo, tmdb_client=client, ttl_days=14, now=lambda: later, ) assert len(client.calls) == first_count + 1 class TestSyncShowForce: def test_force_bypasses_ttl( self, tv_library, foundation_release, tmdb_info_fresh, now_utc ): repo, index = _make_index_and_repos(tv_library) repo.save(foundation_release, show_folder="Foundation") client = _StubTMDBClient(tmdb_info_fresh) sync_show( tv_library, tmdb_id=TmdbId(84958), index=index, release_repo=repo, tmdb_client=client, ttl_days=14, now=lambda: now_utc, ) first_count = len(client.calls) # Same clock instant — would normally be a no-op. sync_show( tv_library, tmdb_id=TmdbId(84958), index=index, release_repo=repo, tmdb_client=client, ttl_days=14, now=lambda: now_utc, force=True, ) assert len(client.calls) == first_count + 1 # ════════════════════════════════════════════════════════════════════════════ # Degenerate paths # ════════════════════════════════════════════════════════════════════════════ class TestSyncShowMissingSidecar: def test_indexed_show_without_sidecar_still_upserts_with_empty_slots( self, tv_library, foundation_release, tmdb_info_fresh, now_utc, caplog ): # First, fully sync — establishes an index entry pointing at # the Foundation folder. repo, index = _make_index_and_repos(tv_library) repo.save(foundation_release, show_folder="Foundation") client = _StubTMDBClient(tmdb_info_fresh) sync_show( tv_library, tmdb_id=TmdbId(84958), index=index, release_repo=repo, tmdb_client=client, ttl_days=14, now=lambda: now_utc, ) # Remove the per-show sidecar; index entry persists. (tv_library / "Foundation" / ".alfred").unlink() # Force a re-sync — load_by_tmdb_id will return None, but the # index entry's path anchor lets us proceed with empty slots. with caplog.at_level(logging.WARNING): result = sync_show( tv_library, tmdb_id=TmdbId(84958), index=index, release_repo=repo, tmdb_client=client, ttl_days=14, now=lambda: now_utc, force=True, ) assert result.metadata.path == "Foundation" # Slot map is empty across all seasons. for season in result.seasons: assert season.episodes == {} assert any("per-show sidecar missing" in r.message for r in caplog.records) class TestSyncShowNotFound: def test_no_index_entry_no_sidecar_raises( self, tv_library, tmdb_info_fresh, now_clock ): repo, index = _make_index_and_repos(tv_library) # No per-show sidecar saved → index auto-heals to an empty # entry list. tmdb_id=84958 is not in the (empty) library. client = _StubTMDBClient(tmdb_info_fresh) with pytest.raises(ShowNotFoundInLibrary, match="84958"): sync_show( tv_library, tmdb_id=TmdbId(84958), index=index, release_repo=repo, tmdb_client=client, ttl_days=14, now=now_clock, ) # Note: TMDB *was* called (sync hits TMDB then checks for the # release) — that's by design, so the caller learns whether # tmdb_id is even valid before being told "not in library". assert client.calls == [84958]