Files
Leopold 2672bd86e6
tests / pytest (push) Has been cancelled
test: add pytest suite + CI workflow (21 tests)
2026-07-06 07:05:17 +02:00

233 lines
7.8 KiB
Python

"""Shared pytest fixtures for agent-platform test suite.
The agent_platform package uses flat module imports (e.g. ``from api import app``)
configured by ``agent_platform/pyproject.toml``'s ``py-modules``. We mirror that
by adding ``agent_platform/`` to ``sys.path`` so the same import paths work in
tests without needing the package to be installed.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import AsyncIterator
import aiosqlite
import pytest
import pytest_asyncio
# Make ``agent_platform/`` flat modules importable as if the package was installed.
ROOT = Path(__file__).resolve().parent.parent
AGENT_PLATFORM_DIR = ROOT / "agent_platform"
if str(AGENT_PLATFORM_DIR) not in sys.path:
sys.path.insert(0, str(AGENT_PLATFORM_DIR))
# Set AUTH_TOKEN env var BEFORE importing config so the settings instance
# sees a strong token (the production boot-guard requires >= 32 chars).
# Individual fixtures monkeypatch settings.AUTH_TOKEN back to the test value.
os.environ.setdefault("AUTH_TOKEN", "x" * 64)
os.environ.setdefault("DB_PATH", str(ROOT / "tests" / ".unused.db"))
os.environ.setdefault("LLM_API_KEY", "")
os.environ.setdefault("MCP_SERVER_URL", "http://localhost:8501/mcp")
from config import settings # noqa: E402 (must come after sys.path/env setup)
import config as config_module # noqa: E402
import api as api_module # noqa: E402
import auth as auth_module # noqa: E402
import db as db_module # noqa: E402
import llm as llm_module # noqa: E402
import mcp_client as mcp_client_module # noqa: E402
import agent as agent_module # noqa: E402
from api import app # noqa: E402
# === Paths & Auth ===
AUTH_TOKEN_VALUE = "change-me-in-production"
AUTH_HEADERS = {"Authorization": f"Bearer {AUTH_TOKEN_VALUE}"}
@pytest.fixture
def tmp_db_path(tmp_path) -> str:
"""Return a unique DB path under pytest's tmp_path."""
return str(tmp_path / "test.db")
@pytest.fixture
def auth_headers() -> dict:
"""Authorization header dict using the literal default AUTH_TOKEN."""
return dict(AUTH_HEADERS)
# === DB + Settings ===
@pytest_asyncio.fixture
async def clean_db(tmp_db_path, monkeypatch) -> AsyncIterator[aiosqlite.Connection]:
"""Point settings at tmp_db_path, init schema, yield a live connection."""
monkeypatch.setattr(config_module.settings, "DB_PATH", tmp_db_path)
monkeypatch.setattr(config_module.settings, "AUTH_TOKEN", AUTH_TOKEN_VALUE)
await db_module.init_db()
db = await aiosqlite.connect(tmp_db_path)
try:
yield db
finally:
await db.close()
# === MCP Stub ===
class _StubMCPClient:
"""In-memory MCP replacement used by API + agent layer."""
def __init__(self, tools=("echo", "calculate"), healthy: bool = True,
health_exc: BaseException | None = None,
connect_exc: BaseException | None = None,
list_tools_exc: BaseException | None = None):
self._tools = list(tools)
self._healthy = healthy
self._health_exc = health_exc
self._connect_exc = connect_exc
self._list_tools_exc = list_tools_exc
async def list_tools(self):
if self._list_tools_exc:
raise self._list_tools_exc
return [{"name": t, "description": f"stub:{t}", "inputSchema": {"type": "object", "properties": {}}} for t in self._tools]
async def health(self) -> bool:
if self._health_exc:
raise self._health_exc
if self._connect_exc:
raise self._connect_exc
return self._healthy
async def connect(self):
if self._connect_exc:
raise self._connect_exc
return None
async def disconnect(self):
return None
async def call_tool(self, name, arguments):
return {"name": name, "result": f"stub-{name}"}
async def get_tools_for_llm(self, allowed=None):
tools = await self.list_tools()
result = []
for t in tools:
name = t.get("name")
if allowed and name not in allowed:
continue
result.append({
"type": "function",
"function": {
"name": name,
"description": t.get("description", ""),
"parameters": t.get("inputSchema", {"type": "object", "properties": {}}),
},
})
return result
@pytest.fixture
def stub_mcp():
"""Default healthy stub returning ['echo', 'calculate']."""
return _StubMCPClient()
@pytest.fixture
def stub_mcp_unreachable():
"""Stub whose health() raises CancelledError (S1 regression fixture)."""
import asyncio
return _StubMCPClient(connect_exc=asyncio.CancelledError())
def AsyncMock_dummy():
"""Async no-op function to replace close_mcp_client during tests."""
async def _noop():
return None
return _noop
# === HTTP Client ===
async def _override_get_db() -> AsyncIterator[aiosqlite.Connection]:
"""Yield a connection to settings.DB_PATH for the duration of one request."""
db = await aiosqlite.connect(config_module.settings.DB_PATH)
try:
yield db
finally:
await db.close()
@pytest_asyncio.fixture
async def app_client(clean_db, monkeypatch, stub_mcp) -> AsyncIterator:
"""httpx.AsyncClient against the FastAPI app via ASGI transport.
* Uses ASGITransport WITHOUT lifespan events (boot guard would otherwise
require a 32+ char AUTH_TOKEN, which is set via clean_db).
* Overrides ``api.get_db`` so every request gets the tmp DB connection.
* Stubs ``get_mcp_client()`` (in api + agent modules, not just mcp_client)
so list_tools/health never hit the network.
"""
import httpx
# Override DB dependency
app.dependency_overrides[api_module.get_db] = _override_get_db
# Stub MCP client in ALL modules that imported it via `from mcp_client import`
# (api + agent both have their own references that monkeypatch on the source
# module alone won't affect).
factory = lambda: stub_mcp
monkeypatch.setattr(mcp_client_module, "get_mcp_client", factory)
monkeypatch.setattr(api_module, "get_mcp_client", factory)
monkeypatch.setattr(agent_module, "get_mcp_client", factory)
monkeypatch.setattr(mcp_client_module, "close_mcp_client", AsyncMock_dummy())
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
yield client
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def app_client_mcp_down(clean_db, monkeypatch, stub_mcp_unreachable) -> AsyncIterator:
"""Same as app_client but MCP client raises CancelledError on health()."""
import httpx
app.dependency_overrides[api_module.get_db] = _override_get_db
factory = lambda: stub_mcp_unreachable
monkeypatch.setattr(mcp_client_module, "get_mcp_client", factory)
monkeypatch.setattr(api_module, "get_mcp_client", factory)
monkeypatch.setattr(agent_module, "get_mcp_client", factory)
monkeypatch.setattr(mcp_client_module, "close_mcp_client", AsyncMock_dummy())
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
yield client
app.dependency_overrides.clear()
# === Seeding ===
@pytest_asyncio.fixture
async def seed_agent(clean_db) -> dict:
"""Insert one db-source agent 'test_agent' for tests that need it."""
payload = {
"id": "test_agent",
"name": "Test Agent",
"description": "Seeded test agent",
"system_prompt": "You are a test agent. Reply concisely.",
"allowed_tools": ["echo", "calculate"],
"model": None,
"temperature": 0.7,
"max_tokens": 2000,
"enabled": True,
}
await db_module.upsert_agent(clean_db, payload, source="db")
return payload