import assert from "node:assert/strict"; import test from "node:test"; import { clearWecomAccessTokenCacheForTests, createGroupMsgTemplate, listCustomerGroupChats, listExternalContacts, resolveWecomConfig, sendWecomAppMessage, sendWecomRobotMessage, 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("|"), }); } 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 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 ?? [], }); } 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 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("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), /客户群列表为空/, ); });