test: 6 parallel agents — +40 test files across core, agent, translate, services, API routes, dataset_review. core 100%, agent 100%, services 100%, translate plugin mostly done. Pending: ~10 minor failures to fix

This commit is contained in:
2026-06-15 16:45:49 +03:00
parent a20879fa37
commit c8e44a1b86
53 changed files with 9047 additions and 13 deletions

View File

@@ -0,0 +1,75 @@
# #region Test.DictionaryValidation [C:2] [TYPE Module] [SEMANTICS test, translate, validation, bcp47]
# @BRIEF Tests for dictionary_validation.py — _validate_bcp47.
# @RELATION BINDS_TO -> [_validate_bcp47]
# @TEST_CONTRACT: _validate_bcp47 -> None | raises ValueError on invalid tag
# @TEST_EDGE: empty_tag -> ValueError
# @TEST_EDGE: invalid_format -> ValueError
# @TEST_EDGE: valid_format -> None
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:")
import pytest
from src.plugins.translate.dictionary_validation import _validate_bcp47
class TestValidateBcp47:
"""_validate_bcp47 — BCP-47 language tag validation."""
def test_valid_basic_tag(self):
"""Simple language code like 'en' passes."""
_validate_bcp47("en", "language") # no error
def test_valid_region_tag(self):
"""Tag with region like 'zh-CN' passes."""
_validate_bcp47("zh-CN", "language")
def test_valid_script_tag(self):
"""Tag with script like 'zh-Hans' passes."""
_validate_bcp47("zh-Hans", "language")
def test_valid_multi_subtag(self):
"""Complex tag like 'sr-Latn-RS' passes."""
_validate_bcp47("sr-Latn-RS", "language")
def test_empty_string_raises(self):
"""Empty tag raises ValueError."""
with pytest.raises(ValueError, match="must be a non-empty"):
_validate_bcp47("", "language")
def test_whitespace_only_raises(self):
"""Whitespace-only tag raises ValueError."""
with pytest.raises(ValueError, match="must be a non-empty"):
_validate_bcp47(" ", "language")
def test_none_raises(self):
"""None tag raises ValueError."""
with pytest.raises(ValueError, match="must be a non-empty"):
_validate_bcp47(None, "language") # type: ignore[arg-type]
def test_invalid_format_raises(self):
"""Tag with invalid characters raises ValueError."""
with pytest.raises(ValueError, match="not a valid BCP-47"):
_validate_bcp47("en@utf8", "language")
def test_invalid_numeric_start_raises(self):
"""Tag starting with a number raises ValueError."""
with pytest.raises(ValueError, match="not a valid BCP-47"):
_validate_bcp47("123", "language")
def test_error_message_includes_field_name(self):
"""Error message includes the field_name parameter."""
with pytest.raises(ValueError, match="source_language"):
_validate_bcp47("", "source_language")
def test_strips_whitespace(self):
"""Whitespace around tag is stripped."""
_validate_bcp47(" en ", "language") # no error after strip
# #endregion Test.DictionaryValidation

View File

@@ -0,0 +1,275 @@
# #region Test.Metrics [C:3] [TYPE Module] [SEMANTICS test, translate, metrics, job, statistics]
# @BRIEF Tests for metrics.py — TranslationMetrics.get_job_metrics, get_all_metrics.
# @RELATION BINDS_TO -> [TranslationMetrics]
# @TEST_CONTRACT: get_job_metrics -> dict | aggregated job metrics
# @TEST_CONTRACT: get_all_metrics -> list[dict] | aggregated metrics for all jobs
# @TEST_EDGE: no_runs
# @TEST_EDGE: per_language_aggregation
# @TEST_EDGE: metric_snapshot_merge
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 datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from src.plugins.translate.metrics import TranslationMetrics
class _MockDB:
"""Helper to create a DB mock with independent query mocks per call."""
@staticmethod
def create(query_count: int = 8):
"""Create db mock where each db.query() call returns an independent mock."""
db = MagicMock()
query_mocks = [MagicMock() for _ in range(query_count)]
db.query.side_effect = query_mocks
return db, query_mocks
class TestGetJobMetrics:
"""TranslationMetrics.get_job_metrics — aggregated job metrics."""
def _make_lang_stat(self, code: str, **kwargs):
ls = MagicMock()
ls.language_code = code
ls.token_count = 1000
ls.estimated_cost = 0.002
ls.translated_rows = 50
for k, v in kwargs.items():
setattr(ls, k, v)
return ls
def test_basic_metrics(self):
"""Returns dict with all required keys."""
db, q = _MockDB.create()
# 0: run_counts — group_by().all()
q[0].filter.return_value.group_by.return_value.all.return_value = [
("COMPLETED", 5), ("FAILED", 2),
]
# 1: record_stats — first()
q[1].filter.return_value.first.return_value = (500, 480, 10, 10)
# 2: avg_duration — filter(cond1, cond2, cond3).scalar() — single filter with 3 args
q[2].filter.return_value.scalar.return_value = 120000.0
# 3: last_run — filter().order_by().first()
last_run = MagicMock()
last_run.created_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
q[3].filter.return_value.order_by.return_value.first.return_value = last_run
# 4: next_schedule — filter().first()
next_schedule = MagicMock()
next_schedule.last_run_at = datetime(2026, 1, 20, 12, 0, 0, tzinfo=timezone.utc)
q[4].filter.return_value.first.return_value = next_schedule
# 5: latest_snapshot (1st call) — filter().order_by().first()
q[5].filter.return_value.order_by.return_value.first.return_value = None
# 6: lang_stats_rows — join().filter().all()
lang_stats = [self._make_lang_stat("ru"), self._make_lang_stat("de")]
q[6].join.return_value.filter.return_value.all.return_value = lang_stats
# 7: latest_snapshot (2nd call, same query) — filter().order_by().first()
q[7].filter.return_value.order_by.return_value.first.return_value = None
metrics = TranslationMetrics(db)
result = metrics.get_job_metrics("job-1")
assert result["job_id"] == "job-1"
assert result["total_runs"] == 7
assert result["successful_runs"] == 5
assert result["failed_runs"] == 2
assert result["cancelled_runs"] == 0
assert result["total_records"] == 500
assert result["successful_records"] == 480
assert result["failed_records"] == 10
assert result["skipped_records"] == 10
assert result["cumulative_tokens"] == 2000
assert result["cumulative_cost"] == 0.004
assert result["avg_duration_ms"] == 120000
assert result["last_run_at"] is not None
assert result["next_scheduled_run"] is not None
assert len(result["per_language_metrics"]) == 2
assert "ru" in result["per_language_metrics"]
assert "de" in result["per_language_metrics"]
def test_no_runs(self):
"""No runs returns zero metrics."""
db, q = _MockDB.create()
q[0].filter.return_value.group_by.return_value.all.return_value = []
q[1].filter.return_value.first.return_value = (0, 0, 0, 0)
q[2].filter.return_value.scalar.return_value = None
q[3].filter.return_value.order_by.return_value.first.return_value = None
q[4].filter.return_value.first.return_value = None
q[5].filter.return_value.order_by.return_value.first.return_value = None
q[6].join.return_value.filter.return_value.all.return_value = []
q[7].filter.return_value.order_by.return_value.first.return_value = None
metrics = TranslationMetrics(db)
result = metrics.get_job_metrics("job-1")
assert result["total_runs"] == 0
assert result["total_records"] == 0
assert result["avg_duration_ms"] is None
assert result["last_run_at"] is None
assert result["next_scheduled_run"] is None
assert result["per_language_metrics"] == {}
def test_avg_duration_none_when_no_timed_runs(self):
"""No completed runs => avg_duration_ms is None."""
db, q = _MockDB.create()
q[0].filter.return_value.group_by.return_value.all.return_value = [("PENDING", 1)]
q[1].filter.return_value.first.return_value = (0, 0, 0, 0)
q[2].filter.return_value.scalar.return_value = None
q[3].filter.return_value.order_by.return_value.first.return_value = None
q[4].filter.return_value.first.return_value = None
q[5].filter.return_value.order_by.return_value.first.return_value = None
q[6].join.return_value.filter.return_value.all.return_value = []
q[7].filter.return_value.order_by.return_value.first.return_value = None
metrics = TranslationMetrics(db)
result = metrics.get_job_metrics("job-1")
assert result["avg_duration_ms"] is None
def test_no_next_schedule(self):
"""No active schedule => next_scheduled_run is None."""
db, q = _MockDB.create()
q[0].filter.return_value.group_by.return_value.all.return_value = []
q[1].filter.return_value.first.return_value = (0, 0, 0, 0)
q[2].filter.return_value.scalar.return_value = None
q[3].filter.return_value.order_by.return_value.first.return_value = None
q[4].filter.return_value.first.return_value = None
q[5].filter.return_value.order_by.return_value.first.return_value = None
q[6].join.return_value.filter.return_value.all.return_value = []
q[7].filter.return_value.order_by.return_value.first.return_value = None
metrics = TranslationMetrics(db)
result = metrics.get_job_metrics("job-1")
assert result["next_scheduled_run"] is None
def test_per_language_with_snapshot_merge(self):
"""MetricSnapshot per_language_metrics merged into result."""
db, q = _MockDB.create()
q[0].filter.return_value.group_by.return_value.all.return_value = [("COMPLETED", 3)]
q[1].filter.return_value.first.return_value = (100, 95, 3, 2)
q[2].filter.return_value.scalar.return_value = 50000.0
q[3].filter.return_value.order_by.return_value.first.return_value = None # no last_run
q[4].filter.return_value.first.return_value = None # no next_schedule
# 5: latest_snapshot (1st)
snapshot1 = MagicMock()
snapshot1.per_language_metrics = None
q[5].filter.return_value.order_by.return_value.first.return_value = snapshot1
# 6: lang_stats_rows (empty)
q[6].join.return_value.filter.return_value.all.return_value = []
# 7: latest_snapshot (2nd) with per_language_metrics
snapshot2 = MagicMock()
snapshot2.per_language_metrics = {
"ru": {"cumulative_tokens": 5000, "cumulative_cost": 0.01, "runs": 10},
}
q[7].filter.return_value.order_by.return_value.first.return_value = snapshot2
metrics = TranslationMetrics(db)
result = metrics.get_job_metrics("job-1")
assert "ru" in result["per_language_metrics"]
assert result["cumulative_tokens"] == 5000
assert result["cumulative_cost"] == 0.01
def test_per_language_with_live_and_snapshot(self):
"""Live stats + snapshot metrics merge correctly."""
db, q = _MockDB.create()
q[0].filter.return_value.group_by.return_value.all.return_value = [("COMPLETED", 3)]
q[1].filter.return_value.first.return_value = (100, 95, 3, 2)
q[2].filter.return_value.scalar.return_value = 50000.0
q[3].filter.return_value.order_by.return_value.first.return_value = None # no last_run
q[4].filter.return_value.first.return_value = None # no next_schedule
# 5: latest_snapshot (1st)
q[5].filter.return_value.order_by.return_value.first.return_value = None
# 6: lang_stats_rows — live data
q[6].join.return_value.filter.return_value.all.return_value = [
self._make_lang_stat("ru", token_count=500, estimated_cost=0.001, translated_rows=25),
self._make_lang_stat("fr", token_count=200, estimated_cost=0.0005, translated_rows=10),
]
# 7: latest_snapshot (2nd)
snapshot = MagicMock()
snapshot.per_language_metrics = {
"ru": {"cumulative_tokens": 5000, "cumulative_cost": 0.01, "runs": 10},
"de": {"cumulative_tokens": 3000, "cumulative_cost": 0.006, "runs": 5},
}
q[7].filter.return_value.order_by.return_value.first.return_value = snapshot
metrics = TranslationMetrics(db)
result = metrics.get_job_metrics("job-1")
assert "ru" in result["per_language_metrics"]
assert "de" in result["per_language_metrics"]
assert "fr" in result["per_language_metrics"]
# ru: live 500 + snapshot 5000 = 5500
assert result["per_language_metrics"]["ru"]["tokens"] == 5500
assert result["per_language_metrics"]["ru"]["cost"] == 0.011
assert result["per_language_metrics"]["fr"]["tokens"] == 200
assert result["per_language_metrics"]["de"]["tokens"] == 3000
def test_no_last_run_returns_none(self):
"""No last run => last_run_at is None."""
db, q = _MockDB.create()
q[0].filter.return_value.group_by.return_value.all.return_value = []
q[1].filter.return_value.first.return_value = (0, 0, 0, 0)
q[2].filter.return_value.scalar.return_value = None
q[3].filter.return_value.order_by.return_value.first.return_value = None
q[4].filter.return_value.first.return_value = None
q[5].filter.return_value.order_by.return_value.first.return_value = None
q[6].join.return_value.filter.return_value.all.return_value = []
q[7].filter.return_value.order_by.return_value.first.return_value = None
metrics = TranslationMetrics(db)
result = metrics.get_job_metrics("job-1")
assert result["last_run_at"] is None
class TestGetAllMetrics:
"""TranslationMetrics.get_all_metrics — metrics for all jobs."""
def test_all_metrics(self):
"""Returns list of per-job metrics."""
db = MagicMock()
db.query.return_value.distinct.return_value.all.return_value = [
("job-1",), ("job-2",),
]
with patch.object(TranslationMetrics, "get_job_metrics") as mock_get:
mock_get.side_effect = [
{"job_id": "job-1", "total_runs": 5},
{"job_id": "job-2", "total_runs": 3},
]
metrics = TranslationMetrics(db)
result = metrics.get_all_metrics()
assert len(result) == 2
assert result[0]["job_id"] == "job-1"
assert result[1]["job_id"] == "job-2"
def test_empty_jobs(self):
"""No jobs returns empty list."""
db = MagicMock()
db.query.return_value.distinct.return_value.all.return_value = []
metrics = TranslationMetrics(db)
result = metrics.get_all_metrics()
assert result == []
# #endregion Test.Metrics

