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:
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user