- Task 7.1: Update All Test Fixtures to Use Key - Updated FakeSerie/FakeSeriesApp with realistic keys in test_anime_endpoints.py - Updated 6+ fixtures in test_websocket_integration.py - Updated 5 fixtures in test_download_progress_integration.py - Updated 9 fixtures in test_download_progress_websocket.py - Updated 10+ fixtures in test_download_models.py - All fixtures now use URL-safe, lowercase, hyphenated key format - Task 7.2: Add Integration Tests for Identifier Consistency - Created tests/integration/test_identifier_consistency.py with 10 tests - TestAPIIdentifierConsistency: API response validation - TestServiceIdentifierConsistency: Download service key usage - TestWebSocketIdentifierConsistency: WebSocket events - TestIdentifierValidation: Model validation - TestEndToEndIdentifierFlow: Full flow verification - Tests use UUID suffixes for isolation All 1006 tests passing.
276 lines
8.8 KiB
Python
276 lines
8.8 KiB
Python
"""Tests for anime API endpoints."""
|
|
import asyncio
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from src.server.api import anime as anime_module
|
|
from src.server.fastapi_app import app
|
|
from src.server.services.auth_service import auth_service
|
|
|
|
|
|
class FakeSerie:
|
|
"""Mock Serie object for testing.
|
|
|
|
Note on identifiers:
|
|
- key: Provider-assigned URL-safe identifier (e.g., 'attack-on-titan')
|
|
- folder: Filesystem folder name for metadata only (e.g., 'Attack on Titan (2013)')
|
|
|
|
The 'key' is the primary identifier used for all lookups and operations.
|
|
The 'folder' is metadata only, not used for identification.
|
|
"""
|
|
|
|
def __init__(self, key, name, folder, episodeDict=None):
|
|
"""Initialize fake serie.
|
|
|
|
Args:
|
|
key: Provider-assigned URL-safe key (primary identifier)
|
|
name: Display name for the series
|
|
folder: Filesystem folder name (metadata only)
|
|
episodeDict: Dictionary of missing episodes
|
|
"""
|
|
self.key = key
|
|
self.name = name
|
|
self.folder = folder
|
|
self.episodeDict = episodeDict or {}
|
|
self.site = "aniworld.to" # Add site attribute
|
|
|
|
|
|
class FakeSeriesApp:
|
|
"""Mock SeriesApp for testing."""
|
|
|
|
def __init__(self):
|
|
"""Initialize fake series app."""
|
|
self.list = self # Changed from self.List to self.list
|
|
self._items = [
|
|
# Using realistic key values (URL-safe, lowercase, hyphenated)
|
|
FakeSerie("test-show-key", "Test Show", "Test Show (2023)", {1: [1, 2]}),
|
|
FakeSerie("complete-show-key", "Complete Show", "Complete Show (2022)", {}),
|
|
]
|
|
|
|
def GetMissingEpisode(self):
|
|
"""Return series with missing episodes."""
|
|
return [s for s in self._items if s.episodeDict]
|
|
|
|
def GetList(self):
|
|
"""Return all series."""
|
|
return self._items
|
|
|
|
def ReScan(self, callback):
|
|
"""Trigger rescan with callback."""
|
|
callback()
|
|
|
|
def add(self, serie):
|
|
"""Add a serie to the list."""
|
|
# Check if already exists
|
|
if not any(s.key == serie.key for s in self._items):
|
|
self._items.append(serie)
|
|
|
|
async def search(self, query):
|
|
"""Search for series (async)."""
|
|
# Return mock search results
|
|
return [
|
|
{
|
|
"key": "test-result",
|
|
"name": "Test Search Result",
|
|
"site": "aniworld.to",
|
|
"folder": "test-result",
|
|
"link": "https://aniworld.to/anime/test",
|
|
"missing_episodes": {},
|
|
}
|
|
]
|
|
|
|
def refresh_series_list(self):
|
|
"""Refresh series list."""
|
|
pass
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_auth_state():
|
|
"""Reset auth service state before each test."""
|
|
if hasattr(auth_service, '_failed'):
|
|
auth_service._failed.clear()
|
|
yield
|
|
if hasattr(auth_service, '_failed'):
|
|
auth_service._failed.clear()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_series_app_dependency():
|
|
"""Override the series_app dependency with FakeSeriesApp."""
|
|
from src.server.utils.dependencies import get_series_app
|
|
|
|
fake_app = FakeSeriesApp()
|
|
app.dependency_overrides[get_series_app] = lambda: fake_app
|
|
|
|
yield fake_app
|
|
|
|
# Clean up
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
async def authenticated_client():
|
|
"""Create authenticated async client."""
|
|
if not auth_service.is_configured():
|
|
auth_service.setup_master_password("TestPass123!")
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
# Login to get token
|
|
r = await client.post(
|
|
"/api/auth/login", json={"password": "TestPass123!"}
|
|
)
|
|
if r.status_code == 200:
|
|
token = r.json()["access_token"]
|
|
client.headers["Authorization"] = f"Bearer {token}"
|
|
yield client
|
|
|
|
|
|
def test_list_anime_direct_call():
|
|
"""Test list_anime function directly."""
|
|
fake = FakeSeriesApp()
|
|
result = asyncio.run(anime_module.list_anime(series_app=fake))
|
|
assert isinstance(result, list)
|
|
assert any(item.name == "Test Show" for item in result)
|
|
|
|
|
|
def test_get_anime_detail_direct_call():
|
|
"""Test get_anime function directly.
|
|
|
|
Uses the series key (test-show-key) for lookup, not the folder name.
|
|
"""
|
|
fake = FakeSeriesApp()
|
|
# Use the series key (primary identifier) for lookup
|
|
result = asyncio.run(
|
|
anime_module.get_anime("test-show-key", series_app=fake)
|
|
)
|
|
assert result.title == "Test Show"
|
|
assert "1-1" in result.episodes
|
|
|
|
|
|
def test_rescan_direct_call():
|
|
"""Test trigger_rescan function directly."""
|
|
from unittest.mock import AsyncMock
|
|
|
|
from src.server.services.anime_service import AnimeService
|
|
|
|
# Create a mock anime service
|
|
mock_anime_service = AsyncMock(spec=AnimeService)
|
|
mock_anime_service.rescan = AsyncMock()
|
|
|
|
result = asyncio.run(
|
|
anime_module.trigger_rescan(anime_service=mock_anime_service)
|
|
)
|
|
assert result["success"] is True
|
|
mock_anime_service.rescan.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_anime_endpoint_unauthorized():
|
|
"""Test GET /api/anime without authentication.
|
|
|
|
Should return 401 since authentication is required.
|
|
"""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/anime/")
|
|
# Should return 401 since this endpoint requires authentication
|
|
assert response.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rescan_endpoint_unauthorized():
|
|
"""Test POST /api/anime/rescan without authentication."""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post("/api/anime/rescan")
|
|
# Should require auth
|
|
assert response.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_anime_endpoint_unauthorized():
|
|
"""Test GET /api/anime/search without authentication.
|
|
|
|
This endpoint is intentionally public for read-only access.
|
|
"""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(
|
|
transport=transport, base_url="http://test"
|
|
) as client:
|
|
response = await client.get(
|
|
"/api/anime/search", params={"query": "test"}
|
|
)
|
|
# Should return 200 since this is a public endpoint
|
|
assert response.status_code == 200
|
|
assert isinstance(response.json(), list)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_anime_detail_endpoint_unauthorized():
|
|
"""Test GET /api/v1/anime/{id} without authentication."""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/v1/anime/1")
|
|
# Should work or require auth
|
|
assert response.status_code in (200, 401, 404, 503)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_series_endpoint_unauthorized():
|
|
"""Test POST /api/anime/add without authentication."""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/api/anime/add",
|
|
json={"link": "test-link", "name": "Test Anime"}
|
|
)
|
|
# Should require auth
|
|
assert response.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_series_endpoint_authenticated(authenticated_client):
|
|
"""Test POST /api/anime/add with authentication."""
|
|
response = await authenticated_client.post(
|
|
"/api/anime/add",
|
|
json={"link": "test-anime-link", "name": "Test New Anime"}
|
|
)
|
|
|
|
# The endpoint should succeed (returns 200 or may fail if series exists)
|
|
assert response.status_code in (200, 400)
|
|
data = response.json()
|
|
|
|
if response.status_code == 200:
|
|
assert data["status"] == "success"
|
|
assert "Test New Anime" in data["message"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_series_endpoint_empty_name(authenticated_client):
|
|
"""Test POST /api/anime/add with empty name."""
|
|
response = await authenticated_client.post(
|
|
"/api/anime/add",
|
|
json={"link": "test-link", "name": ""}
|
|
)
|
|
|
|
# Should return 400 for empty name
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
assert "name" in data["detail"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_series_endpoint_empty_link(authenticated_client):
|
|
"""Test POST /api/anime/add with empty link."""
|
|
response = await authenticated_client.post(
|
|
"/api/anime/add",
|
|
json={"link": "", "name": "Test Anime"}
|
|
)
|
|
|
|
# Should return 400 for empty link
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
assert "link" in data["detail"].lower()
|