feat: wire frontend pages to live APIs and standardize response contracts (P1)
- Backend: Add COUNT query and paginated response shape to conversations endpoint
Returns { conversations: [...], total, page, per_page } instead of flat array
- Frontend: Replace mock data in DashboardPage with fetchAnalytics() API calls
- Frontend: Replace mock data in ReplayListPage with fetchConversations() API calls
- Frontend: Replace mock data in ReplayPage with fetchReplay() API calls
- Add proper loading, empty, and error states to all three pages
- Align ConversationSummary type with actual DB columns (created_at, status)
- Update unit and E2E tests for new paginated conversation response shape
- Add fetchone() to FakeCursor for COUNT query support in E2E tests
This commit is contained in:
@@ -14,6 +14,10 @@ if TYPE_CHECKING:
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["replay"])
|
||||
|
||||
_COUNT_CONVERSATIONS_SQL = """
|
||||
SELECT COUNT(*) FROM conversations
|
||||
"""
|
||||
|
||||
_LIST_CONVERSATIONS_SQL = """
|
||||
SELECT thread_id, created_at, last_activity, status, total_tokens, total_cost_usd
|
||||
FROM conversations
|
||||
@@ -48,13 +52,22 @@ async def list_conversations(
|
||||
pool = await get_pool(request)
|
||||
offset = (page - 1) * per_page
|
||||
async with pool.connection() as conn:
|
||||
count_cursor = await conn.execute(_COUNT_CONVERSATIONS_SQL)
|
||||
count_row = await count_cursor.fetchone()
|
||||
total = count_row[0] if count_row else 0
|
||||
|
||||
cursor = await conn.execute(
|
||||
_LIST_CONVERSATIONS_SQL,
|
||||
{"limit": per_page, "offset": offset},
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return _envelope([dict(row) for row in rows])
|
||||
return _envelope({
|
||||
"conversations": [dict(row) for row in rows],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/replay/{thread_id}")
|
||||
|
||||
@@ -107,6 +107,9 @@ class FakeCursor:
|
||||
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."""
|
||||
|
||||
@@ -44,6 +44,8 @@ class ReplayPool(FakePool):
|
||||
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:
|
||||
return FakeCursor(self._convos)
|
||||
if "checkpoints" in query:
|
||||
@@ -94,9 +96,11 @@ class TestFlow6ReplayConversation:
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert len(body["data"]) == 2
|
||||
assert body["data"][0]["thread_id"] == "conv-001"
|
||||
assert body["data"][1]["thread_id"] == "conv-002"
|
||||
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 = [
|
||||
@@ -206,7 +210,8 @@ class TestFullUserJourney:
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert any(
|
||||
c["thread_id"] == "e2e-journey-1" for c in body["data"]
|
||||
c["thread_id"] == "e2e-journey-1"
|
||||
for c in body["data"]["conversations"]
|
||||
)
|
||||
|
||||
# Step 3: Health check still works
|
||||
|
||||
@@ -19,13 +19,35 @@ def _build_app() -> FastAPI:
|
||||
return app
|
||||
|
||||
|
||||
def _make_mock_pool(fetchall_result: list[dict]) -> MagicMock:
|
||||
"""Build a mock pool that returns the given rows from fetchall."""
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchall = AsyncMock(return_value=fetchall_result)
|
||||
def _make_mock_pool(
|
||||
fetchall_result: list[dict],
|
||||
*,
|
||||
count: int | None = None,
|
||||
) -> MagicMock:
|
||||
"""Build a mock pool that returns the given rows from fetchall.
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
When *count* is provided, the first execute() call returns a cursor
|
||||
whose fetchone() yields ``(count,)`` (for the COUNT query) and the
|
||||
second call returns the rows via fetchall(). When *count* is None
|
||||
(the default), a single cursor backed by *fetchall_result* is used
|
||||
for all calls.
|
||||
"""
|
||||
if count is not None:
|
||||
count_cursor = AsyncMock()
|
||||
count_cursor.fetchone = AsyncMock(return_value=(count,))
|
||||
|
||||
rows_cursor = AsyncMock()
|
||||
rows_cursor.fetchall = AsyncMock(return_value=fetchall_result)
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.execute = AsyncMock(side_effect=[count_cursor, rows_cursor])
|
||||
else:
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchall = AsyncMock(return_value=fetchall_result)
|
||||
mock_cursor.fetchone = AsyncMock(return_value=None)
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
@@ -39,14 +61,17 @@ def _make_mock_pool(fetchall_result: list[dict]) -> MagicMock:
|
||||
class TestListConversations:
|
||||
def test_returns_200_with_empty_list(self) -> None:
|
||||
app = _build_app()
|
||||
app.state.pool = _make_mock_pool([])
|
||||
app.state.pool = _make_mock_pool([], count=0)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert isinstance(body["data"], list)
|
||||
data = body["data"]
|
||||
assert isinstance(data["conversations"], list)
|
||||
assert data["total"] == 0
|
||||
assert data["page"] == 1
|
||||
assert body["error"] is None
|
||||
|
||||
def test_returns_conversations_list(self) -> None:
|
||||
@@ -61,18 +86,20 @@ class TestListConversations:
|
||||
"total_cost_usd": 0.01,
|
||||
}
|
||||
]
|
||||
app.state.pool = _make_mock_pool(mock_rows)
|
||||
app.state.pool = _make_mock_pool(mock_rows, count=1)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations")
|
||||
body = resp.json()
|
||||
assert resp.status_code == 200
|
||||
assert len(body["data"]) == 1
|
||||
assert body["data"][0]["thread_id"] == "t1"
|
||||
data = body["data"]
|
||||
assert len(data["conversations"]) == 1
|
||||
assert data["conversations"][0]["thread_id"] == "t1"
|
||||
assert data["total"] == 1
|
||||
|
||||
def test_pagination_defaults(self) -> None:
|
||||
app = _build_app()
|
||||
app.state.pool = _make_mock_pool([])
|
||||
app.state.pool = _make_mock_pool([], count=0)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations")
|
||||
@@ -80,7 +107,7 @@ class TestListConversations:
|
||||
|
||||
def test_pagination_custom_params(self) -> None:
|
||||
app = _build_app()
|
||||
app.state.pool = _make_mock_pool([])
|
||||
app.state.pool = _make_mock_pool([], count=0)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations?page=2&per_page=10")
|
||||
@@ -88,7 +115,7 @@ class TestListConversations:
|
||||
|
||||
def test_per_page_max_capped_at_100(self) -> None:
|
||||
app = _build_app()
|
||||
app.state.pool = _make_mock_pool([])
|
||||
app.state.pool = _make_mock_pool([], count=0)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations?per_page=200")
|
||||
|
||||
Reference in New Issue
Block a user