Files
koc-loop/tests/project-push.test.mjs
ABAPPLO a6f5a5fd4a feat: 新增项目中心——按项目组织任务/任务群/总群并一键推送
- 新表 projects / project_group_chats,partners 加 partner_type/wecom_userid,tasks 加 project_id(迁移 0010)
- 项目 CRUD、总群成员配置、任务群人工映射(群主→合作方)、任务挂项目
- 推送:总群成员逐人应用消息 + 任务群按群主企业群发,结果分项回显
2026-08-31 16:35:17 +08:00

265 lines
8.2 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 {
buildProjectAppMessage,
pushProjectTask,
} from "../lib/project-push-service.ts";
import { ProjectError } from "../lib/project-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-01",
task_type: "content_publish",
platform: "小红书",
content_format: "image_text",
share_token: "tok-123",
project_id: "proj-1",
claimed_quantity: 0,
status: "active",
};
const projectRow = {
id: "proj-1",
name: "项目一",
brand: "品牌A",
main_group_member_ids: JSON.stringify(["zhangsan", "lisi"]),
status: "active",
note: "",
created_at: "2026-08-01",
};
function fakePushDb({
task = taskRow,
project = projectRow,
links = [
{ project_id: "proj-1", chat_id: "wr-1", partner_id: "p-1" },
{ project_id: "proj-1", chat_id: "wr-2", partner_id: "" },
],
groupChats = [
{ 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 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 projects WHERE id = \?/.test(sql)) return project;
return null;
},
async all() {
executed.push({ sql, params });
if (/FROM tasks WHERE project_id/.test(sql)) {
return { results: task ? [{ ...task }] : [] };
}
if (/FROM project_group_chats g/.test(sql)) {
return {
results: links
.filter((link) => link.project_id === params[0])
.map((link) => {
const chat = groupChats.find((item) => item.chat_id === link.chat_id);
return {
chat_id: link.chat_id,
partner_id: link.partner_id,
chat_name: chat ? chat.name : null,
owner_user_id: chat ? chat.owner_user_id : null,
member_count: chat ? chat.member_count : null,
chat_status: chat ? chat.status : null,
partner_name: null,
};
}),
};
}
if (/FROM wecom_group_chats/.test(sql)) {
return {
results: sql.includes(" IN (")
? groupChats.filter((row) => params.includes(row.chat_id))
: groupChats,
};
}
return { results: [] };
},
});
return {
executed,
prepare(sql) {
return makeStatement(sql);
},
async batch(statements) {
for (const statement of statements) await statement.run();
},
};
}
function fakeWecomFetch(calls, { failAppFor = [] } = {}) {
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 (failAppFor.includes(body.touser)) {
return Response.json({ errcode: 60111, errmsg: "userid not found" });
}
return Response.json({ errcode: 0, errmsg: "ok" });
}
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.beforeEach(() => {
clearWecomAccessTokenCacheForTests();
});
test("buildProjectAppMessage targets internal members with claim url", () => {
const message = buildProjectAppMessage(taskRow, "https://portal.example.com/");
assert.match(message, /【项目任务】测试任务/);
assert.match(message, /请把任务转发到你负责的项目任务群/);
assert.match(message, /领取链接:https:\/\/portal\.example\.com\/\?task=tok-123/);
});
test("pushProjectTask sends per-member app messages and group templates", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakePushDb();
const calls = [];
const outcome = await pushProjectTask(
db,
{ taskId: "task-1", sendMainGroup: true, sendGroupChats: true },
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls),
);
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),
["zhangsan", "lisi"],
"one message/send per main group member",
);
for (const call of appCalls) {
assert.match(JSON.parse(String(call.init.body)).text.content, /【项目任务】测试任务/);
}
assert.equal(outcome.appResults.length, 2);
assert.ok(outcome.appResults.every((item) => item.ok));
const templateCalls = calls.filter((call) =>
call.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
);
assert.equal(templateCalls.length, 2, "one template per owner");
assert.equal(outcome.groupResults.length, 2);
assert.equal(outcome.groupChatCount, 2);
assert.deepEqual(outcome.skippedReasons, []);
assert.equal(outcome.linkUrl, "https://portal.example.com/?task=tok-123");
} finally {
delete process.env.KOC_PORTAL_URL;
}
});
test("single member failure does not block the rest of the push", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakePushDb();
const calls = [];
const outcome = await pushProjectTask(
db,
{ taskId: "task-1" },
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls, { failAppFor: ["zhangsan"] }),
);
assert.deepEqual(
outcome.appResults.map((item) => [item.userid, item.ok]),
[
["zhangsan", false],
["lisi", true],
],
);
assert.match(outcome.appResults[0].error, /userid not found/);
const templateCalls = calls.filter((call) =>
call.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
);
assert.equal(templateCalls.length, 2, "group push still runs");
} finally {
delete process.env.KOC_PORTAL_URL;
}
});
test("pushProjectTask skips empty targets instead of failing", async () => {
const db = fakePushDb({
project: { ...projectRow, main_group_member_ids: "[]" },
links: [{ project_id: "proj-1", chat_id: "wr-gone", partner_id: "" }],
groupChats: [],
});
const calls = [];
const outcome = await pushProjectTask(
db,
{ taskId: "task-1" },
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls),
);
assert.deepEqual(outcome.skippedReasons, ["项目总群未配置成员", "项目没有可用的任务群"]);
assert.equal(calls.length, 0, "no wecom api calls expected");
});
test("pushProjectTask rejects task without project or unknown task", async () => {
const unlinked = fakePushDb({ task: { ...taskRow, project_id: "" } });
await assert.rejects(
() =>
pushProjectTask(
unlinked,
{ taskId: "task-1" },
resolveWecomConfig(baseBindings),
fakeWecomFetch([]),
),
(error) => error instanceof ProjectError && /未挂项目/.test(error.message),
);
await assert.rejects(
() => pushProjectTask(fakePushDb(), { taskId: "" }, resolveWecomConfig(baseBindings)),
(error) => error instanceof ProjectError,
);
await assert.rejects(
() =>
pushProjectTask(
fakePushDb(),
{ taskId: "task-1", sendMainGroup: false, sendGroupChats: false },
resolveWecomConfig(baseBindings),
fakeWecomFetch([]),
),
(error) => error instanceof ProjectError && /至少选择一个发送目标/.test(error.message),
);
});