feat(services): add key resolution for orphaned anime folders
- Add key_resolution_service.py to resolve provider keys for folders without key/data files - Search anime provider and match folder names (case-insensitive, exact match required) - Only save to DB if exactly one match found; otherwise skip - Add comprehensive unit tests (28 tests) - Integrate into scheduler_service after nfo_repair scan - Update ARCHITECTURE.md documentation
This commit is contained in:
218
tests/unit/test_key_resolution_service.py
Normal file
218
tests/unit/test_key_resolution_service.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Unit tests for key_resolution_service."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.services.key_resolution_service import (
|
||||
_extract_key_from_link,
|
||||
_extract_year_from_folder,
|
||||
_normalize_for_comparison,
|
||||
_strip_year_from_folder,
|
||||
resolve_key_for_folder,
|
||||
)
|
||||
|
||||
|
||||
class TestStripYearFromFolder:
|
||||
"""Tests for _strip_year_from_folder."""
|
||||
|
||||
def test_removes_year_suffix(self):
|
||||
assert _strip_year_from_folder("Rent-A-Girlfriend (2020)") == "Rent-A-Girlfriend"
|
||||
|
||||
def test_removes_year_suffix_with_spaces(self):
|
||||
assert _strip_year_from_folder("Attack on Titan (2013)") == "Attack on Titan"
|
||||
|
||||
def test_no_year_returns_original(self):
|
||||
assert _strip_year_from_folder("Naruto") == "Naruto"
|
||||
|
||||
def test_year_in_middle_not_stripped(self):
|
||||
assert _strip_year_from_folder("2024 Anime (2024)") == "2024 Anime"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _strip_year_from_folder("") == ""
|
||||
|
||||
def test_only_year(self):
|
||||
assert _strip_year_from_folder("(2020)") == ""
|
||||
|
||||
|
||||
class TestExtractYearFromFolder:
|
||||
"""Tests for _extract_year_from_folder."""
|
||||
|
||||
def test_extracts_year(self):
|
||||
assert _extract_year_from_folder("Rent-A-Girlfriend (2020)") == 2020
|
||||
|
||||
def test_no_year_returns_none(self):
|
||||
assert _extract_year_from_folder("Naruto") is None
|
||||
|
||||
def test_year_in_middle_not_extracted(self):
|
||||
# Only trailing year is extracted
|
||||
assert _extract_year_from_folder("2024 Anime") is None
|
||||
|
||||
|
||||
class TestExtractKeyFromLink:
|
||||
"""Tests for _extract_key_from_link."""
|
||||
|
||||
def test_relative_link(self):
|
||||
assert _extract_key_from_link("/anime/stream/rent-a-girlfriend") == "rent-a-girlfriend"
|
||||
|
||||
def test_full_url(self):
|
||||
assert (
|
||||
_extract_key_from_link("https://aniworld.to/anime/stream/attack-on-titan")
|
||||
== "attack-on-titan"
|
||||
)
|
||||
|
||||
def test_link_with_trailing_slash(self):
|
||||
assert _extract_key_from_link("/anime/stream/naruto/") == "naruto"
|
||||
|
||||
def test_empty_link(self):
|
||||
assert _extract_key_from_link("") is None
|
||||
|
||||
def test_none_link(self):
|
||||
assert _extract_key_from_link(None) is None
|
||||
|
||||
def test_slug_only(self):
|
||||
assert _extract_key_from_link("one-piece") == "one-piece"
|
||||
|
||||
|
||||
class TestNormalizeForComparison:
|
||||
"""Tests for _normalize_for_comparison."""
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert _normalize_for_comparison("Rent-A-Girlfriend") == _normalize_for_comparison(
|
||||
"rent-a-girlfriend"
|
||||
)
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert _normalize_for_comparison(" Naruto ") == "naruto"
|
||||
|
||||
def test_normalizes_dashes(self):
|
||||
assert _normalize_for_comparison("Rent-A-Girlfriend") == "rent a girlfriend"
|
||||
|
||||
def test_collapses_spaces(self):
|
||||
assert _normalize_for_comparison("Attack on Titan") == "attack on titan"
|
||||
|
||||
|
||||
class TestResolveKeyForFolder:
|
||||
"""Tests for resolve_key_for_folder."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_exact_match_returns_key(self):
|
||||
"""When provider returns exactly one exact-name match, key is resolved."""
|
||||
search_results = [
|
||||
{"link": "/anime/stream/rent-a-girlfriend", "title": "Rent-A-Girlfriend"},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"src.server.services.key_resolution_service._search_provider",
|
||||
return_value=search_results,
|
||||
):
|
||||
key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)")
|
||||
assert key == "rent-a-girlfriend"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_results_returns_none(self):
|
||||
"""When provider returns no results, returns None."""
|
||||
with patch(
|
||||
"src.server.services.key_resolution_service._search_provider",
|
||||
return_value=[],
|
||||
):
|
||||
key = await resolve_key_for_folder("Unknown Anime (2020)")
|
||||
assert key is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_exact_matches_returns_none(self):
|
||||
"""When multiple results match the same name exactly, returns None."""
|
||||
search_results = [
|
||||
{"link": "/anime/stream/my-anime", "title": "My Anime"},
|
||||
{"link": "/anime/stream/my-anime-2", "title": "My Anime"},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"src.server.services.key_resolution_service._search_provider",
|
||||
return_value=search_results,
|
||||
):
|
||||
key = await resolve_key_for_folder("My Anime (2022)")
|
||||
assert key is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_exact_match_returns_none(self):
|
||||
"""When results exist but none match the folder name, returns None."""
|
||||
search_results = [
|
||||
{"link": "/anime/stream/rent-a-girlfriend-2", "title": "Rent-A-Girlfriend 2nd Season"},
|
||||
{"link": "/anime/stream/rent-a-girlfriend-3", "title": "Rent-A-Girlfriend 3rd Season"},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"src.server.services.key_resolution_service._search_provider",
|
||||
return_value=search_results,
|
||||
):
|
||||
key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)")
|
||||
assert key is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_case_insensitive_match(self):
|
||||
"""Matching is case-insensitive."""
|
||||
search_results = [
|
||||
{"link": "/anime/stream/naruto", "title": "NARUTO"},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"src.server.services.key_resolution_service._search_provider",
|
||||
return_value=search_results,
|
||||
):
|
||||
key = await resolve_key_for_folder("Naruto (2002)")
|
||||
assert key == "naruto"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_error_returns_none(self):
|
||||
"""When provider search raises an exception, returns None gracefully."""
|
||||
with patch(
|
||||
"src.server.services.key_resolution_service._search_provider",
|
||||
side_effect=RuntimeError("Network error"),
|
||||
):
|
||||
key = await resolve_key_for_folder("Some Anime (2020)")
|
||||
assert key is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_result_with_name_field_instead_of_title(self):
|
||||
"""Search results using 'name' field instead of 'title' work."""
|
||||
search_results = [
|
||||
{"link": "/anime/stream/one-piece", "name": "One Piece"},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"src.server.services.key_resolution_service._search_provider",
|
||||
return_value=search_results,
|
||||
):
|
||||
key = await resolve_key_for_folder("One Piece (1999)")
|
||||
assert key == "one-piece"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_folder_without_year(self):
|
||||
"""Folders without year suffix still work."""
|
||||
search_results = [
|
||||
{"link": "/anime/stream/naruto", "title": "Naruto"},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"src.server.services.key_resolution_service._search_provider",
|
||||
return_value=search_results,
|
||||
):
|
||||
key = await resolve_key_for_folder("Naruto")
|
||||
assert key == "naruto"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_match_among_partial_matches(self):
|
||||
"""Only exact matches count, partial matches are ignored."""
|
||||
search_results = [
|
||||
{"link": "/anime/stream/dororo", "title": "Dororo"},
|
||||
{"link": "/anime/stream/dororo-to-hyakkimaru", "title": "Dororo to Hyakkimaru"},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"src.server.services.key_resolution_service._search_provider",
|
||||
return_value=search_results,
|
||||
):
|
||||
key = await resolve_key_for_folder("Dororo (2019)")
|
||||
assert key == "dororo"
|
||||
Reference in New Issue
Block a user