Files
yunke/yunke-server/tests/test_scrape_departments.py
T

188 lines
6.6 KiB
Python
Raw Normal View History

"""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