032: add async regression tests (21 tests covering all fixed bug patterns)
This commit is contained in:
370
backend/tests/core/test_async_regression.py
Normal file
370
backend/tests/core/test_async_regression.py
Normal file
@@ -0,0 +1,370 @@
|
||||
# #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
|
||||
Reference in New Issue
Block a user