- Fix failing test_authenticated_request_succeeds (dependency override) - Expand test_anime_service.py (+35 tests: status events, DB, broadcasts) - Create test_queue_repository.py (27 tests: CRUD, model conversion) - Expand test_enhanced_provider.py (+24 tests: fetch, download, redirect) - Expand test_serie_scanner.py (+25 tests: events, year extract, mp4 scan) - Create test_database_connection.py (38 tests: sessions, transactions) - Expand test_anime_endpoints.py (+39 tests: status, search, loading) - Clean up docs/instructions.md TODO list
950 lines
33 KiB
Python
950 lines
33 KiB
Python
"""Tests for anime API endpoints."""
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
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.serie_scanner = FakeScanner() # Add fake scanner (matches SeriesApp attribute name)
|
|
self.directory = "/tmp/fake_anime"
|
|
self.keyDict = {} # Add keyDict for direct access
|
|
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)", {}),
|
|
]
|
|
# Populate keyDict
|
|
for item in self._items:
|
|
self.keyDict[item.key] = item
|
|
|
|
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, use_sanitized_folder=True):
|
|
"""Add a serie to the list.
|
|
|
|
Args:
|
|
serie: The Serie instance to add
|
|
use_sanitized_folder: Whether to use sanitized folder name
|
|
|
|
Returns:
|
|
str: The folder path (fake path for testing)
|
|
"""
|
|
# Check if already exists
|
|
if not any(s.key == serie.key for s in self._items):
|
|
self._items.append(serie)
|
|
self.keyDict[serie.key] = serie
|
|
return f"/tmp/fake_anime/{serie.folder}"
|
|
|
|
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
|
|
|
|
|
|
class FakeScanner:
|
|
"""Mock SerieScanner for testing."""
|
|
|
|
def scan_single_series(self, key, folder):
|
|
"""Mock scan that returns some fake missing episodes."""
|
|
return {1: [1, 2, 3], 2: [1, 2]}
|
|
|
|
|
|
@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.services.background_loader_service import (
|
|
get_background_loader_service,
|
|
)
|
|
from src.server.utils.dependencies import get_series_app
|
|
|
|
fake_app = FakeSeriesApp()
|
|
app.dependency_overrides[get_series_app] = lambda: fake_app
|
|
|
|
# Mock background loader service
|
|
mock_background_loader = AsyncMock()
|
|
mock_background_loader.add_series_loading_task = AsyncMock()
|
|
app.dependency_overrides[get_background_loader_service] = lambda: mock_background_loader
|
|
|
|
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_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 with 202 Accepted (async operation)
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
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()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_series_extracts_key_from_full_url(authenticated_client):
|
|
"""Test that add_series extracts key from full URL."""
|
|
response = await authenticated_client.post(
|
|
"/api/anime/add",
|
|
json={
|
|
"link": "https://aniworld.to/anime/stream/attack-on-titan",
|
|
"name": "Attack on Titan"
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
assert data["key"] == "attack-on-titan"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_series_sanitizes_folder_name(authenticated_client):
|
|
"""Test that add_series creates sanitized folder name."""
|
|
response = await authenticated_client.post(
|
|
"/api/anime/add",
|
|
json={
|
|
"link": "https://aniworld.to/anime/stream/rezero",
|
|
"name": "Re:Zero - Starting Life in Another World?"
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
|
|
# Folder should not contain invalid characters
|
|
folder = data["folder"]
|
|
assert ":" not in folder
|
|
assert "?" not in folder
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_series_returns_missing_episodes(authenticated_client):
|
|
"""Test that add_series returns loading progress info."""
|
|
response = await authenticated_client.post(
|
|
"/api/anime/add",
|
|
json={
|
|
"link": "https://aniworld.to/anime/stream/test-anime",
|
|
"name": "Test Anime"
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
|
|
# Response should contain loading_progress fields (async endpoint)
|
|
assert "loading_status" in data
|
|
assert "loading_progress" in data
|
|
assert isinstance(data["loading_progress"], dict)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_series_response_structure(authenticated_client):
|
|
"""Test the full response structure of add_series."""
|
|
response = await authenticated_client.post(
|
|
"/api/anime/add",
|
|
json={
|
|
"link": "https://aniworld.to/anime/stream/new-anime",
|
|
"name": "New Anime Series"
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
|
|
# Verify all expected fields are present
|
|
assert "status" in data
|
|
assert "message" in data
|
|
assert "key" in data
|
|
assert "folder" in data
|
|
assert "loading_status" in data
|
|
assert "loading_progress" in data
|
|
|
|
# Status should be success or exists
|
|
assert data["status"] in ("success", "exists")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_series_special_characters_in_name(authenticated_client):
|
|
"""Test adding series with various special characters in name."""
|
|
test_cases = [
|
|
("86: Eighty-Six", "86-eighty-six"),
|
|
("Fate/Stay Night", "fate-stay-night"),
|
|
("What If...?", "what-if"),
|
|
("Steins;Gate", "steins-gate"),
|
|
]
|
|
|
|
for name, key in test_cases:
|
|
response = await authenticated_client.post(
|
|
"/api/anime/add",
|
|
json={
|
|
"link": f"https://aniworld.to/anime/stream/{key}",
|
|
"name": name
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
|
|
# Get just the folder name (last part of path)
|
|
folder_path = data["folder"]
|
|
# Handle both full paths and just folder names
|
|
if "/" in folder_path:
|
|
folder_name = folder_path.rstrip("/").split("/")[-1]
|
|
else:
|
|
folder_name = folder_path
|
|
|
|
# Folder name should not contain invalid filesystem characters
|
|
invalid_chars = [':', '\\', '?', '*', '<', '>', '|', '"']
|
|
for char in invalid_chars:
|
|
assert char not in folder_name, f"Found '{char}' in folder name for {name}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# New tests: get_anime_status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetAnimeStatusEndpoint:
|
|
"""Tests for GET /api/anime/status."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_unauthorized(self):
|
|
"""Status endpoint should require authentication."""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/anime/status")
|
|
assert response.status_code == 401
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_authenticated(self, authenticated_client):
|
|
"""Authenticated request returns directory and series count."""
|
|
response = await authenticated_client.get("/api/anime/status")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "directory" in data
|
|
assert "series_count" in data
|
|
assert isinstance(data["series_count"], int)
|
|
|
|
def test_status_direct_no_series_app(self):
|
|
"""When series_app is None, returns empty directory and 0 count."""
|
|
result = asyncio.run(anime_module.get_anime_status(series_app=None))
|
|
assert result["directory"] == ""
|
|
assert result["series_count"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# New tests: list_anime authenticated
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestListAnimeAuthenticated:
|
|
"""Tests for GET /api/anime/ with authentication."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_anime_returns_summaries(self, authenticated_client):
|
|
"""Authenticated list returns anime summaries."""
|
|
response = await authenticated_client.get("/api/anime/")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_anime_invalid_page(self, authenticated_client):
|
|
"""Negative page number returns validation error."""
|
|
response = await authenticated_client.get(
|
|
"/api/anime/", params={"page": -1}
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_anime_per_page_too_large(self, authenticated_client):
|
|
"""Per page > 1000 returns validation error."""
|
|
response = await authenticated_client.get(
|
|
"/api/anime/", params={"per_page": 5000}
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_anime_invalid_sort_by(self, authenticated_client):
|
|
"""Invalid sort_by parameter returns validation error."""
|
|
response = await authenticated_client.get(
|
|
"/api/anime/", params={"sort_by": "injection_attempt"}
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_anime_valid_sort_by(self, authenticated_client):
|
|
"""Valid sort_by parameter is accepted."""
|
|
response = await authenticated_client.get(
|
|
"/api/anime/", params={"sort_by": "title"}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_anime_invalid_filter(self, authenticated_client):
|
|
"""Invalid filter value returns validation error."""
|
|
response = await authenticated_client.get(
|
|
"/api/anime/", params={"filter": "hacked"}
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# New tests: get_scan_status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetScanStatusEndpoint:
|
|
"""Tests for GET /api/anime/scan/status."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scan_status_unauthorized(self):
|
|
"""Scan status endpoint should require authentication."""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/anime/scan/status")
|
|
assert response.status_code == 401
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scan_status_authenticated(self, authenticated_client):
|
|
"""Authenticated request returns scan status dict."""
|
|
response = await authenticated_client.get("/api/anime/scan/status")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, dict)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# New tests: _validate_search_query_extended
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateSearchQueryExtended:
|
|
"""Tests for the internal _validate_search_query_extended function."""
|
|
|
|
def test_empty_query_raises(self):
|
|
"""Empty string raises 422."""
|
|
from fastapi import HTTPException
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
anime_module._validate_search_query_extended("")
|
|
assert exc_info.value.status_code == 422
|
|
|
|
def test_whitespace_only_raises(self):
|
|
"""Whitespace-only raises 422."""
|
|
from fastapi import HTTPException
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
anime_module._validate_search_query_extended(" ")
|
|
assert exc_info.value.status_code == 422
|
|
|
|
def test_null_bytes_raise(self):
|
|
"""Null bytes in query raise 400."""
|
|
from fastapi import HTTPException
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
anime_module._validate_search_query_extended("test\x00query")
|
|
assert exc_info.value.status_code == 400
|
|
|
|
def test_too_long_query_raises(self):
|
|
"""Query exceeding 200 chars raises 422."""
|
|
from fastapi import HTTPException
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
anime_module._validate_search_query_extended("a" * 201)
|
|
assert exc_info.value.status_code == 422
|
|
|
|
def test_valid_query_returns_string(self):
|
|
"""Valid query is returned (possibly normalised)."""
|
|
result = anime_module._validate_search_query_extended("Naruto")
|
|
assert isinstance(result, str)
|
|
assert len(result) > 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# New tests: search_anime_post
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSearchAnimePost:
|
|
"""Tests for POST /api/anime/search."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_post_returns_results(self):
|
|
"""POST search with valid query returns results."""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/api/anime/search",
|
|
json={"query": "test"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert isinstance(response.json(), list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_post_empty_query_rejected(self):
|
|
"""POST search with empty query returns 422."""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/api/anime/search",
|
|
json={"query": ""},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# New tests: _perform_search
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPerformSearch:
|
|
"""Tests for the internal _perform_search function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_no_series_app(self):
|
|
"""When series_app is None return empty list."""
|
|
result = await anime_module._perform_search("test", None)
|
|
assert result == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_dict_results(self):
|
|
"""Dict-format results are converted to AnimeSummary."""
|
|
mock_app = AsyncMock()
|
|
mock_app.search = AsyncMock(
|
|
return_value=[
|
|
{
|
|
"key": "k1",
|
|
"title": "Title One",
|
|
"site": "aniworld.to",
|
|
"folder": "f1",
|
|
"link": "https://aniworld.to/anime/stream/k1",
|
|
"missing_episodes": {},
|
|
}
|
|
]
|
|
)
|
|
results = await anime_module._perform_search("query", mock_app)
|
|
assert len(results) == 1
|
|
assert results[0].key == "k1"
|
|
assert results[0].name == "Title One"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_object_results(self):
|
|
"""Object-format results (with attributes) are handled."""
|
|
match = MagicMock(spec=[])
|
|
match.key = "obj-key"
|
|
match.id = ""
|
|
match.title = "Object Title"
|
|
match.name = "Object Title"
|
|
match.site = "aniworld.to"
|
|
match.folder = "Object Folder"
|
|
match.link = ""
|
|
match.url = ""
|
|
match.missing_episodes = {}
|
|
mock_app = AsyncMock()
|
|
mock_app.search = AsyncMock(return_value=[match])
|
|
results = await anime_module._perform_search("query", mock_app)
|
|
assert len(results) == 1
|
|
assert results[0].key == "obj-key"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_key_extracted_from_link(self):
|
|
"""When key is empty, extract from link URL."""
|
|
mock_app = AsyncMock()
|
|
mock_app.search = AsyncMock(
|
|
return_value=[
|
|
{
|
|
"key": "",
|
|
"name": "No Key",
|
|
"site": "",
|
|
"folder": "",
|
|
"link": "https://aniworld.to/anime/stream/extracted-key",
|
|
"missing_episodes": {},
|
|
}
|
|
]
|
|
)
|
|
results = await anime_module._perform_search("q", mock_app)
|
|
assert results[0].key == "extracted-key"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_exception_raises_500(self):
|
|
"""Non-HTTP exception in search raises 500."""
|
|
from fastapi import HTTPException
|
|
|
|
mock_app = AsyncMock()
|
|
mock_app.search = AsyncMock(side_effect=RuntimeError("boom"))
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await anime_module._perform_search("q", mock_app)
|
|
assert exc_info.value.status_code == 500
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# New tests: get_loading_status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetLoadingStatusEndpoint:
|
|
"""Tests for GET /api/anime/{key}/loading-status."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loading_status_unauthorized(self):
|
|
"""Loading status requires authentication."""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.get("/api/anime/some-key/loading-status")
|
|
assert response.status_code == 401
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loading_status_no_db(self, authenticated_client):
|
|
"""Without database session returns 503."""
|
|
response = await authenticated_client.get(
|
|
"/api/anime/some-key/loading-status"
|
|
)
|
|
# get_optional_database_session may return None → 503
|
|
assert response.status_code in (503, 404, 500)
|
|
|
|
def test_loading_status_direct_no_db(self):
|
|
"""Direct call with db=None raises 503."""
|
|
from fastapi import HTTPException
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(anime_module.get_loading_status("key", db=None))
|
|
assert exc_info.value.status_code == 503
|
|
|
|
def test_loading_status_not_found(self):
|
|
"""Direct call with unknown key raises 404."""
|
|
from fastapi import HTTPException
|
|
|
|
mock_db = AsyncMock()
|
|
|
|
async def _run():
|
|
with patch(
|
|
"src.server.database.service.AnimeSeriesService"
|
|
) as mock_svc:
|
|
mock_svc.get_by_key = AsyncMock(return_value=None)
|
|
return await anime_module.get_loading_status(
|
|
"missing-key", db=mock_db
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(_run())
|
|
assert exc_info.value.status_code == 404
|
|
|
|
def test_loading_status_pending(self):
|
|
"""Direct call returns correct pending status payload."""
|
|
mock_db = AsyncMock()
|
|
series_row = MagicMock()
|
|
series_row.key = "test-key"
|
|
series_row.loading_status = "pending"
|
|
series_row.episodes_loaded = False
|
|
series_row.has_nfo = False
|
|
series_row.logo_loaded = False
|
|
series_row.images_loaded = False
|
|
series_row.loading_started_at = None
|
|
series_row.loading_completed_at = None
|
|
series_row.loading_error = None
|
|
|
|
async def _run():
|
|
with patch(
|
|
"src.server.database.service.AnimeSeriesService"
|
|
) as mock_svc:
|
|
mock_svc.get_by_key = AsyncMock(return_value=series_row)
|
|
return await anime_module.get_loading_status(
|
|
"test-key", db=mock_db
|
|
)
|
|
|
|
result = asyncio.run(_run())
|
|
assert result["key"] == "test-key"
|
|
assert result["loading_status"] == "pending"
|
|
assert "Queued" in result["message"]
|
|
assert result["progress"]["episodes"] is False
|
|
|
|
def test_loading_status_completed(self):
|
|
"""Completed status returns correct message."""
|
|
from datetime import datetime
|
|
|
|
mock_db = AsyncMock()
|
|
series_row = MagicMock()
|
|
series_row.key = "done-key"
|
|
series_row.loading_status = "completed"
|
|
series_row.episodes_loaded = True
|
|
series_row.has_nfo = True
|
|
series_row.logo_loaded = True
|
|
series_row.images_loaded = True
|
|
series_row.loading_started_at = datetime(2025, 1, 1)
|
|
series_row.loading_completed_at = datetime(2025, 1, 1, 0, 5)
|
|
series_row.loading_error = None
|
|
|
|
async def _run():
|
|
with patch(
|
|
"src.server.database.service.AnimeSeriesService"
|
|
) as mock_svc:
|
|
mock_svc.get_by_key = AsyncMock(return_value=series_row)
|
|
return await anime_module.get_loading_status(
|
|
"done-key", db=mock_db
|
|
)
|
|
|
|
result = asyncio.run(_run())
|
|
assert result["loading_status"] == "completed"
|
|
assert "successfully" in result["message"]
|
|
assert result["progress"]["episodes"] is True
|
|
assert result["completed_at"] is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# New tests: get_anime detail
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetAnimeDetail:
|
|
"""Tests for GET /api/anime/{anime_id} detail endpoint."""
|
|
|
|
def test_get_anime_by_key(self):
|
|
"""Primary lookup by key returns correct detail."""
|
|
fake = FakeSeriesApp()
|
|
result = asyncio.run(
|
|
anime_module.get_anime("test-show-key", series_app=fake)
|
|
)
|
|
assert result.key == "test-show-key"
|
|
assert result.title == "Test Show"
|
|
|
|
def test_get_anime_by_folder_fallback(self):
|
|
"""Folder-based lookup works as deprecated fallback."""
|
|
fake = FakeSeriesApp()
|
|
result = asyncio.run(
|
|
anime_module.get_anime("Test Show (2023)", series_app=fake)
|
|
)
|
|
assert result.key == "test-show-key"
|
|
|
|
def test_get_anime_not_found(self):
|
|
"""Unknown anime_id raises 404."""
|
|
from fastapi import HTTPException
|
|
|
|
fake = FakeSeriesApp()
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(
|
|
anime_module.get_anime("nonexistent", series_app=fake)
|
|
)
|
|
assert exc_info.value.status_code == 404
|
|
|
|
def test_get_anime_no_series_app(self):
|
|
"""None series_app raises 404."""
|
|
from fastapi import HTTPException
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(
|
|
anime_module.get_anime("any-id", series_app=None)
|
|
)
|
|
assert exc_info.value.status_code == 404
|
|
|
|
def test_get_anime_episodes_formatted(self):
|
|
"""Episode dict is converted to season-episode strings."""
|
|
fake = FakeSeriesApp()
|
|
result = asyncio.run(
|
|
anime_module.get_anime("test-show-key", series_app=fake)
|
|
)
|
|
assert "1-1" in result.episodes
|
|
assert "1-2" in result.episodes
|
|
|
|
def test_get_anime_complete_show_no_episodes(self):
|
|
"""Complete show with empty episodeDict returns empty episodes list."""
|
|
fake = FakeSeriesApp()
|
|
result = asyncio.run(
|
|
anime_module.get_anime("complete-show-key", series_app=fake)
|
|
)
|
|
assert result.episodes == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# New tests: trigger_rescan authenticated
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTriggerRescanAuthenticated:
|
|
"""Tests for POST /api/anime/rescan with authentication."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rescan_authenticated(self, authenticated_client):
|
|
"""Authenticated rescan returns success."""
|
|
from src.server.services.anime_service import AnimeService
|
|
from src.server.utils.dependencies import get_anime_service
|
|
|
|
mock_service = AsyncMock(spec=AnimeService)
|
|
mock_service.rescan = AsyncMock()
|
|
app.dependency_overrides[get_anime_service] = lambda: mock_service
|
|
try:
|
|
response = await authenticated_client.post("/api/anime/rescan")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
mock_service.rescan.assert_called_once()
|
|
finally:
|
|
app.dependency_overrides.pop(get_anime_service, None)
|
|
|
|
def test_rescan_service_error(self):
|
|
"""AnimeServiceError is converted to ServerError."""
|
|
from src.server.services.anime_service import AnimeServiceError
|
|
|
|
mock_service = AsyncMock()
|
|
mock_service.rescan = AsyncMock(
|
|
side_effect=AnimeServiceError("scan failed")
|
|
)
|
|
|
|
from src.server.exceptions import ServerError
|
|
|
|
with pytest.raises(ServerError):
|
|
asyncio.run(
|
|
anime_module.trigger_rescan(anime_service=mock_service)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# New tests: search_anime_get additional
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSearchAnimeGetAdditional:
|
|
"""Additional tests for GET /api/anime/search."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_get_with_query(self):
|
|
"""Search GET with valid query returns list."""
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(
|
|
transport=transport, base_url="http://test"
|
|
) as client:
|
|
response = await client.get(
|
|
"/api/anime/search", params={"query": "naruto"}
|
|
)
|
|
assert response.status_code == 200
|
|
assert isinstance(response.json(), list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_get_null_byte_query(self):
|
|
"""Search GET with null byte in query returns 400."""
|
|
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\x00bad"}
|
|
)
|
|
assert response.status_code == 400
|