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", claimed_quantity: 0, status: "active", }; const projectRow = { id: "proj-1", name: "项目一", brand: "品牌A", main_group_member_ids: JSON.stringify(["zhangsan", "lisi"]), status: "active", note: "", created_at: "2026-08-01", }; function fakePushDb({ task = taskRow, project = projectRow, links = [ { project_id: "proj-1", chat_id: "wr-1", partner_id: "p-1" }, { project_id: "proj-1", chat_id: "wr-2", partner_id: "" }, ], groupChats = [ { chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 10, status: 0 }, { chat_id: "wr-2", name: "二群", owner_user_id: "lisi", member_count: 5, status: 0 }, ], } = {}) { const executed = []; const makeStatement = (sql, params = []) => ({ sql, params, bind(...args) { return makeStatement(sql, args); }, async run() { executed.push({ sql, params }); return { meta: { changes: 1 } }; }, async first() { executed.push({ sql, params }); 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 }] : [] }; } if (/FROM project_group_chats g/.test(sql)) { return { results: links .filter((link) => link.project_id === params[0]) .map((link) => { const chat = groupChats.find((item) => item.chat_id === link.chat_id); return { chat_id: link.chat_id, partner_id: link.partner_id, chat_name: chat ? chat.name : null, owner_user_id: chat ? chat.owner_user_id : null, member_count: chat ? chat.member_count : null, chat_status: chat ? chat.status : null, partner_name: null, }; }), }; } if (/FROM wecom_group_chats/.test(sql)) { return { results: sql.includes(" IN (") ? groupChats.filter((row) => params.includes(row.chat_id)) : groupChats, }; } return { results: [] }; }, }); return { executed, prepare(sql) { return makeStatement(sql); }, async batch(statements) { for (const statement of statements) await statement.run(); }, }; } function fakeWecomFetch(calls, { failAppFor = [] } = {}) { 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/externalcontact/add_msg_template")) { return Response.json({ errcode: 0, errmsg: "ok", msgid: "msg-1", fail_list: [] }); } 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 sends per-member app messages and group templates", async () => { process.env.KOC_PORTAL_URL = "https://portal.example.com"; try { const db = fakePushDb(); const calls = []; const outcome = await pushProjectTask( db, { taskId: "task-1", sendMainGroup: true, sendGroupChats: true }, resolveWecomConfig(baseBindings), fakeWecomFetch(calls), ); const appCalls = calls.filter((call) => call.url.pathname.endsWith("/cgi-bin/message/send"), ); assert.deepEqual( appCalls.map((call) => JSON.parse(String(call.init.body)).touser), ["zhangsan", "lisi"], "one message/send per main group member", ); for (const call of appCalls) { assert.match(JSON.parse(String(call.init.body)).text.content, /【项目任务】测试任务/); } assert.equal(outcome.appResults.length, 2); assert.ok(outcome.appResults.every((item) => item.ok)); const templateCalls = calls.filter((call) => call.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"), ); assert.equal(templateCalls.length, 2, "one template per owner"); assert.equal(outcome.groupResults.length, 2); assert.equal(outcome.groupChatCount, 2); assert.deepEqual(outcome.skippedReasons, []); assert.equal(outcome.linkUrl, "https://portal.example.com/?task=tok-123"); } finally { delete process.env.KOC_PORTAL_URL; } }); test("single member failure does not block the rest of the push", async () => { process.env.KOC_PORTAL_URL = "https://portal.example.com"; try { const db = fakePushDb(); const calls = []; const outcome = await pushProjectTask( db, { taskId: "task-1" }, resolveWecomConfig(baseBindings), fakeWecomFetch(calls, { failAppFor: ["zhangsan"] }), ); assert.deepEqual( outcome.appResults.map((item) => [item.userid, item.ok]), [ ["zhangsan", false], ["lisi", true], ], ); assert.match(outcome.appResults[0].error, /userid not found/); const templateCalls = calls.filter((call) => call.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"), ); assert.equal(templateCalls.length, 2, "group push still runs"); } finally { delete process.env.KOC_PORTAL_URL; } }); test("pushProjectTask skips empty targets instead of failing", async () => { const db = fakePushDb({ project: { ...projectRow, main_group_member_ids: "[]" }, links: [{ project_id: "proj-1", chat_id: "wr-gone", partner_id: "" }], groupChats: [], }); const calls = []; const outcome = await pushProjectTask( db, { taskId: "task-1" }, resolveWecomConfig(baseBindings), fakeWecomFetch(calls), ); assert.deepEqual(outcome.skippedReasons, ["项目总群未配置成员", "项目没有可用的任务群"]); assert.equal(calls.length, 0, "no wecom api calls expected"); }); 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([]), ), (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([]), ), (error) => error instanceof ProjectError && /至少选择一个发送目标/.test(error.message), ); });