Subagents delivered tests across all uncovered backend modules: Schemas (100%): agent, auth, health, profile, settings, validation Services (98-100%): auth, profile, health, llm, mapping, resource, security, git, superset_lookup, sql_table_extractor, rbac API routes (new): auth, admin, health, environments, plugins, dashboards (helpers, projection, actions, listing), git (config, deps, env, helpers) Clean Release (100%): DTO, facade, policy_engine, stages, repos, preparation, source_isolation, compliance Git services: base, remote_providers Agent module: app, run, middleware, langgraph_setup Core: trace, cleanup, ws_log_handler, timezone, auth (config/oauth/security), matching Reports: normalizer, report_service, type_profiles Notifications: service, providers Also: - .gitignore: add .coverage, *.cover, coverage-* dirs - src/schemas/auth.py: fix AD group DN regex (comma in CN=...) - Remove co-located src/services/__tests__/ (caused pytest module collision)
652 lines
25 KiB
Python
652 lines
25 KiB
Python
# #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
|