import assert from "node:assert/strict"; import test from "node:test"; import { assignTaskOwner, 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 ?? [ ["tgctaskexisting", { name: "【任务】已有群", userlist: ["owner-a"] }], ], ); const failGet = options.failGet ?? 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")) { 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")) { if (failGet) { return Response.json({ errcode: 86001, errmsg: "chatid invalid" }); } 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, "tgctask1a2b3c4d"); assert.ok(!/-/.test(chatid), "hyphens rejected by wecom chatid rules"); 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, task_owner_userid: null, project_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, task_owner_userid: "owner-a", project_owner_userid: null, }, }); 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, task_owner_userid: "owner-a", project_owner_userid: null, }, }); const result = await ensureTaskChatForMembers( db, { taskId: "task-1a2b3c4d", addUserids: ["zhangsan"] }, resolveWecomConfig(baseBindings), wecom.fetchImpl, ); assert.equal(result.chatId, "tgctask1a2b3c4d"); 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] === "tgctask1a2b3c4d", ), "chat id persisted to tasks", ); }); test("ensureTaskChatForMembers dedupes project owner, task owner and claimants", async () => { const wecom = fakeWecom({ chats: [] }); const { db } = fakeDb({ task: { id: "task-1", name: "任务一", task_chat_id: null, task_owner_userid: "owner-a", project_owner_userid: "owner-a", }, }); const result = await ensureTaskChatForMembers( db, { taskId: "task-1", addUserids: ["owner-a", "zhangsan"] }, resolveWecomConfig(baseBindings), wecom.fetchImpl, ); assert.equal(result.chatId, "tgctask1"); 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"]); }); test("ensureTaskChatForMembers reuses stored chat and diffs members", async () => { const wecom = fakeWecom(); const { db, statements } = fakeDb({ task: { id: "task-existing", name: "已有任务", task_chat_id: "tgctaskexisting", task_owner_userid: "owner-a", project_owner_userid: null, }, }); const result = await ensureTaskChatForMembers( db, { taskId: "task-existing", addUserids: ["zhangsan"] }, resolveWecomConfig(baseBindings), wecom.fetchImpl, ); assert.equal(result.chatId, "tgctaskexisting"); 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 syncs add-only when get is unavailable", async () => { // 可见范围非根部门时 appchat/get 拿不到群成员,降级为全量只增同步 const wecom = fakeWecom({ failGet: true, chats: [["tgctaskexisting", { name: "【任务】已有群", userlist: ["owner-a"] }]], }); const { db } = fakeDb({ task: { id: "task-existing", name: "已有任务", task_chat_id: "tgctaskexisting", task_owner_userid: "owner-a", project_owner_userid: null, }, }); const result = await ensureTaskChatForMembers( db, { taskId: "task-existing", addUserids: ["zhangsan"] }, resolveWecomConfig(baseBindings), wecom.fetchImpl, ); assert.equal(result.chatId, "tgctaskexisting"); 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.sort(), ["owner-a", "zhangsan"]); assert.equal(updateBody.del_user_list, undefined, "add-only fallback"); }); 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, task_owner_userid: "owner-a", project_owner_userid: null, }, }); const result = await ensureTaskChatForMembers( db, { taskId: "task-existing", addUserids: ["zhangsan"] }, resolveWecomConfig(baseBindings), wecom.fetchImpl, ); assert.equal(result.chatId, "tgctaskexisting"); 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, task_owner_userid: "owner-a", project_owner_userid: null, }, }); 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: "tgctaskexisting" }, }); 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: "tgctaskexisting" }, }); 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, "tgctaskexisting"); 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/); }); function assignDb({ task, project, partners = [] }) { const store = { task: { ...task }, project: project ? { ...project } : null, partners: partners.map((item) => ({ ...item })), }; const statements = []; const db = { prepare(sql) { return { bind(...values) { return { async first() { statements.push({ sql, values }); if (/FROM tasks t\s+LEFT JOIN projects p/.test(sql)) { return { id: store.task.id, name: store.task.name, task_chat_id: store.task.task_chat_id ?? null, task_owner_userid: store.task.owner_userid ?? null, project_owner_userid: store.project?.owner_userid ?? null, }; } if (/SELECT task_chat_id FROM tasks/.test(sql)) { return { task_chat_id: store.task.task_chat_id ?? null }; } if (/SELECT id, name, brand, quantity, due_at, project_id FROM tasks/.test(sql)) { return { ...store.task }; } if (/SELECT main_group_member_ids, owner_userid FROM projects/.test(sql)) { return store.project ? { main_group_member_ids: store.project.main_group_member_ids, owner_userid: store.project.owner_userid ?? "", } : null; } if (/SELECT id, name, main_group_member_ids, owner_userid, main_chat_id FROM projects/.test(sql)) { return store.project ? { id: store.project.id, name: store.project.name, main_group_member_ids: store.project.main_group_member_ids, owner_userid: store.project.owner_userid ?? "", main_chat_id: store.project.main_chat_id ?? "", } : null; } if (/partner_type = 'group_owner' AND wecom_userid = \?/.test(sql)) { return ( store.partners.find( (item) => item.partner_type === "group_owner" && item.wecom_userid === values[0], ) ?? null ); } if (/FROM partners WHERE id = \?/.test(sql)) { return store.partners.find((item) => item.id === values[0]) ?? null; } return null; }, async run() { statements.push({ sql, values }); if (/UPDATE tasks SET owner_userid/.test(sql)) { store.task.owner_userid = values[0]; } else if (/UPDATE tasks SET task_chat_id/.test(sql)) { store.task.task_chat_id = values[0]; } else if (/UPDATE projects SET main_group_member_ids/.test(sql)) { if (store.project) store.project.main_group_member_ids = values[0]; } else if (/UPDATE projects SET main_chat_id/.test(sql)) { if (store.project) store.project.main_chat_id = values[0]; } else if (/INSERT INTO partners/.test(sql)) { store.partners.push({ id: values[0], name: values[1], wecom_userid: values[2], partner_type: "group_owner", }); } return { meta: { changes: 1, last_row_id: 0 } }; }, async all() { statements.push({ sql, values }); return { results: [] }; }, }; }, }; }, }; return { db, statements, store }; } test("assignTaskOwner provisions partner, main chat and task chat with intro", async () => { const wecom = fakeWecom({ chats: [] }); const { db, statements, store } = assignDb({ task: { id: "task-1", name: "任务一", brand: "品牌A", quantity: 10, due_at: "2026-09-30", project_id: "proj-1", owner_userid: "", task_chat_id: null, }, project: { id: "proj-1", name: "项目一", owner_userid: "proj-owner", main_group_member_ids: JSON.stringify(["proj-owner", "u1", "u2"]), main_chat_id: "", }, }); const result = await assignTaskOwner( db, { taskId: "task-1", ownerUserid: "owner-a" }, resolveWecomConfig(baseBindings), wecom.fetchImpl, ); assert.equal(result.ownerUserid, "owner-a"); assert.equal(result.chatCreated, true); assert.equal(result.chatId, "tgctask1"); assert.match(result.partnerId, /^partner-go-/); assert.equal(store.task.owner_userid, "owner-a"); assert.deepEqual( JSON.parse(store.project.main_group_member_ids).sort(), ["proj-owner", "u1", "u2", "owner-a"].sort(), "task owner merged into main group members", ); assert.equal(store.project.main_chat_id, "pgcproj1", "main chat created lazily"); assert.ok(wecom.chats.has("pgcproj1")); assert.ok(wecom.chats.has("tgctask1")); const sendBodies = wecom.calls .filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/send")) .map((c) => JSON.parse(String(c.init.body))); assert.equal(sendBodies.length, 1, "intro message sent once to task chat"); assert.equal(sendBodies[0].chatid, "tgctask1"); assert.match(sendBodies[0].text.content, /【新任务】任务一/); assert.ok( statements.some((s) => /INSERT INTO partners/.test(s.sql)), "partner insert recorded", ); }); test("assignTaskOwner reuses the existing group_owner partner", async () => { const wecom = fakeWecom({ chats: [] }); const { db, statements, store } = assignDb({ task: { id: "task-1", name: "任务一", brand: "品牌A", quantity: 10, due_at: "2026-09-30", project_id: "", owner_userid: "", task_chat_id: null, }, partners: [ { id: "partner-go-existing", name: "老负责人", wecom_userid: "owner-a", partner_type: "group_owner" }, ], }); const result = await assignTaskOwner( db, { taskId: "task-1", ownerUserid: "owner-a" }, resolveWecomConfig(baseBindings), wecom.fetchImpl, ); assert.equal(result.partnerId, "partner-go-existing"); assert.equal( statements.filter((s) => /INSERT INTO partners/.test(s.sql)).length, 0, "no duplicate partner insert", ); assert.equal(store.partners.length, 1); }); test("assignTaskOwner collects wecom failure as warning instead of throwing", async () => { const fetchImpl = async (input) => { const url = new URL(String(input)); if (url.pathname.endsWith("/cgi-bin/gettoken")) { return Response.json({ errcode: 0, errmsg: "ok", access_token: "t", expires_in: 7200 }); } return Response.json({ errcode: 60111, errmsg: "chat not found" }); }; const { db, store } = assignDb({ task: { id: "task-1", name: "任务一", brand: "品牌A", quantity: 10, due_at: "2026-09-30", project_id: "proj-1", owner_userid: "", task_chat_id: null, }, project: { id: "proj-1", name: "项目一", owner_userid: "proj-owner", main_group_member_ids: JSON.stringify(["u1", "u2"]), main_chat_id: "", }, }); const result = await assignTaskOwner( db, { taskId: "task-1", ownerUserid: "owner-a" }, resolveWecomConfig(baseBindings), fetchImpl, ); assert.equal(result.ownerUserid, "owner-a"); assert.ok(result.warning, "wecom failure surfaced as warning"); assert.equal(store.task.owner_userid, "owner-a", "owner still persisted"); });