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:
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user