Compare commits

..

9 Commits

Author SHA1 Message Date
e42e223f28 refactory instructions 2025-11-23 11:45:34 +01:00
9a42442f47 removed downloaded and total mb 2025-11-20 19:34:01 +01:00
72a0455d59 download status floating point fix 2025-11-20 19:24:30 +01:00
029abb9be2 fix: progress part 1. percentage is working 2025-11-20 19:21:01 +01:00
34019b7e65 better shutdown 2025-11-20 19:11:05 +01:00
1ca105f330 shut down download thread 2025-11-20 19:03:20 +01:00
57da1f1272 fix: download status 2025-11-20 19:02:04 +01:00
cf503c8d77 fixed empty queu 2025-11-20 18:53:22 +01:00
b1f4d41b27 fix tests 2025-11-19 21:20:22 +01:00
20 changed files with 1508 additions and 547 deletions

View File

@ -17,7 +17,8 @@
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$SKlVihGiVIpR6v1fi9H6Xw$rElvHKWqc8WesNfrOJe4CjQI2janLKJPSy6XSOnkq2c"
"master_password_hash": "$pbkdf2-sha256$29000$AsCYU2pNCYHwHoPwnlPqXQ$uHLpvUnvj9GmNFgkAAgk3Yvvp2WzLyMNUBwKMyH79CQ",
"anime_directory": "/mnt/server/serien/Serien/"
},
"version": "1.0.0"
}

View File

@ -1,23 +0,0 @@
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$MWaMUao1Zuw9hzAmJKS0lg$sV8jdXHeNgzuJEDSbeg/wkwOf5uZpNlYJx3jz/g.eQc"
},
"version": "1.0.0"
}

View File

@ -1,23 +0,0 @@
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$2HtvzRljzPk/R2gN4ZwTIg$3E0ARhmzzt..GN4KMmiJpZbIgR0D23bAPX1HF/v4XlQ"
},
"version": "1.0.0"
}

View File

@ -1,23 +0,0 @@
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$SanV.v8/x1jL.f8fQwghBA$5qbS2ezRPEPpKwzA71U/yLIyPY6c5JkcRdE.bXAebug"
},
"version": "1.0.0"
}

View File

@ -1,23 +0,0 @@
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$eM/5nzPG2Psfo5TSujcGwA$iOo948ox9MUD5.YcCAZoF5Mi1DRzV1OeXXCcEFOFkco"
},
"version": "1.0.0"
}

View File

@ -1,23 +0,0 @@
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$TCnlPMe4F2LMmdOa87639g$UGaXOWv2SrWpKoO92Uo5V/Zce07WpHR8qIN8MmTQ8cM"
},
"version": "1.0.0"
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,7 @@ class DownloadStatusEventArgs:
error: Optional[Exception] = None,
eta: Optional[int] = None,
mbper_sec: Optional[float] = None,
item_id: Optional[str] = None,
):
"""
Initialize download status event arguments.
@ -47,6 +48,7 @@ class DownloadStatusEventArgs:
error: Optional error if status is "failed"
eta: Estimated time remaining in seconds
mbper_sec: Download speed in MB/s
item_id: Optional download queue item ID for tracking
"""
self.serie_folder = serie_folder
self.season = season
@ -57,6 +59,7 @@ class DownloadStatusEventArgs:
self.error = error
self.eta = eta
self.mbper_sec = mbper_sec
self.item_id = item_id
class ScanStatusEventArgs:
"""Event arguments for scan status events."""
@ -203,6 +206,7 @@ class SeriesApp:
episode: int,
key: str,
language: str = "German Dub",
item_id: Optional[str] = None,
) -> bool:
"""
Download an episode (async).
@ -213,6 +217,7 @@ class SeriesApp:
episode: Episode number
key: Serie key
language: Language preference
item_id: Optional download queue item ID for progress tracking
Returns:
True if download succeeded, False otherwise
@ -227,6 +232,7 @@ class SeriesApp:
episode=episode,
status="started",
message="Download started",
item_id=item_id,
)
)
@ -254,6 +260,7 @@ class SeriesApp:
progress=(downloaded / total_bytes) * 100 if total_bytes else 0,
eta=eta,
mbper_sec=mbper_sec,
item_id=item_id,
)
)
# Perform download in thread to avoid blocking event loop
@ -282,6 +289,7 @@ class SeriesApp:
status="completed",
progress=1.0,
message="Download completed successfully",
item_id=item_id,
)
)
else:
@ -297,6 +305,7 @@ class SeriesApp:
episode=episode,
status="failed",
message="Download failed",
item_id=item_id,
)
)
@ -321,6 +330,7 @@ class SeriesApp:
status="failed",
error=e,
message=f"Download error: {str(e)}",
item_id=item_id,
)
)

View File

@ -3,24 +3,25 @@ Health check controller for monitoring and status endpoints.
This module provides health check endpoints for application monitoring.
"""
from typing import Optional
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from src.core.SeriesApp import SeriesApp
from src.server.utils.dependencies import get_series_app
from src.config.settings import settings
from src.server.utils.dependencies import _series_app
router = APIRouter(prefix="/health", tags=["health"])
@router.get("")
async def health_check(
series_app: Optional[SeriesApp] = Depends(get_series_app)
):
"""Health check endpoint for monitoring."""
async def health_check():
"""Health check endpoint for monitoring.
This endpoint does not depend on anime_directory configuration
and should always return 200 OK for basic health monitoring.
"""
return {
"status": "healthy",
"service": "aniworld-api",
"version": "1.0.0",
"series_app_initialized": series_app is not None
"series_app_initialized": _series_app is not None,
"anime_directory_configured": bool(settings.anime_directory)
}

