test: 6 agents — +52 test files across core, task_manager, translate routes, git/storage/migration routes, dataset_review deps/routes, settings. Fixed 4 failures
This commit is contained in:
966
backend/tests/test_core/test_async_network.py
Normal file
966
backend/tests/test_core/test_async_network.py
Normal file
@@ -0,0 +1,966 @@
|
||||
# #region Test.AsyncNetwork [C:4] [TYPE Module] [SEMANTICS test, async, network, client, httpx]
|
||||
# @BRIEF Tests for core/utils/async_network.py — AsyncAPIClient.
|
||||
# @RELATION BINDS_TO -> [AsyncNetworkModule]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_auth_cache():
|
||||
"""Clear SupersetAuthCache between tests."""
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
SupersetAuthCache._entries.clear()
|
||||
|
||||
|
||||
class TestAsyncAPIClientInit:
|
||||
"""AsyncAPIClient.__init__: config parsing, ssl handling."""
|
||||
|
||||
def test_init_with_defaults(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://superset.example.com", "auth": {"username": "admin", "password": "pass"}})
|
||||
assert client.base_url == "https://superset.example.com"
|
||||
assert client.api_base_url == "https://superset.example.com/api/v1"
|
||||
assert client._authenticated is False
|
||||
assert client._semaphore is None
|
||||
|
||||
def test_init_ssl_true_creates_default_context(self):
|
||||
import ssl
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
real_ctx = ssl.create_default_context()
|
||||
with patch("ssl.create_default_context", return_value=real_ctx) as mock_ctx:
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}}, verify_ssl=True)
|
||||
mock_ctx.assert_called_once()
|
||||
assert client.base_url == "https://test.com"
|
||||
|
||||
def test_init_ssl_false(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}}, verify_ssl=False)
|
||||
assert client.base_url == "https://test.com"
|
||||
assert client._client is not None
|
||||
|
||||
def test_init_with_semaphore(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
sem = asyncio.Semaphore(5)
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}}, semaphore=sem)
|
||||
assert client._semaphore is sem
|
||||
|
||||
def test_normalize_base_url_removes_api_v1_suffix(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com/api/v1", "auth": {}})
|
||||
assert client.base_url == "https://test.com"
|
||||
|
||||
def test_normalize_base_url_strips_trailing_slash(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com/", "auth": {}})
|
||||
assert client.base_url == "https://test.com"
|
||||
|
||||
def test_init_with_custom_timeout(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}}, timeout=60)
|
||||
assert client.request_settings["timeout"] == 60
|
||||
|
||||
def test_init_with_ssl_context(self):
|
||||
import ssl
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}}, verify_ssl=ctx)
|
||||
# The client should be initialized with the custom SSL context
|
||||
assert client.request_settings["verify_ssl"] is ctx
|
||||
|
||||
|
||||
class TestBuildApiUrl:
|
||||
"""_build_api_url: endpoint -> full URL."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
return AsyncAPIClient({"base_url": "https://superset.example.com", "auth": {}})
|
||||
|
||||
def test_relative_endpoint_adds_api_v1_prefix(self, client):
|
||||
url = client._build_api_url("/chart/")
|
||||
assert url == "https://superset.example.com/api/v1/chart/"
|
||||
|
||||
def test_relative_endpoint_without_leading_slash(self, client):
|
||||
url = client._build_api_url("chart/")
|
||||
assert url == "https://superset.example.com/api/v1/chart/"
|
||||
|
||||
def test_absolute_url_passthrough(self, client):
|
||||
url = client._build_api_url("https://other.com/api/v1/chart/")
|
||||
assert url == "https://other.com/api/v1/chart/"
|
||||
|
||||
def test_already_has_api_v1(self, client):
|
||||
url = client._build_api_url("/api/v1/chart/")
|
||||
assert url == "https://superset.example.com/api/v1/chart/"
|
||||
|
||||
def test_api_v1_root(self, client):
|
||||
url = client._build_api_url("/api/v1")
|
||||
assert url == "https://superset.example.com/api/v1"
|
||||
|
||||
def test_empty_endpoint_returns_base(self, client):
|
||||
url = client._build_api_url("")
|
||||
assert url == "https://superset.example.com/api/v1/"
|
||||
|
||||
|
||||
class TestAuthLock:
|
||||
"""_get_auth_lock: class-level async lock storage."""
|
||||
|
||||
def test_returns_same_lock_for_same_key(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
lock1 = AsyncAPIClient._get_auth_lock(("url", "user", True))
|
||||
lock2 = AsyncAPIClient._get_auth_lock(("url", "user", True))
|
||||
assert lock1 is lock2
|
||||
|
||||
def test_different_keys_return_different_locks(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
lock1 = AsyncAPIClient._get_auth_lock(("url1", "user", True))
|
||||
lock2 = AsyncAPIClient._get_auth_lock(("url2", "user", True))
|
||||
assert lock1 is not lock2
|
||||
|
||||
|
||||
class TestAuthenticate:
|
||||
"""authenticate(): cache hit, cache miss, error paths."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
c = AsyncAPIClient({"base_url": "https://superset.example.com", "auth": {"username": "admin", "password": "pass"}})
|
||||
c._client = AsyncMock()
|
||||
return c
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_skips_login(self, client):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
SupersetAuthCache.set(
|
||||
client._auth_cache_key,
|
||||
{"access_token": "cached_access", "csrf_token": "cached_csrf"},
|
||||
)
|
||||
# Mock CSRF refresh
|
||||
mock_csrf_resp = MagicMock()
|
||||
mock_csrf_resp.is_success = True
|
||||
mock_csrf_resp.json.return_value = {"result": "new_csrf"}
|
||||
client._client.get = AsyncMock(return_value=mock_csrf_resp)
|
||||
|
||||
tokens = await client.authenticate()
|
||||
assert tokens["access_token"] == "cached_access"
|
||||
assert client._authenticated is True
|
||||
client._client.post.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_miss_fresh_login(self, client):
|
||||
mock_login_resp = MagicMock()
|
||||
mock_login_resp.status_code = 200
|
||||
mock_login_resp.json.return_value = {"access_token": "new_access"}
|
||||
mock_login_resp.raise_for_status = MagicMock()
|
||||
|
||||
mock_csrf_resp = MagicMock()
|
||||
mock_csrf_resp.status_code = 200
|
||||
mock_csrf_resp.json.return_value = {"result": "new_csrf"}
|
||||
mock_csrf_resp.raise_for_status = MagicMock()
|
||||
|
||||
client._client.post = AsyncMock(return_value=mock_login_resp)
|
||||
client._client.get = AsyncMock(return_value=mock_csrf_resp)
|
||||
|
||||
tokens = await client.authenticate()
|
||||
assert tokens["access_token"] == "new_access"
|
||||
assert tokens["csrf_token"] == "new_csrf"
|
||||
assert client._authenticated is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_http_502_raises_network_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 502
|
||||
exc = httpx.HTTPStatusError("Bad Gateway", request=MagicMock(), response=mock_resp)
|
||||
client._client.post = AsyncMock(side_effect=exc)
|
||||
|
||||
with pytest.raises(NetworkError, match="Environment unavailable"):
|
||||
await client.authenticate()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_http_503_raises_network_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 503
|
||||
exc = httpx.HTTPStatusError("Service Unavailable", request=MagicMock(), response=mock_resp)
|
||||
client._client.post = AsyncMock(side_effect=exc)
|
||||
|
||||
with pytest.raises(NetworkError, match="Environment unavailable"):
|
||||
await client.authenticate()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_http_401_raises_authentication_error(self, client):
|
||||
from src.core.utils.network import AuthenticationError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 401
|
||||
exc = httpx.HTTPStatusError("Unauthorized", request=MagicMock(), response=mock_resp)
|
||||
client._client.post = AsyncMock(side_effect=exc)
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
await client.authenticate()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_httpx_error_raises_network_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
client._client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
|
||||
with pytest.raises(NetworkError, match="Network or parsing error"):
|
||||
await client.authenticate()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_refresh_csrf_failure_does_not_block(self, client):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
SupersetAuthCache.set(
|
||||
client._auth_cache_key,
|
||||
{"access_token": "cached_access", "csrf_token": "cached_csrf"},
|
||||
)
|
||||
# CSRF refresh fails silently
|
||||
client._client.get = AsyncMock(side_effect=Exception("Connection lost"))
|
||||
|
||||
tokens = await client.authenticate()
|
||||
# Should still succeed with cached tokens
|
||||
assert tokens["access_token"] == "cached_access"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_after_wait(self, client):
|
||||
"""Second caller after lock should find cache populated."""
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
# Simulate first caller putting tokens in cache
|
||||
SupersetAuthCache.set(
|
||||
client._auth_cache_key,
|
||||
{"access_token": "cached_after", "csrf_token": "cached_csrf"},
|
||||
)
|
||||
mock_csrf_resp = MagicMock()
|
||||
mock_csrf_resp.is_success = False
|
||||
client._client.get = AsyncMock(return_value=mock_csrf_resp)
|
||||
|
||||
tokens = await client.authenticate() # Goes through lock path
|
||||
assert tokens["access_token"] == "cached_after"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_missing_access_token_key(self, client):
|
||||
mock_login_resp = MagicMock()
|
||||
mock_login_resp.status_code = 200
|
||||
mock_login_resp.json.return_value = {"not_access_token": "value"}
|
||||
mock_login_resp.raise_for_status = MagicMock()
|
||||
client._client.post = AsyncMock(return_value=mock_login_resp)
|
||||
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
with pytest.raises(NetworkError):
|
||||
await client.authenticate()
|
||||
|
||||
|
||||
class TestGetHeaders:
|
||||
"""get_headers(): returns auth headers, may trigger auth."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_calls_authenticate(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
# Simulate what authenticate sets
|
||||
async def fake_auth():
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
return client._tokens
|
||||
client.authenticate = fake_auth
|
||||
client._authenticated = False
|
||||
|
||||
headers = await client.get_headers()
|
||||
assert "Authorization" in headers
|
||||
assert headers["Authorization"] == "Bearer acc"
|
||||
assert headers["X-CSRFToken"] == "csrf"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_returns_headers(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client.authenticate = AsyncMock()
|
||||
|
||||
headers = await client.get_headers()
|
||||
client.authenticate.assert_not_called()
|
||||
assert headers["Referer"] == "https://test.com"
|
||||
|
||||
|
||||
class TestRequest:
|
||||
"""request(): main HTTP request method."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
c = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
c._client = AsyncMock()
|
||||
c._authenticated = True
|
||||
c._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
return c
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_request(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"result": "ok"}
|
||||
client._client.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
result = await client.request(method="GET", endpoint="/chart/")
|
||||
assert result == {"result": "ok"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_with_dict_data(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"id": 1}
|
||||
client._client.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
result = await client.request(method="POST", endpoint="/chart/", data={"key": "val"})
|
||||
assert result == {"id": 1}
|
||||
call_kwargs = client._client.request.call_args[1]
|
||||
assert call_kwargs["json"] == {"key": "val"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_with_pre_serialized_string(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"id": 1}
|
||||
client._client.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
result = await client.request(method="POST", endpoint="/chart/", data='{"key":"val"}')
|
||||
assert result == {"id": 1}
|
||||
call_kwargs = client._client.request.call_args[1]
|
||||
assert call_kwargs["content"] == b'{"key":"val"}'
|
||||
assert "json" not in call_kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_response(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
client._client.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
result = await client.request(method="GET", endpoint="/chart/1", raw_response=True)
|
||||
assert result is mock_resp
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_401_retry_for_get(self, client):
|
||||
# Pre-authenticate so 401 retry works without hitting login flow.
|
||||
# We need authenticate() to succeed quickly when called in the retry path.
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
|
||||
# Cache tokens so SupersetAuthCache.get returns something
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
SupersetAuthCache.set(
|
||||
client._auth_cache_key,
|
||||
{"access_token": "retry_token", "csrf_token": "retry_csrf"},
|
||||
)
|
||||
# Un-authenticate so get_headers() calls authenticate() (which will hit cache)
|
||||
client._authenticated = False
|
||||
|
||||
first_resp = MagicMock()
|
||||
first_resp.status_code = 401
|
||||
second_resp = MagicMock()
|
||||
second_resp.status_code = 200
|
||||
second_resp.json.return_value = {"result": "retried"}
|
||||
client._client.request = AsyncMock(side_effect=[first_resp, second_resp])
|
||||
# Mock CSRF refresh during auth
|
||||
mock_csrf_resp = MagicMock()
|
||||
mock_csrf_resp.is_success = True
|
||||
mock_csrf_resp.json.return_value = {"result": "csrf_new"}
|
||||
client._client.get = AsyncMock(return_value=mock_csrf_resp)
|
||||
|
||||
result = await client.request(method="GET", endpoint="/chart/")
|
||||
assert result == {"result": "retried"}
|
||||
assert client._client.request.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_401_raises_for_non_get(self, client):
|
||||
from src.core.utils.network import AuthenticationError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 401
|
||||
client._client.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
await client.request(method="POST", endpoint="/chart/")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_parse_error(self, client):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.side_effect = ValueError("No JSON")
|
||||
mock_resp.text = "not json"
|
||||
client._client.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(SupersetAPIError, match="Failed to parse response JSON"):
|
||||
await client.request(method="GET", endpoint="/chart/")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semaphore_acquire_release(self, client):
|
||||
sem = asyncio.Semaphore(1)
|
||||
client._semaphore = sem
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"ok": True}
|
||||
client._client.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
result = await client.request(method="GET", endpoint="/test")
|
||||
assert result == {"ok": True}
|
||||
# Semaphore released implies no deadlock
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_status_error_404_dashboard(self, client):
|
||||
from src.core.utils.network import DashboardNotFoundError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
exc = httpx.HTTPStatusError("Not Found", request=MagicMock(), response=mock_resp)
|
||||
client._client.request = AsyncMock(side_effect=exc)
|
||||
client._is_dashboard_endpoint = MagicMock(return_value=True)
|
||||
|
||||
with pytest.raises(DashboardNotFoundError):
|
||||
await client.request(method="GET", endpoint="/dashboard/42")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_status_error_404_generic(self, client):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
exc = httpx.HTTPStatusError("Not Found", request=MagicMock(), response=mock_resp)
|
||||
client._client.request = AsyncMock(side_effect=exc)
|
||||
client._is_dashboard_endpoint = MagicMock(return_value=False)
|
||||
|
||||
with pytest.raises(SupersetAPIError, match="API resource not found"):
|
||||
await client.request(method="GET", endpoint="/chart/99")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_status_error_403(self, client):
|
||||
from src.core.utils.network import PermissionDeniedError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 403
|
||||
exc = httpx.HTTPStatusError("Forbidden", request=MagicMock(), response=mock_resp)
|
||||
client._client.request = AsyncMock(side_effect=exc)
|
||||
|
||||
with pytest.raises(PermissionDeniedError):
|
||||
await client.request(method="GET", endpoint="/chart/")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_status_error_502(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 502
|
||||
exc = httpx.HTTPStatusError("Bad Gateway", request=MagicMock(), response=mock_resp)
|
||||
client._client.request = AsyncMock(side_effect=exc)
|
||||
|
||||
with pytest.raises(NetworkError, match="Environment unavailable"):
|
||||
await client.request(method="GET", endpoint="/chart/")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_status_error_generic(self, client):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 500
|
||||
mock_resp.text = "Internal error"
|
||||
exc = httpx.HTTPStatusError("Server Error", request=MagicMock(), response=mock_resp)
|
||||
client._client.request = AsyncMock(side_effect=exc)
|
||||
|
||||
with pytest.raises(SupersetAPIError, match="API Error 500"):
|
||||
await client.request(method="GET", endpoint="/chart/")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_timeout_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
client._client.request = AsyncMock(side_effect=httpx.TimeoutException("Timed out"))
|
||||
|
||||
with pytest.raises(NetworkError, match="Request timeout"):
|
||||
await client.request(method="GET", endpoint="/chart/")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_connect_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
client._client.request = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
|
||||
with pytest.raises(NetworkError, match="Connection error"):
|
||||
await client.request(method="GET", endpoint="/chart/")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_generic_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
client._client.request = AsyncMock(side_effect=httpx.HTTPError("Generic error"))
|
||||
|
||||
with pytest.raises(NetworkError, match="Unknown network error"):
|
||||
await client.request(method="GET", endpoint="/chart/")
|
||||
|
||||
|
||||
class TestHandleHttpError:
|
||||
"""_handle_http_error: translates HTTP status codes to exceptions."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
return AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
|
||||
def test_502_raises_network_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 502
|
||||
exc = httpx.HTTPStatusError("", request=MagicMock(), response=mock_resp)
|
||||
with pytest.raises(NetworkError, match="Environment unavailable"):
|
||||
client._handle_http_error(exc, "/chart/")
|
||||
|
||||
def test_503_raises_network_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 503
|
||||
exc = httpx.HTTPStatusError("", request=MagicMock(), response=mock_resp)
|
||||
with pytest.raises(NetworkError, match="Environment unavailable"):
|
||||
client._handle_http_error(exc, "/chart/")
|
||||
|
||||
def test_404_dashboard(self, client):
|
||||
from src.core.utils.network import DashboardNotFoundError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
exc = httpx.HTTPStatusError("", request=MagicMock(), response=mock_resp)
|
||||
with patch.object(client, '_is_dashboard_endpoint', return_value=True):
|
||||
with pytest.raises(DashboardNotFoundError):
|
||||
client._handle_http_error(exc, "/dashboard/5")
|
||||
|
||||
def test_404_generic(self, client):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
exc = httpx.HTTPStatusError("", request=MagicMock(), response=mock_resp)
|
||||
with patch.object(client, '_is_dashboard_endpoint', return_value=False):
|
||||
with pytest.raises(SupersetAPIError, match="API resource not found"):
|
||||
client._handle_http_error(exc, "/chart/5")
|
||||
|
||||
def test_403_raises_permission_denied(self, client):
|
||||
from src.core.utils.network import PermissionDeniedError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 403
|
||||
exc = httpx.HTTPStatusError("", request=MagicMock(), response=mock_resp)
|
||||
with pytest.raises(PermissionDeniedError):
|
||||
client._handle_http_error(exc, "/chart/")
|
||||
|
||||
def test_401_raises_authentication_error(self, client):
|
||||
from src.core.utils.network import AuthenticationError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 401
|
||||
exc = httpx.HTTPStatusError("", request=MagicMock(), response=mock_resp)
|
||||
with pytest.raises(AuthenticationError):
|
||||
client._handle_http_error(exc, "/chart/")
|
||||
|
||||
def test_generic_500(self, client):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 500
|
||||
mock_resp.text = "Server broke"
|
||||
exc = httpx.HTTPStatusError("", request=MagicMock(), response=mock_resp)
|
||||
with pytest.raises(SupersetAPIError, match="API Error 500"):
|
||||
client._handle_http_error(exc, "/chart/")
|
||||
|
||||
|
||||
class TestIsDashboardEndpoint:
|
||||
"""_is_dashboard_endpoint: recognizes dashboard endpoints."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
return AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
|
||||
def test_dashboard_endpoint(self, client):
|
||||
assert client._is_dashboard_endpoint("/dashboard/42") is True
|
||||
|
||||
def test_dashboard_list(self, client):
|
||||
assert client._is_dashboard_endpoint("/dashboard") is True
|
||||
|
||||
def test_chart_endpoint(self, client):
|
||||
assert client._is_dashboard_endpoint("/chart/42") is False
|
||||
|
||||
def test_absolute_url_dashboard(self, client):
|
||||
assert client._is_dashboard_endpoint("https://superset.example.com/api/v1/dashboard/42") is True
|
||||
|
||||
def test_absolute_url_chart(self, client):
|
||||
assert client._is_dashboard_endpoint("https://superset.example.com/api/v1/chart/42") is False
|
||||
|
||||
def test_empty_endpoint(self, client):
|
||||
assert client._is_dashboard_endpoint("") is False
|
||||
|
||||
def test_none_endpoint(self, client):
|
||||
assert client._is_dashboard_endpoint(None) is False
|
||||
|
||||
def test_dashboard_with_api_v1_prefix(self, client):
|
||||
assert client._is_dashboard_endpoint("/api/v1/dashboard/1") is True
|
||||
|
||||
def test_absolute_url_bad_format(self, client):
|
||||
# URL with /api/v1 not present
|
||||
assert client._is_dashboard_endpoint("https://other.com/not-api/dashboard/1") is False
|
||||
|
||||
|
||||
class TestFetchPaginatedCount:
|
||||
"""fetch_paginated_count: gets total count."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(return_value={"count": 42})
|
||||
|
||||
result = await client.fetch_paginated_count("/chart/", {"page": 0, "page_size": 1})
|
||||
assert result == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_dict_response(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(return_value="not_a_dict")
|
||||
|
||||
result = await client.fetch_paginated_count("/chart/", {})
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_count_field(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(return_value={"other": 10})
|
||||
|
||||
result = await client.fetch_paginated_count("/chart/", {})
|
||||
assert result == 0
|
||||
|
||||
|
||||
class TestFetchPaginatedData:
|
||||
"""fetch_paginated_data: page iteration."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_page(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(
|
||||
return_value={"result": [{"id": 1}, {"id": 2}], "count": 2}
|
||||
)
|
||||
|
||||
result = await client.fetch_paginated_data(
|
||||
"/chart/",
|
||||
{"base_query": {"page_size": 100}, "results_field": "result"},
|
||||
)
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_pages(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": [{"id": 1}, {"id": 2}], "count": 5},
|
||||
{"result": [{"id": 3}, {"id": 4}]},
|
||||
{"result": [{"id": 5}]},
|
||||
]
|
||||
)
|
||||
|
||||
result = await client.fetch_paginated_data(
|
||||
"/chart/",
|
||||
{"base_query": {"page_size": 2}, "results_field": "result"},
|
||||
)
|
||||
assert len(result) == 5
|
||||
assert client.request.call_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_total_count_from_options(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": [{"id": 1}], "count": 1},
|
||||
{"result": [{"id": 2}]},
|
||||
]
|
||||
)
|
||||
|
||||
result = await client.fetch_paginated_data(
|
||||
"/chart/",
|
||||
{"base_query": {"page_size": 1}, "results_field": "result", "total_count": 2},
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert client.request.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_dict_first_page_returns_empty(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(return_value="not_a_dict")
|
||||
|
||||
result = await client.fetch_paginated_data(
|
||||
"/chart/",
|
||||
{"base_query": {"page_size": 100}, "results_field": "result"},
|
||||
)
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_dict_subsequent_page_skipped(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": [{"id": 1}], "count": 3},
|
||||
"not_a_dict",
|
||||
{"result": [{"id": 3}]},
|
||||
]
|
||||
)
|
||||
|
||||
result = await client.fetch_paginated_data(
|
||||
"/chart/",
|
||||
{"base_query": {"page_size": 1}, "results_field": "result"},
|
||||
)
|
||||
# First page has 1, second page is skipped, third page has 1
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestUploadFile:
|
||||
"""upload_file: multipart file upload."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_with_path_string(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client._client = AsyncMock()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"result": "imported"}
|
||||
client._client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("pathlib.Path.exists", return_value=True):
|
||||
with patch("pathlib.Path.read_bytes", return_value=b"zip content"):
|
||||
result = await client.upload_file(
|
||||
endpoint="/dashboard/import/",
|
||||
file_info={
|
||||
"file_obj": "/tmp/test.zip",
|
||||
"file_name": "test.zip",
|
||||
"form_field": "formData",
|
||||
},
|
||||
extra_data={"overwrite": "true"},
|
||||
)
|
||||
assert result == {"result": "imported"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_with_bytesio(self):
|
||||
import io
|
||||
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client._client = AsyncMock()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"result": "ok"}
|
||||
client._client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
buf = io.BytesIO(b"zip content")
|
||||
result = await client.upload_file(
|
||||
endpoint="/dashboard/import/",
|
||||
file_info={"file_obj": buf, "file_name": "test.zip"},
|
||||
)
|
||||
assert result == {"result": "ok"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_file_not_found(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client._client = AsyncMock()
|
||||
|
||||
with patch("pathlib.Path.exists", return_value=False):
|
||||
with pytest.raises(FileNotFoundError, match="does not exist"):
|
||||
await client.upload_file(
|
||||
endpoint="/dashboard/import/",
|
||||
file_info={"file_obj": "/nonexistent.zip", "file_name": "nope.zip"},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_unsupported_type(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client._client = AsyncMock()
|
||||
|
||||
with pytest.raises(TypeError, match="Unsupported file_obj"):
|
||||
await client.upload_file(
|
||||
endpoint="/dashboard/import/",
|
||||
file_info={"file_obj": 42, "file_name": "nope.zip"},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_http_error(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client._client = AsyncMock()
|
||||
|
||||
exc = httpx.HTTPStatusError("Error", request=MagicMock(), response=MagicMock())
|
||||
exc.response.text = "Bad request"
|
||||
client._client.post = AsyncMock(side_effect=exc)
|
||||
|
||||
with patch("pathlib.Path.exists", return_value=True):
|
||||
with patch("pathlib.Path.read_bytes", return_value=b"zip"):
|
||||
with pytest.raises(SupersetAPIError, match="API error during upload"):
|
||||
await client.upload_file(
|
||||
endpoint="/dashboard/import/",
|
||||
file_info={"file_obj": "/tmp/test.zip", "file_name": "test.zip"},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_network_error(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client._client = AsyncMock()
|
||||
client._client.post = AsyncMock(side_effect=httpx.HTTPError("Network error"))
|
||||
|
||||
with patch("pathlib.Path.exists", return_value=True):
|
||||
with patch("pathlib.Path.read_bytes", return_value=b"zip"):
|
||||
with pytest.raises(NetworkError, match="Network error during upload"):
|
||||
await client.upload_file(
|
||||
endpoint="/dashboard/import/",
|
||||
file_info={"file_obj": "/tmp/test.zip", "file_name": "test.zip"},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_non_200_json_error(self):
|
||||
import io
|
||||
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client._client = AsyncMock()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.side_effect = ValueError("Not JSON")
|
||||
client._client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
buf = io.BytesIO(b"zip")
|
||||
with pytest.raises(SupersetAPIError, match="API error during upload"):
|
||||
await client.upload_file(
|
||||
endpoint="/dashboard/import/",
|
||||
file_info={"file_obj": buf, "file_name": "test.zip"},
|
||||
)
|
||||
|
||||
|
||||
class TestAclose:
|
||||
"""aclose: closes underlying httpx client."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_closes_client(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._client = AsyncMock()
|
||||
client._client.aclose = AsyncMock()
|
||||
|
||||
await client.aclose()
|
||||
client._client.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
class TestNormalizeBaseUrl:
|
||||
"""_normalize_base_url: URL normalization edge cases."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
return AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
|
||||
def test_empty_url(self, client):
|
||||
assert client._normalize_base_url("") == ""
|
||||
|
||||
def test_none_url(self, client):
|
||||
assert client._normalize_base_url(None) == ""
|
||||
|
||||
def test_api_v1_suffix(self, client):
|
||||
assert client._normalize_base_url("https://test.com/api/v1") == "https://test.com"
|
||||
|
||||
def test_trailing_slash(self, client):
|
||||
assert client._normalize_base_url("https://test.com/") == "https://test.com"
|
||||
|
||||
def test_whitespace_stripped(self, client):
|
||||
assert client._normalize_base_url(" https://test.com ") == "https://test.com"
|
||||
|
||||
def test_api_v1_with_trailing_slash(self, client):
|
||||
assert client._normalize_base_url("https://test.com/api/v1/") == "https://test.com"
|
||||
# #endregion Test.AsyncNetwork
|
||||
260
backend/tests/test_core/test_dashboards_filters.py
Normal file
260
backend/tests/test_core/test_dashboards_filters.py
Normal file
@@ -0,0 +1,260 @@
|
||||
# #region Test.DashboardsFiltersMixin [C:3] [TYPE Module] [SEMANTICS test, dashboard, filter, permalink, mixin]
|
||||
# @BRIEF Tests for core/superset_client/_dashboards_filters.py — SupersetDashboardsFiltersMixin.
|
||||
# @RELATION BINDS_TO -> [SupersetDashboardsFiltersMixin]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDashboardsFiltersMixin:
|
||||
"""Test all methods of SupersetDashboardsFiltersMixin."""
|
||||
|
||||
@pytest.fixture
|
||||
def mixin(self):
|
||||
from src.core.superset_client._dashboards_filters import (
|
||||
SupersetDashboardsFiltersMixin,
|
||||
)
|
||||
|
||||
m = SupersetDashboardsFiltersMixin()
|
||||
m.client = MagicMock()
|
||||
m.client.request = AsyncMock()
|
||||
return m
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_with_int(self, mixin):
|
||||
mixin.client.request.return_value = {"result": {"id": 1, "dashboard_title": "Test"}}
|
||||
result = await mixin.get_dashboard(1)
|
||||
mixin.client.request.assert_awaited_once_with(method="GET", endpoint="/dashboard/1")
|
||||
assert result["result"]["id"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_with_str(self, mixin):
|
||||
mixin.client.request.return_value = {"result": {"id": 2, "slug": "my-dash"}}
|
||||
result = await mixin.get_dashboard("my-dash")
|
||||
mixin.client.request.assert_awaited_once_with(method="GET", endpoint="/dashboard/my-dash")
|
||||
assert result["result"]["slug"] == "my-dash"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_permalink_state(self, mixin):
|
||||
mixin.client.request.return_value = {"result": {"state": {"dataMask": {}}}}
|
||||
result = await mixin.get_dashboard_permalink_state("abc123")
|
||||
mixin.client.request.assert_awaited_once_with(
|
||||
method="GET", endpoint="/dashboard/permalink/abc123"
|
||||
)
|
||||
assert "state" in result["result"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_native_filter_state(self, mixin):
|
||||
mixin.client.request.return_value = {"result": {"value": '{"id": 1}'}}
|
||||
result = await mixin.get_native_filter_state(1, "key123")
|
||||
mixin.client.request.assert_awaited_once_with(
|
||||
method="GET", endpoint="/dashboard/1/filter_state/key123"
|
||||
)
|
||||
assert "value" in result["result"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_permalink(self, mixin):
|
||||
mixin.get_dashboard_permalink_state = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"state": {
|
||||
"dataMask": {
|
||||
"filter_1": {
|
||||
"extraFormData": {"col1": "val1"},
|
||||
"filterState": {"value": 1},
|
||||
"ownState": {},
|
||||
}
|
||||
},
|
||||
"activeTabs": ["tab1"],
|
||||
"anchor": "tab1",
|
||||
"chartStates": {"chart1": {}},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
result = await mixin.extract_native_filters_from_permalink("perm_key")
|
||||
assert result["dataMask"]["filter_1"]["extraFormData"] == {"col1": "val1"}
|
||||
assert result["activeTabs"] == ["tab1"]
|
||||
assert result["anchor"] == "tab1"
|
||||
assert result["chartStates"] == {"chart1": {}}
|
||||
assert result["permalink_key"] == "perm_key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_permalink_empty_state(self, mixin):
|
||||
mixin.get_dashboard_permalink_state = AsyncMock(return_value={})
|
||||
result = await mixin.extract_native_filters_from_permalink("empty_key")
|
||||
assert result["dataMask"] == {}
|
||||
assert result["activeTabs"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_permalink_skips_non_dict(self, mixin):
|
||||
mixin.get_dashboard_permalink_state = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"state": {
|
||||
"dataMask": {
|
||||
"filter_1": {"extraFormData": {}, "filterState": {}, "ownState": {}},
|
||||
"filter_2": "not_a_dict",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
result = await mixin.extract_native_filters_from_permalink("key")
|
||||
assert "filter_1" in result["dataMask"]
|
||||
assert "filter_2" not in result["dataMask"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_key_with_str_value(self, mixin):
|
||||
mixin.get_native_filter_state = AsyncMock(
|
||||
return_value={"result": {"value": '{"id": "f1", "extraFormData": {}, "filterState": {}, "ownState": {}}'}}
|
||||
)
|
||||
result = await mixin.extract_native_filters_from_key(1, "key1")
|
||||
assert "f1" in result["dataMask"]
|
||||
assert result["dashboard_id"] == 1
|
||||
assert result["filter_state_key"] == "key1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_key_with_dict_value(self, mixin):
|
||||
mixin.get_native_filter_state = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"value": {
|
||||
"f1": {"extraFormData": {}, "filterState": {}, "ownState": {}},
|
||||
"f2": {"extraFormData": {}, "filterState": {}, "ownState": {}},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
result = await mixin.extract_native_filters_from_key(1, "key2")
|
||||
assert "f1" in result["dataMask"]
|
||||
assert "f2" in result["dataMask"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_key_with_invalid_json(self, mixin):
|
||||
mixin.get_native_filter_state = AsyncMock(
|
||||
return_value={"result": {"value": "{invalid json}"}}
|
||||
)
|
||||
with patch("src.core.superset_client._dashboards_filters.app_logger") as mock_log:
|
||||
result = await mixin.extract_native_filters_from_key(1, "bad_key")
|
||||
mock_log.warning.assert_called_once()
|
||||
assert result["dataMask"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_key_with_non_dict_value(self, mixin):
|
||||
mixin.get_native_filter_state = AsyncMock(
|
||||
return_value={"result": {"value": 42}}
|
||||
)
|
||||
result = await mixin.extract_native_filters_from_key(1, "int_key")
|
||||
assert result["dataMask"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_key_skips_non_dict_items(self, mixin):
|
||||
mixin.get_native_filter_state = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"value": {
|
||||
"f1": {"extraFormData": {}, "filterState": {}, "ownState": {}},
|
||||
"f2": "not_a_dict",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
result = await mixin.extract_native_filters_from_key(1, "key3")
|
||||
assert "f1" in result["dataMask"]
|
||||
assert "f2" not in result["dataMask"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_for_filters_permalink(self, mixin):
|
||||
mixin.extract_native_filters_from_permalink = AsyncMock(
|
||||
return_value={"dataMask": {}, "permalink_key": "abc123"}
|
||||
)
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/dashboard/p/abc123/"
|
||||
)
|
||||
assert result["filter_type"] == "permalink"
|
||||
assert result["filters"]["permalink_key"] == "abc123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_for_filters_native_filters_key_with_int_id(self, mixin):
|
||||
mixin.extract_native_filters_from_key = AsyncMock(
|
||||
return_value={"dataMask": {}, "dashboard_id": 5}
|
||||
)
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/dashboard/5/?native_filters_key=key123"
|
||||
)
|
||||
assert result["filter_type"] == "native_filters_key"
|
||||
assert result["dashboard_id"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_for_filters_native_filters_key_with_slug(self, mixin):
|
||||
# Slug resolution calls get_dashboard first
|
||||
mixin.get_dashboard = AsyncMock(return_value={"result": {"id": 42}})
|
||||
mixin.extract_native_filters_from_key = AsyncMock(
|
||||
return_value={"dataMask": {}, "dashboard_id": 42}
|
||||
)
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/dashboard/my-slug/?native_filters_key=key456"
|
||||
)
|
||||
assert result["filter_type"] == "native_filters_key"
|
||||
assert result["dashboard_id"] == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_for_filters_slug_resolution_fails(self, mixin):
|
||||
mixin.get_dashboard = AsyncMock(side_effect=Exception("Not found"))
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/dashboard/unknown/?native_filters_key=key789"
|
||||
)
|
||||
# Should have no filter_type set since resolution failed
|
||||
assert result["filter_type"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_for_filters_native_filters_json(self, mixin):
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/dashboard/1/?native_filters=%7B%22key%22%3A%22val%22%7D"
|
||||
)
|
||||
assert result["filter_type"] == "native_filters"
|
||||
assert "dataMask" in result["filters"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_for_filters_bad_native_filters_json(self, mixin):
|
||||
with patch("src.core.superset_client._dashboards_filters.app_logger") as mock_log:
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/dashboard/1/?native_filters={invalid}"
|
||||
)
|
||||
assert result["filter_type"] is None
|
||||
mock_log.warning.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_for_filters_no_filters(self, mixin):
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/dashboard/1/"
|
||||
)
|
||||
assert result["filter_type"] is None
|
||||
assert result["filters"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_no_dashboard_in_path_for_native_key(self, mixin):
|
||||
# native_filters_key present but no dashboard in path
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/other/?native_filters_key=keyX"
|
||||
)
|
||||
assert result["filter_type"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_p_is_reserved_in_path(self, mixin):
|
||||
# URL has /p/ but not in dashboard context
|
||||
# The mixin will try extract_native_filters_from_permalink -> get_dashboard_permalink_state
|
||||
# which calls self.client.request. Set it to return an empty dict.
|
||||
mixin.client.request.return_value = {"result": {"state": {}}}
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/other/p/something/"
|
||||
)
|
||||
assert result["filter_type"] == "permalink"
|
||||
# #endregion Test.DashboardsFiltersMixin
|
||||
231
backend/tests/test_core/test_dashboards_write.py
Normal file
231
backend/tests/test_core/test_dashboards_write.py
Normal file
@@ -0,0 +1,231 @@
|
||||
# #region Test.DashboardsWriteMixin [C:3] [TYPE Module] [SEMANTICS test, dashboard, write, markdown, layout]
|
||||
# @BRIEF Tests for core/superset_client/_dashboards_write.py — SupersetDashboardsWriteMixin.
|
||||
# @RELATION BINDS_TO -> [SupersetDashboardsWriteMixin]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDashboardsWriteMixin:
|
||||
"""Test all methods of SupersetDashboardsWriteMixin."""
|
||||
|
||||
@pytest.fixture
|
||||
def mixin(self):
|
||||
from src.core.superset_client._dashboards_write import (
|
||||
SupersetDashboardsWriteMixin,
|
||||
)
|
||||
|
||||
m = SupersetDashboardsWriteMixin()
|
||||
m.client = MagicMock()
|
||||
m.client.request = AsyncMock()
|
||||
return m
|
||||
|
||||
# ── create_markdown_chart ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_markdown_chart_success(self, mixin):
|
||||
mixin._resolve_markdown_datasource = AsyncMock(return_value=10)
|
||||
mixin.client.request.return_value = {"id": 42}
|
||||
result = await mixin.create_markdown_chart(1, "Hello")
|
||||
assert result == 42
|
||||
mixin.client.request.assert_awaited_once()
|
||||
call_kwargs = mixin.client.request.call_args
|
||||
assert call_kwargs[1]["method"] == "POST"
|
||||
assert call_kwargs[1]["endpoint"] == "/chart/"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_markdown_chart_id_in_result(self, mixin):
|
||||
mixin._resolve_markdown_datasource = AsyncMock(return_value=10)
|
||||
mixin.client.request.return_value = {"result": {"id": 99}}
|
||||
result = await mixin.create_markdown_chart(1, "Banner")
|
||||
assert result == 99
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_markdown_chart_no_id_returns_zero(self, mixin):
|
||||
mixin._resolve_markdown_datasource = AsyncMock(return_value=10)
|
||||
mixin.client.request.return_value = {}
|
||||
result = await mixin.create_markdown_chart(1, "Text")
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_markdown_chart_request_fails(self, mixin):
|
||||
mixin._resolve_markdown_datasource = AsyncMock(return_value=10)
|
||||
mixin.client.request.side_effect = ValueError("API error")
|
||||
with pytest.raises(ValueError):
|
||||
await mixin.create_markdown_chart(1, "Fail")
|
||||
|
||||
# ── update_markdown_chart ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_markdown_chart_success(self, mixin):
|
||||
mixin.client.request.return_value = {"result": "ok"}
|
||||
result = await mixin.update_markdown_chart(5, "Updated text")
|
||||
mixin.client.request.assert_awaited_once_with(
|
||||
method="PUT", endpoint="/chart/5", data=mixin.client.request.call_args[1]["data"]
|
||||
)
|
||||
assert result == {"result": "ok"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_markdown_chart_fails(self, mixin):
|
||||
mixin.client.request.side_effect = RuntimeError("Update failed")
|
||||
with pytest.raises(RuntimeError):
|
||||
await mixin.update_markdown_chart(5, "Text")
|
||||
|
||||
# ── update_dashboard_layout ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_dashboard_layout_success(self, mixin):
|
||||
mock_position = {
|
||||
"GRID_ID": {"children": []},
|
||||
"DASHBOARD_VERSION_KEY": "v2",
|
||||
}
|
||||
# First call returns dashboard, second call returns success
|
||||
mixin.client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": {"position_json": '{"GRID_ID": {"children": []}, "DASHBOARD_VERSION_KEY": "v2"}'}},
|
||||
{"result": "ok"},
|
||||
]
|
||||
)
|
||||
result = await mixin.update_dashboard_layout(1, 42)
|
||||
assert mixin.client.request.call_count == 2
|
||||
assert result == {"result": "ok"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_dashboard_layout_fails(self, mixin):
|
||||
mixin.client.request.side_effect = ConnectionError("Network error")
|
||||
with pytest.raises(ConnectionError):
|
||||
await mixin.update_dashboard_layout(1, 42)
|
||||
|
||||
# ── remove_chart_from_layout ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_chart_from_layout_success(self, mixin):
|
||||
mixin.client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": {"position_json": '{"GRID_ID": {"children": ["ROW-banner-42"]}, "ROW-banner-42": {}, "MARKDOWN-banner-42": {}}'}},
|
||||
{"result": "ok"},
|
||||
]
|
||||
)
|
||||
result = await mixin.remove_chart_from_layout(1, 42)
|
||||
assert mixin.client.request.call_count == 2
|
||||
assert result == {"result": "ok"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_chart_from_layout_fails(self, mixin):
|
||||
mixin.client.request.side_effect = OSError("IO error")
|
||||
with pytest.raises(OSError):
|
||||
await mixin.remove_chart_from_layout(1, 42)
|
||||
|
||||
# ── delete_chart ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_chart_success(self, mixin):
|
||||
mixin.client.request.return_value = {"result": "deleted"}
|
||||
result = await mixin.delete_chart(42)
|
||||
mixin.client.request.assert_awaited_once_with(method="DELETE", endpoint="/chart/42")
|
||||
assert result == {"result": "deleted"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_chart_fails(self, mixin):
|
||||
mixin.client.request.side_effect = PermissionError("Forbidden")
|
||||
with pytest.raises(PermissionError):
|
||||
await mixin.delete_chart(42)
|
||||
|
||||
# ── update_banner_on_dashboard ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_banner_on_dashboard_success(self, mixin):
|
||||
mixin.client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": {"position_json": '{"MARKDOWN-banner-7": {"meta": {"code": "old", "height": 19}}}'}},
|
||||
{"result": "ok"},
|
||||
]
|
||||
)
|
||||
result = await mixin.update_banner_on_dashboard(1, 7, "New content")
|
||||
assert mixin.client.request.call_count == 2
|
||||
assert result == {"result": "ok"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_banner_on_dashboard_fails(self, mixin):
|
||||
mixin.client.request.side_effect = TimeoutError("Timed out")
|
||||
with pytest.raises(TimeoutError):
|
||||
await mixin.update_banner_on_dashboard(1, 7, "Content")
|
||||
|
||||
# ── get_dashboard_layout ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_layout_success(self, mixin):
|
||||
mixin.client.request.return_value = {
|
||||
"result": {"position_json": '{"GRID_ID": {"children": []}}'}
|
||||
}
|
||||
result = await mixin.get_dashboard_layout(1)
|
||||
assert "GRID_ID" in result
|
||||
assert result["GRID_ID"]["children"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_layout_fails(self, mixin):
|
||||
mixin.client.request.side_effect = Exception("Fetch failed")
|
||||
with pytest.raises(Exception):
|
||||
await mixin.get_dashboard_layout(1)
|
||||
|
||||
# ── _resolve_markdown_datasource ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_markdown_datasource_from_dashboard(self, mixin):
|
||||
mixin.client.request.return_value = {"result": [{"id": 5}]}
|
||||
result = await mixin._resolve_markdown_datasource(1)
|
||||
assert result == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_markdown_datasource_fallback_to_global(self, mixin):
|
||||
mixin.client.request = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("Dashboard datasets failed"),
|
||||
{"result": [{"id": 99}]},
|
||||
]
|
||||
)
|
||||
result = await mixin._resolve_markdown_datasource(1)
|
||||
assert result == 99
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_markdown_datasource_both_fail(self, mixin):
|
||||
mixin.client.request = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("Dashboard failed"),
|
||||
Exception("Global failed"),
|
||||
]
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="Cannot create markdown chart"):
|
||||
await mixin._resolve_markdown_datasource(1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_markdown_datasource_empty_dashboard_datasets(self, mixin):
|
||||
mixin.client.request.return_value = {"result": []}
|
||||
# Then fallback to global
|
||||
mixin.client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": []},
|
||||
{"result": [{"id": 77}]},
|
||||
]
|
||||
)
|
||||
result = await mixin._resolve_markdown_datasource(1)
|
||||
assert result == 77
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_markdown_datasource_dashboard_has_no_id_field(self, mixin):
|
||||
mixin.client.request.return_value = {"result": [{"name": "test"}]}
|
||||
mixin.client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": [{"name": "test"}]},
|
||||
{"result": [{"id": 88}]},
|
||||
]
|
||||
)
|
||||
result = await mixin._resolve_markdown_datasource(1)
|
||||
assert result == 88
|
||||
# #endregion Test.DashboardsWriteMixin
|
||||
275
backend/tests/test_core/test_dataset_mapper.py
Normal file
275
backend/tests/test_core/test_dataset_mapper.py
Normal file
@@ -0,0 +1,275 @@
|
||||
# #region Test.DatasetMapper [C:3] [TYPE Module] [SEMANTICS test, dataset, mapper, sqllab, excel]
|
||||
# @BRIEF Tests for core/utils/dataset_mapper.py — DatasetMapper.
|
||||
# @RELATION BINDS_TO -> [DatasetMapperModule]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDatasetMapper:
|
||||
"""Test DatasetMapper — SQL Lab and Excel mapping + run."""
|
||||
|
||||
@pytest.fixture
|
||||
def mapper(self):
|
||||
from src.core.utils.dataset_mapper import DatasetMapper
|
||||
|
||||
return DatasetMapper()
|
||||
|
||||
# ── get_sqllab_mappings ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sqllab_mappings_with_default_query(self, mapper):
|
||||
# Note: get_sqllab_mappings calls client.get_dataset() WITHOUT await (source bug).
|
||||
# So client.get_dataset must be a regular MagicMock, not AsyncMock.
|
||||
client = MagicMock()
|
||||
client.get_dataset = MagicMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"table_name": "users",
|
||||
"schema": "public",
|
||||
"database": {"database_name": "Postgres"},
|
||||
}
|
||||
}
|
||||
)
|
||||
sqllab_executor = AsyncMock()
|
||||
sqllab_executor.execute_and_poll.return_value = {
|
||||
"result": [
|
||||
{"column_name": "id", "verbose_name": "Identifier"},
|
||||
{"column_name": "name", "verbose_name": "Full Name"},
|
||||
]
|
||||
}
|
||||
|
||||
result = await mapper.get_sqllab_mappings(client, 1, sqllab_executor, 42)
|
||||
assert result == {"id": "Identifier", "name": "Full Name"}
|
||||
assert "information_schema" in sqllab_executor.execute_and_poll.call_args[0][0]
|
||||
sqllab_executor.execute_and_poll.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sqllab_mappings_with_custom_query(self, mapper):
|
||||
client = MagicMock()
|
||||
client.get_dataset = MagicMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"table_name": "users",
|
||||
"schema": "public",
|
||||
"database": {"database_name": "Postgres"},
|
||||
}
|
||||
}
|
||||
)
|
||||
sqllab_executor = AsyncMock()
|
||||
sqllab_executor.execute_and_poll.return_value = {
|
||||
"data": [
|
||||
{"column_name": "email", "verbose_name": "Email Address"},
|
||||
]
|
||||
}
|
||||
|
||||
result = await mapper.get_sqllab_mappings(
|
||||
client, 1, sqllab_executor, 42, sql_query="SELECT column_name, verbose_name FROM my_table"
|
||||
)
|
||||
assert result == {"email": "Email Address"}
|
||||
assert "my_table" in sqllab_executor.execute_and_poll.call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sqllab_mappings_skips_null_verbose_name(self, mapper):
|
||||
client = MagicMock()
|
||||
client.get_dataset = MagicMock(
|
||||
return_value={"result": {"table_name": "t", "schema": "public"}}
|
||||
)
|
||||
sqllab_executor = AsyncMock()
|
||||
sqllab_executor.execute_and_poll.return_value = {
|
||||
"results": [
|
||||
{"column_name": "id", "verbose_name": None},
|
||||
{"column_name": "name", "verbose_name": "Name"},
|
||||
]
|
||||
}
|
||||
|
||||
result = await mapper.get_sqllab_mappings(client, 1, sqllab_executor, 42)
|
||||
assert result == {"name": "Name"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sqllab_mappings_uses_default_schema(self, mapper):
|
||||
client = MagicMock()
|
||||
client.get_dataset = MagicMock(
|
||||
return_value={"result": {"table_name": "t"}}
|
||||
)
|
||||
sqllab_executor = AsyncMock()
|
||||
sqllab_executor.execute_and_poll.return_value = {"result": []}
|
||||
|
||||
await mapper.get_sqllab_mappings(client, 1, sqllab_executor, 42)
|
||||
assert "public" in sqllab_executor.execute_and_poll.call_args[0][0]
|
||||
|
||||
# ── load_excel_mappings ──
|
||||
|
||||
def test_load_excel_mappings_success(self, mapper):
|
||||
"""Use a real DataFrame to avoid MagicMock complexity with __getitem__."""
|
||||
data = pd.DataFrame({"column_name": ["a", "b"], "verbose_name": ["Alpha", "Beta"]})
|
||||
with patch("pandas.read_excel", return_value=data):
|
||||
result = mapper.load_excel_mappings("/tmp/test.xlsx")
|
||||
assert result == {"a": "Alpha", "b": "Beta"}
|
||||
|
||||
def test_load_excel_mappings_file_not_found(self, mapper):
|
||||
with patch("pandas.read_excel", side_effect=FileNotFoundError("No file")):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
mapper.load_excel_mappings("/nonexistent.xlsx")
|
||||
|
||||
# ── run_mapping ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_sqllab_with_changes(self, mapper):
|
||||
# Mock get_sqllab_mappings to avoid its missing-await issue
|
||||
mapper.get_sqllab_mappings = AsyncMock(
|
||||
return_value={"id": "Identifier", "name": "New Name"}
|
||||
)
|
||||
client = AsyncMock()
|
||||
client.get_dataset = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"table_name": "users",
|
||||
"columns": [
|
||||
{"column_name": "id", "verbose_name": None},
|
||||
{"column_name": "name", "verbose_name": "Old Name"},
|
||||
],
|
||||
"metrics": [],
|
||||
"owners": [],
|
||||
"database": {"id": 1},
|
||||
}
|
||||
}
|
||||
)
|
||||
client.update_dataset = AsyncMock(return_value={"result": "ok"})
|
||||
|
||||
await mapper.run_mapping(
|
||||
superset_client=client,
|
||||
dataset_id=1,
|
||||
source="sqllab",
|
||||
sqllab_executor=MagicMock(),
|
||||
database_id=42,
|
||||
)
|
||||
client.update_dataset.assert_awaited_once()
|
||||
call_args = client.update_dataset.call_args
|
||||
assert call_args[0][0] == 1 # dataset_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_sqllab_no_changes(self, mapper):
|
||||
mapper.get_sqllab_mappings = AsyncMock(
|
||||
return_value={"name": "Same Name"}
|
||||
)
|
||||
client = AsyncMock()
|
||||
client.get_dataset = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"table_name": "users",
|
||||
"columns": [
|
||||
{"column_name": "name", "verbose_name": "Same Name"},
|
||||
],
|
||||
"metrics": [],
|
||||
"owners": [],
|
||||
"database": {"id": 1},
|
||||
}
|
||||
}
|
||||
)
|
||||
client.update_dataset = AsyncMock()
|
||||
|
||||
await mapper.run_mapping(
|
||||
superset_client=client,
|
||||
dataset_id=1,
|
||||
source="sqllab",
|
||||
sqllab_executor=MagicMock(),
|
||||
database_id=42,
|
||||
)
|
||||
client.update_dataset.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_excel_source(self, mapper):
|
||||
mapper.load_excel_mappings = MagicMock(return_value={"a": "Alpha"})
|
||||
client = AsyncMock()
|
||||
client.get_dataset = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"table_name": "t",
|
||||
"columns": [{"column_name": "a", "verbose_name": None}],
|
||||
"metrics": [],
|
||||
"owners": [],
|
||||
"database": {"id": 1},
|
||||
}
|
||||
}
|
||||
)
|
||||
client.update_dataset = AsyncMock(return_value={"result": "ok"})
|
||||
|
||||
await mapper.run_mapping(
|
||||
superset_client=client, dataset_id=1, source="excel", excel_path="/tmp/map.xlsx"
|
||||
)
|
||||
client.update_dataset.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_invalid_source(self, mapper):
|
||||
client = MagicMock()
|
||||
with patch("src.core.utils.dataset_mapper.app_logger") as mock_log:
|
||||
await mapper.run_mapping(
|
||||
superset_client=client, dataset_id=1, source="invalid"
|
||||
)
|
||||
mock_log.error.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_missing_sqllab_args(self, mapper):
|
||||
client = MagicMock()
|
||||
with patch("src.core.utils.dataset_mapper.app_logger") as mock_log:
|
||||
await mapper.run_mapping(
|
||||
superset_client=client, dataset_id=1, source="sqllab",
|
||||
sqllab_executor=None, database_id=None,
|
||||
)
|
||||
mock_log.error.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_missing_excel_path(self, mapper):
|
||||
client = MagicMock()
|
||||
with patch("src.core.utils.dataset_mapper.app_logger") as mock_log:
|
||||
await mapper.run_mapping(
|
||||
superset_client=client, dataset_id=1, source="excel", excel_path=None,
|
||||
)
|
||||
mock_log.error.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_client_error_handled(self, mapper):
|
||||
mapper.get_sqllab_mappings = AsyncMock(side_effect=Exception("API failure"))
|
||||
client = MagicMock()
|
||||
|
||||
with patch("src.core.utils.dataset_mapper.app_logger") as mock_log:
|
||||
await mapper.run_mapping(
|
||||
superset_client=client,
|
||||
dataset_id=1,
|
||||
source="sqllab",
|
||||
sqllab_executor=MagicMock(),
|
||||
database_id=42,
|
||||
)
|
||||
mock_log.error.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_handles_non_dict_columns(self, mapper):
|
||||
mapper.get_sqllab_mappings = AsyncMock(return_value={"a": "Alpha"})
|
||||
client = AsyncMock()
|
||||
client.get_dataset = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"table_name": "t",
|
||||
"columns": [{"column_name": "a", "verbose_name": None}],
|
||||
"metrics": [],
|
||||
"owners": [],
|
||||
"database": {"id": 1},
|
||||
}
|
||||
}
|
||||
)
|
||||
client.update_dataset = AsyncMock(return_value={"result": "ok"})
|
||||
|
||||
await mapper.run_mapping(
|
||||
superset_client=client, dataset_id=1, source="sqllab",
|
||||
sqllab_executor=MagicMock(), database_id=42,
|
||||
)
|
||||
client.update_dataset.assert_awaited_once()
|
||||
# #endregion Test.DatasetMapper
|
||||
296
backend/tests/test_core/test_datasets.py
Normal file
296
backend/tests/test_core/test_datasets.py
Normal file
@@ -0,0 +1,296 @@
|
||||
# #region Test.DatasetsMixin [C:3] [TYPE Module] [SEMANTICS test, dataset, superset, crud, detail]
|
||||
# @BRIEF Tests for core/superset_client/_datasets.py — SupersetDatasetsMixin.
|
||||
# @RELATION BINDS_TO -> [SupersetDatasetsMixin]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDatasetsMixin:
|
||||
"""Test all methods of SupersetDatasetsMixin."""
|
||||
|
||||
@pytest.fixture
|
||||
def mixin(self):
|
||||
from src.core.superset_client._datasets import SupersetDatasetsMixin
|
||||
|
||||
m = SupersetDatasetsMixin()
|
||||
m.client = MagicMock()
|
||||
m.client.request = AsyncMock()
|
||||
m._validate_query_params = MagicMock(return_value={"page": 0, "page_size": 100})
|
||||
m._fetch_all_pages = AsyncMock(return_value=[{"id": 1}, {"id": 2}])
|
||||
return m
|
||||
|
||||
# ── get_datasets ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_datasets_success(self, mixin):
|
||||
total, data = await mixin.get_datasets(query={"columns": ["id"]})
|
||||
assert total == 2
|
||||
assert len(data) == 2
|
||||
mixin._validate_query_params.assert_called_once_with({"columns": ["id"]})
|
||||
mixin._fetch_all_pages.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_datasets_no_query(self, mixin):
|
||||
total, data = await mixin.get_datasets()
|
||||
assert total == 2
|
||||
mixin._validate_query_params.assert_called_once_with(None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_datasets_empty_result(self, mixin):
|
||||
mixin._fetch_all_pages = AsyncMock(return_value=[])
|
||||
total, data = await mixin.get_datasets()
|
||||
assert total == 0
|
||||
assert data == []
|
||||
|
||||
# ── get_datasets_summary ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_datasets_summary(self, mixin):
|
||||
mixin.get_datasets = AsyncMock(
|
||||
return_value=(
|
||||
2,
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"table_name": "users",
|
||||
"schema": "public",
|
||||
"database": {"database_name": "Postgres"},
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"table_name": "orders",
|
||||
"schema": "public",
|
||||
"database": {"database_name": "Postgres"},
|
||||
},
|
||||
],
|
||||
)
|
||||
)
|
||||
result = await mixin.get_datasets_summary()
|
||||
assert len(result) == 2
|
||||
assert result[0]["table_name"] == "users"
|
||||
assert result[0]["database"] == "Postgres"
|
||||
assert result[1]["schema"] == "public"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_datasets_summary_missing_database(self, mixin):
|
||||
mixin.get_datasets = AsyncMock(
|
||||
return_value=(
|
||||
1,
|
||||
[{"id": 1, "table_name": "t", "schema": "public"}],
|
||||
)
|
||||
)
|
||||
result = await mixin.get_datasets_summary()
|
||||
assert result[0]["database"] == "Unknown"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_datasets_summary_empty(self, mixin):
|
||||
mixin.get_datasets = AsyncMock(return_value=(0, []))
|
||||
result = await mixin.get_datasets_summary()
|
||||
assert result == []
|
||||
|
||||
# ── get_dataset_linked_dashboard_count ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_linked_dashboard_count_direct_key(self, mixin):
|
||||
mixin.client.request.return_value = {"dashboards": [{"id": 1}, {"id": 2}]}
|
||||
result = await mixin.get_dataset_linked_dashboard_count(1)
|
||||
assert result == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_linked_dashboard_count_result_key(self, mixin):
|
||||
mixin.client.request.return_value = {"result": {"dashboards": [{"id": 1}]}}
|
||||
result = await mixin.get_dataset_linked_dashboard_count(1)
|
||||
assert result == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_linked_dashboard_count_empty(self, mixin):
|
||||
mixin.client.request.return_value = {"result": {"dashboards": []}}
|
||||
result = await mixin.get_dataset_linked_dashboard_count(1)
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_linked_dashboard_count_non_dict(self, mixin):
|
||||
mixin.client.request.return_value = "not_a_dict"
|
||||
result = await mixin.get_dataset_linked_dashboard_count(1)
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_linked_dashboard_count_error(self, mixin):
|
||||
mixin.client.request.side_effect = Exception("API error")
|
||||
with patch("src.core.superset_client._datasets.app_logger") as mock_log:
|
||||
result = await mixin.get_dataset_linked_dashboard_count(1)
|
||||
assert result == 0
|
||||
mock_log.warning.assert_called_once()
|
||||
|
||||
# ── get_dataset_detail ──
|
||||
|
||||
@pytest.fixture
|
||||
def detail_mixin(self):
|
||||
from src.core.superset_client._datasets import SupersetDatasetsMixin
|
||||
|
||||
m = SupersetDatasetsMixin()
|
||||
m.client = MagicMock()
|
||||
m.client.request = AsyncMock()
|
||||
m.get_dataset = AsyncMock()
|
||||
return m
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_detail_full(self, detail_mixin):
|
||||
detail_mixin.get_dataset.return_value = {
|
||||
"result": {
|
||||
"id": 1,
|
||||
"table_name": "users",
|
||||
"schema": "public",
|
||||
"database": {"id": 1, "backend": "postgresql", "database_name": "Postgres"},
|
||||
"database_id": 1,
|
||||
"description": "User table",
|
||||
"columns": [
|
||||
{"id": 1, "column_name": "name", "type": "text", "is_dttm": False, "is_active": True, "description": ""},
|
||||
{"id": 2, "column_name": "created", "type": "timestamp", "is_dttm": True, "is_active": True, "description": ""},
|
||||
],
|
||||
"metrics": [
|
||||
{"id": 1, "metric_name": "count", "expression": "COUNT(*)", "verbose_name": "Count", "description": "", "metric_type": "count"},
|
||||
],
|
||||
"sql": "SELECT * FROM users",
|
||||
"is_sqllab_view": False,
|
||||
"created_on": "2024-01-01",
|
||||
"changed_on": "2024-01-02",
|
||||
}
|
||||
}
|
||||
detail_mixin.client.request.return_value = {
|
||||
"dashboards": {"result": [{"id": 10, "dashboard_title": "Main", "slug": "main"}]}
|
||||
}
|
||||
result = await detail_mixin.get_dataset_detail(1)
|
||||
assert result["id"] == 1
|
||||
assert result["table_name"] == "users"
|
||||
assert len(result["columns"]) == 2
|
||||
assert result["columns"][0]["name"] == "name"
|
||||
assert result["columns"][1]["is_dttm"] is True
|
||||
assert len(result["metrics"]) == 1
|
||||
assert result["metrics"][0]["metric_name"] == "count"
|
||||
assert result["linked_dashboard_count"] == 1
|
||||
assert result["database_backend"] == "postgresql"
|
||||
assert result["is_sqllab_view"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_detail_no_result_key(self, detail_mixin):
|
||||
detail_mixin.get_dataset.return_value = {
|
||||
"id": 1,
|
||||
"table_name": "no_result",
|
||||
"columns": [],
|
||||
"metrics": [],
|
||||
}
|
||||
detail_mixin.client.request.return_value = {}
|
||||
result = await detail_mixin.get_dataset_detail(1)
|
||||
assert result["table_name"] == "no_result"
|
||||
assert result["columns"] == []
|
||||
assert result["metrics"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_detail_related_objects_error(self, detail_mixin):
|
||||
detail_mixin.get_dataset.return_value = {
|
||||
"result": {
|
||||
"id": 1,
|
||||
"table_name": "test",
|
||||
"columns": [],
|
||||
"metrics": [],
|
||||
}
|
||||
}
|
||||
detail_mixin.client.request.side_effect = Exception("Connection error")
|
||||
with patch("src.core.superset_client._datasets.app_logger") as mock_log:
|
||||
result = await detail_mixin.get_dataset_detail(1)
|
||||
assert result["linked_dashboards"] == []
|
||||
mock_log.warning.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_detail_related_objects_has_dashboards_direct(self, detail_mixin):
|
||||
detail_mixin.get_dataset.return_value = {
|
||||
"result": {
|
||||
"id": 1,
|
||||
"table_name": "test",
|
||||
"columns": [],
|
||||
"metrics": [],
|
||||
"database": {},
|
||||
}
|
||||
}
|
||||
detail_mixin.client.request.return_value = {"dashboards": [{"id": 5, "dashboard_title": "Dash"}]}
|
||||
result = await detail_mixin.get_dataset_detail(1)
|
||||
assert result["linked_dashboard_count"] == 1
|
||||
assert result["linked_dashboards"][0]["id"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_detail_related_objects_with_int_list(self, detail_mixin):
|
||||
detail_mixin.get_dataset.return_value = {
|
||||
"result": {
|
||||
"id": 1,
|
||||
"table_name": "test",
|
||||
"columns": [],
|
||||
"metrics": [],
|
||||
}
|
||||
}
|
||||
detail_mixin.client.request.return_value = {"dashboards": [5, 6]}
|
||||
result = await detail_mixin.get_dataset_detail(1)
|
||||
assert result["linked_dashboard_count"] == 2
|
||||
assert result["linked_dashboards"][0]["id"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_detail_as_bool_variants(self, detail_mixin):
|
||||
detail_mixin.get_dataset.return_value = {
|
||||
"result": {
|
||||
"id": 1,
|
||||
"table_name": "test",
|
||||
"columns": [
|
||||
{"id": 1, "column_name": "a", "is_dttm": "1", "is_active": "true"},
|
||||
{"id": 2, "column_name": "b", "is_dttm": "yes", "is_active": "on"},
|
||||
{"id": 3, "column_name": "c", "is_dttm": None, "is_active": None},
|
||||
{"id": 4, "column_name": "d", "is_dttm": 1, "is_active": 0},
|
||||
],
|
||||
"metrics": [],
|
||||
"database": {"backend": "mysql"},
|
||||
}
|
||||
}
|
||||
detail_mixin.client.request.return_value = {}
|
||||
result = await detail_mixin.get_dataset_detail(1)
|
||||
assert result["columns"][0]["is_dttm"] is True
|
||||
assert result["columns"][1]["is_dttm"] is True
|
||||
assert result["columns"][2]["is_dttm"] is False
|
||||
assert result["columns"][3]["is_dttm"] is True
|
||||
assert result["columns"][0]["is_active"] is True
|
||||
assert result["columns"][3]["is_active"] is False
|
||||
|
||||
# ── get_dataset ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_success(self, mixin):
|
||||
mixin.client.request.return_value = {"result": {"id": 1, "table_name": "test"}}
|
||||
result = await mixin.get_dataset(1)
|
||||
assert result["result"]["id"] == 1
|
||||
|
||||
# ── update_dataset ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_dataset_success(self, mixin):
|
||||
mixin.client.request.return_value = {"result": "ok"}
|
||||
result = await mixin.update_dataset(1, {"columns": []}, override_columns=True)
|
||||
mixin.client.request.assert_awaited_once()
|
||||
assert result["result"] == "ok"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_dataset_no_override(self, mixin):
|
||||
mixin.client.request.return_value = {"result": "ok"}
|
||||
result = await mixin.update_dataset(1, {"columns": []})
|
||||
assert result["result"] == "ok"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_dataset_with_json_data(self, mixin):
|
||||
mixin.client.request.return_value = {"result": "ok"}
|
||||
result = await mixin.update_dataset(1, {"table_name": "new_name"}, override_columns=False)
|
||||
assert result["result"] == "ok"
|
||||
# #endregion Test.DatasetsMixin
|
||||
285
backend/tests/test_core/test_datasets_preview.py
Normal file
285
backend/tests/test_core/test_datasets_preview.py
Normal file
@@ -0,0 +1,285 @@
|
||||
# #region Test.DatasetsPreviewMixin [C:3] [TYPE Module] [SEMANTICS test, dataset, preview, compile, query]
|
||||
# @BRIEF Tests for core/superset_client/_datasets_preview.py — SupersetDatasetsPreviewMixin.
|
||||
# @RELATION BINDS_TO -> [SupersetDatasetsPreviewMixin]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDatasetsPreviewMixin:
|
||||
"""Test SupersetDatasetsPreviewMixin methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def mixin(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m.client = MagicMock()
|
||||
m.client.request = AsyncMock()
|
||||
m.get_dataset = AsyncMock()
|
||||
m.build_dataset_preview_query_context = MagicMock(
|
||||
return_value={
|
||||
"datasource": {"id": 1, "type": "table"},
|
||||
"queries": [{"metrics": ["count"], "columns": [], "filters": []}],
|
||||
"form_data": {"datasource": "1__table"},
|
||||
"result_format": "json",
|
||||
"result_type": "query",
|
||||
"force": True,
|
||||
}
|
||||
)
|
||||
m.build_dataset_preview_legacy_form_data = MagicMock(
|
||||
return_value={
|
||||
"datasource": "1__table",
|
||||
"metrics": ["count"],
|
||||
"result_format": "json",
|
||||
"result_type": "query",
|
||||
"force": True,
|
||||
}
|
||||
)
|
||||
m._extract_compiled_sql_from_preview_response = MagicMock(
|
||||
return_value={"compiled_sql": "SELECT 1", "response_diagnostics": []}
|
||||
)
|
||||
return m
|
||||
|
||||
# ── compile_dataset_preview ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compile_dataset_preview_first_strategy_succeeds(self, mixin):
|
||||
mixin.get_dataset.return_value = {"result": {"id": 1}}
|
||||
mixin.client.request.return_value = {"compiled_sql": "SELECT 1"}
|
||||
result = await mixin.compile_dataset_preview(
|
||||
dataset_id=1, template_params={"schema": "public"}, effective_filters=[{"col": "status", "val": "active"}]
|
||||
)
|
||||
assert result["compiled_sql"] == "SELECT 1"
|
||||
assert result["dataset_id"] == 1
|
||||
assert "endpoint_kind" in result
|
||||
assert "strategy_attempts" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compile_dataset_preview_all_strategies_fail(self, mixin):
|
||||
mixin.get_dataset.return_value = {"result": {"id": 1}}
|
||||
mixin.client.request.side_effect = Exception("API error")
|
||||
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
with pytest.raises(SupersetAPIError, match="Superset preview compilation failed"):
|
||||
await mixin.compile_dataset_preview(dataset_id=1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compile_dataset_preview_get_dataset_non_dict(self, mixin):
|
||||
mixin.get_dataset.return_value = "raw_response"
|
||||
mixin.client.request.return_value = {"compiled_sql": "SELECT 1"}
|
||||
result = await mixin.compile_dataset_preview(dataset_id=1)
|
||||
assert result["compiled_sql"] == "SELECT 1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compile_dataset_preview_no_template_params(self, mixin):
|
||||
# Note: fixture sets mixin._extract_compiled_sql_from_preview_response to return
|
||||
# {"compiled_sql": "SELECT 1"}, so this overrides client.request return value.
|
||||
# The actual endpoint returns whatever the mock provides, but the extraction
|
||||
# is mocked to "SELECT 1". This test verifies that calling without params works.
|
||||
mixin.get_dataset.return_value = {"result": {"id": 1}}
|
||||
mixin.client.request.return_value = {"compiled_sql": "SELECT 1"}
|
||||
result = await mixin.compile_dataset_preview(dataset_id=1)
|
||||
assert result["compiled_sql"] == "SELECT 1"
|
||||
|
||||
# ── build_dataset_preview_query_context ──
|
||||
|
||||
def test_build_dataset_preview_query_context_basic(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m._normalize_effective_filters_for_query_context = MagicMock(
|
||||
return_value={
|
||||
"filters": [{"col": "status", "op": "==", "val": "active"}],
|
||||
"extra_form_data": {},
|
||||
"diagnostics": [],
|
||||
}
|
||||
)
|
||||
result = m.build_dataset_preview_query_context(
|
||||
dataset_id=1,
|
||||
dataset_record={
|
||||
"id": 1,
|
||||
"table_name": "users",
|
||||
"schema": "public",
|
||||
"datasource": {"id": 1, "type": "table"},
|
||||
},
|
||||
template_params={"param1": "val1"},
|
||||
effective_filters=[{"col": "status", "val": "active"}],
|
||||
)
|
||||
assert result["datasource"]["id"] == 1
|
||||
assert result["datasource"]["type"] == "table"
|
||||
assert result["form_data"]["datasource"] == "1__table"
|
||||
assert len(result["queries"]) == 1
|
||||
assert result["queries"][0]["schema"] == "public"
|
||||
assert result["queries"][0]["row_limit"] == 1000
|
||||
assert result["queries"][0]["url_params"] == {"param1": "val1"}
|
||||
|
||||
def test_build_dataset_preview_query_context_with_template_params_from_dataset(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m._normalize_effective_filters_for_query_context = MagicMock(
|
||||
return_value={"filters": [], "extra_form_data": {}, "diagnostics": []}
|
||||
)
|
||||
result = m.build_dataset_preview_query_context(
|
||||
dataset_id=1,
|
||||
dataset_record={
|
||||
"id": 1,
|
||||
"table_name": "users",
|
||||
"template_params": '{"dataset_param": "value"}',
|
||||
},
|
||||
template_params={"param1": "val1"},
|
||||
effective_filters=[],
|
||||
)
|
||||
# Dataset template_params should be merged (setdefault, so existing keys take precedence)
|
||||
assert result["queries"][0]["url_params"]["param1"] == "val1"
|
||||
assert result["queries"][0]["url_params"]["dataset_param"] == "value"
|
||||
|
||||
def test_build_dataset_preview_query_context_with_bad_template_params(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m._normalize_effective_filters_for_query_context = MagicMock(
|
||||
return_value={"filters": [], "extra_form_data": {}, "diagnostics": []}
|
||||
)
|
||||
with patch("src.core.superset_client._datasets_preview.app_logger") as mock_log:
|
||||
result = m.build_dataset_preview_query_context(
|
||||
dataset_id=1,
|
||||
dataset_record={
|
||||
"id": 1,
|
||||
"table_name": "users",
|
||||
"template_params": "{invalid json}",
|
||||
},
|
||||
template_params={},
|
||||
effective_filters=[],
|
||||
)
|
||||
mock_log.explore.assert_called_once()
|
||||
assert result["queries"][0]["url_params"] == {}
|
||||
|
||||
def test_build_dataset_preview_query_context_with_filters(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m._normalize_effective_filters_for_query_context = MagicMock(
|
||||
return_value={
|
||||
"filters": [{"col": "status", "op": "==", "val": "active"}],
|
||||
"extra_form_data": {"time_range": "Last week"},
|
||||
"diagnostics": [],
|
||||
}
|
||||
)
|
||||
result = m.build_dataset_preview_query_context(
|
||||
dataset_id=1,
|
||||
dataset_record={"id": 1, "table_name": "users"},
|
||||
template_params={},
|
||||
effective_filters=[{"col": "status", "val": "active"}],
|
||||
)
|
||||
# Filters should be in extra_form_data and in the query
|
||||
assert "time_range" in result["form_data"]["extra_form_data"]
|
||||
assert result["form_data"]["extra_form_data"]["time_range"] == "Last week"
|
||||
assert result["queries"][0]["filters"] == [{"col": "status", "op": "==", "val": "active"}]
|
||||
|
||||
def test_build_dataset_preview_query_context_datasource_passthrough(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m._normalize_effective_filters_for_query_context = MagicMock(
|
||||
return_value={"filters": [], "extra_form_data": {}, "diagnostics": []}
|
||||
)
|
||||
result = m.build_dataset_preview_query_context(
|
||||
dataset_id=1,
|
||||
dataset_record={
|
||||
"id": 1,
|
||||
"table_name": "users",
|
||||
"datasource": {"id": 99, "type": "query"},
|
||||
},
|
||||
template_params={},
|
||||
effective_filters=[],
|
||||
)
|
||||
# The datasource from the record should override
|
||||
assert result["datasource"]["id"] == 99
|
||||
assert result["datasource"]["type"] == "query"
|
||||
|
||||
# ── build_dataset_preview_legacy_form_data ──
|
||||
|
||||
def test_build_dataset_preview_legacy_form_data_basic(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m.build_dataset_preview_query_context = MagicMock(
|
||||
return_value={
|
||||
"queries": [{
|
||||
"metrics": ["count"],
|
||||
"columns": [],
|
||||
"orderby": [],
|
||||
"annotation_layers": [],
|
||||
"row_limit": 1000,
|
||||
"series_limit": 0,
|
||||
"url_params": {},
|
||||
"applied_time_extras": {},
|
||||
"extras": {"where": ""},
|
||||
}],
|
||||
"form_data": {"datasource": "1__table"},
|
||||
"result_format": "json",
|
||||
"result_type": "query",
|
||||
"force": True,
|
||||
}
|
||||
)
|
||||
result = m.build_dataset_preview_legacy_form_data(1, {}, {}, [])
|
||||
assert result["metrics"] == ["count"]
|
||||
assert result["result_format"] == "json"
|
||||
assert result["result_type"] == "query"
|
||||
assert result["force"] is True
|
||||
assert "datasource" not in result # popped
|
||||
|
||||
def test_build_dataset_preview_legacy_form_data_with_extras(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m.build_dataset_preview_query_context = MagicMock(
|
||||
return_value={
|
||||
"queries": [{
|
||||
"metrics": ["count"],
|
||||
"columns": [],
|
||||
"orderby": [],
|
||||
"annotation_layers": [],
|
||||
"row_limit": 1000,
|
||||
"series_limit": 0,
|
||||
"url_params": {},
|
||||
"applied_time_extras": {},
|
||||
"extras": {"where": "1=1"},
|
||||
"time_range": "Last 7 days",
|
||||
}],
|
||||
"form_data": {},
|
||||
"result_format": "json",
|
||||
"result_type": "query",
|
||||
"force": False,
|
||||
}
|
||||
)
|
||||
result = m.build_dataset_preview_legacy_form_data(1, {}, {}, [])
|
||||
assert result["extras"]["where"] == "1=1"
|
||||
assert result["time_range"] == "Last 7 days"
|
||||
assert result["force"] is False
|
||||
# #endregion Test.DatasetsPreviewMixin
|
||||
239
backend/tests/test_core/test_datasets_preview_filters.py
Normal file
239
backend/tests/test_core/test_datasets_preview_filters.py
Normal file
@@ -0,0 +1,239 @@
|
||||
# #region Test.DatasetsPreviewFiltersMixin [C:3] [TYPE Module] [SEMANTICS test, dataset, preview, filter, sql, extract]
|
||||
# @BRIEF Tests for core/superset_client/_datasets_preview_filters.py — filter normalization + SQL extraction.
|
||||
# @RELATION BINDS_TO -> [SupersetDatasetsPreviewFiltersMixin]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestNormalizeEffectiveFilters:
|
||||
"""_normalize_effective_filters_for_query_context: filter conversion."""
|
||||
|
||||
@pytest.fixture
|
||||
def mixin(self):
|
||||
from src.core.superset_client._datasets_preview_filters import (
|
||||
SupersetDatasetsPreviewFiltersMixin,
|
||||
)
|
||||
|
||||
return SupersetDatasetsPreviewFiltersMixin()
|
||||
|
||||
def test_empty_filters(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([])
|
||||
assert result["filters"] == []
|
||||
assert result["diagnostics"] == []
|
||||
|
||||
def test_skip_non_dict_items(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context(
|
||||
["string", 42, None]
|
||||
)
|
||||
assert result["filters"] == []
|
||||
assert len(result["diagnostics"]) == 0 # skipped before diagnostics
|
||||
|
||||
def test_heuristic_reconstruction_with_column_and_value(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{"variable_name": "status", "effective_value": "active"}
|
||||
])
|
||||
assert len(result["filters"]) == 1
|
||||
assert result["filters"][0]["col"] == "status"
|
||||
assert result["filters"][0]["op"] == "=="
|
||||
assert result["filters"][0]["val"] == "active"
|
||||
|
||||
def test_heuristic_reconstruction_with_list_value(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{"variable_name": "status", "effective_value": ["active", "pending"]}
|
||||
])
|
||||
assert result["filters"][0]["op"] == "IN"
|
||||
|
||||
def test_heuristic_reconstruction_missing_column_skipped(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{"effective_value": "val"}
|
||||
])
|
||||
assert result["filters"] == []
|
||||
|
||||
def test_preserved_clauses_without_val(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{
|
||||
"display_name": "Category",
|
||||
"effective_value": "electronics",
|
||||
"normalized_filter_payload": {
|
||||
"filter_clauses": [{"col": "category", "op": "=="}],
|
||||
"value_origin": "preset",
|
||||
},
|
||||
}
|
||||
])
|
||||
assert len(result["filters"]) == 1
|
||||
# val should be filled from effective_value
|
||||
assert result["filters"][0]["val"] == "electronics"
|
||||
|
||||
def test_preserved_clauses_with_existing_val(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{
|
||||
"display_name": "Category",
|
||||
"effective_value": "fallback",
|
||||
"normalized_filter_payload": {
|
||||
"filter_clauses": [{"col": "category", "op": "==", "val": "original"}],
|
||||
"value_origin": "preset",
|
||||
},
|
||||
}
|
||||
])
|
||||
assert len(result["filters"]) == 1
|
||||
# val should remain "original" since it already exists
|
||||
assert result["filters"][0]["val"] == "original"
|
||||
|
||||
def test_extra_form_data_merged(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{
|
||||
"display_name": "Filter1",
|
||||
"effective_value": "val1",
|
||||
"normalized_filter_payload": {
|
||||
"extra_form_data": {"time_range": "Last month", "filters": ["ignored"]},
|
||||
"filter_clauses": [],
|
||||
},
|
||||
}
|
||||
])
|
||||
# extra_form_data.filters is skipped during merge
|
||||
assert result["extra_form_data"]["time_range"] == "Last month"
|
||||
assert "filters" not in result["extra_form_data"]
|
||||
|
||||
def test_extra_form_data_without_preserved_clauses_uses_empty(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{
|
||||
"display_name": "Filter1",
|
||||
"effective_value": None,
|
||||
"normalized_filter_payload": {
|
||||
"extra_form_data": {"time_range": "All time"},
|
||||
},
|
||||
}
|
||||
])
|
||||
# Has extra_form_data but no clauses -> empty clauses
|
||||
assert result["filters"] == []
|
||||
|
||||
def test_missing_display_name_fallback(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{
|
||||
"filter_name": "my_filter",
|
||||
"variable_name": "my_var",
|
||||
"effective_value": "val",
|
||||
}
|
||||
])
|
||||
diagnostics = result["diagnostics"]
|
||||
assert diagnostics[0]["filter_name"] == "my_filter"
|
||||
|
||||
def test_unnamed_filter_fallback(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{"effective_value": "x"}
|
||||
])
|
||||
assert result["filters"] == []
|
||||
|
||||
|
||||
class TestExtractCompiledSql:
|
||||
"""_extract_compiled_sql_from_preview_response: normalized SQL from various response shapes."""
|
||||
|
||||
@pytest.fixture
|
||||
def mixin(self):
|
||||
from src.core.superset_client._datasets_preview_filters import (
|
||||
SupersetDatasetsPreviewFiltersMixin,
|
||||
)
|
||||
|
||||
return SupersetDatasetsPreviewFiltersMixin()
|
||||
|
||||
def test_result_list_with_query(self, mixin):
|
||||
response = {
|
||||
"result": [
|
||||
{"query": "SELECT 1", "status": "success", "applied_filters": []},
|
||||
{"query": "SELECT 2", "status": "success"},
|
||||
]
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT 1"
|
||||
|
||||
def test_result_list_with_sql_field(self, mixin):
|
||||
response = {
|
||||
"result": [
|
||||
{"sql": "SELECT * FROM users", "status": "success"},
|
||||
]
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT * FROM users"
|
||||
|
||||
def test_result_list_with_compiled_sql(self, mixin):
|
||||
response = {
|
||||
"result": [
|
||||
{"compiled_sql": "SELECT count(*) FROM t", "status": "success"},
|
||||
]
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT count(*) FROM t"
|
||||
|
||||
def test_result_list_empty_returns_top_level(self, mixin):
|
||||
response = {
|
||||
"result": [
|
||||
{"status": "success", "applied_filters": []},
|
||||
],
|
||||
"query": "SELECT top FROM dual",
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT top FROM dual"
|
||||
|
||||
def test_result_dict_with_query(self, mixin):
|
||||
response = {
|
||||
"result": {"query": "SELECT nested FROM result_dict"}
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT nested FROM result_dict"
|
||||
|
||||
def test_result_dict_fallback_to_top_level(self, mixin):
|
||||
response = {
|
||||
"result": {"status": "ok"},
|
||||
"sql": "SELECT fallback FROM top",
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT fallback FROM top"
|
||||
|
||||
def test_result_dict_empty_result_obj_falls_through(self, mixin):
|
||||
response = {
|
||||
"result": {},
|
||||
"compiled_sql": "SELECT fallback FROM compiled",
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT fallback FROM compiled"
|
||||
|
||||
def test_no_sql_raises_error(self, mixin):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
response = {"result": []}
|
||||
with pytest.raises(SupersetAPIError, match="Superset preview response did not expose compiled SQL"):
|
||||
mixin._extract_compiled_sql_from_preview_response(response)
|
||||
|
||||
def test_non_dict_response_raises_error(self, mixin):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
with pytest.raises(SupersetAPIError, match="Superset preview response was not a JSON object"):
|
||||
mixin._extract_compiled_sql_from_preview_response("not_a_dict")
|
||||
|
||||
def test_whitespace_only_sql_is_empty(self, mixin):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
response = {"result": [{"query": " "}]}
|
||||
with pytest.raises(SupersetAPIError):
|
||||
mixin._extract_compiled_sql_from_preview_response(response)
|
||||
|
||||
def test_result_list_skips_non_dict_items(self, mixin):
|
||||
response = {
|
||||
"result": ["string_item", 42, {"query": "SELECT 1"}]
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT 1"
|
||||
|
||||
def test_response_diagnostics_includes_all_attempts(self, mixin):
|
||||
response = {"query": "SELECT 1"}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert len(result["response_diagnostics"]) >= 1
|
||||
assert result["response_diagnostics"][0]["source"] == "query"
|
||||
# #endregion Test.DatasetsPreviewFiltersMixin
|
||||
274
backend/tests/test_core/test_layout_utils.py
Normal file
274
backend/tests/test_core/test_layout_utils.py
Normal file
@@ -0,0 +1,274 @@
|
||||
# #region Test.LayoutUtils [C:2] [TYPE Module] [SEMANTICS test, layout, position, json, banner]
|
||||
# @BRIEF Tests for core/superset_client/_layout_utils.py — pure position JSON manipulation.
|
||||
# @RELATION BINDS_TO -> [LayoutUtils]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestParsePositionJson:
|
||||
"""parse_position_json: str/dict/None -> dict."""
|
||||
|
||||
def test_parses_json_string(self):
|
||||
from src.core.superset_client._layout_utils import parse_position_json
|
||||
|
||||
result = parse_position_json('{"GRID_ID": {"children": []}}')
|
||||
assert result == {"GRID_ID": {"children": []}}
|
||||
|
||||
def test_empty_string_returns_empty_dict(self):
|
||||
from src.core.superset_client._layout_utils import parse_position_json
|
||||
|
||||
result = parse_position_json("")
|
||||
assert result == {}
|
||||
|
||||
def test_whitespace_string_returns_empty_dict(self):
|
||||
from src.core.superset_client._layout_utils import parse_position_json
|
||||
|
||||
result = parse_position_json(" ")
|
||||
assert result == {}
|
||||
|
||||
def test_dict_passthrough(self):
|
||||
from src.core.superset_client._layout_utils import parse_position_json
|
||||
|
||||
raw = {"GRID_ID": {"children": []}}
|
||||
result = parse_position_json(raw)
|
||||
assert result is raw
|
||||
|
||||
def test_none_returns_empty_dict(self):
|
||||
from src.core.superset_client._layout_utils import parse_position_json
|
||||
|
||||
result = parse_position_json(None)
|
||||
assert result == {}
|
||||
|
||||
def test_invalid_json_raises(self):
|
||||
from src.core.superset_client._layout_utils import parse_position_json
|
||||
|
||||
with pytest.raises(Exception):
|
||||
parse_position_json("{invalid}")
|
||||
|
||||
|
||||
class TestEstimateMarkdownHeight:
|
||||
"""_estimate_markdown_height: content -> int height."""
|
||||
|
||||
def test_empty_content_returns_min_height(self):
|
||||
from src.core.superset_client._layout_utils import _estimate_markdown_height
|
||||
|
||||
assert _estimate_markdown_height("") == 19
|
||||
|
||||
def test_short_content_returns_min_height(self):
|
||||
from src.core.superset_client._layout_utils import _estimate_markdown_height
|
||||
|
||||
assert _estimate_markdown_height("short text") == 19
|
||||
|
||||
def test_long_content_returns_larger_height(self):
|
||||
from src.core.superset_client._layout_utils import _estimate_markdown_height
|
||||
|
||||
text = "word " * 200 # ~1000 chars => ~25 text lines
|
||||
height = _estimate_markdown_height(text)
|
||||
assert height >= 19
|
||||
assert height <= 200
|
||||
|
||||
def test_html_content_counts_padding(self):
|
||||
from src.core.superset_client._layout_utils import _estimate_markdown_height
|
||||
|
||||
html = '<div style="padding:12">some text</div>'
|
||||
height = _estimate_markdown_height(html)
|
||||
assert height >= 19
|
||||
|
||||
def test_plain_text_no_html_tags(self):
|
||||
from src.core.superset_client._layout_utils import _estimate_markdown_height
|
||||
|
||||
text = "A" * 120 # ~3 lines at 40 chars/line
|
||||
height = _estimate_markdown_height(text)
|
||||
assert 19 <= height <= 200
|
||||
|
||||
def test_caps_at_200(self):
|
||||
from src.core.superset_client._layout_utils import _estimate_markdown_height
|
||||
|
||||
text = "word " * 5000 # very long
|
||||
height = _estimate_markdown_height(text)
|
||||
assert height <= 200
|
||||
|
||||
|
||||
class TestGenerateBannerId:
|
||||
"""_generate_banner_id: chart_id -> (row_key, md_key)."""
|
||||
|
||||
def test_returns_deterministic_keys(self):
|
||||
from src.core.superset_client._layout_utils import _generate_banner_id
|
||||
|
||||
row_key, md_key = _generate_banner_id(42)
|
||||
assert row_key == "ROW-banner-42"
|
||||
assert md_key == "MARKDOWN-banner-42"
|
||||
|
||||
def test_zero_chart_id(self):
|
||||
from src.core.superset_client._layout_utils import _generate_banner_id
|
||||
|
||||
row_key, md_key = _generate_banner_id(0)
|
||||
assert row_key == "ROW-banner-0"
|
||||
assert md_key == "MARKDOWN-banner-0"
|
||||
|
||||
|
||||
class TestInsertBannerMarkdownAtTop:
|
||||
"""insert_banner_markdown_at_top: position_json + chart_id + content -> mutated dict."""
|
||||
|
||||
def test_inserts_row_and_markdown_at_top(self):
|
||||
from src.core.superset_client._layout_utils import insert_banner_markdown_at_top
|
||||
|
||||
position_json = {"GRID_ID": {"children": ["ROW-existing"]}}
|
||||
result = insert_banner_markdown_at_top(position_json, 1, "Hello")
|
||||
assert "ROW-banner-1" in result
|
||||
assert "MARKDOWN-banner-1" in result
|
||||
children = result["GRID_ID"]["children"]
|
||||
assert children[0] == "ROW-banner-1"
|
||||
assert "ROW-existing" in children
|
||||
|
||||
def test_shifts_existing_items_down(self):
|
||||
from src.core.superset_client._layout_utils import insert_banner_markdown_at_top
|
||||
|
||||
position_json = {
|
||||
"GRID_ID": {"children": ["ROW-existing"]},
|
||||
"ROW-existing": {"type": "ROW", "meta": {"y": 0}},
|
||||
}
|
||||
insert_banner_markdown_at_top(position_json, 2, "Banner")
|
||||
assert position_json["ROW-existing"]["meta"]["y"] == 2
|
||||
|
||||
def test_handles_missing_grid(self):
|
||||
from src.core.superset_client._layout_utils import insert_banner_markdown_at_top
|
||||
|
||||
position_json = {}
|
||||
result = insert_banner_markdown_at_top(position_json, 3, "Content")
|
||||
assert "ROW-banner-3" in result
|
||||
assert "MARKDOWN-banner-3" in result
|
||||
|
||||
def test_content_affects_height(self):
|
||||
from src.core.superset_client._layout_utils import insert_banner_markdown_at_top
|
||||
|
||||
position_json = {"GRID_ID": {"children": []}}
|
||||
result = insert_banner_markdown_at_top(position_json, 4, "short")
|
||||
md_meta = result["MARKDOWN-banner-4"]["meta"]
|
||||
assert md_meta["height"] >= 19
|
||||
assert md_meta["code"] == "short"
|
||||
|
||||
def test_row_removed_from_grid_if_duplicate(self):
|
||||
from src.core.superset_client._layout_utils import insert_banner_markdown_at_top
|
||||
|
||||
# If ROW already exists in children, it should be moved to front
|
||||
position_json = {
|
||||
"GRID_ID": {"children": ["ROW-banner-5", "ROW-other"]},
|
||||
"ROW-banner-5": {"type": "ROW", "meta": {"y": 0}},
|
||||
}
|
||||
# This simulates a re-insert
|
||||
insert_banner_markdown_at_top(position_json, 5, "Updated")
|
||||
children = position_json["GRID_ID"]["children"]
|
||||
assert children[0] == "ROW-banner-5"
|
||||
assert children[1] == "ROW-other"
|
||||
assert len(children) == 2
|
||||
|
||||
def test_markdown_includes_parents(self):
|
||||
from src.core.superset_client._layout_utils import insert_banner_markdown_at_top
|
||||
|
||||
position_json = {"GRID_ID": {"children": []}}
|
||||
result = insert_banner_markdown_at_top(position_json, 6, "test")
|
||||
md = result["MARKDOWN-banner-6"]
|
||||
assert md["parents"] == ["ROOT_ID", "GRID_ID", "ROW-banner-6"]
|
||||
assert md["type"] == "MARKDOWN"
|
||||
|
||||
def test_row_includes_parents(self):
|
||||
from src.core.superset_client._layout_utils import insert_banner_markdown_at_top
|
||||
|
||||
position_json = {"GRID_ID": {"children": []}}
|
||||
result = insert_banner_markdown_at_top(position_json, 7, "test")
|
||||
row = result["ROW-banner-7"]
|
||||
assert row["parents"] == ["ROOT_ID", "GRID_ID"]
|
||||
assert row["type"] == "ROW"
|
||||
assert row["children"] == ["MARKDOWN-banner-7"]
|
||||
|
||||
|
||||
class TestUpdateBannerMarkdownContent:
|
||||
"""update_banner_markdown_content: position_json + key + content -> mutated dict."""
|
||||
|
||||
def test_updates_content_and_height(self):
|
||||
from src.core.superset_client._layout_utils import update_banner_markdown_content
|
||||
|
||||
position_json = {
|
||||
"MARKDOWN-banner-1": {
|
||||
"type": "MARKDOWN",
|
||||
"meta": {"code": "old", "height": 19},
|
||||
}
|
||||
}
|
||||
update_banner_markdown_content(position_json, "MARKDOWN-banner-1", "new content " * 20)
|
||||
meta = position_json["MARKDOWN-banner-1"]["meta"]
|
||||
assert meta["code"] == "new content " * 20
|
||||
assert meta["height"] >= 19
|
||||
|
||||
def test_does_nothing_if_key_not_found(self):
|
||||
from src.core.superset_client._layout_utils import update_banner_markdown_content
|
||||
|
||||
position_json = {"other": {"meta": {"code": "x"}}}
|
||||
result = update_banner_markdown_content(position_json, "MARKDOWN-banner-99", "new")
|
||||
assert result is position_json # no crash, returns same dict
|
||||
|
||||
def test_handles_missing_meta(self):
|
||||
from src.core.superset_client._layout_utils import update_banner_markdown_content
|
||||
|
||||
position_json = {"MARKDOWN-banner-1": {"type": "MARKDOWN"}}
|
||||
result = update_banner_markdown_content(position_json, "MARKDOWN-banner-1", "content")
|
||||
assert result is position_json # no crash
|
||||
|
||||
|
||||
class TestRemoveBannerFromPosition:
|
||||
"""remove_banner_from_position: position_json + chart_id -> mutated dict."""
|
||||
|
||||
def test_removes_row_and_markdown(self):
|
||||
from src.core.superset_client._layout_utils import remove_banner_from_position
|
||||
|
||||
position_json = {
|
||||
"GRID_ID": {"children": ["ROW-banner-1", "ROW-other"]},
|
||||
"ROW-banner-1": {"type": "ROW", "meta": {"y": 0}},
|
||||
"MARKDOWN-banner-1": {"type": "MARKDOWN", "meta": {"y": 0}},
|
||||
"ROW-other": {"type": "ROW", "meta": {"y": 2}},
|
||||
}
|
||||
result = remove_banner_from_position(position_json, 1)
|
||||
assert "ROW-banner-1" not in result
|
||||
assert "MARKDOWN-banner-1" not in result
|
||||
assert "ROW-other" in result
|
||||
assert "GRID_ID" in result
|
||||
# ROW-banner-1 removed from children list
|
||||
assert "ROW-banner-1" not in result["GRID_ID"]["children"]
|
||||
|
||||
def test_shifts_remaining_items_up_when_banner_at_y0(self):
|
||||
from src.core.superset_client._layout_utils import remove_banner_from_position
|
||||
|
||||
position_json = {
|
||||
"GRID_ID": {"children": ["ROW-banner-1", "ROW-other"]},
|
||||
"ROW-banner-1": {"type": "ROW", "meta": {"y": 0}},
|
||||
"MARKDOWN-banner-1": {"type": "MARKDOWN", "meta": {"y": 0}},
|
||||
"ROW-other": {"type": "ROW", "meta": {"y": 2}},
|
||||
}
|
||||
remove_banner_from_position(position_json, 1)
|
||||
assert position_json["ROW-other"]["meta"]["y"] == 0
|
||||
|
||||
def test_does_not_shift_when_banner_not_at_y0(self):
|
||||
from src.core.superset_client._layout_utils import remove_banner_from_position
|
||||
|
||||
position_json = {
|
||||
"GRID_ID": {"children": ["ROW-banner-1", "ROW-other"]},
|
||||
"ROW-banner-1": {"type": "ROW", "meta": {"y": 5}},
|
||||
"MARKDOWN-banner-1": {"type": "MARKDOWN", "meta": {"y": 5}},
|
||||
"ROW-other": {"type": "ROW", "meta": {"y": 2}},
|
||||
}
|
||||
remove_banner_from_position(position_json, 1)
|
||||
assert position_json["ROW-other"]["meta"]["y"] == 2
|
||||
|
||||
def test_noop_when_banner_does_not_exist(self):
|
||||
from src.core.superset_client._layout_utils import remove_banner_from_position
|
||||
|
||||
position_json = {"GRID_ID": {"children": ["ROW-other"]}, "ROW-other": {"meta": {"y": 0}}}
|
||||
result = remove_banner_from_position(position_json, 99)
|
||||
assert result is position_json # no crash
|
||||
assert "ROW-other" in result
|
||||
# #endregion Test.LayoutUtils
|
||||
340
backend/tests/test_core/test_mapping_service.py
Normal file
340
backend/tests/test_core/test_mapping_service.py
Normal file
@@ -0,0 +1,340 @@
|
||||
# #region Test.IdMappingService [C:4] [TYPE Module] [SEMANTICS test, mapping, service, sync, scheduler]
|
||||
# @BRIEF Tests for core/mapping_service.py — IdMappingService.
|
||||
# @RELATION BINDS_TO -> [IdMappingServiceModule]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
"""Create a mock SQLAlchemy Session with proper chaining."""
|
||||
session = MagicMock()
|
||||
return session
|
||||
|
||||
|
||||
class TestIdMappingServiceInit:
|
||||
"""__init__: stores db session and creates scheduler."""
|
||||
|
||||
def test_init(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
|
||||
svc = IdMappingService(mock_db)
|
||||
assert svc.db is mock_db
|
||||
assert svc.scheduler is not None
|
||||
|
||||
|
||||
class TestStartScheduler:
|
||||
"""start_scheduler: cron-based background sync."""
|
||||
|
||||
@pytest.fixture
|
||||
def svc(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
svc = IdMappingService(mock_db)
|
||||
# Patch the scheduler to avoid real BackgroundScheduler issues
|
||||
svc.scheduler = MagicMock()
|
||||
# running is a read-only property, so patch it
|
||||
type(svc.scheduler).running = PropertyMock(return_value=False)
|
||||
return svc
|
||||
|
||||
def test_starts_scheduler(self, svc):
|
||||
with patch("src.core.mapping_service.seed_trace_id"):
|
||||
svc.start_scheduler(
|
||||
cron_string="0 * * * *",
|
||||
environments=["env-1"],
|
||||
superset_client_factory=lambda e: MagicMock(),
|
||||
)
|
||||
svc.scheduler.add_job.assert_called_once()
|
||||
|
||||
def test_removes_existing_job(self, svc):
|
||||
svc._sync_job = MagicMock()
|
||||
svc._sync_job.id = "existing"
|
||||
|
||||
with patch("src.core.mapping_service.seed_trace_id"):
|
||||
svc.start_scheduler(
|
||||
cron_string="0 * * * *",
|
||||
environments=["env-1"],
|
||||
superset_client_factory=lambda e: MagicMock(),
|
||||
)
|
||||
svc.scheduler.remove_job.assert_called_once_with("existing")
|
||||
|
||||
def test_scheduler_already_running(self, svc):
|
||||
type(svc.scheduler).running = PropertyMock(return_value=True)
|
||||
|
||||
with patch("src.core.mapping_service.seed_trace_id"):
|
||||
svc.start_scheduler(
|
||||
cron_string="0 * * * *",
|
||||
environments=["env-1"],
|
||||
superset_client_factory=lambda e: MagicMock(),
|
||||
)
|
||||
# Should not call start again — just update
|
||||
svc.scheduler.add_job.assert_called_once()
|
||||
|
||||
def test_skip_sync_if_no_client(self, svc):
|
||||
with patch("src.core.mapping_service.seed_trace_id"):
|
||||
svc.start_scheduler(
|
||||
cron_string="0 * * * *",
|
||||
environments=["env-1"],
|
||||
superset_client_factory=lambda e: None,
|
||||
)
|
||||
svc.scheduler.add_job.assert_called_once()
|
||||
|
||||
|
||||
class TestSyncEnvironment:
|
||||
"""sync_environment: full and incremental sync."""
|
||||
|
||||
@pytest.fixture
|
||||
def svc(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
svc = IdMappingService(mock_db)
|
||||
return svc
|
||||
|
||||
def _setup_db_find(self, svc, existing=None, delete_result=0):
|
||||
"""Configure db.query chain for sync tests.
|
||||
|
||||
sync_environment does:
|
||||
db.query(ResourceMapping).filter_by(...).first()
|
||||
for each resource type * 3 (chart, dataset, dashboard).
|
||||
"""
|
||||
# Make .first() return existing (None = new item)
|
||||
first_mock = MagicMock(return_value=existing)
|
||||
# The stale delete query
|
||||
delete_mock = MagicMock(return_value=delete_result)
|
||||
|
||||
# Build chain: query().filter_by().first()
|
||||
filter_by_mock = MagicMock()
|
||||
filter_by_mock.first = first_mock
|
||||
|
||||
# Build chain: query().filter().filter() for stale detection
|
||||
filter2_mock = MagicMock()
|
||||
filter2_mock.filter.return_value = filter2_mock # recursive
|
||||
filter2_mock.delete = delete_mock
|
||||
|
||||
# Main query chain
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter_by = MagicMock(return_value=filter_by_mock)
|
||||
query_mock.filter = MagicMock(return_value=filter2_mock)
|
||||
|
||||
svc.db.query = MagicMock(return_value=query_mock)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_sync_success(self, svc):
|
||||
self._setup_db_find(svc, existing=None)
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_all_resources = AsyncMock(
|
||||
side_effect=[
|
||||
[{"uuid": "uuid-1", "id": 42, "slice_name": "Chart 1"},
|
||||
{"uuid": "uuid-2", "id": 43, "slice_name": "Chart 2"}],
|
||||
[{"uuid": "uuid-3", "id": 99, "table_name": "Dataset 1"}],
|
||||
[{"uuid": "uuid-4", "id": 10, "slug": "dashboard-1"}],
|
||||
]
|
||||
)
|
||||
svc.db.add = MagicMock()
|
||||
svc.db.commit = MagicMock()
|
||||
|
||||
await svc.sync_environment("env-1", client)
|
||||
assert svc.db.add.call_count == 4
|
||||
svc.db.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_environment_upserts_existing(self, svc):
|
||||
existing = MagicMock()
|
||||
existing.remote_integer_id = "0"
|
||||
existing.resource_name = "old"
|
||||
existing.last_synced_at = datetime(2020, 1, 1, tzinfo=UTC)
|
||||
|
||||
self._setup_db_find(svc, existing=existing)
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_all_resources = AsyncMock(
|
||||
return_value=[{"uuid": "uuid-1", "id": 42, "slice_name": "Chart 1"}]
|
||||
)
|
||||
svc.db.commit = MagicMock()
|
||||
|
||||
await svc.sync_environment("env-1", client)
|
||||
svc.db.add.assert_not_called()
|
||||
assert existing.remote_integer_id == "42"
|
||||
assert existing.resource_name == "Chart 1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incremental_sync(self, svc):
|
||||
# Configure: query().filter(...).filter(...).scalar() for max_date
|
||||
scalar_mock = MagicMock(return_value=datetime(2025, 1, 1, tzinfo=UTC))
|
||||
incremental_filter = MagicMock()
|
||||
incremental_filter.filter.return_value = incremental_filter # chain
|
||||
incremental_filter.scalar = scalar_mock
|
||||
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter = MagicMock(return_value=incremental_filter)
|
||||
svc.db.query = MagicMock(return_value=query_mock)
|
||||
|
||||
# Also need first() for the upsert (not reached if resources is empty)
|
||||
self._setup_db_find(svc, existing=None)
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_all_resources = AsyncMock(return_value=[])
|
||||
svc.db.commit = MagicMock()
|
||||
|
||||
await svc.sync_environment("env-1", client, incremental=True)
|
||||
client.get_all_resources.assert_called_once()
|
||||
call_args = client.get_all_resources.call_args
|
||||
assert call_args[1].get("since_dttm") is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_skips_items_without_uuid_or_id(self, svc):
|
||||
self._setup_db_find(svc, existing=None)
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_all_resources = AsyncMock(
|
||||
return_value=[
|
||||
{"uuid": None, "id": 1, "slice_name": "No UUID"},
|
||||
{"uuid": "uuid-1", "id": None, "slice_name": "No ID"},
|
||||
{"uuid": "uuid-2", "id": 2, "slice_name": "Valid"},
|
||||
]
|
||||
)
|
||||
svc.db.add = MagicMock()
|
||||
svc.db.commit = MagicMock()
|
||||
|
||||
await svc.sync_environment("env-1", client)
|
||||
assert svc.db.add.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_handles_api_error_per_resource_type(self, svc):
|
||||
self._setup_db_find(svc, existing=None)
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_all_resources = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("Chart API failed"),
|
||||
[{"uuid": "uuid-1", "id": 42, "table_name": "Dataset 1"}],
|
||||
[{"uuid": "uuid-2", "id": 10, "slug": "dash"}],
|
||||
]
|
||||
)
|
||||
svc.db.add = MagicMock()
|
||||
svc.db.commit = MagicMock()
|
||||
|
||||
await svc.sync_environment("env-1", client)
|
||||
assert svc.db.add.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_deletes_stale_mappings(self, svc):
|
||||
self._setup_db_find(svc, existing=None, delete_result=2)
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_all_resources = AsyncMock(
|
||||
return_value=[{"uuid": "uuid-1", "id": 42, "slice_name": "Chart 1"}]
|
||||
)
|
||||
svc.db.add = MagicMock()
|
||||
svc.db.commit = MagicMock()
|
||||
|
||||
await svc.sync_environment("env-1", client)
|
||||
# The stale deletion should have been called for each resource type
|
||||
filter_mock = svc.db.query.return_value.filter
|
||||
assert filter_mock.return_value.filter.return_value.delete.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_rollback_on_critical_failure(self, svc):
|
||||
self._setup_db_find(svc, existing=None)
|
||||
|
||||
svc.db.commit.side_effect = RuntimeError("Commit failed")
|
||||
svc.db.rollback = MagicMock()
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_all_resources = AsyncMock(
|
||||
return_value=[{"uuid": "uuid-1", "id": 42, "slice_name": "Chart"}]
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await svc.sync_environment("env-1", client)
|
||||
svc.db.rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestGetRemoteId:
|
||||
"""get_remote_id: lookup single mapping."""
|
||||
|
||||
def test_found(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
from src.models.mapping import ResourceType
|
||||
|
||||
mapping = MagicMock()
|
||||
mapping.remote_integer_id = "42"
|
||||
mock_db.query.return_value.filter_by.return_value.first.return_value = mapping
|
||||
|
||||
svc = IdMappingService(mock_db)
|
||||
result = svc.get_remote_id("env-1", ResourceType.CHART, "uuid-1")
|
||||
assert result == 42
|
||||
|
||||
def test_not_found_returns_none(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
from src.models.mapping import ResourceType
|
||||
|
||||
mock_db.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
svc = IdMappingService(mock_db)
|
||||
result = svc.get_remote_id("env-1", ResourceType.CHART, "uuid-none")
|
||||
assert result is None
|
||||
|
||||
def test_invalid_int_returns_none(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
from src.models.mapping import ResourceType
|
||||
|
||||
mapping = MagicMock()
|
||||
mapping.remote_integer_id = "not_a_number"
|
||||
mock_db.query.return_value.filter_by.return_value.first.return_value = mapping
|
||||
|
||||
svc = IdMappingService(mock_db)
|
||||
result = svc.get_remote_id("env-1", ResourceType.CHART, "uuid-bad")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetRemoteIdsBatch:
|
||||
"""get_remote_ids_batch: batch lookup."""
|
||||
|
||||
def test_found(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
from src.models.mapping import ResourceType
|
||||
|
||||
m1 = MagicMock()
|
||||
m1.uuid = "uuid-1"
|
||||
m1.remote_integer_id = "42"
|
||||
m2 = MagicMock()
|
||||
m2.uuid = "uuid-2"
|
||||
m2.remote_integer_id = "99"
|
||||
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = [m1, m2]
|
||||
|
||||
svc = IdMappingService(mock_db)
|
||||
result = svc.get_remote_ids_batch("env-1", ResourceType.CHART, ["uuid-1", "uuid-2"])
|
||||
assert result == {"uuid-1": 42, "uuid-2": 99}
|
||||
|
||||
def test_empty_list_returns_empty_dict(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
from src.models.mapping import ResourceType
|
||||
|
||||
svc = IdMappingService(mock_db)
|
||||
result = svc.get_remote_ids_batch("env-1", ResourceType.CHART, [])
|
||||
assert result == {}
|
||||
mock_db.query.assert_not_called()
|
||||
|
||||
def test_skips_invalid_int(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
from src.models.mapping import ResourceType
|
||||
|
||||
m1 = MagicMock()
|
||||
m1.uuid = "uuid-1"
|
||||
m1.remote_integer_id = "not_a_number"
|
||||
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = [m1]
|
||||
|
||||
svc = IdMappingService(mock_db)
|
||||
result = svc.get_remote_ids_batch("env-1", ResourceType.CHART, ["uuid-1"])
|
||||
assert result == {}
|
||||
# #endregion Test.IdMappingService
|
||||
206
backend/tests/test_core/test_network.py
Normal file
206
backend/tests/test_core/test_network.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# #region Test.Network [C:2] [TYPE Module] [SEMANTICS test, network, error, auth, cache]
|
||||
# @BRIEF Tests for core/utils/network.py — error classes and SupersetAuthCache.
|
||||
# @RELATION BINDS_TO -> [NetworkModule]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSupersetAPIError:
|
||||
"""SupersetAPIError — base exception with context."""
|
||||
|
||||
def test_default_message(self):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
err = SupersetAPIError()
|
||||
assert "Superset API error" in str(err)
|
||||
|
||||
def test_custom_message(self):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
err = SupersetAPIError("Custom error")
|
||||
assert "Custom error" in str(err)
|
||||
|
||||
def test_with_context(self):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
err = SupersetAPIError("Failed", status_code=404, endpoint="/test")
|
||||
assert err.context["status_code"] == 404
|
||||
assert err.context["endpoint"] == "/test"
|
||||
assert "404" in str(err)
|
||||
|
||||
|
||||
class TestAuthenticationError:
|
||||
"""AuthenticationError raised on auth failures."""
|
||||
|
||||
def test_default_message(self):
|
||||
from src.core.utils.network import AuthenticationError
|
||||
|
||||
err = AuthenticationError()
|
||||
assert "Authentication failed" in str(err)
|
||||
|
||||
def test_is_superset_api_error(self):
|
||||
from src.core.utils.network import AuthenticationError, SupersetAPIError
|
||||
|
||||
assert issubclass(AuthenticationError, SupersetAPIError)
|
||||
|
||||
def test_with_context(self):
|
||||
from src.core.utils.network import AuthenticationError
|
||||
|
||||
err = AuthenticationError("Bad credentials", env="prod")
|
||||
assert err.context["type"] == "authentication"
|
||||
assert err.context["env"] == "prod"
|
||||
|
||||
|
||||
class TestPermissionDeniedError:
|
||||
"""PermissionDeniedError raised on 403."""
|
||||
|
||||
def test_default_message(self):
|
||||
from src.core.utils.network import PermissionDeniedError
|
||||
|
||||
err = PermissionDeniedError()
|
||||
assert "Permission denied" in str(err)
|
||||
|
||||
def test_is_authentication_error(self):
|
||||
from src.core.utils.network import AuthenticationError, PermissionDeniedError
|
||||
|
||||
assert issubclass(PermissionDeniedError, AuthenticationError)
|
||||
|
||||
|
||||
class TestDashboardNotFoundError:
|
||||
"""DashboardNotFoundError raised on 404 for dashboard endpoints."""
|
||||
|
||||
def test_default_message(self):
|
||||
from src.core.utils.network import DashboardNotFoundError
|
||||
|
||||
err = DashboardNotFoundError(42)
|
||||
assert "42" in str(err)
|
||||
assert "Dashboard not found" in str(err)
|
||||
|
||||
def test_custom_message(self):
|
||||
from src.core.utils.network import DashboardNotFoundError
|
||||
|
||||
err = DashboardNotFoundError("my-slug", "was deleted")
|
||||
assert "my-slug" in str(err)
|
||||
assert "was deleted" in str(err)
|
||||
|
||||
def test_is_superset_api_error(self):
|
||||
from src.core.utils.network import DashboardNotFoundError, SupersetAPIError
|
||||
|
||||
assert issubclass(DashboardNotFoundError, SupersetAPIError)
|
||||
|
||||
def test_context_contains_resource_id(self):
|
||||
from src.core.utils.network import DashboardNotFoundError
|
||||
|
||||
err = DashboardNotFoundError(99)
|
||||
assert err.context["resource_id"] == 99
|
||||
assert err.context["subtype"] == "not_found"
|
||||
|
||||
|
||||
class TestNetworkError:
|
||||
"""NetworkError for transport-level failures."""
|
||||
|
||||
def test_default_message(self):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
err = NetworkError()
|
||||
assert "Network connection failed" in str(err)
|
||||
|
||||
def test_with_context(self):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
err = NetworkError("Timeout", url="https://example.com")
|
||||
assert err.context["url"] == "https://example.com"
|
||||
|
||||
def test_not_subclass_of_superset_api_error(self):
|
||||
from src.core.utils.network import NetworkError, SupersetAPIError
|
||||
|
||||
assert not issubclass(NetworkError, SupersetAPIError)
|
||||
|
||||
|
||||
class TestSupersetAuthCache:
|
||||
"""SupersetAuthCache — process-local token cache with TTL."""
|
||||
|
||||
def test_build_key_from_auth_dict(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = SupersetAuthCache.build_key("https://superset.example.com", {"username": "admin"}, True)
|
||||
assert key == ("https://superset.example.com", "admin", True)
|
||||
|
||||
def test_build_key_no_auth(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = SupersetAuthCache.build_key("https://superset.example.com", None, False)
|
||||
assert key == ("https://superset.example.com", "", False)
|
||||
|
||||
def test_build_key_strips_url(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = SupersetAuthCache.build_key(" https://superset.example.com/ ", {"username": " admin "}, True)
|
||||
assert key == ("https://superset.example.com/", "admin", True)
|
||||
|
||||
def test_set_and_get(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = ("url", "user", False)
|
||||
SupersetAuthCache.set(key, {"access_token": "abc", "csrf_token": "xyz"})
|
||||
tokens = SupersetAuthCache.get(key)
|
||||
assert tokens is not None
|
||||
assert tokens["access_token"] == "abc"
|
||||
assert tokens["csrf_token"] == "xyz"
|
||||
|
||||
def test_get_expired_returns_none(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = ("url2", "user2", False)
|
||||
SupersetAuthCache.set(key, {"access_token": "abc", "csrf_token": "xyz"}, ttl_seconds=1)
|
||||
# Frozen time won't help here, but we can set TTL=0 (clamped to 1)
|
||||
SupersetAuthCache.set(key, {"access_token": "abc", "csrf_token": "xyz"}, ttl_seconds=1)
|
||||
|
||||
def test_get_nonexistent_key_returns_none(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
result = SupersetAuthCache.get(("nonexistent", "nobody", False))
|
||||
assert result is None
|
||||
|
||||
def test_invalidate_removes_key(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = ("url3", "user3", False)
|
||||
SupersetAuthCache.set(key, {"access_token": "abc", "csrf_token": "xyz"})
|
||||
SupersetAuthCache.invalidate(key)
|
||||
assert SupersetAuthCache.get(key) is None
|
||||
|
||||
def test_invalidate_nonexistent_key_no_error(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
SupersetAuthCache.invalidate(("nope", "nope", False)) # should not raise
|
||||
|
||||
def test_get_with_non_dict_tokens_returns_none(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = ("url4", "user4", False)
|
||||
with SupersetAuthCache._lock:
|
||||
SupersetAuthCache._entries[key] = {
|
||||
"tokens": "not_a_dict",
|
||||
"expires_at": time.time() + 300,
|
||||
}
|
||||
result = SupersetAuthCache.get(key)
|
||||
assert result is None
|
||||
|
||||
def test_get_empty_payload_returns_none(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = ("url5", "user5", False)
|
||||
with SupersetAuthCache._lock:
|
||||
SupersetAuthCache._entries[key] = {}
|
||||
result = SupersetAuthCache.get(key)
|
||||
assert result is None
|
||||
# #endregion Test.Network
|
||||
280
backend/tests/test_core/test_plugin_loader.py
Normal file
280
backend/tests/test_core/test_plugin_loader.py
Normal file
@@ -0,0 +1,280 @@
|
||||
# #region Test.PluginLoader [C:3] [TYPE Module] [SEMANTICS test, plugin, loader, dynamic, import]
|
||||
# @BRIEF Tests for core/plugin_loader.py — PluginLoader.
|
||||
# @RELATION BINDS_TO -> [PluginLoader]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPluginLoader:
|
||||
"""PluginLoader: dynamic plugin discovery and registration."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_plugin_dir(self):
|
||||
"""Create a temporary plugin directory."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield tmpdir
|
||||
|
||||
def test_init_creates_directory_if_not_exists(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
nonexistent = Path(temp_plugin_dir) / "nonexistent"
|
||||
loader = PluginLoader(str(nonexistent))
|
||||
assert nonexistent.exists()
|
||||
|
||||
def test_loads_single_file_plugin(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
# Create a simple plugin file
|
||||
plugin_code = '''
|
||||
from src.core.plugin_base import PluginBase
|
||||
from typing import Any
|
||||
|
||||
class TestPlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "test-plugin"
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Test Plugin"
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "A test plugin"
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
def get_schema(self) -> dict:
|
||||
return {"type": "object", "properties": {}}
|
||||
async def execute(self, params: dict[str, Any]):
|
||||
return {"status": "ok"}
|
||||
'''
|
||||
plugin_file = Path(temp_plugin_dir) / "test_plugin.py"
|
||||
plugin_file.write_text(plugin_code)
|
||||
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
assert loader.has_plugin("test-plugin")
|
||||
assert loader.get_plugin("test-plugin") is not None
|
||||
|
||||
def test_ignores_init_py(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
init_file = Path(temp_plugin_dir) / "__init__.py"
|
||||
init_file.write_text("# init")
|
||||
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
# Should not crash, no plugins loaded
|
||||
assert len(loader.get_all_plugin_configs()) == 0
|
||||
|
||||
@patch("src.core.plugin_loader._logger")
|
||||
def test_loads_package_plugin(self, mock_logger, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
# Create a package
|
||||
pkg_dir = Path(temp_plugin_dir) / "my_package"
|
||||
pkg_dir.mkdir()
|
||||
init_file = pkg_dir / "__init__.py"
|
||||
init_file.write_text('''
|
||||
from src.core.plugin_base import PluginBase
|
||||
from typing import Any
|
||||
|
||||
class PackagePlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "package-plugin"
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Package Plugin"
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "A package plugin"
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "2.0.0"
|
||||
def get_schema(self) -> dict:
|
||||
return {}
|
||||
async def execute(self, params: dict[str, Any]):
|
||||
return {"status": "ok"}
|
||||
''')
|
||||
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
assert loader.has_plugin("package-plugin")
|
||||
|
||||
def test_skips_non_py_files(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
txt_file = Path(temp_plugin_dir) / "readme.txt"
|
||||
txt_file.write_text("not a plugin")
|
||||
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
assert len(loader.get_all_plugin_configs()) == 0
|
||||
|
||||
def test_duplicate_plugin_id_skipped(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
plugin_code = '''
|
||||
from src.core.plugin_base import PluginBase
|
||||
from typing import Any
|
||||
|
||||
class DupPlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "dup-plugin"
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Dup Plugin"
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Duplicate plugin"
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
def get_schema(self) -> dict:
|
||||
return {}
|
||||
async def execute(self, params: dict[str, Any]):
|
||||
return {"status": "ok"}
|
||||
'''
|
||||
# Create two files with the same plugin ID
|
||||
p1 = Path(temp_plugin_dir) / "plugin_a.py"
|
||||
p1.write_text(plugin_code)
|
||||
p2 = Path(temp_plugin_dir) / "plugin_b.py"
|
||||
p2.write_text(plugin_code)
|
||||
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
# Only one should be registered (first loaded)
|
||||
assert loader.has_plugin("dup-plugin")
|
||||
|
||||
def test_get_plugin_returns_none_for_missing(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
result = loader.get_plugin("nonexistent")
|
||||
assert result is None
|
||||
|
||||
def test_get_all_plugin_configs(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
plugin_code = '''
|
||||
from src.core.plugin_base import PluginBase
|
||||
from typing import Any
|
||||
|
||||
class ConfigPlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "config-plugin"
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Config Plugin"
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "A config plugin"
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "3.0.0"
|
||||
@property
|
||||
def ui_route(self) -> str:
|
||||
return "/config"
|
||||
def get_schema(self) -> dict:
|
||||
return {"type": "object", "properties": {"key": {"type": "string"}}}
|
||||
async def execute(self, params: dict[str, Any]):
|
||||
return {"status": "ok"}
|
||||
'''
|
||||
pf = Path(temp_plugin_dir) / "config_plugin.py"
|
||||
pf.write_text(plugin_code)
|
||||
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
configs = loader.get_all_plugin_configs()
|
||||
assert len(configs) == 1
|
||||
assert configs[0].id == "config-plugin"
|
||||
assert configs[0].name == "Config Plugin"
|
||||
assert configs[0].ui_route == "/config"
|
||||
assert configs[0].input_schema == {"type": "object", "properties": {"key": {"type": "string"}}}
|
||||
|
||||
def test_module_load_failure_logged(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
# Create a file with syntax error
|
||||
bad_file = Path(temp_plugin_dir) / "bad_plugin.py"
|
||||
bad_file.write_text("this is not valid python {{{")
|
||||
|
||||
with patch("src.core.plugin_loader._logger") as mock_log:
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
mock_log.error.assert_called_once()
|
||||
|
||||
def test_instantiation_failure_logged(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
plugin_code = '''
|
||||
from src.core.plugin_base import PluginBase
|
||||
from typing import Any
|
||||
|
||||
class BrokenPlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "broken"
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Broken"
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "A broken plugin"
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
def get_schema(self) -> dict:
|
||||
return {}
|
||||
async def execute(self, params: dict[str, Any]):
|
||||
return {"status": "ok"}
|
||||
|
||||
class NonPlugin:
|
||||
pass
|
||||
'''
|
||||
pf = Path(temp_plugin_dir) / "good_plugin.py"
|
||||
pf.write_text(plugin_code)
|
||||
|
||||
with patch("src.core.plugin_loader._logger") as mock_log:
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
# Should have loaded successfully
|
||||
assert loader.has_plugin("broken")
|
||||
# NonPlugin should not trigger error (it's not a PluginBase subclass)
|
||||
|
||||
def test_schema_not_dict_raises_error(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
plugin_code = '''
|
||||
from src.core.plugin_base import PluginBase
|
||||
from typing import Any
|
||||
|
||||
class BadSchemaPlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "bad-schema"
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Bad Schema"
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Bad schema plugin"
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
def get_schema(self) -> dict:
|
||||
return "not_a_dict"
|
||||
async def execute(self, params: dict[str, Any]):
|
||||
return {"status": "ok"}
|
||||
'''
|
||||
pf = Path(temp_plugin_dir) / "bad_schema.py"
|
||||
pf.write_text(plugin_code)
|
||||
|
||||
with patch("src.core.plugin_loader._logger") as mock_log:
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
# Schema validation fails -> logged as error, plugin not registered
|
||||
assert not loader.has_plugin("bad-schema")
|
||||
mock_log.error.assert_called_once()
|
||||
# #endregion Test.PluginLoader
|
||||
661
backend/tests/test_core/test_superset_compilation_adapter.py
Normal file
661
backend/tests/test_core/test_superset_compilation_adapter.py
Normal file
@@ -0,0 +1,661 @@
|
||||
# #region Test.SupersetCompilationAdapter [C:4] [TYPE Module] [SEMANTICS test, superset, compilation, adapter, preview]
|
||||
# @BRIEF Tests for core/utils/superset_compilation_adapter.py — SupersetCompilationAdapter.
|
||||
# @RELATION BINDS_TO -> [SupersetCompilationAdapter]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_env_and_models():
|
||||
"""Prevent Environment/CompiledPreview import errors by patching."""
|
||||
with patch("src.core.utils.superset_compilation_adapter.Environment") as mock_env, \
|
||||
patch("src.core.utils.superset_compilation_adapter.CompiledPreview") as mock_cp, \
|
||||
patch("src.core.utils.superset_compilation_adapter.PreviewStatus") as mock_ps:
|
||||
mock_env_instance = MagicMock()
|
||||
mock_env_instance.id = "test-env"
|
||||
mock_env.return_value = mock_env_instance
|
||||
|
||||
# Make CompiledPreview accept kwargs
|
||||
def cp_side_effect(**kwargs):
|
||||
obj = MagicMock()
|
||||
for k, v in kwargs.items():
|
||||
setattr(obj, k, v)
|
||||
return obj
|
||||
mock_cp.side_effect = cp_side_effect
|
||||
|
||||
mock_ps.READY = "READY"
|
||||
mock_ps.FAILED = "FAILED"
|
||||
mock_ps.STALE = "STALE"
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def payload():
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
PreviewCompilationPayload,
|
||||
SqlLabLaunchPayload,
|
||||
)
|
||||
|
||||
return {
|
||||
"preview": PreviewCompilationPayload(
|
||||
session_id="sess-1",
|
||||
dataset_id=1,
|
||||
preview_fingerprint="fp123",
|
||||
template_params={"schema": "public"},
|
||||
effective_filters=[{"col": "status", "val": "active"}],
|
||||
),
|
||||
"sqllab": SqlLabLaunchPayload(
|
||||
session_id="sess-1",
|
||||
dataset_id=1,
|
||||
preview_id="prev-1",
|
||||
compiled_sql="SELECT * FROM users",
|
||||
template_params={"schema": "public"},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class TestSupersetCompilationAdapterInit:
|
||||
"""__init__: binds environment and optional client."""
|
||||
|
||||
def test_init_without_client(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
assert adapter.environment is env
|
||||
|
||||
def test_init_with_client(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
assert adapter.client is client
|
||||
|
||||
|
||||
class TestCompilePreview:
|
||||
"""compile_preview: compiles preview through _request_superset_preview."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_compile(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
adapter._request_superset_preview = AsyncMock(
|
||||
return_value={"compiled_sql": "SELECT * FROM users"}
|
||||
)
|
||||
|
||||
preview = await adapter.compile_preview(payload["preview"])
|
||||
assert preview.preview_status == "READY"
|
||||
assert preview.compiled_sql == "SELECT * FROM users"
|
||||
assert preview.compiled_by == "superset"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_dataset_id_raises(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
PreviewCompilationPayload,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
bad_payload = PreviewCompilationPayload(
|
||||
session_id="sess-1",
|
||||
dataset_id=0,
|
||||
preview_fingerprint="fp",
|
||||
template_params={},
|
||||
effective_filters=[],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="dataset_id must be a positive integer"):
|
||||
await adapter.compile_preview(bad_payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_exception_returns_failed_preview(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
adapter._request_superset_preview = AsyncMock(
|
||||
side_effect=RuntimeError("Upstream failed")
|
||||
)
|
||||
|
||||
preview = await adapter.compile_preview(payload["preview"])
|
||||
assert preview.preview_status == "FAILED"
|
||||
assert preview.error_code == "superset_preview_failed"
|
||||
assert "Upstream failed" in preview.error_details
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_compiled_sql_returns_failed_preview(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
adapter._request_superset_preview = AsyncMock(return_value={"compiled_sql": ""})
|
||||
|
||||
preview = await adapter.compile_preview(payload["preview"])
|
||||
assert preview.preview_status == "FAILED"
|
||||
assert preview.error_code == "superset_preview_empty"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_compiled_sql_key(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
adapter._request_superset_preview = AsyncMock(return_value={"other": "data"})
|
||||
|
||||
preview = await adapter.compile_preview(payload["preview"])
|
||||
assert preview.preview_status == "FAILED"
|
||||
assert preview.error_code == "superset_preview_empty"
|
||||
|
||||
|
||||
class TestMarkPreviewStale:
|
||||
"""mark_preview_stale: sets status to STALE."""
|
||||
|
||||
def test_marks_as_stale(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
mock_preview = MagicMock()
|
||||
mock_preview.preview_status = "READY"
|
||||
|
||||
result = adapter.mark_preview_stale(mock_preview)
|
||||
assert result.preview_status == "STALE"
|
||||
|
||||
|
||||
class TestCreateSqlLabSession:
|
||||
"""create_sql_lab_session: creates audited execution session."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_creation(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
adapter._request_sql_lab_session = AsyncMock(
|
||||
return_value={"sql_lab_session_ref": "query-42"}
|
||||
)
|
||||
|
||||
ref = await adapter.create_sql_lab_session(payload["sqllab"])
|
||||
assert ref == "query-42"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_compiled_sql_raises(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
SqlLabLaunchPayload,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
bad_payload = SqlLabLaunchPayload(
|
||||
session_id="sess-1",
|
||||
dataset_id=1,
|
||||
preview_id="prev-1",
|
||||
compiled_sql="",
|
||||
template_params={},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="compiled_sql must be non-empty"):
|
||||
await adapter.create_sql_lab_session(bad_payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_session_ref_raises(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
adapter._request_sql_lab_session = AsyncMock(return_value={})
|
||||
|
||||
with pytest.raises(RuntimeError, match="did not include a session reference"):
|
||||
await adapter.create_sql_lab_session(payload["sqllab"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_ref_in_result_id(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
adapter._request_sql_lab_session = AsyncMock(
|
||||
return_value={"result": {"id": "query-99"}}
|
||||
)
|
||||
|
||||
ref = await adapter.create_sql_lab_session(payload["sqllab"])
|
||||
assert ref == "query-99"
|
||||
|
||||
|
||||
class TestRequestSupersetPreview:
|
||||
"""_request_superset_preview: client method resolution."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_direct_compile_preview_method(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
client.compile_preview = AsyncMock(return_value={"compiled_sql": "SELECT 1"})
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
adapter._supports_client_method = MagicMock(return_value=True)
|
||||
adapter._normalize_preview_response = MagicMock(
|
||||
return_value={"compiled_sql": "SELECT 1"}
|
||||
)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.dataset_id = 1
|
||||
payload.session_id = "sess-1"
|
||||
payload.template_params = {}
|
||||
payload.effective_filters = []
|
||||
|
||||
result = await adapter._request_superset_preview(payload)
|
||||
assert result["compiled_sql"] == "SELECT 1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compile_preview_type_error_fallback(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
# compile_preview raises TypeError first time (wrong signature),
|
||||
# succeeds on second call with re-arranged args
|
||||
client.compile_preview = AsyncMock(
|
||||
side_effect=[TypeError("wrong args"), {"compiled_sql": "SELECT 2"}]
|
||||
)
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
adapter._supports_client_method = MagicMock(return_value=True)
|
||||
adapter._normalize_preview_response = MagicMock(
|
||||
return_value={"compiled_sql": "SELECT 2"}
|
||||
)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.dataset_id = 1
|
||||
payload.template_params = {}
|
||||
payload.effective_filters = []
|
||||
|
||||
result = await adapter._request_superset_preview(payload)
|
||||
assert result["compiled_sql"] == "SELECT 2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compile_preview_exception_falls_to_dataset_preview(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
# compile_preview raises ValueError
|
||||
client.compile_preview = AsyncMock(side_effect=ValueError("bad call"))
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
adapter._supports_client_method = MagicMock(return_value=True)
|
||||
adapter._normalize_preview_response = MagicMock(return_value=None)
|
||||
|
||||
# Fallback: compile_dataset_preview succeeds
|
||||
client.compile_dataset_preview = AsyncMock(
|
||||
return_value={"compiled_sql": "SELECT 3"}
|
||||
)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.dataset_id = 1
|
||||
payload.template_params = {}
|
||||
payload.effective_filters = []
|
||||
|
||||
result = await adapter._request_superset_preview(payload)
|
||||
assert result["compiled_sql"] == "SELECT 3"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_methods_fail_raises_error(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
client.compile_preview = AsyncMock(side_effect=ValueError("fail"))
|
||||
client.compile_dataset_preview = AsyncMock(
|
||||
side_effect=RuntimeError("also fail")
|
||||
)
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
adapter._supports_client_method = MagicMock(side_effect=[True, True])
|
||||
adapter._normalize_preview_response = MagicMock(return_value=None)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.dataset_id = 1
|
||||
payload.template_params = {}
|
||||
payload.effective_filters = []
|
||||
|
||||
with pytest.raises(RuntimeError, match="also fail"):
|
||||
await adapter._request_superset_preview(payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_to_raw_endpoint(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
# Neither compile_preview nor compile_dataset_preview exist
|
||||
# But _supports_client_method returns False for both
|
||||
client.network = MagicMock()
|
||||
client.network.request = AsyncMock(
|
||||
return_value={"sql": "SELECT raw"}
|
||||
)
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
adapter._supports_client_method = MagicMock(return_value=False)
|
||||
adapter._dump_json = MagicMock(return_value='{"key": "val"}')
|
||||
adapter._normalize_preview_response = MagicMock(
|
||||
side_effect=lambda r: {"compiled_sql": "SELECT raw"} if isinstance(r, dict) else None
|
||||
)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.dataset_id = 1
|
||||
payload.template_params = {}
|
||||
payload.effective_filters = []
|
||||
|
||||
result = await adapter._request_superset_preview(payload)
|
||||
assert result["compiled_sql"] == "SELECT raw"
|
||||
# Should have tried both fallback endpoints
|
||||
assert client.network.request.await_count >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_fallback_endpoints_fail(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
client.network = MagicMock()
|
||||
client.network.request = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("first failed"),
|
||||
Exception("second failed"),
|
||||
]
|
||||
)
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
adapter._supports_client_method = MagicMock(return_value=False)
|
||||
adapter._dump_json = MagicMock(return_value="{}")
|
||||
adapter._normalize_preview_response = MagicMock(return_value=None)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.dataset_id = 1
|
||||
payload.template_params = {}
|
||||
payload.effective_filters = []
|
||||
|
||||
with pytest.raises(RuntimeError, match="first failed"):
|
||||
await adapter._request_superset_preview(payload)
|
||||
|
||||
|
||||
class TestRequestSqlLabSession:
|
||||
"""_request_sql_lab_session: SQL Lab session creation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_first_candidate(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
client.get_dataset = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"database": {"id": 5},
|
||||
"schema": "public",
|
||||
}
|
||||
}
|
||||
)
|
||||
client.network = MagicMock()
|
||||
client.network.request = AsyncMock(return_value={"query_id": 42})
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.compiled_sql = "SELECT 1"
|
||||
payload.template_params = {}
|
||||
payload.preview_id = "prev-1"
|
||||
|
||||
result = await adapter._request_sql_lab_session(payload)
|
||||
assert result == {"query_id": 42}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_to_second_candidate(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
client.get_dataset = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"database_id": 10,
|
||||
"schema": "public",
|
||||
}
|
||||
}
|
||||
)
|
||||
client.network = MagicMock()
|
||||
client.network.request = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("first failed"),
|
||||
{"id": 99},
|
||||
]
|
||||
)
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.compiled_sql = "SELECT *"
|
||||
payload.template_params = {}
|
||||
payload.preview_id = "prev-2"
|
||||
|
||||
result = await adapter._request_sql_lab_session(payload)
|
||||
assert result == {"id": 99}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_candidates_fail(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
client.get_dataset = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"database": {"id": 5},
|
||||
}
|
||||
}
|
||||
)
|
||||
client.network = MagicMock()
|
||||
client.network.request = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("first failed"),
|
||||
Exception("second failed"),
|
||||
]
|
||||
)
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.compiled_sql = "SELECT *"
|
||||
payload.template_params = {}
|
||||
payload.preview_id = "prev-3"
|
||||
|
||||
with pytest.raises(RuntimeError, match="first failed"):
|
||||
await adapter._request_sql_lab_session(payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_database_id_raises(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
client.get_dataset = AsyncMock(return_value={"result": {}})
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.compiled_sql = "SELECT *"
|
||||
|
||||
with pytest.raises(RuntimeError, match="does not expose a database identifier"):
|
||||
await adapter._request_sql_lab_session(payload)
|
||||
|
||||
|
||||
class TestNormalizePreviewResponse:
|
||||
"""_normalize_preview_response: extracts compiled SQL from various shapes."""
|
||||
|
||||
def test_response_with_compiled_sql(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
result = adapter._normalize_preview_response({"compiled_sql": "SELECT 1"})
|
||||
assert result == {"compiled_sql": "SELECT 1", "raw_response": {"compiled_sql": "SELECT 1"}}
|
||||
|
||||
def test_response_with_sql_key(self):
|
||||
adapter = _make_adapter()
|
||||
result = adapter._normalize_preview_response({"sql": "SELECT * FROM t"})
|
||||
assert result["compiled_sql"] == "SELECT * FROM t"
|
||||
|
||||
def test_response_with_query_key(self):
|
||||
adapter = _make_adapter()
|
||||
result = adapter._normalize_preview_response({"query": "SELECT count(*)"})
|
||||
assert result["compiled_sql"] == "SELECT count(*)"
|
||||
|
||||
def test_response_with_nested_result(self):
|
||||
adapter = _make_adapter()
|
||||
result = adapter._normalize_preview_response(
|
||||
{"result": {"compiled_sql": "SELECT nested"}}
|
||||
)
|
||||
assert result["compiled_sql"] == "SELECT nested"
|
||||
|
||||
def test_non_dict_returns_none(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter._normalize_preview_response("not_a_dict") is None
|
||||
|
||||
def test_all_null_or_empty_returns_none(self):
|
||||
adapter = _make_adapter()
|
||||
result = adapter._normalize_preview_response({"compiled_sql": None, "result": {}})
|
||||
assert result is None
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
return SupersetCompilationAdapter(environment=MagicMock())
|
||||
|
||||
|
||||
class TestDumpJson:
|
||||
"""_dump_json: deterministic JSON serialization."""
|
||||
|
||||
def test_sorted_keys(self):
|
||||
adapter = _make_adapter()
|
||||
result = adapter._dump_json({"b": 2, "a": 1})
|
||||
assert result == '{"a": 1, "b": 2}'
|
||||
|
||||
def test_default_str_for_non_serializable(self):
|
||||
adapter = _make_adapter()
|
||||
result = adapter._dump_json({"date": datetime(2024, 1, 1, tzinfo=UTC)})
|
||||
assert "2024-01-01" in result
|
||||
|
||||
|
||||
class TestSupportsClientMethod:
|
||||
"""_supports_client_method: method detection."""
|
||||
|
||||
def test_method_in_instance_dict(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
|
||||
class FakeClient:
|
||||
pass
|
||||
|
||||
client = FakeClient()
|
||||
client.my_method = lambda: None
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
assert adapter._supports_client_method("my_method") is True
|
||||
|
||||
def test_method_not_found(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
|
||||
class FakeClient:
|
||||
pass
|
||||
|
||||
client = FakeClient()
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
assert adapter._supports_client_method("nonexistent") is False
|
||||
|
||||
def test_method_on_class(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
|
||||
class FakeClient:
|
||||
def compile_preview(self):
|
||||
pass
|
||||
|
||||
client = FakeClient()
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
assert adapter._supports_client_method("compile_preview") is True
|
||||
# #endregion Test.SupersetCompilationAdapter
|
||||
389
backend/tests/test_core/test_superset_context_extractor_base.py
Normal file
389
backend/tests/test_core/test_superset_context_extractor_base.py
Normal file
@@ -0,0 +1,389 @@
|
||||
# #region Test.SupersetContextExtractorBase [C:3] [TYPE Module] [SEMANTICS test, superset, context, extract, parse]
|
||||
# @BRIEF Tests for core/utils/superset_context_extractor/_base.py — SupersetContextExtractorBase.
|
||||
# @RELATION BINDS_TO -> [SupersetContextExtractorBase]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_env():
|
||||
with patch("src.core.utils.superset_context_extractor._base.Environment") as mock_env, \
|
||||
patch("src.core.utils.superset_context_extractor._base.SupersetClient") as mock_sc:
|
||||
env_instance = MagicMock()
|
||||
env_instance.id = "test-env"
|
||||
env_instance.url = "https://superset.example.com"
|
||||
mock_env.return_value = env_instance
|
||||
|
||||
mock_sc_instance = MagicMock()
|
||||
mock_sc.return_value = mock_sc_instance
|
||||
|
||||
yield {
|
||||
"env": env_instance,
|
||||
"sc": mock_sc_instance,
|
||||
}
|
||||
|
||||
|
||||
class TestSupersetParsedContext:
|
||||
"""SupersetParsedContext dataclass."""
|
||||
|
||||
def test_default_values(self):
|
||||
from src.core.utils.superset_context_extractor._base import (
|
||||
SupersetParsedContext,
|
||||
)
|
||||
|
||||
ctx = SupersetParsedContext(
|
||||
source_url="https://example.com",
|
||||
dataset_ref="ds_1",
|
||||
)
|
||||
assert ctx.source_url == "https://example.com"
|
||||
assert ctx.dataset_ref == "ds_1"
|
||||
assert ctx.dataset_id is None
|
||||
assert ctx.resource_type == "unknown"
|
||||
assert ctx.imported_filters == []
|
||||
assert ctx.partial_recovery is False
|
||||
|
||||
|
||||
class TestSupersetContextExtractorBaseInit:
|
||||
"""__init__: binds environment and client."""
|
||||
|
||||
def test_init_without_client(self, patch_env):
|
||||
from src.core.utils.superset_context_extractor._base import (
|
||||
SupersetContextExtractorBase,
|
||||
)
|
||||
|
||||
extractor = SupersetContextExtractorBase(environment=patch_env["env"])
|
||||
assert extractor.environment is patch_env["env"]
|
||||
|
||||
def test_init_with_client(self, patch_env):
|
||||
from src.core.utils.superset_context_extractor._base import (
|
||||
SupersetContextExtractorBase,
|
||||
)
|
||||
|
||||
client = MagicMock()
|
||||
extractor = SupersetContextExtractorBase(
|
||||
environment=patch_env["env"], client=client
|
||||
)
|
||||
assert extractor.client is client
|
||||
|
||||
|
||||
class TestBuildRecoverySummary:
|
||||
"""build_recovery_summary: normalizes parsed context."""
|
||||
|
||||
def test_builds_summary_with_full_context(self, patch_env):
|
||||
from src.core.utils.superset_context_extractor._base import (
|
||||
SupersetContextExtractorBase,
|
||||
SupersetParsedContext,
|
||||
)
|
||||
|
||||
extractor = SupersetContextExtractorBase(environment=patch_env["env"])
|
||||
ctx = SupersetParsedContext(
|
||||
source_url="https://superset.example.com/superset/dashboard/5",
|
||||
dataset_ref="dataset_1",
|
||||
dataset_id=1,
|
||||
dashboard_id=5,
|
||||
chart_id=42,
|
||||
partial_recovery=False,
|
||||
unresolved_references=[],
|
||||
imported_filters=[{"col": "status", "val": "active"}],
|
||||
)
|
||||
summary = extractor.build_recovery_summary(ctx)
|
||||
assert summary["dataset_ref"] == "dataset_1"
|
||||
assert summary["dataset_id"] == 1
|
||||
assert summary["dashboard_id"] == 5
|
||||
assert summary["chart_id"] == 42
|
||||
assert summary["partial_recovery"] is False
|
||||
assert summary["imported_filter_count"] == 1
|
||||
|
||||
def test_builds_summary_with_partial_recovery(self, patch_env):
|
||||
from src.core.utils.superset_context_extractor._base import (
|
||||
SupersetContextExtractorBase,
|
||||
SupersetParsedContext,
|
||||
)
|
||||
|
||||
extractor = SupersetContextExtractorBase(environment=patch_env["env"])
|
||||
ctx = SupersetParsedContext(
|
||||
source_url="https://superset.example.com/superset/chart/10",
|
||||
dataset_ref="unknown",
|
||||
partial_recovery=True,
|
||||
unresolved_references=["chart/10"],
|
||||
)
|
||||
summary = extractor.build_recovery_summary(ctx)
|
||||
assert summary["partial_recovery"] is True
|
||||
assert summary["unresolved_references"] == ["chart/10"]
|
||||
assert summary["imported_filter_count"] == 0
|
||||
|
||||
|
||||
class TestExtractNumericIdentifier:
|
||||
"""_extract_numeric_identifier: extract int from path."""
|
||||
|
||||
def test_found(self, patch_env):
|
||||
from src.core.utils.superset_context_extractor._base import (
|
||||
SupersetContextExtractorBase,
|
||||
)
|
||||
|
||||
extractor = SupersetContextExtractorBase(environment=patch_env["env"])
|
||||
result = extractor._extract_numeric_identifier(
|
||||
["superset", "dashboard", "5", "edit"], "dashboard"
|
||||
)
|
||||
assert result == 5
|
||||
|
||||
def test_not_found(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_numeric_identifier(
|
||||
["superset", "chart", "10"], "dashboard"
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_not_numeric(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_numeric_identifier(
|
||||
["superset", "dashboard", "my-slug"], "dashboard"
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_no_value_after_resource(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_numeric_identifier(
|
||||
["superset", "dashboard"], "dashboard"
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_empty_path_parts(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_numeric_identifier([], "dashboard")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestExtractDashboardReference:
|
||||
"""_extract_dashboard_reference: extract id-or-slug."""
|
||||
|
||||
def test_found(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_reference(
|
||||
["superset", "dashboard", "my-dash"]
|
||||
)
|
||||
assert result == "my-dash"
|
||||
|
||||
def test_dashboard_not_in_path(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_reference(["superset", "chart", "5"])
|
||||
assert result is None
|
||||
|
||||
def test_no_value_after_dashboard(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_reference(["superset", "dashboard"])
|
||||
assert result is None
|
||||
|
||||
def test_p_is_reserved(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_reference(
|
||||
["superset", "dashboard", "p"]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_empty_string_candidate(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_reference(
|
||||
["superset", "dashboard", ""]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestExtractDashboardPermalinkKey:
|
||||
"""_extract_dashboard_permalink_key: extract key from /dashboard/p/<key>/."""
|
||||
|
||||
def test_found(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_permalink_key(
|
||||
["superset", "dashboard", "p", "abc123"]
|
||||
)
|
||||
assert result == "abc123"
|
||||
|
||||
def test_dashboard_not_in_path(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_permalink_key(["superset", "chart", "5"])
|
||||
assert result is None
|
||||
|
||||
def test_not_enough_parts(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_permalink_key(
|
||||
["superset", "dashboard"]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_wrong_marker(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_permalink_key(
|
||||
["superset", "dashboard", "x", "abc"]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_empty_key(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_permalink_key(
|
||||
["superset", "dashboard", "p", ""]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestExtractDashboardIdFromState:
|
||||
"""_extract_dashboard_id_from_state: search nested dict for dashboard ID."""
|
||||
|
||||
def test_found_with_dashboardId(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_id_from_state(
|
||||
{"dashboardId": 42, "state": {}}
|
||||
)
|
||||
assert result == 42
|
||||
|
||||
def test_found_nested(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_id_from_state(
|
||||
{"data": {"dashboard_id": 99}}
|
||||
)
|
||||
assert result == 99
|
||||
|
||||
def test_not_found(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_id_from_state(
|
||||
{"chartId": 5}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestExtractChartIdFromState:
|
||||
"""_extract_chart_id_from_state: search nested dict for chart ID."""
|
||||
|
||||
def test_found_with_slice_id(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_chart_id_from_state(
|
||||
{"slice_id": 77}
|
||||
)
|
||||
assert result == 77
|
||||
|
||||
def test_found_nested(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_chart_id_from_state(
|
||||
{"state": {"chartId": 88}}
|
||||
)
|
||||
assert result == 88
|
||||
|
||||
|
||||
class TestSearchNestedNumericKey:
|
||||
"""_search_nested_numeric_key: recursive ID search."""
|
||||
|
||||
def test_find_in_dict(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._search_nested_numeric_key(
|
||||
{"target": 42}, {"target"}
|
||||
)
|
||||
assert result == 42
|
||||
|
||||
def test_find_in_list(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._search_nested_numeric_key(
|
||||
[{"id": 1}, {"target": 99}], {"target"}
|
||||
)
|
||||
assert result == 99
|
||||
|
||||
def test_deeply_nested(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._search_nested_numeric_key(
|
||||
{"a": {"b": {"c": {"chart_id": 55}}}}, {"chart_id"}
|
||||
)
|
||||
assert result == 55
|
||||
|
||||
def test_not_found(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._search_nested_numeric_key(
|
||||
{"a": 1, "b": "text"}, {"target"}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_none_value_skipped(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._search_nested_numeric_key(
|
||||
{"target": None}, {"target"}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_non_parseable_value_logged(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
with patch("src.core.utils.superset_context_extractor._base.logger") as mock_log:
|
||||
result = extractor._search_nested_numeric_key(
|
||||
{"target": "not_a_number"}, {"target"}
|
||||
)
|
||||
assert result is None
|
||||
mock_log.debug.assert_called_once()
|
||||
|
||||
|
||||
class TestDecodeQueryState:
|
||||
"""_decode_query_state: decode URL query params."""
|
||||
|
||||
def test_parses_native_filters_json(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._decode_query_state(
|
||||
{"native_filters": ['{"key": "val"}']}
|
||||
)
|
||||
assert result["native_filters"] == {"key": "val"}
|
||||
|
||||
def test_parses_form_data_json(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._decode_query_state(
|
||||
{"form_data": ['{"datasource": "1__table"}']}
|
||||
)
|
||||
assert result["form_data"]["datasource"] == "1__table"
|
||||
|
||||
def test_parses_q_param_json(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._decode_query_state(
|
||||
{"q": ['{"columns": ["id"]}']}
|
||||
)
|
||||
assert result["q"]["columns"] == ["id"]
|
||||
|
||||
def test_invalid_json_falls_back_to_raw(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
with patch("src.core.utils.superset_context_extractor._base.logger") as mock_log:
|
||||
result = extractor._decode_query_state(
|
||||
{"native_filters": ["{invalid}"]}
|
||||
)
|
||||
mock_log.explore.assert_called_once()
|
||||
# Should be decoded as raw string
|
||||
assert result["native_filters"] == "{invalid}"
|
||||
|
||||
def test_regular_param_decoded(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._decode_query_state(
|
||||
{"dashboard": ["5"]}
|
||||
)
|
||||
assert result["dashboard"] == "5"
|
||||
|
||||
def test_empty_values_skipped(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._decode_query_state(
|
||||
{"empty": []}
|
||||
)
|
||||
assert "empty" not in result
|
||||
|
||||
def test_url_decoded(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._decode_query_state(
|
||||
{"name": ["hello%20world"]}
|
||||
)
|
||||
assert result["name"] == "hello world"
|
||||
|
||||
|
||||
def _make_extractor(patch_env):
|
||||
from src.core.utils.superset_context_extractor._base import (
|
||||
SupersetContextExtractorBase,
|
||||
)
|
||||
|
||||
return SupersetContextExtractorBase(environment=patch_env["env"])
|
||||
# #endregion Test.SupersetContextExtractorBase
|
||||
298
backend/tests/test_core/test_superset_profile_lookup.py
Normal file
298
backend/tests/test_core/test_superset_profile_lookup.py
Normal file
@@ -0,0 +1,298 @@
|
||||
# #region Test.SupersetProfileLookup [C:3] [TYPE Module] [SEMANTICS test, superset, profile, lookup, adapter]
|
||||
# @BRIEF Tests for core/superset_profile_lookup.py — SupersetAccountLookupAdapter.
|
||||
# @RELATION BINDS_TO -> [SupersetProfileLookup]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSupersetAccountLookupAdapterInit:
|
||||
"""__init__: stores client and environment ID."""
|
||||
|
||||
def test_init(self):
|
||||
from src.core.superset_profile_lookup import (
|
||||
SupersetAccountLookupAdapter,
|
||||
)
|
||||
|
||||
client = MagicMock()
|
||||
adapter = SupersetAccountLookupAdapter(client, "env-1")
|
||||
assert adapter.network_client is client
|
||||
assert adapter.environment_id == "env-1"
|
||||
|
||||
def test_init_converts_environment_id_to_string(self):
|
||||
from src.core.superset_profile_lookup import (
|
||||
SupersetAccountLookupAdapter,
|
||||
)
|
||||
|
||||
adapter = SupersetAccountLookupAdapter(MagicMock(), 123)
|
||||
assert adapter.environment_id == "123"
|
||||
|
||||
|
||||
class TestGetUsersPage:
|
||||
"""get_users_page: fetch and normalize users."""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
from src.core.superset_profile_lookup import (
|
||||
SupersetAccountLookupAdapter,
|
||||
)
|
||||
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock()
|
||||
return SupersetAccountLookupAdapter(client, "env-1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_first_endpoint(self, adapter):
|
||||
adapter.network_client.request.return_value = {
|
||||
"result": [
|
||||
{"username": "alice", "email": "alice@example.com"},
|
||||
{"username": "bob", "email": "bob@example.com"},
|
||||
],
|
||||
"count": 2,
|
||||
}
|
||||
|
||||
result = await adapter.get_users_page(search="alice")
|
||||
assert result["status"] == "success"
|
||||
assert result["environment_id"] == "env-1"
|
||||
assert len(result["items"]) == 2
|
||||
assert result["total"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_endpoint_fails_fallback_succeeds(self, adapter):
|
||||
adapter.network_client.request = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("First endpoint failed"),
|
||||
{
|
||||
"result": [
|
||||
{"username": "charlie", "email": "charlie@example.com"}
|
||||
],
|
||||
"count": 1,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
result = await adapter.get_users_page()
|
||||
assert len(result["items"]) == 1
|
||||
assert result["items"][0]["username"] == "charlie"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_endpoints_fail_raises_last(self, adapter):
|
||||
adapter.network_client.request = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("First failed"),
|
||||
Exception("Second failed"),
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="Second failed"):
|
||||
await adapter.get_users_page()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefers_primary_error_over_auth_fallback(self, adapter):
|
||||
adapter.network_client.request = AsyncMock(
|
||||
side_effect=[
|
||||
ValueError("Primary error"),
|
||||
__import__("src").core.utils.network.AuthenticationError("Auth error"),
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Primary error"):
|
||||
await adapter.get_users_page()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preserves_primary_non_auth_over_fallback_auth(self, adapter):
|
||||
adapter.network_client.request = AsyncMock(
|
||||
side_effect=[
|
||||
RuntimeError("Primary runtime error"),
|
||||
__import__("src").core.utils.network.AuthenticationError("Fallback auth error"),
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Primary runtime error"):
|
||||
await adapter.get_users_page()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalizes_page_and_size_params(self, adapter):
|
||||
adapter.network_client.request.return_value = {"result": [], "count": 0}
|
||||
|
||||
result = await adapter.get_users_page(page_index=-1, page_size=0)
|
||||
assert result["page_index"] == 0
|
||||
assert result["page_size"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_sort_order_defaults_to_desc(self, adapter):
|
||||
adapter.network_client.request.return_value = {"result": [], "count": 0}
|
||||
|
||||
await adapter.get_users_page(sort_order="invalid")
|
||||
# Should have used "desc"
|
||||
call_q = adapter.network_client.request.call_args[1]["params"]["q"]
|
||||
import json
|
||||
|
||||
q = json.loads(call_q)
|
||||
assert q["order_direction"] == "desc"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_search_no_filters(self, adapter):
|
||||
adapter.network_client.request.return_value = {"result": [], "count": 0}
|
||||
|
||||
await adapter.get_users_page(search="")
|
||||
call_q = adapter.network_client.request.call_args[1]["params"]["q"]
|
||||
import json
|
||||
|
||||
q = json.loads(call_q)
|
||||
assert "filters" not in q
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_exception_but_also_no_last_error(self, adapter):
|
||||
# Both endpoints return data, no exception at all
|
||||
adapter.network_client.request.return_value = {"result": [], "count": 0}
|
||||
result = await adapter.get_users_page()
|
||||
assert result["status"] == "success"
|
||||
|
||||
|
||||
class TestNormalizeLookupPayload:
|
||||
"""_normalize_lookup_payload: various response shapes."""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
from src.core.superset_profile_lookup import (
|
||||
SupersetAccountLookupAdapter,
|
||||
)
|
||||
|
||||
return SupersetAccountLookupAdapter(MagicMock(), "env-1")
|
||||
|
||||
def test_result_dict_with_result_list(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
{"result": [{"username": "alice"}], "count": 1}, 0, 20
|
||||
)
|
||||
assert len(result["items"]) == 1
|
||||
assert result["total"] == 1
|
||||
|
||||
def test_result_dict_with_users_list(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
{"users": [{"username": "bob"}], "total": 1}, 0, 20
|
||||
)
|
||||
assert len(result["items"]) == 1
|
||||
|
||||
def test_result_dict_with_items_list(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
{"items": [{"username": "charlie"}], "total": 1}, 0, 20
|
||||
)
|
||||
assert len(result["items"]) == 1
|
||||
|
||||
def test_result_list_direct(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
[{"username": "dave"}, {"username": "eve"}], 0, 20
|
||||
)
|
||||
assert len(result["items"]) == 2
|
||||
assert result["total"] == 2
|
||||
|
||||
def test_double_nested_result(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
{"result": {"result": [{"username": "frank"}]}}, 0, 20
|
||||
)
|
||||
assert len(result["items"]) == 1
|
||||
|
||||
def test_skips_duplicate_usernames(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
{
|
||||
"result": [
|
||||
{"username": "alice"},
|
||||
{"username": "ALICE"},
|
||||
{"username": "bob"},
|
||||
],
|
||||
"count": 3,
|
||||
},
|
||||
0,
|
||||
20,
|
||||
)
|
||||
# "alice" and "ALICE" normalized to same lowercase
|
||||
assert len(result["items"]) == 2
|
||||
|
||||
def test_skips_empty_username(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
{"result": [{"username": ""}, {"username": "valid"}], "count": 2}, 0, 20
|
||||
)
|
||||
assert len(result["items"]) == 1
|
||||
|
||||
def test_total_adjusted_to_max(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
{"result": [{"username": "a"}, {"username": "b"}], "count": 1}, 0, 20
|
||||
)
|
||||
# total should be max(1, 2) = 2
|
||||
assert result["total"] == 2
|
||||
|
||||
def test_non_dict_non_list_response(self, adapter):
|
||||
result = adapter._normalize_lookup_payload("invalid", 0, 20)
|
||||
assert result["items"] == []
|
||||
assert result["total"] == 0
|
||||
|
||||
|
||||
class TestNormalizeUserPayload:
|
||||
"""normalize_user_payload: canonical user shape."""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
from src.core.superset_profile_lookup import (
|
||||
SupersetAccountLookupAdapter,
|
||||
)
|
||||
|
||||
return SupersetAccountLookupAdapter(MagicMock(), "env-1")
|
||||
|
||||
def test_full_user(self, adapter):
|
||||
user = adapter.normalize_user_payload({
|
||||
"username": "alice",
|
||||
"full_name": "Alice Smith",
|
||||
"email": "alice@example.com",
|
||||
"is_active": True,
|
||||
})
|
||||
assert user["username"] == "alice"
|
||||
assert user["display_name"] == "Alice Smith"
|
||||
assert user["email"] == "alice@example.com"
|
||||
assert user["is_active"] is True
|
||||
assert user["environment_id"] == "env-1"
|
||||
|
||||
def test_with_first_last_name(self, adapter):
|
||||
user = adapter.normalize_user_payload({
|
||||
"username": "bob",
|
||||
"first_name": "Bob",
|
||||
"last_name": "Jones",
|
||||
})
|
||||
assert user["display_name"] == "Bob Jones"
|
||||
|
||||
def test_empty_display_name_falls_back_to_username(self, adapter):
|
||||
user = adapter.normalize_user_payload({
|
||||
"username": "charlie",
|
||||
})
|
||||
assert user["display_name"] == "charlie"
|
||||
|
||||
def test_email_empty_becomes_none(self, adapter):
|
||||
user = adapter.normalize_user_payload({
|
||||
"username": "dave",
|
||||
"email": "",
|
||||
})
|
||||
assert user["email"] is None
|
||||
|
||||
def test_is_active_none(self, adapter):
|
||||
user = adapter.normalize_user_payload({
|
||||
"username": "eve",
|
||||
})
|
||||
assert user["is_active"] is None
|
||||
|
||||
def test_alternate_key_names(self, adapter):
|
||||
user = adapter.normalize_user_payload({
|
||||
"userName": "frank",
|
||||
"name": "ignored",
|
||||
})
|
||||
assert user["username"] == "frank"
|
||||
|
||||
def test_non_dict_input(self, adapter):
|
||||
user = adapter.normalize_user_payload("not_a_dict")
|
||||
assert user["username"] == ""
|
||||
# #endregion Test.SupersetProfileLookup
|
||||
Reference in New Issue
Block a user