2b79e7a232
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>
193 lines
6.3 KiB
Python
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
|