Files
ai-kefu-admin/tests/conftest.py
T
浅念 d041077bb6 feat: 装配切换——main/服务构造线/conftest 迁至 Registry(含默认租户过渡兜底)
- main.py: 删全局 engine/async_session;lifespan 装配 TenantRegistry
  (seed_profiles → 仓库根 tenants/,路径锚定),预热默认租户 jiancai 挂
  app.state.default_runtime,逐 active 租户(库文件已存在)跑
  sweep_stale_pending,退出 dispose_all。app.state.registry 预置即沿用
  (测试注入缝)
- routes/runtime.py(过渡,Task 7 删): get_runtime——state.tenant 优先,
  缺省落 default_runtime;chat.py/chat_rest.py 全部改走它,消除
  main↔routes 循环依赖
- ConversationManager 构造线: 新增 runtime 可选参(持 session_factory/
  compiled_wall/config 引用;prompts 仍模块级,Task 6;业务接线 Task 9),
  fire_pending_pushes 随任务传 session_factory(后台写回派发租户的库)
- lead_push_service: 去 app.main import;写库入口显式收 session_factory,
  模块级 async_session=None 仅作测试注入兜底
- conftest: client 夹具 = 每测试独立 Registry(tmp data_dir)+ 真实
  lifespan 在 TestClient 循环内预热;删 _dispose_main_engine;
  async_db 保持 NullPool(跨 asyncio.run 循环安全,刻意不换 Registry 引擎)
- 测试仅动夹具/桩签名(断言零改动): wiring 桩补 session_factory 形参;
  history-404 用例改经真实 WS 路由造 widget 会话(引擎常驻连接绑定
  TestClient 循环,测试线程不得直开 session_factory)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-12 19:15:46 +08:00

86 lines
3.0 KiB
Python

import asyncio
import os
import shutil
import tempfile
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 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 目录删不掉),随后清理 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:
shutil.rmtree(tmp_path, ignore_errors=True)