Aniworld/tests/unit/test_scan_service.py
Lukas 6726c176b2 feat(Task 3.4): Implement ScanService with key-based identification
- Create ScanService class (src/server/services/scan_service.py)
  - Use 'key' as primary series identifier throughout
  - Include 'folder' as metadata only for display purposes
  - Implement scan progress tracking via ProgressService
  - Add callback classes for progress, error, and completion
  - Support scan event subscription and broadcasting
  - Maintain scan history with configurable limit
  - Provide cancellation support for in-progress scans

- Create comprehensive unit tests (tests/unit/test_scan_service.py)
  - 38 tests covering all functionality
  - Test ScanProgress dataclass serialization
  - Test callback classes (progress, error, completion)
  - Test service lifecycle (start, cancel, status)
  - Test event subscription and broadcasting
  - Test key-based identification throughout
  - Test singleton pattern

- Update infrastructure.md with ScanService documentation
  - Document service overview and key features
  - Document components and event types
  - Document integration points
  - Include usage example

- Update instructions.md
  - Mark Task 3.4 as complete
  - Mark Phase 3 as fully complete
  - Remove finished task definition

Task: Phase 3, Task 3.4 - Update ScanService to Use Key
Completion Date: November 27, 2025
2025-11-27 18:50:02 +01:00

740 lines
25 KiB
Python

