# #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