- 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
440 lines
17 KiB
Python
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"])
|