- 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
364 lines
14 KiB
Python
364 lines
14 KiB
Python
"""
|
|
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"])
|