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 记录归属群主 - 后台任务卡「群主链接」面板:复制链接/推送内部群主/任务群状态 - 催办与超期升级同步播报进任务群
This commit is contained in:
@@ -7,6 +7,11 @@ const routeSource = readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const portalSource = readFileSync(
|
||||
new URL("../koc-portal/app/page.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("partner route accepts identityTicket for wecom-backed claim", () => {
|
||||
assert.match(routeSource, /identityTicket\?: string/);
|
||||
assert.match(routeSource, /verifyWecomIdentityTicket/);
|
||||
@@ -33,6 +38,72 @@ test("claim with ticket upserts wecom_external_user_id for reminders", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("internal identity claim resolves the group_owner partner by wecom_userid", () => {
|
||||
assert.match(
|
||||
routeSource,
|
||||
/WHERE wecom_userid = \? AND partner_type = 'group_owner'/,
|
||||
);
|
||||
assert.match(routeSource, /未找到你的群主身份/);
|
||||
assert.match(
|
||||
routeSource,
|
||||
/claimantName = ownerPartner\.name \|\| internalUserid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("external wecom claim upserts partner_type group_owner", () => {
|
||||
assert.match(
|
||||
routeSource,
|
||||
/wecom_external_user_id, partner_type\)\s*\n\s*VALUES \(\?, \?, \?, '外部KOC', 0, 0, \?, 'group_owner'\)/,
|
||||
);
|
||||
assert.match(
|
||||
routeSource,
|
||||
/partner_type = 'group_owner'`/,
|
||||
);
|
||||
});
|
||||
|
||||
test("claim records owner_partner_id after validating a group_owner partner", () => {
|
||||
assert.match(routeSource, /owner\?: string/);
|
||||
assert.match(
|
||||
routeSource,
|
||||
/SELECT id FROM partners WHERE id = \? AND partner_type = 'group_owner'/,
|
||||
);
|
||||
assert.match(
|
||||
routeSource,
|
||||
/INSERT INTO claims\s*\n\s*\(\s*id, task_id, partner_id, owner_partner_id, claimant_name, claim_token, quantity\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("claim schedules task group creation and broadcast in background", () => {
|
||||
assert.match(routeSource, /ensureTaskChatForMembers/);
|
||||
assert.match(routeSource, /taskClaimBroadcastText/);
|
||||
assert.match(routeSource, /taskChatIntroText/);
|
||||
assert.match(routeSource, /runInBackground\(/);
|
||||
assert.match(
|
||||
routeSource,
|
||||
/internalUserid \? \{ addUserids: \[internalUserid\] \} : \{\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("recover resolves internal identity via wecom_userid as well", () => {
|
||||
assert.match(
|
||||
routeSource,
|
||||
/wecomIdentity\?\.userId[\s\S]{0,200}WHERE wecom_userid = \? AND partner_type = 'group_owner'/,
|
||||
);
|
||||
});
|
||||
|
||||
test("portal preserves owner param through the oauth round trip and sends it with claim", () => {
|
||||
assert.match(portalSource, /owner: string;/);
|
||||
assert.match(portalSource, /compact\.set\("o", params\.owner\)/);
|
||||
assert.match(portalSource, /fallback\.set\("o", params\.owner\)/);
|
||||
assert.match(portalSource, /parsed\.get\("o"\) \?\? ""/);
|
||||
assert.match(portalSource, /params\.get\("owner"\) \?\? ""/);
|
||||
assert.match(
|
||||
portalSource,
|
||||
/ownerPartnerId \? \{ owner: ownerPartnerId \} : \{\}/,
|
||||
);
|
||||
assert.match(portalSource, /userType === "internal"/);
|
||||
});
|
||||
|
||||
test("recover builds dynamic partner candidates including wecom identity", () => {
|
||||
assert.match(
|
||||
routeSource,
|
||||
|
||||
@@ -27,20 +27,22 @@ function fakeProjectDb({
|
||||
name: params[1],
|
||||
brand: params[2],
|
||||
main_group_member_ids: params[3],
|
||||
owner_userid: params[4],
|
||||
status: "active",
|
||||
note: params[4],
|
||||
created_at: params[5],
|
||||
note: params[5],
|
||||
created_at: params[6],
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
if (/^UPDATE projects/.test(normalized)) {
|
||||
const row = projects.find((item) => item.id === params[4]);
|
||||
const row = projects.find((item) => item.id === params[5]);
|
||||
if (!row) return 0;
|
||||
Object.assign(row, {
|
||||
name: params[0],
|
||||
brand: params[1],
|
||||
main_group_member_ids: params[2],
|
||||
note: params[3],
|
||||
owner_userid: params[3],
|
||||
note: params[4],
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
@@ -177,10 +179,12 @@ test("saveProject inserts with proj- prefixed id and JSON members", async () =>
|
||||
name: "暑期焕新",
|
||||
brand: "青柠实验室",
|
||||
mainGroupMemberIds: "zhangsan\nlisi\nzhangsan",
|
||||
ownerUserid: "owner-zhang",
|
||||
note: "备注",
|
||||
});
|
||||
assert.match(project.id, /^proj-/);
|
||||
assert.equal(project.name, "暑期焕新");
|
||||
assert.equal(project.ownerUserid, "owner-zhang");
|
||||
assert.deepEqual(project.mainGroupMemberIds, ["zhangsan", "lisi"]);
|
||||
assert.equal(db.projects.length, 1);
|
||||
assert.equal(db.projects[0].status, "active");
|
||||
@@ -205,9 +209,11 @@ test("saveProject updates existing row and rejects blank name", async () => {
|
||||
name: "新名",
|
||||
brand: "品牌",
|
||||
mainGroupMemberIds: ["wangwu"],
|
||||
ownerUserid: "owner-li",
|
||||
});
|
||||
assert.equal(db.projects[0].name, "新名");
|
||||
assert.equal(db.projects[0].brand, "品牌");
|
||||
assert.equal(db.projects[0].owner_userid, "owner-li");
|
||||
assert.deepEqual(JSON.parse(db.projects[0].main_group_member_ids), ["wangwu"]);
|
||||
assert.equal(db.projects.length, 1, "update must not insert");
|
||||
|
||||
|
||||
352
tests/task-group-service.test.mjs
Normal file
352
tests/task-group-service.test.mjs
Normal file
@@ -0,0 +1,352 @@
|
||||
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/);
|
||||
});
|
||||
@@ -3,14 +3,18 @@ import test from "node:test";
|
||||
import {
|
||||
buildWecomOAuthUrl,
|
||||
clearWecomAccessTokenCacheForTests,
|
||||
createAppChat,
|
||||
createGroupMsgTemplate,
|
||||
exchangeWecomOAuthCode,
|
||||
fetchWecomExternalContactName,
|
||||
getAppChat,
|
||||
listCustomerGroupChats,
|
||||
listExternalContacts,
|
||||
resolveWecomConfig,
|
||||
sendAppChatMessage,
|
||||
sendWecomAppMessage,
|
||||
sendWecomRobotMessage,
|
||||
updateAppChat,
|
||||
WecomClientError,
|
||||
} from "../lib/wecom-client.ts";
|
||||
|
||||
@@ -771,3 +775,175 @@ test("fetchWecomExternalContactName resolves name and falls back to null", async
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
function fakeAppChat(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 };
|
||||
}
|
||||
|
||||
test("createAppChat posts name/userlist/chatid and returns chatid", async () => {
|
||||
const { calls, fetchImpl } = fakeAppChat();
|
||||
const result = await createAppChat(
|
||||
resolveWecomConfig(baseBindings),
|
||||
{ name: "【任务】任务名超长会被截断到二十个字符", userlist: ["owner-a", "owner-b"], chatid: "tgc_task-new" },
|
||||
fetchImpl,
|
||||
);
|
||||
assert.equal(result.chatid, "tgc_task-new");
|
||||
const call = calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"));
|
||||
assert.ok(call, "appchat/create was called");
|
||||
assert.equal(call.url.searchParams.get("access_token"), "token-test");
|
||||
const body = JSON.parse(String(call.init.body));
|
||||
assert.equal(body.chatid, "tgc_task-new");
|
||||
assert.equal(body.userlist.length, 2);
|
||||
assert.ok(body.name.length <= 20, "chat name truncated to 20 chars");
|
||||
});
|
||||
|
||||
test("createAppChat rejects fewer than 2 members and oversized chatid", async () => {
|
||||
const { calls, fetchImpl } = fakeAppChat();
|
||||
const config = resolveWecomConfig(baseBindings);
|
||||
await assert.rejects(
|
||||
() => createAppChat(config, { name: "x", userlist: ["only"], chatid: "tgc_t" }, fetchImpl),
|
||||
/至少需要 2 名成员/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
createAppChat(
|
||||
config,
|
||||
{ name: "x", userlist: ["a", "b"], chatid: "c".repeat(33) },
|
||||
fetchImpl,
|
||||
),
|
||||
/32 字符/,
|
||||
);
|
||||
assert.equal(calls.length, 0, "no API calls for invalid payload");
|
||||
});
|
||||
|
||||
test("updateAppChat adds and removes members", async () => {
|
||||
const { calls, fetchImpl, chats } = fakeAppChat();
|
||||
await updateAppChat(
|
||||
resolveWecomConfig(baseBindings),
|
||||
{ chatid: "tgc_task-existing", addUserList: ["owner-b"], delUserList: [] },
|
||||
fetchImpl,
|
||||
);
|
||||
const call = calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update"));
|
||||
assert.ok(call, "appchat/update was called");
|
||||
const body = JSON.parse(String(call.init.body));
|
||||
assert.deepEqual(body.add_user_list, ["owner-b"]);
|
||||
assert.deepEqual(chats.get("tgc_task-existing").userlist, ["owner-a", "owner-b"]);
|
||||
});
|
||||
|
||||
test("updateAppChat skips the API call when there is nothing to change", async () => {
|
||||
const { calls, fetchImpl } = fakeAppChat();
|
||||
await updateAppChat(
|
||||
resolveWecomConfig(baseBindings),
|
||||
{ chatid: "tgc_task-existing", addUserList: [], delUserList: [] },
|
||||
fetchImpl,
|
||||
);
|
||||
assert.equal(
|
||||
calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update")).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("getAppChat returns member list or null on error", async () => {
|
||||
const { fetchImpl } = fakeAppChat();
|
||||
const info = await getAppChat(
|
||||
resolveWecomConfig(baseBindings),
|
||||
"tgc_task-existing",
|
||||
fetchImpl,
|
||||
);
|
||||
assert.equal(info.chatId, "tgc_task-existing");
|
||||
assert.deepEqual(info.userlist, ["owner-a"]);
|
||||
assert.equal(
|
||||
await getAppChat(resolveWecomConfig(baseBindings), "tgc_task-missing", fetchImpl),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("sendAppChatMessage posts text payload to appchat/send", async () => {
|
||||
const { calls, fetchImpl } = fakeAppChat();
|
||||
await sendAppChatMessage(
|
||||
resolveWecomConfig(baseBindings),
|
||||
{ chatid: "tgc_task-existing", text: "【领取】张三 认领 5 份" },
|
||||
fetchImpl,
|
||||
);
|
||||
const call = calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/send"));
|
||||
assert.ok(call, "appchat/send was called");
|
||||
const body = JSON.parse(String(call.init.body));
|
||||
assert.equal(body.chatid, "tgc_task-existing");
|
||||
assert.equal(body.msgtype, "text");
|
||||
assert.equal(body.text.content, "【领取】张三 认领 5 份");
|
||||
});
|
||||
|
||||
test("sendAppChatMessage throws WecomClientError when provider errors", async () => {
|
||||
const { fetchImpl } = fakeAppChat({ chats: [] });
|
||||
await assert.rejects(
|
||||
() =>
|
||||
sendAppChatMessage(
|
||||
resolveWecomConfig(baseBindings),
|
||||
{ chatid: "tgc_task-missing", text: "hello" },
|
||||
fetchImpl,
|
||||
),
|
||||
(error) => {
|
||||
assert.ok(error instanceof WecomClientError);
|
||||
assert.equal(error.status, 502);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -41,6 +41,48 @@ test("identity ticket sign → verify roundtrip", async () => {
|
||||
assert.deepEqual(identity, { externalUserId: "wmExt123", displayName: "张三" });
|
||||
});
|
||||
|
||||
test("internal identity ticket carries userId and no external id", async () => {
|
||||
const { ticket } = await signWecomIdentityTicket("", "", {
|
||||
secret: SECRET,
|
||||
userId: "zhangsan",
|
||||
});
|
||||
const identity = await verifyWecomIdentityTicket(ticket, { secret: SECRET });
|
||||
assert.equal(identity.externalUserId, "");
|
||||
assert.equal(identity.userId, "zhangsan");
|
||||
});
|
||||
|
||||
test("signWecomIdentityTicket rejects when neither external nor internal id present", async () => {
|
||||
await assert.rejects(
|
||||
() => signWecomIdentityTicket("", "", { secret: SECRET }),
|
||||
/缺少企微身份/,
|
||||
);
|
||||
});
|
||||
|
||||
test("internal identity ticket forgery is rejected", async () => {
|
||||
const { ticket } = await signWecomIdentityTicket("", "", {
|
||||
secret: SECRET,
|
||||
userId: "zhangsan",
|
||||
});
|
||||
const [payload, signature] = ticket.split(".");
|
||||
const decoded = JSON.parse(
|
||||
Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString(
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
decoded.uid = "wangwu";
|
||||
const forgedPayload = Buffer.from(JSON.stringify(decoded))
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
assert.equal(
|
||||
await verifyWecomIdentityTicket(`${forgedPayload}.${signature}`, {
|
||||
secret: SECRET,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("identity ticket falls back to default display name when empty", async () => {
|
||||
const { ticket } = await signWecomIdentityTicket("wmExt123", " ", {
|
||||
secret: SECRET,
|
||||
|
||||
Reference in New Issue
Block a user