feat: 任务一键推送企微(总群+KOC个人直达+群发,含推送记录)
This commit is contained in:
250
tests/task-one-click-push.test.mjs
Normal file
250
tests/task-one-click-push.test.mjs
Normal file
@@ -0,0 +1,250 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildKocDirectMessage,
|
||||
oneClickPushTask,
|
||||
OneClickPushError,
|
||||
} from "../lib/task-one-click-push-service.ts";
|
||||
import {
|
||||
clearWecomAccessTokenCacheForTests,
|
||||
resolveWecomConfig,
|
||||
} from "../lib/wecom-client.ts";
|
||||
|
||||
const baseBindings = {
|
||||
WECOM_CORP_ID: "corp-test",
|
||||
WECOM_AGENT_ID: "1000001",
|
||||
WECOM_SECRET: "secret-test",
|
||||
};
|
||||
|
||||
const taskRow = {
|
||||
id: "task-1",
|
||||
name: "测试任务",
|
||||
brand: "品牌A",
|
||||
quantity: 3,
|
||||
due_at: "2026-09-05",
|
||||
task_type: "content_publish",
|
||||
platform: "小红书",
|
||||
content_format: "image_text",
|
||||
share_token: "tok-123",
|
||||
project_id: "",
|
||||
};
|
||||
|
||||
const boundPartners = [
|
||||
{ id: "p-1", name: "合作方甲", wecom_external_user_id: "wmOK1" },
|
||||
{ id: "p-2", name: "合作方乙", wecom_external_user_id: "wmOK2" },
|
||||
{ id: "p-3", name: "合作方丙", wecom_external_user_id: "wmFAIL" },
|
||||
];
|
||||
|
||||
function fakePushDb({
|
||||
task = taskRow,
|
||||
partners = boundPartners,
|
||||
todayLog = null,
|
||||
} = {}) {
|
||||
const executed = [];
|
||||
const makeStatement = (sql, params = []) => ({
|
||||
sql,
|
||||
params,
|
||||
bind(...args) {
|
||||
return makeStatement(sql, args);
|
||||
},
|
||||
async run() {
|
||||
executed.push({ sql, params });
|
||||
return { meta: { changes: 1 } };
|
||||
},
|
||||
async first() {
|
||||
executed.push({ sql, params });
|
||||
if (/FROM tasks WHERE id = \?/.test(sql)) return task;
|
||||
if (/FROM wecom_push_logs/.test(sql) && /DATE\(created_at\) = CURDATE\(\)/.test(sql)) {
|
||||
return todayLog;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async all() {
|
||||
executed.push({ sql, params });
|
||||
if (/FROM partners/.test(sql) && /wecom_external_user_id/.test(sql)) {
|
||||
return { results: partners };
|
||||
}
|
||||
return { results: [] };
|
||||
},
|
||||
});
|
||||
return {
|
||||
executed,
|
||||
prepare(sql) {
|
||||
return makeStatement(sql);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeWecomFetch(calls, { invalidUsers = [] } = {}) {
|
||||
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")) {
|
||||
const body = JSON.parse(String(init.body));
|
||||
if (invalidUsers.includes(body.touser)) {
|
||||
return Response.json({
|
||||
errcode: 0,
|
||||
errmsg: "ok",
|
||||
invaliduser: body.touser,
|
||||
});
|
||||
}
|
||||
return Response.json({ errcode: 0, errmsg: "ok" });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
clearWecomAccessTokenCacheForTests();
|
||||
});
|
||||
|
||||
test("buildKocDirectMessage targets KOC with claim url and OAuth hint", () => {
|
||||
const message = buildKocDirectMessage(taskRow, "https://portal.example.com/");
|
||||
assert.match(message, /【新任务】测试任务/);
|
||||
assert.match(message, /先到先得/);
|
||||
assert.match(message, /免输身份/);
|
||||
assert.match(message, /https:\/\/portal\.example\.com\/\?task=tok-123/);
|
||||
});
|
||||
|
||||
test("oneClickPushTask sends only to selected partners and logs the push", async () => {
|
||||
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
||||
try {
|
||||
const db = fakePushDb();
|
||||
const calls = [];
|
||||
const outcome = await oneClickPushTask(
|
||||
db,
|
||||
{
|
||||
taskId: "task-1",
|
||||
sendMainGroup: false,
|
||||
sendKoc: true,
|
||||
createGroupTemplate: false,
|
||||
selectedKocPartnerIds: ["p-1", "p-3"],
|
||||
},
|
||||
resolveWecomConfig(baseBindings),
|
||||
fakeWecomFetch(calls, { invalidUsers: ["wmFAIL"] }),
|
||||
);
|
||||
const appCalls = calls.filter((call) =>
|
||||
call.url.pathname.endsWith("/cgi-bin/message/send"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
appCalls.map((call) => JSON.parse(String(call.init.body)).touser),
|
||||
["wmOK1", "wmFAIL"],
|
||||
);
|
||||
for (const call of appCalls) {
|
||||
assert.match(JSON.parse(String(call.init.body)).text.content, /【新任务】测试任务/);
|
||||
}
|
||||
assert.equal(outcome.kocSent, 1);
|
||||
assert.equal(outcome.kocFailed, 1);
|
||||
assert.equal(outcome.kocResults[1].partnerName, "合作方丙");
|
||||
assert.match(outcome.kocResults[1].error, /invaliduser/);
|
||||
assert.equal(outcome.linkUrl, "https://portal.example.com/?task=tok-123");
|
||||
|
||||
const insert = db.executed.find((item) => /INSERT INTO wecom_push_logs/.test(item.sql));
|
||||
assert.ok(insert, "push log should be written");
|
||||
assert.equal(insert.params[1], "task-1");
|
||||
assert.equal(insert.params[3], "task_open");
|
||||
assert.equal(insert.params[6], 1);
|
||||
const kocUserids = JSON.parse(insert.params[5]);
|
||||
assert.equal(kocUserids.length, 2);
|
||||
assert.equal(kocUserids[0].externalUserId, "wmOK1");
|
||||
} finally {
|
||||
delete process.env.KOC_PORTAL_URL;
|
||||
}
|
||||
});
|
||||
|
||||
test("oneClickPushTask blocks duplicate same-day push unless forced", async () => {
|
||||
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
||||
try {
|
||||
const db = fakePushDb({
|
||||
todayLog: { id: "wpl-old", task_id: "task-1", push_kind: "task_open" },
|
||||
});
|
||||
const calls = [];
|
||||
await assert.rejects(
|
||||
() =>
|
||||
oneClickPushTask(
|
||||
db,
|
||||
{ taskId: "task-1", sendMainGroup: false, sendKoc: true },
|
||||
resolveWecomConfig(baseBindings),
|
||||
fakeWecomFetch(calls),
|
||||
),
|
||||
(error) =>
|
||||
error instanceof OneClickPushError && /仍要推送/.test(error.message) && error.status === 409,
|
||||
);
|
||||
assert.equal(calls.length, 0, "no wecom api calls on duplicate block");
|
||||
|
||||
const outcome = await oneClickPushTask(
|
||||
db,
|
||||
{
|
||||
taskId: "task-1",
|
||||
sendMainGroup: false,
|
||||
sendKoc: true,
|
||||
selectedKocPartnerIds: ["p-2"],
|
||||
force: true,
|
||||
},
|
||||
resolveWecomConfig(baseBindings),
|
||||
fakeWecomFetch(calls),
|
||||
);
|
||||
assert.equal(outcome.kocSent, 1);
|
||||
} finally {
|
||||
delete process.env.KOC_PORTAL_URL;
|
||||
}
|
||||
});
|
||||
|
||||
test("oneClickPushTask without project skips project targets and still reaches KOC", async () => {
|
||||
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
||||
try {
|
||||
const db = fakePushDb({ task: { ...taskRow, project_id: "" } });
|
||||
const calls = [];
|
||||
const outcome = await oneClickPushTask(
|
||||
db,
|
||||
{ taskId: "task-1", sendMainGroup: true, sendKoc: true },
|
||||
resolveWecomConfig(baseBindings),
|
||||
fakeWecomFetch(calls),
|
||||
);
|
||||
assert.ok(
|
||||
outcome.skippedReasons.some((reason) => /未挂项目/.test(reason)),
|
||||
"should explain project skip",
|
||||
);
|
||||
assert.equal(outcome.projectOutcome, null);
|
||||
assert.equal(outcome.kocSent, 3);
|
||||
assert.equal(
|
||||
calls.filter((call) => call.url.pathname.includes("/cgi-bin/externalcontact/")).length,
|
||||
0,
|
||||
"no group template calls expected",
|
||||
);
|
||||
} finally {
|
||||
delete process.env.KOC_PORTAL_URL;
|
||||
}
|
||||
});
|
||||
|
||||
test("oneClickPushTask requires share token and at least one target", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
oneClickPushTask(
|
||||
fakePushDb({ task: { ...taskRow, share_token: "" } }),
|
||||
{ taskId: "task-1", sendMainGroup: false, sendKoc: true },
|
||||
resolveWecomConfig(baseBindings),
|
||||
fakeWecomFetch([]),
|
||||
),
|
||||
(error) => error instanceof OneClickPushError && /领取链接/.test(error.message),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
oneClickPushTask(
|
||||
fakePushDb(),
|
||||
{ taskId: "task-1", sendMainGroup: false, sendKoc: false, createGroupTemplate: false },
|
||||
resolveWecomConfig(baseBindings),
|
||||
fakeWecomFetch([]),
|
||||
),
|
||||
(error) => error instanceof OneClickPushError && /至少选择一个推送目标/.test(error.message),
|
||||
);
|
||||
});
|
||||
@@ -84,8 +84,23 @@ test("parse callback xml handles CDATA and plain fields", () => {
|
||||
assert.equal(extractWecomEncryptField(body), "RW5jcnlwdGVkQmFzZTY0PT0=");
|
||||
});
|
||||
|
||||
test("event dispatcher classifies template_card mark_handled and ignores others", () => {
|
||||
const ack = handleWecomCallbackEvent({
|
||||
test("event dispatcher classifies template_card mark_handled and ignores others", async () => {
|
||||
const inserts = [];
|
||||
const fakeDb = {
|
||||
prepare(sql) {
|
||||
return {
|
||||
bind(...params) {
|
||||
return {
|
||||
async run() {
|
||||
inserts.push({ sql, params });
|
||||
return { success: true };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const ack = await handleWecomCallbackEvent(fakeDb, {
|
||||
MsgType: "event",
|
||||
Event: "template_card_event",
|
||||
TaskId: "task:t123:due",
|
||||
@@ -96,8 +111,12 @@ test("event dispatcher classifies template_card mark_handled and ignores others"
|
||||
assert.equal(ack.action, "card_ack");
|
||||
assert.equal(ack.taskId, "task:t123:due");
|
||||
assert.equal(ack.operator, "opsUser01");
|
||||
assert.equal(inserts.length, 1);
|
||||
assert.match(inserts[0].sql, /INSERT INTO wecom_push_logs/);
|
||||
assert.equal(inserts[0].params[0].startsWith("wpl-"), true);
|
||||
assert.equal(inserts[0].params[1], "t123");
|
||||
|
||||
const view = handleWecomCallbackEvent({
|
||||
const view = await handleWecomCallbackEvent(fakeDb, {
|
||||
MsgType: "event",
|
||||
Event: "template_card_event",
|
||||
TaskId: "task:t123:due",
|
||||
@@ -106,7 +125,7 @@ test("event dispatcher classifies template_card mark_handled and ignores others"
|
||||
assert.equal(view.recognized, true);
|
||||
assert.equal(view.action, "ignored");
|
||||
|
||||
const other = handleWecomCallbackEvent({
|
||||
const other = await handleWecomCallbackEvent(fakeDb, {
|
||||
MsgType: "event",
|
||||
Event: "change_contact",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user