配置 SSOT(TOML + .env) 统一错误契约 Auth 与事务边界 Redis / Celery 可靠性:业务 Redis(DB/0)与 Celery broker/backend(DB/1)显式拆分;连接池、sync client 可观测性(OpenTelemetry + LGTM)
242 lines
8.0 KiB
Python
242 lines
8.0 KiB
Python
"""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
|