Files
yunke/yunke-server/tests/test_scrape_wechat_accounts.py
T
浅念 2b79e7a232 feat: department + wechat account/staff scrapers
M2 organization scrapers for yunke-server:

- scrape_departments: POST /usercenter/dept/tree. Single request (no
  pagination). Defensively unwraps the envelope (data is node list OR an
  object wrapping nodes under children), flattens BOTH nested and flat
  shapes, and reconstructs the materialized path by walking the pId chain
  (ancestor names joined with /). Maps id/pId/name -> dept_id/parent_id/name.

- scrape_wechat_accounts: POST /wechatweb/wxUser/list. Page-number
  pagination via the existing paginate_by_page helper. Envelope differs
  from friend_list (no basicResult wrapper): data.data/data.total. Maps
  wechatId/nickName/alias/phone/userId/userName/contactsNum/groupNum/
  sendNum/receiveNum/isOnJob/dbSize/onlineStatus/lastUpdate/version to
  WechatAccount columns; carries embedded staff pair for the repo layer.

Repos:
- upsert_department: idempotent upsert into Department (PK dept_id).
- upsert_staff: primitive idempotent staff upsert (PK user_id, is_on_job
  coerced to bool).
- upsert_wechat_account: now ALSO upserts the embedded Staff row via
  upsert_staff_from_account (best-effort; skips when account has no user_id).
- upsert_staff_from_account: refactored to delegate to upsert_staff and
  forward phone/sale_phone/is_on_job when present.

Tests (TDD red->green): test_scrape_departments (nested + flat + wrapped-data
+ empty), test_scrape_wechat_accounts (2-page pagination + body advance +
empty first page), test_repos extended (dept idempotency/root parent, direct
staff upsert + bool coercion, account-also-upserts-staff, re-upsert updates
both, no-user_id skips staff). Cassettes: dept_tree_fixture (same 4 nodes in
both nested + flat shapes), wxUser_fixture (4 capture-shaped accounts incl.
departed + orphan).

Suite: 97 -> 111 passing. ruff check clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 13:46:23 +08:00

193 lines
6.3 KiB
Python

