feat: 项目总群自动建群+任务负责人后台视角+客户群活码进群+任务编辑
- 项目总群:≥3 人 appchat/create 自动建群(chatid=pgc+projectId),成员 diff 增删同步;DROP project_group_chats;推送改 appchat/send 群发、无群降级逐人 - 任务负责人:tasks.owner_userid(新建/编辑任务通讯录选人),task_set_owner 分配即预建 tgc 任务群发介绍,群主链接只归属负责人 - 任务编辑 task_update:基础字段+截图任务数量/关键词/说明,contents 调平只删 available 尾行 - 删除「一键推送」:task-one-click-push-service 整体移除(recordCardAck 移入 wecom-callback-events) - 负责人后台视角:users.wecom_userid 绑定,lib/owner-scope 行级过滤(undefined=全量/""=空范围/非空=过滤),~30 action 补 managerGate/taskOwnerGate,owner 登录仅见工作台/任务中心/数据回收/复盘报表 - 任务绑定客户群+进群活码:tasks 增 customer_chat_id/name、join_config_id、join_qr_code 4 列,join_way add/update/get/del(换绑走 update 保 config_id 活码不变),后台「客户群」弹窗,portal 认领后扫码自助进群(满员自动开新群) - 迁移 0013/0014/0015;催办支持按任务;企微可信域名 IP 验证文件 - 288 测试全绿;真机全链路验收通过(列群/活码四端点/portal 渲染/解绑清空)
This commit is contained in:
@@ -1,23 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
projectMainChatId,
|
||||
deleteProject,
|
||||
ensureProjectMainChat,
|
||||
effectiveProjectMembers,
|
||||
findOrCreateGroupOwnerPartner,
|
||||
getProjectDetail,
|
||||
listProjects,
|
||||
parseMemberIds,
|
||||
ProjectError,
|
||||
saveGroupOwnerPartner,
|
||||
saveProject,
|
||||
setProjectGroupChats,
|
||||
} from "../lib/project-service.ts";
|
||||
import {
|
||||
clearWecomAccessTokenCacheForTests,
|
||||
resolveWecomConfig,
|
||||
} from "../lib/wecom-client.ts";
|
||||
|
||||
function fakeProjectDb({
|
||||
projects = [],
|
||||
links = [],
|
||||
tasks = [],
|
||||
groupChats = [],
|
||||
partners = [],
|
||||
} = {}) {
|
||||
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 ?? []);
|
||||
const failCreate = options.failCreate ?? false;
|
||||
const failGet = options.failGet ?? false;
|
||||
const createExisted = options.createExisted ?? false;
|
||||
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")) {
|
||||
if (createExisted) {
|
||||
return Response.json({ errcode: 86215, errmsg: "chatid existed" });
|
||||
}
|
||||
if (failCreate) {
|
||||
return Response.json({ errcode: 60111, errmsg: "user not in scope" });
|
||||
}
|
||||
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);
|
||||
}
|
||||
for (const userid of body.del_user_list ?? []) {
|
||||
chat.userlist = chat.userlist.filter((id) => id !== userid);
|
||||
}
|
||||
return Response.json({ errcode: 0, errmsg: "ok" });
|
||||
}
|
||||
if (url.pathname.endsWith("/cgi-bin/appchat/get")) {
|
||||
if (failGet) {
|
||||
return Response.json({ errcode: 86001, errmsg: "chatid invalid" });
|
||||
}
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
return { calls, fetchImpl, chats };
|
||||
}
|
||||
|
||||
function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
|
||||
const executed = [];
|
||||
const applyWrite = (sql, params) => {
|
||||
const normalized = sql.trim();
|
||||
@@ -28,12 +105,18 @@ function fakeProjectDb({
|
||||
brand: params[2],
|
||||
main_group_member_ids: params[3],
|
||||
owner_userid: params[4],
|
||||
main_chat_id: "",
|
||||
status: "active",
|
||||
note: params[5],
|
||||
created_at: params[6],
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
if (/^UPDATE projects SET main_chat_id/.test(normalized)) {
|
||||
const row = projects.find((item) => item.id === params[1]);
|
||||
if (row) row.main_chat_id = params[0];
|
||||
return row ? 1 : 0;
|
||||
}
|
||||
if (/^UPDATE projects/.test(normalized)) {
|
||||
const row = projects.find((item) => item.id === params[5]);
|
||||
if (!row) return 0;
|
||||
@@ -52,16 +135,6 @@ function fakeProjectDb({
|
||||
projects.splice(index, 1);
|
||||
return 1;
|
||||
}
|
||||
if (/^INSERT INTO project_group_chats/.test(normalized)) {
|
||||
links.push({ project_id: params[1], chat_id: params[2], partner_id: params[3] });
|
||||
return 1;
|
||||
}
|
||||
if (/^DELETE FROM project_group_chats/.test(normalized)) {
|
||||
for (let index = links.length - 1; index >= 0; index -= 1) {
|
||||
if (links[index].project_id === params[0]) links.splice(index, 1);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
if (/^UPDATE tasks SET project_id = ''/.test(normalized)) {
|
||||
for (const task of tasks) {
|
||||
if (task.project_id === params[0]) task.project_id = "";
|
||||
@@ -103,6 +176,15 @@ function fakeProjectDb({
|
||||
if (/FROM partners WHERE id = \?/.test(sql)) {
|
||||
return partners.find((item) => item.id === params[0]) ?? null;
|
||||
}
|
||||
if (/partner_type = 'group_owner' AND wecom_userid = \?/.test(sql)) {
|
||||
return (
|
||||
partners.find(
|
||||
(item) =>
|
||||
item.partner_type === "group_owner" &&
|
||||
item.wecom_userid === params[0],
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async all() {
|
||||
@@ -112,7 +194,6 @@ function fakeProjectDb({
|
||||
results: projects.map((item) => ({
|
||||
...item,
|
||||
task_count: tasks.filter((task) => task.project_id === item.id).length,
|
||||
group_chat_count: links.filter((link) => link.project_id === item.id).length,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -123,25 +204,6 @@ function fakeProjectDb({
|
||||
.map((task) => ({ ...task })),
|
||||
};
|
||||
}
|
||||
if (/FROM project_group_chats g/.test(sql)) {
|
||||
return {
|
||||
results: links
|
||||
.filter((link) => link.project_id === params[0])
|
||||
.map((link) => {
|
||||
const chat = groupChats.find((item) => item.chat_id === link.chat_id);
|
||||
return {
|
||||
chat_id: link.chat_id,
|
||||
partner_id: link.partner_id,
|
||||
chat_name: chat ? chat.name : null,
|
||||
owner_user_id: chat ? chat.owner_user_id : null,
|
||||
member_count: chat ? chat.member_count : null,
|
||||
chat_status: chat ? chat.status : null,
|
||||
partner_name:
|
||||
partners.find((item) => item.id === link.partner_id)?.name ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (/FROM partners/.test(sql)) {
|
||||
return { results: partners.map((item) => ({ ...item })) };
|
||||
}
|
||||
@@ -150,9 +212,7 @@ function fakeProjectDb({
|
||||
});
|
||||
return {
|
||||
projects,
|
||||
links,
|
||||
tasks,
|
||||
groupChats,
|
||||
partners,
|
||||
executed,
|
||||
prepare(sql) {
|
||||
@@ -166,6 +226,19 @@ function fakeProjectDb({
|
||||
};
|
||||
}
|
||||
|
||||
const projectRow = (overrides = {}) => ({
|
||||
id: "proj-1",
|
||||
name: "A",
|
||||
brand: "",
|
||||
main_group_member_ids: "[]",
|
||||
owner_userid: "",
|
||||
main_chat_id: "",
|
||||
status: "active",
|
||||
note: "",
|
||||
created_at: "2026-08-01",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("parseMemberIds accepts lines, arrays and mixed separators, dedupes", () => {
|
||||
assert.deepEqual(parseMemberIds("zhangsan\nlisi zhangsan"), ["zhangsan", "lisi"]);
|
||||
assert.deepEqual(parseMemberIds(["a", " b ", "a"]), ["a", "b"]);
|
||||
@@ -173,9 +246,18 @@ test("parseMemberIds accepts lines, arrays and mixed separators, dedupes", () =>
|
||||
assert.deepEqual(parseMemberIds(" \n "), []);
|
||||
});
|
||||
|
||||
test("saveProject inserts with proj- prefixed id and JSON members", async () => {
|
||||
test("effectiveProjectMembers parses stored JSON and merges owner", () => {
|
||||
assert.deepEqual(
|
||||
effectiveProjectMembers('["u1","u2"]', "owner-a"),
|
||||
["u1", "u2", "owner-a"],
|
||||
);
|
||||
assert.deepEqual(effectiveProjectMembers("u1\nu1,u2", ""), ["u1", "u2"]);
|
||||
assert.deepEqual(effectiveProjectMembers('["u1"]', "u1"), ["u1"]);
|
||||
});
|
||||
|
||||
test("saveProject inserts with proj- prefixed id and JSON members incl owner", async () => {
|
||||
const db = fakeProjectDb();
|
||||
const project = await saveProject(db, {
|
||||
const { project, mainChat } = await saveProject(db, {
|
||||
name: "暑期焕新",
|
||||
brand: "青柠实验室",
|
||||
mainGroupMemberIds: "zhangsan\nlisi\nzhangsan",
|
||||
@@ -185,24 +267,20 @@ test("saveProject inserts with proj- prefixed id and JSON members", async () =>
|
||||
assert.match(project.id, /^proj-/);
|
||||
assert.equal(project.name, "暑期焕新");
|
||||
assert.equal(project.ownerUserid, "owner-zhang");
|
||||
assert.deepEqual(project.mainGroupMemberIds, ["zhangsan", "lisi"]);
|
||||
assert.deepEqual(project.mainGroupMemberIds, ["zhangsan", "lisi", "owner-zhang"]);
|
||||
assert.equal(db.projects.length, 1);
|
||||
assert.equal(db.projects[0].status, "active");
|
||||
assert.deepEqual(JSON.parse(db.projects[0].main_group_member_ids), [
|
||||
"zhangsan",
|
||||
"lisi",
|
||||
"owner-zhang",
|
||||
]);
|
||||
assert.equal(mainChat.reason, "no_credentials", "no config → lazy chat");
|
||||
});
|
||||
|
||||
test("saveProject updates existing row and rejects blank name", async () => {
|
||||
const db = fakeProjectDb({
|
||||
projects: [
|
||||
{
|
||||
id: "proj-1",
|
||||
name: "旧名",
|
||||
brand: "",
|
||||
main_group_member_ids: "[]",
|
||||
status: "active",
|
||||
note: "",
|
||||
created_at: "2026-08-01",
|
||||
},
|
||||
],
|
||||
projects: [projectRow({ id: "proj-1", name: "旧名" })],
|
||||
});
|
||||
await saveProject(db, {
|
||||
id: "proj-1",
|
||||
@@ -214,7 +292,10 @@ test("saveProject updates existing row and rejects blank name", async () => {
|
||||
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.deepEqual(JSON.parse(db.projects[0].main_group_member_ids), [
|
||||
"wangwu",
|
||||
"owner-li",
|
||||
]);
|
||||
assert.equal(db.projects.length, 1, "update must not insert");
|
||||
|
||||
await assert.rejects(
|
||||
@@ -231,63 +312,81 @@ test("saveProject rejects unknown id on update", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("listProjects aggregates task and group chat counts", async () => {
|
||||
test("saveProject with config creates main chat once members reach 3", async () => {
|
||||
const db = fakeProjectDb();
|
||||
const wecom = fakeWecom();
|
||||
const { project, mainChat } = await saveProject(
|
||||
db,
|
||||
{
|
||||
name: "满三人群",
|
||||
mainGroupMemberIds: "u1\nu2",
|
||||
ownerUserid: "owner-a",
|
||||
},
|
||||
resolveWecomConfig(baseBindings),
|
||||
wecom.fetchImpl,
|
||||
);
|
||||
assert.equal(mainChat.chatId, projectMainChatId(project.id));
|
||||
assert.equal(mainChat.reason, undefined);
|
||||
assert.equal(db.projects[0].main_chat_id, projectMainChatId(project.id));
|
||||
const createBody = JSON.parse(
|
||||
String(
|
||||
wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"))
|
||||
.init.body,
|
||||
),
|
||||
);
|
||||
assert.equal(createBody.chatid, projectMainChatId(project.id));
|
||||
assert.deepEqual(createBody.userlist.sort(), ["owner-a", "u1", "u2"]);
|
||||
});
|
||||
|
||||
test("listProjects aggregates task counts and exposes mainChatId", async () => {
|
||||
const db = fakeProjectDb({
|
||||
projects: [
|
||||
{
|
||||
projectRow({
|
||||
id: "proj-1",
|
||||
name: "A",
|
||||
brand: "",
|
||||
main_group_member_ids: "[\"u1\",\"u2\"]",
|
||||
status: "active",
|
||||
note: "",
|
||||
created_at: "2026-08-01",
|
||||
},
|
||||
main_group_member_ids: '["u1","u2"]',
|
||||
main_chat_id: "pgcproj1",
|
||||
}),
|
||||
],
|
||||
tasks: [
|
||||
{ id: "t1", project_id: "proj-1" },
|
||||
{ id: "t2", project_id: "proj-1" },
|
||||
{ id: "t3", project_id: "" },
|
||||
],
|
||||
links: [{ project_id: "proj-1", chat_id: "wr-1", partner_id: "" }],
|
||||
});
|
||||
const projects = await listProjects(db);
|
||||
assert.equal(projects.length, 1);
|
||||
assert.equal(projects[0].taskCount, 2);
|
||||
assert.equal(projects[0].groupChatCount, 1);
|
||||
assert.equal(projects[0].mainChatId, "pgcproj1");
|
||||
assert.deepEqual(projects[0].mainGroupMemberIds, ["u1", "u2"]);
|
||||
});
|
||||
|
||||
test("getProjectDetail marks missing chats and dangling partners", async () => {
|
||||
test("getProjectDetail returns tasks with owner and mainChat members", async () => {
|
||||
const db = fakeProjectDb({
|
||||
projects: [
|
||||
{
|
||||
projectRow({
|
||||
id: "proj-1",
|
||||
name: "A",
|
||||
brand: "",
|
||||
main_group_member_ids: "[]",
|
||||
status: "active",
|
||||
note: "",
|
||||
created_at: "2026-08-01",
|
||||
main_group_member_ids: '["u1"]',
|
||||
owner_userid: "owner-a",
|
||||
main_chat_id: "pgcproj1",
|
||||
}),
|
||||
],
|
||||
tasks: [
|
||||
{
|
||||
id: "t1",
|
||||
name: "任务一",
|
||||
project_id: "proj-1",
|
||||
owner_userid: "owner-a",
|
||||
task_chat_id: "tgc_t1",
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ project_id: "proj-1", chat_id: "wr-1", partner_id: "p-1" },
|
||||
{ project_id: "proj-1", chat_id: "wr-gone", partner_id: "" },
|
||||
],
|
||||
groupChats: [
|
||||
{ chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 12, status: 0 },
|
||||
],
|
||||
partners: [],
|
||||
});
|
||||
const detail = await getProjectDetail(db, "proj-1");
|
||||
assert.equal(detail.groupChats.length, 2);
|
||||
const alive = detail.groupChats.find((chat) => chat.chatId === "wr-1");
|
||||
assert.equal(alive.missing, false);
|
||||
assert.equal(alive.partnerName, "", "dangling partner shows blank name");
|
||||
const gone = detail.groupChats.find((chat) => chat.chatId === "wr-gone");
|
||||
assert.equal(gone.missing, true);
|
||||
assert.equal(gone.name, "");
|
||||
assert.equal(detail.project.taskCount, 1);
|
||||
assert.equal(detail.project.mainChatId, "pgcproj1");
|
||||
assert.equal(detail.tasks[0].owner_userid, "owner-a");
|
||||
assert.equal(detail.tasks[0].task_chat_id, "tgc_t1");
|
||||
assert.equal(detail.mainChat.chatId, "pgcproj1");
|
||||
assert.deepEqual(detail.mainChat.members, ["u1", "owner-a"]);
|
||||
});
|
||||
|
||||
test("getProjectDetail throws 404 for unknown project", async () => {
|
||||
@@ -298,51 +397,9 @@ test("getProjectDetail throws 404 for unknown project", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("setProjectGroupChats replaces links and dedupes chat ids", async () => {
|
||||
test("deleteProject unlinks tasks", async () => {
|
||||
const db = fakeProjectDb({
|
||||
projects: [
|
||||
{
|
||||
id: "proj-1",
|
||||
name: "A",
|
||||
brand: "",
|
||||
main_group_member_ids: "[]",
|
||||
status: "active",
|
||||
note: "",
|
||||
created_at: "2026-08-01",
|
||||
},
|
||||
],
|
||||
links: [{ project_id: "proj-1", chat_id: "wr-old", partner_id: "" }],
|
||||
groupChats: [
|
||||
{ chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 12, status: 0 },
|
||||
],
|
||||
partners: [{ id: "p-1", name: "张三", wecom_userid: "zhangsan" }],
|
||||
});
|
||||
const detail = await setProjectGroupChats(db, "proj-1", [
|
||||
{ chatId: "wr-1", partnerId: "p-1" },
|
||||
{ chatId: "wr-1", partnerId: "p-1" },
|
||||
{ chatId: " ", partnerId: "" },
|
||||
]);
|
||||
assert.equal(detail.groupChats.length, 1);
|
||||
assert.equal(detail.groupChats[0].chatId, "wr-1");
|
||||
assert.equal(detail.groupChats[0].partnerName, "张三");
|
||||
assert.equal(db.links.length, 1);
|
||||
assert.equal(db.links[0].chat_id, "wr-1");
|
||||
});
|
||||
|
||||
test("deleteProject removes links and unlinks tasks", async () => {
|
||||
const db = fakeProjectDb({
|
||||
projects: [
|
||||
{
|
||||
id: "proj-1",
|
||||
name: "A",
|
||||
brand: "",
|
||||
main_group_member_ids: "[]",
|
||||
status: "active",
|
||||
note: "",
|
||||
created_at: "2026-08-01",
|
||||
},
|
||||
],
|
||||
links: [{ project_id: "proj-1", chat_id: "wr-1", partner_id: "" }],
|
||||
projects: [projectRow({ id: "proj-1" })],
|
||||
tasks: [
|
||||
{ id: "t1", project_id: "proj-1" },
|
||||
{ id: "t2", project_id: "proj-2" },
|
||||
@@ -350,7 +407,6 @@ test("deleteProject removes links and unlinks tasks", async () => {
|
||||
});
|
||||
await deleteProject(db, "proj-1");
|
||||
assert.equal(db.projects.length, 0);
|
||||
assert.equal(db.links.length, 0);
|
||||
assert.equal(db.tasks[0].project_id, "");
|
||||
assert.equal(db.tasks[1].project_id, "proj-2");
|
||||
|
||||
@@ -360,6 +416,216 @@ test("deleteProject removes links and unlinks tasks", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("ensureProjectMainChat returns no_project for unknown ids", async () => {
|
||||
const wecom = fakeWecom();
|
||||
const db = fakeProjectDb();
|
||||
const result = await ensureProjectMainChat(
|
||||
db,
|
||||
"proj-404",
|
||||
resolveWecomConfig(baseBindings),
|
||||
wecom.fetchImpl,
|
||||
);
|
||||
assert.equal(result.reason, "no_project");
|
||||
assert.equal(result.chatId, null);
|
||||
});
|
||||
|
||||
test("ensureProjectMainChat skips without app credentials", async () => {
|
||||
const wecom = fakeWecom();
|
||||
const db = fakeProjectDb({
|
||||
projects: [projectRow({ main_group_member_ids: '["u1","u2","u3"]' })],
|
||||
});
|
||||
const result = await ensureProjectMainChat(
|
||||
db,
|
||||
"proj-1",
|
||||
resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }),
|
||||
wecom.fetchImpl,
|
||||
);
|
||||
assert.equal(result.reason, "no_credentials");
|
||||
assert.equal(result.chatId, null);
|
||||
assert.equal(wecom.calls.length, 0);
|
||||
});
|
||||
|
||||
test("ensureProjectMainChat skips creation when fewer than 3 members", async () => {
|
||||
const wecom = fakeWecom();
|
||||
const db = fakeProjectDb({
|
||||
projects: [projectRow({ main_group_member_ids: '["u1","u2"]' })],
|
||||
});
|
||||
const result = await ensureProjectMainChat(
|
||||
db,
|
||||
"proj-1",
|
||||
resolveWecomConfig(baseBindings),
|
||||
wecom.fetchImpl,
|
||||
);
|
||||
assert.equal(result.reason, "not_enough_members");
|
||||
assert.equal(result.chatId, null);
|
||||
assert.equal(db.projects[0].main_chat_id, "");
|
||||
assert.equal(
|
||||
wecom.calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"))
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("ensureProjectMainChat probes before creating and persists chat id", async () => {
|
||||
const wecom = fakeWecom();
|
||||
const db = fakeProjectDb({
|
||||
projects: [
|
||||
projectRow({
|
||||
name: "项目一",
|
||||
main_group_member_ids: '["u1","u2"]',
|
||||
owner_userid: "owner-a",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const result = await ensureProjectMainChat(
|
||||
db,
|
||||
"proj-1",
|
||||
resolveWecomConfig(baseBindings),
|
||||
wecom.fetchImpl,
|
||||
);
|
||||
assert.equal(result.chatId, "pgcproj1");
|
||||
assert.deepEqual(result.members.sort(), ["owner-a", "u1", "u2"]);
|
||||
assert.equal(db.projects[0].main_chat_id, "pgcproj1");
|
||||
const paths = wecom.calls.map((c) => c.url.pathname);
|
||||
assert.ok(paths.some((p) => p.endsWith("/cgi-bin/appchat/get")), "probe first");
|
||||
assert.ok(paths.some((p) => p.endsWith("/cgi-bin/appchat/create")));
|
||||
assert.ok(
|
||||
paths.indexOf(paths.find((p) => p.endsWith("/cgi-bin/appchat/get"))) <
|
||||
paths.indexOf(paths.find((p) => p.endsWith("/cgi-bin/appchat/create"))),
|
||||
"probe precedes create",
|
||||
);
|
||||
});
|
||||
|
||||
test("ensureProjectMainChat is idempotent when chat already exists remotely", async () => {
|
||||
const wecom = fakeWecom({
|
||||
chats: [["pgcproj1", { name: "【总群】项目一", userlist: ["u1", "u2", "owner-a"] }]],
|
||||
});
|
||||
const db = fakeProjectDb({
|
||||
projects: [
|
||||
projectRow({
|
||||
name: "项目一",
|
||||
main_group_member_ids: '["u1","u2"]',
|
||||
owner_userid: "owner-a",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const result = await ensureProjectMainChat(
|
||||
db,
|
||||
"proj-1",
|
||||
resolveWecomConfig(baseBindings),
|
||||
wecom.fetchImpl,
|
||||
);
|
||||
assert.equal(result.chatId, "pgcproj1");
|
||||
assert.equal(db.projects[0].main_chat_id, "pgcproj1", "id persisted on recovery");
|
||||
assert.equal(
|
||||
wecom.calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"))
|
||||
.length,
|
||||
0,
|
||||
"no duplicate create",
|
||||
);
|
||||
});
|
||||
|
||||
test("ensureProjectMainChat diffs members add and del on existing chat", async () => {
|
||||
const wecom = fakeWecom({
|
||||
chats: [["pgcproj1", { name: "【总群】项目一", userlist: ["u1", "gone-user"] }]],
|
||||
});
|
||||
const db = fakeProjectDb({
|
||||
projects: [
|
||||
projectRow({
|
||||
main_group_member_ids: '["u1","u2"]',
|
||||
owner_userid: "owner-a",
|
||||
main_chat_id: "pgcproj1",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const result = await ensureProjectMainChat(
|
||||
db,
|
||||
"proj-1",
|
||||
resolveWecomConfig(baseBindings),
|
||||
wecom.fetchImpl,
|
||||
);
|
||||
assert.equal(result.chatId, "pgcproj1");
|
||||
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", "u2"]);
|
||||
assert.deepEqual(updateBody.del_user_list, ["gone-user"]);
|
||||
});
|
||||
|
||||
test("ensureProjectMainChat reports warning and keeps main_chat_id empty on create failure", async () => {
|
||||
const wecom = fakeWecom({ failCreate: true });
|
||||
const db = fakeProjectDb({
|
||||
projects: [projectRow({ main_group_member_ids: '["u1","u2","u3"]' })],
|
||||
});
|
||||
const result = await ensureProjectMainChat(
|
||||
db,
|
||||
"proj-1",
|
||||
resolveWecomConfig(baseBindings),
|
||||
wecom.fetchImpl,
|
||||
);
|
||||
assert.equal(result.chatId, null);
|
||||
assert.match(result.warning, /创建失败/);
|
||||
assert.equal(db.projects[0].main_chat_id, "", "chat id not persisted on failure");
|
||||
});
|
||||
|
||||
test("ensureProjectMainChat adopts chat when create reports chatid existed", async () => {
|
||||
// 可见范围非根部门时 appchat/get 永远 86001,但群实际已建过(create 返回 86215)
|
||||
const wecom = fakeWecom({ failGet: true, createExisted: true });
|
||||
const db = fakeProjectDb({
|
||||
projects: [
|
||||
projectRow({
|
||||
name: "项目一",
|
||||
main_group_member_ids: '["u1","u2"]',
|
||||
owner_userid: "owner-a",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const result = await ensureProjectMainChat(
|
||||
db,
|
||||
"proj-1",
|
||||
resolveWecomConfig(baseBindings),
|
||||
wecom.fetchImpl,
|
||||
);
|
||||
assert.equal(result.chatId, "pgcproj1");
|
||||
assert.equal(result.warning, undefined);
|
||||
assert.equal(db.projects[0].main_chat_id, "pgcproj1", "existing chat id adopted");
|
||||
});
|
||||
|
||||
test("ensureProjectMainChat falls back to add-only sync when get is unavailable", async () => {
|
||||
const wecom = fakeWecom({
|
||||
failGet: true,
|
||||
chats: [["pgcproj1", { name: "【总群】项目一", userlist: ["u1", "gone-user"] }]],
|
||||
});
|
||||
const db = fakeProjectDb({
|
||||
projects: [
|
||||
projectRow({
|
||||
main_group_member_ids: '["u1","u2"]',
|
||||
owner_userid: "owner-a",
|
||||
main_chat_id: "pgcproj1",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const result = await ensureProjectMainChat(
|
||||
db,
|
||||
"proj-1",
|
||||
resolveWecomConfig(baseBindings),
|
||||
wecom.fetchImpl,
|
||||
);
|
||||
assert.equal(result.chatId, "pgcproj1");
|
||||
assert.equal(result.warning, undefined);
|
||||
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", "u1", "u2"]);
|
||||
assert.equal(updateBody.del_user_list, undefined, "no removals without member list");
|
||||
});
|
||||
|
||||
test("saveGroupOwnerPartner inserts and updates group_owner partners", async () => {
|
||||
const db = fakeProjectDb();
|
||||
const created = await saveGroupOwnerPartner(db, {
|
||||
@@ -382,3 +648,23 @@ test("saveGroupOwnerPartner inserts and updates group_owner partners", async ()
|
||||
(error) => error instanceof ProjectError,
|
||||
);
|
||||
});
|
||||
|
||||
test("findOrCreateGroupOwnerPartner returns existing row without duplicating", async () => {
|
||||
const db = fakeProjectDb({
|
||||
partners: [
|
||||
{ id: "partner-go-old", name: "负责人", wecom_userid: "owner-a", partner_type: "group_owner" },
|
||||
],
|
||||
});
|
||||
const found = await findOrCreateGroupOwnerPartner(db, "owner-a");
|
||||
assert.equal(found.id, "partner-go-old");
|
||||
assert.equal(db.partners.length, 1);
|
||||
});
|
||||
|
||||
test("findOrCreateGroupOwnerPartner creates partner with userid fallback name", async () => {
|
||||
const db = fakeProjectDb();
|
||||
const created = await findOrCreateGroupOwnerPartner(db, "owner-b");
|
||||
assert.match(created.id, /^partner-go-/);
|
||||
assert.equal(created.name, "owner-b");
|
||||
assert.equal(created.wecom_userid, "owner-b");
|
||||
assert.equal(db.partners.length, 1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user