032: add async regression tests (21 tests covering all fixed bug patterns)

This commit is contained in:
2026-06-05 10:40:51 +03:00
parent 3fc09b086e
commit 909b568784

View 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