"""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