269 lines
8.2 KiB
JavaScript
269 lines
8.2 KiB
JavaScript
|
|
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);
|
|||
|
|
});
|