Files
alfred/tests/infrastructure/persistence/dot_alfred/test_repository.py
T
francwa c22b2b78eb refactor(domain): Phase 3 — TVShow/Movie aggregates become TMDB-only
Filesystem-side concerns (file paths, tracks, quality, mode, added_at)
move to the releases/ domain added in Phase 1; the TMDB aggregates now
carry only identity + TMDB catalog facts.

Domain entities:
- TVShow: tmdb_id: TmdbId required (primary key), imdb_id: ImdbId | None
  optional, status: str = "unknown" added.
- Season: episode_count: int = 0 added (TMDB-cached); audio_tracks,
  subtitle_tracks, mode property removed.
- Episode: slimmed to identity + title. file_path/file_size/tracks
  removed. No longer inherits MediaWithTracks.
- Movie: tmdb_id required, imdb_id optional. file_path/file_size/quality/
  added_at/audio_tracks/subtitle_tracks removed. get_filename() now
  returns "Title.Year" — quality moves to MovieRelease.

Builders:
- TVShowBuilder requires tmdb_id: TmdbId; imdb_id/status optional.
- SeasonBuilder.set_episode_count(int) replaces set_audio_tracks /
  set_subtitle_tracks.

No-coercion contract: TVShow(tmdb_id=1396) raises — callers pass
TmdbId(1396). No ergonomic shim per the no-shims rule.

Cascade fixes:
- MediaOrganizer test fixtures updated to new Movie/TVShow shapes.
- Movie.get_filename() re-added (without Quality) so MediaOrganizer
  keeps working until Phase 4 rewires it through MovieRelease.

Quarantined (deleted in Phase 4 alongside v1 dot_alfred):
- tests/application/library/test_rescan.py — module-level skip.
- tests/infrastructure/persistence/dot_alfred/test_repository.py —
  module-level skip.
- tests/infrastructure/persistence/dot_alfred/test_serializer.py —
  module-level skip.

Suite: 1216 passed, 11 skipped (8 pre-existing + 3 Phase 3
quarantines), 4 xfailed. CHANGELOG updated under [Unreleased].
2026-05-25 19:54:35 +02:00

317 lines
11 KiB
Python

