Files
ss-tools/backend/tests/core/test_async_regression.py
busya ce0369ae5c test(backend): add 55+ test files to push coverage to 98%
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)
2026-06-15 13:55:57 +03:00

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