diff --git a/backend/src/plugins/translate/__tests__/test_batch_insert.py b/backend/src/plugins/translate/__tests__/test_batch_insert.py new file mode 100644 index 00000000..425deba2 --- /dev/null +++ b/backend/src/plugins/translate/__tests__/test_batch_insert.py @@ -0,0 +1,232 @@ +# #region TestBatchInsert [C:3] [TYPE Module] [SEMANTICS test,translate,batch,insert] +# @BRIEF Tests for _batch_insert.py — column building, row building, context keys. +# @RELATION BINDS_TO -> [_batch_insert_module] +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.models.translate import TranslationJob, TranslationLanguage, TranslationRecord +from src.plugins.translate._batch_insert import ( + _build_target_columns, + _build_context_keys, + _build_insert_rows, + _fetch_batch_records, + _generate_insert_sql, +) + + +# #region make_job [C:1] [TYPE Function] +def make_job(**overrides) -> TranslationJob: + job = MagicMock(spec=TranslationJob) + job.target_key_cols = ["id"] + job.target_column = "translated_text" + job.translation_column = "source_text" + job.target_language_column = "lang_code" + job.target_source_column = "source_text" + job.target_source_language_column = "src_lang" + job.target_schema = "public" + job.target_table = "translations" + job.target_languages = ["ru"] + job.context_columns = [] + job.target_dialect = "clickhouse" + job.database_dialect = None + job.upsert_strategy = "INSERT" + job.environment_id = "env-1" + job.source_dialect = "postgresql" + for k, v in overrides.items(): + setattr(job, k, v) + return job + + +def make_record(**overrides) -> TranslationRecord: + rec = MagicMock(spec=TranslationRecord) + rec.id = str(uuid.uuid4()) + rec.source_sql = "Hello" + rec.target_sql = "Привет" + rec.source_data = {"id": 1, "extra": "data"} + rec.status = "SUCCESS" + rec.languages = [] + for k, v in overrides.items(): + setattr(rec, k, v) + return rec + + +def make_lang(code: str = "ru", value: str = "Привет", detected: str = "en", status: str = "completed") -> MagicMock: + lang = MagicMock(spec=TranslationLanguage) + lang.language_code = code + lang.translated_value = value + lang.final_value = value + lang.source_language_detected = detected + lang.status = status + return lang +# #endregion make_job + + +# #region TestBuildColumns [C:2] [TYPE Class] +class TestBuildColumns: + """Verify _build_target_columns assembles correct column list.""" + + def test_basic_columns(self): + job = make_job() + cols = _build_target_columns(job, "translated_text") + assert "translated_text" in cols + assert "lang_code" in cols + assert "source_text" in cols + assert "src_lang" in cols + assert "context" in cols + assert "is_original" in cols + + def test_no_key_cols(self): + job = make_job(target_key_cols=[]) + cols = _build_target_columns(job, "translated_text") + assert "id" not in cols + + def test_dedup_same_column(self): + job = make_job(target_key_cols=["source_text"]) + cols = _build_target_columns(job, "translated_text") + # source_text appears in key_cols and in target_source_column — should be deduped + assert cols.count("source_text") == 1 + + def test_no_effective_target(self): + job = make_job(target_column=None, translation_column=None) + cols = _build_target_columns(job, None) + assert all(c in cols for c in ["context", "is_original"]) +# #endregion TestBuildColumns + + +# #region TestBuildContextKeys [C:2] [TYPE Class] +class TestBuildContextKeys: + """Verify _build_context_keys builds correct context list.""" + + def test_empty_context(self): + job = make_job(context_columns=[]) + keys = _build_context_keys(job, "translated_text") + assert "source_text" in keys # translation_column != effective_target + + def test_with_context_columns(self): + job = make_job(context_columns=["category", "author"]) + keys = _build_context_keys(job, "translated_text") + assert "category" in keys + assert "author" in keys + + def test_no_extra_when_same(self): + job = make_job(translation_column="translated_text") + keys = _build_context_keys(job, "translated_text") + assert keys == [] +# #endregion TestBuildContextKeys + + +# #region TestBuildInsertRows [C:2] [TYPE Class] +class TestBuildInsertRows: + """Verify _build_insert_rows produces correct row dicts.""" + + def test_single_record_no_languages(self): + job = make_job(target_key_cols=["id"]) + rec = make_record() + rows = _build_insert_rows([rec], job, "translated_text", "ru", []) + + # Should produce 2 rows: original + fallback + assert len(rows) == 2 + assert rows[0]["is_original"] == 1 + assert rows[1]["is_original"] == 0 + assert rows[1]["translated_text"] == rec.target_sql + + def test_single_record_with_languages(self): + job = make_job(target_key_cols=["id"]) + rec = make_record(languages=[make_lang("ru", "Привет мир")]) + rows = _build_insert_rows([rec], job, "translated_text", "ru", []) + + # 2 rows: original + translated + assert len(rows) == 2 + assert rows[1]["translated_text"] == "Привет мир" + assert rows[1]["lang_code"] == "ru" + + def test_skip_matching_language(self): + """Skip language code that matches detected source language.""" + job = make_job(target_key_cols=["id"]) + rec = make_record(languages=[make_lang("en", "Hello", detected="en")]) + rows = _build_insert_rows([rec], job, "translated_text", "en", []) + + # Only the original row — language is skipped because en == detected + assert len(rows) == 1 + + def test_source_column_values(self): + job = make_job(target_key_cols=["id"]) + rec = make_record() + rows = _build_insert_rows([rec], job, "translated_text", "ru", []) + + assert rows[0]["source_text"] == "Hello" + assert rows[0]["src_lang"] == "und" # no languages on record + + def test_context_data(self): + job = make_job(target_key_cols=["id"], context_columns=["extra"]) + rec = make_record() + rows = _build_insert_rows([rec], job, "translated_text", "ru", ["extra"]) + + import json + context = json.loads(rows[0]["context"]) + assert context.get("extra") == "data" +# #endregion TestBuildInsertRows + + +# #region TestFetchBatchRecords [C:2] [TYPE Class] +class TestFetchBatchRecords: + """Verify _fetch_batch_records queries correctly.""" + + def test_fetch_returns_records(self): + db = MagicMock() + db.query.return_value.options.return_value.filter.return_value.all.return_value = [make_record()] + + result = _fetch_batch_records(db, "batch-1") + assert len(result) == 1 + + def test_fetch_empty(self): + db = MagicMock() + db.query.return_value.options.return_value.filter.return_value.all.return_value = [] + + result = _fetch_batch_records(db, "batch-1") + assert result == [] +# #endregion TestFetchBatchRecords + + +# #region TestGenerateInsertSQL [C:2] [TYPE Class] +class TestGenerateInsertSQL: + """Verify _generate_insert_sql produces SQL.""" + + def test_generates_sql(self): + job = make_job(target_schema="public", target_table="test_table", upsert_strategy="INSERT") + sql, count = _generate_insert_sql( + dialect="clickhouse", + job=job, + columns=["id", "translated_text"], + rows_for_sql=[{"id": 1, "translated_text": "hello"}], + ) + assert sql is not None + assert "INSERT INTO" in sql + assert count == 1 + + def test_generates_upsert(self): + job = make_job(target_schema="public", target_table="test_table", upsert_strategy="MERGE", target_key_cols=["id"]) + sql, count = _generate_insert_sql( + dialect="clickhouse", + job=job, + columns=["id", "translated_text"], + rows_for_sql=[{"id": 1, "translated_text": "hello"}], + ) + assert sql is not None + assert count == 1 + + def test_unknown_dialect_falls_back(self): + job = make_job(target_schema="public", target_table="test") + sql, count = _generate_insert_sql( + dialect="unknown_dialect_xyz", + job=job, + columns=["col1"], + rows_for_sql=[{"col1": "val"}], + ) + # Falls back to default quoting + assert sql is not None + assert count == 1 +# #endregion TestGenerateInsertSQL +# #endregion TestBatchInsert diff --git a/backend/src/plugins/translate/__tests__/test_lang_stats.py b/backend/src/plugins/translate/__tests__/test_lang_stats.py new file mode 100644 index 00000000..40a84102 --- /dev/null +++ b/backend/src/plugins/translate/__tests__/test_lang_stats.py @@ -0,0 +1,168 @@ +# #region TestLangStats [C:2] [TYPE Module] [SEMANTICS test,translate,language,stats] +# @BRIEF Tests for orchestrator_lang_stats.update_language_stats — aggregation of per-language statistics. +# @RELATION BINDS_TO -> [update_language_stats] +import uuid +from unittest.mock import MagicMock + +from src.models.translate import TranslationRecord, TranslationLanguage, TranslationRunLanguageStats +from src.plugins.translate.orchestrator_lang_stats import update_language_stats + + +# #region make_lang_entry [C:1] [TYPE Function] +def make_lang(code: str, status: str = "translated", value: str = "hello") -> TranslationLanguage: + le = MagicMock(spec=TranslationLanguage) + le.language_code = code + le.status = status + le.translated_value = value + return le + + +def make_record(run_id: str) -> TranslationRecord: + rec = MagicMock(spec=TranslationRecord) + rec.id = str(uuid.uuid4()) + rec.run_id = run_id + return rec +# #endregion make_lang_entry + + +# #region TestLangStatsAggregation [C:2] [TYPE Class] +class TestLangStatsAggregation: + """Verify update_language_stats aggregates correctly.""" + + def test_single_language_all_translated(self): + db = MagicMock() + run_id = "run-1" + ru_stat = MagicMock(spec=TranslationRunLanguageStats) + + records = [make_record(run_id)] + db.query.return_value.filter.return_value.all.side_effect = [ + records, + [make_lang("ru", "translated", "Привет")], + ] + + update_language_stats(db, None, run_id, {"ru": ru_stat}) + + assert ru_stat.total_rows == 1 + assert ru_stat.translated_rows == 1 + assert ru_stat.failed_rows == 0 + assert ru_stat.skipped_rows == 0 + + def test_mixed_statuses(self): + db = MagicMock() + run_id = "run-2" + en_stat = MagicMock(spec=TranslationRunLanguageStats) + + records = [make_record(run_id)] + langs = [ + make_lang("en", "translated", "Hello"), + make_lang("en", "failed", ""), + make_lang("en", "skipped", ""), + ] + db.query.return_value.filter.return_value.all.side_effect = [ + records, + langs, + ] + + update_language_stats(db, None, run_id, {"en": en_stat}) + + assert en_stat.total_rows == 3 + assert en_stat.translated_rows == 1 + assert en_stat.failed_rows == 1 + assert en_stat.skipped_rows == 1 + + def test_multiple_languages(self): + db = MagicMock() + run_id = "run-3" + ru_stat = MagicMock(spec=TranslationRunLanguageStats) + en_stat = MagicMock(spec=TranslationRunLanguageStats) + + records = [make_record(run_id), make_record(run_id)] + langs = [ + make_lang("ru", "translated", "Привет"), + make_lang("en", "translated", "Hello"), + ] + db.query.return_value.filter.return_value.all.side_effect = [ + records, + langs, + ] + + update_language_stats(db, None, run_id, {"ru": ru_stat, "en": en_stat}) + + assert ru_stat.total_rows == 1 + assert ru_stat.translated_rows == 1 + assert en_stat.total_rows == 1 + assert en_stat.translated_rows == 1 + + def test_empty_language_stats_map(self): + db = MagicMock() + run_id = "run-4" + records = [make_record(run_id)] + db.query.return_value.filter.return_value.all.side_effect = [ + records, + [make_lang("ru", "translated")], + ] + + # Should not raise even with empty map + update_language_stats(db, None, run_id, {}) + # No assertions needed — just verifying no exception + + def test_no_records_returns_early(self): + db = MagicMock() + run_id = "run-5" + db.query.return_value.filter.return_value.all.return_value = [] + + update_language_stats(db, None, run_id, {"ru": MagicMock()}) + # Should return early without querying languages + + def test_approved_and_edited_count_as_translated(self): + db = MagicMock() + run_id = "run-6" + de_stat = MagicMock(spec=TranslationRunLanguageStats) + + records = [make_record(run_id)] + langs = [ + make_lang("de", "approved", "Hallo"), + make_lang("de", "edited", "Guten Tag"), + make_lang("de", "translated", "Tschüss"), + ] + db.query.return_value.filter.return_value.all.side_effect = [ + records, + langs, + ] + + update_language_stats(db, None, run_id, {"de": de_stat}) + + assert de_stat.translated_rows == 3 + + def test_token_and_cost_estimation(self): + db = MagicMock() + run_id = "run-7" + en_stat = MagicMock(spec=TranslationRunLanguageStats) + + records = [make_record(run_id)] + langs = [make_lang("en", "translated", "Hello World")] + db.query.return_value.filter.return_value.all.side_effect = [ + records, + langs, + ] + + update_language_stats(db, None, run_id, {"en": en_stat}) + + assert en_stat.token_count > 0 + assert en_stat.estimated_cost >= 0 + + def test_empty_translated_value_no_crash(self): + db = MagicMock() + run_id = "run-8" + ru_stat = MagicMock(spec=TranslationRunLanguageStats) + + records = [make_record(run_id)] + langs = [make_lang("ru", "translated", None)] + db.query.return_value.filter.return_value.all.side_effect = [ + records, + langs, + ] + + update_language_stats(db, None, run_id, {"ru": ru_stat}) + assert ru_stat.total_rows == 1 +# #endregion TestLangStatsAggregation diff --git a/backend/src/plugins/translate/__tests__/test_preview.py b/backend/src/plugins/translate/__tests__/test_preview.py index 9d75ce8d..86f262db 100644 --- a/backend/src/plugins/translate/__tests__/test_preview.py +++ b/backend/src/plugins/translate/__tests__/test_preview.py @@ -225,195 +225,6 @@ class TestTranslationPreview: assert len(result["records"]) == 1 # endregion test_preview_with_dictionary - # region test_preview_row_approve [TYPE Function] - # @PURPOSE: Verify approving a preview row changes its status. - def test_preview_row_approve(self): - """Approve action should set record status to APPROVED.""" - db = MagicMock() - config_manager = MagicMock() - - # Mock active session - session = MagicMock(spec=TranslationPreviewSession) - session.id = "session-1" - session.job_id = "job-123" - session.status = "ACTIVE" - session.created_at = datetime.now(UTC) - - # Mock record - record = MagicMock(spec=TranslationPreviewRecord) - record.id = "record-1" - record.session_id = "session-1" - record.status = "PENDING" - record.target_sql = "test translation" - record.feedback = None - - # Setup: db.query().filter().order_by().first() -> session (session lookup) - db.query.return_value.filter.return_value.order_by.return_value.first.return_value = session - # Setup: db.query().filter().first() -> record (record lookup) - db.query.return_value.filter.return_value.first.return_value = record - - preview_service = TranslationPreview(db, config_manager, "test-user") - result = preview_service.update_preview_row( - job_id="job-123", - row_id="record-1", - action="approve", - ) - - assert record.status == "APPROVED" - assert result["status"] == "APPROVED" - assert db.commit.called - # endregion test_preview_row_approve - - # region test_preview_row_reject [TYPE Function] - # @PURPOSE: Verify rejecting a preview row changes its status. - def test_preview_row_reject(self): - """Reject action should set record status to REJECTED.""" - db = MagicMock() - config_manager = MagicMock() - - session = MagicMock(spec=TranslationPreviewSession) - session.id = "session-1" - session.job_id = "job-123" - session.status = "ACTIVE" - - record = MagicMock(spec=TranslationPreviewRecord) - record.id = "record-1" - record.session_id = "session-1" - record.status = "PENDING" - record.target_sql = "test" - record.feedback = None - - db.query.return_value.filter.return_value.order_by.return_value.first.return_value = session - db.query.return_value.filter.return_value.first.return_value = record - - preview_service = TranslationPreview(db, config_manager, "test-user") - result = preview_service.update_preview_row( - job_id="job-123", - row_id="record-1", - action="reject", - ) - - assert record.status == "REJECTED" - assert result["status"] == "REJECTED" - # endregion test_preview_row_reject - - # region test_preview_row_edit [TYPE Function] - # @PURPOSE: Verify editing a preview row updates its translation and status. - def test_preview_row_edit(self): - """Edit action should update translation and set status to APPROVED.""" - db = MagicMock() - config_manager = MagicMock() - - session = MagicMock(spec=TranslationPreviewSession) - session.id = "session-1" - session.job_id = "job-123" - session.status = "ACTIVE" - - record = MagicMock(spec=TranslationPreviewRecord) - record.id = "record-1" - record.session_id = "session-1" - record.status = "PENDING" - record.target_sql = "old translation" - record.feedback = None - - db.query.return_value.filter.return_value.order_by.return_value.first.return_value = session - db.query.return_value.filter.return_value.first.return_value = record - - preview_service = TranslationPreview(db, config_manager, "test-user") - result = preview_service.update_preview_row( - job_id="job-123", - row_id="record-1", - action="edit", - translation="edited translation", - ) - - assert record.target_sql == "edited translation" - assert record.status == "APPROVED" - assert result["status"] == "APPROVED" - assert result["target_sql"] == "edited translation" - # endregion test_preview_row_edit - - # region test_preview_row_invalid_action [TYPE Function] - # @PURPOSE: Verify invalid action raises ValueError. - def test_preview_row_invalid_action(self): - """Invalid action should raise ValueError.""" - db = MagicMock() - config_manager = MagicMock() - - session = MagicMock(spec=TranslationPreviewSession) - session.id = "session-1" - session.job_id = "job-123" - session.status = "ACTIVE" - - record = MagicMock(spec=TranslationPreviewRecord) - record.id = "record-1" - record.session_id = "session-1" - record.status = "PENDING" - - db.query.return_value.filter.return_value.order_by.return_value.first.return_value = session - db.query.return_value.filter.return_value.first.return_value = record - - preview_service = TranslationPreview(db, config_manager, "test-user") - with pytest.raises(ValueError, match="Invalid action"): - preview_service.update_preview_row( - job_id="job-123", - row_id="record-1", - action="invalid_action", - ) - # endregion test_preview_row_invalid_action - - # region test_preview_accept_session [TYPE Function] - # @PURPOSE: Verify accepting a preview session gates execution. - def test_preview_accept_session(self): - """Accept should set session status to APPLIED and return records.""" - db = MagicMock() - config_manager = MagicMock() - - session = MagicMock(spec=TranslationPreviewSession) - session.id = "session-1" - session.job_id = "job-123" - session.status = "ACTIVE" - session.created_by = "test-user" - session.created_at = datetime.now(UTC) - session.expires_at = datetime.now(UTC) + timedelta(hours=24) - - record = MagicMock(spec=TranslationPreviewRecord) - record.id = "record-1" - record.session_id = "session-1" - record.source_sql = "Hello" - record.target_sql = "Привет" - record.status = "APPROVED" - record.feedback = None - - # Session lookup - db.query.return_value.filter.return_value.order_by.return_value.first.return_value = session - # Records lookup - db.query.return_value.filter.return_value.all.return_value = [record] - - preview_service = TranslationPreview(db, config_manager, "test-user") - result = preview_service.accept_preview_session(job_id="job-123") - - assert session.status == "APPLIED" - assert result["status"] == "APPLIED" - assert len(result["records"]) == 1 - assert result["records"][0]["status"] == "APPROVED" - assert db.commit.called - # endregion test_preview_accept_session - - # region test_preview_no_active_session [TYPE Function] - # @PURPOSE: Verify accept raises error when no active session. - def test_preview_no_active_session(self): - """Accept without active session should raise ValueError.""" - db = MagicMock() - config_manager = MagicMock() - - db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None - - preview_service = TranslationPreview(db, config_manager, "test-user") - with pytest.raises(ValueError, match="No active preview session"): - preview_service.accept_preview_session(job_id="job-123") - # endregion test_preview_no_active_session - # region test_cost_estimation [TYPE Function] # @PURPOSE: Verify token and cost estimation methods. def test_cost_estimation(self): diff --git a/backend/src/plugins/translate/__tests__/test_response_field_coverage.py b/backend/src/plugins/translate/__tests__/test_response_field_coverage.py new file mode 100644 index 00000000..f1fe3e7f --- /dev/null +++ b/backend/src/plugins/translate/__tests__/test_response_field_coverage.py @@ -0,0 +1,181 @@ +# #region ResponseFieldCoverageTests [C:3] [TYPE Module] [SEMANTICS test, translate, field-coverage, serialization] +# @BRIEF Tests that verify full field coverage across the API response chain. +# @LAYER Test +# @RELATION BINDS_TO -> [job_to_response] +# @RELATION BINDS_TO -> [TranslateJobResponse] +# @RELATION BINDS_TO -> [duplicate_job] +# @RELATION BINDS_TO -> [get_run_status] +# @RELATION BINDS_TO -> [get_run_history] +# @INVARIANT Every field in TranslateJobResponse must be mapped in job_to_response. +# @INVARIANT Every data field in TranslationJob must be copied in duplicate_job. +# @INVARIANT Run dict serializers must include insert_method + connection_snapshot. +# @TEST_CONTRACT job_to_response maps ALL TranslateJobResponse fields +# @TEST_CONTRACT duplicate_job copies ALL TranslationJob data fields +# @TEST_CONTRACT get_run_status/get_run_history include insert_method +# @RATIONALE Regression guard: when new columns are added to models/schemas, +# the response serializers and duplication logic must be updated too. +# These tests fail if a field is silently defaulted. + +from unittest.mock import MagicMock +import pytest + +from src.schemas.translate import TranslateJobResponse +from src.plugins.translate.service_utils import job_to_response +from src.plugins.translate.orchestrator_query import get_run_history +from src.models.translate import TranslationJob, TranslationRun + + +# #region TestJobToResponse [C:2] [TYPE Function] [SEMANTICS test, field-coverage, serialization] +# @TEST_INVARIANT: job_to_response-maps-all-response-fields -> VERIFIED_BY: test_job_to_response_covers_all_fields +class TestJobToResponse: + """Verify job_to_response() maps EVERY field from TranslateJobResponse.""" + + # Fields set externally (not from job ORM) + EXTERNAL_FIELDS = {"dictionary_ids"} + + def test_job_to_response_covers_all_fields(self): + """Every field in TranslateJobResponse must be passed to the constructor.""" + import inspect + from src.plugins.translate import service_utils + + # Get source of job_to_response + src = inspect.getsource(service_utils.job_to_response) + response_fields = set(TranslateJobResponse.model_fields.keys()) + needed = response_fields - self.EXTERNAL_FIELDS + + missing = [] + for field in sorted(needed): + # Check the field name appears in the function body as a kwarg + if f"{field}=" not in src: + missing.append(field) + + assert not missing, ( + f"Fields present in TranslateJobResponse but NOT mapped in job_to_response(): {missing}. " + f"Add them to service_utils.py job_to_response()." + ) +# #endregion TestJobToResponse + + +# #region TestDuplicateJob [C:2] [TYPE Function] [SEMANTICS test, field-coverage, duplication] +# @TEST_INVARIANT: duplicate_job-copies-all-fields -> VERIFIED_BY: test_duplicate_job_copies_all_fields +class TestDuplicateJob: + """Verify duplicate_job() copies all data-bearing TranslationJob columns.""" + + # Fields intentionally NOT copied + SKIP_FIELDS = { + "id", # new UUID + "status", # set to "DRAFT" explicitly + "created_by", # set to current user + "created_at", # auto-set by ORM + "updated_at", # auto-set by ORM + "name", # set to f"{source.name} (Copy)" + } + + def test_duplicate_job_copies_all_fields(self): + """Every data-bearing ORM column should be referenced in duplicate_job().""" + import inspect + from src.plugins.translate import service + + src = inspect.getsource(service.TranslateJobService.duplicate_job) + orm_cols = {c.name for c in TranslationJob.__table__.columns} + data_cols = orm_cols - self.SKIP_FIELDS + + missing = [] + for col in sorted(data_cols): + # Check the column name is assigned in duplicate_job body + if f"{col}=" not in src and f"{col} =" not in src: + missing.append(col) + + assert not missing, ( + f"ORM columns present but NOT copied in duplicate_job(): {missing}. " + f"Add them to service.py duplicate_job()." + ) +# #endregion TestDuplicateJob + + +# #region TestRunSerializers [C:2] [TYPE Function] [SEMANTICS test, field-coverage, run, serialization] +# @TEST_INVARIANT: get_run_status-includes-insert_method -> VERIFIED_BY: test_get_run_status_includes_insert_method +# @TEST_INVARIANT: get_run_history-includes-insert_method -> VERIFIED_BY: test_get_run_history_includes_insert_method +class TestRunSerializers: + """Verify run dict serializers include insert_method and connection_snapshot.""" + + FIELDS = {"insert_method", "connection_snapshot"} + + def test_get_run_status_includes_insert_method(self): + """get_run_status() dict must include insert_method and connection_snapshot.""" + import inspect + from src.plugins.translate import orchestrator_aggregator + + src = inspect.getsource(orchestrator_aggregator.TranslationResultAggregator.get_run_status) + for field in self.FIELDS: + # The returned dict literal should contain the field + if f'"{field}"' not in src and f"'{field}'" not in src: + # Also check run. field access + pass + assert f'"{field}"' in src or f"'{field}'" in src or f"run.{field}" in src, ( + f"Field '{field}' missing from get_run_status() dict. " + f"Add it to orchestrator_aggregator.py get_run_status()." + ) + + def test_get_run_history_includes_insert_method(self): + """get_run_history() dict must include insert_method and connection_snapshot.""" + import inspect + from src.plugins.translate import orchestrator_query + + src = inspect.getsource(orchestrator_query.get_run_history) + for field in self.FIELDS: + assert f'"{field}"' in src or f"'{field}'" in src or f"r.{field}" in src, ( + f"Field '{field}' missing from get_run_history() dict. " + f"Add it to orchestrator_query.py get_run_history()." + ) +# #endregion TestRunSerializers + + +# #region TestRunModelColumns [C:1] [TYPE Function] [SEMANTICS test, field-coverage, orm] +# @TEST_EDGE: new column on TranslationRun without serialization -> test_catches +class TestRunModelColumns: + """Verify that new columns on TranslationRun are not forgotten.""" + + KNOWN_RUN_SERIALIZERS = { + "orchestrator_query.get_run_history": [ + "id", "job_id", "status", "trigger_type", + "started_at", "completed_at", "error_message", + "total_records", "successful_records", "failed_records", "skipped_records", + "cache_hits", "insert_status", "insert_method", "connection_snapshot", + "superset_execution_id", "created_by", "created_at", + ], + "orchestrator_aggregator.get_run_status": [ + "id", "job_id", "status", "trigger_type", + "started_at", "completed_at", "error_message", + "total_records", "successful_records", "failed_records", "skipped_records", + "cache_hits", "insert_status", "insert_method", "connection_snapshot", + "config_snapshot", + "superset_execution_id", "batch_count", "language_stats", + "event_invariants", "created_by", "created_at", + ], + } + + # ORM columns that are NOT expected in any run serializer + SKIP_RUN_COLUMNS = { + "key_hash", "config_hash", "dict_snapshot_hash", + "id", "job_id", + "records_inserted_at", "insert_batches_json", + "superset_execution_log", + } + + def test_all_run_columns_serialized(self): + """Every TranslationRun column should appear in at least one serializer.""" + orm_cols = {c.name for c in TranslationRun.__table__.columns} + expected_columns = orm_cols - self.SKIP_RUN_COLUMNS + + all_serialized = set() + for fields in self.KNOWN_RUN_SERIALIZERS.values(): + all_serialized.update(fields) + + missing = expected_columns - all_serialized + assert not missing, ( + f"TranslationRun columns not serialized in any run dict: {missing}. " + f"Add them to get_run_status() and/or get_run_history()." + ) +# #endregion TestRunModelColumns +# #endregion ResponseFieldCoverageTests diff --git a/backend/src/plugins/translate/__tests__/test_retry.py b/backend/src/plugins/translate/__tests__/test_retry.py new file mode 100644 index 00000000..17a0a5c5 --- /dev/null +++ b/backend/src/plugins/translate/__tests__/test_retry.py @@ -0,0 +1,132 @@ +# #region TestRunRetry [C:3] [TYPE Module] [SEMANTICS test,translate,retry,integration] +# @BRIEF Tests for TranslationRunRetryManager — retry_failed_batches, cancel_run. +# @RELATION BINDS_TO -> [TranslationRunRetryManager] +import uuid +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +import pytest + +from src.models.translate import TranslationBatch, TranslationJob, TranslationRun, TranslationRecord +from src.plugins.translate.orchestrator_retry import TranslationRunRetryManager +from src.plugins.translate.executor import TranslationExecutor + + +# #region TestRetryFailedBatches [C:3] [TYPE Class] +class TestRetryFailedBatches: + """Verify retry_failed_batches handles runs, batches, records correctly.""" + + def _make_manager(self, db=None): + if db is None: + db = MagicMock() + config = MagicMock() + event_log = MagicMock() + return TranslationRunRetryManager(db, config, event_log, "test_user") + + def _make_run(self, status="FAILED") -> TranslationRun: + run = MagicMock(spec=TranslationRun) + run.id = str(uuid.uuid4()) + run.job_id = str(uuid.uuid4()) + run.status = status + run.successful_records = 0 + run.failed_records = 10 + run.skipped_records = 0 + return run + + def _make_job(self) -> TranslationJob: + job = MagicMock(spec=TranslationJob) + job.id = str(uuid.uuid4()) + return job + + def _make_batch(self, run_id: str, status="FAILED", idx=0) -> TranslationBatch: + batch = MagicMock(spec=TranslationBatch) + batch.id = str(uuid.uuid4()) + batch.run_id = run_id + batch.batch_index = idx + batch.status = status + return batch + + async def test_retry_run_not_found(self): + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None + + mgr = self._make_manager(db) + with pytest.raises(ValueError, match="not found"): + await mgr.retry_failed_batches("non-existent") + + async def test_retry_job_not_found(self): + db = MagicMock() + run = self._make_run() + + def query_side_effect(model): + q = MagicMock() + if model == TranslationRun: + q.filter.return_value.first.return_value = run + elif model == TranslationJob: + q.filter.return_value.first.return_value = None + else: + q.filter.return_value.filter.return_value.all.return_value = [] + return q + + db.query.side_effect = query_side_effect + mgr = self._make_manager(db) + with pytest.raises(ValueError, match="not found"): + await mgr.retry_failed_batches(run.id) + + async def test_retry_no_failed_batches(self): + db = MagicMock() + run = self._make_run() + job = self._make_job() + + def query_side_effect(model): + q = MagicMock() + q.filter.return_value.filter.return_value.all.return_value = [] + q.filter.return_value.all.return_value = [] + if model == TranslationRun: + q.filter.return_value.first.return_value = run + elif model == TranslationJob: + q.filter.return_value.first.return_value = job + return q + + db.query.side_effect = query_side_effect + mgr = self._make_manager(db) + with pytest.raises(ValueError, match="No failed batches"): + await mgr.retry_failed_batches(run.id) + + async def test_retry_job_not_found(self): + db = MagicMock() + run = self._make_run() + + def query_side_effect(model): + q = MagicMock() + if model == TranslationRun: + q.filter.return_value.first.return_value = run + else: + q.filter.return_value.first.return_value = None + return q + + db.query.side_effect = query_side_effect + mgr = self._make_manager(db) + with pytest.raises(ValueError, match="not found"): + await mgr.retry_failed_batches(run.id) + + async def test_cancel_run(self): + db = MagicMock() + mgr = self._make_manager(db) + run = self._make_run("RUNNING") + + with patch('src.plugins.translate.orchestrator_retry._cancel_run') as mock_cancel: + mock_cancel.return_value = run + result = mgr.cancel_run(run.id) + assert result.status == "RUNNING" + mock_cancel.assert_called_once() + async def test_retry_insert(self): + db = MagicMock() + mgr = self._make_manager(db) + run = self._make_run("COMPLETED") + + with patch('src.plugins.translate.orchestrator_retry._retry_insert', new_callable=AsyncMock) as mock_retry_insert: + mock_retry_insert.return_value = run + result = await mgr.retry_insert(run.id) + assert result.status == "COMPLETED" + mock_retry_insert.assert_called_once() +# #endregion TestRetryFailedBatches diff --git a/backend/src/plugins/translate/__tests__/test_run_service.py b/backend/src/plugins/translate/__tests__/test_run_service.py new file mode 100644 index 00000000..21e37fe8 --- /dev/null +++ b/backend/src/plugins/translate/__tests__/test_run_service.py @@ -0,0 +1,295 @@ +# #region TestRunExecutionService [C:3] [TYPE Module] [SEMANTICS test,translate,run,orchestration] +# @BRIEF Tests for RunExecutionService — filter_new_keys, load_preview_edits, key_hash, lang_stats. +# @RELATION BINDS_TO -> [RunExecutionService] +import uuid +from unittest.mock import MagicMock, patch + +import pytest + +from src.models.translate import ( + TranslationJob, + TranslationRun, + TranslationRecord, + TranslationRunLanguageStats, + TranslationPreviewSession, + TranslationPreviewRecord, + TranslationPreviewLanguage, + TranslationLanguage, +) +from src.plugins.translate._run_service import RunExecutionService + + +# #region make_service [C:1] [TYPE Function] +def make_service(db=None) -> RunExecutionService: + if db is None: + db = MagicMock() + return RunExecutionService(db, MagicMock(), "test_user") + + +def make_job(**overrides) -> TranslationJob: + job = MagicMock(spec=TranslationJob) + job.id = "job-1" + job.target_key_cols = ["id"] + job.source_key_cols = [] + for k, v in overrides.items(): + setattr(job, k, v) + return job + + +def make_run(**overrides) -> TranslationRun: + run = MagicMock(spec=TranslationRun) + run.id = str(uuid.uuid4()) + run.job_id = "job-1" + run.status = "RUNNING" + for k, v in overrides.items(): + setattr(run, k, v) + return run + + +def make_record(**overrides) -> TranslationRecord: + rec = MagicMock(spec=TranslationRecord) + rec.id = str(uuid.uuid4()) + rec.run_id = "run-1" + rec.status = "SUCCESS" + rec.source_data = {"id": 1, "name": "Alice"} + rec.source_sql = "SELECT 1" + rec.target_sql = "translated" + for k, v in overrides.items(): + setattr(rec, k, v) + return rec +# #endregion make_service + + +# #region TestFilterNewKeys [C:3] [TYPE Class] +class TestFilterNewKeys: + """Verify _filter_new_keys correctly deduplicates by key columns.""" + + def test_no_prev_run_returns_all(self): + db = MagicMock() + db.query.return_value.filter.return_value.filter.return_value.order_by.return_value.first.return_value = None + + svc = make_service(db) + job = make_job(target_key_cols=["id"]) + rows = [{"source_data": {"id": 1}}, {"source_data": {"id": 2}}] + + result = svc._filter_new_keys(job, "run-1", rows) + assert len(result) == 2 + + def test_filters_duplicates(self): + db = MagicMock() + prev_run = make_run(status="COMPLETED", id="prev-run-1") + + def make_run_query(): + q = MagicMock() + # Code calls: .filter(a, b, c).order_by(...).first() + q.filter.return_value.order_by.return_value.first.return_value = prev_run + return q + + def make_rec_query(): + q = MagicMock() + rec = make_record(run_id="prev-run-1", source_data={"id": 1}) + # Code calls: .filter(a, b).all() + q.filter.return_value.all.return_value = [rec] + return q + + db.query.side_effect = lambda model: make_rec_query() if model == TranslationRecord else make_run_query() + + svc = make_service(db) + job = make_job(target_key_cols=["id"]) + rows = [ + {"source_data": {"id": 1}}, + {"source_data": {"id": 2}}, + {"source_data": {"id": 3}}, + ] + + result = svc._filter_new_keys(job, "run-1", rows) + assert len(result) == 2 + assert result[0]["source_data"]["id"] == 2 + + def test_all_duplicates_returns_empty(self): + db = MagicMock() + prev_run = make_run(status="COMPLETED", id="prev-run-2") + + def make_run_query(): + q = MagicMock() + q.filter.return_value.order_by.return_value.first.return_value = prev_run + return q + + def make_rec_query(): + q = MagicMock() + rec1 = make_record(run_id="prev-run-2", source_data={"id": 1}) + rec2 = make_record(run_id="prev-run-2", source_data={"id": 2}) + q.filter.return_value.all.return_value = [rec1, rec2] + return q + + db.query.side_effect = lambda model: make_rec_query() if model == TranslationRecord else make_run_query() + + svc = make_service(db) + job = make_job(target_key_cols=["id"]) + rows = [ + {"source_data": {"id": 1}}, + {"source_data": {"id": 2}}, + ] + + result = svc._filter_new_keys(job, "run-1", rows) + assert len(result) == 0 + + def test_no_key_cols_returns_all(self): + db = MagicMock() + svc = make_service(db) + job = make_job(target_key_cols=[], source_key_cols=[]) + rows = [{"source_data": {"id": 1}}] + + result = svc._filter_new_keys(job, "run-1", rows) + assert len(result) == 1 + + def test_missing_source_data_handles_gracefully(self): + db = MagicMock() + db.query.return_value.filter.return_value.filter.return_value.order_by.return_value.first.return_value = None + + svc = make_service(db) + job = make_job(target_key_cols=["id"]) + rows = [{"source_data": None}, {}] + + result = svc._filter_new_keys(job, "run-1", rows) + assert len(result) == 2 +# #endregion TestFilterNewKeys + + +# #region TestLoadPreviewEdits [C:3] [TYPE Class] +class TestLoadPreviewEdits: + """Verify _load_preview_edits loads approved/edited preview translations.""" + + def test_no_session_returns_empty_cache(self): + db = MagicMock() + db.query.return_value.filter.return_value.filter.return_value.order_by.return_value.first.return_value = None + + svc = make_service(db) + svc._load_preview_edits("job-1") + assert svc._preview_edits_cache == {} + + def test_loads_edited_languages(self): + db = MagicMock() + + def query_side_effect(model): + q = MagicMock() + if model == TranslationPreviewSession: + session = MagicMock(spec=TranslationPreviewSession) + session.id = "preview-1" + # Code calls: .filter(a, b).order_by(...).first() + q.filter.return_value.order_by.return_value.first.return_value = session + elif model == TranslationPreviewRecord: + lang_entry = MagicMock() + lang_entry.language_code = "ru" + lang_entry.status = "edited" + lang_entry.user_edit = "исправленный" + lang_entry.final_value = "исправленный" + + record = MagicMock() + record.source_data = {"id": 1} + record.languages = [lang_entry] + # Code calls: .filter(a).all() + q.filter.return_value.all.return_value = [record] + return q + + db.query.side_effect = query_side_effect + + svc = make_service(db) + svc._load_preview_edits("job-1") + assert svc._preview_edits_cache is not None + assert len(svc._preview_edits_cache) == 1 + + def test_approved_language_loaded(self): + db = MagicMock() + + def query_side_effect(model): + q = MagicMock() + if model == TranslationPreviewSession: + session = MagicMock(spec=TranslationPreviewSession) + session.id = "preview-1" + q.filter.return_value.order_by.return_value.first.return_value = session + elif model == TranslationPreviewRecord: + lang_entry = MagicMock() + lang_entry.language_code = "de" + lang_entry.status = "approved" + lang_entry.user_edit = "Guten Tag" + lang_entry.final_value = "Guten Tag" + + record = MagicMock() + record.source_data = {"id": 2} + record.languages = [lang_entry] + q.filter.return_value.all.return_value = [record] + return q + + db.query.side_effect = query_side_effect + + svc = make_service(db) + svc._load_preview_edits("job-1") + assert len(svc._preview_edits_cache) == 1 +# #endregion TestLoadPreviewEdits + + +# #region TestKeyHash [C:3] [TYPE Class] +class TestKeyHash: + """Verify _compute_key_hash produces consistent hashes.""" + + def test_hash_consistent(self): + svc = make_service() + h1 = svc._compute_key_hash({"id": 1, "name": "Alice"}) + h2 = svc._compute_key_hash({"name": "Alice", "id": 1}) + assert h1 == h2 # same data, sorted keys + + def test_hash_different_data(self): + svc = make_service() + h1 = svc._compute_key_hash({"id": 1}) + h2 = svc._compute_key_hash({"id": 2}) + assert h1 != h2 + + def test_hash_empty_dict(self): + svc = make_service() + h = svc._compute_key_hash({}) + assert isinstance(h, str) + assert len(h) == 16 +# #endregion TestKeyHash + + +# #region TestUpdateLangStatsIncremental [C:3] [TYPE Class] +class TestUpdateLangStatsIncremental: + """Verify _update_language_stats_incremental aggregates correctly.""" + + def test_updates_stats(self): + db = MagicMock() + record = make_record() + + def query_side_effect(model): + q = MagicMock() + if model == TranslationRecord: + # Code calls: .filter(run_id == ...).all() + q.filter.return_value.all.return_value = [record] + elif model == TranslationLanguage: + lang = MagicMock() + lang.language_code = "ru" + lang.status = "translated" + lang.translated_value = "Привет" + # Code calls: .filter(record_id.in_(...)).all() + q.filter.return_value.all.return_value = [lang] + return q + + db.query.side_effect = query_side_effect + + svc = make_service(db) + ru_stat = MagicMock(spec=TranslationRunLanguageStats) + svc._update_language_stats_incremental("run-1", {"ru": ru_stat}) + + assert ru_stat.total_rows == 1 + assert ru_stat.translated_rows == 1 + + def test_no_records_returns_early(self): + db = MagicMock() + db.query.return_value.filter.return_value.all.return_value = [] + + svc = make_service(db) + svc._update_language_stats_incremental("run-1", {"ru": MagicMock()}) + # Should not query language entries when there are no records +# #endregion TestUpdateLangStatsIncremental +# #endregion TestRunExecutionService diff --git a/backend/src/plugins/translate/__tests__/test_sql_insert_service.py b/backend/src/plugins/translate/__tests__/test_sql_insert_service.py new file mode 100644 index 00000000..488601eb --- /dev/null +++ b/backend/src/plugins/translate/__tests__/test_sql_insert_service.py @@ -0,0 +1,221 @@ +# #region TestSQLInsertService [C:3] [TYPE Module] [SEMANTICS test,translate,sql,insert] +# @BRIEF Tests for SQLInsertService — SQL generation, Superset/direct DB execution. +# @RELATION BINDS_TO -> [SQLInsertService] +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from src.models.translate import TranslationJob, TranslationRun, TranslationRecord +from src.plugins.translate.orchestrator_sql import SQLInsertService + + +# #region TestSQLInsertService [C:3] [TYPE Class] +@pytest.mark.asyncio +class TestSQLInsertService: + """Verify SQLInsertService handles generation and execution.""" + + def _make_service(self, db=None): + if db is None: + db = MagicMock() + config = MagicMock() + event_log = MagicMock() + return SQLInsertService(db, config, event_log) + + def _make_job(self, **kwargs) -> TranslationJob: + job = MagicMock(spec=TranslationJob) + job.id = "job-1" + job.target_column = "translated_text" + job.translation_column = "source_text" + job.target_languages = ["ru"] + job.target_schema = "public" + job.target_table = "translations" + job.target_key_cols = [] + job.upsert_strategy = "INSERT" + job.insert_method = "sqllab" + job.connection_id = None + job.environment_id = "env-1" + job.source_dialect = "postgresql" + job.database_dialect = None + job.target_dialect = None + for k, v in kwargs.items(): + setattr(job, k, v) + return job + + def _make_run(self) -> TranslationRun: + run = MagicMock(spec=TranslationRun) + run.id = "run-1" + return run + + async def test_no_records_returns_skipped(self): + db = MagicMock() + db.query.return_value.options.return_value.filter.return_value.all.return_value = [] + service = self._make_service(db) + job = self._make_job() + run = self._make_run() + + result = await service.generate_and_insert_sql(job, run) + + assert result["status"] == "skipped" + assert result["reason"] == "no_records" + + async def test_sql_generation_failure_returns_failed(self): + db = MagicMock() + record = MagicMock(spec=TranslationRecord) + record.target_sql = "test" + db.query.return_value.options.return_value.filter.return_value.all.return_value = [record] + + service = self._make_service(db) + job = self._make_job() + run = self._make_run() + + with patch('src.plugins.translate.orchestrator_sql.SQLGenerator.generate', + side_effect=ValueError("Invalid SQL")): + with patch('src.plugins.translate.orchestrator_sql.build_columns', return_value=["col1"]): + with patch('src.plugins.translate.orchestrator_sql.build_rows', return_value=[{"col1": "val1"}]): + with patch('src.plugins.translate.orchestrator_sql.build_context_keys', return_value=[]): + result = await service.generate_and_insert_sql(job, run) + + assert result["status"] == "failed" + assert "Invalid SQL" in result["error_message"] + + async def test_direct_db_connection_not_found(self): + db = MagicMock() + record = MagicMock(spec=TranslationRecord) + record.target_sql = "test" + db.query.return_value.options.return_value.filter.return_value.all.return_value = [record] + + service = self._make_service(db) + job = self._make_job(insert_method="direct_db", connection_id="conn-1") + run = self._make_run() + + with patch('src.plugins.translate.orchestrator_sql.SQLGenerator.generate', + return_value=("INSERT INTO ...", 1)): + with patch('src.plugins.translate.orchestrator_sql.build_columns', return_value=["col1"]): + with patch('src.plugins.translate.orchestrator_sql.build_rows', return_value=[{"col1": "val1"}]): + with patch('src.plugins.translate.orchestrator_sql.build_context_keys', return_value=[]): + with patch('src.plugins.translate.orchestrator_sql.ConnectionService') as mock_conn: + mock_conn.return_value.get_connection.return_value = None + result = await service.generate_and_insert_sql(job, run) + + assert result["status"] == "failed" + assert "not found" in result["error_message"] + + async def test_resolve_dialect_fallback_to_default(self): + service = self._make_service() + job = self._make_job(database_dialect=None, target_dialect=None) + + with patch('src.plugins.translate.orchestrator_sql.SupersetSqlLabExecutor') as mock_exec: + mock_exec.return_value.resolve_database_id = AsyncMock() + mock_exec.return_value.get_database_backend = MagicMock(return_value=None) + dialect = await service._resolve_dialect(job) + + assert dialect == "postgresql" + + async def test_superset_sqllab_exception_returns_failed(self): + db = MagicMock() + service = self._make_service(db) + job = self._make_job() + run = self._make_run() + + with patch('src.plugins.translate.orchestrator_sql.SupersetSqlLabExecutor') as mock_exec: + instance = mock_exec.return_value + instance.resolve_database_id = AsyncMock() + instance.execute_and_poll = AsyncMock(side_effect=Exception("Superset API error")) + + result = await service._execute_superset_sqllab(job, run, "INSERT INTO ...") + + assert result["status"] == "failed" + assert "Superset API error" in result["error_message"] + + async def test_direct_db_success(self): + db = MagicMock() + service = self._make_service(db) + job = self._make_job(insert_method="direct_db", connection_id="conn-1") + run = self._make_run() + + with patch('src.plugins.translate.orchestrator_sql.ConnectionService') as mock_conn_cls: + mock_conn = MagicMock() + mock_conn.name = "ch_test" + mock_conn.dialect = "clickhouse" + mock_conn.host = "localhost" + mock_conn.port = 8123 + mock_conn.database = "test_db" + mock_conn_cls.return_value.get_connection.return_value = mock_conn + + with patch('src.plugins.translate.orchestrator_sql.DbExecutor') as mock_exec_cls: + mock_result = MagicMock() + mock_result.success = True + mock_result.rows_affected = 10 + mock_result.execution_time_ms = 50 + mock_exec_cls.return_value.execute_sql.return_value = mock_result + + result = await service._execute_direct_db(job, run, "INSERT INTO ...") + + assert result["status"] == "success" + assert result["rows_affected"] == 10 + assert result["connection_name"] == "ch_test" + assert run.connection_snapshot is not None + assert run.insert_method == "direct_db" + assert db.commit.called + + async def test_direct_db_execution_failure(self): + db = MagicMock() + service = self._make_service(db) + job = self._make_job(insert_method="direct_db", connection_id="conn-1") + run = self._make_run() + + with patch('src.plugins.translate.orchestrator_sql.ConnectionService') as mock_conn_cls: + mock_conn = MagicMock() + mock_conn.name = "ch_test" + mock_conn.dialect = "clickhouse" + mock_conn.host = "localhost" + mock_conn.port = 8123 + mock_conn.database = "test_db" + mock_conn_cls.return_value.get_connection.return_value = mock_conn + + with patch('src.plugins.translate.orchestrator_sql.DbExecutor') as mock_exec_cls: + mock_result = MagicMock() + mock_result.success = False + mock_result.error = "DB timeout" + mock_result.execution_time_ms = 5000 + mock_exec_cls.return_value.execute_sql.return_value = mock_result + + result = await service._execute_direct_db(job, run, "INSERT INTO ...") + + assert result["status"] == "failed" + assert "DB timeout" in result["error_message"] + assert result["execution_time_ms"] == 5000 + + async def test_resolve_dialect_direct_db_connection(self): + service = self._make_service() + job = self._make_job(insert_method="direct_db", connection_id="conn-1") + + with patch('src.plugins.translate.orchestrator_sql.ConnectionService') as mock_conn_cls: + mock_conn = MagicMock() + mock_conn.dialect = "clickhouse" + mock_conn_cls.return_value.get_connection.return_value = mock_conn + + dialect = await service._resolve_dialect(job) + assert dialect == "clickhouse" + + async def test_empty_columns_fallback(self): + db = MagicMock() + record = MagicMock() + record.target_sql = "test" + db.query.return_value.options.return_value.filter.return_value.all.return_value = [record] + + service = self._make_service(db) + job = self._make_job(target_column=None, translation_column="source_text") + run = self._make_run() + + with patch('src.plugins.translate.orchestrator_sql.SQLGenerator.generate', + return_value=("INSERT INTO ... VALUES ('test')", 1)) as mock_gen: + with patch('src.plugins.translate.orchestrator_sql.build_columns', return_value=[]): + with patch('src.plugins.translate.orchestrator_sql.build_rows', return_value=[]): + with patch('src.plugins.translate.orchestrator_sql.build_context_keys', return_value=[]): + with patch.object(service, '_resolve_dialect', AsyncMock(return_value="postgresql")): + with patch.object(service, '_execute_superset_sqllab', AsyncMock(return_value={"status": "success"})): + result = await service.generate_and_insert_sql(job, run) + + assert result["status"] == "success" + mock_gen.assert_called_once() +# #endregion TestSQLInsertService diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cdeb67a2..796ca6f1 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -74,6 +74,15 @@ def pytest_configure(config): def pytest_unconfigure(config): + try: + from src.core.database import engine as _global_engine + from src.core.database import tasks_engine as _tasks_engine + from src.core.database import auth_engine as _auth_engine + _global_engine.dispose() + _tasks_engine.dispose() + _auth_engine.dispose() + except Exception: + pass try: os.unlink(_TEST_DB_PATH) except Exception: diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index 20588132..fba8ea47 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -424,4 +424,64 @@ def superset_admin_headers(superset_url, superset_admin_password): # #endregion superset_admin_headers +# ── JWT-based Superset Fixtures ──────────────────────────────────── +# ADR-0012: после установки psycopg2 + superset_config.py + Docker bridge IP, +# JWT-авторизация (/api/v1/security/login) работает в тестовом контейнере. + + +# #region superset_jwt_headers [C:2] [TYPE Fixture] +# @BRIEF Function-scoped — JWT Bearer token headers for Superset REST API. +# @POST Returns dict with Authorization: Bearer . +@pytest.fixture +def superset_jwt_headers(superset_url, superset_admin_password): + """Obtain JWT access token from the running Superset container.""" + resp = requests.post( + f"{superset_url}/api/v1/security/login", + json={ + "username": "admin", + "password": superset_admin_password, + "provider": "db", + }, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + access_token = data["access_token"] + assert access_token, "No access_token in JWT response" + return {"Authorization": f"Bearer {access_token}"} +# #endregion superset_jwt_headers + + +# #region superset_env [C:2] [TYPE Fixture] +# @BRIEF Function-scoped — real Environment pointing to the running Superset container. +@pytest.fixture +def superset_env(superset_url, superset_admin_password): + """Create a real Environment config pointing to the Superset container.""" + from src.core.config_models import Environment + + return Environment( + id="test_env", + name="Test Superset Container", + url=superset_url, + username="admin", + password=superset_admin_password, + verify_ssl=False, + timeout=30, + ) +# #endregion superset_env + + +# #region superset_client [C:2] [TYPE Fixture] +# @BRIEF Function-scoped — real SupersetClient connected to the container. +@pytest.fixture +async def superset_client(superset_env): + """Create and authenticate a SupersetClient against the real container.""" + from src.core.superset_client import SupersetClient + + client = SupersetClient(superset_env) + await client.authenticate() + return client +# #endregion superset_client + + # #endregion IntegrationTestConftest diff --git a/backend/tests/integration/test_superset_sqllab_api_integration.py b/backend/tests/integration/test_superset_sqllab_api_integration.py index 219913f3..a142617b 100644 --- a/backend/tests/integration/test_superset_sqllab_api_integration.py +++ b/backend/tests/integration/test_superset_sqllab_api_integration.py @@ -1,257 +1,255 @@ -# #region TestSupersetSqllabApi [C:4] [TYPE Module] [SEMANTICS test,superset,sqllab,integration] -# @BRIEF Integration tests for Superset SQL Lab REST API using real Testcontainers Superset. +# #region TestSupersetSqllabApiJwt [C:4] [TYPE Module] [SEMANTICS test,superset,sqllab,integration,jwt] +# @BRIEF Integration tests for Superset REST API using JWT Bearer auth against real Testcontainers. # @RELATION BINDS_TO -> [SupersetSqlLabExecutor] # @RELATION BINDS_TO -> [SupersetClient] # @RELATION BINDS_TO -> [ConfigManager] # -# @TEST_CONTRACT SupersetSqlLabApi -> +# @TEST_CONTRACT SupersetSqllabApi -> # { # invariants: [ -# "GET /api/v1/database/ returns paginated database list after login", +# "JWT login returns access_token + refresh_token", +# "Bearer token authorizes all /api/v1/ endpoints", +# "GET /api/v1/database/ returns paginated database list", # "POST /api/v1/sqllab/execute/ accepts SQL and returns query_id", -# "Form-based session auth works for all API v1 endpoints", -# "CSRF token acquisition from login page enables API mutation endpoints" +# "GET /api/v1/dashboard/ returns paginated list with Bearer token" # ] # } -# @TEST_EDGE: missing_csrf_token -> API calls without CSRF return 403 -# @TEST_EDGE: wrong_database_id -> SQL execution returns 404 -# @TEST_EDGE: unauthenticated_sqllab -> 401 without session +# @TEST_EDGE: wrong_password -> 401 on JWT login +# @TEST_EDGE: expired_token -> 401 on protected endpoints +# @TEST_EDGE: missing_database_id -> 400 on sqllab execute # -# @RATIONALE Smoke tests for the real Superset 4.1.2 SQL Lab API to verify -# our integration assumptions match actual behavior. JWT auth is unavailable -# in the test container (ADR-0012), so we use form-based session auth instead. -# The raw REST API tests complement the smoke tests already in -# test_superset_integration.py. +# @RATIONALE ADR-0012 resolved the JWT auth issue via psycopg2 + superset_config.py + +# Docker bridge IP. Now JWT works end-to-end with the test container. +# All API tests use real Bearer tokens against the live Superset instance. # # @REQUIRES Docker daemon running (testcontainers starts superset + postgres) -# -# @TEST_INVARIANT sqllab_execute_accepts_sql -> VERIFIED_BY: [test_sqllab_execute_endpoint_reachable] -# @TEST_INVARIANT form_auth_works_for_api -> VERIFIED_BY: [test_list_databases_after_form_login] -import re -import uuid - import pytest import requests +from uuid import uuid4 -# #region TestSupersetSqllabRawApi [C:3] [TYPE Class] -# @BRIEF Verify Superset SQL Lab REST API endpoints with form-based session auth. -class TestSupersetSqllabRawApi: - """Integration tests for Superset SQL Lab REST API with session auth.""" +# #region TestSupersetJwtHealthCheck [C:3] [TYPE Class] +# @BRIEF Verify health and JWT login work correctly. +class TestSupersetJwtHealthCheck: + """JWT auth smoke tests against the real Superset container.""" - # #region _form_login [C:1] [TYPE Function] - # @BRIEF Helper: log into Superset via form and return authenticated session. - def _form_login(self, superset_url, superset_admin_password) -> requests.Session: - session = requests.Session() - - # Step 1: Get CSRF token from login page - resp = session.get(f"{superset_url}/login/", timeout=10) - resp.raise_for_status() - - csrf_match = re.search(r'csrf_token["\s:=]+[\'"]([^\'"]+)', resp.text) - csrf_token = csrf_match.group(1) if csrf_match else "" - - # Step 2: Submit login form - resp = session.post( - f"{superset_url}/login/", - data={ - "username": "admin", - "password": superset_admin_password, - "csrf_token": csrf_token, - }, - headers={"Referer": f"{superset_url}/login/"}, - timeout=10, - allow_redirects=True, - ) - resp.raise_for_status() - - # Verify session cookie is set - assert session.cookies.get("session") is not None - return session - # #endregion _form_login - - - # #region test_list_databases_after_form_login [C:2] [TYPE Function] - # @BRIEF GET /api/v1/database/ returns paginated list after form login. - # @TEST_EDGE: form_auth_works_for_api — VERIFIED_BY: test_list_databases_after_form_login - def test_list_databases_after_form_login(self, superset_url, superset_admin_password): - session = self._form_login(superset_url, superset_admin_password) - - resp = session.get( - f"{superset_url}/api/v1/database/", - timeout=10, - allow_redirects=False, - ) - # After form login, Superset API should accept session cookie - assert resp.status_code in (200, 401), \ - f"Expected 200/401, got {resp.status_code}" - - if resp.status_code == 200: - data = resp.json() - # Should contain paginated result - assert "result" in data or "count" in data, \ - f"Unexpected response shape: {list(data.keys())}" - # #endregion test_list_databases_after_form_login - - - # #region test_sqllab_execute_endpoint_reachable [C:2] [TYPE Function] - # @BRIEF POST /api/v1/sqllab/execute/ is reachable (may fail on DB not found — that's OK). - # @TEST_INVARIANT sqllab_execute_accepts_sql — VERIFIED_BY: test_sqllab_execute_endpoint_reachable - def test_sqllab_execute_endpoint_reachable(self, superset_url, superset_admin_password): - session = self._form_login(superset_url, superset_admin_password) - - # We don't have a configured database in the container, but the endpoint - # should be reachable and return a structured error (not 404) - payload = { - "database_id": 1, - "sql": "SELECT 1", - "runAsync": True, - } - - resp = session.post( - f"{superset_url}/api/v1/sqllab/execute/", - json=payload, - headers={ - "Referer": f"{superset_url}/", - "Content-Type": "application/json", - }, - timeout=10, - allow_redirects=False, - ) - # The endpoint exists: returns 200/400/500 depending on DB state - # 200 = execution started, 400 = bad request, 500 = DB not found - assert resp.status_code in (200, 400, 500), \ - (f"sqllab/execute/ returned unexpected {resp.status_code}: " - f"{resp.text[:200]}") - # #endregion test_sqllab_execute_endpoint_reachable - - - # #region test_sqllab_execute_rejects_naive_sql [C:2] [TYPE Function] - # @BRIEF POST /api/v1/sqllab/execute/ with missing database_id returns error. - # @TEST_EDGE: missing_database_id — VERIFIED_BY: test_sqllab_execute_rejects_naive_sql - def test_sqllab_execute_rejects_naive_sql(self, superset_url, superset_admin_password): - session = self._form_login(superset_url, superset_admin_password) - - # No database_id in payload - resp = session.post( - f"{superset_url}/api/v1/sqllab/execute/", - json={"sql": "SELECT 1"}, - headers={ - "Referer": f"{superset_url}/", - "Content-Type": "application/json", - }, - timeout=10, - allow_redirects=False, - ) - # Should return 400 (bad request) because database_id is required - assert resp.status_code in (400, 500), \ - f"Expected 400/500, got {resp.status_code}: {resp.text[:200]}" - # #endregion test_sqllab_execute_rejects_naive_sql - - - # #region test_csrf_token_required_for_mutations [C:2] [TYPE Function] - # @BRIEF POST without CSRF token returns 403. - # @TEST_EDGE: missing_csrf_token — VERIFIED_BY: test_csrf_token_required_for_mutations - def test_csrf_token_required_for_mutations(self, superset_url, superset_admin_password): - session = self._form_login(superset_url, superset_admin_password) - - # POST without the CSRF header should fail - resp = session.post( - f"{superset_url}/api/v1/sqllab/execute/", - json={"sql": "SELECT 1", "database_id": 1}, - # NOTE: requests.Session automatically handles cookies, - # but Superset also requires a CSRF token in the header - timeout=10, - allow_redirects=False, - ) - # Without Referer or X-CSRFToken, Superset should reject with 403 - assert resp.status_code != 200, \ - "Expected CSRF protection to block missing header" - # #endregion test_csrf_token_required_for_mutations - - - # #region test_database_columns_endpoint [C:2] [TYPE Function] - # @BRIEF GET /api/v1/database//columns/ for non-existent DB returns 404. - def test_database_columns_endpoint(self, superset_url, superset_admin_password): - session = self._form_login(superset_url, superset_admin_password) - - resp = session.get( - f"{superset_url}/api/v1/database/99999/columns/", - timeout=10, - allow_redirects=False, - ) - assert resp.status_code in (404, 401, 500), \ - f"Expected 404/401/500, got {resp.status_code}" - # #endregion test_database_columns_endpoint - - -# #endregion TestSupersetSqllabRawApi - - -# #region TestConfigManagerSupersetIntegration [C:3] [TYPE Class] -# @BRIEF Verify ConfigManager integration with real Superset URL/credentials. -class TestConfigManagerSupersetIntegration: - """Integration tests for ConfigManager with real Superset container.""" - - # #region test_config_manager_superset_url [C:2] [TYPE Function] - # @BRIEF ConfigManager with test environment connected to real Superset container. - def test_config_manager_superset_url( - self, superset_url, superset_admin_password, mock_config_manager - ): - """Verify mock_config_manager's environment URL matches real Superset.""" - env = mock_config_manager.get_environment("test_env") - assert env is not None - assert env.url is not None - # The mock_config_manager's URL can be overridden to match the container - # This test confirms the fixture wiring is correct - assert len(env.url) > 0 - # #endregion test_config_manager_superset_url - - -# #endregion TestConfigManagerSupersetIntegration - - -# #region TestSupersetClientRawApi [C:3] [TYPE Class] -# @BRIEF Verify SupersetClient can communicate with real container using JWT (known to fail). -# @RATIONALE Document the JWT auth limitation: Superset 4.1.2 `fab create-admin` user -# cannot obtain JWT token via /api/v1/security/login. This test proves the limitation -# and prevents regression if a future Superset version fixes it. -# @REJECTED Attempting to work around JWT limitation — it's a Superset/FAB permission -# issue that cannot be solved at the client level. -class TestSupersetClientRawApi: - """Document JWT auth limitation with the test container.""" - - # #region test_jwt_login_fails_as_expected [C:2] [TYPE Function] - # @BRIEF POST /api/v1/security/login returns 401 — documented limitation. - # @TEST_EDGE: jwt_auth_limited — VERIFIED_BY: test_jwt_login_fails_as_expected - def test_jwt_login_fails_as_expected(self, superset_url, superset_admin_password): - """Verify JWT auth fails as documented in ADR-0012.""" + # #region test_jwt_login_returns_token [C:2] [TYPE Function] + # @BRIEF POST /api/v1/security/login returns access_token with refresh flag. + def test_jwt_login_returns_token(self, superset_url, superset_admin_password): resp = requests.post( f"{superset_url}/api/v1/security/login", json={ "username": "admin", "password": superset_admin_password, "provider": "db", + "refresh": True, }, timeout=10, ) - assert resp.status_code == 401, \ - (f"JWT login returned {resp.status_code} — if this passes, " - f"update ADR-0012 because JWT auth is now working!") - assert "Not authorized" in resp.text or "401" in resp.text, \ - f"Unexpected error: {resp.text[:200]}" - # #endregion test_jwt_login_fails_as_expected + assert resp.status_code == 200, f"JWT login failed: {resp.status_code} {resp.text}" + data = resp.json() + assert "access_token" in data, f"No access_token in response: {data.keys()}" + assert "refresh_token" in data, f"No refresh_token in response: {data.keys()}" + assert len(data["access_token"]) > 20, "access_token too short" + # #endregion test_jwt_login_returns_token - # #region test_health_still_works_without_auth [C:2] [TYPE Function] - # @BRIEF /health does not require authentication — regression guard. - def test_health_still_works_without_auth(self, superset_url): + # #region test_jwt_login_wrong_password [C:2] [TYPE Function] + # @BRIEF POST /api/v1/security/login with wrong password returns 401. + # @TEST_EDGE: wrong_password — VERIFIED_BY: test_jwt_login_wrong_password + def test_jwt_login_wrong_password(self, superset_url): + resp = requests.post( + f"{superset_url}/api/v1/security/login", + json={ + "username": "admin", + "password": "wrong_password_123", + "provider": "db", + }, + timeout=10, + ) + assert resp.status_code == 401, f"Expected 401, got {resp.status_code}" + # #endregion test_jwt_login_wrong_password + + + # #region test_health_still_works [C:2] [TYPE Function] + # @BRIEF /health does not require auth — regression guard. + def test_health_still_works(self, superset_url): resp = requests.get(f"{superset_url}/health", timeout=10) assert resp.status_code == 200 assert resp.text.strip() == "OK" - # #endregion test_health_still_works_without_auth + # #endregion test_health_still_works -# #endregion TestSupersetClientRawApi +# #endregion TestSupersetJwtHealthCheck -# #endregion TestSupersetSqllabApi + +# #region TestSupersetApiWithJwt [C:3] [TYPE Class] +# @BRIEF Verify REST API endpoints with JWT Bearer token auth. +class TestSupersetApiWithJwt: + """Authenticated API tests against real Superset container.""" + + # #region test_list_databases_with_jwt [C:2] [TYPE Function] + # @BRIEF GET /api/v1/database/ returns paginated list with JWT Bearer token. + def test_list_databases_with_jwt(self, superset_url, superset_jwt_headers): + resp = requests.get( + f"{superset_url}/api/v1/database/", + headers=superset_jwt_headers, + timeout=10, + ) + assert resp.status_code == 200, \ + f"GET /api/v1/database/ failed: {resp.status_code} {resp.text[:200]}" + data = resp.json() + assert "result" in data, f"No 'result' key: {list(data.keys())}" + assert isinstance(data["result"], list), "result should be a list" + # #endregion test_list_databases_with_jwt + + + # #region test_list_dashboards_with_jwt [C:2] [TYPE Function] + # @BRIEF GET /api/v1/dashboard/ returns paginated list with JWT Bearer token. + def test_list_dashboards_with_jwt(self, superset_url, superset_jwt_headers): + resp = requests.get( + f"{superset_url}/api/v1/dashboard/", + headers=superset_jwt_headers, + timeout=10, + ) + assert resp.status_code == 200, \ + f"GET /api/v1/dashboard/ failed: {resp.status_code} {resp.text[:200]}" + data = resp.json() + assert "result" in data, f"No 'result' key: {list(data.keys())}" + assert "count" in data, f"No 'count' key: {list(data.keys())}" + # #endregion test_list_dashboards_with_jwt + + + # #region test_sqllab_execute_with_jwt [C:2] [TYPE Function] + # @BRIEF POST /api/v1/sqllab/execute/ is reachable with JWT. + def test_sqllab_execute_with_jwt(self, superset_url, superset_jwt_headers): + payload = { + "database_id": 1, + "sql": "SELECT 1", + "runAsync": True, + } + resp = requests.post( + f"{superset_url}/api/v1/sqllab/execute/", + json=payload, + headers={ + **superset_jwt_headers, + "Content-Type": "application/json", + }, + timeout=10, + ) + # Without a real DB configured, we expect 400 or 500 + # But the endpoint exists and is reachable (not 404/401) + assert resp.status_code in (200, 400, 500), \ + f"sqllab/execute/ returned {resp.status_code}: {resp.text[:300]}" + # #endregion test_sqllab_execute_with_jwt + + + # #region test_sqllab_without_database_id [C:2] [TYPE Function] + # @BRIEF POST /api/v1/sqllab/execute/ without database_id returns 400. + # @TEST_EDGE: missing_database_id — VERIFIED_BY: test_sqllab_without_database_id + def test_sqllab_without_database_id(self, superset_url, superset_jwt_headers): + resp = requests.post( + f"{superset_url}/api/v1/sqllab/execute/", + json={"sql": "SELECT 1"}, + headers={ + **superset_jwt_headers, + "Content-Type": "application/json", + }, + timeout=10, + ) + assert resp.status_code in (400, 500), \ + f"Expected 400/500, got {resp.status_code}: {resp.text[:200]}" + # #endregion test_sqllab_without_database_id + + + # #region test_get_nonexistent_database [C:2] [TYPE Function] + # @BRIEF GET /api/v1/database/ returns 404. + def test_get_nonexistent_database(self, superset_url, superset_jwt_headers): + resp = requests.get( + f"{superset_url}/api/v1/database/99999", + headers=superset_jwt_headers, + timeout=10, + ) + assert resp.status_code == 404, \ + f"Expected 404, got {resp.status_code}: {resp.text[:200]}" + # #endregion test_get_nonexistent_database + + + # #region test_me_endpoint [C:2] [TYPE Function] + # @BRIEF GET /api/v1/me/ — в Superset 4.1.2 это публичный эндпоинт, но + # доступен только авторизованным через JWT. Проверяем что 401/200, а не 404. + def test_me_endpoint(self, superset_url, superset_jwt_headers): + resp = requests.get( + f"{superset_url}/api/v1/me/", + headers=superset_jwt_headers, + timeout=10, + ) + # Superset 4.1.2 может вернуть 200 (есть endpoint) или 401 (нет прав у admin role). + # Главное — не 404, значит endpoint существует. + assert resp.status_code in (200, 401, 403), \ + f"GET /api/v1/me/ failed: {resp.status_code} {resp.text[:200]}" + if resp.status_code == 200: + data = resp.json() + assert "result" in data, f"No 'result' key: {list(data.keys())}" + # #endregion test_me_endpoint + + + # #region test_database_columns_with_jwt [C:2] [TYPE Function] + # @BRIEF GET /api/v1/database//columns/ with valid id returns 404 (no DB configured). + def test_database_columns_with_jwt(self, superset_url, superset_jwt_headers): + resp = requests.get( + f"{superset_url}/api/v1/database/99999/columns/", + headers=superset_jwt_headers, + timeout=10, + ) + assert resp.status_code == 404, \ + f"Expected 404, got {resp.status_code}" + # #endregion test_database_columns_with_jwt + + + # #region test_unauthenticated_api_rejected [C:2] [TYPE Function] + # @BRIEF GET /api/v1/dashboard/ without auth returns 401. + def test_unauthenticated_api_rejected(self, superset_url): + resp = requests.get( + f"{superset_url}/api/v1/dashboard/", + timeout=10, + ) + assert resp.status_code == 401, \ + f"Expected 401, got {resp.status_code}" + # #endregion test_unauthenticated_api_rejected + + +# #endregion TestSupersetApiWithJwt + + +# #region TestSupersetJwtIntegration [C:3] [TYPE Class] +# @BRIEF Verify SupersetClient and SupersetSqlLabExecutor with real JWT auth. +class TestSupersetJwtIntegration: + """Full SupersetClient integration tests with JWT auth.""" + + # #region test_superset_client_authenticated [C:2] [TYPE Function] + # @BRIEF Real SupersetClient authenticates and can call API. + @pytest.mark.asyncio + async def test_superset_client_authenticated(self, superset_client): + # Authenticated client should be able to list databases + count, databases = await superset_client.get_databases() + assert isinstance(count, int) + assert isinstance(databases, list) + # #endregion test_superset_client_authenticated + + + # #region test_superset_client_user_via_me_endpoint [C:2] [TYPE Function] + # @BRIEF SupersetClient can call /me/ raw endpoint — проверяем доступность. + @pytest.mark.asyncio + async def test_superset_client_user_via_me_endpoint(self, superset_client): + me = await superset_client.client.request("GET", "/me/") + assert me is not None + # Superset 4.1.2 может вернуть 200 (есть endpoint) или {"message":"Not authorized"} + # в зависимости от прав роли Admin на конкретный endpoint + # #endregion test_superset_client_user_via_me_endpoint + + +# #endregion TestSupersetJwtIntegration + + +# #endregion TestSupersetSqllabApiJwt diff --git a/backend/tests/integration/test_superset_sqllab_e2e.py b/backend/tests/integration/test_superset_sqllab_e2e.py new file mode 100644 index 00000000..0d65bdfa --- /dev/null +++ b/backend/tests/integration/test_superset_sqllab_e2e.py @@ -0,0 +1,227 @@ +# #region SupersetSqllabE2E [C:5] [TYPE Module] [SEMANTICS test,superset,sqllab,e2e,execution] +# @BRIEF End-to-end: register real PostgreSQL in Superset → execute SQL via SupersetClient. +# @RELATION BINDS_TO -> [SupersetSqlLabExecutor] +# @RELATION BINDS_TO -> [SupersetClient] +# +# @TEST_CONTRACT SupersetSqlLabE2E -> +# { +# invariants: [ +# "SupersetClient.create_database() registers a real Postgres DB via REST API", +# "SupersetClient handles JWT + CSRF internally for mutation endpoints", +# "SQL Lab execute returns query_id for valid SQL on registered DB", +# "SupersetSqlLabExecutor can resolve DB and execute SQL end-to-end" +# ] +# } +# +# @REQUIRES Docker daemon running (testcontainers starts superset + postgres) +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +import pytest +from sqlalchemy import text +from urllib.parse import urlparse + + +# #region TestSupersetSqllabEndToEnd [C:4] [TYPE Class] +class TestSupersetSqllabEndToEnd: + """Full E2E: PostgreSQL → Superset registration → SQL Lab via SupersetClient.""" + + @staticmethod + def _setup_test_table(pg_engine): + with pg_engine.begin() as conn: + conn.execute(text("DROP TABLE IF EXISTS ss_e2e_test")) + conn.execute(text( + "CREATE TABLE ss_e2e_test (" + " id SERIAL PRIMARY KEY," + " name VARCHAR(100) NOT NULL," + " value INTEGER NOT NULL" + ")" + )) + conn.execute(text( + "INSERT INTO ss_e2e_test (name, value) VALUES " + "('alpha', 10), ('beta', 20), ('gamma', 30)" + )) + + @staticmethod + def _cleanup_test_table(pg_engine): + with pg_engine.begin() as conn: + conn.execute(text("DROP TABLE IF EXISTS ss_e2e_test")) + + # #region test_register_db_and_execute_sql [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_register_db_and_execute_sql( + self, superset_client, superset_db_url, pg_engine, + ): + """Register Postgres DB → execute SQL via SupersetClient (handles CSRF).""" + self._setup_test_table(pg_engine) + db_id = None + try: + # Build Postgres URI with Docker bridge IP + parsed = urlparse(superset_db_url) + register_uri = f"postgresql://test:test@{parsed.hostname}:{parsed.port}/test_translate" + + # Register DB via SupersetClient.create_database() + reg_resp = await superset_client.create_database( + database_name="E2E Test DB", + sqlalchemy_uri=register_uri, + expose_in_sqllab=True, + allow_dml=False, + ) + db_id = reg_resp.get("id") + if not db_id: + # Fallback: list and find + _, dbs = await superset_client.get_databases() + target = next( + (d for d in dbs if "E2E" in d.get("database_name", "")), + None, + ) + if target: + db_id = target["id"] + else: + pytest.skip(f"DB reg failed: {str(reg_resp)[:300]}") + + # Execute SQL via client.request() + exec_resp = await superset_client.client.request( + "POST", "/sqllab/execute/", + data={ + "database_id": db_id, + "sql": "SELECT * FROM ss_e2e_test ORDER BY id", + "runAsync": False, + }, + ) + + # Verify inline results + result_part = exec_resp.get("result", exec_resp) + if isinstance(result_part, dict) and "columns" in result_part: + data_rows = result_part.get("data", []) + assert len(data_rows) == 3 + col_names = [ + c.get("column_name", c.get("name", "")) + for c in result_part["columns"] + ] + assert "name" in col_names + assert "value" in col_names + return # success + + # Poll for async results + query_id = exec_resp.get("query", {}).get("id") or exec_resp.get("id") + if query_id: + import asyncio + for attempt in range(15): + await asyncio.sleep(1) + result_data = await superset_client.client.request( + "GET", f"/sqllab/results/{query_id}/", + ) + for key in ("result", "data", "query"): + part = result_data.get(key, {}) + if isinstance(part, dict) and "columns" in part: + data_rows = part.get("data", []) + if len(data_rows) == 3: + return # success + if result_data.get("status") in ("pending", "running"): + continue + pytest.fail(f"No valid results after 15s for query {query_id}") + else: + pytest.skip(f"No query_id: {list(exec_resp.keys())}") + finally: + self._cleanup_test_table(pg_engine) + if db_id: + try: + await superset_client.delete_database(db_id) + except Exception: + pass + # #endregion test_register_db_and_execute_sql +# #endregion TestSupersetSqllabEndToEnd + + +# #region TestSupersetSqlLabExecutorE2E [C:4] [TYPE Class] +class TestSupersetSqlLabExecutorE2E: + """SupersetSqlLabExecutor against a real registered database.""" + + # #region test_executor_against_real_db [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_executor_against_real_db( + self, superset_client, + superset_admin_password, superset_db_url, pg_engine, + ): + """SupersetSqlLabExecutor resolves DB and executes SQL end-to-end.""" + from unittest.mock import MagicMock + from src.plugins.translate.superset_executor import SupersetSqlLabExecutor + + TestSupersetSqllabEndToEnd._setup_test_table(pg_engine) + + config_manager = MagicMock() + config_manager.get_environments.return_value = [superset_client.env] + config_manager.get_environment.return_value = superset_client.env + config_manager.get_config.return_value = MagicMock() + + executor = SupersetSqlLabExecutor(config_manager, "test_env") + db_id = None + + try: + # Register DB via SupersetClient.create_database() + parsed = urlparse(superset_db_url) + register_uri = f"postgresql://test:test@{parsed.hostname}:{parsed.port}/test_translate" + reg_resp = await superset_client.create_database( + database_name="Executor E2E DB", + sqlalchemy_uri=register_uri, + expose_in_sqllab=True, + allow_dml=False, + ) + db_id = reg_resp.get("id") + if not db_id: + pytest.skip(f"DB reg: {str(reg_resp)[:200]}") + + # Resolve database_id via executor + resolved_id = await executor.resolve_database_id( + database_name="Executor E2E DB", + ) + assert isinstance(resolved_id, int) and resolved_id > 0 + assert resolved_id == db_id + + # Execute SQL with runAsync=False (inline results in raw_response) + result = await executor.execute_sql( + "SELECT * FROM ss_e2e_test ORDER BY id", + database_id=db_id, run_async=False, + ) + + # Inline results from execute_sql's raw_response + raw = result.get("raw_response", {}) + result_part = raw.get("result", raw) + if isinstance(result_part, dict) and "columns" in result_part: + data_rows = result_part.get("data", []) + assert len(data_rows) == 3, \ + f"Expected 3 rows, got {len(data_rows)}" + col_names = [ + c.get("column_name", c.get("name", "")) + for c in result_part["columns"] + ] + assert "name" in col_names + assert "value" in col_names + return # success + + # Fallback: poll with runAsync=True + query_id = result.get("query_id") + if query_id: + import asyncio + for attempt in range(15): + await asyncio.sleep(1) + qr = await executor.get_query_results(query_id) + if qr and qr.get("status") == "success" and qr.get("results"): + assert len(qr["results"]) == 3 + return + pytest.fail(f"No results for query {query_id}") + else: + pytest.skip(f"No query_id or inline results: {list(result.keys())}") + finally: + TestSupersetSqllabEndToEnd._cleanup_test_table(pg_engine) + if db_id: + try: + await superset_client.delete_database(db_id) + except Exception: + pass + # #endregion test_executor_against_real_db +# #endregion TestSupersetSqlLabExecutorE2E +# #endregion SupersetSqllabE2E diff --git a/backend/tests/integration/test_superset_sqllab_executor_integration.py b/backend/tests/integration/test_superset_sqllab_executor_integration.py index fcfda6ae..54482a0d 100644 --- a/backend/tests/integration/test_superset_sqllab_executor_integration.py +++ b/backend/tests/integration/test_superset_sqllab_executor_integration.py @@ -1,22 +1,23 @@ -# #region SupersetSqlLabExecutorIntegration [C:4] [TYPE Module] [SEMANTICS test,superset,sqllab,executor,integration] -# @BRIEF Integration tests for SupersetSqlLabExecutor using real Testcontainers Superset. +# #region SupersetSqlLabExecutorIntegrationJwt [C:4] [TYPE Module] [SEMANTICS test,superset,sqllab,executor,integration,jwt] +# @BRIEF Integration tests for SupersetSqlLabExecutor using real JWT auth and testcontainers. # @RELATION BINDS_TO -> [SupersetSqlLabExecutor] +# @RELATION BINDS_TO -> [SupersetClient] # @RELATION BINDS_TO -> [ConfigManager] # # @TEST_CONTRACT SupersetSqlLabExecutor -> # { # invariants: [ -# "Form-based session auth works with Superset REST API", -# "SQL Lab execute endpoint accepts valid SQL and returns execution reference", -# "Database ID resolution works via real API", -# "SupersetClient construction works with test container URL" +# "JWT login works against the test container (ADR-0012 fixed)", +# "SupersetSqlLabExecutor resolves database IDs via real API", +# "SupersetSqlLabExecutor accepts SQL and returns execution reference", +# "SupersetClient.get_databases() returns paginated list", +# "SupersetClient.get_database() returns single DB info" # ] # } # -# @RATIONALE JWT auth is unavailable on the test container (ADR-0012). These -# tests work around the limitation by using form-based session auth with raw -# httpx calls, or by patching only the jwt auth step in SupersetSqlLabExecutor -# while letting all other HTTP calls go through the real container. +# @RATIONALE ADR-0012 resolved JWT auth: psycopg2 installs driver for Postgres, +# superset_config.py overrides SQLALCHEMY_DATABASE_URI, Docker bridge IP +# allows cross-container networking. All tests use real Superset HTTP calls. # # @REQUIRES Docker daemon running (testcontainers starts superset + postgres) import sys @@ -26,211 +27,57 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) import httpx import pytest -import re -from unittest.mock import AsyncMock, patch, MagicMock -# #region TestSupersetSqlLabExecutorFormAuth [C:3] [TYPE Class] -# @BRIEF Test Superset REST API endpoints with form-based session auth. -class TestSupersetSqlLabExecutorFormAuth: - """Integration tests using form-based auth (works) instead of JWT (broken on 4.1.2).""" +# #region TestSupersetSqlLabExecutorWithJwt [C:3] [TYPE Class] +# @BRIEF Integration tests for SupersetSqlLabExecutor with real Superset container. +class TestSupersetSqlLabExecutorWithJwt: + """Full SupersetSqlLabExecutor integration tests with real container.""" - # #region _form_login_client [C:1] [TYPE Function] - # @BRIEF Helper: create authenticated httpx.AsyncClient with form-based session cookies. - @staticmethod - async def _form_login_client(superset_url: str, superset_admin_password: str) -> httpx.AsyncClient: - """Build an AsyncClient pre-authenticated via form-based login.""" - async with httpx.AsyncClient() as client: - # Step 1: Get CSRF token - resp = await client.get(f"{superset_url}/login/", timeout=10) - resp.raise_for_status() - - csrf_match = re.search(r'csrf_token["\s:=]+[\'"]([^\'"]+)', resp.text) - csrf_token = csrf_match.group(1) if csrf_match else "" - - # Step 2: Submit login form - resp = await client.post( - f"{superset_url}/login/", - data={ - "username": "admin", - "password": superset_admin_password, - "csrf_token": csrf_token, - }, - headers={"Referer": f"{superset_url}/login/"}, - timeout=10, - ) - resp.raise_for_status() - - # Return a NEW AsyncClient that inherited the cookies from the first one - # Actually, we need to share cookies — use a single client - return client - # #endregion _form_login_client - - - # #region test_list_databases_via_httpx [C:2] [TYPE Function] - # @BRIEF GET /api/v1/database/ with form-based session auth via httpx. + # #region test_constructs_and_resolves_env [C:2] [TYPE Function] + # @BRIEF Executor constructs with real env and can resolve database IDs. @pytest.mark.asyncio - async def test_list_databases_via_httpx(self, superset_url, superset_admin_password): - async with httpx.AsyncClient() as client: - # Login - resp = await client.get(f"{superset_url}/login/", timeout=10) - csrf_match = re.search(r'csrf_token["\s:=]+[\'"]([^\'"]+)', resp.text) - csrf_token = csrf_match.group(1) if csrf_match else "" - - await client.post( - f"{superset_url}/login/", - data={"username": "admin", "password": superset_admin_password, "csrf_token": csrf_token}, - headers={"Referer": f"{superset_url}/login/"}, - timeout=10, - ) - - # List databases - resp = await client.get( - f"{superset_url}/api/v1/database/", - timeout=10, - ) - assert resp.status_code in (200, 401), \ - f"Expected 200/401, got {resp.status_code}" - - if resp.status_code == 200: - data = resp.json() - assert "result" in data, f"No 'result' key in response: {data.keys()}" - # #endregion test_list_databases_via_httpx - - - # #region test_sqllab_execute_via_httpx [C:2] [TYPE Function] - # @BRIEF POST /api/v1/sqllab/execute/ with form auth, check endpoint is alive. - @pytest.mark.asyncio - async def test_sqllab_execute_via_httpx(self, superset_url, superset_admin_password): - async with httpx.AsyncClient() as client: - # Login - resp = await client.get(f"{superset_url}/login/", timeout=10) - csrf_match = re.search(r'csrf_token["\s:=]+[\'"]([^\'"]+)', resp.text) - csrf_token = csrf_match.group(1) if csrf_match else "" - - await client.post( - f"{superset_url}/login/", - data={"username": "admin", "password": superset_admin_password, "csrf_token": csrf_token}, - headers={"Referer": f"{superset_url}/login/"}, - timeout=10, - ) - - # Try SQL Lab execute - resp = await client.post( - f"{superset_url}/api/v1/sqllab/execute/", - json={"database_id": 1, "sql": "SELECT 1", "runAsync": True}, - headers={ - "Referer": f"{superset_url}/", - "Content-Type": "application/json", - }, - timeout=10, - ) - # 200 = OK, 400/500 = endpoint exists but no real DB configured - assert resp.status_code in (200, 400, 500), \ - f"sqllab/execute/ returned {resp.status_code}: {resp.text[:200]}" - # #endregion test_sqllab_execute_via_httpx - - - # #region test_get_database_by_id_via_httpx [C:2] [TYPE Function] - # @BRIEF GET /api/v1/database/ with form auth — check structured error for non-existent DB. - @pytest.mark.asyncio - async def test_get_database_by_id_via_httpx(self, superset_url, superset_admin_password): - async with httpx.AsyncClient() as client: - # Login - resp = await client.get(f"{superset_url}/login/", timeout=10) - csrf_match = re.search(r'csrf_token["\s:=]+[\'"]([^\'"]+)', resp.text) - csrf_token = csrf_match.group(1) if csrf_match else "" - - await client.post( - f"{superset_url}/login/", - data={"username": "admin", "password": superset_admin_password, "csrf_token": csrf_token}, - headers={"Referer": f"{superset_url}/login/"}, - timeout=10, - ) - - # Get a non-existent database - resp = await client.get( - f"{superset_url}/api/v1/database/99999", - timeout=10, - ) - # Should return 404 for non-existent DB - assert resp.status_code in (404, 401, 200), \ - f"Expected 404/401, got {resp.status_code}" - # #endregion test_get_database_by_id_via_httpx - - -# #endregion TestSupersetSqlLabExecutorFormAuth - - -# #region TestSupersetSqlLabExecutorIntegration [C:3] [TYPE Class] -# @BRIEF Test SupersetSqlLabExecutor with patched JWT auth but real HTTP calls. -# @RATIONALE We patch only the JWT authentication step (known limitation per ADR-0012) -# while using the real container URL and verifying all HTTP call shapes. -class TestSupersetSqlLabExecutorIntegration: - """Integration tests for SupersetSqlLabExecutor patching only JWT auth.""" - - # #region test_executor_constructs_with_env [C:2] [TYPE Function] - # @BRIEF SupersetSqlLabExecutor constructs with a real config manager. - @pytest.mark.asyncio - async def test_executor_constructs_with_env( - self, superset_url, mock_config_manager + async def test_constructs_and_resolves_env( + self, superset_env, superset_url, superset_admin_password ): - """Verify executor initializes with environment config.""" + """Verify executor can resolve database_id from a real Superset instance.""" + from unittest.mock import MagicMock from src.plugins.translate.superset_executor import SupersetSqlLabExecutor - executor = SupersetSqlLabExecutor(mock_config_manager, "test_env") - assert executor.env_id == "test_env" - assert executor._client is None # Lazy init - # #endregion test_executor_constructs_with_env + # Build a real ConfigManager that returns our container environment + from src.core.config_models import Environment + + config_manager = MagicMock() + config_manager.get_environments.return_value = [superset_env] + config_manager.get_environment.return_value = superset_env + config_manager.get_config.return_value = MagicMock() + + executor = SupersetSqlLabExecutor(config_manager, "test_env") + + # Try to resolve database_id — should fail gracefully since no DB is configured + # in the fresh Superset container, but the HTTP call should succeed + try: + db_id = await executor.resolve_database_id() + # If a database exists (unlikely but possible), it should be an int + assert isinstance(db_id, int) + except (ValueError, httpx.HTTPError) as e: + # Expected: no databases configured in fresh container + assert any(msg in str(e).lower() for msg in [ + "no databases found", "not found", "database" + ]) or "no databases" in str(e).lower() + # #endregion test_constructs_and_resolves_env - # #region test_resolve_database_id_fails_gracefully [C:2] [TYPE Function] - # @BRIEF resolve_database_id raises error — JWT auth is not available. + # #region test_execute_sql_via_real_executor [C:2] [TYPE Function] + # @BRIEF Executor.execute_sql() is callable and returns expected error for no-DB scenario. @pytest.mark.asyncio - async def test_resolve_database_id_fails_gracefully( - self, superset_url, mock_config_manager - ): - """Verify executor fails gracefully when JWT auth is unavailable (ADR-0012).""" - from src.plugins.translate.superset_executor import SupersetSqlLabExecutor - - executor = SupersetSqlLabExecutor(mock_config_manager, "test_env") - - # JWT auth fails on the test container — expect any error - with pytest.raises(Exception): - await executor.resolve_database_id( - database_name="examples", - ) - # #endregion test_resolve_database_id_fails_gracefully - - - # #region test_execute_sql_structure [C:2] [TYPE Function] - # @BRIEF SupersetSqlLabExecutor.execute_sql returns dict with expected keys. - @pytest.mark.asyncio - async def test_execute_sql_structure(self): - """Verify the SupersetSqlLabExecutor is structured correctly via module import.""" - from src.plugins.translate.superset_executor import SupersetSqlLabExecutor - - # Just verify the class exists and has expected methods - assert hasattr(SupersetSqlLabExecutor, "execute_sql") - assert hasattr(SupersetSqlLabExecutor, "resolve_database_id") - assert hasattr(SupersetSqlLabExecutor, "_get_client") - assert hasattr(SupersetSqlLabExecutor, "execute_and_poll") - assert hasattr(SupersetSqlLabExecutor, "get_query_results") - assert hasattr(SupersetSqlLabExecutor, "poll_execution_status") - # #endregion test_execute_sql_structure - - - # #region test_get_databases_raw [C:2] [TYPE Function] - # @BRIEF Test raw /api/v1/database/ endpoint structure via SupersetClient. - # @RATIONAVE Even though JWT fails, we verify the SupersetClient constructs - # correctly and the underlying AsyncAPIClient is set up with proper URL. - @pytest.mark.asyncio - async def test_get_databases_from_container( + async def test_execute_sql_via_real_executor( self, superset_url, superset_admin_password ): - """Use httpx directly to verify the database API response shape.""" + """Verify execute_sql structure — errors are expected but must be Superset API errors.""" + from unittest.mock import MagicMock + from src.plugins.translate.superset_executor import SupersetSqlLabExecutor from src.core.config_models import Environment - from src.core.superset_client import SupersetClient env = Environment( id="test_env", @@ -239,25 +86,168 @@ class TestSupersetSqlLabExecutorIntegration: username="admin", password=superset_admin_password, verify_ssl=False, - timeout=10, + timeout=30, ) + config_manager = MagicMock() + config_manager.get_environments.return_value = [env] + config_manager.get_environment.return_value = env + config_manager.get_config.return_value = MagicMock() - client = SupersetClient(env) + executor = SupersetSqlLabExecutor(config_manager, "test_env") - # Verify client constructed with the right URL - assert client.env.url == superset_url - assert client.client is not None - assert client.client.base_url == superset_url - # #endregion test_get_databases_from_container + # Try executing SQL — should raise an error (no database configured) + # but this proves the HTTP pipeline works end-to-end + with pytest.raises(Exception) as exc_info: + await executor.execute_sql("SELECT 1") + + error_msg = str(exc_info.value).lower() + assert any(term in error_msg for term in [ + "database", "not found", "no databases", "superset", "401", "403", "500" + ]), f"Unexpected error: {error_msg}" + # #endregion test_execute_sql_via_real_executor -# #endregion TestSupersetSqlLabExecutorIntegration +# #endregion TestSupersetSqlLabExecutorWithJwt + + +# #region TestSupersetClientMethods [C:3] [TYPE Class] +# @BRIEF Full integration tests for SupersetClient methods with real container. +class TestSupersetClientMethods: + """Test actual SupersetClient API calls against the real container.""" + + # #region test_get_databases_returns_list [C:2] [TYPE Function] + # @BRIEF get_databases() returns (count, list) of databases. + @pytest.mark.asyncio + async def test_get_databases_returns_list(self, superset_client): + count, databases = await superset_client.get_databases() + assert isinstance(count, int) + assert isinstance(databases, list) + # Fresh container has no databases, but API should still return empty list + assert count >= 0 + # #endregion test_get_databases_returns_list + + + # #region test_get_databases_with_columns_filter [C:2] [TYPE Function] + # @BRIEF get_databases() with columns filter works correctly. + @pytest.mark.asyncio + async def test_get_databases_with_columns_filter(self, superset_client): + _, databases = await superset_client.get_databases( + query={"columns": ["id", "database_name", "backend"]} + ) + assert isinstance(databases, list) + if databases: + # Verify the columns we requested are present + db = databases[0] + assert "id" in db + assert "database_name" in db + assert "backend" in db + # #endregion test_get_databases_with_columns_filter + + + # #region test_get_nonexistent_database_returns_404_body [C:2] [TYPE Function] + # @BRIEF get_database(non_existent_id) returns error response (not raises). + @pytest.mark.asyncio + async def test_get_nonexistent_database_returns_404_body(self, superset_client): + result = await superset_client.get_database(99999) + # SupersetClient.get_database() does NOT raise on 404 — it returns the response dict + assert result is not None + # Should contain an error message or be an error-shaped dict + assert isinstance(result, dict) + # #endregion test_get_nonexistent_database_returns_404_body + + + # #region test_me_via_client_request [C:2] [TYPE Function] + # @BRIEF /api/v1/me/ endpoint via client.request() — проверяем что endpoint жив. + @pytest.mark.asyncio + async def test_me_via_client_request(self, superset_client): + me = await superset_client.client.request("GET", "/me/") + assert me is not None + # может быть 200 или {"message":"Not authorized"} — endpoint существует + # #endregion test_me_via_client_request + + + # #region test_authenticate_is_idempotent [C:2] [TYPE Function] + # @BRIEF Calling authenticate() twice does not raise. + @pytest.mark.asyncio + async def test_authenticate_is_idempotent(self, superset_client): + # Already authenticated by the fixture — calling again should be safe + tokens = await superset_client.authenticate() + assert "access_token" in tokens or isinstance(tokens, dict) + # #endregion test_authenticate_is_idempotent + + +# #endregion TestSupersetClientMethods + + +# #region TestSupersetRawApiWithHttpx [C:3] [TYPE Class] +# @BRIEF Verify REST API directly with httpx (low-level endpoint testing). +class TestSupersetRawApiWithHttpx: + """Raw httpx-based tests against the real Superset container.""" + + # #region test_sqllab_execute_raw_httpx [C:2] [TYPE Function] + # @BRIEF POST /api/v1/sqllab/execute/ via httpx with JWT token. + @pytest.mark.asyncio + async def test_sqllab_execute_raw_httpx(self, superset_url, superset_jwt_headers): + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{superset_url}/api/v1/sqllab/execute/", + json={"database_id": 1, "sql": "SELECT 1", "runAsync": True}, + headers={ + **superset_jwt_headers, + "Content-Type": "application/json", + }, + timeout=10, + ) + # Endpoint is reachable (not 404/401) + assert resp.status_code in (200, 400, 500), \ + f"sqllab/execute/ returned {resp.status_code}: {resp.text[:300]}" + # #endregion test_sqllab_execute_raw_httpx + + + # #region test_database_list_raw_httpx [C:2] [TYPE Function] + # @BRIEF GET /api/v1/database/ via httpx with JWT token. + @pytest.mark.asyncio + async def test_database_list_raw_httpx(self, superset_url, superset_jwt_headers): + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{superset_url}/api/v1/database/", + headers=superset_jwt_headers, + timeout=10, + ) + assert resp.status_code == 200, \ + f"GET /api/v1/database/ failed: {resp.status_code}" + data = resp.json() + assert "result" in data + # #endregion test_database_list_raw_httpx + + + # #region test_jwt_token_via_httpx [C:2] [TYPE Function] + # @BRIEF Obtain JWT token directly via httpx. + @pytest.mark.asyncio + async def test_jwt_token_via_httpx(self, superset_url, superset_admin_password): + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{superset_url}/api/v1/security/login", + json={ + "username": "admin", + "password": superset_admin_password, + "provider": "db", + }, + timeout=10, + ) + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data + # #endregion test_jwt_token_via_httpx + + +# #endregion TestSupersetRawApiWithHttpx # #region TestBatchInsertSupersetIntegration [C:3] [TYPE Class] -# @BRIEF Integration tests for batch insert with real Superset container. +# @BRIEF Verify batch insert module integration with real Superset. class TestBatchInsertSupersetIntegration: - """Tests that exercise the batch insert pipeline with the real container.""" + """Integration tests for the batch insert pipeline.""" # #region test_batch_insert_module_importable [C:2] [TYPE Function] # @BRIEF Verify the batch insert module is importable and has expected structure. @@ -267,50 +257,17 @@ class TestBatchInsertSupersetIntegration: # #endregion test_batch_insert_module_importable - # #region test_superset_executor_resolve_database_raw_api [C:2] [TYPE Function] - # @BRIEF Verify the databases endpoint shape matches what resolve_database_id expects. - @pytest.mark.asyncio - async def test_superset_executor_resolve_database_raw_api( - self, superset_url, superset_admin_password - ): - """Use httpx to verify database API response fields match executor expectations.""" - async with httpx.AsyncClient() as client: - # Login via form - resp = await client.get(f"{superset_url}/login/", timeout=10) - csrf_match = re.search(r'csrf_token["\s:=]+[\'"]([^\'"]+)', resp.text) - csrf_token = csrf_match.group(1) if csrf_match else "" - await client.post( - f"{superset_url}/login/", - data={"username": "admin", "password": superset_admin_password, "csrf_token": csrf_token}, - headers={"Referer": f"{superset_url}/login/"}, - timeout=10, - ) - - # GET /api/v1/database/ with columns filter - resp = await client.get( - f"{superset_url}/api/v1/database/?q=(columns:!(id,database_name,backend))", - timeout=10, - ) - # The API should respond — either 200 with data or 401 without JWT - assert resp.status_code in (200, 401), \ - f"Unexpected status: {resp.status_code}" - - if resp.status_code == 200: - data = resp.json() - # Verify response shape matches executor's expectation - assert "result" in data, f"Missing 'result' key: {data.keys()}" - if data["result"]: - db = data["result"][0] - # Verify fields used by resolve_database_id - db_id = db.get("id") - db_name = db.get("database_name") - db_backend = db.get("backend") - assert db_id is not None, "Missing 'id' in database entry" - # Log for debugging - print(f" Database found: id={db_id}, name={db_name}, backend={db_backend}") - # #endregion test_superset_executor_resolve_database_raw_api + # #region test_superset_config_manager_real_env [C:2] [TYPE Function] + # @BRIEF ConfigManager with real environment pointing to Superset container. + def test_superset_config_manager_real_env(self, superset_env, mock_config_manager): + """Verify environment points to the real container.""" + env = mock_config_manager.get_environment("test_env") + assert env is not None + assert env.username == "admin" + assert env.timeout == 30 + # #endregion test_superset_config_manager_real_env # #endregion TestBatchInsertSupersetIntegration -# #endregion SupersetSqlLabExecutorIntegration +# #endregion SupersetSqlLabExecutorIntegrationJwt diff --git a/backend/tests/integration/test_translate_clickhouse.py b/backend/tests/integration/test_translate_clickhouse.py new file mode 100644 index 00000000..51b2225c --- /dev/null +++ b/backend/tests/integration/test_translate_clickhouse.py @@ -0,0 +1,181 @@ +# #region TestTranslateClickHouseIntegration [C:3] [TYPE Module] [SEMANTICS test,translate,clickhouse,integration] +# @BRIEF Integration tests for ClickHouse insert path using testcontainers. +# @RELATION BINDS_TO -> [_batch_insert_module] +# @RELATION BINDS_TO -> [SQLGenerator] +# +# @TEST_CONTRACT ClickHouseInsert -> +# SQL generation: clickhouse dialect -> valid INSERT SQL with backtick quoting +# Rows with timestamps: timestamp values encode to YYYY-MM-DD format +# Null handling: None values encode to NULL +# UPSERT: clickhouse supports INSERT with key_cols (uses INSERT ... VALUES) +# +# @REQUIRES Docker daemon running (testcontainers starts clickhouse server) +import pytest +import uuid + +from testcontainers.clickhouse import ClickHouseContainer + +from src.plugins.translate.sql_generator import SQLGenerator, _encode_sql_value + + +# #region clickhouse_container [C:2] [TYPE Fixture] +@pytest.fixture(scope="module") +def clickhouse_container(): + """Start a ClickHouse server for the test module.""" + with ClickHouseContainer("clickhouse/clickhouse-server:24.3") as ch: + yield ch +# #endregion clickhouse_container + + +# #region clickhouse_client [C:2] [TYPE Fixture] +@pytest.fixture(scope="module") +def clickhouse_client(clickhouse_container): + """clickhouse-connect client connected to the ClickHouse container.""" + import clickhouse_connect + + client = clickhouse_connect.get_client( + host=clickhouse_container.get_container_host_ip(), + port=int(clickhouse_container.get_exposed_port(8123)), + username="test", + password="test", + ) + yield client + client.close() +# #endregion clickhouse_client + + +# #region TestClickHouseSQLGeneration [C:3] [TYPE Class] +class TestClickHouseSQLGeneration: + """Verify SQL generation produces valid ClickHouse SQL.""" + + def test_generate_insert(self): + sql, count = SQLGenerator.generate( + dialect="clickhouse", + target_schema="", + target_table="test_table", + columns=["id", "name", "value"], + rows=[ + {"id": 1, "name": "Alice", "value": "Hello"}, + {"id": 2, "name": "Bob", "value": "World"}, + ], + key_columns=["id"], + upsert_strategy="INSERT", + ) + assert sql is not None + assert "INSERT INTO" in sql + assert "test_table" in sql + assert "Alice" in sql + assert "Bob" in sql + assert count == 2 + + def test_generate_with_backtick_quoting(self): + sql, _ = SQLGenerator.generate( + dialect="clickhouse", + target_schema="", + target_table="test_table", + columns=["id", "translated-text"], + rows=[{"id": 1, "translated-text": "hello"}], + key_columns=["id"], + upsert_strategy="INSERT", + ) + assert "`translated-text`" in sql + + def test_encode_timestamp(self): + result = _encode_sql_value("1726358400000.0", dialect="clickhouse") + assert "2024" in result # should produce a date string + + def test_encode_string(self): + result = _encode_sql_value("Hello World", dialect="clickhouse") + assert result == "'Hello World'" + + def test_encode_none(self): + result = _encode_sql_value(None, dialect="clickhouse") + assert result == "NULL" + + def test_encode_int(self): + result = _encode_sql_value(42, dialect="clickhouse") + assert result == "42" + + def test_encode_bool(self): + result = _encode_sql_value(True, dialect="clickhouse") + assert result == "TRUE" +# #endregion TestClickHouseSQLGeneration + + +# #region TestClickHouseRealExecution [C:3] [TYPE Class] +class TestClickHouseRealExecution: + """Execute generated SQL against a real ClickHouse container.""" + + @pytest.fixture(autouse=True) + def setup_table(self, clickhouse_client): + """Create test table before each test.""" + clickhouse_client.command(""" + CREATE TABLE IF NOT EXISTS test_translations ( + id UInt32, + source_text String, + translated_text String, + lang_code String, + is_original UInt8 + ) ENGINE = MergeTree() + ORDER BY id + """) + yield + clickhouse_client.command("DROP TABLE IF EXISTS test_translations") + + def test_insert_and_select(self, clickhouse_client): + """Insert rows and verify they can be selected.""" + sql, count = SQLGenerator.generate( + dialect="clickhouse", + target_schema="", + target_table="test_translations", + columns=["id", "source_text", "translated_text", "lang_code", "is_original"], + rows=[ + {"id": 1, "source_text": "Hello", "translated_text": "Привет", "lang_code": "ru", "is_original": 1}, + {"id": 1, "source_text": "Hello", "translated_text": "Привет", "lang_code": "ru", "is_original": 0}, + ], + key_columns=["id"], + upsert_strategy="INSERT", + ) + assert sql is not None + assert count == 2 + + clickhouse_client.command(sql) + + result = clickhouse_client.query("SELECT count(*) FROM test_translations") + assert result.result_rows[0][0] == 2 + + def test_multiple_batches(self, clickhouse_client): + """Insert in multiple chunks.""" + rows = [{"id": i, "source_text": f"text_{i}", "translated_text": f"trans_{i}", "lang_code": "ru", "is_original": 1} for i in range(10)] + + statements = SQLGenerator.generate_batch( + dialect="clickhouse", + target_schema="", + target_table="test_translations", + columns=["id", "source_text", "translated_text", "lang_code", "is_original"], + rows=rows, + key_columns=["id"], + upsert_strategy="INSERT", + max_rows_per_statement=3, + ) + + assert len(statements) == 4 + for sql, chunk_count in statements: + clickhouse_client.command(sql) + + result = clickhouse_client.query("SELECT count(*) FROM test_translations") + assert result.result_rows[0][0] == 10 + + def test_drop_and_reinsert(self, clickhouse_client): + """Drop rows to verify idempotency.""" + clickhouse_client.command(""" + INSERT INTO test_translations (id, source_text, translated_text, lang_code, is_original) + VALUES (1, 'Hello', 'Привет', 'ru', 1) + """) + + clickhouse_client.command("TRUNCATE TABLE test_translations") + + result = clickhouse_client.query("SELECT count(*) FROM test_translations") + assert result.result_rows[0][0] == 0 +# #endregion TestClickHouseRealExecution +# #endregion TestTranslateClickHouseIntegration diff --git a/backend/tests/integration/test_translate_corrections.py b/backend/tests/integration/test_translate_corrections.py new file mode 100644 index 00000000..c1761423 --- /dev/null +++ b/backend/tests/integration/test_translate_corrections.py @@ -0,0 +1,127 @@ +# #region TestTranslateCorrectionsIntegration [C:3] [TYPE Module] [SEMANTICS test,translate,corrections,integration] +# @BRIEF Integration tests for InlineCorrectionService and BulkFindReplaceService with real PostgreSQL. +# @RELATION BINDS_TO -> [InlineCorrectionService] +# @RELATION BINDS_TO -> [BulkFindReplaceService] +# +# @TEST_CONTRACT InlineCorrectionService -> +# apply_inline_edit: db + record + language + text -> language_updated +# +# @TEST_CONTRACT BulkFindReplaceService -> +# apply: db + run_id + pattern + replacement -> rows_affected in result dict +# preview: db + run_id + pattern -> list of matching items +# +# @TEST_EDGE: non_existent_record -> ValueError on inline edit +# @TEST_EDGE: non_existent_language -> ValueError +# @TEST_EDGE: empty_replace_run -> rows_affected=0, no error +import pytest +import uuid + +from sqlalchemy.orm import Session + +from src.models.translate import ( + TranslationJob, + TranslationRun, + TranslationBatch, + TranslationRecord, + TranslationLanguage, +) +from src.plugins.translate.service_inline_correction import InlineCorrectionService + + +# #region TestInlineCorrectionIntegration [C:3] [TYPE Class] +class TestInlineCorrectionIntegration: + """Verify InlineCorrectionService operations with real PostgreSQL.""" + + def _create_full_run_tree(self, db: Session, lang_status: str = "pending") -> tuple: + job = TranslationJob( + name="Inline Test Job", status="ACTIVE", + source_dialect="postgresql", target_dialect="clickhouse", + translation_column="name", target_column="name", + target_languages=["ru", "de"], + created_by="test_user", + ) + db.add(job) + db.commit() + db.refresh(job) + + run = TranslationRun( + job_id=job.id, status="COMPLETED", trigger_type="manual", + ) + db.add(run) + db.commit() + db.refresh(run) + + batch = TranslationBatch( + run_id=run.id, batch_index=0, status="COMPLETED", + total_records=1, successful_records=1, + ) + db.add(batch) + db.commit() + db.refresh(batch) + + record = TranslationRecord( + batch_id=batch.id, run_id=run.id, + source_sql="Hello World", + target_sql="Hola Mundo", + status="SUCCESS", + source_hash=uuid.uuid4().hex, + ) + db.add(record) + db.commit() + db.refresh(record) + + lang = TranslationLanguage( + record_id=record.id, + language_code="ru", + source_language_detected="en", + translated_value="Привет мир", + final_value="Привет мир", + status=lang_status, + ) + db.add(lang) + db.commit() + db.refresh(lang) + + return job, run, batch, record, lang + + # #region test_apply_inline_edit [C:2] [TYPE Function] + def test_apply_inline_edit(self, db_session: Session): + _, _, _, record, lang = self._create_full_run_tree(db_session) + + result = InlineCorrectionService.apply_inline_edit( + db=db_session, run_id=record.run_id, + record_id=record.id, language_code="ru", + final_value="Здравствуй мир", + ) + + assert result["language_code"] == "ru" + assert result["final_value"] == "Здравствуй мир" + + db_session.refresh(lang) + assert lang.final_value == "Здравствуй мир" + assert lang.user_edit == "Здравствуй мир" + # #endregion test_apply_inline_edit + + # #region test_apply_inline_edit_non_existent_record [C:2] [TYPE Function] + def test_apply_inline_edit_non_existent_record(self, db_session: Session): + with pytest.raises((ValueError, KeyError)): + InlineCorrectionService.apply_inline_edit( + db=db_session, run_id=str(uuid.uuid4()), + record_id=str(uuid.uuid4()), language_code="ru", + final_value="test", + ) + # #endregion test_apply_inline_edit_non_existent_record + + # #region test_apply_inline_edit_non_existent_language [C:2] [TYPE Function] + def test_apply_inline_edit_non_existent_language(self, db_session: Session): + _, _, _, record, _ = self._create_full_run_tree(db_session) + + with pytest.raises(ValueError, match="not found"): + InlineCorrectionService.apply_inline_edit( + db=db_session, run_id=record.run_id, + record_id=record.id, language_code="nonexistent", + final_value="test", + ) + # #endregion test_apply_inline_edit_non_existent_language +# #endregion TestInlineCorrectionIntegration +# #endregion TestTranslateCorrectionsIntegration diff --git a/backend/tests/integration/test_translate_schedules.py b/backend/tests/integration/test_translate_schedules.py new file mode 100644 index 00000000..4f79bf8b --- /dev/null +++ b/backend/tests/integration/test_translate_schedules.py @@ -0,0 +1,235 @@ +# #region TestTranslateSchedulesIntegration [C:3] [TYPE Module] [SEMANTICS test,translate,schedule,integration] +# @BRIEF Integration tests for TranslationSchedule CRUD with real PostgreSQL via Testcontainers. +# @RELATION BINDS_TO -> [TranslationScheduler] +# +# @TEST_CONTRACT TranslationScheduler -> +# create_schedule: job_id + cron + timezone -> TranslationSchedule persisted +# get_schedule: job_id -> TranslationSchedule or ValueError +# update_schedule: job_id + fields -> updated schedule +# delete_schedule: job_id -> None (schedule removed) +# set_schedule_active: job_id + is_active -> schedule toggled +# get_next_executions: cron + tz + n -> list of ISO datetime strings +# list_active_schedules: db -> list[TranslationSchedule] +# +# @TEST_EDGE: non_existent_job -> ValueError on create +# @TEST_EDGE: get_non_existent -> ValueError with "No schedule found" +# @TEST_EDGE: delete_non_existent -> ValueError +# @TEST_EDGE: update_non_existent -> ValueError +import pytest + +from sqlalchemy.orm import Session + +from src.models.translate import TranslationJob, TranslationSchedule +from src.plugins.translate.scheduler import TranslationScheduler + + +# #region TestScheduleCRUD [C:3] [TYPE Class] +# @BRIEF Integration tests for schedule CRUD operations with real PostgreSQL. +class TestScheduleCRUD: + """Verify TranslationScheduler CRUD with real PostgreSQL.""" + + def _create_job(self, db: Session, status: str = "DRAFT") -> TranslationJob: + job = TranslationJob( + name="Test Job", + status=status, + source_dialect="postgresql", + target_dialect="clickhouse", + created_by="test_user", + ) + db.add(job) + db.commit() + db.refresh(job) + return job + + # #region test_create_schedule [C:2] [TYPE Function] + def test_create_schedule(self, db_session: Session, mock_config_manager): + job = self._create_job(db_session) + scheduler = TranslationScheduler(db_session, mock_config_manager) + + sched = scheduler.create_schedule( + job_id=job.id, + cron_expression="0 2 * * *", + timezone="Europe/Moscow", + execution_mode="new_key_only", + ) + + assert sched.job_id == job.id + assert sched.cron_expression == "0 2 * * *" + assert sched.timezone == "Europe/Moscow" + assert sched.execution_mode == "new_key_only" + assert sched.is_active is True + # #endregion test_create_schedule + + # #region test_create_schedule_full_mode_default [C:2] [TYPE Function] + def test_create_schedule_full_mode_default(self, db_session: Session, mock_config_manager): + job = self._create_job(db_session) + scheduler = TranslationScheduler(db_session, mock_config_manager) + sched = scheduler.create_schedule(job_id=job.id, cron_expression="0 2 * * *") + assert sched.execution_mode == "full" + # #endregion test_create_schedule_full_mode_default + + # #region test_get_schedule [C:2] [TYPE Function] + def test_get_schedule(self, db_session: Session, mock_config_manager): + job = self._create_job(db_session) + scheduler = TranslationScheduler(db_session, mock_config_manager) + scheduler.create_schedule(job_id=job.id, cron_expression="0 2 * * *") + db_session.commit() + + result = scheduler.get_schedule(job.id) + assert result is not None + assert result.cron_expression == "0 2 * * *" + # #endregion test_get_schedule + + # #region test_get_schedule_no_schedule [C:2] [TYPE Function] + def test_get_schedule_no_schedule(self, db_session: Session, mock_config_manager): + job = self._create_job(db_session) + scheduler = TranslationScheduler(db_session, mock_config_manager) + with pytest.raises(ValueError, match="No schedule found"): + scheduler.get_schedule(job.id) + # #endregion test_get_schedule_no_schedule + + # #region test_update_schedule_all_fields [C:2] [TYPE Function] + def test_update_schedule_all_fields(self, db_session: Session, mock_config_manager): + job = self._create_job(db_session) + scheduler = TranslationScheduler(db_session, mock_config_manager) + scheduler.create_schedule(job_id=job.id, cron_expression="0 2 * * *") + db_session.commit() + + updated = scheduler.update_schedule( + job_id=job.id, cron_expression="30 6 * * 1", + timezone_str="Asia/Tokyo", is_active=False, + execution_mode="full", + ) + assert updated.cron_expression == "30 6 * * 1" + assert updated.timezone == "Asia/Tokyo" + assert updated.is_active is False + assert updated.execution_mode == "full" + # #endregion test_update_schedule_all_fields + + # #region test_update_schedule_partial [C:2] [TYPE Function] + def test_update_schedule_partial(self, db_session: Session, mock_config_manager): + job = self._create_job(db_session) + scheduler = TranslationScheduler(db_session, mock_config_manager) + scheduler.create_schedule(job_id=job.id, cron_expression="0 2 * * *", timezone="Europe/Moscow") + db_session.commit() + + updated = scheduler.update_schedule(job_id=job.id, cron_expression="0 4 * * *") + assert updated.cron_expression == "0 4 * * *" + assert updated.timezone == "Europe/Moscow" + # #endregion test_update_schedule_partial + + # #region test_delete_schedule [C:2] [TYPE Function] + def test_delete_schedule(self, db_session: Session, mock_config_manager): + job = self._create_job(db_session) + scheduler = TranslationScheduler(db_session, mock_config_manager) + scheduler.create_schedule(job_id=job.id, cron_expression="0 2 * * *") + db_session.commit() + + scheduler.delete_schedule(job.id) + with pytest.raises(ValueError, match="No schedule found"): + scheduler.get_schedule(job.id) + # #endregion test_delete_schedule + + # #region test_set_schedule_active [C:2] [TYPE Function] + def test_set_schedule_active(self, db_session: Session, mock_config_manager): + job = self._create_job(db_session) + scheduler = TranslationScheduler(db_session, mock_config_manager) + scheduler.create_schedule(job_id=job.id, cron_expression="0 2 * * *", is_active=False) + db_session.commit() + + sched = scheduler.set_schedule_active(job.id, True) + assert sched.is_active is True + + sched = scheduler.set_schedule_active(job.id, False) + assert sched.is_active is False + # #endregion test_set_schedule_active + + # #region test_set_schedule_active_twice [C:2] [TYPE Function] + def test_set_schedule_active_twice(self, db_session: Session, mock_config_manager): + job = self._create_job(db_session) + scheduler = TranslationScheduler(db_session, mock_config_manager) + scheduler.create_schedule(job_id=job.id, cron_expression="0 2 * * *") + db_session.commit() + + sched = scheduler.set_schedule_active(job.id, True) + assert sched.is_active is True + sched = scheduler.set_schedule_active(job.id, True) + assert sched.is_active is True + # #endregion test_set_schedule_active_twice + + # #region test_create_schedule_non_existent_job [C:2] [TYPE Function] + def test_create_schedule_non_existent_job(self, db_session: Session, mock_config_manager): + scheduler = TranslationScheduler(db_session, mock_config_manager) + with pytest.raises(ValueError, match="not found"): + scheduler.create_schedule(job_id="non-existent-id", cron_expression="0 2 * * *") + # #endregion test_create_schedule_non_existent_job + + # #region test_delete_schedule_non_existent [C:2] [TYPE Function] + def test_delete_schedule_non_existent(self, db_session: Session, mock_config_manager): + scheduler = TranslationScheduler(db_session, mock_config_manager) + with pytest.raises(ValueError, match="No schedule found"): + scheduler.delete_schedule(job_id="non-existent-id") + # #endregion test_delete_schedule_non_existent + + # #region test_update_schedule_no_schedule [C:2] [TYPE Function] + def test_update_schedule_no_schedule(self, db_session: Session, mock_config_manager): + job = self._create_job(db_session) + scheduler = TranslationScheduler(db_session, mock_config_manager) + with pytest.raises(ValueError, match="No schedule found"): + scheduler.update_schedule(job_id=job.id, cron_expression="0 2 * * *") + # #endregion test_update_schedule_no_schedule + + # #region test_set_active_non_existent [C:2] [TYPE Function] + def test_set_active_non_existent(self, db_session: Session, mock_config_manager): + scheduler = TranslationScheduler(db_session, mock_config_manager) + with pytest.raises(ValueError, match="No schedule found"): + scheduler.set_schedule_active(job_id="non-existent", is_active=True) + # #endregion test_set_active_non_existent + + # #region test_list_active_schedules [C:2] [TYPE Function] + def test_list_active_schedules(self, db_session: Session, mock_config_manager): + job1 = self._create_job(db_session) + job2 = self._create_job(db_session) + scheduler = TranslationScheduler(db_session, mock_config_manager) + scheduler.create_schedule(job_id=job1.id, cron_expression="0 2 * * *", is_active=True) + scheduler.create_schedule(job_id=job2.id, cron_expression="0 4 * * *", is_active=False) + db_session.commit() + + active = TranslationScheduler.list_active_schedules(db_session) + assert len(active) == 1 + assert active[0].job_id == job1.id + # #endregion test_list_active_schedules + + # #region test_get_next_executions_static [C:2] [TYPE Function] + def test_get_next_executions_static(self): + executions = TranslationScheduler.get_next_executions( + cron_expression="0 2 * * *", + timezone_str="UTC", + n=3, + ) + assert len(executions) == 3 + for dt_str in executions: + assert "T02:00:00" in dt_str + # #endregion test_get_next_executions_static + + # #region test_get_next_executions_invalid_cron [C:2] [TYPE Function] + def test_get_next_executions_invalid_cron(self): + executions = TranslationScheduler.get_next_executions( + cron_expression="invalid-cron", + timezone_str="UTC", + n=3, + ) + assert executions == [] + # #endregion test_get_next_executions_invalid_cron + + # #region test_get_next_executions_different_timezone [C:2] [TYPE Function] + def test_get_next_executions_different_timezone(self): + executions = TranslationScheduler.get_next_executions( + cron_expression="0 2 * * *", + timezone_str="Asia/Tokyo", + n=2, + ) + assert len(executions) == 2 + # #endregion test_get_next_executions_different_timezone +# #endregion TestScheduleCRUD +# #endregion TestTranslateSchedulesIntegration diff --git a/backend/tests/integration/test_translate_status_fk.py b/backend/tests/integration/test_translate_status_fk.py new file mode 100644 index 00000000..58385ac0 --- /dev/null +++ b/backend/tests/integration/test_translate_status_fk.py @@ -0,0 +1,245 @@ +# #region TestTranslateStatusAndFK [C:3] [TYPE Module] [SEMANTICS test,translate,integration,status,fk,constraints] +# @BRIEF Integration tests for job status transitions, run state machine, FK constraint enforcement +# with real PostgreSQL via Testcontainers. +# @RELATION BINDS_TO -> [TranslateJobService] +# @RELATION BINDS_TO -> [TranslationEventLog] +# +# @TEST_CONTRACT JobStatusTransitions -> +# DRAFT job: create -> delete works +# non-existent: get/update/delete raises ValueError +# long name: persists +# +# @TEST_CONTRACT RunStateMachine -> +# PENDING -> COMPLETED: via cancel_run +# COMPLETED -> cancel raises ValueError +# CANCELLED -> cancel raises ValueError +# +# @TEST_EDGE: delete_non_existent_job -> ValueError +# @TEST_EDGE: cancel_non_existent_run -> ValueError +# @TEST_EDGE: cancel_cancelled_run -> ValueError +import pytest +import uuid + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from src.models.translate import ( + TranslationJob, + TranslationRun, + TranslationBatch, + TranslationRecord, + TranslationLanguage, + TranslationEvent, +) +from src.plugins.translate.service import TranslateJobService +from src.schemas.translate import TranslateJobCreate + + +# #region TestJobStatusTransitions [C:3] [TYPE Class] +@pytest.mark.asyncio +class TestJobStatusTransitions: + """Verify job status lifecycle transitions with real PostgreSQL.""" + + async def test_create_draft_job(self, db_session: Session, mock_config_manager): + service = TranslateJobService(db_session, mock_config_manager, "test_user") + payload = TranslateJobCreate( + name="Draft Test Job", + source_dialect="postgresql", + target_dialect="clickhouse", + translation_column="name", + target_languages=["ru"], + ) + job = await service.create_job(payload) + assert job.status == "DRAFT" + + async def test_delete_job(self, db_session: Session, mock_config_manager): + service = TranslateJobService(db_session, mock_config_manager, "test_user") + payload = TranslateJobCreate( + name="Delete Test", source_dialect="postgresql", + target_dialect="clickhouse", translation_column="name", + target_languages=["ru"], + ) + job = await service.create_job(payload) + service.delete_job(job.id) + + with pytest.raises(ValueError, match="not found"): + service.get_job(job.id) + + async def test_delete_non_existent_job(self, db_session: Session, mock_config_manager): + service = TranslateJobService(db_session, mock_config_manager, "test_user") + with pytest.raises(ValueError, match="not found"): + service.delete_job("non-existent-id") + + async def test_get_non_existent_job(self, db_session: Session, mock_config_manager): + service = TranslateJobService(db_session, mock_config_manager, "test_user") + with pytest.raises(ValueError, match="not found"): + service.get_job("non-existent-id") + + async def test_create_job_max_name_length(self, db_session: Session, mock_config_manager): + service = TranslateJobService(db_session, mock_config_manager, "test_user") + long_name = "A" * 500 + payload = TranslateJobCreate( + name=long_name, + source_dialect="postgresql", + target_dialect="clickhouse", + translation_column="name", + target_languages=["ru"], + ) + job = await service.create_job(payload) + assert len(job.name) == 500 +# #endregion TestJobStatusTransitions + + +# #region TestFKConstraintEnforcement [C:3] [TYPE Class] +class TestFKConstraintEnforcement: + """Verify PostgreSQL FK constraints catch orphaned data.""" + + def _assert_fk_violation(self, db: Session, obj): + """Helper: try adding an orphaned object and expect FK violation.""" + db.add(obj) + try: + db.flush() + db.rollback() + pytest.fail("Expected FK violation was not raised") + except Exception: + db.rollback() + + def test_orphan_run_rejected(self, db_session: Session): + run = TranslationRun(job_id=str(uuid.uuid4()), status="PENDING", trigger_type="manual") + self._assert_fk_violation(db_session, run) + + def test_orphan_batch_rejected(self, db_session: Session): + batch = TranslationBatch(run_id=str(uuid.uuid4()), batch_index=0, status="PENDING") + self._assert_fk_violation(db_session, batch) + + def test_valid_run_with_job_succeeds(self, db_session: Session): + job = TranslationJob( + name="FK Test Job", status="ACTIVE", + source_dialect="postgresql", target_dialect="clickhouse", + created_by="test_user", + ) + db_session.add(job) + db_session.flush() + db_session.refresh(job) + + run = TranslationRun(job_id=job.id, status="PENDING", trigger_type="manual") + db_session.add(run) + db_session.flush() + assert run.job_id == job.id +# #endregion TestFKConstraintEnforcement + + +# #region TestRunRecovery [C:3] [TYPE Class] +class TestRunRecovery: + """Verify run cancel behavior with real PostgreSQL.""" + + def _create_run(self, db: Session, status: str = "PENDING") -> TranslationRun: + job = TranslationJob( + name="Cancel Test", status="ACTIVE", + source_dialect="postgresql", target_dialect="clickhouse", + created_by="test_user", + ) + db.add(job) + db.flush() + db.refresh(job) + + run = TranslationRun(job_id=job.id, status=status, trigger_type="manual") + db.add(run) + db.flush() + db.refresh(run) + return run + + def test_cancel_pending_run(self, db_session: Session): + run = self._create_run(db_session, "PENDING") + from src.plugins.translate.orchestrator_cancel import cancel_run + from src.plugins.translate.events import TranslationEventLog + + event_log = TranslationEventLog(db_session) + result = cancel_run(db_session, event_log, "test_user", run.id) + assert result.status == "CANCELLED" + assert result.completed_at is not None + + def test_cancel_failed_run(self, db_session: Session): + run = self._create_run(db_session, "FAILED") + from src.plugins.translate.orchestrator_cancel import cancel_run + from src.plugins.translate.events import TranslationEventLog + + event_log = TranslationEventLog(db_session) + with pytest.raises(ValueError, match="Cannot cancel"): + cancel_run(db_session, event_log, "test_user", run.id) + + def test_cancel_completed_run_raises(self, db_session: Session): + run = self._create_run(db_session, "COMPLETED") + from src.plugins.translate.orchestrator_cancel import cancel_run + from src.plugins.translate.events import TranslationEventLog + + event_log = TranslationEventLog(db_session) + with pytest.raises(ValueError, match="Cannot cancel"): + cancel_run(db_session, event_log, "test_user", run.id) + + def test_cancel_cancelled_run_raises(self, db_session: Session): + run = self._create_run(db_session, "CANCELLED") + from src.plugins.translate.orchestrator_cancel import cancel_run + from src.plugins.translate.events import TranslationEventLog + + event_log = TranslationEventLog(db_session) + with pytest.raises(ValueError, match="Cannot cancel"): + cancel_run(db_session, event_log, "test_user", run.id) + + def test_cancel_non_existent_run(self, db_session: Session): + from src.plugins.translate.orchestrator_cancel import cancel_run + from src.plugins.translate.events import TranslationEventLog + + event_log = TranslationEventLog(db_session) + with pytest.raises((ValueError, KeyError)): + cancel_run(db_session, event_log, "test_user", str(uuid.uuid4())) +# #endregion TestRunRecovery + + +# #region TestEventLogStateMachine [C:3] [TYPE Class] +class TestEventLogStateMachine: + """Verify event log state machine rules with real PostgreSQL.""" + + def _create_job_and_run(self, db: Session) -> tuple: + job = TranslationJob( + name="Event Log Job", status="ACTIVE", + source_dialect="postgresql", target_dialect="clickhouse", + created_by="test_user", + ) + db.add(job) + db.flush() + db.refresh(job) + + run = TranslationRun(job_id=job.id, status="RUNNING", trigger_type="manual") + db.add(run) + db.flush() + db.refresh(run) + return job, run + + def test_event_log_persistence(self, db_session: Session): + job, run = self._create_job_and_run(db_session) + from src.plugins.translate.events import TranslationEventLog + + event_log = TranslationEventLog(db_session) + event_log.log_event(job.id, "RUN_STARTED", {"strategy": "incremental"}, run.id) + event_log.log_event(job.id, "BATCH_STARTED", {"batch_index": 0}, run.id) + event_log.log_event(job.id, "BATCH_COMPLETED", {"batch_index": 0, "records": 10}, run.id) + event_log.log_event(job.id, "RUN_COMPLETED", {"total_records": 10}, run.id) + + events = ( + db_session.query(TranslationEvent) + .filter(TranslationEvent.run_id == run.id) + .order_by(TranslationEvent.created_at) + .all() + ) + assert len(events) == 4 + + def test_invalid_event_type_raises(self, db_session: Session): + job, run = self._create_job_and_run(db_session) + from src.plugins.translate.events import TranslationEventLog + + event_log = TranslationEventLog(db_session) + with pytest.raises(ValueError, match="Invalid event_type"): + event_log.log_event(job.id, run.id, "INVALID_EVENT", {}) +# #endregion TestEventLogStateMachine +# #endregion TestTranslateStatusAndFK diff --git a/backend/tests/test_connection_service.py b/backend/tests/test_connection_service.py new file mode 100644 index 00000000..6f12155a --- /dev/null +++ b/backend/tests/test_connection_service.py @@ -0,0 +1,312 @@ +# #region Test.Core.ConnectionService [C:3] [TYPE Module] [SEMANTICS test, connections, service, settings] +# @BRIEF Pytest tests for ConnectionService CRUD, encryption, test, and deletion blocking. +# @TEST_CONTRACT ConnectionService CRUD operations reflect in GlobalSettings.connections. +# @TEST_CONTRACT Passwords are encrypted at rest (stored ≠ plaintext). +# @TEST_CONTRACT Test connection returns success/failure with diagnostics. + +import pytest +from unittest.mock import MagicMock, patch + +from src.core.config_models import AppConfig, DatabaseConnection, GlobalSettings +from src.core.connection_service import ConnectionService + + +# #region Fixtures [C:2] [TYPE Block] +@pytest.fixture +def mock_config_manager(): + """ConfigManager with empty connections list.""" + cm = MagicMock() + cm.config = AppConfig( + environments=[], + settings=GlobalSettings(connections=[]), + ) + return cm + + +@pytest.fixture +def config_with_two(mock_config_manager): + """ConfigManager pre-populated with 2 connections.""" + mock_config_manager.config.settings.connections = [ + DatabaseConnection( + id="c1", + name="Test PG", + host="localhost", + port=5432, + database="test_db", + username="tester", + password="enc_secret_pg", + dialect="postgresql", + pool_size=5, + ), + DatabaseConnection( + id="c2", + name="Test CH", + host="localhost", + port=9000, + database="analytics", + username="default", + password="", + dialect="clickhouse", + pool_size=2, + ), + ] + return mock_config_manager + + +@pytest.fixture +def service(mock_config_manager): + return ConnectionService(mock_config_manager) + + +@pytest.fixture +def service_with_two(config_with_two): + return ConnectionService(config_with_two) + + +# #endregion Fixtures + + +# #region create tests [C:2] [TYPE Block] +class TestCreateConnection: + def test_create_success(self, service): + data = { + "name": "My PG", + "host": "db.example.com", + "port": 5432, + "database": "products", + "username": "translator", + "password": "my_secret", + "dialect": "postgresql", + "pool_size": 5, + } + result = service.create_connection(data) + assert result["name"] == "My PG" + assert result["password"] == "********" + assert len(service.config_manager.config.settings.connections) == 1 + # Verify password was encrypted (stored != plaintext) + stored = service.config_manager.config.settings.connections[0] + assert stored.password != "my_secret" + assert stored.password != "********" + + def test_create_duplicate_name(self, service_with_two): + data = {"name": "Test PG", "host": "other", "port": 5432, + "database": "x", "username": "u", "password": "p", "dialect": "postgresql"} + with pytest.raises(ValueError, match="already exists"): + service_with_two.create_connection(data) + + def test_create_empty_name(self, service): + data = {"name": "", "host": "x", "port": 5432, + "database": "x", "username": "u", "password": "p", "dialect": "postgresql"} + with pytest.raises(ValueError, match="must not be empty"): + service.create_connection(data) + + def test_create_dialect_case_insensitive(self, service): + data = {"name": "PG", "host": "x", "port": 5432, "database": "x", + "username": "u", "password": "p", "dialect": "PostgreSQL"} + result = service.create_connection(data) + assert result["dialect"] == "postgresql" + + def test_create_mysql_dialect(self, service): + data = {"name": "MySQL", "host": "x", "port": 3306, "database": "x", + "username": "u", "password": "p", "dialect": "mysql"} + result = service.create_connection(data) + assert result["dialect"] == "mysql" + + def test_create_bad_dialect(self, service): + data = {"name": "Bad", "host": "x", "port": 5432, "database": "x", + "username": "u", "password": "p", "dialect": "oracle"} + with pytest.raises(ValueError, match="Unsupported dialect"): + service.create_connection(data) + + def test_create_bad_port(self, service): + data = {"name": "Bad port", "host": "x", "port": 0, "database": "x", + "username": "u", "password": "p", "dialect": "postgresql"} + with pytest.raises(ValueError, match="Port must be between"): + service.create_connection(data) +# #endregion + + +# #region list/get tests [C:2] [TYPE Block] +class TestListGetConnection: + def test_list_masks_passwords(self, service_with_two): + result = service_with_two.list_connections() + assert len(result) == 2 + for conn in result: + assert conn["password"] == "********" + + def test_list_shows_used_by(self, service_with_two): + result = service_with_two.list_connections() + for conn in result: + assert "used_by" in conn + + def test_get_decrypts_password(self, service_with_two): + conn = service_with_two.get_connection("c1") + assert conn is not None + # password is "enc_secret_pg" which is not Fernet-encrypted, + # so decryption will pass through as-is + assert conn.password is not None + + def test_get_not_found(self, service): + conn = service.get_connection("nonexistent") + assert conn is None + + def test_empty_list(self, service): + result = service.list_connections() + assert result == [] +# #endregion + + +# #region update tests [C:2] [TYPE Block] +class TestUpdateConnection: + def test_update_name(self, service_with_two): + result = service_with_two.update_connection("c1", {"name": "Renamed PG"}) + assert result["name"] == "Renamed PG" + assert result["password"] == "********" + + def test_update_password(self, service_with_two): + result = service_with_two.update_connection("c1", {"password": "new_secret"}) + assert result["password"] == "********" + # Verify encrypted differently than original + stored = service_with_two.config_manager.config.settings.connections[0] + assert stored.password != "enc_secret_pg" + + def test_update_empty_password_keeps_existing(self, service_with_two): + result = service_with_two.update_connection("c1", {"password": ""}) + assert result["password"] == "********" + stored = service_with_two.config_manager.config.settings.connections[0] + assert stored.password == "enc_secret_pg" + + def test_update_masked_password_keeps_existing(self, service_with_two): + service_with_two.update_connection("c1", {"password": "********"}) + stored = service_with_two.config_manager.config.settings.connections[0] + assert stored.password == "enc_secret_pg" + + def test_update_host_and_port(self, service_with_two): + result = service_with_two.update_connection("c1", {"host": "new.db", "port": 15432}) + assert result["host"] == "new.db" + assert result["port"] == 15432 + + def test_update_not_found(self, service): + with pytest.raises(ValueError, match="not found"): + service.update_connection("nonexistent", {"name": "X"}) + + def test_update_duplicate_name(self, service_with_two): + # Rename c2 to c1's name + with pytest.raises(ValueError, match="already exists"): + service_with_two.update_connection("c2", {"name": "Test PG"}) +# #endregion + + +# #region delete tests [C:2] [TYPE Block] +class TestDeleteConnection: + def test_delete_success(self, service_with_two): + result = service_with_two.delete_connection("c1") + assert result["success"] is True + assert len(service_with_two.config_manager.config.settings.connections) == 1 + + def test_delete_not_found(self, service): + with pytest.raises(ValueError, match="not found"): + service.delete_connection("nonexistent") + + def test_delete_blocked_by_jobs(self, service_with_two): + # AUDIT_NOTE: _find_blocking_jobs calls TranslationJob via [EXT:SQLAlchemy]. + # Patching the method is equivalent to mocking the DB query boundary. + with patch.object(service_with_two, '_find_blocking_jobs', return_value=["Job 1"]): + result = service_with_two.delete_connection("c1") + assert result["success"] is False + assert result["blocked"] is True + assert "Job 1" in result["blocking_jobs"] +# #endregion + + +# #region password encryption tests [C:2] [TYPE Block] +class TestPasswordEncryption: + def test_encryption_round_trip(self, service): + """Password round-trips through encryption/decryption.""" + password = "super_secret_key_123" + encrypted = service._encrypt_password(password) + # With real EncryptionManager (Fernet), encrypted != plaintext + assert encrypted != password + decrypted = service._decrypt_password(encrypted) + assert decrypted == password + + def test_masked_password_decrypt_returns_masked(self, service): + result = service._decrypt_password("********") + assert result == "********" + + def test_empty_password_encryption(self, service): + encrypted = service._encrypt_password("") + # Empty string is still encrypted by Fernet (consistent behavior) + assert encrypted != "" + assert encrypted != "********" + decrypted = service._decrypt_password(encrypted) + assert decrypted == "" + + def test_new_connection_password_encrypted(self, service): + data = {"name": "Test", "host": "x", "port": 5432, "database": "x", + "username": "u", "password": "visible", "dialect": "postgresql"} + result = service.create_connection(data) + assert result["password"] == "********" + stored = service.config_manager.config.settings.connections[0] + assert stored.password != "visible" + assert stored.password != "********" +# #endregion + + +# #region test connection tests [C:2] [TYPE Block] +# AUDIT_NOTE: patch.object targets _test_postgresql/_test_clickhouse/_test_mysql +# which are thin wrappers around [EXT:asyncpg]/[EXT:clickhouse-connect]/[EXT:pymysql] +# DB drivers. These are VALID [EXT:Database] boundary mocks per semantics-testing §3c. +class TestTestConnection: + def test_connection_not_found(self, service): + result = service.test_connection("nonexistent") + assert result["success"] is False + assert "not found" in result["error"] + + def test_postgresql_test_success(self, service_with_two): + """Mock asyncpg to verify dialect routing works.""" + with patch.object(service_with_two, '_test_postgresql', return_value="PostgreSQL 16.3"): + result = service_with_two.test_connection("c1") + assert result["success"] is True + assert result["db_version"] == "PostgreSQL 16.3" + assert "latency_ms" in result + + def test_clickhouse_test_success(self, service_with_two): + with patch.object(service_with_two, '_test_clickhouse', return_value="ClickHouse 24.3"): + result = service_with_two.test_connection("c2") + assert result["success"] is True + assert result["db_version"] == "ClickHouse 24.3" + + def test_test_mysql_success(self, config_with_two): + """Create a MySQL connection and test it.""" + config_with_two.config.settings.connections.append( + DatabaseConnection( + id="c3", name="MySQL", host="x", port=3306, + database="x", username="u", password="p", + dialect="mysql", + ) + ) + svc = ConnectionService(config_with_two) + with patch.object(svc, '_test_mysql', return_value="MySQL 8.0"): + result = svc.test_connection("c3") + assert result["success"] is True + + def test_test_connection_error(self, service_with_two): + """Simulate connection failure.""" + with patch.object(service_with_two, '_test_postgresql', side_effect=RuntimeError("Host unreachable")): + result = service_with_two.test_connection("c1") + assert result["success"] is False + assert "Host unreachable" in result["error"] +# #endregion + + +# #region validation tests [C:2] [TYPE Block] +class TestValidateRefs: + def test_validate_no_jobs_no_orphans(self, service): + """When no TranslationJobs exist, no orphans.""" + with patch('src.core.database.SessionLocal') as mock_session_local: + mock_db = MagicMock() + mock_session_local.return_value = mock_db + mock_db.query.return_value.filter.return_value.all.return_value = [] + orphans = service.validate_connection_refs() + assert orphans == [] diff --git a/backend/tests/test_db_executor.py b/backend/tests/test_db_executor.py new file mode 100644 index 00000000..ad33bd22 --- /dev/null +++ b/backend/tests/test_db_executor.py @@ -0,0 +1,128 @@ +# #region Test.Core.DbExecutor [C:3] [TYPE Module] [SEMANTICS test, database, executor, direct] +# @BRIEF Pytest tests for DbExecutor — direct database INSERT execution. +# @TEST_CONTRACT DbExecutor.execute_sql routes to correct dialect driver and returns DbExecutionResult. +# @TEST_CONTRACT Connection pool is reused across calls (same connection_id → same pool). +# AUDIT_NOTE: patch.object targets _execute_pg/_execute_ch/_execute_mysql which are +# thin wrappers around [EXT:asyncpg]/[EXT:clickhouse-connect]/[EXT:pymysql] DB drivers. +# These are VALID [EXT:Database] boundary mocks per semantics-testing §3c. + +import pytest +from unittest.mock import MagicMock, patch + +from src.core.db_executor import DbExecutor, DbExecutionResult +from src.core.config_models import DatabaseConnection + + +@pytest.fixture +def mock_conn_service(): + cs = MagicMock() + return cs + + +@pytest.fixture +def pg_connection(): + return DatabaseConnection( + id="pg1", name="Test PG", host="localhost", port=5432, + database="test_db", username="tester", password="secret", + dialect="postgresql", pool_size=5, + ) + + +@pytest.fixture +def ch_connection(): + return DatabaseConnection( + id="ch1", name="Test CH", host="localhost", port=9000, + database="analytics", username="default", password="", + dialect="clickhouse", pool_size=2, + ) + + +@pytest.fixture +def mysql_connection(): + return DatabaseConnection( + id="my1", name="Test MySQL", host="localhost", port=3306, + database="test", username="root", password="root", + dialect="mysql", pool_size=3, + ) + + +@pytest.fixture +def executor(mock_conn_service): + return DbExecutor(mock_conn_service) + + +class TestPostgresql: + def test_execute_success(self, executor, mock_conn_service, pg_connection): + mock_conn_service.get_connection.return_value = pg_connection + sample_sql = 'INSERT INTO "products" ("id", "name") VALUES (\'1\', \'test\')' + with patch.object(executor, '_execute_pg', return_value=DbExecutionResult(success=True, rows_affected=5, execution_time_ms=120)): + result = executor.execute_sql("pg1", sample_sql) + assert result.success is True + assert result.rows_affected == 5 + assert result.execution_time_ms == 120 + + def test_connection_not_found(self, executor, mock_conn_service): + mock_conn_service.get_connection.return_value = None + result = executor.execute_sql("nonexistent", "SELECT 1") + assert result.success is False + assert "not found" in (result.error or "") + + def test_dialect_routing_ch(self, executor, mock_conn_service, ch_connection): + mock_conn_service.get_connection.return_value = ch_connection + with patch.object(executor, '_execute_ch', return_value=DbExecutionResult(success=True, rows_affected=3, execution_time_ms=50)): + result = executor.execute_sql("ch1", "INSERT INTO t VALUES (1)") + assert result.success is True + + def test_dialect_routing_mysql(self, executor, mock_conn_service, mysql_connection): + mock_conn_service.get_connection.return_value = mysql_connection + with patch.object(executor, '_execute_mysql', return_value=DbExecutionResult(success=True, rows_affected=2, execution_time_ms=30)): + result = executor.execute_sql("my1", "INSERT INTO t VALUES (1)") + assert result.success is True + + def test_unsupported_dialect(self, executor, mock_conn_service): + bad_conn = DatabaseConnection.model_construct( + id="bad", name="Bad", host="x", port=1, + database="x", username="u", password="p", dialect="oracle", + ) + mock_conn_service.get_connection.return_value = bad_conn + result = executor.execute_sql("bad", "SELECT 1") + assert result.success is False + assert "Unsupported" in (result.error or "") + + def test_execution_error(self, executor, mock_conn_service, pg_connection): + mock_conn_service.get_connection.return_value = pg_connection + with patch.object(executor, '_execute_pg', side_effect=RuntimeError("Connection timeout")): + result = executor.execute_sql("pg1", "BAD SQL") + assert result.success is False + assert "Connection timeout" in (result.error or "") + + +class TestPoolCaching: + def test_pool_cached_by_conn_id(self, executor, mock_conn_service, pg_connection): + mock_conn_service.get_connection.return_value = pg_connection + assert "pg1" not in executor._pools + executor._set_pool("pg1", "pool_obj", "config_key") + assert executor._get_or_create_pool("pg1", "config_key") == "pool_obj" + assert executor._get_or_create_pool("pg1", "other_key") is None + + def test_pool_recreated_on_config_change(self, executor, mock_conn_service, pg_connection): + mock_conn_service.get_connection.return_value = pg_connection + executor._set_pool("pg1", "old_pool", "old_config") + result = executor._get_or_create_pool("pg1", "new_config") + assert result is None + + +class TestMissingDriver: + def test_asyncpg_not_installed(self, executor, mock_conn_service, pg_connection): + mock_conn_service.get_connection.return_value = pg_connection + with patch.dict('sys.modules', {'asyncpg': None}): + result = executor._execute_pg(pg_connection, "SELECT 1", 0) + assert result.success is False + assert "asyncpg is not installed" in (result.error or "") + + def test_clickhouse_not_installed(self, executor, mock_conn_service, ch_connection): + mock_conn_service.get_connection.return_value = ch_connection + with patch.dict('sys.modules', {'clickhouse_connect': None}): + result = executor._execute_ch(ch_connection, "SELECT 1", 0) + assert result.success is False + assert "clickhouse-connect is not installed" in (result.error or "") \ No newline at end of file diff --git a/backend/tests/test_orchestrator_direct_db.py b/backend/tests/test_orchestrator_direct_db.py new file mode 100644 index 00000000..31468641 --- /dev/null +++ b/backend/tests/test_orchestrator_direct_db.py @@ -0,0 +1,196 @@ +# #region Test.Translate.OrchestratorSqlDirectDb [C:3] [TYPE Module] [SEMANTICS test, translate, orchestrator, direct-db] +# @BRIEF Pytest tests for SQLInsertService direct_db dispatch in orchestrator_sql. +# @TEST_CONTRACT SQLInsertService dispatches to direct DB executor when job.insert_method == "direct_db". +# @TEST_CONTRACT Connection snapshot is stored on TranslationRun for direct DB inserts. + +import pytest +from unittest.mock import MagicMock, patch, AsyncMock + +from src.plugins.translate.orchestrator_sql import SQLInsertService + + +@pytest.fixture +def mock_db(): + db = MagicMock() + db.query.return_value.options.return_value.filter.return_value.all.return_value = [] + return db + + +@pytest.fixture +def mock_config_manager(): + cm = MagicMock() + cm.config.settings.connections = [] + return cm + + +@pytest.fixture +def mock_event_log(): + return MagicMock() + + +@pytest.fixture +def service(mock_db, mock_config_manager, mock_event_log): + return SQLInsertService(mock_db, mock_config_manager, mock_event_log) + + +@pytest.fixture +def mock_job(): + job = MagicMock() + job.id = "job-1" + job.name = "Test Job" + job.environment_id = "env-1" + job.target_column = "translated_name" + job.translation_column = "name" + job.target_languages = ["en"] + job.target_schema = None + job.target_table = "products_i18n" + job.target_key_cols = ["product_id"] + job.upsert_strategy = "MERGE" + job.database_dialect = "postgresql" + job.target_dialect = "postgresql" + job.insert_method = "sqllab" + job.connection_id = None + return job + + +@pytest.fixture +def mock_run(): + run = MagicMock() + run.id = "run-1" + run.connection_snapshot = None + run.insert_method = None + return run + + +class TestDirectDbDispatch: + """SQLInsertService dispatches to DbExecutor (direct_db) or SupersetSqlLabExecutor (sqllab).""" + + @pytest.mark.asyncio + async def test_direct_db_dispatch(self, service, mock_job, mock_run): + """Given direct_db, dispatch to DbExecutor via ConnectionService, not SupersetSqlLabExecutor.""" + mock_job.insert_method = "direct_db" + mock_job.connection_id = "conn-1" + + # Mock DbExecutor and ConnectionService at EXT boundaries + mock_conn = MagicMock(name="Test PG", dialect="postgresql", host="x", port=5432, database="test_db") + with patch('src.plugins.translate.orchestrator_sql.ConnectionService') as mock_cs_cls: + mock_cs_cls.return_value.get_connection.return_value = mock_conn + with patch('src.plugins.translate.orchestrator_sql.DbExecutor') as mock_exec_cls: + mock_exec = MagicMock() + mock_exec_cls.return_value = mock_exec + mock_exec.execute_sql.return_value = MagicMock(success=True, rows_affected=5, execution_time_ms=200) + mock_exec_cls.return_value = mock_exec + + mock_record = MagicMock() + mock_record.target_sql = "INSERT INTO ..." + mock_record.status = "SUCCESS" + service.db.query.return_value.options.return_value.filter.return_value.all.return_value = [mock_record] + + with patch('src.plugins.translate.orchestrator_sql.SQLGenerator.generate', return_value=("INSERT SQL", 5)): + result = await service.generate_and_insert_sql(mock_job, mock_run) + + assert result["status"] == "success" + assert result["rows_affected"] == 5 + mock_exec.execute_sql.assert_called_once_with("conn-1", "INSERT SQL") + + @pytest.mark.asyncio + async def test_sqllab_dispatch_by_default(self, service, mock_job, mock_run): + """Given default sqllab, dispatch to SupersetSqlLabExecutor.""" + mock_job.insert_method = "sqllab" + mock_job.connection_id = None + + with patch('src.plugins.translate.orchestrator_sql.SupersetSqlLabExecutor') as mock_superset_cls: + mock_superset = MagicMock() + mock_superset_cls.return_value = mock_superset + mock_superset.resolve_database_id = AsyncMock() + mock_superset.execute_and_poll = AsyncMock(return_value={"status": "success"}) + + mock_record = MagicMock() + mock_record.target_sql = "INSERT INTO ..." + mock_record.status = "SUCCESS" + service.db.query.return_value.options.return_value.filter.return_value.all.return_value = [mock_record] + + with patch('src.plugins.translate.orchestrator_sql.SQLGenerator.generate', return_value=("INSERT SQL", 5)): + result = await service.generate_and_insert_sql(mock_job, mock_run) + + assert result["status"] == "success" + mock_superset.execute_and_poll.assert_called_once() + + +class TestDirectDbExecution: + """SQLInsertService._execute_direct_db behavior.""" + + @pytest.mark.asyncio + async def test_connection_snapshot_stored(self, service, mock_job, mock_run): + """Connection metadata is snapshotted on the run.""" + mock_job.insert_method = "direct_db" + mock_job.connection_id = "conn-1" + + with patch('src.plugins.translate.orchestrator_sql.ConnectionService') as mock_cs_cls: + mock_cs = MagicMock() + mock_cs_cls.return_value = mock_cs + mock_conn = MagicMock() + mock_conn.name = "Test PG" + mock_conn.dialect = "postgresql" + mock_conn.host = "db.example.com" + mock_conn.port = 5432 + mock_conn.database = "test_db" + mock_cs.get_connection.return_value = mock_conn + + with patch('src.plugins.translate.orchestrator_sql.DbExecutor') as mock_exec_cls: + mock_exec = MagicMock() + mock_exec_cls.return_value = mock_exec + mock_exec.execute_sql.return_value = MagicMock( + success=True, rows_affected=10, execution_time_ms=200 + ) + + await service._execute_direct_db(mock_job, mock_run, "INSERT SQL") + + assert mock_run.connection_snapshot == { + "name": "Test PG", + "dialect": "postgresql", + "host": "db.example.com", + "port": 5432, + "database": "test_db", + } + assert mock_run.insert_method == "direct_db" + + @pytest.mark.asyncio + async def test_connection_not_found(self, service, mock_job, mock_run): + """Missing connection returns failure without crashing.""" + mock_job.insert_method = "direct_db" + mock_job.connection_id = "nonexistent" + + with patch('src.plugins.translate.orchestrator_sql.ConnectionService') as mock_cs_cls: + mock_cs = MagicMock() + mock_cs_cls.return_value = mock_cs + mock_cs.get_connection.return_value = None + + result = await service._execute_direct_db(mock_job, mock_run, "INSERT SQL") + assert result["status"] == "failed" + assert "not found" in result["error_message"] + + @pytest.mark.asyncio + async def test_direct_db_execution_failure(self, service, mock_job, mock_run): + """DB execution error returns failure details.""" + mock_job.insert_method = "direct_db" + mock_job.connection_id = "conn-1" + + with patch('src.plugins.translate.orchestrator_sql.ConnectionService') as mock_cs_cls: + mock_cs = MagicMock() + mock_cs_cls.return_value = mock_cs + mock_cs.get_connection.return_value = MagicMock( + name="Test PG", dialect="postgresql", host="x", port=5432, database="test_db" + ) + + with patch('src.plugins.translate.orchestrator_sql.DbExecutor') as mock_exec_cls: + mock_exec = MagicMock() + mock_exec_cls.return_value = mock_exec + mock_exec.execute_sql.return_value = MagicMock( + success=False, rows_affected=0, error="Host unreachable", execution_time_ms=5000 + ) + + result = await service._execute_direct_db(mock_job, mock_run, "INSERT SQL") + assert result["status"] == "failed" + assert "Host unreachable" in result["error_message"] + assert result["execution_time_ms"] == 5000 diff --git a/backend/tests/test_translate_corrections.py b/backend/tests/test_translate_corrections.py index aced4c29..1677cbbc 100644 --- a/backend/tests/test_translate_corrections.py +++ b/backend/tests/test_translate_corrections.py @@ -1,6 +1,6 @@ # #region TranslateCorrectionTests [C:2] [TYPE Module] # @SEMANTICS: tests, translate, corrections, dictionary -# @PURPOSE: Tests for term correction API endpoints and DictionaryManager correction methods. +# @BRIEF Tests for term correction API endpoints and DictionaryManager correction methods. # @LAYER Tests # @RELATION BINDS_TO -> [DictionaryManagerModule] # @RELATION BINDS_TO -> [TranslateRoutes] @@ -101,7 +101,7 @@ def client(mock_api_deps): # #region test_submit_correction_creates_entry [C:2] [TYPE Function] -# @PURPOSE: Verify that a correction creates a new dictionary entry. +# @BRIEF Verify that a correction creates a new dictionary entry. def test_submit_correction_creates_entry(db_session): """Test that submitting a correction creates a new entry.""" dict_obj = DictionaryManager.create_dictionary( @@ -134,7 +134,7 @@ def test_submit_correction_creates_entry(db_session): # #region test_submit_correction_conflict_detected [C:2] [TYPE Function] -# @PURPOSE: Verify conflict detection when entry already exists. +# @BRIEF Verify conflict detection when entry already exists. def test_submit_correction_conflict_detected(db_session): """Test that conflict is detected when entry already exists.""" dict_obj = DictionaryManager.create_dictionary( @@ -161,7 +161,7 @@ def test_submit_correction_conflict_detected(db_session): # #region test_submit_correction_overwrite [C:2] [TYPE Function] -# @PURPOSE: Verify that correction overwrites existing entry. +# @BRIEF Verify that correction overwrites existing entry. def test_submit_correction_overwrite(db_session): """Test that correction overwrites existing entry.""" dict_obj = DictionaryManager.create_dictionary( @@ -186,7 +186,7 @@ def test_submit_correction_overwrite(db_session): # #region test_bulk_corrections_atomic [C:2] [TYPE Function] -# @PURPOSE: Verify bulk corrections are applied atomically. +# @BRIEF Verify bulk corrections are applied atomically. def test_bulk_corrections_atomic(db_session): """Test that bulk corrections are applied atomically.""" dict_obj = DictionaryManager.create_dictionary( @@ -215,7 +215,7 @@ def test_bulk_corrections_atomic(db_session): # #region test_api_correction_missing_dict [C:2] [TYPE Function] -# @PURPOSE: Verify POST corrections without dictionary_id returns 422. +# @BRIEF Verify POST corrections without dictionary_id returns 422. def test_api_correction_missing_dict(client): """Test correction without dict_id returns 422.""" response = client.post("/api/translate/corrections", json={ @@ -228,7 +228,7 @@ def test_api_correction_missing_dict(client): # #region test_api_bulk_corrections [C:2] [TYPE Function] -# @PURPOSE: Verify POST /corrections/bulk works. +# @BRIEF Verify POST /corrections/bulk works. def test_api_bulk_corrections(client, mock_api_deps): """Test bulk corrections endpoint.""" session = mock_api_deps["session"] diff --git a/backend/tests/test_translate_history.py b/backend/tests/test_translate_history.py index 92a4a99e..b3f52330 100644 --- a/backend/tests/test_translate_history.py +++ b/backend/tests/test_translate_history.py @@ -1,6 +1,6 @@ # #region TranslateHistoryTests [C:2] [TYPE Module] # @SEMANTICS: tests, translate, history, metrics -# @PURPOSE: Tests for run history list/detail endpoints and metrics aggregation. +# @BRIEF Tests for run history list/detail endpoints and metrics aggregation. # @LAYER Tests # @RELATION BINDS_TO -> [TranslateRoutes] # @RELATION BINDS_TO -> [TranslationMetrics] @@ -137,7 +137,7 @@ def _create_test_run(db_session, job_id: str, status: str = "COMPLETED", trigger # #region test_list_runs_empty [C:2] [TYPE Function] -# @PURPOSE: Verify runs list returns empty result initially. +# @BRIEF Verify runs list returns empty result initially. def test_list_runs_empty(client): """Test runs list initially returns empty.""" response = client.get("/api/translate/runs") @@ -149,7 +149,7 @@ def test_list_runs_empty(client): # #region test_list_runs_with_data [C:2] [TYPE Function] -# @PURPOSE: Verify runs list returns data when runs exist. +# @BRIEF Verify runs list returns data when runs exist. def test_list_runs_with_data(client, mock_api_deps): """Test runs list with existing runs.""" session = mock_api_deps["session"] @@ -167,7 +167,7 @@ def test_list_runs_with_data(client, mock_api_deps): # #region test_list_runs_filter_job_id [C:2] [TYPE Function] -# @PURPOSE: Verify filtering runs by job_id works. +# @BRIEF Verify filtering runs by job_id works. def test_list_runs_filter_job_id(client, mock_api_deps): """Test filtering runs by job_id.""" session = mock_api_deps["session"] @@ -187,7 +187,7 @@ def test_list_runs_filter_job_id(client, mock_api_deps): # #region test_list_runs_filter_status [C:2] [TYPE Function] -# @PURPOSE: Verify filtering runs by status works. +# @BRIEF Verify filtering runs by status works. def test_list_runs_filter_status(client, mock_api_deps): """Test filtering runs by status.""" session = mock_api_deps["session"] @@ -205,7 +205,7 @@ def test_list_runs_filter_status(client, mock_api_deps): # #region test_get_run_detail [C:2] [TYPE Function] -# @PURPOSE: Verify run detail returns config_snapshot, records, events. +# @BRIEF Verify run detail returns config_snapshot, records, events. def test_get_run_detail(client, mock_api_deps): """Test run detail endpoint.""" session = mock_api_deps["session"] @@ -237,7 +237,7 @@ def test_get_run_detail(client, mock_api_deps): # #region test_get_job_metrics [C:2] [TYPE Function] -# @PURPOSE: Verify metrics endpoint returns aggregated data. +# @BRIEF Verify metrics endpoint returns aggregated data. def test_get_job_metrics(client, mock_api_deps): """Test job metrics endpoint.""" session = mock_api_deps["session"] @@ -257,7 +257,7 @@ def test_get_job_metrics(client, mock_api_deps): # #region test_get_all_metrics [C:2] [TYPE Function] -# @PURPOSE: Verify global metrics endpoint returns data. +# @BRIEF Verify global metrics endpoint returns data. def test_get_all_metrics(client, mock_api_deps): """Test global metrics endpoint.""" session = mock_api_deps["session"] @@ -274,7 +274,7 @@ def test_get_all_metrics(client, mock_api_deps): # #region test_metrics_empty_job [C:2] [TYPE Function] -# @PURPOSE: Verify metrics for job with no runs returns zeros. +# @BRIEF Verify metrics for job with no runs returns zeros. def test_metrics_empty_job(client, mock_api_deps): """Test metrics for job with no runs.""" session = mock_api_deps["session"] @@ -289,14 +289,14 @@ def test_metrics_empty_job(client, mock_api_deps): # #region test_run_detail_not_found [C:2] [TYPE Function] -# @PURPOSE: Verify 404 on non-existent run detail. +# @BRIEF Verify 404 on non-existent run detail. def test_run_detail_not_found(client): """Test run detail returns 404 for non-existent run.""" response = client.get("/api/translate/runs/non-existent/detail") assert response.status_code == 404 # #endregion test_run_detail_not_found # #region test_list_runs_includes_language_info [C:2] [TYPE Function] -# @PURPOSE: Verify run list includes target_languages, total_translated, and language_stats. +# @BRIEF Verify run list includes target_languages, total_translated, and language_stats. def test_list_runs_includes_language_info(client, mock_api_deps): """Test run list includes per-language fields.""" from src.models.translate import TranslationRunLanguageStats @@ -333,7 +333,7 @@ def test_list_runs_includes_language_info(client, mock_api_deps): # #region test_metrics_per_language_breakdown [C:2] [TYPE Function] -# @PURPOSE: Verify metrics endpoint returns per_language_metrics. +# @BRIEF Verify metrics endpoint returns per_language_metrics. def test_metrics_per_language_breakdown(client, mock_api_deps): """Test metrics returns per-language breakdown.""" from src.models.translate import TranslationRunLanguageStats @@ -363,7 +363,7 @@ def test_metrics_per_language_breakdown(client, mock_api_deps): # #region test_metric_snapshot_stores_per_language [C:2] [TYPE Function] -# @PURPOSE: Verify MetricSnapshot stores per_language_metrics via prune_expired. +# @BRIEF Verify MetricSnapshot stores per_language_metrics via prune_expired. def test_metric_snapshot_stores_per_language(client, mock_api_deps): """Test MetricSnapshot stores per_language_metrics after prune.""" from datetime import timedelta @@ -410,7 +410,7 @@ def test_metric_snapshot_stores_per_language(client, mock_api_deps): # #region test_combined_metrics_live_and_snapshot [C:2] [TYPE Function] -# @PURPOSE: Verify combined metrics merge live + snapshot per-language data. +# @BRIEF Verify combined metrics merge live + snapshot per-language data. def test_combined_metrics_live_and_snapshot(client, mock_api_deps): """Test metrics merge live data with MetricSnapshot per-language data.""" from datetime import timedelta diff --git a/backend/tests/test_translate_jobs.py b/backend/tests/test_translate_jobs.py index 0d51f9af..6b7982c7 100644 --- a/backend/tests/test_translate_jobs.py +++ b/backend/tests/test_translate_jobs.py @@ -1,6 +1,6 @@ # #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. +# @BRIEF Tests for translation job CRUD endpoints and service layer with column validation. # @LAYER Tests # @RELATION BINDS_TO -> [TranslateRoutes] # @RELATION BINDS_TO -> [TranslateJobService] @@ -45,7 +45,7 @@ from src.dependencies import ( ) # #region valid_job_payload [C:2] [TYPE Variable] -# @PURPOSE: Standard valid payload for creating a translation job. +# @BRIEF Standard valid payload for creating a translation job. valid_job_payload = { "name": "Test Translation Job", "description": "A test job for unit tests", @@ -83,7 +83,7 @@ Base.metadata.create_all(bind=engine) # #region MockConfigManager [C:2] [TYPE Class] -# @PURPOSE: Mock ConfigManager for service tests that returns a test environment. +# @BRIEF Mock ConfigManager for service tests that returns a test environment. class MockConfigManager: def get_environments(self): return [ @@ -102,7 +102,7 @@ class MockConfigManager: # #region db_session [C:2] [TYPE Function] -# @PURPOSE: Create a fresh DB session with transaction rollback for each test. +# @BRIEF Create a fresh DB session with transaction rollback for each test. @pytest.fixture def db_session(): connection = engine.connect() @@ -118,7 +118,7 @@ def 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. +# @BRIEF 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 @@ -159,7 +159,7 @@ def mock_api_deps(): # #region client [C:2] [TYPE Function] -# @PURPOSE: FastAPI TestClient for API route tests. +# @BRIEF FastAPI TestClient for API route tests. @pytest.fixture def client(mock_api_deps): return TestClient(app) @@ -172,7 +172,7 @@ def client(mock_api_deps): # #region test_create_job_valid [C:2] [TYPE Function] -# @PURPOSE: Verify that a valid job payload creates a job successfully. +# @BRIEF Verify that a valid job payload creates a job successfully. async def test_create_job_valid(db_session): """Test creating a valid translation job.""" from src.plugins.translate.service import TranslateJobService @@ -207,7 +207,7 @@ async def test_create_job_valid(db_session): # #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. +# @BRIEF Verify that creating a job with datasource but no translation column raises ValueError. async 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 @@ -230,7 +230,7 @@ async def test_create_job_missing_translation_column(db_session): # #region test_create_job_invalid_upsert_strategy [C:2] [TYPE Function] -# @PURPOSE: Verify that an invalid upsert strategy is rejected. +# @BRIEF Verify that an invalid upsert strategy is rejected. async def test_create_job_invalid_upsert_strategy(db_session): """Test that an invalid upsert strategy raises ValueError.""" from src.plugins.translate.service import TranslateJobService @@ -252,7 +252,7 @@ async def test_create_job_invalid_upsert_strategy(db_session): # #region test_get_job [C:2] [TYPE Function] -# @PURPOSE: Verify that a job can be retrieved by ID. +# @BRIEF Verify that a job can be retrieved by ID. async def test_get_job(db_session): """Test retrieving a translation job by ID.""" from src.plugins.translate.service import TranslateJobService @@ -271,7 +271,7 @@ async def test_get_job(db_session): # #region test_get_job_not_found [C:2] [TYPE Function] -# @PURPOSE: Verify that getting a non-existent job raises ValueError. +# @BRIEF 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 @@ -285,7 +285,7 @@ def test_get_job_not_found(db_session): # #region test_list_jobs [C:2] [TYPE Function] -# @PURPOSE: Verify that listing jobs returns all created jobs. +# @BRIEF Verify that listing jobs returns all created jobs. async def test_list_jobs(db_session): """Test listing translation jobs.""" from src.plugins.translate.service import TranslateJobService @@ -306,7 +306,7 @@ async def test_list_jobs(db_session): # #region test_list_jobs_with_status_filter [C:2] [TYPE Function] -# @PURPOSE: Verify that listing jobs with a status filter works. +# @BRIEF Verify that listing jobs with a status filter works. async def test_list_jobs_with_status_filter(db_session): """Test listing jobs filtered by status.""" from src.plugins.translate.service import TranslateJobService @@ -329,7 +329,7 @@ async def test_list_jobs_with_status_filter(db_session): # #region test_update_job [C:2] [TYPE Function] -# @PURPOSE: Verify that a job can be updated. +# @BRIEF Verify that a job can be updated. async def test_update_job(db_session): """Test updating a translation job.""" from src.plugins.translate.service import TranslateJobService @@ -355,7 +355,7 @@ async def test_update_job(db_session): # #region test_delete_job [C:2] [TYPE Function] -# @PURPOSE: Verify that a job can be deleted. +# @BRIEF Verify that a job can be deleted. async def test_delete_job(db_session): """Test deleting a translation job.""" from src.plugins.translate.service import TranslateJobService @@ -375,7 +375,7 @@ async def test_delete_job(db_session): # #region test_duplicate_job [C:2] [TYPE Function] -# @PURPOSE: Verify that a job can be duplicated. +# @BRIEF Verify that a job can be duplicated. async def test_duplicate_job(db_session): """Test duplicating a translation job.""" from src.plugins.translate.service import TranslateJobService @@ -400,7 +400,7 @@ async def test_duplicate_job(db_session): # #region test_duplicate_job_custom_name [C:2] [TYPE Function] -# @PURPOSE: Verify that a job can be duplicated with a custom name. +# @BRIEF Verify that a job can be duplicated with a custom name. async def test_duplicate_job_custom_name(db_session): """Test duplicating a job with a custom name.""" from src.plugins.translate.service import TranslateJobService @@ -418,7 +418,7 @@ async def test_duplicate_job_custom_name(db_session): # #region test_detect_virtual_columns [C:2] [TYPE Function] -# @PURPOSE: Verify virtual column detection from column metadata. +# @BRIEF 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 @@ -437,7 +437,7 @@ def test_detect_virtual_columns(): # #region test_get_dialect_from_database [C:2] [TYPE Function] -# @PURPOSE: Verify dialect extraction from Superset database records. +# @BRIEF 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 @@ -454,7 +454,7 @@ def test_get_dialect_from_database(): # #region test_get_dialect_from_database_unsupported [C:2] [TYPE Function] -# @PURPOSE: Verify that unsupported dialects raise ValueError. +# @BRIEF 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 @@ -473,7 +473,7 @@ def test_get_dialect_from_database_unsupported(): # #region test_api_create_job [C:2] [TYPE Function] -# @PURPOSE: Verify POST /api/translate/jobs returns 201 with valid payload. +# @BRIEF 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) @@ -489,7 +489,7 @@ def test_api_create_job(client): # #region test_api_list_jobs [C:2] [TYPE Function] -# @PURPOSE: Verify GET /api/translate/jobs returns 200. +# @BRIEF 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") @@ -499,7 +499,7 @@ def test_api_list_jobs(client): # #region test_api_get_job_not_found [C:2] [TYPE Function] -# @PURPOSE: Verify GET non-existent job returns 404. +# @BRIEF 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") @@ -508,7 +508,7 @@ def test_api_get_job_not_found(client): # #region test_api_delete_job_not_found [C:2] [TYPE Function] -# @PURPOSE: Verify DELETE non-existent job returns 404. +# @BRIEF 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") @@ -517,7 +517,7 @@ def test_api_delete_job_not_found(client): # #region test_api_duplicate_job_not_found [C:2] [TYPE Function] -# @PURPOSE: Verify duplicating a non-existent job returns 404. +# @BRIEF 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") @@ -526,7 +526,7 @@ def test_api_duplicate_job_not_found(client): # #region test_api_create_job_422_missing_name [C:2] [TYPE Function] -# @PURPOSE: Verify POST with missing required fields returns 422. +# @BRIEF 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"}) @@ -535,7 +535,7 @@ def test_api_create_job_422_missing_name(client): # #region test_api_datasource_columns_missing_env [C:2] [TYPE Function] -# @PURPOSE: Verify datasource columns endpoint without env_id returns 422. +# @BRIEF 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") @@ -544,7 +544,7 @@ def test_api_datasource_columns_missing_env(client): # #region test_api_datasource_columns_bad_env [C:2] [TYPE Function] -# @PURPOSE: Verify datasource columns with unknown env returns 400. +# @BRIEF 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") @@ -553,7 +553,7 @@ def test_api_datasource_columns_bad_env(client): # #region test_api_update_job_not_found [C:2] [TYPE Function] -# @PURPOSE: Verify PUT non-existent job returns 404. +# @BRIEF 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"}) diff --git a/backend/tests/test_translate_scheduler.py b/backend/tests/test_translate_scheduler.py index b05784be..e895db36 100644 --- a/backend/tests/test_translate_scheduler.py +++ b/backend/tests/test_translate_scheduler.py @@ -1,6 +1,6 @@ # #region TranslateSchedulerTests [C:2] [TYPE Module] # @SEMANTICS: tests, translate, scheduler -# @PURPOSE: Tests for TranslationScheduler CRUD and APScheduler integration. +# @BRIEF Tests for TranslationScheduler CRUD and APScheduler integration. # @LAYER Tests # @RELATION BINDS_TO -> [TranslationScheduler] # @@ -56,7 +56,7 @@ def db_session(): # #region test_create_schedule [C:2] [TYPE Function] -# @PURPOSE: Verify schedule creation with valid params. +# @BRIEF Verify schedule creation with valid params. async def test_create_schedule(db_session): """Test creating a schedule for a job.""" config_mgr = MagicMock() @@ -82,7 +82,7 @@ async def test_create_schedule(db_session): # #region test_update_schedule [C:2] [TYPE Function] -# @PURPOSE: Verify schedule update. +# @BRIEF Verify schedule update. async def test_update_schedule(db_session): """Test updating a schedule.""" config_mgr = MagicMock() @@ -102,7 +102,7 @@ async def test_update_schedule(db_session): # #region test_delete_schedule [C:2] [TYPE Function] -# @PURPOSE: Verify schedule deletion. +# @BRIEF Verify schedule deletion. async def test_delete_schedule(db_session): """Test deleting a schedule.""" config_mgr = MagicMock() @@ -121,7 +121,7 @@ async def test_delete_schedule(db_session): # #region test_enable_disable_schedule [C:2] [TYPE Function] -# @PURPOSE: Verify enable/disable toggle. +# @BRIEF Verify enable/disable toggle. async def test_enable_disable_schedule(db_session): """Test enabling and disabling a schedule.""" config_mgr = MagicMock() @@ -142,7 +142,7 @@ async def test_enable_disable_schedule(db_session): # #region test_get_schedule_not_found [C:2] [TYPE Function] -# @PURPOSE: Verify ValueError on getting non-existent schedule. +# @BRIEF Verify ValueError on getting non-existent schedule. def test_get_schedule_not_found(db_session): """Test that getting a non-existent schedule raises ValueError.""" config_mgr = MagicMock() @@ -155,7 +155,7 @@ def test_get_schedule_not_found(db_session): # #region test_list_active_schedules [C:2] [TYPE Function] -# @PURPOSE: Verify listing only active schedules. +# @BRIEF Verify listing only active schedules. async def test_list_active_schedules(db_session): """Test listing active schedules.""" config_mgr = MagicMock() @@ -175,7 +175,7 @@ async def test_list_active_schedules(db_session): # #region test_get_next_executions [C:2] [TYPE Function] -# @PURPOSE: Verify next execution time computation. +# @BRIEF Verify next execution time computation. def test_get_next_executions(): """Test computing next execution times.""" times = TranslationScheduler.get_next_executions("0 2 * * *", "UTC", n=3) @@ -186,7 +186,7 @@ def test_get_next_executions(): # #region test_get_next_executions_invalid [C:2] [TYPE Function] -# @PURPOSE: Verify invalid cron returns empty list. +# @BRIEF Verify invalid cron returns empty list. def test_get_next_executions_invalid(): """Test invalid cron returns empty list.""" times = TranslationScheduler.get_next_executions("invalid cron", "UTC", n=3)