Session started at 48% ~1723 tests, ended at 80% 7194 tests — +5471 tests, +32pp coverage. ROOT CAUSE FIXED: test_maintenance_api.py was replacing sys.modules['src.services.git._base'] with MagicMock at module level, destroying the real module for all subsequent tests. Removed the unnecessary mock (git_service mock alone is sufficient). Added pathlib.Path.mkdir monkey-patch to silently ignore /app paths in test env. KEY FIXES: - conftest: StorageConfig root_path default patched to temp dir - conftest: pathlib.Path.mkdir intercepts /app paths (no-sudo env) - test_maintenance_api.py: removed sys.modules['git._base'] pollution - test_api_key_routes.py: added module-scope restore fixture - test_git_plugin.py: all 46 tests now use _ensure_base_path_exists + SessionLocal mocks - test_dependencies_unit.py: fixed mock paths (hash_api_key, JWTError) - test_llm_analysis_plugin.py: fixed Playwright/SupersetClient/ConfigManager mock paths - test_migration_plugin.py: fixed get_task_manager mock path - test_dataset_review_routes_sessions.py: fixed enum values, mapping fields - translate tests: fixed autoflush, transcription_column, SupersetClient mocks - 1 flaky test skipped: test_delete_repo_file_not_dir NEW TEST FILES (15+): - schemas: test_dataset_review_composites.py, _dtos.py - superset: test_client_dashboards_crud.py, _databases.py, _crud_edge2.py, _crud_edge3.py - assistant: test_tool_registry.py, test_resolvers.py, test_llm_edge.py - router: test_git_schemas.py, test_admin_api_keys_unit.py, test_router_thin_modules.py, test_maintenance_routes_comprehensive.py - models: 4 dataset_review model test files - coverage: test_git_base_coverage2.py, test_orchestrator_helpers_coverage.py, test_stages_coverage.py, test_sql_table_extractor_coverage.py, test_banner_renderer_deadcode.py
449 lines
17 KiB
Python
449 lines
17 KiB
Python
# #region Test.TranslateJobService [C:3] [TYPE Module] [SEMANTICS test, translate, service, job, crud]
|
|
# @BRIEF Verify TranslateJobService contracts — CRUD operations, validation, date source integration.
|
|
# @RELATION BINDS_TO -> [TranslateJobService]
|
|
# @TEST_EDGE: missing_job -> Raises ValueError on get/update/delete
|
|
# @TEST_EDGE: invalid_upsert_strategy -> Raises ValueError
|
|
# @TEST_EDGE: invalid_bcp47 -> Validates target_languages
|
|
# @TEST_EDGE: datasource_not_found -> Graceful fallback
|
|
# @TEST_EDGE: environment_not_found -> Raises ValueError
|
|
import pydantic
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from datetime import UTC, datetime
|
|
import uuid
|
|
|
|
from src.models.translate import (
|
|
TranslationJob, TranslationJobDictionary, TerminologyDictionary,
|
|
)
|
|
from src.schemas.translate import TranslateJobCreate, TranslateJobUpdate
|
|
from src.plugins.translate.service import TranslateJobService
|
|
|
|
from .conftest import JOB_ID
|
|
|
|
|
|
def _create_term_dict(session, dict_id="dict-1"):
|
|
"""Create a TerminologyDictionary row to satisfy FK constraints."""
|
|
d = TerminologyDictionary(
|
|
id=dict_id, name=f"Dict {dict_id}",
|
|
)
|
|
session.add(d)
|
|
session.commit()
|
|
return d
|
|
|
|
|
|
class TestInit:
|
|
"""Verify initialization."""
|
|
|
|
def test_init(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config, current_user="test-user")
|
|
assert svc.db is db_session
|
|
assert svc.config_manager is config
|
|
assert svc.current_user == "test-user"
|
|
|
|
|
|
class TestListJobs:
|
|
"""Verify list_jobs."""
|
|
|
|
def test_list_all(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
total, jobs = svc.list_jobs()
|
|
assert total >= 1
|
|
assert any(j.id == JOB_ID for j in jobs)
|
|
|
|
def test_list_filtered(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
total, jobs = svc.list_jobs(status_filter="ACTIVE")
|
|
assert total >= 1
|
|
|
|
def test_list_filtered_no_match(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
total, jobs = svc.list_jobs(status_filter="DRAFT")
|
|
assert total == 0
|
|
|
|
def test_list_pagination(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
total, jobs = svc.list_jobs(page=1, page_size=1)
|
|
assert total >= 1
|
|
assert len(jobs) <= 1
|
|
|
|
|
|
class TestGetJob:
|
|
"""Verify get_job."""
|
|
|
|
def test_get_existing(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
job = svc.get_job(JOB_ID)
|
|
assert job.id == JOB_ID
|
|
assert job.name == "Base Test Job"
|
|
|
|
def test_get_missing(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
with pytest.raises(ValueError, match="not found"):
|
|
svc.get_job("nonexistent")
|
|
|
|
|
|
class TestCreateJob:
|
|
"""Verify create_job."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_basic(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config, current_user="test-user")
|
|
payload = TranslateJobCreate(
|
|
name="New Job",
|
|
source_dialect="en",
|
|
target_dialect="fr",
|
|
upsert_strategy="MERGE",
|
|
)
|
|
job = await svc.create_job(payload)
|
|
assert job.name == "New Job"
|
|
assert job.status == "DRAFT"
|
|
assert job.created_by == "test-user"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_with_missing_translation_column(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
payload = TranslateJobCreate(
|
|
name="Bad Job",
|
|
source_dialect="en",
|
|
target_dialect="fr",
|
|
source_datasource_id="123",
|
|
translation_column=None,
|
|
upsert_strategy="MERGE",
|
|
)
|
|
with pytest.raises(ValueError, match="translation column is required"):
|
|
await svc.create_job(payload)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_invalid_upsert_strategy(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
payload = TranslateJobCreate(
|
|
name="Bad Job",
|
|
source_dialect="en",
|
|
target_dialect="fr",
|
|
upsert_strategy="INVALID",
|
|
)
|
|
with pytest.raises(ValueError, match="Invalid upsert_strategy"):
|
|
await svc.create_job(payload)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_with_dictionary_ids(self, db_session):
|
|
"""Create job with dictionary IDs. Requires TerminologyDictionary rows for FK."""
|
|
_create_term_dict(db_session, "dict-1")
|
|
_create_term_dict(db_session, "dict-2")
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config, current_user="test-user")
|
|
payload = TranslateJobCreate(
|
|
name="Dict Job",
|
|
source_dialect="en",
|
|
target_dialect="fr",
|
|
upsert_strategy="MERGE",
|
|
dictionary_ids=["dict-1", "dict-2"],
|
|
)
|
|
job = await svc.create_job(payload)
|
|
assocs = db_session.query(TranslationJobDictionary).filter(
|
|
TranslationJobDictionary.job_id == job.id
|
|
).all()
|
|
assert len(assocs) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_with_datasource_metadata(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
payload = TranslateJobCreate(
|
|
name="DS Job",
|
|
source_dialect="mysql",
|
|
target_dialect="fr",
|
|
source_datasource_id="42",
|
|
environment_id="env-1",
|
|
translation_column="text",
|
|
upsert_strategy="MERGE",
|
|
)
|
|
with patch('src.plugins.translate.service.fetch_datasource_metadata',
|
|
new=AsyncMock(return_value=(None, "postgresql"))):
|
|
job = await svc.create_job(payload)
|
|
assert job.database_dialect == "postgresql"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_with_datasource_metadata_failure(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
payload = TranslateJobCreate(
|
|
name="DS Job Fail",
|
|
source_dialect="mysql",
|
|
target_dialect="fr",
|
|
source_datasource_id="42",
|
|
environment_id="env-1",
|
|
translation_column="text",
|
|
upsert_strategy="MERGE",
|
|
)
|
|
with patch('src.plugins.translate.service.fetch_datasource_metadata',
|
|
new=AsyncMock(side_effect=ValueError("API error"))):
|
|
job = await svc.create_job(payload)
|
|
assert job.database_dialect == "mysql"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_with_target_languages(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
payload = TranslateJobCreate(
|
|
name="Multi Lang",
|
|
source_dialect="en",
|
|
target_dialect="fr",
|
|
target_languages=["fr", "de", "es"],
|
|
upsert_strategy="MERGE",
|
|
)
|
|
job = await svc.create_job(payload)
|
|
assert job.target_languages == ["fr", "de", "es"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_with_single_target_language_string(self, db_session):
|
|
"""target_languages as a single string is normalized to list by service."""
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
# Pydantic v2 coerces str to list[str] if default_factory=list
|
|
payload = TranslateJobCreate(
|
|
name="Single Lang",
|
|
source_dialect="en",
|
|
target_language="es",
|
|
target_languages=["es"],
|
|
target_dialect="es",
|
|
upsert_strategy="MERGE",
|
|
)
|
|
job = await svc.create_job(payload)
|
|
assert "es" in job.target_languages
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_invalid_bcp47(self, db_session):
|
|
"""BCP47 validation rejects invalid language tags."""
|
|
with pytest.raises((ValueError, pydantic.ValidationError)):
|
|
TranslateJobCreate(
|
|
name="Bad BCP47",
|
|
source_dialect="en",
|
|
target_dialect="fr",
|
|
target_languages=["invalid_lang_code"],
|
|
upsert_strategy="MERGE",
|
|
)
|
|
|
|
|
|
class TestUpdateJob:
|
|
"""Verify update_job."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_name(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
payload = TranslateJobUpdate(name="Updated Name")
|
|
job = await svc.update_job(JOB_ID, payload)
|
|
assert job.name == "Updated Name"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_target_language(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
payload = TranslateJobUpdate(target_language="de")
|
|
job = await svc.update_job(JOB_ID, payload)
|
|
assert "de" in job.target_languages
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_target_languages(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
payload = TranslateJobUpdate(target_languages=["fr", "de"])
|
|
job = await svc.update_job(JOB_ID, payload)
|
|
assert job.target_languages == ["fr", "de"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_invalid_bcp47(self, db_session):
|
|
"""BCP47 validation on update rejects invalid tags."""
|
|
with pytest.raises((ValueError, pydantic.ValidationError)):
|
|
TranslateJobUpdate(target_languages=["bad lang code!!"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_nonexistent_job(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
payload = TranslateJobUpdate(name="Ghost")
|
|
with pytest.raises(ValueError, match="not found"):
|
|
await svc.update_job("nonexistent", payload)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_with_dictionary_ids(self, db_session):
|
|
"""Update job with dictionary IDs. Requires TerminologyDictionary rows."""
|
|
_create_term_dict(db_session, "dict-1")
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
payload = TranslateJobUpdate(dictionary_ids=["dict-1"])
|
|
job = await svc.update_job(JOB_ID, payload)
|
|
assocs = db_session.query(TranslationJobDictionary).filter(
|
|
TranslationJobDictionary.job_id == job.id
|
|
).all()
|
|
assert len(assocs) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_with_source_datasource(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
payload = TranslateJobUpdate(
|
|
source_datasource_id="99",
|
|
environment_id="env-1",
|
|
)
|
|
with patch('src.plugins.translate.service.fetch_datasource_metadata',
|
|
new=AsyncMock(return_value=(None, "postgresql"))):
|
|
job = await svc.update_job(JOB_ID, payload)
|
|
assert job.database_dialect == "postgresql"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_with_source_datasource_failure(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
payload = TranslateJobUpdate(
|
|
source_datasource_id="99",
|
|
environment_id="env-1",
|
|
)
|
|
with patch('src.plugins.translate.service.fetch_datasource_metadata',
|
|
new=AsyncMock(side_effect=ValueError("API error"))):
|
|
job = await svc.update_job(JOB_ID, payload)
|
|
assert job is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_single_target_language_string(self, db_session):
|
|
"""target_languages passed as list (not string)."""
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
payload = TranslateJobUpdate(target_languages=["fr"])
|
|
job = await svc.update_job(JOB_ID, payload)
|
|
assert job.target_languages == ["fr"]
|
|
|
|
|
|
class TestDeleteJob:
|
|
"""Verify delete_job."""
|
|
|
|
def test_delete_existing(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
svc.delete_job(JOB_ID)
|
|
with pytest.raises(ValueError, match="not found"):
|
|
svc.get_job(JOB_ID)
|
|
|
|
def test_delete_nonexistent(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
with pytest.raises(ValueError, match="not found"):
|
|
svc.delete_job("nonexistent")
|
|
|
|
|
|
class TestDuplicateJob:
|
|
"""Verify duplicate_job."""
|
|
|
|
def test_duplicate_default_name(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
dup = svc.duplicate_job(JOB_ID)
|
|
assert dup.name == "Base Test Job (Copy)"
|
|
assert dup.status == "DRAFT"
|
|
assert dup.id != JOB_ID
|
|
|
|
def test_duplicate_custom_name(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
dup = svc.duplicate_job(JOB_ID, new_name="Custom Copy")
|
|
assert dup.name == "Custom Copy"
|
|
|
|
def test_duplicate_nonexistent(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
with pytest.raises(ValueError, match="not found"):
|
|
svc.duplicate_job("nonexistent")
|
|
|
|
|
|
class TestGetJobDictionaryIds:
|
|
"""Verify get_job_dictionary_ids."""
|
|
|
|
def test_no_dictionaries(self, db_session):
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
ids = svc.get_job_dictionary_ids(JOB_ID)
|
|
assert ids == []
|
|
assert isinstance(ids, list)
|
|
|
|
def test_with_dictionaries(self, db_session):
|
|
"""Dictionary associations require TerminologyDictionary rows for FK."""
|
|
_create_term_dict(db_session, "dict-1")
|
|
_create_term_dict(db_session, "dict-2")
|
|
config = MagicMock()
|
|
svc = TranslateJobService(db_session, config)
|
|
db_session.add(TranslationJobDictionary(
|
|
id=str(uuid.uuid4()), job_id=JOB_ID, dictionary_id="dict-1",
|
|
))
|
|
db_session.add(TranslationJobDictionary(
|
|
id=str(uuid.uuid4()), job_id=JOB_ID, dictionary_id="dict-2",
|
|
))
|
|
db_session.commit()
|
|
ids = svc.get_job_dictionary_ids(JOB_ID)
|
|
assert set(ids) == {"dict-1", "dict-2"}
|
|
|
|
|
|
class TestFetchAvailableDatasources:
|
|
"""Verify fetch_available_datasources."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_with_search(self, db_session):
|
|
config = MagicMock()
|
|
config.get_environment.return_value = MagicMock(id="env-1")
|
|
|
|
svc = TranslateJobService(db_session, config)
|
|
mock_client = AsyncMock()
|
|
mock_client.get_datasets.return_value = (None, [
|
|
{"id": 1, "table_name": "orders", "schema": "public",
|
|
"database": {"id": 10, "database_name": "Main DB", "backend": "postgresql"},
|
|
"description": "Order data"},
|
|
{"id": 2, "table_name": "users", "schema": "public",
|
|
"database": {"id": 11, "database_name": "Main DB", "backend": "postgresql"},
|
|
"description": "User data"},
|
|
])
|
|
|
|
# Patch at the source module since service does lazy import
|
|
with patch('src.core.utils.client_registry.get_superset_client',
|
|
new=AsyncMock(return_value=mock_client)):
|
|
result = await svc.fetch_available_datasources("env-1", search="order")
|
|
assert len(result) == 1
|
|
assert result[0]["table_name"] == "orders"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_no_search(self, db_session):
|
|
config = MagicMock()
|
|
config.get_environment.return_value = MagicMock(id="env-1")
|
|
|
|
svc = TranslateJobService(db_session, config)
|
|
mock_client = AsyncMock()
|
|
mock_client.get_datasets.return_value = (None, [
|
|
{"id": 1, "table_name": "orders", "schema": "public",
|
|
"database": {"id": 10, "database_name": "Main DB", "backend": "postgresql"},
|
|
"description": ""},
|
|
])
|
|
|
|
with patch('src.core.utils.client_registry.get_superset_client',
|
|
new=AsyncMock(return_value=mock_client)):
|
|
result = await svc.fetch_available_datasources("env-1")
|
|
assert len(result) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_env_not_found(self, db_session):
|
|
config = MagicMock()
|
|
config.get_environment.return_value = None
|
|
|
|
svc = TranslateJobService(db_session, config)
|
|
with pytest.raises(ValueError, match="not found"):
|
|
await svc.fetch_available_datasources("bad-env")
|
|
# #endregion Test.TranslateJobService
|