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>
188 lines
6.6 KiB
Python
188 lines
6.6 KiB
Python
"""Tests for the department-tree scraper (Task 2.0).
|
|
|
|
Drives the real flatten + normalize logic through ``respx`` by wiring a real
|
|
``YunkeClient`` (primed with a fake session so the login flow is skipped)
|
|
against a mocked transport.
|
|
|
|
The dept/tree endpoint is a SINGLE request (no pagination). The scraper must:
|
|
|
|
* decode the envelope defensively (``data`` is the node list, OR an object
|
|
wrapping the node list under ``children``),
|
|
* flatten BOTH a nested tree (each node carrying ``children``) AND a flat list
|
|
of nodes (parents via ``pId``) into the same flat set of records,
|
|
* map ``id``/``pId``/``name`` to ``dept_id``/``parent_id``/``name``,
|
|
* build a materialized ``path`` by walking up the parent chain (names joined
|
|
with ``/``; root nodes have ``path == name``),
|
|
* preserve the verbatim node under ``raw_json``.
|
|
|
|
The two-shape contract (nested vs flat) is asserted by serving the SAME four
|
|
nodes in both shapes and asserting the scraper yields the SAME result set in
|
|
both cases.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import respx
|
|
|
|
from app.scraper.wechat import scrape_departments
|
|
from app.session.yunke_client import YunkeClient
|
|
from tests.cassettes.dept_tree_fixture import (
|
|
ALL_NODES,
|
|
EAST_NODE,
|
|
FLAT_TREE_NODES,
|
|
NESTED_TREE_NODES,
|
|
NORTH_NODE,
|
|
ROOT_NODE,
|
|
TEAMA_NODE,
|
|
make_dept_tree_response,
|
|
)
|
|
|
|
BASE = "https://crm2.yunkecn.com"
|
|
DEPT_TREE_URL = f"{BASE}/usercenter/dept/tree"
|
|
|
|
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)
|
|
|
|
|
|
def _expected_path(node: dict) -> str:
|
|
"""Compute the expected materialized path for a fixture node.
|
|
|
|
Root (Sales) -> "Sales"; East -> "Sales/East"; TeamA -> "Sales/East/TeamA";
|
|
North -> "Sales/North".
|
|
"""
|
|
by_id = {n["id"]: n for n in ALL_NODES}
|
|
parts: list[str] = []
|
|
cur: dict | None = node
|
|
while cur is not None:
|
|
parts.append(cur["name"])
|
|
pid = cur.get("pId")
|
|
# Falsy / unknown pId terminates the walk (root reached).
|
|
cur = by_id.get(pid) if pid else None
|
|
return "/".join(reversed(parts))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End-to-end: nested tree flattens correctly with paths reconstructed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@respx.mock
|
|
async def test_scrape_departments_flattens_nested_tree_with_paths():
|
|
"""Nested tree -> flat list of 4 depts with correct parent_id + path."""
|
|
payload = make_dept_tree_response(NESTED_TREE_NODES)
|
|
route = respx.post(DEPT_TREE_URL).mock(
|
|
return_value=httpx.Response(200, json=payload)
|
|
)
|
|
|
|
client = _primed_client()
|
|
try:
|
|
results = [r async for r in scrape_departments(client)]
|
|
finally:
|
|
await client.aclose()
|
|
|
|
# Single request (no pagination).
|
|
assert route.call_count == 1
|
|
|
|
# All four nodes yielded (order-independent).
|
|
assert len(results) == 4
|
|
by_dept_id = {r["dept_id"]: r for r in results}
|
|
assert set(by_dept_id) == {n["id"] for n in ALL_NODES}
|
|
|
|
# Field mapping + parent reconstruction for each node.
|
|
for node in ALL_NODES:
|
|
rec = by_dept_id[node["id"]]
|
|
assert rec["dept_id"] == node["id"]
|
|
assert rec["name"] == node["name"]
|
|
# Root node's parent_id is preserved verbatim (pId="0") — the scraper
|
|
# does not synthesize NULL; it carries pId through. Materialized-path
|
|
# construction treats falsy/unknown pId as "stop walking".
|
|
assert rec["parent_id"] == node["pId"]
|
|
assert rec["path"] == _expected_path(node)
|
|
# raw_json preserves the original (flattened) node — keys id/pId/name.
|
|
assert rec["raw_json"]["id"] == node["id"]
|
|
assert rec["raw_json"]["name"] == node["name"]
|
|
|
|
# Spot-check the headline path expectations (defensive against path-helper
|
|
# bugs masking themselves).
|
|
assert by_dept_id[ROOT_NODE["id"]]["path"] == "Sales"
|
|
assert by_dept_id[EAST_NODE["id"]]["path"] == "Sales/East"
|
|
assert by_dept_id[TEAMA_NODE["id"]]["path"] == "Sales/East/TeamA"
|
|
assert by_dept_id[NORTH_NODE["id"]]["path"] == "Sales/North"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Flat list of nodes (parents via pId) yields the SAME result as nested
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@respx.mock
|
|
async def test_scrape_departments_flattens_flat_list_to_same_result():
|
|
"""Flat node list (no children arrays) flattens to the same 4 records."""
|
|
payload = make_dept_tree_response(FLAT_TREE_NODES)
|
|
respx.post(DEPT_TREE_URL).mock(return_value=httpx.Response(200, json=payload))
|
|
|
|
client = _primed_client()
|
|
try:
|
|
results = [r async for r in scrape_departments(client)]
|
|
finally:
|
|
await client.aclose()
|
|
|
|
assert len(results) == 4
|
|
by_dept_id = {r["dept_id"]: r for r in results}
|
|
for node in ALL_NODES:
|
|
rec = by_dept_id[node["id"]]
|
|
assert rec["parent_id"] == node["pId"]
|
|
assert rec["path"] == _expected_path(node)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Defensive envelope: data is an OBJECT wrapping nodes under `children`
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@respx.mock
|
|
async def test_scrape_departments_unwraps_data_object_children():
|
|
"""When ``data`` is an object with ``children``, scraper unwraps it."""
|
|
payload = make_dept_tree_response(
|
|
FLAT_TREE_NODES, nested_in_data_object=True
|
|
)
|
|
respx.post(DEPT_TREE_URL).mock(return_value=httpx.Response(200, json=payload))
|
|
|
|
client = _primed_client()
|
|
try:
|
|
results = [r async for r in scrape_departments(client)]
|
|
finally:
|
|
await client.aclose()
|
|
|
|
assert len(results) == 4
|
|
assert {r["dept_id"] for r in results} == {n["id"] for n in ALL_NODES}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Empty tree -> no yields, single call (no infinite loop on missing data)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@respx.mock
|
|
async def test_scrape_departments_empty_tree_yields_nothing():
|
|
"""Empty data array -> no yields, exactly one call."""
|
|
payload = make_dept_tree_response([])
|
|
route = respx.post(DEPT_TREE_URL).mock(
|
|
return_value=httpx.Response(200, json=payload)
|
|
)
|
|
|
|
client = _primed_client()
|
|
try:
|
|
results = [r async for r in scrape_departments(client)]
|
|
finally:
|
|
await client.aclose()
|
|
|
|
assert results == []
|
|
assert route.call_count == 1
|