diff --git a/yunke-server/app/scraper/base.py b/yunke-server/app/scraper/base.py index 875ae0d..fb9d701 100644 --- a/yunke-server/app/scraper/base.py +++ b/yunke-server/app/scraper/base.py @@ -25,6 +25,13 @@ over the response shape via three callables: * ``build_body(page_num, page_size, **extra) -> dict`` — build the request body for a given page. +By default the body is sent as JSON. A few yunke endpoints +(e.g. ``msg/chats``, ``wxUser/list``) bind ``@RequestParam`` from +**form-urlencoded** bodies instead — pass ``form=True`` to switch the paginator +to ``data=
`` with content-type +``application/x-www-form-urlencoded`` (mirroring the form-encoded +``msg/chats`` request the message scraper already makes directly). + ``paginate_by_cursor`` is decoupled from the client entirely: it takes a single ``fetch(cursor)`` coroutine that performs whatever request the caller needs and returns ``(rows, next_cursor, has_more)``. The cursor-paginator's @@ -45,7 +52,17 @@ if TYPE_CHECKING: # :class:`RateLimiter` at runtime without forming an import cycle. from app.session.yunke_client import YunkeClient -__all__ = ["RateLimiter", "paginate_by_cursor", "paginate_by_page"] +__all__ = [ + "FORM_CONTENT_TYPE", + "RateLimiter", + "paginate_by_cursor", + "paginate_by_page", +] + +# Content type for form-encoded bodies. Several yunke endpoints (msg/chats, +# wxUser/list) bind @RequestParam from form data rather than JSON, so the +# paginator + the manual message fetcher both send this content-type. +FORM_CONTENT_TYPE = "application/x-www-form-urlencoded" # Informal type aliases for the response-handling callables. The yunke # response shapes are dict-shaped JSON and do not benefit from finer typing. @@ -65,6 +82,7 @@ async def paginate_by_page( parse_rows: ParseRows, build_body: BuildBody, rate_limiter: RateLimiter | None = None, + form: bool = False, **extra: Any, ) -> AsyncIterator[dict[str, Any]]: """Walk a page-numbered list endpoint, yielding one dict per record. @@ -85,6 +103,15 @@ async def paginate_by_page( ``rate_limiter`` is an optional :class:`RateLimiter`; when supplied, it is acquired before every upstream call. + + ``form`` selects the wire encoding of ``build_body``'s output. The default + (``False``) sends the body as JSON (``client.post(path, json=body)``) — + correct for most yunke list endpoints. Pass ``True`` for endpoints that + bind ``@RequestParam`` from a form-urlencoded body (``msg/chats``, + ``wxUser/list``): the paginator then sends + ``client.post(path, data=body, headers={"content-type": ...})`` and lets + httpx URL-encode the dict. Either way ``build_body`` returns the same + plain dict of field names → values. """ page_num = 1 total: int | None = None @@ -95,7 +122,14 @@ async def paginate_by_page( await rate_limiter.acquire() body = build_body(page_num, page_size, **extra) - resp = await client.post(path, json=body) + if form: + resp = await client.post( + path, + data=body, + headers={"content-type": FORM_CONTENT_TYPE}, + ) + else: + resp = await client.post(path, json=body) # Read the declared total once from the first response. The yunke # envelopes keep it on every page, but reading it once is enough for diff --git a/yunke-server/app/scraper/wechat.py b/yunke-server/app/scraper/wechat.py index 5a33cfe..c6386d5 100644 --- a/yunke-server/app/scraper/wechat.py +++ b/yunke-server/app/scraper/wechat.py @@ -13,8 +13,12 @@ Endpoints covered today: ``path`` is reconstructed from parent links (Task 2.0). * :func:`scrape_wechat_accounts` — wraps ``POST /wechatweb/wxUser/list`` and yields normalized :class:`app.db.models.WechatAccount` dicts (plus the - embedded ``userId``/``userName`` staff pair). Page-number pagination - (Task 2.1). + embedded ``userId``/``userName`` staff pair). The endpoint binds + ``@RequestParam`` from a **form-urlencoded** body (NOT JSON) and REQUIRES + ``isOnJob`` / ``departmentId`` (the company-root dept id) / ``startdate`` / + ``enddate`` or it answers ``code -90000`` "缺少..."; a recent ~7-day + window works (the date affects per-account stats, not which accounts are + returned). Page-number pagination (Task 2.1). * :func:`scrape_wechat_groups` — wraps ``POST /wechatweb/chatroom/getChatroomList`` and yields normalized :class:`app.db.models.ChatGroup` dicts, parsing the semicolon-joined ``memberIds`` into a ``member_ids_list`` so the @@ -58,6 +62,7 @@ disambiguations where the source has two fields for one column: from __future__ import annotations +import datetime as dt from collections.abc import AsyncIterator from typing import Any @@ -660,13 +665,57 @@ def _parse_wxuser_rows(resp: Any) -> list[dict[str, Any]]: return rows if isinstance(rows, list) else [] -def _build_wxuser_body(page_num: int, page_size: int) -> dict[str, Any]: - """Build the ``wxUser/list`` request body for a given page. +def _build_wxuser_body( + page_num: int, + page_size: int, + *, + department_id: str, + is_on_job: str = "1", + start: str, + end: str, +) -> dict[str, Any]: + """Build the form-urlencoded ``wxUser/list`` body for a given page. - Unlike ``friend_list/es``, the ``wxUser/list`` body carries no - staff/dept filter parameters in the live capture — just the page slice. + Phase-0 live probing established the real contract: the endpoint binds + Spring ``@RequestParam`` from a form body (NOT JSON) and REQUIRES the + filters below or it answers ``code -90000`` ("缺少..."). Empty + ``userId`` / ``salesname`` / ``weChatNickName`` are sent explicitly because + the real client does, and the upstream treats missing keys differently + from empty strings for some filters. """ - return {"pageNum": page_num, "pageSize": page_size} + return { + "pageNum": page_num, + "pageSize": page_size, + # REQUIRED — empty fails with code -90000. "1" => on-job accounts only + # (the 55 live rows). "0" would pull departed staff instead. + "isOnJob": is_on_job, + # REQUIRED — the company ROOT dept id (caller obtains it from + # scrape_departments). NOT hardcoded here. + "departmentId": department_id, + # REQUIRED — "缺少时间参数" if absent. YYYY-MM-DD strings. The window + # affects per-account stats, NOT which accounts are returned. + "startdate": start, + "enddate": end, + # Always-empty free-text filters (sent to mirror the real client). + "userId": "", + "salesname": "", + "weChatNickName": "", + } + + +def _default_date_window(now: dt.datetime | None = None) -> tuple[str, str]: + """A recent 7-day window as ``(start, end)`` ``YYYY-MM-DD`` strings. + + The ``wxUser/list`` stats window is cosmetic (it affects per-account + counters, not which accounts are returned), so any recent window works. + We default to the last 7 days measured from ``now`` (UTC by default to + stay deterministic and match the rest of the scraper layer's naive-UTC + convention). Both bounds are inclusive calendar days. + """ + now = now or dt.datetime.now(dt.UTC) + end_date = now.date() + start_date = end_date - dt.timedelta(days=7) + return start_date.isoformat(), end_date.isoformat() def _normalize_wxuser_account(record: dict[str, Any]) -> dict[str, Any]: @@ -702,7 +751,11 @@ def _normalize_wxuser_account(record: dict[str, Any]) -> dict[str, Any]: async def scrape_wechat_accounts( client: YunkeClient, *, + department_id: str, page_size: int = 50, + is_on_job: str = "1", + start: str | None = None, + end: str | None = None, ) -> AsyncIterator[dict[str, Any]]: """Yield normalized WeChat staff-account records from ``wxUser/list``. @@ -711,6 +764,13 @@ async def scrape_wechat_accounts( mapping each record to the ``WechatAccount`` column shape plus a verbatim ``raw_json`` copy. + The request is **form-urlencoded** (NOT JSON): the endpoint binds Spring + ``@RequestParam`` from the form body and REQUIRES ``isOnJob`` / + ``departmentId`` / ``startdate`` / ``enddate``. ``department_id`` is the + company-root dept id (the caller resolves it from + :func:`scrape_departments`); it is NOT hardcoded here so this scraper + stays account-agnostic. + The envelope differs from ``friend_list/es``: there is **no** ``basicResult`` wrapper. The pagination fields sit directly under ``data`` (``data.data`` / ``data.total`` / ``data.pageNum`` / ``data.pageSize``). @@ -720,10 +780,26 @@ async def scrape_wechat_accounts( client: Authenticated :class:`YunkeClient`. Auth headers / 401 re-login are handled by the client. + department_id: + Company-root department id (REQUIRED upstream filter). The caller + obtains it from :func:`scrape_departments` (the root node whose + ``parent_id`` is falsy). page_size: Page size to request. Defaults to 50 (matches the live capture's observed page size for this endpoint). + is_on_job: + ``"1"`` (default) for on-job staff, ``"0"`` for departed. Required + upstream filter — empty fails with ``code -90000``. + start, end: + Optional ``YYYY-MM-DD`` bounds for the per-account stats window. The + date affects per-account counters, NOT which accounts are returned + (live total is 55 regardless), so any recent window works. Defaults + to a recent 7-day window computed from ``datetime.now(UTC)``. """ + default_start, default_end = _default_date_window() + window_start = start or default_start + window_end = end or default_end + async for record in paginate_by_page( client, WXUSER_LIST_PATH, @@ -731,6 +807,11 @@ async def scrape_wechat_accounts( parse_total=_parse_wxuser_total, parse_rows=_parse_wxuser_rows, build_body=_build_wxuser_body, + form=True, + department_id=department_id, + is_on_job=is_on_job, + start=window_start, + end=window_end, ): yield _normalize_wxuser_account(record) diff --git a/yunke-server/app/sync/orchestrator.py b/yunke-server/app/sync/orchestrator.py index 4c75500..9876e31 100644 --- a/yunke-server/app/sync/orchestrator.py +++ b/yunke-server/app/sync/orchestrator.py @@ -43,9 +43,10 @@ import logging from collections.abc import AsyncIterator, Callable from typing import Any +import sqlalchemy as sa from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import SyncRun +from app.db.models import Department, SyncRun from app.scraper.call import scrape_phone_calls, scrape_sms from app.scraper.media import download_message_media from app.scraper.wechat import ( @@ -385,16 +386,72 @@ async def sync_departments( ) +async def _find_root_department_id( + session: AsyncSession, +) -> str | None: + """Resolve the company-root department id from the mirrored ``Department`` table. + + The ``wxUser/list`` form body REQUIRES ``departmentId`` (the company-root + dept id) — empty fails with ``code -90000``. The root is a node whose + ``parent_id`` is falsy (``None`` / ``""`` / ``"0"`` — the yunke tree + convention). If several roots exist, the first by ``dept_id`` is returned + (with a warning) so a multi-root org still scrapes. Returns ``None`` when + no root is present so the caller can skip gracefully rather than send a + known-bad empty filter. + """ + stmt = ( + sa.select(Department) + .where( + sa.or_( + Department.parent_id.is_(None), + Department.parent_id == "", + Department.parent_id == "0", + ) + ) + .order_by(Department.dept_id.asc()) + .limit(1) + ) + row = (await session.execute(stmt)).scalars().first() + return row.dept_id if row is not None else None + + async def sync_wechat_accounts( - session: AsyncSession, client: YunkeClient + session: AsyncSession, + client: YunkeClient, + *, + department_id: str | None = None, ) -> SyncResult: - """Full re-scrape of all WeChat staff accounts (+ embedded staff rows).""" + """Full re-scrape of all WeChat staff accounts (+ embedded staff rows). + + ``department_id`` is the company-root dept id required by the + ``wxUser/list`` form body. When omitted, the orchestrator resolves it from + the mirrored ``Department`` table (a root node whose ``parent_id`` is + falsy) — which is why :func:`sync_departments` must run first. If no root + can be resolved, the scrape is SKIPPED with a logged warning (returns a + ``status='skipped'`` :class:`SyncResult` and records no ``SyncRun``) so a + missing org tree does not abort a multi-entity batch. + """ + resolved = department_id + if resolved is None: + resolved = await _find_root_department_id(session) + if not resolved: + logger.warning( + "sync_wechat_accounts: no root department_id resolved" + " (sync departments first?); skipping wxUser/list scrape" + ) + return SyncResult( + entity=ENTITY_WECHAT_ACCOUNT, + status="skipped", + fetched=0, + upserted=0, + error="no root department_id available", + ) return await _sync_full_rescrape( session, entity_type=ENTITY_WECHAT_ACCOUNT, channel=None, scope_id=None, - scrape=lambda: scrape_wechat_accounts(client), + scrape=lambda: scrape_wechat_accounts(client, department_id=resolved), upsert=upsert_wechat_account, ) diff --git a/yunke-server/scripts/seed_accounts.py b/yunke-server/scripts/seed_accounts.py new file mode 100644 index 0000000..5b3c39f --- /dev/null +++ b/yunke-server/scripts/seed_accounts.py @@ -0,0 +1,53 @@ +"""Seed WeChat + WhatsApp accounts (and their embedded staff) into smoke.db.""" +import asyncio + +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.db import models # noqa: F401 +from app.scraper.wechat import scrape_departments, scrape_wechat_accounts +from app.scraper.whatsapp import scrape_whatsapp_accounts +from app.storage.repos import upsert_wechat_account, upsert_whatsapp_account +from scripts.smoke_wechat import _push_settings_into_environ, build_client_from_env + + +async def _root_department_id(client) -> str | None: + """First root department (parent_id falsy) from the live dept tree. + + ``wxUser/list`` REQUIRES the company-root ``departmentId`` filter; we pull + it from ``scrape_departments`` rather than hardcoding the live id. + """ + async for d in scrape_departments(client): + if not d.get("parent_id"): + return d.get("dept_id") + return None + + +async def main() -> None: + _push_settings_into_environ() + client = build_client_from_env() + engine = create_async_engine("sqlite+aiosqlite:///./smoke.db") + sm = async_sessionmaker(engine, expire_on_commit=False) + + root_dept = await _root_department_id(client) + if not root_dept: + print("WARNING: no root department found; skipping wxUser/list scrape") + + wx_n = wa_n = 0 + async with sm() as session: + if root_dept: + async for rec in scrape_wechat_accounts( + client, department_id=root_dept, page_size=50 + ): + await upsert_wechat_account(session, rec) + wx_n += 1 + async for rec in scrape_whatsapp_accounts(client, page_size=50): + await upsert_whatsapp_account(session, rec) + wa_n += 1 + await session.commit() + print(f"upserted {wx_n} wechat accounts, {wa_n} whatsapp accounts (+ their staff)") + await client.aclose() + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/yunke-server/tests/cassettes/wxUser_fixture.py b/yunke-server/tests/cassettes/wxUser_fixture.py index 60312e3..e783351 100644 --- a/yunke-server/tests/cassettes/wxUser_fixture.py +++ b/yunke-server/tests/cassettes/wxUser_fixture.py @@ -5,8 +5,15 @@ data dictionary §2: ``wechatId, nickName, alias, phone, userId, userName(员工真名), contactsNum, groupNum, sendNum/receiveNum, isOnJob, dbSize, 在线状态, lastUpdate``; total=55). -The envelope differs from ``wxFriends/friend_list/es``: there is **no** -``basicResult`` wrapper. The pagination fields sit directly under ``data``:: +The REQUEST is **form-urlencoded** (NOT JSON): the endpoint binds Spring +``@RequestParam`` from the form body and REQUIRES ``isOnJob`` / +``departmentId`` (the company-root dept id) / ``startdate`` / ``enddate`` or +it answers ``code -90000`` ("缺少..."). ``userId`` / ``salesname`` / +``weChatNickName`` are sent empty to mirror the real client. + +The RESPONSE envelope differs from ``wxFriends/friend_list/es``: there is +**no** ``basicResult`` wrapper. The pagination fields sit directly under +``data``:: {"code":"10000","data":{"data":[...], "total":.., "pageNum":.., "pageSize":..}} diff --git a/yunke-server/tests/test_orchestrator.py b/yunke-server/tests/test_orchestrator.py index 733234d..3ce17fe 100644 --- a/yunke-server/tests/test_orchestrator.py +++ b/yunke-server/tests/test_orchestrator.py @@ -29,7 +29,7 @@ import sqlalchemy as sa from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.db.base import Base -from app.db.models import SyncRun, SyncState +from app.db.models import Department, SyncRun, SyncState from app.sync import orchestrator from app.sync.watermarks import get_last_timestamp @@ -477,7 +477,7 @@ async def test_sync_all_wechat_runs_each_entity_then_messages(monkeypatch): try: SessionLocal = async_sessionmaker(engine, expire_on_commit=False) - async def empty_async_gen(_client=None): + async def empty_async_gen(_client=None, **_kwargs): if False: # pragma: no cover yield {} @@ -496,6 +496,11 @@ async def test_sync_all_wechat_runs_each_entity_then_messages(monkeypatch): # orchestrator namespace) are never called. No need to patch them. async with SessionLocal() as session: + # sync_wechat_accounts resolves the company-root dept id required + # by the wxUser/list form body from the Department table, so seed + # a root (parent_id=None) before running the coordinator. + session.add(Department(dept_id="ROOT_DEPT", parent_id=None, name="Root")) + await session.flush() results = await orchestrator.sync_all_wechat( session, client=object(), @@ -520,6 +525,92 @@ async def test_sync_all_wechat_runs_each_entity_then_messages(monkeypatch): await engine.dispose() +async def test_sync_wechat_accounts_skips_when_no_root_department(monkeypatch): + """``wxUser/list`` REQUIRES a ``departmentId`` filter. When the org tree is + empty (no root resolvable from the Department table) the orchestrator must + NOT send a known-bad empty filter; it skips with a logged warning and + returns a ``status='skipped'`` result without ever calling the scraper.""" + engine = await _make_engine() + try: + SessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + called = False + + async def fake_scrape_accounts(client, *, department_id, **_kwargs): + nonlocal called + called = True + if False: # pragma: no cover + yield {} + + monkeypatch.setattr( + orchestrator, "scrape_wechat_accounts", fake_scrape_accounts + ) + + async with SessionLocal() as session: + result = await orchestrator.sync_wechat_accounts( + session, client=object() + ) + await session.commit() + + assert result.status == "skipped" + assert result.fetched == 0 + assert result.upserted == 0 + assert not called + + # No SyncRun recorded for a skip (it is not a success or a failure). + async with SessionLocal() as session: + rows = ( + await session.execute( + sa.select(SyncRun).where( + SyncRun.entity_type == "wechat_account" + ) + ) + ).scalars().all() + assert rows == [] + finally: + await engine.dispose() + + +async def test_sync_wechat_accounts_passes_resolved_root_to_scraper(monkeypatch): + """When a root department exists in the DB, the orchestrator resolves its + id and passes it as ``department_id`` to the scraper.""" + engine = await _make_engine() + try: + SessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + captured: dict = {} + + async def fake_scrape_accounts(client, *, department_id, **_kwargs): + captured["department_id"] = department_id + if False: # pragma: no cover + yield {} + + async def fake_upsert(session, rec): + return None + + monkeypatch.setattr( + orchestrator, "scrape_wechat_accounts", fake_scrape_accounts + ) + monkeypatch.setattr(orchestrator, "upsert_wechat_account", fake_upsert) + + async with SessionLocal() as session: + session.add( + Department( + dept_id="ROOT_DEPT", parent_id=None, name="Root" + ) + ) + await session.flush() + result = await orchestrator.sync_wechat_accounts( + session, client=object() + ) + await session.commit() + + assert result.status == "success" + assert captured["department_id"] == "ROOT_DEPT" + finally: + await engine.dispose() + + # =========================================================================== # Phone calls + SMS — full re-scrape entities (M4) # =========================================================================== diff --git a/yunke-server/tests/test_scrape_wechat_accounts.py b/yunke-server/tests/test_scrape_wechat_accounts.py index 5fc15c5..9ceffba 100644 --- a/yunke-server/tests/test_scrape_wechat_accounts.py +++ b/yunke-server/tests/test_scrape_wechat_accounts.py @@ -16,15 +16,20 @@ 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). + +The REQUEST body is form-urlencoded (NOT JSON) and must carry the required +``isOnJob`` / ``departmentId`` / ``startdate`` / ``enddate`` filters; the +"send pagination body" test asserts those keys land on the wire. """ from __future__ import annotations -import json as _json +from urllib.parse import parse_qs import httpx import respx +from app.scraper.base import FORM_CONTENT_TYPE from app.scraper.wechat import scrape_wechat_accounts from app.session.yunke_client import YunkeClient from tests.cassettes.wxUser_fixture import ( @@ -40,12 +45,30 @@ WXUSER_LIST_URL = f"{BASE}/wechatweb/wxUser/list" USERID = "a" * 32 COMPANY = "8m0atd" +# The company-root department id used as the required ``departmentId`` filter. +# Mirrors the shape of the real root dept id (32 hex chars) without hardcoding +# the live value in the scraper itself. +ROOT_DEPARTMENT_ID = "0E9C4B803E134C7687CBA824886319DA" + 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 _parse_form(request: httpx.Request) -> dict[str, str]: + """Decode a form-urlencoded request body into a flat ``{key: value}`` dict. + + ``parse_qs`` returns lists per key; the wxUser body has no repeated keys, + so we collapse each to its single value for ergonomic assertions. + ``keep_blank_values=True`` so the always-empty ``userId`` / ``salesname`` + filters (sent as ``userId=``) survive decoding and can be asserted on. + """ + body = request.content.decode() + parsed = parse_qs(body, keep_blank_values=True) + return {k: v[0] for k, v in parsed.items()} + + # --------------------------------------------------------------------------- # End-to-end: pagination + field mapping + raw_json # --------------------------------------------------------------------------- @@ -77,7 +100,9 @@ async def test_scrape_wechat_accounts_yields_records_and_paginates(): results = [ r async for r in scrape_wechat_accounts( - client, page_size=DEFAULT_PAGE_SIZE + client, + department_id=ROOT_DEPARTMENT_ID, + page_size=DEFAULT_PAGE_SIZE, ) ] finally: @@ -86,6 +111,16 @@ async def test_scrape_wechat_accounts_yields_records_and_paginates(): # Pagination: exactly 3 calls (page1, page2, empty-page stop). assert route.call_count == 3 + # The required form filters ride on every request (the live endpoint + # answers code -90000 without them). + for call in route.calls: + body = _parse_form(call.request) + assert body["departmentId"] == ROOT_DEPARTMENT_ID + assert body["isOnJob"] == "1" + assert body["startdate"] + assert body["enddate"] + assert call.request.headers["content-type"] == FORM_CONTENT_TYPE + # Exactly the 4 fixture records (not duplicated, not dropped). assert len(results) == 4 assert {r["wx_id"] for r in results} == { @@ -132,7 +167,8 @@ async def test_scrape_wechat_accounts_yields_records_and_paginates(): @respx.mock async def test_scrape_wechat_accounts_sends_pagination_body(): - """Outgoing request body carries pageNum/pageSize; advances per page.""" + """Outgoing form body carries pageNum/pageSize + the required filters; the + page number advances per request.""" page1 = make_wxuser_list_response( WXUSER_RECORDS[:1], total=TOTAL, page_num=1, page_size=1 ) @@ -149,7 +185,14 @@ async def test_scrape_wechat_accounts_sends_pagination_body(): client = _primed_client() try: results = [ - r async for r in scrape_wechat_accounts(client, page_size=1) + r + async for r in scrape_wechat_accounts( + client, + department_id=ROOT_DEPARTMENT_ID, + page_size=1, + start="2026-07-01", + end="2026-07-08", + ) ] finally: await client.aclose() @@ -158,15 +201,28 @@ async def test_scrape_wechat_accounts_sends_pagination_body(): 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 + # Every request is form-urlencoded (NOT JSON) and carries the required + # filters the live endpoint demands (isOnJob / departmentId / startdate / + # enddate), plus the always-empty free-text filters from the real client. + for call in route.calls: + assert call.request.headers["content-type"] == FORM_CONTENT_TYPE + body = _parse_form(call.request) + assert body["isOnJob"] == "1" + assert body["departmentId"] == ROOT_DEPARTMENT_ID + assert body["startdate"] == "2026-07-01" + assert body["enddate"] == "2026-07-08" + assert body["userId"] == "" + assert body["salesname"] == "" + assert body["weChatNickName"] == "" - # Second request: pageNum advanced to 2. - body2 = _json.loads(route.calls[1].request.content.decode()) - assert body2["pageNum"] == 2 - assert body2["pageSize"] == 1 + # pageNum/pageSize advance correctly across the form body. + body1 = _parse_form(route.calls[0].request) + assert body1["pageNum"] == "1" + assert body1["pageSize"] == "1" + + body2 = _parse_form(route.calls[1].request) + assert body2["pageNum"] == "2" + assert body2["pageSize"] == "1" # --------------------------------------------------------------------------- @@ -184,7 +240,12 @@ async def test_scrape_wechat_accounts_stops_on_empty_first_page(): client = _primed_client() try: - results = [r async for r in scrape_wechat_accounts(client)] + results = [ + r + async for r in scrape_wechat_accounts( + client, department_id=ROOT_DEPARTMENT_ID + ) + ] finally: await client.aclose() diff --git a/yunkes/src/layouts/MainLayout.vue b/yunkes/src/layouts/MainLayout.vue index b4abeb5..176bdc8 100644 --- a/yunkes/src/layouts/MainLayout.vue +++ b/yunkes/src/layouts/MainLayout.vue @@ -119,6 +119,10 @@ onMounted(async () => {