- Created QueueRepository adapter in src/server/services/queue_repository.py - Refactored DownloadService to use repository pattern instead of JSON - Updated application startup to initialize download service from database - Updated all test fixtures to use MockQueueRepository - All 1104 tests passing
518 lines
17 KiB
Python
518 lines
17 KiB
Python
"""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 mock repository for testing."""
|
|
from tests.unit.test_download_service import MockQueueRepository
|
|
|
|
mock_repo = MockQueueRepository()
|
|
|
|
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,
|
|
queue_repository=mock_repo,
|
|
)
|
|
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
|
|
"""
|
|
# 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,
|
|
)
|
|
|
|
# Verify item is in pending queue (in-memory cache synced with DB)
|
|
pending_items = list(download_service._pending_queue)
|
|
assert len(pending_items) == 1
|
|
|
|
persisted_item = pending_items[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"])
|