Files
ss-tools/backend/tests/test_core_scheduler.py
root 632b730fff chore: migrate GRACE-Poly anchors to hierarchical dotted naming
Systematic rename of all semantic anchors (#region, [DEF], @RELATION)
across 1400+ files — backend Python, frontend Svelte/TS, specs, docs:
- Flat anchors become Namespace.Module.Entity
- @RELATION references updated to match new anchor paths
- Zero business logic changes
2026-07-22 11:48:15 +03:00

642 lines
24 KiB
Python

# #region Test.CoreScheduler.TestCoreScheduler [C:3] [TYPE Module] [SEMANTICS test, scheduler, apscheduler, load, reload]
# @BRIEF Verify SchedulerService load_schedules and translation job add/remove contracts.
# @RELATION BINDS_TO -> [Core.Scheduler.SchedulerService]
# @TEST_EDGE: load_schedules_reloads — jobs survive remove_all_jobs when re-registered from DB
# @TEST_EDGE: add_remove_translation_job — job correctly registered and removed from APScheduler
# @TEST_INVARIANT: translation_jobs_persist_across_reload -> VERIFIED_BY: [Test.CoreScheduler.TestLoadSchedulesReloadsTranslationSchedules]
import asyncio
from datetime import time
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from unittest.mock import MagicMock, patch
from apscheduler.schedulers.background import BackgroundScheduler
# ---------------------------------------------------------------------------
# Helpers — fresh scheduler & mock config
# ---------------------------------------------------------------------------
def _make_mock_config_manager(backup_env_id="env1", backup_cron="0 3 * * *"):
"""Build a mock ConfigManager returning a single backup-enabled environment."""
mgr = MagicMock()
mock_env = MagicMock()
mock_env.id = backup_env_id
mock_env.backup_schedule = MagicMock()
mock_env.backup_schedule.enabled = True
mock_env.backup_schedule.cron_expression = backup_cron
mock_config = MagicMock()
mock_config.environments = [mock_env]
mgr.get_config.return_value = mock_config
return mgr
def _make_mock_task_manager():
"""Build a mock TaskManager returning no active tasks."""
tm = MagicMock()
tm.get_tasks.return_value = []
return tm
# ---------------------------------------------------------------------------
# Fixture helpers — start/stop a real BackgroundScheduler
# ---------------------------------------------------------------------------
def _start_scheduler():
"""Create and start a BackgroundScheduler. Returns the scheduler instance."""
sched = BackgroundScheduler()
sched.start()
return sched
def _stop_scheduler(sched):
"""Shutdown a BackgroundScheduler if running."""
if sched.running:
sched.shutdown(wait=False)
# #region Test.CoreScheduler.TestLoadSchedulesReloadsTranslationSchedules [C:1] [TYPE Function]
def test_load_schedules_reloads_translation_schedules():
"""Verify load_schedules re-registers translation jobs after remove_all_jobs."""
from src.core.scheduler import SchedulerService
sched = _start_scheduler()
try:
cfg = _make_mock_config_manager()
tm = _make_mock_task_manager()
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = tm
svc.config_manager = cfg
svc.scheduler = sched
svc.runner = MagicMock()
# Pre-register a translation job (simulates what happens before reload)
# Patch to avoid heavy deps import in test env
with patch.dict('sys.modules', {'src.plugins.translate.scheduler': MagicMock(execute_scheduled_translation=MagicMock())}):
svc.add_translation_job(
schedule_id="sched-1",
job_id="job-1",
cron_expression="0 2 * * *",
)
# Verify it exists BEFORE reload
job_before = sched.get_job("translate_sched-1")
assert job_before is not None, "Translation job should exist before reload"
# Mock the DB query that load_schedules uses internally
mock_schedule = MagicMock()
mock_schedule.id = "sched-1"
mock_schedule.job_id = "job-1"
mock_schedule.cron_expression = "0 2 * * *"
mock_schedule.timezone = "UTC"
mock_schedule.execution_mode = "full"
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.all.return_value = [mock_schedule]
# Patch heavy imports for translation scheduler to avoid missing deps like aiosmtplib in test env
with patch.dict('sys.modules', {'src.plugins.translate.scheduler': MagicMock(execute_scheduled_translation=MagicMock())}):
with patch("src.core.scheduler.SessionLocal", return_value=mock_db):
svc.load_schedules()
# After reload, the translation job should STILL exist
job_after = sched.get_job("translate_sched-1")
assert job_after is not None, (
"Translation job should survive load_schedules (re-registered from DB)"
)
# Backup job should also exist (added from config)
backup_job = sched.get_job("backup_env1")
assert backup_job is not None, "Backup job should be registered by load_schedules"
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestLoadSchedulesReloadsTranslationSchedules
# #region Test.CoreScheduler.TestAddAndRemoveTranslationJob [C:1] [TYPE Function]
def test_add_and_remove_translation_job():
"""Verify add_translation_job registers and remove_translation_job removes."""
from src.core.scheduler import SchedulerService
sched = _start_scheduler()
try:
cfg = _make_mock_config_manager()
tm = _make_mock_task_manager()
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = tm
svc.config_manager = cfg
svc.scheduler = sched
svc.runner = MagicMock()
# Should NOT exist before add
assert sched.get_job("translate_sched-x") is None
# Patch to avoid heavy import chain (notifications, aiosmtplib etc.) in minimal test env
with patch.dict('sys.modules', {'src.plugins.translate.scheduler': MagicMock(execute_scheduled_translation=MagicMock())}):
svc.add_translation_job(
schedule_id="sched-x",
job_id="job-x",
cron_expression="*/5 * * * *",
)
# Should exist after add
job = sched.get_job("translate_sched-x")
assert job is not None, "Translation job should be registered after add"
assert job.id == "translate_sched-x"
svc.remove_translation_job("sched-x")
# Should NOT exist after remove
assert sched.get_job("translate_sched-x") is None, (
"Translation job should be removed after remove_translation_job"
)
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestAddAndRemoveTranslationJob
# #region Test.CoreScheduler.TestRemoveNonexistentTranslationJobNoError [C:1] [TYPE Function]
def test_remove_nonexistent_translation_job_no_error():
"""Verify remove_translation_job handles non-existent job silently."""
from src.core.scheduler import SchedulerService
sched = _start_scheduler()
try:
cfg = _make_mock_config_manager()
tm = _make_mock_task_manager()
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = tm
svc.config_manager = cfg
svc.scheduler = sched
svc.runner = MagicMock()
# Should not raise — silent ignore
svc.remove_translation_job("nonexistent-sched")
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestRemoveNonexistentTranslationJobNoError
# #region Test.CoreScheduler.TestAddValidationJob [C:1] [TYPE Function]
def test_add_validation_job():
"""Verify add_validation_job registers an APScheduler job."""
from src.core.scheduler import SchedulerService
sched = _start_scheduler()
try:
cfg = _make_mock_config_manager()
tm = _make_mock_task_manager()
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = tm
svc.config_manager = cfg
svc.scheduler = sched
svc.runner = MagicMock()
assert sched.get_job("validation_pol-1") is None
svc.add_validation_job(
policy_id="pol-1",
cron_expression="30 10 * * 0,2,4", # 10:30 Mon, Wed, Fri
app_timezone="Europe/Moscow",
)
job = sched.get_job("validation_pol-1")
assert job is not None, "Validation job should be registered after add"
assert job.id == "validation_pol-1"
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestAddValidationJob
# #region Test.CoreScheduler.TestLoadSchedulesLoadsValidationPolicies [C:1] [TYPE Function]
def test_load_schedules_loads_validation_policies():
"""Verify load_schedules re-registers validation jobs from DB."""
from src.core.scheduler import SchedulerService
sched = _start_scheduler()
try:
cfg = _make_mock_config_manager()
# Add app_timezone to config so cron gets a timezone
cfg.get_config.return_value.settings.app_timezone = "Europe/Moscow"
tm = _make_mock_task_manager()
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = tm
svc.config_manager = cfg
svc.scheduler = sched
svc.runner = MagicMock()
# Pre-register a validation job (to test it survives reload)
svc.add_validation_job(
policy_id="pol-1",
cron_expression="0 10 * * 1,3,5",
)
assert sched.get_job("validation_pol-1") is not None
# Mock DB to return an active ValidationPolicy with schedule_days
mock_policy = MagicMock()
mock_policy.id = "pol-1"
mock_policy.schedule_days = [0, 2, 4] # Mon, Wed, Fri
mock_policy.window_start = time(10, 30)
mock_policy.window_end = time(11, 0)
mock_db = MagicMock()
# First query is for TranslationSchedule (empty), second is ValidationPolicy
mock_db.query.side_effect = [
MagicMock(filter=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))),
MagicMock(filter=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_policy])))),
]
with patch("src.core.scheduler.SessionLocal", return_value=mock_db), \
patch.dict('sys.modules', {'src.models.translate': MagicMock(TranslationSchedule=MagicMock())}):
svc.load_schedules()
# Validation job should still exist after reload
job = sched.get_job("validation_pol-1")
assert job is not None, "Validation job should survive load_schedules"
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestLoadSchedulesLoadsValidationPolicies
# #region Test.CoreScheduler.TestTriggerValidationCreatesTasks [C:1] [TYPE Function]
def test_trigger_validation_creates_tasks():
"""Verify _trigger_validation creates TaskManager tasks for each dashboard."""
from src.core.async_job_runner import AsyncJobRunner
from src.core.scheduler import SchedulerService
sched = _start_scheduler()
try:
cfg = _make_mock_config_manager()
tm = _make_mock_task_manager()
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = tm
svc.config_manager = cfg
svc.scheduler = sched
svc.runner = MagicMock()
svc.loop = asyncio.new_event_loop()
svc.runner = MagicMock(spec=AsyncJobRunner)
# Mock the ValidationPolicy DB query
mock_policy = MagicMock()
mock_policy.id = "pol-1"
mock_policy.is_active = True
mock_policy.dashboard_ids = ["dash-1", "dash-2"]
mock_policy.environment_id = "ss-dev"
mock_policy.provider_id = "prov-1"
mock_policy.window_start = time(10, 0)
mock_policy.window_end = time(10, 30)
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_policy
with patch("src.core.scheduler.SessionLocal", return_value=mock_db):
svc._trigger_validation("pol-1")
# Should have submitted 2 tasks via runner (one per dashboard)
assert svc.runner.run.call_count + svc.runner.run_later.call_count == 2, (
f"Expected 2 task submissions via runner, got {svc.runner.run.call_count + svc.runner.run_later.call_count}"
)
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestTriggerValidationCreatesTasks
# #region Test.CoreScheduler.TestTriggerValidationSkipsMissingPolicy [C:1] [TYPE Function]
def test_trigger_validation_skips_missing_policy():
"""Verify _trigger_validation does not fail when policy is missing."""
from src.core.scheduler import SchedulerService
sched = _start_scheduler()
try:
cfg = _make_mock_config_manager()
tm = _make_mock_task_manager()
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = tm
svc.config_manager = cfg
svc.scheduler = sched
svc.runner = MagicMock()
svc.loop = asyncio.new_event_loop()
# Mock DB to return None (policy not found)
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
with patch("src.core.scheduler.SessionLocal", return_value=mock_db):
# Should not raise
svc._trigger_validation("nonexistent-policy")
# create_task should not have been called
assert tm.create_task.call_count == 0
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestTriggerValidationSkipsMissingPolicy
# #region Test.CoreScheduler.TestTriggerValidationSkipsInactivePolicy [C:1] [TYPE Function]
def test_trigger_validation_skips_inactive_policy():
"""Verify _trigger_validation skips an inactive policy."""
from src.core.scheduler import SchedulerService
sched = _start_scheduler()
try:
cfg = _make_mock_config_manager()
tm = _make_mock_task_manager()
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = tm
svc.config_manager = cfg
svc.scheduler = sched
svc.runner = MagicMock()
svc.loop = asyncio.new_event_loop()
mock_policy = MagicMock()
mock_policy.id = "pol-inactive"
mock_policy.is_active = False
mock_policy.dashboard_ids = ["dash-1"]
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_policy
with patch("src.core.scheduler.SessionLocal", return_value=mock_db):
svc._trigger_validation("pol-inactive")
# create_task should not have been called for inactive policy
assert tm.create_task.call_count == 0
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestTriggerValidationSkipsInactivePolicy
# #region Test.CoreScheduler.TestTriggerValidationSkipsNoDashboards [C:1] [TYPE Function]
def test_trigger_validation_skips_no_dashboards():
"""Verify _trigger_validation skips a policy with no dashboards."""
from src.core.scheduler import SchedulerService
sched = _start_scheduler()
try:
cfg = _make_mock_config_manager()
tm = _make_mock_task_manager()
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = tm
svc.config_manager = cfg
svc.scheduler = sched
svc.runner = MagicMock()
svc.loop = asyncio.new_event_loop()
mock_policy = MagicMock()
mock_policy.id = "pol-empty"
mock_policy.is_active = True
mock_policy.dashboard_ids = []
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_policy
with patch("src.core.scheduler.SessionLocal", return_value=mock_db):
svc._trigger_validation("pol-empty")
assert tm.create_task.call_count == 0
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestTriggerValidationSkipsNoDashboards
# #region Test.CoreScheduler.TestRemoveValidationJob [C:1] [TYPE Function]
def test_remove_validation_job():
"""Verify remove_validation_job removes an existing job."""
from src.core.scheduler import SchedulerService
sched = _start_scheduler()
try:
cfg = _make_mock_config_manager()
tm = _make_mock_task_manager()
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = tm
svc.config_manager = cfg
svc.scheduler = sched
svc.runner = MagicMock()
svc.add_validation_job("pol-remove", cron_expression="0 10 * * 1")
assert sched.get_job("validation_pol-remove") is not None
svc.remove_validation_job("pol-remove")
assert sched.get_job("validation_pol-remove") is None
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestRemoveValidationJob
# #region Test.CoreScheduler.TestRemoveNonexistentValidationJob [C:1] [TYPE Function]
def test_remove_nonexistent_validation_job():
"""Verify remove_validation_job handles non-existent job silently."""
from src.core.scheduler import SchedulerService
sched = _start_scheduler()
try:
cfg = _make_mock_config_manager()
tm = _make_mock_task_manager()
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = tm
svc.config_manager = cfg
svc.scheduler = sched
svc.runner = MagicMock()
# Should not raise
svc.remove_validation_job("nonexistent-pol")
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestRemoveNonexistentValidationJob
# #region Test.CoreScheduler.TestReloadValidationPolicyAddsJob [C:1] [TYPE Function]
def test_reload_validation_policy_adds_job():
"""Verify reload_validation_policy registers a job for an active policy with schedule."""
from src.core.scheduler import SchedulerService
sched = _start_scheduler()
try:
cfg = _make_mock_config_manager()
tm = _make_mock_task_manager()
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = tm
svc.config_manager = cfg
svc.scheduler = sched
svc.runner = MagicMock()
# Mock DB to return an active policy with schedule_days
mock_policy = MagicMock()
mock_policy.id = "pol-reload"
mock_policy.is_active = True
mock_policy.schedule_days = [0, 2, 4]
mock_policy.window_start = time(10, 30)
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_policy
with patch("src.core.scheduler.SessionLocal", return_value=mock_db):
svc.reload_validation_policy("pol-reload")
job = sched.get_job("validation_pol-reload")
assert job is not None, "Job should be registered after reload"
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestReloadValidationPolicyAddsJob
# #region Test.CoreScheduler.TestReloadValidationPolicyRemovesJobWhenPolicyGone [C:1] [TYPE Function]
def test_reload_validation_policy_removes_job_when_policy_gone():
"""Verify reload_validation_policy removes job when policy is missing or inactive."""
from src.core.scheduler import SchedulerService
sched = _start_scheduler()
try:
cfg = _make_mock_config_manager()
tm = _make_mock_task_manager()
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = tm
svc.config_manager = cfg
svc.scheduler = sched
svc.runner = MagicMock()
# Pre-register a job
svc.add_validation_job("pol-gone", cron_expression="0 10 * * 1")
assert sched.get_job("validation_pol-gone") is not None
# Mock DB to return None (policy not found)
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
with patch("src.core.scheduler.SessionLocal", return_value=mock_db):
svc.reload_validation_policy("pol-gone")
# Job should have been removed
assert sched.get_job("validation_pol-gone") is None
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestReloadValidationPolicyRemovesJobWhenPolicyGone
# #region Test.CoreScheduler.TestSchedulerInitUsesPersistentJobstore [C:1] [TYPE Function]
def test_scheduler_init_uses_persistent_jobstore():
"""SchedulerService.__init__ configures SQLAlchemyJobStore (inspired by APS production recommendations)."""
from unittest.mock import patch
from src.core.scheduler import SchedulerService
cfg = _make_mock_config_manager()
tm = _make_mock_task_manager()
with patch("src.core.scheduler.SQLAlchemyJobStore") as mock_js_cls:
mock_js = MagicMock()
mock_js_cls.return_value = mock_js
with patch("src.core.scheduler.BackgroundScheduler") as mock_bg_cls:
mock_sched = MagicMock()
mock_bg_cls.return_value = mock_sched
svc = SchedulerService(tm, cfg)
mock_js_cls.assert_called_once()
mock_bg_cls.assert_called_once()
# Check that jobstores was passed with SQLAlchemy one
call_kwargs = mock_bg_cls.call_args[1]
assert 'jobstores' in call_kwargs
assert 'default' in call_kwargs['jobstores']
assert svc.scheduler is mock_sched
# #endregion Test.CoreScheduler.TestSchedulerInitUsesPersistentJobstore
# #region Test.CoreScheduler.TestBackupJobUsesModuleLevelCallback [C:1] [TYPE Function]
def test_backup_job_uses_module_level_callback():
"""Persistent backup jobs must not capture SchedulerService's object graph."""
from src.core.scheduler import SchedulerService, execute_scheduled_backup
sched = _start_scheduler()
try:
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = _make_mock_task_manager()
svc.config_manager = _make_mock_config_manager()
svc.scheduler = sched
svc.runner = MagicMock()
svc.add_backup_job("env-persist", "0 3 * * *")
job = sched.get_job("backup_env-persist")
assert job is not None
assert job.func is execute_scheduled_backup
assert job.args == ("env-persist",)
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestBackupJobUsesModuleLevelCallback
# #region Test.CoreScheduler.TestValidationJobUsesModuleLevelCallback [C:1] [TYPE Function]
def test_validation_job_uses_module_level_callback():
"""Persistent validation jobs must not capture SchedulerService's object graph."""
from src.core.scheduler import SchedulerService, execute_scheduled_validation
sched = _start_scheduler()
try:
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = _make_mock_task_manager()
svc.config_manager = _make_mock_config_manager()
svc.scheduler = sched
svc.runner = MagicMock()
svc.add_validation_job("policy-persist", "0 3 * * *")
job = sched.get_job("validation_policy-persist")
assert job is not None
assert job.func is execute_scheduled_validation
assert job.args == ("policy-persist",)
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestValidationJobUsesModuleLevelCallback
# #region Test.CoreScheduler.TestLoadSchedulesDifferentialPrune [C:1] [TYPE Function]
def test_load_schedules_differential_prune():
"""load_schedules now does differential sync instead of remove_all (uses jobstore)."""
from src.core.scheduler import SchedulerService
sched = _start_scheduler()
try:
cfg = _make_mock_config_manager(backup_env_id="keep-env")
tm = _make_mock_task_manager()
svc = SchedulerService.__new__(SchedulerService)
svc.task_manager = tm
svc.config_manager = cfg
svc.scheduler = sched
svc.runner = MagicMock()
# Add an obsolete job manually
sched.add_job(lambda: None, "interval", seconds=60, id="obsolete-old-job")
# No need to mock DB for translation since we use only backup in this config
# load_schedules will add the backup job from cfg and prune others
svc.load_schedules()
# obsolete should be pruned (differential cleanup)
assert sched.get_job("obsolete-old-job") is None
# backup job should be present
assert sched.get_job("backup_keep-env") is not None
finally:
_stop_scheduler(sched)
# #endregion Test.CoreScheduler.TestLoadSchedulesDifferentialPrune
# #endregion Test.CoreScheduler.TestCoreScheduler