# #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 # Use datetime.now() (naive) — SQLite stores naive datetimes pending_run = TranslationRun( id="concurrent-run", job_id=JOB_ID, status="PENDING", created_at=datetime.now(), ) 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.scheduler.UTC", None), # SQLite stores naive datetimes patch("src.plugins.translate.scheduler.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) # Use datetime.now() (naive) — SQLite stores naive datetimes stale_time = datetime.now() - 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) # Prevent session close so we can still read stale_run after the call original_close = db_session.close db_session.close = MagicMock() db_session.expire_on_commit = False try: with ( patch("src.plugins.translate.scheduler.seed_trace_id"), patch("src.plugins.translate.scheduler.UTC", None), # SQLite stores naive datetimes 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 finally: db_session.close = original_close db_session.expire_on_commit = True 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) # Use datetime.now() (naive) — SQLite stores naive datetimes recent = datetime.now() 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.scheduler.UTC", None), # SQLite stores naive datetimes 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(), execution_mode="new_key_only") # 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.""" 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() # Very old successful run (baseline expired > 90 days) # Use datetime.now() (naive) — SQLite stores naive datetimes old_time = datetime.now() - 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.scheduler.UTC", None), # SQLite stores naive datetimes 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(), execution_mode="new_key_only") # 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.scheduler.UTC", None), # SQLite stores naive datetimes patch("src.plugins.translate.orchestrator.TranslationOrchestrator") as MockOrch, patch( "src.plugins.translate.scheduler.NotificationService", ) as MockNotif, patch( "src.dependencies.get_async_job_runner", ) as MockRunner, ): 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 # Mock the runner to raise the exception from execute_run mock_runner = MagicMock() mock_runner.run.side_effect = RuntimeError("LLM call failed") MockRunner.return_value = mock_runner # Provider mock — send must return a real coroutine for runner.run() async def _dummy_send(*args, **kwargs): pass mock_provider = MagicMock() mock_provider.send = MagicMock(wraps=_dummy_send) 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 mock_provider.send.assert_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