Files
ai-kefu-admin/tests/test_registry.py
T

108 lines
4.1 KiB
Python

"""TenantRegistry 单元契约(Task 5a 文件加载器)。
seed_profiles/<slug>/tenant.json + 同目录 wall.json → Registry;
Host(小写/去端口)→站点→租户;首次 resolve 懒建 engine(WAL/pool=2)+
ensure_schema;reload 原子换条目并 dispose 旧 engine。
Windows 纪律:每个建过 engine 的用例结尾 await reg.dispose_all()——
连接不关,文件锁着,tmp_path 清理 PermissionError。
"""
import asyncio
import pytest
from app.platform.registry import (
TenantRegistry,
UnknownTenantError,
make_tenant_engine,
)
SEED_DIR = "seed_profiles"
async def test_resolve_by_host(tmp_path):
reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
rt = await reg.resolve("chat2.agent.hnxinyouhe.com")
assert rt.config.slug == "jiancai"
assert rt.config.data["push"]["prefix"] == "ai-kefu-jc"
await reg.dispose_all()
async def test_unknown_host_404_semantics(tmp_path):
reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
with pytest.raises(UnknownTenantError):
await reg.resolve("nope.example.com")
await reg.dispose_all()
async def test_tenant_db_isolated_per_slug(tmp_path):
reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
a = await reg.resolve("chat2.agent.hnxinyouhe.com")
b = await reg.resolve("chat.agent.hnxinyouhe.com")
assert a.db_path != b.db_path and a.db_path.name == "jiancai.db"
await reg.dispose_all()
async def test_reload_swaps_config_atomically(tmp_path):
reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
rt = await reg.resolve("chat2.agent.hnxinyouhe.com")
await reg.registry_reload("jiancai") # 重新读盘
assert await reg.resolve("chat2.agent.hnxinyouhe.com") is not rt
assert rt.engine is None # 旧 runtime 的 engine 必须已 dispose(Windows 文件锁)
await reg.dispose_all()
async def test_site_brand_overlay_and_inheritance(tmp_path):
reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
rt, site = await reg.resolve_site("chat2.agent.hnxinyouhe.com")
assert site.slug == "main"
es = rt.site_by_slug("es") # 空域名站点按 slug 取
assert es.brand["title"] == "HESUPIPE Soporte" # 覆盖生效
assert es.brand["color"] == rt.config.data["brand"]["color"] # 未覆盖项继承租户默认
await reg.dispose_all()
async def test_host_normalization_case_and_port(tmp_path):
"""真实 Host 头带端口/可能大写(spec §4:Host 识别必须先归一)。"""
reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
rt = await reg.resolve("CHAT2.Agent.hNxinyouHe.com:8018")
assert rt.config.slug == "jiancai"
with pytest.raises(UnknownTenantError):
await reg.resolve("NOPE.example.com:8018")
await reg.dispose_all()
async def test_concurrent_first_resolve_builds_engine_once(tmp_path, monkeypatch):
"""两个协程同时首 resolve 同一租户:engine 只建一次(asyncio.Lock 串行)。"""
import app.platform.registry as registry_mod
calls = []
real_make = registry_mod.make_tenant_engine
def counting_make(db_path):
calls.append(db_path)
return real_make(db_path)
monkeypatch.setattr(registry_mod, "make_tenant_engine", counting_make)
reg = TenantRegistry.load_from_dir(SEED_DIR, data_dir=tmp_path)
rts = await asyncio.gather(
*[reg.resolve("chat2.agent.hnxinyouhe.com") for _ in range(5)])
assert len(calls) == 1 # 无锁则各协程各建一个,前 4 个泄漏
assert all(rt is rts[0] for rt in rts) # 同一 runtime 实例
assert rts[0].engine is not None and rts[0].session_factory is not None
await reg.dispose_all()
async def test_make_tenant_engine_enables_wal(tmp_path):
"""spec §6.1 硬纪律:每租户库打开即 PRAGMA journal_mode=WAL(读写不互斥)。"""
from sqlalchemy import text
engine = make_tenant_engine(tmp_path / "wal_check.db")
try:
async with engine.connect() as conn:
mode = (await conn.execute(text("PRAGMA journal_mode"))).scalar()
assert str(mode).lower() == "wal"
finally:
await engine.dispose()