Files
Edu/services/ai/tests/test_main_app.py
2026-07-10 18:57:39 +08:00

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