View File

@@ -0,0 +1,212 @@
# #region Test.Orchestrator [C:3] [TYPE Module] [SEMANTICS test, translate, orchestrator, lifecycle]
# @BRIEF Tests for orchestrator.py — TranslationOrchestrator delegates to sub-components.
# @RELATION BINDS_TO -> [TranslationOrchestrator]
# @TEST_CONTRACT: start_run -> TranslationRun | delegates to TranslationPlanner
# @TEST_CONTRACT: execute_run -> TranslationRun | delegates to TranslationStageRunner
# @TEST_CONTRACT: retry_failed_batches -> TranslationRun | delegates to TranslationStageRunner
# @TEST_CONTRACT: retry_insert -> TranslationRun | delegates to TranslationStageRunner
# @TEST_CONTRACT: cancel_run -> TranslationRun | delegates to TranslationStageRunner
# @TEST_CONTRACT: get_run_status -> dict | delegates to TranslationResultAggregator
# @TEST_CONTRACT: get_run_records -> dict | delegates to TranslationResultAggregator
# @TEST_CONTRACT: get_run_history -> tuple | delegates to TranslationResultAggregator
# @TEST_EDGE: sql_insert_service_backward_compat
# @TEST_EDGE: language_stats_updates
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 import TranslationOrchestrator
class TestTranslationOrchestrator:
"""TranslationOrchestrator — delegates all operations to sub-components."""
def _make_orchestrator(self):
"""Create orchestrator with mocked sub-components."""
db = MagicMock()
config = MagicMock()
with patch(
"src.plugins.translate.orchestrator.TranslationPlanner"
) as MockPlanner, patch(
"src.plugins.translate.orchestrator.TranslationStageRunner"
) as MockRunner, patch(
"src.plugins.translate.orchestrator.TranslationResultAggregator"
) as MockAggregator:
orch = TranslationOrchestrator(db, config, "user")
return orch, db, config, MockPlanner, MockRunner, MockAggregator
def test_start_run_delegates(self):
"""start_run delegates to TranslationPlanner."""
orch, _, _, MockPlanner, _, _ = self._make_orchestrator()
mock_run = MagicMock()
orch._planner = MockPlanner.return_value
orch._planner.plan_run.return_value = mock_run
result = orch.start_run("job-1", is_scheduled=True, trigger_type="cron", full_translation=True)
orch._planner.plan_run.assert_called_once_with(
job_id="job-1", is_scheduled=True, trigger_type="cron", full_translation=True,
)
assert result == mock_run
def test_start_run_minimal(self):
"""start_run with only job_id uses defaults."""
orch, _, _, MockPlanner, _, _ = self._make_orchestrator()
mock_run = MagicMock()
orch._planner = MockPlanner.return_value
orch._planner.plan_run.return_value = mock_run
result = orch.start_run("job-1")
orch._planner.plan_run.assert_called_once_with(
job_id="job-1", is_scheduled=False, trigger_type=None, full_translation=False,
)
@pytest.mark.asyncio
async def test_execute_run_delegates(self):
"""execute_run delegates to TranslationStageRunner."""
orch, _, _, _, MockRunner, _ = self._make_orchestrator()
mock_run = MagicMock()
orch._runner = MockRunner.return_value
orch._runner.execute_run = AsyncMock(return_value=mock_run)
progress = MagicMock()
result = await orch.execute_run(mock_run, on_batch_progress=progress, skip_insert=True)
orch._runner.execute_run.assert_called_once_with(
run=mock_run, on_batch_progress=progress, skip_insert=True,
)
assert result == mock_run
@pytest.mark.asyncio
async def test_retry_failed_batches_delegates(self):
"""retry_failed_batches delegates to TranslationStageRunner."""
orch, _, _, _, MockRunner, _ = self._make_orchestrator()
mock_run = MagicMock()
orch._runner = MockRunner.return_value
orch._runner.retry_failed_batches = AsyncMock(return_value=mock_run)
result = await orch.retry_failed_batches("run-1")
orch._runner.retry_failed_batches.assert_called_once_with("run-1")
@pytest.mark.asyncio
async def test_retry_insert_delegates(self):
"""retry_insert delegates to TranslationStageRunner."""
orch, _, _, _, MockRunner, _ = self._make_orchestrator()
mock_run = MagicMock()
orch._runner = MockRunner.return_value
orch._runner.retry_insert = AsyncMock(return_value=mock_run)
result = await orch.retry_insert("run-1")
orch._runner.retry_insert.assert_called_once_with("run-1")
def test_cancel_run_delegates(self):
"""cancel_run delegates to TranslationStageRunner."""
orch, _, _, _, MockRunner, _ = self._make_orchestrator()
mock_run = MagicMock()
orch._runner = MockRunner.return_value
orch._runner.cancel_run.return_value = mock_run
result = orch.cancel_run("run-1")
orch._runner.cancel_run.assert_called_once_with("run-1")
def test_get_run_status_delegates(self):
"""get_run_status delegates to TranslationResultAggregator."""
orch, _, _, _, _, MockAggregator = self._make_orchestrator()
orch._aggregator = MockAggregator.return_value
orch._aggregator.get_run_status.return_value = {"status": "COMPLETED"}
result = orch.get_run_status("run-1")
orch._aggregator.get_run_status.assert_called_once_with("run-1")
def test_get_run_records_delegates(self):
"""get_run_records delegates to TranslationResultAggregator."""
orch, _, _, _, _, MockAggregator = self._make_orchestrator()
orch._aggregator = MockAggregator.return_value
orch._aggregator.get_run_records.return_value = {"items": [], "total": 0}
result = orch.get_run_records("run-1", page=2, page_size=10, status_filter="failed",
deduplicate=True)
orch._aggregator.get_run_records.assert_called_once_with(
"run-1", 2, 10, "failed", deduplicate=True,
)
def test_get_run_history_delegates(self):
"""get_run_history delegates to TranslationResultAggregator."""
orch, _, _, _, _, MockAggregator = self._make_orchestrator()
orch._aggregator = MockAggregator.return_value
orch._aggregator.get_run_history.return_value = (5, [{"id": "r1"}])
total, items = orch.get_run_history("job-1", page=1, page_size=20)
orch._aggregator.get_run_history.assert_called_once_with("job-1", 1, 20)
assert total == 5
assert items == [{"id": "r1"}]
def test_generate_and_insert_sql_backward_compat(self):
"""_generate_and_insert_sql creates SQLInsertService and delegates."""
orch, db, config, _, _, _ = self._make_orchestrator()
mock_job = MagicMock()
mock_run = MagicMock()
# SQLInsertService is imported INSIDE the method from .orchestrator_sql
with patch(
"src.plugins.translate.orchestrator_sql.SQLInsertService"
) as MockSQL:
mock_svc = MockSQL.return_value
mock_svc.generate_and_insert_sql.return_value = {"status": "success"}
import asyncio
result = asyncio.run(orch._generate_and_insert_sql(mock_job, mock_run))
MockSQL.assert_called_once_with(db, config, orch.event_log)
mock_svc.generate_and_insert_sql.assert_called_once_with(mock_job, mock_run)
def test_update_language_stats_delegates(self):
"""_update_language_stats delegates to TranslationResultAggregator."""
orch, _, _, _, _, MockAggregator = self._make_orchestrator()
orch._aggregator = MockAggregator.return_value
stats_map = {"ru": MagicMock()}
orch._update_language_stats("run-1", stats_map)
orch._aggregator.update_language_stats.assert_called_once_with("run-1", stats_map)
def test_initializes_event_log_and_components(self):
"""Constructor initializes all sub-components."""
db = MagicMock()
config = MagicMock()
with patch(
"src.plugins.translate.orchestrator.TranslationPlanner"
) as MockPlanner, patch(
"src.plugins.translate.orchestrator.TranslationStageRunner"
) as MockRunner, patch(
"src.plugins.translate.orchestrator.TranslationResultAggregator"
) as MockAggregator:
orch = TranslationOrchestrator(db, config, "user")
assert orch.db == db
assert orch.config_manager == config
assert orch.current_user == "user"
assert orch.event_log is not None
MockPlanner.assert_called_once_with(db, orch.event_log, "user")
MockRunner.assert_called_once_with(db, config, orch.event_log, "user")
MockAggregator.assert_called_once_with(db, orch.event_log)
# #endregion Test.Orchestrator

