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

269 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
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",
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",
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 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) && /publish_url IS NULL/.test(sql)) {
return { results: rows.filter((row) => row.due_at < 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) && /publish_url IS NULL/.test(sql)) {
return { results: rows.filter((row) => row.due_at < 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("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);
});