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
|