254 lines
9.8 KiB
Python
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
|