Files
ss-tools/backend/tests/test_core_scheduler.py
busya 8d0bc6fb93 feat(translate,scheduler,trace): new-key-only mode, stale PENDING protection, trace_id propagation
Translations:
- FR-045: new-key-only execution mode — translate only rows with unseen keys
- Compare source row keys against TranslationRecord.source_data from last succeeded run
- Baseline expired (>90 days) fallback to full mode with baseline_expired event
- run_noop early return when zero new keys (skip LLM + SQL)

Scheduler reliability:
- Stale PENDING protection: runs older than 1h auto-marked FAILED, no longer block schedule
- load_schedules() reloads active translation schedules from DB on restart
- add_translation_job/remove_translation_job register/unregister with APScheduler
- execution_mode column on translation_schedules with additive DB migration

Automation view:
- GET /settings/automation/translation-schedules endpoint
- Translation schedules displayed on Automation page below validation policies

Trace propagation:
- seed_trace_id() in all background entry points:
  TaskManager._run_task/_flusher_loop, SchedulerService._trigger_backup,
  websocket_endpoint, IdMappingService, all standalone scripts

Migrations:
- dictionary_entries: origin_run_id, origin_row_key, origin_user_id
- translation_schedules: execution_mode

Tests:
- 3 new test modules (22 tests): core_scheduler, executor_filter, scheduler_execution+guard
- Fix pre-existing translate test isolation (conftest.py)
- Fix test_list_runs_filter_status parameter
2026-05-14 10:13:56 +03:00

178 lines
6.3 KiB
Python

# #region 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 -> [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_load_schedules_reloads_translation_schedules]
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime, timezone
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_load_schedules_reloads_translation_schedules [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
# Pre-register a translation job (simulates what happens before reload)
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]
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_load_schedules_reloads_translation_schedules
# #region test_add_and_remove_translation_job [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
# Should NOT exist before add
assert sched.get_job("translate_sched-x") is None
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_add_and_remove_translation_job
# #region test_remove_nonexistent_translation_job_no_error [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
# Should not raise — silent ignore
svc.remove_translation_job("nonexistent-sched")
finally:
_stop_scheduler(sched)
# #endregion test_remove_nonexistent_translation_job_no_error
# #endregion TestCoreScheduler