Compare commits

..

No commits in common. "17c7a2e29542f68f469b4d13a2d0db6027b6e5b5" and "2441730862c3f7c6e3eea5e735e65f8c3c99bbd1" have entirely different histories.

31 changed files with 1055 additions and 1906 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$8v4/p1RKyRnDWEspJSTEeA$u8rsOktLvjCgB2XeHrQvcSGj2vq.Gea0rQQt/e6Ygm0",
"anime_directory": "/home/lukas/Volume/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

View File

@ -1,131 +0,0 @@
#!/usr/bin/env python3
"""Script to fix test files that use old set_broadcast_callback pattern."""
import re
import sys
from pathlib import Path
def fix_file(filepath: Path) -> bool:
"""Fix a single test file.
Args:
filepath: Path to the test file
Returns:
True if file was modified, False otherwise
"""
content = filepath.read_text()
original = content
# Pattern 1: Replace set_broadcast_callback calls
# Old: service.set_broadcast_callback(mock_broadcast)
# New: progress_service.subscribe("progress_updated", mock_event_handler)
# Pattern 2: Fix download_service fixture to return tuple
if "async def download_service(" in content and "yield service" in content:
content = re.sub(
r'(async def download_service\([^)]+\):.*?)(yield service)',
r'\1yield service, progress_service',
content,
flags=re.DOTALL
)
#Pattern 3: Unpack download_service in tests
if "def test_" in content or "async def test_" in content:
# Find tests that use download_service but don't unpack it
content = re.sub(
r'(async def test_[^\(]+\([^)]*download_service[^)]*\):.*?""".*?""")\s*broadcasts',
r'\1\n download_svc, progress_svc = download_service\n broadcasts',
content,
flags=re.DOTALL,
count=1 # Only first occurrence in each test
)
# Pattern 4: Replace set_broadcast_callback with subscribe
content = re.sub(
r'(\w+)\.set_broadcast_callback\((\w+)\)',
r'progress_service.subscribe("progress_updated", \2)',
content
)
# Pattern 5: Fix event handler signatures
# Old: async def mock_broadcast(message_type: str, room: str, data: dict):
# New: async def mock_event_handler(event):
content = re.sub(
r'async def (mock_broadcast\w*)\([^)]+\):(\s+"""[^"]*""")?(\s+)broadcasts\.append',
r'async def mock_event_handler(event):\2\3broadcasts.append',
content
)
# Pattern 6: Fix broadcast append calls
# Old: broadcasts.append({"type": message_type, "data": data})
# New: broadcasts.append({"type": event.event_type, "data": event.progress.to_dict()})
content = re.sub(
r'broadcasts\.append\(\{[^}]*"type":\s*message_type[^}]*\}\)',
'broadcasts.append({"type": event.event_type, "data": event.progress.to_dict()})',
content
)
# Pattern 7: Update download_service usage in tests to use unpacked version
content = re.sub(
r'await download_service\.add_to_queue\(',
r'await download_svc.add_to_queue(',
content
)
content = re.sub(
r'await download_service\.start',
r'await download_svc.start',
content
)
content = re.sub(
r'await download_service\.stop',
r'await download_svc.stop',
content
)
content = re.sub(
r'await download_service\.get_queue_status\(',
r'await download_svc.get_queue_status(',
content
)
content = re.sub(
r'await download_service\.remove_from_queue\(',
r'await download_svc.remove_from_queue(',
content
)
content = re.sub(
r'await download_service\.clear_completed\(',
r'await download_svc.clear_completed(',
content
)
if content != original:
filepath.write_text(content)
print(f"✓ Fixed {filepath}")
return True
else:
print(f" Skipped {filepath} (no changes needed)")
return False
def main():
"""Main function to fix all test files."""
test_dir = Path(__file__).parent / "tests"
# Find all test files that might need fixing
test_files = list(test_dir.rglob("test_*.py"))
print(f"Found {len(test_files)} test files")
print("Fixing test files...")
fixed_count = 0
for test_file in test_files:
if fix_file(test_file):
fixed_count += 1
print(f"\nFixed {fixed_count}/{len(test_files)} files")
return 0 if fixed_count > 0 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,104 +0,0 @@
#!/usr/bin/env python3
"""Script to batch fix common test issues after API changes."""
import re
import sys
from pathlib import Path
def fix_add_to_queue_calls(content: str) -> str:
"""Add serie_folder parameter to add_to_queue calls."""
# Pattern: add_to_queue(\n serie_id="...",
# Add: serie_folder="...",
pattern = r'(add_to_queue\(\s+serie_id="([^"]+)",)'
def replace_func(match):
serie_id = match.group(2)
# Extract just the series name without number if present
serie_folder = serie_id.split('-')[0] if '-' in serie_id else serie_id
return f'{match.group(1)}\n serie_folder="{serie_folder}",'
return re.sub(pattern, replace_func, content)
def fix_queue_status_response(content: str) -> str:
"""Fix queue status response structure - remove nested 'status' key."""
# Replace data["status"]["pending"] with data["pending_queue"]
content = re.sub(r'data\["status"\]\["pending"\]', 'data["pending_queue"]', content)
content = re.sub(r'data\["status"\]\["active"\]', 'data["active_downloads"]', content)
content = re.sub(r'data\["status"\]\["completed"\]', 'data["completed_downloads"]', content)
content = re.sub(r'data\["status"\]\["failed"\]', 'data["failed_downloads"]', content)
content = re.sub(r'data\["status"\]\["is_running"\]', 'data["is_running"]', content)
content = re.sub(r'data\["status"\]\["is_paused"\]', 'data["is_paused"]', content)
# Also fix response.json()["status"]["..."]
content = re.sub(r'response\.json\(\)\["status"\]\["pending"\]', 'response.json()["pending_queue"]', content)
content = re.sub(r'response\.json\(\)\["status"\]\["is_running"\]', 'response.json()["is_running"]', content)
content = re.sub(r'status\.json\(\)\["status"\]\["is_running"\]', 'status.json()["is_running"]', content)
content = re.sub(r'status\.json\(\)\["status"\]\["failed"\]', 'status.json()["failed_downloads"]', content)
content = re.sub(r'status\.json\(\)\["status"\]\["completed"\]', 'status.json()["completed_downloads"]', content)
# Fix assert "status" in data
content = re.sub(r'assert "status" in data', 'assert "is_running" in data', content)
return content
def fix_anime_service_init(content: str) -> str:
"""Fix AnimeService initialization in test fixtures."""
# This one is complex, so we'll just note files that need manual review
if 'AnimeService(' in content and 'directory=' in content:
print(" ⚠️ Contains AnimeService with directory= parameter - needs manual review")
return content
def main():
test_dir = Path(__file__).parent / "tests"
if not test_dir.exists():
print(f"Error: {test_dir} not found")
sys.exit(1)
files_to_fix = [
# Download service tests
"unit/test_download_service.py",
"unit/test_download_progress_websocket.py",
"integration/test_download_progress_integration.py",
"integration/test_websocket_integration.py",
# API tests with queue status
"api/test_queue_features.py",
"api/test_download_endpoints.py",
"frontend/test_existing_ui_integration.py",
]
for file_path in files_to_fix:
full_path = test_dir / file_path
if not full_path.exists():
print(f"Skipping {file_path} (not found)")
continue
print(f"Processing {file_path}...")
# Read content
content = full_path.read_text()
original_content = content
# Apply fixes
if 'add_to_queue(' in content:
content = fix_add_to_queue_calls(content)
if 'data["status"]' in content or 'response.json()["status"]' in content:
content = fix_queue_status_response(content)
content = fix_anime_service_init(content)
# Write back if changed
if content != original_content:
full_path.write_text(content)
print(f" ✓ Updated {file_path}")
else:
print(f" - No changes needed for {file_path}")
if __name__ == "__main__":
main()

View File

