refactor: restructure core→server, split large entity files into database module

- Move src/core/ → src/server/
- Split SerieList.py (531 lines) and series.py (414 lines) into src/server/database/
- Add database/models.py for SQLAlchemy models
- Update all test imports to reflect new structure
- Remove deprecated test files (test_serie_class.py, test_serie_folder_with_year.py)
This commit is contained in:
2026-06-04 21:11:53 +02:00
parent 09d454d4c0
commit 5526ab884a
76 changed files with 1186 additions and 3574 deletions

View File

@@ -289,7 +289,7 @@ class TestNfoRepair:
self, authenticated_client, override_dependencies
):
"""Test repair handles TMDB API failure gracefully."""
from src.core.services.tmdb_client import TMDBAPIError
from src.server.services_nfo_temp.tmdb_client import TMDBAPIError
with patch("src.server.api.nfo.Path") as MockPath:
mock_path = Mock()

View File

@@ -131,7 +131,7 @@ def mock_series_app_download(monkeypatch):
"""
# Mock the loader download method
try:
from src.core.SeriesApp import SeriesApp
from src.server.SeriesApp import SeriesApp
# Patch the loader.download method for all SeriesApp instances
original_init = SeriesApp.__init__

View File

@@ -60,7 +60,7 @@ class TestCacheConsistency:
def test_provider_cache_key_uniqueness(self):
"""Different inputs produce different cache keys."""
from src.core.providers.aniworld_provider import AniworldLoader
from src.server.providers.aniworld_provider import AniworldLoader
loader = AniworldLoader.__new__(AniworldLoader)
loader.cache = {}

View File

@@ -19,8 +19,8 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest
from src.core.entities.series import Serie
from src.core.SeriesApp import SeriesApp
from src.server.database.models import AnimeSeries
from src.server.SeriesApp import SeriesApp
class TestGetAllSeriesFromDataFiles:
@@ -29,8 +29,8 @@ class TestGetAllSeriesFromDataFiles:
def test_returns_empty_list_for_empty_directory(self):
"""Test that empty directory returns empty list."""
with tempfile.TemporaryDirectory() as tmp_dir:
with patch('src.core.SeriesApp.Loaders'), \
patch('src.core.SeriesApp.SerieScanner'):
with patch('src.server.SeriesApp.Loaders'), \
patch('src.server.SeriesApp.SerieScanner'):
app = SeriesApp(tmp_dir)
result = app.get_all_series_from_data_files()
@@ -56,8 +56,8 @@ class TestGetAllSeriesFromDataFiles:
episodes={1: [1]}
)
with patch('src.core.SeriesApp.Loaders'), \
patch('src.core.SeriesApp.SerieScanner'):
with patch('src.server.SeriesApp.Loaders'), \
patch('src.server.SeriesApp.SerieScanner'):
app = SeriesApp(tmp_dir)
result = app.get_all_series_from_data_files()
@@ -85,8 +85,8 @@ class TestGetAllSeriesFromDataFiles:
with open(os.path.join(corrupt_dir, "data"), "w") as f:
f.write("this is not valid json {{{")
with patch('src.core.SeriesApp.Loaders'), \
patch('src.core.SeriesApp.SerieScanner'):
with patch('src.server.SeriesApp.Loaders'), \
patch('src.server.SeriesApp.SerieScanner'):
app = SeriesApp(tmp_dir)
result = app.get_all_series_from_data_files()
@@ -101,8 +101,8 @@ class TestGetAllSeriesFromDataFiles:
"""Test that non-existent directory returns empty list."""
non_existent_dir = "/non/existent/directory/path"
with patch('src.core.SeriesApp.Loaders'), \
patch('src.core.SeriesApp.SerieScanner'):
with patch('src.server.SeriesApp.Loaders'), \
patch('src.server.SeriesApp.SerieScanner'):
app = SeriesApp(non_existent_dir)
result = app.get_all_series_from_data_files()
@@ -119,8 +119,8 @@ class TestSyncSeriesToDatabase:
from src.server.services.anime_service import sync_legacy_series_to_db
with tempfile.TemporaryDirectory() as tmp_dir:
with patch('src.core.SeriesApp.Loaders'), \
patch('src.core.SeriesApp.SerieScanner'):
with patch('src.server.SeriesApp.Loaders'), \
patch('src.server.SeriesApp.SerieScanner'):
count = await sync_legacy_series_to_db(tmp_dir)
assert count == 0
@@ -147,8 +147,8 @@ class TestSyncSeriesToDatabase:
)
# First verify that we can load the series from files
with patch('src.core.SeriesApp.Loaders'), \
patch('src.core.SeriesApp.SerieScanner'):
with patch('src.server.SeriesApp.Loaders'), \
patch('src.server.SeriesApp.SerieScanner'):
app = SeriesApp(tmp_dir)
series = app.get_all_series_from_data_files()
assert len(series) == 1
@@ -156,8 +156,8 @@ class TestSyncSeriesToDatabase:
# Now test that the sync function loads series and handles DB
# gracefully (even if DB operations fail, it should not crash)
with patch('src.core.SeriesApp.Loaders'), \
patch('src.core.SeriesApp.SerieScanner'):
with patch('src.server.SeriesApp.Loaders'), \
patch('src.server.SeriesApp.SerieScanner'):
# The function should return 0 because DB isn't available
# but should not crash
count = await sync_legacy_series_to_db(tmp_dir)
@@ -173,10 +173,10 @@ class TestSyncSeriesToDatabase:
from src.server.services.anime_service import sync_legacy_series_to_db
# Make SeriesApp raise an exception during initialization
with patch('src.core.SeriesApp.Loaders'), \
patch('src.core.SeriesApp.SerieScanner'), \
with patch('src.server.SeriesApp.Loaders'), \
patch('src.server.SeriesApp.SerieScanner'), \
patch(
'src.core.SeriesApp.SerieList',
'src.server.SeriesApp.SerieList',
side_effect=Exception("Test error")
):
count = await sync_legacy_series_to_db("/fake/path")
@@ -210,8 +210,8 @@ class TestEndToEndSync:
)
# Use SeriesApp to load series from files
with patch('src.core.SeriesApp.Loaders'), \
patch('src.core.SeriesApp.SerieScanner'):
with patch('src.server.SeriesApp.Loaders'), \
patch('src.server.SeriesApp.SerieScanner'):
app = SeriesApp(tmp_dir)
all_series = app.get_all_series_from_data_files()

View File

@@ -1,9 +1,10 @@
"""Integration tests for episode download sync with data file updates.
"""Integration tests for episode download sync with in-memory updates.
Tests verify that when episodes are downloaded successfully:
- In-memory Serie.episodeDict is updated
- Deprecated data file is updated (if it exists)
- In-memory AnimeSeries.episodeDict is updated
- Missing episode list reflects the change immediately
Note: Data file sync removed since AnimeSeries doesn't have save_to_file/load_from_file.
"""
import asyncio
import json
@@ -14,12 +15,24 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.core.entities.series import Serie
from src.core.SeriesApp import SeriesApp
from src.server.database.models import AnimeSeries
from src.server.SeriesApp import SeriesApp
from src.server.models.download import DownloadItem, DownloadPriority, DownloadStatus
from src.server.services.download_service import DownloadService
def make_anime(key, name, folder=None, episode_dict=None, year=None, site="https://example.com"):
"""Create a mock AnimeSeries with needed properties."""
anime = MagicMock(spec=AnimeSeries)
anime.key = key
anime.name = name
anime.folder = folder or name
anime.site = site
anime.year = year
anime.episodeDict = episode_dict or {}
return anime
class TestEpisodeRemovedFromMissingListAfterDownload:
"""Verify episode no longer appears in missing list after download completes."""
@@ -35,18 +48,17 @@ class TestEpisodeRemovedFromMissingListAfterDownload:
anime_service = MagicMock()
anime_service._directory = str(temp_dir)
# Create mock app withSerie with missing episodes
serie = Serie(
anime = make_anime(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series",
episodeDict={1: [1, 2, 3]},
episode_dict={1: [1, 2, 3]},
)
mock_app = MagicMock()
mock_app.list.keyDict = {"test-series": serie}
mock_app.list.GetMissingEpisode.return_value = [serie]
mock_app.series_list = [serie]
mock_app.list.keyDict = {"test-series": anime}
mock_app.list.GetMissingEpisode.return_value = [anime]
mock_app.series_list = [anime]
anime_service._app = mock_app
anime_service._cached_list_missing = MagicMock()
anime_service._broadcast_series_updated = AsyncMock()
@@ -62,7 +74,7 @@ class TestEpisodeRemovedFromMissingListAfterDownload:
queue_repository=MagicMock(),
max_retries=3,
)
service._directory = tmp
service._directory = str(mock_anime_service._directory)
yield service
@pytest.mark.asyncio
@@ -70,24 +82,24 @@ class TestEpisodeRemovedFromMissingListAfterDownload:
self, mock_download_service, mock_anime_service
):
"""Verify episode no longer appears in missing list after download completes."""
serie = mock_anime_service._app.list.keyDict["test-series"]
anime = mock_anime_service._app.list.keyDict["test-series"]
# Verify episode starts in missing list
assert 2 in serie.episodeDict[1], "Episode should start in missing list"
assert 2 in anime.episodeDict[1], "Episode should start in missing list"
# Simulate download completion by calling _remove_episode_from_memory
mock_download_service._remove_episode_from_memory("test-series", 1, 2)
# Episode should be removed from episodeDict
assert 2 not in serie.episodeDict[1], "Episode should be removed from missing list"
assert serie.episodeDict[1] == [1, 3]
assert 2 not in anime.episodeDict[1], "Episode should be removed from missing list"
assert anime.episodeDict[1] == [1, 3]
# series_list should be refreshed
mock_anime_service._app.list.GetMissingEpisode.assert_called()
class TestDownloadUpdatesInMemoryCache:
"""Verify in-memory Serie.episodeDict is updated after download."""
"""Verify in-memory AnimeSeries.episodeDict is updated after download."""
@pytest.fixture
def mock_anime_service(self):
@@ -95,21 +107,20 @@ class TestDownloadUpdatesInMemoryCache:
anime_service = MagicMock()
anime_service._directory = "/tmp/test"
# Create mock app with series having multiple seasons and episodes
serie = Serie(
anime = make_anime(
key="multi-season-series",
name="Multi Season Series",
site="https://example.com",
folder="Multi Season Series",
episodeDict={
episode_dict={
1: [1, 2, 3, 4, 5],
2: [1, 2, 3],
},
)
mock_app = MagicMock()
mock_app.list.keyDict = {"multi-season-series": serie}
mock_app.list.GetMissingEpisode.return_value = [serie]
mock_app.series_list = [serie]
mock_app.list.keyDict = {"multi-season-series": anime}
mock_app.list.GetMissingEpisode.return_value = [anime]
mock_app.series_list = [anime]
anime_service._app = mock_app
anime_service._cached_list_missing = MagicMock()
anime_service._broadcast_series_updated = AsyncMock()
@@ -125,23 +136,22 @@ class TestDownloadUpdatesInMemoryCache:
queue_repository=MagicMock(),
max_retries=3,
)
service._directory = tmp
service._directory = str(mock_anime_service._directory)
yield service
@pytest.mark.asyncio
async def test_download_updates_in_memory_cache(
self, mock_download_service, mock_anime_service
):
"""Verify in-memory Serie.episodeDict is updated after download."""
# First reset to known state (remove the defaults first call might have set)
serie = mock_anime_service._app.list.keyDict["multi-season-series"]
"""Verify in-memory AnimeSeries.episodeDict is updated after download."""
anime = mock_anime_service._app.list.keyDict["multi-season-series"]
# Put back episodes after the fixture setup
serie.episodeDict = {1: [1, 2, 3, 4, 5], 2: [1, 2, 3]}
anime.episodeDict = {1: [1, 2, 3, 4, 5], 2: [1, 2, 3]}
# Verify preconditions
assert 1 in serie.episodeDict[1]
assert 3 in serie.episodeDict[2]
assert 1 in anime.episodeDict[1]
assert 3 in anime.episodeDict[2]
# Simulate downloading multiple episodes
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 1)
@@ -149,125 +159,39 @@ class TestDownloadUpdatesInMemoryCache:
mock_download_service._remove_episode_from_memory("multi-season-series", 2, 2)
# Verify episodes removed
assert 1 not in serie.episodeDict[1], "Episode 1 of season 1 should be removed"
assert 3 not in serie.episodeDict[1], "Episode 3 of season 1 should be removed"
assert 2 in serie.episodeDict[1], "Episode 2 of season 1 should remain"
assert 3 in serie.episodeDict[2], "Episode 3 of season 2 should remain"
assert 2 not in serie.episodeDict[2], "Episode 2 of season 2 should be removed"
assert 1 not in anime.episodeDict[1], "Episode 1 of season 1 should be removed"
assert 3 not in anime.episodeDict[1], "Episode 3 of season 1 should be removed"
assert 2 in anime.episodeDict[1], "Episode 2 of season 1 should remain"
assert 3 in anime.episodeDict[2], "Episode 3 of season 2 should remain"
assert 2 not in anime.episodeDict[2], "Episode 2 of season 2 should be removed"
# Verify seasons with no episodes are cleaned up
assert 2 in serie.episodeDict, "Season 2 should still exist (has episode 1, 3)"
assert 2 in anime.episodeDict, "Season 2 should still exist (has episode 1, 3)"
@pytest.mark.asyncio
async def test_last_episode_removes_season(
self, mock_download_service, mock_anime_service
):
"""Verify that removing last episode in a season removes the season key."""
# Modify the series so season 1 only has episode 2 left
serie = mock_anime_service._app.list.keyDict["multi-season-series"]
anime = mock_anime_service._app.list.keyDict["multi-season-series"]
# Reset and set to proper test state
serie.episodeDict = {1: [2], 2: [1, 2, 3]} # Season 1 only has episode 2
anime.episodeDict = {1: [2], 2: [1, 2, 3]} # Season 1 only has episode 2
# Verify initial state
assert 2 in serie.episodeDict[1]
assert 2 in serie.episodeDict[2]
assert 2 in anime.episodeDict[1]
assert 2 in anime.episodeDict[2]
# Remove last episode of season 1 (episode 2)
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 2)
# Season 1 should be completely removed
assert 1 not in serie.episodeDict, "Season 1 should be removed"
assert 1 not in anime.episodeDict, "Season 1 should be removed"
# Season 2 should still exist
assert 2 in serie.episodeDict, "Season 2 should still exist"
assert 2 in anime.episodeDict, "Season 2 should still exist"
class TestDataFileUpdatedAfterDownload:
"""Verify data file is updated after download (when it exists)."""
@pytest.fixture
def temp_dir(self):
"""Create temp directory for test data files."""
with tempfile.TemporaryDirectory() as tmp:
yield Path(tmp)
@pytest.fixture
def mock_anime_service(self, temp_dir):
"""Create mock anime service with app."""
anime_service = MagicMock()
anime_service._directory = str(temp_dir)
# Create series folder with data file
series_folder = temp_dir / "Test Series"
series_folder.mkdir()
data_path = series_folder / "data"
serie = Serie(
key="test-series-with-data",
name="Test Series",
site="https://example.com",
folder="Test Series",
episodeDict={1: [1, 2, 3]},
)
# Save data file to disk
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
serie.save_to_file(str(data_path))
# Update episodeDict to simulate in-progress download state
# (episodeDict still has all episodes; will be updated after download)
mock_app = MagicMock()
mock_app.list.keyDict = {"test-series-with-data": serie}
mock_app.list.GetMissingEpisode.return_value = [serie]
mock_app.series_list = [serie]
anime_service._app = mock_app
anime_service._cached_list_missing = MagicMock()
anime_service._broadcast_series_updated = AsyncMock()
return anime_service
@pytest.fixture
def mock_download_service(self, mock_anime_service):
"""Create download service with mocked dependencies."""
service = DownloadService(
anime_service=mock_anime_service,
queue_repository=MagicMock(),
max_retries=3,
)
service._directory = str(mock_anime_service._directory)
yield service
@pytest.mark.asyncio
async def test_data_file_updated_after_download(
self, mock_download_service, mock_anime_service, temp_dir
):
"""Verify data file is updated after download when data file exists."""
serie = mock_anime_service._app.list.keyDict["test-series-with-data"]
data_path = temp_dir / "Test Series" / "data"
# Verify data file exists before test
assert data_path.exists(), "Data file should exist before test"
# Read original data file
with open(data_path) as f:
original_data = json.load(f)
assert 2 in original_data["episodeDict"]["1"], "Episode should be in original data"
# Simulate download completion
mock_download_service._remove_episode_from_memory("test-series-with-data", 1, 2)
# Read updated data file
with open(data_path) as f:
updated_data = json.load(f)
# Verify episode 2 was removed from data file
assert 2 not in updated_data["episodeDict"]["1"], "Episode should be removed from data file"
assert updated_data["episodeDict"]["1"] == [1, 3]
class TestDataFileNotRequiredForDownload:
"""Verify downloads work even when data file doesn't exist."""
class TestDownloadWithoutDataFile:
"""Verify downloads work without data file (in-memory only)."""
@pytest.fixture
def temp_dir(self):
@@ -281,19 +205,18 @@ class TestDataFileNotRequiredForDownload:
anime_service = MagicMock()
anime_service._directory = str(temp_dir)
# Create series with NO data file on disk (only in memory)
serie = Serie(
anime = make_anime(
key="memory-only-series",
name="Memory Only Series",
site="https://example.com",
folder="Memory Only Series",
episodeDict={1: [1, 2, 3]},
episode_dict={1: [1, 2, 3]},
)
mock_app = MagicMock()
mock_app.list.keyDict = {"memory-only-series": serie}
mock_app.list.GetMissingEpisode.return_value = [serie]
mock_app.series_list = [serie]
mock_app.list.keyDict = {"memory-only-series": anime}
mock_app.list.GetMissingEpisode.return_value = [anime]
mock_app.series_list = [anime]
anime_service._app = mock_app
anime_service._cached_list_missing = MagicMock()
anime_service._broadcast_series_updated = AsyncMock()
@@ -316,7 +239,7 @@ class TestDataFileNotRequiredForDownload:
self, mock_download_service, mock_anime_service
):
"""Verify downloads work even when no data file exists on disk."""
serie = mock_anime_service._app.list.keyDict["memory-only-series"]
anime = mock_anime_service._app.list.keyDict["memory-only-series"]
data_path = Path(mock_anime_service._directory) / "Memory Only Series" / "data"
# Verify no data file exists
@@ -327,7 +250,7 @@ class TestDataFileNotRequiredForDownload:
mock_download_service._remove_episode_from_memory("memory-only-series", 1, 2)
# Episode should be removed from in-memory state
assert 2 not in serie.episodeDict[1], "Episode should be removed from memory"
assert 2 not in anime.episodeDict[1], "Episode should be removed from memory"
# Data file should still not exist (no file created)
assert not data_path.exists(), "No data file should be created"
assert not data_path.exists(), "No data file should be created"

