- 迁移 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 记录归属群主 - 后台任务卡「群主链接」面板:复制链接/推送内部群主/任务群状态 - 催办与超期升级同步播报进任务群
353 lines
9.7 KiB
JavaScript
353 lines
9.7 KiB
JavaScript
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/);
|
||
});
|