"""Unit tests for anidb_mapper module — AniDB ID resolution from multiple sources.""" import gzip import os import xml.etree.ElementTree as ET from unittest.mock import MagicMock, patch import pytest import anidb_mapper # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_settings(**overrides): """Create a mock settings object with sensible defaults for AniDB tests.""" defaults = { "anidb_enabled": True, "anidb_custom_field_name": "anidb_id", "anidb_fallback_to_mapping": True, } defaults.update(overrides) mock = MagicMock() for k, v in defaults.items(): setattr(mock, k, v) return mock def _build_title_dump_xml(entries: list[tuple[int, list[str]]]) -> bytes: """Build a minimal anime-titles.xml.gz payload. Args: entries: list of (aid, [title, ...]) tuples. Returns: gzip-compressed XML bytes. """ root = ET.Element("animetitles") for aid, titles in entries: anime_el = ET.SubElement(root, "anime", aid=str(aid)) for t in titles: title_el = ET.SubElement(anime_el, "title", type="official") title_el.text = t raw = ET.tostring(root, encoding="unicode").encode("utf-8") return gzip.compress(raw) @pytest.fixture(autouse=True) def _reset_title_index(): """Reset module-level title index state between tests.""" anidb_mapper._title_index = {} anidb_mapper._title_index_loaded_at = 0.0 yield anidb_mapper._title_index = {} anidb_mapper._title_index_loaded_at = 0.0 @pytest.fixture(autouse=True) def _reset_title_res_cache(): """Reset the AniList title-resolution TTL cache between tests. Without this, resolve_anidb_from_title() calls from earlier tests leak cached (hit or miss) entries into later tests using the same title. """ anidb_mapper._title_res_cache.clear() yield anidb_mapper._title_res_cache.clear() @pytest.fixture(autouse=True) def _reset_dump_miss_cache(): """Reset the Tier-4 title-dump fuzzy-miss cache between tests. Without this, a "no match" result cached by one test could shadow a genuine (differently-indexed) lookup for the same normalized title in a later test. """ anidb_mapper._dump_miss_cache.clear() yield anidb_mapper._dump_miss_cache.clear() # =========================================================================== # extract_anidb_from_custom_fields # =========================================================================== class TestExtractAnidbFromCustomFields: """Tests for extracting AniDB IDs from Sonarr custom fields.""" @patch("anidb_mapper.get_settings") def test_returns_none_for_empty_series(self, mock_get_settings): mock_get_settings.return_value = _make_settings() assert anidb_mapper.extract_anidb_from_custom_fields({}) is None assert anidb_mapper.extract_anidb_from_custom_fields(None) is None @patch("anidb_mapper.get_settings") def test_extracts_int_value_from_configured_field(self, mock_get_settings): mock_get_settings.return_value = _make_settings(anidb_custom_field_name="anidb_id") series = {"customFields": {"anidb_id": 9876}} assert anidb_mapper.extract_anidb_from_custom_fields(series) == 9876 @patch("anidb_mapper.get_settings") def test_extracts_string_value_from_configured_field(self, mock_get_settings): mock_get_settings.return_value = _make_settings(anidb_custom_field_name="anidb_id") series = {"customFields": {"anidb_id": "1234"}} assert anidb_mapper.extract_anidb_from_custom_fields(series) == 1234 @patch("anidb_mapper.get_settings") def test_tries_fallback_field_names(self, mock_get_settings): mock_get_settings.return_value = _make_settings(anidb_custom_field_name="nonexistent") series = {"customFields": {"AniDB": 555}} assert anidb_mapper.extract_anidb_from_custom_fields(series) == 555 @patch("anidb_mapper.get_settings") def test_tries_anidb_id_camel_case(self, mock_get_settings): mock_get_settings.return_value = _make_settings(anidb_custom_field_name="") series = {"customFields": {"anidbId": 777}} assert anidb_mapper.extract_anidb_from_custom_fields(series) == 777 @patch("anidb_mapper.get_settings") def test_returns_none_when_no_custom_fields(self, mock_get_settings): mock_get_settings.return_value = _make_settings() series = {"title": "Some Series"} assert anidb_mapper.extract_anidb_from_custom_fields(series) is None @patch("anidb_mapper.get_settings") def test_returns_none_for_invalid_string_value(self, mock_get_settings): mock_get_settings.return_value = _make_settings() series = {"customFields": {"anidb_id": "not_a_number"}} assert anidb_mapper.extract_anidb_from_custom_fields(series) is None @patch("anidb_mapper.get_settings") def test_returns_none_for_zero_value(self, mock_get_settings): mock_get_settings.return_value = _make_settings() series = {"customFields": {"anidb_id": 0}} assert anidb_mapper.extract_anidb_from_custom_fields(series) is None @patch("anidb_mapper.get_settings") def test_returns_none_for_negative_value(self, mock_get_settings): mock_get_settings.return_value = _make_settings() series = {"customFields": {"anidb_id": -5}} assert anidb_mapper.extract_anidb_from_custom_fields(series) is None @patch("anidb_mapper.get_settings") def test_explicit_custom_field_name_overrides_config(self, mock_get_settings): mock_get_settings.return_value = _make_settings(anidb_custom_field_name="wrong_name") series = {"customFields": {"my_field": 42}} result = anidb_mapper.extract_anidb_from_custom_fields(series, custom_field_name="my_field") assert result == 42 @patch("anidb_mapper.get_settings") def test_empty_custom_fields_dict(self, mock_get_settings): mock_get_settings.return_value = _make_settings() series = {"customFields": {}} assert anidb_mapper.extract_anidb_from_custom_fields(series) is None # =========================================================================== # get_anidb_id # =========================================================================== class TestGetAnidbId: """Tests for the main get_anidb_id orchestrator function.""" @patch("anidb_mapper.get_settings") def test_returns_none_when_anidb_disabled(self, mock_get_settings): mock_get_settings.return_value = _make_settings(anidb_enabled=False) assert anidb_mapper.get_anidb_id(tvdb_id=123, series_title="Naruto") is None @patch("anidb_mapper.save_anidb_mapping") @patch("anidb_mapper.extract_anidb_from_custom_fields", return_value=9999) @patch("anidb_mapper.get_settings") def test_custom_fields_highest_priority(self, mock_settings, mock_extract, mock_save): mock_settings.return_value = _make_settings() series = {"customFields": {"anidb_id": 9999}} result = anidb_mapper.get_anidb_id(tvdb_id=100, series_title="Test", series=series) assert result == 9999 mock_extract.assert_called_once_with(series) @patch("anidb_mapper.save_anidb_mapping") @patch("anidb_mapper.extract_anidb_from_custom_fields", return_value=9999) @patch("anidb_mapper.get_settings") def test_caches_mapping_when_tvdb_id_available(self, mock_settings, mock_extract, mock_save): mock_settings.return_value = _make_settings() series = {"customFields": {"anidb_id": 9999}} anidb_mapper.get_anidb_id(tvdb_id=100, series_title="Test", series=series) mock_save.assert_called_once_with(100, 9999, "Test") @patch("anidb_mapper.save_anidb_mapping", side_effect=Exception("DB error")) @patch("anidb_mapper.extract_anidb_from_custom_fields", return_value=9999) @patch("anidb_mapper.get_settings") def test_cache_save_failure_does_not_propagate(self, mock_settings, mock_extract, mock_save): mock_settings.return_value = _make_settings() series = {"customFields": {"anidb_id": 9999}} # Should not raise despite DB error result = anidb_mapper.get_anidb_id(tvdb_id=100, series_title="Test", series=series) assert result == 9999 @patch("anidb_mapper.extract_anidb_from_custom_fields", return_value=9999) @patch("anidb_mapper.get_settings") def test_skips_caching_without_tvdb_id(self, mock_settings, mock_extract): mock_settings.return_value = _make_settings() series = {"customFields": {"anidb_id": 9999}} with patch("anidb_mapper.save_anidb_mapping") as mock_save: anidb_mapper.get_anidb_id(tvdb_id=None, series=series) mock_save.assert_not_called() @patch("anidb_mapper.get_anidb_mapping", return_value=5555) @patch("anidb_mapper.get_settings") def test_falls_back_to_cache(self, mock_settings, mock_get_mapping): mock_settings.return_value = _make_settings() result = anidb_mapper.get_anidb_id(tvdb_id=200) assert result == 5555 mock_get_mapping.assert_called_once_with(200) @patch("anidb_mapper.get_anidb_mapping", return_value=None) @patch("anidb_mapper.get_settings") def test_returns_none_when_cache_empty(self, mock_settings, mock_get_mapping): mock_settings.return_value = _make_settings() result = anidb_mapper.get_anidb_id(tvdb_id=200) assert result is None @patch("anidb_mapper.get_settings") def test_skips_cache_when_fallback_disabled(self, mock_settings): mock_settings.return_value = _make_settings(anidb_fallback_to_mapping=False) with patch("anidb_mapper.get_anidb_mapping") as mock_get_mapping: result = anidb_mapper.get_anidb_id(tvdb_id=200) assert result is None mock_get_mapping.assert_not_called() @patch("anidb_mapper.get_settings") def test_returns_none_when_no_tvdb_id_and_no_series(self, mock_settings): mock_settings.return_value = _make_settings() assert anidb_mapper.get_anidb_id() is None @patch("anidb_mapper.save_anidb_mapping") @patch("anidb_mapper.extract_anidb_from_custom_fields", return_value=9999) @patch("anidb_mapper.get_settings") def test_skips_caching_when_fallback_disabled(self, mock_settings, mock_extract, mock_save): """Even with tvdb_id, caching should not happen when fallback is disabled.""" mock_settings.return_value = _make_settings(anidb_fallback_to_mapping=False) series = {"customFields": {"anidb_id": 9999}} anidb_mapper.get_anidb_id(tvdb_id=100, series_title="Test", series=series) mock_save.assert_not_called() # =========================================================================== # resolve_anidb_from_anilist # =========================================================================== class TestResolveAnidbFromAnilist: """Tests for AniList external links resolution.""" @patch("anidb_mapper.save_anidb_mapping") def test_resolves_anidb_from_external_links(self, mock_save): mock_client = MagicMock() mock_client.get_details.return_value = { "externalLinks": [ {"site": "MyAnimeList", "url": "https://myanimelist.net/anime/1"}, {"site": "AniDB", "url": "https://anidb.net/anime/4563"}, ] } with ( patch.dict("sys.modules", {"metadata.anilist_client": MagicMock()}), patch("anidb_mapper.AniListClient", return_value=mock_client, create=True), ): # Need to re-import within the patched context pass # Simpler approach: patch at the import point mock_module = MagicMock() mock_module.get_anilist_client.return_value = mock_client with patch.dict( "sys.modules", {"metadata": MagicMock(), "metadata.anilist_client": mock_module} ): result = anidb_mapper.resolve_anidb_from_anilist(12345, tvdb_id=100) assert result == 4563 mock_save.assert_called_once_with(100, 4563) @patch("anidb_mapper.save_anidb_mapping") def test_returns_none_when_no_details(self, mock_save): mock_client = MagicMock() mock_client.get_details.return_value = None mock_module = MagicMock() mock_module.get_anilist_client.return_value = mock_client with patch.dict( "sys.modules", {"metadata": MagicMock(), "metadata.anilist_client": mock_module} ): result = anidb_mapper.resolve_anidb_from_anilist(99999) assert result is None mock_save.assert_not_called() @patch("anidb_mapper.save_anidb_mapping") def test_returns_none_when_no_anidb_link(self, mock_save): mock_client = MagicMock() mock_client.get_details.return_value = { "externalLinks": [ {"site": "MyAnimeList", "url": "https://myanimelist.net/anime/1"}, ] } mock_module = MagicMock() mock_module.get_anilist_client.return_value = mock_client with patch.dict( "sys.modules", {"metadata": MagicMock(), "metadata.anilist_client": mock_module} ): result = anidb_mapper.resolve_anidb_from_anilist(12345) assert result is None @patch("anidb_mapper.save_anidb_mapping") def test_skips_caching_without_tvdb_id(self, mock_save): mock_client = MagicMock() mock_client.get_details.return_value = { "externalLinks": [ {"site": "AniDB", "url": "https://anidb.net/anime/4563"}, ] } mock_module = MagicMock() mock_module.get_anilist_client.return_value = mock_client with patch.dict( "sys.modules", {"metadata": MagicMock(), "metadata.anilist_client": mock_module} ): result = anidb_mapper.resolve_anidb_from_anilist(12345, tvdb_id=None) assert result == 4563 mock_save.assert_not_called() def test_handles_import_error_gracefully(self): """If metadata.anilist_client cannot be imported, returns None.""" with patch.dict("sys.modules", {"metadata": None, "metadata.anilist_client": None}): # Force ImportError by making module None result = anidb_mapper.resolve_anidb_from_anilist(12345) assert result is None @patch("anidb_mapper.save_anidb_mapping", side_effect=Exception("DB error")) def test_cache_save_failure_still_returns_id(self, mock_save): mock_client = MagicMock() mock_client.get_details.return_value = { "externalLinks": [ {"site": "AniDB", "url": "https://anidb.net/anime/4563"}, ] } mock_module = MagicMock() mock_module.get_anilist_client.return_value = mock_client with patch.dict( "sys.modules", {"metadata": MagicMock(), "metadata.anilist_client": mock_module} ): result = anidb_mapper.resolve_anidb_from_anilist(12345, tvdb_id=100) assert result == 4563 def test_handles_malformed_url(self): """AniDB link with no numeric anime ID in URL returns None for that link.""" mock_client = MagicMock() mock_client.get_details.return_value = { "externalLinks": [ {"site": "AniDB", "url": "https://anidb.net/no-id-here"}, ] } mock_module = MagicMock() mock_module.get_anilist_client.return_value = mock_client with patch.dict( "sys.modules", {"metadata": MagicMock(), "metadata.anilist_client": mock_module} ): result = anidb_mapper.resolve_anidb_from_anilist(12345) assert result is None def test_empty_external_links(self): mock_client = MagicMock() mock_client.get_details.return_value = {"externalLinks": []} mock_module = MagicMock() mock_module.get_anilist_client.return_value = mock_client with patch.dict( "sys.modules", {"metadata": MagicMock(), "metadata.anilist_client": mock_module} ): result = anidb_mapper.resolve_anidb_from_anilist(12345) assert result is None # =========================================================================== # _normalize # =========================================================================== class TestNormalize: """Tests for internal title normalization.""" def test_lowercases(self): assert anidb_mapper._normalize("HELLO WORLD") == "hello world" def test_collapses_whitespace(self): assert anidb_mapper._normalize("hello world") == "hello world" def test_strips_leading_trailing(self): assert anidb_mapper._normalize(" hello ") == "hello" def test_handles_tabs_and_newlines(self): assert anidb_mapper._normalize("hello\t\nworld") == "hello world" def test_empty_string(self): assert anidb_mapper._normalize("") == "" def test_mixed_case_and_spaces(self): assert anidb_mapper._normalize(" Sword Art Online ") == "sword art online" # =========================================================================== # _fetch_dump # =========================================================================== class TestFetchDump: """Tests for downloading and validating the AniDB title dump.""" @patch("anidb_mapper.urlopen") def test_successful_download(self, mock_urlopen, tmp_path): """Valid gzip with enough entries is accepted.""" # Build valid XML with > _DUMP_MIN_ENTRIES entries root = ET.Element("animetitles") for i in range(8001): anime_el = ET.SubElement(root, "anime", aid=str(i + 1)) title_el = ET.SubElement(anime_el, "title") title_el.text = f"Anime {i + 1}" raw = ET.tostring(root, encoding="unicode").encode("utf-8") compressed = gzip.compress(raw) mock_resp = MagicMock() mock_resp.read.return_value = compressed mock_resp.__enter__ = MagicMock(return_value=mock_resp) mock_resp.__exit__ = MagicMock(return_value=False) mock_urlopen.return_value = mock_resp cache_file = str(tmp_path / "test_dump.xml.gz") with patch.object(anidb_mapper, "_DUMP_CACHE_FILE", cache_file): result = anidb_mapper._fetch_dump() assert result is True assert os.path.exists(cache_file) @patch("anidb_mapper.urlopen") def test_rejects_too_few_entries(self, mock_urlopen, tmp_path): """Dump with fewer than _DUMP_MIN_ENTRIES is rejected.""" root = ET.Element("animetitles") for i in range(100): ET.SubElement(root, "anime", aid=str(i + 1)) raw = ET.tostring(root, encoding="unicode").encode("utf-8") compressed = gzip.compress(raw) mock_resp = MagicMock() mock_resp.read.return_value = compressed mock_resp.__enter__ = MagicMock(return_value=mock_resp) mock_resp.__exit__ = MagicMock(return_value=False) mock_urlopen.return_value = mock_resp cache_file = str(tmp_path / "test_dump.xml.gz") with patch.object(anidb_mapper, "_DUMP_CACHE_FILE", cache_file): result = anidb_mapper._fetch_dump() assert result is False @patch("anidb_mapper.urlopen") def test_rejects_invalid_gzip(self, mock_urlopen, tmp_path): """Non-gzip data causes parse failure and returns False.""" mock_resp = MagicMock() mock_resp.read.return_value = b"not gzip data" mock_resp.__enter__ = MagicMock(return_value=mock_resp) mock_resp.__exit__ = MagicMock(return_value=False) mock_urlopen.return_value = mock_resp cache_file = str(tmp_path / "test_dump.xml.gz") with patch.object(anidb_mapper, "_DUMP_CACHE_FILE", cache_file): result = anidb_mapper._fetch_dump() assert result is False @patch("anidb_mapper.urlopen", side_effect=Exception("Network error")) def test_network_error_returns_false(self, mock_urlopen): result = anidb_mapper._fetch_dump() assert result is False # =========================================================================== # warm_title_index # =========================================================================== class TestWarmTitleIndex: """Tests for building the in-memory title index.""" @patch("anidb_mapper._fetch_dump", return_value=True) def test_loads_from_cache_file(self, mock_fetch, tmp_path): """Parses a valid gzip cache file into the title index.""" data = _build_title_dump_xml( [ (100, ["Naruto"]), (200, ["One Piece", "OP"]), ] ) cache_file = str(tmp_path / "dump.xml.gz") with open(cache_file, "wb") as f: f.write(data) with ( patch.object(anidb_mapper, "_DUMP_CACHE_FILE", cache_file), patch("os.path.exists", return_value=True), patch("os.path.getmtime", return_value=9999999999.0), patch("time.time", return_value=9999999999.0), ): anidb_mapper.warm_title_index() assert anidb_mapper._title_index.get("naruto") == 100 assert anidb_mapper._title_index.get("one piece") == 200 assert anidb_mapper._title_index.get("op") == 200 def test_skips_when_index_still_fresh(self): """Does not reload if the index was loaded within TTL.""" import time as _time anidb_mapper._title_index = {"test": 1} anidb_mapper._title_index_loaded_at = _time.monotonic() # just loaded with patch("anidb_mapper._fetch_dump") as mock_fetch: anidb_mapper.warm_title_index() mock_fetch.assert_not_called() assert anidb_mapper._title_index == {"test": 1} @patch("anidb_mapper._fetch_dump", return_value=False) @patch("os.path.exists", return_value=False) def test_no_cache_file_and_download_fails(self, mock_exists, mock_fetch): """When download fails and no cache file exists, index stays empty.""" anidb_mapper.warm_title_index() assert anidb_mapper._title_index == {} @patch("anidb_mapper._fetch_dump", return_value=True) def test_handles_corrupt_cache_file(self, mock_fetch, tmp_path): """Corrupt gzip file does not crash, index stays empty.""" cache_file = str(tmp_path / "dump.xml.gz") with open(cache_file, "wb") as f: f.write(b"corrupt data") with ( patch.object(anidb_mapper, "_DUMP_CACHE_FILE", cache_file), patch("os.path.exists", return_value=True), patch("os.path.getmtime", return_value=9999999999.0), patch("time.time", return_value=9999999999.0), ): anidb_mapper.warm_title_index() assert anidb_mapper._title_index == {} @patch("anidb_mapper._fetch_dump", return_value=False) def test_uses_stale_cache_when_download_fails(self, mock_fetch, tmp_path): """When download fails but a stale cache file exists, it still parses.""" data = _build_title_dump_xml([(999, ["Stale Entry"])]) cache_file = str(tmp_path / "dump.xml.gz") with open(cache_file, "wb") as f: f.write(data) with ( patch.object(anidb_mapper, "_DUMP_CACHE_FILE", cache_file), # File exists but is old (triggers download attempt) patch("os.path.exists", return_value=True), patch("os.path.getmtime", return_value=0.0), patch("time.time", return_value=9999999999.0), ): anidb_mapper.warm_title_index() assert anidb_mapper._title_index.get("stale entry") == 999 @patch("anidb_mapper._fetch_dump", return_value=True) def test_skips_anime_with_invalid_aid(self, mock_fetch, tmp_path): """Anime entries with non-numeric or zero aid are skipped.""" root = ET.Element("animetitles") # Valid entry a1 = ET.SubElement(root, "anime", aid="100") t1 = ET.SubElement(a1, "title") t1.text = "Valid" # Zero aid a2 = ET.SubElement(root, "anime", aid="0") t2 = ET.SubElement(a2, "title") t2.text = "Zero" # Invalid aid a3 = ET.SubElement(root, "anime", aid="abc") t3 = ET.SubElement(a3, "title") t3.text = "Invalid" raw = ET.tostring(root, encoding="unicode").encode("utf-8") data = gzip.compress(raw) cache_file = str(tmp_path / "dump.xml.gz") with open(cache_file, "wb") as f: f.write(data) with ( patch.object(anidb_mapper, "_DUMP_CACHE_FILE", cache_file), patch("os.path.exists", return_value=True), patch("os.path.getmtime", return_value=9999999999.0), patch("time.time", return_value=9999999999.0), ): anidb_mapper.warm_title_index() assert anidb_mapper._title_index.get("valid") == 100 assert "zero" not in anidb_mapper._title_index assert "invalid" not in anidb_mapper._title_index @patch("anidb_mapper._fetch_dump", return_value=True) def test_skips_download_when_cache_fresh(self, mock_fetch, tmp_path): """When cache file is fresh (within TTL), no download is triggered.""" data = _build_title_dump_xml([(111, ["Fresh"])]) cache_file = str(tmp_path / "dump.xml.gz") with open(cache_file, "wb") as f: f.write(data) import time as _time current_time = _time.time() with ( patch.object(anidb_mapper, "_DUMP_CACHE_FILE", cache_file), patch("os.path.exists", return_value=True), # File was just modified patch("os.path.getmtime", return_value=current_time), patch("time.time", return_value=current_time), ): anidb_mapper.warm_title_index() # _fetch_dump should not be called (cache is fresh) mock_fetch.assert_not_called() assert anidb_mapper._title_index.get("fresh") == 111 # =========================================================================== # resolve_anidb_from_title_dump # =========================================================================== class TestResolveAnidbFromTitleDump: """Tests for offline title dump resolution. resolve_anidb_from_title_dump() never builds the index itself (Task 5) — every test here pre-populates ``_title_index`` directly instead of relying on a lazy build via a patched loader. """ @patch("anidb_mapper.save_anidb_mapping") def test_exact_match(self, mock_save): anidb_mapper._title_index = {"naruto": 100, "one piece": 200} result = anidb_mapper.resolve_anidb_from_title_dump("Naruto") assert result == 100 @patch("anidb_mapper.save_anidb_mapping") def test_exact_match_caches_with_tvdb_id(self, mock_save): anidb_mapper._title_index = {"naruto": 100} anidb_mapper.resolve_anidb_from_title_dump("Naruto", tvdb_id=999) mock_save.assert_called_once_with(999, 100) def test_exact_match_no_caching_without_tvdb_id(self): anidb_mapper._title_index = {"naruto": 100} with patch("anidb_mapper.save_anidb_mapping") as mock_save: anidb_mapper.resolve_anidb_from_title_dump("Naruto", tvdb_id=None) mock_save.assert_not_called() @patch("anidb_mapper.save_anidb_mapping") def test_fuzzy_match_above_threshold(self, mock_save): # "narutoo" vs "naruto" should be above 0.82 threshold anidb_mapper._title_index = {"naruto": 100} result = anidb_mapper.resolve_anidb_from_title_dump("Narutoo") # rapidfuzz fuzz.ratio("naruto", "narutoo") ~ 92.3 — above the 82 # score_cutoff. Metric switched from difflib.SequenceMatcher to # rapidfuzz.fuzz.ratio (Task 4); outcome for this pair is unchanged. assert result == 100 @patch("anidb_mapper.save_anidb_mapping") def test_fuzzy_match_below_threshold(self, mock_save): anidb_mapper._title_index = {"naruto": 100} result = anidb_mapper.resolve_anidb_from_title_dump("Completely Different Title") assert result is None def test_returns_none_for_empty_title(self): # Task 5: empty title must short-circuit before even considering the # index — and never trigger a warm build either. with patch("anidb_mapper.warm_title_index") as mock_warm: result = anidb_mapper.resolve_anidb_from_title_dump("") assert result is None mock_warm.assert_not_called() def test_returns_none_when_index_empty(self): anidb_mapper._title_index = {} result = anidb_mapper.resolve_anidb_from_title_dump("Naruto") assert result is None @patch("anidb_mapper.save_anidb_mapping") def test_fuzzy_match_caches_with_tvdb_id(self, mock_save): anidb_mapper._title_index = {"naruto": 100} anidb_mapper.resolve_anidb_from_title_dump("Narutoo", tvdb_id=888) mock_save.assert_called_once_with(888, 100) @patch("anidb_mapper.save_anidb_mapping", side_effect=Exception("DB error")) def test_cache_save_failure_on_exact_match(self, mock_save): """Cache save failure on exact match does not propagate.""" anidb_mapper._title_index = {"naruto": 100} result = anidb_mapper.resolve_anidb_from_title_dump("Naruto", tvdb_id=999) assert result == 100 @patch("anidb_mapper.save_anidb_mapping", side_effect=Exception("DB error")) def test_cache_save_failure_on_fuzzy_match(self, mock_save): """Cache save failure on fuzzy match does not propagate.""" anidb_mapper._title_index = {"naruto": 100} result = anidb_mapper.resolve_anidb_from_title_dump("Narutoo", tvdb_id=888) assert result == 100 # =========================================================================== # resolve_anidb_from_title # =========================================================================== class TestResolveAnidbFromTitle: """Tests for AniList title-search-based resolution.""" def test_returns_none_for_empty_title(self): result = anidb_mapper.resolve_anidb_from_title("") assert result is None def test_returns_none_for_none_title(self): result = anidb_mapper.resolve_anidb_from_title(None) assert result is None def test_resolves_via_anilist_search(self): mock_client = MagicMock() mock_client.search_anime.return_value = { "id": 555, "title": {"romaji": "Naruto"}, } mock_module = MagicMock() mock_module.get_anilist_client.return_value = mock_client with ( patch.dict( "sys.modules", {"metadata": MagicMock(), "metadata.anilist_client": mock_module} ), patch("anidb_mapper.resolve_anidb_from_anilist", return_value=1234) as mock_resolve, ): result = anidb_mapper.resolve_anidb_from_title("Naruto", tvdb_id=100) assert result == 1234 mock_resolve.assert_called_once_with(555, tvdb_id=100) def test_returns_none_when_search_returns_nothing(self): mock_client = MagicMock() mock_client.search_anime.return_value = None mock_module = MagicMock() mock_module.get_anilist_client.return_value = mock_client with patch.dict( "sys.modules", {"metadata": MagicMock(), "metadata.anilist_client": mock_module} ): result = anidb_mapper.resolve_anidb_from_title("Unknown Series") assert result is None def test_returns_none_when_match_has_no_id(self): mock_client = MagicMock() mock_client.search_anime.return_value = {"title": {"romaji": "Test"}} mock_module = MagicMock() mock_module.get_anilist_client.return_value = mock_client with patch.dict( "sys.modules", {"metadata": MagicMock(), "metadata.anilist_client": mock_module} ): result = anidb_mapper.resolve_anidb_from_title("Test") assert result is None def test_handles_exception_gracefully(self): """Import or API errors return None.""" with patch.dict("sys.modules", {"metadata": None, "metadata.anilist_client": None}): result = anidb_mapper.resolve_anidb_from_title("Naruto") assert result is None