fix tests

This commit is contained in:
Lukas 2025-10-19 20:49:42 +02:00
parent 4db53c93df
commit e578623999
6 changed files with 434 additions and 54 deletions

349
data/download_queue.json Normal file
View File

@ -0,0 +1,349 @@
{
"pending": [
{
"id": "2f55bb9b-b283-4b6f-89e9-c6792ab7caec",
"serie_id": "workflow-series",
"serie_name": "Workflow Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "high",
"added_at": "2025-10-19T18:45:30.539850",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "3c21028d-b693-48a2-ad74-8e5a6cb64df3",
"serie_id": "series-high",
"serie_name": "Series High",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "high",
"added_at": "2025-10-19T18:45:29.960612",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "8eef16de-4f7e-4d15-9d0a-c06f3a518b97",
"serie_id": "test-series-2",
"serie_name": "Another Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "high",
"added_at": "2025-10-19T18:45:29.927823",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "b8f261a5-37f1-436a-a2c0-f6c117336d1c",
"serie_id": "series-normal",
"serie_name": "Series Normal",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "normal",
"added_at": "2025-10-19T18:45:29.962485",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "d1200f69-f8c5-4f72-8036-9a0d977ffc11",
"serie_id": "series-low",
"serie_name": "Series Low",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "low",
"added_at": "2025-10-19T18:45:29.964184",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "ce975176-a09a-4cf0-9c05-848b41a4782e",
"serie_id": "test-series",
"serie_name": "Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "normal",
"added_at": "2025-10-19T18:45:30.164321",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "8dd8d2da-174b-45e3-a65d-874f6f287647",
"serie_id": "series-0",
"serie_name": "Series 0",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "normal",
"added_at": "2025-10-19T18:45:30.216242",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "e1260703-8b35-45e4-8c92-94a5c73bb55c",
"serie_id": "test-series",
"serie_name": "Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "normal",
"added_at": "2025-10-19T18:45:30.249755",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "49a971a2-9f03-4615-9ceb-4ff59bad091e",
"serie_id": "invalid-series",
"serie_name": "Invalid Series",
"episode": {
"season": 99,
"episode": 99,
"title": null
},
"status": "pending",
"priority": "normal",
"added_at": "2025-10-19T18:45:30.315163",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "968764a6-3c32-45fb-9d25-f8a66d5dd11b",
"serie_id": "test-series",
"serie_name": "Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "normal",
"added_at": "2025-10-19T18:45:30.341633",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "19936ed5-557a-429a-8995-cf4b559ab707",
"serie_id": "series-3",
"serie_name": "Series 3",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "normal",
"added_at": "2025-10-19T18:45:30.397524",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "be83afe7-7ca4-4c7a-b162-0f0546d727b8",
"serie_id": "series-2",
"serie_name": "Series 2",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "normal",
"added_at": "2025-10-19T18:45:30.398322",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "a273d44b-a5d0-48b4-87d5-e6ada165593c",
"serie_id": "series-4",
"serie_name": "Series 4",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "normal",
"added_at": "2025-10-19T18:45:30.398929",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "30498265-d3bf-4a2c-9bc4-912c7ba5eeba",
"serie_id": "series-0",
"serie_name": "Series 0",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "normal",
"added_at": "2025-10-19T18:45:30.399472",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "aaebb8ea-2360-4dd4-a218-86a244f0258f",
"serie_id": "series-1",
"serie_name": "Series 1",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "normal",
"added_at": "2025-10-19T18:45:30.400026",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "45a2d1c1-f28f-4cc4-b767-a44e4de49e52",
"serie_id": "persistent-series",
"serie_name": "Persistent Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "normal",
"added_at": "2025-10-19T18:45:30.463785",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "503d27dd-a691-4451-86f6-0ad191803b64",
"serie_id": "ws-series",
"serie_name": "WebSocket Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "normal",
"added_at": "2025-10-19T18:45:30.514154",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "445173fb-84ff-404b-b0e4-d70a125b0c9f",
"serie_id": "pause-test",
"serie_name": "Pause Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "normal",
"added_at": "2025-10-19T18:45:30.570440",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
}
],
"active": [],
"failed": [],
"timestamp": "2025-10-19T18:45:30.570822"
}

View File

