2026-09-03 11:30:26 +08:00
|
|
|
|
import assert from "node:assert/strict";
|
|
|
|
|
|
import test from "node:test";
|
|
|
|
|
|
import {
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assignTaskOwner,
|
2026-09-03 11:30:26 +08:00
|
|
|
|
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 ?? [
|
2026-09-04 18:03:46 +08:00
|
|
|
|
["tgctaskexisting", { name: "【任务】已有群", userlist: ["owner-a"] }],
|
2026-09-03 11:30:26 +08:00
|
|
|
|
],
|
|
|
|
|
|
);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
const failGet = options.failGet ?? false;
|
2026-09-03 11:30:26 +08:00
|
|
|
|
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")) {
|
2026-09-04 18:03:46 +08:00
|
|
|
|
if (failGet) {
|
|
|
|
|
|
return Response.json({ errcode: 86001, errmsg: "chatid invalid" });
|
|
|
|
|
|
}
|
2026-09-03 11:30:26 +08:00
|
|
|
|
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);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.equal(chatid, "tgctask1a2b3c4d");
|
|
|
|
|
|
assert.ok(!/-/.test(chatid), "hyphens rejected by wecom chatid rules");
|
2026-09-03 11:30:26 +08:00
|
|
|
|
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,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
task_owner_userid: null,
|
|
|
|
|
|
project_owner_userid: null,
|
2026-09-03 11:30:26 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
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,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
task_owner_userid: "owner-a",
|
|
|
|
|
|
project_owner_userid: null,
|
2026-09-03 11:30:26 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
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,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
task_owner_userid: "owner-a",
|
|
|
|
|
|
project_owner_userid: null,
|
2026-09-03 11:30:26 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
const result = await ensureTaskChatForMembers(
|
|
|
|
|
|
db,
|
|
|
|
|
|
{ taskId: "task-1a2b3c4d", addUserids: ["zhangsan"] },
|
|
|
|
|
|
resolveWecomConfig(baseBindings),
|
|
|
|
|
|
wecom.fetchImpl,
|
|
|
|
|
|
);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.equal(result.chatId, "tgctask1a2b3c4d");
|
2026-09-03 11:30:26 +08:00
|
|
|
|
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) &&
|
2026-09-04 18:03:46 +08:00
|
|
|
|
statement.values[0] === "tgctask1a2b3c4d",
|
2026-09-03 11:30:26 +08:00
|
|
|
|
),
|
|
|
|
|
|
"chat id persisted to tasks",
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
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"]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-03 11:30:26 +08:00
|
|
|
|
test("ensureTaskChatForMembers reuses stored chat and diffs members", async () => {
|
|
|
|
|
|
const wecom = fakeWecom();
|
|
|
|
|
|
const { db, statements } = fakeDb({
|
|
|
|
|
|
task: {
|
|
|
|
|
|
id: "task-existing",
|
|
|
|
|
|
name: "已有任务",
|
2026-09-04 18:03:46 +08:00
|
|
|
|
task_chat_id: "tgctaskexisting",
|
|
|
|
|
|
task_owner_userid: "owner-a",
|
|
|
|
|
|
project_owner_userid: null,
|
2026-09-03 11:30:26 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
const result = await ensureTaskChatForMembers(
|
|
|
|
|
|
db,
|
|
|
|
|
|
{ taskId: "task-existing", addUserids: ["zhangsan"] },
|
|
|
|
|
|
resolveWecomConfig(baseBindings),
|
|
|
|
|
|
wecom.fetchImpl,
|
|
|
|
|
|
);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.equal(result.chatId, "tgctaskexisting");
|
2026-09-03 11:30:26 +08:00
|
|
|
|
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"]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-03 11:30:26 +08:00
|
|
|
|
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,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
task_owner_userid: "owner-a",
|
|
|
|
|
|
project_owner_userid: null,
|
2026-09-03 11:30:26 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
const result = await ensureTaskChatForMembers(
|
|
|
|
|
|
db,
|
|
|
|
|
|
{ taskId: "task-existing", addUserids: ["zhangsan"] },
|
|
|
|
|
|
resolveWecomConfig(baseBindings),
|
|
|
|
|
|
wecom.fetchImpl,
|
|
|
|
|
|
);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.equal(result.chatId, "tgctaskexisting");
|
2026-09-03 11:30:26 +08:00
|
|
|
|
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,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
task_owner_userid: "owner-a",
|
|
|
|
|
|
project_owner_userid: null,
|
2026-09-03 11:30:26 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
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({
|
2026-09-04 18:03:46 +08:00
|
|
|
|
task: { id: "task-existing", task_chat_id: "tgctaskexisting" },
|
2026-09-03 11:30:26 +08:00
|
|
|
|
});
|
|
|
|
|
|
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({
|
2026-09-04 18:03:46 +08:00
|
|
|
|
task: { id: "task-existing", task_chat_id: "tgctaskexisting" },
|
2026-09-03 11:30:26 +08:00
|
|
|
|
});
|
|
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.equal(sendBody.chatid, "tgctaskexisting");
|
2026-09-03 11:30:26 +08:00
|
|
|
|
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/);
|
|
|
|
|
|
});
|
2026-09-04 18:03:46 +08:00
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
});
|