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.
231 lines
6.7 KiB
Python
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()
|