import assert from "node:assert/strict"; import test from "node:test"; import { buildProjectAppMessage, pushProjectTask, } from "../lib/project-push-service.ts"; import { ProjectError } from "../lib/project-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", }; const taskRow = { id: "task-1", name: "测试任务", brand: "品牌A", quantity: 3, due_at: "2026-09-01", task_type: "content_publish", platform: "小红书", content_format: "image_text", share_token: "tok-123", project_id: "proj-1", owner_userid: "owner-task", task_chat_id: null, }; const projectRow = { id: "proj-1", name: "项目一", brand: "品牌A", main_group_member_ids: JSON.stringify(["zhangsan", "lisi", "owner-proj"]), owner_userid: "owner-proj", main_chat_id: "", status: "active", note: "", created_at: "2026-08-01", }; function fakePushDb({ task = { ...taskRow }, project = { ...projectRow } } = {}) { const executed = []; const makeStatement = (sql, params = []) => ({ sql, params, bind(...args) { return makeStatement(sql, args); }, async run() { executed.push({ sql, params }); if (/UPDATE tasks SET task_chat_id/.test(sql)) { task.task_chat_id = params[0]; } return { meta: { changes: 1 } }; }, async first() { executed.push({ sql, params }); if (/FROM tasks t\s+LEFT JOIN projects p/.test(sql)) { return { id: task.id, name: task.name, task_chat_id: task.task_chat_id ?? null, task_owner_userid: task.owner_userid ?? null, project_owner_userid: project ? (project.owner_userid ?? null) : null, }; } if (/SELECT task_chat_id FROM tasks/.test(sql)) { return { task_chat_id: task.task_chat_id ?? null }; } if (/FROM tasks WHERE id = \?/.test(sql)) return task; if (/FROM projects WHERE id = \?/.test(sql)) return project; return null; }, async all() { executed.push({ sql, params }); if (/FROM tasks WHERE project_id/.test(sql)) { return { results: task ? [{ ...task }] : [] }; } return { results: [] }; }, }); return { executed, task, prepare(sql) { return makeStatement(sql); }, async batch(statements) { for (const statement of statements) await statement.run(); }, }; } function fakeWecomFetch(calls, chats, { failAppFor = [], failChatSend = [] } = {}) { return 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/message/send")) { const body = JSON.parse(String(init.body)); if (failAppFor.includes(body.touser)) { return Response.json({ errcode: 60111, errmsg: "userid not found" }); } return Response.json({ errcode: 0, errmsg: "ok" }); } 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/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/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/send")) { const body = JSON.parse(String(init.body ?? "{}")); if (failChatSend.includes(body.chatid) || !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 }); }; } test.beforeEach(() => { clearWecomAccessTokenCacheForTests(); }); test("buildProjectAppMessage targets internal members with claim url", () => { const message = buildProjectAppMessage(taskRow, "https://portal.example.com/"); assert.match(message, /【项目任务】测试任务/); assert.match(message, /请把任务转发到你负责的项目任务群/); assert.match(message, /领取链接:https:\/\/portal\.example\.com\/\?task=tok-123/); }); test("pushProjectTask broadcasts once into the main chat and sends the task chat", async () => { process.env.KOC_PORTAL_URL = "https://portal.example.com"; try { const db = fakePushDb({ project: { ...projectRow, main_chat_id: "pgcproj1" }, }); const chats = new Map([ ["pgcproj1", { name: "【总群】项目一", userlist: ["zhangsan", "lisi", "owner-proj"] }], ]); const calls = []; const outcome = await pushProjectTask( db, { taskId: "task-1", sendMainGroup: true, sendGroupChats: true }, resolveWecomConfig(baseBindings), fakeWecomFetch(calls, chats), ); assert.equal(outcome.mainChatMode, "group"); assert.deepEqual(outcome.appResults, [], "no per-member sends when chat exists"); const chatSends = calls.filter((call) => call.url.pathname.endsWith("/cgi-bin/appchat/send"), ); assert.deepEqual( chatSends.map((call) => JSON.parse(String(call.init.body)).chatid).sort(), ["pgcproj1", "tgctask1"], "one send into main chat and one into task chat", ); assert.equal(calls.filter((call) => call.url.pathname.endsWith("/cgi-bin/message/send")).length, 0); assert.deepEqual(outcome.skippedReasons, []); assert.equal(outcome.taskChat.sent, true); assert.equal(outcome.taskChat.chatId, "tgctask1"); assert.equal(outcome.linkUrl, "https://portal.example.com/?task=tok-123"); } finally { delete process.env.KOC_PORTAL_URL; } }); test("pushProjectTask falls back to per-member messages when main chat missing", async () => { process.env.KOC_PORTAL_URL = "https://portal.example.com"; try { const db = fakePushDb({ project: { ...projectRow, main_chat_id: "" } }); const chats = new Map(); const calls = []; const outcome = await pushProjectTask( db, { taskId: "task-1", sendMainGroup: true, sendGroupChats: true }, resolveWecomConfig(baseBindings), fakeWecomFetch(calls, chats), ); assert.equal(outcome.mainChatMode, "per_member"); assert.deepEqual( outcome.appResults.map((item) => item.userid).sort(), ["lisi", "owner-proj", "zhangsan"], ); assert.ok(outcome.appResults.every((item) => item.ok)); assert.ok(outcome.skippedReasons.includes("项目总群未建,已逐人私信")); assert.equal(outcome.taskChat.sent, true); } finally { delete process.env.KOC_PORTAL_URL; } }); test("single member failure does not block the rest of the per-member push", async () => { process.env.KOC_PORTAL_URL = "https://portal.example.com"; try { const db = fakePushDb({ project: { ...projectRow, main_chat_id: "" } }); const calls = []; const outcome = await pushProjectTask( db, { taskId: "task-1", sendMainGroup: true, sendGroupChats: false }, resolveWecomConfig(baseBindings), fakeWecomFetch(calls, new Map(), { failAppFor: ["zhangsan"] }), ); const byUserid = Object.fromEntries( outcome.appResults.map((item) => [item.userid, item.ok]), ); assert.equal(byUserid.zhangsan, false, "failed member recorded"); assert.equal(byUserid.lisi, true, "other members still sent"); assert.equal(byUserid["owner-proj"], true, "project owner also notified"); assert.match( outcome.appResults.find((item) => !item.ok).error, /userid not found/, ); } finally { delete process.env.KOC_PORTAL_URL; } }); test("main chat send failure degrades to per-member push", async () => { process.env.KOC_PORTAL_URL = "https://portal.example.com"; try { const db = fakePushDb({ project: { ...projectRow, main_chat_id: "pgcproj1" }, }); const chats = new Map([ ["pgcproj1", { name: "【总群】项目一", userlist: ["zhangsan"] }], ]); const calls = []; const outcome = await pushProjectTask( db, { taskId: "task-1", sendMainGroup: true, sendGroupChats: false }, resolveWecomConfig(baseBindings), fakeWecomFetch(calls, chats, { failChatSend: ["pgcproj1"] }), ); assert.equal(outcome.mainChatMode, "per_member"); assert.ok( outcome.skippedReasons.some((reason) => /降级逐人私信/.test(reason)), ); assert.equal(outcome.appResults.length, 3); } finally { delete process.env.KOC_PORTAL_URL; } }); test("task chat is skipped with reason when no owner at all", async () => { const db = fakePushDb({ task: { ...taskRow, owner_userid: "" }, project: { ...projectRow, owner_userid: "", main_group_member_ids: "[]" }, }); const calls = []; const outcome = await pushProjectTask( db, { taskId: "task-1", sendMainGroup: false, sendGroupChats: true }, resolveWecomConfig(baseBindings), fakeWecomFetch(calls, new Map()), ); assert.equal(outcome.taskChat.sent, false); assert.equal(outcome.taskChat.chatId, ""); assert.equal(outcome.taskChat.reason, "任务与项目都未填负责人"); assert.ok(outcome.skippedReasons.includes("任务群未发送:任务与项目都未填负责人")); assert.equal(calls.length, 0); }); test("task chat is skipped when fewer than 2 members", async () => { const db = fakePushDb({ task: { ...taskRow, owner_userid: "" }, project: { ...projectRow, owner_userid: "owner-proj", main_group_member_ids: "[]" }, }); const outcome = await pushProjectTask( db, { taskId: "task-1", sendMainGroup: false, sendGroupChats: true }, resolveWecomConfig(baseBindings), fakeWecomFetch([], new Map()), ); assert.equal(outcome.taskChat.reason, "任务群成员不足 2 人"); }); test("pushProjectTask rejects task without project or unknown task", async () => { const unlinked = fakePushDb({ task: { ...taskRow, project_id: "" } }); await assert.rejects( () => pushProjectTask( unlinked, { taskId: "task-1" }, resolveWecomConfig(baseBindings), fakeWecomFetch([], new Map()), ), (error) => error instanceof ProjectError && /未挂项目/.test(error.message), ); await assert.rejects( () => pushProjectTask(fakePushDb(), { taskId: "" }, resolveWecomConfig(baseBindings)), (error) => error instanceof ProjectError, ); await assert.rejects( () => pushProjectTask( fakePushDb(), { taskId: "task-1", sendMainGroup: false, sendGroupChats: false }, resolveWecomConfig(baseBindings), fakeWecomFetch([], new Map()), ), (error) => error instanceof ProjectError && /至少选择一个发送目标/.test(error.message), ); });