View File

@@ -0,0 +1,220 @@
# #region Test.OrchestratorAggregator [C:3] [TYPE Module] [SEMANTICS test, translate, aggregator, records, history]
# @BRIEF Tests for orchestrator_aggregator.py — TranslationResultAggregator.
# @RELATION BINDS_TO -> [TranslationResultAggregator]
# @TEST_CONTRACT: get_run_status -> dict | run status with statistics
# @TEST_CONTRACT: get_run_records -> dict | delegated to orchestrator_query
# @TEST_CONTRACT: get_run_history -> tuple[int, list] | delegated to orchestrator_query
# @TEST_CONTRACT: update_language_stats -> None | delegated to orchestrator_lang_stats
# @TEST_EDGE: missing_run
# @TEST_EDGE: empty_language_stats
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 datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from src.plugins.translate.orchestrator_aggregator import TranslationResultAggregator
class TestGetRunStatus:
"""TranslationResultAggregator.get_run_status — run status with stats."""
def _make_run(self, **kwargs):
run = MagicMock()
run.id = "run-1"
run.job_id = "job-1"
run.status = "COMPLETED"
run.trigger_type = "manual"
run.started_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
run.completed_at = datetime(2026, 1, 15, 13, 0, 0, tzinfo=timezone.utc)
run.error_message = None
run.total_records = 100
run.successful_records = 95
run.failed_records = 3
run.skipped_records = 2
run.cache_hits = 5
run.insert_status = "success"
run.insert_method = "cte_insert"
run.connection_snapshot = {"db": "test"}
run.config_snapshot = {"batch_size": 50}
run.superset_execution_id = "q-1"
run.created_by = "user"
run.created_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
for k, v in kwargs.items():
setattr(run, k, v)
return run
def _make_lang_stat(self, code: str, **kwargs):
ls = MagicMock()
ls.language_code = code
ls.total_rows = 50
ls.translated_rows = 48
ls.failed_rows = 1
ls.skipped_rows = 1
ls.token_count = 1000
ls.estimated_cost = 0.002
for k, v in kwargs.items():
setattr(ls, k, v)
return ls
def test_valid_run_returns_full_status(self):
"""Valid run returns dict with all fields."""
db = MagicMock()
event_log = MagicMock()
run = self._make_run()
db.query.return_value.filter.return_value.first.return_value = run
db.query.return_value.filter.return_value.count.return_value = 5 # batch_count
event_log.get_run_event_summary.return_value = {
"has_run_started": True,
"terminal_event_count": 1,
"invariant_valid": True,
}
lang_stats = [self._make_lang_stat("ru"), self._make_lang_stat("de")]
db.query.return_value.filter.return_value.all.return_value = lang_stats
aggregator = TranslationResultAggregator(db, event_log)
result = aggregator.get_run_status("run-1")
assert result["id"] == "run-1"
assert result["status"] == "COMPLETED"
assert result["total_records"] == 100
assert result["batch_count"] == 5
assert len(result["language_stats"]) == 2
assert result["language_stats"][0]["language_code"] == "ru"
assert result["language_stats"][0]["total_rows"] == 50
assert result["event_invariants"]["invariant_valid"] is True
def test_missing_run_raises(self):
"""Missing run raises ValueError."""
db = MagicMock()
event_log = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
aggregator = TranslationResultAggregator(db, event_log)
with pytest.raises(ValueError, match="Run 'bad-id' not found"):
aggregator.get_run_status("bad-id")
def test_no_language_stats_returns_empty(self):
"""No language stats returns empty list."""
db = MagicMock()
event_log = MagicMock()
run = self._make_run()
db.query.return_value.filter.return_value.first.return_value = run
db.query.return_value.filter.return_value.count.return_value = 0
event_log.get_run_event_summary.return_value = {
"has_run_started": True, "terminal_event_count": 0, "invariant_valid": True,
}
db.query.return_value.filter.return_value.all.return_value = []
aggregator = TranslationResultAggregator(db, event_log)
result = aggregator.get_run_status("run-1")
assert result["language_stats"] == []
def test_none_dates_returns_none(self):
"""None dates produce None isoformat."""
db = MagicMock()
event_log = MagicMock()
run = self._make_run(started_at=None, completed_at=None)
db.query.return_value.filter.return_value.first.return_value = run
db.query.return_value.filter.return_value.count.return_value = 0
event_log.get_run_event_summary.return_value = {
"has_run_started": True, "terminal_event_count": 0, "invariant_valid": True,
}
db.query.return_value.filter.return_value.all.return_value = []
aggregator = TranslationResultAggregator(db, event_log)
result = aggregator.get_run_status("run-1")
assert result["started_at"] is None
assert result["completed_at"] is None
def test_none_numeric_fields_defaults_zero(self):
"""Numeric fields default to 0 when None."""
db = MagicMock()
event_log = MagicMock()
run = self._make_run(
total_records=None, successful_records=None,
failed_records=None, skipped_records=None, cache_hits=None,
)
db.query.return_value.filter.return_value.first.return_value = run
db.query.return_value.filter.return_value.count.return_value = 0
event_log.get_run_event_summary.return_value = {
"has_run_started": True, "terminal_event_count": 0, "invariant_valid": True,
}
db.query.return_value.filter.return_value.all.return_value = []
aggregator = TranslationResultAggregator(db, event_log)
result = aggregator.get_run_status("run-1")
assert result["total_records"] == 0
assert result["successful_records"] == 0
assert result["failed_records"] == 0
assert result["skipped_records"] == 0
assert result["cache_hits"] == 0
class TestGetRunRecords:
"""TranslationResultAggregator.get_run_records — delegates to orchestrator_query."""
def test_delegates_to_query_function(self):
"""Calls get_run_records from orchestrator_query."""
db = MagicMock()
event_log = MagicMock()
with patch("src.plugins.translate.orchestrator_aggregator._get_run_records") as mock_fn:
mock_fn.return_value = {"items": [], "total": 0}
aggregator = TranslationResultAggregator(db, event_log)
result = aggregator.get_run_records("run-1", 2, 25, "failed", True)
mock_fn.assert_called_once_with(db, "run-1", 2, 25, "failed", deduplicate=True)
assert result == {"items": [], "total": 0}
class TestGetRunHistory:
"""TranslationResultAggregator.get_run_history — delegates to orchestrator_query."""
def test_delegates_to_query_function(self):
"""Calls get_run_history from orchestrator_query."""
db = MagicMock()
event_log = MagicMock()
with patch("src.plugins.translate.orchestrator_aggregator._get_run_history") as mock_fn:
mock_fn.return_value = (5, [{"id": "r1"}])
aggregator = TranslationResultAggregator(db, event_log)
total, items = aggregator.get_run_history("job-1", 1, 20)
mock_fn.assert_called_once_with(db, "job-1", 1, 20)
assert total == 5
assert items == [{"id": "r1"}]
class TestUpdateLanguageStats:
"""TranslationResultAggregator.update_language_stats — delegates to orchestrator_lang_stats."""
def test_delegates_to_update_function(self):
"""Calls update_language_stats from orchestrator_lang_stats."""
db = MagicMock()
event_log = MagicMock()
stats_map = {"ru": MagicMock()}
with patch("src.plugins.translate.orchestrator_aggregator.update_language_stats") as mock_fn:
aggregator = TranslationResultAggregator(db, event_log)
aggregator.update_language_stats("run-1", stats_map)
mock_fn.assert_called_once_with(
db=db, event_log=event_log, run_id="run-1", language_stats_map=stats_map,
)
# #endregion Test.OrchestratorAggregator

View File

