Files
ss-tools/backend/tests/plugins/translate/test_scheduler.py
busya 8a4310169b v4: 7643 tests passing, 83% raw / 96.9% real coverage (excl __tests__).
12 known failures — all from Agent 3 new unverified tests (mock setup issues):
- 3 dataset_review_routes_extended (DTO field mismatches)
- 1 settings_consolidated (dict key access)
- 1 llm_analysis_service_coverage (rate_limit mock)
- 1 migration_plugin (SessionLocal side_effect exhaustion)
- 1 preview (DB query vs dict key)
- 5 scheduler (datetime timezone + async mock mismatches)

NEW TEST FILES THIS SESSION:
- test_batch_insert_coverage.py — 3 tests
- test_storage_plugin.py — +3 tests
- test_search.py — +2 tests
- test_mapper.py — already 100%
- test_llm_analysis_migration_v1_to_v2.py — 14 tests
- test_llm_async_http.py — +1 test
- test_prompt_builder.py — +1 test
- test_service_datasource.py — +1 test
- test_lang_detect.py — +1 test
- test_scheduler.py — +6 tests
- test_llm_analysis_service_coverage.py — +15 tests
- test_dataset_review_routes_extended.py — +14 tests
- test_settings_consolidated.py — +13 tests

Modules pushed to 100%: _batch_insert, dictionary_entries, service_datasource,
_llm_async_http, prompt_builder, dictionary_crud, _batch_sizer, storage/plugin,
mapper.py

Session: 7194→7643 tests (+449), 80%→83% raw (+3pp), 93.4%→96.9% real (+3.5pp).
Remaining: 12 failures to fix + ~300 statements to reach 98% real coverage.
2026-06-16 09:34:10 +03:00

422 lines
17 KiB
Python

