220 lines
7.0 KiB
Python
220 lines
7.0 KiB
Python
# -*- 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"
|