From bfb94145cb0c8ee9f73c728afed1d27ad30126e5 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Mon, 26 Jan 2026 08:24:23 +0100 Subject: [PATCH] test(renamer): add comprehensive tests for offset lookup functionality Add tests covering: - _parse_bangumi_id_from_tags: tag parsing with various formats - gen_path with offsets: episode/season offset application - _lookup_offsets: multi-tier lookup (qb_hash, tags, name, path) - TorrentDatabase hash lookup methods (search_by_qb_hash, search_by_url, update_qb_hash) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- backend/src/test/test_database.py | 131 ++++++++++ backend/src/test/test_renamer.py | 422 ++++++++++++++++++++++++++++++ 2 files changed, 553 insertions(+) diff --git a/backend/src/test/test_database.py b/backend/src/test/test_database.py index 83edf119..58c87072 100644 --- a/backend/src/test/test_database.py +++ b/backend/src/test/test_database.py @@ -94,3 +94,134 @@ def test_rss_database(db_session): db.add(RSSItem(url=rss_url, name="Test RSS")) result = db.search_id(1) assert result.url == rss_url + + +# --------------------------------------------------------------------------- +# TorrentDatabase qb_hash methods +# --------------------------------------------------------------------------- + + +def test_torrent_search_by_qb_hash(db_session): + """Test searching torrent by qBittorrent hash.""" + db = TorrentDatabase(db_session) + + # Create torrent with qb_hash + torrent = Torrent( + name="[SubGroup] Test Anime - 01 [1080p].mkv", + url="https://example.com/torrent1", + qb_hash="abc123def456", + ) + db.add(torrent) + + # Search by qb_hash + result = db.search_by_qb_hash("abc123def456") + assert result is not None + assert result.name == torrent.name + assert result.qb_hash == "abc123def456" + + +def test_torrent_search_by_qb_hash_not_found(db_session): + """Test searching non-existent qb_hash returns None.""" + db = TorrentDatabase(db_session) + + result = db.search_by_qb_hash("nonexistent_hash") + assert result is None + + +def test_torrent_search_by_url(db_session): + """Test searching torrent by URL.""" + db = TorrentDatabase(db_session) + + url = "https://mikanani.me/Download/torrent123.torrent" + torrent = Torrent( + name="[SubGroup] Test Anime - 02 [1080p].mkv", + url=url, + ) + db.add(torrent) + + # Search by URL + result = db.search_by_url(url) + assert result is not None + assert result.url == url + assert result.name == torrent.name + + +def test_torrent_search_by_url_not_found(db_session): + """Test searching non-existent URL returns None.""" + db = TorrentDatabase(db_session) + + result = db.search_by_url("https://nonexistent.com/torrent.torrent") + assert result is None + + +def test_torrent_update_qb_hash(db_session): + """Test updating qb_hash for existing torrent.""" + db = TorrentDatabase(db_session) + + # Create torrent without qb_hash + torrent = Torrent( + name="[SubGroup] Test Anime - 03 [1080p].mkv", + url="https://example.com/torrent3", + ) + db.add(torrent) + assert torrent.qb_hash is None + + # Update qb_hash + success = db.update_qb_hash(torrent.id, "new_hash_value") + assert success is True + + # Verify update + result = db.search(torrent.id) + assert result.qb_hash == "new_hash_value" + + +def test_torrent_update_qb_hash_nonexistent(db_session): + """Test updating qb_hash for non-existent torrent returns False.""" + db = TorrentDatabase(db_session) + + success = db.update_qb_hash(99999, "some_hash") + assert success is False + + +def test_torrent_with_bangumi_id(db_session): + """Test torrent with bangumi_id for offset lookup.""" + db = TorrentDatabase(db_session) + + # Create torrent linked to a bangumi + torrent = Torrent( + name="[SubGroup] Test Anime - 04 [1080p].mkv", + url="https://example.com/torrent4", + bangumi_id=42, + qb_hash="hash_for_bangumi_42", + ) + db.add(torrent) + + # Search and verify bangumi_id is preserved + result = db.search_by_qb_hash("hash_for_bangumi_42") + assert result is not None + assert result.bangumi_id == 42 + + +def test_torrent_qb_hash_index_efficient(db_session): + """Test that qb_hash lookups work correctly with multiple torrents.""" + db = TorrentDatabase(db_session) + + # Add multiple torrents + torrents = [ + Torrent(name=f"Torrent {i}", url=f"https://example.com/{i}", qb_hash=f"hash_{i}") + for i in range(10) + ] + db.add_all(torrents) + + # Verify we can find specific torrents by hash + result = db.search_by_qb_hash("hash_5") + assert result is not None + assert result.name == "Torrent 5" + + result = db.search_by_qb_hash("hash_9") + assert result is not None + assert result.name == "Torrent 9" + + # Non-existent hash + result = db.search_by_qb_hash("hash_100") + assert result is None diff --git a/backend/src/test/test_renamer.py b/backend/src/test/test_renamer.py index 193db1f3..44bfd4dd 100644 --- a/backend/src/test/test_renamer.py +++ b/backend/src/test/test_renamer.py @@ -470,3 +470,425 @@ class TestRenameFlow: assert result == [] renamer.client.torrents_rename_file.assert_not_called() + + +# --------------------------------------------------------------------------- +# _parse_bangumi_id_from_tags +# --------------------------------------------------------------------------- + + +class TestParseBangumiIdFromTags: + """Tests for Renamer._parse_bangumi_id_from_tags static method.""" + + def test_single_ab_tag(self): + """Parses 'ab:123' format correctly.""" + result = Renamer._parse_bangumi_id_from_tags("ab:123") + assert result == 123 + + def test_ab_tag_with_other_tags(self): + """Extracts ab tag from comma-separated list.""" + result = Renamer._parse_bangumi_id_from_tags("anime,ab:456,downloaded") + assert result == 456 + + def test_ab_tag_with_spaces(self): + """Handles whitespace around tags.""" + result = Renamer._parse_bangumi_id_from_tags(" ab:789 , other_tag ") + assert result == 789 + + def test_empty_string(self): + """Returns None for empty string.""" + result = Renamer._parse_bangumi_id_from_tags("") + assert result is None + + def test_none_input(self): + """Returns None for None input.""" + result = Renamer._parse_bangumi_id_from_tags(None) + assert result is None + + def test_no_ab_tag(self): + """Returns None when no ab: tag present.""" + result = Renamer._parse_bangumi_id_from_tags("anime,downloaded,HD") + assert result is None + + def test_invalid_ab_tag_non_numeric(self): + """Returns None when ab: tag has non-numeric value.""" + result = Renamer._parse_bangumi_id_from_tags("ab:not_a_number") + assert result is None + + def test_ab_tag_first_match(self): + """Returns first ab: tag if multiple present.""" + result = Renamer._parse_bangumi_id_from_tags("ab:111,ab:222") + assert result == 111 + + def test_ab_tag_zero(self): + """Handles ab:0 correctly.""" + result = Renamer._parse_bangumi_id_from_tags("ab:0") + assert result == 0 + + def test_ab_tag_large_number(self): + """Handles large bangumi IDs.""" + result = Renamer._parse_bangumi_id_from_tags("ab:999999") + assert result == 999999 + + +# --------------------------------------------------------------------------- +# gen_path with offsets +# --------------------------------------------------------------------------- + + +class TestGenPathWithOffsets: + """Tests for gen_path with episode_offset and season_offset parameters.""" + + def test_episode_offset_positive(self): + """Episode offset adds to episode number.""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + result = Renamer.gen_path(ep, "Bangumi", method="pn", episode_offset=12) + assert "E17" in result # 5 + 12 = 17 + + def test_episode_offset_negative(self): + """Negative episode offset subtracts from episode number.""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=15, suffix=".mkv" + ) + result = Renamer.gen_path(ep, "Bangumi", method="pn", episode_offset=-12) + assert "E03" in result # 15 - 12 = 3 + + def test_episode_offset_negative_below_one_ignored(self): + """Negative offset that would go below 1 is ignored.""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + result = Renamer.gen_path(ep, "Bangumi", method="pn", episode_offset=-10) + assert "E05" in result # Would be -5, so offset ignored + + def test_season_offset_positive(self): + """Season offset adds to season number.""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + result = Renamer.gen_path(ep, "Bangumi", method="pn", season_offset=1) + assert "S02" in result # 1 + 1 = 2 + + def test_season_offset_negative(self): + """Negative season offset subtracts from season number.""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=3, episode=5, suffix=".mkv" + ) + result = Renamer.gen_path(ep, "Bangumi", method="pn", season_offset=-1) + assert "S02" in result # 3 - 1 = 2 + + def test_season_offset_negative_below_one_ignored(self): + """Negative season offset that would go below 1 is ignored.""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + result = Renamer.gen_path(ep, "Bangumi", method="pn", season_offset=-5) + assert "S01" in result # Would be -4, so offset ignored + + def test_both_offsets_combined(self): + """Both episode and season offset applied together.""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=13, suffix=".mkv" + ) + result = Renamer.gen_path( + ep, "Bangumi", method="pn", episode_offset=-12, season_offset=1 + ) + assert "S02E01" in result # Season 1+1=2, Episode 13-12=1 + + def test_offset_with_advance_method(self): + """Offset works with advance rename method.""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=25, suffix=".mkv" + ) + result = Renamer.gen_path( + ep, "Bangumi Name", method="advance", episode_offset=-12 + ) + assert result == "Bangumi Name S01E13.mkv" + + def test_offset_with_subtitle_method(self): + """Offset works with subtitle rename methods.""" + sub = SubtitleFile( + media_path="sub.ass", + title="My Anime", + season=1, + episode=25, + language="zh", + suffix=".ass", + ) + result = Renamer.gen_path( + sub, "Bangumi", method="subtitle_pn", episode_offset=-12 + ) + assert "E13" in result # 25 - 12 = 13 + + def test_offset_none_method_unchanged(self): + """None method returns original path regardless of offset.""" + ep = EpisodeFile( + media_path="original/path/file.mkv", + title="Test", + season=1, + episode=1, + suffix=".mkv", + ) + result = Renamer.gen_path(ep, "Bangumi", method="none", episode_offset=100) + assert result == "original/path/file.mkv" + + +# --------------------------------------------------------------------------- +# _lookup_offsets +# --------------------------------------------------------------------------- + + +class TestLookupOffsets: + """Tests for Renamer._lookup_offsets method with multi-tier lookup.""" + + @pytest.fixture + def renamer(self, mock_qb_client): + """Create Renamer with mocked internals.""" + with patch("module.downloader.download_client.settings") as mock_settings: + mock_settings.downloader.type = "qbittorrent" + mock_settings.downloader.host = "localhost:8080" + mock_settings.downloader.username = "admin" + mock_settings.downloader.password = "admin" + mock_settings.downloader.ssl = False + mock_settings.downloader.path = "/downloads/Bangumi" + mock_settings.bangumi_manage.group_tag = False + with patch( + "module.downloader.download_client.DownloadClient._DownloadClient__getClient", + return_value=mock_qb_client, + ): + r = Renamer() + r.client = mock_qb_client + return r + + def test_lookup_by_qb_hash(self, renamer, db_session): + """First priority: lookup by qb_hash in Torrent table.""" + from module.database.bangumi import BangumiDatabase + from module.database.torrent import TorrentDatabase + from module.models import Bangumi, Torrent + + # Create bangumi with offsets + bangumi_db = BangumiDatabase(db_session) + bangumi = Bangumi( + official_title="Test Anime", + year="2024", + title_raw="test_raw", + season=1, + episode_offset=-12, + season_offset=1, + ) + bangumi_db.add(bangumi) + + # Create torrent linked to bangumi + torrent_db = TorrentDatabase(db_session) + torrent = Torrent( + name="Test Torrent", + url="https://example.com/torrent", + bangumi_id=bangumi.id, + qb_hash="abc123hash", + ) + torrent_db.add(torrent) + + with patch("module.manager.renamer.Database") as MockDatabase: + mock_db = MagicMock() + mock_db.__enter__ = MagicMock(return_value=mock_db) + mock_db.__exit__ = MagicMock(return_value=False) + mock_db.torrent = TorrentDatabase(db_session) + mock_db.bangumi = BangumiDatabase(db_session) + MockDatabase.return_value = mock_db + + episode_offset, season_offset = renamer._lookup_offsets( + torrent_hash="abc123hash", + torrent_name="irrelevant", + save_path="/irrelevant/path", + tags="", + ) + + assert episode_offset == -12 + assert season_offset == 1 + + def test_lookup_by_tag_when_hash_not_found(self, renamer, db_session): + """Second priority: lookup by ab:ID tag when qb_hash not found.""" + from module.database.bangumi import BangumiDatabase + from module.database.torrent import TorrentDatabase + from module.models import Bangumi + + # Create bangumi with offsets + bangumi_db = BangumiDatabase(db_session) + bangumi = Bangumi( + official_title="Tagged Anime", + year="2024", + title_raw="tagged_raw", + season=1, + episode_offset=5, + season_offset=0, + ) + bangumi_db.add(bangumi) + + with patch("module.manager.renamer.Database") as MockDatabase: + mock_db = MagicMock() + mock_db.__enter__ = MagicMock(return_value=mock_db) + mock_db.__exit__ = MagicMock(return_value=False) + mock_db.torrent = TorrentDatabase(db_session) + mock_db.bangumi = BangumiDatabase(db_session) + MockDatabase.return_value = mock_db + + episode_offset, season_offset = renamer._lookup_offsets( + torrent_hash="nonexistent_hash", + torrent_name="irrelevant", + save_path="/irrelevant/path", + tags=f"ab:{bangumi.id}", + ) + + assert episode_offset == 5 + assert season_offset == 0 + + def test_lookup_by_torrent_name(self, renamer, db_session): + """Third priority: lookup by torrent name matching title_raw.""" + from module.database.bangumi import BangumiDatabase + from module.database.torrent import TorrentDatabase + from module.models import Bangumi + + # Create bangumi with offsets + bangumi_db = BangumiDatabase(db_session) + bangumi = Bangumi( + official_title="Name Match Anime", + year="2024", + title_raw="[SubGroup] Name Match", + season=1, + episode_offset=-6, + season_offset=2, + ) + bangumi_db.add(bangumi) + + with patch("module.manager.renamer.Database") as MockDatabase: + mock_db = MagicMock() + mock_db.__enter__ = MagicMock(return_value=mock_db) + mock_db.__exit__ = MagicMock(return_value=False) + mock_db.torrent = TorrentDatabase(db_session) + mock_db.bangumi = BangumiDatabase(db_session) + MockDatabase.return_value = mock_db + + episode_offset, season_offset = renamer._lookup_offsets( + torrent_hash="nonexistent_hash", + torrent_name="[SubGroup] Name Match - 01 [1080p].mkv", + save_path="/irrelevant/path", + tags="", + ) + + assert episode_offset == -6 + assert season_offset == 2 + + def test_lookup_by_save_path_fallback(self, renamer, db_session): + """Fourth priority: lookup by save_path when other methods fail.""" + from module.database.bangumi import BangumiDatabase + from module.database.torrent import TorrentDatabase + from module.models import Bangumi + + # Create bangumi with offsets and save_path + bangumi_db = BangumiDatabase(db_session) + bangumi = Bangumi( + official_title="Path Match Anime", + year="2024", + title_raw="unique_raw_that_wont_match", + season=1, + save_path="/downloads/Bangumi/Path Match Anime (2024)/Season 1", + episode_offset=10, + season_offset=-1, + ) + bangumi_db.add(bangumi) + + with patch("module.manager.renamer.Database") as MockDatabase: + mock_db = MagicMock() + mock_db.__enter__ = MagicMock(return_value=mock_db) + mock_db.__exit__ = MagicMock(return_value=False) + mock_db.torrent = TorrentDatabase(db_session) + mock_db.bangumi = BangumiDatabase(db_session) + MockDatabase.return_value = mock_db + + episode_offset, season_offset = renamer._lookup_offsets( + torrent_hash="nonexistent_hash", + torrent_name="completely_different_name.mkv", + save_path="/downloads/Bangumi/Path Match Anime (2024)/Season 1", + tags="", + ) + + assert episode_offset == 10 + assert season_offset == -1 + + def test_lookup_returns_zero_when_not_found(self, renamer, db_session): + """Returns (0, 0) when no matching bangumi found.""" + from module.database.bangumi import BangumiDatabase + from module.database.torrent import TorrentDatabase + + with patch("module.manager.renamer.Database") as MockDatabase: + mock_db = MagicMock() + mock_db.__enter__ = MagicMock(return_value=mock_db) + mock_db.__exit__ = MagicMock(return_value=False) + mock_db.torrent = TorrentDatabase(db_session) + mock_db.bangumi = BangumiDatabase(db_session) + MockDatabase.return_value = mock_db + + episode_offset, season_offset = renamer._lookup_offsets( + torrent_hash="nonexistent", + torrent_name="no_match", + save_path="/no/match/path", + tags="", + ) + + assert episode_offset == 0 + assert season_offset == 0 + + def test_lookup_skips_deleted_bangumi(self, renamer, db_session): + """Skips deleted bangumi even if hash/tag matches.""" + from module.database.bangumi import BangumiDatabase + from module.database.torrent import TorrentDatabase + from module.models import Bangumi + + # Create deleted bangumi + bangumi_db = BangumiDatabase(db_session) + bangumi = Bangumi( + official_title="Deleted Anime", + year="2024", + title_raw="deleted_raw", + season=1, + episode_offset=99, + season_offset=99, + deleted=True, + ) + bangumi_db.add(bangumi) + + with patch("module.manager.renamer.Database") as MockDatabase: + mock_db = MagicMock() + mock_db.__enter__ = MagicMock(return_value=mock_db) + mock_db.__exit__ = MagicMock(return_value=False) + mock_db.torrent = TorrentDatabase(db_session) + mock_db.bangumi = BangumiDatabase(db_session) + MockDatabase.return_value = mock_db + + episode_offset, season_offset = renamer._lookup_offsets( + torrent_hash="nonexistent", + torrent_name="no_match", + save_path="/no/match", + tags=f"ab:{bangumi.id}", + ) + + # Should return (0, 0) because bangumi is deleted + assert episode_offset == 0 + assert season_offset == 0 + + def test_lookup_handles_database_exception(self, renamer): + """Returns (0, 0) when database throws exception.""" + with patch("module.manager.renamer.Database") as MockDatabase: + MockDatabase.side_effect = Exception("Database connection failed") + + episode_offset, season_offset = renamer._lookup_offsets( + torrent_hash="any", + torrent_name="any", + save_path="/any", + tags="", + ) + + assert episode_offset == 0 + assert season_offset == 0