"""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_config import TenantConfig 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, es_status="active"): """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", status=es_status, 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() 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() # ---- 文件源(直测;等价性由既有 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" 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) # ---- 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()