- 临期/超期/自动释放查询按 task_type 区分完成口径:内容任务看 publish_url,截图任务看 result_submitted_at,截图任务不再被永久误判为未发布 - portal 领取改为事务内 SELECT ... FOR UPDATE 锁定可用内容,消除并发领取超发窗口
362 lines
11 KiB
JavaScript
362 lines
11 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",
|
||
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);
|
||
});
|