Aniworld/tests/frontend/test_existing_ui_integration.py
2025-10-20 22:23:59 +02:00

621 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, 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/v1/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/v1/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/v1/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/v1/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 POST /api/v1/anime/search returns search results."""
# This test actually calls the real aniworld API
response = await authenticated_client.post(
"/api/v1/anime/search",
json={"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 "title" in data[0]
async def test_rescan_anime(self, authenticated_client):
"""Test POST /api/v1/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/v1/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 "status" in data or "statistics" in data
async def test_start_download_queue(self, authenticated_client):
"""Test POST /api/queue/start starts queue."""
response = await authenticated_client.post("/api/queue/start")
assert response.status_code == 200
data = response.json()
assert "message" in data or "status" in data
async def test_pause_download_queue(self, authenticated_client):
"""Test POST /api/queue/pause pauses queue."""
response = await authenticated_client.post("/api/queue/pause")
assert response.status_code == 200
data = response.json()
assert "message" in data or "status" in data
async def test_stop_download_queue(self, authenticated_client):
"""Test POST /api/queue/stop stops queue."""
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."""
# Get token from authenticated client
token = authenticated_client.headers.get("Authorization", "").replace("Bearer ", "")
async with authenticated_client.websocket_connect(
f"/ws/connect?token={token}"
) as websocket:
# Should receive connection confirmation
message = await websocket.receive_json()
assert message["type"] == "connection"
assert message["data"]["status"] == "connected"
async def test_websocket_receives_queue_updates(self, authenticated_client):
"""Test WebSocket receives queue status updates."""
token = authenticated_client.headers.get(
"Authorization", ""
).replace("Bearer ", "")
async with authenticated_client.websocket_connect(
f"/ws/connect?token={token}"
) as websocket:
# Receive connection message
await websocket.receive_json()
# 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"]
})
# Should receive the broadcast
message = await websocket.receive_json()
assert message["type"] == "queue_status"
assert message["data"]["action"] == "items_added"
async def test_websocket_receives_download_progress(
self, authenticated_client
):
"""Test WebSocket receives download progress updates."""
token = authenticated_client.headers.get(
"Authorization", ""
).replace("Bearer ", "")
async with authenticated_client.websocket_connect(
f"/ws/connect?token={token}"
) as websocket:
# Receive connection message
await websocket.receive_json()
# 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
)
# Should receive progress update
message = await websocket.receive_json()
assert message["type"] == "download_progress"
assert message["data"]["progress"] == 0.5
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()
assert "anime_directory" in data or "config" in data
async def test_update_config(self, authenticated_client):
"""Test POST /api/config updates configuration."""
with patch(
"src.server.api.config.get_config_service"
) as mock_get_service:
mock_service = Mock()
mock_service.update_config = Mock()
mock_get_service.return_value = mock_service
response = await authenticated_client.post(
"/api/config",
json={"anime_directory": "/new/path"}
)
# Should accept the request
assert response.status_code in [200, 400]
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/v1/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/v1/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."""
with patch("src.server.api.anime.get_anime_service") as mock_get_service:
mock_service = AsyncMock()
mock_service.search_series = AsyncMock(
side_effect=Exception("Search failed")
)
mock_get_service.return_value = mock_service
response = await authenticated_client.post(
"/api/v1/anime/search",
json={"query": "test"}
)
# Should return error in JSON format
assert response.headers.get("content-type", "").startswith("application/json")
async def test_validation_error_returns_400(self, authenticated_client):
"""Test that validation errors return 400 with details."""
# Send invalid data
response = await authenticated_client.post(
"/api/download",
json={"invalid": "data"}
)
# Should return 400 or 422 (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."""
token = authenticated_client.headers.get(
"Authorization", ""
).replace("Bearer ", "")
async with authenticated_client.websocket_connect(
f"/ws/connect?token={token}"
) as websocket:
# Clear connection message
await websocket.receive_json()
# Simulate download started broadcast using system message
ws_service = get_websocket_service()
await ws_service.broadcast_system_message("download_started", {
"item_id": "item_123",
"serie_name": "Test Anime"
})
message = await websocket.receive_json()
assert message["type"] == "system_download_started"
async def test_download_completed_notification(self, authenticated_client):
"""Test that download_completed events are broadcasted."""
token = authenticated_client.headers.get(
"Authorization", ""
).replace("Bearer ", "")
async with authenticated_client.websocket_connect(
f"/ws/connect?token={token}"
) as websocket:
# Clear connection message
await websocket.receive_json()
# Simulate download completed broadcast
ws_service = get_websocket_service()
await ws_service.broadcast_download_complete("item_123", {
"serie_name": "Test Anime",
"episode": {"season": 1, "episode": 1}
})
message = await websocket.receive_json()
assert message["type"] == "download_complete"
async def test_multiple_clients_receive_broadcasts(
self, authenticated_client
):
"""Test that multiple WebSocket clients receive broadcasts."""
token = authenticated_client.headers.get(
"Authorization", ""
).replace("Bearer ", "")
# Create two WebSocket connections
async with authenticated_client.websocket_connect(
f"/ws/connect?token={token}"
) as ws1:
async with authenticated_client.websocket_connect(
f"/ws/connect?token={token}"
) as ws2:
# Clear connection messages
await ws1.receive_json()
await ws2.receive_json()
# Broadcast to all using system message
ws_service = get_websocket_service()
await ws_service.broadcast_system_message(
"test_event", {"message": "hello"}
)
# Both should receive it
msg1 = await ws1.receive_json()
msg2 = await ws2.receive_json()
assert msg1["type"] == "system_test_event"
assert msg2["type"] == "system_test_event"
assert msg1["data"]["message"] == "hello"
assert msg2["data"]["message"] == "hello"
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."""
with patch(
"src.server.api.anime.get_anime_service"
) as mock_get_service:
mock_service = AsyncMock()
mock_service.get_all_series = AsyncMock(return_value=[
{
"id": "test_1",
"name": "Test Anime",
"folder": "/path/to/anime",
"missing_episodes": 5,
"total_episodes": 12,
"seasons": [{"season": 1, "episodes": [1, 2, 3]}]
}
])
mock_get_service.return_value = mock_service
response = await authenticated_client.get("/api/v1/anime")
data = response.json()
# Frontend expects these fields
anime = data[0]
assert "id" in anime
assert "name" in anime
assert "missing_episodes" in anime
assert isinstance(anime["missing_episodes"], int)
async def test_queue_status_format(self, authenticated_client):
"""Test queue status has required fields for queue.js."""
with patch(
"src.server.api.download.get_download_service"
) as mock_get_service:
mock_service = AsyncMock()
mock_service.get_queue_status = AsyncMock(return_value={
"total_items": 5,
"pending_items": 3,
"downloading_items": 1,
"is_downloading": True,
"is_paused": False,
"queue": [
{
"id": "item_1",
"serie_name": "Test",
"episode": {"season": 1, "episode": 1},
"status": "pending"
}
]
})
mock_get_service.return_value = mock_service
response = await authenticated_client.get(
"/api/v1/download/queue"
)
data = response.json()
# Frontend expects these fields
assert "total_items" in data
assert "is_downloading" in data
assert "queue" in data
assert isinstance(data["queue"], list)
async def test_websocket_message_format(self, authenticated_client):
"""Test WebSocket messages match websocket_client.js expectations."""
token = authenticated_client.headers.get(
"Authorization", ""
).replace("Bearer ", "")
async with authenticated_client.websocket_connect(
f"/ws/connect?token={token}"
) as websocket:
message = await websocket.receive_json()
# WebSocket client expects type and data fields
assert "type" in message
assert "data" in message
assert isinstance(message["data"], dict)