Files
ss-tools/backend/tests/plugins/translate/test_orchestrator_retry.py

254 lines
9.8 KiB
Python

# #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