# #region Test.TargetSchemaValidation [C:3] [TYPE Module] [SEMANTICS test, translate, schema, validation] # @BRIEF Tests for service_target_schema.py — _build_expected_columns, _extract_columns_from_rows, # _parse_sqllab_result, validate_target_table_schema. # @RELATION BINDS_TO -> [TargetSchemaValidation] # @TEST_CONTRACT: _build_expected_columns -> list[TargetSchemaColumnInfo] | deduplicated column list # @TEST_CONTRACT: _extract_columns_from_rows -> list[dict] | normalised column info # @TEST_CONTRACT: _parse_sqllab_result -> tuple | data_rows + table_exists # @TEST_CONTRACT: validate_target_table_schema -> TargetSchemaValidationResponse | full schema diff import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) import os os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") from unittest.mock import AsyncMock, MagicMock, patch import pytest from src.schemas.translate import ( TargetSchemaColumnInfo, TargetSchemaValidationRequest, TargetSchemaValidationResponse, ) from src.plugins.translate.service_target_schema import ( _build_expected_columns, _extract_columns_from_rows, _parse_sqllab_result, validate_target_table_schema, ) class TestBuildExpectedColumns: """_build_expected_columns — build deduplicated column list from request.""" def _make_req(self, overrides: dict | None = None) -> TargetSchemaValidationRequest: defaults = { "environment_id": "env-1", "target_database_id": "db-1", "target_table": "my_table", "target_schema": "public", "target_key_cols": ["id"], "target_column": None, "translation_column": "name", "target_language_column": "lang", "target_source_column": "src", "target_source_language_column": "src_lang", } if overrides: defaults.update(overrides) return TargetSchemaValidationRequest(**defaults) def test_with_all_columns(self): """All column types included in order.""" req = self._make_req() cols = _build_expected_columns(req) names = [c.name for c in cols] assert names == ["id", "name", "lang", "src", "src_lang", "context", "is_original"] def test_basic_minimal(self): """Minimal config: only key cols and translation column.""" req = self._make_req({ "target_key_cols": ["id"], "translation_column": "name", "target_column": None, "target_language_column": None, "target_source_column": None, "target_source_language_column": None, }) cols = _build_expected_columns(req) names = [c.name for c in cols] assert names == ["id", "name", "context", "is_original"] def test_empty_key_cols(self): """Empty key cols list handled.""" req = self._make_req({ "target_key_cols": [], "translation_column": "name", "target_column": None, "target_language_column": None, "target_source_column": None, "target_source_language_column": None, }) cols = _build_expected_columns(req) names = [c.name for c in cols] assert names == ["name", "context", "is_original"] def test_target_column_overrides_translation(self): """target_column used instead of translation_column when set.""" req = self._make_req({ "target_key_cols": [], "translation_column": "source_col", "target_column": "target_col", "target_language_column": None, "target_source_column": None, "target_source_language_column": None, }) cols = _build_expected_columns(req) names = [c.name for c in cols] assert "target_col" in names assert "source_col" not in names def test_deduplication(self): """Duplicate column names are removed.""" req = self._make_req({ "target_key_cols": ["id", "id"], "target_column": "name", "translation_column": "name", "target_language_column": None, "target_source_column": None, "target_source_language_column": None, }) cols = _build_expected_columns(req) names = [c.name for c in cols] assert names == ["id", "name", "context", "is_original"] def test_none_key_cols(self): """None key cols handled.""" req = self._make_req({ "target_key_cols": None, "translation_column": "name", "target_column": None, "target_language_column": None, "target_source_column": None, "target_source_language_column": None, }) cols = _build_expected_columns(req) names = [c.name for c in cols] assert names == ["name", "context", "is_original"] def test_all_nullable_fields(self): """All optional column fields are None.""" req = self._make_req({ "target_key_cols": None, "translation_column": None, "target_column": None, "target_language_column": None, "target_source_column": None, "target_source_language_column": None, }) cols = _build_expected_columns(req) names = [c.name for c in cols] # Only the always-included columns assert names == ["context", "is_original"] def test_return_type(self): """Returns list of TargetSchemaColumnInfo.""" req = self._make_req() cols = _build_expected_columns(req) for c in cols: assert isinstance(c, TargetSchemaColumnInfo) assert c.name assert c.data_type is None assert c.is_nullable is True class TestExtractColumnsFromRows: """_extract_columns_from_rows — parse column info from data rows.""" def test_postgres_format(self): """PostgreSQL information_schema.columns format.""" rows = [ {"column_name": "id", "data_type": "integer", "is_nullable": "YES"}, {"column_name": "name", "data_type": "varchar", "is_nullable": "NO"}, ] cols = _extract_columns_from_rows(rows) assert len(cols) == 2 assert cols[0]["name"] == "id" assert cols[0]["type"] == "integer" assert cols[0]["is_nullable"] is True assert cols[1]["name"] == "name" assert cols[1]["type"] == "varchar" assert cols[1]["is_nullable"] is False def test_clickhouse_format(self): """ClickHouse DESCRIBE TABLE format.""" rows = [ {"name": "id", "type": "Int32"}, {"name": "name", "type": "String"}, ] cols = _extract_columns_from_rows(rows) assert len(cols) == 2 assert cols[0]["name"] == "id" assert cols[0]["type"] == "Int32" assert cols[0]["is_nullable"] is True # default for CH def test_mysql_format(self): """MySQL SHOW COLUMNS format.""" rows = [ {"Field": "id", "Type": "int(11)"}, {"Field": "name", "Type": "varchar(255)"}, ] cols = _extract_columns_from_rows(rows) assert len(cols) == 2 assert cols[0]["name"] == "id" assert cols[0]["type"] == "int(11)" def test_non_dict_row_skipped(self): """Non-dict rows are skipped.""" rows = [{"column_name": "id"}, "not a dict", 42] cols = _extract_columns_from_rows(rows) assert len(cols) == 1 def test_missing_column_name_skipped(self): """Rows without any known column key are skipped.""" rows = [{"unknown_key": "value"}] cols = _extract_columns_from_rows(rows) assert len(cols) == 0 def test_empty_rows(self): """Empty list returns empty list.""" assert _extract_columns_from_rows([]) == [] def test_is_nullable_string_handling(self): """is_nullable string 'YES'/'NO' converted to bool.""" rows = [ {"column_name": "a", "is_nullable": "YES"}, {"column_name": "b", "is_nullable": "NO"}, {"column_name": "c", "is_nullable": "yes"}, {"column_name": "d", "is_nullable": None}, ] cols = _extract_columns_from_rows(rows) assert cols[0]["is_nullable"] is True assert cols[1]["is_nullable"] is False assert cols[2]["is_nullable"] is True assert cols[3]["is_nullable"] is True # None -> default True def test_type_none_handled(self): """type is None when not found in row.""" rows = [{"column_name": "id"}] cols = _extract_columns_from_rows(rows) assert cols[0]["type"] is None class TestParseSqllabResult: """_parse_sqllab_result — extract data rows from SQL Lab response.""" def test_format_direct_data(self): """Format 1: direct 'data' key.""" result = {"data": [{"col": 1}, {"col": 2}]} rows, exists = _parse_sqllab_result(result) assert len(rows) == 2 assert exists is True def test_format_async_result(self): """Format 2: nested 'result' dict.""" result = {"result": {"data": [{"col": 1}]}} rows, exists = _parse_sqllab_result(result) assert len(rows) == 1 def test_format_get_query_results(self): """Format 3: 'results' dict with 'data'.""" result = {"results": {"data": [{"col": 1}]}} rows, exists = _parse_sqllab_result(result) assert len(rows) == 1 def test_format_get_query_results_nested(self): """Format 3: 'results' -> 'result' -> 'data'.""" result = {"results": {"result": {"data": [{"col": 1}]}}} rows, exists = _parse_sqllab_result(result) assert len(rows) == 1 def test_format_results_list(self): """Format 4: 'results' as list.""" result = {"results": [{"col": 1}]} rows, exists = _parse_sqllab_result(result) assert len(rows) == 1 def test_empty_result(self): """None/empty result returns empty.""" rows, exists = _parse_sqllab_result({}) assert rows == [] assert exists is False def test_none_result(self): """None result returns empty.""" rows, exists = _parse_sqllab_result(None) assert rows == [] assert exists is False def test_no_data_key(self): """Result without data/result/results returns empty.""" result = {"status": "success"} rows, exists = _parse_sqllab_result(result) assert rows == [] assert exists is False def test_raw_response_is_falsy(self): """raw_response is a falsy value -> returns empty.""" result = {"raw_response": None} rows, exists = _parse_sqllab_result(result) assert rows == [] assert exists is False def test_raw_response_is_empty_string(self): """raw_response is empty string -> returns empty.""" result = {"raw_response": ""} rows, exists = _parse_sqllab_result(result) assert rows == [] assert exists is False def test_with_raw_response_wrapper(self): """Result with raw_response wrapper unwrapped.""" result = {"raw_response": {"data": [{"col": 1}]}} rows, exists = _parse_sqllab_result(result) assert len(rows) == 1 def test_normalize_list_rows_to_dicts(self): """List-of-lists with columns metadata normalized to dicts.""" result = { "data": [["id1", "name1"], ["id2", "name2"]], "columns": [{"name": "id"}, {"name": "name"}], } rows, exists = _parse_sqllab_result(result) assert len(rows) == 2 assert rows[0] == {"id": "id1", "name": "name1"} assert rows[1] == {"id": "id2", "name": "name2"} def test_table_not_exists_empty_data(self): """Empty data means table does not exist.""" result = {"data": []} rows, exists = _parse_sqllab_result(result) assert rows == [] assert exists is False def test_data_not_list(self): """data key exists but is not a list.""" result = {"data": "not a list"} rows, exists = _parse_sqllab_result(result) assert rows == [] assert exists is False class TestValidateTargetTableSchema: """validate_target_table_schema — full schema validation flow.""" @pytest.mark.asyncio async def test_invalid_table_name_sql_injection(self): """Invalid table name (SQL injection) returns error response.""" req = TargetSchemaValidationRequest( environment_id="env-1", target_database_id="db-1", target_table="users; DROP TABLE", target_schema="public", ) config_manager = MagicMock() result = await validate_target_table_schema(req, config_manager) assert result.table_exists is False assert "Invalid target table name" in result.error assert len(result.missing_columns) > 0 assert result.all_present is False @pytest.mark.asyncio async def test_invalid_schema_name(self): """Invalid schema name returns error response.""" req = TargetSchemaValidationRequest( environment_id="env-1", target_database_id="db-1", target_table="my_table", target_schema="schema; DROP", ) config_manager = MagicMock() result = await validate_target_table_schema(req, config_manager) assert result.table_exists is False assert "Invalid target schema name" in result.error assert result.all_present is False @pytest.mark.asyncio async def test_direct_db_path(self): """Direct DB path uses DbExecutor instead of SQL Lab.""" req = TargetSchemaValidationRequest( environment_id="env-1", target_database_id="db-1", target_table="my_table", target_schema="public", insert_method="direct_db", connection_id="conn-1", ) config_manager = MagicMock() mock_column = MagicMock() mock_column.name = "id" mock_column.data_type = "integer" mock_executor = AsyncMock() mock_executor.fetch_schema.return_value = [mock_column] with patch("src.plugins.translate.service_target_schema.ConnectionService") as MockCS, \ patch("src.plugins.translate.service_target_schema.DbExecutor", return_value=mock_executor): result = await validate_target_table_schema(req, config_manager) assert result.table_exists is True assert len(result.actual_columns) == 1 assert result.actual_columns[0].name == "id" assert result.actual_columns[0].data_type == "integer" @pytest.mark.asyncio async def test_direct_db_no_columns(self): """Direct DB path with None fetch returns table_exists=False.""" req = TargetSchemaValidationRequest( environment_id="env-1", target_database_id="db-1", target_table="my_table", target_schema="public", insert_method="direct_db", connection_id="conn-1", ) config_manager = MagicMock() mock_executor = AsyncMock() mock_executor.fetch_schema.return_value = None with patch("src.plugins.translate.service_target_schema.ConnectionService"), \ patch("src.plugins.translate.service_target_schema.DbExecutor", return_value=mock_executor): result = await validate_target_table_schema(req, config_manager) assert result.table_exists is False assert "Failed to fetch schema" in (result.error or "") def _make_sqllab_executor(self, backend="postgresql", db_name="test_db"): """Create a mock executor with proper sync/async methods.""" executor = MagicMock() executor.resolve_database_id = AsyncMock() executor.get_database_backend.return_value = backend executor.get_database_name.return_value = db_name executor.execute_and_poll = AsyncMock() return executor @pytest.mark.asyncio async def test_sqllab_path_success(self): """SQL Lab path: query succeeds, columns match.""" req = TargetSchemaValidationRequest( environment_id="env-1", target_database_id="db-1", target_table="my_table", target_schema="public", target_key_cols=["id"], translation_column="name", target_column=None, target_language_column=None, target_source_column=None, target_source_language_column=None, ) config_manager = MagicMock() executor = self._make_sqllab_executor() executor.execute_and_poll.return_value = { "status": "success", "data": [ {"column_name": "id", "data_type": "integer", "is_nullable": "NO"}, {"column_name": "name", "data_type": "varchar", "is_nullable": "YES"}, {"column_name": "context", "data_type": "text", "is_nullable": "YES"}, {"column_name": "is_original", "data_type": "boolean", "is_nullable": "YES"}, ], } with patch("src.plugins.translate.service_target_schema.SupersetSqlLabExecutor", return_value=executor): result = await validate_target_table_schema(req, config_manager) assert result.table_exists is True assert result.all_present is True assert len(result.expected_columns) == 4 # id, name, context, is_original assert len(result.missing_columns) == 0 @pytest.mark.asyncio async def test_sqllab_path_missing_columns(self): """SQL Lab path: some expected columns missing.""" req = TargetSchemaValidationRequest( environment_id="env-1", target_database_id="db-1", target_table="my_table", target_schema="public", target_key_cols=["id"], translation_column="name", ) config_manager = MagicMock() executor = self._make_sqllab_executor() executor.execute_and_poll.return_value = { "status": "success", "data": [ {"column_name": "id", "data_type": "integer", "is_nullable": "NO"}, ], } with patch("src.plugins.translate.service_target_schema.SupersetSqlLabExecutor", return_value=executor): result = await validate_target_table_schema(req, config_manager) assert result.table_exists is True assert result.all_present is False assert len(result.missing_columns) > 0 missing_names = [c.name for c in result.missing_columns] assert "name" in missing_names @pytest.mark.asyncio async def test_sqllab_path_clickhouse(self): """ClickHouse backend uses system.columns.""" req = TargetSchemaValidationRequest( environment_id="env-1", target_database_id="db-1", target_table="my_table", target_schema="public", target_key_cols=["id"], translation_column="name", ) config_manager = MagicMock() executor = self._make_sqllab_executor(backend="clickhouse", db_name="test_ch") executor.execute_and_poll.return_value = { "status": "success", "data": [ {"name": "id", "type": "Int32"}, {"name": "name", "type": "String"}, {"name": "context", "type": "String"}, {"name": "is_original", "type": "UInt8"}, ], } with patch("src.plugins.translate.service_target_schema.SupersetSqlLabExecutor", return_value=executor): result = await validate_target_table_schema(req, config_manager) assert result.table_exists is True assert result.all_present is True # Verify clickhouse SQL was used call_sql = executor.execute_and_poll.call_args[1]["sql"] assert "system.columns" in call_sql assert "SELECT name, type" in call_sql @pytest.mark.asyncio async def test_sqllab_path_failed_status(self): """SQL Lab query fails status -> error response.""" req = TargetSchemaValidationRequest( environment_id="env-1", target_database_id="db-1", target_table="my_table", ) config_manager = MagicMock() executor = self._make_sqllab_executor() executor.execute_and_poll.return_value = { "status": "failed", "error_message": "Table does not exist", } with patch("src.plugins.translate.service_target_schema.SupersetSqlLabExecutor", return_value=executor): result = await validate_target_table_schema(req, config_manager) assert result.table_exists is False assert "Superset SQL Lab error" in (result.error or "") assert result.database_name == "test_db" assert result.database_backend == "postgresql" @pytest.mark.asyncio async def test_sqllab_path_exception(self): """Exception during SQL Lab execution -> error response.""" req = TargetSchemaValidationRequest( environment_id="env-1", target_database_id="db-1", target_table="my_table", ) config_manager = MagicMock() executor = MagicMock() executor.resolve_database_id = AsyncMock(side_effect=Exception("Connection refused")) with patch("src.plugins.translate.service_target_schema.SupersetSqlLabExecutor", return_value=executor): result = await validate_target_table_schema(req, config_manager) assert result.table_exists is False assert "Failed to validate" in (result.error or "") @pytest.mark.asyncio async def test_sqllab_with_extra_columns(self): """Extra columns in target table are reported.""" req = TargetSchemaValidationRequest( environment_id="env-1", target_database_id="db-1", target_table="my_table", target_key_cols=["id"], translation_column="name", ) config_manager = MagicMock() executor = self._make_sqllab_executor() executor.execute_and_poll.return_value = { "status": "success", "data": [ {"column_name": "id", "data_type": "integer"}, {"column_name": "name", "data_type": "varchar"}, {"column_name": "context", "data_type": "text"}, {"column_name": "is_original", "data_type": "boolean"}, {"column_name": "extra_col", "data_type": "text"}, ], } with patch("src.plugins.translate.service_target_schema.SupersetSqlLabExecutor", return_value=executor): result = await validate_target_table_schema(req, config_manager) assert result.all_present is True extra_names = [c.name for c in result.extra_columns] assert "extra_col" in extra_names @pytest.mark.asyncio async def test_clickhousedb_normalized_to_clickhouse(self): """Backend 'clickhousedb' normalized to 'clickhouse' for SQL generation.""" req = TargetSchemaValidationRequest( environment_id="env-1", target_database_id="db-1", target_table="my_table", target_schema="default", ) config_manager = MagicMock() executor = self._make_sqllab_executor(backend="clickhousedb", db_name="test_ch") executor.execute_and_poll.return_value = { "status": "success", "data": [], } with patch("src.plugins.translate.service_target_schema.SupersetSqlLabExecutor", return_value=executor): result = await validate_target_table_schema(req, config_manager) call_sql = executor.execute_and_poll.call_args[1]["sql"] assert "system.columns" in call_sql @pytest.mark.asyncio async def test_greenplum_normalized_to_postgresql(self): """Backend 'greenplum' normalized to 'postgresql' for SQL generation.""" req = TargetSchemaValidationRequest( environment_id="env-1", target_database_id="db-1", target_table="my_table", target_schema="public", ) config_manager = MagicMock() executor = self._make_sqllab_executor(backend="greenplum", db_name="test_gp") executor.execute_and_poll.return_value = { "status": "success", "data": [], } with patch("src.plugins.translate.service_target_schema.SupersetSqlLabExecutor", return_value=executor): result = await validate_target_table_schema(req, config_manager) call_sql = executor.execute_and_poll.call_args[1]["sql"] assert "information_schema.columns" in call_sql # #endregion Test.TargetSchemaValidation