"""Integration tests for series identifier consistency. This module verifies that the 'key' identifier is used consistently across all layers of the application (API, services, database, WebSocket). The identifier standardization ensures: - 'key' is the primary identifier (provider-assigned, URL-safe) - 'folder' is metadata only (not used for lookups) - Consistent identifier usage throughout the codebase """ import asyncio from datetime import datetime, timezone from typing import Any, Dict, List from unittest.mock import AsyncMock, Mock import pytest from httpx import ASGITransport, AsyncClient from src.server.fastapi_app import app from src.server.models.download import ( DownloadItem, DownloadPriority, DownloadStatus, EpisodeIdentifier, ) from src.server.services.anime_service import AnimeService from src.server.services.auth_service import auth_service from src.server.services.download_service import DownloadService from src.server.services.progress_service import ProgressService @pytest.fixture(autouse=True) def reset_auth(): """Reset authentication state before each test.""" original_hash = auth_service._hash auth_service._hash = None auth_service._failed.clear() yield auth_service._hash = original_hash auth_service._failed.clear() @pytest.fixture async def client(): """Create an 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 an authenticated test client with token.""" # Setup master password await client.post( "/api/auth/setup", json={"master_password": "TestPassword123!"} ) # Login to get token response = await client.post( "/api/auth/login", json={"password": "TestPassword123!"} ) token = response.json()["access_token"] # Add token to default headers client.headers.update({"Authorization": f"Bearer {token}"}) yield client @pytest.fixture def mock_series_app(): """Mock SeriesApp for testing.""" app_mock = Mock() app_mock.series_list = [] app_mock.search = Mock(return_value=[]) app_mock.ReScan = Mock() app_mock.download = Mock(return_value=True) return app_mock @pytest.fixture def progress_service(): """Create a ProgressService instance for testing.""" return ProgressService() @pytest.fixture async def download_service(mock_series_app, progress_service, tmp_path): """Create a DownloadService with dependencies.""" import uuid persistence_path = tmp_path / f"test_queue_{uuid.uuid4()}.json" anime_service = AnimeService( series_app=mock_series_app, progress_service=progress_service, ) anime_service.download = AsyncMock(return_value=True) service = DownloadService( anime_service=anime_service, progress_service=progress_service, persistence_path=str(persistence_path), ) yield service await service.stop() class TestAPIIdentifierConsistency: """Test that API endpoints use 'key' as the primary identifier.""" @pytest.mark.asyncio async def test_queue_add_returns_key_in_response( self, authenticated_client ): """Test that adding to queue uses key as identifier. Verifies: - Request accepts serie_id (key) as primary identifier - serie_folder is accepted as metadata - Response reflects correct identifiers """ request_data = { "serie_id": "attack-on-titan", # Key (primary identifier) "serie_folder": "Attack on Titan (2013)", # Metadata only "serie_name": "Attack on Titan", "episodes": [{"season": 1, "episode": 1}], "priority": "normal" } response = await authenticated_client.post( "/api/queue/add", json=request_data ) assert response.status_code == 201 data = response.json() # Verify response structure assert data["status"] == "success" assert len(data.get("added_items", [])) > 0 @pytest.mark.asyncio async def test_queue_status_contains_key_identifier( self, authenticated_client ): """Test that queue status returns key as identifier. Verifies: - Queue items have serie_id (key) as identifier - Queue items have serie_folder as metadata - Both fields are present and distinct """ import uuid # Add an item first with unique key unique_suffix = str(uuid.uuid4())[:8] unique_key = f"one-piece-{unique_suffix}" unique_folder = f"One Piece ({unique_suffix})" await authenticated_client.post( "/api/queue/add", json={ "serie_id": unique_key, "serie_folder": unique_folder, "serie_name": "One Piece", "episodes": [{"season": 1, "episode": 1}], "priority": "normal" } ) # Get queue status response = await authenticated_client.get("/api/queue/status") assert response.status_code == 200 data = response.json() # Navigate to pending queue pending = data["status"]["pending_queue"] assert len(pending) > 0 # Find the item we just added by key matching_items = [ item for item in pending if item["serie_id"] == unique_key ] assert len(matching_items) == 1, ( f"Expected to find item with key {unique_key}" ) item = matching_items[0] # Verify identifier structure in queue item assert "serie_id" in item, "Queue item must have serie_id (key)" assert "serie_folder" in item, "Queue item must have serie_folder" # Verify key format (lowercase, hyphenated) assert item["serie_id"] == unique_key # Verify folder is preserved as metadata assert item["serie_folder"] == unique_folder # Verify both are present but different assert item["serie_id"] != item["serie_folder"] @pytest.mark.asyncio async def test_key_used_for_lookup_not_folder( self, authenticated_client ): """Test that lookups use key, not folder. Verifies: - Items can be identified by serie_id (key) - Multiple items with same folder but different keys are distinct """ import uuid unique_suffix = str(uuid.uuid4())[:8] # Add two items with different keys but similar folders key1 = f"naruto-original-{unique_suffix}" key2 = f"naruto-shippuden-{unique_suffix}" shared_folder = f"Naruto Series ({unique_suffix})" await authenticated_client.post( "/api/queue/add", json={ "serie_id": key1, "serie_folder": shared_folder, "serie_name": "Naruto", "episodes": [{"season": 1, "episode": 1}], "priority": "normal" } ) await authenticated_client.post( "/api/queue/add", json={ "serie_id": key2, "serie_folder": shared_folder, # Same folder "serie_name": "Naruto Shippuden", "episodes": [{"season": 1, "episode": 1}], "priority": "normal" } ) # Get queue status response = await authenticated_client.get("/api/queue/status") data = response.json() pending = data["status"]["pending_queue"] # Both items should be present (same folder doesn't cause collision) serie_ids = [item["serie_id"] for item in pending] assert key1 in serie_ids assert key2 in serie_ids class TestServiceIdentifierConsistency: """Test that services use 'key' as the primary identifier.""" @pytest.mark.asyncio async def test_download_service_uses_key(self, download_service): """Test that DownloadService uses key as identifier. Verifies: - Items are stored with serie_id (key) - Items can be retrieved by key - Queue operations use key consistently """ # Add item to queue item_ids = await download_service.add_to_queue( serie_id="my-hero-academia", serie_folder="My Hero Academia (2016)", serie_name="My Hero Academia", episodes=[EpisodeIdentifier(season=1, episode=1)], priority=DownloadPriority.NORMAL, ) assert len(item_ids) == 1 # Verify item is stored correctly pending = download_service._pending_queue assert len(pending) == 1 item = pending[0] assert item.serie_id == "my-hero-academia" assert item.serie_folder == "My Hero Academia (2016)" @pytest.mark.asyncio async def test_download_item_normalizes_key(self, download_service): """Test that serie_id is normalized (lowercase, stripped). Verifies: - Key is converted to lowercase - Whitespace is stripped """ # Add item with uppercase key item_ids = await download_service.add_to_queue( serie_id=" DEMON-SLAYER ", serie_folder="Demon Slayer (2019)", serie_name="Demon Slayer", episodes=[EpisodeIdentifier(season=1, episode=1)], priority=DownloadPriority.NORMAL, ) assert len(item_ids) == 1 # Verify key is normalized item = download_service._pending_queue[0] assert item.serie_id == "demon-slayer" @pytest.mark.asyncio async def test_queue_persistence_uses_key( self, download_service, tmp_path ): """Test that persisted queue data uses key as identifier. Verifies: - Persisted data contains serie_id (key) - Data can be restored with correct identifiers """ import json # Add item to queue await download_service.add_to_queue( serie_id="jujutsu-kaisen", serie_folder="Jujutsu Kaisen (2020)", serie_name="Jujutsu Kaisen", episodes=[EpisodeIdentifier(season=1, episode=1)], priority=DownloadPriority.NORMAL, ) # Read persisted data persistence_path = download_service._persistence_path with open(persistence_path, "r") as f: data = json.load(f) # Verify persisted data structure assert "pending" in data assert len(data["pending"]) == 1 persisted_item = data["pending"][0] assert persisted_item["serie_id"] == "jujutsu-kaisen" assert persisted_item["serie_folder"] == "Jujutsu Kaisen (2020)" class TestWebSocketIdentifierConsistency: """Test that WebSocket events use 'key' in their payloads.""" @pytest.mark.asyncio async def test_progress_events_include_key( self, download_service, progress_service ): """Test that progress events include key identifier. Verifies: - Progress events contain key information - Events use consistent identifier structure """ broadcasts: List[Dict[str, Any]] = [] async def mock_event_handler(event): broadcasts.append({ "type": event.event_type, "data": event.progress.to_dict(), "room": event.room, }) progress_service.subscribe("progress_updated", mock_event_handler) # Add item to trigger events await download_service.add_to_queue( serie_id="spy-x-family", serie_folder="Spy x Family (2022)", serie_name="Spy x Family", episodes=[EpisodeIdentifier(season=1, episode=1)], priority=DownloadPriority.NORMAL, ) # Verify events were emitted assert len(broadcasts) >= 1 # Check queue progress events for metadata queue_events = [ b for b in broadcasts if b["type"] == "queue_progress" ] # Verify metadata structure includes identifier info for event in queue_events: metadata = event["data"].get("metadata", {}) # Queue events should track items by their identifiers if "added_ids" in metadata: assert len(metadata["added_ids"]) > 0 class TestIdentifierValidation: """Test identifier validation and edge cases.""" @pytest.mark.asyncio async def test_key_format_validation(self, authenticated_client): """Test that key format is validated correctly. Verifies: - Valid keys are accepted (lowercase, hyphenated) - Keys are normalized on input """ import uuid unique_suffix = str(uuid.uuid4())[:8] # Valid key format response = await authenticated_client.post( "/api/queue/add", json={ "serie_id": f"valid-key-format-{unique_suffix}", "serie_folder": f"Valid Key ({unique_suffix})", "serie_name": "Valid Key", "episodes": [{"season": 1, "episode": 1}], "priority": "normal" } ) assert response.status_code == 201 @pytest.mark.asyncio async def test_folder_not_used_for_identification( self, download_service ): """Test that folder changes don't affect identification. Verifies: - Same key with different folder is same series - Folder is metadata only, not identity """ # Add item await download_service.add_to_queue( serie_id="chainsaw-man", serie_folder="Chainsaw Man (2022)", serie_name="Chainsaw Man", episodes=[EpisodeIdentifier(season=1, episode=1)], priority=DownloadPriority.NORMAL, ) # Add another episode for same key, different folder await download_service.add_to_queue( serie_id="chainsaw-man", serie_folder="Chainsaw Man Updated (2022)", # Different folder serie_name="Chainsaw Man", episodes=[EpisodeIdentifier(season=1, episode=2)], priority=DownloadPriority.NORMAL, ) # Both should be added (same key, different episodes) assert len(download_service._pending_queue) == 2 # Verify both use the same key keys = [item.serie_id for item in download_service._pending_queue] assert all(k == "chainsaw-man" for k in keys) class TestEndToEndIdentifierFlow: """End-to-end tests for identifier consistency across layers.""" @pytest.mark.asyncio async def test_complete_flow_with_key( self, authenticated_client ): """Test complete flow uses key consistently. Verifies: - API -> Service -> Storage uses key - All responses contain correct identifiers """ import uuid # Use unique key to avoid conflicts with other tests unique_suffix = str(uuid.uuid4())[:8] unique_key = f"bleach-tybw-{unique_suffix}" unique_folder = f"Bleach: TYBW ({unique_suffix})" # 1. Add to queue via API add_response = await authenticated_client.post( "/api/queue/add", json={ "serie_id": unique_key, "serie_folder": unique_folder, "serie_name": "Bleach: TYBW", "episodes": [{"season": 1, "episode": 1}], "priority": "high" } ) assert add_response.status_code == 201 # 2. Verify in queue status status_response = await authenticated_client.get("/api/queue/status") assert status_response.status_code == 200 status_data = status_response.json() pending = status_data["status"]["pending_queue"] # Find our item by key items = [ i for i in pending if i["serie_id"] == unique_key ] assert len(items) == 1, ( f"Expected exactly 1 item with key {unique_key}, " f"found {len(items)}" ) item = items[0] # 3. Verify identifier consistency assert item["serie_id"] == unique_key assert item["serie_folder"] == unique_folder assert item["serie_name"] == "Bleach: TYBW" # 4. Verify key and folder are different assert item["serie_id"] != item["serie_folder"] if __name__ == "__main__": pytest.main([__file__, "-v"])