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

187 lines
8.2 KiB
Python
Raw Normal View History

"""Phase 4 Task 5A: resolver 平台默认回退链接入 platform_settings。
回退链(email/ai 两组,键名对齐 admin 平台设置页推荐键):
租户槽位 → 平台设置快照(settings_store 内存 dict)→ env settings。
- 快照数据源: lifespan 启动 load_snapshot 全表读入;PUT /api/platform/
settings 提交后 refresh 热刷新(即时生效,无需重启);
- push 组不在接线范围(平台设置页无 push 推荐键)——保持租户槽位 → env
两级,快照里即使有 push.* 键也不被消费(钉死防误接线);
- 空快照(未加载/未设置)= 接线前行为,env 兜底原样(既有 wiring 测试
的隐含前提,conftest autouse 复位快照隔离本文件外的直调)。
"""
from sqlalchemy import select
from app.engine.config import settings
from app.platform import settings_store
from app.platform.config_views import (
resolve_ai_settings,
resolve_email_settings,
resolve_push_settings,
)
from app.platform.db import (
PlatformSetting,
ensure_platform_schema,
make_platform_engine,
)
from tests.test_tenant_admin_api import _auth
SETTINGS = "/api/platform/settings"
def _load_snapshot_values(mapping: dict) -> None:
"""测试直调注入: 快照整表替换(等价 load_snapshot 对同批行的产物,
免去逐测试建临时库——decode 路径由下方 async 测试单独钉)。"""
settings_store._snapshot.clear()
settings_store._snapshot.update(mapping)
async def test_load_snapshot_reads_and_decodes_all_rows(tmp_path):
"""启动装配路径(lifespan 调的同一函数): 全表读入 + JSON 还原类型;
手工裸文本回退原串(_decode_setting 同款容错);重复 load 整表替换,
库里消失的键不残留。"""
engine = make_platform_engine(tmp_path / "platform.db")
try:
await ensure_platform_schema(engine)
from sqlalchemy.ext.asyncio import AsyncSession
async with AsyncSession(engine) as session:
session.add_all([
PlatformSetting(key="ai.model", value='"glm-4-air"'),
PlatformSetting(key="smtp.port", value="587"),
PlatformSetting(key="plain_note", value="裸文本"),
])
await session.commit()
snap = await settings_store.load_snapshot(engine)
assert snap == {"ai.model": "glm-4-air", "smtp.port": 587,
"plain_note": "裸文本"}
assert settings_store.get("ai.model") == "glm-4-air"
assert settings_store.get("smtp.port") == 587
assert settings_store.get("nope") is None
# 整表替换: 删一行后重 load,旧键不残留
async with AsyncSession(engine) as session:
row = (await session.execute(
select(PlatformSetting).where(
PlatformSetting.key == "plain_note"))).scalar_one()
await session.delete(row)
await session.commit()
await settings_store.refresh(engine)
assert settings_store.get("plain_note") is None
assert settings_store.get("ai.model") == "glm-4-air"
finally:
await engine.dispose()
def test_ai_three_level_fallback_chain(monkeypatch):
"""ai 组三级: 租户槽位 > 平台设置快照 > env;model 出厂哨兵同样落
平台 ai.model;快照缺键 → env。"""
monkeypatch.setattr(settings, "ZHIPU_API_KEY", "env-key")
monkeypatch.setattr(settings, "ZHIPU_BASE_URL", "env-url")
monkeypatch.setattr(settings, "AI_MODEL", "glm-env")
_load_snapshot_values({"ai.api_key": "plat-key", "ai.model": "glm-plat"})
# 1) 租户槽位全胜
assert resolve_ai_settings({"ai": {
"api_key": "t-key", "base_url": "t-url", "model": "glm-t",
}}) == {"provider": "glm", "api_key": "t-key", "base_url": "t-url",
"model": "glm-t"}
# 2) 租户空槽/出厂哨兵 → 平台设置(ai.base_url 快照缺键 → env)
r = resolve_ai_settings({})
assert r == {"provider": "glm", "api_key": "plat-key",
"base_url": "env-url", "model": "glm-plat"}
# model 出厂默认值同样视同未设置 → 平台 ai.model
assert resolve_ai_settings(
{"ai": {"model": "glm-4-flash"}})["model"] == "glm-plat"
# 3) 快照缺键 → env(api_key 平台清空后回落 env)
_load_snapshot_values({})
r = resolve_ai_settings({})
assert r == {"provider": "glm", "api_key": "env-key",
"base_url": "env-url", "model": "glm-env"}
def test_email_three_level_fallback_chain(monkeypatch):
"""smtp 组三级: 租户槽位 > 平台设置 smtp.* > env SMTP_*;段内逐键
回退保留(租户可只改 host);sender_name/notify_emails 不进平台设置
(无推荐键,沿既有规则)。"""
for k, v in (("SMTP_HOST", "env-host"), ("SMTP_PORT", 465),
("SMTP_USER", "env@x.com"), ("SMTP_PASS", "env-pass")):
monkeypatch.setattr(settings, k, v)
_load_snapshot_values({
"smtp.host": "plat.smtp", "smtp.port": 587,
"smtp.user": "plat@x.com", "smtp.password": "plat-pass"})
# 1) 租户段全量覆盖
r = resolve_email_settings({"email": {"smtp": {
"host": "t.smtp", "port": 25, "user": "t@x.com",
"password": "t-pass"}}})
assert (r["host"], r["port"], r["user"], r["password"]) == (
"t.smtp", 25, "t@x.com", "t-pass")
# 2) 空槽 → 平台设置四键齐取
r = resolve_email_settings({})
assert (r["host"], r["port"], r["user"], r["password"]) == (
"plat.smtp", 587, "plat@x.com", "plat-pass")
# 3) 段内逐键: 租户只改 host,其余走平台
r = resolve_email_settings({"email": {"smtp": {"host": "t.smtp"}}})
assert (r["host"], r["port"], r["user"], r["password"]) == (
"t.smtp", 587, "plat@x.com", "plat-pass")
# 4) 快照缺键 → env
_load_snapshot_values({})
r = resolve_email_settings({})
assert (r["host"], r["port"], r["user"], r["password"]) == (
"env-host", 465, "env@x.com", "env-pass")
def test_platform_smtp_port_coercion(monkeypatch):
"""smtp.port 自由 KV 容错: 数字串转 int;非法串/布尔/空 → 视同未设置
落 env(端口是数值语义,不让手工 API 的脏值顶掉可用默认)。"""
monkeypatch.setattr(settings, "SMTP_PORT", 465)
for raw, expected in (("587", 587), (" 465 ", 465), (993, 993)):
_load_snapshot_values({"smtp.port": raw})
assert resolve_email_settings({})["port"] == expected, raw
for raw in ("abc", True, "", None, [465]):
_load_snapshot_values({"smtp.port": raw})
assert resolve_email_settings({})["port"] == 465, raw
def test_push_stays_two_level(monkeypatch):
"""push 不在接线范围: 快照即使存了 push.* 键也不被消费——env 兜底
原样(平台设置页无 push 推荐键,接线时再改本测试)。"""
monkeypatch.setattr(settings, "PUSH_API_URL", "env-url")
monkeypatch.setattr(settings, "PUSH_API_KEY", "env-key")
_load_snapshot_values({"push.url": "plat-url", "push.api_key": "plat-key"})
r = resolve_push_settings({})
assert r["url"] == "env-url" and r["api_key"] == "env-key"
def test_put_settings_refreshes_snapshot_immediately(
super_client, monkeypatch):
"""PUT /api/platform/settings → settings_store.refresh 即时生效: 提交
后 resolver 立刻取到新值;同 key 再写,快照跟随更新(不残留旧值)。
env patch 成异值证明读的是平台层而非 env 兜底。"""
monkeypatch.setattr(settings, "ZHIPU_API_KEY", "env-key")
monkeypatch.setattr(settings, "AI_MODEL", "glm-env")
def _put(key, value):
r = super_client.put(SETTINGS, headers=_auth(super_client),
json={"key": key, "value": value})
assert r.status_code == 200, r.text
_put("ai.api_key", "sk-plat-1")
_put("ai.model", "glm-4-air")
r = resolve_ai_settings({})
assert r["api_key"] == "sk-plat-1" and r["model"] == "glm-4-air"
# 同 key 再写 → 快照跟随(refresh 不是只在新增时生效)
_put("ai.model", "glm-4-plus")
assert resolve_ai_settings({})["model"] == "glm-4-plus"
# 密钥值不因响应掩码而脏: 哨兵掩码只作用响应层,快照存原值
_put("smtp.password", "plat-pass")
assert resolve_email_settings({})["password"] == "plat-pass"