@@ -0,0 +1,214 @@
# #region Test.OrchestratorExec [C:3] [TYPE Module] [SEMANTICS test, translate, execution, run, engine]
# @BRIEF Tests for orchestrator_exec.py — TranslationExecutionEngine.
# @RELATION BINDS_TO -> [TranslationExecutionEngine]
# @TEST_CONTRACT: execute_run -> TranslationRun | dispatches executor, handles outcomes
# @TEST_CONTRACT: _init_language_stats -> dict | initializes per-language stats
# @TEST_EDGE: missing_job
# @TEST_EDGE: wrong_status
# @TEST_EDGE: executor_failure
# @TEST_EDGE: cancelled_run
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_exec import TranslationExecutionEngine
class TestTranslationExecutionEngine:
"""TranslationExecutionEngine — execute runs, handle outcomes."""
def _make_engine(self):
"""Create engine with mocked deps."""
db = MagicMock()
config = MagicMock()
event_log = MagicMock()
engine = TranslationExecutionEngine(db, config, event_log, "user")
return engine, db, config, event_log
def _make_run(self, **kwargs):
run = MagicMock()
run.id = "run-1"
run.job_id = "job-1"
run.status = "PENDING"
for k, v in kwargs.items():
setattr(run, k, v)
return run
def _make_job(self, **kwargs):
job = MagicMock()
job.id = "job-1"
job.target_languages = ["ru", "de"]
job.target_dialect = "en"
for k, v in kwargs.items():
setattr(job, k, v)
return job
@pytest.mark.asyncio
async def test_execute_run_success(self):
"""Happy path: run executed, success completion handled."""
engine, db, config, event_log = self._make_engine()
run = self._make_run()
job = self._make_job()
db.query.return_value.filter.return_value.first.return_value = job
with patch(
"src.plugins.translate.orchestrator_exec.TranslationExecutor"
) as MockExecutor:
mock_executor = MockExecutor.return_value
mock_executor.execute_run = AsyncMock(return_value=run)
with patch(
"src.plugins.translate.orchestrator_exec._complete_success"
) as mock_success:
mock_success.return_value = run
result = await engine.execute_run(run)
event_log.log_event.assert_called_once_with(
job_id="job-1", run_id="run-1", event_type="TRANSLATION_PHASE_STARTED",
payload={}, created_by="user",
)
mock_success.assert_called_once()
@pytest.mark.asyncio
async def test_execute_run_missing_job_raises(self):
"""Missing job raises ValueError."""
engine, db, config, event_log = self._make_engine()
run = self._make_run()
db.query.return_value.filter.return_value.first.return_value = None
with pytest.raises(ValueError, match="Job 'job-1' not found"):
await engine.execute_run(run)
@pytest.mark.asyncio
async def test_execute_run_wrong_status_raises(self):
"""Non-PENDING run raises ValueError."""
engine, db, config, event_log = self._make_engine()
run = self._make_run(status="RUNNING")
job = self._make_job()
db.query.return_value.filter.return_value.first.return_value = job
with pytest.raises(ValueError, match="Cannot execute run in status 'RUNNING'"):
await engine.execute_run(run)
@pytest.mark.asyncio
async def test_executor_failure_handled(self):
"""Executor exception triggers failure handler."""
engine, db, config, event_log = self._make_engine()
run = self._make_run()
job = self._make_job()
db.query.return_value.filter.return_value.first.return_value = job
with patch(
"src.plugins.translate.orchestrator_exec.TranslationExecutor"
) as MockExecutor:
mock_executor = MockExecutor.return_value
mock_executor.execute_run = AsyncMock(side_effect=ValueError("LLM failed"))
with patch(
"src.plugins.translate.orchestrator_exec._handle_executor_failure"
) as mock_failure:
mock_failure.return_value = run
result = await engine.execute_run(run)
mock_failure.assert_called_once()
assert result == run
@pytest.mark.asyncio
async def test_cancelled_run_handled(self):
"""Run with CANCELLED status after executor triggers complete_cancelled."""
engine, db, config, event_log = self._make_engine()
run = self._make_run()
job = self._make_job()
db.query.return_value.filter.return_value.first.return_value = job
with patch(
"src.plugins.translate.orchestrator_exec.TranslationExecutor"
) as MockExecutor:
cancelled_run = self._make_run(status="CANCELLED")
mock_executor = MockExecutor.return_value
mock_executor.execute_run = AsyncMock(return_value=cancelled_run)
with patch(
"src.plugins.translate.orchestrator_exec._complete_cancelled"
) as mock_cancelled:
mock_cancelled.return_value = cancelled_run
result = await engine.execute_run(run)
mock_cancelled.assert_called_once()
assert result == cancelled_run
@pytest.mark.asyncio
async def test_executor_passed_progress_callback(self):
"""Progress callback and skip_insert passed to executor."""
engine, db, config, event_log = self._make_engine()
run = self._make_run()
job = self._make_job()
db.query.return_value.filter.return_value.first.return_value = job
progress_cb = MagicMock()
with patch(
"src.plugins.translate.orchestrator_exec.TranslationExecutor"
) as MockExecutor:
mock_executor = MockExecutor.return_value
mock_executor.execute_run = AsyncMock(return_value=run)
with patch(
"src.plugins.translate.orchestrator_exec._complete_success"
) as mock_success:
mock_success.return_value = run
result = await engine.execute_run(run, on_batch_progress=progress_cb, skip_insert=True)
# Verify executor was created with callback
MockExecutor.assert_called_once()
_, kwargs = MockExecutor.call_args
assert kwargs["on_batch_progress"] == progress_cb
def test_init_language_stats(self):
"""_init_language_stats creates stats for each target language."""
engine, db, config, event_log = self._make_engine()
run = self._make_run()
job = self._make_job(target_languages=["ru", "de"])
result = engine._init_language_stats(run, job)
assert len(result) == 2
assert "ru" in result
assert "de" in result
assert result["ru"].language_code == "ru"
assert result["ru"].run_id == "run-1"
assert db.add.call_count == 2
db.flush.assert_called_once()
def test_init_language_stats_string_languages(self):
"""String target_languages is converted to list."""
engine, db, config, event_log = self._make_engine()
run = self._make_run()
job = self._make_job(target_languages=None, target_dialect="fr")
result = engine._init_language_stats(run, job)
assert len(result) == 1
assert "fr" in result
def test_init_language_stats_default_fallback(self):
"""No target_languages and no target_dialect defaults to 'en'."""
engine, db, config, event_log = self._make_engine()
run = self._make_run()
job = self._make_job(target_languages=None, target_dialect=None)
result = engine._init_language_stats(run, job)
assert len(result) == 1
assert "en" in result
# #endregion Test.OrchestratorExec

View File

@@ -0,0 +1,227 @@
# #region Test.OrchestratorPlanner [C:3] [TYPE Module] [SEMANTICS test, translate, planner, run, creation]
# @BRIEF Tests for orchestrator_planner.py — TranslationPlanner.plan_run.
# @RELATION BINDS_TO -> [TranslationPlanner]
# @TEST_CONTRACT: plan_run -> TranslationRun | creates run with config snapshot and hashes
# @TEST_EDGE: missing_job
# @TEST_EDGE: scheduled_trigger
# @TEST_EDGE: full_translation
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, patch
import pytest
from src.plugins.translate.orchestrator_planner import TranslationPlanner
class TestTranslationPlanner:
"""TranslationPlanner.plan_run — create TranslationRun with hashes."""
def _make_job(self, **kwargs):
job = MagicMock()
job.id = "job-1"
job.source_dialect = "postgresql"
job.target_dialect = "clickhouse"
job.database_dialect = "postgresql"
job.source_datasource_id = "ds-1"
job.source_table = "source_table"
job.target_schema = "target_schema"
job.target_table = "target_table"
job.source_key_cols = ["id"]
job.target_key_cols = ["id"]
job.translation_column = "name"
job.target_column = "name_translated"
job.context_columns = ["desc"]
job.provider_id = "provider-1"
job.batch_size = 50
job.upsert_strategy = "MERGE"
job.target_languages = ["ru", "de"]
for k, v in kwargs.items():
setattr(job, k, v)
return job
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
def test_plan_run_success(
self, mock_validate, mock_dict_hash, mock_config_hash
):
"""Creates run with all required fields."""
db = MagicMock()
event_log = MagicMock()
job = self._make_job()
db.query.return_value.filter.return_value.first.return_value = job
mock_config_hash.return_value = "config_hash_1234"
mock_dict_hash.return_value = "dict_hash_5678"
planner = TranslationPlanner(db, event_log, "user")
result = planner.plan_run("job-1")
assert result.job_id == "job-1"
assert result.status == "PENDING"
assert result.trigger_type == "manual"
assert result.config_hash == "config_hash_1234"
assert result.dict_snapshot_hash == "dict_hash_5678"
assert result.key_hash is not None
assert result.created_by == "user"
assert result.config_snapshot is not None
assert result.config_snapshot["source_dialect"] == "postgresql"
assert result.config_snapshot["target_dialect"] == "clickhouse"
assert result.config_snapshot["batch_size"] == 50
assert result.config_snapshot["full_translation"] is False
db.add.assert_called_once_with(result)
db.flush.assert_called()
db.commit.assert_called()
db.refresh.assert_called_once_with(result)
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
def test_missing_job_raises(
self, mock_validate, mock_dict_hash, mock_config_hash
):
"""Missing job raises ValueError."""
db = MagicMock()
event_log = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
planner = TranslationPlanner(db, event_log, "user")
with pytest.raises(ValueError, match="Translation job 'bad-id' not found"):
planner.plan_run("bad-id")
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
def test_scheduled_trigger(
self, mock_validate, mock_dict_hash, mock_config_hash
):
"""is_scheduled=True sets trigger_type to 'scheduled'."""
db = MagicMock()
event_log = MagicMock()
job = self._make_job()
db.query.return_value.filter.return_value.first.return_value = job
mock_config_hash.return_value = "hash1"
mock_dict_hash.return_value = "hash2"
planner = TranslationPlanner(db, event_log, "user")
result = planner.plan_run("job-1", is_scheduled=True)
assert result.trigger_type == "scheduled"
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
def test_custom_trigger_type(
self, mock_validate, mock_dict_hash, mock_config_hash
):
"""Custom trigger_type is preserved."""
db = MagicMock()
event_log = MagicMock()
job = self._make_job()
db.query.return_value.filter.return_value.first.return_value = job
mock_config_hash.return_value = "hash1"
mock_dict_hash.return_value = "hash2"
planner = TranslationPlanner(db, event_log, "user")
result = planner.plan_run("job-1", trigger_type="api_webhook")
assert result.trigger_type == "api_webhook"
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
def test_full_translation_flag(
self, mock_validate, mock_dict_hash, mock_config_hash
):
"""full_translation=True sets flag in config_snapshot."""
db = MagicMock()
event_log = MagicMock()
job = self._make_job()
db.query.return_value.filter.return_value.first.return_value = job
mock_config_hash.return_value = "hash1"
mock_dict_hash.return_value = "hash2"
planner = TranslationPlanner(db, event_log, "user")
result = planner.plan_run("job-1", full_translation=True)
assert result.config_snapshot["full_translation"] is True
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
def test_event_logged_on_creation(
self, mock_validate, mock_dict_hash, mock_config_hash
):
"""RUN_STARTED event logged after run creation."""
db = MagicMock()
event_log = MagicMock()
job = self._make_job()
db.query.return_value.filter.return_value.first.return_value = job
mock_config_hash.return_value = "ch"
mock_dict_hash.return_value = "dh"
planner = TranslationPlanner(db, event_log, "user")
result = planner.plan_run("job-1")
event_log.log_event.assert_called_once()
call_args = event_log.log_event.call_args[1]
assert call_args["event_type"] == "RUN_STARTED"
assert call_args["run_id"] == result.id
assert call_args["payload"]["config_hash"] == "ch"
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
def test_validates_preconditions(
self, mock_validate, mock_dict_hash, mock_config_hash
):
"""Calls validate_job_preconditions with correct args."""
db = MagicMock()
event_log = MagicMock()
job = self._make_job()
db.query.return_value.filter.return_value.first.return_value = job
mock_config_hash.return_value = "ch"
mock_dict_hash.return_value = "dh"
planner = TranslationPlanner(db, event_log, "user")
planner.plan_run("job-1", is_scheduled=True)
mock_validate.assert_called_once_with(job, db, is_scheduled=True)
@patch("src.plugins.translate.orchestrator_planner.compute_config_hash")
@patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash")
@patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions")
def test_config_snapshot_includes_all_fields(
self, mock_validate, mock_dict_hash, mock_config_hash
):
"""Config snapshot contains all relevant job fields."""
db = MagicMock()
event_log = MagicMock()
job = self._make_job()
db.query.return_value.filter.return_value.first.return_value = job
mock_config_hash.return_value = "ch"
mock_dict_hash.return_value = "dh"
planner = TranslationPlanner(db, event_log, "user")
result = planner.plan_run("job-1")
snapshot = result.config_snapshot
assert snapshot["source_dialect"] == "postgresql"
assert snapshot["target_dialect"] == "clickhouse"
assert snapshot["source_datasource_id"] == "ds-1"
assert snapshot["source_table"] == "source_table"
assert snapshot["target_schema"] == "target_schema"
assert snapshot["target_table"] == "target_table"
assert snapshot["source_key_cols"] == ["id"]
assert snapshot["translation_column"] == "name"
assert snapshot["provider_id"] == "provider-1"
assert snapshot["upsert_strategy"] == "MERGE"
# #endregion Test.OrchestratorPlanner