@ -4,12 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from src.core.entities.series import Serie
from src.server.services.anime_service import AnimeService, AnimeServiceError
from src.server.utils.dependencies import (
get_anime_service,
get_series_app,
require_auth,
)
from src.server.utils.dependencies import get_series_app, require_auth
router = APIRouter(prefix="/api/anime", tags=["anime"])
@ -229,34 +224,63 @@ async def list_anime(
@router.post("/rescan")
async def trigger_rescan(
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
series_app: Any = Depends(get_series_app),
) -> dict:
"""Kick off a rescan of the local library.
Args:
_auth: Ensures the caller is authenticated (value unused)
anime_service: AnimeService instance provided via dependency.
series_app: Core `SeriesApp` instance provided via dependency.
Returns:
Dict[str, Any]: Status payload confirming scan started
Dict[str, Any]: Status payload with scan results including
number of series found.
Raises:
HTTPException: If the rescan command fails.
HTTPException: If the rescan command is unsupported or fails.
"""
try:
# Use the async rescan method from AnimeService
# Progress tracking is handled automatically via event handlers
await anime_service.rescan()
return {
"success": True,
"message": "Rescan started successfully",
}
except AnimeServiceError as e:
# SeriesApp.ReScan expects a callback; pass a no-op
if hasattr(series_app, "ReScan"):
result = series_app.ReScan(lambda *args, **kwargs: None)
# Handle cases where ReScan might not return anything
if result is None:
# If no result, assume success
return {
"success": True,
"message": "Rescan completed successfully",
"series_count": 0
}
elif hasattr(result, 'success') and result.success:
series_count = (
result.data.get("series_count", 0)
if result.data else 0
)
return {
"success": True,
"message": result.message,
"series_count": series_count
}
elif hasattr(result, 'success'):
return {
"success": False,
"message": result.message
}
else:
# Result exists but has no success attribute
return {
"success": True,
"message": "Rescan completed",
"series_count": 0
}
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Rescan failed: {str(e)}",
) from e
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Rescan not available",
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -400,8 +424,8 @@ async def _perform_search(
matches: List[Any] = []
if hasattr(series_app, "search"):
# SeriesApp.search is async; await the result
matches = await series_app.search(validated_query)
# SeriesApp.search is synchronous in core; call directly
matches = series_app.search(validated_query)
summaries: List[AnimeSummary] = []
for match in matches:

View File

@ -43,13 +43,47 @@ async def get_queue_status(
queue_status = await download_service.get_queue_status()
queue_stats = await download_service.get_queue_stats()
# Build response matching QueueStatusResponse model
response = QueueStatusResponse(
status=queue_status,
statistics=queue_stats,
# Build response with field names expected by frontend
# Frontend expects top-level arrays (active_downloads, pending_queue, etc.)
# not nested under a 'status' object
active_downloads = [
it.model_dump(mode="json")
for it in queue_status.active_downloads
]
pending_queue = [
it.model_dump(mode="json")
for it in queue_status.pending_queue
]
completed_downloads = [
it.model_dump(mode="json")
for it in queue_status.completed_downloads
]
failed_downloads = [
it.model_dump(mode="json")
for it in queue_status.failed_downloads
]
# Calculate success rate
completed = queue_stats.completed_count
failed = queue_stats.failed_count
success_rate = None
if (completed + failed) > 0:
success_rate = completed / (completed + failed)
stats_payload = queue_stats.model_dump(mode="json")
stats_payload["success_rate"] = success_rate
return JSONResponse(
content={
"is_running": queue_status.is_running,
"is_paused": queue_status.is_paused,
"active_downloads": active_downloads,
"pending_queue": pending_queue,
"completed_downloads": completed_downloads,
"failed_downloads": failed_downloads,
"statistics": stats_payload,
}
)
return response
except Exception as e:
raise HTTPException(
@ -276,51 +310,6 @@ async def remove_from_queue(
)
@router.delete("/", status_code=status.HTTP_204_NO_CONTENT)
async def remove_multiple_from_queue(
request: QueueOperationRequest,
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Remove multiple items from the download queue.
Removes multiple download items from the queue based on provided IDs.
Items that are currently downloading will be cancelled.
Requires authentication.
Args:
request: Request containing list of item IDs to remove
Raises:
HTTPException: 401 if not authenticated, 404 if no items found,
500 on service error
"""
try:
removed_ids = await download_service.remove_from_queue(
request.item_ids
)
if not removed_ids:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No matching items found in queue",
)
except DownloadServiceError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to remove items from queue: {str(e)}",
)
@router.post("/start", status_code=status.HTTP_200_OK)
async def start_queue(
_: dict = Depends(require_auth),
@ -409,78 +398,6 @@ async def stop_queue(
)
@router.post("/pause", status_code=status.HTTP_200_OK)
async def pause_queue(
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Pause queue processing (alias for stop).
Prevents new downloads from starting. The current active download will
continue to completion, but no new downloads will be started from the
pending queue.
Requires authentication.
Returns:
dict: Status message indicating queue processing has been paused
Raises:
HTTPException: 401 if not authenticated, 500 on service error
"""
try:
await download_service.stop_downloads()
return {
"status": "success",
"message": "Queue processing paused",
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to pause queue processing: {str(e)}",
)
@router.post("/reorder", status_code=status.HTTP_200_OK)
async def reorder_queue(
request: QueueOperationRequest,
_: dict = Depends(require_auth),
download_service: DownloadService = Depends(get_download_service),
):
"""Reorder items in the pending queue.
Reorders the pending queue based on the provided list of item IDs.
Items will be placed in the order specified by the item_ids list.
Items not included in the list will remain at the end of the queue.
Requires authentication.
Args:
request: List of download item IDs in desired order
Returns:
dict: Status message
Raises:
HTTPException: 401 if not authenticated, 404 if no items match,
500 on service error
"""
try:
await download_service.reorder_queue(request.item_ids)
return {
"status": "success",
"message": f"Queue reordered with {len(request.item_ids)} items",
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reorder queue: {str(e)}",
)
@router.post("/retry", status_code=status.HTTP_200_OK)
async def retry_failed(
request: QueueOperationRequest,

View File

@ -54,20 +54,9 @@ class AnimeService:
args: DownloadStatusEventArgs from SeriesApp
"""
try:
# Check if there's a running event loop
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop - log and skip
logger.debug(
"No running event loop for download status event",
status=args.status
)
return
# Map SeriesApp download events to progress service
if args.status == "started":
loop.create_task(
asyncio.create_task(
self._progress_service.start_progress(
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
progress_type=ProgressType.DOWNLOAD,
@ -76,7 +65,7 @@ class AnimeService:
)
)
elif args.status == "progress":
loop.create_task(
asyncio.create_task(
self._progress_service.update_progress(
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
current=int(args.progress),
@ -85,14 +74,14 @@ class AnimeService:
)
)
elif args.status == "completed":
loop.create_task(
asyncio.create_task(
self._progress_service.complete_progress(
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
message="Download completed",
)
)
elif args.status == "failed":
loop.create_task(
asyncio.create_task(
self._progress_service.fail_progress(
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
error_message=args.message or str(args.error),
@ -112,21 +101,10 @@ class AnimeService:
"""
try:
scan_id = "library_scan"
# Check if there's a running event loop
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop - log and skip
logger.debug(
"No running event loop for scan status event",
status=args.status
)
return
# Map SeriesApp scan events to progress service
if args.status == "started":
loop.create_task(
asyncio.create_task(
self._progress_service.start_progress(
progress_id=scan_id,
progress_type=ProgressType.SCAN,
@ -135,7 +113,7 @@ class AnimeService:
)
)
elif args.status == "progress":
loop.create_task(
asyncio.create_task(
self._progress_service.update_progress(
progress_id=scan_id,
current=args.current,
@ -144,21 +122,21 @@ class AnimeService:
)
)
elif args.status == "completed":
loop.create_task(
asyncio.create_task(
self._progress_service.complete_progress(
progress_id=scan_id,
message=args.message or "Scan completed",
)
)
elif args.status == "failed":
loop.create_task(
asyncio.create_task(
self._progress_service.fail_progress(
progress_id=scan_id,
error_message=args.message or str(args.error),
)
)
elif args.status == "cancelled":
loop.create_task(
asyncio.create_task(
self._progress_service.fail_progress(
progress_id=scan_id,
error_message=args.message or "Scan cancelled",
@ -219,8 +197,8 @@ class AnimeService:
forwarded to the ProgressService through event handlers.
"""
try:
# SeriesApp.rescan is now async and handles events internally
await self._app.rescan()
# SeriesApp.re_scan is now async and handles events internally
await self._app.re_scan()
# invalidate cache
try:

View File

@ -84,12 +84,12 @@ class DownloadService:
# Statistics tracking
self._total_downloaded_mb: float = 0.0
self._download_speeds: deque[float] = deque(maxlen=10)
# Track if queue progress has been initialized
self._queue_progress_initialized: bool = False
# Load persisted queue
self._load_queue()
# Initialize queue progress tracking
asyncio.create_task(self._init_queue_progress())
logger.info(
"DownloadService initialized",
@ -97,14 +97,7 @@ class DownloadService:
)
async def _init_queue_progress(self) -> None:
"""Initialize the download queue progress tracking.
This is called lazily when queue processing starts to ensure
the event loop is running and the coroutine can be properly awaited.
"""
if self._queue_progress_initialized:
return
"""Initialize the download queue progress tracking."""
try:
from src.server.services.progress_service import ProgressType
await self._progress_service.start_progress(
@ -113,7 +106,6 @@ class DownloadService:
title="Download Queue",
message="Queue ready",
)
self._queue_progress_initialized = True
except Exception as e:
logger.error("Failed to initialize queue progress", error=str(e))
@ -247,9 +239,6 @@ class DownloadService:
Raises:
DownloadServiceError: If adding items fails
"""
# Initialize queue progress tracking if not already done
await self._init_queue_progress()
created_ids = []
try:
@ -360,59 +349,6 @@ class DownloadService:
f"Failed to remove items: {str(e)}"
) from e
async def reorder_queue(self, item_ids: List[str]) -> None:
"""Reorder pending queue items.
Args:
item_ids: List of item IDs in desired order.
Items not in this list remain at end of queue.
Raises:
DownloadServiceError: If reordering fails
"""
try:
# Build new queue based on specified order
new_queue = deque()
remaining_items = list(self._pending_queue)
# Add items in specified order
for item_id in item_ids:
if item_id in self._pending_items_by_id:
item = self._pending_items_by_id[item_id]
new_queue.append(item)
remaining_items.remove(item)
# Add remaining items that weren't in the reorder list
for item in remaining_items:
new_queue.append(item)
# Replace queue
self._pending_queue = new_queue
# Save updated queue
self._save_queue()
# Notify via progress service
queue_status = await self.get_queue_status()
await self._progress_service.update_progress(
progress_id="download_queue",
message=f"Queue reordered with {len(item_ids)} items",
metadata={
"action": "queue_reordered",
"reordered_count": len(item_ids),
"queue_status": queue_status.model_dump(mode="json"),
},
force_broadcast=True,
)
logger.info("Queue reordered", reordered_count=len(item_ids))
except Exception as e:
logger.error("Failed to reorder queue", error=str(e))
raise DownloadServiceError(
f"Failed to reorder queue: {str(e)}"
) from e
async def start_queue_processing(self) -> Optional[str]:
"""Start automatic queue processing of all pending downloads.
@ -427,9 +363,6 @@ class DownloadService:
DownloadServiceError: If queue processing is already active
"""
try:
# Initialize queue progress tracking if not already done
await self._init_queue_progress()
# Check if download already active
if self._active_download:
raise DownloadServiceError(

View File

@ -79,8 +79,7 @@ class ProgressUpdate:
"percent": round(self.percent, 2),
"current": self.current,
"total": self.total,
# Make a copy to prevent mutation issues
"metadata": self.metadata.copy(),
"metadata": self.metadata,
"started_at": self.started_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@ -158,7 +157,7 @@ class ProgressService:
self._event_handlers[event_name] = []
self._event_handlers[event_name].append(handler)
logger.debug("Event handler subscribed", event_type=event_name)
logger.debug("Event handler subscribed", event=event_name)
def unsubscribe(
self, event_name: str, handler: Callable[[ProgressEvent], None]
@ -172,13 +171,10 @@ class ProgressService:
if event_name in self._event_handlers:
try:
self._event_handlers[event_name].remove(handler)
logger.debug(
"Event handler unsubscribed", event_type=event_name
)
logger.debug("Event handler unsubscribed", event=event_name)
except ValueError:
logger.warning(
"Handler not found for unsubscribe",
event_type=event_name,
"Handler not found for unsubscribe", event=event_name
)
async def _emit_event(self, event: ProgressEvent) -> None:
@ -208,7 +204,7 @@ class ProgressService:
if isinstance(result, Exception):
logger.error(
"Event handler raised exception",
event_type=event_name,
event=event_name,
error=str(result),
handler_index=idx,
)

View File

@ -445,8 +445,20 @@
<script src="/static/js/localization.js"></script>
<!-- UX Enhancement Scripts -->
<script src="/static/js/keyboard_shortcuts.js"></script>
<script src="/static/js/drag_drop.js"></script>
<script src="/static/js/bulk_operations.js"></script>
<script src="/static/js/user_preferences.js"></script>
<script src="/static/js/advanced_search.js"></script>
<script src="/static/js/undo_redo.js"></script>
<!-- Mobile & Accessibility Scripts -->
<script src="/static/js/mobile_responsive.js"></script>
<script src="/static/js/touch_gestures.js"></script>
<script src="/static/js/accessibility_features.js"></script>
<script src="/static/js/screen_reader_support.js"></script>
<script src="/static/js/color_contrast_compliance.js"></script>
<script src="/static/js/multi_screen_support.js"></script>
<script src="/static/js/app.js"></script>
</body>

View File

@ -18,7 +18,6 @@ class FakeSerie:
self.name = name
self.folder = folder
self.episodeDict = episodeDict or {}
self.site = "aniworld.to" # Add site attribute
class FakeSeriesApp:
@ -26,7 +25,7 @@ class FakeSeriesApp:
def __init__(self):
"""Initialize fake series app."""
self.list = self # Changed from self.List to self.list
self.List = self
self._items = [
FakeSerie("1", "Test Show", "test_show", {1: [1, 2]}),
FakeSerie("2", "Complete Show", "complete_show", {}),
@ -50,20 +49,6 @@ class FakeSeriesApp:
if not any(s.key == serie.key for s in self._items):
self._items.append(serie)
async def search(self, query):
"""Search for series (async)."""
# Return mock search results
return [
{
"key": "test-result",
"name": "Test Search Result",
"site": "aniworld.to",
"folder": "test-result",
"link": "https://aniworld.to/anime/test",
"missing_episodes": {},
}
]
def refresh_series_list(self):
"""Refresh series list."""
pass
@ -79,20 +64,6 @@ def reset_auth_state():
auth_service._failed.clear()
@pytest.fixture(autouse=True)
def mock_series_app_dependency():
"""Override the series_app dependency with FakeSeriesApp."""
from src.server.utils.dependencies import get_series_app
fake_app = FakeSeriesApp()
app.dependency_overrides[get_series_app] = lambda: fake_app
yield fake_app
# Clean up
app.dependency_overrides.clear()
@pytest.fixture
async def authenticated_client():
"""Create authenticated async client."""
@ -129,19 +100,9 @@ def test_get_anime_detail_direct_call():
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)
)
fake = FakeSeriesApp()
result = asyncio.run(anime_module.trigger_rescan(series_app=fake))
assert result["success"] is True
mock_anime_service.rescan.assert_called_once()
@pytest.mark.asyncio

View File

@ -92,9 +92,8 @@ def mock_download_service():
# Mock remove_from_queue
service.remove_from_queue = AsyncMock(return_value=["item-id-1"])
# Mock start/stop - start_queue_processing returns True on success
service.start_queue_processing = AsyncMock(return_value=True)
service.stop = AsyncMock()
# Mock start/stop
service.start_next_download = AsyncMock(return_value="item-id-1")
service.stop_downloads = AsyncMock()
# Mock clear_completed and retry_failed
@ -112,19 +111,16 @@ async def test_get_queue_status(authenticated_client, mock_download_service):
assert response.status_code == 200
data = response.json()
# Updated to match new response structure with nested status
assert "status" in data
# Updated to match new response structure
assert "is_running" in data
assert "is_paused" in data
assert "active_downloads" in data
assert "pending_queue" in data
assert "completed_downloads" in data
assert "failed_downloads" in data
assert "statistics" in data
status_data = data["status"]
assert "is_running" in status_data
assert "is_paused" in status_data
assert "active_downloads" in status_data
assert "pending_queue" in status_data
assert "completed_downloads" in status_data
assert "failed_downloads" in status_data
assert status_data["is_running"] is True
assert status_data["is_paused"] is False
assert data["is_running"] is True
assert data["is_paused"] is False
mock_download_service.get_queue_status.assert_called_once()
mock_download_service.get_queue_stats.assert_called_once()
@ -267,16 +263,17 @@ async def test_remove_from_queue_not_found(
async def test_start_download_success(
authenticated_client, mock_download_service
):
"""Test POST /api/queue/start starts queue processing."""
"""Test POST /api/queue/start starts first pending download."""
response = await authenticated_client.post("/api/queue/start")
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert "started" in data["message"].lower()
assert "item_id" in data
assert data["item_id"] == "item-id-1"
mock_download_service.start_queue_processing.assert_called_once()
mock_download_service.start_next_download.assert_called_once()
@pytest.mark.asyncio
@ -284,7 +281,7 @@ async def test_start_download_empty_queue(
authenticated_client, mock_download_service
):
"""Test starting download with empty queue returns 400."""
mock_download_service.start_queue_processing.return_value = None
mock_download_service.start_next_download.return_value = None
response = await authenticated_client.post("/api/queue/start")
@ -299,7 +296,7 @@ async def test_start_download_already_active(
authenticated_client, mock_download_service
):
"""Test starting download while one is active returns 400."""
mock_download_service.start_queue_processing.side_effect = (
mock_download_service.start_next_download.side_effect = (
DownloadServiceError("A download is already in progress")
)
@ -307,8 +304,7 @@ async def test_start_download_already_active(
assert response.status_code == 400
data = response.json()
detail_lower = data["detail"].lower()
assert "already" in detail_lower or "progress" in detail_lower
assert "already" in data["detail"].lower()
@pytest.mark.asyncio

View File

@ -73,16 +73,15 @@ class TestQueueDisplay:
assert response.status_code == 200
data = response.json()
# Verify top-level structure
# Verify structure
assert "status" in data
assert "statistics" in data
# Verify status nested structure
status = data["status"]
assert "active_downloads" in status
assert "pending_queue" in status
assert "completed_downloads" in status
assert "failed_downloads" in status
assert "active" in status
assert "pending" in status
assert "completed" in status
assert "failed" in status
assert "is_running" in status
assert "is_paused" in status
@ -108,8 +107,7 @@ class TestQueueDisplay:
assert response.status_code == 200
data = response.json()
# Updated for nested status structure
pending = data["status"]["pending_queue"]
pending = data["status"]["pending"]
assert len(pending) > 0
item = pending[0]
@ -142,7 +140,7 @@ class TestQueueReordering:
)
existing_items = [
item["id"]
for item in status_response.json()["status"]["pending_queue"]
for item in status_response.json()["status"]["pending"]
]
if existing_items:
await client.request(
@ -192,7 +190,7 @@ class TestQueueReordering:
)
current_order = [
item["id"]
for item in status_response.json()["status"]["pending_queue"]
for item in status_response.json()["status"]["pending"]
]
assert current_order == new_order
@ -325,7 +323,7 @@ class TestCompletedDownloads:
data = status.json()
completed_count = data["statistics"]["completed_count"]
completed_list = len(data["status"]["completed_downloads"])
completed_list = len(data["status"]["completed"])
# Count should match list length
assert completed_count == completed_list
@ -392,7 +390,7 @@ class TestFailedDownloads:
data = status.json()
failed_count = data["statistics"]["failed_count"]
failed_list = len(data["status"]["failed_downloads"])
failed_list = len(data["status"]["failed"])
# Count should match list length
assert failed_count == failed_list
@ -445,7 +443,7 @@ class TestBulkOperations:
"/api/queue/status",
headers=auth_headers
)
pending = status.json()["status"]["pending_queue"]
pending = status.json()["status"]["pending"]
if pending:
item_ids = [item["id"] for item in pending]
@ -465,4 +463,4 @@ class TestBulkOperations:
"/api/queue/status",
headers=auth_headers
)
assert len(status.json()["status"]["pending_queue"]) == 0
assert len(status.json()["status"]["pending"]) == 0

View File

@ -1,7 +1,5 @@
"""Pytest configuration and shared fixtures for all tests."""
from unittest.mock import Mock
import pytest
from src.server.services.auth_service import auth_service
@ -77,7 +75,6 @@ def reset_auth_and_rate_limits(request):
# but we continue anyway - they're not critical
pass
yield
# Clean up after test
@ -85,32 +82,4 @@ def reset_auth_and_rate_limits(request):
auth_service._failed.clear() # noqa: SLF001
@pytest.fixture(autouse=True)
def mock_series_app_download(monkeypatch):
"""Mock SeriesApp loader download to prevent real downloads in tests.
This fixture automatically mocks all download operations to prevent
tests from performing real network downloads.
Applied to all tests automatically via autouse=True.
"""
# Mock the loader download method
try:
from src.core.SeriesApp import SeriesApp
# Patch the loader.download method for all SeriesApp instances
original_init = SeriesApp.__init__
def patched_init(self, *args, **kwargs):
original_init(self, *args, **kwargs)
# Mock the loader's download method
if hasattr(self, 'loader'):
self.loader.download = Mock(return_value=True)
monkeypatch.setattr(SeriesApp, '__init__', patched_init)
except ImportError:
# If imports fail, tests will continue but may perform downloads
pass
yield

View File

@ -8,7 +8,7 @@ This module tests the integration between the existing JavaScript frontend
- API endpoints respond with expected data formats
- Frontend JavaScript can interact with backend services
"""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, Mock, patch
import pytest
from httpx import ASGITransport, AsyncClient
@ -200,48 +200,21 @@ class TestFrontendAnimeAPI:
assert "name" in data[0]
async def test_rescan_anime(self, authenticated_client):
"""Test POST /api/anime/rescan triggers rescan with events."""
from unittest.mock import MagicMock
from src.server.services.progress_service import ProgressService
from src.server.utils.dependencies import get_anime_service
# Mock the underlying SeriesApp
mock_series_app = MagicMock()
mock_series_app.directory_to_search = "/tmp/test"
mock_series_app.series_list = []
mock_series_app.rescan = AsyncMock()
mock_series_app.download_status = None
mock_series_app.scan_status = None
"""Test POST /api/anime/rescan triggers rescan."""
# Mock SeriesApp instance with ReScan method
mock_series_app = Mock()
mock_series_app.ReScan = Mock()
# Mock the ProgressService
mock_progress_service = MagicMock(spec=ProgressService)
mock_progress_service.start_progress = AsyncMock()
mock_progress_service.update_progress = AsyncMock()
mock_progress_service.complete_progress = AsyncMock()
mock_progress_service.fail_progress = AsyncMock()
# Create real AnimeService with mocked dependencies
from src.server.services.anime_service import AnimeService
anime_service = AnimeService(
series_app=mock_series_app,
progress_service=mock_progress_service,
)
# Override the dependency
app.dependency_overrides[get_anime_service] = lambda: anime_service
try:
with patch(
"src.server.utils.dependencies.get_series_app"
) as mock_get_app:
mock_get_app.return_value = mock_series_app
response = await authenticated_client.post("/api/anime/rescan")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
# Verify rescan was called on the underlying SeriesApp
mock_series_app.rescan.assert_called_once()
finally:
# Clean up override
app.dependency_overrides.pop(get_anime_service, None)
class TestFrontendDownloadAPI:
@ -270,19 +243,18 @@ class TestFrontendDownloadAPI:
assert response.status_code == 200
data = response.json()
# Check for expected response structure (nested status)
assert "status" in data
assert "statistics" in data
# Check for expected response structure
assert "status" in data or "statistics" in data
async def test_start_download_queue(self, authenticated_client):
"""Test POST /api/queue/start starts next download."""
response = await authenticated_client.post("/api/queue/start")
# Should return 200 with success message, or 400 if queue is empty
# Should return 200 with item_id, or 400 if queue is empty
assert response.status_code in [200, 400]
data = response.json()
if response.status_code == 200:
assert "message" in data or "status" in data
assert "item_id" in data
async def test_stop_download_queue(self, authenticated_client):
"""Test POST /api/queue/stop stops processing new downloads."""

View File

@ -13,7 +13,7 @@ import asyncio
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List
from unittest.mock import AsyncMock, Mock
from unittest.mock import AsyncMock, Mock, patch
import pytest
from httpx import ASGITransport, AsyncClient
@ -89,10 +89,13 @@ def mock_anime_service(mock_series_app, tmp_path):
test_dir = tmp_path / "anime"
test_dir.mkdir()
# Create AnimeService with the mocked SeriesApp
service = AnimeService(series_app=mock_series_app)
service.download = AsyncMock(return_value=True)
return service
with patch(
"src.server.services.anime_service.SeriesApp",
return_value=mock_series_app
):
service = AnimeService(directory=str(test_dir))
service.download = AsyncMock(return_value=True)
yield service
@pytest.fixture
@ -150,18 +153,14 @@ class TestDownloadFlowEndToEnd:
if response.status_code == 200:
data = response.json()
# Verify response structure (status and statistics at top level)
assert "status" in data
# Verify status structure (updated for new response format)
assert "is_running" in data
assert "is_paused" in data
assert "pending_queue" in data
assert "active_downloads" in data
assert "completed_downloads" in data
assert "failed_downloads" in data
assert "statistics" in data
# Verify status fields
status_data = data["status"]
assert "is_running" in status_data
assert "is_paused" in status_data
assert "pending_queue" in status_data
assert "active_downloads" in status_data
assert "completed_downloads" in status_data
assert "failed_downloads" in status_data
async def test_add_with_different_priorities(self, authenticated_client):
"""Test adding episodes with different priority levels."""
@ -292,16 +291,14 @@ class TestDownloadProgressTracking:
if response.status_code == 200:
data = response.json()
# Updated for new nested response format
assert "status" in data
status_data = data["status"]
assert "active_downloads" in status_data
# Updated for new response format
assert "active_downloads" in data
# Check that items can have progress
for item in status_data.get("active_downloads", []):
for item in data.get("active_downloads", []):
if "progress" in item and item["progress"]:
assert "percent" in item["progress"]
assert "downloaded_mb" in item["progress"]
assert "percentage" in item["progress"]
assert "current_mb" in item["progress"]
assert "total_mb" in item["progress"]
async def test_queue_statistics(self, authenticated_client):
@ -320,7 +317,7 @@ class TestDownloadProgressTracking:
assert "active_count" in stats
assert "completed_count" in stats
assert "failed_count" in stats
# Note: success_rate not currently in QueueStats model
assert "success_rate" in stats
class TestErrorHandlingAndRetries:
@ -540,7 +537,7 @@ class TestCompleteDownloadWorkflow:
assert status_response.status_code in [200, 503]
# 3. Start queue processing
start_response = await authenticated_client.post("/api/queue/start")
start_response = await authenticated_client.post("/api/queue/control/start")
assert start_response.status_code in [200, 503]
# 4. Check status during processing

View File

@ -24,7 +24,7 @@ def mock_series_app():
app.search = Mock(return_value=[])
app.ReScan = Mock()
async def mock_download(
def mock_download(
serie_folder, season, episode, key, callback=None, **kwargs
):
"""Simulate download with realistic progress updates."""
@ -44,7 +44,7 @@ def mock_series_app():
result.message = "Download completed"
return result
app.download = mock_download
app.download = Mock(side_effect=mock_download)
return app
@ -63,11 +63,15 @@ def websocket_service():
@pytest.fixture
async def anime_service(mock_series_app, progress_service):
"""Create an AnimeService."""
service = AnimeService(
series_app=mock_series_app,
progress_service=progress_service,
)
yield service
with patch(
"src.server.services.anime_service.SeriesApp",
return_value=mock_series_app
):
service = AnimeService(
directory="/test/anime",
progress_service=progress_service,
)
yield service
@pytest.fixture
@ -87,42 +91,42 @@ class TestDownloadProgressIntegration:
@pytest.mark.asyncio
async def test_full_progress_flow_with_websocket(
self, download_service, websocket_service, progress_service
self, download_service, websocket_service
):
"""Test complete flow from download to WebSocket broadcast."""
# Track all messages sent via WebSocket
sent_messages: List[Dict[str, Any]] = []
# Mock WebSocket broadcast to room method
original_broadcast = websocket_service.manager.broadcast_to_room
# Mock WebSocket broadcast methods
original_broadcast_progress = (
websocket_service.broadcast_download_progress
)
async def mock_broadcast(message: dict, room: str):
async def mock_broadcast_progress(download_id: str, data: dict):
"""Capture broadcast calls."""
sent_messages.append({
'type': message.get('type'),
'data': message.get('data'),
'room': room,
'type': 'download_progress',
'download_id': download_id,
'data': data,
})
# Call original to maintain functionality
await original_broadcast(message, room)
await original_broadcast_progress(download_id, data)
websocket_service.manager.broadcast_to_room = mock_broadcast
# Subscribe to progress events and forward to WebSocket
async def progress_event_handler(event):
"""Handle progress events and broadcast via WebSocket."""
message = {
"type": event.event_type,
"data": event.progress.to_dict(),
}
await websocket_service.manager.broadcast_to_room(
message, event.room
)
progress_service.subscribe(
"progress_updated", progress_event_handler
websocket_service.broadcast_download_progress = (
mock_broadcast_progress
)
# Connect download service to WebSocket service
async def broadcast_callback(update_type: str, data: dict):
"""Bridge download service to WebSocket service."""
if update_type == "download_progress":
await websocket_service.broadcast_download_progress(
data.get("download_id", ""),
data,
)
download_service.set_broadcast_callback(broadcast_callback)
# Add download to queue
await download_service.add_to_queue(
serie_id="integration_test",
@ -137,19 +141,29 @@ class TestDownloadProgressIntegration:
# Wait for download to complete
await asyncio.sleep(1.0)
# Verify progress messages were sent (queue progress)
# Verify progress messages were sent
progress_messages = [
m for m in sent_messages
if 'queue_progress' in m.get('type', '')
m for m in sent_messages if m['type'] == 'download_progress'
]
# Should have queue progress updates
# (init + items added + processing started + item processing, etc.)
assert len(progress_messages) >= 2
assert len(progress_messages) >= 3 # Multiple progress updates
# Verify progress increases
percentages = [
m['data'].get('progress', {}).get('percent', 0)
for m in progress_messages
]
# Should have increasing percentages
for i in range(1, len(percentages)):
assert percentages[i] >= percentages[i - 1]
# Last update should be close to 100%
assert percentages[-1] >= 90
@pytest.mark.asyncio
async def test_websocket_client_receives_progress(
self, download_service, websocket_service, progress_service
self, download_service, websocket_service
):
"""Test that WebSocket clients receive progress messages."""
# Track messages received by clients
@ -176,25 +190,15 @@ class TestDownloadProgressIntegration:
connection_id = "test_client_1"
await websocket_service.connect(mock_ws, connection_id)
# Join the queue_progress room to receive queue updates
await websocket_service.manager.join_room(
connection_id, "queue_progress"
)
# Connect download service to WebSocket service
async def broadcast_callback(update_type: str, data: dict):
if update_type == "download_progress":
await websocket_service.broadcast_download_progress(
data.get("download_id", ""),
data,
)
# Subscribe to progress events and forward to WebSocket
async def progress_event_handler(event):
"""Handle progress events and broadcast via WebSocket."""
message = {
"type": event.event_type,
"data": event.progress.to_dict(),
}
await websocket_service.manager.broadcast_to_room(
message, event.room
)
progress_service.subscribe(
"progress_updated", progress_event_handler
)
download_service.set_broadcast_callback(broadcast_callback)
# Add and start download
await download_service.add_to_queue(
@ -207,20 +211,20 @@ class TestDownloadProgressIntegration:
await download_service.start_queue_processing()
await asyncio.sleep(1.0)
# Verify client received messages (queue progress events)
# Verify client received messages
progress_messages = [
m for m in client_messages
if 'queue_progress' in m.get('type', '')
if m.get('type') == 'download_progress'
]
assert len(progress_messages) >= 1
assert len(progress_messages) >= 2
# Cleanup
await websocket_service.disconnect(connection_id)
@pytest.mark.asyncio
async def test_multiple_clients_receive_same_progress(
self, download_service, websocket_service, progress_service
self, download_service, websocket_service
):
"""Test that all connected clients receive progress updates."""
# Track messages for each client
@ -249,28 +253,15 @@ class TestDownloadProgressIntegration:
await websocket_service.connect(client1, "client1")
await websocket_service.connect(client2, "client2")
# Join both clients to the queue_progress room
await websocket_service.manager.join_room(
"client1", "queue_progress"
)
await websocket_service.manager.join_room(
"client2", "queue_progress"
)
# Connect download service
async def broadcast_callback(update_type: str, data: dict):
if update_type == "download_progress":
await websocket_service.broadcast_download_progress(
data.get("download_id", ""),
data,
)
# Subscribe to progress events and forward to WebSocket
async def progress_event_handler(event):
"""Handle progress events and broadcast via WebSocket."""
message = {
"type": event.event_type,
"data": event.progress.to_dict(),
}
await websocket_service.manager.broadcast_to_room(
message, event.room
)
progress_service.subscribe(
"progress_updated", progress_event_handler
)
download_service.set_broadcast_callback(broadcast_callback)
# Start download
await download_service.add_to_queue(
@ -283,18 +274,21 @@ class TestDownloadProgressIntegration:
await download_service.start_queue_processing()
await asyncio.sleep(1.0)
# Both clients should receive progress (queue progress events)
# Both clients should receive progress
client1_progress = [
m for m in client1_messages
if 'queue_progress' in m.get('type', '')
if m.get('type') == 'download_progress'
]
client2_progress = [
m for m in client2_messages
if 'queue_progress' in m.get('type', '')
if m.get('type') == 'download_progress'
]
assert len(client1_progress) >= 1
assert len(client2_progress) >= 1
assert len(client1_progress) >= 2
assert len(client2_progress) >= 2
# Both should have similar number of updates
assert abs(len(client1_progress) - len(client2_progress)) <= 2
# Cleanup
await websocket_service.disconnect("client1")
@ -302,23 +296,20 @@ class TestDownloadProgressIntegration:
@pytest.mark.asyncio
async def test_progress_data_structure_matches_frontend_expectations(
self, download_service, websocket_service, progress_service
self, download_service, websocket_service
):
"""Test that progress data structure matches frontend requirements."""
captured_data: List[Dict] = []
async def capture_broadcast(event):
"""Capture progress events."""
captured_data.append(event.progress.to_dict())
message = {
"type": event.event_type,
"data": event.progress.to_dict(),
}
await websocket_service.manager.broadcast_to_room(
message, event.room
)
async def capture_broadcast(update_type: str, data: dict):
if update_type == "download_progress":
captured_data.append(data)
await websocket_service.broadcast_download_progress(
data.get("download_id", ""),
data,
)
progress_service.subscribe("progress_updated", capture_broadcast)
download_service.set_broadcast_callback(capture_broadcast)
await download_service.add_to_queue(
serie_id="structure_test",
@ -332,19 +323,29 @@ class TestDownloadProgressIntegration:
assert len(captured_data) > 0
# Verify data structure - it's now a ProgressUpdate dict
# Verify data structure matches frontend expectations
for data in captured_data:
# Required fields in ProgressUpdate
assert 'id' in data
assert 'type' in data
assert 'status' in data
assert 'title' in data
assert 'percent' in data
assert 'metadata' in data
# Required fields for frontend (queue.js)
assert 'download_id' in data or 'item_id' in data
assert 'serie_name' in data
assert 'season' in data
assert 'episode' in data
assert 'progress' in data
# Progress object structure
progress = data['progress']
assert 'percent' in progress
assert 'downloaded_mb' in progress
assert 'total_mb' in progress
# Verify episode info
assert data['season'] == 2
assert data['episode'] == 3
assert data['serie_name'] == "Structure Test"
@pytest.mark.asyncio
async def test_disconnected_client_doesnt_receive_progress(
self, download_service, websocket_service, progress_service
self, download_service, websocket_service
):
"""Test that disconnected clients don't receive updates."""
client_messages: List[Dict] = []
@ -366,20 +367,15 @@ class TestDownloadProgressIntegration:
await websocket_service.connect(mock_ws, connection_id)
await websocket_service.disconnect(connection_id)
# Subscribe to progress events and forward to WebSocket
async def progress_event_handler(event):
"""Handle progress events and broadcast via WebSocket."""
message = {
"type": event.event_type,
"data": event.progress.to_dict(),
}
await websocket_service.manager.broadcast_to_room(
message, event.room
)
# Connect download service
async def broadcast_callback(update_type: str, data: dict):
if update_type == "download_progress":
await websocket_service.broadcast_download_progress(
data.get("download_id", ""),
data,
)
progress_service.subscribe(
"progress_updated", progress_event_handler
)
download_service.set_broadcast_callback(broadcast_callback)
# Start download after disconnect
await download_service.add_to_queue(
@ -396,7 +392,7 @@ class TestDownloadProgressIntegration:
# Should not receive progress updates after disconnect
progress_messages = [
m for m in client_messages[initial_message_count:]
if 'queue_progress' in m.get('type', '')
if m.get('type') == 'download_progress'
]
assert len(progress_messages) == 0

View File

@ -26,28 +26,15 @@ def mock_series_app():
"""Mock SeriesApp for testing."""
app = Mock()
app.series_list = []
async def mock_search():
return []
async def mock_rescan():
pass
async def mock_download(*args, **kwargs):
return True
app.search = mock_search
app.rescan = mock_rescan
app.download = mock_download
app.search = Mock(return_value=[])
app.ReScan = Mock()
app.download = Mock(return_value=True)
return app
@pytest.fixture
def progress_service():
"""Create a ProgressService instance for testing.
Each test gets its own instance to avoid state pollution.
"""
"""Create a ProgressService instance for testing."""
return ProgressService()
@ -60,27 +47,23 @@ def websocket_service():
@pytest.fixture
async def anime_service(mock_series_app, progress_service):
"""Create an AnimeService with mocked dependencies."""
service = AnimeService(
series_app=mock_series_app,
progress_service=progress_service,
)
yield service
with patch("src.server.services.anime_service.SeriesApp", return_value=mock_series_app):
service = AnimeService(
directory="/test/anime",
progress_service=progress_service,
)
yield service
@pytest.fixture
async def download_service(anime_service, progress_service, tmp_path):
"""Create a DownloadService with dependencies.
Uses tmp_path to ensure each test has isolated queue storage.
"""
import uuid
persistence_path = tmp_path / f"test_queue_{uuid.uuid4()}.json"
async def download_service(anime_service, progress_service):
"""Create a DownloadService with dependencies."""
service = DownloadService(
anime_service=anime_service,
progress_service=progress_service,
persistence_path=str(persistence_path),
persistence_path="/tmp/test_queue.json",
)
yield service, progress_service
yield service
await service.stop()
@ -92,146 +75,114 @@ class TestWebSocketDownloadIntegration:
self, download_service, websocket_service
):
"""Test that download progress updates are broadcasted."""
download_svc, progress_svc = download_service
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
"""Capture progress events."""
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
async def mock_broadcast(update_type: str, data: dict):
"""Capture broadcast calls."""
broadcasts.append({"type": update_type, "data": data})
# Subscribe to progress events
progress_svc.subscribe("progress_updated", mock_event_handler)
download_service.set_broadcast_callback(mock_broadcast)
# Add item to queue
item_ids = await download_svc.add_to_queue(
item_ids = await download_service.add_to_queue(
serie_id="test_serie",
serie_folder="test_serie",
serie_name="Test Anime",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.HIGH,
)
assert len(item_ids) == 1
# Should have at least one event (queue init + items_added)
assert len(broadcasts) >= 1
# Check that queue progress event was emitted
items_added_events = [
b for b in broadcasts
if b["data"]["metadata"].get("action") == "items_added"
]
assert len(items_added_events) >= 1
assert items_added_events[0]["type"] == "queue_progress"
assert len(broadcasts) == 1
assert broadcasts[0]["type"] == "queue_status"
assert broadcasts[0]["data"]["action"] == "items_added"
assert item_ids[0] in broadcasts[0]["data"]["added_ids"]
@pytest.mark.asyncio
async def test_queue_operations_broadcast(
self, download_service
):
"""Test that queue operations emit progress events."""
download_svc, progress_svc = download_service
"""Test that queue operations broadcast status updates."""
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
async def mock_broadcast(update_type: str, data: dict):
broadcasts.append({"type": update_type, "data": data})
progress_svc.subscribe("progress_updated", mock_event_handler)
download_service.set_broadcast_callback(mock_broadcast)
# Add items
item_ids = await download_svc.add_to_queue(
item_ids = await download_service.add_to_queue(
serie_id="test",
serie_folder="test",
serie_name="Test",
episodes=[
EpisodeIdentifier(season=1, episode=i)
for i in range(1, 4)
],
episodes=[EpisodeIdentifier(season=1, episode=i) for i in range(1, 4)],
priority=DownloadPriority.NORMAL,
)
# Remove items
removed = await download_svc.remove_from_queue([item_ids[0]])
removed = await download_service.remove_from_queue([item_ids[0]])
assert len(removed) == 1
# Check broadcasts
add_broadcast = None
remove_broadcast = None
for b in broadcasts:
if b["data"]["metadata"].get("action") == "items_added":
add_broadcast = b
if b["data"]["metadata"].get("action") == "items_removed":
remove_broadcast = b
add_broadcast = next(
b for b in broadcasts
if b["data"].get("action") == "items_added"
)
remove_broadcast = next(
b for b in broadcasts
if b["data"].get("action") == "items_removed"
)
assert add_broadcast is not None
assert add_broadcast["type"] == "queue_progress"
assert len(add_broadcast["data"]["metadata"]["added_ids"]) == 3
assert add_broadcast["type"] == "queue_status"
assert len(add_broadcast["data"]["added_ids"]) == 3
assert remove_broadcast is not None
assert remove_broadcast["type"] == "queue_progress"
removed_ids = remove_broadcast["data"]["metadata"]["removed_ids"]
assert item_ids[0] in removed_ids
assert remove_broadcast["type"] == "queue_status"
assert item_ids[0] in remove_broadcast["data"]["removed_ids"]
@pytest.mark.asyncio
async def test_queue_start_stop_broadcast(
self, download_service
):
"""Test that queue operations with items emit progress events."""
download_svc, progress_svc = download_service
"""Test that start/stop operations broadcast updates."""
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
async def mock_broadcast(update_type: str, data: dict):
broadcasts.append({"type": update_type, "data": data})
progress_svc.subscribe("progress_updated", mock_event_handler)
download_service.set_broadcast_callback(mock_broadcast)
# Add an item to initialize the queue progress
await download_svc.add_to_queue(
serie_id="test",
serie_folder="test",
serie_name="Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
# Start queue
await download_service.start()
await asyncio.sleep(0.1)
# Stop queue
await download_service.stop()
# Find start/stop broadcasts
start_broadcast = next(
(b for b in broadcasts if b["type"] == "queue_started"),
None,
)
stop_broadcast = next(
(b for b in broadcasts if b["type"] == "queue_stopped"),
None,
)
# Find start/stop broadcasts (queue progress events)
queue_broadcasts = [
b for b in broadcasts if b["type"] == "queue_progress"
]
# Should have at least 2 queue progress updates
# (init + items_added)
assert len(queue_broadcasts) >= 2
assert start_broadcast is not None
assert start_broadcast["data"]["is_running"] is True
assert stop_broadcast is not None
assert stop_broadcast["data"]["is_running"] is False
@pytest.mark.asyncio
async def test_clear_completed_broadcast(
self, download_service
):
"""Test that clearing completed items emits progress event."""
download_svc, progress_svc = download_service
"""Test that clearing completed items broadcasts update."""
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
async def mock_broadcast(update_type: str, data: dict):
broadcasts.append({"type": update_type, "data": data})
progress_svc.subscribe("progress_updated", mock_event_handler)
# Initialize the download queue progress by adding an item
await download_svc.add_to_queue(
serie_id="test",
serie_folder="test",
serie_name="Test Init",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
download_service.set_broadcast_callback(mock_broadcast)
# Manually add a completed item to test
from datetime import datetime, timezone
@ -242,29 +193,29 @@ class TestWebSocketDownloadIntegration:
id="test_completed",
serie_id="test",
serie_name="Test",
serie_folder="Test",
episode=EpisodeIdentifier(season=1, episode=1),
status=DownloadStatus.COMPLETED,
priority=DownloadPriority.NORMAL,
added_at=datetime.now(timezone.utc),
)
download_svc._completed_items.append(completed_item)
download_service._completed_items.append(completed_item)
# Clear completed
count = await download_svc.clear_completed()
count = await download_service.clear_completed()
assert count == 1
# Find clear broadcast (queue progress event)
clear_broadcast = None
for b in broadcasts:
if b["data"]["metadata"].get("action") == "completed_cleared":
clear_broadcast = b
break
# Find clear broadcast
clear_broadcast = next(
(
b for b in broadcasts
if b["data"].get("action") == "completed_cleared"
),
None,
)
assert clear_broadcast is not None
metadata = clear_broadcast["data"]["metadata"]
assert metadata["cleared_count"] == 1
assert clear_broadcast["data"]["cleared_count"] == 1
class TestWebSocketScanIntegration:
@ -274,41 +225,27 @@ class TestWebSocketScanIntegration:
async def test_scan_progress_broadcast(
self, anime_service, progress_service, mock_series_app
):
"""Test that scan progress updates emit events."""
"""Test that scan progress updates are broadcasted."""
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
"""Capture progress events."""
async def mock_broadcast(message_type: str, data: dict, room: str):
"""Capture broadcast calls."""
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict(),
"room": event.room,
"type": message_type,
"data": data,
"room": room,
})
# Subscribe to progress events
progress_service.subscribe("progress_updated", mock_event_handler)
progress_service.set_broadcast_callback(mock_broadcast)
# Mock async rescan
async def mock_rescan():
# Mock scan callback to simulate progress
def mock_scan_callback(callback):
"""Simulate scan progress."""
# Trigger progress events via progress_service
await progress_service.start_progress(
progress_id="scan_test",
progress_type=ProgressType.SCAN,
title="Scanning library",
total=10,
)
await progress_service.update_progress(
progress_id="scan_test",
current=5,
message="Scanning...",
)
await progress_service.complete_progress(
progress_id="scan_test",
message="Complete",
)
if callback:
callback({"current": 5, "total": 10, "message": "Scanning..."})
callback({"current": 10, "total": 10, "message": "Complete"})
mock_series_app.rescan = mock_rescan
mock_series_app.ReScan = mock_scan_callback
# Run scan
await anime_service.rescan()
@ -338,33 +275,20 @@ class TestWebSocketScanIntegration:
"""Test that scan failures are broadcasted."""
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
"""Capture progress events."""
async def mock_broadcast(message_type: str, data: dict, room: str):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict(),
"room": event.room,
"type": message_type,
"data": data,
"room": room,
})
progress_service.subscribe("progress_updated", mock_event_handler)
progress_service.set_broadcast_callback(mock_broadcast)
# Mock async rescan to emit start event then fail
async def mock_scan_error():
# Emit start event
await progress_service.start_progress(
progress_id="library_scan",
progress_type=ProgressType.SCAN,
title="Scanning anime library",
message="Initializing scan...",
)
# Then fail
await progress_service.fail_progress(
progress_id="library_scan",
error_message="Scan failed",
)
# Mock scan to raise error
def mock_scan_error(callback):
raise RuntimeError("Scan failed")
mock_series_app.rescan = mock_scan_error
mock_series_app.ReScan = mock_scan_error
# Run scan (should fail)
with pytest.raises(Exception):
@ -392,17 +316,17 @@ class TestWebSocketProgressIntegration:
async def test_progress_lifecycle_broadcast(
self, progress_service
):
"""Test that progress lifecycle events emit properly."""
"""Test that progress lifecycle events are broadcasted."""
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
async def mock_broadcast(message_type: str, data: dict, room: str):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict(),
"room": event.room,
"type": message_type,
"data": data,
"room": room,
})
progress_service.subscribe("progress_updated", mock_event_handler)
progress_service.set_broadcast_callback(mock_broadcast)
# Start progress
await progress_service.start_progress(
@ -448,45 +372,63 @@ class TestWebSocketEndToEnd:
async def test_complete_download_flow_with_broadcasts(
self, download_service, anime_service, progress_service
):
"""Test complete download flow with all progress events."""
download_svc, _ = download_service
"""Test complete download flow with all broadcasts."""
all_broadcasts: List[Dict[str, Any]] = []
async def capture_event(event):
async def capture_download_broadcast(update_type: str, data: dict):
all_broadcasts.append({
"source": "progress",
"type": event.event_type,
"data": event.progress.to_dict(),
"room": event.room,
"source": "download",
"type": update_type,
"data": data,
})
progress_service.subscribe("progress_updated", capture_event)
async def capture_progress_broadcast(
message_type: str, data: dict, room: str
):
all_broadcasts.append({
"source": "progress",
"type": message_type,
"data": data,
"room": room,
})
download_service.set_broadcast_callback(capture_download_broadcast)
progress_service.set_broadcast_callback(capture_progress_broadcast)
# Add items to queue
item_ids = await download_svc.add_to_queue(
item_ids = await download_service.add_to_queue(
serie_id="test",
serie_folder="test",
serie_name="Test Anime",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.HIGH,
)
# Start queue
await download_svc.start()
await download_service.start()
await asyncio.sleep(0.1)
# Stop queue
await download_svc.stop()
# Pause queue
await download_service.pause_queue()
# Verify we received events
assert len(all_broadcasts) >= 1
# Resume queue
await download_service.resume_queue()
# Stop queue
await download_service.stop()
# Verify we received broadcasts from both services
download_broadcasts = [
b for b in all_broadcasts if b["source"] == "download"
]
assert len(download_broadcasts) >= 4 # add, start, pause, resume, stop
assert len(item_ids) == 1
# Verify queue progress broadcasts
queue_events = [
b for b in all_broadcasts if b["type"] == "queue_progress"
# Verify queue status broadcasts
queue_status_broadcasts = [
b for b in download_broadcasts if b["type"] == "queue_status"
]
assert len(queue_events) >= 1
assert len(queue_status_broadcasts) >= 1
if __name__ == "__main__":

View File

@ -32,6 +32,7 @@ class TestDownloadQueueStress:
persistence_path = str(tmp_path / "test_queue.json")
service = DownloadService(
anime_service=mock_anime_service,
max_concurrent_downloads=10,
max_retries=3,
persistence_path=persistence_path,
)
@ -48,7 +49,6 @@ class TestDownloadQueueStress:
tasks = [
download_service.add_to_queue(
serie_id=f"series-{i}",
serie_folder=f"series_{i}",
serie_name=f"Test Series {i}",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
@ -79,8 +79,7 @@ class TestDownloadQueueStress:
try:
await download_service.add_to_queue(
serie_id=f"series-{i}",
serie_folder=f"series_folder",
serie_name=f"Test Series {i}",
serie_name=f"Test Series {i}",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
)
@ -104,8 +103,7 @@ class TestDownloadQueueStress:
operations.append(
download_service.add_to_queue(
serie_id=f"series-{i}",
serie_folder=f"series_folder",
serie_name=f"Test Series {i}",
serie_name=f"Test Series {i}",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
)
@ -139,7 +137,6 @@ class TestDownloadQueueStress:
for i in range(10):
await download_service.add_to_queue(
serie_id=f"series-{i}",
serie_folder=f"series_folder",
serie_name=f"Test Series {i}",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
@ -180,6 +177,7 @@ class TestDownloadMemoryUsage:
persistence_path = str(tmp_path / "test_queue.json")
service = DownloadService(
anime_service=mock_anime_service,
max_concurrent_downloads=10,
max_retries=3,
persistence_path=persistence_path,
)
@ -196,7 +194,6 @@ class TestDownloadMemoryUsage:
for i in range(1000):
await download_service.add_to_queue(
serie_id=f"series-{i}",
serie_folder=f"series_folder",
serie_name=f"Test Series {i}",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
@ -236,6 +233,7 @@ class TestDownloadConcurrency:
persistence_path = str(tmp_path / "test_queue.json")
service = DownloadService(
anime_service=mock_anime_service,
max_concurrent_downloads=10,
max_retries=3,
persistence_path=persistence_path,
)
@ -251,7 +249,6 @@ class TestDownloadConcurrency:
tasks = [
download_service.add_to_queue(
serie_id=f"series-{i}",
serie_folder=f"series_folder",
serie_name=f"Test Series {i}",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
@ -278,22 +275,19 @@ class TestDownloadConcurrency:
# Add downloads with different priorities
await download_service.add_to_queue(
serie_id="series-1",
serie_folder=f"series_folder",
serie_name="Test Series 1",
serie_name="Test Series 1",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.LOW,
)
await download_service.add_to_queue(
serie_id="series-2",
serie_folder=f"series_folder",
serie_name="Test Series 2",
serie_name="Test Series 2",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.HIGH,
)
await download_service.add_to_queue(
serie_id="series-3",
serie_folder=f"series_folder",
serie_name="Test Series 3",
serie_name="Test Series 3",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
)
@ -324,6 +318,7 @@ class TestDownloadErrorHandling:
persistence_path = str(tmp_path / "test_queue.json")
service = DownloadService(
anime_service=mock_failing_anime_service,
max_concurrent_downloads=10,
max_retries=3,
persistence_path=persistence_path,
)
@ -342,6 +337,7 @@ class TestDownloadErrorHandling:
persistence_path = str(tmp_path / "test_queue.json")
service = DownloadService(
anime_service=mock_anime_service,
max_concurrent_downloads=10,
max_retries=3,
persistence_path=persistence_path,
)
@ -356,7 +352,6 @@ class TestDownloadErrorHandling:
for i in range(50):
await download_service_failing.add_to_queue(
serie_id=f"series-{i}",
serie_folder=f"series_folder",
serie_name=f"Test Series {i}",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
@ -378,8 +373,7 @@ class TestDownloadErrorHandling:
# System should still work
await download_service.add_to_queue(
serie_id="series-1",
serie_folder=f"series_folder",
serie_name="Test Series 1",
serie_name="Test Series 1",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
)

View File

@ -20,22 +20,9 @@ class TestInputValidation:
"""Create async HTTP client for testing."""
from httpx import ASGITransport
from src.server.services.auth_service import auth_service
# Ensure auth is configured
if not auth_service.is_configured():
auth_service.setup_master_password("TestPass123!")
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
# Login to get token
r = await ac.post(
"/api/auth/login", json={"password": "TestPass123!"}
)
if r.status_code == 200:
token = r.json()["access_token"]
ac.headers["Authorization"] = f"Bearer {token}"
yield ac
@pytest.mark.asyncio
@ -70,17 +57,12 @@ class TestInputValidation:
huge_string = "A" * 1000000 # 1MB of data
response = await client.post(
"/api/queue/add",
json={
"serie_id": huge_string,
"serie_name": "Test",
"episodes": [{"season": 1, "episode": 1}],
},
"/api/anime",
json={"title": huge_string, "description": "Test"},
)
# Currently accepts large inputs - TODO: Add size limits
# Should reject or truncate in future
assert response.status_code in [200, 201, 400, 413, 422]
# Should reject or truncate
assert response.status_code in [400, 413, 422]
@pytest.mark.asyncio
async def test_null_byte_injection(self, client):
@ -150,12 +132,11 @@ class TestInputValidation:
):
"""Test handling of negative numbers in inappropriate contexts."""
response = await client.post(
"/api/queue/add",
"/api/downloads",
json={
"serie_id": "test",
"serie_name": "Test Series",
"episodes": [{"season": -1, "episode": -5}],
"priority": "normal",
"anime_id": -1,
"episode_number": -5,
"priority": -10,
},
)
@ -218,11 +199,10 @@ class TestInputValidation:
async def test_array_injection(self, client):
"""Test handling of array inputs in unexpected places."""
response = await client.post(
"/api/queue/add",
"/api/anime",
json={
"serie_id": ["array", "instead", "of", "string"],
"serie_name": "Test",
"episodes": [{"season": 1, "episode": 1}],
"title": ["array", "instead", "of", "string"],
"description": "Test",
},
)

View File

@ -6,7 +6,7 @@ error handling, and progress reporting integration.
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -15,17 +15,16 @@ from src.server.services.progress_service import ProgressService
@pytest.fixture
def mock_series_app(tmp_path):
def mock_series_app():
"""Create a mock SeriesApp instance."""
mock_instance = MagicMock()
mock_instance.directory_to_search = str(tmp_path)
mock_instance.series_list = []
mock_instance.search = AsyncMock(return_value=[])
mock_instance.rescan = AsyncMock()
mock_instance.download = AsyncMock(return_value=True)
mock_instance.download_status = None
mock_instance.scan_status = None
return mock_instance
with patch("src.server.services.anime_service.SeriesApp") as mock_class:
mock_instance = MagicMock()
mock_instance.series_list = []
mock_instance.search = MagicMock(return_value=[])
mock_instance.ReScan = MagicMock()
mock_instance.download = MagicMock(return_value=True)
mock_class.return_value = mock_instance
yield mock_instance
@pytest.fixture
@ -43,7 +42,8 @@ def mock_progress_service():
def anime_service(tmp_path, mock_series_app, mock_progress_service):
"""Create an AnimeService instance for testing."""
return AnimeService(
series_app=mock_series_app,
directory=str(tmp_path),
max_workers=2,
progress_service=mock_progress_service,
)
@ -51,40 +51,35 @@ def anime_service(tmp_path, mock_series_app, mock_progress_service):
class TestAnimeServiceInitialization:
"""Test AnimeService initialization."""
def test_initialization_success(
self, mock_series_app, mock_progress_service
):
def test_initialization_success(self, tmp_path, mock_progress_service):
"""Test successful service initialization."""
service = AnimeService(
series_app=mock_series_app,
progress_service=mock_progress_service,
)
assert service._app is mock_series_app
assert service._progress_service is mock_progress_service
with patch("src.server.services.anime_service.SeriesApp"):
service = AnimeService(
directory=str(tmp_path),
max_workers=2,
progress_service=mock_progress_service,
)
assert service._directory == str(tmp_path)
assert service._executor is not None
assert service._progress_service is mock_progress_service
def test_initialization_failure_raises_error(
self, tmp_path, mock_progress_service
):
"""Test SeriesApp initialization failure raises error."""
bad_series_app = MagicMock()
bad_series_app.directory_to_search = str(tmp_path)
# Make event subscription fail by raising on property access
type(bad_series_app).download_status = property(
lambda self: None,
lambda self, value: (_ for _ in ()).throw(
Exception("Initialization failed")
)
)
with pytest.raises(
AnimeServiceError, match="Initialization failed"
):
AnimeService(
series_app=bad_series_app,
progress_service=mock_progress_service,
)
with patch(
"src.server.services.anime_service.SeriesApp"
) as mock_class:
mock_class.side_effect = Exception("Initialization failed")
with pytest.raises(
AnimeServiceError, match="Initialization failed"
):
AnimeService(
directory=str(tmp_path),
progress_service=mock_progress_service,
)
class TestListMissing:
@ -185,18 +180,35 @@ class TestRescan:
"""Test successful rescan operation."""
await anime_service.rescan()
# Verify SeriesApp.rescan was called (lowercase, not ReScan)
mock_series_app.rescan.assert_called_once()
# Verify SeriesApp.ReScan was called
mock_series_app.ReScan.assert_called_once()
# Verify progress tracking
mock_progress_service.start_progress.assert_called_once()
mock_progress_service.complete_progress.assert_called_once()
@pytest.mark.asyncio
async def test_rescan_with_callback(self, anime_service, mock_series_app):
"""Test rescan operation (callback parameter removed)."""
# Rescan no longer accepts callback parameter
# Progress is tracked via event handlers automatically
await anime_service.rescan()
"""Test rescan with progress callback."""
callback_called = False
callback_data = None
# Verify rescan was called
mock_series_app.rescan.assert_called_once()
def callback(data):
nonlocal callback_called, callback_data
callback_called = True
callback_data = data
# Mock ReScan to call the callback
def mock_rescan(cb):
if cb:
cb({"current": 5, "total": 10, "message": "Scanning..."})
mock_series_app.ReScan.side_effect = mock_rescan
await anime_service.rescan(callback=callback)
assert callback_called
assert callback_data is not None
@pytest.mark.asyncio
async def test_rescan_clears_cache(self, anime_service, mock_series_app):
@ -220,10 +232,13 @@ class TestRescan:
self, anime_service, mock_series_app, mock_progress_service
):
"""Test error handling during rescan."""
mock_series_app.rescan.side_effect = Exception("Rescan failed")
mock_series_app.ReScan.side_effect = Exception("Rescan failed")
with pytest.raises(AnimeServiceError, match="Rescan failed"):
await anime_service.rescan()
# Verify progress failure was recorded
mock_progress_service.fail_progress.assert_called_once()
class TestDownload:
@ -243,19 +258,13 @@ class TestDownload:
assert result is True
mock_series_app.download.assert_called_once_with(
serie_folder="test_series",
season=1,
episode=1,
key="test_key",
"test_series", 1, 1, "test_key", None
)
@pytest.mark.asyncio
async def test_download_with_callback(
self, anime_service, mock_series_app
):
"""Test download operation (callback parameter removed)."""
# Download no longer accepts callback parameter
# Progress is tracked via event handlers automatically
async def test_download_with_callback(self, anime_service, mock_series_app):
"""Test download with progress callback."""
callback = MagicMock()
mock_series_app.download.return_value = True
result = await anime_service.download(
@ -263,21 +272,17 @@ class TestDownload:
season=1,
episode=1,
key="test_key",
callback=callback,
)
assert result is True
# Verify download was called with correct parameters
# Verify callback was passed to SeriesApp
mock_series_app.download.assert_called_once_with(
serie_folder="test_series",
season=1,
episode=1,
key="test_key",
"test_series", 1, 1, "test_key", callback
)
@pytest.mark.asyncio
async def test_download_error_handling(
self, anime_service, mock_series_app
):
async def test_download_error_handling(self, anime_service, mock_series_app):
"""Test error handling during download."""
mock_series_app.download.side_effect = Exception("Download failed")
@ -316,12 +321,12 @@ class TestConcurrency:
class TestFactoryFunction:
"""Test factory function."""
def test_get_anime_service(self, mock_series_app):
def test_get_anime_service(self, tmp_path):
"""Test get_anime_service factory function."""
from src.server.services.anime_service import get_anime_service
# The factory function requires a series_app parameter
service = get_anime_service(mock_series_app)
assert isinstance(service, AnimeService)
assert service._app is mock_series_app
with patch("src.server.services.anime_service.SeriesApp"):
service = get_anime_service(directory=str(tmp_path))
assert isinstance(service, AnimeService)
assert service._directory == str(tmp_path)

View File

@ -48,15 +48,15 @@ class TestDownloadPriority:
def test_all_priorities_exist(self):
"""Test that all expected priorities are defined."""
assert DownloadPriority.LOW == "LOW"
assert DownloadPriority.NORMAL == "NORMAL"
assert DownloadPriority.HIGH == "HIGH"
assert DownloadPriority.LOW == "low"
assert DownloadPriority.NORMAL == "normal"
assert DownloadPriority.HIGH == "high"
def test_priority_values(self):
"""Test that priority values are uppercase strings."""
"""Test that priority values are lowercase strings."""
for priority in DownloadPriority:
assert isinstance(priority.value, str)
assert priority.value.isupper()
assert priority.value.islower()
class TestEpisodeIdentifier:

View File

@ -10,7 +10,11 @@ from unittest.mock import Mock, patch
import pytest
from src.server.models.download import DownloadPriority, EpisodeIdentifier
from src.server.models.download import (
DownloadPriority,
DownloadProgress,
EpisodeIdentifier,
)
from src.server.services.anime_service import AnimeService
from src.server.services.download_service import DownloadService
from src.server.services.progress_service import ProgressService
@ -19,60 +23,45 @@ from src.server.services.progress_service import ProgressService
@pytest.fixture
def mock_series_app():
"""Mock SeriesApp for testing."""
from unittest.mock import MagicMock
app = MagicMock()
app = Mock()
app.series_list = []
app.search = Mock(return_value=[])
app.ReScan = Mock()
# Create mock event handlers that can be assigned
app.download_status = None
app.scan_status = None
# Mock download with event triggering
async def mock_download(
serie_folder, season, episode, key, **kwargs
# Mock download with progress callback
def mock_download(
serie_folder, season, episode, key, callback=None, **kwargs
):
"""Simulate download with events."""
# Create event args that mimic SeriesApp's DownloadStatusEventArgs
class MockDownloadArgs:
def __init__(
self, status, serie_folder, season, episode,
progress=None, message=None, error=None
):
self.status = status
self.serie_folder = serie_folder
self.season = season
self.episode = episode
self.progress = progress
self.message = message
self.error = error
"""Simulate download with progress updates."""
if callback:
# Simulate progress updates
callback({
'percent': 25.0,
'downloaded_mb': 25.0,
'total_mb': 100.0,
'speed_mbps': 2.5,
'eta_seconds': 30,
})
callback({
'percent': 50.0,
'downloaded_mb': 50.0,
'total_mb': 100.0,
'speed_mbps': 2.5,
'eta_seconds': 20,
})
callback({
'percent': 100.0,
'downloaded_mb': 100.0,
'total_mb': 100.0,
'speed_mbps': 2.5,
'eta_seconds': 0,
})
# Trigger started event
if app.download_status:
app.download_status(MockDownloadArgs(
"started", serie_folder, season, episode
))
# Simulate progress updates
progress_values = [25.0, 50.0, 75.0, 100.0]
for progress in progress_values:
if app.download_status:
await asyncio.sleep(0.01) # Small delay
app.download_status(MockDownloadArgs(
"progress", serie_folder, season, episode,
progress=progress,
message=f"Downloading... {progress}%"
))
# Trigger completed event
if app.download_status:
app.download_status(MockDownloadArgs(
"completed", serie_folder, season, episode
))
return True
# Return success result
result = Mock()
result.success = True
result.message = "Download completed"
return result
app.download = Mock(side_effect=mock_download)
return app
@ -87,36 +76,27 @@ def progress_service():
@pytest.fixture
async def anime_service(mock_series_app, progress_service):
"""Create an AnimeService with mocked dependencies."""
service = AnimeService(
series_app=mock_series_app,
progress_service=progress_service,
)
yield service
with patch(
"src.server.services.anime_service.SeriesApp",
return_value=mock_series_app
):
service = AnimeService(
directory="/test/anime",
progress_service=progress_service,
)
yield service
@pytest.fixture
async def download_service(anime_service, progress_service):
"""Create a DownloadService with dependencies."""
import os
persistence_path = "/tmp/test_download_progress_queue.json"
# Remove any existing queue file
if os.path.exists(persistence_path):
os.remove(persistence_path)
service = DownloadService(
anime_service=anime_service,
progress_service=progress_service,
persistence_path=persistence_path,
persistence_path="/tmp/test_download_progress_queue.json",
)
yield service, progress_service
yield service
await service.stop()
# Clean up after test
if os.path.exists(persistence_path):
os.remove(persistence_path)
class TestDownloadProgressWebSocket:
@ -126,24 +106,19 @@ class TestDownloadProgressWebSocket:
async def test_progress_callback_broadcasts_updates(
self, download_service
):
"""Test that progress updates are emitted via events."""
download_svc, progress_svc = download_service
"""Test that progress callback broadcasts updates via WebSocket."""
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
"""Capture progress events."""
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
async def mock_broadcast(update_type: str, data: dict):
"""Capture broadcast calls."""
broadcasts.append({"type": update_type, "data": data})
# Subscribe to progress_updated events
progress_svc.subscribe("progress_updated", mock_event_handler)
download_service.set_broadcast_callback(mock_broadcast)
# Add item to queue
item_ids = await download_svc.add_to_queue(
item_ids = await download_service.add_to_queue(
serie_id="test_serie_1",
serie_folder="test_serie_1",
serie_folder="test_folder",
serie_name="Test Anime",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
@ -152,13 +127,13 @@ class TestDownloadProgressWebSocket:
assert len(item_ids) == 1
# Start processing - this should trigger download with progress
result = await download_svc.start_queue_processing()
result = await download_service.start_queue_processing()
assert result is not None
# Wait for download to process
await asyncio.sleep(0.5)
# Filter download progress broadcasts
# Filter progress broadcasts
progress_broadcasts = [
b for b in broadcasts if b["type"] == "download_progress"
]
@ -166,41 +141,41 @@ class TestDownloadProgressWebSocket:
# Should have received multiple progress updates
assert len(progress_broadcasts) >= 2
# Verify progress data structure (Progress model format)
# Verify progress data structure
for broadcast in progress_broadcasts:
data = broadcast["data"]
assert "id" in data # Progress ID
assert "type" in data # Progress type
# Progress events use 'current' and 'total'
assert "current" in data or "message" in data
assert "download_id" in data or "item_id" in data
assert "progress" in data
progress = data["progress"]
assert "percent" in progress
assert "downloaded_mb" in progress
assert "total_mb" in progress
assert 0 <= progress["percent"] <= 100
@pytest.mark.asyncio
async def test_progress_updates_include_episode_info(
self, download_service
):
"""Test that progress updates include episode information."""
download_svc, progress_svc = download_service
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
async def mock_broadcast(update_type: str, data: dict):
broadcasts.append({"type": update_type, "data": data})
progress_svc.subscribe("progress_updated", mock_event_handler)
download_service.set_broadcast_callback(mock_broadcast)
# Add item with specific episode info
await download_svc.add_to_queue(
await download_service.add_to_queue(
serie_id="test_serie_2",
serie_folder="test_serie_2",
serie_folder="test_folder",
serie_name="My Test Anime",
episodes=[EpisodeIdentifier(season=2, episode=5)],
priority=DownloadPriority.HIGH,
)
# Start processing
await download_svc.start_queue_processing()
await download_service.start_queue_processing()
await asyncio.sleep(0.5)
# Find progress broadcasts
@ -210,34 +185,30 @@ class TestDownloadProgressWebSocket:
assert len(progress_broadcasts) > 0
# Verify progress info is included
# Verify episode info is included
data = progress_broadcasts[0]["data"]
assert "id" in data
# ID should contain folder name: download_test_serie_2_2_5
assert "test_serie_2" in data["id"]
assert data["serie_name"] == "My Test Anime"
assert data["season"] == 2
assert data["episode"] == 5
@pytest.mark.asyncio
async def test_progress_percent_increases(self, download_service):
"""Test that progress percentage increases over time."""
download_svc, progress_svc = download_service
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
async def mock_broadcast(update_type: str, data: dict):
broadcasts.append({"type": update_type, "data": data})
progress_svc.subscribe("progress_updated", mock_event_handler)
download_service.set_broadcast_callback(mock_broadcast)
await download_svc.add_to_queue(
await download_service.add_to_queue(
serie_id="test_serie_3",
serie_folder="test_serie_3",
serie_folder="test_folder",
serie_name="Progress Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
await download_svc.start_queue_processing()
await download_service.start_queue_processing()
await asyncio.sleep(0.5)
# Get progress broadcasts in order
@ -248,37 +219,33 @@ class TestDownloadProgressWebSocket:
# Verify we have multiple updates
assert len(progress_broadcasts) >= 2
# Verify progress increases (using current value)
current_values = [
b["data"].get("current", 0) for b in progress_broadcasts
# Verify progress increases
percentages = [
b["data"]["progress"]["percent"] for b in progress_broadcasts
]
# Each current value should be >= the previous one
for i in range(1, len(current_values)):
assert current_values[i] >= current_values[i - 1]
# Each percentage should be >= the previous one
for i in range(1, len(percentages)):
assert percentages[i] >= percentages[i - 1]
@pytest.mark.asyncio
async def test_progress_includes_speed_and_eta(self, download_service):
"""Test that progress updates include speed and ETA."""
download_svc, progress_svc = download_service
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
async def mock_broadcast(update_type: str, data: dict):
broadcasts.append({"type": update_type, "data": data})
progress_svc.subscribe("progress_updated", mock_event_handler)
download_service.set_broadcast_callback(mock_broadcast)
await download_svc.add_to_queue(
await download_service.add_to_queue(
serie_id="test_serie_4",
serie_folder="test_serie_4",
serie_folder="test_folder",
serie_name="Speed Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
await download_svc.start_queue_processing()
await download_service.start_queue_processing()
await asyncio.sleep(0.5)
progress_broadcasts = [
@ -287,85 +254,84 @@ class TestDownloadProgressWebSocket:
assert len(progress_broadcasts) > 0
# Check that progress data is present
progress_data = progress_broadcasts[0]["data"]
assert "id" in progress_data
assert "type" in progress_data
assert progress_data["type"] == "download"
# Check that speed and ETA are present
progress = progress_broadcasts[0]["data"]["progress"]
assert "speed_mbps" in progress
assert "eta_seconds" in progress
# Speed and ETA should be numeric (or None)
if progress["speed_mbps"] is not None:
assert isinstance(progress["speed_mbps"], (int, float))
if progress["eta_seconds"] is not None:
assert isinstance(progress["eta_seconds"], (int, float))
@pytest.mark.asyncio
async def test_no_broadcast_without_callback(self, download_service):
"""Test that no errors occur when no event handlers subscribed."""
download_svc, progress_svc = download_service
# Don't subscribe to any events
"""Test that no errors occur when broadcast callback is not set."""
# Don't set broadcast callback
await download_svc.add_to_queue(
await download_service.add_to_queue(
serie_id="test_serie_5",
serie_folder="test_serie_5",
serie_folder="test_folder",
serie_name="No Broadcast Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Should complete without errors
await download_svc.start_queue_processing()
await download_service.start_queue_processing()
await asyncio.sleep(0.5)
# Verify download completed successfully
status = await download_svc.get_queue_status()
status = await download_service.get_queue_status()
assert len(status.completed_downloads) == 1
@pytest.mark.asyncio
async def test_broadcast_error_handling(self, download_service):
"""Test that event handler errors don't break download process."""
download_svc, progress_svc = download_service
"""Test that broadcast errors don't break download process."""
error_count = 0
async def failing_handler(event):
"""Event handler that always fails."""
async def failing_broadcast(update_type: str, data: dict):
"""Broadcast that always fails."""
nonlocal error_count
error_count += 1
raise RuntimeError("Event handler failed")
raise RuntimeError("Broadcast failed")
progress_svc.subscribe("progress_updated", failing_handler)
download_service.set_broadcast_callback(failing_broadcast)
await download_svc.add_to_queue(
await download_service.add_to_queue(
serie_id="test_serie_6",
serie_folder="test_serie_6",
serie_folder="test_folder",
serie_name="Error Handling Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Should complete despite handler errors
await download_svc.start_queue_processing()
# Should complete despite broadcast errors
await download_service.start_queue_processing()
await asyncio.sleep(0.5)
# Verify download still completed
status = await download_svc.get_queue_status()
status = await download_service.get_queue_status()
assert len(status.completed_downloads) == 1
# Verify handler was attempted
# Verify broadcast was attempted
assert error_count > 0
@pytest.mark.asyncio
async def test_multiple_downloads_broadcast_separately(
self, download_service
):
"""Test that multiple downloads emit progress separately."""
download_svc, progress_svc = download_service
"""Test that multiple downloads broadcast their progress separately."""
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
async def mock_broadcast(update_type: str, data: dict):
broadcasts.append({"type": update_type, "data": data})
progress_svc.subscribe("progress_updated", mock_event_handler)
download_service.set_broadcast_callback(mock_broadcast)
# Add multiple episodes
item_ids = await download_svc.add_to_queue(
item_ids = await download_service.add_to_queue(
serie_id="test_serie_7",
serie_folder="test_serie_7",
serie_folder="test_folder",
serie_name="Multi Episode Test",
episodes=[
EpisodeIdentifier(season=1, episode=1),
@ -376,9 +342,8 @@ class TestDownloadProgressWebSocket:
assert len(item_ids) == 2
# Start processing
# Give time for both downloads
await download_svc.start_queue_processing()
await asyncio.sleep(2.0)
await download_service.start_queue_processing()
await asyncio.sleep(1.0) # Give time for both downloads
# Get progress broadcasts
progress_broadcasts = [
@ -386,40 +351,39 @@ class TestDownloadProgressWebSocket:
]
# Should have progress for both episodes
assert len(progress_broadcasts) >= 4 # At least 2 updates per ep
assert len(progress_broadcasts) >= 4 # At least 2 updates per episode
# Verify different download IDs
download_ids = set()
for broadcast in progress_broadcasts:
download_id = broadcast["data"].get("id", "")
if "download_" in download_id:
download_id = (
broadcast["data"].get("download_id")
or broadcast["data"].get("item_id")
)
if download_id:
download_ids.add(download_id)
# Should have at least 2 unique download progress IDs
# Should have at least 2 unique download IDs
assert len(download_ids) >= 2
@pytest.mark.asyncio
async def test_progress_data_format_matches_model(self, download_service):
"""Test that event data matches Progress model."""
download_svc, progress_svc = download_service
"""Test that broadcast data matches DownloadProgress model."""
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
async def mock_broadcast(update_type: str, data: dict):
broadcasts.append({"type": update_type, "data": data})
progress_svc.subscribe("progress_updated", mock_event_handler)
download_service.set_broadcast_callback(mock_broadcast)
await download_svc.add_to_queue(
await download_service.add_to_queue(
serie_id="test_serie_8",
serie_folder="test_serie_8",
serie_folder="test_folder",
serie_name="Model Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
await download_svc.start_queue_processing()
await download_service.start_queue_processing()
await asyncio.sleep(0.5)
progress_broadcasts = [
@ -428,11 +392,12 @@ class TestDownloadProgressWebSocket:
assert len(progress_broadcasts) > 0
# Verify progress follows Progress model structure
progress_data = progress_broadcasts[0]["data"]
# Verify progress can be parsed as DownloadProgress
progress_data = progress_broadcasts[0]["data"]["progress"]
progress = DownloadProgress(**progress_data)
# Verify required fields from Progress model
assert "id" in progress_data
assert "type" in progress_data
assert "status" in progress_data
assert progress_data["type"] == "download"
# Verify required fields
assert isinstance(progress.percent, float)
assert isinstance(progress.downloaded_mb, float)
assert 0 <= progress.percent <= 100
assert progress.downloaded_mb >= 0

View File

@ -78,11 +78,10 @@ class TestDownloadServiceInitialization:
{
"id": "test-id-1",
"serie_id": "series-1",
"serie_folder": "test-series", # Added missing field
"serie_name": "Test Series",
"episode": {"season": 1, "episode": 1, "title": None},
"status": "pending",
"priority": "NORMAL", # Must be uppercase
"priority": "normal",
"added_at": datetime.now(timezone.utc).isoformat(),
"started_at": None,
"completed_at": None,
@ -119,7 +118,6 @@ class TestQueueManagement:
item_ids = await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=episodes,
priority=DownloadPriority.NORMAL,
@ -144,7 +142,6 @@ class TestQueueManagement:
item_ids = await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=episodes,
priority=DownloadPriority.NORMAL,
@ -158,7 +155,6 @@ class TestQueueManagement:
"""Test removing items from pending queue."""
item_ids = await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
@ -173,9 +169,8 @@ class TestQueueManagement:
async def test_start_next_download(self, download_service):
"""Test starting the next download from queue."""
# Add items to queue
await download_service.add_to_queue(
item_ids = await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[
EpisodeIdentifier(season=1, episode=1),
@ -187,11 +182,8 @@ class TestQueueManagement:
started_id = await download_service.start_next_download()
assert started_id is not None
assert started_id == "queue_started" # Service returns this string
# Queue processing starts in background, wait a moment
await asyncio.sleep(0.2)
# First item should be processing or completed
assert len(download_service._pending_queue) <= 2
assert started_id == item_ids[0]
assert len(download_service._pending_queue) == 1
assert download_service._is_stopped is False
@pytest.mark.asyncio
@ -208,7 +200,6 @@ class TestQueueManagement:
# Add items and start one
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[
EpisodeIdentifier(season=1, episode=1),
@ -216,20 +207,19 @@ class TestQueueManagement:
],
)
# Make download slow so it stays active (fake - no real download)
async def fake_slow_download(**kwargs):
await asyncio.sleep(0.5) # Reduced from 10s to speed up test
return True # Fake success
# Make download slow so it stays active
async def slow_download(**kwargs):
await asyncio.sleep(10)
mock_anime_service.download = AsyncMock(side_effect=fake_slow_download)
mock_anime_service.download = AsyncMock(side_effect=slow_download)
# Start first download (will block for 0.5s in background)
# Start first download (will block for 10s in background)
item_id = await download_service.start_next_download()
assert item_id is not None
await asyncio.sleep(0.1) # Let it start processing
# Try to start another - should fail because one is active
with pytest.raises(DownloadServiceError, match="already active"):
with pytest.raises(DownloadServiceError, match="already in progress"):
await download_service.start_next_download()
@pytest.mark.asyncio
@ -243,13 +233,9 @@ class TestQueueManagement:
self, download_service, mock_anime_service
):
"""Test successful download moves item to completed list."""
# Ensure mock returns success (fake download - no real download)
mock_anime_service.download = AsyncMock(return_value=True)
# Add item
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
@ -266,13 +252,12 @@ class TestQueueManagement:
self, download_service, mock_anime_service
):
"""Test failed download moves item to failed list."""
# Make download fail (fake download failure - no real download)
# Make download fail
mock_anime_service.download = AsyncMock(return_value=False)
# Add item
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
@ -294,7 +279,6 @@ class TestQueueStatus:
# Add items to queue
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[
EpisodeIdentifier(season=1, episode=1),
@ -318,7 +302,6 @@ class TestQueueStatus:
# Add items
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[
EpisodeIdentifier(season=1, episode=1),
@ -397,7 +380,6 @@ class TestPersistence:
"""Test that queue state is persisted to disk."""
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
@ -426,7 +408,6 @@ class TestPersistence:
await service1.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[
EpisodeIdentifier(season=1, episode=1),
@ -494,48 +475,106 @@ class TestRetryLogic:
class TestBroadcastCallbacks:
"""Test WebSocket broadcast functionality."""
@pytest.mark.asyncio
async def test_set_broadcast_callback(self, download_service):
"""Test setting broadcast callback."""
mock_callback = AsyncMock()
download_service.set_broadcast_callback(mock_callback)
assert download_service._broadcast_callback == mock_callback
@pytest.mark.asyncio
async def test_broadcast_on_queue_update(self, download_service):
"""Test that queue updates work correctly (no broadcast callbacks)."""
# Note: The service no longer has set_broadcast_callback method
# It uses the progress service internally for websocket updates
"""Test that broadcasts are sent on queue updates."""
mock_callback = AsyncMock()
download_service.set_broadcast_callback(mock_callback)
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Verify item was added successfully
assert len(download_service._pending_queue) == 1
# Allow async callback to execute
await asyncio.sleep(0.1)
# Verify callback was called
mock_callback.assert_called()
@pytest.mark.asyncio
async def test_progress_callback_format(self, download_service):
"""Test that download completes successfully with mocked service."""
# Note: Progress updates are handled by SeriesApp events and
# ProgressService, not via direct callbacks to the download service.
# This test verifies that downloads complete without errors.
# Mock successful download (fake download - no real download)
download_service._anime_service.download = AsyncMock(return_value=True)
# Add and process a download
"""Test that progress callback receives correct data format."""
# Set up a mock callback to capture progress updates
progress_updates = []
def capture_progress(progress_data: dict):
progress_updates.append(progress_data)
# Mock download to simulate progress
async def mock_download_with_progress(*args, **kwargs):
# Get the callback from kwargs
callback = kwargs.get('callback')
if callback:
# Simulate progress updates with the expected format
callback({
'percent': 50.0,
'downloaded_mb': 250.5,
'total_mb': 501.0,
'speed_mbps': 5.2,
'eta_seconds': 48,
})
return True
download_service._anime_service.download = mock_download_with_progress
# Add an item to the queue
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Start download and wait for completion
await download_service.start_next_download()
await asyncio.sleep(0.5) # Wait for processing
# Process the download
item = download_service._pending_queue.popleft()
del download_service._pending_items_by_id[item.id]
# Replace the progress callback with our capture function
original_callback = download_service._create_progress_callback
def wrapper(item):
callback = original_callback(item)
def wrapped_callback(data):
capture_progress(data)
callback(data)
return wrapped_callback
download_service._create_progress_callback = wrapper
await download_service._process_download(item)
# Verify download completed successfully
assert len(download_service._completed_items) == 1
assert download_service._completed_items[0].status == (
DownloadStatus.COMPLETED
# Verify progress callback was called with correct format
assert len(progress_updates) > 0
progress_data = progress_updates[0]
# Check all expected keys are present
assert 'percent' in progress_data
assert 'downloaded_mb' in progress_data
assert 'total_mb' in progress_data
assert 'speed_mbps' in progress_data
assert 'eta_seconds' in progress_data
# Verify values are of correct type
assert isinstance(progress_data['percent'], (int, float))
assert isinstance(progress_data['downloaded_mb'], (int, float))
assert (
progress_data['total_mb'] is None
or isinstance(progress_data['total_mb'], (int, float))
)
assert (
progress_data['speed_mbps'] is None
or isinstance(progress_data['speed_mbps'], (int, float))
)
@ -571,14 +610,13 @@ class TestErrorHandling:
@pytest.mark.asyncio
async def test_download_failure_moves_to_failed(self, download_service):
"""Test that download failures are handled correctly."""
# Mock download to fail with exception (fake - no real download)
# Mock download to fail
download_service._anime_service.download = AsyncMock(
side_effect=Exception("Fake download failed")
side_effect=Exception("Download failed")
)
await download_service.add_to_queue(
serie_id="series-1",
serie_folder="series",
serie_name="Test Series",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)

View File

@ -338,8 +338,7 @@ class TestProgressService:
@pytest.mark.asyncio
async def test_broadcast_callback(self, service, mock_broadcast):
"""Test broadcast callback is invoked correctly."""
# Subscribe to progress_updated events
service.subscribe("progress_updated", mock_broadcast)
service.set_broadcast_callback(mock_broadcast)
await service.start_progress(
progress_id="test-1",
@ -349,18 +348,15 @@ class TestProgressService:
# Verify callback was called for start
mock_broadcast.assert_called_once()
# First positional arg is ProgressEvent
call_args = mock_broadcast.call_args[0][0]
assert call_args.event_type == "download_progress"
assert call_args.room == "download_progress"
assert call_args.progress_id == "test-1"
assert call_args.progress.id == "test-1"
call_args = mock_broadcast.call_args
assert call_args[1]["message_type"] == "download_progress"
assert call_args[1]["room"] == "download_progress"
assert "test-1" in str(call_args[1]["data"])
@pytest.mark.asyncio
async def test_broadcast_on_update(self, service, mock_broadcast):
"""Test broadcast on progress update."""
# Subscribe to progress_updated events
service.subscribe("progress_updated", mock_broadcast)
service.set_broadcast_callback(mock_broadcast)
await service.start_progress(
progress_id="test-1",
@ -379,15 +375,11 @@ class TestProgressService:
# Should have been called
assert mock_broadcast.call_count >= 1
# First positional arg is ProgressEvent
call_args = mock_broadcast.call_args[0][0]
assert call_args.progress.current == 50
@pytest.mark.asyncio
async def test_broadcast_on_complete(self, service, mock_broadcast):
"""Test broadcast on progress completion."""
# Subscribe to progress_updated events
service.subscribe("progress_updated", mock_broadcast)
service.set_broadcast_callback(mock_broadcast)
await service.start_progress(
progress_id="test-1",
@ -403,15 +395,13 @@ class TestProgressService:
# Should have been called
mock_broadcast.assert_called_once()
# First positional arg is ProgressEvent
call_args = mock_broadcast.call_args[0][0]
assert call_args.progress.status.value == "completed"
call_args = mock_broadcast.call_args
assert "completed" in str(call_args[1]["data"]).lower()
@pytest.mark.asyncio
async def test_broadcast_on_failure(self, service, mock_broadcast):
"""Test broadcast on progress failure."""
# Subscribe to progress_updated events
service.subscribe("progress_updated", mock_broadcast)
service.set_broadcast_callback(mock_broadcast)
await service.start_progress(
progress_id="test-1",
@ -427,9 +417,8 @@ class TestProgressService:
# Should have been called
mock_broadcast.assert_called_once()
# First positional arg is ProgressEvent
call_args = mock_broadcast.call_args[0][0]
assert call_args.progress.status.value == "failed"
call_args = mock_broadcast.call_args
assert "failed" in str(call_args[1]["data"]).lower()
@pytest.mark.asyncio
async def test_clear_history(self, service):

View File

@ -7,14 +7,15 @@ Tests the functionality of SeriesApp including:
- Download with progress callbacks
- Directory scanning with progress reporting
- Async versions of operations
- Cancellation support
- Error handling
"""
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import Mock, patch
import pytest
from src.core.SeriesApp import SeriesApp
from src.core.SeriesApp import OperationResult, OperationStatus, ProgressInfo, SeriesApp
class TestSeriesAppInitialization:
@ -34,30 +35,62 @@ class TestSeriesAppInitialization:
# Verify initialization
assert app.directory_to_search == test_dir
assert app._operation_status == OperationStatus.IDLE
assert app._cancel_flag is False
assert app._current_operation is None
mock_loaders.assert_called_once()
mock_scanner.assert_called_once()
@patch('src.core.SeriesApp.Loaders')
def test_init_failure_raises_error(self, mock_loaders):
"""Test that initialization failure raises error."""
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
def test_init_with_callbacks(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test initialization with progress and error callbacks."""
test_dir = "/test/anime"
progress_callback = Mock()
error_callback = Mock()
# Create app with callbacks
app = SeriesApp(
test_dir,
progress_callback=progress_callback,
error_callback=error_callback
)
# Verify callbacks are stored
assert app.progress_callback == progress_callback
assert app.error_callback == error_callback
@patch('src.core.SeriesApp.Loaders')
def test_init_failure_calls_error_callback(self, mock_loaders):
"""Test that initialization failure triggers error callback."""
test_dir = "/test/anime"
error_callback = Mock()
# Make Loaders raise an exception
mock_loaders.side_effect = RuntimeError("Init failed")
# Create app should raise
# Create app should raise but call error callback
with pytest.raises(RuntimeError):
SeriesApp(test_dir)
SeriesApp(test_dir, error_callback=error_callback)
# Verify error callback was called
error_callback.assert_called_once()
assert isinstance(
error_callback.call_args[0][0],
RuntimeError
)
class TestSeriesAppSearch:
"""Test search functionality."""
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
async def test_search_success(
def test_search_success(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test successful search."""
@ -71,56 +104,54 @@ class TestSeriesAppSearch:
]
app.loader.search = Mock(return_value=expected_results)
# Perform search (now async)
results = await app.search("test anime")
# Perform search
results = app.search("test anime")
# Verify results
assert results == expected_results
app.loader.search.assert_called_once_with("test anime")
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
async def test_search_failure_raises_error(
def test_search_failure_calls_error_callback(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test search failure raises error."""
"""Test search failure triggers error callback."""
test_dir = "/test/anime"
app = SeriesApp(test_dir)
error_callback = Mock()
app = SeriesApp(test_dir, error_callback=error_callback)
# Make search raise an exception
app.loader.search = Mock(
side_effect=RuntimeError("Search failed")
)
# Search should raise
# Search should raise and call error callback
with pytest.raises(RuntimeError):
await app.search("test")
app.search("test")
error_callback.assert_called_once()
class TestSeriesAppDownload:
"""Test download functionality."""
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
async def test_download_success(
def test_download_success(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test successful download."""
test_dir = "/test/anime"
app = SeriesApp(test_dir)
# Mock the events to prevent NoneType errors
app._events.download_status = Mock()
# Mock download
app.loader.download = Mock(return_value=True)
app.loader.download = Mock()
# Perform download
result = await app.download(
result = app.download(
"anime_folder",
season=1,
episode=1,
@ -128,59 +159,57 @@ class TestSeriesAppDownload:
)
# Verify result
assert result is True
assert result.success is True
assert "Successfully downloaded" in result.message
# After successful completion, finally block resets operation
assert app._current_operation is None
app.loader.download.assert_called_once()
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
async def test_download_with_progress_callback(
def test_download_with_progress_callback(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test download with progress callback."""
test_dir = "/test/anime"
app = SeriesApp(test_dir)
# Mock the events
app._events.download_status = Mock()
# Mock download that calls progress callback
def mock_download(*args, **kwargs):
callback = args[-1] if len(args) > 6 else kwargs.get('callback')
if callback:
callback({'downloaded_bytes': 50, 'total_bytes': 100})
callback({'downloaded_bytes': 100, 'total_bytes': 100})
return True
callback(0.5)
callback(1.0)
app.loader.download = Mock(side_effect=mock_download)
progress_callback = Mock()
# Perform download - no need for progress_callback parameter
result = await app.download(
# Perform download
result = app.download(
"anime_folder",
season=1,
episode=1,
key="anime_key"
key="anime_key",
callback=progress_callback
)
# Verify download succeeded
assert result is True
app.loader.download.assert_called_once()
# Verify progress callback was called
assert result.success is True
assert progress_callback.call_count == 2
progress_callback.assert_any_call(0.5)
progress_callback.assert_any_call(1.0)
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
async def test_download_cancellation(
def test_download_cancellation(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test download cancellation during operation."""
test_dir = "/test/anime"
app = SeriesApp(test_dir)
# Mock the events
app._events.download_status = Mock()
# Mock download that raises InterruptedError for cancellation
def mock_download_cancelled(*args, **kwargs):
# Simulate cancellation by raising InterruptedError
@ -188,30 +217,33 @@ class TestSeriesAppDownload:
app.loader.download = Mock(side_effect=mock_download_cancelled)
# Set cancel flag before calling (will be reset by download())
# but the mock will raise InterruptedError anyway
app._cancel_flag = True
# Perform download - should catch InterruptedError
result = await app.download(
result = app.download(
"anime_folder",
season=1,
episode=1,
key="anime_key"
)
# Verify cancellation was handled (returns False on error)
assert result is False
# Verify cancellation was handled
assert result.success is False
assert "cancelled" in result.message.lower()
assert app._current_operation is None
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
async def test_download_failure(
def test_download_failure(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test download failure handling."""
test_dir = "/test/anime"
app = SeriesApp(test_dir)
# Mock the events
app._events.download_status = Mock()
error_callback = Mock()
app = SeriesApp(test_dir, error_callback=error_callback)
# Make download fail
app.loader.download = Mock(
@ -219,105 +251,106 @@ class TestSeriesAppDownload:
)
# Perform download
result = await app.download(
result = app.download(
"anime_folder",
season=1,
episode=1,
key="anime_key"
)
# Verify failure (returns False on error)
assert result is False
# Verify failure
assert result.success is False
assert "failed" in result.message.lower()
assert result.error is not None
# After failure, finally block resets operation
assert app._current_operation is None
error_callback.assert_called_once()
class TestSeriesAppReScan:
"""Test directory scanning functionality."""
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
async def test_rescan_success(
def test_rescan_success(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test successful directory rescan."""
test_dir = "/test/anime"
app = SeriesApp(test_dir)
# Mock the events
app._events.scan_status = Mock()
# Mock scanner
app.serie_scanner.get_total_to_scan = Mock(return_value=5)
app.serie_scanner.reinit = Mock()
app.serie_scanner.scan = Mock()
app.SerieScanner.get_total_to_scan = Mock(return_value=5)
app.SerieScanner.reinit = Mock()
app.SerieScanner.scan = Mock()
# Perform rescan
await app.rescan()
result = app.ReScan()
# Verify rescan completed
app.serie_scanner.reinit.assert_called_once()
app.serie_scanner.scan.assert_called_once()
# Verify result
assert result.success is True
assert "completed" in result.message.lower()
# After successful completion, finally block resets operation
assert app._current_operation is None
app.SerieScanner.reinit.assert_called_once()
app.SerieScanner.scan.assert_called_once()
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
async def test_rescan_with_callback(
def test_rescan_with_progress_callback(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test rescan with progress callbacks."""
test_dir = "/test/anime"
app = SeriesApp(test_dir)
# Mock the events
app._events.scan_status = Mock()
progress_callback = Mock()
app = SeriesApp(test_dir, progress_callback=progress_callback)
# Mock scanner
app.serie_scanner.get_total_to_scan = Mock(return_value=3)
app.serie_scanner.reinit = Mock()
app.SerieScanner.get_total_to_scan = Mock(return_value=3)
app.SerieScanner.reinit = Mock()
def mock_scan(callback):
callback("folder1", 1)
callback("folder2", 2)
callback("folder3", 3)
app.serie_scanner.scan = Mock(side_effect=mock_scan)
app.SerieScanner.scan = Mock(side_effect=mock_scan)
# Perform rescan
await app.rescan()
result = app.ReScan()
# Verify rescan completed
app.serie_scanner.scan.assert_called_once()
# Verify progress callbacks were called
assert result.success is True
assert progress_callback.call_count == 3
@pytest.mark.asyncio
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
async def test_rescan_cancellation(
def test_rescan_cancellation(
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test rescan cancellation."""
test_dir = "/test/anime"
app = SeriesApp(test_dir)
# Mock the events
app._events.scan_status = Mock()
# Mock scanner
app.serie_scanner.get_total_to_scan = Mock(return_value=3)
app.serie_scanner.reinit = Mock()
app.SerieScanner.get_total_to_scan = Mock(return_value=3)
app.SerieScanner.reinit = Mock()
def mock_scan(callback):
raise InterruptedError("Scan cancelled")
app._cancel_flag = True
callback("folder1", 1)
app.serie_scanner.scan = Mock(side_effect=mock_scan)
app.SerieScanner.scan = Mock(side_effect=mock_scan)
# Perform rescan - should handle cancellation
try:
await app.rescan()
except Exception:
pass # Cancellation is expected
# Perform rescan
result = app.ReScan()
# Verify cancellation
assert result.success is False
assert "cancelled" in result.message.lower()
class TestSeriesAppCancellation:
@ -333,9 +366,16 @@ class TestSeriesAppCancellation:
test_dir = "/test/anime"
app = SeriesApp(test_dir)
# These attributes may not exist anymore - skip this test
# as the cancel mechanism may have changed
pass
# Set operation as running
app._current_operation = "test_operation"
app._operation_status = OperationStatus.RUNNING
# Cancel operation
result = app.cancel_operation()
# Verify cancellation
assert result is True
assert app._cancel_flag is True
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@ -344,8 +384,15 @@ class TestSeriesAppCancellation:
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test cancelling when no operation is running."""
# Skip - cancel mechanism may have changed
pass
test_dir = "/test/anime"
app = SeriesApp(test_dir)
# Cancel operation (none running)
result = app.cancel_operation()
# Verify no cancellation occurred
assert result is False
assert app._cancel_flag is False
class TestSeriesAppGetters:
@ -361,8 +408,11 @@ class TestSeriesAppGetters:
test_dir = "/test/anime"
app = SeriesApp(test_dir)
# Verify app was created
assert app is not None
# Get series list
series_list = app.get_series_list()
# Verify
assert series_list is not None
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@ -371,8 +421,14 @@ class TestSeriesAppGetters:
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test getting operation status."""
# Skip - operation status API may have changed
pass
test_dir = "/test/anime"
app = SeriesApp(test_dir)
# Get status
status = app.get_operation_status()
# Verify
assert status == OperationStatus.IDLE
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@ -381,7 +437,67 @@ class TestSeriesAppGetters:
self, mock_serie_list, mock_scanner, mock_loaders
):
"""Test getting current operation."""
# Skip - operation tracking API may have changed
pass
test_dir = "/test/anime"
app = SeriesApp(test_dir)
# Get current operation
operation = app.get_current_operation()
# Verify
assert operation is None
# Set an operation
app._current_operation = "test_op"
operation = app.get_current_operation()
assert operation == "test_op"
class TestProgressInfo:
"""Test ProgressInfo dataclass."""
def test_progress_info_creation(self):
"""Test creating ProgressInfo."""
info = ProgressInfo(
current=5,
total=10,
message="Processing...",
percentage=50.0,
status=OperationStatus.RUNNING
)
assert info.current == 5
assert info.total == 10
assert info.message == "Processing..."
assert info.percentage == 50.0
assert info.status == OperationStatus.RUNNING
class TestOperationResult:
"""Test OperationResult dataclass."""
def test_operation_result_success(self):
"""Test creating successful OperationResult."""
result = OperationResult(
success=True,
message="Operation completed",
data={"key": "value"}
)
assert result.success is True
assert result.message == "Operation completed"
assert result.data == {"key": "value"}
assert result.error is None
def test_operation_result_failure(self):
"""Test creating failed OperationResult."""
error = RuntimeError("Test error")
result = OperationResult(
success=False,
message="Operation failed",
error=error
)
assert result.success is False
assert result.message == "Operation failed"
assert result.error == error
assert result.data is None