272 lines
9.7 KiB
Python
272 lines
9.7 KiB
Python
"""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]
|