"""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"