"""Tests for the filesystem-backed ``.alfred`` repository."""
from __future__ import annotations
import pytest
import yaml
# Phase 3 (refactor/dot-alfred-v2): v1 repository is intentionally
# left in tree as a frozen reference until Phase 4 deletes both v1
# and this test module in one swing.
pytest.skip(
"v1 dot_alfred repository — replaced in Phase 4",
allow_module_level=True,
)
from alfred.domain.shared.media import AudioTrack, SubtitleTrack
from alfred.domain.shared.value_objects import FilePath, ImdbId
from alfred.domain.tv_shows.builders import TVShowBuilder
from alfred.domain.tv_shows.entities import Episode
from alfred.infrastructure.persistence.dot_alfred import (
DotAlfredTVShowRepository,
ShowFolderUnknown,
)
from alfred.infrastructure.persistence.dot_alfred.repository import (
SIDECAR_FILENAME,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_breaking_bad_episodic():
"""Breaking Bad with one EPISODIC season carrying two episodes."""
return (
TVShowBuilder(
imdb_id="tt0903747",
title="Breaking Bad",
tmdb_id=1396,
)
.add_episode(
Episode(
season_number=5,
episode_number=1,
title="Live Free or Die",
file_path=FilePath("Breaking.Bad.S05E01.mkv"),
audio_tracks=(
AudioTrack(
index=0,
codec=None,
channels=None,
channel_layout=None,
language="eng",
),
),
subtitle_tracks=(
SubtitleTrack(
index=0,
codec=None,
language="eng",
is_default=False,
is_forced=False,
),
),
)
)
.add_episode(
Episode(
season_number=5,
episode_number=2,
title="Madrigal",
file_path=FilePath("Breaking.Bad.S05E02.mkv"),
audio_tracks=(
AudioTrack(
index=0,
codec=None,
channels=None,
channel_layout=None,
language="eng",
),
),
)
)
.build()
)
def _make_foundation_pack():
"""Foundation as a PACK season (no episodes in the aggregate)."""
return TVShowBuilder(
imdb_id="tt0804484",
title="Foundation",
tmdb_id=84958,
).build()
# ---------------------------------------------------------------------------
# save + find_by_imdb_id round-trip
# ---------------------------------------------------------------------------
class TestSaveAndRead:
def test_save_then_find_by_imdb_id(self, tmp_path):
# Folder must exist before save (the repo never invents one).
(tmp_path / "Breaking.Bad").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
show = _make_breaking_bad_episodic()
repo.save(show)
recovered = repo.find_by_imdb_id(ImdbId("tt0903747"))
assert recovered is not None
assert recovered.imdb_id == show.imdb_id
assert recovered.tmdb_id == show.tmdb_id
assert recovered.seasons_count == 1
assert recovered.episode_count == 2
def test_find_by_imdb_id_uses_folder_name_as_title(self, tmp_path):
(tmp_path / "Breaking.Bad").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
recovered = repo.find_by_imdb_id(ImdbId("tt0903747"))
assert recovered is not None
assert recovered.title == "Breaking.Bad"
def test_find_returns_none_for_unknown(self, tmp_path):
repo = DotAlfredTVShowRepository(tmp_path)
assert repo.find_by_imdb_id(ImdbId("tt9999999")) is None
def test_find_returns_none_for_cold_folder(self, tmp_path):
# Folder exists but has no .alfred — cold scan returns None.
(tmp_path / "Breaking.Bad").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
assert repo.find_by_imdb_id(ImdbId("tt0903747")) is None
def test_pack_season_round_trip(self, tmp_path):
(tmp_path / "Foundation").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_foundation_pack())
recovered = repo.find_by_imdb_id(ImdbId("tt0804484"))
assert recovered is not None
assert recovered.seasons_count == 0
# ---------------------------------------------------------------------------
# find_all
# ---------------------------------------------------------------------------
class TestFindAll:
def test_returns_every_sidecar(self, tmp_path):
(tmp_path / "Breaking.Bad").mkdir()
(tmp_path / "Foundation").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
repo.save(_make_foundation_pack())
all_shows = repo.find_all()
ids = {str(s.imdb_id) for s in all_shows}
assert ids == {"tt0903747", "tt0804484"}
def test_skips_folders_without_sidecar(self, tmp_path):
(tmp_path / "Breaking.Bad").mkdir()
(tmp_path / "ColdFolder").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
assert len(repo.find_all()) == 1
def test_skips_corrupted_sidecar(self, tmp_path, caplog):
cold = tmp_path / "Garbage"
cold.mkdir()
(cold / SIDECAR_FILENAME).write_text("not: valid: yaml: :{[")
repo = DotAlfredTVShowRepository(tmp_path)
assert repo.find_all() == []
def test_skips_schema_violation(self, tmp_path):
bad = tmp_path / "WrongSchema"
bad.mkdir()
(bad / SIDECAR_FILENAME).write_text(
yaml.safe_dump({"schema_version": 99, "imdb_id": "tt0", "seasons": []})
)
repo = DotAlfredTVShowRepository(tmp_path)
assert repo.find_all() == []
def test_empty_library(self, tmp_path):
repo = DotAlfredTVShowRepository(tmp_path)
assert repo.find_all() == []
# ---------------------------------------------------------------------------
# delete + exists
# ---------------------------------------------------------------------------
class TestDeleteAndExists:
def test_exists_after_save(self, tmp_path):
(tmp_path / "Breaking.Bad").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
assert repo.exists(ImdbId("tt0903747")) is True
def test_exists_false_before_save(self, tmp_path):
repo = DotAlfredTVShowRepository(tmp_path)
assert repo.exists(ImdbId("tt0903747")) is False
def test_delete_removes_sidecar(self, tmp_path):
show_dir = tmp_path / "Breaking.Bad"
show_dir.mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
assert (show_dir / SIDECAR_FILENAME).is_file()
assert repo.delete(ImdbId("tt0903747")) is True
assert not (show_dir / SIDECAR_FILENAME).exists()
# The show folder itself stays — the repo only owns the sidecar.
assert show_dir.is_dir()
def test_delete_returns_false_when_unknown(self, tmp_path):
repo = DotAlfredTVShowRepository(tmp_path)
assert repo.delete(ImdbId("tt9999999")) is False
# ---------------------------------------------------------------------------
# save edge cases
# ---------------------------------------------------------------------------
class TestSaveEdgeCases:
def test_save_raises_when_folder_missing(self, tmp_path):
repo = DotAlfredTVShowRepository(tmp_path)
with pytest.raises(ShowFolderUnknown):
repo.save(_make_breaking_bad_episodic())
def test_save_atomic_no_tmp_file_left_behind(self, tmp_path):
show_dir = tmp_path / "Breaking.Bad"
show_dir.mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
# Only the final .alfred should remain.
leftovers = [p.name for p in show_dir.iterdir()]
assert leftovers == [SIDECAR_FILENAME]
def test_save_overwrites_existing(self, tmp_path):
show_dir = tmp_path / "Breaking.Bad"
show_dir.mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
# Save a second time with a different aggregate — sidecar must
# carry the new content.
updated = TVShowBuilder(
imdb_id="tt0903747",
title="Breaking Bad",
tmdb_id=1396,
).build()
repo.save(updated)
recovered = repo.find_by_imdb_id(ImdbId("tt0903747"))
assert recovered is not None
assert recovered.seasons_count == 0 # the second save dropped the season
def test_save_finds_folder_via_get_folder_name(self, tmp_path):
# show.get_folder_name() returns "Breaking.Bad" for title "Breaking Bad"
(tmp_path / "Breaking.Bad").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
# No prior save → cache empty → falls back to get_folder_name().
repo.save(_make_breaking_bad_episodic())
assert (tmp_path / "Breaking.Bad" / SIDECAR_FILENAME).is_file()
def test_save_falls_back_to_scan_when_folder_renamed(self, tmp_path):
# Create a custom folder name (not the show's default).
custom = tmp_path / "Breaking.Bad.1080p"
custom.mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
# Pre-populate the index by reading the sidecar — first we need
# to put one there. Write a minimal one by hand.
(custom / SIDECAR_FILENAME).write_text(
yaml.safe_dump(
{
"schema_version": 1,
"imdb_id": "tt0903747",
"tmdb_id": 1396,
"seasons": [],
}
)
)
# find_all primes the folder index.
repo.find_all()
# Now save through the repo — it must reuse the custom folder.
repo.save(_make_breaking_bad_episodic())
assert (custom / SIDECAR_FILENAME).is_file()
# Default folder was never created.
assert not (tmp_path / "Breaking.Bad").exists()
# ---------------------------------------------------------------------------
# Sidecar content sanity
# ---------------------------------------------------------------------------
class TestSidecarContent:
def test_written_yaml_matches_schema(self, tmp_path):
(tmp_path / "Breaking.Bad").mkdir()
repo = DotAlfredTVShowRepository(tmp_path)
repo.save(_make_breaking_bad_episodic())
text = (tmp_path / "Breaking.Bad" / SIDECAR_FILENAME).read_text()
data = yaml.safe_load(text)
assert data["schema_version"] == 1
assert data["imdb_id"] == "tt0903747"
assert data["tmdb_id"] == 1396
# One EPISODIC season with two episodes.
assert len(data["seasons"]) == 1
season = data["seasons"][0]
assert season["number"] == 5
assert len(season["episodes"]) == 2
# Episode paths come from FilePath.
assert season["episodes"][0]["path"] == "Breaking.Bad.S05E01.mkv"