Files
Aniworld/tests/unit/test_scan_service.py

525 lines
18 KiB
Python

"""Unit tests for ScanService.
This module contains comprehensive tests for the scan service,
including scan lifecycle, progress events, and key-based identification.
"""
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, Mock
import pytest
from src.server.services.scan_service import (
ScanProgress,
ScanService,
ScanServiceError,
get_scan_service,
reset_scan_service,
)
class TestScanProgress:
"""Test ScanProgress class."""
def test_scan_progress_creation(self):
"""Test creating a scan progress instance."""
progress = ScanProgress("scan-123")
assert progress.scan_id == "scan-123"
assert progress.status == "started"
assert progress.current == 0
assert progress.total == 0
assert progress.percentage == 0.0
assert progress.message == "Initializing scan..."
assert progress.key is None
assert progress.folder is None
assert progress.series_found == 0
assert progress.errors == []
assert isinstance(progress.started_at, datetime)
assert isinstance(progress.updated_at, datetime)
def test_scan_progress_to_dict_basic(self):
"""Test converting scan progress to dictionary without key/folder."""
progress = ScanProgress("scan-123")
progress.current = 5
progress.total = 10
progress.percentage = 50.0
progress.status = "in_progress"
progress.message = "Scanning..."
result = progress.to_dict()
assert result["scan_id"] == "scan-123"
assert result["status"] == "in_progress"
assert result["current"] == 5
assert result["total"] == 10
assert result["percentage"] == 50.0
assert result["message"] == "Scanning..."
assert result["series_found"] == 0
assert result["errors"] == []
assert "started_at" in result
assert "updated_at" in result
# key and folder should not be present when None
assert "key" not in result
assert "folder" not in result
def test_scan_progress_to_dict_with_key_and_folder(self):
"""Test converting scan progress to dictionary with key and folder."""
progress = ScanProgress("scan-123")
progress.key = "attack-on-titan"
progress.folder = "Attack on Titan (2013)"
progress.series_found = 5
result = progress.to_dict()
assert result["key"] == "attack-on-titan"
assert result["folder"] == "Attack on Titan (2013)"
assert result["series_found"] == 5
def test_scan_progress_to_dict_with_errors(self):
"""Test scan progress with error messages."""
progress = ScanProgress("scan-123")
progress.errors = ["Error 1", "Error 2"]
result = progress.to_dict()
assert result["errors"] == ["Error 1", "Error 2"]
class TestScanService:
"""Test ScanService class."""
@pytest.fixture
def mock_progress_service(self):
"""Create a mock progress service."""
service = MagicMock()
service.start_progress = AsyncMock()
service.update_progress = AsyncMock()
service.complete_progress = AsyncMock()
service.fail_progress = AsyncMock()
return service
@pytest.fixture
def service(self, mock_progress_service):
"""Create a ScanService instance for each test."""
return ScanService(progress_service=mock_progress_service)
@pytest.mark.asyncio
async def test_service_initialization(self, service):
"""Test ScanService initialization."""
assert service.is_scanning is False
assert service.current_scan is None
assert service._scan_history == []
@pytest.mark.asyncio
async def test_start_scan(self, service):
"""Test starting a new scan."""
scanner_factory = MagicMock()
scan_id = await service.start_scan(scanner_factory)
assert scan_id is not None
assert len(scan_id) > 0
assert service.is_scanning is True
assert service.current_scan is not None
assert service.current_scan.scan_id == scan_id
@pytest.mark.asyncio
async def test_start_scan_while_scanning(self, service):
"""Test starting scan while another is in progress raises error."""
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
with pytest.raises(ScanServiceError, match="already in progress"):
await service.start_scan(scanner_factory)
@pytest.mark.asyncio
async def test_cancel_scan(self, service):
"""Test cancelling a scan in progress."""
scanner_factory = MagicMock()
scan_id = await service.start_scan(scanner_factory)
result = await service.cancel_scan()
assert result is True
assert service.is_scanning is False
assert service.current_scan.status == "cancelled"
assert len(service._scan_history) == 1
@pytest.mark.asyncio
async def test_cancel_scan_no_scan_in_progress(self, service):
"""Test cancelling when no scan is in progress."""
result = await service.cancel_scan()
assert result is False
@pytest.mark.asyncio
async def test_get_scan_status(self, service):
"""Test getting scan status."""
status = await service.get_scan_status()
assert status["is_scanning"] is False
assert status["current_scan"] is None
@pytest.mark.asyncio
async def test_get_scan_status_while_scanning(self, service):
"""Test getting scan status while scanning."""
scanner_factory = MagicMock()
scan_id = await service.start_scan(scanner_factory)
status = await service.get_scan_status()
assert status["is_scanning"] is True
assert status["current_scan"] is not None
assert status["current_scan"]["scan_id"] == scan_id
@pytest.mark.asyncio
async def test_get_scan_history_empty(self, service):
"""Test getting scan history when empty."""
history = await service.get_scan_history()
assert history == []
@pytest.mark.asyncio
async def test_get_scan_history_with_entries(self, service):
"""Test getting scan history with entries."""
# Start and cancel multiple scans to populate history
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
await service.cancel_scan()
await service.start_scan(scanner_factory)
await service.cancel_scan()
history = await service.get_scan_history()
assert len(history) == 2
# Should be newest first
assert history[0]["status"] == "cancelled"
@pytest.mark.asyncio
async def test_get_scan_history_limit(self, service):
"""Test scan history respects limit."""
scanner_factory = MagicMock()
# Create 3 history entries
for _ in range(3):
await service.start_scan(scanner_factory)
await service.cancel_scan()
history = await service.get_scan_history(limit=2)
assert len(history) == 2
@pytest.mark.asyncio
async def test_subscribe_to_scan_events(self, service):
"""Test subscribing to scan events."""
handler = MagicMock()
service.subscribe_to_scan_events(handler)
assert handler in service._scan_event_handlers
@pytest.mark.asyncio
async def test_unsubscribe_from_scan_events(self, service):
"""Test unsubscribing from scan events."""
handler = MagicMock()
service.subscribe_to_scan_events(handler)
service.unsubscribe_from_scan_events(handler)
assert handler not in service._scan_event_handlers
@pytest.mark.asyncio
async def test_emit_scan_event(self, service):
"""Test emitting scan events to handlers."""
handler = AsyncMock()
service.subscribe_to_scan_events(handler)
await service._emit_scan_event({
"type": "scan_progress",
"key": "test-series",
"folder": "Test Folder",
})
handler.assert_called_once_with({
"type": "scan_progress",
"key": "test-series",
"folder": "Test Folder",
})
@pytest.mark.asyncio
async def test_emit_scan_event_sync_handler(self, service):
"""Test emitting scan events to sync handlers."""
handler = MagicMock()
service.subscribe_to_scan_events(handler)
await service._emit_scan_event({
"type": "scan_progress",
"data": {"key": "test-series"},
})
handler.assert_called_once()
@pytest.mark.asyncio
async def test_handle_progress_update(
self, service, mock_progress_service
):
"""Test handling progress update."""
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
scan_progress = service.current_scan
scan_progress.current = 5
scan_progress.total = 10
scan_progress.percentage = 50.0
scan_progress.message = "Processing..."
scan_progress.key = "test-series"
scan_progress.folder = "Test Folder"
await service._handle_progress_update(scan_progress)
mock_progress_service.update_progress.assert_called_once()
call_kwargs = mock_progress_service.update_progress.call_args.kwargs
assert call_kwargs["progress_id"] == f"scan_{scan_progress.scan_id}"
assert call_kwargs["current"] == 5
assert call_kwargs["total"] == 10
assert call_kwargs["message"] == "Processing..."
@pytest.mark.asyncio
async def test_handle_scan_error(self, service):
"""Test handling scan error."""
handler = AsyncMock()
service.subscribe_to_scan_events(handler)
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
scan_progress = service.current_scan
error_data = {
"error": ValueError("Test error"),
"message": "Test error message",
"recoverable": True,
}
await service._handle_scan_error(scan_progress, error_data)
# Handler is called twice: once for start, once for error
assert handler.call_count == 2
# Get the error event (second call)
error_event = handler.call_args_list[1][0][0]
assert error_event["type"] == "scan_error"
assert error_event["message"] == "Test error message"
assert error_event["recoverable"] is True
@pytest.mark.asyncio
async def test_handle_scan_completion_success(
self, service, mock_progress_service
):
"""Test handling successful scan completion."""
handler = AsyncMock()
service.subscribe_to_scan_events(handler)
scanner_factory = MagicMock()
scan_id = await service.start_scan(scanner_factory)
scan_progress = service.current_scan
completion_data = {
"success": True,
"message": "Scan completed",
"statistics": {"series_found": 5, "total_folders": 10},
}
await service._handle_scan_completion(
scan_progress, completion_data
)
assert service.is_scanning is False
assert len(service._scan_history) == 1
mock_progress_service.complete_progress.assert_called_once()
# Handler is called twice: once for start, once for completion
assert handler.call_count == 2
# Get the completion event (second call)
completion_event = handler.call_args_list[1][0][0]
assert completion_event["type"] == "scan_completed"
assert completion_event["success"] is True
@pytest.mark.asyncio
async def test_handle_scan_completion_failure(
self, service, mock_progress_service
):
"""Test handling failed scan completion."""
handler = AsyncMock()
service.subscribe_to_scan_events(handler)
scanner_factory = MagicMock()
scan_id = await service.start_scan(scanner_factory)
scan_progress = service.current_scan
completion_data = {
"success": False,
"message": "Scan failed: critical error",
}
await service._handle_scan_completion(
scan_progress, completion_data
)
assert service.is_scanning is False
mock_progress_service.fail_progress.assert_called_once()
# Handler is called twice: once for start, once for failure
assert handler.call_count == 2
# Get the failure event (second call)
failure_event = handler.call_args_list[1][0][0]
assert failure_event["type"] == "scan_failed"
assert failure_event["success"] is False
class TestScanServiceSingleton:
"""Test ScanService singleton functions."""
def test_get_scan_service_returns_singleton(self):
"""Test that get_scan_service returns a singleton."""
reset_scan_service()
service1 = get_scan_service()
service2 = get_scan_service()
assert service1 is service2
def test_reset_scan_service(self):
"""Test that reset_scan_service clears the singleton."""
reset_scan_service()
service1 = get_scan_service()
reset_scan_service()
service2 = get_scan_service()
assert service1 is not service2
class TestScanServiceKeyIdentification:
"""Test that ScanService uses key as primary identifier."""
@pytest.fixture
def mock_progress_service(self):
"""Create a mock progress service."""
service = MagicMock()
service.start_progress = AsyncMock()
service.update_progress = AsyncMock()
service.complete_progress = AsyncMock()
service.fail_progress = AsyncMock()
return service
@pytest.fixture
def service(self, mock_progress_service):
"""Create a ScanService instance."""
return ScanService(progress_service=mock_progress_service)
@pytest.mark.asyncio
async def test_progress_update_includes_key(
self, service, mock_progress_service
):
"""Test that progress updates include key via scan event."""
events = []
async def capture(event):
events.append(event)
service.subscribe_to_scan_events(capture)
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
scan_progress = service.current_scan
scan_progress.key = "attack-on-titan"
scan_progress.folder = "Attack on Titan (2013)"
await service._handle_progress_update(scan_progress)
# First event is scan_started, second is the progress update
progress_event = events[-1]
assert progress_event["type"] == "scan_progress"
data = progress_event["data"]
assert data["key"] == "attack-on-titan"
assert data["folder"] == "Attack on Titan (2013)"
@pytest.mark.asyncio
async def test_scan_event_includes_key(self, service):
"""Test that scan events include key as primary identifier."""
events_received = []
async def capture_event(event):
events_received.append(event)
service.subscribe_to_scan_events(capture_event)
await service._emit_scan_event({
"type": "scan_progress",
"key": "my-hero-academia",
"folder": "My Hero Academia (2016)",
"data": {"status": "in_progress"},
})
assert len(events_received) == 1
assert events_received[0]["key"] == "my-hero-academia"
assert events_received[0]["folder"] == "My Hero Academia (2016)"
@pytest.mark.asyncio
async def test_error_event_includes_key(self, service):
"""Test that error events include key as primary identifier."""
events_received = []
async def capture_event(event):
events_received.append(event)
service.subscribe_to_scan_events(capture_event)
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
scan_progress = service.current_scan
error_data = {
"error": ValueError("Test"),
"message": "Error message",
"recoverable": True,
}
await service._handle_scan_error(scan_progress, error_data)
assert len(events_received) == 2 # Started + error
error_event = events_received[1]
assert error_event["type"] == "scan_error"
assert error_event["message"] == "Error message"
@pytest.mark.asyncio
async def test_scan_status_includes_key(self, service):
"""Test that scan status includes key in current scan."""
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
service.current_scan.key = "one-piece"
service.current_scan.folder = "One Piece (1999)"
status = await service.get_scan_status()
assert status["current_scan"]["key"] == "one-piece"
assert status["current_scan"]["folder"] == "One Piece (1999)"
@pytest.mark.asyncio
async def test_scan_history_includes_key(self, service):
"""Test that scan history includes key in entries."""
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
service.current_scan.key = "naruto"
service.current_scan.folder = "Naruto (2002)"
await service.cancel_scan()
history = await service.get_scan_history()
assert len(history) == 1
assert history[0]["key"] == "naruto"
assert history[0]["folder"] == "Naruto (2002)"