@@ -0,0 +1,356 @@
""" 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 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 源测试专属域名(不与文件源种子撞)
_PROMPTS_KEYS = ( " system " , " company_facts " , " extractor " , " evaluator " )
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 ( )