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:
@@ -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 = {}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (< > : " / \ | ? *)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user