import assert from "node:assert/strict"; import test from "node:test"; import { resolveAutoReleaseDays, runOverdueAutoRelease, runOverdueEscalation, } from "../lib/wecom-notifier-service.ts"; import { clearWecomAccessTokenCacheForTests, resolveWecomConfig, } from "../lib/wecom-client.ts"; const config = resolveWecomConfig({ WECOM_CORP_ID: "corp-test", WECOM_AGENT_ID: "1000001", WECOM_SECRET: "secret-test", WECOM_ROBOT_WEBHOOK: "https://qyapi.example.com/webhook/send?key=abc", }); const TODAY = "2026-09-01"; const NOW = new Date(`${TODAY}T02:00:00Z`).getTime(); const overdueRows = [ { id: "d-1", task_id: "task-A", task_name: "超期任务A", due_at: "2026-08-20", task_type: "content_publish", publish_url: null, result_submitted_at: null, project_id: "proj-1", partner_id: "p-1", partner_name: "合作方甲", }, { id: "d-2", task_id: "task-A", task_name: "超期任务A", due_at: "2026-08-20", task_type: "content_publish", publish_url: null, result_submitted_at: null, project_id: "proj-1", partner_id: "p-2", partner_name: "合作方乙", }, { id: "d-3", task_id: "task-B", task_name: "无项目任务B", due_at: "2026-08-25", task_type: "content_publish", publish_url: null, result_submitted_at: null, project_id: "", partner_id: "p-3", partner_name: "合作方丙", }, ]; const releasableDistribution = { id: "d-1", task_id: "task-A", task_type: "content_publish", content_id: "c-1", content_title: "超期内容", partner_id: "p-1", partner_name: "合作方甲", claim_id: null, delegation_bundle_id: null, publish_url: null, result_submitted_at: null, }; function filterOverdue(rows, cutoff) { return rows.filter((row) => { if (!(row.due_at && row.due_at < cutoff)) return false; return row.task_type === "screenshot_collect" ? !row.result_submitted_at : !row.publish_url; }); } function fakeEscalationDb({ rows = overdueRows } = {}) { return { prepare(sql) { return { bind(...params) { return { async first() { if (/FROM projects WHERE id = \?/.test(sql)) { return { main_group_member_ids: JSON.stringify(["opsZhang", "opsLi"]) }; } return null; }, async all() { if ( /FROM distributions d/.test(sql) && /result_submitted_at IS NULL/.test(sql) ) { return { results: filterOverdue(rows, params[0]) }; } return { results: [] }; }, }; }, }; }, }; } function fakeReleaseDb({ rows = overdueRows, blockedIds = [] } = {}) { const executed = []; const inner = { prepare(sql) { return { bind(...params) { return { async first() { if (/FROM projects WHERE id = \?/.test(sql)) { return { main_group_member_ids: JSON.stringify(["opsZhang"]) }; } if (/FOR UPDATE/.test(sql)) { const row = rows.find((item) => item.id === params[0]); if (!row) return null; return { ...releasableDistribution, id: row.id, task_id: row.task_id, partner_id: row.partner_id, partner_name: row.partner_name, result_submitted_at: blockedIds.includes(row.id) ? "2026-08-30 10:00:00" : null, }; } return null; }, async all() { if ( /FROM distributions d/.test(sql) && /result_submitted_at IS NULL/.test(sql) ) { return { results: filterOverdue(rows, params[0]) }; } return { results: [] }; }, async run() { executed.push({ sql, params }); return { meta: { changes: 1 } }; }, }; }, }; }, }; return { executed, ...inner, async transaction(fn) { return fn(inner); }, }; } function wecomFetch(calls) { 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") || url.host === "qyapi.example.com" ) { return Response.json({ errcode: 0, errmsg: "ok" }); } return new Response("not found", { status: 404 }); }; } test.beforeEach(() => { clearWecomAccessTokenCacheForTests(); delete process.env.WECOM_AUTO_RELEASE_DAYS; delete process.env.APP_ORIGIN; }); test("resolveAutoReleaseDays parses bounds and defaults to disabled", () => { assert.equal(resolveAutoReleaseDays(undefined), 0); assert.equal(resolveAutoReleaseDays(""), 0); assert.equal(resolveAutoReleaseDays("0"), 0); assert.equal(resolveAutoReleaseDays("-5"), 0); assert.equal(resolveAutoReleaseDays("abc"), 0); assert.equal(resolveAutoReleaseDays("7"), 7); assert.equal(resolveAutoReleaseDays("120"), 90); }); test("runOverdueEscalation posts robot digest and template cards per project", async () => { const calls = []; const summary = await runOverdueEscalation( fakeEscalationDb(), config, wecomFetch(calls), NOW, ); assert.equal(summary.overdueTasks, 2); assert.equal(summary.overdueDistributions, 3); assert.equal(summary.robotSent, true); const robotBodies = calls .filter((call) => call.url.host === "qyapi.example.com") .map((call) => JSON.parse(String(call.init.body))); assert.equal(robotBodies.length, 1); assert.match(robotBodies[0].text.content, /【超期未发布升级】/); assert.match(robotBodies[0].text.content, /超期任务A/); assert.match(robotBodies[0].text.content, /无项目任务B/); const cardCalls = calls.filter((call) => call.url.pathname.endsWith("/cgi-bin/message/send"), ); assert.equal(cardCalls.length, 1, "only the project task gets a card"); const body = JSON.parse(String(cardCalls[0].init.body)); assert.equal(body.msgtype, "template_card"); assert.equal(body.template_card.task_id, "task:task-A:due"); assert.equal(body.touser, "opsZhang|opsLi"); const buttons = body.template_card.button_list; assert.equal(buttons.length, 2); assert.ok(buttons.some((button) => button.type === 0 && button.key === "mark_handled")); assert.equal(summary.cardSent, 2); assert.equal(summary.cardFailed, 0); }); test("runOverdueEscalation no-ops without overdue rows", async () => { const calls = []; const summary = await runOverdueEscalation( fakeEscalationDb({ rows: [] }), config, wecomFetch(calls), NOW, ); assert.equal(summary.overdueDistributions, 0); assert.equal(summary.robotSent, false); assert.equal(calls.length, 0); }); test("runOverdueEscalation distinguishes screenshot tasks from content tasks", async () => { const rows = [ { id: "d-s1", task_id: "task-S", task_name: "截图任务S", due_at: "2026-08-20", task_type: "screenshot_collect", publish_url: null, result_submitted_at: null, project_id: "", partner_id: "p-1", partner_name: "合作方甲", }, { id: "d-s2", task_id: "task-S", task_name: "截图任务S", due_at: "2026-08-20", task_type: "screenshot_collect", publish_url: null, result_submitted_at: "2026-08-21 09:00:00", project_id: "", partner_id: "p-2", partner_name: "合作方乙", }, { id: "d-c1", task_id: "task-A", task_name: "内容任务A", due_at: "2026-08-20", task_type: "content_publish", publish_url: "https://www.xiaohongshu.com/note/1", result_submitted_at: null, project_id: "", partner_id: "p-3", partner_name: "合作方丙", }, { id: "d-c2", task_id: "task-A", task_name: "内容任务A", due_at: "2026-08-20", task_type: "content_publish", publish_url: null, result_submitted_at: null, project_id: "", partner_id: "p-4", partner_name: "合作方丁", }, ]; const calls = []; const summary = await runOverdueEscalation( fakeEscalationDb({ rows }), config, wecomFetch(calls), NOW, ); // 已提交截图的 d-s2 与已回填链接的 d-c1 不算超期 assert.equal(summary.overdueTasks, 2); assert.equal(summary.overdueDistributions, 2); const robotBodies = calls .filter((call) => call.url.host === "qyapi.example.com") .map((call) => JSON.parse(String(call.init.body))); assert.equal(robotBodies.length, 1); assert.match(robotBodies[0].text.content, /截图任务S(截止 2026-08-20):1 条/); assert.match(robotBodies[0].text.content, /内容任务A(截止 2026-08-20):1 条/); }); test("runOverdueAutoRelease is disabled without env switch", async () => { const calls = []; const db = fakeReleaseDb(); const summary = await runOverdueAutoRelease(db, config, wecomFetch(calls), NOW); assert.equal(summary.enabled, false); assert.equal(summary.released, 0); assert.equal(calls.length, 0); assert.equal(db.executed.length, 0); }); test("runOverdueAutoRelease releases overdue rows and skips blocked ones", async () => { process.env.WECOM_AUTO_RELEASE_DAYS = "10"; const calls = []; const db = fakeReleaseDb({ blockedIds: ["d-3"] }); const summary = await runOverdueAutoRelease(db, config, wecomFetch(calls), NOW); // cutoff = 2026-08-22:d-1/d-2(08-20)命中,d-3(08-25)不在窗口内 assert.equal(summary.enabled, true); assert.equal(summary.cutoff, "2026-08-22"); assert.equal(summary.released, 2); assert.equal(summary.blocked, 0); assert.ok( db.executed.some((item) => /UPDATE contents SET status = 'available'/.test(item.sql)), "released content returns to pool", ); const robotBodies = calls .filter((call) => call.url.host === "qyapi.example.com") .map((call) => JSON.parse(String(call.init.body))); assert.equal(robotBodies.length, 1); assert.match(robotBodies[0].text.content, /已释放 2 条/); }); test("runOverdueAutoRelease counts blocked rows as skipped", async () => { process.env.WECOM_AUTO_RELEASE_DAYS = "3"; const db = fakeReleaseDb({ blockedIds: ["d-1", "d-2", "d-3"] }); const summary = await runOverdueAutoRelease(db, config, wecomFetch([]), NOW); assert.equal(summary.cutoff, "2026-08-29"); assert.equal(summary.released, 0); assert.equal(summary.blocked, 3); assert.equal(summary.failed, 0); });