chore: remainder — backend test infra, agent config, docker, i18n, frontend ui

- Backend: alembic env, config manager/models, dependencies, translate plugin
- Backend tests: async_sync_regression, integration tests, git services, test_agent
- Docker: docker-compose.yml updates
- Agent: qa-tester.md update, semantics-testing SKILL.md update
- Frontend: TopNavbar, sidebarNavigation, FeaturesSettings, FeatureGate
- i18n: assistant.json en/ru locale updates
- New: frontend/src/lib/components/agent/ directory
This commit is contained in:
2026-06-10 15:06:36 +03:00
parent 2b303f92b3
commit 143f14d516
34 changed files with 5693 additions and 178 deletions

View File

@@ -0,0 +1,197 @@
# #region AsyncSyncRegressionTests [C:3] [TYPE Module] [SEMANTICS test, regression, async, sync, migration]
# @BRIEF Regression tests to prevent async/sync mismatch after async migration.
# @RELATION BINDS_TO -> [TranslationOrchestrator]
# @RELATION BINDS_TO -> [TranslationExecutor]
# @RATIONALE
# After async migration (commits ee9123b, 71000db), 20 tests failed because
# async methods were called without await. This module prevents regression.
#
# Root cause: Tests written as `def test_...` called `async def` methods,
# returning coroutine objects instead of results. Tests passed locally but
# failed at runtime with AttributeError: 'coroutine' object has no attribute 'status'.
#
# @REJECTED
# Static analysis tool — adds complexity, not all teams use it.
# Runtime check in production — too late, tests should catch this.
import warnings
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
from src.models.translate import TranslationJob, TranslationRun
from src.plugins.translate.orchestrator import TranslationOrchestrator
from src.plugins.translate.executor import TranslationExecutor
# #region TestAsyncMethodAwaitRegression [C:3] [TYPE Class]
# @BRIEF Verify async methods are properly awaited in tests.
class TestAsyncMethodAwaitRegression:
"""Regression tests for async method await patterns."""
# region test_execute_run_returns_result_not_coroutine [C:2] [TYPE Function]
# @BRIEF Verify execute_run returns a result object, not a coroutine.
async def test_execute_run_returns_result_not_coroutine(self) -> None:
"""Ensure execute_run is awaited and returns TranslationRun, not coroutine."""
db = MagicMock()
config_manager = MagicMock()
job = MagicMock(spec=TranslationJob)
job.id = "job-1"
job.target_table = "target"
job.target_languages = ["en"]
run = MagicMock()
run.id = "run-1"
run.job_id = "job-1"
run.status = "PENDING"
completed_run = MagicMock()
completed_run.id = "run-1"
completed_run.status = "COMPLETED"
completed_run.total_records = 0
db.query.return_value.filter.return_value.first.side_effect = [job, completed_run]
orch = TranslationOrchestrator(db, config_manager, "test-user")
engine = orch._runner._executor_engine
with patch("src.plugins.translate.orchestrator_exec.TranslationExecutor") as MockExec:
mock_exec = MagicMock()
mock_exec.execute_run = AsyncMock(return_value=completed_run)
MockExec.return_value = mock_exec
with patch.object(engine, "event_log"), \
patch.object(engine._aggregator, "update_language_stats"), \
patch.object(engine._sql_service, "generate_and_insert_sql", new_callable=AsyncMock, return_value={"status": "skipped"}):
result = await orch.execute_run(run)
# CRITICAL: result must be a TranslationRun object, not a coroutine
assert not hasattr(result, '__await__'), (
"execute_run returned a coroutine! Did you forget to await it?"
)
assert hasattr(result, 'status'), (
f"Result must have 'status' attribute, got {type(result)}"
)
assert result.status == "COMPLETED"
# endregion test_execute_run_returns_result_not_coroutine
# region test_no_coroutine_never_awaited_warning [C:2] [TYPE Function]
# @BRIEF Verify no RuntimeWarning about unawaited coroutines.
async def test_no_coroutine_never_awaited_warning(self) -> None:
"""Ensure no RuntimeWarning about coroutines not being awaited."""
db = MagicMock()
config_manager = MagicMock()
executor = TranslationExecutor(db, config_manager)
job = MagicMock(spec=TranslationJob)
job.id = "job-1"
job.batch_size = 50
job.target_languages = ["en"]
run = MagicMock()
run.id = "run-1"
run.job_id = "job-1"
run.status = "PENDING"
db.query.return_value.filter.return_value.first.return_value = job
executor._preview_edits_cache = {}
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
with patch.object(executor, '_fetch_source_rows', new_callable=AsyncMock, return_value=[]):
result = await executor.execute_run(run)
# Check for RuntimeWarning about unawaited coroutines
coroutine_warnings = [
warning for warning in w
if issubclass(warning.category, RuntimeWarning)
and "coroutine" in str(warning.message).lower()
and "never awaited" in str(warning.message).lower()
]
assert len(coroutine_warnings) == 0, (
f"Found {len(coroutine_warnings)} RuntimeWarning(s) about unawaited coroutines. "
f"Did you forget to await an async method? Warnings: {coroutine_warnings}"
)
# endregion test_no_coroutine_never_awaited_warning
# region test_async_mock_used_for_async_methods [C:2] [TYPE Function]
# @BRIEF Verify AsyncMock is used for async methods, not MagicMock.
def test_async_mock_used_for_async_methods(self) -> None:
"""Ensure AsyncMock is used when mocking async methods."""
import inspect
# Verify that execute_run is async
assert inspect.iscoroutinefunction(TranslationOrchestrator.execute_run), (
"TranslationOrchestrator.execute_run should be async"
)
assert inspect.iscoroutinefunction(TranslationExecutor.execute_run), (
"TranslationExecutor.execute_run should be async"
)
# When mocking these methods, AsyncMock must be used
# This test documents the requirement
mock_executor = MagicMock()
# WRONG: mock_executor.execute_run.return_value = ...
# RIGHT: mock_executor.execute_run = AsyncMock(return_value=...)
# Verify AsyncMock can be awaited
async_mock = AsyncMock(return_value="result")
assert inspect.iscoroutinefunction(async_mock), (
"AsyncMock should be a coroutine function"
)
# endregion test_async_mock_used_for_async_methods
# #endregion TestAsyncMethodAwaitRegression
# #region TestAsyncHandlerClassification [C:2] [TYPE Class]
# @BRIEF Verify route handlers have correct async/sync classification.
class TestAsyncHandlerClassification:
"""Verify route handlers are correctly classified as async or sync."""
# region test_async_handlers_call_async_methods [C:2] [TYPE Function]
# @BRIEF Verify async handlers call async orchestrator methods.
def test_async_handlers_call_async_methods(self) -> None:
"""Async handlers should call async methods like execute_run."""
import inspect
from src.api.routes.translate._run_routes import router
# These handlers should be async because they call async orchestrator methods
async_handlers = {"run_translation", "retry_run", "retry_insert"}
for route in router.routes:
if hasattr(route, "endpoint"):
name = route.endpoint.__name__
if name in async_handlers:
assert inspect.iscoroutinefunction(route.endpoint), (
f"Handler '{name}' should be async (calls async orchestrator methods)"
)
# endregion test_async_handlers_call_async_methods
# region test_sync_handlers_dont_call_async_methods [C:2] [TYPE Function]
# @BRIEF Verify sync handlers don't call async orchestrator methods.
def test_sync_handlers_dont_call_async_methods(self) -> None:
"""Sync handlers should not call async orchestrator methods."""
import inspect
from src.api.routes.translate._run_routes import router
# These handlers should be sync (they don't call async orchestrator methods)
sync_handlers = {
"cancel_run", "get_run_history", "get_run_status",
"get_run_records", "get_batches",
}
for route in router.routes:
if hasattr(route, "endpoint"):
name = route.endpoint.__name__
if name in sync_handlers:
assert not inspect.iscoroutinefunction(route.endpoint), (
f"Handler '{name}' should be sync (doesn't call async orchestrator methods)"
)
# endregion test_sync_handlers_dont_call_async_methods
# #endregion TestAsyncHandlerClassification
# #endregion AsyncSyncRegressionTests