View File

@ -100,6 +100,18 @@ async def lifespan(app: FastAPI):
# Shutdown
logger.info("FastAPI application shutting down")
# Shutdown download service and its thread pool
try:
from src.server.services.download_service import _download_service_instance
if _download_service_instance is not None:
logger.info("Stopping download service...")
await _download_service_instance.stop()
logger.info("Download service stopped successfully")
except Exception as e:
logger.error("Error stopping download service: %s", e, exc_info=True)
logger.info("FastAPI application shutdown complete")
# Initialize FastAPI app with lifespan

View File

@ -37,6 +37,7 @@ class AnimeService:
self._app = series_app
self._directory = series_app.directory_to_search
self._progress_service = progress_service or get_progress_service()
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
# Subscribe to SeriesApp events
# Note: Events library uses assignment (=), not += operator
try:
@ -54,49 +55,77 @@ class AnimeService:
args: DownloadStatusEventArgs from SeriesApp
"""
try:
# Check if there's a running event loop
# Get event loop - try running loop first, then stored loop
loop = None
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop - log and skip
# No running loop in this thread - use stored loop
loop = self._event_loop
if not loop:
logger.debug(
"No running event loop for download status event",
"No event loop available for download status event",
status=args.status
)
return
# Use item_id if available, otherwise fallback to constructing ID
progress_id = (
args.item_id
if args.item_id
else f"download_{args.serie_folder}_{args.season}_{args.episode}"
)
# Map SeriesApp download events to progress service
if args.status == "started":
loop.create_task(
asyncio.run_coroutine_threadsafe(
self._progress_service.start_progress(
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
progress_id=progress_id,
progress_type=ProgressType.DOWNLOAD,
title=f"Downloading {args.serie_folder}",
message=f"S{args.season:02d}E{args.episode:02d}",
)
metadata={"item_id": args.item_id} if args.item_id else None,
),
loop
)
elif args.status == "progress":
loop.create_task(
# Build metadata with item_id and speed
progress_metadata = {}
if args.item_id:
progress_metadata["item_id"] = args.item_id
if args.mbper_sec is not None:
progress_metadata["speed_mbps"] = round(args.mbper_sec, 2)
if args.eta is not None:
progress_metadata["eta"] = args.eta
asyncio.run_coroutine_threadsafe(
self._progress_service.update_progress(
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
current=int(args.progress),
progress_id=progress_id,
current=args.progress,
total=100,
message=args.message or "Downloading...",
)
metadata=(
progress_metadata if progress_metadata else None
),
),
loop
)
elif args.status == "completed":
loop.create_task(
asyncio.run_coroutine_threadsafe(
self._progress_service.complete_progress(
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
progress_id=progress_id,
message="Download completed",
)
),
loop
)
elif args.status == "failed":
loop.create_task(
asyncio.run_coroutine_threadsafe(
self._progress_service.fail_progress(
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
progress_id=progress_id,
error_message=args.message or str(args.error),
)
),
loop
)
except Exception as exc:
logger.error(
@ -113,56 +142,65 @@ class AnimeService:
try:
scan_id = "library_scan"
# Check if there's a running event loop
# Get event loop - try running loop first, then stored loop
loop = None
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop - log and skip
# No running loop in this thread - use stored loop
loop = self._event_loop
if not loop:
logger.debug(
"No running event loop for scan status event",
"No event loop available for scan status event",
status=args.status
)
return
# Map SeriesApp scan events to progress service
if args.status == "started":
loop.create_task(
asyncio.run_coroutine_threadsafe(
self._progress_service.start_progress(
progress_id=scan_id,
progress_type=ProgressType.SCAN,
title="Scanning anime library",
message=args.message or "Initializing scan...",
)
),
loop
)
elif args.status == "progress":
loop.create_task(
asyncio.run_coroutine_threadsafe(
self._progress_service.update_progress(
progress_id=scan_id,
current=args.current,
total=args.total,
message=args.message or f"Scanning: {args.folder}",
)
),
loop
)
elif args.status == "completed":
loop.create_task(
asyncio.run_coroutine_threadsafe(
self._progress_service.complete_progress(
progress_id=scan_id,
message=args.message or "Scan completed",
)
),
loop
)
elif args.status == "failed":
loop.create_task(
asyncio.run_coroutine_threadsafe(
self._progress_service.fail_progress(
progress_id=scan_id,
error_message=args.message or str(args.error),
)
),
loop
)
elif args.status == "cancelled":
loop.create_task(
asyncio.run_coroutine_threadsafe(
self._progress_service.fail_progress(
progress_id=scan_id,
error_message=args.message or "Scan cancelled",
)
),
loop
)
except Exception as exc:
logger.error("Error handling scan status event", error=str(exc))
@ -219,6 +257,9 @@ class AnimeService:
forwarded to the ProgressService through event handlers.
"""
try:
# Store event loop for event handlers
self._event_loop = asyncio.get_running_loop()
# SeriesApp.rescan is now async and handles events internally
await self._app.rescan()
@ -238,21 +279,33 @@ class AnimeService:
season: int,
episode: int,
key: str,
item_id: Optional[str] = None,
) -> bool:
"""Start a download.
The SeriesApp now handles progress tracking via events which are
forwarded to the ProgressService through event handlers.
Args:
serie_folder: Serie folder name
season: Season number
episode: Episode number
key: Serie key
item_id: Optional download queue item ID for tracking
Returns True on success or raises AnimeServiceError on failure.
"""
try:
# Store event loop for event handlers
self._event_loop = asyncio.get_running_loop()
# SeriesApp.download is now async and handles events internally
return await self._app.download(
serie_folder=serie_folder,
season=season,
episode=episode,
key=key,
item_id=item_id,
)
except Exception as exc:
logger.exception("download failed")

