feat: cron-based scheduler with auto-download after rescan

- Replace asyncio sleep loop with APScheduler AsyncIOScheduler + CronTrigger
- Add schedule_time (HH:MM), schedule_days (days of week), auto_download_after_rescan fields to SchedulerConfig
- Add _auto_download_missing() to queue missing episodes after rescan
- Reload config live via reload_config(SchedulerConfig) without restart
- Update GET/POST /api/scheduler/config to return {success, config, status} envelope
- Add day-of-week pill toggles to Settings -> Scheduler section in UI
- Update JS loadSchedulerConfig / saveSchedulerConfig for new API shape
- Add 29 unit tests for SchedulerConfig model, 18 unit tests for SchedulerService
- Rewrite 23 endpoint tests and 36 integration tests for APScheduler behaviour
- Coverage: 96% api/scheduler, 95% scheduler_service, 90% total (>= 80% threshold)
- Update docs: API.md, CONFIGURATION.md, features.md, CHANGELOG.md
This commit is contained in:
2026-02-21 08:56:17 +01:00
parent ac7e15e1eb
commit 0265ae2a70
15 changed files with 1923 additions and 1628 deletions

View File

@@ -1,10 +1,8 @@
"""Integration tests for scheduler workflow.
This module tests end-to-end scheduler workflows including:
- Scheduler trigger → library rescan → database update workflow
- Configuration changes apply immediately
- Scheduler persistence after application restart
- Concurrent manual and automated scan handling
Tests end-to-end scheduler workflows with the APScheduler-based
SchedulerService, covering lifecycle, manual triggers, config reloading,
WebSocket broadcasting, auto-download, and concurrency protection.
"""
import asyncio
from datetime import datetime, timezone
@@ -15,499 +13,511 @@ import pytest
from src.server.models.config import AppConfig, SchedulerConfig
from src.server.services.scheduler_service import (
SchedulerService,
SchedulerServiceError,
_JOB_ID,
get_scheduler_service,
reset_scheduler_service,
)
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_config_service():
"""Create a mock configuration service."""
"""Patch get_config_service used by SchedulerService.start()."""
with patch("src.server.services.scheduler_service.get_config_service") as mock:
config_service = Mock()
# Default configuration
app_config = AppConfig(
scheduler=SchedulerConfig(
enabled=True,
interval_minutes=1 # Short interval for testing
schedule_time="03:00",
schedule_days=["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
auto_download_after_rescan=False,
)
)
config_service.load_config.return_value = app_config
config_service.update_config = Mock()
mock.return_value = config_service
yield config_service
@pytest.fixture
def mock_anime_service():
"""Create a mock anime service that simulates database updates."""
"""Patch get_anime_service used inside _perform_rescan."""
with patch("src.server.utils.dependencies.get_anime_service") as mock:
service = Mock()
service.rescan = AsyncMock()
service.series_list = []
# Simulate database update during rescan
async def rescan_side_effect():
# Simulate finding new series
service.series_list = [
{"key": "series1", "name": "New Series 1"},
{"key": "series2", "name": "New Series 2"}
]
await asyncio.sleep(0.1) # Simulate work
service.rescan.side_effect = rescan_side_effect
mock.return_value = service
yield service
@pytest.fixture
def mock_websocket_service():
"""Create a mock WebSocket service that tracks broadcasts."""
"""Patch get_websocket_service to capture broadcasts."""
with patch("src.server.services.websocket_service.get_websocket_service") as mock:
service = Mock()
service.manager = Mock()
service.broadcasts = [] # Track all broadcasts
service.broadcasts = []
async def broadcast_side_effect(message):
service.broadcasts.append(message)
service.manager.broadcast = AsyncMock(side_effect=broadcast_side_effect)
mock.return_value = service
yield service
@pytest.fixture
async def scheduler_service():
"""Create a fresh scheduler service instance for each test."""
async def scheduler_service(mock_config_service):
"""Fresh SchedulerService instance; stopped automatically after each test."""
reset_scheduler_service()
service = SchedulerService()
yield service
# Cleanup
if service._is_running:
await service.stop()
svc = SchedulerService()
yield svc
if svc._is_running:
await svc.stop()
class TestSchedulerWorkflow:
"""Tests for end-to-end scheduler workflows."""
# ---------------------------------------------------------------------------
# TestSchedulerLifecycle
# ---------------------------------------------------------------------------
class TestSchedulerLifecycle:
"""Tests for SchedulerService start/stop lifecycle."""
@pytest.mark.asyncio
async def test_scheduled_rescan_updates_database(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
):
"""Test that scheduled rescan updates the database with new series."""
# Start scheduler
await scheduler_service.start()
# Wait for at least one scan cycle (1 minute + buffer)
await asyncio.sleep(65)
# Verify database was updated
assert mock_anime_service.rescan.call_count >= 1
assert len(mock_anime_service.series_list) == 2
# Verify WebSocket notifications were sent
assert len(mock_websocket_service.broadcasts) >= 2
# Check for rescan events
event_types = [b["type"] for b in mock_websocket_service.broadcasts]
assert "scheduled_rescan_started" in event_types
assert "scheduled_rescan_completed" in event_types
# Cleanup
await scheduler_service.stop()
@pytest.mark.asyncio
async def test_configuration_change_applies_immediately(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
):
"""Test that configuration changes are applied immediately."""
# Start with 1 minute interval
await scheduler_service.start()
original_interval = scheduler_service._config.interval_minutes
assert original_interval == 1
# Change interval to 2 minutes
new_config = AppConfig(
scheduler=SchedulerConfig(
enabled=True,
interval_minutes=2
)
)
mock_config_service.load_config.return_value = new_config
# Reload configuration
await scheduler_service.reload_config()
# Verify new interval is applied
assert scheduler_service._config.interval_minutes == 2
assert scheduler_service._is_running is True # Should still be running
# Cleanup
await scheduler_service.stop()
@pytest.mark.asyncio
async def test_disable_scheduler_stops_execution(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
):
"""Test that disabling scheduler stops future rescans."""
# Start scheduler
async def test_start_sets_is_running(self, scheduler_service):
"""start() sets _is_running to True."""
await scheduler_service.start()
assert scheduler_service._is_running is True
# Wait for one scan to complete
await asyncio.sleep(65)
initial_scan_count = mock_anime_service.rescan.call_count
assert initial_scan_count >= 1
# Disable scheduler
disabled_config = AppConfig(
scheduler=SchedulerConfig(
enabled=False,
interval_minutes=1
)
)
mock_config_service.load_config.return_value = disabled_config
await scheduler_service.reload_config()
# Verify scheduler stopped
@pytest.mark.asyncio
async def test_stop_clears_is_running(self, scheduler_service):
"""stop() sets _is_running to False."""
await scheduler_service.start()
await scheduler_service.stop()
assert scheduler_service._is_running is False
# Wait another scan cycle
await asyncio.sleep(65)
# Verify no additional scans occurred
assert mock_anime_service.rescan.call_count == initial_scan_count
@pytest.mark.asyncio
async def test_manual_scan_blocks_scheduled_scan(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
):
"""Test that manual scan prevents concurrent scheduled scan."""
async def test_start_twice_raises(self, scheduler_service):
"""Calling start() when already running raises SchedulerServiceError."""
await scheduler_service.start()
# Make rescan slow to simulate long-running operation
async def slow_rescan():
await asyncio.sleep(2)
mock_anime_service.rescan.side_effect = slow_rescan
# Trigger manual scan
task1 = asyncio.create_task(scheduler_service._perform_rescan())
# Wait a bit to ensure manual scan is in progress
await asyncio.sleep(0.5)
assert scheduler_service._scan_in_progress is True
# Try to trigger another scan (simulating scheduled trigger)
result = await scheduler_service.trigger_rescan()
# Second scan should be blocked
assert result is False
# Wait for first scan to complete
await task1
# Verify only one scan executed
assert mock_anime_service.rescan.call_count == 1
# Cleanup
await scheduler_service.stop()
with pytest.raises(SchedulerServiceError, match="already running"):
await scheduler_service.start()
@pytest.mark.asyncio
async def test_scheduler_state_persists_across_restart(
self,
mock_config_service,
mock_anime_service,
mock_websocket_service
):
"""Test that scheduler can restart with same configuration."""
# Create and start first scheduler instance
async def test_stop_when_not_running_is_noop(self, scheduler_service):
"""stop() when not started does not raise."""
await scheduler_service.stop() # should not raise
assert scheduler_service._is_running is False
@pytest.mark.asyncio
async def test_start_loads_config(self, scheduler_service, mock_config_service):
"""start() loads configuration via config_service."""
await scheduler_service.start()
mock_config_service.load_config.assert_called_once()
@pytest.mark.asyncio
async def test_start_disabled_scheduler_no_job(self, mock_config_service):
"""Disabled scheduler starts but does not add an APScheduler job."""
mock_config_service.load_config.return_value = AppConfig(
scheduler=SchedulerConfig(enabled=False)
)
reset_scheduler_service()
scheduler1 = SchedulerService()
await scheduler1.start()
# Record configuration
original_config = scheduler1._config
assert scheduler1._is_running is True
# Stop scheduler (simulating app shutdown)
await scheduler1.stop()
assert scheduler1._is_running is False
# Create new scheduler instance (simulating app restart)
reset_scheduler_service()
scheduler2 = SchedulerService()
# Start new scheduler with same configuration
await scheduler2.start()
# Verify it has same configuration and is running
assert scheduler2._is_running is True
assert scheduler2._config.enabled == original_config.enabled
assert scheduler2._config.interval_minutes == original_config.interval_minutes
# Cleanup
await scheduler2.stop()
svc = SchedulerService()
await svc.start()
assert svc._is_running is True
# No job should be registered
assert svc._scheduler.get_job(_JOB_ID) is None
await svc.stop()
@pytest.mark.asyncio
async def test_scheduler_recovers_from_rescan_failure(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
):
"""Test that scheduler continues after rescan failure."""
# Make first rescan fail, subsequent rescans succeed
call_count = {"count": 0}
async def failing_rescan():
call_count["count"] += 1
if call_count["count"] == 1:
raise Exception("Database connection error")
# Subsequent calls succeed
mock_anime_service.rescan.side_effect = failing_rescan
async def test_start_registers_apscheduler_job(self, scheduler_service):
"""Enabled scheduler registers a job with _JOB_ID."""
await scheduler_service.start()
job = scheduler_service._scheduler.get_job(_JOB_ID)
assert job is not None
@pytest.mark.asyncio
async def test_restart_after_stop(self, scheduler_service):
"""Service can be started again after being stopped."""
await scheduler_service.start()
# Wait for multiple scan cycles (2 minutes + buffer)
await asyncio.sleep(130)
# Verify multiple scans were attempted despite failure
assert mock_anime_service.rescan.call_count >= 2
# Verify error was broadcast
error_broadcasts = [
b for b in mock_websocket_service.broadcasts
if b.get("type") == "scheduled_rescan_error"
]
assert len(error_broadcasts) >= 1
# Cleanup
await scheduler_service.stop()
await scheduler_service.start()
assert scheduler_service._is_running is True
# ---------------------------------------------------------------------------
# TestSchedulerTriggerRescan
# ---------------------------------------------------------------------------
class TestSchedulerTriggerRescan:
"""Tests for manual trigger_rescan workflow."""
@pytest.mark.asyncio
async def test_full_workflow_trigger_rescan_update_notify(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
async def test_trigger_rescan_calls_anime_service(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""Test complete workflow: triggerrescan → update → notify."""
"""trigger_rescan() calls anime_service.rescan()."""
await scheduler_service.start()
# Trigger manual rescan
result = await scheduler_service.trigger_rescan()
assert result is True
# Verify workflow steps
# 1. Rescan was performed
assert mock_anime_service.rescan.call_count == 1
# 2. Database was updated with new series
assert len(mock_anime_service.series_list) == 2
# 3. WebSocket notifications were sent
assert len(mock_websocket_service.broadcasts) >= 2
# 4. Verify event sequence
event_types = [b["type"] for b in mock_websocket_service.broadcasts]
start_index = event_types.index("scheduled_rescan_started")
complete_index = event_types.index("scheduled_rescan_completed")
assert complete_index > start_index # Complete comes after start
# 5. Verify scan time was recorded
mock_anime_service.rescan.assert_called_once()
@pytest.mark.asyncio
async def test_trigger_rescan_records_last_run(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""trigger_rescan() updates _last_scan_time."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
assert scheduler_service._last_scan_time is not None
assert isinstance(scheduler_service._last_scan_time, datetime)
# 6. Scan is no longer in progress
@pytest.mark.asyncio
async def test_trigger_rescan_when_not_running_raises(self, scheduler_service):
"""trigger_rescan() without start() raises SchedulerServiceError."""
with pytest.raises(SchedulerServiceError, match="not running"):
await scheduler_service.trigger_rescan()
@pytest.mark.asyncio
async def test_trigger_rescan_blocked_during_scan(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""Second trigger_rescan() returns False while a scan is in progress."""
async def slow_rescan():
await asyncio.sleep(0.3)
mock_anime_service.rescan.side_effect = slow_rescan
await scheduler_service.start()
task = asyncio.create_task(scheduler_service._perform_rescan())
await asyncio.sleep(0.05)
assert scheduler_service._scan_in_progress is True
result = await scheduler_service.trigger_rescan()
assert result is False
await task
@pytest.mark.asyncio
async def test_trigger_rescan_scan_in_progress_false_after_completion(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""scan_in_progress returns to False after trigger_rescan completes."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
assert scheduler_service._scan_in_progress is False
# Cleanup
await scheduler_service.stop()
@pytest.mark.asyncio
async def test_multiple_sequential_rescans(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""Test multiple sequential rescans execute successfully."""
"""Three sequential manual rescans all execute successfully."""
await scheduler_service.start()
# Trigger 3 manual rescans sequentially
for i in range(3):
for _ in range(3):
result = await scheduler_service.trigger_rescan()
assert result is True
# Small delay between rescans
await asyncio.sleep(0.1)
# Verify all 3 rescans executed
assert mock_anime_service.rescan.call_count == 3
# Verify 6 WebSocket broadcasts (start + complete for each scan)
assert len(mock_websocket_service.broadcasts) >= 6
# Cleanup
await scheduler_service.stop()
# ---------------------------------------------------------------------------
# TestSchedulerWebSocketBroadcasts
# ---------------------------------------------------------------------------
class TestSchedulerWebSocketBroadcasts:
"""Tests for WebSocket event emission during rescan."""
@pytest.mark.asyncio
async def test_scheduler_status_accuracy_during_workflow(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
async def test_rescan_broadcasts_started_event(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""Test that status accurately reflects scheduler state during workflow."""
# Initial status
"""_perform_rescan() broadcasts 'scheduled_rescan_started'."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
event_types = [b["type"] for b in mock_websocket_service.broadcasts]
assert "scheduled_rescan_started" in event_types
@pytest.mark.asyncio
async def test_rescan_broadcasts_completed_event(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""_perform_rescan() broadcasts 'scheduled_rescan_completed'."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
event_types = [b["type"] for b in mock_websocket_service.broadcasts]
assert "scheduled_rescan_completed" in event_types
@pytest.mark.asyncio
async def test_rescan_broadcasts_error_on_failure(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""_perform_rescan() broadcasts 'scheduled_rescan_error' when rescan raises."""
mock_anime_service.rescan.side_effect = RuntimeError("DB failure")
await scheduler_service.start()
await scheduler_service._perform_rescan()
error_events = [
b for b in mock_websocket_service.broadcasts
if b["type"] == "scheduled_rescan_error"
]
assert len(error_events) >= 1
@pytest.mark.asyncio
async def test_rescan_completed_event_order(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""'started' event precedes 'completed' event in broadcast sequence."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
types = [b["type"] for b in mock_websocket_service.broadcasts]
started_idx = types.index("scheduled_rescan_started")
completed_idx = types.index("scheduled_rescan_completed")
assert completed_idx > started_idx
# ---------------------------------------------------------------------------
# TestSchedulerGetStatus
# ---------------------------------------------------------------------------
class TestSchedulerGetStatus:
"""Tests for get_status() accuracy."""
@pytest.mark.asyncio
async def test_status_not_running_before_start(self, scheduler_service):
"""is_running is False before start()."""
status = scheduler_service.get_status()
assert status["is_running"] is False
assert status["scan_in_progress"] is False
# Start scheduler
@pytest.mark.asyncio
async def test_status_is_running_after_start(self, scheduler_service):
"""is_running is True after start()."""
await scheduler_service.start()
status = scheduler_service.get_status()
assert status["is_running"] is True
assert status["enabled"] is True
assert status["interval_minutes"] == 1
# Make rescan slow to check in-progress status
async def slow_rescan():
await asyncio.sleep(0.5)
mock_anime_service.rescan.side_effect = slow_rescan
# Start rescan
task = asyncio.create_task(scheduler_service._perform_rescan())
# Check status during rescan
await asyncio.sleep(0.1)
status = scheduler_service.get_status()
assert status["scan_in_progress"] is True
# Wait for rescan to complete
await task
# Check status after rescan
status = scheduler_service.get_status()
assert status["scan_in_progress"] is False
assert status["last_scan_time"] is not None
# Cleanup
await scheduler_service.stop()
# Final status
status = scheduler_service.get_status()
assert status["is_running"] is False
class TestSchedulerEdgeCases:
"""Tests for edge cases in scheduler workflows."""
@pytest.mark.asyncio
async def test_rapid_enable_disable_cycles(
self,
mock_config_service,
mock_anime_service,
mock_websocket_service
async def test_status_last_run_populated_after_rescan(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""Test rapid enable/disable cycles don't cause issues."""
reset_scheduler_service()
scheduler = SchedulerService()
# Rapidly enable and disable 5 times
for i in range(5):
enabled_config = AppConfig(
scheduler=SchedulerConfig(
enabled=True,
interval_minutes=1
)
)
disabled_config = AppConfig(
scheduler=SchedulerConfig(
enabled=False,
interval_minutes=1
)
)
if i % 2 == 0:
mock_config_service.load_config.return_value = enabled_config
await scheduler.reload_config()
else:
mock_config_service.load_config.return_value = disabled_config
await scheduler.reload_config()
await asyncio.sleep(0.1)
# Final state should match last configuration (i=4 is even, so enabled)
status = scheduler.get_status()
assert status["is_running"] is True # Last config (i=4) was enabled
# Cleanup
if scheduler._is_running:
await scheduler.stop()
@pytest.mark.asyncio
async def test_interval_change_during_active_scan(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
):
"""Test configuration change during active scan."""
"""last_run is not None after a successful rescan."""
await scheduler_service.start()
# Make rescan slow
await scheduler_service.trigger_rescan()
status = scheduler_service.get_status()
assert status["last_run"] is not None
@pytest.mark.asyncio
async def test_status_scan_in_progress_during_slow_rescan(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""scan_in_progress is True while rescan is executing."""
async def slow_rescan():
await asyncio.sleep(1)
await asyncio.sleep(0.3)
mock_anime_service.rescan.side_effect = slow_rescan
# Start a rescan
await scheduler_service.start()
task = asyncio.create_task(scheduler_service._perform_rescan())
# Change interval while scan is in progress
await asyncio.sleep(0.2)
new_config = AppConfig(
scheduler=SchedulerConfig(
enabled=True,
interval_minutes=5
)
)
mock_config_service.load_config.return_value = new_config
# Reload config (should restart scheduler)
await scheduler_service.reload_config()
# Wait for scan to complete
await asyncio.sleep(0.05)
assert scheduler_service.get_status()["scan_in_progress"] is True
await task
# Verify new interval is applied
assert scheduler_service._config.interval_minutes == 5
# Cleanup
@pytest.mark.asyncio
async def test_status_is_running_false_after_stop(self, scheduler_service):
"""is_running is False after stop()."""
await scheduler_service.start()
await scheduler_service.stop()
assert scheduler_service.get_status()["is_running"] is False
@pytest.mark.asyncio
async def test_status_includes_cron_fields(self, scheduler_service):
"""get_status() includes schedule_time, schedule_days, auto_download keys."""
await scheduler_service.start()
status = scheduler_service.get_status()
for key in ("schedule_time", "schedule_days", "auto_download_after_rescan", "next_run"):
assert key in status
# ---------------------------------------------------------------------------
# TestReloadConfig
# ---------------------------------------------------------------------------
class TestReloadConfig:
"""Tests for reload_config() live reconfiguration."""
@pytest.mark.asyncio
async def test_reload_reschedules_job_on_time_change(self, scheduler_service):
"""Changing schedule_time reschedules the existing job."""
await scheduler_service.start()
assert scheduler_service._scheduler.get_job(_JOB_ID) is not None
new_config = SchedulerConfig(enabled=True, schedule_time="08:00")
scheduler_service.reload_config(new_config)
job = scheduler_service._scheduler.get_job(_JOB_ID)
assert job is not None
assert scheduler_service._config.schedule_time == "08:00"
@pytest.mark.asyncio
async def test_reload_removes_job_when_disabled(self, scheduler_service):
"""Setting enabled=False removes the APScheduler job."""
await scheduler_service.start()
assert scheduler_service._scheduler.get_job(_JOB_ID) is not None
scheduler_service.reload_config(
SchedulerConfig(enabled=False)
)
assert scheduler_service._scheduler.get_job(_JOB_ID) is None
@pytest.mark.asyncio
async def test_reload_removes_job_when_days_empty(self, scheduler_service):
"""Empty schedule_days removes the APScheduler job."""
await scheduler_service.start()
scheduler_service.reload_config(
SchedulerConfig(enabled=True, schedule_days=[])
)
assert scheduler_service._scheduler.get_job(_JOB_ID) is None
@pytest.mark.asyncio
async def test_reload_adds_job_when_reenabling(self, scheduler_service):
"""Re-enabling after disable adds a new job."""
await scheduler_service.start()
scheduler_service.reload_config(SchedulerConfig(enabled=False))
assert scheduler_service._scheduler.get_job(_JOB_ID) is None
scheduler_service.reload_config(
SchedulerConfig(enabled=True, schedule_time="09:00")
)
assert scheduler_service._scheduler.get_job(_JOB_ID) is not None
@pytest.mark.asyncio
async def test_reload_updates_config_attribute(self, scheduler_service):
"""reload_config() updates self._config with the supplied instance."""
await scheduler_service.start()
new = SchedulerConfig(enabled=True, schedule_time="14:30", schedule_days=["mon"])
scheduler_service.reload_config(new)
assert scheduler_service._config.schedule_time == "14:30"
assert scheduler_service._config.schedule_days == ["mon"]
def test_reload_before_start_stores_config(self, scheduler_service):
"""reload_config() before start() stores config without raising."""
new = SchedulerConfig(enabled=True, schedule_time="22:00")
scheduler_service.reload_config(new)
assert scheduler_service._config.schedule_time == "22:00"
# ---------------------------------------------------------------------------
# TestAutoDownloadWorkflow
# ---------------------------------------------------------------------------
class TestAutoDownloadWorkflow:
"""Tests for auto-download-after-rescan integration."""
@pytest.mark.asyncio
async def test_auto_download_triggered_when_enabled(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""_auto_download_missing() is called when auto_download_after_rescan=True."""
scheduler_service._config = SchedulerConfig(
enabled=True,
auto_download_after_rescan=True,
)
scheduler_service._is_running = True
called = []
async def fake_auto_download():
called.append(True)
scheduler_service._auto_download_missing = fake_auto_download
await scheduler_service._perform_rescan()
assert called == [True]
@pytest.mark.asyncio
async def test_auto_download_not_called_when_disabled(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""_auto_download_missing() is NOT called when auto_download_after_rescan=False."""
scheduler_service._config = SchedulerConfig(
enabled=True,
auto_download_after_rescan=False,
)
scheduler_service._is_running = True
called = []
async def fake_auto_download():
called.append(True)
scheduler_service._auto_download_missing = fake_auto_download
await scheduler_service._perform_rescan()
assert called == []
@pytest.mark.asyncio
async def test_auto_download_error_broadcasts_event(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""Error in _auto_download_missing broadcasts 'auto_download_error'."""
scheduler_service._config = SchedulerConfig(
enabled=True,
auto_download_after_rescan=True,
)
scheduler_service._is_running = True
async def failing_auto_download():
raise RuntimeError("download failed")
scheduler_service._auto_download_missing = failing_auto_download
await scheduler_service._perform_rescan()
error_events = [
b for b in mock_websocket_service.broadcasts
if b["type"] == "auto_download_error"
]
assert len(error_events) == 1
# ---------------------------------------------------------------------------
# TestSchedulerSingletonHelpers
# ---------------------------------------------------------------------------
class TestSchedulerSingletonHelpers:
"""Tests for module-level singleton helpers."""
def test_get_scheduler_service_returns_same_instance(self):
"""get_scheduler_service() returns the same object on repeated calls."""
svc1 = get_scheduler_service()
svc2 = get_scheduler_service()
assert svc1 is svc2
def test_reset_clears_singleton(self):
"""reset_scheduler_service() causes get_scheduler_service() to return a new instance."""
svc1 = get_scheduler_service()
reset_scheduler_service()
svc2 = get_scheduler_service()
assert svc1 is not svc2
@pytest.mark.asyncio
async def test_state_persists_across_restart(self, mock_config_service):
"""Stopping and restarting loads config from service each time."""
reset_scheduler_service()
svc = SchedulerService()
await svc.start()
original_time = svc._config.schedule_time
assert svc._is_running is True
await svc.stop()
assert svc._is_running is False
reset_scheduler_service()
svc2 = SchedulerService()
await svc2.start()
assert svc2._is_running is True
assert svc2._config.schedule_time == original_time
await svc2.stop()