- Extract rescan logic into new RescanService (src/server/services/rescan_service.py) - SchedulerService now only handles APScheduler cron scheduling - Move scheduler sub-services (folder_rename, folder_scan, key_resolution) to scheduler/ folder - Keep RescanOrchestrator as backward-compatible alias - Update all imports across api/, server/, and test files
219 lines
7.6 KiB
Python
219 lines
7.6 KiB
Python
"""Unit tests for key_resolution_service."""
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.server.services.scheduler.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.scheduler.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.scheduler.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.scheduler.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.scheduler.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.scheduler.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.scheduler.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.scheduler.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.scheduler.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.scheduler.key_resolution_service._search_provider",
|
|
return_value=search_results,
|
|
):
|
|
key = await resolve_key_for_folder("Dororo (2019)")
|
|
assert key == "dororo"
|