Files
smart-support/backend/tests/e2e/conftest.py
Yaojia Wang af53111928 refactor: fix architectural issues across frontend and backend
Address all architecture review findings:

P0 fixes:
- Add API key authentication for admin endpoints (analytics, replay, openapi)
  and WebSocket connections via ADMIN_API_KEY env var
- Add PostgreSQL-backed PgSessionManager and PgInterruptManager for
  multi-worker production deployments (in-memory defaults preserved)

P1 fixes:
- Implement actual tool generation in OpenAPI approve_job endpoint
  using generate_tool_code() and generate_agent_yaml()
- Add missing clarification, interrupt_expired, and tool_result message
  handlers in frontend ChatPage

P2 fixes:
- Replace monkey-patching on CompiledStateGraph with typed GraphContext
- Replace 9-param dispatch_message with WebSocketContext dataclass
- Extract duplicate _envelope() into shared app/api_utils.py
- Replace mutable module-level counter with crypto.randomUUID()
- Remove hardcoded mock data from ReviewPage, use api.ts wrappers
- Remove `as any` type escape from ReplayPage

All 516 tests passing, 0 TypeScript errors.
2026-04-06 15:59:14 +02:00

231 lines
6.7 KiB
Python

"""E2E test fixtures -- full FastAPI app with mocked LLM and database."""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from httpx import ASGITransport, AsyncClient
from app.analytics.api import router as analytics_router
from app.callbacks import TokenUsageCallbackHandler
from app.graph_context import GraphContext
from app.interrupt_manager import InterruptManager
from app.openapi.review_api import _job_store, router as openapi_router
from app.replay.api import router as replay_router
from app.session_manager import SessionManager
from app.ws_context import WebSocketContext
from app.ws_handler import dispatch_message
# ---------------------------------------------------------------------------
# Graph helpers -- simulate LangGraph streaming behaviour
# ---------------------------------------------------------------------------
class AsyncIterHelper:
"""Make a list behave as an async iterator."""
def __init__(self, items: list) -> None:
self._items = list(items)
def __aiter__(self):
return self
async def __anext__(self):
if not self._items:
raise StopAsyncIteration
return self._items.pop(0)
def make_chunk(content: str, node: str = "order_lookup") -> tuple:
c = MagicMock()
c.content = content
c.tool_calls = []
return (c, {"langgraph_node": node})
def make_tool_chunk(name: str, args: dict, node: str = "order_lookup") -> tuple:
c = MagicMock()
c.content = ""
c.tool_calls = [{"name": name, "args": args}]
return (c, {"langgraph_node": node})
def make_state(*, interrupt: bool = False, data: dict | None = None) -> Any:
s = MagicMock()
if interrupt:
obj = MagicMock()
obj.value = data or {"action": "cancel_order", "order_id": "1042"}
t = MagicMock()
t.interrupts = (obj,)
s.tasks = (t,)
else:
s.tasks = ()
return s
def make_graph(
chunks: list | None = None,
state: Any = None,
resume_chunks: list | None = None,
) -> MagicMock:
"""Build a mock LangGraph CompiledStateGraph."""
g = MagicMock()
if state is None:
state = make_state()
streams = [chunks or [], resume_chunks or []]
idx = {"n": 0}
def astream_side_effect(*a, **kw):
i = min(idx["n"], len(streams) - 1)
idx["n"] += 1
return AsyncIterHelper(list(streams[i]))
g.astream = MagicMock(side_effect=astream_side_effect)
g.aget_state = AsyncMock(return_value=state)
return g
def make_graph_ctx(graph: MagicMock | None = None) -> GraphContext:
"""Build a GraphContext wrapping a mock graph."""
g = graph or make_graph()
registry = MagicMock()
registry.list_agents = MagicMock(return_value=())
return GraphContext(graph=g, registry=registry, intent_classifier=None)
# ---------------------------------------------------------------------------
# Fake database pool
# ---------------------------------------------------------------------------
class FakeCursor:
"""Minimal async cursor returning pre-configured rows."""
def __init__(self, rows: list[dict]) -> None:
self._rows = rows
async def fetchall(self) -> list[dict]:
return self._rows
async def fetchone(self) -> tuple | dict | None:
return self._rows[0] if self._rows else None
class FakeConnection:
"""Fake async connection that returns a FakeCursor."""
def __init__(self, rows: list[dict]) -> None:
self._rows = rows
async def execute(self, query: str, params: dict | None = None) -> FakeCursor:
return FakeCursor(self._rows)
class FakePool:
"""Minimal pool that yields a fake connection."""
def __init__(self, rows: list[dict] | None = None) -> None:
self._rows = rows or []
@asynccontextmanager
async def connection(self):
yield FakeConnection(self._rows)
async def close(self) -> None:
pass
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
def create_e2e_app(
graph: MagicMock | None = None,
pool: FakePool | None = None,
session_ttl: int = 3600,
interrupt_ttl: int = 1800,
) -> FastAPI:
"""Create a FastAPI app wired with mocked dependencies for E2E testing."""
g = graph or make_graph()
graph_ctx = make_graph_ctx(g)
p = pool or FakePool()
sm = SessionManager(session_ttl_seconds=session_ttl)
im = InterruptManager(ttl_seconds=interrupt_ttl)
app = FastAPI(title="Smart Support E2E Test")
app.include_router(openapi_router)
app.include_router(replay_router)
app.include_router(analytics_router)
app.state.graph_ctx = graph_ctx
app.state.session_manager = sm
app.state.interrupt_manager = im
app.state.pool = p
app.state.settings = MagicMock(llm_model="test-model")
app.state.analytics_recorder = AsyncMock()
app.state.conversation_tracker = AsyncMock()
@app.get("/api/health")
def health_check() -> dict:
return {"status": "ok", "version": "test"}
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket) -> None:
await ws.accept()
try:
while True:
raw_data = await ws.receive_text()
ws_ctx = WebSocketContext(
graph_ctx=app.state.graph_ctx,
session_manager=app.state.session_manager,
callback_handler=TokenUsageCallbackHandler(model_name="test-model"),
interrupt_manager=app.state.interrupt_manager,
analytics_recorder=app.state.analytics_recorder,
conversation_tracker=app.state.conversation_tracker,
pool=app.state.pool,
)
await dispatch_message(ws, ws_ctx, raw_data)
except WebSocketDisconnect:
pass
return app
@pytest.fixture
def e2e_graph():
"""Default graph fixture -- returns tokens and message_complete."""
return make_graph(
chunks=[make_chunk("Order 1042 is "), make_chunk("shipped.")]
)
@pytest.fixture
def e2e_app(e2e_graph):
"""Default E2E app fixture."""
return create_e2e_app(graph=e2e_graph)
@pytest.fixture
async def e2e_client(e2e_app):
"""Async HTTP client for E2E tests."""
transport = ASGITransport(app=e2e_app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.fixture(autouse=True)
def clear_openapi_job_store():
"""Clear the in-memory job store between tests."""
_job_store.clear()
yield
_job_store.clear()