- 项目支持父子层级:树形折叠展示、详情面板内联管理总群成员、推送聚合子项目任务、环检测与子项目删除守卫 - 外部KOC合作方绑定 KOC 资源账号(唯一占用、换绑/解绑、资源页一键设为合作方),列表直显联系方式 - 绑定客户群改为全量外部群缓存(含各群主),同步按钮强刷;member_count 缺失以 member_list 兜底 - 任务/项目编辑模式隐藏不可变字段(平台/内容类型/品牌/总群成员),详情面板维护成员时保留 parent_id - 删除复盘报表整栏(导航/页面/task_report_list/导出路由/CSV lib 及测试)
1251 lines
40 KiB
JavaScript
1251 lines
40 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
addTaskJoinWay,
|
|
buildWecomOAuthUrl,
|
|
clearWecomAccessTokenCacheForTests,
|
|
createAppChat,
|
|
createGroupMsgTemplate,
|
|
deleteJoinWay,
|
|
exchangeWecomOAuthCode,
|
|
fetchWecomExternalContactName,
|
|
getAppChat,
|
|
getJoinWayQrCode,
|
|
listCustomerGroupChats,
|
|
listExternalContacts,
|
|
listInternalContacts,
|
|
resolveWecomConfig,
|
|
sendAppChatMessage,
|
|
sendWecomAppMessage,
|
|
sendWecomRobotMessage,
|
|
updateAppChat,
|
|
updateTaskJoinWay,
|
|
WecomClientError,
|
|
} from "../lib/wecom-client.ts";
|
|
|
|
const baseBindings = {
|
|
WECOM_CORP_ID: "corp-test",
|
|
WECOM_AGENT_ID: "1000001",
|
|
WECOM_SECRET: "secret-test",
|
|
WECOM_ROBOT_WEBHOOK:
|
|
"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=robot-test",
|
|
WECOM_NOTIFY_DUE_DAYS: "3",
|
|
};
|
|
|
|
function fakeWecom(options = {}) {
|
|
const calls = [];
|
|
const fetchImpl = async (input, init = {}) => {
|
|
const url = new URL(String(input));
|
|
calls.push({ url, init });
|
|
if (url.pathname.endsWith("/cgi-bin/gettoken")) {
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
access_token: "token-test",
|
|
expires_in: 7200,
|
|
});
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/webhook/send")) {
|
|
return Response.json({
|
|
errcode: options.robotErrcode ?? 0,
|
|
errmsg: options.robotErrmsg ?? "ok",
|
|
});
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/message/send")) {
|
|
const body = JSON.parse(String(init.body ?? "{}"));
|
|
const invalid = options.invalidUser
|
|
? body.touser.split("|").filter((u) => u === options.invalidUser)
|
|
: [];
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
invaliduser: invalid.join("|"),
|
|
});
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/department/list")) {
|
|
if (options.departmentErrcode) {
|
|
return Response.json({
|
|
errcode: options.departmentErrcode,
|
|
errmsg: "not allow to access from your ip",
|
|
});
|
|
}
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
department: options.departments ?? [
|
|
{ id: 1, name: "总办" },
|
|
{ id: 2, name: "运营部" },
|
|
],
|
|
});
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/user/simplelist")) {
|
|
const departmentId = url.searchParams.get("department_id") ?? "";
|
|
const cursor = url.searchParams.get("cursor") ?? "";
|
|
const table = options.departmentUsers ?? {
|
|
"1": [
|
|
{ userid: "zhangsan", name: "张三", department: [1, 2] },
|
|
{ userid: "lisi", name: "李四", department: [1] },
|
|
],
|
|
"2": [
|
|
{ userid: "zhangsan", name: "张三", department: [1, 2] },
|
|
{ userid: "wangwu", name: "王五", department: [2] },
|
|
],
|
|
};
|
|
if (options.paginateSimplelist && departmentId === "1") {
|
|
if (!cursor) {
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
userlist: table[departmentId] ?? [],
|
|
next_cursor: "cursor-2",
|
|
});
|
|
}
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
userlist: [{ userid: "pagetwo", name: "第二页", department: [1] }],
|
|
});
|
|
}
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
userlist: table[departmentId] ?? [],
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
};
|
|
return { calls, fetchImpl };
|
|
}
|
|
|
|
test.beforeEach(() => {
|
|
clearWecomAccessTokenCacheForTests();
|
|
});
|
|
|
|
test("resolveWecomConfig applies dueDays and trims values", () => {
|
|
const config = resolveWecomConfig({
|
|
WECOM_CORP_ID: " corp ",
|
|
WECOM_AGENT_ID: " 10 ",
|
|
WECOM_SECRET: " s ",
|
|
WECOM_ROBOT_WEBHOOK: " https://hook ",
|
|
WECOM_NOTIFY_DUE_DAYS: "5",
|
|
});
|
|
assert.equal(config.corpId, "corp");
|
|
assert.equal(config.agentId, "10");
|
|
assert.equal(config.secret, "s");
|
|
assert.equal(config.robotWebhook, "https://hook");
|
|
assert.equal(config.dueDays, 5);
|
|
});
|
|
|
|
test("resolveWecomConfig falls back to default dueDays for bad input", () => {
|
|
assert.equal(resolveWecomConfig({ WECOM_NOTIFY_DUE_DAYS: "0" }).dueDays, 3);
|
|
assert.equal(resolveWecomConfig({ WECOM_NOTIFY_DUE_DAYS: "abc" }).dueDays, 3);
|
|
assert.equal(
|
|
resolveWecomConfig({ WECOM_NOTIFY_DUE_DAYS: "100" }).dueDays,
|
|
30,
|
|
);
|
|
});
|
|
|
|
test("sendWecomRobotMessage posts text payload and returns void", async () => {
|
|
const { calls, fetchImpl } = fakeWecom();
|
|
await sendWecomRobotMessage(
|
|
"hello",
|
|
resolveWecomConfig(baseBindings),
|
|
fetchImpl,
|
|
);
|
|
const hookCall = calls.find((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/webhook/send"),
|
|
);
|
|
assert.ok(hookCall, "robot webhook was called");
|
|
const body = JSON.parse(String(hookCall.init.body));
|
|
assert.equal(body.msgtype, "text");
|
|
assert.equal(body.text.content, "hello");
|
|
});
|
|
|
|
test("sendWecomRobotMessage skips silently when webhook is empty", async () => {
|
|
const { calls, fetchImpl } = fakeWecom();
|
|
const config = resolveWecomConfig({ ...baseBindings, WECOM_ROBOT_WEBHOOK: "" });
|
|
await sendWecomRobotMessage("hello", config, fetchImpl);
|
|
assert.equal(
|
|
calls.filter((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/webhook/send"),
|
|
).length,
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("sendWecomRobotMessage throws WecomClientError on provider error", async () => {
|
|
const { fetchImpl } = fakeWecom({
|
|
robotErrcode: 93000,
|
|
robotErrmsg: "invalid webhook url",
|
|
});
|
|
await assert.rejects(
|
|
() =>
|
|
sendWecomRobotMessage(
|
|
"hello",
|
|
resolveWecomConfig(baseBindings),
|
|
fetchImpl,
|
|
),
|
|
(error) => {
|
|
assert.ok(error instanceof WecomClientError);
|
|
assert.equal(error.status, 502);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test("sendWecomAppMessage fetches access_token then sends to message/send", async () => {
|
|
const { calls, fetchImpl } = fakeWecom();
|
|
const result = await sendWecomAppMessage(
|
|
["user-a", "user-b"],
|
|
"催办内容",
|
|
resolveWecomConfig(baseBindings),
|
|
fetchImpl,
|
|
);
|
|
assert.equal(result.sent, 2);
|
|
assert.equal(result.failed, 0);
|
|
assert.equal(result.skipped, false);
|
|
const tokenCall = calls.find((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/gettoken"),
|
|
);
|
|
assert.ok(tokenCall, "gettoken was called");
|
|
const sendCall = calls.find((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/message/send"),
|
|
);
|
|
assert.ok(sendCall, "message/send was called");
|
|
assert.equal(sendCall.url.searchParams.get("access_token"), "token-test");
|
|
const body = JSON.parse(String(sendCall.init.body));
|
|
assert.equal(body.touser, "user-a|user-b");
|
|
assert.equal(body.agentid, 1000001);
|
|
assert.equal(body.text.content, "催办内容");
|
|
});
|
|
|
|
test("sendWecomAppMessage skips when no external user ids", async () => {
|
|
const { calls, fetchImpl } = fakeWecom();
|
|
const result = await sendWecomAppMessage(
|
|
[],
|
|
"催办",
|
|
resolveWecomConfig(baseBindings),
|
|
fetchImpl,
|
|
);
|
|
assert.equal(result.skipped, true);
|
|
assert.equal(
|
|
calls.filter((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/message/send"),
|
|
).length,
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("sendWecomAppMessage skips when corp credentials are missing", async () => {
|
|
const { calls, fetchImpl } = fakeWecom();
|
|
const result = await sendWecomAppMessage(
|
|
["user-a"],
|
|
"催办",
|
|
resolveWecomConfig({
|
|
...baseBindings,
|
|
WECOM_CORP_ID: "",
|
|
WECOM_AGENT_ID: "",
|
|
WECOM_SECRET: "",
|
|
}),
|
|
fetchImpl,
|
|
);
|
|
assert.equal(result.skipped, true);
|
|
assert.equal(
|
|
calls.filter((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/gettoken"),
|
|
).length,
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("sendWecomAppMessage reports failed when provider marks invaliduser", async () => {
|
|
const { fetchImpl } = fakeWecom({ invalidUser: "user-a" });
|
|
const result = await sendWecomAppMessage(
|
|
["user-a", "user-b"],
|
|
"催办",
|
|
resolveWecomConfig(baseBindings),
|
|
fetchImpl,
|
|
);
|
|
assert.equal(result.sent, 1);
|
|
assert.equal(result.failed, 1);
|
|
});
|
|
|
|
test("access_token is cached across calls", async () => {
|
|
const { calls, fetchImpl } = fakeWecom();
|
|
const config = resolveWecomConfig(baseBindings);
|
|
await sendWecomAppMessage(["u1"], "a", config, fetchImpl);
|
|
await sendWecomAppMessage(["u2"], "b", config, fetchImpl);
|
|
const tokenCalls = calls.filter((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/gettoken"),
|
|
);
|
|
assert.equal(tokenCalls.length, 1, "access_token cached for second call");
|
|
});
|
|
|
|
function fakeExternalContacts() {
|
|
const calls = [];
|
|
const fetchImpl = async (input, init = {}) => {
|
|
const url = new URL(String(input));
|
|
calls.push({ url, init });
|
|
if (url.pathname.endsWith("/cgi-bin/gettoken")) {
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
access_token: "token-test",
|
|
expires_in: 7200,
|
|
});
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/externalcontact/get_follow_user_list")) {
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
follow_user: [{ userid: "owner-a" }, { userid: "owner-b" }],
|
|
});
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/externalcontact/list")) {
|
|
const userid = url.searchParams.get("userid");
|
|
if (userid === "owner-a") {
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
external_userid: ["ext-1", "ext-2"],
|
|
});
|
|
}
|
|
if (userid === "owner-b") {
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
external_userid: ["ext-3"],
|
|
});
|
|
}
|
|
return Response.json({ errcode: 60020, errmsg: "user not found" });
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/externalcontact/get")) {
|
|
const eid = url.searchParams.get("external_userid");
|
|
if (eid === "ext-1") {
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
external_contact: {
|
|
external_userid: "ext-1",
|
|
name: "张三",
|
|
avatar: "https://example.com/a.png",
|
|
corp_fullname: "ACME 公司",
|
|
},
|
|
});
|
|
}
|
|
if (eid === "ext-2") {
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
external_contact: {
|
|
external_userid: "ext-2",
|
|
name: "李四",
|
|
avatar: "",
|
|
corp_fullname: "",
|
|
},
|
|
});
|
|
}
|
|
if (eid === "ext-3") {
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
external_contact: {
|
|
external_userid: "ext-3",
|
|
name: "王五",
|
|
avatar: "https://example.com/c.png",
|
|
corp_fullname: "Other 公司",
|
|
},
|
|
});
|
|
}
|
|
return Response.json({ errcode: 60111, errmsg: "not found" });
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
};
|
|
return { calls, fetchImpl };
|
|
}
|
|
|
|
test("listExternalContacts flattens follow_user → list → get into a single array", async () => {
|
|
const { fetchImpl } = fakeExternalContacts();
|
|
const contacts = await listExternalContacts(
|
|
resolveWecomConfig(baseBindings),
|
|
fetchImpl,
|
|
);
|
|
assert.equal(contacts.length, 3);
|
|
const first = contacts.find((c) => c.externalUserId === "ext-1");
|
|
assert.ok(first);
|
|
assert.equal(first.name, "张三");
|
|
assert.equal(first.avatar, "https://example.com/a.png");
|
|
assert.equal(first.corpName, "ACME 公司");
|
|
assert.equal(first.ownerUserId, "owner-a");
|
|
const second = contacts.find((c) => c.externalUserId === "ext-2");
|
|
assert.ok(second);
|
|
assert.equal(second.corpName, "李四");
|
|
const third = contacts.find((c) => c.externalUserId === "ext-3");
|
|
assert.ok(third);
|
|
assert.equal(third.ownerUserId, "owner-b");
|
|
});
|
|
|
|
test("listExternalContacts skips members with non-zero errcode", async () => {
|
|
const { fetchImpl } = fakeExternalContacts();
|
|
const contacts = await listExternalContacts(
|
|
resolveWecomConfig(baseBindings),
|
|
fetchImpl,
|
|
);
|
|
const ownerBIds = contacts
|
|
.filter((c) => c.ownerUserId === "owner-b")
|
|
.map((c) => c.externalUserId);
|
|
assert.deepEqual(ownerBIds, ["ext-3"]);
|
|
});
|
|
|
|
test("listExternalContacts returns empty array when app credentials missing", async () => {
|
|
const { calls, fetchImpl } = fakeExternalContacts();
|
|
const contacts = await listExternalContacts(
|
|
resolveWecomConfig({
|
|
...baseBindings,
|
|
WECOM_CORP_ID: "",
|
|
WECOM_AGENT_ID: "",
|
|
WECOM_SECRET: "",
|
|
}),
|
|
fetchImpl,
|
|
);
|
|
assert.deepEqual(contacts, []);
|
|
assert.equal(calls.length, 0, "no API calls without credentials");
|
|
});
|
|
|
|
function fakeGroupPush(options = {}) {
|
|
const calls = [];
|
|
const pages =
|
|
options.pages ??
|
|
[
|
|
{
|
|
next_cursor: "page-2",
|
|
group_chat_list: [
|
|
{ chat_id: "wr-normal-1", status: 0 },
|
|
{ chat_id: "wr-dissolved", status: 1 },
|
|
],
|
|
},
|
|
{ next_cursor: "", group_chat_list: [{ chat_id: "wr-normal-2", status: 0 }] },
|
|
];
|
|
const details =
|
|
options.details ??
|
|
{
|
|
"wr-normal-1": { name: "KOC 一群", owner: "zhangsan", member_count: 42 },
|
|
"wr-normal-2": { name: "KOC 二群", owner: "lisi", member_count: 7 },
|
|
};
|
|
const joinWay = {
|
|
addErrcode: options.joinAddErrcode ?? 0,
|
|
updateErrcode: options.joinUpdateErrcode ?? 0,
|
|
getErrcode: options.joinGetErrcode ?? 0,
|
|
delErrcode: options.joinDelErrcode ?? 0,
|
|
qrCode: options.joinQrCode ?? "https://p.qpic.cn/qr-test.png",
|
|
configId: options.joinConfigId ?? "cfgtest123",
|
|
};
|
|
const fetchImpl = async (input, init = {}) => {
|
|
const url = new URL(String(input));
|
|
calls.push({ url, init });
|
|
if (url.pathname.endsWith("/cgi-bin/gettoken")) {
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
access_token: "token-test",
|
|
expires_in: 7200,
|
|
});
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/list")) {
|
|
const body = JSON.parse(String(init.body ?? "{}"));
|
|
const index = body.cursor ? Number(body.cursor.replace("page-", "")) - 1 : 0;
|
|
const page = pages[index] ?? { next_cursor: "", group_chat_list: [] };
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
next_cursor: page.next_cursor,
|
|
group_chat_list: page.group_chat_list,
|
|
});
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/get")) {
|
|
const body = JSON.parse(String(init.body ?? "{}"));
|
|
const detail = details[body.chat_id];
|
|
if (!detail) {
|
|
return Response.json({ errcode: 60111, errmsg: "not found" });
|
|
}
|
|
return Response.json({ errcode: 0, errmsg: "ok", group_chat: detail });
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template")) {
|
|
return Response.json({
|
|
errcode: options.templateErrcode ?? 0,
|
|
errmsg: options.templateErrmsg ?? "ok",
|
|
msgid: options.msgid ?? "msgGTEST",
|
|
fail_list: options.failList ?? [],
|
|
});
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/add_join_way")) {
|
|
if (joinWay.addErrcode !== 0) {
|
|
return Response.json({ errcode: joinWay.addErrcode, errmsg: "add failed" });
|
|
}
|
|
return Response.json({ errcode: 0, errmsg: "ok", config_id: joinWay.configId });
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/get_join_way")) {
|
|
if (joinWay.getErrcode !== 0) {
|
|
return Response.json({ errcode: joinWay.getErrcode, errmsg: "get failed" });
|
|
}
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
join_way: { qr_code: joinWay.qrCode, scene: 2 },
|
|
});
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/update_join_way")) {
|
|
if (joinWay.updateErrcode !== 0) {
|
|
return Response.json({ errcode: joinWay.updateErrcode, errmsg: "update failed" });
|
|
}
|
|
return Response.json({ errcode: 0, errmsg: "ok" });
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/del_join_way")) {
|
|
if (joinWay.delErrcode !== 0) {
|
|
return Response.json({ errcode: joinWay.delErrcode, errmsg: "del failed" });
|
|
}
|
|
return Response.json({ errcode: 0, errmsg: "ok" });
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
};
|
|
return { calls, fetchImpl };
|
|
}
|
|
|
|
test("listCustomerGroupChats paginates groupchat/list and keeps only normal groups", async () => {
|
|
const { calls, fetchImpl } = fakeGroupPush();
|
|
const groups = await listCustomerGroupChats(
|
|
resolveWecomConfig(baseBindings),
|
|
fetchImpl,
|
|
);
|
|
assert.equal(groups.length, 2);
|
|
const first = groups.find((g) => g.chatId === "wr-normal-1");
|
|
assert.ok(first);
|
|
assert.equal(first.name, "KOC 一群");
|
|
assert.equal(first.ownerUserId, "zhangsan");
|
|
assert.equal(first.memberCount, 42);
|
|
const second = groups.find((g) => g.chatId === "wr-normal-2");
|
|
assert.ok(second);
|
|
assert.equal(second.ownerUserId, "lisi");
|
|
assert.equal(
|
|
groups.some((g) => g.chatId === "wr-dissolved"),
|
|
false,
|
|
"non-normal status groups are filtered",
|
|
);
|
|
const listCalls = calls.filter((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/list"),
|
|
);
|
|
assert.equal(listCalls.length, 2, "cursor pagination followed");
|
|
const secondBody = JSON.parse(String(listCalls[1].init.body));
|
|
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: [
|
|
{
|
|
next_cursor: "",
|
|
group_chat_list: [
|
|
{ chat_id: "wr-normal-1", status: 0 },
|
|
{ chat_id: "wr-gone", status: 0 },
|
|
],
|
|
},
|
|
],
|
|
details: {
|
|
"wr-normal-1": { name: "KOC 一群", owner: "zhangsan", member_count: 42 },
|
|
},
|
|
});
|
|
const groups = await listCustomerGroupChats(
|
|
resolveWecomConfig(baseBindings),
|
|
fetchImpl,
|
|
);
|
|
assert.deepEqual(
|
|
groups.map((g) => g.chatId),
|
|
["wr-normal-1"],
|
|
);
|
|
});
|
|
|
|
test("listCustomerGroupChats sends owner_filter when ownerUserids given", async () => {
|
|
const { calls, fetchImpl } = fakeGroupPush({
|
|
pages: [{ next_cursor: "", group_chat_list: [{ chat_id: "wr-normal-1", status: 0 }] }],
|
|
details: {
|
|
"wr-normal-1": { name: "KOC 一群", owner: "zhangsan", member_count: 42 },
|
|
},
|
|
});
|
|
const groups = await listCustomerGroupChats(
|
|
resolveWecomConfig(baseBindings),
|
|
fetchImpl,
|
|
["zhangsan", ""],
|
|
);
|
|
assert.equal(groups.length, 1);
|
|
const listCall = calls.find((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/list"),
|
|
);
|
|
const body = JSON.parse(String(listCall.init.body));
|
|
assert.deepEqual(body.owner_filter, { userid_list: ["zhangsan"] });
|
|
});
|
|
|
|
test("addTaskJoinWay posts scene/auto_create_room/payload and returns configId", async () => {
|
|
const { calls, fetchImpl } = fakeGroupPush();
|
|
const result = await addTaskJoinWay(
|
|
resolveWecomConfig(baseBindings),
|
|
{
|
|
chatIdList: ["wr-normal-1"],
|
|
roomBaseName: "每".repeat(50),
|
|
state: "task-task-baf7fd59",
|
|
},
|
|
fetchImpl,
|
|
);
|
|
assert.equal(result.configId, "cfgtest123");
|
|
const call = calls.find((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/add_join_way"),
|
|
);
|
|
assert.ok(call, "add_join_way was called");
|
|
assert.equal(call.url.searchParams.get("access_token"), "token-test");
|
|
const body = JSON.parse(String(call.init.body));
|
|
assert.equal(body.scene, 2);
|
|
assert.equal(body.auto_create_room, 1);
|
|
assert.deepEqual(body.chat_id_list, ["wr-normal-1"]);
|
|
assert.equal(body.room_base_name.length, 40, "room_base_name truncated to 40 code points");
|
|
assert.equal(body.state, "task-task-baf7fd59");
|
|
});
|
|
|
|
test("addTaskJoinWay surfaces upstream errcode as WecomClientError", async () => {
|
|
const { fetchImpl } = fakeGroupPush({ joinAddErrcode: 60011 });
|
|
await assert.rejects(
|
|
() =>
|
|
addTaskJoinWay(
|
|
resolveWecomConfig(baseBindings),
|
|
{ chatIdList: ["wr-normal-1"], roomBaseName: "群", state: "task-t1" },
|
|
fetchImpl,
|
|
),
|
|
(error) =>
|
|
error instanceof WecomClientError && /创建企业微信进群活码失败/.test(error.message),
|
|
);
|
|
});
|
|
|
|
test("getJoinWayQrCode returns qr_code url from join_way", async () => {
|
|
const { calls, fetchImpl } = fakeGroupPush({ joinQrCode: "https://p.qpic.cn/qr-x.png" });
|
|
const result = await getJoinWayQrCode(
|
|
resolveWecomConfig(baseBindings),
|
|
"cfgtest123",
|
|
fetchImpl,
|
|
);
|
|
assert.equal(result.qrCode, "https://p.qpic.cn/qr-x.png");
|
|
const call = calls.find((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/get_join_way"),
|
|
);
|
|
assert.deepEqual(JSON.parse(String(call.init.body)), { config_id: "cfgtest123" });
|
|
});
|
|
|
|
test("updateTaskJoinWay re-sends full body including scene for overwrite semantics", async () => {
|
|
const { calls, fetchImpl } = fakeGroupPush();
|
|
await updateTaskJoinWay(
|
|
resolveWecomConfig(baseBindings),
|
|
{
|
|
configId: "cfgtest123",
|
|
chatIdList: ["wr-normal-2"],
|
|
roomBaseName: "新群名",
|
|
state: "task-t2",
|
|
},
|
|
fetchImpl,
|
|
);
|
|
const call = calls.find((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/update_join_way"),
|
|
);
|
|
const body = JSON.parse(String(call.init.body));
|
|
assert.equal(body.config_id, "cfgtest123");
|
|
assert.equal(body.scene, 2);
|
|
assert.equal(body.auto_create_room, 1);
|
|
assert.deepEqual(body.chat_id_list, ["wr-normal-2"]);
|
|
assert.equal(body.room_base_name, "新群名");
|
|
});
|
|
|
|
test("deleteJoinWay tolerates upstream rejection but still calls del endpoint", async () => {
|
|
const { calls, fetchImpl } = fakeGroupPush({ joinDelErrcode: 40068 });
|
|
await deleteJoinWay(resolveWecomConfig(baseBindings), "cfgtest123", fetchImpl);
|
|
const call = calls.find((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/del_join_way"),
|
|
);
|
|
assert.ok(call, "del_join_way was called");
|
|
assert.deepEqual(JSON.parse(String(call.init.body)), { config_id: "cfgtest123" });
|
|
});
|
|
|
|
test("deleteJoinWay skips empty configId", async () => {
|
|
const { calls, fetchImpl } = fakeGroupPush();
|
|
await deleteJoinWay(resolveWecomConfig(baseBindings), " ", fetchImpl);
|
|
assert.equal(calls.length, 0, "no API calls for empty config id");
|
|
});
|
|
|
|
test("createGroupMsgTemplate posts group payload with text and link attachment", async () => {
|
|
const { calls, fetchImpl } = fakeGroupPush();
|
|
const result = await createGroupMsgTemplate(
|
|
resolveWecomConfig(baseBindings),
|
|
{
|
|
sender: "zhangsan",
|
|
chatIdList: ["wr-normal-1", "wr-normal-2"],
|
|
text: "【新任务】测试任务",
|
|
link: {
|
|
title: "测试任务",
|
|
desc: "品牌 A · 3 份",
|
|
url: "https://portal.example.com/?task=tok-1",
|
|
},
|
|
},
|
|
fetchImpl,
|
|
);
|
|
assert.equal(result.msgid, "msgGTEST");
|
|
assert.deepEqual(result.failList, []);
|
|
const sendCall = calls.find((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
|
|
);
|
|
assert.ok(sendCall, "add_msg_template was called");
|
|
assert.equal(sendCall.url.searchParams.get("access_token"), "token-test");
|
|
const body = JSON.parse(String(sendCall.init.body));
|
|
assert.equal(body.chat_type, "group");
|
|
assert.equal(body.sender, "zhangsan");
|
|
assert.deepEqual(body.chat_id_list, ["wr-normal-1", "wr-normal-2"]);
|
|
assert.equal(body.text.content, "【新任务】测试任务");
|
|
assert.equal(body.attachments.length, 1);
|
|
assert.equal(body.attachments[0].msgtype, "link");
|
|
assert.equal(body.attachments[0].link.url, "https://portal.example.com/?task=tok-1");
|
|
});
|
|
|
|
test("createGroupMsgTemplate omits text and attachments keys when absent", async () => {
|
|
const { calls, fetchImpl } = fakeGroupPush();
|
|
await createGroupMsgTemplate(
|
|
resolveWecomConfig(baseBindings),
|
|
{
|
|
sender: "zhangsan",
|
|
chatIdList: ["wr-normal-1"],
|
|
link: { title: "只有图文", url: "https://portal.example.com/?task=tok-1" },
|
|
},
|
|
fetchImpl,
|
|
);
|
|
const sendCall = calls.find((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
|
|
);
|
|
assert.ok(sendCall, "add_msg_template was called");
|
|
const body = JSON.parse(String(sendCall.init.body));
|
|
assert.equal("text" in body, false);
|
|
assert.equal(body.attachments[0].link.title, "只有图文");
|
|
assert.equal(body.attachments[0].link.desc, "");
|
|
});
|
|
|
|
test("createGroupMsgTemplate passes through fail_list from provider", async () => {
|
|
const { fetchImpl } = fakeGroupPush({
|
|
msgid: "msgFAIL",
|
|
failList: ["wr-normal-2"],
|
|
});
|
|
const result = await createGroupMsgTemplate(
|
|
resolveWecomConfig(baseBindings),
|
|
{
|
|
sender: "zhangsan",
|
|
chatIdList: ["wr-normal-1", "wr-normal-2"],
|
|
text: "内容",
|
|
},
|
|
fetchImpl,
|
|
);
|
|
assert.equal(result.msgid, "msgFAIL");
|
|
assert.deepEqual(result.failList, ["wr-normal-2"]);
|
|
});
|
|
|
|
test("createGroupMsgTemplate throws WecomClientError on provider error", async () => {
|
|
const { fetchImpl } = fakeGroupPush({
|
|
templateErrcode: 81053,
|
|
templateErrmsg: "user not in visible scope",
|
|
});
|
|
await assert.rejects(
|
|
() =>
|
|
createGroupMsgTemplate(
|
|
resolveWecomConfig(baseBindings),
|
|
{ sender: "zhangsan", chatIdList: ["wr-normal-1"], text: "内容" },
|
|
fetchImpl,
|
|
),
|
|
(error) => {
|
|
assert.ok(error instanceof WecomClientError);
|
|
assert.equal(error.status, 502);
|
|
assert.match(error.message, /user not in visible scope/);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test("createGroupMsgTemplate rejects empty text and link before calling API", async () => {
|
|
const { calls, fetchImpl } = fakeGroupPush();
|
|
await assert.rejects(
|
|
() =>
|
|
createGroupMsgTemplate(
|
|
resolveWecomConfig(baseBindings),
|
|
{ sender: "zhangsan", chatIdList: ["wr-normal-1"] },
|
|
fetchImpl,
|
|
),
|
|
(error) => {
|
|
assert.ok(error instanceof WecomClientError);
|
|
assert.equal(error.status, 400);
|
|
return true;
|
|
},
|
|
);
|
|
assert.equal(
|
|
calls.filter((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
|
|
).length,
|
|
0,
|
|
"no API call for invalid payload",
|
|
);
|
|
});
|
|
|
|
test("createGroupMsgTemplate rejects missing sender or empty chat list", async () => {
|
|
const { fetchImpl } = fakeGroupPush();
|
|
const config = resolveWecomConfig(baseBindings);
|
|
await assert.rejects(
|
|
() =>
|
|
createGroupMsgTemplate(config, {
|
|
sender: "",
|
|
chatIdList: ["wr-normal-1"],
|
|
text: "内容",
|
|
}, fetchImpl),
|
|
/群主/,
|
|
);
|
|
await assert.rejects(
|
|
() =>
|
|
createGroupMsgTemplate(config, {
|
|
sender: "zhangsan",
|
|
chatIdList: [],
|
|
text: "内容",
|
|
}, fetchImpl),
|
|
/客户群列表为空/,
|
|
);
|
|
});
|
|
|
|
function fakeWecomOAuth(options = {}) {
|
|
const calls = [];
|
|
const fetchImpl = async (input, init = {}) => {
|
|
const url = new URL(String(input));
|
|
calls.push({ url, init });
|
|
if (url.pathname.endsWith("/cgi-bin/gettoken")) {
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
access_token: "token-test",
|
|
expires_in: 7200,
|
|
});
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/auth/getuserinfo")) {
|
|
return Response.json({
|
|
errcode: options.oauthErrcode ?? 0,
|
|
errmsg: options.oauthErrmsg ?? "ok",
|
|
...(options.userId ? { userid: options.userId } : {}),
|
|
...(options.externalUserId ? { external_userid: options.externalUserId } : {}),
|
|
});
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/externalcontact/get")) {
|
|
const eid = url.searchParams.get("external_userid");
|
|
if (options.contactNames && options.contactNames[eid]) {
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
external_contact: { external_userid: eid, name: options.contactNames[eid] },
|
|
});
|
|
}
|
|
return Response.json({ errcode: 60111, errmsg: "not found" });
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
};
|
|
return { calls, fetchImpl };
|
|
}
|
|
|
|
test("buildWecomOAuthUrl assembles authorize URL with encoded params", () => {
|
|
const url = new URL(
|
|
buildWecomOAuthUrl({
|
|
corpId: "corp-test",
|
|
agentId: "1000001",
|
|
redirectUri: "https://portal.example.com/koc/?task=abc",
|
|
state: "t=abc",
|
|
}),
|
|
);
|
|
assert.equal(url.origin, "https://open.weixin.qq.com");
|
|
assert.equal(url.pathname, "/connect/oauth2/authorize");
|
|
assert.equal(url.hash, "#wechat_redirect");
|
|
assert.equal(url.searchParams.get("appid"), "corp-test");
|
|
assert.equal(url.searchParams.get("agentid"), "1000001");
|
|
assert.equal(url.searchParams.get("response_type"), "code");
|
|
assert.equal(url.searchParams.get("scope"), "snsapi_base");
|
|
assert.equal(url.searchParams.get("state"), "t=abc");
|
|
assert.equal(
|
|
url.searchParams.get("redirect_uri"),
|
|
"https://portal.example.com/koc/?task=abc",
|
|
);
|
|
});
|
|
|
|
test("exchangeWecomOAuthCode returns external identity and posts code", async () => {
|
|
const { calls, fetchImpl } = fakeWecomOAuth({ externalUserId: "wmExt123" });
|
|
const identity = await exchangeWecomOAuthCode(
|
|
resolveWecomConfig(baseBindings),
|
|
"code-abc",
|
|
fetchImpl,
|
|
);
|
|
assert.deepEqual(identity, { externalUserId: "wmExt123" });
|
|
const call = calls.find((c) =>
|
|
c.url.pathname.endsWith("/cgi-bin/auth/getuserinfo"),
|
|
);
|
|
assert.ok(call, "getuserinfo was called");
|
|
assert.equal(call.url.searchParams.get("access_token"), "token-test");
|
|
assert.equal(JSON.parse(String(call.init.body)).code, "code-abc");
|
|
});
|
|
|
|
test("exchangeWecomOAuthCode returns internal member userid", async () => {
|
|
const { fetchImpl } = fakeWecomOAuth({ userId: "zhangsan" });
|
|
const identity = await exchangeWecomOAuthCode(
|
|
resolveWecomConfig(baseBindings),
|
|
"code-abc",
|
|
fetchImpl,
|
|
);
|
|
assert.deepEqual(identity, { userId: "zhangsan" });
|
|
});
|
|
|
|
test("exchangeWecomOAuthCode throws 400 on invalid code (errcode 40029)", async () => {
|
|
const { fetchImpl } = fakeWecomOAuth({
|
|
oauthErrcode: 40029,
|
|
oauthErrmsg: "invalid code",
|
|
});
|
|
await assert.rejects(
|
|
() =>
|
|
exchangeWecomOAuthCode(
|
|
resolveWecomConfig(baseBindings),
|
|
"code-expired",
|
|
fetchImpl,
|
|
),
|
|
(error) => {
|
|
assert.ok(error instanceof WecomClientError);
|
|
assert.equal(error.status, 400);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test("exchangeWecomOAuthCode throws when provider returns no identity", async () => {
|
|
const { fetchImpl } = fakeWecomOAuth();
|
|
await assert.rejects(
|
|
() =>
|
|
exchangeWecomOAuthCode(
|
|
resolveWecomConfig(baseBindings),
|
|
"code-abc",
|
|
fetchImpl,
|
|
),
|
|
(error) => {
|
|
assert.ok(error instanceof WecomClientError);
|
|
assert.equal(error.status, 400);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test("exchangeWecomOAuthCode reuses cached access_token", async () => {
|
|
const { calls, fetchImpl } = fakeWecomOAuth({ externalUserId: "wmExt123" });
|
|
const config = resolveWecomConfig(baseBindings);
|
|
await exchangeWecomOAuthCode(config, "code-1", fetchImpl);
|
|
await exchangeWecomOAuthCode(config, "code-2", fetchImpl);
|
|
assert.equal(
|
|
calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/gettoken")).length,
|
|
1,
|
|
);
|
|
});
|
|
|
|
test("fetchWecomExternalContactName resolves name and falls back to null", async () => {
|
|
const okImpl = fakeWecomOAuth({ contactNames: { wmExt123: "张三" } });
|
|
assert.equal(
|
|
await fetchWecomExternalContactName(
|
|
resolveWecomConfig(baseBindings),
|
|
"wmExt123",
|
|
okImpl.fetchImpl,
|
|
),
|
|
"张三",
|
|
);
|
|
const failImpl = fakeWecomOAuth();
|
|
assert.equal(
|
|
await fetchWecomExternalContactName(
|
|
resolveWecomConfig(baseBindings),
|
|
"wmMissing",
|
|
failImpl.fetchImpl,
|
|
),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
await fetchWecomExternalContactName(
|
|
resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }),
|
|
"wmExt123",
|
|
failImpl.fetchImpl,
|
|
),
|
|
null,
|
|
);
|
|
});
|
|
|
|
function fakeAppChat(options = {}) {
|
|
const calls = [];
|
|
const chats = new Map(
|
|
options.chats ?? [
|
|
["tgc_task-existing", { name: "【任务】已有群", userlist: ["owner-a"] }],
|
|
],
|
|
);
|
|
const createExisted = options.createExisted ?? false;
|
|
const fetchImpl = async (input, init = {}) => {
|
|
const url = new URL(String(input));
|
|
calls.push({ url, init });
|
|
if (url.pathname.endsWith("/cgi-bin/gettoken")) {
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
access_token: "token-test",
|
|
expires_in: 7200,
|
|
});
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/appchat/create")) {
|
|
if (createExisted) {
|
|
return Response.json({ errcode: 86215, errmsg: "chatid existed" });
|
|
}
|
|
const body = JSON.parse(String(init.body ?? "{}"));
|
|
chats.set(body.chatid, { name: body.name, userlist: body.userlist });
|
|
return Response.json({ errcode: 0, errmsg: "ok", chatid: body.chatid });
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/appchat/update")) {
|
|
const body = JSON.parse(String(init.body ?? "{}"));
|
|
const chat = chats.get(body.chatid);
|
|
if (!chat) {
|
|
return Response.json({ errcode: 60111, errmsg: "chat not found" });
|
|
}
|
|
for (const userid of body.add_user_list ?? []) {
|
|
if (!chat.userlist.includes(userid)) chat.userlist.push(userid);
|
|
}
|
|
return Response.json({ errcode: 0, errmsg: "ok" });
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/appchat/get")) {
|
|
const body = JSON.parse(String(init.body ?? "{}"));
|
|
const chat = chats.get(body.chatid);
|
|
if (!chat) {
|
|
return Response.json({ errcode: 60111, errmsg: "chat not found" });
|
|
}
|
|
return Response.json({
|
|
errcode: 0,
|
|
errmsg: "ok",
|
|
chat_info: {
|
|
chatid: body.chatid,
|
|
name: chat.name,
|
|
userlist: chat.userlist,
|
|
},
|
|
});
|
|
}
|
|
if (url.pathname.endsWith("/cgi-bin/appchat/send")) {
|
|
const body = JSON.parse(String(init.body ?? "{}"));
|
|
if (!chats.has(body.chatid)) {
|
|
return Response.json({ errcode: 60111, errmsg: "chat not found" });
|
|
}
|
|
return Response.json({ errcode: 0, errmsg: "ok" });
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
};
|
|
return { calls, fetchImpl, chats };
|
|
}
|
|
|
|
test("createAppChat posts name/userlist/chatid and returns chatid", async () => {
|
|
const { calls, fetchImpl } = fakeAppChat();
|
|
const result = await createAppChat(
|
|
resolveWecomConfig(baseBindings),
|
|
{ name: "【任务】任务名超长会被截断到二十个字符", userlist: ["owner-a", "owner-b"], chatid: "tgcnewchat" },
|
|
fetchImpl,
|
|
);
|
|
assert.equal(result.chatid, "tgcnewchat");
|
|
const call = calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"));
|
|
assert.ok(call, "appchat/create was called");
|
|
assert.equal(call.url.searchParams.get("access_token"), "token-test");
|
|
const body = JSON.parse(String(call.init.body));
|
|
assert.equal(body.chatid, "tgcnewchat");
|
|
assert.equal(body.userlist.length, 2);
|
|
assert.ok(body.name.length <= 20, "chat name truncated to 20 chars");
|
|
});
|
|
|
|
test("createAppChat rejects fewer than 2 members and oversized chatid", async () => {
|
|
const { calls, fetchImpl } = fakeAppChat();
|
|
const config = resolveWecomConfig(baseBindings);
|
|
await assert.rejects(
|
|
() => createAppChat(config, { name: "x", userlist: ["only"], chatid: "tgc_t" }, fetchImpl),
|
|
/至少需要 2 名成员/,
|
|
);
|
|
await assert.rejects(
|
|
() =>
|
|
createAppChat(
|
|
config,
|
|
{ name: "x", userlist: ["a", "b"], chatid: "c".repeat(33) },
|
|
fetchImpl,
|
|
),
|
|
/32 字符/,
|
|
);
|
|
assert.equal(calls.length, 0, "no API calls for invalid payload");
|
|
});
|
|
|
|
test("createAppChat treats chatid existed (86215) as success", async () => {
|
|
const { fetchImpl } = fakeAppChat({ createExisted: true });
|
|
const result = await createAppChat(
|
|
resolveWecomConfig(baseBindings),
|
|
{ name: "【总群】项目一", userlist: ["a", "b"], chatid: "pgcproj1" },
|
|
fetchImpl,
|
|
);
|
|
assert.equal(result.chatid, "pgcproj1");
|
|
});
|
|
|
|
test("updateAppChat adds and removes members", async () => {
|
|
const { calls, fetchImpl, chats } = fakeAppChat();
|
|
await updateAppChat(
|
|
resolveWecomConfig(baseBindings),
|
|
{ chatid: "tgc_task-existing", addUserList: ["owner-b"], delUserList: [] },
|
|
fetchImpl,
|
|
);
|
|
const call = calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update"));
|
|
assert.ok(call, "appchat/update was called");
|
|
const body = JSON.parse(String(call.init.body));
|
|
assert.deepEqual(body.add_user_list, ["owner-b"]);
|
|
assert.deepEqual(chats.get("tgc_task-existing").userlist, ["owner-a", "owner-b"]);
|
|
});
|
|
|
|
test("updateAppChat skips the API call when there is nothing to change", async () => {
|
|
const { calls, fetchImpl } = fakeAppChat();
|
|
await updateAppChat(
|
|
resolveWecomConfig(baseBindings),
|
|
{ chatid: "tgc_task-existing", addUserList: [], delUserList: [] },
|
|
fetchImpl,
|
|
);
|
|
assert.equal(
|
|
calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update")).length,
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("getAppChat returns member list or null on error", async () => {
|
|
const { fetchImpl } = fakeAppChat();
|
|
const info = await getAppChat(
|
|
resolveWecomConfig(baseBindings),
|
|
"tgc_task-existing",
|
|
fetchImpl,
|
|
);
|
|
assert.equal(info.chatId, "tgc_task-existing");
|
|
assert.deepEqual(info.userlist, ["owner-a"]);
|
|
assert.equal(
|
|
await getAppChat(resolveWecomConfig(baseBindings), "tgcmissingchat", fetchImpl),
|
|
null,
|
|
);
|
|
});
|
|
|
|
test("sendAppChatMessage posts text payload to appchat/send", async () => {
|
|
const { calls, fetchImpl } = fakeAppChat();
|
|
await sendAppChatMessage(
|
|
resolveWecomConfig(baseBindings),
|
|
{ chatid: "tgc_task-existing", text: "【领取】张三 认领 5 份" },
|
|
fetchImpl,
|
|
);
|
|
const call = calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/send"));
|
|
assert.ok(call, "appchat/send was called");
|
|
const body = JSON.parse(String(call.init.body));
|
|
assert.equal(body.chatid, "tgc_task-existing");
|
|
assert.equal(body.msgtype, "text");
|
|
assert.equal(body.text.content, "【领取】张三 认领 5 份");
|
|
});
|
|
|
|
test("sendAppChatMessage throws WecomClientError when provider errors", async () => {
|
|
const { fetchImpl } = fakeAppChat({ chats: [] });
|
|
await assert.rejects(
|
|
() =>
|
|
sendAppChatMessage(
|
|
resolveWecomConfig(baseBindings),
|
|
{ chatid: "tgcmissingchat", text: "hello" },
|
|
fetchImpl,
|
|
),
|
|
(error) => {
|
|
assert.ok(error instanceof WecomClientError);
|
|
assert.equal(error.status, 502);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test("listInternalContacts merges departments, dedupes and maps dept names", async () => {
|
|
const { calls, fetchImpl } = fakeWecom();
|
|
const contacts = await listInternalContacts(
|
|
resolveWecomConfig(baseBindings),
|
|
fetchImpl,
|
|
);
|
|
assert.deepEqual(
|
|
contacts.map((contact) => `${contact.name}:${contact.userid}`),
|
|
["李四:lisi", "王五:wangwu", "张三:zhangsan"],
|
|
"deduped and sorted by name (zh)",
|
|
);
|
|
const zhangsan = contacts.find((contact) => contact.userid === "zhangsan");
|
|
assert.deepEqual(zhangsan.departmentNames.sort(), ["总办", "运营部"]);
|
|
const simplelistCalls = calls.filter((call) =>
|
|
call.url.pathname.endsWith("/cgi-bin/user/simplelist"),
|
|
);
|
|
assert.deepEqual(
|
|
simplelistCalls
|
|
.map((call) => call.url.searchParams.get("department_id"))
|
|
.sort(),
|
|
["1", "2"],
|
|
"one simplelist call per department",
|
|
);
|
|
assert.ok(
|
|
simplelistCalls.every((call) => call.url.searchParams.get("fetch_child") === "0"),
|
|
);
|
|
});
|
|
|
|
test("listInternalContacts follows simplelist cursor pages", async () => {
|
|
const { fetchImpl } = fakeWecom({ paginateSimplelist: true });
|
|
const contacts = await listInternalContacts(
|
|
resolveWecomConfig(baseBindings),
|
|
fetchImpl,
|
|
);
|
|
assert.ok(contacts.some((contact) => contact.userid === "pagetwo"));
|
|
});
|
|
|
|
test("listInternalContacts throws WecomClientError on department errcode", async () => {
|
|
const { fetchImpl } = fakeWecom({ departmentErrcode: 60020 });
|
|
await assert.rejects(
|
|
() => listInternalContacts(resolveWecomConfig(baseBindings), fetchImpl),
|
|
(error) =>
|
|
error instanceof WecomClientError && /部门列表/.test(error.message),
|
|
);
|
|
});
|
|
|
|
test("listInternalContacts rejects without app credentials", async () => {
|
|
const { fetchImpl } = fakeWecom();
|
|
await assert.rejects(
|
|
() =>
|
|
listInternalContacts(
|
|
resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }),
|
|
fetchImpl,
|
|
),
|
|
/未配置/,
|
|
);
|
|
});
|