@ -3,7 +3,7 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from src.server.utils.dependencies import get_series_app from src.server.utils.dependencies import get_series_app, require_auth
router = APIRouter(prefix="/api/v1/anime", tags=["anime"]) router = APIRouter(prefix="/api/v1/anime", tags=["anime"])
@ -22,7 +22,10 @@ class AnimeDetail(BaseModel):
@router.get("/", response_model=List[AnimeSummary]) @router.get("/", response_model=List[AnimeSummary])
async def list_anime(series_app=Depends(get_series_app)): async def list_anime(
_auth: dict = Depends(require_auth),
series_app=Depends(get_series_app)
):
"""List series with missing episodes using the core SeriesApp.""" """List series with missing episodes using the core SeriesApp."""
try: try:
series = series_app.List.GetMissingEpisode() series = series_app.List.GetMissingEpisode()

View File

@ -1,7 +1,9 @@
"""Authentication API endpoints for Aniworld."""
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials from fastapi import status as http_status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from src.server.models.auth import AuthStatus, LoginRequest, LoginResponse, SetupRequest from src.server.models.auth import AuthStatus, LoginRequest, LoginResponse, SetupRequest
from src.server.services.auth_service import AuthError, LockedOutError, auth_service from src.server.services.auth_service import AuthError, LockedOutError, auth_service
@ -11,20 +13,23 @@ from src.server.services.auth_service import AuthError, LockedOutError, auth_ser
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
# HTTPBearer for optional authentication
optional_bearer = HTTPBearer(auto_error=False)
@router.post("/setup", status_code=status.HTTP_201_CREATED)
@router.post("/setup", status_code=http_status.HTTP_201_CREATED)
def setup_auth(req: SetupRequest): def setup_auth(req: SetupRequest):
"""Initial setup endpoint to configure the master password.""" """Initial setup endpoint to configure the master password."""
if auth_service.is_configured(): if auth_service.is_configured():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=http_status.HTTP_400_BAD_REQUEST,
detail="Master password already configured", detail="Master password already configured",
) )
try: try:
auth_service.setup_master_password(req.master_password) auth_service.setup_master_password(req.master_password)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e)) from e
return {"status": "ok"} return {"status": "ok"}
@ -32,53 +37,65 @@ def setup_auth(req: SetupRequest):
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
def login(req: LoginRequest): def login(req: LoginRequest):
"""Validate master password and return JWT token.""" """Validate master password and return JWT token."""
# Use a simple identifier for failed attempts; prefer IP in a real app # Use a simple identifier for failed attempts; prefer IP in real app
identifier = "global" identifier = "global"
try: try:
valid = auth_service.validate_master_password(req.password, identifier=identifier) valid = auth_service.validate_master_password(
except AuthError as e: req.password, identifier=identifier
raise HTTPException(status_code=400, detail=str(e)) )
except LockedOutError as e: except LockedOutError as e:
raise HTTPException(status_code=429, detail=str(e)) raise HTTPException(
status_code=http_status.HTTP_429_TOO_MANY_REQUESTS,
detail=str(e),
) from e
except AuthError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
if not valid: if not valid:
raise HTTPException(status_code=401, detail="Invalid credentials") raise HTTPException(status_code=401, detail="Invalid credentials")
token = auth_service.create_access_token(subject="master", remember=bool(req.remember)) token = auth_service.create_access_token(
subject="master", remember=bool(req.remember)
)
return token return token
@router.post("/logout") @router.post("/logout")
def logout(credentials: HTTPAuthorizationCredentials = None): def logout_endpoint(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(optional_bearer),
):
"""Logout by revoking token (no-op for stateless JWT).""" """Logout by revoking token (no-op for stateless JWT)."""
# Import security dependency lazily to avoid heavy imports during test
if credentials is None:
from fastapi import Depends
from src.server.utils.dependencies import security as _security
# Trigger dependency resolution during normal request handling
credentials = Depends(_security)
# If a plain credentials object was provided, extract token # If a plain credentials object was provided, extract token
token = getattr(credentials, "credentials", None) token = getattr(credentials, "credentials", None)
# Placeholder; auth_service.revoke_token can be expanded to persist revocations # Placeholder; auth_service.revoke_token can be expanded to persist
# revocations
if token:
auth_service.revoke_token(token) auth_service.revoke_token(token)
return {"status": "ok"} return {"status": "ok", "message": "Logged out successfully"}
async def get_optional_auth(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(
optional_bearer
),
) -> Optional[dict]:
"""Get optional authentication from bearer token."""
if credentials is None:
return None
token = credentials.credentials
try:
# Validate and decode token using the auth service
session = auth_service.create_session_model(token)
return session.dict()
except AuthError:
return None
@router.get("/status", response_model=AuthStatus) @router.get("/status", response_model=AuthStatus)
def status(auth: Optional[dict] = None): async def auth_status(auth: Optional[dict] = Depends(get_optional_auth)):
"""Return whether master password is configured and if caller is authenticated.""" """Return whether master password is configured and authenticated."""
# Lazy import to avoid pulling in database/sqlalchemy during module import return AuthStatus(
from fastapi import Depends configured=auth_service.is_configured(), authenticated=bool(auth)
try: )
from src.server.utils.dependencies import optional_auth as _optional_auth
except Exception:
_optional_auth = None
# If dependency injection didn't provide auth, attempt to resolve optionally
if auth is None and _optional_auth is not None:
auth = Depends(_optional_auth)
return AuthStatus(configured=auth_service.is_configured(), authenticated=bool(auth))

