# #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