import assert from "node:assert/strict"; import test from "node:test"; import { bindTaskCustomerChat, unbindTaskCustomerChat, } from "../lib/task-customer-chat-service.ts"; import { resolveWecomConfig } from "../lib/wecom-client.ts"; const baseBindings = { WECOM_CORP_ID: "corp-test", WECOM_AGENT_ID: "1000001", WECOM_SECRET: "secret-test", }; 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/externalcontact/groupchat/add_join_way")) { if (options.addErrcode) { return Response.json({ errcode: options.addErrcode, errmsg: "add failed" }); } return Response.json({ errcode: 0, errmsg: "ok", config_id: options.configId ?? "cfg-new" }); } if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/update_join_way")) { if (options.updateErrcode) { return Response.json({ errcode: options.updateErrcode, errmsg: "update failed" }); } return Response.json({ errcode: 0, errmsg: "ok" }); } if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/get_join_way")) { if (options.getErrcode) { return Response.json({ errcode: options.getErrcode, errmsg: "get failed" }); } return Response.json({ errcode: 0, errmsg: "ok", join_way: { qr_code: options.qrCode ?? "https://p.qpic.cn/qr-live.png" }, }); } if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/del_join_way")) { if (options.delErrcode) { return Response.json({ errcode: options.delErrcode, errmsg: "del failed" }); } return Response.json({ errcode: 0, errmsg: "ok" }); } return new Response("not found", { status: 404 }); }; return { calls, fetchImpl }; } function captureDb(taskRow) { const captured = []; const makeStatement = (sql, values) => ({ async first() { captured.push({ sql, values }); if (/FROM tasks WHERE id = \?/.test(sql)) return taskRow ?? null; return null; }, async run() { captured.push({ sql, values }); return { changes: 1 }; }, }); const db = { prepare(sql) { const unbound = makeStatement(sql, []); return { bind(...values) { return makeStatement(sql, values); }, first: unbound.first, run: unbound.run, }; }, }; return { db, captured }; } function callsTo(calls, suffix) { return calls.filter((c) => c.url.pathname.endsWith(suffix)); } test("bindTaskCustomerChat creates join way when task has no config", async () => { const { db, captured } = captureDb({ id: "task-1", name: "美团探店任务", join_config_id: null, }); const { calls, fetchImpl } = fakeWecom(); const result = await bindTaskCustomerChat( db, { taskId: "task-1", chatId: "wr-normal-1", chatName: "KOC 一群" }, resolveWecomConfig(baseBindings), fetchImpl, ); assert.equal(callsTo(calls, "/cgi-bin/externalcontact/groupchat/add_join_way").length, 1); assert.equal(callsTo(calls, "/cgi-bin/externalcontact/groupchat/update_join_way").length, 0); const addBody = JSON.parse(String(callsTo(calls, "/add_join_way")[0].init.body)); assert.deepEqual(addBody.chat_id_list, ["wr-normal-1"]); assert.equal(addBody.room_base_name, "美团探店任务"); assert.equal(addBody.state, "task-task-1"); assert.equal(result.joinConfigId, "cfg-new"); assert.equal(result.joinQrCode, "https://p.qpic.cn/qr-live.png"); assert.equal(result.qrError, undefined); const update = captured.find((c) => /UPDATE tasks/.test(c.sql)); assert.ok(update, "tasks row updated"); assert.match(update.sql, /customer_chat_id = \?/); assert.deepEqual(update.values, ["wr-normal-1", "KOC 一群", "cfg-new", "https://p.qpic.cn/qr-live.png", "task-1"]); }); test("bindTaskCustomerChat reuses update_join_way on rebind and falls back to add", async () => { const { db } = captureDb({ id: "task-1", name: "任务", join_config_id: "cfg-old" }); const { calls, fetchImpl } = fakeWecom(); const result = await bindTaskCustomerChat( db, { taskId: "task-1", chatId: "wr-normal-2", chatName: "KOC 二群" }, resolveWecomConfig(baseBindings), fetchImpl, ); assert.equal(callsTo(calls, "/cgi-bin/externalcontact/groupchat/update_join_way").length, 1); assert.equal(callsTo(calls, "/cgi-bin/externalcontact/groupchat/add_join_way").length, 0); assert.equal(result.joinConfigId, "cfg-old", "config id preserved so qr stays valid"); const { db: db2 } = captureDb({ id: "task-1", name: "任务", join_config_id: "cfg-old" }); const { calls: calls2, fetchImpl: fetch2 } = fakeWecom({ updateErrcode: 40068, configId: "cfg-rebuilt", }); const result2 = await bindTaskCustomerChat( db2, { taskId: "task-1", chatId: "wr-normal-2", chatName: "KOC 二群" }, resolveWecomConfig(baseBindings), fetch2, ); assert.equal(callsTo(calls2, "/cgi-bin/externalcontact/groupchat/update_join_way").length, 1); assert.equal(callsTo(calls2, "/cgi-bin/externalcontact/groupchat/add_join_way").length, 1); assert.equal(result2.joinConfigId, "cfg-rebuilt"); }); test("bindTaskCustomerChat tolerates qr fetch failure and records qrError", async () => { const { db } = captureDb({ id: "task-1", name: "任务", join_config_id: null }); const { fetchImpl } = fakeWecom({ getErrcode: 40068 }); const result = await bindTaskCustomerChat( db, { taskId: "task-1", chatId: "wr-normal-1", chatName: "KOC 一群" }, resolveWecomConfig(baseBindings), fetchImpl, ); assert.equal(result.joinQrCode, ""); assert.match(result.qrError, /活码二维码获取失败/); }); test("bindTaskCustomerChat validates missing task / chat", async () => { const { db: missingTask } = captureDb(null); await assert.rejects( () => bindTaskCustomerChat( missingTask, { taskId: "task-x", chatId: "wr-1", chatName: "群" }, resolveWecomConfig(baseBindings), fakeWecom().fetchImpl, ), /任务不存在/, ); const { db } = captureDb({ id: "task-1", name: "任务", join_config_id: null }); await assert.rejects( () => bindTaskCustomerChat( db, { taskId: "task-1", chatId: "", chatName: "" }, resolveWecomConfig(baseBindings), fakeWecom().fetchImpl, ), /请选择要绑定的客户群/, ); }); test("unbindTaskCustomerChat deletes join way (tolerating failure) and clears columns", async () => { const { db, captured } = captureDb({ id: "task-1", join_config_id: "cfg-old" }); const { calls, fetchImpl } = fakeWecom(); await unbindTaskCustomerChat(db, "task-1", resolveWecomConfig(baseBindings), fetchImpl); assert.equal(callsTo(calls, "/cgi-bin/externalcontact/groupchat/del_join_way").length, 1); const update = captured.find((c) => /UPDATE tasks/.test(c.sql)); assert.ok(update); assert.match(update.sql, /customer_chat_id = ''/); const { db: db2, captured: captured2 } = captureDb({ id: "task-1", join_config_id: "cfg-old" }); const { fetchImpl: fetch2 } = fakeWecom({ delErrcode: 40068 }); await unbindTaskCustomerChat(db2, "task-1", resolveWecomConfig(baseBindings), fetch2); assert.ok( captured2.some((c) => /UPDATE tasks/.test(c.sql)), "local columns still cleared when del_join_way fails", ); const { db: db3, captured: captured3 } = captureDb({ id: "task-1", join_config_id: "" }); const { calls: calls3, fetchImpl: fetch3 } = fakeWecom(); await unbindTaskCustomerChat(db3, "task-1", resolveWecomConfig(baseBindings), fetch3); assert.equal(calls3.length, 0, "no wecom calls when nothing bound"); assert.ok(captured3.some((c) => /UPDATE tasks/.test(c.sql))); });