Files
koc-loop/tests/wecom-client.test.mjs

1251 lines
40 KiB
JavaScript
Raw Permalink Normal View History

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,
),
/未配置/,
);
});