Files
koc-loop/tests/task-group-service.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

640 lines
20 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 {
assignTaskOwner,
ensureTaskChatForMembers,
sendTaskChatMessage,
taskChatId,
taskClaimBroadcastText,
taskChatIntroText,
} from "../lib/task-group-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",
};
test.beforeEach(() => {
clearWecomAccessTokenCacheForTests();
});
function fakeWecom(options = {}) {
const calls = [];
const chats = new Map(
options.chats ?? [
["tgctaskexisting", { name: "【任务】已有群", userlist: ["owner-a"] }],
],
);
const failGet = options.failGet ?? false;
const fetchImpl = 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/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/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/get")) {
if (failGet) {
return Response.json({ errcode: 86001, errmsg: "chatid invalid" });
}
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/send")) {
const body = JSON.parse(String(init.body ?? "{}"));
if (!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 });
};
return { calls, fetchImpl, chats };
}
function fakeDb({ task }) {
const statements = [];
const db = {
prepare(sql) {
return {
bind(...values) {
return {
async first() {
if (/FROM tasks t\s+LEFT JOIN projects p/.test(sql)) {
return task;
}
if (/SELECT task_chat_id FROM tasks/.test(sql)) {
return { task_chat_id: task.task_chat_id ?? null };
}
return null;
},
async run() {
statements.push({ sql, values });
return { meta: { changes: 1, last_row_id: 0 } };
},
async all() {
return { results: [] };
},
};
},
};
},
};
return { db, statements };
}
test("taskChatId derives a deterministic chatid within 32 chars", () => {
const taskId = "task-1a2b3c4d";
const chatid = taskChatId(taskId);
assert.equal(chatid, "tgctask1a2b3c4d");
assert.ok(!/-/.test(chatid), "hyphens rejected by wecom chatid rules");
assert.ok(chatid.length <= 32);
});
test("ensureTaskChatForMembers skips when project has no owner", async () => {
const wecom = fakeWecom();
const { db } = fakeDb({
task: {
id: "task-1",
name: "任务一",
task_chat_id: null,
task_owner_userid: null,
project_owner_userid: null,
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-1", addUserids: ["zhangsan"] },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, null);
assert.equal(result.reason, "no_owner");
assert.equal(wecom.calls.length, 0);
});
test("ensureTaskChatForMembers skips creation when fewer than 2 members", async () => {
const wecom = fakeWecom();
const { db } = fakeDb({
task: {
id: "task-1",
name: "任务一",
task_chat_id: null,
task_owner_userid: "owner-a",
project_owner_userid: null,
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-1" },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, null);
assert.equal(result.reason, "not_enough_members");
assert.equal(
wecom.calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"))
.length,
0,
);
});
test("ensureTaskChatForMembers creates the chat lazily and stores chat id", async () => {
const wecom = fakeWecom({ chats: [] });
const { db, statements } = fakeDb({
task: {
id: "task-1a2b3c4d",
name: "任务一",
task_chat_id: null,
task_owner_userid: "owner-a",
project_owner_userid: null,
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-1a2b3c4d", addUserids: ["zhangsan"] },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "tgctask1a2b3c4d");
assert.equal(result.created, true);
const createBody = JSON.parse(
String(
wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"))
.init.body,
),
);
assert.deepEqual(createBody.userlist.sort(), ["owner-a", "zhangsan"]);
assert.ok(
statements.some(
(statement) =>
/UPDATE tasks SET task_chat_id/.test(statement.sql) &&
statement.values[0] === "tgctask1a2b3c4d",
),
"chat id persisted to tasks",
);
});
test("ensureTaskChatForMembers dedupes project owner, task owner and claimants", async () => {
const wecom = fakeWecom({ chats: [] });
const { db } = fakeDb({
task: {
id: "task-1",
name: "任务一",
task_chat_id: null,
task_owner_userid: "owner-a",
project_owner_userid: "owner-a",
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-1", addUserids: ["owner-a", "zhangsan"] },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "tgctask1");
const createBody = JSON.parse(
String(
wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"))
.init.body,
),
);
assert.deepEqual(createBody.userlist.sort(), ["owner-a", "zhangsan"]);
});
test("ensureTaskChatForMembers reuses stored chat and diffs members", async () => {
const wecom = fakeWecom();
const { db, statements } = fakeDb({
task: {
id: "task-existing",
name: "已有任务",
task_chat_id: "tgctaskexisting",
task_owner_userid: "owner-a",
project_owner_userid: null,
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-existing", addUserids: ["zhangsan"] },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "tgctaskexisting");
assert.equal(result.created, false);
assert.equal(
statements.filter((s) => /UPDATE tasks/.test(s.sql)).length,
0,
"no redundant chat id update",
);
const updateBody = JSON.parse(
String(
wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update"))
.init.body,
),
);
assert.deepEqual(updateBody.add_user_list, ["zhangsan"]);
});
test("ensureTaskChatForMembers syncs add-only when get is unavailable", async () => {
// 可见范围非根部门时 appchat/get 拿不到群成员,降级为全量只增同步
const wecom = fakeWecom({
failGet: true,
chats: [["tgctaskexisting", { name: "【任务】已有群", userlist: ["owner-a"] }]],
});
const { db } = fakeDb({
task: {
id: "task-existing",
name: "已有任务",
task_chat_id: "tgctaskexisting",
task_owner_userid: "owner-a",
project_owner_userid: null,
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-existing", addUserids: ["zhangsan"] },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "tgctaskexisting");
const updateBody = JSON.parse(
String(
wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update"))
.init.body,
),
);
assert.deepEqual(updateBody.add_user_list.sort(), ["owner-a", "zhangsan"]);
assert.equal(updateBody.del_user_list, undefined, "add-only fallback");
});
test("ensureTaskChatForMembers recovers when chat exists remotely but not in db", async () => {
const wecom = fakeWecom();
const { db, statements } = fakeDb({
task: {
id: "task-existing",
name: "已有任务",
task_chat_id: null,
task_owner_userid: "owner-a",
project_owner_userid: null,
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-existing", addUserids: ["zhangsan"] },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "tgctaskexisting");
assert.equal(result.created, false);
assert.ok(statements.some((s) => /UPDATE tasks SET task_chat_id/.test(s.sql)));
});
test("ensureTaskChatForMembers skips without app credentials", async () => {
const wecom = fakeWecom();
const { db } = fakeDb({
task: {
id: "task-1",
name: "任务一",
task_chat_id: null,
task_owner_userid: "owner-a",
project_owner_userid: null,
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-1", addUserids: ["zhangsan"] },
resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }),
wecom.fetchImpl,
);
assert.equal(result.reason, "no_credentials");
assert.equal(wecom.calls.length, 0);
});
test("sendTaskChatMessage no-ops without chat id and swallows errors", async () => {
const wecom = fakeWecom();
const noChat = fakeDb({
task: { id: "task-1", task_chat_id: null },
});
assert.equal(
await sendTaskChatMessage(
noChat.db,
"task-1",
"hi",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
),
false,
);
assert.equal(wecom.calls.length, 0);
const failing = fakeWecom({ chats: [] });
const withChat = fakeDb({
task: { id: "task-existing", task_chat_id: "tgctaskexisting" },
});
assert.equal(
await sendTaskChatMessage(
withChat.db,
"task-existing",
"hi",
resolveWecomConfig(baseBindings),
failing.fetchImpl,
),
false,
"provider error swallowed",
);
});
test("sendTaskChatMessage sends text into the task chat", async () => {
const wecom = fakeWecom();
const { db } = fakeDb({
task: { id: "task-existing", task_chat_id: "tgctaskexisting" },
});
assert.equal(
await sendTaskChatMessage(
db,
"task-existing",
"【领取】张三 认领 5 份",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
),
true,
);
const sendBody = JSON.parse(
String(
wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/send"))
.init.body,
),
);
assert.equal(sendBody.chatid, "tgctaskexisting");
assert.equal(sendBody.text.content, "【领取】张三 认领 5 份");
});
test("broadcast and intro text builders format messages", () => {
assert.equal(
taskClaimBroadcastText("任务一", "张三", 5),
"【领取】张三 认领 5 份 · 任务一",
);
const intro = taskChatIntroText({
name: "任务一",
brand: "品牌A",
quantity: 10,
dueAt: "2026-09-30",
});
assert.match(intro, /【新任务】任务一/);
assert.match(intro, /品牌:品牌A/);
assert.match(intro, /截止:2026-09-30/);
});
function assignDb({ task, project, partners = [] }) {
const store = {
task: { ...task },
project: project ? { ...project } : null,
partners: partners.map((item) => ({ ...item })),
};
const statements = [];
const db = {
prepare(sql) {
return {
bind(...values) {
return {
async first() {
statements.push({ sql, values });
if (/FROM tasks t\s+LEFT JOIN projects p/.test(sql)) {
return {
id: store.task.id,
name: store.task.name,
task_chat_id: store.task.task_chat_id ?? null,
task_owner_userid: store.task.owner_userid ?? null,
project_owner_userid: store.project?.owner_userid ?? null,
};
}
if (/SELECT task_chat_id FROM tasks/.test(sql)) {
return { task_chat_id: store.task.task_chat_id ?? null };
}
if (/SELECT id, name, brand, quantity, due_at, project_id FROM tasks/.test(sql)) {
return { ...store.task };
}
if (/SELECT main_group_member_ids, owner_userid FROM projects/.test(sql)) {
return store.project
? {
main_group_member_ids: store.project.main_group_member_ids,
owner_userid: store.project.owner_userid ?? "",
}
: null;
}
if (/SELECT id, name, main_group_member_ids, owner_userid, main_chat_id FROM projects/.test(sql)) {
return store.project
? {
id: store.project.id,
name: store.project.name,
main_group_member_ids: store.project.main_group_member_ids,
owner_userid: store.project.owner_userid ?? "",
main_chat_id: store.project.main_chat_id ?? "",
}
: null;
}
if (/partner_type = 'group_owner' AND wecom_userid = \?/.test(sql)) {
return (
store.partners.find(
(item) =>
item.partner_type === "group_owner" &&
item.wecom_userid === values[0],
) ?? null
);
}
if (/FROM partners WHERE id = \?/.test(sql)) {
return store.partners.find((item) => item.id === values[0]) ?? null;
}
return null;
},
async run() {
statements.push({ sql, values });
if (/UPDATE tasks SET owner_userid/.test(sql)) {
store.task.owner_userid = values[0];
} else if (/UPDATE tasks SET task_chat_id/.test(sql)) {
store.task.task_chat_id = values[0];
} else if (/UPDATE projects SET main_group_member_ids/.test(sql)) {
if (store.project) store.project.main_group_member_ids = values[0];
} else if (/UPDATE projects SET main_chat_id/.test(sql)) {
if (store.project) store.project.main_chat_id = values[0];
} else if (/INSERT INTO partners/.test(sql)) {
store.partners.push({
id: values[0],
name: values[1],
wecom_userid: values[2],
partner_type: "group_owner",
});
}
return { meta: { changes: 1, last_row_id: 0 } };
},
async all() {
statements.push({ sql, values });
return { results: [] };
},
};
},
};
},
};
return { db, statements, store };
}
test("assignTaskOwner provisions partner, main chat and task chat with intro", async () => {
const wecom = fakeWecom({ chats: [] });
const { db, statements, store } = assignDb({
task: {
id: "task-1",
name: "任务一",
brand: "品牌A",
quantity: 10,
due_at: "2026-09-30",
project_id: "proj-1",
owner_userid: "",
task_chat_id: null,
},
project: {
id: "proj-1",
name: "项目一",
owner_userid: "proj-owner",
main_group_member_ids: JSON.stringify(["proj-owner", "u1", "u2"]),
main_chat_id: "",
},
});
const result = await assignTaskOwner(
db,
{ taskId: "task-1", ownerUserid: "owner-a" },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.ownerUserid, "owner-a");
assert.equal(result.chatCreated, true);
assert.equal(result.chatId, "tgctask1");
assert.match(result.partnerId, /^partner-go-/);
assert.equal(store.task.owner_userid, "owner-a");
assert.deepEqual(
JSON.parse(store.project.main_group_member_ids).sort(),
["proj-owner", "u1", "u2", "owner-a"].sort(),
"task owner merged into main group members",
);
assert.equal(store.project.main_chat_id, "pgcproj1", "main chat created lazily");
assert.ok(wecom.chats.has("pgcproj1"));
assert.ok(wecom.chats.has("tgctask1"));
const sendBodies = wecom.calls
.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/send"))
.map((c) => JSON.parse(String(c.init.body)));
assert.equal(sendBodies.length, 1, "intro message sent once to task chat");
assert.equal(sendBodies[0].chatid, "tgctask1");
assert.match(sendBodies[0].text.content, /【新任务】任务一/);
assert.ok(
statements.some((s) => /INSERT INTO partners/.test(s.sql)),
"partner insert recorded",
);
});
test("assignTaskOwner reuses the existing group_owner partner", async () => {
const wecom = fakeWecom({ chats: [] });
const { db, statements, store } = assignDb({
task: {
id: "task-1",
name: "任务一",
brand: "品牌A",
quantity: 10,
due_at: "2026-09-30",
project_id: "",
owner_userid: "",
task_chat_id: null,
},
partners: [
{ id: "partner-go-existing", name: "老负责人", wecom_userid: "owner-a", partner_type: "group_owner" },
],
});
const result = await assignTaskOwner(
db,
{ taskId: "task-1", ownerUserid: "owner-a" },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.partnerId, "partner-go-existing");
assert.equal(
statements.filter((s) => /INSERT INTO partners/.test(s.sql)).length,
0,
"no duplicate partner insert",
);
assert.equal(store.partners.length, 1);
});
test("assignTaskOwner collects wecom failure as warning instead of throwing", async () => {
const fetchImpl = async (input) => {
const url = new URL(String(input));
if (url.pathname.endsWith("/cgi-bin/gettoken")) {
return Response.json({ errcode: 0, errmsg: "ok", access_token: "t", expires_in: 7200 });
}
return Response.json({ errcode: 60111, errmsg: "chat not found" });
};
const { db, store } = assignDb({
task: {
id: "task-1",
name: "任务一",
brand: "品牌A",
quantity: 10,
due_at: "2026-09-30",
project_id: "proj-1",
owner_userid: "",
task_chat_id: null,
},
project: {
id: "proj-1",
name: "项目一",
owner_userid: "proj-owner",
main_group_member_ids: JSON.stringify(["u1", "u2"]),
main_chat_id: "",
},
});
const result = await assignTaskOwner(
db,
{ taskId: "task-1", ownerUserid: "owner-a" },
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.equal(result.ownerUserid, "owner-a");
assert.ok(result.warning, "wecom failure surfaced as warning");
assert.equal(store.task.owner_userid, "owner-a", "owner still persisted");
});