Files
life-echo/api/tests/test_app_error_contract.py
Sully 53e0065e3e refactor(api): TOML 配置 SSOT、统一错误契约、Auth/事务加固与可观测性 (#33)
配置 SSOT(TOML + .env)
统一错误契约
Auth 与事务边界
Redis / Celery 可靠性:业务 Redis(DB/0)与 Celery broker/backend(DB/1)显式拆分;连接池、sync client
可观测性(OpenTelemetry + LGTM)
2026-05-22 13:44:50 +08:00

242 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AppError 统一错误契约 HTTP 场景测试。"""
import pytest
from fastapi import FastAPI, HTTPException
from httpx import ASGITransport, AsyncClient
from app.core.errors import (
AuthenticationError,
NotFoundError,
QuotaExceededError,
RateLimitedError,
ValidationError,
register_exception_handlers,
)
from app.core.middleware import RequestIdMiddleware
from app.features.auth.service import AuthError
from app.features.payment.payment_exceptions import PaymentError
def _test_app() -> FastAPI:
app = FastAPI()
app.add_middleware(RequestIdMiddleware)
register_exception_handlers(app)
@app.get("/not-found")
async def _not_found():
raise NotFoundError("资源不存在")
@app.get("/auth-failed")
async def _auth_failed():
raise AuthenticationError("无法验证凭据")
@app.get("/quota")
async def _quota():
raise QuotaExceededError("配额已用尽")
@app.get("/rate-limited")
async def _rate_limited():
raise RateLimitedError("发送过于频繁请30秒后再试")
@app.get("/validation")
async def _validation():
raise ValidationError("参数无效")
@app.get("/auth-domain")
async def _auth_domain():
raise AuthError("该邮箱已被注册", "EMAIL_EXISTS")
@app.get("/payment-domain")
async def _payment_domain():
raise PaymentError("支付配置错误", code="PAYMENT_CONFIG_ERROR")
@app.get("/http-string")
async def _http_string():
raise HTTPException(status_code=400, detail="请求无效")
@app.get("/http-429")
async def _http_429():
raise HTTPException(status_code=429, detail="发送过于频繁")
@app.get("/http-list")
async def _http_list():
raise HTTPException(
status_code=422,
detail=[{"loc": ["body", "phone"], "msg": "field required"}],
)
@app.get("/http-unknown-status")
async def _http_unknown_status():
raise HTTPException(status_code=418, detail="teapot")
@app.get("/http-unknown-5xx")
async def _http_unknown_5xx():
raise HTTPException(status_code=599, detail="upstream glitch")
@app.get("/boom")
async def _boom():
raise RuntimeError("secret_internal_xyz")
return app
@pytest.mark.asyncio
async def test_not_found_error_contract() -> None:
app = _test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/not-found")
assert r.status_code == 404
body = r.json()
assert body["error_code"] == "NOT_FOUND"
assert body["message"] == "资源不存在"
assert "request_id" in body
@pytest.mark.asyncio
async def test_authentication_error_contract() -> None:
app = _test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/auth-failed")
assert r.status_code == 401
body = r.json()
assert body["error_code"] == "AUTHENTICATION_FAILED"
assert body["message"] == "无法验证凭据"
assert r.headers.get("www-authenticate") == "Bearer"
@pytest.mark.asyncio
async def test_quota_exceeded_error_contract() -> None:
app = _test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/quota")
assert r.status_code == 429
body = r.json()
assert body["error_code"] == "QUOTA_EXCEEDED"
assert body["message"] == "配额已用尽"
@pytest.mark.asyncio
async def test_rate_limited_error_contract() -> None:
app = _test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/rate-limited")
assert r.status_code == 429
body = r.json()
assert body["error_code"] == "RATE_LIMITED"
assert body["message"] == "发送过于频繁请30秒后再试"
@pytest.mark.asyncio
async def test_validation_error_contract() -> None:
app = _test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/validation")
assert r.status_code == 422
body = r.json()
assert body["error_code"] == "VALIDATION_ERROR"
assert body["message"] == "参数无效"
@pytest.mark.asyncio
async def test_auth_error_domain_contract() -> None:
app = _test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/auth-domain")
assert r.status_code == 400
body = r.json()
assert body["error_code"] == "EMAIL_EXISTS"
assert body["message"] == "该邮箱已被注册"
@pytest.mark.asyncio
async def test_payment_error_domain_contract() -> None:
app = _test_app()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/payment-domain")
assert r.status_code == 502
body = r.json()
assert body["error_code"] == "PROVIDER_ERROR"
assert body["message"] == "支付配置错误"
@pytest.mark.asyncio
async def test_http_exception_string_detail_contract() -> None:
app = _test_app()
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/http-string")
assert r.status_code == 400
body = r.json()
assert body["error_code"] == "BAD_REQUEST"
assert body["message"] == "请求无效"
assert "request_id" in body
@pytest.mark.asyncio
async def test_http_exception_429_maps_to_rate_limited() -> None:
app = _test_app()
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/http-429")
assert r.status_code == 429
body = r.json()
assert body["error_code"] == "RATE_LIMITED"
assert body["message"] == "发送过于频繁"
@pytest.mark.asyncio
async def test_http_exception_list_detail_contract() -> None:
app = _test_app()
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/http-list")
assert r.status_code == 422
body = r.json()
assert body["error_code"] == "VALIDATION_ERROR"
assert "body.phone" in body["message"]
assert "field required" in body["message"]
@pytest.mark.asyncio
async def test_http_exception_unknown_5xx_maps_to_internal_error() -> None:
app = _test_app()
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/http-unknown-5xx")
assert r.status_code == 599
body = r.json()
assert body["error_code"] == "INTERNAL_ERROR"
assert body["message"] == "upstream glitch"
@pytest.mark.asyncio
async def test_http_exception_unknown_status_maps_to_bad_request() -> None:
app = _test_app()
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/http-unknown-status")
assert r.status_code == 418
body = r.json()
assert body["error_code"] == "BAD_REQUEST"
assert body["message"] == "teapot"
@pytest.mark.asyncio
async def test_unhandled_exception_contract() -> None:
app = _test_app()
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as client:
r = await client.get("/boom")
assert r.status_code == 500
body = r.json()
assert body["error_code"] == "INTERNAL_ERROR"
assert body["message"] == "服务器内部错误"
assert "secret_internal_xyz" not in r.text