"""E2E tests for replay and analytics flows (flow 6). Flow 6: list conversations -> select one -> step-by-step replay. Also tests the analytics dashboard endpoint. """ from __future__ import annotations from datetime import datetime, timezone import pytest from starlette.testclient import TestClient from tests.e2e.conftest import FakePool, create_e2e_app pytestmark = pytest.mark.e2e # --------------------------------------------------------------------------- # Custom pool that returns specific data per query # --------------------------------------------------------------------------- class ReplayPool(FakePool): """Pool that returns different data depending on the SQL query.""" def __init__( self, conversations: list[dict] | None = None, checkpoints: list[dict] | None = None, analytics_rows: list[dict] | None = None, ) -> None: super().__init__() self._conversations = conversations or [] self._checkpoints = checkpoints or [] self._analytics = analytics_rows or [] class _Conn: def __init__(self, convos, checkpoints, analytics): self._convos = convos self._checkpoints = checkpoints self._analytics = analytics async def execute(self, query: str, params=None): from tests.e2e.conftest import FakeCursor if "COUNT" in query and "conversations" in query: return FakeCursor([(len(self._convos),)]) if "conversations" in query and "SELECT" in query: # Respect LIMIT/OFFSET from params if provided rows = self._convos if params: offset = params.get("offset", 0) limit = params.get("limit", len(rows)) rows = rows[offset : offset + limit] return FakeCursor(rows) if "checkpoints" in query: return FakeCursor(self._checkpoints) # Analytics queries return FakeCursor(self._analytics) def connection(self): from contextlib import asynccontextmanager conn = self._Conn(self._conversations, self._checkpoints, self._analytics) @asynccontextmanager async def _ctx(): yield conn return _ctx() class TestFlow6ReplayConversation: """Flow 6: list conversations -> select one -> step replay.""" def test_list_conversations(self) -> None: now = datetime.now(tz=timezone.utc).isoformat() conversations = [ { "thread_id": "conv-001", "created_at": now, "last_activity": now, "status": "active", "total_tokens": 150, "total_cost_usd": 0.003, }, { "thread_id": "conv-002", "created_at": now, "last_activity": now, "status": "completed", "total_tokens": 300, "total_cost_usd": 0.006, }, ] pool = ReplayPool(conversations=conversations) app = create_e2e_app(pool=pool) with TestClient(app) as client: resp = client.get("/api/v1/conversations") assert resp.status_code == 200 body = resp.json() assert body["success"] is True data = body["data"] assert len(data["conversations"]) == 2 assert data["conversations"][0]["thread_id"] == "conv-001" assert data["conversations"][1]["thread_id"] == "conv-002" assert data["total"] == 2 def test_list_conversations_pagination(self) -> None: conversations = [ { "thread_id": f"conv-{i:03d}", "created_at": "2026-04-01T00:00:00Z", "last_activity": "2026-04-01T00:00:00Z", "status": "active", "total_tokens": 100, "total_cost_usd": 0.001, } for i in range(5) ] pool = ReplayPool(conversations=conversations) app = create_e2e_app(pool=pool) with TestClient(app) as client: resp = client.get("/api/v1/conversations", params={"page": 1, "per_page": 2}) assert resp.status_code == 200 body = resp.json() assert body["success"] is True data = body["data"] assert data["total"] == 5 assert data["page"] == 1 assert data["per_page"] == 2 assert len(data["conversations"]) == 2 def test_replay_thread_not_found(self) -> None: pool = ReplayPool(checkpoints=[]) app = create_e2e_app(pool=pool) with TestClient(app) as client: resp = client.get("/api/v1/replay/nonexistent-thread") assert resp.status_code == 404 def test_replay_invalid_thread_id_format(self) -> None: app = create_e2e_app() with TestClient(app) as client: # Thread ID with special chars fails regex validation resp = client.get("/api/v1/replay/invalid%20thread%21%40") assert resp.status_code == 400 class TestAnalyticsDashboard: """Analytics endpoint tests.""" def test_analytics_invalid_range_format(self) -> None: app = create_e2e_app() with TestClient(app) as client: resp = client.get("/api/v1/analytics", params={"range": "invalid"}) assert resp.status_code == 400 def test_analytics_range_too_large(self) -> None: app = create_e2e_app() with TestClient(app) as client: resp = client.get("/api/v1/analytics", params={"range": "999d"}) assert resp.status_code == 400 def test_analytics_range_zero_rejected(self) -> None: app = create_e2e_app() with TestClient(app) as client: resp = client.get("/api/v1/analytics", params={"range": "0d"}) assert resp.status_code == 400 class TestFullUserJourney: """End-to-end journey: chat -> then check replay list shows the conversation.""" def test_chat_then_check_conversations_endpoint(self) -> None: """After chatting via WebSocket, the conversations endpoint is reachable.""" from tests.e2e.conftest import make_chunk, make_graph graph = make_graph(chunks=[make_chunk("Your order is shipped.")]) now = datetime.now(tz=timezone.utc).isoformat() pool = ReplayPool( conversations=[ { "thread_id": "e2e-journey-1", "created_at": now, "last_activity": now, "status": "active", "total_tokens": 50, "total_cost_usd": 0.001, }, ], ) app = create_e2e_app(graph=graph, pool=pool) with TestClient(app) as client: # Step 1: Chat via WebSocket with client.websocket_connect("/ws") as ws: ws.send_json({ "type": "message", "thread_id": "e2e-journey-1", "content": "Where is my order?", }) messages = [] for _ in range(20): msg = ws.receive_json() messages.append(msg) if msg["type"] in ("message_complete", "error"): break assert any(m["type"] == "message_complete" for m in messages) # Step 2: Check conversations endpoint resp = client.get("/api/v1/conversations") assert resp.status_code == 200 body = resp.json() assert body["success"] is True assert any( c["thread_id"] == "e2e-journey-1" for c in body["data"]["conversations"] ) # Step 3: Health check still works resp = client.get("/api/v1/health") assert resp.status_code == 200