View File

@@ -0,0 +1,280 @@
# #region Test.OrchestratorQuery [C:3] [TYPE Module] [SEMANTICS test, translate, query, records, history]
# @BRIEF Tests for orchestrator_query.py — get_run_records, get_run_history.
# @RELATION BINDS_TO -> [orchestrator_query]
# @TEST_CONTRACT: get_run_records -> dict | paginated records with optional deduplicate
# @TEST_CONTRACT: get_run_history -> tuple[int, list[dict]] | paginated run history
# @TEST_EDGE: deduplicate_empty_source_hash
# @TEST_EDGE: status_filter
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 datetime import datetime, timezone
from unittest.mock import MagicMock, patch, ANY
import pytest
from src.plugins.translate.orchestrator_query import get_run_records, get_run_history
class TestGetRunRecords:
"""get_run_records — paginated run records."""
def _make_record(self, rec_id: str, **kwargs):
rec = MagicMock()
rec.id = rec_id
rec.batch_id = "batch-1"
rec.source_sql = "SELECT * FROM t"
rec.target_sql = "SELECT * FROM t_translated"
rec.source_object_type = "table"
rec.source_object_id = "123"
rec.source_object_name = "my_table"
rec.source_data = {"col": "val"}
rec.source_hash = f"hash_{rec_id}"
rec.status = "translated"
rec.error_message = None
rec.created_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
rec.languages = []
for k, v in kwargs.items():
setattr(rec, k, v)
return rec
def test_basic_query(self):
"""Returns paginated records with metadata."""
db = MagicMock()
# Mock chain: db.query().options().filter()
options_mock = MagicMock()
filter_mock = MagicMock()
options_filter_mock = MagicMock()
db.query.return_value.options.return_value = options_mock
options_mock.filter.return_value = options_filter_mock
options_filter_mock.count.return_value = 2
options_filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [
self._make_record("r1"), self._make_record("r2"),
]
result = get_run_records(db, "run-1")
assert result["total"] == 2
assert len(result["items"]) == 2
assert result["page"] == 1
assert result["page_size"] == 50
assert result["status_filter"] is None
assert result["deduplicate"] is False
def test_with_status_filter(self):
"""Status filter applied to query."""
db = MagicMock()
options_mock = MagicMock()
filter_mock1 = MagicMock()
filter_mock2 = MagicMock()
db.query.return_value.options.return_value = options_mock
options_mock.filter.return_value = filter_mock1
filter_mock1.filter.return_value = filter_mock2
filter_mock2.count.return_value = 1
filter_mock2.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [
self._make_record("r1", status="failed"),
]
result = get_run_records(db, "run-1", status_filter="failed")
assert result["total"] == 1
assert result["status_filter"] == "failed"
def test_deduplicate_applied(self):
"""Deduplicate flag applies exclusion logic."""
db = MagicMock()
options_mock = MagicMock()
filter_mock1 = MagicMock()
filter_mock2 = MagicMock()
db.query.return_value.options.return_value = options_mock
options_mock.filter.return_value = filter_mock1
filter_mock1.filter.return_value = filter_mock2
filter_mock2.count.return_value = 1
filter_mock2.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [
self._make_record("r1", source_hash="abc123"),
]
result = get_run_records(db, "run-1", deduplicate=True)
assert result["deduplicate"] is True
assert len(result["items"]) == 1
def test_empty_records(self):
"""No records returns empty list."""
db = MagicMock()
options_mock = MagicMock()
db.query.return_value.options.return_value = options_mock
options_mock.filter.return_value.count.return_value = 0
options_mock.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = []
result = get_run_records(db, "run-1")
assert result["total"] == 0
assert result["items"] == []
def test_record_languages_included(self):
"""Language entries included in record dict."""
db = MagicMock()
lang = MagicMock()
lang.language_code = "ru"
lang.translated_value = "привет"
lang.final_value = None
lang.source_language_detected = "en"
lang.status = "translated"
lang.needs_review = False
rec = self._make_record("r1", languages=[lang])
options_mock = MagicMock()
db.query.return_value.options.return_value = options_mock
options_mock.filter.return_value.count.return_value = 1
options_mock.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [rec]
result = get_run_records(db, "run-1")
assert len(result["items"][0]["languages"]) == 1
assert result["items"][0]["languages"][0]["language_code"] == "ru"
assert result["items"][0]["languages"][0]["final_value"] == "привет"
def test_record_without_created_at(self):
"""Record without created_at returns None for isoformat."""
db = MagicMock()
rec = self._make_record("r1", created_at=None)
options_mock = MagicMock()
db.query.return_value.options.return_value = options_mock
options_mock.filter.return_value.count.return_value = 1
options_mock.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [rec]
result = get_run_records(db, "run-1")
assert result["items"][0]["created_at"] is None
def test_pagination_params(self):
"""Pagination params affect offset and limit."""
db = MagicMock()
rec = self._make_record("r1")
options_mock = MagicMock()
db.query.return_value.options.return_value = options_mock
options_mock.filter.return_value.count.return_value = 10
options_mock.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [rec]
result = get_run_records(db, "run-1", page=2, page_size=10)
assert result["page"] == 2
assert result["page_size"] == 10
class TestGetRunHistory:
"""get_run_history — paginated run history for a job."""
def _make_run(self, run_id: str, **kwargs):
run = MagicMock()
run.id = run_id
run.job_id = "job-1"
run.status = "COMPLETED"
run.trigger_type = "manual"
run.started_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
run.completed_at = datetime(2026, 1, 15, 13, 0, 0, tzinfo=timezone.utc)
run.error_message = None
run.total_records = 100
run.successful_records = 95
run.failed_records = 3
run.skipped_records = 2
run.cache_hits = 5
run.insert_status = "success"
run.insert_method = "cte_insert"
run.connection_snapshot = {"db": "test"}
run.superset_execution_id = "q-1"
run.created_by = "user"
run.created_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
for k, v in kwargs.items():
setattr(run, k, v)
return run
def test_basic_history(self):
"""Returns total count and run list."""
db = MagicMock()
runs = [self._make_run("r1"), self._make_run("r2")]
filter_mock = MagicMock()
db.query.return_value.filter.return_value = filter_mock
filter_mock.count.return_value = 2
filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = runs
total, items = get_run_history(db, "job-1")
assert total == 2
assert len(items) == 2
assert items[0]["id"] == "r1"
assert items[0]["status"] == "COMPLETED"
def test_empty_history(self):
"""No runs returns total=0 and empty list."""
db = MagicMock()
filter_mock = MagicMock()
db.query.return_value.filter.return_value = filter_mock
filter_mock.count.return_value = 0
filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = []
total, items = get_run_history(db, "job-1")
assert total == 0
assert items == []
def test_pagination_params(self):
"""Pagination affects offset and limit."""
db = MagicMock()
runs = [self._make_run("r1")]
filter_mock = MagicMock()
db.query.return_value.filter.return_value = filter_mock
filter_mock.count.return_value = 10
filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = runs
total, items = get_run_history(db, "job-1", page=3, page_size=5)
assert total == 10
assert len(items) == 1
def test_run_without_dates(self):
"""Run without started_at/completed_at returns None."""
db = MagicMock()
run = self._make_run("r1", started_at=None, completed_at=None)
filter_mock = MagicMock()
db.query.return_value.filter.return_value = filter_mock
filter_mock.count.return_value = 1
filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [run]
total, items = get_run_history(db, "job-1")
assert items[0]["started_at"] is None
assert items[0]["completed_at"] is None
def test_all_numeric_fields_default_to_zero(self):
"""Numeric fields default to 0 when None."""
db = MagicMock()
run = self._make_run(
"r1", total_records=None, successful_records=None,
failed_records=None, skipped_records=None, cache_hits=None,
)
filter_mock = MagicMock()
db.query.return_value.filter.return_value = filter_mock
filter_mock.count.return_value = 1
filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [run]
total, items = get_run_history(db, "job-1")
assert items[0]["total_records"] == 0
assert items[0]["successful_records"] == 0
assert items[0]["failed_records"] == 0
assert items[0]["skipped_records"] == 0
assert items[0]["cache_hits"] == 0
# #endregion Test.OrchestratorQuery

