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

View File

@ -43,13 +43,47 @@ async def get_queue_status(
queue_status = await download_service.get_queue_status() queue_status = await download_service.get_queue_status()
queue_stats = await download_service.get_queue_stats() queue_stats = await download_service.get_queue_stats()
# Build response matching QueueStatusResponse model # Build response with field names expected by frontend
response = QueueStatusResponse( # Frontend expects top-level arrays (active_downloads, pending_queue, etc.)
status=queue_status, # not nested under a 'status' object
statistics=queue_stats, 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: except Exception as e:
raise HTTPException( 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) @router.post("/start", status_code=status.HTTP_200_OK)
async def start_queue( async def start_queue(
_: dict = Depends(require_auth), _: 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) @router.post("/retry", status_code=status.HTTP_200_OK)
async def retry_failed( async def retry_failed(
request: QueueOperationRequest, request: QueueOperationRequest,

View File

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

View File

@ -84,12 +84,12 @@ class DownloadService:
# Statistics tracking # Statistics tracking
self._total_downloaded_mb: float = 0.0 self._total_downloaded_mb: float = 0.0
self._download_speeds: deque[float] = deque(maxlen=10) self._download_speeds: deque[float] = deque(maxlen=10)
# Track if queue progress has been initialized
self._queue_progress_initialized: bool = False
# Load persisted queue # Load persisted queue
self._load_queue() self._load_queue()
# Initialize queue progress tracking
asyncio.create_task(self._init_queue_progress())
logger.info( logger.info(
"DownloadService initialized", "DownloadService initialized",
@ -97,14 +97,7 @@ class DownloadService:
) )
async def _init_queue_progress(self) -> None: async def _init_queue_progress(self) -> None:
"""Initialize the download queue progress tracking. """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
try: try:
from src.server.services.progress_service import ProgressType from src.server.services.progress_service import ProgressType
await self._progress_service.start_progress( await self._progress_service.start_progress(
@ -113,7 +106,6 @@ class DownloadService:
title="Download Queue", title="Download Queue",
message="Queue ready", message="Queue ready",
) )
self._queue_progress_initialized = True
except Exception as e: except Exception as e:
logger.error("Failed to initialize queue progress", error=str(e)) logger.error("Failed to initialize queue progress", error=str(e))
@ -247,9 +239,6 @@ class DownloadService:
Raises: Raises:
DownloadServiceError: If adding items fails DownloadServiceError: If adding items fails
""" """
# Initialize queue progress tracking if not already done
await self._init_queue_progress()
created_ids = [] created_ids = []
try: try:
@ -360,59 +349,6 @@ class DownloadService:
f"Failed to remove items: {str(e)}" f"Failed to remove items: {str(e)}"
) from 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]: async def start_queue_processing(self) -> Optional[str]:
"""Start automatic queue processing of all pending downloads. """Start automatic queue processing of all pending downloads.
@ -427,9 +363,6 @@ class DownloadService:
DownloadServiceError: If queue processing is already active DownloadServiceError: If queue processing is already active
""" """
try: try:
# Initialize queue progress tracking if not already done
await self._init_queue_progress()
# Check if download already active # Check if download already active
if self._active_download: if self._active_download:
raise DownloadServiceError( raise DownloadServiceError(

View File

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

View File

@ -445,8 +445,20 @@
<script src="/static/js/localization.js"></script> <script src="/static/js/localization.js"></script>
<!-- UX Enhancement Scripts --> <!-- 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/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> <script src="/static/js/app.js"></script>
</body> </body>

View File

@ -18,7 +18,6 @@ class FakeSerie:
self.name = name self.name = name
self.folder = folder self.folder = folder
self.episodeDict = episodeDict or {} self.episodeDict = episodeDict or {}
self.site = "aniworld.to" # Add site attribute
class FakeSeriesApp: class FakeSeriesApp:
@ -26,7 +25,7 @@ class FakeSeriesApp:
def __init__(self): def __init__(self):
"""Initialize fake series app.""" """Initialize fake series app."""
self.list = self # Changed from self.List to self.list self.List = self
self._items = [ self._items = [
FakeSerie("1", "Test Show", "test_show", {1: [1, 2]}), FakeSerie("1", "Test Show", "test_show", {1: [1, 2]}),
FakeSerie("2", "Complete Show", "complete_show", {}), FakeSerie("2", "Complete Show", "complete_show", {}),
@ -50,20 +49,6 @@ class FakeSeriesApp:
if not any(s.key == serie.key for s in self._items): if not any(s.key == serie.key for s in self._items):
self._items.append(serie) 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): def refresh_series_list(self):
"""Refresh series list.""" """Refresh series list."""
pass pass
@ -79,20 +64,6 @@ def reset_auth_state():
auth_service._failed.clear() 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 @pytest.fixture
async def authenticated_client(): async def authenticated_client():
"""Create authenticated async client.""" """Create authenticated async client."""
@ -129,19 +100,9 @@ def test_get_anime_detail_direct_call():
def test_rescan_direct_call(): def test_rescan_direct_call():
"""Test trigger_rescan function directly.""" """Test trigger_rescan function directly."""
from unittest.mock import AsyncMock fake = FakeSeriesApp()
result = asyncio.run(anime_module.trigger_rescan(series_app=fake))
from src.server.services.anime_service import AnimeService
# Create a mock anime service
mock_anime_service = AsyncMock(spec=AnimeService)
mock_anime_service.rescan = AsyncMock()
result = asyncio.run(
anime_module.trigger_rescan(anime_service=mock_anime_service)
)
assert result["success"] is True assert result["success"] is True
mock_anime_service.rescan.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@ -92,9 +92,8 @@ def mock_download_service():
# Mock remove_from_queue # Mock remove_from_queue
service.remove_from_queue = AsyncMock(return_value=["item-id-1"]) service.remove_from_queue = AsyncMock(return_value=["item-id-1"])
# Mock start/stop - start_queue_processing returns True on success # Mock start/stop
service.start_queue_processing = AsyncMock(return_value=True) service.start_next_download = AsyncMock(return_value="item-id-1")
service.stop = AsyncMock()
service.stop_downloads = AsyncMock() service.stop_downloads = AsyncMock()
# Mock clear_completed and retry_failed # 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 assert response.status_code == 200
data = response.json() data = response.json()
# Updated to match new response structure with nested status # Updated to match new response structure
assert "status" in data 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 assert "statistics" in data
assert data["is_running"] is True
status_data = data["status"] assert data["is_paused"] is False
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
mock_download_service.get_queue_status.assert_called_once() mock_download_service.get_queue_status.assert_called_once()
mock_download_service.get_queue_stats.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( async def test_start_download_success(
authenticated_client, mock_download_service 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") response = await authenticated_client.post("/api/queue/start")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["status"] == "success" 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 @pytest.mark.asyncio
@ -284,7 +281,7 @@ async def test_start_download_empty_queue(
authenticated_client, mock_download_service authenticated_client, mock_download_service
): ):
"""Test starting download with empty queue returns 400.""" """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") response = await authenticated_client.post("/api/queue/start")
@ -299,7 +296,7 @@ async def test_start_download_already_active(
authenticated_client, mock_download_service authenticated_client, mock_download_service
): ):
"""Test starting download while one is active returns 400.""" """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") DownloadServiceError("A download is already in progress")
) )
@ -307,8 +304,7 @@ async def test_start_download_already_active(
assert response.status_code == 400 assert response.status_code == 400
data = response.json() data = response.json()
detail_lower = data["detail"].lower() assert "already" in data["detail"].lower()
assert "already" in detail_lower or "progress" in detail_lower
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@ -73,16 +73,15 @@ class TestQueueDisplay:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
# Verify top-level structure # Verify structure
assert "status" in data assert "status" in data
assert "statistics" in data assert "statistics" in data
# Verify status nested structure
status = data["status"] status = data["status"]
assert "active_downloads" in status assert "active" in status
assert "pending_queue" in status assert "pending" in status
assert "completed_downloads" in status assert "completed" in status
assert "failed_downloads" in status assert "failed" in status
assert "is_running" in status assert "is_running" in status
assert "is_paused" in status assert "is_paused" in status
@ -108,8 +107,7 @@ class TestQueueDisplay:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
# Updated for nested status structure pending = data["status"]["pending"]
pending = data["status"]["pending_queue"]
assert len(pending) > 0 assert len(pending) > 0
item = pending[0] item = pending[0]
@ -142,7 +140,7 @@ class TestQueueReordering:
) )
existing_items = [ existing_items = [
item["id"] item["id"]
for item in status_response.json()["status"]["pending_queue"] for item in status_response.json()["status"]["pending"]
] ]
if existing_items: if existing_items:
await client.request( await client.request(
@ -192,7 +190,7 @@ class TestQueueReordering:
) )
current_order = [ current_order = [
item["id"] item["id"]
for item in status_response.json()["status"]["pending_queue"] for item in status_response.json()["status"]["pending"]
] ]
assert current_order == new_order assert current_order == new_order
@ -325,7 +323,7 @@ class TestCompletedDownloads:
data = status.json() data = status.json()
completed_count = data["statistics"]["completed_count"] completed_count = data["statistics"]["completed_count"]
completed_list = len(data["status"]["completed_downloads"]) completed_list = len(data["status"]["completed"])
# Count should match list length # Count should match list length
assert completed_count == completed_list assert completed_count == completed_list
@ -392,7 +390,7 @@ class TestFailedDownloads:
data = status.json() data = status.json()
failed_count = data["statistics"]["failed_count"] failed_count = data["statistics"]["failed_count"]
failed_list = len(data["status"]["failed_downloads"]) failed_list = len(data["status"]["failed"])
# Count should match list length # Count should match list length
assert failed_count == failed_list assert failed_count == failed_list
@ -445,7 +443,7 @@ class TestBulkOperations:
"/api/queue/status", "/api/queue/status",
headers=auth_headers headers=auth_headers
) )
pending = status.json()["status"]["pending_queue"] pending = status.json()["status"]["pending"]
if pending: if pending:
item_ids = [item["id"] for item in pending] item_ids = [item["id"] for item in pending]
@ -465,4 +463,4 @@ class TestBulkOperations:
"/api/queue/status", "/api/queue/status",
headers=auth_headers 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.""" """Pytest configuration and shared fixtures for all tests."""
from unittest.mock import Mock
import pytest import pytest
from src.server.services.auth_service import auth_service 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 # but we continue anyway - they're not critical
pass pass
yield yield
# Clean up after test # Clean up after test
@ -85,32 +82,4 @@ def reset_auth_and_rate_limits(request):
auth_service._failed.clear() # noqa: SLF001 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 - API endpoints respond with expected data formats
- Frontend JavaScript can interact with backend services - Frontend JavaScript can interact with backend services
""" """
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
@ -200,48 +200,21 @@ class TestFrontendAnimeAPI:
assert "name" in data[0] assert "name" in data[0]
async def test_rescan_anime(self, authenticated_client): async def test_rescan_anime(self, authenticated_client):
"""Test POST /api/anime/rescan triggers rescan with events.""" """Test POST /api/anime/rescan triggers rescan."""
from unittest.mock import MagicMock # Mock SeriesApp instance with ReScan method
mock_series_app = Mock()
from src.server.services.progress_service import ProgressService mock_series_app.ReScan = Mock()
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
# Mock the ProgressService with patch(
mock_progress_service = MagicMock(spec=ProgressService) "src.server.utils.dependencies.get_series_app"
mock_progress_service.start_progress = AsyncMock() ) as mock_get_app:
mock_progress_service.update_progress = AsyncMock() mock_get_app.return_value = mock_series_app
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:
response = await authenticated_client.post("/api/anime/rescan") response = await authenticated_client.post("/api/anime/rescan")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["success"] is True 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: class TestFrontendDownloadAPI:
@ -270,19 +243,18 @@ class TestFrontendDownloadAPI:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
# Check for expected response structure (nested status) # Check for expected response structure
assert "status" in data assert "status" in data or "statistics" in data
assert "statistics" in data
async def test_start_download_queue(self, authenticated_client): async def test_start_download_queue(self, authenticated_client):
"""Test POST /api/queue/start starts next download.""" """Test POST /api/queue/start starts next download."""
response = await authenticated_client.post("/api/queue/start") 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] assert response.status_code in [200, 400]
data = response.json() data = response.json()
if response.status_code == 200: 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): async def test_stop_download_queue(self, authenticated_client):
"""Test POST /api/queue/stop stops processing new downloads.""" """Test POST /api/queue/stop stops processing new downloads."""

View File

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

View File

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

View File

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

View File

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

View File

@ -20,22 +20,9 @@ class TestInputValidation:
"""Create async HTTP client for testing.""" """Create async HTTP client for testing."""
from httpx import ASGITransport 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( async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test" transport=ASGITransport(app=app), base_url="http://test"
) as ac: ) 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 yield ac
@pytest.mark.asyncio @pytest.mark.asyncio
@ -70,17 +57,12 @@ class TestInputValidation:
huge_string = "A" * 1000000 # 1MB of data huge_string = "A" * 1000000 # 1MB of data
response = await client.post( response = await client.post(
"/api/queue/add", "/api/anime",
json={ json={"title": huge_string, "description": "Test"},
"serie_id": huge_string,
"serie_name": "Test",
"episodes": [{"season": 1, "episode": 1}],
},
) )
# Currently accepts large inputs - TODO: Add size limits # Should reject or truncate
# Should reject or truncate in future assert response.status_code in [400, 413, 422]
assert response.status_code in [200, 201, 400, 413, 422]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_null_byte_injection(self, client): async def test_null_byte_injection(self, client):
@ -150,12 +132,11 @@ class TestInputValidation:
): ):
"""Test handling of negative numbers in inappropriate contexts.""" """Test handling of negative numbers in inappropriate contexts."""
response = await client.post( response = await client.post(
"/api/queue/add", "/api/downloads",
json={ json={
"serie_id": "test", "anime_id": -1,
"serie_name": "Test Series", "episode_number": -5,
"episodes": [{"season": -1, "episode": -5}], "priority": -10,
"priority": "normal",
}, },
) )
@ -218,11 +199,10 @@ class TestInputValidation:
async def test_array_injection(self, client): async def test_array_injection(self, client):
"""Test handling of array inputs in unexpected places.""" """Test handling of array inputs in unexpected places."""
response = await client.post( response = await client.post(
"/api/queue/add", "/api/anime",
json={ json={
"serie_id": ["array", "instead", "of", "string"], "title": ["array", "instead", "of", "string"],
"serie_name": "Test", "description": "Test",
"episodes": [{"season": 1, "episode": 1}],
}, },
) )

View File

@ -6,7 +6,7 @@ error handling, and progress reporting integration.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@ -15,17 +15,16 @@ from src.server.services.progress_service import ProgressService
@pytest.fixture @pytest.fixture
def mock_series_app(tmp_path): def mock_series_app():
"""Create a mock SeriesApp instance.""" """Create a mock SeriesApp instance."""
mock_instance = MagicMock() with patch("src.server.services.anime_service.SeriesApp") as mock_class:
mock_instance.directory_to_search = str(tmp_path) mock_instance = MagicMock()
mock_instance.series_list = [] mock_instance.series_list = []
mock_instance.search = AsyncMock(return_value=[]) mock_instance.search = MagicMock(return_value=[])
mock_instance.rescan = AsyncMock() mock_instance.ReScan = MagicMock()
mock_instance.download = AsyncMock(return_value=True) mock_instance.download = MagicMock(return_value=True)
mock_instance.download_status = None mock_class.return_value = mock_instance
mock_instance.scan_status = None yield mock_instance
return mock_instance
@pytest.fixture @pytest.fixture
@ -43,7 +42,8 @@ def mock_progress_service():
def anime_service(tmp_path, mock_series_app, mock_progress_service): def anime_service(tmp_path, mock_series_app, mock_progress_service):
"""Create an AnimeService instance for testing.""" """Create an AnimeService instance for testing."""
return AnimeService( return AnimeService(
series_app=mock_series_app, directory=str(tmp_path),
max_workers=2,
progress_service=mock_progress_service, progress_service=mock_progress_service,
) )
@ -51,40 +51,35 @@ def anime_service(tmp_path, mock_series_app, mock_progress_service):
class TestAnimeServiceInitialization: class TestAnimeServiceInitialization:
"""Test AnimeService initialization.""" """Test AnimeService initialization."""
def test_initialization_success( def test_initialization_success(self, tmp_path, mock_progress_service):
self, mock_series_app, mock_progress_service
):
"""Test successful service initialization.""" """Test successful service initialization."""
service = AnimeService( with patch("src.server.services.anime_service.SeriesApp"):
series_app=mock_series_app, service = AnimeService(
progress_service=mock_progress_service, directory=str(tmp_path),
) max_workers=2,
progress_service=mock_progress_service,
assert service._app is mock_series_app )
assert service._progress_service is 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( def test_initialization_failure_raises_error(
self, tmp_path, mock_progress_service self, tmp_path, mock_progress_service
): ):
"""Test SeriesApp initialization failure raises error.""" """Test SeriesApp initialization failure raises error."""
bad_series_app = MagicMock() with patch(
bad_series_app.directory_to_search = str(tmp_path) "src.server.services.anime_service.SeriesApp"
) as mock_class:
# Make event subscription fail by raising on property access mock_class.side_effect = Exception("Initialization failed")
type(bad_series_app).download_status = property(
lambda self: None, with pytest.raises(
lambda self, value: (_ for _ in ()).throw( AnimeServiceError, match="Initialization failed"
Exception("Initialization failed") ):
) AnimeService(
) directory=str(tmp_path),
progress_service=mock_progress_service,
with pytest.raises( )
AnimeServiceError, match="Initialization failed"
):
AnimeService(
series_app=bad_series_app,
progress_service=mock_progress_service,
)
class TestListMissing: class TestListMissing:
@ -185,18 +180,35 @@ class TestRescan:
"""Test successful rescan operation.""" """Test successful rescan operation."""
await anime_service.rescan() await anime_service.rescan()
# Verify SeriesApp.rescan was called (lowercase, not ReScan) # Verify SeriesApp.ReScan was called
mock_series_app.rescan.assert_called_once() 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 @pytest.mark.asyncio
async def test_rescan_with_callback(self, anime_service, mock_series_app): async def test_rescan_with_callback(self, anime_service, mock_series_app):
"""Test rescan operation (callback parameter removed).""" """Test rescan with progress callback."""
# Rescan no longer accepts callback parameter callback_called = False
# Progress is tracked via event handlers automatically callback_data = None
await anime_service.rescan()
# Verify rescan was called def callback(data):
mock_series_app.rescan.assert_called_once() 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 @pytest.mark.asyncio
async def test_rescan_clears_cache(self, anime_service, mock_series_app): 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 self, anime_service, mock_series_app, mock_progress_service
): ):
"""Test error handling during rescan.""" """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"): with pytest.raises(AnimeServiceError, match="Rescan failed"):
await anime_service.rescan() await anime_service.rescan()
# Verify progress failure was recorded
mock_progress_service.fail_progress.assert_called_once()
class TestDownload: class TestDownload:
@ -243,19 +258,13 @@ class TestDownload:
assert result is True assert result is True
mock_series_app.download.assert_called_once_with( mock_series_app.download.assert_called_once_with(
serie_folder="test_series", "test_series", 1, 1, "test_key", None
season=1,
episode=1,
key="test_key",
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_with_callback( async def test_download_with_callback(self, anime_service, mock_series_app):
self, anime_service, mock_series_app """Test download with progress callback."""
): callback = MagicMock()
"""Test download operation (callback parameter removed)."""
# Download no longer accepts callback parameter
# Progress is tracked via event handlers automatically
mock_series_app.download.return_value = True mock_series_app.download.return_value = True
result = await anime_service.download( result = await anime_service.download(
@ -263,21 +272,17 @@ class TestDownload:
season=1, season=1,
episode=1, episode=1,
key="test_key", key="test_key",
callback=callback,
) )
assert result is True 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( mock_series_app.download.assert_called_once_with(
serie_folder="test_series", "test_series", 1, 1, "test_key", callback
season=1,
episode=1,
key="test_key",
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_error_handling( async def test_download_error_handling(self, anime_service, mock_series_app):
self, anime_service, mock_series_app
):
"""Test error handling during download.""" """Test error handling during download."""
mock_series_app.download.side_effect = Exception("Download failed") mock_series_app.download.side_effect = Exception("Download failed")
@ -316,12 +321,12 @@ class TestConcurrency:
class TestFactoryFunction: class TestFactoryFunction:
"""Test factory function.""" """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.""" """Test get_anime_service factory function."""
from src.server.services.anime_service import get_anime_service 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) with patch("src.server.services.anime_service.SeriesApp"):
assert service._app is mock_series_app 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): def test_all_priorities_exist(self):
"""Test that all expected priorities are defined.""" """Test that all expected priorities are defined."""
assert DownloadPriority.LOW == "LOW" assert DownloadPriority.LOW == "low"
assert DownloadPriority.NORMAL == "NORMAL" assert DownloadPriority.NORMAL == "normal"
assert DownloadPriority.HIGH == "HIGH" assert DownloadPriority.HIGH == "high"
def test_priority_values(self): def test_priority_values(self):
"""Test that priority values are uppercase strings.""" """Test that priority values are lowercase strings."""
for priority in DownloadPriority: for priority in DownloadPriority:
assert isinstance(priority.value, str) assert isinstance(priority.value, str)
assert priority.value.isupper() assert priority.value.islower()
class TestEpisodeIdentifier: class TestEpisodeIdentifier:

View File

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

View File

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

View File

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

View File

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