"""询盘推送 UniService:模型/服务单元测试(设计 2026-09-28-lead-push-to-uniservice)。""" import asyncio from sqlalchemy import select from app.engine.models import ( Conversation, CustomerProfile, Evaluation, IntentLevel, LeadPushLog, Message, MessageRole, ) def test_lead_push_log_roundtrip(async_db): async def run(): async with async_db() as db: conv = Conversation(session_id="s1") db.add(conv) await db.flush() ev = Evaluation( conversation_id=conv.id, intent_level=IntentLevel.pending, contact_info={}, ) db.add(ev) await db.flush() db.add(LeadPushLog( evaluation_id=ev.id, conversation_id=conv.id, push_type="lead", status="pending", )) await db.commit() row = (await db.execute(select(LeadPushLog))).scalar_one() assert row.id and len(row.id) == 36 # UUID 主键,同既有四表 assert row.status == "pending" and row.attempts == 0 assert row.outcome is None and row.response_status is None asyncio.run(run()) def test_push_settings_defaults(): from app.engine.config import Settings s = Settings(_env_file=None) # 隔离本机 .env(联调时会写入 PUSH_* 真值) assert s.PUSH_ENABLED is False assert s.PUSH_API_URL == ( "https://uniservice.server.hnxinyouhe.com/open/v1/inquiry/push") assert s.PUSH_API_KEY == "" assert s.PUSH_EXTERNAL_KEY_PREFIX == "ai-kefu-jc" # 与汽车项目线索键空间隔离 assert s.PUSH_TIMEOUT_SECONDS == 10.0 assert s.PUSH_MAX_ATTEMPTS == 3 assert s.PUSH_RETRY_BACKOFF_BASE == 1.0 def test_fmt_cst_converts_utc_and_naive(): from datetime import datetime, timezone from app.engine.services.lead_push_service import _fmt_cst aware = datetime(2026, 9, 28, 2, 30, 0, tzinfo=timezone.utc) assert _fmt_cst(aware) == "2026-09-28T10:30:00+08:00" # UTC→+08:00,绝不发 UTC naive = datetime(2026, 9, 28, 2, 30, 0) # SQLite 读回形态,按 UTC 补 assert _fmt_cst(naive) == "2026-09-28T10:30:00+08:00" micro = datetime(2026, 9, 28, 2, 30, 0, 123456, tzinfo=timezone.utc) assert _fmt_cst(micro) == "2026-09-28T10:30:00+08:00" # 微秒归一整秒(契约示例精度) def test_map_intent(): from app.engine.services.lead_push_service import map_intent assert map_intent("interested", True) == "high" assert map_intent("pending", True) == "medium" assert map_intent("not_interested", True) == "low" # 销售优先级解读,设计 7.2 定案 assert map_intent("pending", False) == "unknown" # 未评估≠待确认(D2 语义) assert map_intent("bogus", True) == "unknown" assert map_intent(None, True) == "unknown" assert map_intent(IntentLevel.pending, True) == "medium" # DB 读回枚举成员形态 def _seed_push_ctx(async_db, client_info=None, sid="s-push"): """建 conv/profile/ev 三件套,返回 (conv, profile, ev)。""" from app.engine.models import ConversationSource async def run(): async with async_db() as db: conv = Conversation( session_id=sid, source=ConversationSource.widget, client_info=client_info or {"ip": "8.8.8.8", "lang": "en"}, ) db.add(conv) await db.flush() profile = CustomerProfile( conversation_id=conv.id, raw_info={"目标国家": "肯尼亚"}) ev = Evaluation( conversation_id=conv.id, intent_level=IntentLevel.interested, contact_info={}) db.add_all([profile, ev]) await db.commit() asyncio.run(run()) def test_dispatch_disabled_returns_none(async_db, monkeypatch): from app.engine.services import lead_push_service as svc monkeypatch.setattr(svc.settings, "PUSH_ENABLED", False) _seed_push_ctx(async_db) async def run(): async with async_db() as db: from app.engine.models import Conversation, CustomerProfile, Evaluation conv = (await db.execute(select(Conversation))).scalar_one() profile = (await db.execute(select(CustomerProfile))).scalar_one() ev = (await db.execute(select(Evaluation))).scalar_one() return await svc.dispatch_lead_push( db, ev, conv, profile, {"phone": "+254722111117"}, "lead", {"intent_level": "interested"}, True) assert asyncio.run(run()) is None assert asyncio.run(_select_logs(async_db)) == [] # 开关关闭零落库 def test_dispatch_wechat_only_marks_skipped(async_db, monkeypatch): """wechat-only: 我方发邮件(wechat 算 confirmed)但对方必拒三选一 → skipped 留痕不派发。""" from app.engine.services import lead_push_service as svc monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True) _seed_push_ctx(async_db) async def run(): async with async_db() as db: from app.engine.models import Conversation, CustomerProfile, Evaluation conv = (await db.execute(select(Conversation))).scalar_one() profile = (await db.execute(select(CustomerProfile))).scalar_one() ev = (await db.execute(select(Evaluation))).scalar_one() result = await svc.dispatch_lead_push( db, ev, conv, profile, {"wechat": "li_ming_88"}, "lead", {"intent_level": "interested"}, True) await db.commit() return result assert asyncio.run(run()) is None logs = asyncio.run(_select_logs(async_db)) assert len(logs) == 1 assert logs[0].status == "skipped" assert "phone/whatsapp/email" in logs[0].error_message async def _select_logs(async_db): async with async_db() as db: return list((await db.execute(select(LeadPushLog))).scalars()) def test_dispatch_pending_payload_structure(async_db, monkeypatch): from app.engine.services import lead_push_service as svc monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True) _seed_push_ctx(async_db) async def run(): async with async_db() as db: from app.engine.models import Conversation, CustomerProfile, Evaluation conv = (await db.execute(select(Conversation))).scalar_one() profile = (await db.execute(select(CustomerProfile))).scalar_one() ev = (await db.execute(select(Evaluation))).scalar_one() db.add(Message(conversation_id=conv.id, role=MessageRole.user, content="my whatsapp is +254722111117")) await db.commit() confirmed = {"phone": "+254722111117", "whatsapp": "+254722111117"} log = await svc.dispatch_lead_push( db, ev, conv, profile, confirmed, "lead", {"intent_level": "interested", "reasoning": "询库存留号"}, True) await db.commit() return conv, log conv, log = asyncio.run(run()) assert log is not None and log.status == "pending" p = log.request_payload assert p["event"] == "new" assert p["external_key"] == f"{svc.settings.PUSH_EXTERNAL_KEY_PREFIX}-{conv.id}" # 不硬编码前缀: 联调 .env 会写真实 PUSH_* 值(同 test_push_settings_defaults 的隔离教训) assert p["occurred_at"].endswith("+08:00") assert p["contact"]["phone"] == "+254722111117" assert p["contact"]["skype"] is None assert p["intent_level"] == "high" and p["intent_reason"] == "询库存留号" assert p["profile"]["目标国家"] == "肯尼亚" assert p["profile"]["source"] == "widget" assert p["profile"]["country"] # 8.8.8.8 → 国名非空 assert p["conversation"][0]["role"] == "customer" assert p["conversation"][0]["at"].endswith("+08:00") def test_build_payload_filters_internal_underscore_keys(async_db, monkeypatch): """内部状态键(_cc_asked/_email_asked 等)不得泄进推送画像(spec §4.3—— 渲染侧两处有过滤,推送端曾整体拷贝泄漏)。""" from app.engine.services import lead_push_service as svc monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True) _seed_push_ctx(async_db) async def run(): async with async_db() as db: from app.engine.models import Conversation, CustomerProfile, Evaluation conv = (await db.execute(select(Conversation))).scalar_one() profile = (await db.execute(select(CustomerProfile))).scalar_one() ev = (await db.execute(select(Evaluation))).scalar_one() profile.raw_info = { # 模拟 Task3 写入的 _ 前缀一次性计数键 "目标国家": "肯尼亚", "意向品类": "PE 给水管", "_cc_asked": 1, "_email_asked": 2, } await db.commit() log = await svc.dispatch_lead_push( db, ev, conv, profile, {"phone": "+254722111117"}, "lead", {"intent_level": "interested"}, True) await db.commit() return log log = asyncio.run(run()) assert log is not None and log.status == "pending" profile_out = log.request_payload["profile"] assert not [k for k in profile_out if str(k).startswith("_")] # 内部键零泄漏 assert profile_out["目标国家"] == "肯尼亚" # 正常键保留 assert profile_out["意向品类"] == "PE 给水管" assert profile_out["source"] == "widget" # country/source 追加逻辑不受影响 def test_dispatch_correction_event_rules(async_db, monkeypatch): """7.1 保护规则: 同会话有 success 前科 → update;没有 → new。""" from app.engine.services import lead_push_service as svc monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True) _seed_push_ctx(async_db) async def run(with_prior_success): async with async_db() as db: from app.engine.models import Conversation, CustomerProfile, Evaluation conv = (await db.execute(select(Conversation))).scalar_one() profile = (await db.execute(select(CustomerProfile))).scalar_one() ev = (await db.execute(select(Evaluation))).scalar_one() if with_prior_success: db.add(LeadPushLog( evaluation_id=ev.id, conversation_id=conv.id, push_type="lead", status="success")) await db.flush() log = await svc.dispatch_lead_push( db, ev, conv, profile, {"phone": "+254700000001"}, "correction", {"intent_level": "interested"}, True) await db.commit() return log.request_payload["event"] assert asyncio.run(run(False)) == "new" assert asyncio.run(run(True)) == "update" def test_dispatch_swallows_exceptions(async_db, monkeypatch): """红线: dispatch 内部炸(如 payload 组装抛错)绝不向上抛。""" from app.engine.services import lead_push_service as svc monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True) _seed_push_ctx(async_db) async def boom(*a, **kw): raise RuntimeError("boom") monkeypatch.setattr(svc, "_build_payload", boom) async def run(): async with async_db() as db: from app.engine.models import Conversation, CustomerProfile, Evaluation conv = (await db.execute(select(Conversation))).scalar_one() profile = (await db.execute(select(CustomerProfile))).scalar_one() ev = (await db.execute(select(Evaluation))).scalar_one() return await svc.dispatch_lead_push( db, ev, conv, profile, {"phone": "+254700000001"}, "lead", {}, True) assert asyncio.run(run()) is None def test_dispatch_db_failure_does_not_poison_caller_tx(async_db, monkeypatch): """红线(DB 层): flush 失败只回滚推送写入,调用方事务仍可提交(防重复发邮件)。""" from app.engine.services import lead_push_service as svc monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True) _seed_push_ctx(async_db) async def bad_payload(*a, **kw): return {"bad": object()} # JSON 不可序列化 → flush 必炸 monkeypatch.setattr(svc, "_build_payload", bad_payload) async def run(): async with async_db() as db: from app.engine.models import Conversation, CustomerProfile, Evaluation conv = (await db.execute(select(Conversation))).scalar_one() profile = (await db.execute(select(CustomerProfile))).scalar_one() ev = (await db.execute(select(Evaluation))).scalar_one() ev.reasoning = "caller update after dispatch failure" db.add(ev) result = await svc.dispatch_lead_push( db, ev, conv, profile, {"phone": "+254700000001"}, "lead", {}, True) logs = list((await db.execute(select(LeadPushLog))).scalars()) await db.commit() return result, logs result, logs = asyncio.run(run()) assert result is None and logs == [] async def reload(): async with async_db() as db: from app.engine.models import Evaluation ev = (await db.execute(select(Evaluation))).scalar_one() return ev.reasoning assert asyncio.run(reload()) == "caller update after dispatch failure" def test_dispatch_returning_with_prior_success_still_new(async_db, monkeypatch): """不变量: 仅 correction 可 update——returning 即使同会话已 success 也走 new。""" from app.engine.services import lead_push_service as svc monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True) _seed_push_ctx(async_db) async def run(): async with async_db() as db: from app.engine.models import Conversation, CustomerProfile, Evaluation conv = (await db.execute(select(Conversation))).scalar_one() profile = (await db.execute(select(CustomerProfile))).scalar_one() ev = (await db.execute(select(Evaluation))).scalar_one() db.add(LeadPushLog( evaluation_id=ev.id, conversation_id=conv.id, push_type="lead", status="success")) await db.flush() log = await svc.dispatch_lead_push( db, ev, conv, profile, {"phone": "+254700000001"}, "returning", {"intent_level": "interested"}, True) await db.commit() return log.request_payload["event"] assert asyncio.run(run()) == "new" def _mk_push_log(async_db, status="pending"): async def run(): async with async_db() as db: from app.engine.models import ConversationSource conv = Conversation(session_id=f"s-{status}-{id(async_db)}", source=ConversationSource.widget) db.add(conv) await db.flush() ev = Evaluation(conversation_id=conv.id, intent_level=IntentLevel.pending, contact_info={}) db.add(ev) await db.flush() log = LeadPushLog(evaluation_id=ev.id, conversation_id=conv.id, push_type="lead", status=status, request_payload={"external_key": "ai-kefu-x", "event": "new"}) db.add(log) await db.commit() return log.id return asyncio.run(run()) def _run_push_case(async_db, monkeypatch, do_post, log_id): """注入 _do_post/_sleep 后驱动 _run_push(Task 9: session_factory 直传, 不再借模块级 async_session——已删除),返回退避序列。""" from app.engine.services import lead_push_service as svc sleeps = [] async def fake_sleep(s): sleeps.append(s) # Task 9: _do_post 多了 config_data 参数——适配层吸收,各用例 fake 保持单参 async def adapted_do_post(payload, config_data=None): return await do_post(payload) monkeypatch.setattr(svc, "_do_post", adapted_do_post) monkeypatch.setattr(svc, "_sleep", fake_sleep) async def run(): await svc._run_push( log_id, {"external_key": "ai-kefu-x", "event": "new"}, async_db) asyncio.run(run()) return sleeps def _get_log(async_db, log_id): async def run(): async with async_db() as db: return await db.get(LeadPushLog, log_id) return asyncio.run(run()) def _resp(status, body=None): import httpx return httpx.Response(status, json=body or {}) def test_run_push_success_accepted(async_db, monkeypatch): async def ok(payload): return _resp(200, {"success": True, "data": { "accepted": 1, "duplicated": 0, "failed": []}}), None lid = _mk_push_log(async_db) _run_push_case(async_db, monkeypatch, ok, lid) log = _get_log(async_db, lid) assert log.status == "success" and log.outcome == "accepted" assert log.response_status == 200 and log.attempts == 1 def test_run_push_success_duplicated(async_db, monkeypatch): async def dup(payload): return _resp(200, {"success": True, "data": { "accepted": 0, "duplicated": 1, "failed": []}}), None lid = _mk_push_log(async_db) _run_push_case(async_db, monkeypatch, dup, lid) log = _get_log(async_db, lid) assert log.status == "success" and log.outcome == "duplicated" def test_run_push_200_item_rejected_no_retry(async_db, monkeypatch): """200 但本条在 failed[](如联系方式无效): failed+对方 reason,不重试。""" calls = [] async def rejected(payload): calls.append(1) return _resp(200, {"success": True, "data": { "accepted": 0, "duplicated": 0, "failed": [{"external_key": "ai-kefu-x", "reason": "无有效联系方式"}]}}), None lid = _mk_push_log(async_db) sleeps = _run_push_case(async_db, monkeypatch, rejected, lid) log = _get_log(async_db, lid) assert log.status == "failed" and "无有效联系方式" in log.error_message assert len(calls) == 1 and sleeps == [] def test_run_push_5xx_retries_then_failed(async_db, monkeypatch): calls = [] async def always_500(payload): calls.append(1) return _resp(500), None lid = _mk_push_log(async_db) sleeps = _run_push_case(async_db, monkeypatch, always_500, lid) log = _get_log(async_db, lid) assert len(calls) == 3 and log.attempts == 3 and log.status == "failed" assert log.response_status == 500 assert sleeps == [1.0, 2.0] # 指数退避: 3 次尝试 2 次间隔 def test_run_push_401_fails_fast(async_db, monkeypatch): calls = [] async def unauthorized(payload): calls.append(1) return _resp(401), None lid = _mk_push_log(async_db) sleeps = _run_push_case(async_db, monkeypatch, unauthorized, lid) log = _get_log(async_db, lid) assert len(calls) == 1 and log.attempts == 1 and log.status == "failed" assert sleeps == [] def test_run_push_transport_error_then_success(async_db, monkeypatch): calls = [] async def flaky(payload): calls.append(1) if len(calls) == 1: return None, "ConnectError('timeout')" return _resp(200, {"success": True, "data": { "accepted": 1, "duplicated": 0, "failed": []}}), None lid = _mk_push_log(async_db) sleeps = _run_push_case(async_db, monkeypatch, flaky, lid) log = _get_log(async_db, lid) assert log.status == "success" and log.attempts == 2 and sleeps == [1.0] def test_run_push_crash_guard(async_db, monkeypatch): """红线: _do_post 抛非 HTTP 异常也不得向外传播。""" async def boom(payload): raise RuntimeError("boom") lid = _mk_push_log(async_db) _run_push_case(async_db, monkeypatch, boom, lid) # 不 raise 即通过 log = _get_log(async_db, lid) assert log.status == "pending" # 结果未写,留给启动清扫兜底 def test_run_push_200_nondict_json_normalized(async_db, monkeypatch): """防御: 200 但 body 是合法 JSON 非对象(如数组)——归一为空 data,不 crash。""" async def weird(payload): return _resp(200, ["not", "a", "dict"]), None lid = _mk_push_log(async_db) _run_push_case(async_db, monkeypatch, weird, lid) log = _get_log(async_db, lid) assert log.status == "success" and log.outcome == "accepted" def test_run_push_200_html_body_fails(async_db, monkeypatch): """200 但 body 非 JSON(如重定向到 HTML 登录页)——不得误记 success。""" import httpx async def html(payload): return httpx.Response(200, text="login"), None lid = _mk_push_log(async_db) _run_push_case(async_db, monkeypatch, html, lid) log = _get_log(async_db, lid) assert log.status == "failed" and "non-JSON" in log.error_message assert log.response_status == 200 def test_fire_pending_creates_tasks(monkeypatch): """create_task 派发(不阻塞语义的结构保证);非 pending 不派。""" from types import SimpleNamespace from app.engine.services import lead_push_service as svc pushed = [] async def fake_run(log_id, payload, session_factory=None, config_data=None): pushed.append((log_id, payload)) monkeypatch.setattr(svc, "_run_push", fake_run) logs = [ SimpleNamespace(id="id-pending", status="pending", request_payload={"event": "new"}), SimpleNamespace(id="id-skipped", status="skipped", request_payload=None), ] async def run(): svc.fire_pending_pushes(logs, None) await asyncio.sleep(0) # 同 loop 内让 create_task 跑一轮 asyncio.run(run()) assert pushed == [("id-pending", {"event": "new"})] def test_sweep_stale_pending(async_db, monkeypatch): from datetime import datetime, timezone from app.engine.services import lead_push_service as svc _seed_push_ctx(async_db) async def run(): async with async_db() as db: from app.engine.models import Conversation, Evaluation conv = (await db.execute(select(Conversation))).scalar_one() ev = (await db.execute(select(Evaluation))).scalar_one() stale = LeadPushLog( evaluation_id=ev.id, conversation_id=conv.id, push_type="lead", status="pending", updated_at=datetime(2020, 1, 1, tzinfo=timezone.utc)) fresh = LeadPushLog( evaluation_id=ev.id, conversation_id=conv.id, push_type="lead", status="pending") done = LeadPushLog( evaluation_id=ev.id, conversation_id=conv.id, push_type="lead", status="success", updated_at=datetime(2020, 1, 1, tzinfo=timezone.utc)) db.add_all([stale, fresh, done]) await db.commit() n = await svc.sweep_stale_pending(async_db, stale_minutes=30) assert n == 1 async with async_db() as db: logs = list((await db.execute(select(LeadPushLog))).scalars()) by = {(l.status, l.error_message or ""): l for l in logs} assert ("failed", "interrupted by restart") in by statuses = sorted(l.status for l in logs) assert statuses == ["failed", "pending", "success"] asyncio.run(run()) # ---- Task 6(spec §5 首封即补全号): 推送 contact 纯 e164 + profile 派生标注 ---- _PUSH_CC_WA = {"e164": "+966501234567", "intl": "+966 50 123 4567", "confirmed": False, "attempts": 1, "source": "ip_proposed", "canon": "0501234567"} _PUSH_CC_PHONE = {"e164": "+96613800138000", "intl": "+966 138 001 3800", "confirmed": False, "attempts": 0, "source": "naive", "canon": "13800138000"} def _dispatch_with_cc(async_db, monkeypatch, raw_info, confirmed, sid="s-cc"): """三件套 + 指定 raw_info(_cc) → dispatch,返回 pending log。""" from app.engine.services import lead_push_service as svc monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True) _seed_push_ctx(async_db, sid=sid) async def run(): async with async_db() as db: from app.engine.models import Conversation, CustomerProfile, Evaluation conv = (await db.execute( select(Conversation).where(Conversation.session_id == sid)) ).scalar_one() profile = (await db.execute( select(CustomerProfile).where( CustomerProfile.conversation_id == conv.id))).scalar_one() ev = (await db.execute( select(Evaluation).where( Evaluation.conversation_id == conv.id))).scalar_one() profile.raw_info = raw_info await db.commit() log = await svc.dispatch_lead_push( db, ev, conv, profile, confirmed, "lead", {"intent_level": "interested"}, True) await db.commit() return log return asyncio.run(run()) def test_push_contact_cc_e164_and_derived_note(async_db, monkeypatch): """contact.phone/whatsapp = 纯 e164(confirmed 与否都给——首封即补全号, 契约要求"带国际区号且有效");标注进 profile"号码补全说明"派生键 (出站时由 _cc 派生,不落正常键);_cc 本体不泄漏(先消费后过滤)。""" raw_info = {"目标国家": "肯尼亚", "_cc": {"whatsapp": dict(_PUSH_CC_WA), "phone": dict(_PUSH_CC_PHONE)}} log = _dispatch_with_cc( async_db, monkeypatch, raw_info, {"whatsapp": "0501234567", "phone": "13800138000"}) assert log is not None and log.status == "pending" p = log.request_payload assert p["contact"]["whatsapp"] == "+966501234567" # 补全号直发 assert p["contact"]["phone"] == "+96613800138000" note = p["profile"]["号码补全说明"] assert "0501234567→+966501234567(区号按 IP 推测,未经客户确认)" in note assert "13800138000→+96613800138000(区号按 IP 推测,未经客户确认)" in note assert "_cc" not in p["profile"] # 本体不泄漏 assert not [k for k in p["profile"] if str(k).startswith("_")] assert p["profile"]["目标国家"] == "肯尼亚" # 正常键不受影响 def test_push_confirmed_state_e164_without_note(async_db, monkeypatch): """confirmed 态(prefix_normalized): contact 给 e164,但无需披露—— "号码补全说明"键不出现。""" state = {"e164": "+966501234567", "intl": "+966 50 123 4567", "confirmed": True, "attempts": 0, "source": "prefix_normalized", "canon": "966501234567"} log = _dispatch_with_cc( async_db, monkeypatch, {"_cc": {"whatsapp": state}}, {"whatsapp": "966501234567"}) p = log.request_payload assert p["contact"]["whatsapp"] == "+966501234567" assert "号码补全说明" not in p["profile"] def test_push_no_e164_state_raw_contact_and_missing_note(async_db, monkeypatch): """无 e164 态(no_ip/fallback-无-e164): contact 给裸号原文(拿不出补全号 不硬造),"号码补全说明"披露(缺国家区号)。""" for source in ("no_ip", "fallback"): state = {"confirmed": False, "attempts": 1, "source": source, "canon": "0501234567"} log = _dispatch_with_cc( async_db, monkeypatch, {"_cc": {"whatsapp": dict(state)}}, {"whatsapp": "0501234567"}, sid=f"s-cc-{source}") p = log.request_payload assert p["contact"]["whatsapp"] == "0501234567" # 裸号原文 assert p["profile"]["号码补全说明"] == "0501234567(缺国家区号)" def test_push_stale_cc_canon_ignored(async_db, monkeypatch): """canon 不匹配的残留态(换号后旧 _cc): contact 用原文、零派生—— 旧补全永不跟随新号。""" log = _dispatch_with_cc( async_db, monkeypatch, {"_cc": {"whatsapp": dict(_PUSH_CC_WA)}}, {"whatsapp": "0512345678"}) p = log.request_payload assert p["contact"]["whatsapp"] == "0512345678" assert "号码补全说明" not in p["profile"] assert "_cc" not in p["profile"]