610 lines
22 KiB
Python
610 lines
22 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, Mock, 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."""
|
|
# Mock SeriesApp instance with ReScan method
|
|
mock_series_app = Mock()
|
|
mock_series_app.ReScan = Mock()
|
|
|
|
with patch(
|
|
"src.server.utils.dependencies.get_series_app"
|
|
) as mock_get_app:
|
|
mock_get_app.return_value = mock_series_app
|
|
|
|
response = await authenticated_client.post("/api/anime/rescan")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
|
|
|
|
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_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
|
|
assert "is_running" in data or "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 item_id, or 400 if queue is empty
|
|
assert response.status_code in [200, 400]
|
|
data = response.json()
|
|
if response.status_code == 200:
|
|
assert "item_id" 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
|
|
response = await authenticated_client.post("/api/queue/start")
|
|
assert response.status_code == 200
|
|
|
|
# Test pause
|
|
response = await authenticated_client.post("/api/queue/pause")
|
|
assert response.status_code == 200
|
|
|
|
# Test stop
|
|
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)
|