463 lines
16 KiB
Python
463 lines
16 KiB
Python
"""FastAPI application endpoint and error handler tests.
|
|
|
|
Tests the HTTP layer of the AI gateway service using httpx ASGITransport
|
|
(without triggering lifespan / Redis / Kafka / gRPC connections).
|
|
|
|
All global services are initialized at module import time with redis=None
|
|
and no LLM API keys, so they degrade gracefully:
|
|
- PermissionGuard: toggled via _dev_mode per fixture
|
|
- RateLimiter: redis=None → allows all
|
|
- ChatService/QuestionService/ExpressionService: all providers unavailable → degraded responses
|
|
- WorkflowStateStore: redis=None → in-memory store
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
from collections.abc import AsyncGenerator
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from httpx import ASGITransport
|
|
from starlette.requests import Request
|
|
|
|
from src.ai.errors import AIError
|
|
from src.ai.errors.codes import ErrorCode
|
|
from src.ai.middleware.auth import extract_user_context
|
|
from src.ai.middleware.error_handler import (
|
|
GlobalErrorHandler,
|
|
grpc_error_mapper,
|
|
register_error_handlers,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
async def client() -> AsyncGenerator[httpx.AsyncClient, None]:
|
|
"""HTTP client wired to the FastAPI app (dev_mode=True, permissions skipped)."""
|
|
from src.ai.main import _permission_guard, app
|
|
|
|
original = _permission_guard._dev_mode
|
|
_permission_guard._dev_mode = True
|
|
try:
|
|
transport = ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
finally:
|
|
_permission_guard._dev_mode = original
|
|
|
|
|
|
@pytest.fixture
|
|
async def prod_client() -> AsyncGenerator[httpx.AsyncClient, None]:
|
|
"""HTTP client with permission enforcement enabled (dev_mode=False)."""
|
|
from src.ai.main import _permission_guard, app
|
|
|
|
original = _permission_guard._dev_mode
|
|
_permission_guard._dev_mode = False
|
|
try:
|
|
transport = ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
finally:
|
|
_permission_guard._dev_mode = original
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Health endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_healthz(client: httpx.AsyncClient) -> None:
|
|
"""GET /healthz returns 200 with service name."""
|
|
resp = await client.get("/healthz")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["status"] == "ok"
|
|
assert body["service"] == "ai"
|
|
|
|
|
|
async def test_readyz(client: httpx.AsyncClient) -> None:
|
|
"""GET /readyz returns 200 with readiness fields."""
|
|
resp = await client.get("/readyz")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["status"] == "ok"
|
|
assert body["service"] == "ai"
|
|
assert "llm_configured" in body
|
|
assert "degraded" in body
|
|
assert "grpc_running" in body
|
|
assert "providers" in body
|
|
|
|
|
|
async def test_metrics_endpoint(client: httpx.AsyncClient) -> None:
|
|
"""GET /metrics/ returns 200 (Prometheus metrics)."""
|
|
resp = await client.get("/metrics/", follow_redirects=True)
|
|
assert resp.status_code == 200
|
|
assert len(resp.text) > 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error handler unit tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_handle_ai_error() -> None:
|
|
"""GlobalErrorHandler.handle_ai_error returns correct JSONResponse."""
|
|
exc = AIError(
|
|
ErrorCode.AI_UNAUTHORIZED,
|
|
"User not authenticated",
|
|
details={"reason": "missing_token"},
|
|
)
|
|
resp = GlobalErrorHandler.handle_ai_error(exc, trace_id="trace-123")
|
|
assert resp.status_code == 401
|
|
body = json.loads(resp.body)
|
|
assert body["success"] is False
|
|
assert body["error"]["code"] == "AI_UNAUTHORIZED"
|
|
assert body["error"]["message"] == "User not authenticated"
|
|
assert body["error"]["details"]["reason"] == "missing_token"
|
|
assert body["error"]["traceId"] == "trace-123"
|
|
|
|
|
|
async def test_handle_unknown_error() -> None:
|
|
"""GlobalErrorHandler.handle_unknown_error returns 500."""
|
|
exc = RuntimeError("unexpected failure")
|
|
resp = GlobalErrorHandler.handle_unknown_error(exc, trace_id="trace-456")
|
|
assert resp.status_code == 500
|
|
body = json.loads(resp.body)
|
|
assert body["success"] is False
|
|
assert body["error"]["code"] == "AI_INTERNAL_ERROR"
|
|
assert body["error"]["message"] == "Internal server error"
|
|
assert body["error"]["traceId"] == "trace-456"
|
|
|
|
|
|
async def test_grpc_error_mapper_ai_error() -> None:
|
|
"""grpc_error_mapper maps AIError to correct gRPC status code."""
|
|
cases = [
|
|
(ErrorCode.AI_UNAUTHORIZED, 8), # UNAUTHENTICATED
|
|
(ErrorCode.AI_FORBIDDEN, 7), # PERMISSION_DENIED
|
|
(ErrorCode.AI_RATE_LIMITED, 9), # RESOURCE_EXHAUSTED
|
|
(ErrorCode.AI_QUOTA_EXCEEDED, 9), # RESOURCE_EXHAUSTED
|
|
(ErrorCode.AI_INVALID_MODEL, 3), # INVALID_ARGUMENT
|
|
(ErrorCode.AI_WORKFLOW_NOT_FOUND, 5), # NOT_FOUND
|
|
(ErrorCode.AI_WORKFLOW_STATE_INVALID, 10), # FAILED_PRECONDITION
|
|
(ErrorCode.AI_INTERNAL_ERROR, 13), # INTERNAL
|
|
]
|
|
for code, expected_grpc_status in cases:
|
|
exc = AIError(code, f"test {code.value}")
|
|
error_code, _msg, grpc_status = grpc_error_mapper(exc)
|
|
assert error_code == code.value
|
|
assert grpc_status == expected_grpc_status, (
|
|
f"{code.value} should map to {expected_grpc_status}, got {grpc_status}"
|
|
)
|
|
|
|
|
|
async def test_grpc_error_mapper_unknown() -> None:
|
|
"""grpc_error_mapper maps unknown exception to INTERNAL (13)."""
|
|
exc = ValueError("unknown error")
|
|
code, msg, grpc_status = grpc_error_mapper(exc)
|
|
assert code == "AI_INTERNAL_ERROR"
|
|
assert msg == "Internal server error"
|
|
assert grpc_status == 13
|
|
|
|
|
|
async def test_register_error_handlers() -> None:
|
|
"""register_error_handlers registers handlers for AIError and Exception."""
|
|
test_app = FastAPI()
|
|
register_error_handlers(test_app)
|
|
assert AIError in test_app.exception_handlers
|
|
assert Exception in test_app.exception_handlers
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Request ID middleware
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_request_id_generated(client: httpx.AsyncClient) -> None:
|
|
"""Request without X-Request-Id gets one generated in the response."""
|
|
resp = await client.get("/healthz")
|
|
assert resp.status_code == 200
|
|
assert "x-request-id" in resp.headers
|
|
assert resp.headers["x-request-id"] != ""
|
|
|
|
|
|
async def test_request_id_passthrough(client: httpx.AsyncClient) -> None:
|
|
"""Request with X-Request-Id passes through to the response."""
|
|
resp = await client.get("/healthz", headers={"X-Request-Id": "my-trace-id"})
|
|
assert resp.status_code == 200
|
|
assert resp.headers["x-request-id"] == "my-trace-id"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth context extraction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_extract_user_context_with_headers() -> None:
|
|
"""extract_user_context reads user info from Gateway headers."""
|
|
scope = {
|
|
"type": "http",
|
|
"method": "GET",
|
|
"headers": [
|
|
(b"x-user-id", b"user-123"),
|
|
(b"x-user-role", b"teacher"),
|
|
(b"x-school-id", b"school-456"),
|
|
],
|
|
}
|
|
request = Request(scope)
|
|
ctx = extract_user_context(request)
|
|
assert ctx.user_id == "user-123"
|
|
assert ctx.role == "teacher"
|
|
assert ctx.school_id == "school-456"
|
|
assert ctx.is_authenticated is True
|
|
assert ctx.is_empty is False
|
|
|
|
|
|
async def test_extract_user_context_empty() -> None:
|
|
"""extract_user_context returns empty context when no headers present."""
|
|
scope = {
|
|
"type": "http",
|
|
"method": "GET",
|
|
"headers": [],
|
|
}
|
|
request = Request(scope)
|
|
ctx = extract_user_context(request)
|
|
assert ctx.user_id == ""
|
|
assert ctx.role == ""
|
|
assert ctx.is_authenticated is False
|
|
assert ctx.is_empty is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chat endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_chat_success(client: httpx.AsyncClient) -> None:
|
|
"""POST /v1/ai/chat with valid body returns ChatResponse (degraded)."""
|
|
resp = await client.post(
|
|
"/v1/ai/chat",
|
|
json={"messages": [{"role": "user", "content": "hello"}]},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["success"] is True
|
|
assert body["data"]["degraded"] is True
|
|
assert "degraded" in body["data"]["content"]
|
|
|
|
|
|
async def test_chat_no_messages_raises(client: httpx.AsyncClient) -> None:
|
|
"""POST /v1/ai/chat with empty messages list returns 422."""
|
|
resp = await client.post("/v1/ai/chat", json={"messages": []})
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_chat_invalid_temperature(client: httpx.AsyncClient) -> None:
|
|
"""POST /v1/ai/chat with temperature > 2.0 returns 422."""
|
|
resp = await client.post(
|
|
"/v1/ai/chat",
|
|
json={
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
"temperature": 3.0,
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_chat_stream(client: httpx.AsyncClient) -> None:
|
|
"""POST /v1/ai/chat/stream returns SSE stream."""
|
|
resp = await client.post(
|
|
"/v1/ai/chat/stream",
|
|
json={"messages": [{"role": "user", "content": "hello"}]},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "data:" in resp.text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Generate question endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_generate_question(client: httpx.AsyncClient) -> None:
|
|
"""POST /v1/ai/generate/question returns GeneratedQuestionResponse (degraded)."""
|
|
resp = await client.post(
|
|
"/v1/ai/generate/question",
|
|
json={"prompt": "生成加法题", "subject": "数学"},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["success"] is True
|
|
assert body["data"]["degraded"] is True
|
|
|
|
|
|
async def test_generate_question_stream(client: httpx.AsyncClient) -> None:
|
|
"""POST /v1/ai/generate/question/stream returns SSE stream."""
|
|
resp = await client.post(
|
|
"/v1/ai/generate/question/stream",
|
|
json={"prompt": "生成题目", "subject": "数学"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "data:" in resp.text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Optimize expression endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_optimize_expression(client: httpx.AsyncClient) -> None:
|
|
"""POST /v1/ai/optimize/expression returns OptimizeExpressionResponse (degraded)."""
|
|
resp = await client.post(
|
|
"/v1/ai/optimize/expression",
|
|
json={"text": "这个嗯嗯啊啊"},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["success"] is True
|
|
assert body["data"]["degraded"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lesson plan endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_generate_lesson_plan(client: httpx.AsyncClient) -> None:
|
|
"""POST /v1/ai/lesson-plan/generate starts workflow."""
|
|
resp = await client.post(
|
|
"/v1/ai/lesson-plan/generate",
|
|
json={
|
|
"class_id": "class-1",
|
|
"subject_id": "math",
|
|
"topic": "一元二次方程",
|
|
"question_count": 2,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["success"] is True
|
|
assert "workflow_id" in body["data"]
|
|
assert body["data"]["status"] == "pending"
|
|
|
|
|
|
async def test_get_lesson_plan_status(client: httpx.AsyncClient) -> None:
|
|
"""GET /v1/ai/lesson-plan/status/{id} returns workflow status."""
|
|
gen = await client.post(
|
|
"/v1/ai/lesson-plan/generate",
|
|
json={"class_id": "class-1", "subject_id": "math", "topic": "方程"},
|
|
)
|
|
workflow_id = gen.json()["data"]["workflow_id"]
|
|
|
|
resp = await client.get(f"/v1/ai/lesson-plan/status/{workflow_id}")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["success"] is True
|
|
assert body["data"]["workflow_id"] == workflow_id
|
|
|
|
|
|
async def test_get_lesson_plan_status_not_found(client: httpx.AsyncClient) -> None:
|
|
"""GET /v1/ai/lesson-plan/status/{id} with unknown ID returns 404."""
|
|
resp = await client.get("/v1/ai/lesson-plan/status/non-existent-id")
|
|
assert resp.status_code == 404
|
|
body = resp.json()
|
|
assert body["success"] is False
|
|
assert body["error"]["code"] == "AI_WORKFLOW_NOT_FOUND"
|
|
|
|
|
|
async def test_confirm_lesson_plan_success(client: httpx.AsyncClient) -> None:
|
|
"""POST /v1/ai/lesson-plan/confirm/{id} confirms after workflow completes."""
|
|
gen = await client.post(
|
|
"/v1/ai/lesson-plan/generate",
|
|
json={
|
|
"class_id": "class-1",
|
|
"subject_id": "math",
|
|
"topic": "方程",
|
|
"question_count": 1,
|
|
},
|
|
)
|
|
workflow_id = gen.json()["data"]["workflow_id"]
|
|
|
|
# Poll until the background workflow reaches pending_review
|
|
status = ""
|
|
for _ in range(30):
|
|
status_resp = await client.get(f"/v1/ai/lesson-plan/status/{workflow_id}")
|
|
status = status_resp.json()["data"]["status"]
|
|
if status == "pending_review":
|
|
break
|
|
await asyncio.sleep(0.1)
|
|
|
|
assert status == "pending_review", (
|
|
f"Workflow did not reach pending_review, got: {status}"
|
|
)
|
|
|
|
resp = await client.post(f"/v1/ai/lesson-plan/confirm/{workflow_id}")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["success"] is True
|
|
assert body["data"]["success"] is True
|
|
assert len(body["data"]["persisted_question_ids"]) > 0
|
|
|
|
|
|
async def test_confirm_lesson_plan_not_found(client: httpx.AsyncClient) -> None:
|
|
"""POST /v1/ai/lesson-plan/confirm/{id} with unknown ID returns 404."""
|
|
resp = await client.post("/v1/ai/lesson-plan/confirm/non-existent-id")
|
|
assert resp.status_code == 404
|
|
body = resp.json()
|
|
assert body["success"] is False
|
|
assert body["error"]["code"] == "AI_WORKFLOW_NOT_FOUND"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Permission enforcement (production mode)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_chat_unauthorized_in_production(prod_client: httpx.AsyncClient) -> None:
|
|
"""Unauthenticated chat request in production mode returns 401."""
|
|
resp = await prod_client.post(
|
|
"/v1/ai/chat",
|
|
json={"messages": [{"role": "user", "content": "hello"}]},
|
|
)
|
|
assert resp.status_code == 401
|
|
body = resp.json()
|
|
assert body["success"] is False
|
|
assert body["error"]["code"] == "AI_UNAUTHORIZED"
|
|
|
|
|
|
async def test_permission_denied_returns_403(prod_client: httpx.AsyncClient) -> None:
|
|
"""Student role attempting to generate questions returns 403."""
|
|
resp = await prod_client.post(
|
|
"/v1/ai/generate/question",
|
|
json={"prompt": "生成题目", "subject": "数学"},
|
|
headers={
|
|
"X-User-Id": "student-1",
|
|
"X-User-Role": "student",
|
|
},
|
|
)
|
|
assert resp.status_code == 403
|
|
body = resp.json()
|
|
assert body["success"] is False
|
|
assert body["error"]["code"] == "AI_FORBIDDEN"
|
|
|
|
|
|
async def test_chat_success_in_production_with_auth(
|
|
prod_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""Teacher with auth can chat in production mode (degraded LLM response)."""
|
|
resp = await prod_client.post(
|
|
"/v1/ai/chat",
|
|
json={"messages": [{"role": "user", "content": "hello"}]},
|
|
headers={
|
|
"X-User-Id": "teacher-1",
|
|
"X-User-Role": "teacher",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["success"] is True
|
|
assert body["data"]["degraded"] is True
|