# #region Test.TranslationScheduler [C:3] [TYPE Module] [SEMANTICS test,scheduler,cron]
# @BRIEF Tests for scheduler.py — TranslationScheduler.
# @RELATION BINDS_TO -> [TranslationScheduler]
# @TEST_EDGE: job_not_found -> ValueError
# @TEST_EDGE: schedule_not_found -> ValueError
# @TEST_EDGE: invalid_cron -> empty next_executions
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from unittest.mock import MagicMock, patch
from datetime import UTC, datetime, timedelta
from src.models.translate import TranslationJob, TranslationSchedule
from src.plugins.translate.scheduler import (
TranslationScheduler,
execute_scheduled_translation,
)
from .conftest import JOB_ID, db_session
class TestCreateSchedule:
"""TranslationScheduler.create_schedule."""
def test_creates_schedule(self, db_session):
"""Creates a schedule for a job."""
scheduler = TranslationScheduler(db_session, MagicMock(), "test-user")
schedule = scheduler.create_schedule(JOB_ID, "0 0 * * *")
assert schedule.job_id == JOB_ID
assert schedule.cron_expression == "0 0 * * *"
assert schedule.is_active is True
assert schedule.created_by == "test-user"
def test_job_not_found(self, db_session):
"""Non-existent job raises ValueError."""
scheduler = TranslationScheduler(db_session, MagicMock())
with pytest.raises(ValueError, match="not found"):
scheduler.create_schedule("nonexistent", "0 0 * * *")
def test_custom_mode(self, db_session):
"""Custom execution_mode."""
scheduler = TranslationScheduler(db_session, MagicMock())
schedule = scheduler.create_schedule(
JOB_ID, "0 0 * * *", execution_mode="new_key_only"
)
assert schedule.execution_mode == "new_key_only"
class TestUpdateSchedule:
"""TranslationScheduler.update_schedule."""
def test_update_cron(self, db_session):
"""Update cron expression."""
scheduler = TranslationScheduler(db_session, MagicMock())
scheduler.create_schedule(JOB_ID, "0 0 * * *")
updated = scheduler.update_schedule(JOB_ID, cron_expression="0 30 * * *")
assert updated.cron_expression == "0 30 * * *"
def test_update_timezone(self, db_session):
"""Update timezone."""
scheduler = TranslationScheduler(db_session, MagicMock())
scheduler.create_schedule(JOB_ID, "0 0 * * *")
updated = scheduler.update_schedule(JOB_ID, timezone_str="Europe/Moscow")
assert updated.timezone == "Europe/Moscow"
def test_update_is_active(self, db_session):
"""Update is_active."""
scheduler = TranslationScheduler(db_session, MagicMock())
scheduler.create_schedule(JOB_ID, "0 0 * * *")
updated = scheduler.update_schedule(JOB_ID, is_active=False)
assert updated.is_active is False
def test_update_execution_mode(self, db_session):
"""Update execution_mode."""
scheduler = TranslationScheduler(db_session, MagicMock())
scheduler.create_schedule(JOB_ID, "0 0 * * *")
updated = scheduler.update_schedule(JOB_ID, execution_mode="new_key_only")
assert updated.execution_mode == "new_key_only"
def test_not_found(self, db_session):
"""Non-existent schedule raises ValueError."""
scheduler = TranslationScheduler(db_session, MagicMock())
with pytest.raises(ValueError, match="No schedule found"):
scheduler.update_schedule(JOB_ID)
class TestDeleteSchedule:
"""TranslationScheduler.delete_schedule."""
def test_delete(self, db_session):
"""Delete existing schedule."""
scheduler = TranslationScheduler(db_session, MagicMock())
scheduler.create_schedule(JOB_ID, "0 0 * * *")
scheduler.delete_schedule(JOB_ID)
assert db_session.query(TranslationSchedule).filter(
TranslationSchedule.job_id == JOB_ID
).first() is None
def test_not_found(self, db_session):
"""Non-existent schedule raises ValueError."""
scheduler = TranslationScheduler(db_session, MagicMock())
with pytest.raises(ValueError, match="No schedule found"):
scheduler.delete_schedule(JOB_ID)
class TestSetScheduleActive:
"""TranslationScheduler.set_schedule_active."""
def test_disable(self, db_session):
"""Disable schedule."""
scheduler = TranslationScheduler(db_session, MagicMock())
scheduler.create_schedule(JOB_ID, "0 0 * * *")
updated = scheduler.set_schedule_active(JOB_ID, False)
assert updated.is_active is False
def test_enable(self, db_session):
"""Enable schedule."""
scheduler = TranslationScheduler(db_session, MagicMock())
scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=False)
updated = scheduler.set_schedule_active(JOB_ID, True)
assert updated.is_active is True
def test_not_found(self, db_session):
"""Non-existent raises ValueError."""
scheduler = TranslationScheduler(db_session, MagicMock())
with pytest.raises(ValueError, match="No schedule found"):
scheduler.set_schedule_active(JOB_ID, True)
class TestGetSchedule:
"""TranslationScheduler.get_schedule."""
def test_get(self, db_session):
"""Get existing schedule."""
scheduler = TranslationScheduler(db_session, MagicMock())
scheduler.create_schedule(JOB_ID, "0 0 * * *")
schedule = scheduler.get_schedule(JOB_ID)
assert schedule.job_id == JOB_ID
def test_not_found(self, db_session):
"""Non-existent raises ValueError."""
scheduler = TranslationScheduler(db_session, MagicMock())
with pytest.raises(ValueError, match="No schedule found"):
scheduler.get_schedule(JOB_ID)
class TestListActiveSchedules:
"""TranslationScheduler.list_active_schedules."""
def test_list_active(self, db_session):
"""Returns only active schedules."""
scheduler = TranslationScheduler(db_session, MagicMock())
scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=True)
# Create another job with inactive schedule
job2 = TranslationJob(id="job-2", name="Job 2", status="ACTIVE",
source_dialect="en", target_dialect="fr")
db_session.add(job2)
db_session.commit()
scheduler.create_schedule("job-2", "0 0 * * *", is_active=False)
active = TranslationScheduler.list_active_schedules(db_session)
assert len(active) == 1
assert active[0].job_id == JOB_ID
class TestGetNextExecutions:
"""TranslationScheduler.get_next_executions."""
def test_valid_cron(self):
"""Valid cron returns future times."""
results = TranslationScheduler.get_next_executions("0 0 * * *", "UTC", 3)
assert len(results) == 3
for r in results:
assert isinstance(r, str)
def test_invalid_cron(self):
"""Invalid cron returns empty list."""
results = TranslationScheduler.get_next_executions("invalid-cron", "UTC", 3)
assert results == []
def test_custom_count(self, db_session):
"""Custom N returns that many results."""
results = TranslationScheduler.get_next_executions("*/5 * * * *", "UTC", 5)
assert len(results) == 5
def test_default_count(self, db_session):
"""Default n=3."""
results = TranslationScheduler.get_next_executions("*/5 * * * *", "UTC")
assert len(results) == 3
class TestExecuteScheduledTranslation:
"""execute_scheduled_translation — APScheduler job handler."""
def _make_db_session_maker(self, db_session):
def maker():
return db_session
return maker
def test_schedule_inactive(self, db_session):
"""Inactive schedule returns early."""
scheduler = TranslationScheduler(db_session, MagicMock())
schedule = scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=False)
db_session.commit()
maker = self._make_db_session_maker(db_session)
with patch("src.plugins.translate.scheduler.seed_trace_id"):
execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock())
# Should return early without error
def test_schedule_not_found(self, db_session):
"""Non-existent schedule returns early."""
maker = self._make_db_session_maker(db_session)
with patch("src.plugins.translate.scheduler.seed_trace_id"):
execute_scheduled_translation("nonexistent", JOB_ID, maker, MagicMock())
# Should return early
def test_concurrent_run_skips(self, db_session):
"""Active concurrent run causes scheduled execution to be skipped."""
from src.plugins.translate.scheduler import TranslationScheduler
from src.models.translate import TranslationRun
scheduler = TranslationScheduler(db_session, MagicMock())
schedule = scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=True)
db_session.commit()
# Create a PENDING run to simulate concurrency
pending_run = TranslationRun(
id="concurrent-run", job_id=JOB_ID, status="PENDING",
created_at=datetime.now(UTC),
)
db_session.add(pending_run)
db_session.commit()
maker = self._make_db_session_maker(db_session)
with (
patch("src.plugins.translate.scheduler.seed_trace_id"),
patch("src.plugins.translate.events.TranslationEventLog") as MockEventLog,
):
mock_event_log = MagicMock()
MockEventLog.return_value = mock_event_log
execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock())
# Should have logged skipped event
mock_event_log.log_event.assert_called_once()
def test_stale_concurrent_run_cleared(self, db_session):
"""Stale PENDING run older than 1h is cleared and execution proceeds."""
from src.plugins.translate.scheduler import TranslationScheduler
from src.models.translate import TranslationRun
scheduler = TranslationScheduler(db_session, MagicMock())
schedule = scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=True)
db_session.commit()
# Create a stale PENDING run (older than 1h)
stale_time = datetime.now(UTC) - timedelta(hours=2)
stale_run = TranslationRun(
id="stale-run", job_id=JOB_ID, status="PENDING",
created_at=stale_time,
)
db_session.add(stale_run)
db_session.commit()
maker = self._make_db_session_maker(db_session)
with (
patch("src.plugins.translate.scheduler.seed_trace_id"),
patch("src.plugins.translate.orchestrator.TranslationOrchestrator") as MockOrch,
):
mock_orch = MagicMock()
mock_run = MagicMock()
mock_run.id = "new-run"
mock_run.status = "COMPLETED"
mock_run.insert_status = "succeeded"
mock_orch.start_run.return_value = mock_run
MockOrch.return_value = mock_orch
execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock())
# Stale run should be marked FAILED
assert stale_run.status == "FAILED"
assert "Stale" in stale_run.error_message
def test_execution_new_key_only(self, db_session):
"""execution_mode=new_key_only with fresh baseline."""
from src.plugins.translate.scheduler import TranslationScheduler
from src.models.translate import TranslationRun
scheduler = TranslationScheduler(db_session, MagicMock())
schedule = scheduler.create_schedule(
JOB_ID, "0 0 * * *", is_active=True, execution_mode="new_key_only"
)
db_session.commit()
# Recent successful run (baseline not expired)
recent = datetime.now(UTC)
recent_run = TranslationRun(
id="recent-run", job_id=JOB_ID, status="COMPLETED",
insert_status="succeeded", created_at=recent,
)
db_session.add(recent_run)
db_session.commit()
maker = self._make_db_session_maker(db_session)
with (
patch("src.plugins.translate.scheduler.seed_trace_id"),
patch("src.plugins.translate.orchestrator.TranslationOrchestrator") as MockOrch,
):
mock_orch = MagicMock()
mock_run = MagicMock()
mock_run.id = "new-run"
mock_run.status = "COMPLETED"
mock_run.insert_status = "succeeded"
mock_orch.start_run.return_value = mock_run
MockOrch.return_value = mock_orch
execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock())
# Should set trigger_type to "new_key_only" because baseline is fresh
assert mock_run.trigger_type == "new_key_only"
def test_execution_baseline_expired(self, db_session):
"""Baseline older than 90d triggers full translation even in new_key_only mode."""
scheduler = TranslationScheduler(db_session, MagicMock())
schedule = scheduler.create_schedule(
JOB_ID, "0 0 * * *", is_active=True, execution_mode="new_key_only"
)
db_session.commit()
# Very old successful run (baseline expired > 90 days)
old_time = datetime.now(UTC) - timedelta(days=100)
old_run = TranslationRun(
id="old-run", job_id=JOB_ID, status="COMPLETED",
insert_status="succeeded", created_at=old_time,
)
db_session.add(old_run)
db_session.commit()
maker = self._make_db_session_maker(db_session)
with (
patch("src.plugins.translate.scheduler.seed_trace_id"),
patch("src.plugins.translate.orchestrator.TranslationOrchestrator") as MockOrch,
):
mock_orch = MagicMock()
mock_run = MagicMock()
mock_run.id = "new-run"
mock_run.status = "COMPLETED"
mock_run.insert_status = "succeeded"
mock_orch.start_run.return_value = mock_run
MockOrch.return_value = mock_orch
execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock())
# Should fall back to "scheduled" because baseline expired
assert mock_run.trigger_type == "scheduled"
def test_execution_failure_sends_notification(self, db_session):
"""Execution failure triggers notification and marks run as FAILED."""
from src.plugins.translate.scheduler import TranslationScheduler
from src.models.translate import TranslationRun
scheduler = TranslationScheduler(db_session, MagicMock())
schedule = scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=True)
db_session.commit()
maker = self._make_db_session_maker(db_session)
with (
patch("src.plugins.translate.scheduler.seed_trace_id"),
patch("src.plugins.translate.orchestrator.TranslationOrchestrator") as MockOrch,
patch(
"src.services.notifications.service.NotificationService",
) as MockNotif,
):
mock_orch = MagicMock()
mock_run = MagicMock()
mock_run.id = "fail-run"
mock_run.status = "PENDING"
mock_run.insert_status = None
mock_orch.start_run.return_value = mock_run
mock_orch.execute_run.side_effect = RuntimeError("LLM call failed")
MockOrch.return_value = mock_orch
# Provider mock — send is a plain MagicMock (awaitable via __await__)
mock_provider = MagicMock()
mock_provider.send = MagicMock()
mock_notif = MagicMock()
mock_notif._providers = {"email": mock_provider}
MockNotif.return_value = mock_notif
execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock())
# Notification should be sent
assert mock_provider.send.called
# Run should be marked FAILED
assert mock_run.status == "FAILED"
assert mock_run.error_message == "LLM call failed"
def test_get_next_executions_break_on_none(self):
"""get_next_executions breaks when cron trigger returns None."""
from src.plugins.translate.scheduler import TranslationScheduler
# Use a cron that's unlikely to have many future fires
# or mock the trigger to return None after one fire
results = TranslationScheduler.get_next_executions(
"0 0 30 2 5", "UTC", 10 # Feb 30 doesn't exist, trigger may produce fewer
)
# Should not crash; returns what it can
assert isinstance(results, list)
# #endregion Test.TranslationScheduler