View File

@@ -0,0 +1,253 @@
# #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

View File

@@ -0,0 +1,378 @@
# #region Test.OrchestratorRunCompletion [C:3] [TYPE Module] [SEMANTICS test, translate, run, completion, failure]
# @BRIEF Tests for orchestrator_run_completion.py — handle_executor_failure, complete_cancelled, complete_success.
# @RELATION BINDS_TO -> [orchestrator_run_completion]
# @TEST_CONTRACT: handle_executor_failure -> TranslationRun | marks run as FAILED
# @TEST_CONTRACT: complete_cancelled -> TranslationRun | finalizes cancelled run
# @TEST_CONTRACT: complete_success -> TranslationRun | finalizes successful run
# @TEST_EDGE: rollback_fails
# @TEST_EDGE: skip_insert
# @TEST_EDGE: insert_failed
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_run_completion import (
handle_executor_failure,
complete_cancelled,
complete_success,
)
class TestHandleExecutorFailure:
"""handle_executor_failure — mark run as FAILED with error info."""
def _make_run(self, **kwargs):
run = MagicMock()
run.id = "run-1"
run.status = "RUNNING"
for k, v in kwargs.items():
setattr(run, k, v)
return run
def _make_job(self):
job = MagicMock()
job.id = "job-1"
return job
def test_marks_run_failed(self):
"""Run status set to FAILED."""
db = MagicMock()
db.merge.return_value = db.merge.return_value # default: returns new mock
event_log = MagicMock()
run = self._make_run()
job = self._make_job()
error = ValueError("LLM timeout")
# Make db.merge return the original run so .id is consistent
db.merge.return_value = run
result = handle_executor_failure(db, event_log, run, job, error, "test-user")
assert result.status == "FAILED"
assert "LLM timeout" in result.error_message
assert result.completed_at is not None
db.rollback.assert_called_once()
db.flush.assert_called_once()
db.commit.assert_called_once()
def test_logs_event(self):
"""RUN_FAILED event logged."""
db = MagicMock()
db.merge.return_value = self._make_run(id="run-1")
event_log = MagicMock()
run = self._make_run()
job = self._make_job()
error = RuntimeError("fail")
handle_executor_failure(db, event_log, run, job, error, "test-user")
event_log.log_event.assert_called_once()
call_kwargs = event_log.log_event.call_args[1]
assert call_kwargs["job_id"] == "job-1"
assert call_kwargs["run_id"] == "run-1"
assert call_kwargs["event_type"] == "RUN_FAILED"
assert call_kwargs["payload"] == {"error": "fail", "phase": "translation"}
def test_rollback_error_handled_gracefully(self):
"""If db.rollback() raises, it's silently caught."""
db = MagicMock()
db.rollback.side_effect = Exception("rollback error")
db.merge.return_value = self._make_run()
event_log = MagicMock()
run = self._make_run()
job = self._make_job()
error = Exception("exec error")
result = handle_executor_failure(db, event_log, run, job, error, "test-user")
assert result.status == "FAILED"
db.rollback.assert_called_once()
assert db.merge.called # still continues
def test_run_merged_after_rollback(self):
"""Run is merged after rollback attempt."""
db = MagicMock()
event_log = MagicMock()
run = self._make_run()
job = self._make_job()
error = Exception()
handle_executor_failure(db, event_log, run, job, error)
db.merge.assert_called_once_with(run)
def test_error_message_includes_phase(self):
"""Error message includes 'Translation execution failed:' prefix."""
db = MagicMock()
db.merge.return_value = self._make_run()
event_log = MagicMock()
run = self._make_run()
job = self._make_job()
result = handle_executor_failure(db, event_log, run, job, ValueError("bad"), "user")
assert result.error_message.startswith("Translation execution failed:")
class TestCompleteCancelled:
"""complete_cancelled — finalize cancelled run."""
def test_updates_stats_and_logs(self):
"""Aggregator stats updated and event logged."""
db = MagicMock()
event_log = MagicMock()
aggregator = MagicMock()
run = MagicMock()
run.id = "run-1"
run.job_id = "job-1"
stats_map = {"ru": MagicMock()}
result = complete_cancelled(db, event_log, aggregator, run, stats_map, "test-user")
aggregator.update_language_stats.assert_called_once_with("run-1", stats_map)
event_log.log_event.assert_called_once_with(
job_id="job-1", run_id="run-1", event_type="RUN_CANCELLED",
payload={"reason": "cancellation_flag"}, created_by="test-user",
)
db.commit.assert_called_once()
assert result == run
def test_no_current_user(self):
"""Works without current_user."""
db = MagicMock()
event_log = MagicMock()
aggregator = MagicMock()
run = MagicMock()
run.id = "run-1"
run.job_id = "job-1"
result = complete_cancelled(db, event_log, aggregator, run, {}, None)
event_log.log_event.assert_called_once()
assert result == run
class TestCompleteSuccess:
"""complete_success — finalize successful run."""
def _make_run(self, **kwargs):
run = MagicMock()
run.id = "run-1"
run.job_id = "job-1"
run.status = "RUNNING"
run.total_records = 100
run.successful_records = 95
run.failed_records = 3
run.skipped_records = 2
for k, v in kwargs.items():
setattr(run, k, v)
return run
def _make_job(self):
job = MagicMock()
job.id = "job-1"
return job
@pytest.mark.asyncio
async def test_skip_insert_completes_run(self):
"""skip_insert=True -> COMPLETED without SQL."""
db = MagicMock()
event_log = MagicMock()
aggregator = MagicMock()
sql_service = AsyncMock()
run = self._make_run()
job = self._make_job()
result = await complete_success(
db, event_log, aggregator, sql_service,
run, job, skip_insert=True,
language_stats_map={}, current_user="u",
)
assert result.status == "COMPLETED"
assert result.completed_at is not None
aggregator.update_language_stats.assert_called_once()
sql_service.generate_and_insert_sql.assert_not_called()
assert db.commit.call_count >= 1
@pytest.mark.asyncio
async def test_insert_success(self):
"""SQL insert succeeds -> COMPLETED."""
db = MagicMock()
event_log = MagicMock()
aggregator = MagicMock()
sql_service = AsyncMock()
sql_service.generate_and_insert_sql.return_value = {
"status": "success", "query_id": "q-1",
}
run = self._make_run()
job = self._make_job()
# After commit, run is re-queried
final_run = MagicMock(status="COMPLETED")
final_run.insert_status = "success"
final_run.superset_execution_id = "q-1"
final_run.error_message = None
db.query.return_value.filter.return_value.first.return_value = final_run
result = await complete_success(
db, event_log, aggregator, sql_service,
run, job, skip_insert=False,
language_stats_map={}, current_user="u",
)
assert result.status == "COMPLETED"
sql_service.generate_and_insert_sql.assert_called_once_with(job, run)
assert result.insert_status == "success"
assert result.superset_execution_id == "q-1"
@pytest.mark.asyncio
async def test_insert_failed_sets_status_failed(self):
"""Insert status 'failed' -> run status FAILED."""
db = MagicMock()
event_log = MagicMock()
aggregator = MagicMock()
sql_service = AsyncMock()
sql_service.generate_and_insert_sql.return_value = {
"status": "failed", "error_message": "DB timeout",
}
run = self._make_run()
job = self._make_job()
final_run = MagicMock(status="FAILED")
final_run.error_message = "DB timeout"
db.query.return_value.filter.return_value.first.return_value = final_run
result = await complete_success(
db, event_log, aggregator, sql_service,
run, job, skip_insert=False,
language_stats_map={}, current_user="u",
)
assert result.status == "FAILED"
assert "DB timeout" in result.error_message
@pytest.mark.asyncio
async def test_insert_timeout_sets_status_failed(self):
"""Insert status 'timeout' -> run status FAILED."""
db = MagicMock()
event_log = MagicMock()
aggregator = MagicMock()
sql_service = AsyncMock()
sql_service.generate_and_insert_sql.return_value = {
"status": "timeout", "error_message": None,
}
run = self._make_run()
job = self._make_job()
final_run = MagicMock(status="FAILED")
final_run.error_message = "SQL insert phase timeout"
db.query.return_value.filter.return_value.first.return_value = final_run
result = await complete_success(
db, event_log, aggregator, sql_service,
run, job, skip_insert=False,
language_stats_map={}, current_user="u",
)
assert result.status == "FAILED"
assert "SQL insert phase timeout" in result.error_message
@pytest.mark.asyncio
async def test_insert_skipped_non_failure(self):
"""Insert status 'skipped' -> not a failure, COMPLETED."""
db = MagicMock()
event_log = MagicMock()
aggregator = MagicMock()
sql_service = AsyncMock()
sql_service.generate_and_insert_sql.return_value = {
"status": "skipped", "query_id": None,
}
run = self._make_run()
job = self._make_job()
final_run = MagicMock(status="COMPLETED")
db.query.return_value.filter.return_value.first.return_value = final_run
result = await complete_success(
db, event_log, aggregator, sql_service,
run, job, skip_insert=False,
language_stats_map={}, current_user="u",
)
assert result.status == "COMPLETED"
@pytest.mark.asyncio
async def test_insert_with_error_message_appended(self):
"""error_message from insert appended to run."""
db = MagicMock()
event_log = MagicMock()
aggregator = MagicMock()
sql_service = AsyncMock()
sql_service.generate_and_insert_sql.return_value = {
"status": "success", "error_message": "warning: slow query",
}
run = self._make_run()
run.error_message = None
job = self._make_job()
final_run = MagicMock(status="COMPLETED")
final_run.error_message = "warning: slow query"
db.query.return_value.filter.return_value.first.return_value = final_run
result = await complete_success(
db, event_log, aggregator, sql_service,
run, job, skip_insert=False,
language_stats_map={}, current_user="u",
)
assert result.error_message == "warning: slow query"
@pytest.mark.asyncio
async def test_insert_failed_without_error_message_uses_default(self):
"""Insert failed without error_message -> default message."""
db = MagicMock()
event_log = MagicMock()
aggregator = MagicMock()
sql_service = AsyncMock()
sql_service.generate_and_insert_sql.return_value = {
"status": "failed", "error_message": None,
}
run = self._make_run()
run.error_message = None
job = self._make_job()
final_run = MagicMock(status="FAILED")
final_run.error_message = "SQL insert phase failed"
db.query.return_value.filter.return_value.first.return_value = final_run
result = await complete_success(
db, event_log, aggregator, sql_service,
run, job, skip_insert=False,
language_stats_map={}, current_user="u",
)
assert "SQL insert phase failed" in result.error_message
@pytest.mark.asyncio
async def test_refreshes_run_after_commit(self):
"""Run is re-queried after commit."""
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = MagicMock(status="COMPLETED")
event_log = MagicMock()
aggregator = MagicMock()
sql_service = AsyncMock()
sql_service.generate_and_insert_sql.return_value = {
"status": "success", "query_id": "q-1",
}
run = self._make_run()
job = self._make_job()
result = await complete_success(
db, event_log, aggregator, sql_service,
run, job, skip_insert=False,
language_stats_map={}, current_user="u",
)
# Should re-query run after commit
db.query.assert_called()
# #endregion Test.OrchestratorRunCompletion

