签入版本

This commit is contained in:
2026-09-12 17:39:13 +08:00
parent 25c8b039ac
commit 3233d221b0
871 changed files with 103088 additions and 10561 deletions
+219
View File
@@ -0,0 +1,219 @@
# -*- 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"