# #region Test.OrchestratorRetry [C:3] [TYPE Module] [SEMANTICS test, translate, retry, cancellation] # @BRIEF Tests for orchestrator_retry.py — TranslationRunRetryManager. # @RELATION BINDS_TO -> [TranslationRunRetryManager] # @TEST_CONTRACT: retry_failed_batches -> TranslationRun | retries failed batches # @TEST_CONTRACT: retry_insert -> TranslationRun | delegates to orchestrator_cancel.retry_insert # @TEST_CONTRACT: cancel_run -> TranslationRun | delegates to orchestrator_cancel.cancel_run # @TEST_EDGE: no_failed_batches # @TEST_EDGE: empty_failed_records # @TEST_EDGE: all_retry_successful import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) import os os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") from unittest.mock import MagicMock, AsyncMock, patch import pytest from src.plugins.translate.orchestrator_retry import TranslationRunRetryManager class TestTranslationRunRetryManager: """TranslationRunRetryManager — retry and cancellation.""" def _make_retry_mgr(self): """Create retry manager with mock deps.""" db = MagicMock() config = MagicMock() event_log = MagicMock() mgr = TranslationRunRetryManager(db, config, event_log, "user") return mgr, db, config, event_log def _make_run(self, **kwargs): run = MagicMock() run.id = "run-1" run.job_id = "job-1" run.status = "COMPLETED" run.successful_records = 90 run.failed_records = 5 run.skipped_records = 5 run.completed_at = None for k, v in kwargs.items(): setattr(run, k, v) return run def _make_batch(self, batch_index: int, status: str = "FAILED"): batch = MagicMock() batch.id = f"batch-{batch_index}" batch.batch_index = batch_index batch.status = status return batch def _make_record(self, record_id: str, **kwargs): rec = MagicMock() rec.id = record_id rec.source_object_id = "row_1" rec.source_sql = "SELECT *" rec.source_object_name = "table" rec.status = "FAILED" for k, v in kwargs.items(): setattr(rec, k, v) return rec @pytest.mark.asyncio async def test_retry_failed_batches_success(self): """Retry failed batches updates run stats.""" mgr, db, config, event_log = self._make_retry_mgr() mgr.event_log = event_log run = self._make_run() job = MagicMock() job.id = "job-1" db.query.return_value.filter.return_value.first.side_effect = [run, job] batch1 = self._make_batch(1) failed_rec = self._make_record("rec-1") # Use side_effect on .all() to return different values per call db.query.return_value.filter.return_value.all.side_effect = [ [batch1], # 1st .all(): failed batches [failed_rec], # 2nd .all(): failed records ] with patch( "src.plugins.translate.orchestrator_retry.TranslationExecutor" ) as MockExecutor: mock_executor = MockExecutor.return_value mock_executor._process_batch = AsyncMock(return_value={ "successful": 3, "failed": 1, "skipped": 0, }) result = await mgr.retry_failed_batches("run-1") # failed=5+1=6, successful=90+3=93 (both non-zero -> COMPLETED) assert result.status == "COMPLETED" assert result.successful_records >= 90 assert result.failed_records >= 5 assert result.completed_at is not None event_log.log_event.assert_any_call( job_id="job-1", run_id="run-1", event_type="RUN_RETRYING", payload={"batch_count": 1}, created_by="user", ) event_log.log_event.assert_any_call( job_id="job-1", run_id="run-1", event_type="RUN_COMPLETED", payload={"retry": True}, created_by="user", ) @pytest.mark.asyncio async def test_retry_missing_run_raises(self): """Missing run raises ValueError.""" mgr, db, config, event_log = self._make_retry_mgr() db.query.return_value.filter.return_value.first.return_value = None with pytest.raises(ValueError, match="Run 'bad-id' not found"): await mgr.retry_failed_batches("bad-id") @pytest.mark.asyncio async def test_retry_missing_job_raises(self): """Missing job raises ValueError.""" mgr, db, config, event_log = self._make_retry_mgr() run = self._make_run() db.query.return_value.filter.return_value.first.side_effect = [run, None] with pytest.raises(ValueError, match="Job 'job-1' not found"): await mgr.retry_failed_batches("run-1") @pytest.mark.asyncio async def test_no_failed_batches_raises(self): """No failed batches raises ValueError.""" mgr, db, config, event_log = self._make_retry_mgr() run = self._make_run() job = MagicMock() job.id = "job-1" db.query.return_value.filter.return_value.first.side_effect = [run, job] db.query.return_value.filter.return_value.all.return_value = [] with pytest.raises(ValueError, match="No failed batches found for run 'run-1'"): await mgr.retry_failed_batches("run-1") @pytest.mark.asyncio async def test_retry_skips_batches_with_no_failed_records(self): """Batch with no failed records is skipped.""" mgr, db, config, event_log = self._make_retry_mgr() run = self._make_run() job = MagicMock() job.id = "job-1" db.query.return_value.filter.return_value.first.side_effect = [run, job] batch1 = self._make_batch(1) # Use .all.side_effect — don't overwrite .filter.return_value db.query.return_value.filter.return_value.all.side_effect = [ [batch1], # first .all(): failed batches [], # second .all(): failed records ] with patch( "src.plugins.translate.orchestrator_retry.TranslationExecutor" ) as MockExecutor: mock_executor = MockExecutor.return_value mock_executor._process_batch = AsyncMock() result = await mgr.retry_failed_batches("run-1") # Since no records, _process_batch should NOT be called mock_executor._process_batch.assert_not_called() assert result.completed_at is not None @pytest.mark.asyncio async def test_retry_completed_without_errors(self): """All retries successful -> status COMPLETED.""" mgr, db, config, event_log = self._make_retry_mgr() run = self._make_run(successful_records=90, failed_records=0, skipped_records=0) job = MagicMock() job.id = "job-1" db.query.return_value.filter.return_value.first.side_effect = [run, job] batch1 = self._make_batch(1) failed_rec = self._make_record("rec-1") db.query.return_value.filter.return_value.all.side_effect = [ [batch1], # failed batches [failed_rec], # failed records ] with patch( "src.plugins.translate.orchestrator_retry.TranslationExecutor" ) as MockExecutor: mock_executor = MockExecutor.return_value mock_executor._process_batch = AsyncMock(return_value={ "successful": 5, "failed": 0, "skipped": 0, }) result = await mgr.retry_failed_batches("run-1") # failed=0+0=0 -> COMPLETED assert result.status == "COMPLETED" @pytest.mark.asyncio async def test_retry_all_failed(self): """All retries fail -> status FAILED.""" mgr, db, config, event_log = self._make_retry_mgr() run = self._make_run(successful_records=0, failed_records=10, skipped_records=0) job = MagicMock() job.id = "job-1" db.query.return_value.filter.return_value.first.side_effect = [run, job] batch1 = self._make_batch(1) failed_rec = self._make_record("rec-1") # Use .all.side_effect — don't overwrite .filter.return_value db.query.return_value.filter.return_value.all.side_effect = [ [batch1], # failed batches [failed_rec], # failed records ] with patch( "src.plugins.translate.orchestrator_retry.TranslationExecutor" ) as MockExecutor: mock_executor = MockExecutor.return_value mock_executor._process_batch = AsyncMock(return_value={ "successful": 0, "failed": 5, "skipped": 0, }) result = await mgr.retry_failed_batches("run-1") # failed=10+5=15 != 0, successful=0+0=0 == 0 -> FAILED assert result.status == "FAILED" @pytest.mark.asyncio async def test_retry_insert_delegates(self): """retry_insert delegates to orchestrator_cancel.retry_insert.""" mgr, db, config, event_log = self._make_retry_mgr() with patch( "src.plugins.translate.orchestrator_retry._retry_insert" ) as mock_retry_insert: mock_retry_insert.return_value = MagicMock(id="run-1") result = await mgr.retry_insert("run-1") mock_retry_insert.assert_called_once_with(db, config, event_log, "user", "run-1") def test_cancel_run_delegates(self): """cancel_run delegates to orchestrator_cancel.cancel_run.""" mgr, db, config, event_log = self._make_retry_mgr() with patch( "src.plugins.translate.orchestrator_retry._cancel_run" ) as mock_cancel: mock_cancel.return_value = MagicMock(id="run-1") result = mgr.cancel_run("run-1") mock_cancel.assert_called_once_with(db, event_log, "user", "run-1") # #endregion Test.OrchestratorRetry