View File

@@ -0,0 +1,86 @@
# #region Test.OrchestratorRunner [C:3] [TYPE Module] [SEMANTICS test, translate, runner, execution]
# @BRIEF Tests for orchestrator_runner.py — TranslationStageRunner delegates to sub-components.
# @RELATION BINDS_TO -> [TranslationStageRunner]
# @TEST_CONTRACT: execute_run -> TranslationRun | delegates to TranslationExecutionEngine
# @TEST_CONTRACT: retry_failed_batches -> TranslationRun | delegates to TranslationRunRetryManager
# @TEST_CONTRACT: retry_insert -> TranslationRun | delegates to TranslationRunRetryManager
# @TEST_CONTRACT: cancel_run -> TranslationRun | delegates to TranslationRunRetryManager
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_runner import TranslationStageRunner
class TestTranslationStageRunner:
"""TranslationStageRunner — delegates to sub-components."""
def _make_runner(self) -> tuple[TranslationStageRunner, MagicMock, MagicMock]:
"""Create a runner with mocked sub-components."""
db = MagicMock()
config_manager = MagicMock()
event_log = MagicMock()
with patch(
"src.plugins.translate.orchestrator_runner.TranslationExecutionEngine"
) as MockExec, patch(
"src.plugins.translate.orchestrator_runner.TranslationRunRetryManager"
) as MockRetry:
runner = TranslationStageRunner(db, config_manager, event_log, "user")
mock_exec = MockExec.return_value
mock_retry = MockRetry.return_value
return runner, mock_exec, mock_retry
@pytest.mark.asyncio
async def test_execute_run_delegates(self):
"""execute_run delegates to TranslationExecutionEngine."""
runner, mock_exec, _ = self._make_runner()
mock_run = MagicMock()
mock_exec.execute_run = AsyncMock(return_value=mock_run)
result = await runner.execute_run(mock_run, on_batch_progress=None, skip_insert=False)
mock_exec.execute_run.assert_called_once_with(
run=mock_run, on_batch_progress=None, skip_insert=False,
)
assert result == mock_run
@pytest.mark.asyncio
async def test_retry_failed_batches_delegates(self):
"""retry_failed_batches delegates to TranslationRunRetryManager."""
runner, _, mock_retry = self._make_runner()
mock_retry.retry_failed_batches = AsyncMock(return_value=MagicMock())
result = await runner.retry_failed_batches("run-1")
mock_retry.retry_failed_batches.assert_called_once_with("run-1")
@pytest.mark.asyncio
async def test_retry_insert_delegates(self):
"""retry_insert delegates to TranslationRunRetryManager."""
runner, _, mock_retry = self._make_runner()
mock_retry.retry_insert = AsyncMock(return_value=MagicMock())
result = await runner.retry_insert("run-1")
mock_retry.retry_insert.assert_called_once_with("run-1")
def test_cancel_run_delegates(self):
"""cancel_run delegates to TranslationRunRetryManager."""
runner, _, mock_retry = self._make_runner()
mock_retry.cancel_run.return_value = MagicMock()
result = runner.cancel_run("run-1")
mock_retry.cancel_run.assert_called_once_with("run-1")
# #endregion Test.OrchestratorRunner

View File

