feat: 项目层级树+合作方绑定KOC资源+客户群全量可选+精简编辑表单,移除复盘报表

- 项目支持父子层级:树形折叠展示、详情面板内联管理总群成员、推送聚合子项目任务、环检测与子项目删除守卫
- 外部KOC合作方绑定 KOC 资源账号(唯一占用、换绑/解绑、资源页一键设为合作方),列表直显联系方式
- 绑定客户群改为全量外部群缓存(含各群主),同步按钮强刷;member_count 缺失以 member_list 兜底
- 任务/项目编辑模式隐藏不可变字段(平台/内容类型/品牌/总群成员),详情面板维护成员时保留 parent_id
- 删除复盘报表整栏(导航/页面/task_report_list/导出路由/CSV lib 及测试)
This commit is contained in:
ABAPPLO
2026-09-23 11:13:17 +08:00
parent 8f1ebf7697
commit ab78b187c0
20 changed files with 1354 additions and 958 deletions

View File

@@ -10,10 +10,6 @@ const bootstrapRoute = readFileSync(
new URL("../app/api/bootstrap/route.ts", import.meta.url),
"utf8",
);
const taskReportExportRoute = readFileSync(
new URL("../app/api/task-report-export/route.ts", import.meta.url),
"utf8",
);
const recoveryExportRoute = readFileSync(
new URL("../app/api/recovery-export/route.ts", import.meta.url),
"utf8",
@@ -55,6 +51,8 @@ test("manager-only actions gate on owner scope", () => {
"backfill_account_profiles",
"set_public_account_ids",
"bind_wecom_external_id",
"partner_bind_account",
"partner_create_from_account",
"send_test_wecom",
"trigger_wecom_overdue_escalation",
"wecom_list_external_contacts",
@@ -185,15 +183,6 @@ test("task-scoped actions verify ownership for task owners", () => {
assert.match(manual, /distributionOwnerGate\(/);
});
test("task_report_list scopes reports by owner and drops the broken gate", () => {
const body = section(actionRoute, "task_report_list");
assert.match(body, /getTaskReportData\(db, ownerScopeUserid\(scope\)\)/);
assert.ok(
!/isManagerRequest/.test(body),
"task_report_list 不再是管理员专属(负责人按 scope 过滤)",
);
});
test("project_list opens to owner scope with server-side filtering", () => {
const body = section(actionRoute, "project_list");
assert.match(
@@ -232,23 +221,37 @@ test("action fallthrough dashboard is owner-scoped", () => {
);
});
test("customer chat actions require a task owner before binding", () => {
test("customer chat actions list all owners' groups without owner prerequisite", () => {
const list = section(actionRoute, "task_customer_chats");
assert.match(
list,
/listGroupChatRows\(db\)/,
"task_customer_chats 读全量群缓存(所有群主的外部群)",
);
assert.match(
list,
/syncGroupChats\(db, wecomConfig\)/,
"缓存为空(或要求刷新)时自动全量同步",
);
assert.ok(
!/请先设置任务负责人/.test(list),
"列表不再要求任务先设负责人",
);
const bind = section(actionRoute, "task_bind_customer_chat");
assert.ok(
!/请先设置任务负责人/.test(bind),
"绑定活码用应用身份,不依赖任务负责人",
);
assert.match(bind, /bindTaskCustomerChat\(/);
assert.match(bind, /unbindTaskCustomerChat\(/);
for (const action of ["task_customer_chats", "task_bind_customer_chat"]) {
const body = section(actionRoute, action);
assert.match(
body,
/请先设置任务负责人/,
`${action} 必须在无负责人时明确报错`,
);
assert.match(body, /Response\.json\(/);
assert.ok(
!/getDashboardData/.test(body),
`${action} 显式返回,不能落兜底 dashboard`,
);
}
const bind = section(actionRoute, "task_bind_customer_chat");
assert.match(bind, /bindTaskCustomerChat\(/);
assert.match(bind, /unbindTaskCustomerChat\(/);
});
test("partner claim detail exposes join qr only for claimed tasks", () => {
@@ -275,7 +278,7 @@ test("bootstrap scopes dashboard data by owner scope", () => {
test("getDashboardData filters tasks and distributions by owner", () => {
const dashboard = mvpDb.slice(
mvpDb.indexOf("export async function getDashboardData"),
mvpDb.indexOf("export type TaskReportRow"),
mvpDb.indexOf("export function uid(prefix: string)"),
);
const occurrences =
dashboard.match(
@@ -289,8 +292,6 @@ test("getDashboardData filters tasks and distributions by owner", () => {
});
test("export routes enforce owner scope", () => {
assert.match(taskReportExportRoute, /getOwnerScope\(request\)/);
assert.match(taskReportExportRoute, /ownerScopeUserid\(await getOwnerScope\(request\)\)/);
assert.match(recoveryExportRoute, /isTaskOwner\(/);
assert.match(screenshotTaskExportRoute, /isTaskOwner\(/);
assert.match(taskResultImageRoute, /isDistributionOwner\(/);
@@ -301,3 +302,38 @@ test("users route supports wecom binding lifecycle", () => {
assert.match(usersRoute, /INSERT INTO users[\s\S]*?wecom_userid/);
assert.match(usersRoute, /SELECT id, username, role, wecom_userid, created_at/);
});
test("project detail exposes inline main-chat member management", () => {
const adminApp = readFileSync(
new URL("../app/admin-app.tsx", import.meta.url),
"utf8",
);
const globalsCss = readFileSync(
new URL("../app/globals.css", import.meta.url),
"utf8",
);
assert.match(adminApp, /memberPickerOpen/);
assert.match(adminApp, /mainGroupMemberIds: nextMembers/);
assert.match(adminApp, /project-member-remove/);
assert.match(adminApp, /userid !== detail\.project\.ownerUserid/);
assert.match(globalsCss, /\.project-member-remove/);
});
test("every project_save payload carries parentProjectId(改总群成员不得清空层级)", () => {
const adminApp = readFileSync(
new URL("../app/admin-app.tsx", import.meta.url),
"utf8",
);
const starts = [...adminApp.matchAll(/action: "project_save"/g)].map(
(match) => match.index,
);
assert.ok(starts.length >= 2, "至少应有两处 project_save 调用(编辑器/总群成员)");
for (const start of starts) {
const payload = adminApp.slice(start, start + 600);
assert.match(
payload,
/parentProjectId/,
"project_save 载荷缺 parentProjectId,会把 parent_id 重置为空",
);
}
});

View File

@@ -5,7 +5,6 @@ import {
isTaskOwner,
ownerScopeUserid,
} from "../lib/owner-scope.ts";
import { getTaskReportData } from "../lib/mvp-db.ts";
function captureDb(rowsByPattern) {
const captured = [];
@@ -121,29 +120,3 @@ test("ownerScopeUserid maps scope kinds to filter value", () => {
assert.equal(ownerScopeUserid({ kind: "owner", wecomUserId: "zhangsan" }), "zhangsan");
assert.equal(ownerScopeUserid({ kind: "owner", wecomUserId: "" }), "");
});
test("getTaskReportData filters by owner when ownerUserid given", async () => {
const { db, captured } = captureDb([[/FROM tasks t/, () => []]]);
await getTaskReportData(db, "zhangsan");
assert.equal(captured.length, 1);
assert.match(
captured[0].sql,
/WHERE \(t\.owner_userid = \? OR p\.owner_userid = \?\)/,
);
assert.deepEqual(captured[0].values, ["zhangsan", "zhangsan"]);
});
test("getTaskReportData keeps global aggregation without ownerUserid", async () => {
const { db, captured } = captureDb([[/FROM tasks t/, () => []]]);
await getTaskReportData(db);
assert.equal(captured.length, 1);
assert.ok(!/owner_userid = \?/.test(captured[0].sql));
assert.ok(!captured[0].values?.length);
});
test("getTaskReportData returns empty list for unbound owner (empty string scope)", async () => {
const { db, captured } = captureDb([[/FROM tasks t/, () => [{ id: "task-x" }]]]);
const rows = await getTaskReportData(db, "");
assert.deepEqual(rows, []);
assert.equal(captured.length, 0, "空范围不应执行查询(绑定 '' 会误配 owner_userid='' 的任务)");
});

View File

@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const actionRoute = readFileSync(
new URL("../app/api/action/route.ts", import.meta.url),
"utf8",
);
const mvpDb = readFileSync(
new URL("../lib/mvp-db.ts", import.meta.url),
"utf8",
);
const adminApp = readFileSync(
new URL("../app/admin-app.tsx", import.meta.url),
"utf8",
);
const migration = readFileSync(
new URL("../mysql/0018_partner_resource_binding.sql", import.meta.url),
"utf8",
);
function section(source, action) {
const start = source.indexOf(`"${action}"`);
assert.ok(start >= 0, `action ${action} 不存在于路由源码`);
const next = source.indexOf("} else if (body.action ===", start);
return source.slice(start, next === -1 ? undefined : next);
}
test("partner_bind_account enforces one partner per resource account", () => {
const body = section(actionRoute, "partner_bind_account");
assert.match(
body,
/SELECT id, name FROM partners WHERE account_id = \? AND id != \? LIMIT 1/,
"绑定前必须检查资源账号是否已被其它合作方占用",
);
assert.match(body, /该 KOC 资源已绑定合作方/, "占用时返回可读错误");
assert.match(
body,
/UPDATE partners SET account_id = '' WHERE id = \?/,
"空 accountId 表示解绑",
);
assert.match(body, /群主合作方不绑定 KOC 资源/);
});
test("partner_create_from_account creates external_koc bound to account", () => {
const body = section(actionRoute, "partner_create_from_account");
assert.match(
body,
/SELECT id, name FROM partners WHERE account_id = \? LIMIT 1/,
"一键创建前必须确认资源未被绑定",
);
assert.match(
body,
/partner-koc-/,
"合作方 id 使用 partner-koc- 前缀,避免与领取流程冲突",
);
assert.match(body, /'external_koc'/);
assert.match(
body,
/INSERT INTO partners[\s\S]*account_id[\s\S]*VALUES/,
"INSERT 必须写入 account_id",
);
});
test("dashboard partners query joins accounts for contact display", () => {
assert.match(
mvpDb,
/FROM partners p\s+LEFT JOIN accounts a ON a\.id = p\.account_id/,
"partners 列表需带出资源昵称与联系方式",
);
assert.match(mvpDb, /a\.current_contact AS account_contact/);
assert.match(
mvpDb,
/ensureColumn\(\s*"partners",\s*"account_id"/,
"自托管路径也要补 account_id 列",
);
});
test("migration 0018 adds partners.account_id with index", () => {
assert.match(migration, /ADD COLUMN `account_id`/);
assert.match(migration, /partners_account_idx/);
});
test("admin app renders resource binding affordances", () => {
assert.match(adminApp, /partner_bind_account/);
assert.match(adminApp, /partner_create_from_account/);
assert.match(adminApp, /绑定资源/, "未绑合作方有绑定入口");
assert.match(adminApp, /换绑/, "已绑合作方可换绑");
assert.match(adminApp, /解绑中…/, "已绑合作方可解绑");
assert.match(adminApp, /设为合作方/, "资源页支持一键创建合作方");
});

View File

@@ -105,10 +105,11 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
brand: params[2],
main_group_member_ids: params[3],
owner_userid: params[4],
parent_id: params[5],
main_chat_id: "",
status: "active",
note: params[5],
created_at: params[6],
note: params[6],
created_at: params[7],
});
return 1;
}
@@ -118,7 +119,7 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
return row ? 1 : 0;
}
if (/^UPDATE projects/.test(normalized)) {
const row = projects.find((item) => item.id === params[5]);
const row = projects.find((item) => item.id === params[6]);
if (!row) return 0;
Object.assign(row, {
name: params[0],
@@ -126,6 +127,7 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
main_group_member_ids: params[2],
owner_userid: params[3],
note: params[4],
parent_id: params[5],
});
return 1;
}
@@ -170,6 +172,11 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
},
async first() {
executed.push({ sql, params });
if (/COUNT\(\*\) AS count FROM projects WHERE parent_id = \?/.test(sql)) {
return {
count: projects.filter((item) => item.parent_id === params[0]).length,
};
}
if (/FROM projects WHERE id = \?/.test(sql)) {
return projects.find((item) => item.id === params[0]) ?? null;
}
@@ -189,11 +196,23 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
},
async all() {
executed.push({ sql, params });
if (/^SELECT id, parent_id FROM projects/.test(sql.trim())) {
return {
results: projects.map((item) => ({
id: item.id,
parent_id: item.parent_id ?? "",
})),
};
}
if (/FROM projects p/.test(sql)) {
const ownerFiltered = /WHERE p\.owner_userid = \?/.test(sql);
const rows = ownerFiltered
? projects.filter((item) => item.owner_userid === params[0])
: projects;
const parentFiltered = /WHERE p\.parent_id = \?/.test(sql);
let rows = projects;
if (ownerFiltered) {
rows = rows.filter((item) => item.owner_userid === params[0]);
} else if (parentFiltered) {
rows = rows.filter((item) => item.parent_id === params[0]);
}
return {
results: rows.map((item) => ({
...item,
@@ -208,6 +227,18 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
.map((task) => ({ ...task })),
};
}
if (/FROM tasks t\s+LEFT JOIN projects pj/.test(sql)) {
const wanted = new Set(params);
return {
results: tasks
.filter((task) => wanted.has(task.project_id))
.map((task) => ({
...task,
project_name:
projects.find((item) => item.id === task.project_id)?.name ?? "",
})),
};
}
if (/FROM partners/.test(sql)) {
return { results: partners.map((item) => ({ ...item })) };
}
@@ -236,6 +267,7 @@ const projectRow = (overrides = {}) => ({
brand: "",
main_group_member_ids: "[]",
owner_userid: "",
parent_id: "",
main_chat_id: "",
status: "active",
note: "",
@@ -695,3 +727,101 @@ test("findOrCreateGroupOwnerPartner creates partner with userid fallback name",
assert.equal(created.wecom_userid, "owner-b");
assert.equal(db.partners.length, 1);
});
test("saveProject persists parentProjectId on create and update", async () => {
const db = fakeProjectDb({
projects: [projectRow({ id: "proj-root", name: "总项目" })],
});
await saveProject(db, {
name: "子项目",
brand: "b",
mainGroupMemberIds: [],
ownerUserid: "",
parentProjectId: "proj-root",
});
assert.equal(db.projects.length, 2);
const child = db.projects.find((item) => item.name === "子项目");
assert.equal(child.parent_id, "proj-root");
await saveProject(db, {
id: "proj-root",
name: "总项目",
mainGroupMemberIds: [],
parentProjectId: "",
});
assert.equal(db.projects.find((item) => item.id === "proj-root").parent_id, "");
});
test("saveProject rejects invalid parentProjectId (missing / self / descendant)", async () => {
const db = fakeProjectDb({
projects: [
projectRow({ id: "proj-1", name: "一级" }),
projectRow({ id: "proj-2", name: "二级", parent_id: "proj-1" }),
projectRow({ id: "proj-3", name: "三级", parent_id: "proj-2" }),
],
});
await assert.rejects(
() => saveProject(db, { id: "proj-1", name: "一级", parentProjectId: "proj-404" }),
(error) => error instanceof ProjectError && error.status === 404,
);
await assert.rejects(
() => saveProject(db, { id: "proj-1", name: "一级", parentProjectId: "proj-1" }),
(error) => error instanceof ProjectError && /自身/.test(error.message),
);
await assert.rejects(
() => saveProject(db, { id: "proj-1", name: "一级", parentProjectId: "proj-3" }),
(error) =>
error instanceof ProjectError && /上级项目不能是当前项目的子项目/.test(error.message),
);
// 兄弟项目/父项目合法
await saveProject(db, { id: "proj-3", name: "三级", parentProjectId: "proj-1" });
assert.equal(db.projects.find((item) => item.id === "proj-3").parent_id, "proj-1");
});
test("deleteProject refuses while child projects exist", async () => {
const db = fakeProjectDb({
projects: [
projectRow({ id: "proj-1", name: "一级" }),
projectRow({ id: "proj-2", name: "二级", parent_id: "proj-1" }),
],
});
await assert.rejects(
() => deleteProject(db, "proj-1"),
(error) =>
error instanceof ProjectError && /还有子项目/.test(error.message),
);
assert.equal(db.projects.length, 2);
await deleteProject(db, "proj-2");
await deleteProject(db, "proj-1");
assert.equal(db.projects.length, 0);
});
test("getProjectDetail returns subprojects and aggregated push tasks", async () => {
const db = fakeProjectDb({
projects: [
projectRow({ id: "proj-1", name: "一级" }),
projectRow({ id: "proj-2", name: "二级", parent_id: "proj-1", main_chat_id: "pgc2" }),
projectRow({ id: "proj-3", name: "三级", parent_id: "proj-2" }),
projectRow({ id: "proj-other", name: "无关项目" }),
],
tasks: [
{ id: "t1", name: "直属任务", project_id: "proj-1" },
{ id: "t2", name: "子项目任务", project_id: "proj-2" },
{ id: "t3", name: "孙项目任务", project_id: "proj-3" },
{ id: "t4", name: "无关任务", project_id: "proj-other" },
],
});
const detail = await getProjectDetail(db, "proj-1");
assert.equal(detail.project.parentProjectId, "");
assert.deepEqual(detail.tasks.map((task) => task.id), ["t1"]);
assert.deepEqual(
detail.subprojects.map((sub) => sub.id),
["proj-2"],
);
assert.equal(detail.subprojects[0].mainChatId, "pgc2");
assert.equal(detail.subprojects[0].taskCount, 1);
assert.deepEqual(
detail.pushTasks.map((task) => `${task.project_name}:${task.name}`).sort(),
["一级:直属任务", "三级:孙项目任务", "二级:子项目任务"],
);
});

View File

@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
bindTaskCustomerChat,
listOwnerCustomerChats,
unbindTaskCustomerChat,
} from "../lib/task-customer-chat-service.ts";
import { resolveWecomConfig } from "../lib/wecom-client.ts";
@@ -91,14 +90,6 @@ function callsTo(calls, suffix) {
return calls.filter((c) => c.url.pathname.endsWith(suffix));
}
test("listOwnerCustomerChats rejects without app credentials and ignores empty owner", async () => {
const config = resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" });
await assert.rejects(() => listOwnerCustomerChats(config, "zhangsan"), /未配置/);
const { calls, fetchImpl } = fakeWecom();
assert.deepEqual(await listOwnerCustomerChats(resolveWecomConfig(baseBindings), " ", fetchImpl), []);
assert.equal(calls.length, 0, "empty owner short-circuits without API calls");
});
test("bindTaskCustomerChat creates join way when task has no config", async () => {
const { db, captured } = captureDb({
id: "task-1",

View File

@@ -1,192 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildTaskReportCsv } from "../lib/task-report-csv.ts";
import { getTaskReportData } from "../lib/mvp-db.ts";
const reportRow = {
id: "task-A",
name: "夏日种草计划",
task_type: "content_publish",
brand: "轻食品牌",
platform: "小红书",
status: "active",
due_at: "2026-08-20",
created_at: "2026-08-01 10:00:00",
quantity: 20,
project_name: "八月投放",
claimed_count: 10,
published_count: 8,
completed_count: 5,
exposure_total: 12000,
views_total: 9000,
d2_likes: 100,
d2_comments: 20,
d2_collects: 30,
d5_likes: 150,
d5_comments: 25,
d5_collects: 40,
d7_likes: 180,
d7_comments: 30,
d7_collects: 50,
latest_likes: 200,
latest_comments: 40,
latest_collects: 60,
latest_shares: 10,
related_task_id: "",
related_task_name: null,
reverse_related_task_id: null,
reverse_related_task_name: null,
submitted_count: 0,
};
test("buildTaskReportCsv writes BOM, headers and mapped rows", () => {
const csv = buildTaskReportCsv([reportRow]);
assert.ok(csv.startsWith(""), "starts with UTF-8 BOM");
const lines = csv.slice(1).split("\r\n");
assert.equal(lines.length, 2);
assert.equal(
lines[0],
[
"任务ID",
"任务名称",
"任务类型",
"品牌",
"平台",
"所属项目",
"任务状态",
"计划数量",
"领取数",
"发布数",
"完成数",
"完成率",
"截止日期",
"创建时间",
"曝光量合计",
"阅读量合计",
"D2点赞",
"D2评论",
"D2收藏",
"D5点赞",
"D5评论",
"D5收藏",
"D7点赞",
"D7评论",
"D7收藏",
"最新点赞",
"最新评论",
"最新收藏",
"最新转发",
"关联任务",
"截图提交数",
].join(","),
);
assert.equal(
lines[1],
[
"task-A",
"夏日种草计划",
"内容发布",
"轻食品牌",
"小红书",
"八月投放",
"进行中",
20,
10,
8,
5,
"50%",
"2026-08-20",
"2026-08-01 10:00:00",
12000,
9000,
100,
20,
30,
150,
25,
40,
180,
30,
50,
200,
40,
60,
10,
"",
"",
].join(","),
);
});
test("buildTaskReportCsv escapes commas and quotes, blanks null metrics", () => {
const csv = buildTaskReportCsv([
{
...reportRow,
name: '含,逗号"引号"任务',
project_name: null,
claimed_count: 0,
exposure_total: null,
views_total: null,
d2_likes: null,
d2_comments: null,
d2_collects: null,
d5_likes: null,
d5_comments: null,
d5_collects: null,
d7_likes: null,
d7_comments: null,
d7_collects: null,
latest_likes: null,
latest_comments: null,
latest_collects: null,
latest_shares: null,
},
]);
const rowLine = csv.slice(1).split("\r\n")[1];
assert.ok(rowLine.includes('"含,逗号""引号""任务"'));
const cells = rowLine.split(",");
assert.equal(cells.at(-1), "", "content publish row leaves screenshot count empty");
assert.equal(cells.at(-2), "", "no related task leaves empty");
assert.equal(cells.at(-20), "", "no claims means empty completion rate");
assert.equal(cells.at(-18), "2026-08-01 10:00:00");
});
test("buildTaskReportCsv writes related batch and screenshot submission for collect tasks", () => {
const csv = buildTaskReportCsv([
{
...reportRow,
task_type: "screenshot_collect",
related_task_id: "task-B",
related_task_name: "夏日种草计划",
submitted_count: 7,
claimed_count: 12,
},
]);
const cells = csv.slice(1).split("\r\n")[1].split(",");
assert.equal(cells.at(-2), "夏日种草计划");
assert.equal(cells.at(-1), "7");
});
test("getTaskReportData aggregates per task via distributions join", async () => {
let capturedSql = "";
const db = {
prepare(sql) {
capturedSql = sql;
return {
async all() {
return { results: [reportRow] };
},
};
},
};
const rows = await getTaskReportData(db);
assert.deepEqual(rows, [reportRow]);
assert.match(capturedSql, /FROM tasks t/);
assert.match(capturedSql, /LEFT JOIN distributions d ON d\.task_id = t\.id/);
assert.match(capturedSql, /LEFT JOIN projects p ON p\.id = t\.project_id/);
assert.match(capturedSql, /GROUP BY t\.id/);
assert.match(capturedSql, /publish_url/);
assert.match(capturedSql, /latest_shares/);
assert.match(capturedSql, /related_task_id/);
assert.match(capturedSql, /result_submitted_at/);
});

View File

@@ -539,6 +539,32 @@ test("listCustomerGroupChats paginates groupchat/list and keeps only normal grou
assert.equal(secondBody.cursor, "page-2");
});
test("listCustomerGroupChats derives memberCount from member_list when member_count absent", async () => {
const { fetchImpl } = fakeGroupPush({
pages: [{ next_cursor: "", group_chat_list: [{ chat_id: "wr-normal-1", status: 0 }] }],
details: {
"wr-normal-1": {
name: "",
owner: "zhangsan",
member_list: [
{ type: 1, userid: "zhangsan" },
{ type: 1, userid: "lisi" },
{ type: 2, userid: "wm external 1" },
{ type: 2, userid: "wm external 2" },
{ type: 2, userid: "wm external 3" },
],
},
},
});
const groups = await listCustomerGroupChats(
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.equal(groups.length, 1);
assert.equal(groups[0].name, "");
assert.equal(groups[0].memberCount, 5, "member_count falls back to member_list length");
});
test("listCustomerGroupChats skips groups whose detail lookup fails", async () => {
const { fetchImpl } = fakeGroupPush({
pages: [