View File

@ -77,10 +77,14 @@ class DownloadService:
# Control flags
self._is_stopped = True # Queue processing is stopped by default
self._is_shutting_down = False # Flag to indicate shutdown
# Executor for blocking operations
self._executor = ThreadPoolExecutor(max_workers=1)
# Track active download task for cancellation
self._active_download_task: Optional[asyncio.Task] = None
# Statistics tracking
self._total_downloaded_mb: float = 0.0
self._download_speeds: deque[float] = deque(maxlen=10)
@ -500,7 +504,11 @@ class DownloadService:
)
# Process the download (this will wait until complete)
await self._process_download(item)
self._active_download_task = asyncio.create_task(
self._process_download(item)
)
await self._active_download_task
self._active_download_task = None
# Small delay between downloads
await asyncio.sleep(1)
@ -771,6 +779,11 @@ class DownloadService:
item: Download item to process
"""
try:
# Check if shutting down
if self._is_shutting_down:
logger.info("Skipping download due to shutdown")
return
# Update status
item.status = DownloadStatus.DOWNLOADING
item.started_at = datetime.now(timezone.utc)
@ -795,6 +808,7 @@ class DownloadService:
season=item.episode.season,
episode=item.episode.episode,
key=item.serie_id,
item_id=item.id,
)
# Handle result
@ -814,6 +828,19 @@ class DownloadService:
else:
raise AnimeServiceError("Download returned False")
except asyncio.CancelledError:
# Handle task cancellation during shutdown
logger.info(
"Download cancelled during shutdown",
item_id=item.id,
)
item.status = DownloadStatus.CANCELLED
item.completed_at = datetime.now(timezone.utc)
# Return item to pending queue if not shutting down
if not self._is_shutting_down:
self._add_to_pending_queue(item, front=True)
raise # Re-raise to properly cancel the task
except Exception as e:
# Handle failure
item.status = DownloadStatus.FAILED
@ -845,27 +872,31 @@ class DownloadService:
logger.info("Download queue service initialized")
async def stop(self) -> None:
"""Stop the download queue service and wait for active download.
"""Stop the download queue service and cancel active downloads.
Note: This waits for the current download to complete.
Cancels any active download and shuts down the thread pool immediately.
"""
logger.info("Stopping download queue service...")
# Wait for active download to complete (with timeout)
timeout = 30 # seconds
start_time = asyncio.get_event_loop().time()
# Set shutdown flag
self._is_shutting_down = True
self._is_stopped = True
while (
self._active_download
and (asyncio.get_event_loop().time() - start_time) < timeout
):
await asyncio.sleep(1)
# Cancel active download task if running
if self._active_download_task and not self._active_download_task.done():
logger.info("Cancelling active download task...")
self._active_download_task.cancel()
try:
await self._active_download_task
except asyncio.CancelledError:
logger.info("Active download task cancelled")
# Save final state
self._save_queue()
# Shutdown executor
self._executor.shutdown(wait=True)
# Shutdown executor immediately, don't wait for tasks
logger.info("Shutting down thread pool executor...")
self._executor.shutdown(wait=False, cancel_futures=True)
logger.info("Download queue service stopped")

