Scheduler used a separate SQLite file (scheduler.db) only to persist one cron job. This was originally required because APScheduler's SQLAlchemyJobStore is sync-only, creating an async/sync driver conflict when accessing the same file. The job is rebuilt from config.json on every startup regardless (replace_existing=True), so the persisted state only served misfire detection. Moved misfire detection into the app layer by querying system_settings.last_scan_timestamp on startup: if the last scan is >23h but <25h ago, an immediate rescan is triggered. Change summary: - Remove SQLAlchemyJobStore; use default MemoryJobStore instead - Add _check_missed_run() that reads last_scan_timestamp from aniworld.db - Update docs/DEVELOPMENT.md scheduler troubleshooting section - Update the scheduler unit test that verified SQLAlchemyJobStore
595 lines
22 KiB
Python
595 lines
22 KiB
Python
"""Unit tests for SchedulerService (APScheduler-based implementation).
|
||
|
||
Covers:
|
||
- _build_cron_trigger()
|
||
- start() / stop() with APScheduler mocking
|
||
- reload_config()
|
||
- get_status()
|
||
- _perform_rescan() with auto-download
|
||
- Error handling and edge cases
|
||
"""
|
||
from datetime import datetime, timezone
|
||
from unittest.mock import AsyncMock, MagicMock, Mock, call, patch
|
||
|
||
import pytest
|
||
from apscheduler.triggers.cron import CronTrigger
|
||
|
||
from src.server.models.config import AppConfig, SchedulerConfig
|
||
from src.server.services.scheduler_service import (
|
||
_JOB_ID,
|
||
SchedulerService,
|
||
SchedulerServiceError,
|
||
get_scheduler_service,
|
||
reset_scheduler_service,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Shared fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
ALL_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||
|
||
|
||
def _make_app_config(**scheduler_kwargs) -> AppConfig:
|
||
return AppConfig(scheduler=SchedulerConfig(**scheduler_kwargs))
|
||
|
||
|
||
@pytest.fixture
|
||
def mock_config_service():
|
||
with patch("src.server.services.scheduler_service.get_config_service") as mock:
|
||
svc = Mock()
|
||
svc.load_config.return_value = _make_app_config(
|
||
enabled=True,
|
||
schedule_time="03:00",
|
||
schedule_days=ALL_DAYS,
|
||
)
|
||
mock.return_value = svc
|
||
yield svc
|
||
|
||
|
||
@pytest.fixture
|
||
def scheduler_service():
|
||
reset_scheduler_service()
|
||
yield SchedulerService()
|
||
reset_scheduler_service()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 12.1 _build_cron_trigger
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestBuildCronTrigger:
|
||
def test_standard_case(self, scheduler_service):
|
||
scheduler_service._config = SchedulerConfig(
|
||
schedule_time="03:00",
|
||
schedule_days=["mon", "wed", "fri"],
|
||
)
|
||
trigger = scheduler_service._build_cron_trigger()
|
||
assert isinstance(trigger, CronTrigger)
|
||
fields = {f.name: str(f) for f in trigger.fields}
|
||
assert fields["hour"] == "3"
|
||
assert fields["minute"] == "0"
|
||
assert "mon" in fields["day_of_week"]
|
||
assert "fri" in fields["day_of_week"]
|
||
|
||
def test_all_days(self, scheduler_service):
|
||
scheduler_service._config = SchedulerConfig(
|
||
schedule_time="23:59",
|
||
schedule_days=ALL_DAYS,
|
||
)
|
||
trigger = scheduler_service._build_cron_trigger()
|
||
assert trigger is not None
|
||
fields = {f.name: str(f) for f in trigger.fields}
|
||
for day in ALL_DAYS:
|
||
assert day in fields["day_of_week"]
|
||
|
||
def test_empty_days_returns_none(self, scheduler_service):
|
||
scheduler_service._config = SchedulerConfig(
|
||
schedule_time="03:00",
|
||
schedule_days=[],
|
||
)
|
||
assert scheduler_service._build_cron_trigger() is None
|
||
|
||
def test_no_config_returns_none(self, scheduler_service):
|
||
scheduler_service._config = None
|
||
assert scheduler_service._build_cron_trigger() is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 12.2 start() – normal case
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestStart:
|
||
@pytest.mark.asyncio
|
||
async def test_start_adds_job_and_starts_scheduler(
|
||
self, scheduler_service, mock_config_service
|
||
):
|
||
with patch(
|
||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||
) as MockScheduler:
|
||
mock_sched = MagicMock()
|
||
mock_sched.running = False
|
||
MockScheduler.return_value = mock_sched
|
||
|
||
await scheduler_service.start()
|
||
|
||
mock_sched.add_job.assert_called_once()
|
||
call_kwargs = mock_sched.add_job.call_args
|
||
assert call_kwargs[1]["id"] == _JOB_ID
|
||
assert isinstance(call_kwargs[1]["trigger"], CronTrigger)
|
||
assert call_kwargs[1]["misfire_grace_time"] == 3600
|
||
assert call_kwargs[1]["coalesce"] is True
|
||
mock_sched.start.assert_called_once()
|
||
assert scheduler_service._is_running is True
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_start_raises_if_already_running(self, scheduler_service):
|
||
scheduler_service._is_running = True
|
||
with pytest.raises(SchedulerServiceError, match="already running"):
|
||
await scheduler_service.start()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 12.3 start() – empty schedule_days
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestStartEmptyDays:
|
||
@pytest.mark.asyncio
|
||
async def test_no_job_added_when_days_empty(self, scheduler_service):
|
||
with patch(
|
||
"src.server.services.scheduler_service.get_config_service"
|
||
) as mock_cs, patch(
|
||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||
) as MockScheduler:
|
||
svc = Mock()
|
||
svc.load_config.return_value = _make_app_config(
|
||
enabled=True, schedule_days=[]
|
||
)
|
||
mock_cs.return_value = svc
|
||
|
||
mock_sched = MagicMock()
|
||
MockScheduler.return_value = mock_sched
|
||
|
||
await scheduler_service.start()
|
||
|
||
mock_sched.add_job.assert_not_called()
|
||
mock_sched.start.assert_called_once()
|
||
assert scheduler_service._is_running is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 12.4 stop()
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestStop:
|
||
@pytest.mark.asyncio
|
||
async def test_stop_shuts_down_scheduler(self, scheduler_service):
|
||
mock_sched = MagicMock()
|
||
mock_sched.running = True
|
||
scheduler_service._scheduler = mock_sched
|
||
scheduler_service._is_running = True
|
||
|
||
await scheduler_service.stop()
|
||
|
||
mock_sched.shutdown.assert_called_once_with(wait=False)
|
||
assert scheduler_service._is_running is False
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stop_when_not_running_is_noop(self, scheduler_service):
|
||
# Should not raise
|
||
await scheduler_service.stop()
|
||
assert scheduler_service._is_running is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 12.5 reload_config() – reschedule
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestReloadConfig:
|
||
def test_reschedule_on_time_change(self, scheduler_service):
|
||
mock_sched = MagicMock()
|
||
mock_sched.running = True
|
||
mock_sched.get_job.return_value = Mock() # job exists
|
||
scheduler_service._scheduler = mock_sched
|
||
scheduler_service._config = SchedulerConfig(
|
||
schedule_time="03:00", schedule_days=ALL_DAYS
|
||
)
|
||
|
||
new_config = SchedulerConfig(schedule_time="05:00", schedule_days=ALL_DAYS)
|
||
scheduler_service.reload_config(new_config)
|
||
|
||
mock_sched.reschedule_job.assert_called_once_with(
|
||
_JOB_ID, trigger=mock_sched.reschedule_job.call_args[1]["trigger"]
|
||
)
|
||
assert scheduler_service._config.schedule_time == "05:00"
|
||
|
||
def test_reschedule_on_days_change(self, scheduler_service):
|
||
mock_sched = MagicMock()
|
||
mock_sched.running = True
|
||
mock_sched.get_job.return_value = Mock()
|
||
scheduler_service._scheduler = mock_sched
|
||
scheduler_service._config = SchedulerConfig(
|
||
schedule_time="03:00", schedule_days=ALL_DAYS
|
||
)
|
||
|
||
new_config = SchedulerConfig(schedule_time="03:00", schedule_days=["mon"])
|
||
scheduler_service.reload_config(new_config)
|
||
|
||
mock_sched.reschedule_job.assert_called_once()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 12.6 reload_config() – empty days removes job
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestReloadConfigEmptyDays:
|
||
def test_removes_job_when_days_empty(self, scheduler_service):
|
||
mock_sched = MagicMock()
|
||
mock_sched.running = True
|
||
mock_sched.get_job.return_value = Mock() # job exists
|
||
scheduler_service._scheduler = mock_sched
|
||
scheduler_service._config = SchedulerConfig(
|
||
schedule_time="03:00", schedule_days=ALL_DAYS
|
||
)
|
||
|
||
new_config = SchedulerConfig(schedule_time="03:00", schedule_days=[])
|
||
scheduler_service.reload_config(new_config)
|
||
|
||
mock_sched.remove_job.assert_called_once_with(_JOB_ID)
|
||
mock_sched.reschedule_job.assert_not_called()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 12.7 _perform_rescan() with auto_download=True
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestPerformRescanAutoDownload:
|
||
@pytest.mark.asyncio
|
||
async def test_auto_download_queues_missing_episodes(self, scheduler_service):
|
||
scheduler_service._config = SchedulerConfig(
|
||
auto_download_after_rescan=True,
|
||
schedule_time="03:00",
|
||
schedule_days=ALL_DAYS,
|
||
)
|
||
|
||
mock_anime = MagicMock()
|
||
mock_anime.rescan = AsyncMock()
|
||
mock_anime._cached_list_missing.return_value = [
|
||
{"key": "series-a", "name": "Series A", "folder": "Series A",
|
||
"episodeDict": {"1": [1, 2, 3]}},
|
||
{"key": "series-b", "name": "Series B", "folder": "Series B",
|
||
"episodeDict": {}}, # no missing → should be skipped
|
||
]
|
||
|
||
mock_dl = MagicMock()
|
||
mock_dl.add_to_queue = AsyncMock(return_value=["id1", "id2", "id3"])
|
||
mock_dl.start_queue_processing = AsyncMock(return_value=None)
|
||
|
||
mock_ws = MagicMock()
|
||
mock_ws.manager.broadcast = AsyncMock()
|
||
|
||
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
|
||
patch("src.server.utils.dependencies.get_download_service", return_value=mock_dl), \
|
||
patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws):
|
||
await scheduler_service._perform_rescan()
|
||
|
||
mock_dl.add_to_queue.assert_called_once()
|
||
call_kwargs = mock_dl.add_to_queue.call_args[1]
|
||
assert len(call_kwargs["episodes"]) == 3
|
||
mock_dl.start_queue_processing.assert_called_once()
|
||
|
||
# Check auto_download_started was broadcast
|
||
calls = [str(c) for c in mock_ws.manager.broadcast.call_args_list]
|
||
assert any("auto_download_started" in c for c in calls)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 12.8 _perform_rescan() with auto_download=False
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestPerformRescanNoAutoDownload:
|
||
@pytest.mark.asyncio
|
||
async def test_no_download_when_disabled(self, scheduler_service):
|
||
scheduler_service._config = SchedulerConfig(
|
||
auto_download_after_rescan=False,
|
||
)
|
||
|
||
mock_anime = MagicMock()
|
||
mock_anime.rescan = AsyncMock()
|
||
mock_anime._cached_list_missing.return_value = [
|
||
{"key": "series-a", "name": "Series A", "folder": "Series A",
|
||
"episodeDict": {"1": [1, 2]}},
|
||
]
|
||
|
||
mock_dl = MagicMock()
|
||
mock_dl.add_to_queue = AsyncMock()
|
||
|
||
mock_ws = MagicMock()
|
||
mock_ws.manager.broadcast = AsyncMock()
|
||
|
||
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
|
||
patch("src.server.utils.dependencies.get_download_service", return_value=mock_dl), \
|
||
patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws):
|
||
await scheduler_service._perform_rescan()
|
||
|
||
mock_dl.add_to_queue.assert_not_called()
|
||
calls = [str(c) for c in mock_ws.manager.broadcast.call_args_list]
|
||
assert not any("auto_download_started" in c for c in calls)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 12.9 Auto-download error handling
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestAutoDownloadErrorHandling:
|
||
@pytest.mark.asyncio
|
||
async def test_download_error_broadcasts_and_does_not_crash(
|
||
self, scheduler_service
|
||
):
|
||
scheduler_service._config = SchedulerConfig(
|
||
auto_download_after_rescan=True,
|
||
)
|
||
|
||
mock_anime = MagicMock()
|
||
mock_anime.rescan = AsyncMock()
|
||
mock_anime._cached_list_missing.return_value = [
|
||
{"key": "series-a", "name": "Series A", "folder": "Series A",
|
||
"episodeDict": {"1": [1]}},
|
||
]
|
||
|
||
mock_dl = MagicMock()
|
||
mock_dl.add_to_queue = AsyncMock(side_effect=RuntimeError("boom"))
|
||
|
||
mock_ws = MagicMock()
|
||
mock_ws.manager.broadcast = AsyncMock()
|
||
|
||
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
|
||
patch("src.server.utils.dependencies.get_download_service", return_value=mock_dl), \
|
||
patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws):
|
||
# Should NOT raise
|
||
await scheduler_service._perform_rescan()
|
||
|
||
calls = [str(c) for c in mock_ws.manager.broadcast.call_args_list]
|
||
assert any("auto_download_error" in c for c in calls)
|
||
|
||
# Rescan itself succeeded — scan_in_progress must be False
|
||
assert scheduler_service._scan_in_progress is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 12.10 get_status() returns correct fields
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestGetStatus:
|
||
def test_get_status_fields_when_not_running(self, scheduler_service):
|
||
scheduler_service._config = SchedulerConfig(
|
||
schedule_time="04:00",
|
||
schedule_days=["mon"],
|
||
auto_download_after_rescan=True,
|
||
folder_scan_enabled=True,
|
||
)
|
||
status = scheduler_service.get_status()
|
||
|
||
assert "is_running" in status
|
||
assert "next_run" in status
|
||
assert "last_run" in status
|
||
assert "schedule_time" in status
|
||
assert "schedule_days" in status
|
||
assert "auto_download_after_rescan" in status
|
||
assert "folder_scan_enabled" in status
|
||
assert status["schedule_time"] == "04:00"
|
||
assert status["schedule_days"] == ["mon"]
|
||
assert status["auto_download_after_rescan"] is True
|
||
assert status["folder_scan_enabled"] is True
|
||
assert status["is_running"] is False
|
||
assert status["next_run"] is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 12.11 _perform_rescan() with folder_scan_enabled=True
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestPerformRescanFolderScan:
|
||
@pytest.mark.asyncio
|
||
async def test_folder_scan_called_when_enabled(self, scheduler_service):
|
||
scheduler_service._config = SchedulerConfig(
|
||
folder_scan_enabled=True,
|
||
schedule_time="03:00",
|
||
schedule_days=ALL_DAYS,
|
||
)
|
||
|
||
mock_anime = MagicMock()
|
||
mock_anime.rescan = AsyncMock()
|
||
mock_anime._cached_list_missing.return_value = []
|
||
|
||
mock_ws = MagicMock()
|
||
mock_ws.manager.broadcast = AsyncMock()
|
||
|
||
mock_folder_scan = AsyncMock()
|
||
|
||
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
|
||
patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \
|
||
patch("src.server.services.folder_scan_service.FolderScanService") as MockFSS:
|
||
MockFSS.return_value.run_folder_scan = mock_folder_scan
|
||
await scheduler_service._perform_rescan()
|
||
|
||
mock_folder_scan.assert_awaited_once()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_folder_scan_skipped_when_disabled(self, scheduler_service):
|
||
scheduler_service._config = SchedulerConfig(
|
||
folder_scan_enabled=False,
|
||
schedule_time="03:00",
|
||
schedule_days=ALL_DAYS,
|
||
)
|
||
|
||
mock_anime = MagicMock()
|
||
mock_anime.rescan = AsyncMock()
|
||
mock_anime._cached_list_missing.return_value = []
|
||
|
||
mock_ws = MagicMock()
|
||
mock_ws.manager.broadcast = AsyncMock()
|
||
|
||
mock_folder_scan = AsyncMock()
|
||
|
||
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
|
||
patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \
|
||
patch("src.server.services.folder_scan_service.FolderScanService") as MockFSS:
|
||
MockFSS.return_value.run_folder_scan = mock_folder_scan
|
||
await scheduler_service._perform_rescan()
|
||
|
||
mock_folder_scan.assert_not_called()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_folder_scan_error_broadcasts_and_does_not_crash(self, scheduler_service):
|
||
scheduler_service._config = SchedulerConfig(
|
||
folder_scan_enabled=True,
|
||
schedule_time="03:00",
|
||
schedule_days=ALL_DAYS,
|
||
)
|
||
|
||
mock_anime = MagicMock()
|
||
mock_anime.rescan = AsyncMock()
|
||
mock_anime._cached_list_missing.return_value = []
|
||
|
||
mock_ws = MagicMock()
|
||
mock_ws.manager.broadcast = AsyncMock()
|
||
|
||
mock_folder_scan = AsyncMock(side_effect=RuntimeError("folder scan boom"))
|
||
|
||
with patch("src.server.utils.dependencies.get_anime_service", return_value=mock_anime), \
|
||
patch("src.server.services.websocket_service.get_websocket_service", return_value=mock_ws), \
|
||
patch("src.server.services.folder_scan_service.FolderScanService") as MockFSS:
|
||
MockFSS.return_value.run_folder_scan = mock_folder_scan
|
||
# Should NOT raise
|
||
await scheduler_service._perform_rescan()
|
||
|
||
mock_folder_scan.assert_awaited_once()
|
||
calls = [str(c) for c in mock_ws.manager.broadcast.call_args_list]
|
||
assert any("folder_scan_error" in c for c in calls)
|
||
assert scheduler_service._scan_in_progress is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Singleton helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestSingletonHelpers:
|
||
def test_get_scheduler_service_returns_same_instance(self):
|
||
reset_scheduler_service()
|
||
svc1 = get_scheduler_service()
|
||
svc2 = get_scheduler_service()
|
||
assert svc1 is svc2
|
||
|
||
def test_reset_clears_singleton(self):
|
||
get_scheduler_service()
|
||
reset_scheduler_service()
|
||
svc = get_scheduler_service()
|
||
assert svc is not None # fresh instance
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 12.12 In-memory job store — no separate scheduler.db needed
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestInMemoryJobStore:
|
||
@pytest.mark.asyncio
|
||
async def test_start_creates_scheduler_without_jobstore_arg(
|
||
self, scheduler_service, mock_config_service
|
||
):
|
||
with patch(
|
||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||
) as MockScheduler:
|
||
mock_sched = MagicMock()
|
||
mock_sched.running = False
|
||
MockScheduler.return_value = mock_sched
|
||
|
||
await scheduler_service.start()
|
||
|
||
MockScheduler.assert_called_once()
|
||
call_kwargs = MockScheduler.call_args
|
||
# No jobstores argument — uses default MemoryJobStore
|
||
if call_kwargs[1]:
|
||
assert "jobstores" not in call_kwargs[1]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_job_options_include_misfire_grace_and_coalesce(
|
||
self, scheduler_service, mock_config_service
|
||
):
|
||
with patch(
|
||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||
) as MockScheduler:
|
||
mock_sched = MagicMock()
|
||
mock_sched.running = False
|
||
MockScheduler.return_value = mock_sched
|
||
|
||
await scheduler_service.start()
|
||
|
||
call_kwargs = mock_sched.add_job.call_args
|
||
assert call_kwargs[1]["misfire_grace_time"] == 3600
|
||
assert call_kwargs[1]["coalesce"] is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 12.13 Startup recovery — next run logged after start()
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestStartupRecovery:
|
||
@pytest.mark.asyncio
|
||
async def test_start_logs_next_run_time(
|
||
self, scheduler_service, mock_config_service
|
||
):
|
||
with patch(
|
||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||
) as MockScheduler:
|
||
mock_job = MagicMock()
|
||
next_run_dt = datetime(2026, 5, 25, 3, 0, tzinfo=timezone.utc)
|
||
mock_job.next_run_time = next_run_dt
|
||
mock_sched = MagicMock()
|
||
mock_sched.running = False
|
||
mock_sched.get_job.return_value = mock_job
|
||
MockScheduler.return_value = mock_sched
|
||
|
||
with patch(
|
||
"src.server.services.scheduler_service.logger"
|
||
) as mock_logger:
|
||
await scheduler_service.start()
|
||
info_calls = [str(c) for c in mock_logger.info.call_args_list]
|
||
assert any("next_run" in str(c) or "Scheduler" in str(c) for c in info_calls)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 12.8 ensure_started() – idempotent startup
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestEnsureStarted:
|
||
@pytest.mark.asyncio
|
||
async def test_ensure_started_when_not_running(
|
||
self, scheduler_service, mock_config_service
|
||
):
|
||
"""ensure_started() calls start() when scheduler is not running."""
|
||
# Mock start method
|
||
scheduler_service.start = AsyncMock()
|
||
|
||
# Call ensure_started
|
||
await scheduler_service.ensure_started()
|
||
|
||
# Verify start() was called exactly once
|
||
scheduler_service.start.assert_called_once()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ensure_started_when_already_running(self, scheduler_service):
|
||
"""ensure_started() returns immediately when already running (idempotent)."""
|
||
# Set up as already running
|
||
scheduler_service._is_running = True
|
||
|
||
# Mock start method
|
||
scheduler_service.start = AsyncMock()
|
||
|
||
# Call ensure_started
|
||
await scheduler_service.ensure_started()
|
||
|
||
# Verify start() was NOT called
|
||
scheduler_service.start.assert_not_called()
|
||
|