Files
koc-loop/tests/task-group-service.test.mjs
ABAPPLO bd80b2d7d8 feat: 项目负责人+内部任务群+portal 内部身份领取+群主链接归属
- 迁移 0012:projects.owner_userid / tasks.task_chat_id / claims.owner_partner_id
- lib/task-group-service.ts:懒建 appchat 任务群(首个内部群主领取时创建,成员 diff 补员),播报/卡片文案
- wecom-client 新增 appchat create/update/get/send 封装
- OAuth ticket 增可选 uid(v:1 兼容),内部成员领取按 wecom_userid 解析群主身份
- 外部 OAuth 领取人 upsert 打 partner_type='group_owner'
- portal 链接带 ?owner= 穿透 OAuth 往返,claims 记录归属群主
- 后台任务卡「群主链接」面板:复制链接/推送内部群主/任务群状态
- 催办与超期升级同步播报进任务群
2026-09-03 11:30:26 +08:00

353 lines
9.7 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 {
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 ?? [
["tgc_task-existing", { name: "【任务】已有群", userlist: ["owner-a"] }],
],
);
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")) {
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, `tgc_${taskId}`);
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,
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,
owner_userid: "owner-a",
},
});
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,
owner_userid: "owner-a",
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-1a2b3c4d", addUserids: ["zhangsan"] },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "tgc_task-1a2b3c4d");
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] === "tgc_task-1a2b3c4d",
),
"chat id persisted to tasks",
);
});
test("ensureTaskChatForMembers reuses stored chat and diffs members", async () => {
const wecom = fakeWecom();
const { db, statements } = fakeDb({
task: {
id: "task-existing",
name: "已有任务",
task_chat_id: "tgc_task-existing",
owner_userid: "owner-a",
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-existing", addUserids: ["zhangsan"] },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "tgc_task-existing");
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 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,
owner_userid: "owner-a",
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-existing", addUserids: ["zhangsan"] },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "tgc_task-existing");
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,
owner_userid: "owner-a",
},
});
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: "tgc_task-existing" },
});
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: "tgc_task-existing" },
});
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, "tgc_task-existing");
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/);
});