Files
yunke/yunke-server/tests/test_scrape_wechat_messages_group.py
T
浅念 41b9ec2571 feat: wechat voice-call scraper + group message coverage
- scrape_wechat_voice_calls: wraps POST /wechatweb/callRecord/queryWeChatVoiceList
  with page-number pagination (reuses paginate_by_page); normalizes records to
  the PhoneCall column shape with channel='voice_call'. Field mapping per
  phase0-findings §4: id->call_id, duration(str s)->call_duration(int),
  startTime(YYYY-MM-DD HH:MM:SS)->start_call_time(ms epoch), callStatus->
  call_status, talker->call_number, talkerNickName->plan_customer_name,
  ossFileName->record_file (may be null). isSend (0=in/1=out) is inverted to
  incoming_call (1=in/0=out) so the column's meaning matches the phone_call
  channel.
- upsert_voice_call: idempotent Core upsert into PhoneCall by call_id
  (sqlite/mysql dialect-aware). Adds call_duration/start_call_time to the
  _INT_FIELDS coercion net (scraper already emits ints; defensive for
  hand-built records).
- Group-message test (Task 2.3): locks in that scrape_wechat_messages already
  handles chatroom conversations (friendId=@chatroom, roomid set, talker=member
  wxid) — yields room_id populated and talker_id = the sending member. No
  scraper change required.

Suite: 123 -> 130 passing. ruff clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 14:03:47 +08:00

146 lines
4.9 KiB
Python

"""Tests for the WeChat message scraper on GROUP conversations (Task 2.3).
The existing :func:`scrape_wechat_messages` already maps ``roomid`` ->
``room_id`` and ``talker`` -> ``talker_id``, so group chats — where the
conversation id ``friendId`` is a ``xxx@chatroom`` id and every message
carries ``roomid`` set to that chatroom id plus a per-member ``talker`` wxid
(the sender inside the group) — should flow through unchanged.
This test locks that behavior in: a single group page yields messages with
``room_id`` populated to the chatroom id and ``talker_id`` equal to the
SENDING MEMBER's wxid (NOT the chatroom id), so group traffic is
attributable to the individual sender while still being scoped to the room.
No scraper code change is expected; if the scraper already handles it, this
test just guards the behavior against regressions.
"""
from __future__ import annotations
import httpx
import respx
from app.scraper.wechat import scrape_wechat_messages
from app.session.yunke_client import YunkeClient
from tests.cassettes.msg_chats_fixture import make_msg_chats_response
BASE = "https://crm2.yunkecn.com"
MSG_CHATS_URL = f"{BASE}/wechatweb/msg/chats"
USERID = "a" * 32
COMPANY = "8m0atd"
WECHAT_ID = "wxid_staff_alice"
# The conversation id (friendId) for a group chat is the @chatroom id.
ROOM_ID = "12345678901@chatroom"
START = 1
END = 1784518000000 # the initial upper-bound cursor
def _primed_client() -> YunkeClient:
"""A YunkeClient already in a logged-in state (login flow skipped)."""
return YunkeClient(base_url=BASE, userid=USERID, company=COMPANY)
# Two group messages from two DIFFERENT members of the same chatroom. The
# `talker` is the individual member's wxid; `roomid` is the chatroom id (same
# for every message in the conversation); the upstream sets `oriTalker` to the
# chatroom id for group messages (it carries the conversation key, not the
# sender).
GROUP_MESSAGES = [
{
"msgSvrId": "grp-2000001",
"type": 1,
"messageType": 1,
"text": "hello group from alice",
"wechatId": WECHAT_ID,
"talker": "wxid_member_a1", # the SENDER (a group member)
"oriTalker": ROOM_ID, # upstream convention: oriTalker = chatroom id
"roomid": ROOM_ID,
"mine": 1,
"isSend": 1,
"timestamp": END - 600000,
"createTime": END - 600000,
"name": "Alice (group)",
"headUrl": "https://cdn.example.com/alice.png",
"isDel": 0,
"version": "v1.0",
},
{
"msgSvrId": "grp-2000002",
"type": 1,
"messageType": 1,
"text": "hi from bob",
"wechatId": WECHAT_ID,
"talker": "wxid_member_b2", # a DIFFERENT member
"oriTalker": ROOM_ID,
"roomid": ROOM_ID,
"mine": 0,
"isSend": 0,
"timestamp": END - 300000,
"createTime": END - 300000,
"name": "Bob (group)",
"headUrl": "https://cdn.example.com/bob.png",
"isDel": 0,
"version": "v1.0",
},
]
@respx.mock
async def test_scrape_wechat_messages_group_yields_room_id_and_member_talker():
"""Group messages keep ``room_id`` populated and ``talker_id`` = member wxid.
The scraper must NOT collapse ``talker_id`` to the chatroom id (that would
lose the per-message sender) and must NOT clear ``room_id`` (that would
make the message look like a 1:1 direct message).
"""
# `end` = oldest ts in batch; hasLast=false -> single page, no 2nd fetch.
page = make_msg_chats_response(
GROUP_MESSAGES, end=END - 600000, has_last=False
)
route = respx.post(MSG_CHATS_URL).mock(
return_value=httpx.Response(200, json=page)
)
client = _primed_client()
try:
results = [
r
async for r in scrape_wechat_messages(
client, WECHAT_ID, ROOM_ID, START, END
)
]
finally:
await client.aclose()
# Single page, hasLast=false -> exactly one call.
assert route.call_count == 1
assert len(results) == 2
r1, r2 = results
# Both messages carry the chatroom id on room_id...
assert r1["room_id"] == ROOM_ID
assert r2["room_id"] == ROOM_ID
# ...and the SENDING MEMBER's wxid on talker_id (NOT the chatroom id).
assert r1["talker_id"] == "wxid_member_a1"
assert r2["talker_id"] == "wxid_member_b2"
# ori_talker carries the chatroom id verbatim (upstream convention).
assert r1["ori_talker"] == ROOM_ID
assert r2["ori_talker"] == ROOM_ID
# Standard fields still flow through.
assert r1["channel"] == "wechat"
assert r1["owner_account_id"] == WECHAT_ID
assert r1["msg_id"] == "grp-2000001"
assert r1["text"] == "hello group from alice"
assert r1["raw_json"] == GROUP_MESSAGES[0]
# The friendId we passed in is the chatroom id; it is NOT stamped on the
# record separately (room_id already carries it, so there is no duplicate
# "conversation id" field on Message).
assert r2["msg_id"] == "grp-2000002"