feat: 配置API(分组校验管线/整份版本/回滚/重置出厂/试金石/平台设置)

- config_validation.validate_group: 九组校验管线(类型/键 allowlist/数值
  范围/prompts 大小上限 50KB·100KB/wall 全量交 compile_wall/push.prefix
  跨租户查重 spec E1),错误清单中文带字段路径
- config_routes: GET/PUT config(整组替换原样入版本,MAX+1,热 reload)/
  reset(按 Tenant.profile 取出厂档案组)/versions 列表·详情/rollback
  (旧版复制为新版本,历史不可变)/wall/test 试金石(逐句判定,不落库
  不 bump)/settings 平台设置 CRUD(仅超管,resolver 接线归 Phase 3)
- db.write_config_version: 版本写入公共路径(创建向导与配置 API 单一
  事实源);Tenant 落 profile 列(重置出厂按它取对应档案)
- _profile_snapshot/_blankify 迁 tenant_source(两路由模块共用);
  db_source_app/super_client 夹具收拢 conftest(两测试文件共用)
- 测试: 519 → 537 全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 10:07:40 +08:00
parent ec2e17d5e3
commit 7502176190
10 changed files with 1412 additions and 89 deletions
+46
View File
@@ -143,6 +143,52 @@ AUTO_HOST = "chat.agent.hnxinyouhe.com"
# Task 4 收拢到 conftest 单一定义)
NOPE_HOST = "nope.example.com"
# ---- Phase 2 Task 6: DB 源 app/超管 client 夹具 -----------------------------------
# 自 test_tenant_admin_api 收拢到 conftest(Task 5 时代该文件私有,Task 6 的
# 配置 API/试金石测试与租户管理测试共用同一套 DB 源闭环环境)。
# 跨循环纪律见 db_source_app docstring 与 test_tenant_admin_api 模块头。
@pytest.fixture
def db_source_app(tmp_path):
"""app + 生产形态 registry(DbTenantSource 指向临时 platform.db)。
platform_engine 也指同一临时库(lifespan 在 portal 循环里 ensure_schema
+ bootstrap 超管 admin/admin123)。teardown 复位 app.state(照抄
app_test_client 的关残留窗纪律;engine dispose 由 lifespan finally)。
"""
from app.platform.tenant_source import DbTenantSource
engine = make_platform_engine(tmp_path / "platform.db")
app.state.registry = TenantRegistry(
DbTenantSource(engine), data_dir=tmp_path / "tenants")
app.state.platform_engine = engine
try:
yield app
finally:
app.state.registry = None
app.state.platform_engine = None
@pytest.fixture
def super_client(db_source_app, tmp_path):
"""超管 client:lifespan bootstrap 的 admin/admin123 登录 + 首登改密
(过 must_change 409 门)。token 挂 client.platform_token。"""
with app_test_client(db_source_app, "http://testserver", tmp_path) as c:
r = c.post("/api/platform/auth/login",
json={"username": "admin", "password": "admin123"})
assert r.status_code == 200
c.platform_token = r.json()["token"]
r = c.post("/api/platform/auth/change-password", headers=_bearer_of(c),
json={"old_password": "admin123",
"new_password": "sup3r-pass-1"})
assert r.status_code == 200
yield c
def _bearer_of(client):
return {"Authorization": f"Bearer {client.platform_token}"}
def build_app(seed_dir, data_dir):
"""app 工厂(Task 7): 建 Registry → 预置 app.state.registry → 返回 app。
+431
View File
@@ -0,0 +1,431 @@
"""配置 API(Phase 2 Task 6):分组校验管线/整份版本/回滚/重置出厂/平台设置。
夹具 db_source_app/super_client 自 conftest(DB 源闭环);创建租户等助手
复用 test_tenant_admin_api(_create/_auth/_tenant_admin_token)。
鉴权矩阵(spec §3):config GET/PUT/reset/versions/rollback/wall-test =
require_tenant(slug)(租户全开);settings = require_super。
"""
from app.platform.tenant_source import FileTenantSource
from tests.conftest import SEED_DIR
from tests.test_tenant_admin_api import (
_auth,
_bearer,
_create,
_tenant_admin_token,
)
CONFIG = "/api/platform/tenants/{slug}/config"
SETTINGS = "/api/platform/settings"
LOGIN = "/api/platform/auth/login"
def _cfg(slug):
return CONFIG.format(slug=slug)
def _get_config(client, slug):
r = client.get(_cfg(slug), headers=_auth(client))
assert r.status_code == 200, r.text
return r.json()
def _put_group(client, slug, group, payload, comment=None):
url = f"{_cfg(slug)}/{group}"
if comment is not None:
url += f"?comment={comment}"
return client.put(url, headers=_auth(client), json=payload)
def _jc_snapshot():
return FileTenantSource(SEED_DIR).read_tenant("jiancai").snapshot
# ---- 核心 7 项 ------------------------------------------------------------------
def test_put_group_bumps_version_and_hot_reloads(super_client, db_source_app):
_create(super_client, slug="demo", domains=["demo.example.com"])
# 旧值可见(顺带在 client 循环里懒建 engine——之后断言 reload dispose 它)
r = super_client.get("/api/public/brand",
headers={"host": "demo.example.com"})
assert r.status_code == 200
assert r.json()["color"] == "#066fc0" # jc 档案出厂色
registry = db_source_app.state.registry
rt_old = registry._by_slug["demo"]
assert rt_old.engine is not None
r = _put_group(super_client, "demo", "brand", {"color": "#ff0000"})
assert r.status_code == 200, r.text
assert r.json()["version"] == 2
# 热生效:旧 runtime 已被 reload dispose,新 runtime 服务新值
assert rt_old.engine is None
r = super_client.get("/api/public/brand",
headers={"host": "demo.example.com"})
assert r.status_code == 200
assert r.json()["color"] == "#ff0000"
# 整组替换语义:快照存用户配置原样组,缺省键由消费侧深合并出厂默认
assert r.json()["title"] == "AI Support"
# GET config:新值 + version(原样快照,不是合并视图)
body = _get_config(super_client, "demo")
assert body["version"] == 2
assert body["config"]["brand"] == {"color": "#ff0000"}
def test_put_wall_bad_regex_400_with_field(super_client):
_create(super_client, slug="demo")
wall = _get_config(super_client, "demo")["config"]["wall"]
wall["price_patterns"] = ["([unclosed"]
r = _put_group(super_client, "demo", "wall", wall)
assert r.status_code == 400
detail = r.json()["detail"]
assert "price_patterns[0]" in detail # 字段路径
assert "regex" in detail # regex 字样
# 校验失败版本不涨
assert _get_config(super_client, "demo")["version"] == 1
def test_prompts_size_cap(super_client):
_create(super_client, slug="demo")
prompts = dict(_get_config(super_client, "demo")["config"]["prompts"])
# company_facts 50KB 软上限(spec §5.7):超一节 → 400 带字段路径与上限
fat = dict(prompts, company_facts="x" * (50 * 1024 + 1))
r = _put_group(super_client, "demo", "prompts", fat)
assert r.status_code == 400
assert "company_facts" in r.json()["detail"]
assert "50" in r.json()["detail"]
# 其余三件 100KB 上限
fat = dict(prompts, system="x" * (100 * 1024 + 1))
r = _put_group(super_client, "demo", "prompts", fat)
assert r.status_code == 400
assert "system" in r.json()["detail"]
# 恰在上限内 → 通过(company_facts 正好 50KB)
ok = dict(prompts, company_facts="x" * (50 * 1024))
r = _put_group(super_client, "demo", "prompts", ok)
assert r.status_code == 200
assert r.json()["version"] == 2
# prompts 传字符串(Task 4 守卫兜过的形态)→ 400,不是 500/422
r = super_client.put(f"{_cfg('demo')}/prompts", headers=_auth(super_client),
json="just a string")
assert r.status_code == 400
# 缺键 → 400
r = _put_group(super_client, "demo", "prompts", {"system": "x"})
assert r.status_code == 400
assert "company_facts" in r.json()["detail"]
def test_prefix_uniqueness_across_tenants(super_client):
_create(super_client, slug="jc1", profile="jiancai") # prefix ai-kefu-jc
_create(super_client, slug="au1", profile="auto") # prefix ai-kefu
_create(super_client, slug="bl1", profile="blank") # prefix ""
# auto 租户改成 jc1 的前缀 → 400(spec E1 跨租户查重)
r = _put_group(super_client, "au1", "push", {"prefix": "ai-kefu-jc"})
assert r.status_code == 400
assert "prefix" in r.json()["detail"]
assert "ai-kefu-jc" in r.json()["detail"]
# 新建的 blank 租户(空 prefix)同样撞车 → 400
r = _put_group(super_client, "bl1", "push", {"prefix": "ai-kefu-jc"})
assert r.status_code == 400
# 自身当前前缀重写 → 200(不与自己撞);版本照涨
r = _put_group(super_client, "au1", "push", {"prefix": "ai-kefu"})
assert r.status_code == 200
# 换新前缀后,别人再撞新前缀也 400(查重集合随 DB 当前版本刷新)
r = _put_group(super_client, "au1", "push", {"prefix": "ai-kefu-new"})
assert r.status_code == 200
r = _put_group(super_client, "bl1", "push", {"prefix": "ai-kefu-new"})
assert r.status_code == 400
# 空串(未设置)不参战:多个租户可同为 ""
r = _put_group(super_client, "bl1", "push", {"prefix": ""})
assert r.status_code == 200
def test_rollback_restores(super_client):
_create(super_client, slug="demo", domains=["demo.example.com"])
assert _put_group(super_client, "demo", "brand",
{"color": "#ff0000"}).status_code == 200 # v2
# 版本列表:元数据齐全,不含 config 本体
r = super_client.get(f"{_cfg('demo')}/versions", headers=_auth(super_client))
assert r.status_code == 200
items = r.json()["items"]
assert [i["version"] for i in items] == [1, 2]
assert "config_json" not in r.text and '"config"' not in r.text
assert items[0]["updated_by"].startswith("super#")
assert items[0]["comment"] == "创建向导: profile=jiancai"
# 回滚到 v1 → 生效值回 v1,产生新版本 v3(历史不可变)
r = super_client.post(f"{_cfg('demo')}/rollback", headers=_auth(super_client),
json={"version": 1})
assert r.status_code == 200
assert r.json()["version"] == 3
body = _get_config(super_client, "demo")
assert body["version"] == 3
assert body["config"]["brand"]["color"] == "#066fc0" # v1 的值
# 公开门面也回到 v1 色(reload 已生效)
r = super_client.get("/api/public/brand",
headers={"host": "demo.example.com"})
assert r.json()["color"] == "#066fc0"
# v2 历史仍在(整份可查),回滚目标不存在 → 404
r = super_client.get(f"{_cfg('demo')}/versions/2", headers=_auth(super_client))
assert r.status_code == 200
assert r.json()["config"]["brand"]["color"] == "#ff0000"
r = super_client.post(f"{_cfg('demo')}/rollback", headers=_auth(super_client),
json={"version": 99})
assert r.status_code == 404
def test_tenant_admin_scoped_config(super_client):
_create(super_client, slug="jc2", admin="jc2-admin",
password="jc2-pass-1")
_create(super_client, slug="auto2", profile="auto")
token = _tenant_admin_token(super_client, "jc2-admin", "jc2-pass-1")
# 本租户全开:GET/PUT/versions/rollback/reset/wall-test
assert super_client.get(
_cfg("jc2"), headers=_bearer(token)).status_code == 200
r = super_client.put(f"{_cfg('jc2')}/brand", headers=_bearer(token),
json={"color": "#123456"})
assert r.status_code == 200
assert r.json()["version"] == 2
assert super_client.get(
f"{_cfg('jc2')}/versions", headers=_bearer(token)).status_code == 200
r = super_client.post(f"{_cfg('jc2')}/rollback", headers=_bearer(token),
json={"version": 1})
assert r.status_code == 200
r = super_client.post(f"{_cfg('jc2')}/brand/reset", headers=_bearer(token))
assert r.status_code == 200
r = super_client.post("/api/platform/tenants/jc2/wall/test",
headers=_bearer(token), json={"sentences": ["ok."]})
assert r.status_code == 200
# 改别的租户 → 403
assert super_client.put(
f"{_cfg('auto2')}/brand", headers=_bearer(token),
json={"color": "#000000"}).status_code == 403
assert super_client.get(
_cfg("auto2"), headers=_bearer(token)).status_code == 403
assert super_client.post(
"/api/platform/tenants/auto2/wall/test", headers=_bearer(token),
json={"sentences": ["ok."]}).status_code == 403
# 平台设置超管专属
assert super_client.get(
SETTINGS, headers=_bearer(token)).status_code == 403
assert super_client.put(
SETTINGS, headers=_bearer(token),
json={"key": "k", "value": 1}).status_code == 403
# 未认证 401
assert super_client.get(_cfg("jc2")).status_code == 401
# ---- 校验管线:类型/未知键/范围 ---------------------------------------------------
def test_validation_type_errors(super_client):
_create(super_client, slug="demo")
cases = [
# (group, payload, detail 必含片段)
("nope", {"x": 1}, "未知配置组"),
("brand", {"unknown_key": 1}, "未知键"),
("brand", {"color": 123}, "brand.color"),
("brand", {"topics": ["a", 2]}, "brand.topics"),
("contact", {"cc_completion": "yes"}, "cc_completion"),
("contact", {"template_languages": "en"}, "template_languages"),
("email", {"notify_emails": ["no-at-sign"]}, "notify_emails"),
("email", {"smtp": "host"}, "smtp"),
("push", {"timeout": 0}, "timeout"),
("push", {"max_attempts": 11}, "max_attempts"),
("push", {"retry_backoff_base": -1}, "retry_backoff_base"),
("push", {"enabled": 1}, "enabled"),
("ai", {"model": 42}, "model"),
("engine", {"rate_limit_per_hour": "30"}, "rate_limit_per_hour"),
# bool 不是 int(isinstance 陷阱)
("engine", {"rate_limit_per_hour": True}, "rate_limit_per_hour"),
("engine", {"ws_conn_limit": 0}, "ws_conn_limit"),
("engine", {"context_seed_keys": {"k": 1}}, "context_seed_keys.k"),
("misc", {"name_to_region_extras": "x"}, "name_to_region_extras"),
]
for group, payload, needle in cases:
r = _put_group(super_client, "demo", group, payload)
assert r.status_code == 400, f"{group}/{payload}: {r.text}"
assert needle in r.json()["detail"], f"{group}/{payload}: {r.text}"
# 全军覆没后版本仍是 1
assert _get_config(super_client, "demo")["version"] == 1
def test_valid_groups_pass(super_client):
_create(super_client, slug="demo")
ok_cases = [
("contact", {"cc_completion": False, "email_light_ask": True,
"template_languages": ["en", "zh"]}),
("email", {"sender_name": "Demo", "notify_emails": ["a@b.com"],
"smtp": {"host": "smtp.example.com", "port": 465}}),
("email", {"smtp": None}),
("push", {"timeout": 5.5, "max_attempts": 3,
"retry_backoff_base": 2}),
("push", {"timeout": None, "max_attempts": None}),
("ai", {"api_key": None, "base_url": None, "model": "glm-4-flash"}),
("engine", {"rate_limit_per_hour": 60, "ws_conn_limit": 2,
"context_seed_keys": {"k": "v"}}),
("misc", {"name_to_region_extras": {"US": "加州"}}),
]
version = 1
for group, payload in ok_cases:
r = _put_group(super_client, "demo", group, payload)
assert r.status_code == 200, f"{group}: {r.text}"
version += 1
assert r.json()["version"] == version
assert r.json()["config"] == payload
def test_put_unknown_tenant_and_misc(super_client):
_create(super_client, slug="demo")
assert super_client.get(
_cfg("ghost"), headers=_auth(super_client)).status_code == 404
r = super_client.put(f"{_cfg('ghost')}/brand", headers=_auth(super_client),
json={"color": "#000"})
assert r.status_code == 404
# rollback 坏 body → 400(非 422/500)
r = super_client.post(f"{_cfg('demo')}/rollback", headers=_auth(super_client),
json={"version": "one"})
assert r.status_code == 400
r = super_client.post(f"{_cfg('demo')}/rollback", headers=_auth(super_client),
json={})
assert r.status_code == 400
# PUT comment 落版本元数据
_put_group(super_client, "demo", "brand", {"color": "#000000"},
comment="换主题色")
items = _get_config(super_client, "demo")
r = super_client.get(f"{_cfg('demo')}/versions", headers=_auth(super_client))
assert r.json()["items"][-1]["comment"] == "换主题色"
# ---- 重置出厂 --------------------------------------------------------------------
def test_reset_group_to_factory(super_client):
_create(super_client, slug="demo", domains=["demo.example.com"])
_put_group(super_client, "demo", "brand", {"color": "#ff0000"}) # v2
r = super_client.post(f"{_cfg('demo')}/brand/reset",
headers=_auth(super_client))
assert r.status_code == 200
assert r.json()["version"] == 3
# 回到 jc 档案的整组 brand(重置出厂 = 代码库当前模板,spec §5.6)
assert r.json()["config"] == _jc_snapshot()["brand"]
body = _get_config(super_client, "demo")
assert body["config"]["brand"]["color"] == "#066fc0"
r = super_client.get("/api/public/brand",
headers={"host": "demo.example.com"})
assert r.json()["color"] == "#066fc0"
assert r.json()["title"] == "HESUPIPE Support"
# 未知组 reset → 400
r = super_client.post(f"{_cfg('demo')}/nope/reset",
headers=_auth(super_client))
assert r.status_code == 400
def test_reset_blank_tenant_groups(super_client):
"""blank 租户:wall/prompts 重置取 jiancai 档案;档案没有的组取出厂默认。"""
_create(super_client, slug="bl1", profile="blank")
_put_group(super_client, "bl1", "brand", {"color": "#ff0000"})
# brand → blankified 档案(身份键出厂默认,其余 jc)
r = super_client.post(f"{_cfg('bl1')}/brand/reset",
headers=_auth(super_client))
assert r.status_code == 200
brand = r.json()["config"]
assert brand["title"] == "AI Support" # 身份键 = DEFAULT
assert brand["whatsapp"] == ""
assert brand["color"] == "#066fc0" # 非身份键 = jc 档案
# wall → jc 档案
r = super_client.post(f"{_cfg('bl1')}/wall/reset", headers=_auth(super_client))
assert r.status_code == 200
assert r.json()["config"] == _jc_snapshot()["wall"]
# 档案缺的组 → DEFAULT_CONFIG 整组
from app.platform.defaults import DEFAULT_CONFIG
r = super_client.post(f"{_cfg('bl1')}/contact/reset",
headers=_auth(super_client))
assert r.status_code == 200
assert r.json()["config"] == DEFAULT_CONFIG["contact"]
# ---- 平台设置(仅超管;本阶段只落存储与接口) ----------------------------------------
def test_platform_settings_crud(super_client):
r = super_client.get(SETTINGS, headers=_auth(super_client))
assert r.status_code == 200
assert r.json() == {"items": []}
# 写入结构化值(JSON Text 存储,读回还原类型)
value = {"host": "smtp.example.com", "port": 465, "tls": True}
r = super_client.put(SETTINGS, headers=_auth(super_client),
json={"key": "default_smtp", "value": value})
assert r.status_code == 200
assert r.json() == {"key": "default_smtp", "value": value}
r = super_client.get(SETTINGS, headers=_auth(super_client))
assert r.json()["items"] == [{"key": "default_smtp", "value": value}]
# 同 key 再写 = 更新(upsert,不重复行)
r = super_client.put(SETTINGS, headers=_auth(super_client),
json={"key": "default_smtp", "value": "second"})
assert r.status_code == 200
r = super_client.get(SETTINGS, headers=_auth(super_client))
assert r.json()["items"] == [{"key": "default_smtp", "value": "second"}]
# 标量值也可
r = super_client.put(SETTINGS, headers=_auth(super_client),
json={"key": "glm_timeout", "value": 30})
assert r.status_code == 200
# 坏 key/缺 value → 400
assert super_client.put(
SETTINGS, headers=_auth(super_client),
json={"value": 1}).status_code == 400
assert super_client.put(
SETTINGS, headers=_auth(super_client),
json={"key": "", "value": 1}).status_code == 400
# 未认证 401
assert super_client.get(SETTINGS).status_code == 401
# ---- 版本列表形态 -----------------------------------------------------------------
def test_versions_listing_shape(super_client, tmp_path):
_create(super_client, slug="demo")
_put_group(super_client, "demo", "brand", {"color": "#ff0000"})
r = super_client.get(f"{_cfg('demo')}/versions", headers=_auth(super_client))
items = r.json()["items"]
assert len(items) == 2
for item in items:
assert set(item) == {"version", "created_at", "updated_by", "comment"}
assert item["created_at"] # ISO 时间戳
# 详情 404
r = super_client.get(f"{_cfg('demo')}/versions/9", headers=_auth(super_client))
assert r.status_code == 404
def test_wall_group_round_trip_and_body_hygiene(super_client):
"""整墙组原样 PUT 回去必须通过(compile_wall 全量校验的 happy path);
坏 JSON 原文/组名撞 versions 字面量一律 400;未改密租户管理员 409 门
经 platform_user 连锁生效。"""
_create(super_client, slug="demo")
wall = _get_config(super_client, "demo")["config"]["wall"]
r = _put_group(super_client, "demo", "wall", wall)
assert r.status_code == 200
assert r.json()["config"] == wall
# 组名撞 versions 字面量 → 走未知组 400(不与版本路由混淆)
r = _put_group(super_client, "demo", "versions", {"a": 1})
assert r.status_code == 400
assert "未知配置组" in r.json()["detail"]
# 坏 JSON 原文 → 400(手动解析,不让 422/500 抢答)
r = super_client.put(f"{_cfg('demo')}/brand", headers=_auth(super_client),
content=b"{not json")
assert r.status_code == 400
# 首登未改密的租户管理员 → 409(与登录/改密之外的受保护端点同款门)
_create(super_client, slug="t2", admin="t2-admin", password="t2-pass-1")
token = super_client.post(
LOGIN, json={"username": "t2-admin", "password": "t2-pass-1"}
).json()["token"]
r = super_client.get(_cfg("t2"), headers=_bearer(token))
assert r.status_code == 409
+6 -44
View File
@@ -1,7 +1,8 @@
"""租户管理 API(Phase 2 Task 5):超管的创建向导/站点/域名/停用 + 403 边界。
db_source_app 夹具:app.state.registry 用 DbTenantSource(生产形态)指向
临时 platform.db——创建 API 写库后 registry_reload 走 DB 源闭环(Task 4
db_source_app/super_client 夹具(Task 6 起收拢到 conftest,与配置 API 测试
共用):app.state.registry 用 DbTenantSource(生产形态)指向临时
platform.db——创建 API 写库后 registry_reload 走 DB 源闭环(Task 4
的 upsert 语义:未加载的 slug 直接插入,创建即可用)。platform.db 起始
为空(schema+bootstrap 超管由 lifespan 跑)——租户全部经创建 API 进入,
registry 不做初始 load_all(生产装配的启动次序归 Task 11)。
@@ -20,62 +21,23 @@ from sqlalchemy.pool import NullPool
from app.engine.services.ai_service import PromptProvider
from app.engine.wall_schema import compile_wall
from app.main import app
from app.platform.db import (
AdminUser,
Site,
SiteDomain,
Tenant,
TenantConfigVersion,
make_platform_engine,
)
from app.platform.defaults import DEFAULT_CONFIG
from app.platform.registry import TenantDisabledError, TenantRegistry
from app.platform.routes.tenant_routes import _blankify
from app.platform.tenant_source import DbTenantSource, FileTenantSource
from tests.conftest import SEED_DIR, app_test_client
from app.platform.registry import TenantDisabledError
from app.platform.tenant_source import FileTenantSource, _blankify
from tests.conftest import SEED_DIR
TENANTS = "/api/platform/tenants"
LOGIN = "/api/platform/auth/login"
CHANGE_PW = "/api/platform/auth/change-password"
# ---- 夹具 ----------------------------------------------------------------------
@pytest.fixture
def db_source_app(tmp_path):
"""app + 生产形态 registry(DbTenantSource 指向临时 platform.db)。
platform_engine 也指同一临时库(lifespan 在 portal 循环里 ensure_schema
+ bootstrap 超管 admin/admin123)。teardown 复位 app.state(照抄
app_test_client 的关残留窗纪律;engine dispose 由 lifespan finally)。
"""
engine = make_platform_engine(tmp_path / "platform.db")
app.state.registry = TenantRegistry(
DbTenantSource(engine), data_dir=tmp_path / "tenants")
app.state.platform_engine = engine
try:
yield app
finally:
app.state.registry = None
app.state.platform_engine = None
@pytest.fixture
def super_client(db_source_app, tmp_path):
"""超管 client:lifespan bootstrap 的 admin/admin123 登录 + 首登改密
(过 must_change 409 门)。token 挂 client.platform_token。"""
with app_test_client(db_source_app, "http://testserver", tmp_path) as c:
r = c.post(LOGIN, json={"username": "admin", "password": "admin123"})
assert r.status_code == 200
c.platform_token = r.json()["token"]
r = c.post(CHANGE_PW, headers=_auth(c), json={
"old_password": "admin123", "new_password": "sup3r-pass-1"})
assert r.status_code == 200
yield c
# ---- 助手 ----------------------------------------------------------------------
+136
View File
@@ -0,0 +1,136 @@
"""试金石(wall/test,Phase 2 Task 6):逐句墙判定预览。
复用 test_wall_execution_tenant 的同款姿势——boundary_filter 纯函数 +
jc/auto 两墙的词表差异做判别。判定优先级与 conversation.py 执行侧一致:
价格墙 → 零号码墙 → 再索要墙(仅已留资)。
红线:不落库、不 bump 版本(reset/编辑器共用的预览入口)。
"""
from app.engine.services import boundary_filter
from app.platform.tenant_source import FileTenantSource
from tests.conftest import SEED_DIR
from tests.test_tenant_admin_api import _auth, _create
WALL_TEST = "/api/platform/tenants/{slug}/wall/test"
# 判别句选取依据(两墙 diff 实证,勿随手改文案——判别力依赖词表差异):
# - PRICE 句撞 jc 独有的 [€£]\s?\d 组(auto 词表无此组);
# - EMAIL 句撞零号码墙 any_email(替换句 = jc no_contact["web"]);
# - CLEAN 句三墙全放行。
PRICE = "The total is €500 for one set."
EMAIL = "Sure, you can also email me at sales@example.com anytime."
CLEAN = "We ship worldwide every day."
REASK = "What's your WhatsApp number?"
AUTO_WALL = FileTenantSource(SEED_DIR).read_tenant("auto").snapshot["wall"]
JC_WALL = FileTenantSource(SEED_DIR).read_tenant("jiancai").snapshot["wall"]
def _post(client, slug, payload):
return client.post(WALL_TEST.format(slug=slug),
headers=_auth(client), json=payload)
def _config_version(client, slug):
r = client.get(f"/api/platform/tenants/{slug}/config",
headers=_auth(client))
assert r.status_code == 200
return r.json()["version"]
def test_touchstone_verdicts(super_client):
_create(super_client, slug="demo")
version_before = _config_version(super_client, "demo")
r = _post(super_client, "demo", {"sentences": [PRICE, EMAIL, CLEAN]})
assert r.status_code == 200, r.text
body = r.json()
assert body["wall_source"] == "current"
results = body["results"]
assert len(results) == 3
# 价格墙: blocked + 替换话术来自 jc deflections 池(en 首句)
assert results[0]["action"] == "blocked"
assert results[0]["reason"] == "boundary"
assert results[0]["replacement"] == boundary_filter.DEFLECTIONS_EN[0]
# 零号码墙: replaced + 替换句精确等于 jc no_contact["web"]
assert results[1]["action"] == "replaced"
assert results[1]["reason"] == "no_contact"
assert results[1]["replacement"] == boundary_filter.NO_CONTACT_WEB
# 干净句: pass,无替换
assert results[2]["action"] == "pass"
assert results[2]["reason"] is None
assert results[2]["replacement"] is None
# 不落库不 bump 版本
assert _config_version(super_client, "demo") == version_before
def test_touchstone_has_contact_and_source_variants(super_client):
"""已留资/app 渠道两开关照搬执行侧语义:替换句换池/换变体,再索要墙启用。"""
_create(super_client, slug="demo")
# has_contact=True: 价格墙替换句来自 deflections_contact 池
r = _post(super_client, "demo", {"sentences": [PRICE, EMAIL, REASK],
"has_contact": True})
results = r.json()["results"]
assert results[0]["replacement"] == boundary_filter.DEFLECTIONS_EN_CONTACT[0]
# 零号码墙替换句换 already 变体(已留资不再索要)
assert results[1]["replacement"] == boundary_filter.NO_CONTACT_ALREADY
# 再索要句被丢(has_contact 才启用): blocked 无替换
assert results[2]["action"] == "blocked"
assert results[2]["reason"] == "reask"
assert results[2]["replacement"] is None
# source=app: 零号码墙替换句用 app 变体
r = _post(super_client, "demo", {"sentences": [EMAIL], "source": "app"})
assert r.json()["results"][0]["replacement"] == boundary_filter.NO_CONTACT_APP
# has_contact=False(默认): 再索要句放行(墙未启用)
r = _post(super_client, "demo", {"sentences": [REASK]})
assert r.json()["results"][0]["action"] == "pass"
def test_touchstone_wall_override(super_client):
"""body 带 wall 覆盖 = reset/编辑器的预览:同句换墙判定翻转,不动库。"""
_create(super_client, slug="demo")
version_before = _config_version(super_client, "demo")
# 覆盖为 auto 墙(无 € 组): 价格句放行
r = _post(super_client, "demo",
{"sentences": [PRICE], "wall": AUTO_WALL})
assert r.status_code == 200
body = r.json()
assert body["wall_source"] == "override"
assert body["results"][0]["action"] == "pass"
# 覆盖墙坏 regex → 400 带字段路径与 regex 字样
bad = dict(JC_WALL)
bad["price_patterns"] = ["([unclosed"]
r = _post(super_client, "demo", {"sentences": [PRICE], "wall": bad})
assert r.status_code == 400
assert "price_patterns[0]" in r.json()["detail"]
assert "regex" in r.json()["detail"]
# 覆盖实验全程不 bump 版本
assert _config_version(super_client, "demo") == version_before
def test_touchstone_input_validation(super_client):
_create(super_client, slug="demo")
# sentences 必须是非空字符串列表
assert _post(super_client, "demo", {"sentences": []}).status_code == 400
assert _post(super_client, "demo",
{"sentences": "not a list"}).status_code == 400
assert _post(super_client, "demo",
{"sentences": ["ok", 42]}).status_code == 400
assert _post(super_client, "demo", {}).status_code == 400
# source 只认 web/app;has_contact 必须 bool;未知键拒绝
assert _post(super_client, "demo",
{"sentences": ["ok."], "source": "sms"}).status_code == 400
assert _post(super_client, "demo",
{"sentences": ["ok."],
"has_contact": "yes"}).status_code == 400
assert _post(super_client, "demo",
{"sentences": ["ok."], "surprise": 1}).status_code == 400
# 未知租户 404
assert _post(super_client, "ghost",
{"sentences": ["ok."]}).status_code == 404