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

355 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
from app.engine.services.ai_service import PROMPTS_KEYS, PromptProvider
from app.platform.db import (
Site,
SiteDomain,
Tenant,
TenantConfigVersion,
ensure_platform_schema,
make_platform_engine,
)
from app.platform.registry import UnknownTenantError, TenantRegistry
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")
for name in PROMPTS_KEYS
}
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,
config_json=None, with_version=True):
"""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",
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()
# ---- 文件源(直测;等价性由既有 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"
# ---- 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()