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

@@ -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):