""" 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 AnimeService instance with async rescan method from unittest.mock import AsyncMock mock_anime_service = Mock() mock_anime_service.rescan = AsyncMock() with patch( "src.server.utils.dependencies.get_anime_service" ) as mock_get_service: mock_get_service.return_value = mock_anime_service 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 mock_anime_service.rescan.assert_called_once() 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 (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 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)