Files
ss-tools/backend/tests/integration/test_translate_service_integration.py
busya 143f14d516 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
2026-06-10 15:06:36 +03:00

563 lines
22 KiB
Python

# #region TranslateServiceIntegrationTests [C:4] [TYPE Module] [SEMANTICS test, integration, translate, service, postgres, testcontainers]
# @BRIEF Integration tests for TranslateJobService with real PostgreSQL via Testcontainers.
# @RELATION BINDS_TO -> [TranslateJobService]
# @RELATION BINDS_TO -> [TranslationJob]
# @RELATION BINDS_TO -> [TranslationJobDictionary]
# @TEST_CONTRACT TranslateJobService ->
# {
# invariants: [
# "create_job persists job with all fields",
# "update_job modifies only specified fields",
# "delete_job cascades to dictionary associations",
# "duplicate_job copies all config with new ID",
# "list_jobs respects pagination and status filter",
# "JSON columns (source_key_cols, target_languages) round-trip correctly"
# ]
# }
# @TEST_EDGE missing_translation_column -> ValueError when datasource set but no column
# @TEST_EDGE invalid_upsert_strategy -> ValueError on unsupported strategy
# @TEST_EDGE invalid_language_code -> ValueError on non-BCP47 language
# @TEST_EDGE job_not_found -> ValueError on get/update/delete of non-existent job
# @TEST_EDGE cascade_delete -> dictionary associations removed when job deleted
# @TEST_INVARIANT json_roundtrip -> VERIFIED_BY: [test_create_job_json_columns_persist]
# @TEST_INVARIANT cascade_integrity -> VERIFIED_BY: [test_delete_job_cascades_dictionary_associations]
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import pytest
from sqlalchemy.orm import Session
from unittest.mock import MagicMock, AsyncMock, patch
from src.models.translate import (
TerminologyDictionary,
TranslationJob,
TranslationJobDictionary,
)
from src.plugins.translate.service import TranslateJobService
from src.schemas.translate import TranslateJobCreate, TranslateJobUpdate
# #region TestTranslateJobServiceCRUD [C:3] [TYPE Class]
# @BRIEF Integration tests for job CRUD operations with real PostgreSQL.
class TestTranslateJobServiceCRUD:
"""Verify TranslateJobService CRUD with real PostgreSQL."""
# region test_create_job_persists_all_fields [C:2] [TYPE Function]
# @BRIEF Verify job creation persists all fields including JSON columns.
@pytest.mark.asyncio
async def test_create_job_persists_all_fields(
self, db_session: Session, mock_config_manager: MagicMock
):
service = TranslateJobService(db_session, mock_config_manager, "test_user")
payload = TranslateJobCreate(
name="Integration Test Job",
description="Testing real PostgreSQL persistence",
source_dialect="postgresql",
target_dialect="clickhouse",
source_key_cols=["id", "tenant_id"],
target_key_cols=["id", "tenant_id"],
translation_column="name",
context_columns=["description", "category"],
target_languages=["ru", "en", "de"],
batch_size=100,
upsert_strategy="MERGE",
)
job = await service.create_job(payload)
assert job.id is not None
assert job.name == "Integration Test Job"
assert job.source_dialect == "postgresql"
assert job.target_dialect == "clickhouse"
assert job.status == "DRAFT"
assert job.created_by == "test_user"
# Verify JSON columns round-trip correctly
assert job.source_key_cols == ["id", "tenant_id"]
assert job.target_key_cols == ["id", "tenant_id"]
assert job.context_columns == ["description", "category"]
assert job.target_languages == ["ru", "en", "de"]
assert job.batch_size == 100
assert job.upsert_strategy == "MERGE"
# Verify persisted in DB
fetched = db_session.query(TranslationJob).filter(TranslationJob.id == job.id).first()
assert fetched is not None
assert fetched.name == "Integration Test Job"
assert fetched.target_languages == ["ru", "en", "de"]
# endregion test_create_job_persists_all_fields
# region test_create_job_with_dictionary_associations [C:2] [TYPE Function]
# @BRIEF Verify job creation with dictionary IDs creates associations.
@pytest.mark.asyncio
async def test_create_job_with_dictionary_associations(
self, db_session: Session, mock_config_manager: MagicMock
):
# Create dictionaries first
dict1 = TerminologyDictionary(name="Dict 1", created_by="test_user")
dict2 = TerminologyDictionary(name="Dict 2", created_by="test_user")
db_session.add_all([dict1, dict2])
db_session.commit()
service = TranslateJobService(db_session, mock_config_manager, "test_user")
payload = TranslateJobCreate(
name="Job With Dicts",
source_dialect="postgresql",
target_dialect="clickhouse",
translation_column="name",
batch_size=50,
upsert_strategy="MERGE",
dictionary_ids=[dict1.id, dict2.id],
)
job = await service.create_job(payload)
# Verify associations
associations = (
db_session.query(TranslationJobDictionary)
.filter(TranslationJobDictionary.job_id == job.id)
.all()
)
assert len(associations) == 2
dict_ids = {a.dictionary_id for a in associations}
assert dict_ids == {dict1.id, dict2.id}
# endregion test_create_job_with_dictionary_associations
# region test_create_job_missing_translation_column_raises [C:2] [TYPE Function]
# @BRIEF Verify ValueError when datasource set but no translation column.
@pytest.mark.asyncio
async def test_create_job_missing_translation_column_raises(
self, db_session: Session, mock_config_manager: MagicMock
):
service = TranslateJobService(db_session, mock_config_manager, "test_user")
payload = TranslateJobCreate(
name="Bad Job",
source_dialect="postgresql",
target_dialect="clickhouse",
source_datasource_id="42", # Datasource set
# translation_column missing!
batch_size=50,
upsert_strategy="MERGE",
)
with pytest.raises(ValueError, match="translation column is required"):
await service.create_job(payload)
# endregion test_create_job_missing_translation_column_raises
# region test_create_job_invalid_upsert_strategy_raises [C:2] [TYPE Function]
# @BRIEF Verify ValueError on unsupported upsert strategy.
@pytest.mark.asyncio
async def test_create_job_invalid_upsert_strategy_raises(
self, db_session: Session, mock_config_manager: MagicMock
):
service = TranslateJobService(db_session, mock_config_manager, "test_user")
payload = TranslateJobCreate(
name="Bad Strategy",
source_dialect="postgresql",
target_dialect="clickhouse",
translation_column="name",
batch_size=50,
upsert_strategy="INVALID_STRATEGY",
)
with pytest.raises(ValueError, match="Invalid upsert_strategy"):
await service.create_job(payload)
# endregion test_create_job_invalid_upsert_strategy_raises
# region test_create_job_invalid_language_raises [C:2] [TYPE Function]
# @BRIEF Verify ValueError on non-BCP47 language code.
@pytest.mark.asyncio
async def test_create_job_invalid_language_raises(
self, db_session: Session, mock_config_manager: MagicMock
):
from pydantic import ValidationError
# Pydantic validates at schema level, so we catch ValidationError
with pytest.raises(ValidationError, match="target_languages"):
TranslateJobCreate(
name="Bad Language",
source_dialect="postgresql",
target_dialect="clickhouse",
translation_column="name",
batch_size=50,
upsert_strategy="MERGE",
target_languages=["123-invalid"], # Starts with digits, invalid BCP47
)
# endregion test_create_job_invalid_language_raises
# region test_get_job_not_found_raises [C:2] [TYPE Function]
# @BRIEF Verify ValueError when job not found.
def test_get_job_not_found_raises(
self, db_session: Session, mock_config_manager: MagicMock
):
service = TranslateJobService(db_session, mock_config_manager, "test_user")
with pytest.raises(ValueError, match="not found"):
service.get_job("non-existent-job-id")
# endregion test_get_job_not_found_raises
# region test_update_job_modifies_specified_fields [C:2] [TYPE Function]
# @BRIEF Verify update only modifies specified fields.
@pytest.mark.asyncio
async def test_update_job_modifies_specified_fields(
self, db_session: Session, mock_config_manager: MagicMock
):
service = TranslateJobService(db_session, mock_config_manager, "test_user")
# Create initial job
create_payload = TranslateJobCreate(
name="Original Name",
source_dialect="postgresql",
target_dialect="clickhouse",
translation_column="name",
batch_size=50,
upsert_strategy="MERGE",
target_languages=["ru"],
)
job = await service.create_job(create_payload)
original_id = job.id
original_batch_size = job.batch_size
# Update only name and batch_size
update_payload = TranslateJobUpdate(
name="Updated Name",
batch_size=200,
)
updated_job = await service.update_job(original_id, update_payload)
assert updated_job.id == original_id
assert updated_job.name == "Updated Name"
assert updated_job.batch_size == 200
# Unchanged fields
assert updated_job.source_dialect == "postgresql"
assert updated_job.target_dialect == "clickhouse"
assert updated_job.target_languages == ["ru"]
# endregion test_update_job_modifies_specified_fields
# region test_update_job_dictionary_replacement [C:2] [TYPE Function]
# @BRIEF Verify updating dictionary_ids replaces associations.
@pytest.mark.asyncio
async def test_update_job_dictionary_replacement(
self, db_session: Session, mock_config_manager: MagicMock
):
# Create dictionaries
dict1 = TerminologyDictionary(name="Dict 1", created_by="test_user")
dict2 = TerminologyDictionary(name="Dict 2", created_by="test_user")
dict3 = TerminologyDictionary(name="Dict 3", created_by="test_user")
db_session.add_all([dict1, dict2, dict3])
db_session.commit()
service = TranslateJobService(db_session, mock_config_manager, "test_user")
# Create job with dict1
create_payload = TranslateJobCreate(
name="Job",
source_dialect="postgresql",
target_dialect="clickhouse",
translation_column="name",
batch_size=50,
upsert_strategy="MERGE",
dictionary_ids=[dict1.id],
)
job = await service.create_job(create_payload)
# Update to dict2 and dict3 (replacing dict1)
update_payload = TranslateJobUpdate(dictionary_ids=[dict2.id, dict3.id])
await service.update_job(job.id, update_payload)
# Verify associations replaced
dict_ids = service.get_job_dictionary_ids(job.id)
assert set(dict_ids) == {dict2.id, dict3.id}
assert dict1.id not in dict_ids
# endregion test_update_job_dictionary_replacement
# region test_delete_job_cascades_dictionary_associations [C:2] [TYPE Function]
# @BRIEF Verify deleting job removes dictionary associations.
@pytest.mark.asyncio
async def test_delete_job_cascades_dictionary_associations(
self, db_session: Session, mock_config_manager: MagicMock
):
# Create dictionary and job with association
dictionary = TerminologyDictionary(name="Test Dict", created_by="test_user")
db_session.add(dictionary)
db_session.commit()
service = TranslateJobService(db_session, mock_config_manager, "test_user")
create_payload = TranslateJobCreate(
name="To Delete",
source_dialect="postgresql",
target_dialect="clickhouse",
translation_column="name",
batch_size=50,
upsert_strategy="MERGE",
dictionary_ids=[dictionary.id],
)
job = await service.create_job(create_payload)
job_id = job.id
# Verify association exists
assert len(service.get_job_dictionary_ids(job_id)) == 1
# Delete job
service.delete_job(job_id)
# Verify job deleted
with pytest.raises(ValueError, match="not found"):
service.get_job(job_id)
# Verify associations removed
remaining = (
db_session.query(TranslationJobDictionary)
.filter(TranslationJobDictionary.job_id == job_id)
.all()
)
assert len(remaining) == 0
# endregion test_delete_job_cascades_dictionary_associations
# region test_duplicate_job_copies_all_config [C:2] [TYPE Function]
# @BRIEF Verify duplicate copies all config with new ID and DRAFT status.
@pytest.mark.asyncio
async def test_duplicate_job_copies_all_config(
self, db_session: Session, mock_config_manager: MagicMock
):
# Create dictionary
dictionary = TerminologyDictionary(name="Test Dict", created_by="test_user")
db_session.add(dictionary)
db_session.commit()
service = TranslateJobService(db_session, mock_config_manager, "test_user")
create_payload = TranslateJobCreate(
name="Original Job",
description="Original description",
source_dialect="postgresql",
target_dialect="clickhouse",
source_key_cols=["id"],
target_key_cols=["id"],
translation_column="name",
context_columns=["category"],
target_languages=["ru", "en"],
batch_size=100,
upsert_strategy="MERGE",
dictionary_ids=[dictionary.id],
)
original = await service.create_job(create_payload)
# Duplicate
duplicate = service.duplicate_job(original.id, "Duplicated Job")
# Verify new ID and name
assert duplicate.id != original.id
assert duplicate.name == "Duplicated Job"
assert duplicate.status == "DRAFT"
# Verify all config copied
assert duplicate.description == original.description
assert duplicate.source_dialect == original.source_dialect
assert duplicate.target_dialect == original.target_dialect
assert duplicate.source_key_cols == original.source_key_cols
assert duplicate.target_key_cols == original.target_key_cols
assert duplicate.translation_column == original.translation_column
assert duplicate.context_columns == original.context_columns
assert duplicate.target_languages == original.target_languages
assert duplicate.batch_size == original.batch_size
assert duplicate.upsert_strategy == original.upsert_strategy
# Verify dictionary associations copied
original_dict_ids = set(service.get_job_dictionary_ids(original.id))
duplicate_dict_ids = set(service.get_job_dictionary_ids(duplicate.id))
assert original_dict_ids == duplicate_dict_ids
# endregion test_duplicate_job_copies_all_config
# region test_list_jobs_pagination [C:2] [TYPE Function]
# @BRIEF Verify list_jobs respects pagination.
@pytest.mark.asyncio
async def test_list_jobs_pagination(
self, db_session: Session, mock_config_manager: MagicMock
):
service = TranslateJobService(db_session, mock_config_manager, "test_user")
# Create 5 jobs
for i in range(5):
payload = TranslateJobCreate(
name=f"Job {i}",
source_dialect="postgresql",
target_dialect="clickhouse",
translation_column="name",
batch_size=50,
upsert_strategy="MERGE",
)
await service.create_job(payload)
# Test pagination
total, page1 = service.list_jobs(page=1, page_size=2)
assert total == 5
assert len(page1) == 2
total, page2 = service.list_jobs(page=2, page_size=2)
assert total == 5
assert len(page2) == 2
total, page3 = service.list_jobs(page=3, page_size=2)
assert total == 5
assert len(page3) == 1
# endregion test_list_jobs_pagination
# region test_list_jobs_status_filter [C:2] [TYPE Function]
# @BRIEF Verify list_jobs filters by status.
@pytest.mark.asyncio
async def test_list_jobs_status_filter(
self, db_session: Session, mock_config_manager: MagicMock
):
service = TranslateJobService(db_session, mock_config_manager, "test_user")
# Create jobs with different statuses
for i in range(3):
payload = TranslateJobCreate(
name=f"Draft Job {i}",
source_dialect="postgresql",
target_dialect="clickhouse",
translation_column="name",
batch_size=50,
upsert_strategy="MERGE",
)
job = await service.create_job(payload)
# Jobs are created as DRAFT
# Create a READY job
ready_payload = TranslateJobCreate(
name="Ready Job",
source_dialect="postgresql",
target_dialect="clickhouse",
translation_column="name",
batch_size=50,
upsert_strategy="MERGE",
)
ready_job = await service.create_job(ready_payload)
ready_job.status = "READY"
db_session.commit()
# Filter by DRAFT
total, draft_jobs = service.list_jobs(status_filter="DRAFT")
assert total == 3
assert all(j.status == "DRAFT" for j in draft_jobs)
# Filter by READY
total, ready_jobs = service.list_jobs(status_filter="READY")
assert total == 1
assert ready_jobs[0].status == "READY"
# endregion test_list_jobs_status_filter
# #endregion TestTranslateJobServiceCRUD
# #region TestTranslateJobServiceJSONColumns [C:3] [TYPE Class]
# @BRIEF Integration tests for JSON column behavior with real PostgreSQL.
class TestTranslateJobServiceJSONColumns:
"""Verify JSON column round-trip with PostgreSQL JSONB."""
# region test_json_columns_empty_arrays [C:2] [TYPE Function]
# @BRIEF Verify empty arrays persist correctly in JSON columns.
@pytest.mark.asyncio
async def test_json_columns_empty_arrays(
self, db_session: Session, mock_config_manager: MagicMock
):
service = TranslateJobService(db_session, mock_config_manager, "test_user")
payload = TranslateJobCreate(
name="Empty Arrays Job",
source_dialect="postgresql",
target_dialect="clickhouse",
translation_column="name",
source_key_cols=[],
target_key_cols=[],
context_columns=[],
batch_size=50,
upsert_strategy="MERGE",
)
job = await service.create_job(payload)
# Verify empty arrays persist
assert job.source_key_cols == []
assert job.target_key_cols == []
assert job.context_columns == []
# Re-fetch from DB
fetched = service.get_job(job.id)
assert fetched.source_key_cols == []
assert fetched.target_key_cols == []
assert fetched.context_columns == []
# endregion test_json_columns_empty_arrays
# region test_json_columns_nested_objects [C:2] [TYPE Function]
# @BRIEF Verify complex JSON structures persist correctly.
@pytest.mark.asyncio
async def test_json_columns_nested_objects(
self, db_session: Session, mock_config_manager: MagicMock
):
service = TranslateJobService(db_session, mock_config_manager, "test_user")
payload = TranslateJobCreate(
name="Complex JSON Job",
source_dialect="postgresql",
target_dialect="clickhouse",
translation_column="name",
source_key_cols=["id", "tenant_id", "region"],
target_key_cols=["id", "tenant_id", "region"],
context_columns=["description", "category", "metadata.tags"],
target_languages=["ru", "en", "de", "fr", "es"],
batch_size=50,
upsert_strategy="MERGE",
)
job = await service.create_job(payload)
# Verify complex arrays persist
assert job.source_key_cols == ["id", "tenant_id", "region"]
assert job.target_key_cols == ["id", "tenant_id", "region"]
assert job.context_columns == ["description", "category", "metadata.tags"]
assert job.target_languages == ["ru", "en", "de", "fr", "es"]
# Re-fetch and verify
fetched = service.get_job(job.id)
assert fetched.source_key_cols == ["id", "tenant_id", "region"]
assert fetched.target_languages == ["ru", "en", "de", "fr", "es"]
# endregion test_json_columns_nested_objects
# region test_json_columns_null_handling [C:2] [TYPE Function]
# @BRIEF Verify NULL JSON columns handled correctly.
@pytest.mark.asyncio
async def test_json_columns_null_handling(
self, db_session: Session, mock_config_manager: MagicMock
):
service = TranslateJobService(db_session, mock_config_manager, "test_user")
payload = TranslateJobCreate(
name="Null JSON Job",
source_dialect="postgresql",
target_dialect="clickhouse",
translation_column="name",
batch_size=50,
upsert_strategy="MERGE",
# No source_key_cols, target_key_cols, context_columns, target_languages
)
job = await service.create_job(payload)
# Verify NULL handling
assert job.source_key_cols is None or job.source_key_cols == []
assert job.target_key_cols is None or job.target_key_cols == []
assert job.context_columns is None or job.context_columns == []
assert job.target_languages is None or job.target_languages == []
# endregion test_json_columns_null_handling
# #endregion TestTranslateJobServiceJSONColumns
# #endregion TranslateServiceIntegrationTests