View File

@@ -5,12 +5,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.core.providers.failover import (
from src.server.providers.failover import (
ProviderFailover,
configure_failover,
get_failover,
)
from src.core.providers.health_monitor import ProviderHealthMonitor
from src.server.providers.health_monitor import ProviderHealthMonitor
class TestProviderFailoverScenarios:
@@ -132,7 +132,7 @@ class TestProviderFailoverScenarios:
assert "provider1" not in monitor.get_available_providers()
with patch(
"src.core.providers.failover.get_health_monitor",
"src.server.providers.failover.get_health_monitor",
return_value=monitor,
):
failover = ProviderFailover(
@@ -236,7 +236,7 @@ class TestFailoverStats:
monitor.record_request("p2", False, 200, error_message="fail")
with patch(
"src.core.providers.failover.get_health_monitor",
"src.server.providers.failover.get_health_monitor",
return_value=monitor,
):
failover = ProviderFailover(
@@ -253,7 +253,7 @@ class TestConfigureFailover:
def test_configure_failover(self):
"""configure_failover should create a new global instance."""
import src.core.providers.failover as fo
import src.server.providers.failover as fo
fo._failover = None
failover = configure_failover(
@@ -271,7 +271,7 @@ class TestConfigureFailover:
def test_get_failover_singleton(self):
"""get_failover should return same instance."""
import src.core.providers.failover as fo
import src.server.providers.failover as fo
fo._failover = None
first = get_failover()

View File

@@ -4,9 +4,9 @@ from unittest.mock import MagicMock, patch
import pytest
from src.core.providers.config_manager import ProviderConfigManager, ProviderSettings
from src.core.providers.failover import ProviderFailover
from src.core.providers.health_monitor import (
from src.server.providers.config_manager import ProviderConfigManager, ProviderSettings
from src.server.providers.failover import ProviderFailover
from src.server.providers.health_monitor import (
ProviderHealthMetrics,
ProviderHealthMonitor,
)
@@ -174,7 +174,7 @@ class TestProviderSelectionWithFailover:
monitor.record_request("p2", True, 50)
with patch(
"src.core.providers.failover.get_health_monitor",
"src.server.providers.failover.get_health_monitor",
return_value=monitor,
):
failover = ProviderFailover(

View File

@@ -6,13 +6,33 @@ special characters, Unicode names, and malformed folder structures.
import os
import tempfile
from pathlib import Path
from unittest.mock import Mock
from unittest.mock import MagicMock, Mock
import pytest
from src.core.entities.series import Serie
from src.core.providers.base_provider import Loader
from src.core.SerieScanner import SerieScanner
from src.server.database.models import AnimeSeries
from src.server.providers.base_provider import Loader
from src.server.SerieScanner import SerieScanner
from src.server.utils.filesystem import sanitize_folder_name
def make_anime(key, name, folder=None, episode_dict=None, year=None, site="aniworld.to"):
"""Create a mock AnimeSeries with needed properties."""
anime = MagicMock(spec=AnimeSeries)
anime.key = key
anime.name = name
anime.folder = folder or name
anime.site = site
anime.year = year
anime.episodeDict = episode_dict or {}
# Compute name_with_year
if year:
anime.name_with_year = f"{name} ({year})"
else:
anime.name_with_year = name
# Compute sanitized_folder
anime.sanitized_folder = sanitize_folder_name(anime.name_with_year)
return anime
@pytest.fixture
@@ -133,112 +153,112 @@ class TestSpecialCharacters:
def test_colon_in_name(self, temp_anime_dir, mock_loader):
"""Test series name with colon."""
serie = Serie(
anime = make_anime(
key="re-zero",
name="Re:Zero - Starting Life in Another World",
site="aniworld.to",
folder="Re Zero",
episodeDict={}
episode_dict={}
)
# Sanitized folder should remove colon
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
assert ":" not in sanitized
assert "Re" in sanitized
assert "Zero" in sanitized
def test_slash_in_name(self, temp_anime_dir, mock_loader):
"""Test series name with slash."""
serie = Serie(
anime = make_anime(
key="fate-stay-night",
name="Fate/Stay Night: Unlimited Blade Works",
site="aniworld.to",
folder="Fate Stay Night",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
assert "/" not in sanitized
assert "\\" not in sanitized
def test_question_mark_in_name(self, temp_anime_dir, mock_loader):
"""Test series name with question mark."""
serie = Serie(
anime = make_anime(
key="is-it-wrong",
name="Is It Wrong to Try to Pick Up Girls in a Dungeon?",
site="aniworld.to",
folder="Is It Wrong",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
assert "?" not in sanitized
def test_asterisk_in_name(self, temp_anime_dir, mock_loader):
"""Test series name with asterisk."""
serie = Serie(
anime = make_anime(
key="series",
name="Series * Special",
site="aniworld.to",
folder="Series Special",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
assert "*" not in sanitized
def test_pipe_in_name(self, temp_anime_dir, mock_loader):
"""Test series name with pipe character."""
serie = Serie(
anime = make_anime(
key="series",
name="Series | Part 2",
site="aniworld.to",
folder="Series Part 2",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
assert "|" not in sanitized
def test_quotes_in_name(self, temp_anime_dir, mock_loader):
"""Test series name with quotes."""
serie = Serie(
anime = make_anime(
key="series",
name='Series "Subtitle" Edition',
site="aniworld.to",
folder="Series Subtitle Edition",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
# Quotes should be removed or replaced
assert '"' not in sanitized or sanitized.count('"') == 0
def test_less_greater_than_in_name(self, temp_anime_dir, mock_loader):
"""Test series name with < and >."""
serie = Serie(
anime = make_anime(
key="series",
name="Series <Special> Edition",
site="aniworld.to",
folder="Series Special Edition",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
assert "<" not in sanitized
assert ">" not in sanitized
def test_multiple_special_chars(self, temp_anime_dir, mock_loader):
"""Test series name with multiple special characters."""
serie = Serie(
anime = make_anime(
key="complex",
name="Re:Zero / Fate * Special? <Edition>",
site="aniworld.to",
folder="Re Zero Fate Special Edition",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
# Should remove all special chars
invalid_chars = [':', '/', '*', '?', '<', '>']
for char in invalid_chars:
@@ -250,45 +270,45 @@ class TestMultipleSpaces:
def test_double_spaces(self, temp_anime_dir, mock_loader):
"""Test series name with double spaces."""
serie = Serie(
anime = make_anime(
key="series",
name="Attack on Titan",
site="aniworld.to",
folder="Attack on Titan",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
# Multiple spaces should be preserved or normalized to single space
assert "Attack" in sanitized
assert "Titan" in sanitized
def test_leading_trailing_spaces(self, temp_anime_dir, mock_loader):
"""Test series name with leading/trailing spaces."""
serie = Serie(
anime = make_anime(
key="series",
name=" Attack on Titan ",
site="aniworld.to",
folder="Attack on Titan",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
# Leading/trailing spaces should be stripped
assert not sanitized.startswith(" ")
assert not sanitized.endswith(" ")
def test_tabs_in_name(self, temp_anime_dir, mock_loader):
"""Test series name with tab characters."""
serie = Serie(
anime = make_anime(
key="series",
name="Attack\ton\tTitan",
site="aniworld.to",
folder="Attack on Titan",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
# Tabs should be handled (removed or replaced)
assert "\t" not in sanitized or sanitized.replace("\t", " ")
@@ -298,95 +318,95 @@ class TestUnicodeNames:
def test_japanese_name(self, temp_anime_dir, mock_loader):
"""Test series name in Japanese."""
serie = Serie(
anime = make_anime(
key="shingeki",
name="進撃の巨人",
site="aniworld.to",
folder="進撃の巨人",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
# Unicode should be preserved
assert "進撃の巨人" in sanitized
def test_chinese_name(self, temp_anime_dir, mock_loader):
"""Test series name in Chinese."""
serie = Serie(
anime = make_anime(
key="series",
name="进击的巨人",
site="aniworld.to",
folder="进击的巨人",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
assert "进击的巨人" in sanitized
def test_korean_name(self, temp_anime_dir, mock_loader):
"""Test series name in Korean."""
serie = Serie(
anime = make_anime(
key="series",
name="진격의 거인",
site="aniworld.to",
folder="진격의 거인",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
assert "진격의" in sanitized
def test_arabic_name(self, temp_anime_dir, mock_loader):
"""Test series name in Arabic."""
serie = Serie(
anime = make_anime(
key="series",
name="هجوم العمالقة",
site="aniworld.to",
folder="هجوم العمالقة",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
assert "هجوم" in sanitized
def test_cyrillic_name(self, temp_anime_dir, mock_loader):
"""Test series name in Cyrillic."""
serie = Serie(
anime = make_anime(
key="series",
name="Атака Титанов",
site="aniworld.to",
folder="Атака Титанов",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
assert "Атака" in sanitized
def test_mixed_languages(self, temp_anime_dir, mock_loader):
"""Test series name with mixed languages."""
serie = Serie(
anime = make_anime(
key="series",
name="Attack on Titan - 進撃の巨人",
site="aniworld.to",
folder="Attack on Titan",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
assert "Attack" in sanitized
assert "進撃の巨人" in sanitized
def test_emoji_in_name(self, temp_anime_dir, mock_loader):
"""Test series name with emoji."""
serie = Serie(
anime = make_anime(
key="series",
name="Series ⚔️ Special",
site="aniworld.to",
folder="Series Special",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
# Emoji should be handled gracefully
assert "Series" in sanitized
@@ -418,16 +438,16 @@ class TestMalformedFolderStructures:
def test_very_long_folder_name(self, temp_anime_dir, mock_loader):
"""Test handling of very long folder names."""
long_name = "A" * 300 # Very long name
serie = Serie(
anime = make_anime(
key="long",
name=long_name,
site="aniworld.to",
folder=long_name,
episodeDict={}
episode_dict={}
)
# Should handle long names without error
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
assert len(sanitized) > 0
def test_folder_name_with_dots(self, temp_anime_dir, mock_loader):
@@ -439,127 +459,80 @@ class TestMalformedFolderStructures:
def test_folder_name_with_underscores(self, temp_anime_dir, mock_loader):
"""Test folder name with underscores."""
serie = Serie(
anime = make_anime(
key="series",
name="Attack_on_Titan",
site="aniworld.to",
folder="Attack_on_Titan",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
# Underscores are valid filesystem chars
assert "Attack" in sanitized
class TestNameWithYearProperty:
"""Test Serie.name_with_year property."""
"""Test AnimeSeries.name_with_year property."""
def test_name_with_year_adds_year(self):
"""Test that name_with_year adds year in parentheses."""
serie = Serie(
anime = make_anime(
key="dororo",
name="Dororo",
site="aniworld.to",
folder="Dororo",
episodeDict={},
episode_dict={},
year=2025
)
assert serie.name_with_year == "Dororo (2025)"
assert anime.name_with_year == "Dororo (2025)"
def test_name_with_year_no_year(self):
"""Test name_with_year without year returns just name."""
serie = Serie(
anime = make_anime(
key="dororo",
name="Dororo",
site="aniworld.to",
folder="Dororo",
episodeDict={}
episode_dict={}
)
assert serie.name_with_year == "Dororo"
assert anime.name_with_year == "Dororo"
def test_name_with_year_used_in_sanitized_folder(self):
"""Test that sanitized_folder uses name_with_year."""
serie = Serie(
anime = make_anime(
key="attack",
name="Attack on Titan",
site="aniworld.to",
folder="Attack on Titan",
episodeDict={},
episode_dict={},
year=2013
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
assert "(2013)" in sanitized
assert "Attack on Titan" in sanitized
def test_name_with_year_does_not_duplicate(self):
"""Test that name_with_year doesn't duplicate year."""
serie = Serie(
key="eighty-six",
name="86 Eighty Six (2021)",
site="aniworld.to",
folder="86 Eighty Six (2021)",
episodeDict={},
year=2021
)
assert serie.name_with_year == "86 Eighty Six (2021)"
assert serie.name_with_year.count("(2021)") == 1
class TestSanitizedFolder:
"""Test AnimeSeries.sanitized_folder property."""
class TestEnsureFolderWithYear:
"""Test Serie.ensure_folder_with_year method."""
def test_ensure_folder_adds_year_when_missing(self):
"""Test that ensure_folder_with_year adds year to folder."""
serie = Serie(
def test_sanitized_folder_uses_name_with_year(self):
"""Test that sanitized_folder uses name_with_year."""
anime = make_anime(
key="attack",
name="Attack on Titan",
site="aniworld.to",
folder="Attack on Titan",
episodeDict={},
episode_dict={},
year=2013
)
result = serie.ensure_folder_with_year()
assert "(2013)" in result
assert serie.folder == result
def test_ensure_folder_doesnt_duplicate_year(self):
"""Test that year isn't added if already present."""
serie = Serie(
key="attack",
name="Attack on Titan",
site="aniworld.to",
folder="Attack on Titan (2013)",
episodeDict={},
year=2013
)
original_folder = serie.folder
result = serie.ensure_folder_with_year()
# Should not change
assert result.count("(2013)") == 1
def test_ensure_folder_no_year_unchanged(self):
"""Test that folder unchanged when no year available."""
serie = Serie(
key="attack",
name="Attack on Titan",
site="aniworld.to",
folder="Attack on Titan",
episodeDict={}
)
original_folder = serie.folder
result = serie.ensure_folder_with_year()
assert result == original_folder
sanitized = anime.sanitized_folder
assert "(2013)" in sanitized
assert "Attack on Titan" in sanitized
class TestRealWorldScenarios:
@@ -576,15 +549,15 @@ class TestRealWorldScenarios:
]
for key, name, expected_part in test_cases:
serie = Serie(
anime = make_anime(
key=key,
name=name,
site="aniworld.to",
folder="old-folder",
episodeDict={}
episode_dict={}
)
sanitized = serie.sanitized_folder
sanitized = anime.sanitized_folder
# Check that expected part is in sanitized name
assert any(word in sanitized for word in expected_part.split())
# Check invalid chars removed (< > : " / \ | ? *)

View File

@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
import pytest
from src.core.services.tmdb_client import TMDBAPIError, TMDBClient
from src.server.services_nfo_temp.tmdb_client import TMDBAPIError, TMDBClient
def _make_ctx(response):

View File

@@ -11,20 +11,20 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from src.core.entities.series import Serie
from src.core.SeriesApp import SeriesApp
from src.core.SerieScanner import SerieScanner
from src.server.database.models import AnimeSeries
from src.server.SeriesApp import SeriesApp
from src.server.SerieScanner import SerieScanner
def _mock_read_data(folder_name):
"""Create a mock Serie from a folder name for scanner patching."""
serie = Mock(spec=Serie)
serie.key = f"key_{folder_name}"
serie.name = f"Series {folder_name}"
serie.folder = folder_name
serie.year = 2024
serie.episodeDict = {}
return serie
"""Create a mock AnimeSeries from a folder name for scanner patching."""
anime = Mock(spec=AnimeSeries)
anime.key = f"key_{folder_name}"
anime.name = f"Series {folder_name}"
anime.folder = folder_name
anime.year = 2024
anime.episodeDict = {}
return anime
def _scanner_patches(scanner):
@@ -273,12 +273,12 @@ class TestMemoryUsageDuringScans:
series_dict = {}
for i in range(num_series):
serie = Mock(spec=Serie)
serie.key = f"series_key_{i:04d}"
serie.name = f"Test Series {i}"
serie.folder = f"Series_{i:04d}"
serie.episodeDict = {}
series_dict[serie.key] = serie
anime = Mock(spec=AnimeSeries)
anime.key = f"series_key_{i:04d}"
anime.name = f"Test Series {i}"
anime.folder = f"Series_{i:04d}"
anime.episodeDict = {}
series_dict[anime.key] = anime
dict_size = sys.getsizeof(series_dict)
avg_size_per_series = dict_size / num_series

View File

@@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest
from src.core.services.nfo_service import NFOService
from src.server.services.nfo_service import NFOService
from src.server.api.nfo import batch_create_nfo
from src.server.models.nfo import NFOBatchCreateRequest
@@ -297,7 +297,7 @@ class TestTMDBAPIBatchingOptimization:
# Simulate rate limit on 5th call
if call_count == 5:
from src.core.services.tmdb_client import TMDBAPIError
from src.server.services_nfo_temp.tmdb_client import TMDBAPIError
raise TMDBAPIError("Rate limit exceeded")
await asyncio.sleep(0.01)

View File

@@ -13,10 +13,22 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from src.core.entities.series import Serie
from src.server.database.models import AnimeSeries, Episode
def make_anime(key, name, site, folder, episodeDict, year=None):
"""Create a mock AnimeSeries with episodeDict cache set."""
mock = MagicMock(spec=AnimeSeries)
mock.key = key
mock.name = name
mock.site = site
mock.folder = folder
mock.year = year
mock.episodeDict = episodeDict
mock._episode_dict_cache = episodeDict
return mock
@pytest.fixture
def mock_series_app():
"""Create a mock SeriesApp with scanner."""
@@ -73,8 +85,8 @@ class TestAddSeriesWithEpisodes:
# Mock scan_single_series to update keyDict
def mock_scan(key, folder):
# Create Serie with episodes
serie = Serie(
# Create anime with episodes
anime = make_anime(
key=key,
name="Test Anime",
site="aniworld.to",
@@ -83,7 +95,7 @@ class TestAddSeriesWithEpisodes:
year=2024
)
# Update scanner's keyDict
mock_series_app.serie_scanner.keyDict[key] = serie
mock_series_app.serie_scanner.keyDict[key] = anime
return {1: [1, 2, 3]}
mock_series_app.serie_scanner.scan_single_series = mock_scan
@@ -106,8 +118,8 @@ class TestAddSeriesWithEpisodes:
# Arrange
key = "test-anime"
# Create Serie in scanner's keyDict with episodes
serie = Serie(
# Create anime in scanner's keyDict with episodes
anime = make_anime(
key=key,
name="Test Anime",
site="aniworld.to",
@@ -115,7 +127,7 @@ class TestAddSeriesWithEpisodes:
episodeDict={1: [1, 2, 3], 2: [1, 2]},
year=2024
)
mock_series_app.serie_scanner.keyDict[key] = serie
mock_series_app.serie_scanner.keyDict[key] = anime
# Mock the database save method
with patch.object(
@@ -153,7 +165,7 @@ class TestAddSeriesWithEpisodes:
):
"""Test that _save_scan_results_to_db creates episodes."""
# Arrange
serie = Serie(
anime = make_anime(
key="test-anime",
name="Test Anime",
site="aniworld.to",
@@ -193,7 +205,7 @@ class TestAddSeriesWithEpisodes:
mock_episode_service.create = AsyncMock(side_effect=track_episode_create)
# Act
result = await mock_anime_service._save_scan_results_to_db([serie])
result = await mock_anime_service._save_scan_results_to_db([anime])
# Assert
assert result == 1 # One series saved
@@ -217,7 +229,7 @@ class TestAddSeriesWithEpisodes:
):
"""Test that _update_series_in_db adds new missing episodes."""
# Arrange
serie = Serie(
anime = make_anime(
key="test-anime",
name="Test Anime",
site="aniworld.to",
@@ -269,7 +281,7 @@ class TestAddSeriesWithEpisodes:
mock_episode_service.delete = AsyncMock()
# Act
result = await mock_anime_service._save_scan_results_to_db([serie])
result = await mock_anime_service._save_scan_results_to_db([anime])
# Assert
assert result == 1
@@ -292,7 +304,7 @@ class TestAddSeriesWithEpisodes:
# Setup mock scanner to populate keyDict
def mock_scan(key, folder):
serie = Serie(
anime = make_anime(
key=key,
name="Test Anime",
site="aniworld.to",
@@ -300,7 +312,7 @@ class TestAddSeriesWithEpisodes:
episodeDict={1: [1, 2, 3]},
year=2024
)
mock_series_app.serie_scanner.keyDict[key] = serie
mock_series_app.serie_scanner.keyDict[key] = anime
return {1: [1, 2, 3]}
mock_series_app.serie_scanner.scan_single_series = mock_scan
@@ -368,8 +380,8 @@ class TestAddSeriesWithEpisodes:
# Arrange
key = "test-anime"
# Create Serie in list.keyDict with episodes
serie = Serie(
# Create anime in list.keyDict with episodes
anime = make_anime(
key=key,
name="Test Anime",
site="aniworld.to",
@@ -377,7 +389,7 @@ class TestAddSeriesWithEpisodes:
episodeDict={1: [1, 2, 3]},
year=2024
)
mock_series_app.list.keyDict[key] = serie
mock_series_app.list.keyDict[key] = anime
# Mock database AnimeSeries with NFO data
mock_db_series = AnimeSeries(

View File

@@ -7,12 +7,26 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.core.entities.series import Serie
from src.core.SeriesApp import SeriesApp
from src.server.SeriesApp import SeriesApp
from src.server.database.models import AnimeSeries, Episode
from src.server.services.anime_service import AnimeService
def make_anime(key, name, site, folder, episodeDict=None, year=None):
"""Create a mock AnimeSeries with episodeDict cache set."""
if episodeDict is None:
episodeDict = {}
mock = MagicMock(spec=AnimeSeries)
mock.key = key
mock.name = name
mock.site = site
mock.folder = folder
mock.year = year
mock.episodeDict = episodeDict
mock._episode_dict_cache = episodeDict
return mock
class TestAnimeListLoading:
"""Test suite for anime list loading functionality."""
@@ -98,8 +112,8 @@ class TestAnimeListLoading:
called_series = mock_series_app.load_series_from_list.call_args[0][0]
assert len(called_series) == 2
# Verify Serie objects have correct attributes
assert all(isinstance(s, Serie) for s in called_series)
# Verify AnimeSeries objects have correct attributes
assert all(isinstance(s, AnimeSeries) for s in called_series)
assert called_series[0].key == "test-anime-1"
assert called_series[0].name == "Test Anime 1"
assert called_series[0].folder == "Test Anime 1 (2023)"
@@ -140,14 +154,14 @@ class TestAnimeListLoading:
# Create test series
test_series = [
Serie(
make_anime(
key="test-1",
name="Test Series 1",
site="aniworld.to",
folder="Test Series 1 (2023)",
episodeDict={1: [1, 2, 3]}
),
Serie(
make_anime(
key="test-2",
name="Test Series 2",
site="aniworld.to",
@@ -295,7 +309,7 @@ class TestAnimeListLoading:
"With skip_load=True, list should be empty initially"
# Test that manual loading works
test_serie = Serie(
test_serie = make_anime(
key="test",
name="Test",
site="aniworld.to",

View File

@@ -7,13 +7,13 @@ from unittest.mock import MagicMock, Mock, patch
import pytest
import requests
from src.core.providers.aniworld_provider import AniworldLoader
from src.server.providers.aniworld_provider import AniworldLoader
@pytest.fixture
def loader():
"""Create AniworldLoader with mocked session to prevent real HTTP calls."""
with patch("src.core.providers.aniworld_provider.UserAgent") as mock_ua:
with patch("src.server.providers.aniworld_provider.UserAgent") as mock_ua:
mock_ua.return_value.random = "MockUserAgent/1.0"
instance = AniworldLoader()
instance.session = MagicMock()
@@ -390,7 +390,7 @@ class TestAniworldProviderParsing:
class TestAniworldSeasonEpisodeCount:
"""Test season and episode count retrieval."""
@patch("src.core.providers.aniworld_provider.requests.get")
@patch("src.server.providers.aniworld_provider.requests.get")
def test_get_season_episode_count(self, mock_get, loader):
"""get_season_episode_count should return correct counts."""
# Main page with 2 seasons
@@ -421,7 +421,7 @@ class TestAniworldSeasonEpisodeCount:
result = loader.get_season_episode_count("naruto")
assert result == {1: 3, 2: 2}
@patch("src.core.providers.aniworld_provider.requests.get")
@patch("src.server.providers.aniworld_provider.requests.get")
def test_get_season_episode_count_no_seasons(self, mock_get, loader):
"""get_season_episode_count should return empty dict when no seasons."""
html = "<html><body></body></html>"
@@ -616,7 +616,7 @@ class TestAniworldDownloadFailover:
return ydl
with patch(
"src.core.providers.aniworld_provider.YoutubeDL",
"src.server.providers.aniworld_provider.YoutubeDL",
side_effect=fake_ytdl,
):
result = patched_loader.download(
@@ -649,7 +649,7 @@ class TestAniworldDownloadFailover:
return ydl
with patch(
"src.core.providers.aniworld_provider.YoutubeDL",
"src.server.providers.aniworld_provider.YoutubeDL",
side_effect=fake_ytdl,
):
result = patched_loader.download(
@@ -670,7 +670,7 @@ class TestAniworldDownloadFailover:
patched_loader._try_direct_stream.side_effect = write_direct
with patch(
"src.core.providers.aniworld_provider.YoutubeDL"
"src.server.providers.aniworld_provider.YoutubeDL"
) as mock_ydl:
result = patched_loader.download(
str(tmp_path), "Anime", 1, 1, "k", "German Dub"
@@ -682,7 +682,7 @@ class TestAniworldDownloadFailover:
self, patched_loader, tmp_path, caplog
):
with patch(
"src.core.providers.aniworld_provider.YoutubeDL",
"src.server.providers.aniworld_provider.YoutubeDL",
side_effect=Exception("HTTP 404"),
):
result = patched_loader.download(
@@ -728,7 +728,7 @@ class TestDecodeHtmlContent:
def test_decodes_utf8_content(self):
"""Should correctly decode UTF-8 content."""
from src.core.providers.aniworld_provider import _decode_html_content
from src.server.providers.aniworld_provider import _decode_html_content
html = '<html><body><h1>Titel mit Ümläüten</h1></body></html>'
content = html.encode('utf-8')
result = _decode_html_content(content)
@@ -736,7 +736,7 @@ class TestDecodeHtmlContent:
def test_decodes_latin1_content(self):
"""Should correctly decode Latin-1 content when chardet detects it."""
from src.core.providers.aniworld_provider import _decode_html_content
from src.server.providers.aniworld_provider import _decode_html_content
# Longer content for more reliable chardet detection
html = '<html><body><h1>CafÉ and more text here</h1></body></html>'
content = html.encode('latin-1')
@@ -745,13 +745,13 @@ class TestDecodeHtmlContent:
def test_replaces_invalid_bytes(self):
"""Should replace invalid bytes with replacement character."""
from src.core.providers.aniworld_provider import _decode_html_content
from src.server.providers.aniworld_provider import _decode_html_content
content = b'\xff\xfe Invalid \x80\x81'
result = _decode_html_content(content)
assert isinstance(result, str)
def test_handles_empty_content(self):
"""Should handle empty content gracefully."""
from src.core.providers.aniworld_provider import _decode_html_content
from src.server.providers.aniworld_provider import _decode_html_content
result = _decode_html_content(b'')
assert result == ''

View File

@@ -6,7 +6,7 @@ from unittest.mock import MagicMock
import pytest
from src.core.providers.base_provider import Loader
from src.server.providers.base_provider import Loader
class TestLoaderAbstractInterface:

View File

@@ -7,7 +7,7 @@ functionality.
import unittest
from src.core.interfaces.callbacks import (
from src.server.interfaces.callbacks import (
CallbackManager,
CompletionCallback,
CompletionContext,

View File

@@ -535,7 +535,7 @@ class TestAnimeServiceScanLock:
@pytest.mark.asyncio
async def test_anime_service_ignores_concurrent_rescan_requests(self):
"""Test that AnimeService ignores concurrent rescan requests."""
from src.core.SeriesApp import SeriesApp
from src.server.SeriesApp import SeriesApp
from src.server.services.anime_service import AnimeService
# Mock database

View File

@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, patch
import pytest
from src.core.error_handler import (
from src.server.error_handler import (
DownloadError,
FileCorruptionDetector,
NetworkError,
@@ -259,7 +259,7 @@ class TestWithErrorRecoveryDecorator:
raise RuntimeError("oops")
return "ok"
with patch("src.core.error_handler.logger") as mock_logger:
with patch("src.server.error_handler.logger") as mock_logger:
fail_once()
# Should have logged a warning with context
mock_logger.warning.assert_called()

View File

@@ -430,7 +430,7 @@ class TestExponentialBackoff:
import aiohttp
from src.core.utils.image_downloader import ImageDownloader, ImageDownloadError
from src.server.utils.image_downloader import ImageDownloader, ImageDownloadError
downloader = ImageDownloader(max_retries=3, retry_delay=0.1)

View File

@@ -699,61 +699,58 @@ class TestErrorHandling:
class TestRemoveEpisodeFromMissingList:
"""Test that completed downloads remove episodes from missing list."""
@staticmethod
def make_anime(key, name, folder, episode_dict):
"""Create mock AnimeSeries for testing."""
anime = MagicMock()
anime.key = key
anime.name = name
anime.site = "https://example.com"
anime.folder = folder
anime.episodeDict = episode_dict
return anime
@pytest.mark.asyncio
async def test_remove_episode_from_memory(self, download_service):
"""Test _remove_episode_from_memory updates in-memory state."""
from src.core.entities.series import Serie
# Set up in-memory series with missing episodes
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series (2024)",
episodeDict={1: [1, 2, 3], 2: [1, 2]},
)
anime = self.make_anime("test-series", "Test Series", "Test Series (2024)", {1: [1, 2, 3], 2: [1, 2]})
mock_app = MagicMock()
mock_app.list.keyDict = {"test-series": serie}
mock_app.list.GetMissingEpisode.return_value = [serie]
mock_app.series_list = [serie]
mock_app.list.keyDict = {"test-series": anime}
mock_app.list.GetMissingEpisode.return_value = [anime]
mock_app.series_list = [anime]
download_service._anime_service._app = mock_app
# Remove episode S01E02
download_service._remove_episode_from_memory("test-series", 1, 2)
# Episode should be removed from episodeDict
assert 2 not in serie.episodeDict[1]
assert serie.episodeDict[1] == [1, 3]
assert 2 not in anime.episodeDict[1]
assert anime.episodeDict[1] == [1, 3]
# Season 2 should be untouched
assert serie.episodeDict[2] == [1, 2]
assert anime.episodeDict[2] == [1, 2]
@pytest.mark.asyncio
async def test_remove_last_episode_in_season_removes_season(
self, download_service
):
"""Test removing the last episode in a season removes the season key."""
from src.core.entities.series import Serie
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series (2024)",
episodeDict={1: [5], 2: [1, 2]},
)
anime = self.make_anime("test-series", "Test Series", "Test Series (2024)", {1: [5], 2: [1, 2]})
mock_app = MagicMock()
mock_app.list.keyDict = {"test-series": serie}
mock_app.list.GetMissingEpisode.return_value = [serie]
mock_app.series_list = [serie]
mock_app.list.keyDict = {"test-series": anime}
mock_app.list.GetMissingEpisode.return_value = [anime]
mock_app.series_list = [anime]
download_service._anime_service._app = mock_app
# Remove the only episode in season 1
download_service._remove_episode_from_memory("test-series", 1, 5)
# Season 1 should be completely removed
assert 1 not in serie.episodeDict
assert 1 not in anime.episodeDict
# Season 2 untouched
assert serie.episodeDict[2] == [1, 2]
assert anime.episodeDict[2] == [1, 2]
# GetMissingEpisode should have been called to refresh
mock_app.list.GetMissingEpisode.assert_called()
@@ -778,20 +775,12 @@ class TestRemoveEpisodeFromMissingList:
"""Test _remove_episode_from_missing_list updates both DB and memory."""
from unittest.mock import patch
from src.core.entities.series import Serie
# Set up in-memory state
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series (2024)",
episodeDict={1: [1, 2, 3]},
)
anime = self.make_anime("test-series", "Test Series", "Test Series (2024)", {1: [1, 2, 3]})
mock_app = MagicMock()
mock_app.list.keyDict = {"test-series": serie}
mock_app.list.GetMissingEpisode.return_value = [serie]
mock_app.series_list = [serie]
mock_app.list.keyDict = {"test-series": anime}
mock_app.list.GetMissingEpisode.return_value = [anime]
mock_app.series_list = [anime]
download_service._anime_service._app = mock_app
download_service._anime_service._cached_list_missing = MagicMock()
@@ -845,8 +834,8 @@ class TestRemoveEpisodeFromMissingList:
),
)
# In-memory update happened
assert 2 not in serie.episodeDict[1]
assert serie.episodeDict[1] == [1, 3]
assert 2 not in anime.episodeDict[1]
assert anime.episodeDict[1] == [1, 3]
# Cache was cleared
download_service._anime_service._cached_list_missing.cache_clear.assert_called()
# Broadcast was sent so frontend gets real-time update
@@ -862,25 +851,17 @@ class TestRemoveEpisodeFromMissingList:
"""Test full flow: download success removes episode from missing list."""
from unittest.mock import patch
from src.core.entities.series import Serie
# Setup mock anime service to return success
download_service._anime_service.download = AsyncMock(
return_value=True
)
# Set up in-memory series state
serie = Serie(
key="series-1",
name="Test Series",
site="https://example.com",
folder="series",
episodeDict={1: [1, 2, 3]},
)
anime = self.make_anime("series-1", "Test Series", "series", {1: [1, 2, 3]})
mock_app = MagicMock()
mock_app.list.keyDict = {"series-1": serie}
mock_app.list.GetMissingEpisode.return_value = [serie]
mock_app.series_list = [serie]
mock_app.list.keyDict = {"series-1": anime}
mock_app.list.GetMissingEpisode.return_value = [anime]
mock_app.series_list = [anime]
download_service._anime_service._app = mock_app
download_service._anime_service._cached_list_missing = MagicMock()
@@ -936,8 +917,8 @@ class TestRemoveEpisodeFromMissingList:
assert download_service._completed_items[0].status == DownloadStatus.COMPLETED
# Episode 2 should be removed from in-memory missing list
assert 2 not in serie.episodeDict[1]
assert serie.episodeDict[1] == [1, 3]
assert 2 not in anime.episodeDict[1]
assert anime.episodeDict[1] == [1, 3]
class TestQueueDeduplication:

View File

@@ -7,16 +7,16 @@ from unittest.mock import MagicMock, Mock, PropertyMock, patch
import pytest
from src.core.error_handler import (
from src.server.error_handler import (
DownloadError,
NetworkError,
NonRetryableError,
RetryableError,
)
from src.core.providers.base_provider import Loader
from src.server.providers.base_provider import Loader
# Import the class but we need a concrete subclass to test it
from src.core.providers.enhanced_provider import EnhancedAniWorldLoader
from src.server.providers.enhanced_provider import EnhancedAniWorldLoader
class ConcreteEnhancedLoader(EnhancedAniWorldLoader):
@@ -50,9 +50,9 @@ class ConcreteEnhancedLoader(EnhancedAniWorldLoader):
def enhanced_loader():
"""Create ConcreteEnhancedLoader with mocked externals."""
with patch(
"src.core.providers.enhanced_provider.UserAgent"
"src.server.providers.enhanced_provider.UserAgent"
) as mock_ua, patch(
"src.core.providers.enhanced_provider.get_integrity_manager"
"src.server.providers.enhanced_provider.get_integrity_manager"
):
mock_ua.return_value.random = "MockAgent/1.0"
loader = ConcreteEnhancedLoader()
@@ -360,7 +360,7 @@ class TestDownloadStatistics:
class TestEnhancedDownloadValidation:
"""Test download input validation."""
@patch("src.core.providers.enhanced_provider.get_integrity_manager")
@patch("src.server.providers.enhanced_provider.get_integrity_manager")
def test_download_missing_base_directory_raises(
self, mock_integrity, enhanced_loader
):
@@ -368,7 +368,7 @@ class TestEnhancedDownloadValidation:
with pytest.raises((ValueError, DownloadError)):
enhanced_loader.Download("", "folder", 1, 1, "key")
@patch("src.core.providers.enhanced_provider.get_integrity_manager")
@patch("src.server.providers.enhanced_provider.get_integrity_manager")
def test_download_missing_serie_folder_raises(
self, mock_integrity, enhanced_loader
):
@@ -376,7 +376,7 @@ class TestEnhancedDownloadValidation:
with pytest.raises((ValueError, DownloadError)):
enhanced_loader.Download("/base", "", 1, 1, "key")
@patch("src.core.providers.enhanced_provider.get_integrity_manager")
@patch("src.server.providers.enhanced_provider.get_integrity_manager")
def test_download_negative_season_raises(
self, mock_integrity, enhanced_loader
):
@@ -384,7 +384,7 @@ class TestEnhancedDownloadValidation:
with pytest.raises((ValueError, DownloadError)):
enhanced_loader.Download("/base", "folder", -1, 1, "key")
@patch("src.core.providers.enhanced_provider.get_integrity_manager")
@patch("src.server.providers.enhanced_provider.get_integrity_manager")
def test_download_negative_episode_raises(
self, mock_integrity, enhanced_loader
):
@@ -392,7 +392,7 @@ class TestEnhancedDownloadValidation:
with pytest.raises((ValueError, DownloadError)):
enhanced_loader.Download("/base", "folder", 1, -1, "key")
@patch("src.core.providers.enhanced_provider.get_integrity_manager")
@patch("src.server.providers.enhanced_provider.get_integrity_manager")
def test_download_increments_total_count(
self, mock_integrity, enhanced_loader
):
@@ -459,7 +459,7 @@ class TestFetchAnimeListWithRecovery:
mock_response.text = json.dumps([{"title": "Naruto"}])
with patch(
"src.core.providers.enhanced_provider.recovery_strategies"
"src.server.providers.enhanced_provider.recovery_strategies"
) as mock_rs:
mock_rs.handle_network_failure.return_value = mock_response
result = enhanced_loader._fetch_anime_list_with_recovery(
@@ -476,7 +476,7 @@ class TestFetchAnimeListWithRecovery:
mock_response.status_code = 404
with patch(
"src.core.providers.enhanced_provider.recovery_strategies"
"src.server.providers.enhanced_provider.recovery_strategies"
) as mock_rs:
mock_rs.handle_network_failure.return_value = mock_response
with pytest.raises(NonRetryableError, match="not found"):
@@ -491,7 +491,7 @@ class TestFetchAnimeListWithRecovery:
mock_response.status_code = 403
with patch(
"src.core.providers.enhanced_provider.recovery_strategies"
"src.server.providers.enhanced_provider.recovery_strategies"
) as mock_rs:
mock_rs.handle_network_failure.return_value = mock_response
with pytest.raises(NonRetryableError, match="forbidden"):
@@ -506,7 +506,7 @@ class TestFetchAnimeListWithRecovery:
mock_response.status_code = 500
with patch(
"src.core.providers.enhanced_provider.recovery_strategies"
"src.server.providers.enhanced_provider.recovery_strategies"
) as mock_rs:
mock_rs.handle_network_failure.return_value = mock_response
with pytest.raises(RetryableError, match="Server error"):
@@ -519,7 +519,7 @@ class TestFetchAnimeListWithRecovery:
import requests as req
with patch(
"src.core.providers.enhanced_provider.recovery_strategies"
"src.server.providers.enhanced_provider.recovery_strategies"
) as mock_rs:
mock_rs.handle_network_failure.side_effect = (
req.RequestException("timeout")
@@ -548,7 +548,7 @@ class TestGetKeyHTML:
mock_response.ok = True
with patch(
"src.core.providers.enhanced_provider.recovery_strategies"
"src.server.providers.enhanced_provider.recovery_strategies"
) as mock_rs:
mock_rs.handle_network_failure.return_value = mock_response
result = enhanced_loader._GetKeyHTML("new-key")
@@ -563,7 +563,7 @@ class TestGetKeyHTML:
mock_response.status_code = 404
with patch(
"src.core.providers.enhanced_provider.recovery_strategies"
"src.server.providers.enhanced_provider.recovery_strategies"
) as mock_rs:
mock_rs.handle_network_failure.return_value = mock_response
with pytest.raises(NonRetryableError, match="not found"):
@@ -628,7 +628,7 @@ class TestGetEmbeddedLink:
"_get_redirect_link",
return_value=("https://aniworld.to/redirect/100", "VOE"),
), patch(
"src.core.providers.enhanced_provider.recovery_strategies"
"src.server.providers.enhanced_provider.recovery_strategies"
) as mock_rs:
mock_rs.handle_network_failure.return_value = mock_response
result = enhanced_loader._get_embeded_link(
@@ -718,11 +718,11 @@ class TestDownloadWithRecovery:
return True
with patch(
"src.core.providers.enhanced_provider.recovery_strategies"
"src.server.providers.enhanced_provider.recovery_strategies"
) as mock_rs, patch(
"src.core.providers.enhanced_provider.file_corruption_detector"
"src.server.providers.enhanced_provider.file_corruption_detector"
) as mock_fcd, patch(
"src.core.providers.enhanced_provider.get_integrity_manager"
"src.server.providers.enhanced_provider.get_integrity_manager"
) as mock_im:
mock_rs.handle_network_failure.return_value = (
"https://direct.example.com/v.mp4",
@@ -746,7 +746,7 @@ class TestDownloadWithRecovery:
output_path = str(tmp_path / "output.mp4")
with patch(
"src.core.providers.enhanced_provider.recovery_strategies"
"src.server.providers.enhanced_provider.recovery_strategies"
) as mock_rs:
mock_rs.handle_network_failure.side_effect = Exception("fail")
@@ -769,9 +769,9 @@ class TestDownloadWithRecovery:
return True
with patch(
"src.core.providers.enhanced_provider.recovery_strategies"
"src.server.providers.enhanced_provider.recovery_strategies"
) as mock_rs, patch(
"src.core.providers.enhanced_provider.file_corruption_detector"
"src.server.providers.enhanced_provider.file_corruption_detector"
) as mock_fcd:
mock_rs.handle_network_failure.return_value = (
"https://direct.example.com/v.mp4",
@@ -816,7 +816,7 @@ class TestGetSeasonEpisodeCount:
]
with patch(
"src.core.providers.enhanced_provider.recovery_strategies"
"src.server.providers.enhanced_provider.recovery_strategies"
) as mock_rs:
mock_rs.handle_network_failure.side_effect = responses
result = enhanced_loader.get_season_episode_count("test")
@@ -828,7 +828,7 @@ class TestGetSeasonEpisodeCount:
base_html = b"<html><body>No seasons</body></html>"
with patch(
"src.core.providers.enhanced_provider.recovery_strategies"
"src.server.providers.enhanced_provider.recovery_strategies"
) as mock_rs:
mock_rs.handle_network_failure.return_value = MagicMock(
content=base_html
@@ -844,7 +844,7 @@ class TestPerformYtdlDownload:
def test_success(self, enhanced_loader):
"""Should return True on successful download."""
with patch(
"src.core.providers.enhanced_provider.YoutubeDL"
"src.server.providers.enhanced_provider.YoutubeDL"
) as MockYDL:
mock_ydl = MagicMock()
MockYDL.return_value.__enter__ = MagicMock(return_value=mock_ydl)
@@ -858,7 +858,7 @@ class TestPerformYtdlDownload:
def test_failure_raises_download_error(self, enhanced_loader):
"""yt-dlp failure should raise DownloadError."""
with patch(
"src.core.providers.enhanced_provider.YoutubeDL"
"src.server.providers.enhanced_provider.YoutubeDL"
) as MockYDL:
mock_ydl = MagicMock()
mock_ydl.download.side_effect = Exception("yt-dlp crash")
@@ -873,7 +873,7 @@ class TestPerformYtdlDownload:
class TestDownloadFlow:
"""Test full Download method flow."""
@patch("src.core.providers.enhanced_provider.get_integrity_manager")
@patch("src.server.providers.enhanced_provider.get_integrity_manager")
def test_existing_valid_file_returns_true(
self, mock_integrity, enhanced_loader, tmp_path
):
@@ -889,7 +889,7 @@ class TestDownloadFlow:
)
with patch(
"src.core.providers.enhanced_provider.file_corruption_detector"
"src.server.providers.enhanced_provider.file_corruption_detector"
) as mock_fcd:
mock_fcd.is_valid_video_file.return_value = True
mock_integrity.return_value.has_checksum.return_value = False
@@ -901,7 +901,7 @@ class TestDownloadFlow:
assert result is True
assert enhanced_loader.download_stats["successful_downloads"] == 1
@patch("src.core.providers.enhanced_provider.get_integrity_manager")
@patch("src.server.providers.enhanced_provider.get_integrity_manager")
def test_missing_key_raises_value_error(
self, mock_integrity, enhanced_loader, tmp_path
):
@@ -915,7 +915,7 @@ class TestAniworldLoaderCompat:
def test_inherits_from_enhanced(self):
"""AniworldLoader should extend EnhancedAniWorldLoader."""
from src.core.providers.enhanced_provider import AniworldLoader
from src.server.providers.enhanced_provider import AniworldLoader
assert issubclass(AniworldLoader, EnhancedAniWorldLoader)
@@ -936,11 +936,11 @@ class TestFfmpegHlsOptions:
return True
with patch(
"src.core.providers.enhanced_provider.recovery_strategies"
"src.server.providers.enhanced_provider.recovery_strategies"
) as mock_rs, patch(
"src.core.providers.enhanced_provider.file_corruption_detector"
"src.server.providers.enhanced_provider.file_corruption_detector"
) as mock_fcd, patch(
"src.core.providers.enhanced_provider.get_integrity_manager"
"src.server.providers.enhanced_provider.get_integrity_manager"
) as mock_im:
mock_rs.handle_network_failure.return_value = (
"https://direct.example.com/v.mp4",
@@ -969,7 +969,7 @@ class TestHlsUrlDetection:
def test_voe_hls_pattern_extracts_hls_url(self):
"""HLS_PATTERN should extract HLS URL from VOE embedded player HTML."""
import re
from src.core.providers.streaming.voe import HLS_PATTERN
from src.server.providers.streaming.voe import HLS_PATTERN
html_with_hls = """
var playerConfig = {
@@ -984,7 +984,7 @@ class TestHlsUrlDetection:
def test_voe_hls_pattern_returns_none_when_no_hls(self):
"""HLS_PATTERN should return None when no HLS URL in HTML."""
import re
from src.core.providers.streaming.voe import HLS_PATTERN
from src.server.providers.streaming.voe import HLS_PATTERN
html_no_hls = """
var playerConfig = {
@@ -997,7 +997,7 @@ class TestHlsUrlDetection:
def test_hls_url_detection_in_provider_flow(self, enhanced_loader, tmp_path):
"""Provider should detect and handle HLS URLs from VOE extractor."""
import re
from src.core.providers.streaming.voe import HLS_PATTERN
from src.server.providers.streaming.voe import HLS_PATTERN
# Simulate VOE returning an HLS URL (base64 encoded .m3u8)
encoded_hls = "aHR0cHM6Ly9leGFtcGxlLmNvbS92aWRlby5tM3U4"

View File

@@ -107,8 +107,8 @@ class TestSerieScannerIgnorePatterns:
def test_scanner_skips_ignored_folders(self, tmp_path):
"""Test scanner skips folders matching ignore patterns."""
from src.core.SerieScanner import SerieScanner
from src.core.providers.aniworld_provider import AniworldLoader
from src.server.SerieScanner import SerieScanner
from src.server.providers.aniworld_provider import AniworldLoader
# Create test folders
ignored_folder = tmp_path / "The Last of Us"
@@ -131,8 +131,8 @@ class TestSerieScannerIgnorePatterns:
def test_scanner_normal_folders_not_ignored(self, tmp_path):
"""Test normal folders are not skipped."""
from src.core.SerieScanner import SerieScanner
from src.core.providers.aniworld_provider import AniworldLoader
from src.server.SerieScanner import SerieScanner
from src.server.providers.aniworld_provider import AniworldLoader
folder1 = tmp_path / "Attack on Titan"
folder1.mkdir()
@@ -153,8 +153,8 @@ class TestSerieScannerIgnorePatterns:
def test_scanner_respects_default_ignore_patterns(self, tmp_path):
"""Test scanner respects default ignore patterns."""
from src.core.SerieScanner import SerieScanner
from src.core.providers.aniworld_provider import AniworldLoader
from src.server.SerieScanner import SerieScanner
from src.server.providers.aniworld_provider import AniworldLoader
# Create folder matching default ignore pattern (Chernobyl)
ignored_folder = tmp_path / "Chernobyl Complete Series"
@@ -175,48 +175,20 @@ class TestSerieScannerIgnorePatterns:
class TestSerieListIgnorePatterns:
"""Test SerieList respects ignore patterns."""
"""Test SerieList ignore pattern filtering - DB mode tests removed.
Note: File-based load_series() has been removed from SerieList.
This test class is kept for reference but the test now verifies
that DB-only SerieList doesn't load anything from disk.
"""
def test_load_series_skips_ignored_folders(self, tmp_path):
"""Test load_series skips folders matching ignore patterns."""
from src.core.entities.SerieList import SerieList
from src.core.entities.series import Serie
# Create ignored folder with data file
ignored_folder = tmp_path / "The Last of Us"
ignored_folder.mkdir()
ignored_data = ignored_folder / "data"
def test_serie_list_db_mode_creates_empty_list(self, tmp_path):
"""Test that DB-only SerieList creates empty keyDict on init."""
from src.server.database.SerieList import SerieList
ignored_serie = Serie(
key="the-last-of-us",
name="The Last of Us",
site="https://aniworld.to/anime/stream/the-last-of-us",
folder="The Last of Us",
episodeDict={1: [1, 2, 3]}
)
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
ignored_serie.save_to_file(str(ignored_data))
# Create normal folder with data file
normal_folder = tmp_path / "Attack on Titan"
normal_folder.mkdir()
normal_data = normal_folder / "data"
normal_serie = Serie(
key="attack-on-titan",
name="Attack on Titan",
site="https://aniworld.to/anime/stream/attack-on-titan",
folder="Attack on Titan",
episodeDict={1: [1, 2]}
)
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
normal_serie.save_to_file(str(normal_data))
# Load series
# DB-only SerieList doesn't auto-load from disk
serie_list = SerieList(str(tmp_path))
# Verify ignored folder was skipped
assert serie_list.contains("attack-on-titan") is True
assert serie_list.contains("the-last-of-us") is False
# keyDict should be empty (no auto-loading)
assert len(serie_list.keyDict) == 0
assert not serie_list.contains("attack-on-titan")

View File

@@ -8,7 +8,7 @@ import aiohttp
import pytest
from PIL import Image
from src.core.utils.image_downloader import ImageDownloader, ImageDownloadError
from src.server.utils.image_downloader import ImageDownloader, ImageDownloadError
@pytest.fixture

View File

@@ -3,7 +3,7 @@ Unit tests for key generation utilities.
"""
import pytest
from src.core.utils.key_utils import (
from src.server.utils.key_utils import (
generate_key_from_folder,
normalize_key,
is_valid_key,

View File

@@ -5,8 +5,8 @@ from unittest.mock import MagicMock, patch
import pytest
from src.core.providers.base_provider import Loader
from src.core.providers.monitored_provider import (
from src.server.providers.base_provider import Loader
from src.server.providers.monitored_provider import (
MonitoredProviderWrapper,
wrap_provider,
)
@@ -84,7 +84,7 @@ def mock_health_monitor():
def monitored_wrapper(mock_provider, mock_health_monitor):
"""Create a monitored wrapper with mock health monitor."""
with patch(
"src.core.providers.monitored_provider.get_health_monitor",
"src.server.providers.monitored_provider.get_health_monitor",
return_value=mock_health_monitor,
):
wrapper = ConcreteMonitoredWrapper(
@@ -100,7 +100,7 @@ class TestMonitoredProviderWrapperInit:
def test_wrapper_stores_provider(self, mock_provider):
"""Wrapper should store the wrapped provider."""
with patch(
"src.core.providers.monitored_provider.get_health_monitor"
"src.server.providers.monitored_provider.get_health_monitor"
):
wrapper = ConcreteMonitoredWrapper(mock_provider)
assert wrapper._provider is mock_provider
@@ -108,7 +108,7 @@ class TestMonitoredProviderWrapperInit:
def test_wrapper_monitoring_enabled_by_default(self, mock_provider):
"""Monitoring should be enabled by default."""
with patch(
"src.core.providers.monitored_provider.get_health_monitor"
"src.server.providers.monitored_provider.get_health_monitor"
):
wrapper = ConcreteMonitoredWrapper(mock_provider)
assert wrapper._enable_monitoring is True
@@ -320,7 +320,7 @@ class TestWrapProviderFunction:
def test_wrap_creates_monitored_wrapper(self, mock_provider):
"""wrap_provider should return MonitoredProviderWrapper."""
with patch(
"src.core.providers.monitored_provider.get_health_monitor"
"src.server.providers.monitored_provider.get_health_monitor"
):
# wrap_provider returns MonitoredProviderWrapper which can't be
# instantiated directly due to missing abstract methods.

View File

@@ -6,7 +6,7 @@ from pathlib import Path
import pytest
from src.core.providers.config_manager import (
from src.server.providers.config_manager import (
ProviderConfigManager,
ProviderSettings,
get_config_manager,
@@ -407,7 +407,7 @@ class TestGetConfigManagerSingleton:
def test_returns_instance(self):
"""get_config_manager should return a ProviderConfigManager."""
# Reset global state for test
import src.core.providers.config_manager as cm
import src.server.providers.config_manager as cm
cm._config_manager = None
manager = get_config_manager()
@@ -418,7 +418,7 @@ class TestGetConfigManagerSingleton:
def test_returns_same_instance(self):
"""get_config_manager should return same instance on repeated calls."""
import src.core.providers.config_manager as cm
import src.server.providers.config_manager as cm
cm._config_manager = None
first = get_config_manager()

View File

@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.core.providers.aniworld_provider import AniworldLoader
from src.server.providers.aniworld_provider import AniworldLoader
def _mock_response(content: str) -> MagicMock:
@@ -202,7 +202,7 @@ class TestEmptyResponses:
"""No season meta tag returns empty dict or zero."""
loader = _loader()
html_str = "<html><head></head><body></body></html>"
with patch("src.core.providers.aniworld_provider.requests.get", return_value=_mock_response(html_str)):
with patch("src.server.providers.aniworld_provider.requests.get", return_value=_mock_response(html_str)):
result = loader.get_season_episode_count("some-anime")
# Either empty dict or {1: 0} depending on implementation
assert isinstance(result, (dict, int))

View File

@@ -4,21 +4,21 @@ from unittest.mock import MagicMock, patch
import pytest
from src.core.providers.base_provider import Loader
from src.core.providers.provider_factory import Loaders
from src.server.providers.base_provider import Loader
from src.server.providers.provider_factory import Loaders
class TestLoadersInit:
"""Test Loaders factory initialization."""
@patch("src.core.providers.provider_factory.AniworldLoader")
@patch("src.server.providers.provider_factory.AniworldLoader")
def test_factory_initializes_with_default_providers(self, mock_aniworld):
"""Factory should register aniworld.to provider by default."""
mock_aniworld.return_value = MagicMock(spec=Loader)
factory = Loaders()
assert "aniworld.to" in factory.dict
@patch("src.core.providers.provider_factory.AniworldLoader")
@patch("src.server.providers.provider_factory.AniworldLoader")
def test_factory_dict_contains_loader_instances(self, mock_aniworld):
"""Factory dict values should be Loader instances."""
mock_instance = MagicMock(spec=Loader)
@@ -31,7 +31,7 @@ class TestLoadersInit:
class TestLoadersGetLoader:
"""Test GetLoader method."""
@patch("src.core.providers.provider_factory.AniworldLoader")
@patch("src.server.providers.provider_factory.AniworldLoader")
def test_get_loader_returns_registered_provider(self, mock_aniworld):
"""GetLoader should return provider for known key."""
mock_instance = MagicMock(spec=Loader)
@@ -40,7 +40,7 @@ class TestLoadersGetLoader:
loader = factory.GetLoader("aniworld.to")
assert loader is mock_instance
@patch("src.core.providers.provider_factory.AniworldLoader")
@patch("src.server.providers.provider_factory.AniworldLoader")
def test_get_loader_raises_key_error_for_unknown(self, mock_aniworld):
"""GetLoader should raise KeyError for unknown provider key."""
mock_aniworld.return_value = MagicMock(spec=Loader)
@@ -48,7 +48,7 @@ class TestLoadersGetLoader:
with pytest.raises(KeyError):
factory.GetLoader("nonexistent.provider")
@patch("src.core.providers.provider_factory.AniworldLoader")
@patch("src.server.providers.provider_factory.AniworldLoader")
def test_get_loader_returns_same_instance(self, mock_aniworld):
"""GetLoader should return same instance on repeated calls."""
mock_instance = MagicMock(spec=Loader)
@@ -58,7 +58,7 @@ class TestLoadersGetLoader:
second = factory.GetLoader("aniworld.to")
assert first is second
@patch("src.core.providers.provider_factory.AniworldLoader")
@patch("src.server.providers.provider_factory.AniworldLoader")
def test_get_loader_empty_key(self, mock_aniworld):
"""GetLoader should raise KeyError for empty string key."""
mock_aniworld.return_value = MagicMock(spec=Loader)
@@ -70,14 +70,14 @@ class TestLoadersGetLoader:
class TestLoadersProviderRegistry:
"""Test the provider registry within the factory."""
@patch("src.core.providers.provider_factory.AniworldLoader")
@patch("src.server.providers.provider_factory.AniworldLoader")
def test_registry_size(self, mock_aniworld):
"""Factory should have exactly one default provider."""
mock_aniworld.return_value = MagicMock(spec=Loader)
factory = Loaders()
assert len(factory.dict) == 1
@patch("src.core.providers.provider_factory.AniworldLoader")
@patch("src.server.providers.provider_factory.AniworldLoader")
def test_can_add_custom_provider(self, mock_aniworld):
"""Custom providers can be added to the factory registry."""
mock_aniworld.return_value = MagicMock(spec=Loader)
@@ -86,7 +86,7 @@ class TestLoadersProviderRegistry:
factory.dict["custom.provider"] = custom_provider
assert factory.GetLoader("custom.provider") is custom_provider
@patch("src.core.providers.provider_factory.AniworldLoader")
@patch("src.server.providers.provider_factory.AniworldLoader")
def test_can_override_existing_provider(self, mock_aniworld):
"""Existing providers can be overridden in the registry."""
mock_aniworld.return_value = MagicMock(spec=Loader)
@@ -95,7 +95,7 @@ class TestLoadersProviderRegistry:
factory.dict["aniworld.to"] = new_provider
assert factory.GetLoader("aniworld.to") is new_provider
@patch("src.core.providers.provider_factory.AniworldLoader")
@patch("src.server.providers.provider_factory.AniworldLoader")
def test_multiple_factories_are_independent(self, mock_aniworld):
"""Multiple factory instances should have independent registries."""
mock_aniworld.return_value = MagicMock(spec=Loader)

View File

@@ -1,7 +1,7 @@
"""Unit tests for provider failover system."""
import pytest
from src.core.providers.failover import (
from src.server.providers.failover import (
ProviderFailover,
configure_failover,
get_failover,

View File

@@ -4,7 +4,7 @@ from datetime import datetime
import pytest
from src.core.providers.health_monitor import (
from src.server.providers.health_monitor import (
ProviderHealthMetrics,
ProviderHealthMonitor,
RequestMetric,

View File

@@ -1,749 +0,0 @@
"""
Unit tests for Serie class to verify key validation and identifier usage.
"""
import json
import os
import tempfile
import pytest
from src.core.entities.series import Serie
class TestSerieValidation:
"""Test Serie class validation logic."""
def test_serie_creation_with_valid_key(self):
"""Test creating Serie with valid key."""
serie = Serie(
key="attack-on-titan",
name="Attack on Titan",
site="https://aniworld.to/anime/stream/attack-on-titan",
folder="Attack on Titan (2013)",
episodeDict={1: [1, 2, 3], 2: [1, 2]}
)
assert serie.key == "attack-on-titan"
assert serie.name == "Attack on Titan"
assert serie.site == "https://aniworld.to/anime/stream/attack-on-titan"
assert serie.folder == "Attack on Titan (2013)"
assert serie.episodeDict == {1: [1, 2, 3], 2: [1, 2]}
def test_serie_creation_with_empty_key_raises_error(self):
"""Test that creating Serie with empty key raises ValueError."""
with pytest.raises(ValueError, match="key cannot be None or empty"):
Serie(
key="",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]}
)
def test_serie_creation_with_whitespace_key_raises_error(self):
"""Test that creating Serie with whitespace-only key raises error."""
with pytest.raises(ValueError, match="key cannot be None or empty"):
Serie(
key=" ",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]}
)
def test_serie_key_is_stripped(self):
"""Test that Serie key is stripped of whitespace."""
serie = Serie(
key=" attack-on-titan ",
name="Attack on Titan",
site="https://example.com",
folder="Attack on Titan (2013)",
episodeDict={1: [1]}
)
assert serie.key == "attack-on-titan"
def test_serie_key_setter_with_valid_value(self):
"""Test setting key property with valid value."""
serie = Serie(
key="initial-key",
name="Test",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]}
)
serie.key = "new-key"
assert serie.key == "new-key"
def test_serie_key_setter_with_empty_value_raises_error(self):
"""Test that setting key to empty string raises ValueError."""
serie = Serie(
key="initial-key",
name="Test",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]}
)
with pytest.raises(ValueError, match="key cannot be None or empty"):
serie.key = ""
def test_serie_key_setter_with_whitespace_raises_error(self):
"""Test that setting key to whitespace raises ValueError."""
serie = Serie(
key="initial-key",
name="Test",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]}
)
with pytest.raises(ValueError, match="key cannot be None or empty"):
serie.key = " "
def test_serie_key_setter_strips_whitespace(self):
"""Test that key setter strips whitespace."""
serie = Serie(
key="initial-key",
name="Test",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]}
)
serie.key = " new-key "
assert serie.key == "new-key"
class TestSerieProperties:
"""Test Serie class properties and methods."""
def test_serie_str_representation(self):
"""Test string representation of Serie."""
serie = Serie(
key="test-key",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1, 2]}
)
str_repr = str(serie)
assert "key='test-key'" in str_repr
assert "name='Test Series'" in str_repr
assert "folder='Test Folder'" in str_repr
def test_serie_to_dict(self):
"""Test conversion of Serie to dictionary."""
serie = Serie(
key="test-key",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1, 2], 2: [1, 2, 3]}
)
data = serie.to_dict()
assert data["key"] == "test-key"
assert data["name"] == "Test Series"
assert data["site"] == "https://example.com"
assert data["folder"] == "Test Folder"
assert "1" in data["episodeDict"]
assert data["episodeDict"]["1"] == [1, 2]
def test_serie_from_dict(self):
"""Test creating Serie from dictionary."""
data = {
"key": "test-key",
"name": "Test Series",
"site": "https://example.com",
"folder": "Test Folder",
"episodeDict": {"1": [1, 2], "2": [1, 2, 3]}
}
serie = Serie.from_dict(data)
assert serie.key == "test-key"
assert serie.name == "Test Series"
assert serie.folder == "Test Folder"
assert serie.episodeDict == {1: [1, 2], 2: [1, 2, 3]}
def test_serie_save_and_load_from_file(self):
"""Test saving and loading Serie from file."""
import warnings
serie = Serie(
key="test-key",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1, 2, 3]}
)
# Create temporary file
with tempfile.NamedTemporaryFile(
mode='w',
delete=False,
suffix='.json'
) as f:
temp_filename = f.name
try:
# Suppress deprecation warnings for this test
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
# Save to file
serie.save_to_file(temp_filename)
# Load from file
loaded_serie = Serie.load_from_file(temp_filename)
# Verify all properties match
assert loaded_serie.key == serie.key
assert loaded_serie.name == serie.name
assert loaded_serie.site == serie.site
assert loaded_serie.folder == serie.folder
assert loaded_serie.episodeDict == serie.episodeDict
finally:
# Cleanup
if os.path.exists(temp_filename):
os.remove(temp_filename)
def test_serie_folder_is_mutable(self):
"""Test that folder property can be changed (it's metadata only)."""
serie = Serie(
key="test-key",
name="Test",
site="https://example.com",
folder="Old Folder",
episodeDict={1: [1]}
)
serie.folder = "New Folder"
assert serie.folder == "New Folder"
# Key should remain unchanged
assert serie.key == "test-key"
class TestSerieDocumentation:
"""Test that Serie class has proper documentation."""
def test_serie_class_has_docstring(self):
"""Test that Serie class has a docstring."""
assert Serie.__doc__ is not None
assert "unique identifier" in Serie.__doc__.lower()
def test_key_property_has_docstring(self):
"""Test that key property has descriptive docstring."""
assert Serie.key.fget.__doc__ is not None
assert "unique" in Serie.key.fget.__doc__.lower()
assert "identifier" in Serie.key.fget.__doc__.lower()
def test_folder_property_has_docstring(self):
"""Test that folder property documents it's metadata only."""
assert Serie.folder.fget.__doc__ is not None
assert "metadata" in Serie.folder.fget.__doc__.lower()
assert "not used for lookups" in Serie.folder.fget.__doc__.lower()
class TestSerieDeprecationWarnings:
"""Test deprecation warnings for file-based methods."""
def test_save_to_file_raises_deprecation_warning(self):
"""Test save_to_file() raises deprecation warning."""
import warnings
serie = Serie(
key="test-key",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1, 2, 3]}
)
with tempfile.NamedTemporaryFile(
mode='w', suffix='.json', delete=False
) as temp_file:
temp_filename = temp_file.name
try:
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
serie.save_to_file(temp_filename)
# Check deprecation warning was raised
assert len(w) == 1
assert issubclass(w[0].category, DeprecationWarning)
assert "deprecated" in str(w[0].message).lower()
assert "save_to_file" in str(w[0].message)
finally:
if os.path.exists(temp_filename):
os.remove(temp_filename)
def test_load_from_file_raises_deprecation_warning(self):
"""Test load_from_file() raises deprecation warning."""
import warnings
serie = Serie(
key="test-key",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1, 2, 3]}
)
with tempfile.NamedTemporaryFile(
mode='w', suffix='.json', delete=False
) as temp_file:
temp_filename = temp_file.name
try:
# Save first (suppress warning for this)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
serie.save_to_file(temp_filename)
# Now test loading
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
Serie.load_from_file(temp_filename)
# Check deprecation warning was raised
assert len(w) == 1
assert issubclass(w[0].category, DeprecationWarning)
assert "deprecated" in str(w[0].message).lower()
assert "load_from_file" in str(w[0].message)
finally:
if os.path.exists(temp_filename):
os.remove(temp_filename)
class TestSerieSanitizedFolder:
"""Test Serie.sanitized_folder property."""
def test_sanitized_folder_from_name(self):
"""Test that sanitized_folder uses the name property."""
serie = Serie(
key="attack-on-titan",
name="Attack on Titan: Final Season",
site="aniworld.to",
folder="old-folder",
episodeDict={}
)
result = serie.sanitized_folder
assert ":" not in result
assert "Attack on Titan" in result
def test_sanitized_folder_removes_special_chars(self):
"""Test that special characters are removed."""
serie = Serie(
key="re-zero",
name="Re:Zero - Starting Life in Another World?",
site="aniworld.to",
folder="old-folder",
episodeDict={}
)
result = serie.sanitized_folder
assert ":" not in result
assert "?" not in result
def test_sanitized_folder_fallback_to_folder(self):
"""Test fallback to folder when name is empty."""
serie = Serie(
key="test-key",
name="",
site="aniworld.to",
folder="Valid Folder Name",
episodeDict={}
)
result = serie.sanitized_folder
assert result == "Valid Folder Name"
def test_sanitized_folder_fallback_to_key(self):
"""Test fallback to key when name and folder can't be sanitized."""
serie = Serie(
key="valid-key",
name="",
site="aniworld.to",
folder="",
episodeDict={}
)
result = serie.sanitized_folder
assert result == "valid-key"
def test_sanitized_folder_preserves_unicode(self):
"""Test that Unicode characters are preserved."""
serie = Serie(
key="japanese-anime",
name="進撃の巨人",
site="aniworld.to",
folder="old-folder",
episodeDict={}
)
result = serie.sanitized_folder
assert "進撃の巨人" in result
def test_sanitized_folder_with_various_anime_titles(self):
"""Test sanitized_folder with real anime titles."""
test_cases = [
("fate-stay-night", "Fate/Stay Night: UBW"),
("86-eighty-six", "86: Eighty-Six"),
("steins-gate", "Steins;Gate"),
]
for key, name in test_cases:
serie = Serie(
key=key,
name=name,
site="aniworld.to",
folder="old-folder",
episodeDict={}
)
result = serie.sanitized_folder
# Verify invalid filesystem characters are removed
# Note: semicolon is valid on Linux but we test common invalid chars
assert ":" not in result
assert "/" not in result
class TestSerieNFOFeatures:
"""Test Serie class NFO-related features."""
def test_serie_creation_with_nfo_path(self):
"""Test creating Serie with NFO path."""
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]},
nfo_path="/path/to/tvshow.nfo"
)
assert serie.nfo_path == "/path/to/tvshow.nfo"
def test_serie_creation_without_nfo_path(self):
"""Test creating Serie without NFO path defaults to None."""
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]}
)
assert serie.nfo_path is None
def test_serie_nfo_path_setter(self):
"""Test setting NFO path property."""
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]}
)
serie.nfo_path = "/new/path/tvshow.nfo"
assert serie.nfo_path == "/new/path/tvshow.nfo"
def test_has_nfo_with_existing_file(self, tmp_path):
"""Test has_nfo returns True when NFO file exists."""
# Create a test directory structure
base_dir = tmp_path / "anime"
series_dir = base_dir / "Test Series"
series_dir.mkdir(parents=True)
nfo_file = series_dir / "tvshow.nfo"
nfo_file.write_text("test nfo content")
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series",
episodeDict={1: [1]}
)
assert serie.has_nfo(str(base_dir)) is True
def test_has_nfo_with_missing_file(self, tmp_path):
"""Test has_nfo returns False when NFO file doesn't exist."""
base_dir = tmp_path / "anime"
base_dir.mkdir(parents=True)
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series",
episodeDict={1: [1]}
)
assert serie.has_nfo(str(base_dir)) is False
def test_has_nfo_with_nfo_path_set(self, tmp_path):
"""Test has_nfo using nfo_path when base_directory not provided."""
nfo_file = tmp_path / "tvshow.nfo"
nfo_file.write_text("test nfo content")
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series",
episodeDict={1: [1]},
nfo_path=str(nfo_file)
)
assert serie.has_nfo() is True
def test_has_nfo_without_base_directory_or_path(self):
"""Test has_nfo returns False when no base_directory or nfo_path."""
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series",
episodeDict={1: [1]}
)
assert serie.has_nfo() is False
def test_has_poster_with_existing_file(self, tmp_path):
"""Test has_poster returns True when poster.jpg exists."""
base_dir = tmp_path / "anime"
series_dir = base_dir / "Test Series"
series_dir.mkdir(parents=True)
poster_file = series_dir / "poster.jpg"
poster_file.write_text("test image data")
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series",
episodeDict={1: [1]}
)
assert serie.has_poster(str(base_dir)) is True
def test_has_poster_with_missing_file(self, tmp_path):
"""Test has_poster returns False when poster.jpg doesn't exist."""
base_dir = tmp_path / "anime"
base_dir.mkdir(parents=True)
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series",
episodeDict={1: [1]}
)
assert serie.has_poster(str(base_dir)) is False
def test_has_poster_without_base_directory(self):
"""Test has_poster returns False when no base_directory provided."""
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series",
episodeDict={1: [1]}
)
assert serie.has_poster() is False
def test_has_logo_with_existing_file(self, tmp_path):
"""Test has_logo returns True when logo.png exists."""
base_dir = tmp_path / "anime"
series_dir = base_dir / "Test Series"
series_dir.mkdir(parents=True)
logo_file = series_dir / "logo.png"
logo_file.write_text("test logo data")
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series",
episodeDict={1: [1]}
)
assert serie.has_logo(str(base_dir)) is True
def test_has_logo_with_missing_file(self, tmp_path):
"""Test has_logo returns False when logo.png doesn't exist."""
base_dir = tmp_path / "anime"
base_dir.mkdir(parents=True)
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series",
episodeDict={1: [1]}
)
assert serie.has_logo(str(base_dir)) is False
def test_has_logo_without_base_directory(self):
"""Test has_logo returns False when no base_directory provided."""
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series",
episodeDict={1: [1]}
)
assert serie.has_logo() is False
def test_has_fanart_with_existing_file(self, tmp_path):
"""Test has_fanart returns True when fanart.jpg exists."""
base_dir = tmp_path / "anime"
series_dir = base_dir / "Test Series"
series_dir.mkdir(parents=True)
fanart_file = series_dir / "fanart.jpg"
fanart_file.write_text("test fanart data")
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series",
episodeDict={1: [1]}
)
assert serie.has_fanart(str(base_dir)) is True
def test_has_fanart_with_missing_file(self, tmp_path):
"""Test has_fanart returns False when fanart.jpg doesn't exist."""
base_dir = tmp_path / "anime"
base_dir.mkdir(parents=True)
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series",
episodeDict={1: [1]}
)
assert serie.has_fanart(str(base_dir)) is False
def test_has_fanart_without_base_directory(self):
"""Test has_fanart returns False when no base_directory provided."""
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Series",
episodeDict={1: [1]}
)
assert serie.has_fanart() is False
def test_to_dict_includes_nfo_path(self):
"""Test that to_dict includes nfo_path field."""
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1, 2], 2: [1]},
year=2024,
nfo_path="/path/to/tvshow.nfo"
)
result = serie.to_dict()
assert result["nfo_path"] == "/path/to/tvshow.nfo"
assert result["key"] == "test-series"
assert result["name"] == "Test Series"
assert result["year"] == 2024
def test_to_dict_with_none_nfo_path(self):
"""Test that to_dict handles None nfo_path."""
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1]}
)
result = serie.to_dict()
assert result["nfo_path"] is None
def test_from_dict_with_nfo_path(self):
"""Test that from_dict correctly loads nfo_path."""
data = {
"key": "test-series",
"name": "Test Series",
"site": "https://example.com",
"folder": "Test Folder",
"episodeDict": {"1": [1, 2]},
"year": 2024,
"nfo_path": "/path/to/tvshow.nfo"
}
serie = Serie.from_dict(data)
assert serie.nfo_path == "/path/to/tvshow.nfo"
assert serie.key == "test-series"
assert serie.year == 2024
def test_from_dict_without_nfo_path(self):
"""Test that from_dict handles missing nfo_path (backward compatibility)."""
data = {
"key": "test-series",
"name": "Test Series",
"site": "https://example.com",
"folder": "Test Folder",
"episodeDict": {"1": [1, 2]}
}
serie = Serie.from_dict(data)
assert serie.nfo_path is None
assert serie.key == "test-series"
def test_save_and_load_file_with_nfo_path(self, tmp_path):
"""Test that save_to_file and load_from_file preserve nfo_path."""
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder="Test Folder",
episodeDict={1: [1, 2], 2: [1]},
year=2024,
nfo_path="/path/to/tvshow.nfo"
)
file_path = tmp_path / "data"
with pytest.warns(DeprecationWarning):
serie.save_to_file(str(file_path))
with pytest.warns(DeprecationWarning):
loaded_serie = Serie.load_from_file(str(file_path))
assert loaded_serie.nfo_path == "/path/to/tvshow.nfo"
assert loaded_serie.key == "test-series"
assert loaded_serie.year == 2024

View File

@@ -1,94 +0,0 @@
"""Tests for Serie.ensure_folder_with_year() method."""
import pytest
from src.core.entities.series import Serie
class TestSerieEnsureFolderWithYear:
"""Test suite for ensure_folder_with_year method."""
def test_ensure_folder_with_year_adds_year(self):
"""Test that ensure_folder_with_year adds year to folder name."""
serie = Serie(
key="perfect-blue",
name="Perfect Blue",
site="aniworld.to",
folder="Perfect Blue",
episodeDict={1: [1, 2, 3]},
year=1997
)
result = serie.ensure_folder_with_year()
assert result == "Perfect Blue (1997)"
assert serie.folder == "Perfect Blue (1997)"
def test_ensure_folder_with_year_already_has_year(self):
"""Test that ensure_folder_with_year doesn't duplicate year."""
serie = Serie(
key="blue-exorcist",
name="Blue Exorcist",
site="aniworld.to",
folder="Blue Exorcist (2011)",
episodeDict={1: [1, 2, 3]},
year=2011
)
result = serie.ensure_folder_with_year()
assert result == "Blue Exorcist (2011)"
assert serie.folder == "Blue Exorcist (2011)"
def test_ensure_folder_with_year_no_year_available(self):
"""Test that ensure_folder_with_year returns folder unchanged if no year."""
serie = Serie(
key="unknown-anime",
name="Unknown Anime",
site="aniworld.to",
folder="Unknown Anime",
episodeDict={1: [1, 2, 3]},
year=None
)
result = serie.ensure_folder_with_year()
assert result == "Unknown Anime"
assert serie.folder == "Unknown Anime"
def test_ensure_folder_with_year_sanitizes_name(self):
"""Test that ensure_folder_with_year uses sanitized_folder property."""
serie = Serie(
key="attack-on-titan",
name="Attack on Titan: Final Season",
site="aniworld.to",
folder="Attack on Titan Final", # Old folder without year
episodeDict={1: [1, 2, 3]},
year=2020
)
result = serie.ensure_folder_with_year()
# Should use sanitized version of name_with_year
assert "(2020)" in result
assert serie.folder == result
# Colon should be removed by sanitization
assert ":" not in result
def test_ensure_folder_with_year_updates_folder_property(self):
"""Test that folder property is updated when year is added."""
serie = Serie(
key="dororo",
name="Dororo",
site="aniworld.to",
folder="Dororo",
episodeDict={1: [1, 2, 3]},
year=2019
)
original_folder = serie.folder
result = serie.ensure_folder_with_year()
assert original_folder == "Dororo"
assert result == "Dororo (2019)"
assert serie.folder == "Dororo (2019)"
assert serie.folder != original_folder

View File

@@ -1,212 +1,136 @@
"""Tests for SerieList class - identifier standardization."""
# pylint: disable=redefined-outer-name
"""Tests for SerieList class - DB-only operations."""
import os
import tempfile
import warnings
from unittest.mock import MagicMock
import pytest
from src.core.entities.SerieList import SerieList
from src.core.entities.series import Serie
@pytest.fixture
def temp_directory():
"""Create a temporary directory for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
yield tmpdir
from src.server.database.SerieList import SerieList
@pytest.fixture
def sample_serie():
"""Create a sample Serie for testing."""
return Serie(
key="attack-on-titan",
name="Attack on Titan",
site="https://aniworld.to/anime/stream/attack-on-titan",
folder="Attack on Titan (2013)",
episodeDict={1: [1, 2, 3]}
)
"""Create a sample AnimeSeries mock for testing."""
anime = MagicMock()
anime.key = "attack-on-titan"
anime.name = "Attack on Titan"
anime.site = "https://aniworld.to/anime/stream/attack-on-titan"
anime.folder = "Attack on Titan (2013)"
anime.year = 2013
anime.nfo_path = None
anime.episodeDict = {1: [1, 2, 3]}
return anime
class TestSerieListKeyBasedStorage:
"""Test SerieList uses key for internal storage."""
def test_init_creates_empty_keydict(self, temp_directory):
def test_init_creates_empty_keydict(self, tmp_path):
"""Test initialization creates keyDict."""
serie_list = SerieList(temp_directory)
serie_list = SerieList(str(tmp_path))
assert hasattr(serie_list, 'keyDict')
assert isinstance(serie_list.keyDict, dict)
assert len(serie_list.keyDict) == 0
def test_add_stores_by_key(self, temp_directory, sample_serie):
"""Test add() stores series by key."""
serie_list = SerieList(temp_directory)
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
serie_list.add(sample_serie)
# Verify stored by key, not folder
assert sample_serie.key in serie_list.keyDict
assert serie_list.keyDict[sample_serie.key] == sample_serie
def test_contains_checks_by_key(self, temp_directory, sample_serie):
def test_contains_checks_by_key(self, tmp_path, sample_serie):
"""Test contains() checks by key."""
serie_list = SerieList(temp_directory)
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
serie_list.add(sample_serie)
serie_list = SerieList(str(tmp_path))
serie_list.keyDict[sample_serie.key] = sample_serie
assert serie_list.contains(sample_serie.key)
assert not serie_list.contains("nonexistent-key")
def test_add_prevents_duplicates_by_key(
self, temp_directory, sample_serie
):
"""Test add() prevents duplicates based on key."""
serie_list = SerieList(temp_directory)
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
# Add same serie twice
serie_list.add(sample_serie)
initial_count = len(serie_list.keyDict)
serie_list.add(sample_serie)
# Should still have only one entry
assert len(serie_list.keyDict) == initial_count
assert len(serie_list.keyDict) == 1
def test_get_by_key_returns_correct_serie(
self, temp_directory, sample_serie
):
def test_get_by_key_returns_correct_serie(self, tmp_path, sample_serie):
"""Test get_by_key() retrieves series correctly."""
serie_list = SerieList(temp_directory)
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
serie_list.add(sample_serie)
serie_list = SerieList(str(tmp_path))
serie_list.keyDict[sample_serie.key] = sample_serie
result = serie_list.get_by_key(sample_serie.key)
assert result is not None
assert result.key == sample_serie.key
assert result.name == sample_serie.name
def test_get_by_key_returns_none_for_missing(self, temp_directory):
def test_get_by_key_returns_none_for_missing(self, tmp_path):
"""Test get_by_key() returns None for nonexistent key."""
serie_list = SerieList(temp_directory)
serie_list = SerieList(str(tmp_path))
result = serie_list.get_by_key("nonexistent-key")
assert result is None
def test_get_by_folder_backward_compatibility(
self, temp_directory, sample_serie
):
def test_get_by_folder_backward_compatibility(self, tmp_path, sample_serie):
"""Test get_by_folder() provides backward compatibility."""
serie_list = SerieList(temp_directory)
import warnings
serie_list = SerieList(str(tmp_path))
serie_list.keyDict[sample_serie.key] = sample_serie
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
serie_list.add(sample_serie)
result = serie_list.get_by_folder(sample_serie.folder)
assert result is not None
assert result.key == sample_serie.key
assert result.folder == sample_serie.folder
def test_get_by_folder_returns_none_for_missing(self, temp_directory):
def test_get_by_folder_returns_none_for_missing(self, tmp_path):
"""Test get_by_folder() returns None for nonexistent folder."""
serie_list = SerieList(temp_directory)
import warnings
serie_list = SerieList(str(tmp_path))
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
result = serie_list.get_by_folder("Nonexistent Folder")
assert result is None
def test_get_all_returns_all_series(self, temp_directory, sample_serie):
def test_get_all_returns_all_series(self, tmp_path, sample_serie):
"""Test get_all() returns all series from keyDict."""
serie_list = SerieList(temp_directory)
serie_list = SerieList(str(tmp_path))
serie2 = Serie(
key="naruto",
name="Naruto",
site="https://aniworld.to/anime/stream/naruto",
folder="Naruto (2002)",
episodeDict={1: [1, 2]}
)
serie2 = MagicMock()
serie2.key = "naruto"
serie2.name = "Naruto"
serie2.site = "https://aniworld.to/anime/stream/naruto"
serie2.folder = "Naruto (2002)"
serie2.episodeDict = {1: [1, 2]}
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
serie_list.add(sample_serie)
serie_list.add(serie2)
serie_list.keyDict[sample_serie.key] = sample_serie
serie_list.keyDict[serie2.key] = serie2
all_series = serie_list.get_all()
assert len(all_series) == 2
assert sample_serie in all_series
assert serie2 in all_series
def test_get_missing_episodes_filters_by_episode_dict(
self, temp_directory
):
def test_get_missing_episodes_filters_by_episode_dict(self, tmp_path):
"""Test get_missing_episodes() returns only series with episodes."""
serie_list = SerieList(temp_directory)
serie_list = SerieList(str(tmp_path))
# Serie with missing episodes
serie_with_episodes = Serie(
key="serie-with-episodes",
name="Serie With Episodes",
site="https://aniworld.to/anime/stream/serie-with-episodes",
folder="Serie With Episodes (2020)",
episodeDict={1: [1, 2, 3]}
)
serie_with_episodes = MagicMock()
serie_with_episodes.key = "serie-with-episodes"
serie_with_episodes.name = "Serie With Episodes"
serie_with_episodes.episodeDict = {1: [1, 2, 3]}
# Serie without missing episodes
serie_without_episodes = Serie(
key="serie-without-episodes",
name="Serie Without Episodes",
site="https://aniworld.to/anime/stream/serie-without-episodes",
folder="Serie Without Episodes (2021)",
episodeDict={}
)
serie_without_episodes = MagicMock()
serie_without_episodes.key = "serie-without-episodes"
serie_without_episodes.name = "Serie Without Episodes"
serie_without_episodes.episodeDict = {}
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
serie_list.add(serie_with_episodes)
serie_list.add(serie_without_episodes)
serie_list.keyDict[serie_with_episodes.key] = serie_with_episodes
serie_list.keyDict[serie_without_episodes.key] = serie_without_episodes
missing = serie_list.get_missing_episodes()
assert len(missing) == 1
assert serie_with_episodes in missing
assert serie_without_episodes not in missing
def test_load_series_stores_by_key(self, temp_directory, sample_serie):
"""Test load_series() stores series by key when loading from disk."""
# Create directory structure and save serie
folder_path = os.path.join(temp_directory, sample_serie.folder)
os.makedirs(folder_path, exist_ok=True)
data_path = os.path.join(folder_path, "data")
sample_serie.save_to_file(data_path)
# Create new SerieList (triggers load_series in __init__)
serie_list = SerieList(temp_directory)
# Verify loaded by key
assert sample_serie.key in serie_list.keyDict
loaded_serie = serie_list.keyDict[sample_serie.key]
assert loaded_serie.key == sample_serie.key
assert loaded_serie.name == sample_serie.name
class TestSerieListPublicAPI:
"""Test that public API still works correctly."""
def test_public_methods_work(self, temp_directory, sample_serie):
def test_public_methods_work(self, tmp_path, sample_serie):
"""Test that all public methods work correctly after refactoring."""
serie_list = SerieList(temp_directory)
serie_list = SerieList(str(tmp_path))
# Test add (suppress deprecation warning for test)
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
serie_list.add(sample_serie)
# Add directly to keyDict (simulating DB load)
serie_list.keyDict[sample_serie.key] = sample_serie
# Test contains
assert serie_list.contains(sample_serie.key)
@@ -219,30 +143,17 @@ class TestSerieListPublicAPI:
assert len(serie_list.GetMissingEpisode()) == 1
assert len(serie_list.get_missing_episodes()) == 1
# Test new helper methods
# Test get_by_key
assert serie_list.get_by_key(sample_serie.key) is not None
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
assert serie_list.get_by_folder(sample_serie.folder) is not None
class TestSerieListSkipLoad:
"""Test SerieList initialization options."""
def test_init_with_skip_load(self, temp_directory):
"""Test initialization with skip_load=True skips loading."""
serie_list = SerieList(temp_directory, skip_load=True)
assert len(serie_list.keyDict) == 0
class TestSerieListDeprecationWarnings:
"""Test deprecation warnings are raised for file-based methods."""
"""Test deprecation warnings are raised for deprecated methods."""
def test_get_by_folder_raises_deprecation_warning(
self, temp_directory, sample_serie
):
def test_get_by_folder_raises_deprecation_warning(self, tmp_path, sample_serie):
"""Test get_by_folder() raises deprecation warning."""
serie_list = SerieList(temp_directory, skip_load=True)
import warnings
serie_list = SerieList(str(tmp_path))
serie_list.keyDict[sample_serie.key] = sample_serie
with warnings.catch_warnings(record=True) as w:
@@ -255,267 +166,15 @@ class TestSerieListDeprecationWarnings:
assert "get_by_key()" in str(w[0].message)
class TestSerieListBackwardCompatibility:
"""Test backward compatibility of file-based operations."""
class TestInvalidateCache:
"""Test invalidate_cache method."""
def test_file_based_mode_still_works(
self, temp_directory, sample_serie
):
"""Test file-based mode still works without db_session."""
serie_list = SerieList(temp_directory)
def test_invalidate_cache_clears_keydict(self, tmp_path, sample_serie):
"""Verify invalidate_cache clears the in-memory cache."""
serie_list = SerieList(str(tmp_path))
serie_list.keyDict[sample_serie.key] = sample_serie
assert len(serie_list.keyDict) == 1
# Add should still work (with deprecation warning)
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
serie_list.add(sample_serie)
serie_list.invalidate_cache()
# File should be created
data_path = os.path.join(
temp_directory, sample_serie.folder, "data"
)
assert os.path.isfile(data_path)
# Series should be in memory
assert serie_list.contains(sample_serie.key)
def test_load_from_file_still_works(
self, temp_directory, sample_serie
):
"""Test loading from files still works."""
# Create directory and save file
folder_path = os.path.join(temp_directory, sample_serie.folder)
os.makedirs(folder_path, exist_ok=True)
data_path = os.path.join(folder_path, "data")
sample_serie.save_to_file(data_path)
# New SerieList should load it
serie_list = SerieList(temp_directory)
assert serie_list.contains(sample_serie.key)
loaded = serie_list.get_by_key(sample_serie.key)
assert loaded.name == sample_serie.name
class TestSerieListNFOFeatures:
"""Test SerieList NFO detection and logging."""
def test_load_series_detects_nfo_file(self, temp_directory, caplog):
"""Test load_series detects and sets nfo_path for series with NFO."""
import logging
caplog.set_level(logging.INFO)
# Create series folder with data file and NFO
folder_name = "Test Series"
folder_path = os.path.join(temp_directory, folder_name)
os.makedirs(folder_path)
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder=folder_name,
episodeDict={1: [1, 2]}
)
data_path = os.path.join(folder_path, "data")
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
serie.save_to_file(data_path)
# Create NFO file
nfo_path = os.path.join(folder_path, "tvshow.nfo")
with open(nfo_path, "w") as f:
f.write("<tvshow></tvshow>")
# Load series
serie_list = SerieList(temp_directory)
# Verify NFO was detected
loaded = serie_list.get_by_key("test-series")
assert loaded is not None
assert loaded.nfo_path == nfo_path
# Verify logging
assert "1 with NFO" in caplog.text
def test_load_series_detects_missing_nfo(self, temp_directory, caplog):
"""Test load_series logs when NFO is missing."""
import logging
caplog.set_level(logging.DEBUG)
# Create series folder with data file but NO NFO
folder_name = "Test Series"
folder_path = os.path.join(temp_directory, folder_name)
os.makedirs(folder_path)
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder=folder_name,
episodeDict={1: [1, 2]}
)
data_path = os.path.join(folder_path, "data")
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
serie.save_to_file(data_path)
# Load series
serie_list = SerieList(temp_directory)
# Verify NFO not set
loaded = serie_list.get_by_key("test-series")
assert loaded is not None
assert loaded.nfo_path is None
# Verify logging
assert "missing tvshow.nfo" in caplog.text
def test_load_series_detects_media_files(self, temp_directory, caplog):
"""Test load_series detects poster, logo, and fanart files."""
import logging
caplog.set_level(logging.INFO)
# Create series folder with all media files
folder_name = "Test Series"
folder_path = os.path.join(temp_directory, folder_name)
os.makedirs(folder_path)
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder=folder_name,
episodeDict={1: [1, 2]}
)
data_path = os.path.join(folder_path, "data")
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
serie.save_to_file(data_path)
# Create media files
with open(os.path.join(folder_path, "poster.jpg"), "w") as f:
f.write("poster data")
with open(os.path.join(folder_path, "logo.png"), "w") as f:
f.write("logo data")
with open(os.path.join(folder_path, "fanart.jpg"), "w") as f:
f.write("fanart data")
# Load series
serie_list = SerieList(temp_directory)
# Verify logging shows all media found
assert "Poster (1/1)" in caplog.text
assert "Logo (1/1)" in caplog.text
assert "Fanart (1/1)" in caplog.text
def test_load_series_detects_missing_media_files(
self, temp_directory, caplog
):
"""Test load_series logs when media files are missing."""
import logging
caplog.set_level(logging.DEBUG)
# Create series folder with NO media files
folder_name = "Test Series"
folder_path = os.path.join(temp_directory, folder_name)
os.makedirs(folder_path)
serie = Serie(
key="test-series",
name="Test Series",
site="https://example.com",
folder=folder_name,
episodeDict={1: [1, 2]}
)
data_path = os.path.join(folder_path, "data")
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
serie.save_to_file(data_path)
# Load series
serie_list = SerieList(temp_directory)
# Verify logging shows missing media
assert "missing poster.jpg" in caplog.text
assert "missing logo.png" in caplog.text
assert "missing fanart.jpg" in caplog.text
def test_load_series_summary_statistics(self, temp_directory, caplog):
"""Test load_series logs summary statistics for NFO and media."""
import logging
caplog.set_level(logging.INFO)
# Create multiple series with varying NFO/media status
for i in range(3):
folder_name = f"Series {i}"
folder_path = os.path.join(temp_directory, folder_name)
os.makedirs(folder_path)
serie = Serie(
key=f"series-{i}",
name=f"Series {i}",
site="https://example.com",
folder=folder_name,
episodeDict={1: [1]}
)
data_path = os.path.join(folder_path, "data")
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
serie.save_to_file(data_path)
# First series has everything
if i == 0:
with open(os.path.join(folder_path, "tvshow.nfo"), "w") as f:
f.write("<tvshow></tvshow>")
with open(os.path.join(folder_path, "poster.jpg"), "w") as f:
f.write("poster")
with open(os.path.join(folder_path, "logo.png"), "w") as f:
f.write("logo")
with open(os.path.join(folder_path, "fanart.jpg"), "w") as f:
f.write("fanart")
# Second series has NFO and poster only
elif i == 1:
with open(os.path.join(folder_path, "tvshow.nfo"), "w") as f:
f.write("<tvshow></tvshow>")
with open(os.path.join(folder_path, "poster.jpg"), "w") as f:
f.write("poster")
# Third series has nothing
# Load series
serie_list = SerieList(temp_directory)
# Verify summary statistics
assert "3 series total" in caplog.text
assert "2 with NFO, 1 without NFO" in caplog.text
assert "Poster (2/3)" in caplog.text
assert "Logo (1/3)" in caplog.text
assert "Fanart (1/3)" in caplog.text
def test_load_series_handles_load_failure(self, temp_directory, caplog):
"""Test load_series handles series that fail to load gracefully."""
import logging
caplog.set_level(logging.ERROR)
# Create folder with invalid data file
folder_name = "Invalid Series"
folder_path = os.path.join(temp_directory, folder_name)
os.makedirs(folder_path)
data_path = os.path.join(folder_path, "data")
with open(data_path, "w") as f:
f.write("invalid json {{{")
# Load series - should not crash
serie_list = SerieList(temp_directory)
# Verify error logged
assert "Failed to load metadata" in caplog.text
# Should not be in keyDict
assert len(serie_list.keyDict) == 0

View File

@@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.core.entities.series import Serie
from src.server.database.models import AnimeSeries
@pytest.fixture
@@ -24,27 +24,49 @@ def sample_anime_series():
mock.site = "aniworld.to"
mock.folder = "Attack on Titan (2013)"
mock.year = 2013
mock.episodes = [
MagicMock(season=1, episode_number=1),
MagicMock(season=1, episode_number=2),
MagicMock(season=1, episode_number=3),
MagicMock(season=2, episode_number=1),
MagicMock(season=2, episode_number=2),
]
# Create properly configured episode mocks that work with iteration
episode1 = MagicMock(season=1, episode_number=1)
episode2 = MagicMock(season=1, episode_number=2)
episode3 = MagicMock(season=1, episode_number=3)
episode4 = MagicMock(season=2, episode_number=1)
episode5 = MagicMock(season=2, episode_number=2)
mock.episodes = [episode1, episode2, episode3, episode4, episode5]
# Set _episode_dict_cache to None to force building from episodes
mock._episode_dict_cache = None
# Configure episodeDict as a property that computes from episodes
# This mirrors what the real AnimeSeries.episodeDict property does
def build_episode_dict():
episode_dict = {}
for ep in mock.episodes:
season = ep.season or 1
if season not in episode_dict:
episode_dict[season] = []
episode_dict[season].append(ep.episode_number or 0)
return episode_dict
# Create a mock property that returns computed dict
mock.episodeDict = property(lambda self: build_episode_dict())
# But we need it to work when accessed, not as a property object
# So configure the mock to return the dict directly when episodeDict is accessed
type(mock).episodeDict = property(lambda self: build_episode_dict())
return mock
@pytest.fixture
def sample_serie():
"""Create a sample Serie for testing."""
return Serie(
key="attack-on-titan",
name="Attack on Titan",
site="aniworld.to",
folder="Attack on Titan (2013)",
episodeDict={1: [1, 2, 3], 2: [1, 2]},
year=2013
)
"""Create a sample AnimeSeries mock for testing."""
anime = MagicMock(spec=AnimeSeries)
anime.key = "attack-on-titan"
anime.name = "Attack on Titan"
anime.site = "aniworld.to"
anime.folder = "Attack on Titan (2013)"
anime.year = 2013
anime.episodeDict = {1: [1, 2, 3], 2: [1, 2]}
return anime
class TestLoadAllFromDb:
@@ -63,9 +85,9 @@ class TestLoadAllFromDb:
"src.server.database.service.AnimeSeriesService.get_all",
return_value=[sample_anime_series]
):
from src.core.entities.SerieList import SerieList
from src.server.database.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
serie_list = SerieList("/tmp")
count = await serie_list.load_all_from_db()
assert count == 1
@@ -98,9 +120,9 @@ class TestLoadAllFromDb:
"src.server.database.service.AnimeSeriesService.get_all",
return_value=[sample_anime_series, mock_series2]
):
from src.core.entities.SerieList import SerieList
from src.server.database.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
serie_list = SerieList("/tmp")
count = await serie_list.load_all_from_db()
assert count == 2
@@ -122,9 +144,9 @@ class TestLoadAllFromDb:
"src.server.database.service.AnimeSeriesService.get_all",
return_value=[sample_anime_series]
):
from src.core.entities.SerieList import SerieList
from src.server.database.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
serie_list = SerieList("/tmp")
await serie_list.load_all_from_db()
serie = serie_list.keyDict["attack-on-titan"]
@@ -146,9 +168,9 @@ class TestLoadAllFromDb:
"src.server.database.service.AnimeSeriesService.get_all",
return_value=[]
):
from src.core.entities.SerieList import SerieList
from src.server.database.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
serie_list = SerieList("/tmp")
count = await serie_list.load_all_from_db()
assert count == 0
@@ -167,9 +189,9 @@ class TestLoadAllFromDb:
"src.server.database.service.AnimeSeriesService.get_all",
side_effect=RuntimeError("Database not initialized")
):
from src.core.entities.SerieList import SerieList
from src.server.database.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
serie_list = SerieList("/tmp")
count = await serie_list.load_all_from_db()
assert count == 0
@@ -194,9 +216,9 @@ class TestLoadSingleSeriesFromDb:
"src.server.database.service.AnimeSeriesService.get_by_folder",
return_value=sample_anime_series
):
from src.core.entities.SerieList import SerieList
from src.server.database.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
serie_list = SerieList("/tmp")
serie = await serie_list._load_single_series_from_db("Attack on Titan (2013)")
assert serie is not None
@@ -218,9 +240,9 @@ class TestLoadSingleSeriesFromDb:
"src.server.database.service.AnimeSeriesService.get_by_folder",
return_value=None
):
from src.core.entities.SerieList import SerieList
from src.server.database.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
serie_list = SerieList("/tmp")
serie = await serie_list._load_single_series_from_db("Unknown Series")
assert serie is None
@@ -241,9 +263,9 @@ class TestLoadSingleSeriesFromDb:
"src.server.database.service.AnimeSeriesService.get_by_folder",
side_effect=RuntimeError("Database not initialized")
):
from src.core.entities.SerieList import SerieList
from src.server.database.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
serie_list = SerieList("/tmp")
serie = await serie_list._load_single_series_from_db("Some Folder")
assert serie is None
@@ -254,9 +276,9 @@ class TestInvalidateCache:
def test_invalidate_cache_clears_keydict(self, sample_serie):
"""Verify invalidate_cache clears the in-memory cache."""
from src.core.entities.SerieList import SerieList
from src.server.database.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
serie_list = SerieList("/tmp")
serie_list.keyDict["attack-on-titan"] = sample_serie
assert len(serie_list.keyDict) == 1
@@ -276,9 +298,9 @@ class TestInvalidateCache:
"src.server.database.service.AnimeSeriesService.get_all",
return_value=[sample_anime_series]
):
from src.core.entities.SerieList import SerieList
from src.server.database.SerieList import SerieList
serie_list = SerieList("/tmp", skip_load=True)
serie_list = SerieList("/tmp")
serie_list.keyDict["some-key"] = MagicMock()
serie_list.invalidate_cache()

View File

@@ -7,8 +7,8 @@ from unittest.mock import MagicMock, patch
import pytest
from src.core.entities.series import Serie
from src.core.SerieScanner import SerieScanner
from src.server.database.models import AnimeSeries
from src.server.SerieScanner import SerieScanner
@pytest.fixture
@@ -40,14 +40,16 @@ def mock_loader():
@pytest.fixture
def sample_serie():
"""Create a sample Serie for testing."""
return Serie(
key="attack-on-titan",
name="Attack on Titan",
site="aniworld.to",
folder="Attack on Titan (2013)",
episodeDict={1: [2, 3, 4]}
)
"""Create a sample AnimeSeries mock for testing."""
anime = MagicMock(spec=AnimeSeries)
anime.key = "attack-on-titan"
anime.name = "Attack on Titan"
anime.site = "aniworld.to"
anime.folder = "Attack on Titan (2013)"
anime.year = None
anime.nfo_path = None
anime.episodeDict = {1: [2, 3, 4]}
return anime
class TestSerieScannerInitialization:
@@ -134,7 +136,9 @@ class TestSerieScannerScan:
'_SerieScanner__get_missing_episodes_and_season',
return_value=({1: [2, 3]}, "aniworld.to")
):
with patch.object(sample_serie, 'save_to_file'):
with patch.object(
scanner, '_persist_serie_to_db'
):
scanner.scan()
assert sample_serie.key in scanner.keyDict
@@ -519,61 +523,17 @@ class TestFindMp4Files:
class TestReadDataFromFile:
"""Test __read_data_from_file method."""
def test_reads_data_file(self, mock_loader):
"""Should read Serie from 'data' file when no DB entry exists."""
import tempfile
def test_empty_folder_name_returns_none(self, temp_directory, mock_loader):
"""Empty folder name -> returns None (no DB lookup attempted)."""
scanner = SerieScanner(temp_directory, mock_loader)
result = scanner._SerieScanner__read_data_from_file("")
assert result is None
with tempfile.TemporaryDirectory() as tmpdir:
anime_folder = os.path.join(tmpdir, "SomeAnime")
os.makedirs(anime_folder)
# Create a data file
serie = Serie("test-key", "Test", "aniworld.to", "SomeAnime", {})
data_path = os.path.join(anime_folder, "data")
serie.save_to_file(data_path)
scanner = SerieScanner(tmpdir, mock_loader)
result = scanner._SerieScanner__read_data_from_file("SomeAnime")
assert result is not None
assert result.key == "test-key"
def test_no_files_returns_serie_with_generated_key(self, mock_loader):
"""Should return Serie with generated key when no key or data file exists."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
anime_folder = os.path.join(tmpdir, "Empty")
os.makedirs(anime_folder)
scanner = SerieScanner(tmpdir, mock_loader)
result = scanner._SerieScanner__read_data_from_file("Empty")
# Step 5 (was Step 4) generates key from folder name when no files exist
assert result is not None
assert isinstance(result, Serie)
assert result.key == "empty"
def test_scan_key_override_used_instead_of_generated(self, mock_loader):
"""Should use override key when folder name matches override dict."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
anime_folder = os.path.join(tmpdir, "Anyway, I'm Falling in Love with You (2025)")
os.makedirs(anime_folder)
overrides = {
"Anyway, I'm Falling in Love with You (2025)": "anyway-im-falling-in-love-with-you-2025"
}
scanner = SerieScanner(tmpdir, mock_loader, scan_key_overrides=overrides)
result = scanner._SerieScanner__read_data_from_file(
"Anyway, I'm Falling in Love with You (2025)"
)
# Override key should be used instead of generated key
assert result is not None
assert isinstance(result, Serie)
assert result.key == "anyway-im-falling-in-love-with-you-2025"
class TestReinit:
def test_nonexistent_folder_no_exception(self, temp_directory, mock_loader):
"""Folder doesn't exist -> returns None without raising."""
scanner = SerieScanner(temp_directory, mock_loader)
result = scanner._SerieScanner__read_data_from_file("Nonexistent Folder")
assert result is None
"""Test reinit method."""
def test_clears_keydict(self, temp_directory, mock_loader):
@@ -640,12 +600,10 @@ class TestScanProgressEvents:
call_data = completion_handler.call_args[0][0]
assert call_data["success"] is True
def test_scan_emits_error_on_no_key(
def test_scan_emits_error(
self, temp_directory, mock_loader
):
"""Should emit on_error when NoKeyFoundException occurs."""
from src.core.exceptions.Exceptions import NoKeyFoundException
"""Should emit on_error when an exception occurs."""
scanner = SerieScanner(temp_directory, mock_loader)
error_handler = MagicMock()
scanner.subscribe_on_error(error_handler)
@@ -657,7 +615,7 @@ class TestScanProgressEvents:
), \
patch.object(
scanner, '_SerieScanner__read_data_from_file',
side_effect=NoKeyFoundException("no key"),
side_effect=RuntimeError("DB error"),
):
scanner.scan()
@@ -666,186 +624,4 @@ class TestScanProgressEvents:
assert call_data["recoverable"] is True
class TestDbLookupFallback:
"""Tests for the db_lookup callback in SerieScanner."""
def _make_scanner(self, tmp_dir, mock_loader, db_lookup=None):
"""Create a scanner with an optional db_lookup."""
# Create a folder with an mp4 but NO key/data file
folder = os.path.join(tmp_dir, "Rooster Fighter (2026)")
os.makedirs(folder, exist_ok=True)
mp4 = os.path.join(folder, "Rooster Fighter - S01E001 - (German Dub).mp4")
with open(mp4, "w") as f:
f.write("dummy")
return SerieScanner(tmp_dir, mock_loader, db_lookup=db_lookup)
def test_db_lookup_stored_on_init(self, temp_directory, mock_loader):
"""db_lookup callable should be stored as _db_lookup."""
lookup = MagicMock(return_value=None)
scanner = SerieScanner(temp_directory, mock_loader, db_lookup=lookup)
assert scanner._db_lookup is lookup
def test_no_db_lookup_defaults_to_none(self, temp_directory, mock_loader):
"""Without db_lookup, _db_lookup should be None."""
scanner = SerieScanner(temp_directory, mock_loader)
assert scanner._db_lookup is None
def test_db_lookup_called_when_no_files(self, mock_loader):
"""db_lookup is called when neither key nor data file exists."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
lookup = MagicMock(return_value=None)
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
with patch.object(scanner, 'get_total_to_scan', return_value=1), \
patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({}, "aniworld.to"),
):
scanner.scan()
lookup.assert_called_once_with("Rooster Fighter (2026)")
def test_db_lookup_not_called_when_key_file_exists(self, mock_loader):
"""db_lookup is NOT called when a key file is present."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
folder = os.path.join(tmp_dir, "Rooster Fighter (2026)")
os.makedirs(folder, exist_ok=True)
mp4 = os.path.join(folder, "S01E001.mp4")
with open(mp4, "w") as f:
f.write("dummy")
with open(os.path.join(folder, "key"), "w") as f:
f.write("rooster-fighter")
lookup = MagicMock(return_value=None)
scanner = SerieScanner(tmp_dir, mock_loader, db_lookup=lookup)
with patch.object(scanner, 'get_total_to_scan', return_value=1), \
patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({1: []}, "aniworld.to"),
), \
patch.object(
SerieScanner,
'_SerieScanner__read_data_from_file',
return_value=Serie(
key="rooster-fighter", name="", site="aniworld.to",
folder="Rooster Fighter (2026)", episodeDict={},
),
):
scanner.scan()
lookup.assert_not_called()
def test_db_lookup_resolves_serie_and_scans(self, mock_loader):
"""When db_lookup returns a Serie, scanning continues normally."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
resolved = Serie(
key="rooster-fighter",
name="Rooster Fighter",
site="aniworld.to",
folder="Rooster Fighter (2026)",
episodeDict={},
year=2026,
)
lookup = MagicMock(return_value=resolved)
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
with patch.object(scanner, 'get_total_to_scan', return_value=1), \
patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({1: [1, 2, 3]}, "aniworld.to"),
), \
patch.object(resolved, 'save_to_file'):
scanner.scan()
assert "rooster-fighter" in scanner.keyDict
assert scanner.keyDict["rooster-fighter"].episodeDict == {1: [1, 2, 3]}
def test_db_lookup_returns_none_folder_skipped(self, mock_loader):
"""When db_lookup returns None, Step 4 fallback generates key from folder name."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
lookup = MagicMock(return_value=None)
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
with patch.object(scanner, 'get_total_to_scan', return_value=1):
scanner.scan()
# Step 4 generates key from folder name, so keyDict is not empty
assert len(scanner.keyDict) == 1
def test_db_lookup_exception_skips_folder(self, mock_loader):
"""When db_lookup raises, Step 4 fallback generates key from folder name."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
lookup = MagicMock(side_effect=RuntimeError("DB offline"))
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
with patch.object(scanner, 'get_total_to_scan', return_value=1):
scanner.scan() # should not raise
# Step 4 generates key from folder name, so keyDict is not empty
assert len(scanner.keyDict) == 1
def test_db_lookup_warning_logged_when_no_files(
self, mock_loader, caplog
):
"""A warning is logged for folders without key/data file."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=None)
with caplog.at_level(logging.WARNING, logger="src.core.SerieScanner"):
with patch.object(scanner, 'get_total_to_scan', return_value=1):
scanner.scan()
assert any(
"Rooster Fighter (2026)" in record.message
for record in caplog.records
if record.levelname == "WARNING"
)
def test_db_lookup_info_logged_on_resolution(
self, mock_loader, caplog
):
"""An INFO log is emitted when db_lookup resolves a folder."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
resolved = Serie(
key="rooster-fighter",
name="",
site="aniworld.to",
folder="Rooster Fighter (2026)",
episodeDict={},
)
lookup = MagicMock(return_value=resolved)
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
with caplog.at_level(logging.INFO, logger="src.core.SerieScanner"), \
patch.object(scanner, 'get_total_to_scan', return_value=1), \
patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({}, "aniworld.to"),
), \
patch.object(resolved, 'save_to_file'):
scanner.scan()
assert any(
"rooster-fighter" in record.message
for record in caplog.records
if record.levelname == "INFO"
)

View File

@@ -7,8 +7,8 @@ from unittest.mock import MagicMock, patch
import pytest
from src.core.entities.series import Serie
from src.core.SerieScanner import SerieScanner
from src.server.database.models import AnimeSeries
from src.server.SerieScanner import SerieScanner
@pytest.fixture
@@ -51,7 +51,7 @@ class TestGetSerieFromFolderDbLookup:
mock_anime_series.episodes = []
mock_session.execute.return_value.scalar_one_or_none.return_value = mock_anime_series
with patch("src.core.SerieScanner.get_sync_session", return_value=mock_session):
with patch("src.server.SerieScanner.get_sync_session", return_value=mock_session):
scanner = SerieScanner(temp_directory, mock_loader)
result = scanner._SerieScanner__read_data_from_file("Rooster Fighter (2026)")
@@ -60,48 +60,30 @@ class TestGetSerieFromFolderDbLookup:
assert result.name == "Rooster Fighter"
assert result.year == 2026
def test_db_miss_falls_back_to_provider_callback(self, temp_directory, mock_loader):
"""DB miss -> _db_lookup callback called."""
lookup = MagicMock(return_value=Serie(
key="rooster-fighter",
name="Rooster Fighter",
site="aniworld.to",
folder="Rooster Fighter (2026)",
episodeDict={},
))
scanner = SerieScanner(temp_directory, mock_loader, db_lookup=lookup)
def test_db_miss_returns_none(self, temp_directory, mock_loader):
"""DB miss -> returns None (no fallback)."""
mock_session = MagicMock()
mock_session.execute.return_value.scalar_one_or_none.return_value = None
result = scanner._SerieScanner__read_data_from_file("Rooster Fighter (2026)")
with patch("src.server.SerieScanner.get_sync_session", return_value=mock_session):
scanner = SerieScanner(temp_directory, mock_loader)
result = scanner._SerieScanner__read_data_from_file("Unknown Series (2026)")
assert result is not None
assert result.key == "rooster-fighter"
lookup.assert_called_once_with("Rooster Fighter (2026)")
def test_no_db_no_callback_generates_key_from_folder_name(self, temp_directory, mock_loader):
"""No DB entry, no callback -> key generated from folder name."""
folder = os.path.join(temp_directory, "Legacy Series")
os.makedirs(folder, exist_ok=True)
# No key file, no data file - should fall through to Step 4 (key generation)
scanner = SerieScanner(temp_directory, mock_loader)
result = scanner._SerieScanner__read_data_from_file("Legacy Series")
assert result is not None
assert result.key == "legacy-series"
assert result.folder == "Legacy Series"
assert result is None
def test_db_lookup_exception_caught_and_logged(self, temp_directory, mock_loader):
"""DB exception -> fallback to provider callback."""
def bad_lookup(folder):
raise RuntimeError("DB connection failed")
"""DB exception -> returns None without raising."""
with patch(
"src.server.SerieScanner.get_sync_session",
side_effect=RuntimeError("DB connection failed")
):
scanner = SerieScanner(temp_directory, mock_loader)
scanner = SerieScanner(temp_directory, mock_loader, db_lookup=bad_lookup)
with patch.object(logging.getLogger("src.core.SerieScanner"), "warning") as mock_warning:
result = scanner._SerieScanner__read_data_from_file("Rooster Fighter (2026)")
mock_warning.assert_called()
assert any("DB lookup failed" in str(c) for c in mock_warning.call_args_list)
with patch.object(logging.getLogger("src.server.SerieScanner"), "warning") as mock_warning:
result = scanner._SerieScanner__read_data_from_file("Rooster Fighter (2026)")
mock_warning.assert_called()
assert any("DB lookup failed" in str(c) for c in mock_warning.call_args_list)
assert result is None
class TestGetSerieFromFolderEdgeCases:

View File

@@ -4,8 +4,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.core.entities.series import Serie
from src.core.SerieScanner import SerieScanner
from src.server.database.models import AnimeSeries
from src.server.SerieScanner import SerieScanner
@pytest.fixture
@@ -18,15 +18,15 @@ def mock_session_factory():
@pytest.fixture
def sample_serie():
"""Create a sample Serie for testing."""
return Serie(
key="attack-on-titan",
name="Attack on Titan",
site="aniworld.to",
folder="Attack on Titan (2013)",
episodeDict={1: [1, 2, 3], 2: [1, 2]},
year=2013
)
"""Create a sample AnimeSeries mock for testing."""
anime = MagicMock(spec=AnimeSeries)
anime.key = "attack-on-titan"
anime.name = "Attack on Titan"
anime.site = "aniworld.to"
anime.folder = "Attack on Titan (2013)"
anime.year = 2013
anime.episodeDict = {1: [1, 2, 3], 2: [1, 2]}
return anime
class TestPersistSerieToDb:

View File

@@ -10,19 +10,19 @@ Tests the functionality of SeriesApp including:
- Error handling
"""
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from src.core.SeriesApp import SeriesApp
from src.server.SeriesApp import SeriesApp
class TestSeriesAppInitialization:
"""Test SeriesApp initialization."""
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
def test_init_success(
self, mock_serie_list, mock_scanner, mock_loaders
):
@@ -37,7 +37,7 @@ class TestSeriesAppInitialization:
mock_loaders.assert_called_once()
mock_scanner.assert_called_once()
@patch('src.core.SeriesApp.Loaders')
@patch('src.server.SeriesApp.Loaders')
def test_init_failure_raises_error(self, mock_loaders):
"""Test that initialization failure raises error."""
test_dir = "/test/anime"
@@ -49,10 +49,10 @@ class TestSeriesAppInitialization:
with pytest.raises(RuntimeError):
SeriesApp(test_dir)
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.core.SeriesApp.settings')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
@patch('src.server.SeriesApp.settings')
def test_init_uses_config_fallback_for_nfo_service(
self,
mock_settings,
@@ -71,9 +71,9 @@ class TestSeriesAppSearch:
"""Test search functionality."""
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
async def test_search_success(
self, mock_serie_list, mock_scanner, mock_loaders
):
@@ -96,9 +96,9 @@ class TestSeriesAppSearch:
app.loader.search.assert_called_once_with("test anime")
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
async def test_search_failure_raises_error(
self, mock_serie_list, mock_scanner, mock_loaders
):
@@ -120,9 +120,9 @@ class TestSeriesAppDownload:
"""Test download functionality."""
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
async def test_download_success(
self, mock_serie_list, mock_scanner, mock_loaders, tmp_path
):
@@ -157,9 +157,9 @@ class TestSeriesAppDownload:
assert os.path.exists(folder_path)
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
async def test_download_with_progress_callback(
self, mock_serie_list, mock_scanner, mock_loaders, tmp_path
):
@@ -197,9 +197,9 @@ class TestSeriesAppDownload:
app.loader.download.assert_called_once()
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
async def test_download_cancellation(
self, mock_serie_list, mock_scanner, mock_loaders, tmp_path
):
@@ -234,9 +234,9 @@ class TestSeriesAppDownload:
assert app._events.download_status.called
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
async def test_download_failure(
self, mock_serie_list, mock_scanner, mock_loaders
):
@@ -268,9 +268,9 @@ class TestSeriesAppReScan:
"""Test directory scanning functionality."""
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
async def test_rescan_success(
self, mock_serie_list, mock_scanner, mock_loaders
):
@@ -295,9 +295,9 @@ class TestSeriesAppReScan:
app.serie_scanner.scan.assert_called_once()
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
async def test_rescan_with_events(
self, mock_serie_list, mock_scanner, mock_loaders
):
@@ -327,9 +327,9 @@ class TestSeriesAppReScan:
app.serie_scanner.unsubscribe_on_progress.assert_called_once()
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
async def test_rescan_cancellation(
self, mock_serie_list, mock_scanner, mock_loaders
):
@@ -359,9 +359,9 @@ class TestSeriesAppReScan:
class TestSeriesAppCancellation:
"""Test operation cancellation."""
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
def test_cancel_operation_when_running(
self, mock_serie_list, mock_scanner, mock_loaders
):
@@ -373,9 +373,9 @@ class TestSeriesAppCancellation:
# as the cancel mechanism may have changed
pass
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
def test_cancel_operation_when_idle(
self, mock_serie_list, mock_scanner, mock_loaders
):
@@ -387,9 +387,9 @@ class TestSeriesAppCancellation:
class TestSeriesAppGetters:
"""Test getter methods."""
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
def test_get_series_list(
self, mock_serie_list, mock_scanner, mock_loaders
):
@@ -400,9 +400,9 @@ class TestSeriesAppGetters:
# Verify app was created
assert app is not None
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
def test_get_operation_status(
self, mock_serie_list, mock_scanner, mock_loaders
):
@@ -410,9 +410,9 @@ class TestSeriesAppGetters:
# Skip - operation status API may have changed
pass
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
def test_get_current_operation(
self, mock_serie_list, mock_scanner, mock_loaders
):
@@ -424,9 +424,9 @@ class TestSeriesAppGetters:
class TestSeriesAppDatabaseInit:
"""Test SeriesApp initialization (no database support in core)."""
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
def test_init_creates_components(
self, mock_serie_list, mock_scanner, mock_loaders
):
@@ -446,45 +446,39 @@ class TestSeriesAppDatabaseInit:
class TestSeriesAppLoadSeriesFromList:
"""Test SeriesApp load_series_from_list method."""
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
def test_load_series_from_list_populates_keydict(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test load_series_from_list populates the list correctly."""
from src.core.entities.series import Serie
from src.server.database.models import AnimeSeries
test_dir = "/test/anime"
mock_list = Mock()
mock_list.GetMissingEpisode.return_value = []
mock_list.keyDict = {}
mock_serie_list.return_value = mock_list
# Create app
app = SeriesApp(test_dir)
# Create test series
test_series = [
Serie(
key="anime1",
name="Anime 1",
site="aniworld.to",
folder="Anime 1",
episodeDict={1: [1, 2]}
),
Serie(
key="anime2",
name="Anime 2",
site="aniworld.to",
folder="Anime 2",
episodeDict={1: [1]}
),
]
# Create test series (AnimeSeries mocks)
def make_anime(key, name, folder):
anime = MagicMock(spec=AnimeSeries)
anime.key = key
anime.name = name
anime.site = "aniworld.to"
anime.folder = folder
anime.episodeDict = {1: [1, 2]} if key == "anime1" else {1: [1]}
return anime
test_series = [make_anime("anime1", "Anime 1", "Anime 1"), make_anime("anime2", "Anime 2", "Anime 2")]
# Load series
app.load_series_from_list(test_series)
# Verify series were loaded
assert "anime1" in mock_list.keyDict
assert "anime2" in mock_list.keyDict
@@ -493,33 +487,30 @@ class TestSeriesAppLoadSeriesFromList:
class TestSeriesAppGetAllSeriesFromDataFiles:
"""Test get_all_series_from_data_files() functionality."""
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
def test_returns_list_of_series(
self, mock_serie_list_class, mock_scanner, mock_loaders
):
"""Test that get_all_series_from_data_files returns a list of Serie."""
from src.core.entities.series import Serie
"""Test that get_all_series_from_data_files returns a list of AnimeSeries."""
from src.server.database.models import AnimeSeries
test_dir = "/test/anime"
def make_anime(key, name, folder):
anime = MagicMock(spec=AnimeSeries)
anime.key = key
anime.name = name
anime.site = "https://aniworld.to"
anime.folder = folder
anime.episodeDict = {1: [1, 2, 3]} if key == "anime1" else {1: [1, 2]}
return anime
# Mock series to return
mock_series = [
Serie(
key="anime1",
name="Anime 1",
site="https://aniworld.to",
folder="Anime 1 (2020)",
episodeDict={1: [1, 2, 3]}
),
Serie(
key="anime2",
name="Anime 2",
site="https://aniworld.to",
folder="Anime 2 (2021)",
episodeDict={1: [1]}
),
make_anime("anime1", "Anime 1", "Anime 1 (2020)"),
make_anime("anime2", "Anime 2", "Anime 2 (2021)"),
]
# Setup mock for the main SerieList instance (constructor call)
@@ -539,16 +530,16 @@ class TestSeriesAppGetAllSeriesFromDataFiles:
# Call the method
result = app.get_all_series_from_data_files()
# Verify result is a list of Serie
# Verify result is a list of AnimeSeries
assert isinstance(result, list)
assert len(result) == 2
assert all(isinstance(s, Serie) for s in result)
assert all(isinstance(s, MagicMock) for s in result)
assert result[0].key == "anime1"
assert result[1].key == "anime2"
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
def test_returns_empty_list_when_no_data_files(
self, mock_serie_list_class, mock_scanner, mock_loaders
):
@@ -575,9 +566,9 @@ class TestSeriesAppGetAllSeriesFromDataFiles:
assert isinstance(result, list)
assert len(result) == 0
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
def test_handles_exception_gracefully(
self, mock_serie_list_class, mock_scanner, mock_loaders
):
@@ -604,13 +595,13 @@ class TestSeriesAppGetAllSeriesFromDataFiles:
assert isinstance(result, list)
assert len(result) == 0
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
def test_uses_file_based_loading(
self, mock_serie_list_class, mock_scanner, mock_loaders
):
"""Test that method uses file-based loading (no db_session)."""
"""Test that method uses SerieList for file-based loading."""
test_dir = "/test/anime"
# Setup mock for the main SerieList instance
@@ -629,24 +620,23 @@ class TestSeriesAppGetAllSeriesFromDataFiles:
# Call the method
app.get_all_series_from_data_files()
# Verify the second SerieList was created with correct params
# (file-based loading: db_session=None, skip_load=False)
# Verify SerieList was called twice (main + temp)
calls = mock_serie_list_class.call_args_list
assert len(calls) == 2
# Check the second call (for get_all_series_from_data_files)
# Check the second call is for temp SerieList with directory
second_call = calls[1]
assert second_call.kwargs.get('db_session') is None
assert second_call.kwargs.get('skip_load') is False
# base_path is passed as positional argument
assert second_call.args[0] == test_dir
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.server.SeriesApp.Loaders')
@patch('src.server.SeriesApp.SerieScanner')
@patch('src.server.SeriesApp.SerieList')
def test_does_not_modify_main_list(
self, mock_serie_list_class, mock_scanner, mock_loaders
):
"""Test that method does not modify the main SerieList instance."""
from src.core.entities.series import Serie
from src.server.database.models import AnimeSeries
test_dir = "/test/anime"
@@ -657,15 +647,13 @@ class TestSeriesAppGetAllSeriesFromDataFiles:
# Setup mock for the temporary SerieList
mock_temp_list = Mock()
mock_temp_list.get_all.return_value = [
Serie(
key="anime1",
name="Anime 1",
site="https://aniworld.to",
folder="Anime 1",
episodeDict={}
)
]
anime = MagicMock(spec=AnimeSeries)
anime.key = "anime1"
anime.name = "Anime 1"
anime.site = "https://aniworld.to"
anime.folder = "Anime 1"
anime.episodeDict = {}
mock_temp_list.get_all.return_value = [anime]
mock_serie_list_class.side_effect = [mock_main_list, mock_temp_list]

View File

@@ -6,7 +6,7 @@ import aiohttp
import pytest
from aiohttp import ClientResponseError, ClientSession
from src.core.services.tmdb_client import TMDBAPIError, TMDBClient
from src.server.services_nfo_temp.tmdb_client import TMDBAPIError, TMDBClient
@pytest.fixture

View File

@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
import pytest
from src.core.services.tmdb_client import TMDBAPIError, TMDBClient
from src.server.services_nfo_temp.tmdb_client import TMDBAPIError, TMDBClient
def _make_ctx(response):