View File

@ -48,8 +48,15 @@ class QueueManager {
this.updateQueueDisplay(data);
});
this.socket.on('queue_status', (data) => {
// New backend sends queue_status messages
if (data.queue_status) {
// New backend sends queue_status messages with nested structure
if (data.status && data.statistics) {
// Transform nested structure to flat structure
const queueData = {
...data.status,
statistics: data.statistics
};
this.updateQueueDisplay(queueData);
} else if (data.queue_status) {
this.updateQueueDisplay(data.queue_status);
} else {
this.updateQueueDisplay(data);
@ -228,10 +235,20 @@ class QueueManager {
async loadQueueData() {
try {
const response = await this.makeAuthenticatedRequest('/api/queue/status');
if (!response) return;
if (!response) {
return;
}
const data = await response.json();
this.updateQueueDisplay(data);
// API returns nested structure with 'status' and 'statistics'
// Transform it to the expected flat structure
const queueData = {
...data.status, // includes is_running, active_downloads, pending_queue, etc.
statistics: data.statistics
};
this.updateQueueDisplay(queueData);
// Process any pending progress updates after queue is loaded
this.processPendingProgressUpdates();
@ -376,6 +393,7 @@ class QueueManager {
// Extract progress information - handle both ProgressService and yt-dlp formats
const progress = data.progress || data;
const percent = progress.percent || 0;
const metadata = progress.metadata || data.metadata || {};
// Check if we have detailed yt-dlp progress (downloaded_mb, total_mb, speed_mbps)
// or basic ProgressService progress (current, total)
@ -390,12 +408,13 @@ class QueueManager {
// ProgressService basic format - convert bytes to MB
downloaded = (progress.current / (1024 * 1024)).toFixed(1);
total = progress.total > 0 ? (progress.total / (1024 * 1024)).toFixed(1) : 'Unknown';
speed = '0.0'; // Speed not available in basic format
// Check for speed in metadata
speed = metadata.speed_mbps ? metadata.speed_mbps.toFixed(1) : '0.0';
} else {
// Fallback
downloaded = '0.0';
total = 'Unknown';
speed = '0.0';
speed = metadata.speed_mbps ? metadata.speed_mbps.toFixed(1) : '0.0';
}
// Update progress bar
@ -411,7 +430,7 @@ class QueueManager {
const speedSpan = progressInfo.querySelector('.download-speed');
if (percentSpan) {
percentSpan.textContent = `${percent.toFixed(1)}% (${downloaded} MB / ${total} MB)`;
percentSpan.textContent = percent > 0 ? `${percent.toFixed(1)}%` : 'Starting...';
}
if (speedSpan) {
speedSpan.textContent = `${speed} MB/s`;
@ -470,8 +489,8 @@ class QueueManager {
const progress = download.progress || {};
const progressPercent = progress.percent || 0;
const speed = progress.speed_mbps ? `${progress.speed_mbps.toFixed(1)} MB/s` : '0 MB/s';
const downloaded = progress.downloaded_mb ? `${progress.downloaded_mb.toFixed(1)} MB` : '0 MB';
const total = progress.total_mb ? `${progress.total_mb.toFixed(1)} MB` : 'Unknown';
const downloaded = progress.downloaded_mb ? `${progress.downloaded_mb.toFixed(1)} MB` : '0.0';
const total = progress.total_mb ? `${progress.total_mb.toFixed(1)} MB` : '0.0';
return `
<div class="download-card active" data-download-id="${download.id}">
@ -486,7 +505,7 @@ class QueueManager {
<div class="progress-fill" style="width: ${progressPercent}%"></div>
</div>
<div class="progress-info">
<span>${progressPercent.toFixed(1)}% (${downloaded} / ${total})</span>
<span>${progressPercent > 0 ? `${progressPercent.toFixed(1)}%` : 'Starting...'}</span>
<span class="download-speed">${speed}</span>
</div>
</div>

View File

@ -442,15 +442,18 @@ class TestFrontendJavaScriptIntegration:
async def test_queue_operations_compatibility(self, authenticated_client):
"""Test queue operations match queue.js expectations."""
# Test start
# Test start - should return 400 when queue is empty (valid behavior)
response = await authenticated_client.post("/api/queue/start")
assert response.status_code == 200
assert response.status_code in [200, 400]
if response.status_code == 400:
# Verify error message indicates empty queue
assert "No pending downloads" in response.json()["detail"]
# Test pause
# Test pause - always succeeds even if nothing is processing
response = await authenticated_client.post("/api/queue/pause")
assert response.status_code == 200
# Test stop
# Test stop - always succeeds even if nothing is processing
response = await authenticated_client.post("/api/queue/stop")
assert response.status_code == 200

View File

@ -26,11 +26,33 @@ from src.server.models.download import (
)
from src.server.services.anime_service import AnimeService
from src.server.services.auth_service import auth_service
from src.server.services.config_service import get_config_service
from src.server.services.download_service import DownloadService
from src.server.services.progress_service import get_progress_service
from src.server.services.websocket_service import get_websocket_service
@pytest.fixture(autouse=True)
def setup_temp_config(tmp_path):
"""Setup temporary config directory for tests."""
config_service = get_config_service()
original_path = config_service.config_path
original_backup_dir = config_service.backup_dir
# Set temporary paths
temp_data = tmp_path / "data"
temp_data.mkdir(exist_ok=True)
config_service.config_path = temp_data / "config.json"
config_service.backup_dir = temp_data / "config_backups"
config_service.backup_dir.mkdir(exist_ok=True)
yield
# Restore original paths
config_service.config_path = original_path
config_service.backup_dir = original_backup_dir
@pytest.fixture(autouse=True)
def reset_auth():
"""Reset authentication state before each test."""

View File

@ -8,6 +8,7 @@ concurrent requests and maintain acceptable response times.
import asyncio
import time
from typing import Any, Dict, List
from unittest.mock import AsyncMock, MagicMock
import pytest
from httpx import ASGITransport, AsyncClient
@ -20,6 +21,22 @@ from src.server.fastapi_app import app
class TestAPILoadTesting:
"""Load testing for API endpoints."""
@pytest.fixture(autouse=True)
def mock_series_app_dependency(self):
"""Mock SeriesApp dependency for performance tests."""
from src.server.utils.dependencies import get_series_app
mock_app = MagicMock()
mock_app.list = MagicMock()
mock_app.list.GetMissingEpisode = MagicMock(return_value=[])
mock_app.search = AsyncMock(return_value=[])
app.dependency_overrides[get_series_app] = lambda: mock_app
yield
app.dependency_overrides.clear()
@pytest.fixture
async def client(self):
"""Create async HTTP client."""

View File

@ -17,12 +17,28 @@ class TestSQLInjection:
@pytest.fixture
async def client(self):
"""Create async HTTP client for testing."""
from unittest.mock import AsyncMock, MagicMock
from httpx import ASGITransport
from src.server.utils.dependencies import get_series_app
# Mock SeriesApp to avoid 503 errors
mock_app = MagicMock()
mock_app.list = MagicMock()
mock_app.list.GetMissingEpisode = MagicMock(return_value=[])
mock_app.search = AsyncMock(return_value=[])
# Override dependency
app.dependency_overrides[get_series_app] = lambda: mock_app
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
# Cleanup
app.dependency_overrides.clear()
# Classic SQL Injection payloads
SQL_INJECTION_PAYLOADS = [
@ -138,12 +154,28 @@ class TestNoSQLInjection:
@pytest.fixture
async def client(self):
"""Create async HTTP client for testing."""
from unittest.mock import AsyncMock, MagicMock
from httpx import ASGITransport
from src.server.utils.dependencies import get_series_app
# Mock SeriesApp to avoid 503 errors
mock_app = MagicMock()
mock_app.list = MagicMock()
mock_app.list.GetMissingEpisode = MagicMock(return_value=[])
mock_app.search = AsyncMock(return_value=[])
# Override dependency
app.dependency_overrides[get_series_app] = lambda: mock_app
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
# Cleanup
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_nosql_injection_in_query(self, client):
@ -240,17 +272,33 @@ class TestORMInjection:
@pytest.mark.security
class TestDatabaseSecurity:
"""General database security tests."""
"""Security tests for database access patterns."""
@pytest.fixture
async def client(self):
"""Create async HTTP client for testing."""
from unittest.mock import AsyncMock, MagicMock
from httpx import ASGITransport
from src.server.utils.dependencies import get_series_app
# Mock SeriesApp to avoid 503 errors
mock_app = MagicMock()
mock_app.list = MagicMock()
mock_app.list.GetMissingEpisode = MagicMock(return_value=[])
mock_app.search = AsyncMock(return_value=[])
# Override dependency
app.dependency_overrides[get_series_app] = lambda: mock_app
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
# Cleanup
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_error_messages_no_leak_info(self, client):

View File

@ -247,6 +247,7 @@ class TestDownload:
season=1,
episode=1,
key="test_key",
item_id=None,
)
@pytest.mark.asyncio
@ -272,6 +273,7 @@ class TestDownload:
season=1,
episode=1,
key="test_key",
item_id=None,
)
@pytest.mark.asyncio

View File

@ -1,8 +1,7 @@
"""Unit tests for setup redirect middleware."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.responses import JSONResponse
from httpx import ASGITransport, AsyncClient
from src.server.middleware.setup_redirect import SetupRedirectMiddleware
from src.server.services.auth_service import auth_service
@ -46,9 +45,11 @@ def app():
@pytest.fixture
def client(app):
"""Create a test client."""
return TestClient(app)
async def client(app):
"""Create an async test client."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture(autouse=True)
@ -95,10 +96,11 @@ def reset_config_service():
class TestSetupRedirectMiddleware:
"""Test cases for setup redirect middleware."""
def test_redirect_to_setup_when_not_configured(self, client):
"""Test that HTML requests are redirected to /setup when not configured."""
@pytest.mark.asyncio
async def test_redirect_to_setup_when_not_configured(self, client):
"""Test that HTML requests redirect to /setup when not configured."""
# Request home page with HTML accept header (don't follow redirects)
response = client.get(
response = await client.get(
"/", headers={"Accept": "text/html"}, follow_redirects=False
)
@ -106,36 +108,40 @@ class TestSetupRedirectMiddleware:
assert response.status_code == 302
assert response.headers["location"] == "/setup"
def test_setup_page_accessible_without_config(self, client):
"""Test that /setup page is accessible even when not configured."""
response = client.get("/setup")
@pytest.mark.asyncio
async def test_setup_page_accessible_without_config(self, client):
"""Test that /setup page is accessible when not configured."""
response = await client.get("/setup")
# Should not redirect
assert response.status_code == 200
assert response.json()["message"] == "Setup page"
def test_api_returns_503_when_not_configured(self, client):
@pytest.mark.asyncio
async def test_api_returns_503_when_not_configured(self, client):
"""Test that API requests return 503 when not configured."""
response = client.get("/api/data")
response = await client.get("/api/data")
# Should return 503 Service Unavailable
assert response.status_code == 503
assert "setup_url" in response.json()
assert response.json()["setup_url"] == "/setup"
def test_exempt_api_endpoints_accessible(self, client):
@pytest.mark.asyncio
async def test_exempt_api_endpoints_accessible(self, client):
"""Test that exempt API endpoints are accessible without setup."""
# Health endpoint should be accessible
response = client.get("/api/health")
response = await client.get("/api/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
# Auth status endpoint should be accessible
response = client.get("/api/auth/status")
response = await client.get("/api/auth/status")
assert response.status_code == 200
assert response.json()["configured"] is False
def test_no_redirect_when_configured(self, client):
@pytest.mark.asyncio
async def test_no_redirect_when_configured(self, client):
"""Test that no redirect happens when auth and config are set up."""
# Configure auth service
auth_service.setup_master_password("Test@Password123")
@ -147,13 +153,14 @@ class TestSetupRedirectMiddleware:
config_service.save_config(config, create_backup=False)
# Request home page
response = client.get("/", headers={"Accept": "text/html"})
response = await client.get("/", headers={"Accept": "text/html"})
# Should not redirect
assert response.status_code == 200
assert response.json()["message"] == "Home page"
def test_api_works_when_configured(self, client):
@pytest.mark.asyncio
async def test_api_works_when_configured(self, client):
"""Test that API requests work normally when configured."""
# Configure auth service
auth_service.setup_master_password("Test@Password123")
@ -165,44 +172,44 @@ class TestSetupRedirectMiddleware:
config_service.save_config(config, create_backup=False)
# Request API endpoint
response = client.get("/api/data")
response = await client.get("/api/data")
# Should work normally
assert response.status_code == 200
assert response.json()["data"] == "some data"
def test_static_files_always_accessible(self, client):
@pytest.mark.asyncio
async def test_static_files_always_accessible(self, client, app):
"""Test that static file paths are always accessible."""
# Create a route that mimics static file serving
from fastapi import FastAPI
app = client.app
@app.get("/static/css/style.css")
async def static_css():
return {"content": "css"}
# Request static file
response = client.get("/static/css/style.css")
response = await client.get("/static/css/style.css")
# Should be accessible even without setup
assert response.status_code == 200
def test_redirect_when_only_auth_configured(self, client):
@pytest.mark.asyncio
async def test_redirect_when_only_auth_configured(self, client):
"""Test redirect when auth is configured but config is invalid."""
# Configure auth but don't create config file
auth_service.setup_master_password("Test@Password123")
# Request home page
response = client.get("/", headers={"Accept": "text/html"})
response = await client.get("/", headers={"Accept": "text/html"})
# Should still work because load_config creates default config
# This is the current behavior - may need to adjust if we want
# stricter setup requirements
assert response.status_code in [200, 302]
def test_root_path_redirect(self, client):
@pytest.mark.asyncio
async def test_root_path_redirect(self, client):
"""Test that root path redirects to setup when not configured."""
response = client.get(
response = await client.get(
"/", headers={"Accept": "text/html"}, follow_redirects=False
)
@ -210,8 +217,8 @@ class TestSetupRedirectMiddleware:
assert response.status_code == 302
assert response.headers["location"] == "/setup"
def test_path_matching_exact_and_prefix(self, client):
"""Test that path matching works for both exact and prefix matches."""
def test_path_matching_exact_and_prefix(self):
"""Test that path matching works for both exact and prefix."""
middleware = SetupRedirectMiddleware(app=FastAPI())
# Exact matches