Files
ss-tools/backend/tests/plugins/translate/test_superset_executor.py

558 lines
22 KiB
Python

# #region Test.SupersetSqlLabExecutor [C:3] [TYPE Module] [SEMANTICS test, superset, sqllab, executor, poll]
# @BRIEF Verify SupersetSqlLabExecutor contracts — _get_client, resolve_database_id, execute_sql,
# poll_execution_status, execute_and_poll, get_query_results.
# @RELATION BINDS_TO -> [SupersetSqlLabExecutor]
# @TEST_EDGE: env_not_found -> Raises ValueError
# @TEST_EDGE: target_database_id_resolution -> Resolves via target_database_id
# @TEST_EDGE: database_name_resolution -> Resolves by database_name
# @TEST_EDGE: databases_empty -> Raises ValueError
# @TEST_EDGE: database_name_not_found -> Raises ValueError
# @TEST_EDGE: execute_sql_no_database_id -> Falls back to resolve_database_id
# @TEST_EDGE: execute_sql_missing_query_id -> Returns result with None query_id
# @TEST_EDGE: polling_success -> Returns success status
# @TEST_EDGE: polling_failed -> Returns failed status
# @TEST_EDGE: polling_timeout -> Returns timeout status
# @TEST_EDGE: polling_error_retry -> Retries on network error
# @TEST_EDGE: execute_and_poll_sync_success -> Returns directly from sync mode
# @TEST_EDGE: execute_and_poll_no_query_id -> Returns failed
# @TEST_EDGE: get_query_results_success -> Returns results
# @TEST_EDGE: get_query_results_failure -> Returns error
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from src.core.config_manager import ConfigManager
from src.plugins.translate.superset_executor import SupersetSqlLabExecutor
def _make_config_manager(environments=None):
"""Create a mock ConfigManager with the given environments."""
config = MagicMock(spec=ConfigManager)
if environments is not None:
config.get_environments.return_value = environments
for env in environments:
config.get_environment.return_value = env
else:
config.get_environments.return_value = []
config.get_environment.return_value = None
return config
def _make_env(id="env-1", name="Test Env"):
env = MagicMock()
env.id = id
env.name = name
return env
class TestInit:
"""Verify initialization."""
def test_init(self):
config = _make_config_manager()
executor = SupersetSqlLabExecutor(config, "env-1")
assert executor.config_manager is config
assert executor.env_id == "env-1"
assert executor._client is None
assert executor._database_id is None
class TestGetClient:
"""Verify _get_client."""
@pytest.mark.asyncio
async def test_lazy_initialization(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
with patch('src.plugins.translate.superset_executor.get_superset_client',
new=AsyncMock(return_value=MagicMock())) as mock_get:
client = await executor._get_client()
assert client is not None
assert executor._client is not None
# Second call returns cached client
client2 = await executor._get_client()
assert client2 is client
assert mock_get.call_count == 1
@pytest.mark.asyncio
async def test_env_not_found(self):
config = _make_config_manager([]) # no environments
executor = SupersetSqlLabExecutor(config, "env-1")
with pytest.raises(ValueError, match="not found"):
await executor._get_client()
@pytest.mark.asyncio
async def test_env_not_found_empty_list(self):
config = _make_config_manager()
config.get_environments.return_value = []
executor = SupersetSqlLabExecutor(config, "nonexistent")
with pytest.raises(ValueError, match="not found"):
await executor._get_client()
class TestResolveDatabaseId:
"""Verify resolve_database_id."""
@pytest.mark.asyncio
async def test_resolve_by_target_database_id(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
mock_client.get_database.return_value = {
"result": {"id": 42, "backend": "postgresql", "database_name": "Analytics DB"},
}
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
db_id = await executor.resolve_database_id(target_database_id="42")
assert db_id == 42
assert executor._database_backend == "postgresql"
assert executor._database_name == "Analytics DB"
@pytest.mark.asyncio
async def test_resolve_by_target_database_id_fallback(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
# get_database raises error, fallback to database list
mock_client.get_database.side_effect = ValueError("API error")
mock_client.get_databases.return_value = (None, [
{"id": 1, "database_name": "Main DB", "backend": "postgresql"},
])
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
db_id = await executor.resolve_database_id(target_database_id="99")
assert db_id == 1
assert executor._database_backend == "postgresql"
@pytest.mark.asyncio
async def test_resolve_by_database_name(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
mock_client.get_databases.return_value = (None, [
{"id": 1, "database_name": "Main DB", "backend": "postgresql"},
{"id": 2, "database_name": "Analytics DB", "backend": "clickhouse"},
])
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
db_id = await executor.resolve_database_id(database_name="Analytics DB")
assert db_id == 2
assert executor._database_backend == "clickhouse"
@pytest.mark.asyncio
async def test_resolve_by_database_name_case_insensitive(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
mock_client.get_databases.return_value = (None, [
{"id": 1, "database_name": "Main DB", "backend": "postgresql"},
])
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
db_id = await executor.resolve_database_id(database_name="main db")
assert db_id == 1
@pytest.mark.asyncio
async def test_resolve_database_name_not_found(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
mock_client.get_databases.return_value = (None, [
{"id": 1, "database_name": "Main DB", "backend": "postgresql"},
])
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
with pytest.raises(ValueError, match="not found"):
await executor.resolve_database_id(database_name="Nonexistent DB")
@pytest.mark.asyncio
async def test_resolve_default_first_database(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
mock_client.get_databases.return_value = (None, [
{"id": 42, "database_name": "Default DB", "backend": "mysql"},
])
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
db_id = await executor.resolve_database_id()
assert db_id == 42
assert executor._database_backend == "mysql"
@pytest.mark.asyncio
async def test_resolve_no_databases_raises(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
mock_client.get_databases.return_value = (None, [])
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
with pytest.raises(ValueError, match="No databases found"):
await executor.resolve_database_id()
class TestGetDatabaseBackend:
"""Verify get_database_backend."""
def test_cached_value(self):
config = _make_config_manager()
executor = SupersetSqlLabExecutor(config, "env-1")
assert executor.get_database_backend() is None
executor._database_backend = "postgresql"
assert executor.get_database_backend() == "postgresql"
class TestGetDatabaseName:
"""Verify get_database_name."""
def test_cached_value(self):
config = _make_config_manager()
executor = SupersetSqlLabExecutor(config, "env-1")
assert executor.get_database_name() is None
executor._database_name = "Main DB"
assert executor.get_database_name() == "Main DB"
class TestExecuteSql:
"""Verify execute_sql."""
@pytest.mark.asyncio
async def test_execute_with_database_id(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
executor._database_id = 42
mock_client = AsyncMock()
mock_client.network.request.return_value = {
"query_id": "q-123",
"status": "success",
}
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
result = await executor.execute_sql("SELECT 1", database_id=42)
assert result["query_id"] == "q-123"
assert result["status"] == "success"
assert result["database_id"] == 42
@pytest.mark.asyncio
async def test_execute_falls_back_to_resolve(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
mock_client.network.request.return_value = {
"query_id": "q-456",
"status": "success",
}
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
with patch.object(executor, 'resolve_database_id',
new=AsyncMock(return_value=99)):
result = await executor.execute_sql("SELECT 1")
assert result["query_id"] == "q-456"
assert result["database_id"] == 99
@pytest.mark.asyncio
async def test_execute_api_error(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
executor._database_id = 42
mock_client = AsyncMock()
mock_client.network.request.side_effect = ValueError("Connection refused")
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
with pytest.raises(ValueError, match="Superset SQL Lab execute failed"):
await executor.execute_sql("SELECT 1")
@pytest.mark.asyncio
async def test_execute_missing_query_id(self):
"""Response missing query_id — still returns result with None query_id."""
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
executor._database_id = 42
mock_client = AsyncMock()
mock_client.network.request.return_value = {"status": "pending"}
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
result = await executor.execute_sql("SELECT 1")
assert result["query_id"] is None
assert result["status"] == "pending"
@pytest.mark.asyncio
async def test_execute_query_id_in_result(self):
"""Query ID nested in result block."""
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
executor._database_id = 42
mock_client = AsyncMock()
mock_client.network.request.return_value = {
"result": {"query_id": "q-nested"},
"status": "success",
}
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
result = await executor.execute_sql("SELECT 1")
assert result["query_id"] == "q-nested"
@pytest.mark.asyncio
async def test_execute_query_id_in_query_block(self):
"""Query ID nested in query block."""
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
executor._database_id = 42
mock_client = AsyncMock()
mock_client.network.request.return_value = {
"query": {"id": "q-from-query"},
"status": "pending",
}
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
result = await executor.execute_sql("SELECT 1")
assert result["query_id"] == "q-from-query"
class TestPollExecutionStatus:
"""Verify poll_execution_status."""
@pytest.mark.asyncio
async def test_poll_success(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
mock_client.network.request.return_value = {
"result": {"status": "success", "state": "success", "rows": 10},
}
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
result = await executor.poll_execution_status("q-1", max_polls=1)
assert result["status"] == "success"
assert result["rows_affected"] == 10
@pytest.mark.asyncio
async def test_poll_failed(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
mock_client.network.request.return_value = {
"result": {"status": "failed", "state": "failed", "error_message": "Query error"},
}
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
result = await executor.poll_execution_status("q-1", max_polls=1)
assert result["status"] == "failed"
assert "Query error" in result["error_message"]
@pytest.mark.asyncio
async def test_poll_timeout(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
# Always return pending
mock_client.network.request.return_value = {
"result": {"status": "pending", "state": "pending"},
}
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
result = await executor.poll_execution_status("q-1", max_polls=1, poll_interval_seconds=0.01)
assert result["status"] == "timeout"
@pytest.mark.asyncio
async def test_poll_network_error_retry(self):
"""Network error should be retried."""
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
# First call fails, second succeeds
mock_client.network.request = AsyncMock(side_effect=[
ValueError("Network error"),
{"result": {"status": "success", "state": "success", "rows": 5}},
])
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
result = await executor.poll_execution_status("q-1", max_polls=5, poll_interval_seconds=0.01)
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_poll_unknown_state_continues(self):
"""Unknown state should continue polling."""
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
mock_client.network.request = AsyncMock(side_effect=[
{"result": {"status": "weird_state", "state": "weird_state"}},
{"result": {"status": "success", "state": "success", "rows": 3}},
])
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
result = await executor.poll_execution_status("q-1", max_polls=5, poll_interval_seconds=0.01)
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_poll_uses_raw_result_when_no_result_key(self):
"""When response has no 'result' key, use raw dict."""
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
mock_client.network.request.return_value = {
"status": "success",
"state": "success",
"rows": 7,
}
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
result = await executor.poll_execution_status("q-1", max_polls=1)
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_poll_not_dict_response(self):
"""When response is not a dict, use empty dict."""
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
mock_client.network.request.return_value = "not a dict"
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
result = await executor.poll_execution_status("q-1", max_polls=5, poll_interval_seconds=0.01)
assert result["status"] == "timeout"
class TestExecuteAndPoll:
"""Verify execute_and_poll."""
@pytest.mark.asyncio
async def test_sync_mode_success(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
with patch.object(executor, 'execute_sql',
new=AsyncMock(return_value={
"query_id": "q-1",
"status": "success",
"raw_response": {
"query": {"rows": 5, "endDttm": "2024-01-01"},
"data": [{"col": "val"}],
},
})):
result = await executor.execute_and_poll("SELECT 1")
assert result["status"] == "success"
assert result["rows_affected"] == 5
@pytest.mark.asyncio
async def test_async_mode_poll(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
with patch.object(executor, 'execute_sql',
new=AsyncMock(return_value={
"query_id": "q-1",
"status": "pending",
"raw_response": {},
})):
with patch.object(executor, 'poll_execution_status',
new=AsyncMock(return_value={
"query_id": "q-1",
"status": "success",
"state": "success",
})):
result = await executor.execute_and_poll("SELECT 1")
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_no_query_id_returns_failed(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
with patch.object(executor, 'execute_sql',
new=AsyncMock(return_value={
"query_id": None,
"status": "pending",
"raw_response": {},
})):
result = await executor.execute_and_poll("SELECT 1")
assert result["status"] == "failed"
assert "No query_id" in result["error_message"]
class TestGetQueryResults:
"""Verify get_query_results."""
@pytest.mark.asyncio
async def test_success(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
mock_client.network.request.return_value = {
"results": [{"col": "val"}],
}
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
result = await executor.get_query_results("q-1")
assert result["status"] == "success"
assert result["results"] == [{"col": "val"}]
@pytest.mark.asyncio
async def test_no_results(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
mock_client.network.request.return_value = {}
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
result = await executor.get_query_results("q-1")
assert result["status"] == "no_results"
@pytest.mark.asyncio
async def test_api_error(self):
env = _make_env()
config = _make_config_manager([env])
executor = SupersetSqlLabExecutor(config, "env-1")
mock_client = AsyncMock()
mock_client.network.request.side_effect = ValueError("Not found")
with patch.object(executor, '_get_client', new=AsyncMock(return_value=mock_client)):
result = await executor.get_query_results("q-1")
assert result["status"] == "failed"
assert "Not found" in result["error_message"]
# #endregion Test.SupersetSqlLabExecutor