2026-10-13 08:50:16 +08:00
|
|
|
|
"""TenantSource 抽象与 DB 源(Phase 2 Task 4)。
|
|
|
|
|
|
|
|
|
|
|
|
两源输出同构 LoadedTenant:sites(tenant.json sites 形态,DB 源把
|
|
|
|
|
|
sites.brand_overlay 列映射进站点 "brand" 键)+ snapshot(配置组 ⊕ wall
|
|
|
|
|
|
⊕ prompts 四件套文本——文件源的行为增量:prompts 并入快照,两源对齐)。
|
|
|
|
|
|
|
|
|
|
|
|
registry 构造收 source:load_from_dir 退化为 File 源便捷构造(既有调用
|
|
|
|
|
|
零改动,既有 test_registry 全绿即证文件源行为等价);registry_reload 走
|
|
|
|
|
|
source 并支持 upsert——source 已知但未加载的 slug 载入插入(Task 5
|
|
|
|
|
|
创建租户后 reload 依赖此语义),source 也不知道 → UnknownTenantError。
|
|
|
|
|
|
|
|
|
|
|
|
Windows 纪律:建过 engine 的用例结尾 await engine.dispose()/dispose_all()
|
|
|
|
|
|
(文件锁,tmp 目录才删得掉)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
import json
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from sqlalchemy import text
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
2026-10-13 09:05:09 +08:00
|
|
|
|
from app.engine.services.ai_service import PROMPTS_KEYS, PromptProvider
|
2026-10-13 08:50:16 +08:00
|
|
|
|
from app.platform.db import (
|
|
|
|
|
|
Site,
|
|
|
|
|
|
SiteDomain,
|
|
|
|
|
|
Tenant,
|
|
|
|
|
|
TenantConfigVersion,
|
|
|
|
|
|
ensure_platform_schema,
|
|
|
|
|
|
make_platform_engine,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.platform.registry import UnknownTenantError, TenantRegistry
|
2026-10-13 12:40:21 +08:00
|
|
|
|
from app.platform.tenant_config import TenantConfig
|
2026-10-13 08:50:16 +08:00
|
|
|
|
from app.platform.tenant_source import DbTenantSource, FileTenantSource
|
|
|
|
|
|
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
SEED_DIR = REPO_ROOT / "seed_profiles"
|
|
|
|
|
|
JC_DOMAIN = "db.jc.example.com" # DB 源测试专属域名(不与文件源种子撞)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _jiancai_snapshot(prefix="ai-kefu-jc"):
|
|
|
|
|
|
"""版本 config_json 构造助手:seed jiancai 的 config 组 + wall.json +
|
|
|
|
|
|
prompts 四件套合并(Task 5 创建向导将产出同构内容)。"""
|
|
|
|
|
|
tenant = json.loads(
|
|
|
|
|
|
(SEED_DIR / "jiancai" / "tenant.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
prompts_dir = SEED_DIR / "jiancai" / "prompts"
|
|
|
|
|
|
snapshot = dict(tenant["config"])
|
|
|
|
|
|
snapshot["push"] = dict(snapshot.get("push") or {}, prefix=prefix)
|
|
|
|
|
|
snapshot["wall"] = json.loads(
|
|
|
|
|
|
(SEED_DIR / "jiancai" / "wall.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
snapshot["prompts"] = {
|
|
|
|
|
|
name: (prompts_dir / f"{name}.md").read_text(encoding="utf-8")
|
2026-10-13 09:05:09 +08:00
|
|
|
|
for name in PROMPTS_KEYS
|
2026-10-13 08:50:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
return snapshot
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _make_engine(tmp_path):
|
|
|
|
|
|
engine = make_platform_engine(tmp_path / "platform.db")
|
|
|
|
|
|
await ensure_platform_schema(engine)
|
|
|
|
|
|
return engine
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _add_jiancai(engine, prefix="ai-kefu-jc", version=1,
|
2026-10-13 12:40:21 +08:00
|
|
|
|
config_json=None, with_version=True, es_status="active"):
|
2026-10-13 08:50:16 +08:00
|
|
|
|
"""jiancai 租户 + main(JC_DOMAIN)+ es(brand_overlay)+ 版本行。"""
|
|
|
|
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
|
|
|
|
tenant = Tenant(slug="jiancai", name="HESUPIPE 建材", status="active")
|
|
|
|
|
|
session.add(tenant)
|
|
|
|
|
|
await session.flush()
|
|
|
|
|
|
main = Site(tenant_id=tenant.id, slug="main", name="main")
|
|
|
|
|
|
es = Site(tenant_id=tenant.id, slug="es", name="es",
|
2026-10-13 12:40:21 +08:00
|
|
|
|
status=es_status,
|
2026-10-13 08:50:16 +08:00
|
|
|
|
brand_overlay={"title": "HESUPIPE Soporte"})
|
|
|
|
|
|
session.add_all([main, es])
|
|
|
|
|
|
await session.flush()
|
|
|
|
|
|
session.add(SiteDomain(domain=JC_DOMAIN, site_id=main.id))
|
|
|
|
|
|
if with_version:
|
|
|
|
|
|
session.add(TenantConfigVersion(
|
|
|
|
|
|
tenant_id=tenant.id, version=version,
|
|
|
|
|
|
config_json=config_json if config_json is not None
|
|
|
|
|
|
else json.dumps(_jiancai_snapshot(prefix), ensure_ascii=False)))
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
return tenant.id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _add_version(engine, tenant_id, version, prefix):
|
|
|
|
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
|
|
|
|
session.add(TenantConfigVersion(
|
|
|
|
|
|
tenant_id=tenant_id, version=version,
|
|
|
|
|
|
config_json=json.dumps(_jiancai_snapshot(prefix),
|
|
|
|
|
|
ensure_ascii=False)))
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _add_tenant(engine, slug, domain, prefix):
|
|
|
|
|
|
"""极简单租户(无 es 覆盖)——upsert 测试用。"""
|
|
|
|
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
|
|
|
|
tenant = Tenant(slug=slug, name=slug)
|
|
|
|
|
|
session.add(tenant)
|
|
|
|
|
|
await session.flush()
|
|
|
|
|
|
site = Site(tenant_id=tenant.id, slug="main", name="main")
|
|
|
|
|
|
session.add(site)
|
|
|
|
|
|
await session.flush()
|
|
|
|
|
|
session.add(SiteDomain(domain=domain, site_id=site.id))
|
|
|
|
|
|
session.add(TenantConfigVersion(
|
|
|
|
|
|
tenant_id=tenant.id, version=1,
|
|
|
|
|
|
config_json=json.dumps(_jiancai_snapshot(prefix),
|
|
|
|
|
|
ensure_ascii=False)))
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
async def platform_db_with_tenant(tmp_path):
|
|
|
|
|
|
"""tmp platform.db + jiancai(main+es 含 brand_overlay)+域名+版本1。"""
|
|
|
|
|
|
engine = await _make_engine(tmp_path)
|
|
|
|
|
|
await _add_jiancai(engine)
|
|
|
|
|
|
yield engine
|
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- DB 源加载 ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_db_source_loads_tenant(platform_db_with_tenant):
|
|
|
|
|
|
src = DbTenantSource(platform_db_with_tenant)
|
|
|
|
|
|
loaded = await src.load_all()
|
|
|
|
|
|
assert [lt.slug for lt in loaded] == ["jiancai"]
|
|
|
|
|
|
lt = loaded[0]
|
|
|
|
|
|
assert lt.name == "HESUPIPE 建材" and lt.status == "active"
|
|
|
|
|
|
assert lt.snapshot["push"]["prefix"] == "ai-kefu-jc" # 快照组直读
|
|
|
|
|
|
assert "system" in lt.snapshot["prompts"] # prompts 嵌 config_json
|
|
|
|
|
|
assert lt.seed_dir is None # DB 源无种子目录
|
|
|
|
|
|
main = [s for s in lt.sites if s["slug"] == "main"][0]
|
|
|
|
|
|
es = [s for s in lt.sites if s["slug"] == "es"][0]
|
|
|
|
|
|
assert main["domains"] == [JC_DOMAIN]
|
|
|
|
|
|
assert es["brand"]["title"] == "HESUPIPE Soporte" # brand_overlay 生效
|
|
|
|
|
|
assert es["domains"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_db_source_load_tenant_single_and_unknown(
|
|
|
|
|
|
platform_db_with_tenant):
|
|
|
|
|
|
src = DbTenantSource(platform_db_with_tenant)
|
|
|
|
|
|
lt = await src.load_tenant("jiancai")
|
|
|
|
|
|
assert lt.slug == "jiancai"
|
|
|
|
|
|
with pytest.raises(LookupError, match="nope"):
|
|
|
|
|
|
await src.load_tenant("nope")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_db_source_empty_db_raises(tmp_path):
|
|
|
|
|
|
engine = await _make_engine(tmp_path)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with pytest.raises(ValueError, match="没有任何租户"):
|
|
|
|
|
|
await DbTenantSource(engine).load_all()
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_db_source_no_version_raises(tmp_path):
|
|
|
|
|
|
"""有租户无版本 → 显性 ValueError 带租户名(哪个租户缺版本一目了然)。"""
|
|
|
|
|
|
engine = await _make_engine(tmp_path)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await _add_jiancai(engine, with_version=False)
|
|
|
|
|
|
with pytest.raises(ValueError) as excinfo:
|
|
|
|
|
|
await DbTenantSource(engine).load_all()
|
|
|
|
|
|
assert "jiancai" in str(excinfo.value) and "没有任何配置版本" in str(
|
|
|
|
|
|
excinfo.value)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_db_source_bad_json_raises(tmp_path):
|
|
|
|
|
|
"""config_json 无效 JSON → 带租户名与版本号的清晰错误。
|
|
|
|
|
|
|
|
|
|
|
|
CHECK(json_valid) 挡住正常写入方,坏行只能来自约束启用前的历史/
|
|
|
|
|
|
外部直改——经 PRAGMA ignore_check_constraints 直插构造,防御路径
|
|
|
|
|
|
仍要显性报错。
|
|
|
|
|
|
"""
|
|
|
|
|
|
engine = await _make_engine(tmp_path)
|
|
|
|
|
|
try:
|
|
|
|
|
|
tid = await _add_jiancai(engine)
|
|
|
|
|
|
async with engine.connect() as conn:
|
|
|
|
|
|
await conn.exec_driver_sql("PRAGMA ignore_check_constraints=ON")
|
|
|
|
|
|
await conn.execute(
|
|
|
|
|
|
text("INSERT INTO tenant_config_versions "
|
|
|
|
|
|
"(tenant_id, version, config_json, updated_by, created_at) "
|
|
|
|
|
|
"VALUES (:t, :v, :c, 'test', '2026-01-01 00:00:00')"),
|
|
|
|
|
|
{"t": tid, "v": 2, "c": "{oops"})
|
|
|
|
|
|
await conn.commit()
|
|
|
|
|
|
with pytest.raises(ValueError) as excinfo:
|
|
|
|
|
|
await DbTenantSource(engine).load_tenant("jiancai")
|
|
|
|
|
|
msg = str(excinfo.value)
|
|
|
|
|
|
assert "jiancai" in msg and "版本 2" in msg and "JSON" in msg
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_db_source_non_object_json_raises(tmp_path):
|
|
|
|
|
|
"""合法 JSON 但非对象(如数组)→ 显性错误带租户名与版本号。"""
|
|
|
|
|
|
engine = await _make_engine(tmp_path)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await _add_jiancai(engine, config_json="[1,2]")
|
|
|
|
|
|
with pytest.raises(ValueError) as excinfo:
|
|
|
|
|
|
await DbTenantSource(engine).load_all()
|
|
|
|
|
|
msg = str(excinfo.value)
|
|
|
|
|
|
assert "jiancai" in msg and "版本 1" in msg and "不是 JSON 对象" in msg
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_db_source_missing_prompts_group_raises(tmp_path):
|
|
|
|
|
|
"""快照缺 wall/prompts 组 → 显性错误带租户名与版本号(不留给
|
|
|
|
|
|
compile_wall/from_config 出无上下文的错)。"""
|
|
|
|
|
|
engine = await _make_engine(tmp_path)
|
|
|
|
|
|
try:
|
|
|
|
|
|
snapshot = _jiancai_snapshot()
|
|
|
|
|
|
del snapshot["prompts"]
|
|
|
|
|
|
await _add_jiancai(engine, config_json=json.dumps(
|
|
|
|
|
|
snapshot, ensure_ascii=False))
|
|
|
|
|
|
with pytest.raises(ValueError) as excinfo:
|
|
|
|
|
|
await DbTenantSource(engine).load_all()
|
|
|
|
|
|
msg = str(excinfo.value)
|
|
|
|
|
|
assert "jiancai" in msg and "prompts" in msg and "版本 1" in msg
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 12:40:21 +08:00
|
|
|
|
async def test_db_source_filters_disabled_sites(tmp_path):
|
|
|
|
|
|
"""终审 I-1:site.status != active 的站点不进 LoadedTenant(域名不进
|
|
|
|
|
|
registry 索引 → 该域名 404;site_by_slug 取不到)。es 停用后只剩
|
|
|
|
|
|
main;全部停用 → 空 sites(零站点校验只对库表形状)。"""
|
|
|
|
|
|
engine = await _make_engine(tmp_path)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await _add_jiancai(engine, es_status="disabled")
|
|
|
|
|
|
lt = await DbTenantSource(engine).load_tenant("jiancai")
|
|
|
|
|
|
assert [s["slug"] for s in lt.sites] == ["main"] # es 被过滤
|
|
|
|
|
|
main = lt.sites[0]
|
|
|
|
|
|
assert main["status"] == "active"
|
|
|
|
|
|
# TenantConfig 消费侧:Site 数据类带 status,域名索引只含 main
|
|
|
|
|
|
config = TenantConfig.from_snapshot(
|
|
|
|
|
|
lt.slug, lt.name, lt.snapshot, sites=lt.sites, status=lt.status)
|
|
|
|
|
|
assert [s.slug for s in config.sites] == ["main"]
|
|
|
|
|
|
assert config.sites[0].status == "active"
|
|
|
|
|
|
|
|
|
|
|
|
# 全部停用 = 合法运营状态(门面全下线),输出空 sites 不炸
|
|
|
|
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
|
|
|
|
await session.execute(text(
|
|
|
|
|
|
"UPDATE sites SET status='disabled'"))
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
lt2 = await DbTenantSource(engine).load_tenant("jiancai")
|
|
|
|
|
|
assert lt2.sites == []
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 08:50:16 +08:00
|
|
|
|
# ---- 文件源(直测;等价性由既有 test_registry 全绿证明)-----------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_file_source_loads_same_snapshot_shape():
|
|
|
|
|
|
src = FileTenantSource(SEED_DIR)
|
|
|
|
|
|
loaded = await src.load_all()
|
|
|
|
|
|
by_slug = {lt.slug: lt for lt in loaded}
|
|
|
|
|
|
assert set(by_slug) == {"jiancai", "auto"}
|
|
|
|
|
|
jc = by_slug["jiancai"]
|
|
|
|
|
|
assert jc.snapshot["push"]["prefix"] == "ai-kefu-jc"
|
|
|
|
|
|
assert "system" in jc.snapshot["prompts"] # 行为增量:四件套并入快照
|
|
|
|
|
|
assert jc.seed_dir == SEED_DIR / "jiancai"
|
|
|
|
|
|
es = [s for s in jc.sites if s["slug"] == "es"][0]
|
|
|
|
|
|
assert es["brand"]["title"] == "HESUPIPE Soporte"
|
|
|
|
|
|
# 快照 prompts 与 from_dir 读盘逐字一致(等价迁移直证)
|
|
|
|
|
|
assert (PromptProvider.from_config(jc.snapshot["prompts"])
|
|
|
|
|
|
== PromptProvider.from_dir(SEED_DIR / "jiancai" / "prompts"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_file_source_read_tenant_unknown_slug_raises(tmp_path):
|
|
|
|
|
|
"""reload upsert 的语义地基:文件源对未知 slug 报 LookupError
|
|
|
|
|
|
(registry 映射回 UnknownTenantError,不与坏数据的 ValueError 混)。"""
|
|
|
|
|
|
src = FileTenantSource(tmp_path) # 空目录:一切 slug 未知
|
|
|
|
|
|
with pytest.raises(LookupError, match="ghost"):
|
|
|
|
|
|
src.read_tenant("ghost")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_file_source_read_tenant_rereads_disk(tmp_path):
|
|
|
|
|
|
"""read_tenant 每次现读盘(reload 换配置的数据通路)。"""
|
|
|
|
|
|
tdir = tmp_path / "t1"
|
|
|
|
|
|
tdir.mkdir()
|
|
|
|
|
|
shutil.copyfile(SEED_DIR / "jiancai" / "wall.json", tdir / "wall.json")
|
|
|
|
|
|
shutil.copytree(SEED_DIR / "jiancai" / "prompts", tdir / "prompts")
|
|
|
|
|
|
(tdir / "tenant.json").write_text(json.dumps({
|
|
|
|
|
|
"slug": "t1", "name": "t1", "status": "active",
|
|
|
|
|
|
"sites": [{"slug": "main", "domains": ["a.example.com"]}],
|
|
|
|
|
|
"config": {"push": {"prefix": "v1"}}}, ensure_ascii=False), encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
src = FileTenantSource(tmp_path)
|
|
|
|
|
|
assert src.read_tenant("t1").snapshot["push"]["prefix"] == "v1"
|
|
|
|
|
|
tenant = json.loads((tdir / "tenant.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
tenant["config"]["push"]["prefix"] = "v2"
|
|
|
|
|
|
(tdir / "tenant.json").write_text(
|
|
|
|
|
|
json.dumps(tenant, ensure_ascii=False), encoding="utf-8")
|
|
|
|
|
|
assert src.read_tenant("t1").snapshot["push"]["prefix"] == "v2"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 12:40:21 +08:00
|
|
|
|
def test_file_source_filters_disabled_sites(tmp_path):
|
|
|
|
|
|
"""终审 I-1:tenant.json 站点带 status: "disabled" → 不进
|
|
|
|
|
|
LoadedTenant(文件源同步行为);无 status 键 → 默认 active,既有
|
|
|
|
|
|
种子零改动兼容。"""
|
|
|
|
|
|
_write_min_seed(tmp_path, {"t1": {
|
|
|
|
|
|
"slug": "t1", "name": "t1", "status": "active",
|
|
|
|
|
|
"sites": [
|
|
|
|
|
|
{"slug": "main", "domains": ["a.example.com"]}, # 无键=active
|
|
|
|
|
|
{"slug": "es", "status": "active", "domains": []},
|
|
|
|
|
|
{"slug": "down", "status": "disabled",
|
|
|
|
|
|
"domains": ["down.example.com"], "brand": {"title": "x"}},
|
|
|
|
|
|
],
|
|
|
|
|
|
"config": {}}})
|
|
|
|
|
|
lt = FileTenantSource(tmp_path / "seed").read_tenant("t1")
|
|
|
|
|
|
assert [s["slug"] for s in lt.sites] == ["main", "es"] # down 被滤
|
|
|
|
|
|
# 消费侧同构:from_snapshot 只见 active 站点
|
|
|
|
|
|
config = TenantConfig.from_snapshot(
|
|
|
|
|
|
lt.slug, lt.name, lt.snapshot, sites=lt.sites, status=lt.status)
|
|
|
|
|
|
assert [s.slug for s in config.sites] == ["main", "es"]
|
|
|
|
|
|
assert all(s.status == "active" for s in config.sites)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 08:50:16 +08:00
|
|
|
|
# ---- registry × DB 源(reload / upsert)--------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_registry_reload_via_db_source(tmp_path):
|
|
|
|
|
|
engine = await _make_engine(tmp_path)
|
|
|
|
|
|
try:
|
|
|
|
|
|
tid = await _add_jiancai(engine, prefix="v1")
|
|
|
|
|
|
reg = TenantRegistry(
|
|
|
|
|
|
DbTenantSource(engine), data_dir=tmp_path / "tenants")
|
|
|
|
|
|
await reg.load_all()
|
|
|
|
|
|
old = await reg.resolve(JC_DOMAIN)
|
|
|
|
|
|
assert old.config.data["push"]["prefix"] == "v1"
|
|
|
|
|
|
assert old.prompts.system # prompts 经 from_config 接线
|
|
|
|
|
|
|
|
|
|
|
|
await _add_version(engine, tid, 2, prefix="v2")
|
|
|
|
|
|
await reg.registry_reload("jiancai")
|
|
|
|
|
|
rt = await reg.resolve(JC_DOMAIN)
|
|
|
|
|
|
assert rt is not old
|
|
|
|
|
|
assert rt.config.data["push"]["prefix"] == "v2" # MAX(version) 生效
|
|
|
|
|
|
assert old.engine is None # 旧 engine 已 dispose
|
|
|
|
|
|
await reg.dispose_all()
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_registry_reload_upsert_new_tenant_db_source(tmp_path):
|
|
|
|
|
|
"""source 已知但未加载的 slug(DB 新建租户)→ reload 载入插入不炸;
|
|
|
|
|
|
source 也不知道的 slug → UnknownTenantError(原语义保留)。"""
|
|
|
|
|
|
engine = await _make_engine(tmp_path)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await _add_jiancai(engine)
|
|
|
|
|
|
reg = TenantRegistry(
|
|
|
|
|
|
DbTenantSource(engine), data_dir=tmp_path / "tenants")
|
|
|
|
|
|
await reg.load_all()
|
|
|
|
|
|
|
|
|
|
|
|
await _add_tenant(engine, "demo", "demo.example.com", prefix="dm1")
|
|
|
|
|
|
await reg.registry_reload("demo") # 未加载的 slug:插入
|
|
|
|
|
|
rt = await reg.resolve("demo.example.com")
|
|
|
|
|
|
assert rt.config.slug == "demo"
|
|
|
|
|
|
assert rt.config.data["push"]["prefix"] == "dm1"
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(UnknownTenantError):
|
|
|
|
|
|
await reg.registry_reload("ghost")
|
|
|
|
|
|
await reg.dispose_all()
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _write_min_seed(tmp_path, tenants):
|
|
|
|
|
|
"""最小 seed 目录(拷真实 jiancai wall/prompts,免数据重复维护)。
|
|
|
|
|
|
幂等:第二次调用往同一 seed 下加新租户目录。"""
|
|
|
|
|
|
seed = tmp_path / "seed"
|
|
|
|
|
|
seed.mkdir(exist_ok=True)
|
|
|
|
|
|
for slug, tenant in tenants.items():
|
|
|
|
|
|
tdir = seed / slug
|
|
|
|
|
|
tdir.mkdir(exist_ok=True)
|
|
|
|
|
|
shutil.copyfile(SEED_DIR / "jiancai" / "wall.json", tdir / "wall.json")
|
|
|
|
|
|
shutil.copytree(SEED_DIR / "jiancai" / "prompts", tdir / "prompts",
|
|
|
|
|
|
dirs_exist_ok=True)
|
|
|
|
|
|
(tdir / "tenant.json").write_text(
|
|
|
|
|
|
json.dumps(tenant, ensure_ascii=False), encoding="utf-8")
|
|
|
|
|
|
return seed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_registry_reload_upsert_new_tenant_file_source(tmp_path):
|
|
|
|
|
|
"""文件源同款 upsert 语义:新租户目录落盘后 reload 插入可 resolve
|
|
|
|
|
|
(Task 5 创建向导 + File 源部署形态的数据通路)。"""
|
|
|
|
|
|
_write_min_seed(tmp_path, {"t1": {
|
|
|
|
|
|
"slug": "t1", "name": "t1", "status": "active",
|
|
|
|
|
|
"sites": [{"slug": "main", "domains": ["a.example.com"]}],
|
|
|
|
|
|
"config": {}}})
|
|
|
|
|
|
reg = TenantRegistry.load_from_dir(
|
|
|
|
|
|
tmp_path / "seed", data_dir=tmp_path / "data")
|
|
|
|
|
|
|
|
|
|
|
|
_write_min_seed(tmp_path, {"t2": {
|
|
|
|
|
|
"slug": "t2", "name": "t2", "status": "active",
|
|
|
|
|
|
"sites": [{"slug": "main", "domains": ["b.example.com"]}],
|
|
|
|
|
|
"config": {}}})
|
|
|
|
|
|
await reg.registry_reload("t2")
|
|
|
|
|
|
rt = await reg.resolve("b.example.com")
|
|
|
|
|
|
assert rt.config.slug == "t2"
|
|
|
|
|
|
await reg.dispose_all()
|