Files
koc-loop/tests/wecom-escalation.test.mjs

390 lines
12 KiB
JavaScript
Raw Normal View History

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",
recovery_enabled: 1,
recovery_due_at: "2026-08-27",
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",
recovery_enabled: 1,
recovery_due_at: "2026-08-27",
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",
recovery_enabled: 0,
recovery_due_at: "",
publish_url: null,
result_submitted_at: null,
project_id: "",
partner_id: "p-3",
partner_name: "合作方丙",
},
];
const releasableDistribution = {
id: "d-1",
task_id: "task-A",
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,
};
// 双阶段超期判定:发布阶段看 due_at,回收阶段(已发布未收截图)看 recovery_due_at
function filterOverdue(rows, cutoff, { phases = "all" } = {}) {
const output = [];
for (const row of rows) {
if (!row.publish_url && row.due_at && row.due_at < cutoff) {
output.push({ ...row, pending_phase: "publish", pending_due_at: row.due_at });
continue;
}
if (
phases === "all" &&
row.recovery_enabled === 1 &&
row.publish_url &&
!row.result_submitted_at &&
row.recovery_due_at &&
row.recovery_due_at < cutoff
) {
output.push({
...row,
pending_phase: "recovery",
pending_due_at: row.recovery_due_at,
});
}
}
return output;
}
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)) {
return {
results: filterOverdue(rows, params[0], {
phases: sql.includes("t.recovery_enabled") ? "all" : "publish",
}),
};
}
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)) {
return {
results: filterOverdue(rows, params[0], {
phases: sql.includes("t.recovery_enabled") ? "all" : "publish",
}),
};
}
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 按发布/回收两阶段分别出文案", async () => {
const rows = [
{
// 回收阶段超期:已发布、未收截图、recovery_due_at 已过
id: "d-r1",
task_id: "task-S",
task_name: "回收任务S",
due_at: "2026-08-10",
recovery_enabled: 1,
recovery_due_at: "2026-08-20",
publish_url: "https://www.xiaohongshu.com/note/s1",
result_submitted_at: null,
project_id: "",
partner_id: "p-1",
partner_name: "合作方甲",
},
{
// 已提交截图:两阶段都不超期
id: "d-r2",
task_id: "task-S",
task_name: "回收任务S",
due_at: "2026-08-10",
recovery_enabled: 1,
recovery_due_at: "2026-08-20",
publish_url: "https://www.xiaohongshu.com/note/s2",
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",
recovery_enabled: 1,
recovery_due_at: "2026-09-10",
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",
recovery_enabled: 1,
recovery_due_at: "2026-09-10",
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,
);
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);
});