468 lines
16 KiB
Python
468 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).
|
||
|
||
注入 ContentClientMock 到 workflow_service,因为测试环境无真实 content gRPC server。
|
||
"""
|
||
from src.ai.clients import ContentClientMock
|
||
from src.ai.main import _permission_guard, _workflow_service, app
|
||
|
||
original = _permission_guard._dev_mode
|
||
original_content = _workflow_service._content_client # noqa: SLF001
|
||
_permission_guard._dev_mode = True
|
||
_workflow_service._content_client = ContentClientMock() # noqa: SLF001
|
||
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
|
||
_workflow_service._content_client = original_content # noqa: SLF001
|
||
|
||
|
||
@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
|