## Backend (7 production files + 6 test files) ### P0-2: LLM output truncation cascade fix - _token_budget.py: OUTPUT_PER_ROW_PER_LANG 120→200, OUTPUT_SAFETY_FACTOR 0.70→0.55 - Prevents finish_reason=length → split → retry cascade (3 calls → 1 call per batch) - P2-8: added qwen-flash/qwen-plus/qwen-max/qwen-coder to PROVIDER_DEFAULTS ### P1-4/P1-5: EncryptionManager singleton - encryption.py: get_encryption_manager() process-wide singleton - llm_provider.py: use singleton instead of new EncryptionManager() per batch - Eliminates ~90 redundant Fernet key validations per translation run ### P1-6: Cache-hit log aggregation - _batch_proc.py: one log per batch (batch_rows + cache_hits) instead of per-row - 1076 log lines → ~30 per run ### P1-7: Timezone-aware datetime fix - scheduler.py: _ensure_aware() helper for naive DB datetime → UTC-aware - Fixes TypeError in scheduled translation concurrency check ### P2-9: Connection test timeout - connection_service.py: asyncio.wait_for(15s) on all dialect tests - Prevents 2-minute UI hangs from DNS/TCP stalls ### Trace ID propagation - middleware/trace.py: inject x-trace-id response header via ASGI send wrapper ### Test fixes & integration tests - test_scheduler.py: AsyncMock for execute_run, mock get_async_job_runner - test_sql_insert_service.py: AsyncMock for execute_sql - test_token_budget.py: batch_size 50→45 for new OUTPUT_PER_ROW_PER_LANG=200 - test_encryption.py: +2 singleton tests - test_scheduler_ensure_aware.py: +4 (naive→aware, passthrough, None, subtraction) - test_batch_classify_persist.py: +2 cache-hit aggregation tests - test_connection_service_edge.py: +2 timeout tests - test_trace_middleware.py: +4 x-trace-id header tests - test_token_budget.py: +4 qwen-flash/O200 tests ## Frontend (7 production files + 5 test files) ### Trace ID propagation - api.ts: _captureTraceId() reads x-trace-id → setTraceId() in fetchApi/requestApi/postApi/deleteApi ### Duplicate datasource columns fetch - ConfigTabForm.svelte: guard availableColumns.length === 0 before fetch ### Admin pages Svelte 5 runes fix - admin/users/+page.svelte: plain let → () for all template-bound vars - admin/roles/+page.svelte: same fix - Both pages were stuck on «Загрузка...» due to mixed reactivity models ### Validation popover positioning - +page.svelte: pass trigger HTMLElement instead of event - DashboardHubModel.svelte.ts: toggleValidationPopover(HTMLElement), closeValidationPopover() - Added X close button + click-outside overlay + i18n ### Test fixes & integration tests - api.test.ts: mock setTraceId/getTraceId, +3 _captureTraceId tests - provider_config.integration.test.ts: handleDelete→promptDeleteProvider - DatasetPreview.test.ts: dashboards/ → ROUTES.dashboards - test_config_tab_form.svelte.js: +2 columns fetch guard tests (NEW) - admin-users.test.ts: +3 loading→table tests (NEW) - admin-roles.test.ts: +2 loading→table tests (NEW) ## Semantic curation - Removed @COMPLEXITY N from 6 route files + metrics.py (duplicate of [C:N]) - Added [C:N] to 2 orphan child contracts in metrics.py - Added [C:N] + @BRIEF to 4 frontend anchors - Fixed #region → # #region consistency in validation_tasks.py ## Verification - Backend: 608 pytest passed (0 failures) - Frontend: 2472 vitest passed (128 files, 0 failures) - Frontend build: ✓ built in 18s - Browser: dashboards, admin/users, admin/roles, validation popover — all green
614 lines
24 KiB
Python
614 lines
24 KiB
Python
# #region Test.BatchInsert [C:3] [TYPE Module] [SEMANTICS test,translate,batch,insert]
|
|
# @BRIEF Tests for _batch_insert.py — batch insert logic.
|
|
# @RELATION BINDS_TO -> [BatchInsertService]
|
|
# @TEST_EDGE: empty_batch -> Returns early
|
|
# @TEST_EDGE: no_columns_fallback -> Uses effective_target as single column
|
|
# @TEST_EDGE: backend_resolve_failure -> Returns early
|
|
# @TEST_EDGE: sql_generation_failure -> Returns early
|
|
|
|
import json
|
|
import sys
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from src.models.translate import (
|
|
TranslationBatch,
|
|
TranslationJob,
|
|
TranslationRecord,
|
|
TranslationLanguage,
|
|
TranslationRun,
|
|
)
|
|
from src.plugins.translate._batch_insert import (
|
|
_build_target_columns,
|
|
_build_context_keys,
|
|
_build_insert_rows,
|
|
_generate_insert_sql,
|
|
_execute_insert_sql,
|
|
_resolve_insert_backend,
|
|
_fetch_batch_records,
|
|
insert_batch_to_target,
|
|
)
|
|
from .conftest import JOB_ID
|
|
|
|
|
|
class TestBuildTargetColumns:
|
|
"""_build_target_columns — build INSERT column list."""
|
|
|
|
def test_basic_columns(self):
|
|
"""Basic columns with key_cols and effective_target."""
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = ["id"]
|
|
job.target_language_column = "lang"
|
|
job.target_source_column = "source"
|
|
job.target_source_language_column = "src_lang"
|
|
cols = _build_target_columns(job, "translated_text")
|
|
assert "id" in cols
|
|
assert "translated_text" in cols
|
|
assert "lang" in cols
|
|
assert "source" in cols
|
|
assert "src_lang" in cols
|
|
assert "context" in cols
|
|
assert "is_original" in cols
|
|
|
|
def test_deduplication(self):
|
|
"""Duplicate columns are removed."""
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = ["id", "id"]
|
|
job.target_language_column = None
|
|
job.target_source_column = None
|
|
job.target_source_language_column = None
|
|
cols = _build_target_columns(job, "id")
|
|
# "id" appears only once despite being in key_cols and effective_target
|
|
assert cols.count("id") == 1
|
|
assert "context" in cols
|
|
assert "is_original" in cols
|
|
|
|
def test_empty_key_cols(self):
|
|
"""No key cols, no extra columns."""
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = None
|
|
job.target_language_column = None
|
|
job.target_source_column = None
|
|
job.target_source_language_column = None
|
|
cols = _build_target_columns(job, "text")
|
|
assert cols == ["text", "context", "is_original"]
|
|
|
|
def test_none_filtered(self):
|
|
"""None values in columns are filtered out."""
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = []
|
|
job.target_language_column = None
|
|
job.target_source_column = None
|
|
job.target_source_language_column = None
|
|
cols = _build_target_columns(job, None)
|
|
assert "context" in cols
|
|
assert "is_original" in cols
|
|
assert None not in cols
|
|
|
|
|
|
class TestBuildContextKeys:
|
|
"""_build_context_keys — build context keys list."""
|
|
|
|
def test_basic_context_keys(self):
|
|
"""Context from job.context_columns."""
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.context_columns = ["col1", "col2"]
|
|
job.translation_column = "translated_text"
|
|
keys = _build_context_keys(job, "translated_text")
|
|
assert keys == ["col1", "col2"]
|
|
|
|
def test_translation_column_appended(self):
|
|
"""translation_column added when different from effective_target."""
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.context_columns = ["col1"]
|
|
job.translation_column = "orig_text"
|
|
keys = _build_context_keys(job, "translated_text")
|
|
assert "col1" in keys
|
|
assert "orig_text" in keys
|
|
|
|
def test_no_context_columns(self):
|
|
"""None context_columns returns empty list."""
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.context_columns = None
|
|
job.translation_column = "text"
|
|
keys = _build_context_keys(job, "text")
|
|
assert keys == []
|
|
|
|
def test_no_duplicate_context(self):
|
|
"""translation_column not added if already in context_columns."""
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.context_columns = ["text"]
|
|
job.translation_column = "text"
|
|
keys = _build_context_keys(job, "translated")
|
|
# "text" already in context_columns, effective_target is different
|
|
assert keys == ["text"]
|
|
|
|
|
|
class TestBuildInsertRows:
|
|
"""_build_insert_rows — build INSERT row dicts."""
|
|
|
|
def _make_record(self, source_sql="hello", target_sql="bonjour",
|
|
source_data=None, languages=None, status="SUCCESS"):
|
|
rec = MagicMock(spec=TranslationRecord)
|
|
rec.source_sql = source_sql
|
|
rec.target_sql = target_sql
|
|
rec.source_data = source_data or {}
|
|
rec.languages = languages or []
|
|
rec.status = status
|
|
return rec
|
|
|
|
def test_basic_rows(self):
|
|
"""Basic case: record with languages."""
|
|
lang = MagicMock(spec=TranslationLanguage)
|
|
lang.language_code = "fr"
|
|
lang.final_value = "bonjour"
|
|
lang.translated_value = "bonjour"
|
|
lang.source_language_detected = "en"
|
|
|
|
rec = self._make_record(
|
|
source_sql="hello", target_sql="bonjour",
|
|
source_data={"id": 1}, languages=[lang],
|
|
)
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = ["id"]
|
|
job.target_source_column = "source"
|
|
job.target_source_language_column = "src_lang"
|
|
job.target_language_column = "lang"
|
|
job.context_columns = []
|
|
|
|
rows = _build_insert_rows([rec], job, "translated_text", "en", [])
|
|
# Should have 2 rows: original + translation
|
|
assert len(rows) == 2
|
|
original = rows[0]
|
|
translation = rows[1]
|
|
assert original["is_original"] == 1
|
|
assert translation["is_original"] == 0
|
|
assert translation["lang"] == "fr"
|
|
assert translation["translated_text"] == "bonjour"
|
|
assert original["translated_text"] == "hello"
|
|
|
|
def test_no_languages_fallback(self):
|
|
"""Record with no languages -> fallback row with primary_language."""
|
|
rec = self._make_record(
|
|
source_sql="hello", target_sql="bonjour",
|
|
source_data={"id": 1}, languages=[],
|
|
)
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = ["id"]
|
|
job.target_source_column = None
|
|
job.target_source_language_column = None
|
|
job.target_language_column = "lang"
|
|
job.context_columns = []
|
|
|
|
rows = _build_insert_rows([rec], job, "translated_text", "fr", [])
|
|
assert len(rows) == 2
|
|
assert rows[1]["lang"] == "fr"
|
|
assert rows[1]["is_original"] == 0
|
|
assert rows[1]["translated_text"] == "bonjour"
|
|
|
|
def test_skip_same_language(self):
|
|
"""Skip translation row with same language as detected source."""
|
|
lang = MagicMock(spec=TranslationLanguage)
|
|
lang.language_code = "en"
|
|
lang.final_value = "hello"
|
|
lang.translated_value = "hello"
|
|
lang.source_language_detected = "en"
|
|
|
|
rec = self._make_record(
|
|
source_sql="hello", target_sql="bonjour",
|
|
source_data={"id": 1}, languages=[lang],
|
|
)
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = []
|
|
job.target_source_column = None
|
|
job.target_source_language_column = None
|
|
job.target_language_column = None
|
|
job.context_columns = []
|
|
|
|
rows = _build_insert_rows([rec], job, "translated_text", "en", [])
|
|
# Only the original row (fr translation has same code as detected "en")
|
|
# Wait: lang.language_code == "en" but detected_src_lang from rec.languages[0].source_language_detected = "en"
|
|
# So the skip condition fires and only original is kept
|
|
assert len(rows) == 1
|
|
|
|
def test_no_source_language_detected(self):
|
|
"""When rec.languages has source_language_detected missing."""
|
|
lang = MagicMock(spec=TranslationLanguage)
|
|
lang.language_code = "fr"
|
|
lang.final_value = "bonjour"
|
|
lang.translated_value = "bonjour"
|
|
lang.source_language_detected = None
|
|
|
|
rec = self._make_record(
|
|
source_sql="hello", target_sql="bonjour",
|
|
source_data={}, languages=[lang],
|
|
)
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = []
|
|
job.target_source_column = None
|
|
job.target_source_language_column = None
|
|
job.target_language_column = None
|
|
job.context_columns = []
|
|
|
|
rows = _build_insert_rows([rec], job, "translated_text", "en", [])
|
|
assert len(rows) == 2
|
|
|
|
def test_context_data_included(self):
|
|
"""Context keys are collected."""
|
|
lang = MagicMock(spec=TranslationLanguage)
|
|
lang.language_code = "fr"
|
|
lang.final_value = "bonjour"
|
|
lang.translated_value = "bonjour"
|
|
lang.source_language_detected = "en"
|
|
|
|
rec = self._make_record(
|
|
source_sql="hello", target_sql="bonjour",
|
|
source_data={"id": 1, "desc": "test"}, languages=[lang],
|
|
)
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = ["id"]
|
|
job.target_source_column = None
|
|
job.target_source_language_column = None
|
|
job.target_language_column = None
|
|
job.context_columns = []
|
|
|
|
rows = _build_insert_rows([rec], job, "translated_text", "en", [])
|
|
context = json.loads(rows[0]["context"])
|
|
assert context == {} # no context_keys
|
|
|
|
def test_with_context_keys(self):
|
|
"""Context keys are included in context JSON."""
|
|
lang = MagicMock(spec=TranslationLanguage)
|
|
lang.language_code = "fr"
|
|
lang.final_value = "bonjour"
|
|
lang.translated_value = "bonjour"
|
|
lang.source_language_detected = "en"
|
|
|
|
rec = self._make_record(
|
|
source_sql="hello", target_sql="bonjour",
|
|
source_data={"id": 1, "desc": "test"}, languages=[lang],
|
|
)
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_key_cols = ["id"]
|
|
job.target_source_column = None
|
|
job.target_source_language_column = None
|
|
job.target_language_column = None
|
|
job.context_columns = []
|
|
|
|
rows = _build_insert_rows([rec], job, "translated_text", "en", ["desc"])
|
|
context = json.loads(rows[0]["context"])
|
|
assert context.get("desc") == "test"
|
|
|
|
|
|
class TestFetchBatchRecords:
|
|
"""_fetch_batch_records — DB query."""
|
|
|
|
def _create_run_and_batch(self, db_session):
|
|
"""Helper to create a TranslationRun and TranslationBatch for FK compliance."""
|
|
from src.models.translate import TranslationRun, TranslationBatch
|
|
from datetime import datetime, UTC
|
|
run = TranslationRun(id="test-run-id", job_id=JOB_ID, status="RUNNING",
|
|
started_at=datetime.now(UTC))
|
|
db_session.add(run)
|
|
db_session.flush()
|
|
batch = TranslationBatch(id="batch-1", run_id="test-run-id",
|
|
batch_index=0, status="PENDING")
|
|
db_session.add(batch)
|
|
db_session.commit()
|
|
|
|
def test_returns_records(self, db_session):
|
|
"""Returns records with SUCCESS status and target_sql."""
|
|
self._create_run_and_batch(db_session)
|
|
from src.models.translate import TranslationRecord, TranslationLanguage
|
|
rec = TranslationRecord(
|
|
id="rec-1", batch_id="batch-1", run_id="test-run-id",
|
|
source_sql="hello", target_sql="bonjour", status="SUCCESS",
|
|
)
|
|
db_session.add(rec)
|
|
db_session.commit()
|
|
|
|
results = _fetch_batch_records(db_session, "batch-1")
|
|
assert len(results) == 1
|
|
assert results[0].id == "rec-1"
|
|
|
|
def test_skips_non_success(self, db_session):
|
|
"""Records without SUCCESS status are excluded."""
|
|
self._create_run_and_batch(db_session)
|
|
from src.models.translate import TranslationRecord
|
|
rec = TranslationRecord(
|
|
id="rec-2", batch_id="batch-1", run_id="test-run-id",
|
|
source_sql="hello", target_sql="bonjour", status="FAILED",
|
|
)
|
|
db_session.add(rec)
|
|
db_session.commit()
|
|
|
|
results = _fetch_batch_records(db_session, "batch-1")
|
|
assert len(results) == 0
|
|
|
|
def test_skips_no_target_sql(self, db_session):
|
|
"""Records with null target_sql are excluded."""
|
|
self._create_run_and_batch(db_session)
|
|
from src.models.translate import TranslationRecord
|
|
rec = TranslationRecord(
|
|
id="rec-3", batch_id="batch-1", run_id="test-run-id",
|
|
source_sql="hello", target_sql=None, status="SUCCESS",
|
|
)
|
|
db_session.add(rec)
|
|
db_session.commit()
|
|
|
|
results = _fetch_batch_records(db_session, "batch-1")
|
|
assert len(results) == 0
|
|
|
|
def test_returns_empty_for_missing_batch(self, db_session):
|
|
"""Non-existent batch returns empty list."""
|
|
results = _fetch_batch_records(db_session, "nonexistent")
|
|
assert results == []
|
|
|
|
|
|
class TestGenerateInsertSql:
|
|
"""_generate_insert_sql — SQL generation."""
|
|
|
|
def test_generates_sql(self):
|
|
"""Returns SQL and row count."""
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_schema = "public"
|
|
job.target_table = "translations"
|
|
job.target_key_cols = None
|
|
job.upsert_strategy = "MERGE"
|
|
|
|
rows = [{"col1": "val1", "is_original": 1}]
|
|
sql, count = _generate_insert_sql("postgresql", job, ["col1", "is_original"], rows, "batch-1")
|
|
assert sql is not None
|
|
assert "INSERT" in sql.upper()
|
|
assert count > 0
|
|
|
|
def test_value_error_returns_none(self):
|
|
"""ValueError in SQL generation returns (None, 0)."""
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_schema = "public"
|
|
job.target_table = None # Will cause error
|
|
job.target_key_cols = None
|
|
job.upsert_strategy = "MERGE"
|
|
|
|
with patch("src.plugins.translate._batch_insert.SQLGenerator.generate",
|
|
side_effect=ValueError("test error")):
|
|
sql, count = _generate_insert_sql("postgresql", job, ["col"], [{"col": "v"}], "batch-1")
|
|
assert sql is None
|
|
assert count == 0
|
|
|
|
|
|
class TestExecuteInsertSql:
|
|
"""_execute_insert_sql — execute via Superset."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_success(self):
|
|
"""Successful execution logs reason."""
|
|
executor = AsyncMock()
|
|
executor.execute_and_poll.return_value = {"status": "success"}
|
|
await _execute_insert_sql(executor, "INSERT ...", "batch-1", 5)
|
|
executor.execute_and_poll.assert_called_once_with(sql="INSERT ...", max_polls=30, poll_interval_seconds=2.0)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exception_caught(self):
|
|
"""Exception in execute_and_poll is caught gracefully."""
|
|
executor = AsyncMock()
|
|
executor.execute_and_poll.side_effect = Exception("API error")
|
|
await _execute_insert_sql(executor, "INSERT ...", "batch-1", 5)
|
|
executor.execute_and_poll.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_db_success(self):
|
|
"""DbExecutor path: successful execution."""
|
|
from src.core.db_executor import DbExecutor, DbExecutionResult
|
|
executor = MagicMock(spec=DbExecutor)
|
|
executor.execute_sql = AsyncMock(return_value=DbExecutionResult(
|
|
success=True, rows_affected=5, execution_time_ms=12.0
|
|
))
|
|
await _execute_insert_sql(executor, "INSERT ...", "batch-1", 5, connection_id="conn-1")
|
|
executor.execute_sql.assert_called_once_with("conn-1", "INSERT ...")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_db_failure(self):
|
|
"""DbExecutor path: failed execution with error."""
|
|
from src.core.db_executor import DbExecutor, DbExecutionResult
|
|
executor = MagicMock(spec=DbExecutor)
|
|
executor.execute_sql = AsyncMock(return_value=DbExecutionResult(
|
|
success=False, error="Connection refused"
|
|
))
|
|
await _execute_insert_sql(executor, "INSERT ...", "batch-1", 5, connection_id="conn-1")
|
|
executor.execute_sql.assert_called_once_with("conn-1", "INSERT ...")
|
|
|
|
|
|
class TestResolveInsertBackend:
|
|
"""_resolve_insert_backend — resolve backend dialect and executor."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_success(self):
|
|
"""Returns dialect and executor."""
|
|
config_manager = MagicMock()
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.environment_id = "env-1"
|
|
job.source_dialect = None
|
|
job.target_database_id = None
|
|
job.database_dialect = None
|
|
job.target_dialect = None
|
|
|
|
with patch("src.plugins.translate._batch_insert.SupersetSqlLabExecutor") as mock_exec_cls:
|
|
mock_exec = MagicMock()
|
|
mock_exec.get_database_backend.return_value = "postgresql"
|
|
mock_exec.resolve_database_id = AsyncMock()
|
|
mock_exec_cls.return_value = mock_exec
|
|
|
|
dialect, executor, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
|
|
assert dialect == "postgresql"
|
|
assert executor is not None
|
|
assert connection_id is None # sqllab path has no connection_id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failure_returns_none(self):
|
|
"""Exception in resolve returns (None, None, None)."""
|
|
config_manager = MagicMock()
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.environment_id = None
|
|
job.source_dialect = None
|
|
|
|
with patch("src.plugins.translate._batch_insert.SupersetSqlLabExecutor",
|
|
side_effect=Exception("Failed")):
|
|
dialect, executor, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
|
|
assert dialect is None
|
|
assert executor is None
|
|
assert connection_id is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_dialect(self):
|
|
"""Falls back to job.database_dialect or target_dialect."""
|
|
config_manager = MagicMock()
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.environment_id = None
|
|
job.source_dialect = None
|
|
job.target_database_id = None
|
|
job.database_dialect = "mysql"
|
|
job.target_dialect = None
|
|
|
|
with patch("src.plugins.translate._batch_insert.SupersetSqlLabExecutor") as mock_exec_cls:
|
|
mock_exec = MagicMock()
|
|
mock_exec.get_database_backend.return_value = None
|
|
mock_exec.resolve_database_id = AsyncMock()
|
|
mock_exec_cls.return_value = mock_exec
|
|
|
|
dialect, _, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
|
|
assert dialect == "mysql"
|
|
assert connection_id is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_db_path(self):
|
|
"""direct_db path returns DbExecutor with connection_id."""
|
|
config_manager = MagicMock()
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.insert_method = "direct_db"
|
|
job.connection_id = "clickhouse-conn-1"
|
|
|
|
with patch("src.plugins.translate._batch_insert.ConnectionService") as mock_cs_cls:
|
|
mock_cs = MagicMock()
|
|
mock_conn = MagicMock()
|
|
mock_conn.dialect = "clickhouse"
|
|
mock_conn.name = "Test ClickHouse"
|
|
mock_cs.get_connection.return_value = mock_conn
|
|
mock_cs_cls.return_value = mock_cs
|
|
|
|
dialect, executor, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
|
|
assert dialect == "clickhouse"
|
|
assert connection_id == "clickhouse-conn-1"
|
|
# Should return DbExecutor, not SupersetSqlLabExecutor
|
|
from src.core.db_executor import DbExecutor
|
|
assert isinstance(executor, DbExecutor)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_db_missing_conn_raises(self):
|
|
"""When direct_db connection not found, raises ValueError (no fallback)."""
|
|
config_manager = MagicMock()
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.insert_method = "direct_db"
|
|
job.connection_id = "missing-conn"
|
|
|
|
with patch("src.plugins.translate._batch_insert.ConnectionService") as mock_cs_cls:
|
|
mock_cs = MagicMock()
|
|
mock_cs.get_connection.return_value = None
|
|
mock_cs_cls.return_value = mock_cs
|
|
|
|
with pytest.raises(ValueError, match="connection_id='missing-conn' not found"):
|
|
await _resolve_insert_backend(config_manager, job, "batch-1")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_db_no_connection_id_raises(self):
|
|
"""When direct_db is set but connection_id is None, raises ValueError (no fallback)."""
|
|
config_manager = MagicMock()
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.insert_method = "direct_db"
|
|
job.connection_id = None
|
|
|
|
with pytest.raises(ValueError, match="connection_id is missing"):
|
|
await _resolve_insert_backend(config_manager, job, "batch-1")
|
|
|
|
|
|
class TestInsertBatchToTarget:
|
|
"""insert_batch_to_target — main integration function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_batch_returns_early(self, db_session):
|
|
"""Empty batch returns without error."""
|
|
config_manager = MagicMock()
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_column = None
|
|
job.translation_column = "text"
|
|
job.target_languages = None
|
|
|
|
with patch("src.plugins.translate._batch_insert._fetch_batch_records", return_value=[]):
|
|
result = await insert_batch_to_target(db_session, config_manager, job, "batch-1", "run-1")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_columns_fallback(self, db_session):
|
|
"""When columns empty, falls back to single column."""
|
|
config_manager = MagicMock()
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_column = None
|
|
job.translation_column = None
|
|
job.target_languages = None
|
|
job.target_schema = None
|
|
job.target_table = None
|
|
job.target_key_cols = None
|
|
job.upsert_strategy = "MERGE"
|
|
job.environment_id = None
|
|
job.source_dialect = None
|
|
job.target_database_id = None
|
|
job.database_dialect = "postgresql"
|
|
job.target_dialect = None
|
|
|
|
rec = MagicMock(spec=TranslationRecord)
|
|
rec.target_sql = "bonjour"
|
|
|
|
with patch("src.plugins.translate._batch_insert._fetch_batch_records", return_value=[rec]):
|
|
with patch("src.plugins.translate._batch_insert._resolve_insert_backend",
|
|
return_value=("postgresql", AsyncMock(), None)):
|
|
with patch("src.plugins.translate._batch_insert.SQLGenerator.generate_batch",
|
|
return_value=[("INSERT INTO ...", 1)]):
|
|
with patch("src.plugins.translate._batch_insert._execute_insert_sql",
|
|
AsyncMock()):
|
|
result = await insert_batch_to_target(
|
|
db_session, config_manager, job, "batch-1", "run-1"
|
|
)
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_backend_resolve_none_returns_early(self, db_session):
|
|
"""When _resolve_insert_backend returns None, returns early."""
|
|
config_manager = MagicMock()
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.target_column = "text"
|
|
job.translation_column = None
|
|
job.target_languages = []
|
|
|
|
rec = MagicMock(spec=TranslationRecord)
|
|
rec.target_sql = "bonjour"
|
|
rec.source_sql = "hello"
|
|
rec.source_data = {}
|
|
rec.languages = []
|
|
|
|
with patch("src.plugins.translate._batch_insert._fetch_batch_records", return_value=[rec]):
|
|
with patch("src.plugins.translate._batch_insert._build_target_columns", return_value=["text"]):
|
|
with patch("src.plugins.translate._batch_insert._build_context_keys", return_value=[]):
|
|
with patch("src.plugins.translate._batch_insert._build_insert_rows",
|
|
return_value=[{"text": "bonjour"}]):
|
|
with patch("src.plugins.translate._batch_insert._resolve_insert_backend",
|
|
return_value=(None, None, None)):
|
|
result = await insert_batch_to_target(
|
|
db_session, config_manager, job, "batch-1", "run-1"
|
|
)
|
|
assert result is None
|
|
# #endregion Test.BatchInsert
|