Files
ss-tools/backend/tests/schemas/test_translate.py
busya 488a8f349b test(backend): raise coverage to 95%+ statements and branches (97.8%/95.0%)
- ~60 new/extended test files across api, core, plugins, services, schemas:
  routes, superset clients, task_manager, lineage, git, translate,
  dashboard-testing, load-testing, migration, llm_analysis, scheduler, ssl
- .coveragerc: enable branch coverage; exclude src/__tests__ (test files)
  and src/scripts (CLI/ops tools) from the denominator
- bug fixes found while testing:
  * settings: PUT /settings/reports registered under duplicated prefix
  * schemas/lineage: FleetReportDTO missing run_status (route always 500)
  * dashboard_testing/baseline_inheritance: visual entry read wrong field
  * superset_client/_databases: logger extra name shadowed LogRecord attr
  * routes/datasets: _yaml_string_paths recursion without yield from
  * translate/sql_generator: restore explicit-type timestamp contract
  * baseline_catalog: remove unreachable dashboard_id fallback
- conftest fixes: pytest_plugins to rootdir conftest (pytest 9), test
  filename collision, TMPDIR-safe integration fixtures
2026-08-19 17:14:32 +03:00

375 lines
15 KiB
Python

# #region Test.Schemas.Translate [C:3] [TYPE Module] [SEMANTICS test,schema,translate,validation,bcp47]
# @BRIEF Tests for schemas/translate.py — BCP-47 target-language validator and the
# model-level validators on TranslateJobCreate / TranslateJobUpdate /
# BulkFindReplaceRequest / TargetSchemaValidationRequest.
# @RELATION BINDS_TO -> [Schemas.Translate.TranslateSchemas]
# @TEST_EDGE: missing_field -> optional fields default correctly
# @TEST_EDGE: invalid_type -> BCP-47 / enum / length violations raise ValidationError
# @TEST_EDGE: conditional_required -> direct_db requires connection_id; sqllab requires target_database_id
# @TEST_EDGE: cross_field -> source_key_cols/target_key_cols length mismatch rejected
# @TEST_EDGE: constraint_floor -> numeric performance knobs reject out-of-range values
import pytest
from pydantic import ValidationError
from src.schemas.translate import (
BulkFindReplaceRequest,
TargetSchemaValidationRequest,
TranslateJobCreate,
TranslateJobUpdate,
_validate_bcp47_list,
)
class TestBcp47List:
"""_validate_bcp47_list — raw BCP-47 tag list validator."""
def test_none_returns_none(self):
assert _validate_bcp47_list(None) is None
def test_empty_list_returns_empty(self):
assert _validate_bcp47_list([]) == []
def test_valid_tags_passthrough(self):
tags = ["en", "ru", "zh-CN", "pt-BR"]
assert _validate_bcp47_list(tags) == tags
def test_empty_string_tag_raises(self):
with pytest.raises(ValueError, match="must be a non-empty string"):
_validate_bcp47_list(["en", ""])
def test_whitespace_only_tag_raises(self):
with pytest.raises(ValueError, match="must be a non-empty string"):
_validate_bcp47_list(["en", " "])
def test_invalid_format_raises(self):
with pytest.raises(ValueError, match="Invalid BCP-47 tag"):
_validate_bcp47_list(["en", "not a tag"])
def test_single_char_tag_raises(self):
with pytest.raises(ValueError, match="Invalid BCP-47 tag"):
_validate_bcp47_list(["e"])
class TestTranslateJobCreate:
"""TranslateJobCreate — create payload with conditional model validator."""
def test_minimal_defaults(self):
job = TranslateJobCreate(name="job-1")
assert job.name == "job-1"
assert job.description is None
assert job.source_dialect == ""
assert job.target_dialect == ""
assert job.database_dialect is None
assert job.source_key_cols == []
assert job.target_key_cols == []
assert job.context_columns == []
assert job.target_languages == []
assert job.dictionary_ids == []
assert job.include_source_reference is True
assert job.batch_size == 50
assert job.disable_reasoning is False
assert job.upsert_strategy == "MERGE"
assert job.insert_method == "sqllab"
assert job.connection_id is None
assert job.llm_batch_max_rows is None
assert job.llm_concurrency is None
assert job.insert_concurrency is None
assert job.max_in_flight_batches is None
def test_full_valid(self):
job = TranslateJobCreate(
name="job-1",
description="desc",
source_dialect="postgresql",
target_dialect="clickhouse",
database_dialect="postgresql",
source_key_cols=["id"],
target_key_cols=["id"],
target_languages=["en", "zh-CN"],
multi_lang_mode="per_language",
batch_aggressiveness="fast",
insert_method="direct_db",
connection_id="conn-1",
llm_batch_max_rows=25,
llm_concurrency=8,
insert_concurrency=2,
max_in_flight_batches=16,
include_source_reference=False,
)
assert job.connection_id == "conn-1"
assert job.llm_batch_max_rows == 25
assert job.include_source_reference is False
def test_direct_db_requires_connection_id(self):
with pytest.raises(ValidationError, match="connection_id is required"):
TranslateJobCreate(name="job-1", insert_method="direct_db")
def test_direct_db_with_connection_ok(self):
job = TranslateJobCreate(name="job-1", insert_method="direct_db", connection_id="conn-1")
assert job.insert_method == "direct_db"
def test_sqllab_without_connection_ok(self):
job = TranslateJobCreate(name="job-1", insert_method="sqllab")
assert job.connection_id is None
def test_key_cols_length_mismatch_raises(self):
with pytest.raises(ValidationError, match="must have equal lengths"):
TranslateJobCreate(
name="job-1",
source_key_cols=["id"],
target_key_cols=["id", "name"],
)
def test_key_cols_equal_length_ok(self):
job = TranslateJobCreate(
name="job-1",
source_key_cols=["id", "ts"],
target_key_cols=["id", "ts"],
)
assert job.source_key_cols == ["id", "ts"]
def test_invalid_multi_lang_mode_raises(self):
with pytest.raises(ValidationError, match="single_call"):
TranslateJobCreate(name="job-1", multi_lang_mode="parallel")
def test_valid_multi_lang_modes_ok(self):
for mode in ("single_call", "per_language"):
job = TranslateJobCreate(name="job-1", multi_lang_mode=mode)
assert job.multi_lang_mode == mode
def test_invalid_batch_aggressiveness_raises(self):
with pytest.raises(ValidationError, match="safe"):
TranslateJobCreate(name="job-1", batch_aggressiveness="max")
def test_valid_batch_aggressiveness_ok(self):
for level in ("safe", "balanced", "fast"):
job = TranslateJobCreate(name="job-1", batch_aggressiveness=level)
assert job.batch_aggressiveness == level
def test_invalid_target_languages_raises(self):
with pytest.raises(ValidationError, match="Invalid BCP-47 tag"):
TranslateJobCreate(name="job-1", target_languages=["en", "bad tag"])
def test_valid_target_languages_ok(self):
job = TranslateJobCreate(name="job-1", target_languages=["en", "pt-BR"])
assert job.target_languages == ["en", "pt-BR"]
def test_llm_batch_max_rows_bounds(self):
with pytest.raises(ValidationError):
TranslateJobCreate(name="job-1", llm_batch_max_rows=0)
with pytest.raises(ValidationError):
TranslateJobCreate(name="job-1", llm_batch_max_rows=51)
assert TranslateJobCreate(name="job-1", llm_batch_max_rows=50).llm_batch_max_rows == 50
def test_llm_concurrency_bounds(self):
with pytest.raises(ValidationError):
TranslateJobCreate(name="job-1", llm_concurrency=17)
assert TranslateJobCreate(name="job-1", llm_concurrency=16).llm_concurrency == 16
def test_insert_concurrency_bounds(self):
with pytest.raises(ValidationError):
TranslateJobCreate(name="job-1", insert_concurrency=5)
assert TranslateJobCreate(name="job-1", insert_concurrency=4).insert_concurrency == 4
def test_max_in_flight_batches_bounds(self):
with pytest.raises(ValidationError):
TranslateJobCreate(name="job-1", max_in_flight_batches=3)
assert TranslateJobCreate(name="job-1", max_in_flight_batches=128).max_in_flight_batches == 128
def test_serialize_roundtrip(self):
job = TranslateJobCreate(name="job-1", target_languages=["en"])
data = job.model_dump()
restored = TranslateJobCreate.model_validate(data)
assert restored.name == "job-1"
assert restored.target_languages == ["en"]
class TestTranslateJobUpdate:
"""TranslateJobUpdate — partial update with enum + cross-field validation."""
def test_empty_all_optional(self):
update = TranslateJobUpdate()
assert update.name is None
assert update.source_key_cols is None
assert update.target_key_cols is None
assert update.multi_lang_mode is None
assert update.batch_aggressiveness is None
assert update.insert_method is None
assert update.status is None
def test_partial_fields(self):
update = TranslateJobUpdate(name="renamed", status="RUNNING", upsert_strategy="INSERT")
assert update.name == "renamed"
assert update.status == "RUNNING"
assert update.upsert_strategy == "INSERT"
def test_key_cols_length_mismatch_raises(self):
with pytest.raises(ValidationError, match="must have equal lengths"):
TranslateJobUpdate(source_key_cols=["id"], target_key_cols=["id", "name"])
def test_key_cols_equal_length_ok(self):
update = TranslateJobUpdate(source_key_cols=["id"], target_key_cols=["id"])
assert update.source_key_cols == ["id"]
def test_invalid_multi_lang_mode_raises(self):
with pytest.raises(ValidationError, match="single_call"):
TranslateJobUpdate(multi_lang_mode="parallel")
def test_valid_multi_lang_mode_ok(self):
update = TranslateJobUpdate(multi_lang_mode="per_language")
assert update.multi_lang_mode == "per_language"
def test_invalid_batch_aggressiveness_raises(self):
with pytest.raises(ValidationError, match="safe"):
TranslateJobUpdate(batch_aggressiveness="max")
def test_valid_batch_aggressiveness_ok(self):
update = TranslateJobUpdate(batch_aggressiveness="balanced")
assert update.batch_aggressiveness == "balanced"
def test_invalid_target_languages_raises(self):
with pytest.raises(ValidationError, match="Invalid BCP-47 tag"):
TranslateJobUpdate(target_languages=["en", "bad tag"])
def test_valid_target_languages_ok(self):
update = TranslateJobUpdate(target_languages=["ru"])
assert update.target_languages == ["ru"]
def test_knob_bounds(self):
with pytest.raises(ValidationError):
TranslateJobUpdate(llm_batch_max_rows=0)
with pytest.raises(ValidationError):
TranslateJobUpdate(llm_concurrency=17)
with pytest.raises(ValidationError):
TranslateJobUpdate(max_in_flight_batches=3)
update = TranslateJobUpdate(llm_batch_max_rows=10, llm_concurrency=4)
assert update.llm_batch_max_rows == 10
def test_serialize_roundtrip(self):
update = TranslateJobUpdate(name="new", batch_aggressiveness="fast")
data = update.model_dump()
restored = TranslateJobUpdate.model_validate(data)
assert restored.name == "new"
assert restored.batch_aggressiveness == "fast"
class TestBulkFindReplaceRequest:
"""BulkFindReplaceRequest — find-pattern length guard."""
def test_minimal_valid(self):
req = BulkFindReplaceRequest(find_pattern="old", replacement_text="new", target_language="en")
assert req.is_regex is False
assert req.preview is True
assert req.submit_to_dictionary is False
assert req.submit_to_dictionary_with_context is False
assert req.dictionary_id is None
assert req.usage_notes is None
def test_exactly_500_chars_ok(self):
req = BulkFindReplaceRequest(find_pattern="x" * 500, replacement_text="new", target_language="en")
assert len(req.find_pattern) == 500
def test_over_500_chars_rejected_by_constraint(self):
# pydantic max_length=500 fires before the custom validator, so the
# rejection surfaces as the field constraint error.
with pytest.raises(ValidationError, match="at most 500"):
BulkFindReplaceRequest(find_pattern="x" * 501, replacement_text="new", target_language="en")
def test_flags_and_optional_fields(self):
req = BulkFindReplaceRequest(
find_pattern=r"^old$",
is_regex=True,
replacement_text="new",
target_language="en",
preview=False,
submit_to_dictionary=True,
dictionary_id="dict-1",
usage_notes="note",
submit_to_dictionary_with_context=True,
)
assert req.is_regex is True
assert req.preview is False
assert req.submit_to_dictionary is True
assert req.dictionary_id == "dict-1"
assert req.submit_to_dictionary_with_context is True
def test_serialize_roundtrip(self):
req = BulkFindReplaceRequest(find_pattern="old", replacement_text="new", target_language="en")
data = req.model_dump()
restored = BulkFindReplaceRequest.model_validate(data)
assert restored.find_pattern == "old"
assert restored.target_language == "en"
class TestTargetSchemaValidationRequest:
"""TargetSchemaValidationRequest — conditional backend/connection requirement."""
def test_sqllab_with_database_ok(self):
req = TargetSchemaValidationRequest(
environment_id="env-1",
target_database_id="db-1",
target_table="my_table",
)
assert req.target_schema == "public"
assert req.target_key_cols == []
assert req.insert_method is None
def test_sqllab_requires_target_database(self):
with pytest.raises(ValidationError, match="target_database_id is required"):
TargetSchemaValidationRequest(
environment_id="env-1",
target_table="my_table",
insert_method="sqllab",
)
def test_default_insert_method_requires_target_database(self):
with pytest.raises(ValidationError, match="target_database_id is required"):
TargetSchemaValidationRequest(environment_id="env-1", target_table="my_table")
def test_direct_db_requires_connection_id(self):
with pytest.raises(ValidationError, match="connection_id is required"):
TargetSchemaValidationRequest(
environment_id="env-1",
target_table="my_table",
insert_method="direct_db",
)
def test_direct_db_with_connection_ok(self):
req = TargetSchemaValidationRequest(
environment_id="env-1",
target_table="my_table",
insert_method="direct_db",
connection_id="conn-1",
)
assert req.connection_id == "conn-1"
assert req.target_database_id is None
def test_column_mapping_fields(self):
req = TargetSchemaValidationRequest(
environment_id="env-1",
target_database_id="db-1",
target_table="t",
target_key_cols=["id"],
target_column="translated",
translation_column="source",
target_language_column="lang",
target_source_column="src_text",
target_source_language_column="src_lang",
)
assert req.target_key_cols == ["id"]
assert req.target_column == "translated"
assert req.target_source_language_column == "src_lang"
def test_serialize_roundtrip(self):
req = TargetSchemaValidationRequest(
environment_id="env-1",
target_database_id="db-1",
target_table="t",
)
data = req.model_dump()
restored = TargetSchemaValidationRequest.model_validate(data)
assert restored.target_table == "t"
assert restored.target_schema == "public"
# #endregion Test.Schemas.Translate