"""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 from app.engine.services.cc_completion import name_to_region from app.engine.services.conversation import ConversationManager from app.engine.services.rate_limit import _reset_for_test, check # Task 10: 消费视图 resolver 单入口(原 ai_service/conversation 直引与 # email_service/lead_push_service 模块属性取值改为这里,断言不动) from app.platform.config_views import ( context_seed_map, resolve_ai_settings, resolve_contact_flags, resolve_email_settings, resolve_push_settings, ) 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 = 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 = 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 = 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】 " assert str(captured["msg"]["To"]) == "x@t.com, y@t.com" def test_seed_tenants_ship_email_group(monkeypatch): """I-1(终审): 两租户 seed 自带 email 组——resolve_email_settings 各自 返回正确收件人/发件名(auto: XYH→163 信箱; jiancai: HESUPIPE→hesuu.com), 不再依赖 env NOTIFY_EMAIL 平台兜底(patch 成异值证明 seed 槽胜出)。 收件人取值依据 brand_values.json: auto=notify_email 环境实际值, jiancai=品牌邮箱(notify_email 记录的是 notify@example.com 占位)。 From 头渲染时外层再包【】(send_* 的 msg["From"] 拼法),seed 存裸名。""" monkeypatch.setattr(settings, "NOTIFY_EMAIL", "env-notify@x.com") from tests.conftest import build_auto_runtime au = resolve_email_settings(build_auto_runtime().config.data) jc_r = resolve_email_settings( build_jiancai_runtime().config.data) assert au["sender_name"] == "XYH" assert au["notify_emails"] == ["18339827479@163.com"] assert jc_r["sender_name"] == "HESUPIPE" assert jc_r["notify_emails"] == ["hesu@hesuu.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 = 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 = 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.platform.config_views 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"}