229 lines
8.2 KiB
Python
229 lines
8.2 KiB
Python
"""main 集成冒烟(B7):路由挂载 / trace 中间件 / lifespan 启动期校验 / 统一错误体。
|
||
|
||
main app 全路由经 TestClient 走真实 lifespan(dev 环境);仓储 monkeypatch
|
||
注入 sqlite(audit_log 供 401 留痕);Redis 网关注入 fake(不依赖本机 Redis)。
|
||
全链路 trace 一致性与集成测试归 B8 conftest,此处只验中间件行为本身。
|
||
"""
|
||
|
||
import re
|
||
|
||
import pytest
|
||
from fastapi import Request
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import text
|
||
|
||
from _ddl import create_sqlite_engine
|
||
|
||
from app.config.settings import settings
|
||
from app.main import app
|
||
from app.repository.risk_repository import RiskRepository
|
||
from app.service.risk import redis_gateway
|
||
from app.utils.exceptions import ApiError
|
||
|
||
TRACE_HEADER_PATTERN = r"^trc-[0-9a-f]{16}$"
|
||
|
||
|
||
class FakeGateway:
|
||
def __init__(self):
|
||
self.messages = []
|
||
self.deletes = []
|
||
|
||
def publish(self, channel, payload):
|
||
self.messages.append((channel, payload))
|
||
|
||
def delete(self, *keys):
|
||
self.deletes.append(keys)
|
||
|
||
def exists(self, key: str) -> bool:
|
||
return False
|
||
|
||
def ping(self) -> bool:
|
||
return True
|
||
|
||
|
||
@pytest.fixture()
|
||
def client(monkeypatch):
|
||
engine = create_sqlite_engine() # DDL 单一事实源(B4 评审 P3-12)
|
||
repo = RiskRepository(engine=engine)
|
||
from app.api import deps as deps_mod
|
||
from app.api import risk as risk_api
|
||
from app.api import simulate as simulate_mod
|
||
|
||
monkeypatch.setattr(risk_api, "_repo", lambda: repo)
|
||
monkeypatch.setattr(simulate_mod, "_repo", lambda: repo)
|
||
monkeypatch.setattr(deps_mod, "RiskRepository", lambda: repo)
|
||
with TestClient(app) as c: # lifespan:dev 放行;Redis 惰性连接不触网
|
||
monkeypatch.setattr(redis_gateway, "_gateway", FakeGateway())
|
||
yield c
|
||
engine.dispose()
|
||
|
||
|
||
def test_health(client):
|
||
r = client.get("/health")
|
||
assert r.status_code == 200 and r.json()["status"] == "ok"
|
||
|
||
|
||
def test_health_probe(client):
|
||
r = client.get("/health?probe=1")
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["ok"] is True
|
||
assert body["checks"]["chat_close_all"] is True
|
||
|
||
|
||
def test_api_ready(client):
|
||
r = client.get("/api/ready")
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "checks" in body
|
||
assert body["checks"]["chat_close_all"] is True
|
||
assert body["checks"]["products_nav_history"] is True
|
||
assert body["ok"] is True
|
||
|
||
|
||
def test_api_ready_ok_when_redis_down_but_routes_mounted(client, monkeypatch):
|
||
class BrokenGateway:
|
||
def ping(self) -> bool:
|
||
raise ConnectionError("redis unavailable")
|
||
|
||
monkeypatch.setattr(redis_gateway, "_gateway", BrokenGateway())
|
||
body = client.get("/api/ready").json()
|
||
assert body["ok"] is True
|
||
assert body["degraded"] is True
|
||
assert body["checks"]["redis"] is False
|
||
|
||
|
||
def test_all_routers_mounted(client):
|
||
paths = client.get("/openapi.json").json()["paths"]
|
||
assert set(paths) == {
|
||
"/health",
|
||
"/api/ready",
|
||
"/api/auth/login",
|
||
"/api/customers/{customer_id}",
|
||
"/api/customers/{customer_id}/holdings",
|
||
"/api/customers/{customer_id}/threshold-check",
|
||
"/api/customers/{customer_id}/trades",
|
||
"/api/customers/{customer_id}/products",
|
||
"/api/products",
|
||
"/api/products/{product_id}",
|
||
"/api/products/{product_id}/nav",
|
||
"/api/products/{product_id}/nav/history",
|
||
"/api/advisors/{advisor_id}/customers",
|
||
"/api/staff/me",
|
||
"/api/compliance/suitability-check",
|
||
"/api/risk/alerts",
|
||
"/api/risk/alerts/{alert_id}/handle",
|
||
"/api/risk/suitability/check",
|
||
"/api/risk/aml/scan",
|
||
"/api/simulate/trade",
|
||
"/api/chat",
|
||
"/api/chat/sessions",
|
||
"/api/chat/sessions/close-all",
|
||
"/api/chat/sessions/{session_id}/messages",
|
||
"/api/chat/sessions/{session_id}/close",
|
||
"/api/chat/stream",
|
||
"/api/chat/visitor",
|
||
"/api/analyst/chat",
|
||
"/api/analyst/interpret",
|
||
"/api/analyst/analyze",
|
||
"/api/analyst/template-prompts",
|
||
"/api/analyst/query/{trace_id}/sample",
|
||
"/api/analyst/escalate",
|
||
"/api/analyst/dashboard",
|
||
"/api/analyst/assets",
|
||
"/api/analyst/assets/{kind}/{asset_id}/publish",
|
||
"/api/analyst/dict/ambiguity-check",
|
||
"/api/analyst/ops/metrics",
|
||
"/api/advisor-agent/allocation/ping",
|
||
"/api/advisor-agent/compliance/content-check",
|
||
"/api/advisor-agent/compliance/ping",
|
||
"/api/advisor-agent/compliance/rules",
|
||
"/api/advisor-agent/compliance/rules/{rule_id}",
|
||
"/api/advisor-agent/copy/track",
|
||
"/api/advisor-agent/dashboard/ping",
|
||
"/api/advisor-agent/guard/check",
|
||
"/api/advisor-agent/kyc/ping",
|
||
"/api/advisor-agent/kyc/sessions",
|
||
"/api/advisor-agent/kyc/sessions/{session_id}",
|
||
"/api/advisor-agent/kyc/sessions/{session_id}/chat",
|
||
"/api/advisor-agent/kyc/sessions/{session_id}/complete",
|
||
"/api/advisor-agent/market-alerts",
|
||
"/api/advisor-agent/market-alerts/generate",
|
||
"/api/advisor-agent/market-alerts/ping",
|
||
"/api/advisor-agent/market-alerts/scan",
|
||
"/api/advisor-agent/market-alerts/{alert_id}/feedback",
|
||
"/api/advisor-agent/market/fund/{fund_code}",
|
||
"/api/advisor-agent/script-templates",
|
||
"/api/advisor-agent/script-templates/ping",
|
||
"/api/advisor-agent/script-templates/search",
|
||
"/api/advisor-agent/script-templates/{template_id}",
|
||
"/api/advisor-agent/script-templates/{template_id}/use",
|
||
"/api/simulate/trade/convert/{convert_group_id}/cancel",
|
||
"/api/simulate/trade/convert/{convert_group_id}",
|
||
"/api/admin/convert/confirm",
|
||
}
|
||
|
||
|
||
def test_trace_header_generated(client):
|
||
r = client.get("/health")
|
||
assert r.headers["X-Trace-Id"] and re.fullmatch(TRACE_HEADER_PATTERN, r.headers["X-Trace-Id"])
|
||
|
||
|
||
def test_trace_header_passthrough(client):
|
||
tid = "trc-abc123def45678"
|
||
assert client.get("/health", headers={"X-Trace-Id": tid}).headers["X-Trace-Id"] == tid
|
||
|
||
|
||
def test_trace_header_invalid_regenerated(client):
|
||
bad = "bad id!"
|
||
tid = client.get("/health", headers={"X-Trace-Id": bad}).headers["X-Trace-Id"]
|
||
assert tid != bad and re.fullmatch(TRACE_HEADER_PATTERN, tid)
|
||
|
||
|
||
def test_unified_error_body_401_with_trace(client):
|
||
"""手册 §10 错误体四键 + trace_id/request_id 分别对齐响应头(T-02 独立双 ID)。"""
|
||
r = client.get("/api/risk/alerts") # 无 debug 头
|
||
assert r.status_code == 401
|
||
body = r.json()
|
||
assert body["error_code"] == "AUTH_401_MISSING_DEBUG_HEADERS"
|
||
assert body["message"]
|
||
assert set(body) == {"error_code", "message", "trace_id", "request_id"}
|
||
assert body["trace_id"] == r.headers["X-Trace-Id"]
|
||
assert body["request_id"] == r.headers["X-Request-Id"]
|
||
assert body["trace_id"] != body["request_id"]
|
||
|
||
|
||
def test_trace_middleware_reraises_api_error(monkeypatch):
|
||
"""trace 中间件不得把 ApiError 吞成 500(须交给 FastAPI exception handler)。"""
|
||
from app.main import trace_middleware
|
||
|
||
async def _call_next(_request):
|
||
raise ApiError(403, "AUTH_403_TEST", "forbidden for test")
|
||
|
||
request = Request({"type": "http", "method": "GET", "path": "/", "headers": []})
|
||
with pytest.raises(ApiError) as exc_info:
|
||
import asyncio
|
||
|
||
asyncio.run(trace_middleware(request, _call_next))
|
||
assert exc_info.value.status_code == 403
|
||
|
||
|
||
def test_lifespan_rejects_debug_factory_in_non_dev(monkeypatch):
|
||
"""挂账⑤(T-01 后兜底分支):debug 工厂在场 + 非 dev → 拒绝启动。"""
|
||
monkeypatch.setattr(settings, "app_env", "production")
|
||
from app.api import deps as deps_mod
|
||
|
||
monkeypatch.setattr(deps_mod, "AUTH_FACTORY_IS_DEBUG", True)
|
||
with pytest.raises(RuntimeError, match="debug auth factory is wired"):
|
||
with TestClient(app):
|
||
pass
|
||
|
||
|
||
def test_lifespan_rejects_jwt_not_ready_in_non_dev(monkeypatch):
|
||
"""T-01:非 dev 且 RS256 公钥未配置(HS256 dev secret)→ 拒绝启动。"""
|
||
monkeypatch.setattr(settings, "app_env", "production")
|
||
monkeypatch.setattr(settings, "jwt_public_key_path", "")
|
||
with pytest.raises(RuntimeError, match="JWT auth not ready"):
|
||
with TestClient(app):
|
||
pass
|