Files
koc-loop/tests/project-push.test.mjs
ABAPPLO dd5b603149 feat: 项目总群自动建群+任务负责人后台视角+客户群活码进群+任务编辑
- 项目总群:≥3 人 appchat/create 自动建群(chatid=pgc+projectId),成员 diff 增删同步;DROP project_group_chats;推送改 appchat/send 群发、无群降级逐人
- 任务负责人:tasks.owner_userid(新建/编辑任务通讯录选人),task_set_owner 分配即预建 tgc 任务群发介绍,群主链接只归属负责人
- 任务编辑 task_update:基础字段+截图任务数量/关键词/说明,contents 调平只删 available 尾行
- 删除「一键推送」:task-one-click-push-service 整体移除(recordCardAck 移入 wecom-callback-events)
- 负责人后台视角:users.wecom_userid 绑定,lib/owner-scope 行级过滤(undefined=全量/""=空范围/非空=过滤),~30 action 补 managerGate/taskOwnerGate,owner 登录仅见工作台/任务中心/数据回收/复盘报表
- 任务绑定客户群+进群活码:tasks 增 customer_chat_id/name、join_config_id、join_qr_code 4 列,join_way add/update/get/del(换绑走 update 保 config_id 活码不变),后台「客户群」弹窗,portal 认领后扫码自助进群(满员自动开新群)
- 迁移 0013/0014/0015;催办支持按任务;企微可信域名 IP 验证文件
- 288 测试全绿;真机全链路验收通过(列群/活码四端点/portal 渲染/解绑清空)
2026-09-04 18:03:46 +08:00

343 lines
12 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",
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),
);
});