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