Files
koc-loop/tests/project-push.test.mjs

343 lines
12 KiB
JavaScript
Raw Permalink Normal View History

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",
owner_userid: "owner-task",
task_chat_id: null,
};
const projectRow = {
id: "proj-1",
name: "项目一",
brand: "品牌A",
main_group_member_ids: JSON.stringify(["zhangsan", "lisi", "owner-proj"]),
owner_userid: "owner-proj",
main_chat_id: "",
status: "active",
note: "",
created_at: "2026-08-01",
};
function fakePushDb({ task = { ...taskRow }, project = { ...projectRow } } = {}) {
const executed = [];
const makeStatement = (sql, params = []) => ({
sql,
params,
bind(...args) {
return makeStatement(sql, args);
},
async run() {
executed.push({ sql, params });
if (/UPDATE tasks SET task_chat_id/.test(sql)) {
task.task_chat_id = params[0];
}
return { meta: { changes: 1 } };
},
async first() {
executed.push({ sql, params });
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 };
}
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,
task,
prepare(sql) {
return makeStatement(sql);
},
async batch(statements) {
for (const statement of statements) await statement.run();
},
};
}
function fakeWecomFetch(calls, chats, { failAppFor = [], failChatSend = [] } = {}) {
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/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" });
}
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 broadcasts once into the main chat and sends the task chat", 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", "lisi", "owner-proj"] }],
]);
const calls = [];
const outcome = await pushProjectTask(
db,
{ taskId: "task-1", sendMainGroup: true, sendGroupChats: true },
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls, chats),
);
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"),
);
assert.deepEqual(
chatSends.map((call) => JSON.parse(String(call.init.body)).chatid).sort(),
["pgcproj1", "tgctask1"],
"one send into main chat and one into task chat",
);
assert.equal(calls.filter((call) => call.url.pathname.endsWith("/cgi-bin/message/send")).length, 0);
assert.deepEqual(outcome.skippedReasons, []);
assert.equal(outcome.taskChat.sent, true);
assert.equal(outcome.taskChat.chatId, "tgctask1");
assert.equal(outcome.linkUrl, "https://portal.example.com/?task=tok-123");
} finally {
delete process.env.KOC_PORTAL_URL;
}
});
test("pushProjectTask falls back to per-member messages when main chat missing", async () => {
process.env.KOC_PORTAL_URL = "https://portal.example.com";
try {
const db = fakePushDb({ project: { ...projectRow, main_chat_id: "" } });
const chats = new Map();
const calls = [];
const outcome = await pushProjectTask(
db,
{ taskId: "task-1", sendMainGroup: true, sendGroupChats: true },
resolveWecomConfig(baseBindings),
fakeWecomFetch(calls, chats),
);
assert.equal(outcome.mainChatMode, "per_member");
assert.deepEqual(
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(
outcome.appResults.map((item) => [item.userid, item.ok]),
);
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)),
);
assert.equal(outcome.appResults.length, 3);
} finally {
delete process.env.KOC_PORTAL_URL;
}
});
test("task chat is skipped with reason when no owner at all", async () => {
const db = fakePushDb({
task: { ...taskRow, owner_userid: "" },
project: { ...projectRow, owner_userid: "", main_group_member_ids: "[]" },
});
const calls = [];
const outcome = await pushProjectTask(
db,
{ 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 },
resolveWecomConfig(baseBindings),
fakeWecomFetch([], new Map()),
);
assert.equal(outcome.taskChat.reason, "任务群成员不足 2 人");
});
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([], new Map()),
),
(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([], new Map()),
),
(error) => error instanceof ProjectError && /至少选择一个发送目标/.test(error.message),
);
});