371 lines
13 KiB
Python
371 lines
13 KiB
Python
# #region TestAsyncRegression [C:3] [TYPE Module] [SEMANTICS test,async,regression,await]
|
|
# @BRIEF Regression tests for bugs found during sync→async migration.
|
|
# Ensures that missing `await`, renamed methods, and event-loop issues
|
|
# are caught at test time, not at runtime.
|
|
# @RELATION BINDS_TO -> [AsyncAPIClient]
|
|
# @RELATION BINDS_TO -> [SupersetClient]
|
|
# @RELATION BINDS_TO -> [AsyncSupersetClient]
|
|
# @TEST_CONTRACT Every async method call from a sync context raises TypeError
|
|
# @TEST_CONTRACT SupersetClient.aclose() exists and is awaitable
|
|
# @TEST_CONTRACT get_dashboards_page (not _async) is the canonical method name
|
|
|
|
import asyncio
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
|
|
|
from src.core.superset_client import SupersetClient
|
|
from src.core.utils.async_network import AsyncAPIClient
|
|
|
|
|
|
# ── 1. SupersetClient.aclose() exists and is awaitable ────────────────────
|
|
# Regression: 'SupersetClient' object has no attribute 'aclose'
|
|
# Fixed in commit 079b2dd
|
|
|
|
|
|
class TestSupersetClientAclose:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aclose_is_awaitable(self):
|
|
"""SupersetClient must have aclose() to match AsyncSupersetClient interface."""
|
|
env = MagicMock()
|
|
env.id = "test"
|
|
env.url = "http://test.local"
|
|
env.username = "u"
|
|
env.password = "p"
|
|
env.auth = {"username": "u", "password": "p"}
|
|
env.timeout = 30
|
|
env.verify_ssl = False
|
|
|
|
client = SupersetClient(env)
|
|
assert hasattr(client, "aclose"), "SupersetClient must have aclose()"
|
|
assert asyncio.iscoroutinefunction(client.aclose), "aclose() must be async"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aclose_delegates_to_async_client(self):
|
|
"""aclose() must delegate to AsyncAPIClient.aclose()."""
|
|
from src.core.superset_client._base import SupersetClientBase
|
|
|
|
env = MagicMock()
|
|
env.id = "test"
|
|
env.url = "http://test.local"
|
|
env.username = "u"
|
|
env.password = "p"
|
|
env.auth = {"username": "u", "password": "p"}
|
|
env.timeout = 30
|
|
env.verify_ssl = False
|
|
|
|
mock_transport = AsyncMock()
|
|
mock_transport.aclose = AsyncMock()
|
|
|
|
client = SupersetClientBase.__new__(SupersetClientBase)
|
|
client.env = env
|
|
client.client = mock_transport
|
|
client.network = mock_transport
|
|
|
|
await client.aclose()
|
|
mock_transport.aclose.assert_awaited_once()
|
|
|
|
|
|
# ── 2. get_dashboards_page (not get_dashboards_page_async) ───────────────
|
|
# Regression: get_dashboards_page_async was called but only
|
|
# get_dashboards_page exists (it's async and renamed).
|
|
# Fixed in commit 7115678
|
|
|
|
|
|
class TestDashboardsPageMethod:
|
|
|
|
def test_get_dashboards_page_is_async(self):
|
|
"""get_dashboards_page must be async (the canonical method name)."""
|
|
from src.core.superset_client._dashboards_list import SupersetDashboardsListMixin
|
|
|
|
assert hasattr(SupersetDashboardsListMixin, "get_dashboards_page")
|
|
assert asyncio.iscoroutinefunction(
|
|
SupersetDashboardsListMixin.get_dashboards_page
|
|
), "get_dashboards_page must be async"
|
|
|
|
def test_get_dashboards_page_async_does_not_exist(self):
|
|
"""get_dashboards_page_async was removed — only get_dashboards_page exists."""
|
|
from src.core.superset_client._dashboards_list import SupersetDashboardsListMixin
|
|
|
|
assert not hasattr(
|
|
SupersetDashboardsListMixin, "get_dashboards_page_async"
|
|
), "get_dashboards_page_async must not exist — renamed to get_dashboards_page"
|
|
|
|
|
|
# ── 3. Missing await detection — coroutine leak regression ───────────────
|
|
# Regression pattern: sync function calls async method without await,
|
|
# returns a coroutine that later fails with e.g.:
|
|
# 'coroutine' object is not iterable
|
|
# 'coroutine' object has no attribute 'get'
|
|
# '>' not supported between instances of 'coroutine' and 'int'
|
|
|
|
|
|
class TestMissingAwaitDetection:
|
|
|
|
def test_async_method_returns_coroutine_not_value(self):
|
|
"""Calling async def without await returns a coroutine, not the result."""
|
|
async def async_add(a, b):
|
|
return a + b
|
|
|
|
# Calling without await → coroutine object, NOT the sum
|
|
result = async_add(1, 2)
|
|
assert asyncio.iscoroutine(result), (
|
|
"Async function called without await must return a coroutine, "
|
|
"not the computed value"
|
|
)
|
|
# The actual value is only obtained via await
|
|
assert asyncio.run(result) == 3
|
|
|
|
def test_coroutine_is_not_iterable(self):
|
|
"""Iterating over a coroutine raises TypeError (regression guard)."""
|
|
async def async_list():
|
|
return [1, 2, 3]
|
|
|
|
coro = async_list()
|
|
assert asyncio.iscoroutine(coro)
|
|
with pytest.raises(TypeError, match="coroutine.*not iterable"):
|
|
for item in coro:
|
|
pass
|
|
|
|
def test_coroutine_has_no_dict_attribute(self):
|
|
"""Calling .get() on a coroutine raises AttributeError (regression guard)."""
|
|
async def async_dict():
|
|
return {"key": "value"}
|
|
|
|
coro = async_dict()
|
|
assert asyncio.iscoroutine(coro)
|
|
with pytest.raises(AttributeError, match="coroutine.*has no attribute"):
|
|
_ = coro.get("key")
|
|
|
|
def test_coroutine_cannot_be_compared_to_int(self):
|
|
"""Comparing a coroutine to int raises TypeError (regression guard)."""
|
|
async def async_count():
|
|
return 42
|
|
|
|
coro = async_count()
|
|
assert asyncio.iscoroutine(coro)
|
|
with pytest.raises(TypeError, match="not supported between instances"):
|
|
_ = coro > 0
|
|
|
|
|
|
# ── 4. Async route handler event loop regression ─────────────────────────
|
|
# Regression: run_translation was sync def but used asyncio.create_task
|
|
# inside, which requires a running event loop.
|
|
# Fixed in commit 71000db
|
|
|
|
|
|
class TestAsyncRouteHandlerEventLoop:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_route_handler_has_running_loop(self):
|
|
"""Async route handlers run inside a running event loop."""
|
|
loop = asyncio.get_running_loop()
|
|
assert loop is not None
|
|
assert not loop.is_closed()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_task_works_in_async_context(self):
|
|
"""asyncio.create_task must work when called from an async function."""
|
|
results = []
|
|
|
|
async def background_work():
|
|
await asyncio.sleep(0.001)
|
|
results.append("done")
|
|
|
|
task = asyncio.create_task(background_work())
|
|
await task
|
|
assert results == ["done"]
|
|
|
|
def test_create_task_raises_in_sync_context(self):
|
|
"""asyncio.create_task WITHOUT running loop raises RuntimeError."""
|
|
import threading
|
|
|
|
# This must be tested in a thread with no event loop
|
|
# (sync route handlers run in thread pool)
|
|
errors = []
|
|
|
|
def sync_function():
|
|
try:
|
|
async def dummy():
|
|
pass
|
|
asyncio.create_task(dummy())
|
|
except RuntimeError as e:
|
|
errors.append(str(e))
|
|
|
|
t = threading.Thread(target=sync_function)
|
|
t.start()
|
|
t.join()
|
|
assert len(errors) > 0
|
|
assert "no running event loop" in errors[0]
|
|
|
|
|
|
# ── 5. Sync function calling async method without await ──────────────────
|
|
# Regression: many sync functions called async SupersetClient methods.
|
|
# The pattern can be detected statically (see final scan) but we also
|
|
# verify that the runtime error is predictable.
|
|
|
|
|
|
class TestSyncCallsAsyncWithoutAwait:
|
|
|
|
def test_awaiting_magicmock_raises_typeerror(self):
|
|
"""await on a regular MagicMock raises TypeError."""
|
|
m = MagicMock()
|
|
m.async_method = MagicMock(return_value=42)
|
|
|
|
async def caller():
|
|
return await m.async_method()
|
|
|
|
with pytest.raises(TypeError, match="can't be used in 'await' expression"):
|
|
asyncio.run(caller())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_asyncmock_await_returns_value(self):
|
|
"""await on an AsyncMock returns the configured return_value."""
|
|
m = MagicMock()
|
|
m.async_method = AsyncMock(return_value=42)
|
|
|
|
result = await m.async_method()
|
|
assert result == 42
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mock_for_async_method_must_be_asyncmock(self):
|
|
"""Tests that mock async methods must use AsyncMock, not MagicMock.
|
|
|
|
If a test uses MagicMock.return_value for an async method, the
|
|
production code will receive a coroutine instead of the expected value.
|
|
This test guards against that pattern.
|
|
"""
|
|
# ❌ WRONG: MagicMock with return_value — await fails
|
|
wrong = MagicMock()
|
|
wrong.async_method = MagicMock(return_value={"status": "ok"})
|
|
with pytest.raises(TypeError):
|
|
await wrong.async_method()
|
|
|
|
# ✅ CORRECT: AsyncMock with return_value — await succeeds
|
|
correct = MagicMock()
|
|
correct.async_method = AsyncMock(return_value={"status": "ok"})
|
|
result = await correct.async_method()
|
|
assert result["status"] == "ok"
|
|
|
|
|
|
# ── 6. SupersetClient method name consistency ────────────────────────────
|
|
# All async methods on SupersetClient should exist and be awaitable.
|
|
|
|
|
|
class TestSupersetClientMethodConsistency:
|
|
|
|
"""Verify key SupersetClient async methods exist and are awaitable."""
|
|
|
|
ASYNC_METHODS = [
|
|
"get_dashboards_summary",
|
|
"get_dashboards_page",
|
|
"get_datasets_summary",
|
|
"get_dataset_detail",
|
|
"get_database",
|
|
"get_databases",
|
|
"get_dataset",
|
|
"update_dataset",
|
|
"get_dashboards",
|
|
"get_charts",
|
|
"get_chart",
|
|
"get_dashboard_detail",
|
|
"export_dashboard",
|
|
"get_all_resources",
|
|
"get_database_by_uuid",
|
|
"get_dashboards_summary_page",
|
|
"get_dataset_linked_dashboard_count",
|
|
"aclose",
|
|
]
|
|
|
|
def test_all_async_methods_exist_on_client(self):
|
|
"""All canonical async methods must exist on SupersetClient."""
|
|
from src.core.superset_client import SupersetClient
|
|
|
|
for method_name in self.ASYNC_METHODS:
|
|
assert hasattr(SupersetClient, method_name), (
|
|
f"SupersetClient must have method '{method_name}'"
|
|
)
|
|
|
|
def test_all_async_methods_are_coroutines(self):
|
|
"""All canonical async methods must be awaitable (async def)."""
|
|
from src.core.superset_client import SupersetClient
|
|
|
|
for method_name in self.ASYNC_METHODS:
|
|
method = getattr(SupersetClient, method_name)
|
|
assert asyncio.iscoroutinefunction(method), (
|
|
f"SupersetClient.{method_name} must be async def"
|
|
)
|
|
|
|
def test_no_legacy_sync_names_remain(self):
|
|
"""Legacy _async suffixed names must not exist."""
|
|
from src.core.superset_client import SupersetClient
|
|
|
|
legacy_names = [
|
|
"get_dashboards_page_async",
|
|
"get_dashboards_summary_async",
|
|
"get_dashboard_detail_async",
|
|
"get_datasets_summary_async",
|
|
]
|
|
for name in legacy_names:
|
|
assert not hasattr(SupersetClient, name), (
|
|
f"Legacy name '{name}' must not exist on SupersetClient"
|
|
)
|
|
|
|
|
|
# ── 7. AsyncAPIClient semaphore / pool timeout regression ────────────────
|
|
|
|
|
|
class TestAsyncAPIClientSemaphore:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_semaphore_limits_concurrent_requests(self):
|
|
"""Semaphore should cap concurrent requests."""
|
|
config = {"base_url": "http://test.local", "auth": {"username": "x", "password": "y"}}
|
|
|
|
semaphore = asyncio.Semaphore(2)
|
|
client = AsyncAPIClient(config=config, timeout=30, semaphore=semaphore)
|
|
assert client._semaphore is semaphore
|
|
await client.aclose()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_client_aclose_is_idempotent(self):
|
|
"""Calling aclose() multiple times should not raise."""
|
|
config = {"base_url": "http://test.local", "auth": {"username": "x", "password": "y"}}
|
|
client = AsyncAPIClient(config=config, timeout=30)
|
|
|
|
# First close
|
|
await client.aclose()
|
|
# Second close — should be safe
|
|
await client.aclose()
|
|
|
|
|
|
# ── 8. run_blocking executor regression ──────────────────────────────────
|
|
|
|
|
|
class TestRunBlocking:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_blocking_executes_sync_function(self):
|
|
"""run_blocking must execute a sync function and return its result."""
|
|
from src.core.utils.executors import run_blocking
|
|
|
|
def sync_add(a, b):
|
|
return a + b
|
|
|
|
result = await run_blocking("db", sync_add, 1, 2)
|
|
assert result == 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_blocking_raises_on_exception(self):
|
|
"""run_blocking must propagate exceptions from the sync function."""
|
|
from src.core.utils.executors import run_blocking
|
|
|
|
def sync_fail():
|
|
raise ValueError("sync error")
|
|
|
|
with pytest.raises(ValueError, match="sync error"):
|
|
await run_blocking("file", sync_fail)
|
|
|
|
|
|
# #endregion TestAsyncRegression
|