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
This commit is contained in:
439
tests/unit/test_add_series_episodes.py
Normal file
439
tests/unit/test_add_series_episodes.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""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"])
|
||||
Reference in New Issue
Block a user