# #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) # ── 9. asyncio.run() in running event loop regression ─────────────────── # Regression: _test_postgresql(), _execute_pg(), _fetch_pg() used asyncio.run() # which raises RuntimeError when called from within a running event loop # (e.g., FastAPI async def endpoint). Fixed: all methods converted to # async def; asyncio.run() removed. # Files: src/core/connection_service.py, src/core/db_executor.py class TestAsyncioRunInRunningLoop: """Verify that migrated async methods work when called from a running event loop (the scenario that previously crashed with 'asyncio.run() cannot be called from a running event loop').""" # ── 9a. Static structure: all migrated methods must be async def ────────── def test_connection_service_test_connection_is_async(self): """test_connection must be a coroutine function (regression: was sync).""" from src.core.connection_service import ConnectionService assert asyncio.iscoroutinefunction( ConnectionService.test_connection ), "test_connection must be async def — regression guard against asyncio.run()" def test_connection_service_test_postgresql_is_async(self): """_test_postgresql must be a coroutine function (regression: used asyncio.run()).""" from src.core.connection_service import ConnectionService assert asyncio.iscoroutinefunction( ConnectionService._test_postgresql ), "_test_postgresql must be async def — regression guard against asyncio.run()" def test_db_executor_execute_sql_is_async(self): """execute_sql must be a coroutine function (regression: was sync).""" from src.core.db_executor import DbExecutor assert asyncio.iscoroutinefunction( DbExecutor.execute_sql ), "execute_sql must be async def — regression guard against asyncio.run()" def test_db_executor_fetch_schema_is_async(self): """fetch_schema must be a coroutine function (regression: was sync).""" from src.core.db_executor import DbExecutor assert asyncio.iscoroutinefunction( DbExecutor.fetch_schema ), "fetch_schema must be async def — regression guard against asyncio.run()" def test_db_executor_execute_pg_is_async(self): """_execute_pg must be a coroutine function (regression: used asyncio.run()).""" from src.core.db_executor import DbExecutor assert asyncio.iscoroutinefunction( DbExecutor._execute_pg ), "_execute_pg must be async def — regression guard against asyncio.run()" def test_db_executor_fetch_pg_is_async(self): """_fetch_pg must be a coroutine function (regression: used asyncio.run()).""" from src.core.db_executor import DbExecutor assert asyncio.iscoroutinefunction( DbExecutor._fetch_pg ), "_fetch_pg must be async def — regression guard against asyncio.run()" # ── 9b. Functional: calling from running event loop doesn't crash ──────── @pytest.mark.asyncio async def test_test_postgresql_works_in_running_loop(self): """_test_postgresql must not crash with 'asyncio.run() cannot be called' when awaited from a running event loop. Tests the full asyncpg path.""" import sys from src.core.config_models import DatabaseConnection from src.core.connection_service import ConnectionService cm = MagicMock() cm.config.settings.connections = [] service = ConnectionService(cm) conn = DatabaseConnection( id="reg-asyncpg-1", name="Regression PG", host="localhost", port=5432, database="test", username="u", password="p", dialect="postgresql", pool_size=5, ) # Mock asyncpg driver at EXT boundary mock_pg_conn = AsyncMock() mock_pg_conn.fetchrow = AsyncMock(return_value=["PostgreSQL 16.3"]) mock_asyncpg = MagicMock() mock_asyncpg.connect = AsyncMock(return_value=mock_pg_conn) with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}): # Before fix: RuntimeError: asyncio.run() cannot be called from a running event loop result = await service._test_postgresql(conn) assert result == "PostgreSQL 16.3" mock_asyncpg.connect.assert_awaited_once_with( host="localhost", port=5432, user="u", password="p", database="test", timeout=10, ) mock_pg_conn.fetchrow.assert_awaited_once_with("SELECT version()") mock_pg_conn.close.assert_awaited_once() @pytest.mark.asyncio async def test_execute_pg_works_in_running_loop(self): """_execute_pg must not crash with 'asyncio.run() cannot be called' when awaited from a running event loop. Tests the full asyncpg pool path.""" import sys from src.core.db_executor import DbExecutor conn = MagicMock() conn.id = "reg-execpg-1" conn.dialect = "postgresql" conn.host = "localhost" conn.port = 5432 conn.database = "test" conn.username = "u" conn.password = "p" conn.pool_size = 5 conn.updated_at = "2025-01-01" svc = MagicMock() svc.get_connection.return_value = conn executor = DbExecutor(svc) # Mock asyncpg pool at EXT boundary mock_pg_conn = AsyncMock() mock_pg_conn.execute = AsyncMock(return_value="INSERT 0 5") mock_pool_ctx = AsyncMock() mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn) mock_pool_ctx.__aexit__ = AsyncMock(return_value=False) mock_pool = MagicMock() mock_pool.acquire.return_value = mock_pool_ctx mock_asyncpg = MagicMock() mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool) with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}): # Before fix: RuntimeError: asyncio.run() cannot be called from a running event loop result = await executor._execute_pg(conn, "INSERT INTO t VALUES (1)", 0.0) assert result.success is True assert result.rows_affected == 5 mock_asyncpg.create_pool.assert_awaited_once() mock_pg_conn.execute.assert_awaited_once_with("INSERT INTO t VALUES (1)") @pytest.mark.asyncio async def test_fetch_pg_works_in_running_loop(self): """_fetch_pg must not crash with 'asyncio.run() cannot be called' when awaited from a running event loop. Tests the full asyncpg pool path.""" import sys from src.core.db_executor import DbExecutor, DbSchemaColumn conn = MagicMock() conn.id = "reg-fetchpg-1" conn.dialect = "postgresql" conn.host = "localhost" conn.port = 5432 conn.database = "test" conn.username = "u" conn.password = "p" conn.pool_size = 5 conn.updated_at = "2025-01-01" svc = MagicMock() svc.get_connection.return_value = conn executor = DbExecutor(svc) # Mock asyncpg pool at EXT boundary fake_rows = [ {"name": "id", "type": "integer"}, {"name": "email", "type": "varchar"}, ] mock_pg_conn = AsyncMock() mock_pg_conn.fetch = AsyncMock(return_value=fake_rows) mock_pool_ctx = AsyncMock() mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn) mock_pool_ctx.__aexit__ = AsyncMock(return_value=False) mock_pool = MagicMock() mock_pool.acquire.return_value = mock_pool_ctx mock_asyncpg = MagicMock() mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool) with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}): # Before fix: RuntimeError: asyncio.run() cannot be called from a running event loop result = await executor._fetch_pg( conn, "SELECT column_name, data_type FROM information_schema.columns" ) assert result is not None assert len(result) == 2 assert result[0].name == "id" assert result[0].data_type == "integer" assert result[1].name == "email" assert result[1].data_type == "varchar" mock_asyncpg.create_pool.assert_awaited_once() mock_pg_conn.fetch.assert_awaited_once() @pytest.mark.asyncio async def test_connection_service_test_connection_routes_to_async_pg(self): """test_connection must route to async _test_postgresql when dialect=postgresql and work from a running event loop.""" from src.core.config_models import DatabaseConnection from src.core.connection_service import ConnectionService cm = MagicMock() cm.config.settings.connections = [] service = ConnectionService(cm) conn = DatabaseConnection( id="reg-route-1", name="PG Route", host="localhost", port=5432, database="test", username="u", password="p", dialect="postgresql", pool_size=5, ) cm.config.settings.connections.append(conn) # Mock decryption to pass through with patch.object(service, '_decrypt_password', return_value="p"), \ patch.object(service, '_test_postgresql', return_value="PostgreSQL 17"): result = await service.test_connection("reg-route-1") assert result["success"] is True assert result["db_version"] == "PostgreSQL 17" assert "latency_ms" in result @pytest.mark.asyncio async def test_execute_sql_pg_routes_to_async_execute_pg(self): """execute_sql must route to async _execute_pg when dialect=postgresql and work from a running event loop.""" from unittest.mock import AsyncMock from src.core.db_executor import DbExecutor, DbExecutionResult conn = MagicMock() conn.id = "reg-route-pg-1" conn.dialect = "postgresql" conn.host = "localhost" conn.port = 5432 conn.database = "test" conn.username = "u" conn.password = "p" conn.pool_size = 5 conn.updated_at = "2025-01-01" svc = MagicMock() svc.get_connection.return_value = conn executor = DbExecutor(svc) expected = DbExecutionResult(success=True, rows_affected=10, execution_time_ms=50) mock_pg = AsyncMock(return_value=expected) with patch.object(executor, '_execute_pg', mock_pg): result = await executor.execute_sql("reg-route-pg-1", "INSERT INTO t VALUES (1)") assert result.success is True assert result.rows_affected == 10 # ── 9c. Rejected path: asyncio.run() on coroutine raises TypeError ─────── # Ensures that if someone mistakenly calls execute_sql() without await, # the error is immediate and informative. @pytest.mark.asyncio async def test_async_method_without_await_returns_coroutine(self): """Calling execute_sql() without await returns a coroutine, not a result. This guards the contract: the method IS async and must be awaited.""" from src.core.db_executor import DbExecutor conn = MagicMock() conn.id = "reg-coro-1" conn.dialect = "clickhouse" conn.updated_at = "2025-01-01" svc = MagicMock() svc.get_connection.return_value = conn executor = DbExecutor(svc) # Call without await → should return coroutine, NOT DbExecutionResult coro = executor.execute_sql("reg-coro-1", "SELECT 1") assert asyncio.iscoroutine(coro), ( "execute_sql() called without await must return a coroutine, " "not a result — guards the contract that it's async def" ) # #endregion TestAsyncRegression