View File

@@ -12,7 +12,7 @@
# @TEST_EDGE non_timestamp_string -> passed through unchanged
import logging
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from src.plugins.translate.sql_generator import (
SQLGenerator,
@@ -334,7 +334,7 @@ class TestOrchestratorInsertFlow:
# region test_generate_and_insert_sql_with_timestamps [TYPE Function]
# @BRIEF Orchestrator builds correct rows_for_sql from source_data with timestamps.
def test_generate_and_insert_sql_with_timestamps(self) -> None:
async def test_generate_and_insert_sql_with_timestamps(self) -> None:
"""Verify the orchestrator correctly passes source_data through to SQLGenerator."""
from src.plugins.translate.orchestrator import TranslationOrchestrator
@@ -377,16 +377,16 @@ class TestOrchestratorInsertFlow:
) as MockExecutor:
mock_executor = MagicMock()
MockExecutor.return_value = mock_executor
mock_executor.resolve_database_id.return_value = None
mock_executor.resolve_database_id = AsyncMock(return_value=None)
mock_executor.get_database_backend.return_value = None
mock_executor.execute_and_poll.return_value = {
mock_executor.execute_and_poll = AsyncMock(return_value={
"status": "success",
"query_id": "q-123",
"rows_affected": 2,
}
})
# Call _generate_and_insert_sql
result = orch._generate_and_insert_sql(job, run)
result = await orch._generate_and_insert_sql(job, run)
# Verify executor was called
mock_executor.execute_and_poll.assert_called_once()

