- 新表 projects / project_group_chats,partners 加 partner_type/wecom_userid,tasks 加 project_id(迁移 0010) - 项目 CRUD、总群成员配置、任务群人工映射(群主→合作方)、任务挂项目 - 推送:总群成员逐人应用消息 + 任务群按群主企业群发,结果分项回显
265 lines
8.2 KiB
JavaScript
265 lines
8.2 KiB
JavaScript
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),
|
||
);
|
||
});
|