Files
koc-loop/tests/wecom-escalation.test.mjs
ABAPPLO 5cb056507d fix: 催办按任务类型分流 + 领取事务加锁防超发
- 临期/超期/自动释放查询按 task_type 区分完成口径:内容任务看 publish_url,截图任务看 result_submitted_at,截图任务不再被永久误判为未发布
- portal 领取改为事务内 SELECT ... FOR UPDATE 锁定可用内容,消除并发领取超发窗口
2026-09-02 13:56:07 +08:00

362 lines
11 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",
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);
});