""" 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)