View File

@@ -8,7 +8,7 @@
# @TEST_EDGE cancellation_flag_during_execution -> stops batch processing
import pytest
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from src.models.translate import (
TranslationJob,
@@ -154,7 +154,7 @@ class TestCancellationFlag:
# region test_cancel_requested_detected_after_commit [TYPE Function]
# @PURPOSE: After batch commit, executor re-fetches run and detects CANCEL_REQUESTED flag.
def test_cancel_requested_detected_after_commit(self, mock_job: MagicMock, mock_run: MagicMock) -> None:
async def test_cancel_requested_detected_after_commit(self, mock_job: MagicMock, mock_run: MagicMock) -> None:
"""Verify that executor detects CANCEL_REQUESTED flag after commit and stops."""
db = MagicMock()
config_manager = MagicMock()
@@ -174,8 +174,8 @@ class TestCancellationFlag:
"source_object_name": "Row 1", "source_data": {"id": "1", "name": "world"}},
]
with (
patch.object(executor, '_fetch_source_rows', return_value=mock_source_rows),
patch.object(executor, '_process_batch', return_value={
patch.object(executor, '_fetch_source_rows', new_callable=AsyncMock, return_value=mock_source_rows),
patch.object(executor, '_process_batch', new_callable=AsyncMock, return_value={
"successful": 2, "failed": 0, "skipped": 0,
}) as mock_process_batch,
):
@@ -186,7 +186,7 @@ class TestCancellationFlag:
obj.error_message = "CANCEL_REQUESTED"
db.refresh.side_effect = refresh_side_effect
result = executor.execute_run(mock_run)
result = await executor.execute_run(mock_run)
# Should have stopped after first batch (not processing more)
assert mock_process_batch.call_count == 1, (
@@ -202,7 +202,7 @@ class TestCancellationFlag:
# region test_no_cancellation_completes_normally [TYPE Function]
# @PURPOSE: Without cancellation flag, executor processes all batches normally.
def test_no_cancellation_completes_normally(self, mock_job: MagicMock, mock_run: MagicMock) -> None:
async def test_no_cancellation_completes_normally(self, mock_job: MagicMock, mock_run: MagicMock) -> None:
"""Verify that without cancellation flag, all batches are processed."""
db = MagicMock()
config_manager = MagicMock()
@@ -219,8 +219,8 @@ class TestCancellationFlag:
"source_object_name": "Row 0", "source_data": {"id": "0", "name": "hello"}},
]
with (
patch.object(executor, '_fetch_source_rows', return_value=mock_source_rows),
patch.object(executor, '_process_batch', return_value={
patch.object(executor, '_fetch_source_rows', new_callable=AsyncMock, return_value=mock_source_rows),
patch.object(executor, '_process_batch', new_callable=AsyncMock, return_value={
"successful": 1, "failed": 0, "skipped": 0,
}) as mock_process_batch,
):
@@ -228,7 +228,7 @@ class TestCancellationFlag:
db.refresh.side_effect = None
db.refresh.return_value = None
result = executor.execute_run(mock_run)
result = await executor.execute_run(mock_run)
assert mock_process_batch.call_count == 1
assert result.status != "CANCELLED", (
@@ -239,7 +239,7 @@ class TestCancellationFlag:
# region test_cancel_requested_no_rows [TYPE Function]
# @PURPOSE: When there are no source rows, cancellation is not relevant.
def test_cancel_requested_no_rows(self, mock_job: MagicMock, mock_run: MagicMock) -> None:
async def test_cancel_requested_no_rows(self, mock_job: MagicMock, mock_run: MagicMock) -> None:
"""Zero source rows should return early without processing batches."""
db = MagicMock()
config_manager = MagicMock()
@@ -249,10 +249,10 @@ class TestCancellationFlag:
executor._preview_edits_cache = {}
with (
patch.object(executor, '_fetch_source_rows', return_value=[]),
patch.object(executor, '_process_batch') as mock_process_batch,
patch.object(executor, '_fetch_source_rows', new_callable=AsyncMock, return_value=[]),
patch.object(executor, '_process_batch', new_callable=AsyncMock) as mock_process_batch,
):
result = executor.execute_run(mock_run)
result = await executor.execute_run(mock_run)
mock_process_batch.assert_not_called()
# Should be COMPLETED (no rows) not CANCELLED

View File

@@ -14,7 +14,7 @@
from datetime import UTC, datetime
import json
import pytest
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from src.models.translate import (
TranslationJob,
@@ -250,7 +250,7 @@ class TestTranslationOrchestrator:
# region test_execute_run_invalid_status [TYPE Function]
# @PURPOSE: Cannot execute a run that is not in PENDING status.
def test_execute_run_invalid_status(self, mock_job: MagicMock) -> None:
async def test_execute_run_invalid_status(self, mock_job: MagicMock) -> None:
db = MagicMock()
config_manager = MagicMock()
@@ -262,11 +262,11 @@ class TestTranslationOrchestrator:
orch = TranslationOrchestrator(db, config_manager, "test-user")
with pytest.raises(ValueError, match="PENDING"):
orch.execute_run(run)
await orch.execute_run(run)
# region test_execute_run_success [TYPE Function]
# @BRIEF Happy path: executor completes, SQL generated, Superset submits.
def test_execute_run_success(self, mock_job: MagicMock) -> None:
async def test_execute_run_success(self, mock_job: MagicMock) -> None:
db = MagicMock()
config_manager = MagicMock()
mock_job.target_table = "target_tbl"
@@ -301,16 +301,17 @@ class TestTranslationOrchestrator:
"src.plugins.translate.orchestrator_exec.TranslationExecutor"
) as MockExecutor:
mock_executor_instance = MagicMock()
mock_executor_instance.execute_run.return_value = completed_run_response
mock_executor_instance.execute_run = AsyncMock(return_value=completed_run_response)
MockExecutor.return_value = mock_executor_instance
orch = TranslationOrchestrator(db, config_manager, "test-user")
engine = orch._runner._executor_engine
with patch.object(engine._sql_service, 'generate_and_insert_sql',
new_callable=AsyncMock,
return_value={"status": "success", "query_id": "q-1", "rows_affected": 10}), \
patch.object(engine._aggregator, 'update_language_stats'), \
patch.object(engine, 'event_log'):
result = orch.execute_run(run)
result = await orch.execute_run(run)
assert result.status == "COMPLETED"
assert result.insert_status == "success"
@@ -322,7 +323,7 @@ class TestTranslationOrchestrator:
# region test_execute_run_executor_failure [TYPE Function]
# @BRIEF Executor raises exception, run is marked FAILED.
def test_execute_run_executor_failure(self, mock_job: MagicMock) -> None:
async def test_execute_run_executor_failure(self, mock_job: MagicMock) -> None:
db = MagicMock()
config_manager = MagicMock()
@@ -339,14 +340,14 @@ class TestTranslationOrchestrator:
"src.plugins.translate.orchestrator_exec.TranslationExecutor"
) as MockExecutor:
mock_executor_instance = MagicMock()
mock_executor_instance.execute_run.side_effect = ValueError(
mock_executor_instance.execute_run = AsyncMock(side_effect=ValueError(
"LLM provider unavailable"
)
))
MockExecutor.return_value = mock_executor_instance
orch = TranslationOrchestrator(db, config_manager, "test-user")
with patch.object(orch._runner._executor_engine, "event_log"):
result = orch.execute_run(run)
result = await orch.execute_run(run)
assert result.status == "FAILED"
assert "LLM provider unavailable" in (result.error_message or "")
@@ -355,7 +356,7 @@ class TestTranslationOrchestrator:
# region test_execute_run_skip_insert [TYPE Function]
# @BRIEF skip_insert=True completes run without SQL generation or Superset submission.
def test_execute_run_skip_insert(self, mock_job: MagicMock) -> None:
async def test_execute_run_skip_insert(self, mock_job: MagicMock) -> None:
db = MagicMock()
config_manager = MagicMock()
@@ -382,15 +383,15 @@ class TestTranslationOrchestrator:
"src.plugins.translate.orchestrator_exec.TranslationExecutor"
) as MockExecutor:
mock_executor_instance = MagicMock()
mock_executor_instance.execute_run.return_value = completed_run
mock_executor_instance.execute_run = AsyncMock(return_value=completed_run)
MockExecutor.return_value = mock_executor_instance
orch = TranslationOrchestrator(db, config_manager, "test-user")
engine = orch._runner._executor_engine
with patch.object(engine._sql_service, 'generate_and_insert_sql') as mock_gen_sql, \
with patch.object(engine._sql_service, 'generate_and_insert_sql', new_callable=AsyncMock) as mock_gen_sql, \
patch.object(engine._aggregator, 'update_language_stats'), \
patch.object(engine, 'event_log'):
result = orch.execute_run(run, skip_insert=True)
result = await orch.execute_run(run, skip_insert=True)
assert result.status == "COMPLETED"
# generate_and_insert_sql should NOT be called in skip_insert mode
@@ -401,7 +402,7 @@ class TestTranslationOrchestrator:
# region test_execute_run_no_job [TYPE Function]
# @BRIEF Job not found raises ValueError.
def test_execute_run_no_job(self) -> None:
async def test_execute_run_no_job(self) -> None:
db = MagicMock()
config_manager = MagicMock()
@@ -415,13 +416,13 @@ class TestTranslationOrchestrator:
orch = TranslationOrchestrator(db, config_manager, "test-user")
with pytest.raises(ValueError, match="not found"):
orch.execute_run(run)
await orch.execute_run(run)
# endregion test_execute_run_no_job
# region test_execute_run_insert_failure [TYPE Function]
# @BRIEF SQL generation/insert returns failure — run marked FAILED with error logged.
def test_execute_run_insert_failure(self, mock_job: MagicMock) -> None:
async def test_execute_run_insert_failure(self, mock_job: MagicMock) -> None:
db = MagicMock()
config_manager = MagicMock()
@@ -453,16 +454,17 @@ class TestTranslationOrchestrator:
"src.plugins.translate.orchestrator_exec.TranslationExecutor"
) as MockExecutor:
mock_executor_instance = MagicMock()
mock_executor_instance.execute_run.return_value = completed_run_response
mock_executor_instance.execute_run = AsyncMock(return_value=completed_run_response)
MockExecutor.return_value = mock_executor_instance
orch = TranslationOrchestrator(db, config_manager, "test-user")
engine = orch._runner._executor_engine
with patch.object(engine._sql_service, 'generate_and_insert_sql',
new_callable=AsyncMock,
return_value={"status": "failed", "error_message": "timeout", "query_id": None}), \
patch.object(engine._aggregator, 'update_language_stats'), \
patch.object(engine, 'event_log'):
result = orch.execute_run(run)
result = await orch.execute_run(run)
assert result.status == "FAILED"
assert result.insert_status == "failed"
@@ -506,7 +508,7 @@ class TestTranslationOrchestrator:
# region test_retry_failed_batches_no_failures [TYPE Function]
# @PURPOSE: Raises if no failed batches found.
def test_retry_failed_batches_no_failures(self) -> None:
async def test_retry_failed_batches_no_failures(self) -> None:
db = MagicMock()
config_manager = MagicMock()
@@ -523,7 +525,7 @@ class TestTranslationOrchestrator:
orch = TranslationOrchestrator(db, config_manager, "test-user")
with pytest.raises(ValueError, match="No failed batches"):
orch.retry_failed_batches("run-1")
await orch.retry_failed_batches("run-1")
# region test_get_run_status [TYPE Function]
# @PURPOSE: get_run_status returns structured status.
@@ -680,7 +682,7 @@ class TestTranslationExecutorMultiLang:
# region test_multi_language_translation_language_entries [TYPE Function]
# @PURPOSE: Multi-language LLM response creates per-language TranslationLanguage entries.
def test_multi_language_translation_language_entries(self) -> None:
async def test_multi_language_translation_language_entries(self) -> None:
db = MagicMock()
config_manager = MagicMock()
@@ -709,8 +711,9 @@ class TestTranslationExecutorMultiLang:
]
with patch.object(LLMTranslationService, 'call_llm',
new_callable=AsyncMock,
return_value=(multi_lang_response, 'stop')):
result = executor._call_llm_for_batch(
result = await executor._call_llm_for_batch(
job=job,
run_id="run-ml-1",
batch_rows=batch_rows,
@@ -746,7 +749,7 @@ class TestTranslationExecutorMultiLang:
# region test_source_as_reference [TYPE Function]
# @PURPOSE: When detected source language matches a target language, store original text verbatim.
def test_source_as_reference(self) -> None:
async def test_source_as_reference(self) -> None:
db = MagicMock()
config_manager = MagicMock()
@@ -772,8 +775,9 @@ class TestTranslationExecutorMultiLang:
]
with patch.object(LLMTranslationService, 'call_llm',
new_callable=AsyncMock,
return_value=(response, 'stop')):
result = executor._call_llm_for_batch(
result = await executor._call_llm_for_batch(
job=job,
run_id="run-sar-1",
batch_rows=batch_rows,
@@ -807,7 +811,7 @@ class TestTranslationExecutorMultiLang:
# region test_per_language_stats_on_execute_run [TYPE Function]
# @PURPOSE: execute_run with multi-language job creates and populates TranslationRunLanguageStats.
def test_per_language_stats_on_execute_run(self, mock_job: MagicMock) -> None:
async def test_per_language_stats_on_execute_run(self, mock_job: MagicMock) -> None:
db = MagicMock()
config_manager = MagicMock()
@@ -846,15 +850,16 @@ class TestTranslationExecutorMultiLang:
engine = orch._runner._executor_engine
with patch.object(engine, 'event_log'), \
patch.object(engine._sql_service, 'generate_and_insert_sql',
new_callable=AsyncMock,
return_value={"status": "success", "query_id": "q-1", "rows_affected": 5}), \
patch.object(engine._aggregator, 'update_language_stats') as mock_update_stats, \
patch('src.plugins.translate.orchestrator_exec.TranslationExecutor') as MockExecutor:
mock_executor_instance = MagicMock()
mock_executor_instance.execute_run.return_value = completed_run
mock_executor_instance.execute_run = AsyncMock(return_value=completed_run)
MockExecutor.return_value = mock_executor_instance
result = orch.execute_run(run)
result = await orch.execute_run(run)
assert result.status == "COMPLETED"

View File

@@ -16,7 +16,7 @@
from datetime import UTC, datetime
import pytest
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from src.models.translate import (
TranslationJob,
@@ -301,7 +301,7 @@ class TestExecuteRunCancellation:
# region test_execute_run_returns_cancelled [C:2] [TYPE Function]
# @BRIEF When executor returns a CANCELLED run, orchestrator should not attempt SQL insert.
def test_execute_run_returns_cancelled(self) -> None:
async def test_execute_run_returns_cancelled(self) -> None:
db = MagicMock()
config_manager = MagicMock()
@@ -329,11 +329,11 @@ class TestExecuteRunCancellation:
engine = orch._runner._executor_engine
with patch("src.plugins.translate.orchestrator_exec.TranslationExecutor") as MockExec:
mock_exec = MagicMock()
mock_exec.execute_run.return_value = cancelled_run
mock_exec.execute_run = AsyncMock(return_value=cancelled_run)
MockExec.return_value = mock_exec
with patch.object(engine, "event_log"), \
patch.object(engine._aggregator, "update_language_stats"):
result = orch.execute_run(run)
result = await orch.execute_run(run)
# Should NOT attempt SQL generation when executor returned CANCELLED
assert result.status == "CANCELLED"
@@ -341,7 +341,7 @@ class TestExecuteRunCancellation:
# region test_execute_run_zero_rows [C:2] [TYPE Function]
# @BRIEF Executor with zero source rows returns COMPLETED, orchestrator handles it.
def test_execute_run_zero_rows(self) -> None:
async def test_execute_run_zero_rows(self) -> None:
db = MagicMock()
config_manager = MagicMock()
@@ -370,11 +370,11 @@ class TestExecuteRunCancellation:
engine = orch._runner._executor_engine
with patch("src.plugins.translate.orchestrator_exec.TranslationExecutor") as MockExec:
mock_exec = MagicMock()
mock_exec.execute_run.return_value = completed_run
mock_exec.execute_run = AsyncMock(return_value=completed_run)
MockExec.return_value = mock_exec
with patch.object(engine, "event_log"), \
patch.object(engine._aggregator, "update_language_stats"):
result = orch.execute_run(run, skip_insert=True)
result = await orch.execute_run(run, skip_insert=True)
assert result.status == "COMPLETED"
assert result.total_records == 0
@@ -447,7 +447,7 @@ class TestPeriodicCommitVisibility:
# region test_commit_called_after_each_batch [C:2] [TYPE Function]
# @BRIEF db.commit() is called after each batch processing.
def test_commit_called_after_each_batch(self, mock_job: MagicMock, mock_run: MagicMock) -> None:
async def test_commit_called_after_each_batch(self, mock_job: MagicMock, mock_run: MagicMock) -> None:
db = MagicMock()
config_manager = MagicMock()
executor = TranslationExecutor(db, config_manager)
@@ -465,14 +465,14 @@ class TestPeriodicCommitVisibility:
]
with (
patch.object(executor, '_fetch_source_rows', return_value=mock_source_rows),
patch.object(executor, '_process_batch', return_value={
patch.object(executor, '_fetch_source_rows', new_callable=AsyncMock, return_value=mock_source_rows),
patch.object(executor, '_process_batch', new_callable=AsyncMock, return_value={
"successful": 1, "failed": 0, "skipped": 0,
}),
):
db.refresh.side_effect = None
db.refresh.return_value = None
executor.execute_run(mock_run)
await executor.execute_run(mock_run)
# commit is called after each batch (line 172 in executor.py).
# Final status uses flush(), not commit() — the orchestrator commits.
@@ -483,7 +483,7 @@ class TestPeriodicCommitVisibility:
# region test_refresh_called_after_commit [C:2] [TYPE Function]
# @BRIEF db.refresh(run) is called after each batch commit to re-read cancellation flag.
def test_refresh_called_after_commit(self, mock_job: MagicMock, mock_run: MagicMock) -> None:
async def test_refresh_called_after_commit(self, mock_job: MagicMock, mock_run: MagicMock) -> None:
db = MagicMock()
config_manager = MagicMock()
executor = TranslationExecutor(db, config_manager)
@@ -499,14 +499,14 @@ class TestPeriodicCommitVisibility:
]
with (
patch.object(executor, '_fetch_source_rows', return_value=mock_source_rows),
patch.object(executor, '_process_batch', return_value={
patch.object(executor, '_fetch_source_rows', new_callable=AsyncMock, return_value=mock_source_rows),
patch.object(executor, '_process_batch', new_callable=AsyncMock, return_value={
"successful": 1, "failed": 0, "skipped": 0,
}),
):
db.refresh.side_effect = None
db.refresh.return_value = None
executor.execute_run(mock_run)
await executor.execute_run(mock_run)
# refresh should be called after each batch commit
assert db.refresh.call_count >= 1, (
@@ -518,56 +518,63 @@ class TestPeriodicCommitVisibility:
# region TestAsyncToDefMigration [TYPE Class]
# @BRIEF Verify that the async->def migration in _run_routes.py works correctly.
# @BRIEF Verify that the async migration in _run_routes.py works correctly.
class TestAsyncToDefMigration:
"""Verify route handlers are sync and work with FastAPI TestClient."""
"""Verify route handlers are correctly async/sync and work with FastAPI TestClient."""
# region test_all_handlers_are_sync [C:2] [TYPE Function]
# @BRIEF All 11 route handlers should be sync (def, not async def).
def test_all_handlers_are_sync(self) -> None:
"""Import the router and verify all handler functions are sync."""
# region test_handler_async_sync_classification [C:2] [TYPE Function]
# @BRIEF Verify correct async/sync classification of route handlers.
def test_handler_async_sync_classification(self) -> None:
"""Import the router and verify handler async/sync classification."""
import inspect
from src.api.routes.translate._run_routes import router
sync_handlers = []
async_handlers = []
target_names = {
"run_translation", "retry_run", "retry_insert", "cancel_run",
"get_run_history", "get_run_status", "get_run_records", "get_batches",
"override_detected_language", "inline_edit_translation", "bulk_find_replace",
# Handlers that should be async (they call async orchestrator methods)
async_target_names = {
"run_translation", "retry_run", "retry_insert",
}
# Handlers that should be sync (they don't call async methods)
sync_target_names = {
"cancel_run", "get_run_history", "get_run_status", "get_run_records",
"get_batches", "override_detected_language", "inline_edit_translation",
"bulk_find_replace",
}
found_async = []
found_sync = []
for route in router.routes:
if hasattr(route, "endpoint"):
name = route.endpoint.__name__
if name in target_names:
if name in async_target_names or name in sync_target_names:
if inspect.iscoroutinefunction(route.endpoint):
async_handlers.append(name)
found_async.append(name)
else:
sync_handlers.append(name)
found_sync.append(name)
assert len(async_handlers) == 0, (
f"Found async handlers (should be sync): {async_handlers}"
)
assert len(sync_handlers) == len(target_names), (
f"Expected {len(target_names)} sync handlers, found {len(sync_handlers)}: {sync_handlers}"
# Verify async handlers
assert set(found_async) == async_target_names, (
f"Expected async handlers {async_target_names}, found {found_async}"
)
# region test_no_await_in_handler_bodies [C:2] [TYPE Function]
# @BRIEF No `await` keyword should appear in the handler function bodies.
def test_no_await_in_handler_bodies(self) -> None:
"""Scan handler source code for 'await' — should not appear in sync handlers."""
# Verify sync handlers
assert set(found_sync) == sync_target_names, (
f"Expected sync handlers {sync_target_names}, found {found_sync}"
)
# region test_await_in_async_handler_bodies [C:2] [TYPE Function]
# @BRIEF Async handlers should contain `await` keyword for async operations.
def test_await_in_async_handler_bodies(self) -> None:
"""Scan async handler source code for 'await' — should appear in async handlers."""
import ast
import inspect
from src.api.routes.translate._run_routes import router
target_names = {
"run_translation", "retry_run", "retry_insert", "cancel_run",
"get_run_history", "get_run_status", "get_run_records", "get_batches",
"override_detected_language", "inline_edit_translation", "bulk_find_replace",
"run_translation", "retry_run", "retry_insert",
}
for route in router.routes:
@@ -580,8 +587,8 @@ class TestAsyncToDefMigration:
node for node in ast.walk(tree)
if isinstance(node, ast.Await)
]
assert len(awaits) == 0, (
f"Handler '{name}' contains {len(awaits)} await expressions"
assert len(awaits) > 0, (
f"Async handler '{name}' should contain await expressions"
)
# region test_cancel_run_route_accessible [C:2] [TYPE Function]