"""Integration tests for /api/v1/conversations and /api/v1/replay/{thread_id}. Tests the full API layer with a mocked database pool, verifying routing, serialization, pagination, and error handling in envelope format. """ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock import pytest from httpx import ASGITransport, AsyncClient pytestmark = pytest.mark.integration def _make_fake_cursor(rows, *, fetchone_value=None): """Build a fake async cursor returning the given rows on fetchall.""" cursor = AsyncMock() cursor.fetchall = AsyncMock(return_value=rows) if fetchone_value is not None: cursor.fetchone = AsyncMock(return_value=fetchone_value) return cursor class _FakeConnection: """Fake async connection that returns pre-configured cursors in order.""" def __init__(self, cursors: list) -> None: self._cursors = list(cursors) self._idx = 0 async def execute(self, sql, params=None): cursor = self._cursors[self._idx] self._idx += 1 return cursor async def __aenter__(self): return self async def __aexit__(self, *args): pass class _FakePool: """Fake connection pool that yields a fake connection.""" def __init__(self, conn: _FakeConnection) -> None: self._conn = conn def connection(self): return self._conn def _build_app(pool=None): """Build a minimal FastAPI app with the replay router and mocked deps.""" from fastapi import FastAPI, HTTPException from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from app.api_utils import envelope from app.replay.api import router as replay_router test_app = FastAPI() test_app.include_router(replay_router) @test_app.exception_handler(HTTPException) async def _http_exc(request, exc): return JSONResponse( status_code=exc.status_code, content=envelope(None, success=False, error=exc.detail), ) @test_app.exception_handler(RequestValidationError) async def _validation_exc(request, exc): return JSONResponse( status_code=422, content=envelope(None, success=False, error=str(exc)), ) test_app.state.settings = MagicMock(admin_api_key="") test_app.state.pool = pool or MagicMock() return test_app class TestListConversations: """Tests for GET /api/v1/conversations endpoint.""" async def test_returns_paginated_envelope(self) -> None: """Conversations list returns envelope with pagination metadata.""" count_cursor = _make_fake_cursor([], fetchone_value=(3,)) rows = [ {"thread_id": "t1", "created_at": "2026-01-01", "last_activity": "2026-01-01", "status": "active", "total_tokens": 100, "total_cost_usd": 0.01}, {"thread_id": "t2", "created_at": "2026-01-02", "last_activity": "2026-01-02", "status": "resolved", "total_tokens": 200, "total_cost_usd": 0.02}, ] list_cursor = _make_fake_cursor(rows) conn = _FakeConnection([count_cursor, list_cursor]) pool = _FakePool(conn) test_app = _build_app(pool) async with AsyncClient( transport=ASGITransport(app=test_app), base_url="http://test" ) as client: resp = await client.get("/api/v1/conversations") assert resp.status_code == 200 body = resp.json() assert body["success"] is True assert body["data"]["total"] == 3 assert len(body["data"]["conversations"]) == 2 assert body["data"]["page"] == 1 assert body["data"]["per_page"] == 20 async def test_custom_page_and_per_page(self) -> None: """Custom page/per_page params are reflected in the response.""" count_cursor = _make_fake_cursor([], fetchone_value=(50,)) list_cursor = _make_fake_cursor([]) conn = _FakeConnection([count_cursor, list_cursor]) pool = _FakePool(conn) test_app = _build_app(pool) async with AsyncClient( transport=ASGITransport(app=test_app), base_url="http://test" ) as client: resp = await client.get("/api/v1/conversations", params={"page": 3, "per_page": 10}) assert resp.status_code == 200 body = resp.json() assert body["data"]["page"] == 3 assert body["data"]["per_page"] == 10 async def test_invalid_page_returns_422(self) -> None: """page=0 violates ge=1 constraint and returns 422 error envelope.""" test_app = _build_app() async with AsyncClient( transport=ASGITransport(app=test_app), base_url="http://test" ) as client: resp = await client.get("/api/v1/conversations", params={"page": 0}) assert resp.status_code == 422 body = resp.json() assert body["success"] is False class TestReplayEndpoint: """Tests for GET /api/v1/replay/{thread_id} endpoint.""" async def test_valid_thread_returns_timeline(self) -> None: """Replay with valid thread_id returns steps in envelope format.""" checkpoint_rows = [ { "thread_id": "abc123", "checkpoint_id": "cp1", "checkpoint": { "channel_values": { "messages": [ {"type": "human", "content": "Hello", "created_at": "2026-01-01T00:00:00Z"}, {"type": "ai", "content": "Hi there!", "created_at": "2026-01-01T00:00:01Z"}, ] } }, "metadata": {}, } ] cursor = _make_fake_cursor(checkpoint_rows) conn = _FakeConnection([cursor]) pool = _FakePool(conn) test_app = _build_app(pool) async with AsyncClient( transport=ASGITransport(app=test_app), base_url="http://test" ) as client: resp = await client.get("/api/v1/replay/abc123") assert resp.status_code == 200 body = resp.json() assert body["success"] is True assert body["data"]["thread_id"] == "abc123" assert body["data"]["total_steps"] == 2 assert len(body["data"]["steps"]) == 2 assert body["data"]["steps"][0]["type"] == "user_message" assert body["data"]["steps"][1]["type"] == "agent_response" async def test_invalid_thread_id_format_returns_400(self) -> None: """Thread IDs with path traversal characters are rejected with 400.""" test_app = _build_app() async with AsyncClient( transport=ASGITransport(app=test_app), base_url="http://test" ) as client: resp = await client.get("/api/v1/replay/../../etc/passwd") # FastAPI may return 400 from our handler or 404 from routing assert resp.status_code in (400, 404, 422) async def test_nonexistent_thread_returns_404(self) -> None: """Replay with a thread_id that has no checkpoints returns 404.""" cursor = _make_fake_cursor([]) conn = _FakeConnection([cursor]) pool = _FakePool(conn) test_app = _build_app(pool) async with AsyncClient( transport=ASGITransport(app=test_app), base_url="http://test" ) as client: resp = await client.get("/api/v1/replay/nonexistent-thread") assert resp.status_code == 404 body = resp.json() assert body["success"] is False assert "not found" in body["error"].lower()