4a47e824c7
Add the first list scraper for the yunke data-mirror system, establishing the reusable pagination pattern all later list scrapers will build on. - app/scraper/base.py: generic `paginate_by_page` async generator that walks page-numbered endpoints until a page comes back empty (the yunke CRM's primary "no more rows" signal), with a defensive over-fetch ceiling keyed off the declared total (strict `>` so an honest source yields exactly `total`). Plus a tiny `RateLimiter` enforcing a min interval between calls. - app/scraper/wechat.py: `scrape_wechat_friends` wraps `POST /wechatweb/wxFriends/friend_list/es`, maps each record to the `Customer` column shape (snake_case) with `channel="wechat"` and a verbatim `raw_json` copy. Optional `department_id`/`user_id` filters pass through to the request body. - tests/cassettes/wxFriends_fixture.py: two realistic, capture-shaped friend records + a response-envelope builder, reused by the scraper tests. - tests/test_scrape_wechat_friends.py: drives the real pagination + normalization logic through respx (page1 returns both records, page2 empty -> stop). Asserts exact field mappings, `raw_json` fidelity, and exactly 2 upstream calls. - tests/test_scrape_base.py: smoke tests for the RateLimiter spacing. Persistence (upsert into `Customer`) is deliberately deferred to Task 1.7; scrapers remain pure producers of normalized dicts. Co-Authored-By: Claude <noreply@anthropic.com>
155 lines
5.4 KiB
Python
155 lines
5.4 KiB
Python
"""Tests for the WeChat friend-list scraper (Task 1.5).
|
|
|
|
Drives the *real* pagination + normalization logic through ``respx`` by wiring
|
|
a real ``YunkeClient`` (primed with a fake session so the login flow is
|
|
skipped) against a mocked transport. Two pages are served:
|
|
|
|
* page 1 -> total=2, both records (i.e. the full result set in one page),
|
|
* page 2 -> total=2, empty ``data`` array (the "no more rows" signal).
|
|
|
|
The paginator must yield exactly the 2 records (normalized), make exactly 2
|
|
calls, then stop without a 3rd call.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import respx
|
|
|
|
from app.scraper.wechat import scrape_wechat_friends
|
|
from app.session.yunke_client import YunkeClient
|
|
from tests.cassettes.wxFriends_fixture import (
|
|
FRIEND_RECORDS,
|
|
make_friend_list_response,
|
|
)
|
|
|
|
BASE = "https://crm2.yunkecn.com"
|
|
FRIEND_LIST_URL = f"{BASE}/wechatweb/wxFriends/friend_list/es"
|
|
|
|
USERID = "a" * 32
|
|
COMPANY = "8m0atd"
|
|
|
|
|
|
def _primed_client() -> YunkeClient:
|
|
"""A YunkeClient already in a logged-in state (login flow skipped)."""
|
|
c = YunkeClient(base_url=BASE, userid=USERID, company=COMPANY)
|
|
return c
|
|
|
|
|
|
@respx.mock
|
|
async def test_scrape_wechat_friends_yields_normalized_records_and_paginates():
|
|
"""Two pages served; scraper yields 2 mapped dicts and makes exactly 2 calls."""
|
|
# Page 1: both records, total=2. Page 2: empty data, total still 2.
|
|
page1 = make_friend_list_response(FRIEND_RECORDS, total=len(FRIEND_RECORDS), page_num=1)
|
|
page2 = make_friend_list_response([], total=len(FRIEND_RECORDS), page_num=2)
|
|
|
|
route = respx.post(FRIEND_LIST_URL).mock(
|
|
side_effect=[
|
|
httpx.Response(200, json=page1),
|
|
httpx.Response(200, json=page2),
|
|
]
|
|
)
|
|
|
|
client = _primed_client()
|
|
try:
|
|
results = [r async for r in scrape_wechat_friends(client)]
|
|
finally:
|
|
await client.aclose()
|
|
|
|
# --- Pagination behaviour: exactly 2 calls, no speculative 3rd. ---
|
|
assert route.call_count == 2
|
|
|
|
# --- Yielded exactly the 2 fixture records (not duplicated, not dropped). ---
|
|
assert len(results) == 2
|
|
assert {r["friend_id"] for r in results} == {
|
|
rec["friendWxId"] for rec in FRIEND_RECORDS
|
|
}
|
|
|
|
# --- Field mapping for each record (compare against the source record). ---
|
|
for result, record in zip(results, FRIEND_RECORDS, strict=True):
|
|
# Identity / ownership / channel.
|
|
assert result["friend_id"] == record["friendWxId"]
|
|
assert result["channel"] == "wechat"
|
|
assert result["owner_account_id"] == record["wxId"]
|
|
assert result["user_id"] == record["userId"]
|
|
|
|
# Naming.
|
|
assert result["friend_nickname"] == record["friendWxNickname"]
|
|
assert result["friend_remark"] == record["friendWxRemark"]
|
|
assert result["real_name"] == record["realName"]
|
|
|
|
# Phones: `phone` <- "phone", `wechat_phone` <- "wechatPhone".
|
|
assert result["phone"] == record["phone"]
|
|
assert result["wechat_phone"] == record["wechatPhone"]
|
|
|
|
# Misc mapped fields.
|
|
assert result["gender"] == record["gender"]
|
|
assert result["region"] == record["region"]
|
|
assert result["description"] == record["description"]
|
|
assert result["sale_name"] == record["saleName"]
|
|
assert result["dept_route"] == record["deptRoute"]
|
|
assert result["dept_name"] == record["deptName"]
|
|
assert result["head_image_url"] == record["friendHeadImageUrl"]
|
|
assert result["last_chat_time"] == record["lastChatTime"]
|
|
assert result["send_num"] == record["sendNum"]
|
|
assert result["receive_num"] == record["receiveNum"]
|
|
assert result["message_count"] == record["messageCount"]
|
|
assert result["delete_flag"] == record["deleteFlag"]
|
|
assert result["is_block"] == record["isBlock"]
|
|
assert result["friend_state"] == record["friendState"]
|
|
|
|
# Version carried verbatim; raw_json preserves the original record.
|
|
assert result["version"] == record["version"]
|
|
assert result["raw_json"] == record
|
|
|
|
|
|
@respx.mock
|
|
async def test_scrape_wechat_friends_sends_pagination_body():
|
|
"""Outgoing request carries pageNum/pageSize and optional filters."""
|
|
page1 = make_friend_list_response([], total=0, page_num=1)
|
|
route = respx.post(FRIEND_LIST_URL).mock(
|
|
return_value=httpx.Response(200, json=page1)
|
|
)
|
|
|
|
client = _primed_client()
|
|
try:
|
|
results = [
|
|
r
|
|
async for r in scrape_wechat_friends(
|
|
client, department_id="dept-9", user_id="staff-1", page_size=7
|
|
)
|
|
]
|
|
finally:
|
|
await client.aclose()
|
|
|
|
# Empty upstream -> no rows, exactly one call (the empty-page stop).
|
|
assert results == []
|
|
assert route.call_count == 1
|
|
|
|
sent = route.calls.last.request
|
|
import json as _json
|
|
|
|
body = _json.loads(sent.content.decode())
|
|
assert body["pageNum"] == 1
|
|
assert body["pageSize"] == 7
|
|
assert body["departmentId"] == "dept-9"
|
|
assert body["userId"] == "staff-1"
|
|
|
|
|
|
@respx.mock
|
|
async def test_scrape_wechat_friends_stops_on_empty_first_page():
|
|
"""If the very first page is empty, the scraper stops without over-fetching."""
|
|
empty = make_friend_list_response([], total=0, page_num=1)
|
|
route = respx.post(FRIEND_LIST_URL).mock(
|
|
return_value=httpx.Response(200, json=empty)
|
|
)
|
|
|
|
client = _primed_client()
|
|
try:
|
|
results = [r async for r in scrape_wechat_friends(client)]
|
|
finally:
|
|
await client.aclose()
|
|
|
|
assert results == []
|
|
assert route.call_count == 1
|