View File

@ -20,7 +20,8 @@ from src.core.SeriesApp import SeriesApp
from src.server.services.auth_service import AuthError, auth_service from src.server.services.auth_service import AuthError, auth_service
# Security scheme for JWT authentication # Security scheme for JWT authentication
security = HTTPBearer() # Use auto_error=False to handle errors manually and return 401 instead of 403
security = HTTPBearer(auto_error=False)
# Global SeriesApp instance # Global SeriesApp instance
@ -99,7 +100,7 @@ async def get_database_session() -> AsyncGenerator:
def get_current_user( def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security) credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
) -> dict: ) -> dict:
""" """
Dependency to get current authenticated user. Dependency to get current authenticated user.
@ -128,7 +129,7 @@ def get_current_user(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e), detail=str(e),
) ) from e
def require_auth( def require_auth(

View File

@ -180,14 +180,14 @@ async def test_delete_backup(authenticated_client, mock_config_service):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_config_persistence(client, mock_config_service): async def test_config_persistence(authenticated_client, mock_config_service):
"""Test end-to-end configuration persistence.""" """Test end-to-end configuration persistence."""
# Get initial config # Get initial config
resp = await client.get("/api/config") resp = await authenticated_client.get("/api/config")
assert resp.status_code == 200 assert resp.status_code == 200
initial = resp.json() initial = resp.json()
# Validate it can be loaded again # Validate it can be loaded again
resp2 = await client.get("/api/config") resp2 = await authenticated_client.get("/api/config")
assert resp2.status_code == 200 assert resp2.status_code == 200
assert resp2.json() == initial assert resp2.json() == initial

View File

@ -45,6 +45,16 @@ async def client():
@pytest.fixture @pytest.fixture
async def authenticated_client(client): async def authenticated_client(client):
"""Create authenticated test client with JWT token.""" """Create authenticated test client with JWT token."""
# Setup anime directory in settings
import tempfile
from src.config.settings import settings
settings.anime_directory = tempfile.gettempdir()
# Reset series app to pick up new directory
from src.server.utils.dependencies import reset_series_app
reset_series_app()
# Setup master password # Setup master password
await client.post( await client.post(
"/api/auth/setup", "/api/auth/setup",
@ -142,20 +152,20 @@ class TestFrontendAuthentication:
) )
# Try to access protected endpoint without token # Try to access protected endpoint without token
response = await client.get("/api/v1/anime") response = await client.get("/api/v1/anime/")
assert response.status_code == 401 assert response.status_code == 401
async def test_authenticated_request_succeeds(self, authenticated_client): async def test_authenticated_request_succeeds(self, authenticated_client):
"""Test that requests with valid token succeed.""" """Test that requests with valid token succeed."""
with patch( with patch("src.server.utils.dependencies.get_series_app") as mock_get_app:
"src.server.services.anime_service.AnimeService" mock_app = AsyncMock()
) as mock_service: mock_list = AsyncMock()
mock_instance = AsyncMock() mock_list.GetMissingEpisode = AsyncMock(return_value=[])
mock_instance.get_all_series = AsyncMock(return_value=[]) mock_app.List = mock_list
mock_service.return_value = mock_instance mock_get_app.return_value = mock_app
response = await authenticated_client.get("/api/v1/anime") response = await authenticated_client.get("/api/v1/anime/")
assert response.status_code == 200 assert response.status_code == 200
@ -179,7 +189,7 @@ class TestFrontendAnimeAPI:
]) ])
mock_get_service.return_value = mock_service mock_get_service.return_value = mock_service
response = await authenticated_client.get("/api/v1/anime") response = await authenticated_client.get("/api/v1/anime/")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()