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:
@@ -15,12 +15,10 @@ from src.server.exceptions import (
|
|||||||
ValidationError,
|
ValidationError,
|
||||||
)
|
)
|
||||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||||
from src.server.services.background_loader_service import (
|
from src.server.services.background_loader_service import BackgroundLoaderService
|
||||||
BackgroundLoaderService,
|
|
||||||
get_background_loader_service,
|
|
||||||
)
|
|
||||||
from src.server.utils.dependencies import (
|
from src.server.utils.dependencies import (
|
||||||
get_anime_service,
|
get_anime_service,
|
||||||
|
get_background_loader_service,
|
||||||
get_optional_database_session,
|
get_optional_database_session,
|
||||||
get_series_app,
|
get_series_app,
|
||||||
require_auth,
|
require_auth,
|
||||||
@@ -641,6 +639,7 @@ async def add_series(
|
|||||||
request: AddSeriesRequest,
|
request: AddSeriesRequest,
|
||||||
_auth: dict = Depends(require_auth),
|
_auth: dict = Depends(require_auth),
|
||||||
series_app: Any = Depends(get_series_app),
|
series_app: Any = Depends(get_series_app),
|
||||||
|
anime_service: AnimeService = Depends(get_anime_service),
|
||||||
db: Optional[AsyncSession] = Depends(get_optional_database_session),
|
db: Optional[AsyncSession] = Depends(get_optional_database_session),
|
||||||
background_loader: BackgroundLoaderService = Depends(get_background_loader_service),
|
background_loader: BackgroundLoaderService = Depends(get_background_loader_service),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -831,8 +830,44 @@ async def add_series(
|
|||||||
key,
|
key,
|
||||||
e
|
e
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Step F: Scan missing episodes immediately if background loader is not running
|
||||||
|
# Uses existing SerieScanner and AnimeService sync to avoid duplicates
|
||||||
|
try:
|
||||||
|
loader_running = (
|
||||||
|
background_loader.worker_task is not None
|
||||||
|
and not background_loader.worker_task.done()
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not loader_running
|
||||||
|
and series_app
|
||||||
|
and hasattr(series_app, "serie_scanner")
|
||||||
|
):
|
||||||
|
missing_episodes = series_app.serie_scanner.scan_single_series(
|
||||||
|
key=key,
|
||||||
|
folder=folder
|
||||||
|
)
|
||||||
|
total_missing = sum(
|
||||||
|
len(eps) for eps in missing_episodes.values()
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Scanned %d missing episodes for %s",
|
||||||
|
total_missing,
|
||||||
|
key
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist scan results to database (includes episodes)
|
||||||
|
# scan_single_series updates serie_scanner.keyDict with episodeDict
|
||||||
|
# sync_single_series_after_scan retrieves from there and saves to DB
|
||||||
|
await anime_service.sync_single_series_after_scan(key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to scan missing episodes for %s: %s",
|
||||||
|
key,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
|
||||||
# Step F: Return immediate response (202 Accepted)
|
# Step G: Return immediate response (202 Accepted)
|
||||||
response = {
|
response = {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"Series added successfully: {name}. Data will be loaded in background.",
|
"message": f"Series added successfully: {name}. Data will be loaded in background.",
|
||||||
|
|||||||
@@ -251,17 +251,9 @@ async def lifespan(_application: FastAPI):
|
|||||||
logger.info("Download service initialized and queue restored")
|
logger.info("Download service initialized and queue restored")
|
||||||
|
|
||||||
# Initialize background loader service
|
# Initialize background loader service
|
||||||
from src.server.services.background_loader_service import (
|
from src.server.utils.dependencies import get_background_loader_service
|
||||||
init_background_loader_service,
|
|
||||||
)
|
|
||||||
from src.server.utils.dependencies import get_series_app
|
|
||||||
|
|
||||||
series_app_instance = get_series_app()
|
background_loader = get_background_loader_service()
|
||||||
background_loader = init_background_loader_service(
|
|
||||||
websocket_service=ws_service,
|
|
||||||
anime_service=anime_service,
|
|
||||||
series_app=series_app_instance
|
|
||||||
)
|
|
||||||
await background_loader.start()
|
await background_loader.start()
|
||||||
initialized['background_loader'] = True
|
initialized['background_loader'] = True
|
||||||
logger.info("Background loader service started")
|
logger.info("Background loader service started")
|
||||||
|
|||||||
@@ -658,6 +658,57 @@ class AnimeService:
|
|||||||
logger.exception("rescan failed")
|
logger.exception("rescan failed")
|
||||||
raise AnimeServiceError("Rescan failed") from exc
|
raise AnimeServiceError("Rescan failed") from exc
|
||||||
|
|
||||||
|
async def sync_single_series_after_scan(self, series_key: str) -> None:
|
||||||
|
"""Persist a single scanned series and refresh cached state.
|
||||||
|
|
||||||
|
Reuses the same save/reload/cache invalidation flow as `rescan`
|
||||||
|
to keep the database, in-memory list, and UI in sync.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
series_key: Series key to persist and refresh.
|
||||||
|
"""
|
||||||
|
# Get serie from scanner's keyDict, not series_app.list.keyDict
|
||||||
|
# scan_single_series updates serie_scanner.keyDict with episodeDict
|
||||||
|
if not hasattr(self._app, "serie_scanner") or not hasattr(self._app.serie_scanner, "keyDict"):
|
||||||
|
logger.warning(
|
||||||
|
"Serie scanner not available for single-series sync: %s",
|
||||||
|
series_key,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
serie = self._app.serie_scanner.keyDict.get(series_key)
|
||||||
|
if not serie:
|
||||||
|
logger.warning(
|
||||||
|
"Series not found in scanner keyDict for single-series sync: %s",
|
||||||
|
series_key,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
total_episodes = sum(len(eps) for eps in (serie.episodeDict or {}).values())
|
||||||
|
logger.info(
|
||||||
|
"Syncing series %s with %d missing episodes. episodeDict: %s",
|
||||||
|
series_key,
|
||||||
|
total_episodes,
|
||||||
|
serie.episodeDict
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._save_scan_results_to_db([serie])
|
||||||
|
await self._load_series_from_db()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._cached_list_missing.cache_clear()
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._broadcast_series_updated(series_key)
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
logger.warning(
|
||||||
|
"Failed to broadcast series update for %s: %s",
|
||||||
|
series_key,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
async def _save_scan_results_to_db(self, series_list: list) -> int:
|
async def _save_scan_results_to_db(self, series_list: list) -> int:
|
||||||
"""
|
"""
|
||||||
Save scan results to the database.
|
Save scan results to the database.
|
||||||
@@ -684,13 +735,27 @@ class AnimeService:
|
|||||||
db, serie.key
|
db, serie.key
|
||||||
)
|
)
|
||||||
|
|
||||||
|
total_episodes = sum(len(eps) for eps in (serie.episodeDict or {}).values())
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# Update existing series
|
# Update existing series
|
||||||
|
logger.info(
|
||||||
|
"Updating existing series %s with %d episodes. episodeDict: %s",
|
||||||
|
serie.key,
|
||||||
|
total_episodes,
|
||||||
|
serie.episodeDict
|
||||||
|
)
|
||||||
await self._update_series_in_db(
|
await self._update_series_in_db(
|
||||||
serie, existing, db
|
serie, existing, db
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Create new series
|
# Create new series
|
||||||
|
logger.info(
|
||||||
|
"Creating new series %s with %d episodes. episodeDict: %s",
|
||||||
|
serie.key,
|
||||||
|
total_episodes,
|
||||||
|
serie.episodeDict
|
||||||
|
)
|
||||||
await self._create_series_in_db(serie, db)
|
await self._create_series_in_db(serie, db)
|
||||||
|
|
||||||
saved_count += 1
|
saved_count += 1
|
||||||
@@ -936,17 +1001,79 @@ class AnimeService:
|
|||||||
return episodes_added
|
return episodes_added
|
||||||
|
|
||||||
async def _broadcast_series_updated(self, series_key: str) -> None:
|
async def _broadcast_series_updated(self, series_key: str) -> None:
|
||||||
"""Broadcast series update event to WebSocket clients."""
|
"""Broadcast series update event to WebSocket clients with full data."""
|
||||||
if not self._websocket_service:
|
if not self._websocket_service:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Get updated series data to send to frontend
|
||||||
|
series_data = None
|
||||||
|
if hasattr(self._app, 'list') and hasattr(self._app.list, 'keyDict'):
|
||||||
|
serie = self._app.list.keyDict.get(series_key)
|
||||||
|
if serie:
|
||||||
|
# Convert episode dict keys to strings for JSON
|
||||||
|
missing_episodes = {str(k): v for k, v in (serie.episodeDict or {}).items()}
|
||||||
|
total_missing = sum(len(eps) for eps in missing_episodes.values())
|
||||||
|
|
||||||
|
# Fetch NFO metadata from database
|
||||||
|
has_nfo = False
|
||||||
|
nfo_created_at = None
|
||||||
|
nfo_updated_at = None
|
||||||
|
tmdb_id = None
|
||||||
|
tvdb_id = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
async with get_db_session() as db:
|
||||||
|
db_series = await AnimeSeriesService.get_by_key(db, series_key)
|
||||||
|
if db_series:
|
||||||
|
has_nfo = db_series.has_nfo or False
|
||||||
|
nfo_created_at = (
|
||||||
|
db_series.nfo_created_at.isoformat()
|
||||||
|
if db_series.nfo_created_at else None
|
||||||
|
)
|
||||||
|
nfo_updated_at = (
|
||||||
|
db_series.nfo_updated_at.isoformat()
|
||||||
|
if db_series.nfo_updated_at else None
|
||||||
|
)
|
||||||
|
tmdb_id = db_series.tmdb_id
|
||||||
|
tvdb_id = db_series.tvdb_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Could not fetch NFO data for %s: %s",
|
||||||
|
series_key,
|
||||||
|
str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
series_data = {
|
||||||
|
"key": serie.key,
|
||||||
|
"name": serie.name,
|
||||||
|
"folder": serie.folder,
|
||||||
|
"site": serie.site,
|
||||||
|
"missing_episodes": missing_episodes,
|
||||||
|
"has_missing": total_missing > 0,
|
||||||
|
"has_nfo": has_nfo,
|
||||||
|
"nfo_created_at": nfo_created_at,
|
||||||
|
"nfo_updated_at": nfo_updated_at,
|
||||||
|
"tmdb_id": tmdb_id,
|
||||||
|
"tvdb_id": tvdb_id,
|
||||||
|
}
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"type": "series_updated",
|
"type": "series_updated",
|
||||||
"key": series_key,
|
"key": series_key,
|
||||||
"message": "Episodes updated",
|
"data": series_data,
|
||||||
|
"message": "Series episodes updated",
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat()
|
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Broadcasting series update for %s with %d missing episodes",
|
||||||
|
series_key,
|
||||||
|
sum(len(eps) for eps in (series_data.get("missing_episodes", {}).values())) if series_data else 0
|
||||||
|
)
|
||||||
|
|
||||||
await self._websocket_service.broadcast(payload)
|
await self._websocket_service.broadcast(payload)
|
||||||
|
|
||||||
async def add_series_to_db(
|
async def add_series_to_db(
|
||||||
|
|||||||
@@ -626,9 +626,10 @@ class BackgroundLoaderService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Notify anime_service to sync episodes to database
|
# Notify anime_service to sync episodes to database
|
||||||
|
# Use sync_single_series_after_scan which gets data from serie_scanner.keyDict
|
||||||
if self.anime_service:
|
if self.anime_service:
|
||||||
logger.debug(f"Calling anime_service.sync_episodes_to_db for {task.key}")
|
logger.debug(f"Calling anime_service.sync_single_series_after_scan for {task.key}")
|
||||||
await self.anime_service.sync_episodes_to_db(task.key)
|
await self.anime_service.sync_single_series_after_scan(task.key)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"anime_service not available, episodes will not be synced to DB for {task.key}")
|
logger.warning(f"anime_service not available, episodes will not be synced to DB for {task.key}")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -338,6 +338,18 @@ AniWorld.SeriesManager = (function() {
|
|||||||
const canBeSelected = hasMissingEpisodes;
|
const canBeSelected = hasMissingEpisodes;
|
||||||
const hasNfo = serie.has_nfo || false;
|
const hasNfo = serie.has_nfo || false;
|
||||||
const isLoading = serie.loading_status && serie.loading_status !== 'completed' && serie.loading_status !== 'failed';
|
const isLoading = serie.loading_status && serie.loading_status !== 'completed' && serie.loading_status !== 'failed';
|
||||||
|
|
||||||
|
// Debug logging for troubleshooting
|
||||||
|
if (serie.key === 'so-im-a-spider-so-what') {
|
||||||
|
console.log('[createSerieCard] Spider series:', {
|
||||||
|
key: serie.key,
|
||||||
|
missing_episodes: serie.missing_episodes,
|
||||||
|
missing_episodes_type: typeof serie.missing_episodes,
|
||||||
|
episodeDict: serie.episodeDict,
|
||||||
|
hasMissingEpisodes: hasMissingEpisodes,
|
||||||
|
has_missing: serie.has_missing
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return '<div class="series-card ' + (isSelected ? 'selected' : '') + ' ' +
|
return '<div class="series-card ' + (isSelected ? 'selected' : '') + ' ' +
|
||||||
(hasMissingEpisodes ? 'has-missing' : 'complete') + ' ' +
|
(hasMissingEpisodes ? 'has-missing' : 'complete') + ' ' +
|
||||||
@@ -455,6 +467,76 @@ AniWorld.SeriesManager = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a single series from WebSocket data
|
||||||
|
* @param {Object} updatedData - Updated series data from WebSocket
|
||||||
|
*/
|
||||||
|
function updateSingleSeries(updatedData) {
|
||||||
|
if (!updatedData || !updatedData.key) {
|
||||||
|
console.warn('Invalid series update data:', updatedData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[updateSingleSeries] Received data:', updatedData);
|
||||||
|
console.log('[updateSingleSeries] missing_episodes type:', typeof updatedData.missing_episodes);
|
||||||
|
console.log('[updateSingleSeries] missing_episodes value:', updatedData.missing_episodes);
|
||||||
|
|
||||||
|
// Count total missing episodes from the episode dictionary
|
||||||
|
const episodeDict = updatedData.missing_episodes || {};
|
||||||
|
console.log('[updateSingleSeries] episodeDict:', episodeDict);
|
||||||
|
|
||||||
|
const totalMissing = Object.values(episodeDict).reduce(
|
||||||
|
function(sum, episodes) {
|
||||||
|
return sum + (Array.isArray(episodes) ? episodes.length : 0);
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[updateSingleSeries] totalMissing calculated:', totalMissing);
|
||||||
|
|
||||||
|
// Transform WebSocket data to match our internal format
|
||||||
|
const transformedSerie = {
|
||||||
|
key: updatedData.key,
|
||||||
|
name: updatedData.name,
|
||||||
|
site: updatedData.site || 'aniworld.to',
|
||||||
|
folder: updatedData.folder,
|
||||||
|
episodeDict: episodeDict,
|
||||||
|
missing_episodes: totalMissing,
|
||||||
|
has_missing: updatedData.has_missing || totalMissing > 0,
|
||||||
|
has_nfo: updatedData.has_nfo || false,
|
||||||
|
nfo_created_at: updatedData.nfo_created_at || null,
|
||||||
|
nfo_updated_at: updatedData.nfo_updated_at || null,
|
||||||
|
tmdb_id: updatedData.tmdb_id || null,
|
||||||
|
tvdb_id: updatedData.tvdb_id || null,
|
||||||
|
loading_status: updatedData.loading_status || 'completed',
|
||||||
|
episodes_loaded: updatedData.episodes_loaded !== false,
|
||||||
|
nfo_loaded: updatedData.nfo_loaded !== false,
|
||||||
|
logo_loaded: updatedData.logo_loaded !== false,
|
||||||
|
images_loaded: updatedData.images_loaded !== false
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[updateSingleSeries] Transformed serie:', transformedSerie);
|
||||||
|
|
||||||
|
// Find existing series in our data
|
||||||
|
const existingIndex = seriesData.findIndex(function(s) {
|
||||||
|
return s.key === updatedData.key;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Update existing series
|
||||||
|
seriesData[existingIndex] = transformedSerie;
|
||||||
|
console.log('Updated existing series:', updatedData.key, transformedSerie);
|
||||||
|
} else {
|
||||||
|
// Add new series
|
||||||
|
seriesData.push(transformedSerie);
|
||||||
|
console.log('Added new series:', updatedData.key, transformedSerie);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reapply filters and re-render
|
||||||
|
applyFiltersAndSort();
|
||||||
|
renderSeries();
|
||||||
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
return {
|
return {
|
||||||
init: init,
|
init: init,
|
||||||
@@ -464,6 +546,7 @@ AniWorld.SeriesManager = (function() {
|
|||||||
getSeriesData: getSeriesData,
|
getSeriesData: getSeriesData,
|
||||||
getFilteredSeriesData: getFilteredSeriesData,
|
getFilteredSeriesData: getFilteredSeriesData,
|
||||||
findByKey: findByKey,
|
findByKey: findByKey,
|
||||||
updateSeriesLoadingStatus: updateSeriesLoadingStatus
|
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
|
||||||
|
updateSingleSeries: updateSingleSeries
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -133,6 +133,22 @@ AniWorld.IndexSocketHandler = (function() {
|
|||||||
AniWorld.ScanManager.updateProcessStatus('download', false, true);
|
AniWorld.ScanManager.updateProcessStatus('download', false, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Series events
|
||||||
|
socket.on(WS_EVENTS.SERIES_UPDATED, function(data) {
|
||||||
|
console.log('Series updated:', data);
|
||||||
|
|
||||||
|
// Use the data directly to update the series instead of full refresh
|
||||||
|
if (data && data.data && AniWorld.SeriesManager && AniWorld.SeriesManager.updateSingleSeries) {
|
||||||
|
AniWorld.SeriesManager.updateSingleSeries(data.data);
|
||||||
|
} else {
|
||||||
|
// Fallback to full reload if data is incomplete
|
||||||
|
console.warn('Incomplete series update data, falling back to full reload');
|
||||||
|
if (AniWorld.SeriesManager && AniWorld.SeriesManager.loadSeries) {
|
||||||
|
AniWorld.SeriesManager.loadSeries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Series loading events
|
// Series loading events
|
||||||
socket.on(WS_EVENTS.SERIES_LOADING_UPDATE, function(data) {
|
socket.on(WS_EVENTS.SERIES_LOADING_UPDATE, function(data) {
|
||||||
console.log('Series loading update:', data);
|
console.log('Series loading update:', data);
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ AniWorld.Constants = (function() {
|
|||||||
SCAN_ERROR: 'scan_error',
|
SCAN_ERROR: 'scan_error',
|
||||||
SCAN_FAILED: 'scan_failed',
|
SCAN_FAILED: 'scan_failed',
|
||||||
|
|
||||||
// Series loading events
|
// Series events
|
||||||
|
SERIES_UPDATED: 'series_updated',
|
||||||
SERIES_LOADING_UPDATE: 'series_loading_update',
|
SERIES_LOADING_UPDATE: 'series_loading_update',
|
||||||
|
|
||||||
// Scheduled scan events
|
// Scheduled scan events
|
||||||
|
|||||||
363
tests/frontend/test_websocket_series_card_rendering.py
Normal file
363
tests/frontend/test_websocket_series_card_rendering.py
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
"""
|
||||||
|
Frontend test for WebSocket series card HTML rendering.
|
||||||
|
|
||||||
|
This test verifies that when WebSocket data is received for a series update,
|
||||||
|
the JavaScript correctly generates HTML elements with proper classes and content.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from src.server.fastapi_app import app
|
||||||
|
from src.server.services.auth_service import auth_service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_auth():
|
||||||
|
"""Reset authentication state before each test."""
|
||||||
|
original_hash = auth_service._hash
|
||||||
|
auth_service._hash = None
|
||||||
|
if hasattr(auth_service, '_failed'):
|
||||||
|
auth_service._failed.clear()
|
||||||
|
yield
|
||||||
|
auth_service._hash = original_hash
|
||||||
|
if hasattr(auth_service, '_failed'):
|
||||||
|
auth_service._failed.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def authenticated_client():
|
||||||
|
"""Create authenticated test client with JWT token."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from src.config.settings import settings
|
||||||
|
|
||||||
|
# Setup temporary anime directory
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
settings.anime_directory = temp_dir
|
||||||
|
|
||||||
|
# Set admin credentials
|
||||||
|
password = "Hallo123!"
|
||||||
|
auth_service.setup_master_password(password)
|
||||||
|
|
||||||
|
# Create client
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
# Login to get token
|
||||||
|
login_response = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "admin", "password": password}
|
||||||
|
)
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
token = login_response.json()["access_token"]
|
||||||
|
|
||||||
|
# Add token to client headers
|
||||||
|
client.headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestWebSocketSeriesCardRendering:
|
||||||
|
"""Test series card HTML rendering from WebSocket data."""
|
||||||
|
|
||||||
|
async def test_series_card_html_with_missing_episodes(self):
|
||||||
|
"""Test that series card data structure is correct for missing episodes."""
|
||||||
|
# Simulate WebSocket series update data
|
||||||
|
websocket_data = {
|
||||||
|
"type": "series_updated",
|
||||||
|
"key": "so-im-a-spider-so-what",
|
||||||
|
"data": {
|
||||||
|
"key": "so-im-a-spider-so-what",
|
||||||
|
"name": "So I'm a Spider, So What?",
|
||||||
|
"folder": "So I'm a Spider, So What",
|
||||||
|
"site": "aniworld.to",
|
||||||
|
"missing_episodes": {"1": list(range(1, 25))}, # 24 episodes
|
||||||
|
"has_missing": True,
|
||||||
|
"has_nfo": True,
|
||||||
|
"nfo_created_at": "2026-01-23T20:14:31.565228",
|
||||||
|
"nfo_updated_at": None,
|
||||||
|
"tmdb_id": None,
|
||||||
|
"tvdb_id": None,
|
||||||
|
},
|
||||||
|
"message": "Series episodes updated",
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify WebSocket payload structure
|
||||||
|
assert websocket_data["type"] == "series_updated"
|
||||||
|
assert "data" in websocket_data
|
||||||
|
|
||||||
|
data = websocket_data["data"]
|
||||||
|
|
||||||
|
# Verify episode count calculation (what JavaScript does)
|
||||||
|
total_missing = sum(len(eps) for eps in data["missing_episodes"].values())
|
||||||
|
assert total_missing == 24
|
||||||
|
|
||||||
|
# Verify has_missing matches the episode count
|
||||||
|
assert data["has_missing"] is True
|
||||||
|
assert total_missing > 0
|
||||||
|
|
||||||
|
# Verify NFO metadata is present
|
||||||
|
assert "has_nfo" in data
|
||||||
|
assert "nfo_created_at" in data
|
||||||
|
assert "tmdb_id" in data
|
||||||
|
assert "tvdb_id" in data
|
||||||
|
|
||||||
|
async def test_websocket_data_structure_matches_api_format(self):
|
||||||
|
"""Test that WebSocket data structure matches expected API format."""
|
||||||
|
# This verifies the data structure that should be sent via WebSocket
|
||||||
|
# matches what the API endpoints return
|
||||||
|
|
||||||
|
# Expected series data structure (from API and WebSocket)
|
||||||
|
series_data = {
|
||||||
|
"key": "test-anime",
|
||||||
|
"name": "Test Anime",
|
||||||
|
"site": "aniworld.to",
|
||||||
|
"folder": "Test Anime (2024)",
|
||||||
|
"missing_episodes": {"1": [1, 2, 3], "2": [1, 2]},
|
||||||
|
"has_missing": True,
|
||||||
|
"has_nfo": False,
|
||||||
|
"nfo_created_at": None,
|
||||||
|
"nfo_updated_at": None,
|
||||||
|
"tmdb_id": None,
|
||||||
|
"tvdb_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify required fields exist
|
||||||
|
required_fields = [
|
||||||
|
"key", "name", "folder", "site",
|
||||||
|
"missing_episodes", "has_missing",
|
||||||
|
"has_nfo"
|
||||||
|
]
|
||||||
|
for field in required_fields:
|
||||||
|
assert field in series_data, f"Missing required field: {field}"
|
||||||
|
|
||||||
|
# Verify missing_episodes is a dictionary with string keys
|
||||||
|
assert isinstance(series_data["missing_episodes"], dict)
|
||||||
|
for season_key, episodes in series_data["missing_episodes"].items():
|
||||||
|
assert isinstance(season_key, str), "Season keys must be strings for JSON"
|
||||||
|
assert isinstance(episodes, list), "Episodes must be a list"
|
||||||
|
|
||||||
|
# Calculate total episodes (what JavaScript does)
|
||||||
|
total_episodes = sum(len(eps) for eps in series_data["missing_episodes"].values())
|
||||||
|
assert total_episodes == 5 # 3 + 2
|
||||||
|
|
||||||
|
# Verify has_missing reflects episode count
|
||||||
|
assert series_data["has_missing"] is True
|
||||||
|
assert total_episodes > 0
|
||||||
|
|
||||||
|
async def test_series_card_elements_for_missing_episodes(self):
|
||||||
|
"""Test that series card HTML elements are correct for series with missing episodes."""
|
||||||
|
# This test validates the expected HTML structure
|
||||||
|
# The actual rendering happens in JavaScript, so we test the data flow
|
||||||
|
|
||||||
|
# Expected WebSocket data format
|
||||||
|
websocket_data = {
|
||||||
|
"key": "so-im-a-spider-so-what",
|
||||||
|
"name": "So I'm a Spider, So What?",
|
||||||
|
"folder": "So I'm a Spider, So What",
|
||||||
|
"site": "aniworld.to",
|
||||||
|
"missing_episodes": {"1": list(range(1, 25))}, # Season 1: episodes 1-24
|
||||||
|
"has_missing": True,
|
||||||
|
"has_nfo": True,
|
||||||
|
"nfo_created_at": "2026-01-23T20:14:31.565228",
|
||||||
|
"nfo_updated_at": None,
|
||||||
|
"tmdb_id": None,
|
||||||
|
"tvdb_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Expected HTML elements that should be generated:
|
||||||
|
# 1. Series card should have class "has-missing" (not "complete")
|
||||||
|
# 2. Checkbox should be enabled (not disabled)
|
||||||
|
# 3. Status text should show "24 missing episodes" (not "Complete")
|
||||||
|
# 4. Status icon should be fa-exclamation-triangle (not fa-check)
|
||||||
|
# 5. NFO badge should have class "nfo-exists" (since has_nfo is True)
|
||||||
|
|
||||||
|
# Calculate expected episode count
|
||||||
|
total_episodes = sum(len(eps) for eps in websocket_data["missing_episodes"].values())
|
||||||
|
assert total_episodes == 24, "Should count 24 missing episodes"
|
||||||
|
|
||||||
|
# Expected HTML patterns that should be generated by JavaScript
|
||||||
|
expected_patterns = {
|
||||||
|
"card_class": "has-missing", # Not "complete"
|
||||||
|
"checkbox_disabled": False, # Should be enabled
|
||||||
|
"status_text": f"{total_episodes} missing episodes", # Not "Complete"
|
||||||
|
"status_icon": "fa-exclamation-triangle", # Not "fa-check"
|
||||||
|
"nfo_badge_class": "nfo-exists", # Since has_nfo is True
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify data structure for JavaScript processing
|
||||||
|
assert websocket_data["has_missing"] is True
|
||||||
|
assert isinstance(websocket_data["missing_episodes"], dict)
|
||||||
|
assert len(websocket_data["missing_episodes"]) > 0
|
||||||
|
assert websocket_data["has_nfo"] is True
|
||||||
|
|
||||||
|
# The JavaScript should:
|
||||||
|
# 1. Count total episodes: Object.values(episodeDict).reduce(...)
|
||||||
|
# 2. Set hasMissingEpisodes = totalMissing > 0 (should be True)
|
||||||
|
# 3. Add "has-missing" class to card
|
||||||
|
# 4. Enable checkbox (canBeSelected = hasMissingEpisodes)
|
||||||
|
# 5. Show episode count instead of "Complete"
|
||||||
|
|
||||||
|
return expected_patterns
|
||||||
|
|
||||||
|
async def test_series_card_elements_for_complete_series(self):
|
||||||
|
"""Test that series card HTML elements are correct for complete series."""
|
||||||
|
# Expected WebSocket data format for complete series
|
||||||
|
websocket_data = {
|
||||||
|
"key": "complete-anime",
|
||||||
|
"name": "Complete Anime",
|
||||||
|
"folder": "Complete Anime (2024)",
|
||||||
|
"site": "aniworld.to",
|
||||||
|
"missing_episodes": {}, # No missing episodes
|
||||||
|
"has_missing": False,
|
||||||
|
"has_nfo": True,
|
||||||
|
"nfo_created_at": "2026-01-23T20:14:31.565228",
|
||||||
|
"nfo_updated_at": None,
|
||||||
|
"tmdb_id": "12345",
|
||||||
|
"tvdb_id": "67890",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Expected HTML elements for complete series:
|
||||||
|
# 1. Series card should have class "complete" (not "has-missing")
|
||||||
|
# 2. Checkbox should be disabled
|
||||||
|
# 3. Status text should show "Complete" (not episode count)
|
||||||
|
# 4. Status icon should be fa-check (not fa-exclamation-triangle)
|
||||||
|
# 5. Status icon should have class "status-complete"
|
||||||
|
|
||||||
|
# Calculate expected episode count
|
||||||
|
total_episodes = sum(len(eps) for eps in websocket_data["missing_episodes"].values())
|
||||||
|
assert total_episodes == 0, "Should have no missing episodes"
|
||||||
|
|
||||||
|
# Expected HTML patterns
|
||||||
|
expected_patterns = {
|
||||||
|
"card_class": "complete", # Not "has-missing"
|
||||||
|
"checkbox_disabled": True, # Should be disabled
|
||||||
|
"status_text": "Complete", # Not episode count
|
||||||
|
"status_icon": "fa-check", # Not "fa-exclamation-triangle"
|
||||||
|
"status_icon_class": "status-complete",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify data structure
|
||||||
|
assert websocket_data["has_missing"] is False
|
||||||
|
assert isinstance(websocket_data["missing_episodes"], dict)
|
||||||
|
assert len(websocket_data["missing_episodes"]) == 0
|
||||||
|
|
||||||
|
return expected_patterns
|
||||||
|
|
||||||
|
async def test_javascript_episode_counting_logic(self):
|
||||||
|
"""Test the logic for counting episodes from dictionary structure."""
|
||||||
|
# Test various episode dictionary structures
|
||||||
|
|
||||||
|
test_cases = [
|
||||||
|
{
|
||||||
|
"name": "Single season with multiple episodes",
|
||||||
|
"episode_dict": {"1": [1, 2, 3, 4, 5]},
|
||||||
|
"expected_count": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Multiple seasons",
|
||||||
|
"episode_dict": {"1": [1, 2, 3], "2": [1, 2], "3": [1]},
|
||||||
|
"expected_count": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Season with large episode count",
|
||||||
|
"episode_dict": {"1": list(range(1, 25))}, # 24 episodes
|
||||||
|
"expected_count": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Empty dictionary",
|
||||||
|
"episode_dict": {},
|
||||||
|
"expected_count": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Season with empty array",
|
||||||
|
"episode_dict": {"1": []},
|
||||||
|
"expected_count": 0
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for test_case in test_cases:
|
||||||
|
episode_dict = test_case["episode_dict"]
|
||||||
|
expected = test_case["expected_count"]
|
||||||
|
|
||||||
|
# Simulate JavaScript logic:
|
||||||
|
# Object.values(episodeDict).reduce((sum, episodes) => sum + episodes.length, 0)
|
||||||
|
actual = sum(len(eps) for eps in episode_dict.values())
|
||||||
|
|
||||||
|
assert actual == expected, (
|
||||||
|
f"Failed for {test_case['name']}: "
|
||||||
|
f"expected {expected}, got {actual}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_websocket_payload_structure(self):
|
||||||
|
"""Test the complete WebSocket payload structure sent by backend."""
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
|
# This is the structure sent by _broadcast_series_updated
|
||||||
|
websocket_payload = {
|
||||||
|
"type": "series_updated",
|
||||||
|
"key": "test-series",
|
||||||
|
"data": {
|
||||||
|
"key": "test-series",
|
||||||
|
"name": "Test Series",
|
||||||
|
"folder": "Test Series (2024)",
|
||||||
|
"site": "aniworld.to",
|
||||||
|
"missing_episodes": {"1": [1, 2, 3]},
|
||||||
|
"has_missing": True,
|
||||||
|
"has_nfo": True,
|
||||||
|
"nfo_created_at": "2026-01-23T20:14:31.565228",
|
||||||
|
"nfo_updated_at": None,
|
||||||
|
"tmdb_id": "12345",
|
||||||
|
"tvdb_id": "67890",
|
||||||
|
},
|
||||||
|
"message": "Series episodes updated",
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify top-level structure
|
||||||
|
assert "type" in websocket_payload
|
||||||
|
assert "key" in websocket_payload
|
||||||
|
assert "data" in websocket_payload
|
||||||
|
assert "message" in websocket_payload
|
||||||
|
assert "timestamp" in websocket_payload
|
||||||
|
|
||||||
|
# Verify event type
|
||||||
|
assert websocket_payload["type"] == "series_updated"
|
||||||
|
|
||||||
|
# Verify data structure
|
||||||
|
data = websocket_payload["data"]
|
||||||
|
required_fields = [
|
||||||
|
"key", "name", "folder", "site",
|
||||||
|
"missing_episodes", "has_missing",
|
||||||
|
"has_nfo", "nfo_created_at", "nfo_updated_at",
|
||||||
|
"tmdb_id", "tvdb_id"
|
||||||
|
]
|
||||||
|
|
||||||
|
for field in required_fields:
|
||||||
|
assert field in data, f"Missing required field: {field}"
|
||||||
|
|
||||||
|
# Verify data types
|
||||||
|
assert isinstance(data["key"], str)
|
||||||
|
assert isinstance(data["name"], str)
|
||||||
|
assert isinstance(data["folder"], str)
|
||||||
|
assert isinstance(data["site"], str)
|
||||||
|
assert isinstance(data["missing_episodes"], dict)
|
||||||
|
assert isinstance(data["has_missing"], bool)
|
||||||
|
assert isinstance(data["has_nfo"], bool)
|
||||||
|
|
||||||
|
# Verify missing_episodes structure
|
||||||
|
for season, episodes in data["missing_episodes"].items():
|
||||||
|
assert isinstance(season, str), "Season keys should be strings"
|
||||||
|
assert isinstance(episodes, list), "Episode values should be lists"
|
||||||
|
for ep in episodes:
|
||||||
|
assert isinstance(ep, int), "Episodes should be integers"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
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