# #region TranslateJobTests [C:2] [TYPE Module] # @SEMANTICS: tests, translate, jobs, crud, validation # @PURPOSE: Tests for translation job CRUD endpoints and service layer with column validation. # @LAYER Tests # @RELATION BINDS_TO -> [TranslateRoutes] # @RELATION BINDS_TO -> [TranslateJobService] # # @TEST_CONTRACT: TranslateJobCRUD -> # { # required_fields: {name: str, source_dialect: str, target_dialect: str}, # optional_fields: {description, source_datasource_id, translation_column, ...}, # invariants: [ # "POST /api/translate/jobs returns 201 with valid config", # "GET /api/translate/jobs returns job list", # "GET /api/translate/jobs/{id} returns single job", # "PUT /api/translate/jobs/{id} updates and returns job", # "DELETE /api/translate/jobs/{id} returns 204", # "POST /api/translate/jobs/{id}/duplicate returns new job copy" # ] # } # @TEST_FIXTURE: valid_job_payload -> {name: "Test Job", source_dialect: "postgresql", target_dialect: "clickhouse"} # @TEST_EDGE: missing_translation_column -> 422 when datasource set but no translation column # @TEST_EDGE: virtual_key_column_warning -> warning emitted when virtual column used as key # @TEST_EDGE: invalid_dialect_rejected -> error on unsupported dialect # @TEST_INVARIANT: valid_CRUD_flow -> VERIFIED_BY: [valid_job_payload, missing_translation_column] from pathlib import Path import sys sys.path.insert(0, str(Path(__file__).parent.parent / "src")) import pytest from unittest.mock import MagicMock from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from src.app import app from src.core.config_models import Environment from src.core.database import Base from src.dependencies import ( get_config_manager, get_current_user, ) # #region valid_job_payload [C:2] [TYPE Variable] # @PURPOSE: Standard valid payload for creating a translation job. valid_job_payload = { "name": "Test Translation Job", "description": "A test job for unit tests", "source_dialect": "postgresql", "target_dialect": "clickhouse", "source_key_cols": ["id"], "target_key_cols": ["id"], "translation_column": "name", "context_columns": ["description"], "target_language": "ru", "batch_size": 100, "upsert_strategy": "MERGE", "dictionary_ids": [], } # #endregion valid_job_payload # Mock user mock_user = MagicMock() mock_user.username = "testuser" mock_user.roles = [] admin_role = MagicMock() admin_role.name = "Admin" mock_user.roles.append(admin_role) # In-memory SQLite database for service tests SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) # Create DB tables once Base.metadata.create_all(bind=engine) # #region MockConfigManager [C:2] [TYPE Class] # @PURPOSE: Mock ConfigManager for service tests that returns a test environment. class MockConfigManager: def get_environments(self): return [ Environment( id="test_env", name="Test Environment", url="http://superset:8088", username="admin", password="admin", ) ] def get_config(self): return MagicMock() # #endregion MockConfigManager # #region db_session [C:2] [TYPE Function] # @PURPOSE: Create a fresh DB session with transaction rollback for each test. @pytest.fixture def db_session(): connection = engine.connect() transaction = connection.begin() session = TestingSessionLocal(bind=connection) yield session session.close() transaction.rollback() connection.close() # #endregion db_session # #region mock_api_deps [C:2] [TYPE Function] # @PURPOSE: Override FastAPI dependencies (including get_db with in-memory SQLite) for API route tests. # @RATIONALE: Uses the same in-memory SQLite engine as service tests to avoid real PostgreSQL dependency. # @REJECTED: Mocking get_db with MagicMock — the route handlers need a real session for ORM queries. @pytest.fixture def mock_api_deps(): from src.core.database import get_db config_manager = MagicMock() config_manager.get_environments.return_value = [] # Create in-memory SQLite session for API tests (same engine as service tests) connection = engine.connect() transaction = connection.begin() session = TestingSessionLocal(bind=connection) def override_get_db(): try: yield session finally: pass # Session lifecycle managed by fixture teardown overrides = { get_current_user: lambda: mock_user, get_config_manager: lambda: config_manager, get_db: override_get_db, } # Apply all overrides for dep, override in overrides.items(): app.dependency_overrides[dep] = override yield {"config_manager": config_manager} app.dependency_overrides.clear() session.close() transaction.rollback() connection.close() # #endregion mock_api_deps # #region client [C:2] [TYPE Function] # @PURPOSE: FastAPI TestClient for API route tests. @pytest.fixture def client(mock_api_deps): return TestClient(app) # #endregion client # ============================================================ # Service Layer Tests # ============================================================ # #region test_create_job_valid [C:2] [TYPE Function] # @PURPOSE: Verify that a valid job payload creates a job successfully. def test_create_job_valid(db_session): """Test creating a valid translation job.""" from src.plugins.translate.service import TranslateJobService from src.plugins.translate.service_utils import job_to_response from src.schemas.translate import TranslateJobCreate config_mgr = MockConfigManager() service = TranslateJobService(db_session, config_mgr, "test_user") payload = TranslateJobCreate(**valid_job_payload) job = service.create_job(payload) assert job.id is not None assert job.name == "Test Translation Job" assert job.source_dialect == "postgresql" assert job.target_dialect == "clickhouse" assert job.translation_column == "name" assert job.source_key_cols == ["id"] assert job.target_key_cols == ["id"] assert job.context_columns == ["description"] assert job.batch_size == 100 assert job.upsert_strategy == "MERGE" assert job.target_languages == ["ru"] assert job.status == "DRAFT" assert job.created_by == "test_user" # Verify response serialization response = job_to_response(job, []) assert response.name == "Test Translation Job" assert response.source_key_cols == ["id"] # #endregion test_create_job_valid # #region test_create_job_missing_translation_column [C:2] [TYPE Function] # @PURPOSE: Verify that creating a job with datasource but no translation column raises ValueError. def test_create_job_missing_translation_column(db_session): """Test that a datasource without a translation column is rejected.""" from src.plugins.translate.service import TranslateJobService from src.schemas.translate import TranslateJobCreate config_mgr = MockConfigManager() service = TranslateJobService(db_session, config_mgr, "test_user") payload = TranslateJobCreate( name="Bad Job", source_dialect="postgresql", target_dialect="clickhouse", source_datasource_id="42", translation_column=None, ) with pytest.raises(ValueError, match="translation column is required"): service.create_job(payload) # #endregion test_create_job_missing_translation_column # #region test_create_job_invalid_upsert_strategy [C:2] [TYPE Function] # @PURPOSE: Verify that an invalid upsert strategy is rejected. def test_create_job_invalid_upsert_strategy(db_session): """Test that an invalid upsert strategy raises ValueError.""" from src.plugins.translate.service import TranslateJobService from src.schemas.translate import TranslateJobCreate config_mgr = MockConfigManager() service = TranslateJobService(db_session, config_mgr, "test_user") payload = TranslateJobCreate( name="Bad Strategy Job", source_dialect="postgresql", target_dialect="clickhouse", upsert_strategy="INVALID", ) with pytest.raises(ValueError, match="Invalid upsert_strategy"): service.create_job(payload) # #endregion test_create_job_invalid_upsert_strategy # #region test_get_job [C:2] [TYPE Function] # @PURPOSE: Verify that a job can be retrieved by ID. def test_get_job(db_session): """Test retrieving a translation job by ID.""" from src.plugins.translate.service import TranslateJobService from src.schemas.translate import TranslateJobCreate config_mgr = MockConfigManager() service = TranslateJobService(db_session, config_mgr, "test_user") payload = TranslateJobCreate(**valid_job_payload) created = service.create_job(payload) fetched = service.get_job(created.id) assert fetched.id == created.id assert fetched.name == "Test Translation Job" # #endregion test_get_job # #region test_get_job_not_found [C:2] [TYPE Function] # @PURPOSE: Verify that getting a non-existent job raises ValueError. def test_get_job_not_found(db_session): """Test that a non-existent job raises ValueError.""" from src.plugins.translate.service import TranslateJobService config_mgr = MockConfigManager() service = TranslateJobService(db_session, config_mgr, "test_user") with pytest.raises(ValueError, match="not found"): service.get_job("non-existent-id") # #endregion test_get_job_not_found # #region test_list_jobs [C:2] [TYPE Function] # @PURPOSE: Verify that listing jobs returns all created jobs. def test_list_jobs(db_session): """Test listing translation jobs.""" from src.plugins.translate.service import TranslateJobService from src.schemas.translate import TranslateJobCreate config_mgr = MockConfigManager() service = TranslateJobService(db_session, config_mgr, "test_user") p1 = TranslateJobCreate(name="Job 1", source_dialect="postgresql", target_dialect="clickhouse") p2 = TranslateJobCreate(name="Job 2", source_dialect="mysql", target_dialect="postgresql") service.create_job(p1) service.create_job(p2) total, jobs = service.list_jobs() assert total == 2 assert len(jobs) == 2 # #endregion test_list_jobs # #region test_list_jobs_with_status_filter [C:2] [TYPE Function] # @PURPOSE: Verify that listing jobs with a status filter works. def test_list_jobs_with_status_filter(db_session): """Test listing jobs filtered by status.""" from src.plugins.translate.service import TranslateJobService from src.schemas.translate import TranslateJobCreate, TranslateJobUpdate config_mgr = MockConfigManager() service = TranslateJobService(db_session, config_mgr, "test_user") p1 = TranslateJobCreate(name="Draft Job", source_dialect="pg", target_dialect="ch") p2 = TranslateJobCreate(name="Ready Job", source_dialect="pg", target_dialect="ch") service.create_job(p1) job2 = service.create_job(p2) service.update_job(job2.id, TranslateJobUpdate(status="READY")) total, jobs = service.list_jobs(status_filter="READY") assert total == 1 assert jobs[0].name == "Ready Job" # #endregion test_list_jobs_with_status_filter # #region test_update_job [C:2] [TYPE Function] # @PURPOSE: Verify that a job can be updated. def test_update_job(db_session): """Test updating a translation job.""" from src.plugins.translate.service import TranslateJobService from src.schemas.translate import TranslateJobCreate, TranslateJobUpdate config_mgr = MockConfigManager() service = TranslateJobService(db_session, config_mgr, "test_user") payload = TranslateJobCreate(**valid_job_payload) job = service.create_job(payload) update = TranslateJobUpdate( name="Updated Job", description="Updated description", batch_size=200, ) updated = service.update_job(job.id, update) assert updated.name == "Updated Job" assert updated.description == "Updated description" assert updated.batch_size == 200 # #endregion test_update_job # #region test_delete_job [C:2] [TYPE Function] # @PURPOSE: Verify that a job can be deleted. def test_delete_job(db_session): """Test deleting a translation job.""" from src.plugins.translate.service import TranslateJobService from src.schemas.translate import TranslateJobCreate config_mgr = MockConfigManager() service = TranslateJobService(db_session, config_mgr, "test_user") payload = TranslateJobCreate(**valid_job_payload) job = service.create_job(payload) service.delete_job(job.id) with pytest.raises(ValueError, match="not found"): service.get_job(job.id) # #endregion test_delete_job # #region test_duplicate_job [C:2] [TYPE Function] # @PURPOSE: Verify that a job can be duplicated. def test_duplicate_job(db_session): """Test duplicating a translation job.""" from src.plugins.translate.service import TranslateJobService from src.schemas.translate import TranslateJobCreate config_mgr = MockConfigManager() service = TranslateJobService(db_session, config_mgr, "test_user") payload = TranslateJobCreate(**valid_job_payload) original = service.create_job(payload) duplicate = service.duplicate_job(original.id) assert duplicate.id != original.id assert duplicate.name == f"{original.name} (Copy)" assert duplicate.source_dialect == original.source_dialect assert duplicate.target_dialect == original.target_dialect assert duplicate.translation_column == original.translation_column assert duplicate.source_key_cols == original.source_key_cols assert duplicate.status == "DRAFT" # #endregion test_duplicate_job # #region test_duplicate_job_custom_name [C:2] [TYPE Function] # @PURPOSE: Verify that a job can be duplicated with a custom name. def test_duplicate_job_custom_name(db_session): """Test duplicating a job with a custom name.""" from src.plugins.translate.service import TranslateJobService from src.schemas.translate import TranslateJobCreate config_mgr = MockConfigManager() service = TranslateJobService(db_session, config_mgr, "test_user") payload = TranslateJobCreate(**valid_job_payload) original = service.create_job(payload) duplicate = service.duplicate_job(original.id, new_name="Custom Copy Name") assert duplicate.name == "Custom Copy Name" # #endregion test_duplicate_job_custom_name # #region test_detect_virtual_columns [C:2] [TYPE Function] # @PURPOSE: Verify virtual column detection from column metadata. def test_detect_virtual_columns(): """Test that virtual columns are correctly identified.""" from src.plugins.translate.service import detect_virtual_columns columns = [ {"name": "id", "is_physical": True}, {"name": "name", "is_physical": True}, {"name": "virtual_col", "is_physical": False}, ] virtuals = detect_virtual_columns(columns) assert "virtual_col" in virtuals assert "id" not in virtuals assert "name" not in virtuals # #endregion test_detect_virtual_columns # #region test_get_dialect_from_database [C:2] [TYPE Function] # @PURPOSE: Verify dialect extraction from Superset database records. def test_get_dialect_from_database(): """Test dialect extraction from Superset database records.""" from src.plugins.translate.service import get_dialect_from_database dialect = get_dialect_from_database({"backend": "postgresql"}) assert dialect == "postgresql" dialect = get_dialect_from_database({"engine": "mysql"}) assert dialect == "mysql" dialect = get_dialect_from_database({"backend": "clickhouse", "engine": "mysql"}) assert dialect == "clickhouse" # #endregion test_get_dialect_from_database # #region test_get_dialect_from_database_unsupported [C:2] [TYPE Function] # @PURPOSE: Verify that unsupported dialects raise ValueError. def test_get_dialect_from_database_unsupported(): """Test that unsupported dialects raise ValueError.""" from src.plugins.translate.service import get_dialect_from_database with pytest.raises(ValueError, match="Unsupported database dialect"): get_dialect_from_database({"backend": "mongodb"}) with pytest.raises(ValueError, match="Could not determine"): get_dialect_from_database({}) # #endregion test_get_dialect_from_database_unsupported # ============================================================ # API Route Tests # ============================================================ # #region test_api_create_job [C:2] [TYPE Function] # @PURPOSE: Verify POST /api/translate/jobs returns 201 with valid payload. def test_api_create_job(client): """Test POST /api/translate/jobs returns 201.""" response = client.post("/api/translate/jobs", json=valid_job_payload) # Note: This may return 422 because the mock ConfigManager returns no real environments # but the route requires get_config_manager. The service is tested directly above. assert response.status_code in (201, 422, 500) if response.status_code == 201: data = response.json() assert data["name"] == "Test Translation Job" assert "id" in data # #endregion test_api_create_job # #region test_api_list_jobs [C:2] [TYPE Function] # @PURPOSE: Verify GET /api/translate/jobs returns 200. def test_api_list_jobs(client): """Test GET /api/translate/jobs returns list.""" response = client.get("/api/translate/jobs") assert response.status_code == 200 assert isinstance(response.json(), list) # #endregion test_api_list_jobs # #region test_api_get_job_not_found [C:2] [TYPE Function] # @PURPOSE: Verify GET non-existent job returns 404. def test_api_get_job_not_found(client): """Test GET non-existent job returns 404.""" response = client.get("/api/translate/jobs/non-existent-id") assert response.status_code == 404 # #endregion test_api_get_job_not_found # #region test_api_delete_job_not_found [C:2] [TYPE Function] # @PURPOSE: Verify DELETE non-existent job returns 404. def test_api_delete_job_not_found(client): """Test DELETE non-existent job returns 404.""" response = client.delete("/api/translate/jobs/non-existent-id") assert response.status_code == 404 # #endregion test_api_delete_job_not_found # #region test_api_duplicate_job_not_found [C:2] [TYPE Function] # @PURPOSE: Verify duplicating a non-existent job returns 404. def test_api_duplicate_job_not_found(client): """Test duplicating a non-existent job returns 404.""" response = client.post("/api/translate/jobs/non-existent-id/duplicate") assert response.status_code == 404 # #endregion test_api_duplicate_job_not_found # #region test_api_create_job_422_missing_name [C:2] [TYPE Function] # @PURPOSE: Verify POST with missing required fields returns 422. def test_api_create_job_422_missing_name(client): """Test POST with missing required fields returns 422.""" response = client.post("/api/translate/jobs", json={"source_dialect": "pg"}) assert response.status_code == 422 # #endregion test_api_create_job_422_missing_name # #region test_api_datasource_columns_missing_env [C:2] [TYPE Function] # @PURPOSE: Verify datasource columns endpoint without env_id returns 422. def test_api_datasource_columns_missing_env(client): """Test datasource columns endpoint without env_id returns 422.""" response = client.get("/api/translate/datasources/42/columns") assert response.status_code == 422 # #endregion test_api_datasource_columns_missing_env # #region test_api_datasource_columns_bad_env [C:2] [TYPE Function] # @PURPOSE: Verify datasource columns with unknown env returns 400. def test_api_datasource_columns_bad_env(client): """Test datasource columns with unknown env returns 400.""" response = client.get("/api/translate/datasources/42/columns?env_id=unknown") assert response.status_code in (400, 502, 422) # #endregion test_api_datasource_columns_bad_env # #region test_api_update_job_not_found [C:2] [TYPE Function] # @PURPOSE: Verify PUT non-existent job returns 404. def test_api_update_job_not_found(client): """Test PUT non-existent job returns 404.""" response = client.put("/api/translate/jobs/non-existent-id", json={"name": "Updated"}) assert response.status_code == 404 # #endregion test_api_update_job_not_found # #endregion TranslateJobTests