2026-08-31 16:35:17 +08:00
|
|
|
|
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",
|
2026-09-04 18:03:46 +08:00
|
|
|
|
owner_userid: "owner-task",
|
|
|
|
|
|
task_chat_id: null,
|
2026-08-31 16:35:17 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const projectRow = {
|
|
|
|
|
|
id: "proj-1",
|
|
|
|
|
|
name: "项目一",
|
|
|
|
|
|
brand: "品牌A",
|
2026-09-04 18:03:46 +08:00
|
|
|
|
main_group_member_ids: JSON.stringify(["zhangsan", "lisi", "owner-proj"]),
|
|
|
|
|
|
owner_userid: "owner-proj",
|
|
|
|
|
|
main_chat_id: "",
|
2026-08-31 16:35:17 +08:00
|
|
|
|
status: "active",
|
|
|
|
|
|
note: "",
|
|
|
|
|
|
created_at: "2026-08-01",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
function fakePushDb({ task = { ...taskRow }, project = { ...projectRow } } = {}) {
|
2026-08-31 16:35:17 +08:00
|
|
|
|
const executed = [];
|
|
|
|
|
|
const makeStatement = (sql, params = []) => ({
|
|
|
|
|
|
sql,
|
|
|
|
|
|
params,
|
|
|
|
|
|
bind(...args) {
|
|
|
|
|
|
return makeStatement(sql, args);
|
|
|
|
|
|
},
|
|
|
|
|
|
async run() {
|
|
|
|
|
|
executed.push({ sql, params });
|
2026-09-04 18:03:46 +08:00
|
|
|
|
if (/UPDATE tasks SET task_chat_id/.test(sql)) {
|
|
|
|
|
|
task.task_chat_id = params[0];
|
|
|
|
|
|
}
|
2026-08-31 16:35:17 +08:00
|
|
|
|
return { meta: { changes: 1 } };
|
|
|
|
|
|
},
|
|
|
|
|
|
async first() {
|
|
|
|
|
|
executed.push({ sql, params });
|
2026-09-04 18:03:46 +08:00
|
|
|
|
if (/FROM tasks t\s+LEFT JOIN projects p/.test(sql)) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: task.id,
|
|
|
|
|
|
name: task.name,
|
|
|
|
|
|
task_chat_id: task.task_chat_id ?? null,
|
|
|
|
|
|
task_owner_userid: task.owner_userid ?? null,
|
|
|
|
|
|
project_owner_userid: project ? (project.owner_userid ?? null) : null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (/SELECT task_chat_id FROM tasks/.test(sql)) {
|
|
|
|
|
|
return { task_chat_id: task.task_chat_id ?? null };
|
|
|
|
|
|
}
|
2026-08-31 16:35:17 +08:00
|
|
|
|
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 }] : [] };
|
|
|
|
|
|
}
|
|
|
|
|
|
return { results: [] };
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
|
|
|
executed,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
task,
|
2026-08-31 16:35:17 +08:00
|
|
|
|
prepare(sql) {
|
|
|
|
|
|
return makeStatement(sql);
|
|
|
|
|
|
},
|
|
|
|
|
|
async batch(statements) {
|
|
|
|
|
|
for (const statement of statements) await statement.run();
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
function fakeWecomFetch(calls, chats, { failAppFor = [], failChatSend = [] } = {}) {
|
2026-08-31 16:35:17 +08:00
|
|
|
|
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" });
|
|
|
|
|
|
}
|
2026-09-04 18:03:46 +08:00
|
|
|
|
if (url.pathname.endsWith("/cgi-bin/appchat/create")) {
|
|
|
|
|
|
const body = JSON.parse(String(init.body ?? "{}"));
|
|
|
|
|
|
chats.set(body.chatid, { name: body.name, userlist: body.userlist });
|
|
|
|
|
|
return Response.json({ errcode: 0, errmsg: "ok", chatid: body.chatid });
|
|
|
|
|
|
}
|
|
|
|
|
|
if (url.pathname.endsWith("/cgi-bin/appchat/get")) {
|
|
|
|
|
|
const body = JSON.parse(String(init.body ?? "{}"));
|
|
|
|
|
|
const chat = chats.get(body.chatid);
|
|
|
|
|
|
if (!chat) {
|
|
|
|
|
|
return Response.json({ errcode: 60111, errmsg: "chat not found" });
|
|
|
|
|
|
}
|
|
|
|
|
|
return Response.json({
|
|
|
|
|
|
errcode: 0,
|
|
|
|
|
|
errmsg: "ok",
|
|
|
|
|
|
chat_info: { chatid: body.chatid, name: chat.name, userlist: chat.userlist },
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if (url.pathname.endsWith("/cgi-bin/appchat/update")) {
|
|
|
|
|
|
const body = JSON.parse(String(init.body ?? "{}"));
|
|
|
|
|
|
const chat = chats.get(body.chatid);
|
|
|
|
|
|
if (!chat) {
|
|
|
|
|
|
return Response.json({ errcode: 60111, errmsg: "chat not found" });
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const userid of body.add_user_list ?? []) {
|
|
|
|
|
|
if (!chat.userlist.includes(userid)) chat.userlist.push(userid);
|
|
|
|
|
|
}
|
|
|
|
|
|
return Response.json({ errcode: 0, errmsg: "ok" });
|
|
|
|
|
|
}
|
|
|
|
|
|
if (url.pathname.endsWith("/cgi-bin/appchat/send")) {
|
|
|
|
|
|
const body = JSON.parse(String(init.body ?? "{}"));
|
|
|
|
|
|
if (failChatSend.includes(body.chatid) || !chats.has(body.chatid)) {
|
|
|
|
|
|
return Response.json({ errcode: 60111, errmsg: "chat not found" });
|
|
|
|
|
|
}
|
|
|
|
|
|
return Response.json({ errcode: 0, errmsg: "ok" });
|
2026-08-31 16:35:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
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/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
test("pushProjectTask broadcasts once into the main chat and sends the task chat", async () => {
|
2026-08-31 16:35:17 +08:00
|
|
|
|
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
|
|
|
|
|
try {
|
2026-09-04 18:03:46 +08:00
|
|
|
|
const db = fakePushDb({
|
|
|
|
|
|
project: { ...projectRow, main_chat_id: "pgcproj1" },
|
|
|
|
|
|
});
|
|
|
|
|
|
const chats = new Map([
|
|
|
|
|
|
["pgcproj1", { name: "【总群】项目一", userlist: ["zhangsan", "lisi", "owner-proj"] }],
|
|
|
|
|
|
]);
|
2026-08-31 16:35:17 +08:00
|
|
|
|
const calls = [];
|
|
|
|
|
|
const outcome = await pushProjectTask(
|
|
|
|
|
|
db,
|
|
|
|
|
|
{ taskId: "task-1", sendMainGroup: true, sendGroupChats: true },
|
|
|
|
|
|
resolveWecomConfig(baseBindings),
|
2026-09-04 18:03:46 +08:00
|
|
|
|
fakeWecomFetch(calls, chats),
|
2026-08-31 16:35:17 +08:00
|
|
|
|
);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.equal(outcome.mainChatMode, "group");
|
|
|
|
|
|
assert.deepEqual(outcome.appResults, [], "no per-member sends when chat exists");
|
|
|
|
|
|
const chatSends = calls.filter((call) =>
|
|
|
|
|
|
call.url.pathname.endsWith("/cgi-bin/appchat/send"),
|
2026-08-31 16:35:17 +08:00
|
|
|
|
);
|
|
|
|
|
|
assert.deepEqual(
|
2026-09-04 18:03:46 +08:00
|
|
|
|
chatSends.map((call) => JSON.parse(String(call.init.body)).chatid).sort(),
|
|
|
|
|
|
["pgcproj1", "tgctask1"],
|
|
|
|
|
|
"one send into main chat and one into task chat",
|
2026-08-31 16:35:17 +08:00
|
|
|
|
);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.equal(calls.filter((call) => call.url.pathname.endsWith("/cgi-bin/message/send")).length, 0);
|
2026-08-31 16:35:17 +08:00
|
|
|
|
assert.deepEqual(outcome.skippedReasons, []);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.equal(outcome.taskChat.sent, true);
|
|
|
|
|
|
assert.equal(outcome.taskChat.chatId, "tgctask1");
|
2026-08-31 16:35:17 +08:00
|
|
|
|
assert.equal(outcome.linkUrl, "https://portal.example.com/?task=tok-123");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
delete process.env.KOC_PORTAL_URL;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
test("pushProjectTask falls back to per-member messages when main chat missing", async () => {
|
2026-08-31 16:35:17 +08:00
|
|
|
|
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
|
|
|
|
|
try {
|
2026-09-04 18:03:46 +08:00
|
|
|
|
const db = fakePushDb({ project: { ...projectRow, main_chat_id: "" } });
|
|
|
|
|
|
const chats = new Map();
|
2026-08-31 16:35:17 +08:00
|
|
|
|
const calls = [];
|
|
|
|
|
|
const outcome = await pushProjectTask(
|
|
|
|
|
|
db,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
{ taskId: "task-1", sendMainGroup: true, sendGroupChats: true },
|
2026-08-31 16:35:17 +08:00
|
|
|
|
resolveWecomConfig(baseBindings),
|
2026-09-04 18:03:46 +08:00
|
|
|
|
fakeWecomFetch(calls, chats),
|
2026-08-31 16:35:17 +08:00
|
|
|
|
);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.equal(outcome.mainChatMode, "per_member");
|
2026-08-31 16:35:17 +08:00
|
|
|
|
assert.deepEqual(
|
2026-09-04 18:03:46 +08:00
|
|
|
|
outcome.appResults.map((item) => item.userid).sort(),
|
|
|
|
|
|
["lisi", "owner-proj", "zhangsan"],
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.ok(outcome.appResults.every((item) => item.ok));
|
|
|
|
|
|
assert.ok(outcome.skippedReasons.includes("项目总群未建,已逐人私信"));
|
|
|
|
|
|
assert.equal(outcome.taskChat.sent, true);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
delete process.env.KOC_PORTAL_URL;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("single member failure does not block the rest of the per-member push", async () => {
|
|
|
|
|
|
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const db = fakePushDb({ project: { ...projectRow, main_chat_id: "" } });
|
|
|
|
|
|
const calls = [];
|
|
|
|
|
|
const outcome = await pushProjectTask(
|
|
|
|
|
|
db,
|
|
|
|
|
|
{ taskId: "task-1", sendMainGroup: true, sendGroupChats: false },
|
|
|
|
|
|
resolveWecomConfig(baseBindings),
|
|
|
|
|
|
fakeWecomFetch(calls, new Map(), { failAppFor: ["zhangsan"] }),
|
|
|
|
|
|
);
|
|
|
|
|
|
const byUserid = Object.fromEntries(
|
2026-08-31 16:35:17 +08:00
|
|
|
|
outcome.appResults.map((item) => [item.userid, item.ok]),
|
|
|
|
|
|
);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.equal(byUserid.zhangsan, false, "failed member recorded");
|
|
|
|
|
|
assert.equal(byUserid.lisi, true, "other members still sent");
|
|
|
|
|
|
assert.equal(byUserid["owner-proj"], true, "project owner also notified");
|
|
|
|
|
|
assert.match(
|
|
|
|
|
|
outcome.appResults.find((item) => !item.ok).error,
|
|
|
|
|
|
/userid not found/,
|
|
|
|
|
|
);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
delete process.env.KOC_PORTAL_URL;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("main chat send failure degrades to per-member push", async () => {
|
|
|
|
|
|
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const db = fakePushDb({
|
|
|
|
|
|
project: { ...projectRow, main_chat_id: "pgcproj1" },
|
|
|
|
|
|
});
|
|
|
|
|
|
const chats = new Map([
|
|
|
|
|
|
["pgcproj1", { name: "【总群】项目一", userlist: ["zhangsan"] }],
|
|
|
|
|
|
]);
|
|
|
|
|
|
const calls = [];
|
|
|
|
|
|
const outcome = await pushProjectTask(
|
|
|
|
|
|
db,
|
|
|
|
|
|
{ taskId: "task-1", sendMainGroup: true, sendGroupChats: false },
|
|
|
|
|
|
resolveWecomConfig(baseBindings),
|
|
|
|
|
|
fakeWecomFetch(calls, chats, { failChatSend: ["pgcproj1"] }),
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(outcome.mainChatMode, "per_member");
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
outcome.skippedReasons.some((reason) => /降级逐人私信/.test(reason)),
|
2026-08-31 16:35:17 +08:00
|
|
|
|
);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.equal(outcome.appResults.length, 3);
|
2026-08-31 16:35:17 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
delete process.env.KOC_PORTAL_URL;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
test("task chat is skipped with reason when no owner at all", async () => {
|
2026-08-31 16:35:17 +08:00
|
|
|
|
const db = fakePushDb({
|
2026-09-04 18:03:46 +08:00
|
|
|
|
task: { ...taskRow, owner_userid: "" },
|
|
|
|
|
|
project: { ...projectRow, owner_userid: "", main_group_member_ids: "[]" },
|
2026-08-31 16:35:17 +08:00
|
|
|
|
});
|
|
|
|
|
|
const calls = [];
|
|
|
|
|
|
const outcome = await pushProjectTask(
|
|
|
|
|
|
db,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
{ taskId: "task-1", sendMainGroup: false, sendGroupChats: true },
|
|
|
|
|
|
resolveWecomConfig(baseBindings),
|
|
|
|
|
|
fakeWecomFetch(calls, new Map()),
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(outcome.taskChat.sent, false);
|
|
|
|
|
|
assert.equal(outcome.taskChat.chatId, "");
|
|
|
|
|
|
assert.equal(outcome.taskChat.reason, "任务与项目都未填负责人");
|
|
|
|
|
|
assert.ok(outcome.skippedReasons.includes("任务群未发送:任务与项目都未填负责人"));
|
|
|
|
|
|
assert.equal(calls.length, 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("task chat is skipped when fewer than 2 members", async () => {
|
|
|
|
|
|
const db = fakePushDb({
|
|
|
|
|
|
task: { ...taskRow, owner_userid: "" },
|
|
|
|
|
|
project: { ...projectRow, owner_userid: "owner-proj", main_group_member_ids: "[]" },
|
|
|
|
|
|
});
|
|
|
|
|
|
const outcome = await pushProjectTask(
|
|
|
|
|
|
db,
|
|
|
|
|
|
{ taskId: "task-1", sendMainGroup: false, sendGroupChats: true },
|
2026-08-31 16:35:17 +08:00
|
|
|
|
resolveWecomConfig(baseBindings),
|
2026-09-04 18:03:46 +08:00
|
|
|
|
fakeWecomFetch([], new Map()),
|
2026-08-31 16:35:17 +08:00
|
|
|
|
);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.equal(outcome.taskChat.reason, "任务群成员不足 2 人");
|
2026-08-31 16:35:17 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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),
|
2026-09-04 18:03:46 +08:00
|
|
|
|
fakeWecomFetch([], new Map()),
|
2026-08-31 16:35:17 +08:00
|
|
|
|
),
|
|
|
|
|
|
(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),
|
2026-09-04 18:03:46 +08:00
|
|
|
|
fakeWecomFetch([], new Map()),
|
2026-08-31 16:35:17 +08:00
|
|
|
|
),
|
|
|
|
|
|
(error) => error instanceof ProjectError && /至少选择一个发送目标/.test(error.message),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|