"""Unit tests for ScanService.
This module contains comprehensive tests for the scan service,
including scan lifecycle, progress callbacks, event handling,
and key-based identification.
"""
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.core.interfaces.callbacks import (
CallbackManager,
CompletionContext,
ErrorContext,
OperationType,
ProgressContext,
ProgressPhase,
)
from src.server.services.scan_service import (
ScanProgress,
ScanService,
ScanServiceCompletionCallback,
ScanServiceError,
ScanServiceErrorCallback,
ScanServiceProgressCallback,
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 TestScanServiceProgressCallback:
"""Test ScanServiceProgressCallback class."""
@pytest.fixture
def mock_service(self):
"""Create a mock ScanService."""
service = MagicMock(spec=ScanService)
service._handle_progress_update = AsyncMock()
return service
@pytest.fixture
def scan_progress(self):
"""Create a scan progress instance."""
return ScanProgress("scan-123")
def test_on_progress_updates_progress(self, mock_service, scan_progress):
"""Test that on_progress updates scan progress correctly."""
callback = ScanServiceProgressCallback(mock_service, scan_progress)
context = ProgressContext(
operation_type=OperationType.SCAN,
operation_id="scan-123",
phase=ProgressPhase.IN_PROGRESS,
current=5,
total=10,
percentage=50.0,
message="Scanning: Test Folder",
key="test-series",
folder="Test Folder",
)
# Call directly - no event loop needed since we handle RuntimeError
callback.on_progress(context)
assert scan_progress.current == 5
assert scan_progress.total == 10
assert scan_progress.percentage == 50.0
assert scan_progress.message == "Scanning: Test Folder"
assert scan_progress.key == "test-series"
assert scan_progress.folder == "Test Folder"
assert scan_progress.status == "in_progress"
def test_on_progress_starting_phase(self, mock_service, scan_progress):
"""Test progress callback with STARTING phase."""
callback = ScanServiceProgressCallback(mock_service, scan_progress)
context = ProgressContext(
operation_type=OperationType.SCAN,
operation_id="scan-123",
phase=ProgressPhase.STARTING,
current=0,
total=0,
percentage=0.0,
message="Initializing...",
)
callback.on_progress(context)
assert scan_progress.status == "started"
def test_on_progress_completed_phase(self, mock_service, scan_progress):
"""Test progress callback with COMPLETED phase."""
callback = ScanServiceProgressCallback(mock_service, scan_progress)
context = ProgressContext(
operation_type=OperationType.SCAN,
operation_id="scan-123",
phase=ProgressPhase.COMPLETED,
current=10,
total=10,
percentage=100.0,
message="Scan completed",
)
callback.on_progress(context)
assert scan_progress.status == "completed"
class TestScanServiceErrorCallback:
"""Test ScanServiceErrorCallback class."""
@pytest.fixture
def mock_service(self):
"""Create a mock ScanService."""
service = MagicMock(spec=ScanService)
service._handle_scan_error = AsyncMock()
return service
@pytest.fixture
def scan_progress(self):
"""Create a scan progress instance."""
return ScanProgress("scan-123")
def test_on_error_adds_error_message(self, mock_service, scan_progress):
"""Test that on_error adds error to scan progress."""
callback = ScanServiceErrorCallback(mock_service, scan_progress)
error = ValueError("Test error")
context = ErrorContext(
operation_type=OperationType.SCAN,
operation_id="scan-123",
error=error,
message="Failed to process folder",
recoverable=True,
key="test-series",
folder="Test Folder",
)
callback.on_error(context)
assert len(scan_progress.errors) == 1
assert "[Test Folder]" in scan_progress.errors[0]
assert "Failed to process folder" in scan_progress.errors[0]
def test_on_error_without_folder(self, mock_service, scan_progress):
"""Test error callback without folder information."""
callback = ScanServiceErrorCallback(mock_service, scan_progress)
error = ValueError("Test error")
context = ErrorContext(
operation_type=OperationType.SCAN,
operation_id="scan-123",
error=error,
message="Generic error",
recoverable=False,
)
callback.on_error(context)
assert len(scan_progress.errors) == 1
assert scan_progress.errors[0] == "Generic error"
class TestScanServiceCompletionCallback:
"""Test ScanServiceCompletionCallback class."""
@pytest.fixture
def mock_service(self):
"""Create a mock ScanService."""
service = MagicMock(spec=ScanService)
service._handle_scan_completion = AsyncMock()
return service
@pytest.fixture
def scan_progress(self):
"""Create a scan progress instance."""
return ScanProgress("scan-123")
def test_on_completion_success(self, mock_service, scan_progress):
"""Test completion callback with success."""
callback = ScanServiceCompletionCallback(mock_service, scan_progress)
context = CompletionContext(
operation_type=OperationType.SCAN,
operation_id="scan-123",
success=True,
message="Scan completed successfully",
statistics={"series_found": 10, "total_folders": 15},
)
callback.on_completion(context)
assert scan_progress.status == "completed"
assert scan_progress.message == "Scan completed successfully"
assert scan_progress.series_found == 10
def test_on_completion_failure(self, mock_service, scan_progress):
"""Test completion callback with failure."""
callback = ScanServiceCompletionCallback(mock_service, scan_progress)
context = CompletionContext(
operation_type=OperationType.SCAN,
operation_id="scan-123",
success=False,
message="Scan failed: critical error",
)
callback.on_completion(context)
assert scan_progress.status == "failed"
assert scan_progress.message == "Scan failed: critical error"
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_create_callback_manager(self, service):
"""Test creating a callback manager."""
scanner_factory = MagicMock()
await service.start_scan(scanner_factory)
callback_manager = service.create_callback_manager()
assert callback_manager is not None
assert isinstance(callback_manager, CallbackManager)
@pytest.mark.asyncio
async def test_create_callback_manager_no_current_scan(self, service):
"""Test creating callback manager without current scan."""
callback_manager = service.create_callback_manager()
assert callback_manager is not None
assert service.current_scan is not None
@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["key"] == "test-series"
assert call_kwargs["folder"] == "Test Folder"
@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_context = ErrorContext(
operation_type=OperationType.SCAN,
operation_id=scan_progress.scan_id,
error=ValueError("Test error"),
message="Test error message",
recoverable=True,
key="test-series",
folder="Test Folder",
)
await service._handle_scan_error(scan_progress, error_context)
# 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["key"] == "test-series"
assert error_event["folder"] == "Test Folder"
@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_context = CompletionContext(
operation_type=OperationType.SCAN,
operation_id=scan_id,
success=True,
message="Scan completed",
statistics={"series_found": 5, "total_folders": 10},
)
await service._handle_scan_completion(
scan_progress, completion_context
)
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_context = CompletionContext(
operation_type=OperationType.SCAN,
operation_id=scan_id,
success=False,
message="Scan failed: critical error",
)
await service._handle_scan_completion(
scan_progress, completion_context
)
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 as primary identifier."""
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)
call_kwargs = mock_progress_service.update_progress.call_args.kwargs
assert call_kwargs["key"] == "attack-on-titan"
assert call_kwargs["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_context = ErrorContext(
operation_type=OperationType.SCAN,
operation_id=scan_progress.scan_id,
error=ValueError("Test"),
message="Error message",
key="demon-slayer",
folder="Demon Slayer (2019)",
)
await service._handle_scan_error(scan_progress, error_context)
assert len(events_received) == 2 # Started + error
error_event = events_received[1]
assert error_event["type"] == "scan_error"
assert error_event["key"] == "demon-slayer"
assert error_event["folder"] == "Demon Slayer (2019)"
@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)"