Files
ai-kefu-admin/tests/conftest.py
T
浅念 1824bb1590 test: PromptProvider 审查修复(组装缝断言/占位符约定文档与glob守护/边界注记)
- I-1: test_company_facts 捕获完整 prompt 的用例补话术池注入断言
  ("- EN:"/"- ZH:" 分组行在场)——守 AIService 组装缝,防 Task 9
  重接提示词线时静默丢池
- I-2: seed_profiles/README.md 落档 $DEFLECTION_POOL 占位符约定
  (缺失 = 话术池静默不注入);test_prompt_provider 占位符防漂移改
  glob 遍历 seed_profiles/*/prompts/system.md,第三租户自动纳管
  (空 glob 显性兜底,防假守护)
- Minor-1 注记: _format_deflection_pool docstring 记已知边界——条目
  含引号/换行暂不转义,Phase 2 编辑器批次处理
- Minor-5: test_provider_loads_from_tenant_dir 删恒真 or 侧,改单一
  明确断言;registry 加载期显性失败清单补 prompts 读取失败
  (FileNotFoundError 说明);conftest build_jiancai_runtime 注明
  data_dir 仅占位、从不 ensure_ready

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-12 20:16:40 +08:00

127 lines
4.7 KiB
Python

import asyncio
import os
import shutil
import tempfile
import warnings
from pathlib import Path
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
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.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
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 跨
循环纪律),manager.session_factory 为 None 时 lead_push_service 回退
模块级兜底,与旧 runtime=None 行为一致。无 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
@pytest.fixture
def client(tmp_path):
"""TestClient + 每测试独立 Registry(tmp_path 作 data_dir)。
- 预置 app.state.registry(测试注入缝): lifespan 见 registry 已挂则
沿用,不再加载仓库 seed_profiles/tenants——每测试拿到全新租户库,
与生产数据互不污染。
- 默认租户(jiancai)的 engine 由 lifespan 在 TestClient 的 portal 事件
循环内预热,后续请求也在同一循环处理(连接/循环绑定安全)。测试
线程需要直接造数时不得用该 engine——asyncio.run 另起循环,跨循环
复用常驻连接会死锁;直连断言走 async_db,或经真实路由(WS/REST)。
- 退出: lifespan finally 里 registry.dispose_all()(Windows 文件锁,
不关则 tmp 目录删不掉);随后置空 app.state.registry(关残留窗——
不让已 dispose 的测试 Registry 被后续无夹具的 lifespan 误用)并
清理 tmp 目录(删不掉告警留痕,不静默吞)。
"""
from fastapi.testclient import TestClient
registry = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
app.state.registry = registry
try:
with TestClient(app) as c:
yield c
finally:
app.state.registry = None
try:
shutil.rmtree(tmp_path)
except OSError as exc:
warnings.warn(f"client 夹具清理 tmp 目录失败(文件锁?): {exc}")
shutil.rmtree(tmp_path, ignore_errors=True)