"""Tests for the WeChat staff-account scraper (Task 2.1).
Drives the real page-number 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.
The fixture ships 4 records across 2 pages of size 2, plus a terminating
empty page (the documented primary stop signal for page-number endpoints):
* page 1 -> 2 records, ``total=4``
* page 2 -> 2 records, ``total=4``
* page 3 -> empty ``data`` array (no more rows)
The scraper must yield exactly the 4 records (normalized to ``WechatAccount``
snake_case columns + ``raw_json``), make exactly 3 calls, and stop without a
4th. The empty-page stop is the primary signal; ``total=4`` is only a
defensive over-fetch ceiling that must NOT trigger early (we yield exactly
4 == total, so the strict-``>`` ceiling never fires here).
"""
from __future__ import annotations
import json as _json
import httpx
import respx
from app.scraper.wechat import scrape_wechat_accounts
from app.session.yunke_client import YunkeClient
from tests.cassettes.wxUser_fixture import (
DEFAULT_PAGE_SIZE,
TOTAL,
WXUSER_RECORDS,
make_wxuser_list_response,
)
BASE = "https://crm2.yunkecn.com"
WXUSER_LIST_URL = f"{BASE}/wechatweb/wxUser/list"
USERID = "a" * 32
COMPANY = "8m0atd"
def _primed_client() -> YunkeClient:
"""A YunkeClient already in a logged-in state (login flow skipped)."""
return YunkeClient(base_url=BASE, userid=USERID, company=COMPANY)
# ---------------------------------------------------------------------------
# End-to-end: pagination + field mapping + raw_json
# ---------------------------------------------------------------------------
@respx.mock
async def test_scrape_wechat_accounts_yields_records_and_paginates():
"""Two full pages + a terminating empty page -> 4 records, 3 calls."""
page1 = make_wxuser_list_response(
WXUSER_RECORDS[:2], total=TOTAL, page_num=1, page_size=DEFAULT_PAGE_SIZE
)
page2 = make_wxuser_list_response(
WXUSER_RECORDS[2:], total=TOTAL, page_num=2, page_size=DEFAULT_PAGE_SIZE
)
page3 = make_wxuser_list_response(
[], total=TOTAL, page_num=3, page_size=DEFAULT_PAGE_SIZE
)
route = respx.post(WXUSER_LIST_URL).mock(
side_effect=[
httpx.Response(200, json=page1),
httpx.Response(200, json=page2),
httpx.Response(200, json=page3),
]
)
client = _primed_client()
try:
results = [
r
async for r in scrape_wechat_accounts(
client, page_size=DEFAULT_PAGE_SIZE
)
]
finally:
await client.aclose()
# Pagination: exactly 3 calls (page1, page2, empty-page stop).
assert route.call_count == 3
# Exactly the 4 fixture records (not duplicated, not dropped).
assert len(results) == 4
assert {r["wx_id"] for r in results} == {
rec["wechatId"] for rec in WXUSER_RECORDS
}
# Field mapping for each record (compare against the source record).
for result, record in zip(results, WXUSER_RECORDS, strict=True):
# Identity.
assert result["wx_id"] == record["wechatId"]
# Naming.
assert result["nick_name"] == record["nickName"]
assert result["alias"] == record["alias"]
# Phones.
assert result["phone"] == record["phone"]
# Embedded staff fields (the owning employee).
assert result["user_id"] == record["userId"]
assert result["user_name"] == record["userName"]
# Counts.
assert result["contacts_num"] == record["contactsNum"]
assert result["group_num"] == record["groupNum"]
assert result["send_num"] == record["sendNum"]
assert result["receive_num"] == record["receiveNum"]
# Status / sizing / timestamps.
assert result["is_on_job"] == record["isOnJob"]
assert result["db_size"] == record["dbSize"]
assert result["online_status"] == record["onlineStatus"]
assert result["last_update"] == record["lastUpdate"]
# Version carried verbatim; raw_json preserves the original record.
assert result["version"] == record["version"]
assert result["raw_json"] == record
# ---------------------------------------------------------------------------
# Pagination body: pageNum/pageSize advanced correctly
# ---------------------------------------------------------------------------
@respx.mock
async def test_scrape_wechat_accounts_sends_pagination_body():
"""Outgoing request body carries pageNum/pageSize; advances per page."""
page1 = make_wxuser_list_response(
WXUSER_RECORDS[:1], total=TOTAL, page_num=1, page_size=1
)
page2 = make_wxuser_list_response(
[], total=TOTAL, page_num=2, page_size=1
)
route = respx.post(WXUSER_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_accounts(client, page_size=1)
]
finally:
await client.aclose()
# One record on page 1, empty page 2 stops the loop.
assert len(results) == 1
assert route.call_count == 2
# First request: pageNum=1.
body1 = _json.loads(route.calls[0].request.content.decode())
assert body1["pageNum"] == 1
assert body1["pageSize"] == 1
# Second request: pageNum advanced to 2.
body2 = _json.loads(route.calls[1].request.content.decode())
assert body2["pageNum"] == 2
assert body2["pageSize"] == 1
# ---------------------------------------------------------------------------
# Empty first page -> no yields, single call
# ---------------------------------------------------------------------------
@respx.mock
async def test_scrape_wechat_accounts_stops_on_empty_first_page():
"""If the very first page is empty, scraper stops without over-fetching."""
empty = make_wxuser_list_response([], total=0, page_num=1)
route = respx.post(WXUSER_LIST_URL).mock(
return_value=httpx.Response(200, json=empty)
)
client = _primed_client()
try:
results = [r async for r in scrape_wechat_accounts(client)]
finally:
await client.aclose()
assert results == []
assert route.call_count == 1