From 909b56878455ad7650937d7e39267d49c24d3698 Mon Sep 17 00:00:00 2001 From: busya Date: Fri, 5 Jun 2026 10:40:51 +0300 Subject: [PATCH] 032: add async regression tests (21 tests covering all fixed bug patterns) --- backend/tests/core/test_async_regression.py | 370 ++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 backend/tests/core/test_async_regression.py diff --git a/backend/tests/core/test_async_regression.py b/backend/tests/core/test_async_regression.py new file mode 100644 index 00000000..2aec8a7d --- /dev/null +++ b/backend/tests/core/test_async_regression.py @@ -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