Files
Aniworld/tests/unit/test_add_series_episodes.py
Lukas d72b8cb1ab Add sync_single_series_after_scan with NFO metadata and WebSocket updates
- Implement sync_single_series_after_scan to persist scanned series to database
- Enhanced _broadcast_series_updated to include full NFO metadata (nfo_created_at, nfo_updated_at, tmdb_id, tvdb_id)
- Add immediate episode scanning in add_series endpoint when background loader isn't running
- Implement updateSingleSeries in frontend to handle series_updated WebSocket events
- Add SERIES_UPDATED event constant to WebSocket event definitions
- Update background loader to use sync_single_series_after_scan method
- Simplified background loader initialization in FastAPI app
- Add comprehensive tests for series update WebSocket payload and episode counting logic
- Import reorganization: move get_background_loader_service to dependencies module
2026-02-06 18:47:47 +01:00

440 lines
17 KiB
Python

"""Unit tests for adding series with episode scanning.
This module tests the complete flow of adding a series:
1. Series is added to database
2. Episodes are scanned
3. Episodes are saved to database
4. GUI is updated via WebSocket
All tests use mocks to avoid network traffic.
"""
from datetime import datetime
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
@pytest.fixture
def mock_series_app():
"""Create a mock SeriesApp with scanner."""
app = MagicMock()
# Mock serie_scanner
app.serie_scanner = MagicMock()
app.serie_scanner.keyDict = {}
# Mock list
app.list = MagicMock()
app.list.keyDict = {}
# Mock loader
app.loader = MagicMock()
app.loader.get_year = MagicMock(return_value=2024)
return app
@pytest.fixture
def mock_db_session():
"""Create a mock database session."""
session = AsyncMock()
session.commit = AsyncMock()
session.rollback = AsyncMock()
session.close = AsyncMock()
session.flush = AsyncMock()
session.refresh = AsyncMock()
return session
@pytest.fixture
def mock_anime_service(mock_series_app):
"""Create a mock AnimeService."""
from src.server.services.anime_service import AnimeService
service = AnimeService(mock_series_app)
return service
@pytest.mark.asyncio
class TestAddSeriesWithEpisodes:
"""Test suite for adding series with episode scanning."""
async def test_scan_single_series_updates_scanner_keydict(
self,
mock_series_app
):
"""Test that scan_single_series updates serie_scanner.keyDict."""
# Arrange
key = "test-anime"
folder = "Test Anime (2024)"
# Mock scan_single_series to update keyDict
def mock_scan(key, folder):
# Create Serie with episodes
serie = Serie(
key=key,
name="Test Anime",
site="aniworld.to",
folder=folder,
episodeDict={1: [1, 2, 3]},
year=2024
)
# Update scanner's keyDict
mock_series_app.serie_scanner.keyDict[key] = serie
return {1: [1, 2, 3]}
mock_series_app.serie_scanner.scan_single_series = mock_scan
# Act
result = mock_series_app.serie_scanner.scan_single_series(key, folder)
# Assert
assert key in mock_series_app.serie_scanner.keyDict
serie = mock_series_app.serie_scanner.keyDict[key]
assert serie.episodeDict == {1: [1, 2, 3]}
assert len(serie.episodeDict[1]) == 3
async def test_sync_single_series_gets_from_scanner_keydict(
self,
mock_series_app,
mock_anime_service
):
"""Test that sync_single_series_after_scan gets Serie from scanner.keyDict."""
# Arrange
key = "test-anime"
# Create Serie in scanner's keyDict with episodes
serie = Serie(
key=key,
name="Test Anime",
site="aniworld.to",
folder="Test Anime (2024)",
episodeDict={1: [1, 2, 3], 2: [1, 2]},
year=2024
)
mock_series_app.serie_scanner.keyDict[key] = serie
# Mock the database save method
with patch.object(
mock_anime_service,
'_save_scan_results_to_db',
new_callable=AsyncMock
) as mock_save:
mock_save.return_value = 1
with patch.object(
mock_anime_service,
'_load_series_from_db',
new_callable=AsyncMock
):
with patch.object(
mock_anime_service,
'_broadcast_series_updated',
new_callable=AsyncMock
):
# Act
await mock_anime_service.sync_single_series_after_scan(key)
# Assert
mock_save.assert_called_once()
series_list = mock_save.call_args[0][0]
assert len(series_list) == 1
saved_serie = series_list[0]
assert saved_serie.key == key
assert saved_serie.episodeDict == {1: [1, 2, 3], 2: [1, 2]}
async def test_save_scan_results_creates_episodes_in_db(
self,
mock_anime_service,
mock_db_session
):
"""Test that _save_scan_results_to_db creates episodes."""
# Arrange
serie = Serie(
key="test-anime",
name="Test Anime",
site="aniworld.to",
folder="Test Anime (2024)",
episodeDict={1: [1, 2, 3], 2: [1, 2]},
year=2024
)
# Mock database services
with patch('src.server.database.connection.get_db_session') as mock_get_db:
# Setup context manager for database session
mock_get_db.return_value.__aenter__.return_value = mock_db_session
mock_get_db.return_value.__aexit__.return_value = None
with patch('src.server.database.service.AnimeSeriesService') as mock_series_service:
with patch('src.server.database.service.EpisodeService') as mock_episode_service:
# Series doesn't exist - will create new
mock_series_service.get_by_key = AsyncMock(return_value=None)
# Mock create to return a series with ID
mock_db_series = MagicMock()
mock_db_series.id = 1
mock_db_series.key = "test-anime"
mock_series_service.create = AsyncMock(return_value=mock_db_series)
# Mock episode creation
episode_create_calls = []
async def track_episode_create(db, series_id, season, episode_number):
episode_create_calls.append((series_id, season, episode_number))
ep = MagicMock()
ep.id = len(episode_create_calls)
ep.series_id = series_id
ep.season = season
ep.episode_number = episode_number
return ep
mock_episode_service.create = AsyncMock(side_effect=track_episode_create)
# Act
result = await mock_anime_service._save_scan_results_to_db([serie])
# Assert
assert result == 1 # One series saved
# Verify episodes were created
assert len(episode_create_calls) == 5 # 3 from season 1, 2 from season 2
# Check season 1 episodes
assert (1, 1, 1) in episode_create_calls
assert (1, 1, 2) in episode_create_calls
assert (1, 1, 3) in episode_create_calls
# Check season 2 episodes
assert (1, 2, 1) in episode_create_calls
assert (1, 2, 2) in episode_create_calls
async def test_update_series_adds_missing_episodes(
self,
mock_anime_service,
mock_db_session
):
"""Test that _update_series_in_db adds new missing episodes."""
# Arrange
serie = Serie(
key="test-anime",
name="Test Anime",
site="aniworld.to",
folder="Test Anime (2024)",
episodeDict={1: [1, 2, 3, 4]}, # 4 episodes
year=2024
)
# Existing series in DB with only 2 episodes
existing_db_series = MagicMock()
existing_db_series.id = 1
existing_db_series.key = "test-anime"
existing_db_series.folder = "Test Anime (2024)"
# Mock existing episodes in DB
existing_episode_1 = MagicMock()
existing_episode_1.id = 1
existing_episode_1.series_id = 1
existing_episode_1.season = 1
existing_episode_1.episode_number = 1
existing_episode_2 = MagicMock()
existing_episode_2.id = 2
existing_episode_2.series_id = 1
existing_episode_2.season = 1
existing_episode_2.episode_number = 2
existing_episodes = [existing_episode_1, existing_episode_2]
with patch('src.server.database.connection.get_db_session') as mock_get_db:
mock_get_db.return_value.__aenter__.return_value = mock_db_session
mock_get_db.return_value.__aexit__.return_value = None
with patch('src.server.database.service.AnimeSeriesService') as mock_series_service:
with patch('src.server.database.service.EpisodeService') as mock_episode_service:
# Series exists
mock_series_service.get_by_key = AsyncMock(return_value=existing_db_series)
# Mock get_by_series to return existing episodes
mock_episode_service.get_by_series = AsyncMock(return_value=existing_episodes)
# Track new episodes created
new_episodes_created = []
async def track_episode_create(db, series_id, season, episode_number):
new_episodes_created.append((series_id, season, episode_number))
return MagicMock()
mock_episode_service.create = AsyncMock(side_effect=track_episode_create)
mock_episode_service.delete = AsyncMock()
# Act
result = await mock_anime_service._save_scan_results_to_db([serie])
# Assert
assert result == 1
# Should create 2 new episodes (episode 3 and 4)
assert len(new_episodes_created) == 2
assert (1, 1, 3) in new_episodes_created
assert (1, 1, 4) in new_episodes_created
async def test_complete_add_series_flow(
self,
mock_series_app
):
"""Integration test for complete add series flow."""
from src.server.services.anime_service import AnimeService
# Arrange
key = "test-anime"
folder = "Test Anime (2024)"
# Setup mock scanner to populate keyDict
def mock_scan(key, folder):
serie = Serie(
key=key,
name="Test Anime",
site="aniworld.to",
folder=folder,
episodeDict={1: [1, 2, 3]},
year=2024
)
mock_series_app.serie_scanner.keyDict[key] = serie
return {1: [1, 2, 3]}
mock_series_app.serie_scanner.scan_single_series = mock_scan
# Create service
anime_service = AnimeService(mock_series_app)
# Mock database operations
with patch('src.server.database.connection.get_db_session') as mock_get_db:
mock_db = AsyncMock()
mock_get_db.return_value.__aenter__.return_value = mock_db
mock_get_db.return_value.__aexit__.return_value = None
with patch('src.server.database.service.AnimeSeriesService') as mock_series_service:
with patch('src.server.database.service.EpisodeService') as mock_episode_service:
# Series doesn't exist
mock_series_service.get_by_key = AsyncMock(return_value=None)
# Mock series creation
mock_db_series = MagicMock()
mock_db_series.id = 1
mock_series_service.create = AsyncMock(return_value=mock_db_series)
# Track episodes
episodes_created = []
async def track_create(db, series_id, season, episode_number):
episodes_created.append((season, episode_number))
return MagicMock()
mock_episode_service.create = AsyncMock(side_effect=track_create)
# Mock other methods
with patch.object(anime_service, '_load_series_from_db', new_callable=AsyncMock):
with patch.object(anime_service, '_broadcast_series_updated', new_callable=AsyncMock):
# Act
# 1. Scan episodes
result = mock_series_app.serie_scanner.scan_single_series(key, folder)
# 2. Sync to database
await anime_service.sync_single_series_after_scan(key)
# Assert
# Episodes were scanned
assert result == {1: [1, 2, 3]}
# Serie was added to scanner keyDict
assert key in mock_series_app.serie_scanner.keyDict
# Episodes were saved to DB
assert len(episodes_created) == 3
assert (1, 1) in episodes_created
assert (1, 2) in episodes_created
assert (1, 3) in episodes_created
async def test_websocket_broadcast_on_series_update(
self,
mock_series_app
):
"""Test that WebSocket broadcasts series_updated event with complete data including NFO fields."""
from unittest.mock import AsyncMock, MagicMock, patch
from src.server.database.models import AnimeSeries
from src.server.services.anime_service import AnimeService
# Arrange
key = "test-anime"
# Create Serie in list.keyDict with episodes
serie = Serie(
key=key,
name="Test Anime",
site="aniworld.to",
folder="Test Anime (2024)",
episodeDict={1: [1, 2, 3]},
year=2024
)
mock_series_app.list.keyDict[key] = serie
# Mock database AnimeSeries with NFO data
mock_db_series = AnimeSeries(
key=key,
name="Test Anime",
folder="Test Anime (2024)",
site="aniworld.to",
year=2024,
has_nfo=True,
tmdb_id="12345",
tvdb_id="67890",
nfo_created_at=datetime(2024, 1, 1, 12, 0, 0),
nfo_updated_at=datetime(2024, 1, 2, 12, 0, 0)
)
# Create service with mocked WebSocket
anime_service = AnimeService(mock_series_app)
mock_websocket = AsyncMock()
anime_service._websocket_service = mock_websocket
# Mock database session and service
mock_db_session = AsyncMock()
mock_db_session.__aenter__ = AsyncMock(return_value=mock_db_session)
mock_db_session.__aexit__ = AsyncMock()
with patch('src.server.database.connection.get_db_session', return_value=mock_db_session):
with patch('src.server.database.service.AnimeSeriesService') as MockAnimeSeriesService:
MockAnimeSeriesService.get_by_key = AsyncMock(return_value=mock_db_series)
# Act
await anime_service._broadcast_series_updated(key)
# Assert
mock_websocket.broadcast.assert_called_once()
call_args = mock_websocket.broadcast.call_args[0][0]
# Verify payload structure
assert call_args["type"] == "series_updated"
assert call_args["key"] == key
assert "data" in call_args
# Verify basic series data
assert call_args["data"]["key"] == key
assert call_args["data"]["name"] == "Test Anime"
assert call_args["data"]["missing_episodes"] == {"1": [1, 2, 3]}
assert call_args["data"]["has_missing"] is True
# Verify NFO metadata fields are included
assert call_args["data"]["has_nfo"] is True
assert call_args["data"]["tmdb_id"] == "12345"
assert call_args["data"]["tvdb_id"] == "67890"
assert call_args["data"]["nfo_created_at"] == "2024-01-01T12:00:00"
assert call_args["data"]["nfo_updated_at"] == "2024-01-02T12:00:00"
assert "timestamp" in call_args
if __name__ == "__main__":
pytest.main([__file__, "-v"])