- Updated DownloadRequest and DownloadItem models with comprehensive docstrings explaining serie_id (key as primary identifier) vs serie_folder (filesystem metadata) - Updated add_to_queue() endpoint docstring to document request parameters - Updated all test files to include required serie_folder field: - tests/api/test_download_endpoints.py - tests/api/test_queue_features.py - tests/frontend/test_existing_ui_integration.py - tests/integration/test_download_flow.py - Updated infrastructure.md with Download Queue request/response models - All 869 tests pass This is part of the Series Identifier Standardization effort (Phase 4.2) to ensure key is used as the primary identifier throughout the codebase.
642 lines
23 KiB
Python
642 lines
23 KiB
Python
"""
|
|
Frontend integration tests for existing UI components.
|
|
|
|
This module tests the integration between the existing JavaScript frontend
|
|
(app.js, websocket_client.js, queue.js) and the FastAPI backend, ensuring:
|
|
- Authentication flow with JWT tokens works correctly
|
|
- WebSocket connections and real-time updates function properly
|
|
- API endpoints respond with expected data formats
|
|
- Frontend JavaScript can interact with backend services
|
|
"""
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from src.server.fastapi_app import app
|
|
from src.server.services.auth_service import auth_service
|
|
from src.server.services.websocket_service import get_websocket_service
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_auth():
|
|
"""Reset authentication state before each test."""
|
|
# Store original state
|
|
original_hash = auth_service._hash
|
|
# Reset to unconfigured state
|
|
auth_service._hash = None
|
|
if hasattr(auth_service, '_failed'):
|
|
auth_service._failed.clear()
|
|
yield
|
|
# Restore original state after test
|
|
auth_service._hash = original_hash
|
|
if hasattr(auth_service, '_failed'):
|
|
auth_service._failed.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
"""Create async test client."""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
yield ac
|
|
|
|
|
|
@pytest.fixture
|
|
async def authenticated_client(client):
|
|
"""Create authenticated test client with JWT token."""
|
|
# Setup anime directory in settings
|
|
import tempfile
|
|
|
|
from src.config.settings import settings
|
|
settings.anime_directory = tempfile.gettempdir()
|
|
|
|
# Reset series app to pick up new directory
|
|
from src.server.utils.dependencies import reset_series_app
|
|
reset_series_app()
|
|
|
|
# Setup master password
|
|
await client.post(
|
|
"/api/auth/setup",
|
|
json={"master_password": "StrongP@ss123"}
|
|
)
|
|
|
|
# Login to get token
|
|
response = await client.post(
|
|
"/api/auth/login",
|
|
json={"password": "StrongP@ss123"}
|
|
)
|
|
|
|
data = response.json()
|
|
token = data["access_token"]
|
|
|
|
# Set authorization header for future requests
|
|
client.headers["Authorization"] = f"Bearer {token}"
|
|
|
|
yield client
|
|
|
|
|
|
class TestFrontendAuthentication:
|
|
"""Test authentication flow as used by frontend JavaScript."""
|
|
|
|
async def test_auth_status_endpoint_not_configured(self, client):
|
|
"""Test /api/auth/status when master password not configured."""
|
|
response = await client.get("/api/auth/status")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["configured"] is False
|
|
assert data["authenticated"] is False
|
|
|
|
async def test_auth_status_configured_not_authenticated(self, client):
|
|
"""Test /api/auth/status when configured but not authenticated."""
|
|
# Setup master password
|
|
await client.post(
|
|
"/api/auth/setup",
|
|
json={"master_password": "StrongP@ss123"}
|
|
)
|
|
|
|
response = await client.get("/api/auth/status")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["configured"] is True
|
|
assert data["authenticated"] is False
|
|
|
|
async def test_auth_status_authenticated(self, authenticated_client):
|
|
"""Test /api/auth/status when authenticated with JWT."""
|
|
response = await authenticated_client.get("/api/auth/status")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["configured"] is True
|
|
assert data["authenticated"] is True
|
|
|
|
async def test_login_returns_jwt_token(self, client):
|
|
"""Test login returns JWT token in format expected by app.js."""
|
|
# Setup
|
|
await client.post(
|
|
"/api/auth/setup",
|
|
json={"master_password": "StrongP@ss123"}
|
|
)
|
|
|
|
# Login
|
|
response = await client.post(
|
|
"/api/auth/login",
|
|
json={"password": "StrongP@ss123"}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "access_token" in data
|
|
assert "token_type" in data
|
|
assert data["token_type"] == "bearer"
|
|
assert isinstance(data["access_token"], str)
|
|
assert len(data["access_token"]) > 0
|
|
|
|
async def test_logout_endpoint(self, authenticated_client):
|
|
"""Test logout endpoint clears authentication."""
|
|
response = await authenticated_client.post("/api/auth/logout")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "ok"
|
|
assert data["message"] == "Logged out successfully"
|
|
|
|
async def test_unauthorized_request_returns_401(self, client):
|
|
"""Test that requests without token return 401."""
|
|
# Setup auth
|
|
await client.post(
|
|
"/api/auth/setup",
|
|
json={"master_password": "StrongP@ss123"}
|
|
)
|
|
|
|
# Try to access protected endpoint without token
|
|
response = await client.get("/api/anime/")
|
|
|
|
assert response.status_code == 401
|
|
|
|
async def test_authenticated_request_succeeds(self, authenticated_client):
|
|
"""Test that requests with valid token succeed."""
|
|
with patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
|
|
mock_app = AsyncMock()
|
|
mock_list = AsyncMock()
|
|
mock_list.GetMissingEpisode = AsyncMock(return_value=[])
|
|
mock_app.List = mock_list
|
|
mock_get_app.return_value = mock_app
|
|
|
|
response = await authenticated_client.get("/api/anime/")
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
class TestFrontendAnimeAPI:
|
|
"""Test anime API endpoints as used by app.js."""
|
|
|
|
async def test_get_anime_list(self, authenticated_client):
|
|
"""Test GET /api/anime returns anime list in expected format."""
|
|
# This test works with the real SeriesApp which scans /tmp
|
|
# Since /tmp has no anime folders, it returns empty list
|
|
response = await authenticated_client.get("/api/anime/")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, list)
|
|
# The list may be empty if no anime with missing episodes
|
|
|
|
async def test_search_anime(self, authenticated_client):
|
|
"""Test GET /api/anime/search returns search results."""
|
|
# This test actually calls the real aniworld API
|
|
response = await authenticated_client.get(
|
|
"/api/anime/search",
|
|
params={"query": "naruto"}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, list)
|
|
# Search should return results (actual API call)
|
|
if len(data) > 0:
|
|
assert "name" in data[0]
|
|
|
|
async def test_rescan_anime(self, authenticated_client):
|
|
"""Test POST /api/anime/rescan triggers rescan with events."""
|
|
from unittest.mock import MagicMock
|
|
|
|
from src.server.services.progress_service import ProgressService
|
|
from src.server.utils.dependencies import get_anime_service
|
|
|
|
# Mock the underlying SeriesApp
|
|
mock_series_app = MagicMock()
|
|
mock_series_app.directory_to_search = "/tmp/test"
|
|
mock_series_app.series_list = []
|
|
mock_series_app.rescan = AsyncMock()
|
|
mock_series_app.download_status = None
|
|
mock_series_app.scan_status = None
|
|
|
|
# Mock the ProgressService
|
|
mock_progress_service = MagicMock(spec=ProgressService)
|
|
mock_progress_service.start_progress = AsyncMock()
|
|
mock_progress_service.update_progress = AsyncMock()
|
|
mock_progress_service.complete_progress = AsyncMock()
|
|
mock_progress_service.fail_progress = AsyncMock()
|
|
|
|
# Create real AnimeService with mocked dependencies
|
|
from src.server.services.anime_service import AnimeService
|
|
anime_service = AnimeService(
|
|
series_app=mock_series_app,
|
|
progress_service=mock_progress_service,
|
|
)
|
|
|
|
# Override the dependency
|
|
app.dependency_overrides[get_anime_service] = lambda: anime_service
|
|
|
|
try:
|
|
response = await authenticated_client.post("/api/anime/rescan")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
# Verify rescan was called on the underlying SeriesApp
|
|
mock_series_app.rescan.assert_called_once()
|
|
finally:
|
|
# Clean up override
|
|
app.dependency_overrides.pop(get_anime_service, None)
|
|
|
|
|
|
class TestFrontendDownloadAPI:
|
|
"""Test download API endpoints as used by app.js and queue.js."""
|
|
|
|
async def test_add_to_download_queue(self, authenticated_client):
|
|
"""Test adding episodes to download queue."""
|
|
response = await authenticated_client.post(
|
|
"/api/queue/add",
|
|
json={
|
|
"serie_id": "test-anime",
|
|
"serie_folder": "Test Anime (2024)",
|
|
"serie_name": "Test Anime",
|
|
"episodes": [{"season": 1, "episode": 1}],
|
|
"priority": "normal"
|
|
}
|
|
)
|
|
|
|
# Should return 201 for successful creation
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert "added_items" in data
|
|
|
|
async def test_get_queue_status(self, authenticated_client):
|
|
"""Test GET /api/queue/status returns queue status."""
|
|
response = await authenticated_client.get("/api/queue/status")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
# Check for expected response structure (nested status)
|
|
assert "status" in data
|
|
assert "statistics" in data
|
|
|
|
async def test_start_download_queue(self, authenticated_client):
|
|
"""Test POST /api/queue/start starts next download."""
|
|
response = await authenticated_client.post("/api/queue/start")
|
|
|
|
# Should return 200 with success message, or 400 if queue is empty
|
|
assert response.status_code in [200, 400]
|
|
data = response.json()
|
|
if response.status_code == 200:
|
|
assert "message" in data or "status" in data
|
|
|
|
async def test_stop_download_queue(self, authenticated_client):
|
|
"""Test POST /api/queue/stop stops processing new downloads."""
|
|
response = await authenticated_client.post("/api/queue/stop")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "message" in data or "status" in data
|
|
|
|
|
|
class TestFrontendWebSocketIntegration:
|
|
"""Test WebSocket integration as used by websocket_client.js."""
|
|
|
|
async def test_websocket_connection(self, authenticated_client):
|
|
"""Test WebSocket connection establishment using mock."""
|
|
# Create a mock WebSocket
|
|
mock_ws = AsyncMock()
|
|
mock_ws.accept = AsyncMock()
|
|
|
|
ws_service = get_websocket_service()
|
|
connection_id = "test-frontend-conn"
|
|
|
|
# Test connection flow
|
|
await ws_service.manager.connect(mock_ws, connection_id)
|
|
|
|
# Verify connection was established
|
|
mock_ws.accept.assert_called_once()
|
|
count = await ws_service.manager.get_connection_count()
|
|
assert count >= 1
|
|
|
|
# Cleanup
|
|
await ws_service.manager.disconnect(connection_id)
|
|
|
|
async def test_websocket_receives_queue_updates(self, authenticated_client):
|
|
"""Test WebSocket receives queue status updates."""
|
|
# Create a mock WebSocket
|
|
mock_ws = AsyncMock()
|
|
mock_ws.accept = AsyncMock()
|
|
mock_ws.send_json = AsyncMock()
|
|
|
|
ws_service = get_websocket_service()
|
|
connection_id = "test-queue-update"
|
|
|
|
# Connect the mock WebSocket and join the downloads room
|
|
await ws_service.manager.connect(mock_ws, connection_id)
|
|
await ws_service.manager.join_room(connection_id, "downloads")
|
|
|
|
# Simulate queue update broadcast using service method
|
|
ws_service = get_websocket_service()
|
|
await ws_service.broadcast_queue_status({
|
|
"action": "items_added",
|
|
"total_items": 1,
|
|
"added_ids": ["item_123"]
|
|
})
|
|
|
|
# Verify the broadcast was sent
|
|
assert mock_ws.send_json.called
|
|
|
|
# Cleanup
|
|
await ws_service.manager.disconnect(connection_id)
|
|
|
|
async def test_websocket_receives_download_progress(
|
|
self, authenticated_client
|
|
):
|
|
"""Test WebSocket receives download progress updates."""
|
|
# Create a mock WebSocket
|
|
mock_ws = AsyncMock()
|
|
mock_ws.accept = AsyncMock()
|
|
mock_ws.send_json = AsyncMock()
|
|
|
|
ws_service = get_websocket_service()
|
|
connection_id = "test-download-progress"
|
|
|
|
# Connect the mock WebSocket and join the downloads room
|
|
await ws_service.manager.connect(mock_ws, connection_id)
|
|
await ws_service.manager.join_room(connection_id, "downloads")
|
|
|
|
# Simulate progress update using service method
|
|
progress_data = {
|
|
"serie_name": "Test Anime",
|
|
"episode": {"season": 1, "episode": 1},
|
|
"progress": 0.5,
|
|
"speed": "2.5 MB/s",
|
|
"eta": "00:02:30"
|
|
}
|
|
|
|
ws_service = get_websocket_service()
|
|
await ws_service.broadcast_download_progress(
|
|
"item_123", progress_data
|
|
)
|
|
|
|
# Verify the broadcast was sent
|
|
assert mock_ws.send_json.called
|
|
|
|
# Cleanup
|
|
await ws_service.manager.disconnect(connection_id)
|
|
|
|
|
|
class TestFrontendConfigAPI:
|
|
"""Test configuration API endpoints as used by app.js."""
|
|
|
|
async def test_get_config(self, authenticated_client):
|
|
"""Test GET /api/config returns configuration."""
|
|
response = await authenticated_client.get("/api/config")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
# Check for actual config fields returned by the API
|
|
assert isinstance(data, dict)
|
|
assert len(data) > 0 # Config should have some fields
|
|
|
|
async def test_update_config(self, authenticated_client):
|
|
"""Test POST /api/config updates configuration."""
|
|
# Check what method is actually supported - might be PUT or PATCH
|
|
response = await authenticated_client.put(
|
|
"/api/config",
|
|
json={"name": "Test Config"}
|
|
)
|
|
|
|
# Should accept the request or return method not allowed
|
|
assert response.status_code in [200, 400, 405]
|
|
|
|
|
|
class TestFrontendJavaScriptIntegration:
|
|
"""Test JavaScript functionality integration with backend."""
|
|
|
|
async def test_makeAuthenticatedRequest_bearer_token(
|
|
self, authenticated_client
|
|
):
|
|
"""Test frontend's makeAuthenticatedRequest pattern works."""
|
|
# Simulate what app.js does: include Bearer token header
|
|
token = authenticated_client.headers.get(
|
|
"Authorization", ""
|
|
).replace("Bearer ", "")
|
|
|
|
response = await authenticated_client.get(
|
|
"/api/anime/",
|
|
headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
|
|
# Should work with properly formatted token
|
|
assert response.status_code == 200
|
|
|
|
async def test_frontend_handles_401_gracefully(self, client):
|
|
"""Test that 401 responses can be detected by frontend."""
|
|
# Setup auth
|
|
await client.post(
|
|
"/api/auth/setup",
|
|
json={"master_password": "StrongP@ss123"}
|
|
)
|
|
|
|
# Try accessing protected endpoint without token
|
|
response = await client.get("/api/anime/")
|
|
|
|
assert response.status_code == 401
|
|
# Frontend JavaScript checks for 401 and redirects to login
|
|
|
|
async def test_queue_operations_compatibility(self, authenticated_client):
|
|
"""Test queue operations match queue.js expectations."""
|
|
# Test start - should return 400 when queue is empty (valid behavior)
|
|
response = await authenticated_client.post("/api/queue/start")
|
|
assert response.status_code in [200, 400]
|
|
if response.status_code == 400:
|
|
# Verify error message indicates empty queue
|
|
assert "No pending downloads" in response.json()["detail"]
|
|
|
|
# Test pause - always succeeds even if nothing is processing
|
|
response = await authenticated_client.post("/api/queue/pause")
|
|
assert response.status_code == 200
|
|
|
|
# Test stop - always succeeds even if nothing is processing
|
|
response = await authenticated_client.post("/api/queue/stop")
|
|
assert response.status_code == 200
|
|
|
|
|
|
class TestFrontendErrorHandling:
|
|
"""Test error handling as expected by frontend JavaScript."""
|
|
|
|
async def test_api_error_returns_json(self, authenticated_client):
|
|
"""Test that API errors return JSON format expected by frontend."""
|
|
# Test with a non-existent endpoint
|
|
response = await authenticated_client.get(
|
|
"/api/nonexistent"
|
|
)
|
|
|
|
# Should return error response (404 or other error code)
|
|
assert response.status_code >= 400
|
|
|
|
async def test_validation_error_returns_400(self, authenticated_client):
|
|
"""Test that validation errors return 400/422 with details."""
|
|
# Send invalid data to queue/add endpoint
|
|
response = await authenticated_client.post(
|
|
"/api/queue/add",
|
|
json={} # Empty request should fail validation
|
|
)
|
|
|
|
# Should return validation error
|
|
assert response.status_code in [400, 422]
|
|
|
|
|
|
class TestFrontendRealTimeUpdates:
|
|
"""Test real-time update scenarios as used by frontend."""
|
|
|
|
async def test_download_started_notification(self, authenticated_client):
|
|
"""Test that download_started events are broadcasted."""
|
|
# Create mock WebSocket
|
|
mock_ws = AsyncMock()
|
|
mock_ws.accept = AsyncMock()
|
|
mock_ws.send_json = AsyncMock()
|
|
|
|
ws_service = get_websocket_service()
|
|
connection_id = "test-download-started"
|
|
|
|
# Connect the mock WebSocket
|
|
await ws_service.manager.connect(mock_ws, connection_id)
|
|
|
|
# Simulate download started broadcast using system message
|
|
await ws_service.broadcast_system_message("download_started", {
|
|
"item_id": "item_123",
|
|
"serie_name": "Test Anime"
|
|
})
|
|
|
|
# Verify broadcast was sent
|
|
assert mock_ws.send_json.called
|
|
|
|
# Cleanup
|
|
await ws_service.manager.disconnect(connection_id)
|
|
|
|
async def test_download_completed_notification(self, authenticated_client):
|
|
"""Test that download_completed events are broadcasted."""
|
|
# Create mock WebSocket
|
|
mock_ws = AsyncMock()
|
|
mock_ws.accept = AsyncMock()
|
|
mock_ws.send_json = AsyncMock()
|
|
|
|
ws_service = get_websocket_service()
|
|
connection_id = "test-download-completed"
|
|
|
|
# Connect the mock WebSocket and join the downloads room
|
|
await ws_service.manager.connect(mock_ws, connection_id)
|
|
await ws_service.manager.join_room(connection_id, "downloads")
|
|
|
|
# Simulate download completed broadcast
|
|
await ws_service.broadcast_download_complete("item_123", {
|
|
"serie_name": "Test Anime",
|
|
"episode": {"season": 1, "episode": 1}
|
|
})
|
|
|
|
# Verify broadcast was sent
|
|
assert mock_ws.send_json.called
|
|
|
|
# Cleanup
|
|
await ws_service.manager.disconnect(connection_id)
|
|
|
|
async def test_multiple_clients_receive_broadcasts(
|
|
self, authenticated_client
|
|
):
|
|
"""Test that multiple WebSocket clients receive broadcasts."""
|
|
# Create two mock WebSockets
|
|
mock_ws1 = AsyncMock()
|
|
mock_ws1.accept = AsyncMock()
|
|
mock_ws1.send_json = AsyncMock()
|
|
|
|
mock_ws2 = AsyncMock()
|
|
mock_ws2.accept = AsyncMock()
|
|
mock_ws2.send_json = AsyncMock()
|
|
|
|
ws_service = get_websocket_service()
|
|
|
|
# Connect both mock WebSockets
|
|
await ws_service.manager.connect(mock_ws1, "test-client-1")
|
|
await ws_service.manager.connect(mock_ws2, "test-client-2")
|
|
|
|
# Broadcast to all using system message
|
|
await ws_service.broadcast_system_message(
|
|
"test_event", {"message": "hello"}
|
|
)
|
|
|
|
# Both should have received it
|
|
assert mock_ws1.send_json.called
|
|
assert mock_ws2.send_json.called
|
|
|
|
# Cleanup
|
|
await ws_service.manager.disconnect("test-client-1")
|
|
await ws_service.manager.disconnect("test-client-2")
|
|
|
|
|
|
class TestFrontendDataFormats:
|
|
"""Test that backend returns data in formats expected by frontend."""
|
|
|
|
async def test_anime_list_format(self, authenticated_client):
|
|
"""Test anime list has required fields for frontend rendering."""
|
|
# Get the actual anime list from the service (follow redirects)
|
|
response = await authenticated_client.get(
|
|
"/api/anime", follow_redirects=True
|
|
)
|
|
|
|
# Should return successfully
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Should be a list
|
|
assert isinstance(data, list)
|
|
|
|
# If there are anime, check the structure
|
|
if data:
|
|
anime = data[0]
|
|
# Frontend expects these fields
|
|
assert "name" in anime or "title" in anime
|
|
|
|
async def test_queue_status_format(self, authenticated_client):
|
|
"""Test queue status has required fields for queue.js."""
|
|
# Use the correct endpoint path (follow redirects)
|
|
response = await authenticated_client.get(
|
|
"/api/queue/status", follow_redirects=True
|
|
)
|
|
|
|
# Should return successfully
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Frontend expects these fields for queue status
|
|
assert "items" in data or "queue" in data or "status" in data
|
|
# Status endpoint should return a valid response structure
|
|
assert isinstance(data, dict)
|
|
|
|
async def test_websocket_message_format(self, authenticated_client):
|
|
"""Test WebSocket messages match websocket_client.js expectations."""
|
|
# Create mock WebSocket
|
|
mock_ws = AsyncMock()
|
|
mock_ws.accept = AsyncMock()
|
|
mock_ws.send_json = AsyncMock()
|
|
|
|
ws_service = get_websocket_service()
|
|
connection_id = "test-message-format"
|
|
|
|
# Connect the mock WebSocket
|
|
await ws_service.manager.connect(mock_ws, connection_id)
|
|
|
|
# Broadcast a message
|
|
await ws_service.broadcast_system_message(
|
|
"test_type", {"test_key": "test_value"}
|
|
)
|
|
|
|
# Verify message was sent with correct format
|
|
assert mock_ws.send_json.called
|
|
call_args = mock_ws.send_json.call_args[0][0]
|
|
|
|
# WebSocket client expects type and data fields
|
|
assert "type" in call_args
|
|
assert "data" in call_args
|
|
assert isinstance(call_args["data"], dict)
|
|
|
|
# Cleanup
|
|
await ws_service.manager.disconnect(connection_id)
|