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>
This commit is contained in:
+35
-70
@@ -1,27 +1,23 @@
|
||||
import os
|
||||
import tempfile
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.engine.models import Base
|
||||
|
||||
# `chat_rest` is created in Task A4. Tolerate its absence so earlier tasks
|
||||
# (e.g. A0's /health smoke) can use the `client` fixture without it.
|
||||
# Import `app.main` FIRST so the natural main -> chat_rest load order resolves
|
||||
# (chat_rest does `from app.main import async_session`; if conftest imports
|
||||
# chat_rest before main is loaded, main's `from chat_rest import router` hits
|
||||
# a partially-initialized module -> circular ImportError).
|
||||
_HAS_CHAT_REST = False
|
||||
chat_rest_mod = None
|
||||
try:
|
||||
import app.main # noqa: F401
|
||||
import app.engine.routes.chat_rest as chat_rest_mod
|
||||
_HAS_CHAT_REST = True
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
# 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):
|
||||
@@ -29,34 +25,15 @@ async def _create_all(engine):
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _dispose_main_engine():
|
||||
"""Dispose `app.main.engine` at session end.
|
||||
|
||||
Best-effort cleanup: explicitly disposing the app-level engine before
|
||||
interpreter shutdown reduces (but does NOT eliminate) the noisy
|
||||
`Windows fatal exception: access violation` traces that aiosqlite worker
|
||||
threads emit when interrupted mid-cleanup at interpreter exit.
|
||||
|
||||
Reality check on Windows:
|
||||
- All tests pass and pytest exits 0; the traces are written to stderr
|
||||
AFTER the run completes and do not affect pass/fail or the exit code.
|
||||
- They originate from aiosqlite background threads being torn down by the
|
||||
interpreter and are harmless.
|
||||
- They do not occur on Linux/CI.
|
||||
"""
|
||||
yield
|
||||
from app.main import engine as main_engine
|
||||
try:
|
||||
asyncio.run(main_engine.dispose())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@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)
|
||||
@@ -84,37 +61,25 @@ def _reset_rate_limit():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
"""Temporary file-SQLite TestClient with `chat_rest.async_session` overridden.
|
||||
def client(tmp_path):
|
||||
"""TestClient + 每测试独立 Registry(tmp_path 作 data_dir)。
|
||||
|
||||
Design notes:
|
||||
- File DB (not :memory:) avoids the StaticPool cross-loop reuse pitfall.
|
||||
- `NullPool` closes each connection when the session returns it, so no
|
||||
aiosqlite worker thread outlives the event loop that created it.
|
||||
Together with `_dispose_main_engine`, this REDUCES but does NOT
|
||||
eliminate the harmless Windows access-violation stderr traces described
|
||||
above; it has no effect on test correctness or on Linux/CI.
|
||||
- Monkeypatching the module-level `async_session` binding in `chat_rest`
|
||||
works because the route handlers resolve it as a module global at call.
|
||||
- 预置 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
|
||||
from app.main import app
|
||||
|
||||
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)
|
||||
if _HAS_CHAT_REST:
|
||||
monkeypatch.setattr(chat_rest_mod, "async_session", sm)
|
||||
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
asyncio.run(engine.dispose())
|
||||
registry = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
|
||||
app.state.registry = registry
|
||||
try:
|
||||
os.remove(path)
|
||||
except PermissionError:
|
||||
pass
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
finally:
|
||||
shutil.rmtree(tmp_path, ignore_errors=True)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# `client` fixture from conftest (temp SQLite + overrides chat_rest.async_session).
|
||||
# `client` fixture from conftest (per-test Registry on tmp data_dir; default
|
||||
# tenant runtime pre-warmed by the app lifespan inside the TestClient loop).
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def test_session_creates_new(client):
|
||||
@@ -26,17 +29,10 @@ def test_history_404_unknown_sid(client):
|
||||
|
||||
def test_history_rejects_non_app_session(client):
|
||||
# widget-source conversations are not readable via this public endpoint
|
||||
# (narrow the read surface to app only).
|
||||
import asyncio
|
||||
from app.engine.routes import chat_rest
|
||||
from app.engine.services.conversation import ConversationManager
|
||||
from app.engine.models import ConversationSource
|
||||
|
||||
async def _make_widget():
|
||||
async with chat_rest.async_session() as db:
|
||||
mgr = ConversationManager(db)
|
||||
c = await mgr.create_conversation(source=ConversationSource.widget)
|
||||
return c.session_id
|
||||
|
||||
sid = asyncio.run(_make_widget())
|
||||
# (narrow the read surface to app only). 经真实 WS 路由造 widget 会话:
|
||||
# 租户 engine 的常驻连接绑定 TestClient 的事件循环,测试线程不得另起
|
||||
# asyncio.run 直开其 session_factory(跨循环复用会死锁)。
|
||||
with client.websocket_connect("/ws/chat") as ws:
|
||||
ws.send_text(json.dumps({"source": "widget"}))
|
||||
sid = ws.receive_json()["session_id"]
|
||||
assert client.get(f"/api/chat/{sid}/messages").status_code == 404
|
||||
|
||||
@@ -482,7 +482,7 @@ def test_fire_pending_creates_tasks(monkeypatch):
|
||||
from app.engine.services import lead_push_service as svc
|
||||
pushed = []
|
||||
|
||||
async def fake_run(log_id, payload):
|
||||
async def fake_run(log_id, payload, session_factory=None):
|
||||
pushed.append((log_id, payload))
|
||||
|
||||
monkeypatch.setattr(svc, "_run_push", fake_run)
|
||||
|
||||
@@ -50,7 +50,7 @@ def _setup(async_db, monkeypatch, ai, lead_succeeds=True):
|
||||
|
||||
fired = []
|
||||
monkeypatch.setattr(lead_push_service, "fire_pending_pushes",
|
||||
lambda logs: fired.extend(logs))
|
||||
lambda logs, session_factory=None: fired.extend(logs))
|
||||
monkeypatch.setattr(lead_push_service.settings, "PUSH_ENABLED", True)
|
||||
|
||||
async def run():
|
||||
@@ -165,7 +165,7 @@ def test_push_disabled_writes_nothing(async_db, monkeypatch):
|
||||
monkeypatch.setattr(email_service, "send_lead_email", fake_lead)
|
||||
fired = []
|
||||
monkeypatch.setattr(lead_push_service, "fire_pending_pushes",
|
||||
lambda logs: fired.extend(logs))
|
||||
lambda logs, session_factory=None: fired.extend(logs))
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
@@ -183,7 +183,7 @@ def test_fire_end_to_end_single_loop(async_db, monkeypatch):
|
||||
"""真 fire + 假 _run_push: 同一事件循环内 stream 完成后任务确实被派发执行。"""
|
||||
pushed = []
|
||||
|
||||
async def fake_run(log_id, payload):
|
||||
async def fake_run(log_id, payload, session_factory=None):
|
||||
pushed.append(log_id)
|
||||
|
||||
monkeypatch.setattr(lead_push_service, "_run_push", fake_run)
|
||||
@@ -234,7 +234,7 @@ def test_fire_happens_only_after_commit(async_db, monkeypatch):
|
||||
sent.append("lead")
|
||||
return True
|
||||
|
||||
async def fake_run(log_id, payload):
|
||||
async def fake_run(log_id, payload, session_factory=None):
|
||||
async with async_db() as db:
|
||||
try:
|
||||
row = (await db.execute(
|
||||
@@ -249,9 +249,9 @@ def test_fire_happens_only_after_commit(async_db, monkeypatch):
|
||||
|
||||
real_fire = lead_push_service.fire_pending_pushes
|
||||
|
||||
def recording_fire(logs):
|
||||
def recording_fire(logs, session_factory=None):
|
||||
fired.append(True)
|
||||
real_fire(logs)
|
||||
real_fire(logs, session_factory)
|
||||
|
||||
monkeypatch.setattr(email_service, "send_lead_email", fake_lead)
|
||||
monkeypatch.setattr(lead_push_service, "_run_push", fake_run)
|
||||
|
||||
Reference in New Issue
Block a user