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:
@@ -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;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user