@@ -0,0 +1,340 @@
# #region Test.PreviewPromptBuilder [C:3] [TYPE Module] [SEMANTICS test, translate, preview, prompt, builder]
# @BRIEF Tests for preview_prompt_builder.py — PreviewPromptBuilder.build_prompt_from_rows.
# @RELATION BINDS_TO -> [PreviewPromptBuilder]
# @TEST_CONTRACT: build_prompt_from_rows -> dict | builds LLM prompt with glossary
# @TEST_CONTRACT: estimate_token_budget_for_rows -> dict | delegates to helper
# @TEST_EDGE: dictionary_matches
# @TEST_EDGE: no_dictionary_matches
# @TEST_EDGE: custom_prompt_template
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, patch
import pytest
from src.plugins.translate.preview_prompt_builder import PreviewPromptBuilder
class TestBuildPromptFromRows:
"""PreviewPromptBuilder.build_prompt_from_rows — builds prompt with dictionary glossary."""
def _make_job(self, **kwargs):
job = MagicMock()
job.id = "job-1"
job.source_dialect = "postgresql"
job.target_dialect = "clickhouse"
job.target_languages = ["ru", "de"]
job.translation_column = "name"
job.context_columns = ["description"]
for k, v in kwargs.items():
setattr(job, k, v)
return job
def _make_db(self):
return MagicMock()
def test_build_prompt_basic(self):
"""Builds prompt with row data and no dictionary section."""
db = self._make_db()
job = self._make_job()
source_rows = [
{"name": "hello", "description": "greeting"},
{"name": "world", "description": "earth"},
]
with patch(
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
) as mock_filter:
mock_filter.return_value = []
with patch(
"src.plugins.translate.preview_prompt_builder.render_prompt"
) as mock_render:
mock_render.return_value = "rendered prompt"
with patch(
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
) as mock_meta:
mock_meta.return_value = {
"prompt": "rendered prompt", "row_meta": [],
"target_languages": ["ru", "de"], "num_languages": 2,
"dictionary_section": "", "sample_prompt_tokens": 100,
"sample_output_tokens": 200, "sample_total_tokens": 300,
"sample_cost": 0.001, "total_est_rows": 50, "total_est_tokens": 500,
"total_est_cost": 0.01, "cost_warning": None, "actual_row_count": 2,
}
builder = PreviewPromptBuilder(db)
result = builder.build_prompt_from_rows(job, source_rows, sample_size=5)
assert result["prompt"] == "rendered prompt"
assert result["sample_prompt_tokens"] == 100
mock_filter.assert_called_once()
mock_render.assert_called_once()
def test_build_prompt_with_dictionary(self):
"""Dictionary matches produce glossary section."""
db = self._make_db()
job = self._make_job()
source_rows = [{"name": "hello", "description": "greeting"}]
with patch(
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
) as mock_filter:
mock_filter.return_value = [
{"source_term": "hello", "target_term": "привет", "context_notes": "informal"},
{"source_term": "world", "target_term": "мир", "context_notes": None},
]
with patch(
"src.plugins.translate.preview_prompt_builder.render_prompt"
) as mock_render:
mock_render.side_effect = lambda template, ctx: ctx.get("dictionary_section", "")
with patch(
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
) as mock_meta:
mock_meta.return_value = {
"prompt": "", "row_meta": [], "target_languages": ["ru", "de"],
"num_languages": 2, "dictionary_section": "...",
"sample_prompt_tokens": 0, "sample_output_tokens": 0,
"sample_total_tokens": 0, "sample_cost": 0.0,
"total_est_rows": 10, "total_est_tokens": 0, "total_est_cost": 0.0,
"cost_warning": None, "actual_row_count": 1,
}
builder = PreviewPromptBuilder(db)
result = builder.build_prompt_from_rows(job, source_rows, sample_size=1)
# Verify dictionary was included in render
mock_render.assert_called_once()
variables = mock_render.call_args[0][1]
assert "Terminology dictionary" in variables["dictionary_section"]
assert "hello" in variables["dictionary_section"]
assert "привет" in variables["dictionary_section"]
assert "informal" in variables["dictionary_section"]
def test_build_prompt_no_context_columns(self):
"""Job without context columns still works."""
db = self._make_db()
job = self._make_job(context_columns=None)
source_rows = [{"name": "hello"}]
with patch(
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
) as mock_filter:
mock_filter.return_value = []
with patch(
"src.plugins.translate.preview_prompt_builder.render_prompt"
) as mock_render:
mock_render.return_value = "prompt"
with patch(
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
) as mock_meta:
mock_meta.return_value = {"prompt": "prompt"}
builder = PreviewPromptBuilder(db)
result = builder.build_prompt_from_rows(job, source_rows, sample_size=1)
assert result["prompt"] == "prompt"
def test_custom_prompt_template(self):
"""Custom prompt template overrides default."""
db = self._make_db()
job = self._make_job()
source_rows = [{"name": "hello"}]
custom_template = "Custom: {source_language} -> {target_languages}"
with patch(
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
) as mock_filter:
mock_filter.return_value = []
with patch(
"src.plugins.translate.preview_prompt_builder.render_prompt"
) as mock_render:
mock_render.return_value = "custom rendered"
with patch(
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
) as mock_meta:
mock_meta.return_value = {"prompt": "custom rendered"}
builder = PreviewPromptBuilder(db)
result = builder.build_prompt_from_rows(
job, source_rows, sample_size=1, prompt_template=custom_template,
)
# Verify custom template was used
mock_render.assert_called_once()
assert mock_render.call_args[0][0] == custom_template
def test_default_prompt_template(self):
"""Default prompt template when none provided."""
db = self._make_db()
job = self._make_job()
source_rows = [{"name": "hello"}]
with patch(
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
) as mock_filter:
mock_filter.return_value = []
with patch(
"src.plugins.translate.preview_prompt_builder.render_prompt"
) as mock_render:
mock_render.return_value = "default rendered"
with patch(
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
) as mock_meta:
mock_meta.return_value = {"prompt": "default rendered"}
from src.plugins.translate.preview_constants import DEFAULT_PREVIEW_PROMPT_TEMPLATE
builder = PreviewPromptBuilder(db)
result = builder.build_prompt_from_rows(job, source_rows, sample_size=1)
assert mock_render.call_args[0][0] == DEFAULT_PREVIEW_PROMPT_TEMPLATE
def test_string_target_languages_converted(self):
"""String target_languages converted to list."""
db = self._make_db()
job = self._make_job(target_languages="ru") # string, not list
source_rows = [{"name": "hello"}]
with patch(
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
) as mock_filter:
mock_filter.return_value = []
with patch(
"src.plugins.translate.preview_prompt_builder.render_prompt"
) as mock_render:
mock_render.return_value = "prompt"
with patch(
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
) as mock_meta:
mock_meta.return_value = {"prompt": "prompt"}
builder = PreviewPromptBuilder(db)
result = builder.build_prompt_from_rows(job, source_rows, sample_size=1)
# Verify target_languages was converted to list
mock_render.assert_called_once()
variables = mock_render.call_args[0][1]
assert "ru" in variables["target_languages"]
def test_empty_source_rows(self):
"""Empty source rows handled."""
db = self._make_db()
job = self._make_job()
source_rows = []
with patch(
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
) as mock_filter:
mock_filter.return_value = []
with patch(
"src.plugins.translate.preview_prompt_builder.render_prompt"
) as mock_render:
mock_render.return_value = "prompt"
with patch(
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
) as mock_meta:
mock_meta.return_value = {"prompt": "prompt"}
builder = PreviewPromptBuilder(db)
result = builder.build_prompt_from_rows(job, source_rows, sample_size=5)
assert result["prompt"] == "prompt"
def test_json_rows_format(self):
"""Rows are serialized to JSON in prompt context."""
db = self._make_db()
job = self._make_job()
source_rows = [{"name": "hello", "description": "greeting"}]
with patch(
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
) as mock_filter:
mock_filter.return_value = []
with patch(
"src.plugins.translate.preview_prompt_builder.render_prompt"
) as mock_render:
mock_render.return_value = "prompt"
with patch(
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
) as mock_meta:
mock_meta.return_value = {"prompt": "prompt"}
builder = PreviewPromptBuilder(db)
result = builder.build_prompt_from_rows(job, source_rows, sample_size=1)
variables = mock_render.call_args[0][1]
assert "row_id" in variables["rows_json"]
assert "text" in variables["rows_json"]
assert "hello" in variables["rows_json"]
def test_row_meta_contains_context(self):
"""Row meta includes context_data from context columns."""
db = self._make_db()
job = self._make_job(context_columns=["description", "category"])
source_rows = [{"name": "hello", "description": "greeting", "category": "common"}]
with patch(
"src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch"
) as mock_filter:
mock_filter.return_value = []
with patch(
"src.plugins.translate.preview_prompt_builder.render_prompt"
) as mock_render:
mock_render.return_value = "prompt"
with patch(
"src.plugins.translate.preview_prompt_builder.compute_build_token_metadata"
) as mock_meta:
mock_meta.return_value = {"prompt": "prompt"}
builder = PreviewPromptBuilder(db)
result = builder.build_prompt_from_rows(job, source_rows, sample_size=1)
# Verify the row_context was passed to filter_for_batch
mock_filter.assert_called_once()
filter_kwargs = mock_filter.call_args[1]
assert filter_kwargs["row_context"] == {"description": "greeting", "category": "common"}
class TestEstimateTokenBudgetForRows:
"""PreviewPromptBuilder.estimate_token_budget_for_rows — delegates to helper."""
def test_delegates_to_helper(self):
"""Calls estimate_token_budget_for_rows helper."""
db = MagicMock()
job = MagicMock()
source_rows = [{"name": "hello"}]
with patch(
"src.plugins.translate.preview_prompt_builder.estimate_token_budget_for_rows"
) as mock_helper:
mock_helper.return_value = {"actual_row_count": 1, "source_rows": source_rows}
builder = PreviewPromptBuilder(db)
result = builder.estimate_token_budget_for_rows(
source_rows, ["ru"], job, "gpt-4o-mini",
)
mock_helper.assert_called_once_with(
source_rows=source_rows, target_languages=["ru"],
job=job, provider_model="gpt-4o-mini",
)
assert result == {"actual_row_count": 1, "source_rows": source_rows}
# #endregion Test.PreviewPromptBuilder

View File

@@ -0,0 +1,135 @@
# #region Test.PreviewTokenEstimator [C:2] [TYPE Module] [SEMANTICS test, translate, token, cost, estimation]
# @BRIEF Tests for preview_token_estimator.py — TokenEstimator static methods.
# @RELATION BINDS_TO -> [TokenEstimator]
# @TEST_CONTRACT: TokenEstimator.estimate_prompt_tokens -> int | token estimate from string
# @TEST_CONTRACT: TokenEstimator.estimate_output_tokens -> int | output token estimate
# @TEST_CONTRACT: TokenEstimator.estimate_cost -> float | cost estimate from tokens
# @TEST_CONTRACT: TokenEstimator.check_cost_warning -> str | None | cost threshold warning
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:")
import pytest
from src.plugins.translate.preview_token_estimator import TokenEstimator
class TestEstimatePromptTokens:
"""TokenEstimator.estimate_prompt_tokens — estimate tokens from string."""
def test_empty_string_returns_zero(self):
"""Empty string returns 0."""
assert TokenEstimator.estimate_prompt_tokens("") == 0
def test_short_string_returns_at_least_one(self):
"""Short string returns at least 1."""
assert TokenEstimator.estimate_prompt_tokens("a") == 1
def test_long_string_estimates(self):
"""Longer string produces token estimate."""
text = "hello world " * 100 # ~1300 chars
tokens = TokenEstimator.estimate_prompt_tokens(text)
assert tokens > 0
assert tokens <= len(text) # tokens shouldn't exceed chars
def test_empty_string_handled(self):
"""Empty string returns 0. Whitespace is truthy so gets min 1."""
assert TokenEstimator.estimate_prompt_tokens("") == 0
assert TokenEstimator.estimate_prompt_tokens(" ") == 1
class TestEstimateOutputTokens:
"""TokenEstimator.estimate_output_tokens — estimate output tokens."""
def test_zero_rows_returns_zero(self):
"""0 rows returns 0."""
assert TokenEstimator.estimate_output_tokens(0) == 0
def test_single_row_single_language(self):
"""1 row, 1 language gives output estimate."""
result = TokenEstimator.estimate_output_tokens(1, 1)
expected = int(1 * 1 * 120 * 1.2)
assert result == expected
def test_multi_row_single_language(self):
"""10 rows, 1 language."""
result = TokenEstimator.estimate_output_tokens(10, 1)
expected = int(10 * 1 * 120 * 1.2)
assert result == expected
def test_multi_row_multi_language(self):
"""5 rows, 3 languages."""
result = TokenEstimator.estimate_output_tokens(5, 3)
expected = int(5 * 3 * 120 * 1.2)
assert result == expected
def test_default_language(self):
"""Default num_languages is 1."""
result = TokenEstimator.estimate_output_tokens(2)
expected = int(2 * 1 * 120 * 1.2)
assert result == expected
class TestEstimateCost:
"""TokenEstimator.estimate_cost — cost from tokens."""
def test_zero_tokens_zero_cost(self):
"""0 tokens costs 0."""
assert TokenEstimator.estimate_cost(0) == 0.0
def test_default_rate(self):
"""1000 tokens at default rate."""
cost = TokenEstimator.estimate_cost(1000)
assert cost == pytest.approx(0.002, 0.000001)
def test_custom_rate(self):
"""Custom cost per 1k tokens."""
cost = TokenEstimator.estimate_cost(1000, 0.01)
assert cost == pytest.approx(0.01, 0.000001)
def test_large_token_count(self):
"""Large token count calculates correctly."""
cost = TokenEstimator.estimate_cost(100000)
assert cost == pytest.approx(0.2, 0.000001)
def test_rounding_precision(self):
"""Result is rounded to 6 decimal places."""
cost = TokenEstimator.estimate_cost(333)
assert isinstance(cost, float)
class TestCheckCostWarning:
"""TokenEstimator.check_cost_warning — cost threshold warning."""
def test_below_threshold_no_warning(self):
"""sample_size <= 30 returns None."""
assert TokenEstimator.check_cost_warning(10, 1, 100, 0.001) is None
assert TokenEstimator.check_cost_warning(30, 1, 100, 0.001) is None
def test_above_threshold_returns_warning(self):
"""sample_size > 30 returns warning string."""
warning = TokenEstimator.check_cost_warning(50, 2, 5000, 0.05)
assert warning is not None
assert "Large preview" in warning
assert "5000 tokens" in warning
assert "$0.0500" in warning
assert "2 languages" in warning
def test_warning_format_high_cost(self):
"""Warning includes formatted cost."""
warning = TokenEstimator.check_cost_warning(100, 1, 10000, 0.5)
assert warning is not None
assert "$0.5000" in warning
def test_single_language_format(self):
"""Warning for single language."""
warning = TokenEstimator.check_cost_warning(40, 1, 2000, 0.004)
assert warning is not None
assert "1 languages" in warning
# #endregion Test.PreviewTokenEstimator