import assert from "node:assert/strict"; import test from "node:test"; import { ensureTaskChatForMembers, sendTaskChatMessage, taskChatId, taskClaimBroadcastText, taskChatIntroText, } from "../lib/task-group-service.ts"; import { clearWecomAccessTokenCacheForTests, resolveWecomConfig, } from "../lib/wecom-client.ts"; const baseBindings = { WECOM_CORP_ID: "corp-test", WECOM_AGENT_ID: "1000001", WECOM_SECRET: "secret-test", }; test.beforeEach(() => { clearWecomAccessTokenCacheForTests(); }); function fakeWecom(options = {}) { const calls = []; const chats = new Map( options.chats ?? [ ["tgc_task-existing", { name: "【任务】已有群", userlist: ["owner-a"] }], ], ); 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")) { 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 }; } function fakeDb({ task }) { const statements = []; const db = { prepare(sql) { return { bind(...values) { return { async first() { if (/FROM tasks t\s+LEFT JOIN projects p/.test(sql)) { return task; } if (/SELECT task_chat_id FROM tasks/.test(sql)) { return { task_chat_id: task.task_chat_id ?? null }; } return null; }, async run() { statements.push({ sql, values }); return { meta: { changes: 1, last_row_id: 0 } }; }, async all() { return { results: [] }; }, }; }, }; }, }; return { db, statements }; } test("taskChatId derives a deterministic chatid within 32 chars", () => { const taskId = "task-1a2b3c4d"; const chatid = taskChatId(taskId); assert.equal(chatid, `tgc_${taskId}`); assert.ok(chatid.length <= 32); }); test("ensureTaskChatForMembers skips when project has no owner", async () => { const wecom = fakeWecom(); const { db } = fakeDb({ task: { id: "task-1", name: "任务一", task_chat_id: null, owner_userid: null, }, }); const result = await ensureTaskChatForMembers( db, { taskId: "task-1", addUserids: ["zhangsan"] }, resolveWecomConfig(baseBindings), wecom.fetchImpl, ); assert.equal(result.chatId, null); assert.equal(result.reason, "no_owner"); assert.equal(wecom.calls.length, 0); }); test("ensureTaskChatForMembers skips creation when fewer than 2 members", async () => { const wecom = fakeWecom(); const { db } = fakeDb({ task: { id: "task-1", name: "任务一", task_chat_id: null, owner_userid: "owner-a", }, }); const result = await ensureTaskChatForMembers( db, { taskId: "task-1" }, resolveWecomConfig(baseBindings), wecom.fetchImpl, ); assert.equal(result.chatId, null); assert.equal(result.reason, "not_enough_members"); assert.equal( wecom.calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create")) .length, 0, ); }); test("ensureTaskChatForMembers creates the chat lazily and stores chat id", async () => { const wecom = fakeWecom({ chats: [] }); const { db, statements } = fakeDb({ task: { id: "task-1a2b3c4d", name: "任务一", task_chat_id: null, owner_userid: "owner-a", }, }); const result = await ensureTaskChatForMembers( db, { taskId: "task-1a2b3c4d", addUserids: ["zhangsan"] }, resolveWecomConfig(baseBindings), wecom.fetchImpl, ); assert.equal(result.chatId, "tgc_task-1a2b3c4d"); assert.equal(result.created, true); const createBody = JSON.parse( String( wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create")) .init.body, ), ); assert.deepEqual(createBody.userlist.sort(), ["owner-a", "zhangsan"]); assert.ok( statements.some( (statement) => /UPDATE tasks SET task_chat_id/.test(statement.sql) && statement.values[0] === "tgc_task-1a2b3c4d", ), "chat id persisted to tasks", ); }); test("ensureTaskChatForMembers reuses stored chat and diffs members", async () => { const wecom = fakeWecom(); const { db, statements } = fakeDb({ task: { id: "task-existing", name: "已有任务", task_chat_id: "tgc_task-existing", owner_userid: "owner-a", }, }); const result = await ensureTaskChatForMembers( db, { taskId: "task-existing", addUserids: ["zhangsan"] }, resolveWecomConfig(baseBindings), wecom.fetchImpl, ); assert.equal(result.chatId, "tgc_task-existing"); assert.equal(result.created, false); assert.equal( statements.filter((s) => /UPDATE tasks/.test(s.sql)).length, 0, "no redundant chat id update", ); const updateBody = JSON.parse( String( wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update")) .init.body, ), ); assert.deepEqual(updateBody.add_user_list, ["zhangsan"]); }); test("ensureTaskChatForMembers recovers when chat exists remotely but not in db", async () => { const wecom = fakeWecom(); const { db, statements } = fakeDb({ task: { id: "task-existing", name: "已有任务", task_chat_id: null, owner_userid: "owner-a", }, }); const result = await ensureTaskChatForMembers( db, { taskId: "task-existing", addUserids: ["zhangsan"] }, resolveWecomConfig(baseBindings), wecom.fetchImpl, ); assert.equal(result.chatId, "tgc_task-existing"); assert.equal(result.created, false); assert.ok(statements.some((s) => /UPDATE tasks SET task_chat_id/.test(s.sql))); }); test("ensureTaskChatForMembers skips without app credentials", async () => { const wecom = fakeWecom(); const { db } = fakeDb({ task: { id: "task-1", name: "任务一", task_chat_id: null, owner_userid: "owner-a", }, }); const result = await ensureTaskChatForMembers( db, { taskId: "task-1", addUserids: ["zhangsan"] }, resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }), wecom.fetchImpl, ); assert.equal(result.reason, "no_credentials"); assert.equal(wecom.calls.length, 0); }); test("sendTaskChatMessage no-ops without chat id and swallows errors", async () => { const wecom = fakeWecom(); const noChat = fakeDb({ task: { id: "task-1", task_chat_id: null }, }); assert.equal( await sendTaskChatMessage( noChat.db, "task-1", "hi", resolveWecomConfig(baseBindings), wecom.fetchImpl, ), false, ); assert.equal(wecom.calls.length, 0); const failing = fakeWecom({ chats: [] }); const withChat = fakeDb({ task: { id: "task-existing", task_chat_id: "tgc_task-existing" }, }); assert.equal( await sendTaskChatMessage( withChat.db, "task-existing", "hi", resolveWecomConfig(baseBindings), failing.fetchImpl, ), false, "provider error swallowed", ); }); test("sendTaskChatMessage sends text into the task chat", async () => { const wecom = fakeWecom(); const { db } = fakeDb({ task: { id: "task-existing", task_chat_id: "tgc_task-existing" }, }); assert.equal( await sendTaskChatMessage( db, "task-existing", "【领取】张三 认领 5 份", resolveWecomConfig(baseBindings), wecom.fetchImpl, ), true, ); const sendBody = JSON.parse( String( wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/send")) .init.body, ), ); assert.equal(sendBody.chatid, "tgc_task-existing"); assert.equal(sendBody.text.content, "【领取】张三 认领 5 份"); }); test("broadcast and intro text builders format messages", () => { assert.equal( taskClaimBroadcastText("任务一", "张三", 5), "【领取】张三 认领 5 份 · 任务一", ); const intro = taskChatIntroText({ name: "任务一", brand: "品牌A", quantity: 10, dueAt: "2026-09-30", }); assert.match(intro, /【新任务】任务一/); assert.match(intro, /品牌:品牌A/); assert.match(intro, /截止:2026-09-30/); });