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