Files
ai-kefu-admin/tests/conftest.py
T
浅念 fff4f27e98 feat: 测试端点限流(每租户5次/15分钟)
- email/test 与 push/test 复用 LoginLimiter 滑动窗模式: (kind, tenant_slug)
  键每租户 15 分钟 5 次真实出站,超限 429——guard 在端点最前(鉴权后),
  被限流的租户连 400 预检也不触发;record 只落在通过全部预检真正出站
  的那次(校验失败/未配置不消耗预算,两端口预算按 kind 互不挤占)
- conftest autouse 复位限流器(跨测试隔离);新增三测: 第 6 次 429+
  不同租户独立+email/push 互不挤占、校验失败不耗预算
- 回归: 后端 630 passed(627+3)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 10:17:52 +08:00

350 lines
14 KiB
Python

import asyncio
import os
import shutil
import tempfile
import warnings
from contextlib import contextmanager
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from urllib.parse import urlsplit
from app.engine.models import Base
# Task 5b 装配切换: app 不再有全局 engine/async_session——旧 conftest 里
# "先 import app.main 再 import chat_rest"的循环导入次序坑随全局单例一并
# 消失(routes 不再反向 import app.main),直接导入即可。
from app.main import app
from app.platform.db import make_platform_engine
from app.platform.registry import TenantRegistry
REPO_ROOT = Path(__file__).resolve().parents[1]
SEED_DIR = REPO_ROOT / "seed_profiles"
async def _create_all(engine):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@pytest.fixture
def async_db():
"""Fresh temp-file SQLite sessionmaker for direct async-DB tests
(e.g. testing ConversationManager without HTTP). Isolated per test.
刻意不走 Registry 引擎: 本夹具被各测试反复 asyncio.run(每次全新事件
循环),NullPool 连接即借即还才能跨循环安全;Registry 的 pool=2 常驻
连接与创建它的循环绑定,只能活在单一事件循环里(client 夹具的
TestClient 循环,见下)。
"""
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
engine = create_async_engine(
f"sqlite+aiosqlite:///{path}", poolclass=NullPool
)
asyncio.run(_create_all(engine))
sm = async_sessionmaker(engine, expire_on_commit=False)
yield sm
asyncio.run(engine.dispose())
try:
os.remove(path)
except PermissionError:
pass
@pytest.fixture(autouse=True)
def _reset_rate_limit():
"""Reset the in-memory rate limiter before and after every test so that
module-global bucket state can never leak across tests."""
from app.engine.services.rate_limit import _reset_for_test
_reset_for_test()
yield
_reset_for_test()
@pytest.fixture(autouse=True)
def _reset_conn_limiter():
"""Task 8: 同上——WS 连接限流器的模块级单例计数前后复位,跨测试不泄漏
(TestClient 所有连接同落 ip="testclient",不复位会互相顶名额)。"""
from app.engine.services.conn_limiter import _reset_for_test
_reset_for_test()
yield
_reset_for_test()
@pytest.fixture(autouse=True)
def _reset_platform_auth():
"""Phase 2 Task 2: 平台认证模块级单例(内存 token 表/登录限流失败
计数)前后复位,跨测试不泄漏——token/限流窗不隔离会串测试(前一脚的
锁定状态顶掉后一脚的登录);注入的 TTL/窗长一并还原默认。"""
from app.platform.auth import _reset_for_test
_reset_for_test()
yield
_reset_for_test()
@pytest.fixture(autouse=True)
def _reset_platform_settings_snapshot():
"""Phase 4 Task 5A: 平台设置内存快照前后复位——resolver 直调测试
之间不串平台默认值(不隔离时一脚 PUT 进快照的 ai.api_key 会顶掉后脚
的 env 回退断言)。"""
from app.platform.settings_store import _reset_for_test
_reset_for_test()
yield
_reset_for_test()
@pytest.fixture(autouse=True)
def _reset_test_endpoint_limiter():
"""Phase 4 Task 5B: email/push 测试端点限流器前后复位——同测试内
连发会被窗限流(这是被测行为),跨测试不隔离则前一脚打满的预算顶掉
后脚的断言;注入的窗长/上限一并还原默认。"""
from app.platform.routes.config_routes import _reset_test_limiter_for_test
_reset_test_limiter_for_test()
yield
_reset_test_limiter_for_test()
# ---- Phase 2 Task 10: FakeAI 公共确定性桩(清理单 #1) --------------------------
# 收敛自四份拷贝(test_lead_email_trigger / test_cc_integration /
# test_channel_hygiene / test_lead_push_wiring,接口取超集: 前两份全量,
# hygiene/push_wiring 只用 extract 传入——evaluate=None 即它们原固定返回)。
class FakeAI:
"""替代 GLM 的确定性桩: 构造参数即每轮返回值。
- extract: dict 或 list——第 N 轮客户消息取 seq[N-1](messages 为
[u,a,u,a...] 全量历史),越界取末位;
- evaluate: dict 或 None——None = 出厂 pending 默认;子类可覆写
extract_info/evaluate 做捕获或定向注毒(既有 RecAI 用法);
- eval_calls: evaluate 被调计数(结束守卫轮次断言用)。
"""
def __init__(self, extract=None, evaluate=None):
self._extract_seq = extract if isinstance(extract, list) else [extract or {}]
self._evaluate = evaluate or {}
self.eval_calls = 0
async def chat(self, messages, context=""):
return "ok reply"
async def stream_chat(self, messages, context=""):
for token in ("ok ", "reply"):
yield token
async def extract_info(self, messages, existing_info):
# 第 N 轮客户消息 → seq[N-1](messages 为 [u,a,u,a...] 全量历史)
idx = min(max(len(messages) // 2 - 1, 0), len(self._extract_seq) - 1)
return self._extract_seq[idx]
async def evaluate(self, profile, recent_messages):
self.eval_calls += 1
result = dict(self._evaluate)
result.setdefault("intent_level", "pending")
result.setdefault("reasoning", "")
result.setdefault("contact_info", {})
result.setdefault("info_sufficient", False)
if result["intent_level"] not in ("interested", "not_interested", "pending"):
result["intent_level"] = "pending"
return result
@pytest.fixture
def jiancai_runtime():
"""直连测试用的 jiancai TenantRuntime(Task 6: manager 的 runtime 必填)。
委托 build_jiancai_runtime()(见其注释: 只走加载期,无 engine 无句柄,
跨测试共享单例安全)。
"""
return build_jiancai_runtime()
_JIANCAI_RUNTIME = None
def build_jiancai_runtime():
"""(非夹具)jiancai TenantRuntime——给批量集成测试的 _setup 辅助线用,
免去百处测试签名逐一加夹具。
只走加载期(config/prompts/compiled_wall 就位),从不 ensure_ready——
engine/session_factory 保持 None: 直连测试自管 async_db(NullPool 跨
循环纪律;Task 9 起 lead_push_service 无模块级 session 兜底,session_factory
由调用方/测试显式传)。无 engine 即无句柄,无需清理;
data_dir 仅占位(只拼 db_path 字符串,无任何 IO——从不 ensure_ready,
不会创建 tenants/ 目录或库文件),模块级缓存单例跨测试复用。
"""
global _JIANCAI_RUNTIME
if _JIANCAI_RUNTIME is None:
reg = TenantRegistry.load_from_dir(
SEED_DIR, data_dir=REPO_ROOT / "tenants")
_JIANCAI_RUNTIME = next(
rt for rt in reg.runtimes() if rt.config.slug == "jiancai")
return _JIANCAI_RUNTIME
_AUTO_RUNTIME = None
def build_auto_runtime():
"""(非夹具)auto TenantRuntime——C-1 终审回归锁用(两租户墙差异断言),
纪律同 build_jiancai_runtime: 只走加载期,无 engine 无句柄,
模块级缓存单例跨测试复用。"""
global _AUTO_RUNTIME
if _AUTO_RUNTIME is None:
reg = TenantRegistry.load_from_dir(
SEED_DIR, data_dir=REPO_ROOT / "tenants")
_AUTO_RUNTIME = next(
rt for rt in reg.runtimes() if rt.config.slug == "auto")
return _AUTO_RUNTIME
# Task 7: app_client 默认域名——既有测试不带 host 头,默认落 jiancai
# (与 5b 之前 default_runtime 兜底的语义一致,既有断言零改动)
JIANCAI_HOST = "chat2.agent.hnxinyouhe.com"
AUTO_HOST = "chat.agent.hnxinyouhe.com"
# 未知 Host 哨兵(原 test_admin_mounting/test_host_routing 各自定义,
# Task 4 收拢到 conftest 单一定义)
NOPE_HOST = "nope.example.com"
# ---- Phase 2 Task 6: DB 源 app/超管 client 夹具 -----------------------------------
# 自 test_tenant_admin_api 收拢到 conftest(Task 5 时代该文件私有,Task 6 的
# 配置 API/试金石测试与租户管理测试共用同一套 DB 源闭环环境)。
# 跨循环纪律见 db_source_app docstring 与 test_tenant_admin_api 模块头。
@pytest.fixture
def db_source_app(tmp_path):
"""app + 生产形态 registry(DbTenantSource 指向临时 platform.db)。
platform_engine 也指同一临时库(lifespan 在 portal 循环里 ensure_schema
+ bootstrap 超管 admin/admin123)。teardown 复位 app.state(照抄
app_test_client 的关残留窗纪律;engine dispose 由 lifespan finally)。
"""
from app.platform.tenant_source import DbTenantSource
engine = make_platform_engine(tmp_path / "platform.db")
app.state.registry = TenantRegistry(
DbTenantSource(engine), data_dir=tmp_path / "tenants")
app.state.platform_engine = engine
try:
yield app
finally:
app.state.registry = None
app.state.platform_engine = None
@pytest.fixture
def super_client(db_source_app, tmp_path):
"""超管 client:lifespan bootstrap 的 admin/admin123 登录 + 首登改密
(过 must_change 409 门)。token 挂 client.platform_token。"""
with app_test_client(db_source_app, "http://testserver", tmp_path) as c:
r = c.post("/api/platform/auth/login",
json={"username": "admin", "password": "admin123"})
assert r.status_code == 200
c.platform_token = r.json()["token"]
r = c.post("/api/platform/auth/change-password", headers=_bearer_of(c),
json={"old_password": "admin123",
"new_password": "sup3r-pass-1"})
assert r.status_code == 200
yield c
def _bearer_of(client):
return {"Authorization": f"Bearer {client.platform_token}"}
def build_app(seed_dir, data_dir):
"""app 工厂(Task 7): 建 Registry → 预置 app.state.registry → 返回 app。
Host 中间件已在 app.main 模块级挂好(add_middleware,经 scope["app"]
延迟取 registry),工厂只负责把 Registry 放进 app.state——lifespan 见
已挂则沿用(测试注入缝),不再加载仓库 seed。调用方用完必须复位
app.state.registry = None(见 app_test_client 的 finally)。
Phase 2 Task 1 起同时预置 platform_engine(data_dir 下的临时平台库,
同一注入缝)——不预置则 lifespan 会在仓库根建出真实 platform.db;
schema+bootstrap 由 lifespan 统一跑(幂等),dispose 也在 lifespan
finally(与 registry.dispose_all 同处)。
"""
registry = TenantRegistry.load_from_dir(seed_dir, data_dir=data_dir)
app.state.registry = registry
# data_dir 可能尚不存在(租户库由 _ensure_ready 按需 mkdir,平台库由
# lifespan 立即连接——这里先建目录,否则 sqlite "unable to open database
# file")
Path(data_dir).mkdir(parents=True, exist_ok=True)
app.state.platform_engine = make_platform_engine(
Path(data_dir) / "platform.db")
return app
def _rmtree_warn(path):
try:
shutil.rmtree(path)
except OSError as exc:
warnings.warn(f"测试夹具清理 tmp 目录失败(文件锁?): {exc}")
shutil.rmtree(path, ignore_errors=True)
class TenantTestClient(TestClient):
"""WS 连接默认 Host 取 base_url 域名。
starlette 的 websocket_connect 硬编码 ``ws://testserver`` 拼绝对 URL,
base_url 对 WS 不生效——不补这层,既有 WS 测试的 Host 全落 testserver
(未知域名,被中间件 1008 拒)。显式传 headers={"host": ...} 仍可覆盖
(切租户用,见 test_host_routing)。"""
def __init__(self, *args, ws_default_host: str = "", **kwargs):
super().__init__(*args, **kwargs)
self._ws_default_host = ws_default_host
def websocket_connect(self, url, subprotocols=None, **kwargs):
headers = dict(kwargs.pop("headers", None) or {})
if self._ws_default_host:
headers.setdefault("host", self._ws_default_host)
kwargs["headers"] = headers
return super().websocket_connect(url, subprotocols=subprotocols, **kwargs)
@contextmanager
def app_test_client(app_instance, base_url, cleanup_root):
"""TestClient 包装:统一退出纪律——lifespan finally 已 dispose_all +
platform_engine.dispose(Windows 文件锁),再置空 app.state.registry /
platform_engine(关残留窗:不让已 dispose 的测试对象被后续无夹具的
lifespan 误用),最后清 tmp 目录(删不掉告警留痕,不静默吞)。"""
try:
with TenantTestClient(
app_instance, base_url=base_url,
ws_default_host=urlsplit(base_url).netloc) as c:
yield c
finally:
app_instance.state.registry = None
app_instance.state.platform_engine = None
_rmtree_warn(cleanup_root)
@pytest.fixture
def app_client(tmp_path):
"""TestClient(base_url=jiancai 域名) + 每测试独立 Registry(tmp data_dir)。
- Host 中间件按请求 Host 逐请求 resolve_site:engine 懒建在 TestClient
的 portal 事件循环里(连接/循环绑定安全)。测试线程需要直接造数时
不得用该 engine——asyncio.run 另起循环,跨循环复用常驻连接会死锁;
直连断言走 async_db,或经真实路由(WS/REST)。
- 切换租户: 请求级 headers={"host": ...} 覆盖(WS 同理),不用另开
TestClient——同一 app 上两个 client 各跑一次 lifespan 会共享
registry 而 engine 各绑一个循环,跨循环死锁。
"""
a = build_app(SEED_DIR, tmp_path)
with app_test_client(a, f"http://{JIANCAI_HOST}", tmp_path) as c:
yield c
@pytest.fixture
def client(app_client):
"""既有测试的 client 夹具(Task 7 起基于 app_client,仅换名)。"""
yield app_client