# -*- 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'ok') 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"