607baff83c
Co-Authored-By: Claude Code <noreply@anthropic.com>
108 lines
4.1 KiB
Python
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()
|