Expand test coverage: ~188 new tests across 6 critical files

- 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
This commit is contained in:
2026-02-15 17:44:27 +01:00
parent d7ab689fe1
commit e84a220f55
8 changed files with 3254 additions and 115 deletions

View File

@@ -1,6 +1,6 @@
"""Tests for anime API endpoints."""
import asyncio
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
@@ -414,3 +414,536 @@ async def test_add_series_special_characters_in_name(authenticated_client):
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