Files
koc-loop/tests/wecom-group-push.test.mjs
ABAPPLO 9697b5890d feat: 任务发布到企微客户群(企业群发)与资源库单条新增
- 任务中心新增「发布到企微群」:同步客户群清单(wecom_group_chats)、
  按群主分组创建企业群发任务(add_msg_template)、发送记录落库
  wecom_group_pushes,迁移 mysql/0009;lib/wecom-client.ts 补
  listCustomerGroupChats/createGroupMsgTemplate
- KOC 资源库支持单条新增:app/api/resources-insert + lib/resource-write,
  拆出资源写入公共逻辑供导入复用;0008 补合作方外部联系人字段
- 环境变量示例补企微凭证与 SEED_DEMO_DATA;next.config 增加
  allowedDevOrigins;CLAUDE.md 补充项目说明
- .gitignore 排除 .codegraph/ 与 .ipynb_checkpoints/
2026-08-20 15:16:18 +08:00

263 lines
8.4 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 {
buildTaskGroupPushMessage,
groupChatsByOwner,
pushTaskToGroupChats,
trimToBytes,
} from "../lib/wecom-group-push-service.ts";
import {
clearWecomAccessTokenCacheForTests,
resolveWecomConfig,
WecomClientError,
} from "../lib/wecom-client.ts";
const baseBindings = {
WECOM_CORP_ID: "corp-test",
WECOM_AGENT_ID: "1000001",
WECOM_SECRET: "secret-test",
};
const baseTask = {
id: "task-1",
name: "测试任务",
brand: "品牌A",
quantity: 3,
due_at: "2026-09-01",
task_type: "content_publish",
platform: "小红书",
content_format: "image_text",
share_token: "tok-123",
};
test.beforeEach(() => {
clearWecomAccessTokenCacheForTests();
});
test("trimToBytes cuts on UTF-8 boundaries", () => {
assert.equal(trimToBytes("abc", 10), "abc");
assert.equal(trimToBytes("abcdef", 3), "abc");
// 「汉」占 3 字节
assert.equal(trimToBytes("a汉b", 4), "a汉");
assert.equal(trimToBytes("汉汉汉", 7), "汉汉");
});
test("buildTaskGroupPushMessage composes text and link with claim url", () => {
const message = buildTaskGroupPushMessage(baseTask, "https://portal.example.com/");
assert.match(message.text, /【新任务】测试任务/);
assert.match(message.text, /品牌品牌A数量3 份截止2026-09-01/);
assert.match(message.text, /平台:小红书|形式:图文/);
assert.match(message.text, /领取链接https:\/\/portal\.example\.com\/\?task=tok-123/);
assert.ok(message.link);
assert.equal(message.link.title, "测试任务");
assert.equal(message.link.url, "https://portal.example.com/?task=tok-123");
assert.match(message.link.desc, /品牌A · 3 份 · 截止 2026-09-01/);
});
test("buildTaskGroupPushMessage marks video and screenshot tasks", () => {
const video = buildTaskGroupPushMessage(
{ ...baseTask, content_format: "video" },
"https://portal.example.com",
);
assert.match(video.text, /形式:视频/);
const screenshot = buildTaskGroupPushMessage(
{ ...baseTask, task_type: "screenshot_collect" },
"https://portal.example.com",
);
assert.match(screenshot.text, /形式:截图回收/);
});
test("buildTaskGroupPushMessage drops link when share_token missing", () => {
const message = buildTaskGroupPushMessage(
{ ...baseTask, share_token: null },
"https://portal.example.com",
);
assert.equal(message.link, null);
assert.match(message.text, /领取链接生成失败/);
});
test("buildTaskGroupPushMessage trims long content to byte limits", () => {
const longName = "长".repeat(200);
const longBrand = "牌".repeat(400);
const message = buildTaskGroupPushMessage(
{ ...baseTask, name: longName, brand: longBrand },
"https://portal.example.com",
);
assert.ok(Buffer.byteLength(message.text, "utf8") <= 4000);
assert.ok(Buffer.byteLength(message.link.title, "utf8") <= 128);
assert.ok(Buffer.byteLength(message.link.desc, "utf8") <= 512);
});
test("groupChatsByOwner groups chat ids by owner and skips empty owners", () => {
const grouped = groupChatsByOwner([
{ chat_id: "wr-1", name: "一", owner_user_id: "zhangsan", member_count: 1, status: 0 },
{ chat_id: "wr-2", name: "二", owner_user_id: "lisi", member_count: 2, status: 0 },
{ chat_id: "wr-3", name: "三", owner_user_id: "zhangsan", member_count: 3, status: 0 },
{ chat_id: "wr-4", name: "四", owner_user_id: " ", member_count: 4, status: 0 },
]);
assert.deepEqual(grouped, [
{ ownerUserId: "zhangsan", chatIds: ["wr-1", "wr-3"] },
{ ownerUserId: "lisi", chatIds: ["wr-2"] },
]);
});
function fakeGroupPushDb(taskRow) {
const executed = [];
const groupRows = [
{ chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 10, status: 0 },
{ chat_id: "wr-2", name: "二群", owner_user_id: "lisi", member_count: 5, status: 0 },
];
const makeStatement = (sql, params = []) => ({
sql,
params,
bind(...args) {
return makeStatement(sql, args);
},
async run() {
executed.push({ sql, params });
return {};
},
async first() {
executed.push({ sql, params });
if (/FROM tasks WHERE id = \?/.test(sql)) {
return params[0] === taskRow.id ? taskRow : null;
}
return null;
},
async all() {
executed.push({ sql, params });
if (/FROM wecom_group_chats/.test(sql)) {
return {
results: sql.includes(" IN (")
? groupRows.filter((row) => params.includes(row.chat_id))
: groupRows,
};
}
return { results: [] };
},
});
return {
executed,
prepare(sql) {
return makeStatement(sql);
},
async batch(statements) {
for (const statement of statements) await statement.run();
},
};
}
function fakeTemplateFetch(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/externalcontact/add_msg_template")) {
return Response.json({ errcode: 0, errmsg: "ok", msgid: "msg-1", fail_list: [] });
}
return new Response("not found", { status: 404 });
};
}
test("pushTaskToGroupChats batches per owner and records each send", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakeGroupPushDb(baseTask);
const calls = [];
const summary = await pushTaskToGroupChats(
db,
{ taskId: "task-1", chatIds: ["wr-2", "wr-1"] },
resolveWecomConfig(baseBindings),
fakeTemplateFetch(calls),
);
assert.equal(summary.results.length, 2);
const senders = summary.results.map((item) => item.sender).sort();
assert.deepEqual(senders, ["lisi", "zhangsan"]);
const templateCalls = calls.filter((c) =>
c.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
);
assert.equal(templateCalls.length, 2, "one call per owner");
const bodies = templateCalls.map((c) => JSON.parse(String(c.init.body)));
const bySender = new Map(bodies.map((body) => [body.sender, body]));
assert.deepEqual(bySender.get("zhangsan").chat_id_list, ["wr-1"]);
assert.deepEqual(bySender.get("lisi").chat_id_list, ["wr-2"]);
assert.match(bySender.get("zhangsan").text.content, /【新任务】测试任务/);
assert.equal(
bySender.get("zhangsan").attachments[0].link.url,
"https://portal.example.com/?task=tok-123",
);
const inserts = db.executed.filter((item) =>
item.sql.includes("INSERT INTO wecom_group_pushes"),
);
assert.equal(inserts.length, 2);
} finally {
delete process.env.KOC_PORTAL_URL;
}
});
test("pushTaskToGroupChats rejects unknown chat ids", async () => {
const db = fakeGroupPushDb(baseTask);
const calls = [];
await assert.rejects(
() =>
pushTaskToGroupChats(
db,
{ taskId: "task-1", chatIds: ["wr-404"] },
resolveWecomConfig(baseBindings),
fakeTemplateFetch(calls),
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 400);
assert.match(error.message, /未同步/);
return true;
},
);
});
test("pushTaskToGroupChats rejects missing task", async () => {
const db = fakeGroupPushDb(baseTask);
await assert.rejects(
() =>
pushTaskToGroupChats(
db,
{ taskId: "task-404", chatIds: ["wr-1"] },
resolveWecomConfig(baseBindings),
fakeTemplateFetch([]),
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 404);
return true;
},
);
});
test("pushTaskToGroupChats prefers explicit text over template", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakeGroupPushDb(baseTask);
const calls = [];
await pushTaskToGroupChats(
db,
{ taskId: "task-1", chatIds: ["wr-1"], text: "运营手动编辑的文案" },
resolveWecomConfig(baseBindings),
fakeTemplateFetch(calls),
);
const templateCall = calls.find((c) =>
c.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
);
const body = JSON.parse(String(templateCall.init.body));
assert.equal(body.text.content, "运营手动编辑的文案");
} finally {
delete process.env.KOC_PORTAL_URL;
}
});