- 项目总群:≥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 渲染/解绑清空)
343 lines
12 KiB
JavaScript
343 lines
12 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",
|
||
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),
|
||
);
|
||
});
|