Files
ss-tools/backend/tests/core/test_scheduler.py
busya 997329e2a5 fix(agent): resolve ModuleNotFoundError for backend, add E2E test infra
- Dockerfile.agent: fix CMD (python -m src.agent.run), use backend/requirements.txt,
  minimal COPY (only src.agent + src.core.cot_logger), add GRACE contract
- docker-compose.yml: SERVICE_TOKEN_SECRET -> SERVICE_JWT (match code)
- docker-compose.enterprise-clean.yml: same env var fix
- docker/.env.agent.example: same env var fix
- build.sh: same env var fix
- chore: semantics-testing SKILL.md, backend tests, pyproject.toml
2026-06-14 15:41:46 +03:00

911 lines
47 KiB
Python

# #region Test.Scheduler [C:3] [TYPE Module] [SEMANTICS test,scheduler,core]
# @BRIEF Unit tests for SchedulerModule — SchedulerService lifecycle, job management, and ThrottledSchedulerConfigurator.
# @RELATION BINDS_TO -> [SchedulerModule]
# @TEST_EDGE: empty_queue -> _trigger_backup with no active tasks creates a new task
# @TEST_EDGE: duplicate_backup -> _trigger_backup skips when backup already running
# @TEST_EDGE: invalid_cron -> add_backup_job handles invalid cron expression gracefully
# @TEST_EDGE: translation_load_failure -> load_schedules continues when translation DB query fails
# @TEST_EDGE: validation_load_failure -> load_schedules continues when validation DB query fails
# @TEST_EDGE: policy_not_found -> reload_validation_policy removes job when policy missing
# @TEST_EDGE: policy_inactive -> reload_validation_policy removes job when policy inactive
# @TEST_EDGE: policy_no_schedule -> reload_validation_policy skips when no schedule_days/window
# @TEST_EDGE: zero_window -> calculate_schedule returns start_dt repeated for zero-length window
# @TEST_EDGE: midnight_crossing -> calculate_schedule handles window crossing midnight
# @TEST_EDGE: single_dashboard -> calculate_schedule returns single start_dt for one dashboard
import asyncio
from datetime import date, datetime, time, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
import pytest
# ── Helpers (C1 — anchor pair only) ──
# #region _build_config_manager [C:1] [TYPE Function]
def _build_config_manager(environments=None, app_timezone="Europe/Moscow"):
"""Build a mock ConfigManager with configurable environments."""
cm = MagicMock()
config = MagicMock()
settings = MagicMock()
settings.app_timezone = app_timezone
config.settings = settings
config.environments = environments or []
cm.get_config.return_value = config
return cm
# #endregion _build_config_manager
# #region _build_env [C:1] [TYPE Function]
def _build_env(env_id="env-1", cron="0 2 * * *", enabled=True):
"""Build a mock Environment with backup_schedule."""
env = MagicMock()
env.id = env_id
sched = MagicMock()
sched.enabled = enabled
sched.cron_expression = cron
env.backup_schedule = sched
return env
# #endregion _build_env
# #region _build_task [C:1] [TYPE Function]
def _build_task(plugin_id="superset-backup", status="RUNNING", environment_id="env-1"):
"""Build a mock TaskRecord."""
task = MagicMock()
task.plugin_id = plugin_id
task.status = status
task.params = {"environment_id": environment_id}
return task
# #endregion _build_task
# #region _build_policy [C:1] [TYPE Function]
def _build_policy(policy_id="pol-1", is_active=True, schedule_days=None, window_start=None, window_end=None, dashboard_ids=None, environment_id="env-1", provider_id="prov-1"):
"""Build a mock ValidationPolicy."""
policy = MagicMock()
policy.id = policy_id
policy.is_active = is_active
policy.schedule_days = schedule_days
policy.window_start = window_start
policy.window_end = window_end
policy.dashboard_ids = dashboard_ids
policy.environment_id = environment_id
policy.provider_id = provider_id
return policy
# #endregion _build_policy
# ── Fixtures ──
@pytest.fixture
def mock_task_manager():
tm = MagicMock()
tm.get_tasks.return_value = []
tm.create_task = AsyncMock(return_value=MagicMock())
return tm
@pytest.fixture
def mock_config_manager():
return _build_config_manager()
@pytest.fixture
def mock_scheduler():
"""Patch BackgroundScheduler so no real threads are started."""
with patch("src.core.scheduler.BackgroundScheduler") as cls:
sched_instance = MagicMock()
sched_instance.running = False
cls.return_value = sched_instance
yield sched_instance
@pytest.fixture
def service(mock_task_manager, mock_config_manager, mock_scheduler):
"""Construct a SchedulerService with all external deps mocked."""
# Create a fresh event loop to avoid "no current event loop" errors
# when the scheduler test runs after async tests in the same session.
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.asyncio.get_event_loop", return_value=loop):
from src.core.scheduler import SchedulerService
svc = SchedulerService(mock_task_manager, mock_config_manager)
yield svc
loop.close()
# ══════════════════════════════════════════════════════════════
# SchedulerService.__init__ (lines 28-32)
# ══════════════════════════════════════════════════════════════
# #region test_init_stores_deps [C:2] [TYPE Function]
# @BRIEF __init__ stores task_manager, config_manager, creates scheduler and loop.
def test_init_stores_deps(service, mock_task_manager, mock_config_manager, mock_scheduler):
assert service.task_manager is mock_task_manager
assert service.config_manager is mock_config_manager
assert service.scheduler is mock_scheduler
assert service.loop is not None
# #endregion test_init_stores_deps
# ══════════════════════════════════════════════════════════════
# SchedulerService.start (lines 40-44)
# ══════════════════════════════════════════════════════════════
# #region test_start_when_not_running [C:2] [TYPE Function]
# @BRIEF start() starts scheduler and loads schedules when not already running.
def test_start_when_not_running(service, mock_scheduler):
mock_scheduler.running = False
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch.object(service, "load_schedules") as mock_load:
service.start()
mock_scheduler.start.assert_called_once()
mock_load.assert_called_once()
# #endregion test_start_when_not_running
# #region test_start_when_already_running [C:2] [TYPE Function]
# @BRIEF start() is a no-op when scheduler is already running.
def test_start_when_already_running(service, mock_scheduler):
mock_scheduler.running = True
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch.object(service, "load_schedules") as mock_load:
service.start()
mock_scheduler.start.assert_not_called()
mock_load.assert_not_called()
# #endregion test_start_when_already_running
# ══════════════════════════════════════════════════════════════
# SchedulerService.stop (lines 52-55)
# ══════════════════════════════════════════════════════════════
# #region test_stop_when_running [C:2] [TYPE Function]
# @BRIEF stop() shuts down scheduler when it is running.
def test_stop_when_running(service, mock_scheduler):
mock_scheduler.running = True
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
service.stop()
mock_scheduler.shutdown.assert_called_once()
# #endregion test_stop_when_running
# #region test_stop_when_not_running [C:2] [TYPE Function]
# @BRIEF stop() is a no-op when scheduler is not running.
def test_stop_when_not_running(service, mock_scheduler):
mock_scheduler.running = False
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
service.stop()
mock_scheduler.shutdown.assert_not_called()
# #endregion test_stop_when_not_running
# ══════════════════════════════════════════════════════════════
# SchedulerService.load_schedules (lines 63-130)
# ══════════════════════════════════════════════════════════════
# #region test_load_schedules_backup_jobs [C:2] [TYPE Function]
# @BRIEF load_schedules registers backup jobs for enabled environments.
def test_load_schedules_backup_jobs(service, mock_scheduler, mock_config_manager):
env1 = _build_env("env-1", "0 2 * * *", enabled=True)
env2 = _build_env("env-2", "0 3 * * *", enabled=False)
cm = _build_config_manager(environments=[env1, env2])
service.config_manager = cm
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
# No translation schedules, no validation policies
db_mock.query.return_value.filter.return_value.all.return_value = []
with patch.object(service, "add_backup_job") as mock_add:
service.load_schedules()
# Only env-1 has enabled schedule
mock_add.assert_called_once_with("env-1", "0 2 * * *")
# #endregion test_load_schedules_backup_jobs
# #region test_load_schedules_translation_exception [C:2] [TYPE Function]
# @BRIEF load_schedules catches exception when loading translation schedules (lines 93-94).
def test_load_schedules_translation_exception(service, mock_config_manager):
cm = _build_config_manager(environments=[])
service.config_manager = cm
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal", side_effect=RuntimeError("DB connection lost")):
# Should not raise — exception is caught at line 93
service.load_schedules()
# #endregion test_load_schedules_translation_exception
# #region test_load_schedules_validation_exception [C:2] [TYPE Function]
# @BRIEF load_schedules catches exception when loading validation schedules (lines 128-129).
def test_load_schedules_validation_exception(service, mock_config_manager):
cm = _build_config_manager(environments=[])
service.config_manager = cm
call_count = 0
original_session_local = None
def session_side_effect():
nonlocal call_count
call_count += 1
if call_count == 1:
# First call (translation) — return empty mock
db = MagicMock()
db.query.return_value.filter.return_value.all.return_value = []
return db
else:
# Second call (validation) — raise
raise RuntimeError("Validation DB error")
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal", side_effect=session_side_effect):
# Should not raise — exception caught at line 128
service.load_schedules()
# #endregion test_load_schedules_validation_exception
# #region test_load_schedules_translation_schedules_success [C:2] [TYPE Function]
# @BRIEF load_schedules registers active translation schedules from DB (lines 82, 90).
def test_load_schedules_translation_schedules_success(service, mock_config_manager):
cm = _build_config_manager(environments=[], app_timezone="UTC")
service.config_manager = cm
# Build a mock TranslationSchedule row
ts = MagicMock()
ts.id = "ts-1"
ts.job_id = "job-ts-1"
ts.cron_expression = "*/10 * * * *"
ts.timezone = "US/Pacific"
ts.execution_mode = "incremental"
call_count = 0
def session_factory():
nonlocal call_count
call_count += 1
db = MagicMock()
if call_count == 1:
# Translation query — return one active schedule
db.query.return_value.filter.return_value.all.return_value = [ts]
else:
# Validation query — empty
db.query.return_value.filter.return_value.all.return_value = []
return db
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal", side_effect=session_factory):
with patch.object(service, "add_translation_job") as mock_add_t:
with patch.object(service, "add_validation_job") as mock_add_v:
service.load_schedules()
mock_add_t.assert_called_once_with(
schedule_id="ts-1",
job_id="job-ts-1",
cron_expression="*/10 * * * *",
timezone="US/Pacific",
execution_mode="incremental",
)
mock_add_v.assert_not_called()
# #endregion test_load_schedules_translation_schedules_success
# #region test_load_schedules_validation_policy_skip_empty_days [C:2] [TYPE Function]
# @BRIEF load_schedules skips validation policies with empty schedule_days (line 112).
def test_load_schedules_validation_policy_skip_empty_days(service, mock_config_manager):
cm = _build_config_manager(environments=[], app_timezone="UTC")
service.config_manager = cm
policy_no_days = _build_policy("pol-1", is_active=True, schedule_days=[], window_start=time(10, 0))
policy_none_days = _build_policy("pol-2", is_active=True, schedule_days=None, window_start=time(10, 0))
call_count = 0
def session_factory():
nonlocal call_count
call_count += 1
db = MagicMock()
if call_count == 1:
# Translation query — empty
db.query.return_value.filter.return_value.all.return_value = []
else:
# Validation query — policies with empty/None schedule_days
db.query.return_value.filter.return_value.all.return_value = [policy_no_days, policy_none_days]
return db
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal", side_effect=session_factory):
with patch.object(service, "add_validation_job") as mock_add:
service.load_schedules()
# Neither policy should be registered (both have empty/None schedule_days)
mock_add.assert_not_called()
# #endregion test_load_schedules_validation_policy_skip_empty_days
# #region test_load_schedules_validation_policy_skip_no_window [C:2] [TYPE Function]
# @BRIEF load_schedules skips validation policies with None window_start (line 115).
def test_load_schedules_validation_policy_skip_no_window(service, mock_config_manager):
cm = _build_config_manager(environments=[], app_timezone="UTC")
service.config_manager = cm
policy_no_ws = _build_policy("pol-3", is_active=True, schedule_days=[1, 3], window_start=None)
call_count = 0
def session_factory():
nonlocal call_count
call_count += 1
db = MagicMock()
if call_count == 1:
db.query.return_value.filter.return_value.all.return_value = []
else:
db.query.return_value.filter.return_value.all.return_value = [policy_no_ws]
return db
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal", side_effect=session_factory):
with patch.object(service, "add_validation_job") as mock_add:
service.load_schedules()
mock_add.assert_not_called()
# #endregion test_load_schedules_validation_policy_skip_no_window
# #region test_load_schedules_validation_policy_success [C:2] [TYPE Function]
# @BRIEF load_schedules registers valid validation policies with cron expression.
def test_load_schedules_validation_policy_success(service, mock_config_manager):
cm = _build_config_manager(environments=[], app_timezone="UTC")
service.config_manager = cm
ws = time(10, 30)
policy = _build_policy("pol-ok", is_active=True, schedule_days=[1, 3, 5], window_start=ws)
call_count = 0
def session_factory():
nonlocal call_count
call_count += 1
db = MagicMock()
if call_count == 1:
db.query.return_value.filter.return_value.all.return_value = []
else:
db.query.return_value.filter.return_value.all.return_value = [policy]
return db
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal", side_effect=session_factory):
with patch.object(service, "add_validation_job") as mock_add:
service.load_schedules()
# cron: minute=30 hour=10 * * 1,3,5
mock_add.assert_called_once_with(policy_id="pol-ok", cron_expression="30 10 * * 1,3,5", app_timezone="UTC")
# #endregion test_load_schedules_validation_policy_success
# ══════════════════════════════════════════════════════════════
# SchedulerService.add_backup_job (lines 139-157)
# ══════════════════════════════════════════════════════════════
# #region test_add_backup_job_success [C:2] [TYPE Function]
# @BRIEF add_backup_job registers a job with APScheduler.
def test_add_backup_job_success(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.CronTrigger") as mock_trigger:
mock_trigger.from_crontab.return_value = "trigger_obj"
service.add_backup_job("env-1", "0 2 * * *")
mock_scheduler.add_job.assert_called_once()
call_kwargs = mock_scheduler.add_job.call_args
assert call_kwargs.kwargs["id"] == "backup_env-1"
assert call_kwargs.kwargs["replace_existing"] is True
# #endregion test_add_backup_job_success
# #region test_add_backup_job_invalid_cron [C:2] [TYPE Function]
# @BRIEF add_backup_job handles invalid cron expression without raising (lines 156-157).
def test_add_backup_job_invalid_cron(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.CronTrigger") as mock_trigger:
mock_trigger.from_crontab.side_effect = ValueError("Invalid cron")
# Should not raise
service.add_backup_job("env-bad", "invalid_cron")
mock_scheduler.add_job.assert_not_called()
# #endregion test_add_backup_job_invalid_cron
# ══════════════════════════════════════════════════════════════
# SchedulerService.add_translation_job (lines 167-193)
# ══════════════════════════════════════════════════════════════
# #region test_add_translation_job_success [C:2] [TYPE Function]
# @BRIEF add_translation_job registers a translation job with APScheduler.
def test_add_translation_job_success(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.CronTrigger") as mock_trigger:
mock_trigger.from_crontab.return_value = "trigger_obj"
service.add_translation_job("sched-1", "job-1", "*/5 * * * *", "US/Eastern", "incremental")
mock_scheduler.add_job.assert_called_once()
call_kwargs = mock_scheduler.add_job.call_args
assert call_kwargs.kwargs["id"] == "translate_sched-1"
# #endregion test_add_translation_job_success
# #region test_add_translation_job_exception [C:2] [TYPE Function]
# @BRIEF add_translation_job handles registration failure gracefully (lines 189-190).
def test_add_translation_job_exception(service, mock_scheduler):
mock_scheduler.add_job.side_effect = RuntimeError("APScheduler failure")
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.CronTrigger") as mock_trigger:
mock_trigger.from_crontab.return_value = "trigger_obj"
# Should not raise
service.add_translation_job("sched-2", "job-2", "0 0 * * *")
# #endregion test_add_translation_job_exception
# ══════════════════════════════════════════════════════════════
# SchedulerService.remove_translation_job
# ══════════════════════════════════════════════════════════════
# #region test_remove_translation_job_exists [C:2] [TYPE Function]
# @BRIEF remove_translation_job removes an existing job.
def test_remove_translation_job_exists(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
service.remove_translation_job("sched-1")
mock_scheduler.remove_job.assert_called_once_with("translate_sched-1")
# #endregion test_remove_translation_job_exists
# #region test_remove_translation_job_not_found [C:2] [TYPE Function]
# @BRIEF remove_translation_job silently handles missing job.
def test_remove_translation_job_not_found(service, mock_scheduler):
mock_scheduler.remove_job.side_effect = Exception("Job not found")
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
# Should not raise
service.remove_translation_job("sched-missing")
# #endregion test_remove_translation_job_not_found
# ══════════════════════════════════════════════════════════════
# SchedulerService._trigger_backup (lines 218-241)
# ══════════════════════════════════════════════════════════════
# #region test_trigger_backup_no_active_tasks [C:2] [TYPE Function]
# @BRIEF _trigger_backup creates a task when no backup is running (lines 219-236).
def test_trigger_backup_no_active_tasks(service, mock_task_manager):
mock_task_manager.get_tasks.return_value = []
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run:
service._trigger_backup("env-1")
# create_task is called and its coroutine passed to run_coroutine_threadsafe
mock_run.assert_called_once()
mock_task_manager.create_task.assert_called_once_with(
"superset-backup", {"environment_id": "env-1"}
)
# #endregion test_trigger_backup_no_active_tasks
# #region test_trigger_backup_already_running [C:2] [TYPE Function]
# @BRIEF _trigger_backup skips when a backup is already running for the environment.
def test_trigger_backup_already_running(service, mock_task_manager):
running_task = _build_task(plugin_id="superset-backup", status="RUNNING", environment_id="env-1")
mock_task_manager.get_tasks.return_value = [running_task]
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run:
service._trigger_backup("env-1")
mock_run.assert_not_called()
# #endregion test_trigger_backup_already_running
# #region test_trigger_backup_pending_status [C:2] [TYPE Function]
# @BRIEF _trigger_backup skips when a backup is in PENDING status for the environment.
def test_trigger_backup_pending_status(service, mock_task_manager):
pending_task = _build_task(plugin_id="superset-backup", status="PENDING", environment_id="env-1")
mock_task_manager.get_tasks.return_value = [pending_task]
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run:
service._trigger_backup("env-1")
mock_run.assert_not_called()
# #endregion test_trigger_backup_pending_status
# #region test_trigger_backup_different_env [C:2] [TYPE Function]
# @BRIEF _trigger_backup proceeds when running backup is for a different environment.
def test_trigger_backup_different_env(service, mock_task_manager):
other_task = _build_task(plugin_id="superset-backup", status="RUNNING", environment_id="env-2")
mock_task_manager.get_tasks.return_value = [other_task]
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run:
service._trigger_backup("env-1")
mock_run.assert_called_once()
# #endregion test_trigger_backup_different_env
# #region test_trigger_backup_other_plugin [C:2] [TYPE Function]
# @BRIEF _trigger_backup proceeds when running task is a different plugin.
def test_trigger_backup_other_plugin(service, mock_task_manager):
other_plugin_task = _build_task(plugin_id="other-plugin", status="RUNNING", environment_id="env-1")
mock_task_manager.get_tasks.return_value = [other_plugin_task]
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run:
service._trigger_backup("env-1")
mock_run.assert_called_once()
# #endregion test_trigger_backup_other_plugin
# ══════════════════════════════════════════════════════════════
# SchedulerService.add_validation_job
# ══════════════════════════════════════════════════════════════
# #region test_add_validation_job_success [C:2] [TYPE Function]
# @BRIEF add_validation_job registers a validation job with APScheduler.
def test_add_validation_job_success(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.CronTrigger") as mock_trigger:
mock_trigger.from_crontab.return_value = "trigger_obj"
service.add_validation_job("pol-1", "30 10 * * 1,3", app_timezone="UTC")
mock_scheduler.add_job.assert_called_once()
call_kwargs = mock_scheduler.add_job.call_args
assert call_kwargs.kwargs["id"] == "validation_pol-1"
# #endregion test_add_validation_job_success
# #region test_add_validation_job_exception [C:2] [TYPE Function]
# @BRIEF add_validation_job handles registration failure gracefully.
def test_add_validation_job_exception(service, mock_scheduler):
mock_scheduler.add_job.side_effect = RuntimeError("Scheduler error")
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.CronTrigger") as mock_trigger:
mock_trigger.from_crontab.return_value = "trigger_obj"
# Should not raise
service.add_validation_job("pol-bad", "30 10 * * 1")
# #endregion test_add_validation_job_exception
# ══════════════════════════════════════════════════════════════
# SchedulerService.remove_validation_job
# ══════════════════════════════════════════════════════════════
# #region test_remove_validation_job_exists [C:2] [TYPE Function]
# @BRIEF remove_validation_job removes an existing job.
def test_remove_validation_job_exists(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
service.remove_validation_job("pol-1")
mock_scheduler.remove_job.assert_called_once_with("validation_pol-1")
# #endregion test_remove_validation_job_exists
# #region test_remove_validation_job_not_found [C:2] [TYPE Function]
# @BRIEF remove_validation_job silently handles missing job.
def test_remove_validation_job_not_found(service, mock_scheduler):
mock_scheduler.remove_job.side_effect = Exception("Not found")
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
# Should not raise
service.remove_validation_job("pol-missing")
# #endregion test_remove_validation_job_not_found
# ══════════════════════════════════════════════════════════════
# SchedulerService.reload_validation_policy (lines 300-331)
# ══════════════════════════════════════════════════════════════
# #region test_reload_validation_policy_not_found [C:2] [TYPE Function]
# @BRIEF reload_validation_policy removes job when policy not found in DB (lines 315-316).
def test_reload_validation_policy_not_found(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = None
with patch.object(service, "remove_validation_job") as mock_remove:
with patch.object(service, "add_validation_job") as mock_add:
service.reload_validation_policy("pol-missing")
mock_remove.assert_called_once_with("pol-missing")
mock_add.assert_not_called()
# #endregion test_reload_validation_policy_not_found
# #region test_reload_validation_policy_inactive [C:2] [TYPE Function]
# @BRIEF reload_validation_policy removes job when policy is inactive (lines 319-320).
def test_reload_validation_policy_inactive(service, mock_scheduler):
policy = _build_policy("pol-inactive", is_active=False)
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = policy
with patch.object(service, "remove_validation_job") as mock_remove:
with patch.object(service, "add_validation_job") as mock_add:
service.reload_validation_policy("pol-inactive")
mock_remove.assert_called_once_with("pol-inactive")
mock_add.assert_not_called()
# #endregion test_reload_validation_policy_inactive
# #region test_reload_validation_policy_no_schedule [C:2] [TYPE Function]
# @BRIEF reload_validation_policy skips registration when no schedule_days or window_start (lines 327-328 area).
def test_reload_validation_policy_no_schedule(service, mock_scheduler):
policy = _build_policy("pol-nosched", is_active=True, schedule_days=[], window_start=None)
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = policy
with patch.object(service, "remove_validation_job") as mock_remove:
with patch.object(service, "add_validation_job") as mock_add:
service.reload_validation_policy("pol-nosched")
mock_remove.assert_called_once_with("pol-nosched")
mock_add.assert_not_called()
# #endregion test_reload_validation_policy_no_schedule
# #region test_reload_validation_policy_success [C:2] [TYPE Function]
# @BRIEF reload_validation_policy removes old and registers new job for active policy with schedule.
def test_reload_validation_policy_success(service, mock_scheduler):
ws = time(14, 0)
policy = _build_policy("pol-ok", is_active=True, schedule_days=[0, 2, 4], window_start=ws)
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = policy
with patch.object(service, "remove_validation_job") as mock_remove:
with patch.object(service, "add_validation_job") as mock_add:
service.reload_validation_policy("pol-ok", app_timezone="UTC")
mock_remove.assert_called_once_with("pol-ok")
# cron: minute=0 hour=14 * * 0,2,4
mock_add.assert_called_once_with("pol-ok", "0 14 * * 0,2,4", app_timezone="UTC")
# #endregion test_reload_validation_policy_success
# #region test_reload_validation_policy_exception [C:2] [TYPE Function]
# @BRIEF reload_validation_policy handles DB exception gracefully (lines 327-328).
def test_reload_validation_policy_exception(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal", side_effect=RuntimeError("DB down")):
with patch.object(service, "remove_validation_job"):
# Should not raise
service.reload_validation_policy("pol-err")
# #endregion test_reload_validation_policy_exception
# ══════════════════════════════════════════════════════════════
# SchedulerService._trigger_validation (lines 338-391)
# ══════════════════════════════════════════════════════════════
# #region test_trigger_validation_policy_not_found [C:2] [TYPE Function]
# @BRIEF _trigger_validation returns early when policy not found in DB.
def test_trigger_validation_policy_not_found(service, mock_task_manager):
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = None
# Should not raise
service._trigger_validation("pol-missing")
db_mock.close.assert_called_once()
# #endregion test_trigger_validation_policy_not_found
# #region test_trigger_validation_policy_inactive [C:2] [TYPE Function]
# @BRIEF _trigger_validation returns early when policy is inactive.
def test_trigger_validation_policy_inactive(service, mock_task_manager):
policy = _build_policy("pol-inactive", is_active=False)
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = policy
service._trigger_validation("pol-inactive")
mock_task_manager.create_task.assert_not_called()
# #endregion test_trigger_validation_policy_inactive
# #region test_trigger_validation_no_dashboards [C:2] [TYPE Function]
# @BRIEF _trigger_validation returns early when policy has no dashboard_ids.
def test_trigger_validation_no_dashboards(service, mock_task_manager):
policy = _build_policy("pol-nodash", is_active=True, dashboard_ids=[], window_start=time(10, 0), window_end=time(11, 0))
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = policy
service._trigger_validation("pol-nodash")
mock_task_manager.create_task.assert_not_called()
# #endregion test_trigger_validation_no_dashboards
# #region test_trigger_validation_success [C:2] [TYPE Function]
# @BRIEF _trigger_validation spawns tasks for each dashboard in the policy.
def test_trigger_validation_success(service, mock_task_manager):
dash_ids = ["dash-1", "dash-2"]
policy = _build_policy(
"pol-ok", is_active=True, dashboard_ids=dash_ids,
window_start=time(10, 0), window_end=time(11, 0),
environment_id="env-1", provider_id="prov-1",
)
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = policy
with patch("src.core.scheduler.ThrottledSchedulerConfigurator") as mock_cfg:
mock_cfg.calculate_schedule.return_value = [
datetime(2026, 6, 11, 10, 0),
datetime(2026, 6, 11, 11, 0),
]
with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run:
service._trigger_validation("pol-ok")
# Two dashboards → two run_coroutine_threadsafe calls
assert mock_run.call_count == 2
# #endregion test_trigger_validation_success
# #region test_trigger_validation_exception [C:2] [TYPE Function]
# @BRIEF _trigger_validation handles exception gracefully (lines 385-386).
def test_trigger_validation_exception(service, mock_task_manager):
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
# Raise inside the try block (query, not SessionLocal itself)
db_mock.query.side_effect = RuntimeError("DB crash")
# Should not raise
service._trigger_validation("pol-err")
db_mock.close.assert_called_once()
# #endregion test_trigger_validation_exception
# ══════════════════════════════════════════════════════════════
# ThrottledSchedulerConfigurator.calculate_schedule (lines 410-445)
# ══════════════════════════════════════════════════════════════
# #region test_calculate_schedule_empty [C:2] [TYPE Function]
# @BRIEF calculate_schedule returns empty list for empty dashboard_ids (line 417).
def test_calculate_schedule_empty():
from src.core.scheduler import ThrottledSchedulerConfigurator
result = ThrottledSchedulerConfigurator.calculate_schedule(
window_start=time(10, 0),
window_end=time(11, 0),
dashboard_ids=[],
current_date=date(2026, 6, 11),
)
assert result == []
# #endregion test_calculate_schedule_empty
# #region test_calculate_schedule_single_dashboard [C:2] [TYPE Function]
# @BRIEF calculate_schedule returns single start_dt for one dashboard (line 434).
def test_calculate_schedule_single_dashboard():
from src.core.scheduler import ThrottledSchedulerConfigurator
result = ThrottledSchedulerConfigurator.calculate_schedule(
window_start=time(10, 0),
window_end=time(11, 0),
dashboard_ids=["dash-1"],
current_date=date(2026, 6, 11),
)
assert len(result) == 1
assert result[0] == datetime(2026, 6, 11, 10, 0)
# #endregion test_calculate_schedule_single_dashboard
# #region test_calculate_schedule_multiple [C:2] [TYPE Function]
# @BRIEF calculate_schedule distributes tasks evenly across the window.
def test_calculate_schedule_multiple():
from src.core.scheduler import ThrottledSchedulerConfigurator
result = ThrottledSchedulerConfigurator.calculate_schedule(
window_start=time(10, 0),
window_end=time(11, 0),
dashboard_ids=["d1", "d2", "d3"],
current_date=date(2026, 6, 11),
)
assert len(result) == 3
# First at 10:00, last at 11:00, middle at 10:30
assert result[0] == datetime(2026, 6, 11, 10, 0)
assert result[1] == datetime(2026, 6, 11, 10, 30)
assert result[2] == datetime(2026, 6, 11, 11, 0)
# #endregion test_calculate_schedule_multiple
# #region test_calculate_schedule_midnight_crossing [C:2] [TYPE Function]
# @BRIEF calculate_schedule handles window crossing midnight (line 422).
def test_calculate_schedule_midnight_crossing():
from src.core.scheduler import ThrottledSchedulerConfigurator
result = ThrottledSchedulerConfigurator.calculate_schedule(
window_start=time(23, 0),
window_end=time(1, 0),
dashboard_ids=["d1", "d2", "d3"],
current_date=date(2026, 6, 11),
)
assert len(result) == 3
# Window: 23:00 → 01:00 next day = 2 hours = 7200s
# Interval: 7200 / 2 = 3600s = 1h
assert result[0] == datetime(2026, 6, 11, 23, 0)
assert result[1] == datetime(2026, 6, 12, 0, 0)
assert result[2] == datetime(2026, 6, 12, 1, 0)
# #endregion test_calculate_schedule_midnight_crossing
# #region test_calculate_schedule_zero_window [C:2] [TYPE Function]
# @BRIEF calculate_schedule returns start_dt repeated for zero-length window (lines 426-429).
def test_calculate_schedule_zero_window():
from src.core.scheduler import ThrottledSchedulerConfigurator
result = ThrottledSchedulerConfigurator.calculate_schedule(
window_start=time(10, 0),
window_end=time(10, 0),
dashboard_ids=["d1", "d2", "d3"],
current_date=date(2026, 6, 11),
)
assert len(result) == 3
expected = datetime(2026, 6, 11, 10, 0)
assert all(t == expected for t in result)
# #endregion test_calculate_schedule_zero_window
# #region test_calculate_schedule_small_window_warning [C:2] [TYPE Function]
# @BRIEF calculate_schedule warns when interval < 1s (lines 439+).
def test_calculate_schedule_small_window_warning():
from src.core.scheduler import ThrottledSchedulerConfigurator
# 10 dashboards in 5 seconds → interval = 5/9 ≈ 0.56s < 1s
result = ThrottledSchedulerConfigurator.calculate_schedule(
window_start=time(10, 0, 0),
window_end=time(10, 0, 5),
dashboard_ids=[f"d{i}" for i in range(10)],
current_date=date(2026, 6, 11),
)
assert len(result) == 10
# First should be at start, last should be within the window
assert result[0] == datetime(2026, 6, 11, 10, 0, 0)
# #endregion test_calculate_schedule_small_window_warning
# #endregion Test.Scheduler