- API versioning: all REST endpoints prefixed with /api/v1/ - Structured logging: replaced stdlib logging with structlog (console/JSON modes) - Alembic migrations: versioned DB schema with initial migration - Error standardization: global exception handlers for consistent envelope format - Interrupt cleanup: asyncio background task for expired interrupt removal - Integration tests: +30 tests (analytics, replay, openapi, error, session APIs) - Frontend tests: +57 tests (all components, pages, useWebSocket hook) - Backend: 557 tests, 89.75% coverage | Frontend: 80 tests, 16 test files
129 lines
4.5 KiB
Python
129 lines
4.5 KiB
Python
"""Integration tests for global error handling and envelope format consistency.
|
|
|
|
Tests that all error responses from the FastAPI app conform to the
|
|
standard envelope: {"success": false, "data": null, "error": "..."}.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
def _build_app():
|
|
"""Build the actual FastAPI app with exception handlers but mocked state."""
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from app.analytics.api import router as analytics_router
|
|
from app.api_utils import envelope
|
|
from app.replay.api import router as replay_router
|
|
|
|
test_app = FastAPI()
|
|
test_app.include_router(analytics_router)
|
|
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.exception_handler(Exception)
|
|
async def _catch_all(request, exc):
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content=envelope(None, success=False, error="Internal server error"),
|
|
)
|
|
|
|
@test_app.get("/api/v1/health")
|
|
def health_check():
|
|
return {"status": "ok", "version": "0.6.0"}
|
|
|
|
test_app.state.settings = MagicMock(admin_api_key="")
|
|
test_app.state.pool = MagicMock()
|
|
|
|
return test_app
|
|
|
|
|
|
class TestEnvelopeFormat:
|
|
"""Tests that error responses consistently follow envelope format."""
|
|
|
|
async def test_http_400_produces_envelope(self) -> None:
|
|
"""A 400 error returns standard envelope with success=false."""
|
|
test_app = _build_app()
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=test_app), base_url="http://test"
|
|
) as client:
|
|
resp = await client.get("/api/v1/analytics", params={"range": "invalid"})
|
|
|
|
assert resp.status_code == 400
|
|
body = resp.json()
|
|
assert body["success"] is False
|
|
assert body["data"] is None
|
|
assert isinstance(body["error"], str)
|
|
assert len(body["error"]) > 0
|
|
|
|
async def test_validation_error_produces_422_envelope(self) -> None:
|
|
"""Invalid query param type returns 422 with envelope format."""
|
|
test_app = _build_app()
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=test_app), base_url="http://test"
|
|
) as client:
|
|
# page must be >= 1; passing 0 triggers validation error
|
|
resp = await client.get("/api/v1/conversations", params={"page": 0})
|
|
|
|
assert resp.status_code == 422
|
|
body = resp.json()
|
|
assert body["success"] is False
|
|
assert body["data"] is None
|
|
assert isinstance(body["error"], str)
|
|
|
|
async def test_all_error_fields_present(self) -> None:
|
|
"""Error envelope contains exactly success, data, and error keys."""
|
|
test_app = _build_app()
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=test_app), base_url="http://test"
|
|
) as client:
|
|
resp = await client.get("/api/v1/analytics", params={"range": "bad"})
|
|
|
|
body = resp.json()
|
|
assert set(body.keys()) == {"success", "data", "error"}
|
|
|
|
async def test_health_endpoint_returns_200(self) -> None:
|
|
"""Health check returns 200 with status ok."""
|
|
test_app = _build_app()
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=test_app), base_url="http://test"
|
|
) as client:
|
|
resp = await client.get("/api/v1/health")
|
|
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["status"] == "ok"
|
|
assert "version" in body
|
|
|
|
async def test_unknown_endpoint_returns_404(self) -> None:
|
|
"""Requesting a non-existent path returns 404."""
|
|
test_app = _build_app()
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=test_app), base_url="http://test"
|
|
) as client:
|
|
resp = await client.get("/api/v1/nonexistent-path")
|
|
|
|
# FastAPI returns 404 for unknown routes; may or may not be wrapped
|
|
assert resp.status_code == 404
|