Files
DjangoAdmin_FastAPI_AntdVue/tests/test_middleware_smoke.py
T
2026-09-12 18:15:20 +08:00

220 lines
7.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.
# -*- coding: utf-8 -*-
"""中间件修复冒烟测试:验证响应体重建、事务提交/回滚、操作日志捕获。
无需真实 MySQL/Redis:SessionLocal 与操作日志记录均被 monkeypatch。
"""
import asyncio
import sys
import pytest
sys.path.insert(0, 'src')
class FakeSession:
def __init__(self):
self.commits = 0
self.rollbacks = 0
self.closed = False
def commit(self):
self.commits += 1
def rollback(self):
self.rollbacks += 1
def close(self):
self.closed = True
@pytest.fixture
def fake_session():
return FakeSession()
def _make_app(fake_session, monkeypatch, log_sink):
from fastapi import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
import middleware.db_session as mw_db
import middleware.operation_log as mw_op
from modules.operation_log import service as op_service
# 打桩 DB 会话与操作日志记录,避免真实依赖
monkeypatch.setattr(mw_db, "SessionLocal", lambda: fake_session)
monkeypatch.setattr(
op_service, "record_operation_log",
lambda request, **kw: log_sink.append(kw))
app = FastAPI()
@app.middleware("http")
async def inject_user(request: Request, call_next):
request.state.user_id = 1
request.state.username = "tester"
return await call_next(request)
# 与 create_app 相同的嵌套顺序:db_session 内层,operation_log 外层
app.add_middleware(
BaseHTTPMiddleware, dispatch=mw_db.db_session_middleware)
app.add_middleware(
BaseHTTPMiddleware, dispatch=mw_op.operation_log_middleware)
app.add_middleware(BaseHTTPMiddleware, dispatch=inject_user)
@app.post("/ok")
def ok():
return {"code": 0, "data": None, "msg": "操作成功", "ok": True}
@app.post("/fail")
def fail():
from starlette.responses import JSONResponse
return JSONResponse(
{"code": 1, "data": None, "msg": "业务失败", "ok": False})
@app.post("/boom")
def boom():
raise RuntimeError("unexpected error")
@app.get("/read")
def read():
return {"code": 0, "data": {"x": 1}}
return app
def test_response_body_helpers():
"""read_response_body / rebuild_response 基础行为。"""
from starlette.responses import JSONResponse
from utils.response_body import read_response_body, rebuild_response
# 普通 Response:body 可直接读,consumed=False
resp = JSONResponse({"code": 0})
body, consumed = asyncio.run(read_response_body(resp))
assert consumed is False
assert b'"code"' in body
# 流式响应:需消费 body_iterator,consumed=True
from starlette.responses import StreamingResponse
resp2 = StreamingResponse(iter([b'{"code": 1, "msg": "x"}']))
body2, consumed2 = asyncio.run(read_response_body(resp2))
assert consumed2 is True
assert body2 == b'{"code": 1, "msg": "x"}'
rebuilt = rebuild_response(resp2, body2)
assert rebuilt.status_code == 200
assert rebuilt.body == body2
def test_write_ok_commits(fake_session, monkeypatch):
from fastapi.testclient import TestClient
log_sink = []
app = _make_app(fake_session, monkeypatch, log_sink)
with TestClient(app) as client:
resp = client.post("/ok")
assert resp.json()["code"] == 0
assert fake_session.commits == 1
assert fake_session.rollbacks == 0
assert fake_session.closed
assert len(log_sink) == 1
assert log_sink[0]["status"] == 0
def test_write_fail_rolls_back(fake_session, monkeypatch):
from fastapi.testclient import TestClient
log_sink = []
app = _make_app(fake_session, monkeypatch, log_sink)
with TestClient(app) as client:
resp = client.post("/fail")
# 客户端仍能收到完整响应体(重建后未被消费掉)
assert resp.json()["code"] == 1
assert resp.json()["msg"] == "业务失败"
assert fake_session.commits == 0
assert fake_session.rollbacks == 1
assert fake_session.closed
assert len(log_sink) == 1
assert log_sink[0]["status"] == 1
assert "业务失败" in log_sink[0]["error"]
def test_read_does_not_touch_tx(fake_session, monkeypatch):
from fastapi.testclient import TestClient
log_sink = []
app = _make_app(fake_session, monkeypatch, log_sink)
with TestClient(app) as client:
resp = client.get("/read")
assert resp.json()["code"] == 0
assert fake_session.commits == 0
assert fake_session.rollbacks == 0
assert len(log_sink) == 0 # GET 不记录操作日志
def test_unexpected_exception_rolls_back_and_client_gets_500(fake_session, monkeypatch):
from fastapi.testclient import TestClient
log_sink = []
app = _make_app(fake_session, monkeypatch, log_sink)
with TestClient(app, raise_server_exceptions=False) as client:
resp = client.post("/boom")
assert resp.status_code == 500
assert fake_session.commits == 0
assert fake_session.rollbacks == 1
assert len(log_sink) == 1
assert log_sink[0]["status"] == 1
def test_is_business_success_fails_closed_on_non_json():
"""非 JSON / 空体 / 非对象响应一律视为失败,仅 JSON code==0 提交。"""
from middleware import db_session as mw_db
assert mw_db._is_business_success(b'{"code":0,"msg":"ok"}') is True
assert mw_db._is_business_success(b'{"code":1,"msg":"fail"}') is False
# 非 JSON 2xx 响应体:无法确认业务成功 → 失败(回滚)
assert mw_db._is_business_success(b'<html>ok</html>') is False
# 空体(如 204)→ 失败
assert mw_db._is_business_success(b'') is False
# 非对象 JSON(数组)→ 失败
assert mw_db._is_business_success(b'[1,2,3]') is False
# 非法 JSON → 失败
assert mw_db._is_business_success(b'not json') is False
def test_http_exception_handler_unifies_404_405_format():
"""注册核心异常处理器后,404/405 等 Starlette HTTP 异常统一为 {code:1}+200。
不加此处理器时,Starlette 默认 HTTPException 处理器优先级高于 Exception
兜底,会返回非 200 的 {"detail": ...},破坏全站统一响应格式。
"""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from core.app import register_exception
app = FastAPI()
@app.get("/exists")
def exists():
return {"code": 0, "data": None, "msg": "ok", "ok": True}
register_exception(app)
with TestClient(app) as client:
# 404:不存在路由
resp = client.get("/nonexistent")
body = resp.json()
assert resp.status_code == 200
assert body["code"] == 1
assert body["ok"] is False
assert body["data"] is None
assert body["msg"] == "Not Found"
# 405:方法不允许
resp = client.post("/exists")
body = resp.json()
assert resp.status_code == 200
assert body["code"] == 1
assert body["ok"] is False
assert body["msg"] == "Method Not Allowed"