Files
ai-kefu-admin/tests/test_tenant_config_wiring.py
T
浅念 49e2b476f5 fix: 配置接线审查修复(push数值哨兵化/恒真测试修正/哨兵防漂移/种子映射消双路径)
Important-1: push 数值字段(timeout/max_attempts/retry_backoff_base)
出厂默认改 None 哨兵(与 ai.model 同款),resolve_push_settings 对 None
回退 env——根治 truthy 出厂默认永久遮住 env 覆盖;恒真测试修正(env 数值
patch 成 7.5/5/2.0 与出厂区分)+租户显式数值优先断言;ai.provider 遮
env 的边界记入 docstring。Minor-2: 出厂 model 哨兵与 DEFAULT_CONFIG
一致性测试 + defaults.py 双源注释(model/rate_limit_per_hour)。
Minor-4: allowed_seed_keys → context_seed_map(返回映射,单一取值路径)。
Minor-6/7/3/8: WS 配置冻结时机注释/name_to_region 查表序注释改准确/
chat_rest limit 0 语义注释/resolver None 路径仅供测试注释。404 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-12 21:57:57 +08:00

322 lines
14 KiB
Python

"""Task 9: 服务层配置接线——DEFAULT_CONFIG 九组 → 真实消费方的缺口测试。
既有测试 monkeypatch 的都是 settings(环境层): 配置组没人读时它们照样绿,
两租户联调只会静默错配。本文件从 TenantConfig 出发逐组逼出消费点:
- email: resolve_email_settings 租户段优先, 空槽回退 env settings;
send_* 消费 resolved 产物(From/To 走租户发件名/通知列表)
- push: resolve_push_settings 全字段(租户段优先, 空槽回退 env)
- engine.rate_limit_per_hour + rate_limit 桶键含租户维度
- ai: resolve_ai_settings 覆盖生效; api_key/base_url/model 空槽回退 env
- contact: cc_completion/email_light_ask 行为开关在引擎级真实门控
- misc.name_to_region_extras ⊕ cc_completion.NAME_TO_REGION
- engine.context_seed_keys 种子白名单(create_conversation 消费)
"""
import asyncio
from dataclasses import replace
from sqlalchemy import select
from app.engine.config import settings
from app.engine.services import email_service, lead_push_service
from app.engine.services.ai_service import resolve_ai_settings
from app.engine.services.cc_completion import name_to_region
from app.engine.services.conversation import (
ConversationManager, context_seed_map, resolve_contact_flags,
)
from app.engine.services.rate_limit import _reset_for_test, check
from app.platform.tenant_config import TenantConfig
from tests.conftest import build_jiancai_runtime
def _cfg(snapshot):
return TenantConfig.from_snapshot("t1", "T", snapshot)
# ---- email ---------------------------------------------------------------
def test_email_reads_tenant_smtp(monkeypatch):
"""租户段优先于全局 settings: smtp/sender_name/notify_emails 全量覆盖。"""
monkeypatch.setattr(settings, "SMTP_HOST", "env-host")
monkeypatch.setattr(settings, "SMTP_USER", "env@x.com")
r = email_service.resolve_email_settings(_cfg({"email": {
"smtp": {"host": "t.smtp", "port": 587, "user": "t@x.com",
"password": "t-pass"},
"sender_name": "Auto Co",
"notify_emails": ["a@t.com", "b@t.com"],
}}).data)
assert r == {
"host": "t.smtp", "port": 587, "user": "t@x.com",
"password": "t-pass", "sender_name": "Auto Co",
"notify_emails": ["a@t.com", "b@t.com"],
}
def test_email_falls_back_to_env_settings(monkeypatch):
"""smtp=None(缺省/显式清空)→ env SMTP_*; 发件名空 → 平台默认
(旧硬编码【HESUPIPE】); notify_emails 空列表 → env NOTIFY_EMAIL。"""
for k, v in (("SMTP_HOST", "env-host"), ("SMTP_PORT", 465),
("SMTP_USER", "env@x.com"), ("SMTP_PASS", "env-pass"),
("NOTIFY_EMAIL", "notify@env.com")):
monkeypatch.setattr(settings, k, v)
r = email_service.resolve_email_settings(_cfg({}).data)
assert r == {
"host": "env-host", "port": 465, "user": "env@x.com",
"password": "env-pass", "sender_name": "HESUPIPE",
"notify_emails": ["notify@env.com"],
}
def test_email_partial_smtp_override(monkeypatch):
"""smtp 段内逐键回退: 只给 host, 其余(user/password/port)沿用 env。"""
monkeypatch.setattr(settings, "SMTP_HOST", "env-host")
monkeypatch.setattr(settings, "SMTP_PORT", 465)
monkeypatch.setattr(settings, "SMTP_USER", "env@x.com")
monkeypatch.setattr(settings, "SMTP_PASS", "env-pass")
r = email_service.resolve_email_settings(
_cfg({"email": {"smtp": {"host": "t.smtp"}}}).data)
assert (r["host"], r["port"], r["user"], r["password"]) == (
"t.smtp", 465, "env@x.com", "env-pass")
def test_send_lead_email_uses_tenant_sender_and_recipients(monkeypatch):
"""send_* 消费接线: From = 租户发件名, To = 租户通知列表。"""
captured = {}
async def fake_send(msg, cfg=None):
captured["msg"] = msg
return True
monkeypatch.setattr(email_service, "_send", fake_send)
cfg = _cfg({"email": {
"smtp": {"host": "h", "user": "t@x.com"},
"sender_name": "ACME",
"notify_emails": ["x@t.com", "y@t.com"],
}})
ok = asyncio.run(email_service.send_lead_email(
customer_profile={}, contact_info={"email": "c@x.com"},
reasoning="", conversation_messages=[],
config_data=cfg.data,
))
assert ok is True
assert str(captured["msg"]["From"]) == "【ACME】 <t@x.com>"
assert str(captured["msg"]["To"]) == "x@t.com, y@t.com"
# ---- push ----------------------------------------------------------------
def _patch_push_env(monkeypatch):
"""env PUSH_* 全量 patch——数值刻意取与出厂不同的值(7.5/5/2.0):
出厂哨兵 None 不参与取值,回退是否真实生效由此可判(审查 Important-1,
修复前 env patch 成出厂同值 10/3/1.0 时数值断言恒真)。"""
monkeypatch.setattr(settings, "PUSH_ENABLED", True)
monkeypatch.setattr(settings, "PUSH_API_URL", "env-url")
monkeypatch.setattr(settings, "PUSH_API_KEY", "env-key")
monkeypatch.setattr(settings, "PUSH_EXTERNAL_KEY_PREFIX", "env-pfx")
monkeypatch.setattr(settings, "PUSH_TIMEOUT_SECONDS", 7.5)
monkeypatch.setattr(settings, "PUSH_MAX_ATTEMPTS", 5)
monkeypatch.setattr(settings, "PUSH_RETRY_BACKOFF_BASE", 2.0)
def test_push_reads_tenant_section(monkeypatch):
"""PUSH 组全部字段: 租户段给了就用租户的(显式数值优先于 env——
env 已 patch 成 7.5/5/2.0,断言 5/4/0.5 才证明读的是租户槽)。"""
_patch_push_env(monkeypatch)
r = lead_push_service.resolve_push_settings(_cfg({"push": {
"enabled": True, "url": "https://t.example/push", "api_key": "t-key",
"prefix": "t-pfx", "timeout": 5, "max_attempts": 4,
"retry_backoff_base": 0.5,
}}).data)
assert r == {
"enabled": True, "url": "https://t.example/push", "api_key": "t-key",
"prefix": "t-pfx", "timeout": 5, "max_attempts": 4,
"retry_backoff_base": 0.5,
}
# 租户显式数值优先于 env(哨兵化后 falsy-truthy 争议不再依赖巧合)
assert (r["timeout"], r["max_attempts"], r["retry_backoff_base"]) == (
5, 4, 0.5)
def test_push_empty_section_falls_back_to_env(monkeypatch):
"""空槽(默认 False/空串/数值 None 哨兵)视同未设置 → 回退 env PUSH_*。"""
_patch_push_env(monkeypatch)
r = lead_push_service.resolve_push_settings(_cfg({}).data)
assert r == {
"enabled": True, "url": "env-url", "api_key": "env-key",
"prefix": "env-pfx", "timeout": 7.5, "max_attempts": 5,
"retry_backoff_base": 2.0,
}
# ---- rate limit ----------------------------------------------------------
def test_rate_limit_key_includes_tenant():
"""桶键 = (tenant, ip, device): 同 ip+device 在两租户不共享预算;
limit 读 config.engine.rate_limit_per_hour。"""
_reset_for_test()
for _ in range(3):
assert check("tenant-a", "1.2.3.4", "dev1", max_requests=3) is True
assert check("tenant-a", "1.2.3.4", "dev1", max_requests=3) is False
assert check("tenant-b", "1.2.3.4", "dev1", max_requests=3) is True
limit = _cfg({"engine": {"rate_limit_per_hour": 2}})\
.data["engine"]["rate_limit_per_hour"]
assert check("t", "9.9.9.9", "d", max_requests=limit) is True
assert check("t", "9.9.9.9", "d", max_requests=limit) is True
assert check("t", "9.9.9.9", "d", max_requests=limit) is False
# ---- AI ------------------------------------------------------------------
def test_ai_settings_override_and_fallback(monkeypatch):
monkeypatch.setattr(settings, "ZHIPU_API_KEY", "env-key")
monkeypatch.setattr(settings, "ZHIPU_BASE_URL", "env-url")
monkeypatch.setattr(settings, "AI_MODEL", "glm-4-plus")
assert resolve_ai_settings(_cfg({"ai": {
"api_key": "t-key", "base_url": "t-url", "model": "glm-4-air",
}}).data) == {
"provider": "glm", "api_key": "t-key", "base_url": "t-url",
"model": "glm-4-air",
}
# 未覆盖回退 env: api_key/base_url 空槽(None)→ env;
# model 出厂默认值(glm-4-flash)视同未设置 → env AI_MODEL
r = resolve_ai_settings(_cfg({}).data)
assert r == {"provider": "glm", "api_key": "env-key",
"base_url": "env-url", "model": "glm-4-plus"}
# ---- contact 行为开关 ------------------------------------------------------
def test_contact_toggles_resolve():
assert resolve_contact_flags(_cfg({}).data) == {
"cc_completion": True, "email_light_ask": True}
assert resolve_contact_flags(_cfg({"contact": {
"cc_completion": False, "email_light_ask": False}}).data) == {
"cc_completion": False, "email_light_ask": False}
def _runtime_with(snapshot):
"""jiancai 运行时 + 覆盖配置段(共享加载期产物, 无 engine 无句柄)。"""
cfg = TenantConfig.from_snapshot("jiancai", "T", snapshot)
return replace(build_jiancai_runtime(), config=cfg)
class _PassThroughAI:
"""确定性桩: 不拦截不抽取, 只回固定句。"""
async def stream_chat(self, messages, context=""):
for token in ("ok ", "reply"):
yield token
async def extract_info(self, messages, existing_info):
return {}
async def evaluate(self, profile, recent_messages):
return {"intent_level": "pending", "reasoning": "",
"contact_info": {}, "info_sufficient": False}
def _send_and_get_raw(async_db, runtime, text):
async def run():
async with async_db() as db:
mgr = ConversationManager(db, runtime=runtime,
ai_service=_PassThroughAI())
conv = await mgr.create_conversation(source=None)
async for _ in mgr.stream_process_message(conv.session_id, text):
pass
return conv.id
cid = asyncio.run(run())
async def fetch():
from app.engine.models import CustomerProfile
async with async_db() as db:
profile = (await db.execute(select(CustomerProfile).where(
CustomerProfile.conversation_id == cid))).scalar_one()
return dict(profile.raw_info or {})
return asyncio.run(fetch())
def test_cc_completion_toggle_gates_engine(async_db):
"""cc_completion=False: 轮前判定树不建 _cc(裸号无状态机痕迹);
True(默认): 照建。"""
raw_off = _send_and_get_raw(
async_db, _runtime_with({"contact": {"cc_completion": False}}),
"my whatsapp is 0501234567")
assert "_cc" not in raw_off
assert raw_off.get("联系方式", {}).get("whatsapp") == "0501234567"
raw_on = _send_and_get_raw(async_db, build_jiancai_runtime(),
"my whatsapp is 0501234567")
assert raw_on.get("_cc", {}).get("whatsapp", {}).get("canon") == "0501234567"
def test_email_light_ask_toggle_gates_engine(async_db):
"""email_light_ask=False: 一次性计数键 _email_asked 不写;
True(默认): 写。"""
raw_off = _send_and_get_raw(
async_db, _runtime_with({"contact": {"email_light_ask": False}}),
"my whatsapp is +966501234567")
assert "_email_asked" not in raw_off
raw_on = _send_and_get_raw(async_db, build_jiancai_runtime(),
"my whatsapp is +966501234567")
assert raw_on.get("_email_asked") is True
# ---- 映射表 / 种子键 -------------------------------------------------------
def test_name_to_region_extras_merged():
"""misc.name_to_region_extras ⊕ NAME_TO_REGION: extras 可扩表也可覆盖。"""
extras = {"德意志": "DE", "沙特": "QA"}
assert name_to_region("德意志") is None # 基础表无此键
assert name_to_region("德意志", extras=extras) == "DE"
assert name_to_region("沙特", extras=extras) == "QA" # extras 覆盖基础表
assert name_to_region("中国", extras=extras) == "CN" # 基础表照常可用
assert name_to_region("SAUDI ARABIA", extras=extras) == "SA"
def test_chat_rest_seed_keys_from_config():
"""种子键白名单 = context_seed_map 映射的键集合。"""
cfg = _cfg({"engine": {"context_seed_keys": {
"vehicle_name": "app_vehicle_name", "vehicle_id": "app_vehicle_id"}}})
assert set(context_seed_map(cfg.data)) == {"vehicle_name", "vehicle_id"}
# jiancai 出厂 = 旧硬编码白名单的配置化形态
assert set(context_seed_map(build_jiancai_runtime().config.data)) == {
"product_name", "product_id"}
def test_factory_model_sentinel_in_sync():
"""哨兵防漂移(审查 Minor-2): resolve_ai_settings 的"未设置"哨兵必须
与 DEFAULT_CONFIG 出厂 model 一致——默认值一旦漂移,"回退 env AI_MODEL"
永不触发且无测试报错。"""
from app.platform.defaults import DEFAULT_CONFIG
from app.engine.services.ai_service import _FACTORY_DEFAULT_MODEL
assert _FACTORY_DEFAULT_MODEL == DEFAULT_CONFIG["ai"]["model"]
def test_seed_keys_drive_create_conversation(async_db):
"""create_conversation 按映射种子化: 白名单外的 context 键被丢弃。"""
async def run():
async with async_db() as db:
mgr = ConversationManager(
db, runtime=_runtime_with({"engine": {"context_seed_keys": {
"vehicle_name": "app_vehicle_name",
"vehicle_id": "app_vehicle_id"}}}))
conv = await mgr.create_conversation(context={
"vehicle_name": "Toyota Hilux", "vehicle_id": "v9",
"product_name": "should_be_ignored",
})
return conv.id
cid = asyncio.run(run())
from app.engine.models import CustomerProfile
async def fetch():
async with async_db() as db:
profile = (await db.execute(select(CustomerProfile).where(
CustomerProfile.conversation_id == cid))).scalar_one()
return profile.raw_info
assert asyncio.run(fetch()) == {
"app_vehicle_name": "Toyota Hilux", "app_vehicle_id": "v9"}