"""TenantRegistry 单元契约(Task 5a 文件加载器)。 seed_profiles//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()