2026-08-31 16:35:17 +08:00
|
|
|
|
import assert from "node:assert/strict";
|
|
|
|
|
|
import test from "node:test";
|
|
|
|
|
|
import {
|
2026-09-04 18:03:46 +08:00
|
|
|
|
projectMainChatId,
|
2026-08-31 16:35:17 +08:00
|
|
|
|
deleteProject,
|
2026-09-04 18:03:46 +08:00
|
|
|
|
ensureProjectMainChat,
|
|
|
|
|
|
effectiveProjectMembers,
|
|
|
|
|
|
findOrCreateGroupOwnerPartner,
|
2026-08-31 16:35:17 +08:00
|
|
|
|
getProjectDetail,
|
|
|
|
|
|
listProjects,
|
|
|
|
|
|
parseMemberIds,
|
|
|
|
|
|
ProjectError,
|
|
|
|
|
|
saveGroupOwnerPartner,
|
|
|
|
|
|
saveProject,
|
|
|
|
|
|
} from "../lib/project-service.ts";
|
2026-09-04 18:03:46 +08:00
|
|
|
|
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 ?? []);
|
|
|
|
|
|
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 };
|
|
|
|
|
|
}
|
2026-08-31 16:35:17 +08:00
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
|
2026-08-31 16:35:17 +08:00
|
|
|
|
const executed = [];
|
|
|
|
|
|
const applyWrite = (sql, params) => {
|
|
|
|
|
|
const normalized = sql.trim();
|
|
|
|
|
|
if (/^INSERT INTO projects/.test(normalized)) {
|
|
|
|
|
|
projects.push({
|
|
|
|
|
|
id: params[0],
|
|
|
|
|
|
name: params[1],
|
|
|
|
|
|
brand: params[2],
|
|
|
|
|
|
main_group_member_ids: params[3],
|
2026-09-03 11:30:26 +08:00
|
|
|
|
owner_userid: params[4],
|
2026-09-23 11:13:17 +08:00
|
|
|
|
parent_id: params[5],
|
2026-09-04 18:03:46 +08:00
|
|
|
|
main_chat_id: "",
|
2026-08-31 16:35:17 +08:00
|
|
|
|
status: "active",
|
2026-09-23 11:13:17 +08:00
|
|
|
|
note: params[6],
|
|
|
|
|
|
created_at: params[7],
|
2026-08-31 16:35:17 +08:00
|
|
|
|
});
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
}
|
2026-09-04 18:03:46 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-08-31 16:35:17 +08:00
|
|
|
|
if (/^UPDATE projects/.test(normalized)) {
|
2026-09-23 11:13:17 +08:00
|
|
|
|
const row = projects.find((item) => item.id === params[6]);
|
2026-08-31 16:35:17 +08:00
|
|
|
|
if (!row) return 0;
|
|
|
|
|
|
Object.assign(row, {
|
|
|
|
|
|
name: params[0],
|
|
|
|
|
|
brand: params[1],
|
|
|
|
|
|
main_group_member_ids: params[2],
|
2026-09-03 11:30:26 +08:00
|
|
|
|
owner_userid: params[3],
|
|
|
|
|
|
note: params[4],
|
2026-09-23 11:13:17 +08:00
|
|
|
|
parent_id: params[5],
|
2026-08-31 16:35:17 +08:00
|
|
|
|
});
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (/^DELETE FROM projects/.test(normalized)) {
|
|
|
|
|
|
const index = projects.findIndex((item) => item.id === params[0]);
|
|
|
|
|
|
if (index < 0) return 0;
|
|
|
|
|
|
projects.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 = "";
|
|
|
|
|
|
}
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (/^INSERT INTO partners/.test(normalized)) {
|
|
|
|
|
|
partners.push({
|
|
|
|
|
|
id: params[0],
|
|
|
|
|
|
name: params[1],
|
|
|
|
|
|
wecom_userid: params[2],
|
|
|
|
|
|
partner_type: "group_owner",
|
|
|
|
|
|
});
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (/^UPDATE partners/.test(normalized)) {
|
|
|
|
|
|
const row = partners.find((item) => item.id === params[2]);
|
|
|
|
|
|
if (!row) return 0;
|
|
|
|
|
|
Object.assign(row, { name: params[0], wecom_userid: params[1] });
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
};
|
|
|
|
|
|
const makeStatement = (sql, params = []) => ({
|
|
|
|
|
|
sql,
|
|
|
|
|
|
params,
|
|
|
|
|
|
bind(...args) {
|
|
|
|
|
|
return makeStatement(sql, args);
|
|
|
|
|
|
},
|
|
|
|
|
|
async run() {
|
|
|
|
|
|
executed.push({ sql, params });
|
|
|
|
|
|
return { meta: { changes: applyWrite(sql, params) } };
|
|
|
|
|
|
},
|
|
|
|
|
|
async first() {
|
|
|
|
|
|
executed.push({ sql, params });
|
2026-09-23 11:13:17 +08:00
|
|
|
|
if (/COUNT\(\*\) AS count FROM projects WHERE parent_id = \?/.test(sql)) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
count: projects.filter((item) => item.parent_id === params[0]).length,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-08-31 16:35:17 +08:00
|
|
|
|
if (/FROM projects WHERE id = \?/.test(sql)) {
|
|
|
|
|
|
return projects.find((item) => item.id === params[0]) ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (/FROM partners WHERE id = \?/.test(sql)) {
|
|
|
|
|
|
return partners.find((item) => item.id === params[0]) ?? null;
|
|
|
|
|
|
}
|
2026-09-04 18:03:46 +08:00
|
|
|
|
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
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-08-31 16:35:17 +08:00
|
|
|
|
return null;
|
|
|
|
|
|
},
|
|
|
|
|
|
async all() {
|
|
|
|
|
|
executed.push({ sql, params });
|
2026-09-23 11:13:17 +08:00
|
|
|
|
if (/^SELECT id, parent_id FROM projects/.test(sql.trim())) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
results: projects.map((item) => ({
|
|
|
|
|
|
id: item.id,
|
|
|
|
|
|
parent_id: item.parent_id ?? "",
|
|
|
|
|
|
})),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-08-31 16:35:17 +08:00
|
|
|
|
if (/FROM projects p/.test(sql)) {
|
2026-09-17 15:21:24 +08:00
|
|
|
|
const ownerFiltered = /WHERE p\.owner_userid = \?/.test(sql);
|
2026-09-23 11:13:17 +08:00
|
|
|
|
const parentFiltered = /WHERE p\.parent_id = \?/.test(sql);
|
|
|
|
|
|
let rows = projects;
|
|
|
|
|
|
if (ownerFiltered) {
|
|
|
|
|
|
rows = rows.filter((item) => item.owner_userid === params[0]);
|
|
|
|
|
|
} else if (parentFiltered) {
|
|
|
|
|
|
rows = rows.filter((item) => item.parent_id === params[0]);
|
|
|
|
|
|
}
|
2026-08-31 16:35:17 +08:00
|
|
|
|
return {
|
2026-09-17 15:21:24 +08:00
|
|
|
|
results: rows.map((item) => ({
|
2026-08-31 16:35:17 +08:00
|
|
|
|
...item,
|
|
|
|
|
|
task_count: tasks.filter((task) => task.project_id === item.id).length,
|
|
|
|
|
|
})),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (/FROM tasks WHERE project_id/.test(sql)) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
results: tasks
|
|
|
|
|
|
.filter((task) => task.project_id === params[0])
|
|
|
|
|
|
.map((task) => ({ ...task })),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-09-23 11:13:17 +08:00
|
|
|
|
if (/FROM tasks t\s+LEFT JOIN projects pj/.test(sql)) {
|
|
|
|
|
|
const wanted = new Set(params);
|
|
|
|
|
|
return {
|
|
|
|
|
|
results: tasks
|
|
|
|
|
|
.filter((task) => wanted.has(task.project_id))
|
|
|
|
|
|
.map((task) => ({
|
|
|
|
|
|
...task,
|
|
|
|
|
|
project_name:
|
|
|
|
|
|
projects.find((item) => item.id === task.project_id)?.name ?? "",
|
|
|
|
|
|
})),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-08-31 16:35:17 +08:00
|
|
|
|
if (/FROM partners/.test(sql)) {
|
|
|
|
|
|
return { results: partners.map((item) => ({ ...item })) };
|
|
|
|
|
|
}
|
|
|
|
|
|
return { results: [] };
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
|
|
|
projects,
|
|
|
|
|
|
tasks,
|
|
|
|
|
|
partners,
|
|
|
|
|
|
executed,
|
|
|
|
|
|
prepare(sql) {
|
|
|
|
|
|
return makeStatement(sql);
|
|
|
|
|
|
},
|
|
|
|
|
|
async batch(statements) {
|
|
|
|
|
|
const output = [];
|
|
|
|
|
|
for (const statement of statements) output.push(await statement.run());
|
|
|
|
|
|
return output;
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
const projectRow = (overrides = {}) => ({
|
|
|
|
|
|
id: "proj-1",
|
|
|
|
|
|
name: "A",
|
|
|
|
|
|
brand: "",
|
|
|
|
|
|
main_group_member_ids: "[]",
|
|
|
|
|
|
owner_userid: "",
|
2026-09-23 11:13:17 +08:00
|
|
|
|
parent_id: "",
|
2026-09-04 18:03:46 +08:00
|
|
|
|
main_chat_id: "",
|
|
|
|
|
|
status: "active",
|
|
|
|
|
|
note: "",
|
|
|
|
|
|
created_at: "2026-08-01",
|
|
|
|
|
|
...overrides,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-31 16:35:17 +08:00
|
|
|
|
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"]);
|
|
|
|
|
|
assert.deepEqual(parseMemberIds("a,b;c,d"), ["a", "b", "c", "d"]);
|
|
|
|
|
|
assert.deepEqual(parseMemberIds(" \n "), []);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
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 () => {
|
2026-08-31 16:35:17 +08:00
|
|
|
|
const db = fakeProjectDb();
|
2026-09-04 18:03:46 +08:00
|
|
|
|
const { project, mainChat } = await saveProject(db, {
|
2026-08-31 16:35:17 +08:00
|
|
|
|
name: "暑期焕新",
|
|
|
|
|
|
brand: "青柠实验室",
|
|
|
|
|
|
mainGroupMemberIds: "zhangsan\nlisi\nzhangsan",
|
2026-09-03 11:30:26 +08:00
|
|
|
|
ownerUserid: "owner-zhang",
|
2026-08-31 16:35:17 +08:00
|
|
|
|
note: "备注",
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.match(project.id, /^proj-/);
|
|
|
|
|
|
assert.equal(project.name, "暑期焕新");
|
2026-09-03 11:30:26 +08:00
|
|
|
|
assert.equal(project.ownerUserid, "owner-zhang");
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.deepEqual(project.mainGroupMemberIds, ["zhangsan", "lisi", "owner-zhang"]);
|
2026-08-31 16:35:17 +08:00
|
|
|
|
assert.equal(db.projects.length, 1);
|
|
|
|
|
|
assert.equal(db.projects[0].status, "active");
|
2026-09-04 18:03:46 +08:00
|
|
|
|
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");
|
2026-08-31 16:35:17 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("saveProject updates existing row and rejects blank name", async () => {
|
|
|
|
|
|
const db = fakeProjectDb({
|
2026-09-04 18:03:46 +08:00
|
|
|
|
projects: [projectRow({ id: "proj-1", name: "旧名" })],
|
2026-08-31 16:35:17 +08:00
|
|
|
|
});
|
|
|
|
|
|
await saveProject(db, {
|
|
|
|
|
|
id: "proj-1",
|
|
|
|
|
|
name: "新名",
|
|
|
|
|
|
brand: "品牌",
|
|
|
|
|
|
mainGroupMemberIds: ["wangwu"],
|
2026-09-03 11:30:26 +08:00
|
|
|
|
ownerUserid: "owner-li",
|
2026-08-31 16:35:17 +08:00
|
|
|
|
});
|
|
|
|
|
|
assert.equal(db.projects[0].name, "新名");
|
|
|
|
|
|
assert.equal(db.projects[0].brand, "品牌");
|
2026-09-03 11:30:26 +08:00
|
|
|
|
assert.equal(db.projects[0].owner_userid, "owner-li");
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.deepEqual(JSON.parse(db.projects[0].main_group_member_ids), [
|
|
|
|
|
|
"wangwu",
|
|
|
|
|
|
"owner-li",
|
|
|
|
|
|
]);
|
2026-08-31 16:35:17 +08:00
|
|
|
|
assert.equal(db.projects.length, 1, "update must not insert");
|
|
|
|
|
|
|
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
|
() => saveProject(db, { id: "proj-1", name: " " }),
|
|
|
|
|
|
(error) => error instanceof ProjectError && error.status === 400,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("saveProject rejects unknown id on update", async () => {
|
|
|
|
|
|
const db = fakeProjectDb();
|
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
|
() => saveProject(db, { id: "proj-404", name: "x" }),
|
|
|
|
|
|
(error) => error instanceof ProjectError && error.status === 404,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
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 () => {
|
2026-08-31 16:35:17 +08:00
|
|
|
|
const db = fakeProjectDb({
|
|
|
|
|
|
projects: [
|
2026-09-04 18:03:46 +08:00
|
|
|
|
projectRow({
|
2026-08-31 16:35:17 +08:00
|
|
|
|
id: "proj-1",
|
2026-09-04 18:03:46 +08:00
|
|
|
|
main_group_member_ids: '["u1","u2"]',
|
|
|
|
|
|
main_chat_id: "pgcproj1",
|
|
|
|
|
|
}),
|
2026-08-31 16:35:17 +08:00
|
|
|
|
],
|
|
|
|
|
|
tasks: [
|
|
|
|
|
|
{ id: "t1", project_id: "proj-1" },
|
|
|
|
|
|
{ id: "t2", project_id: "proj-1" },
|
|
|
|
|
|
{ id: "t3", project_id: "" },
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
const projects = await listProjects(db);
|
|
|
|
|
|
assert.equal(projects.length, 1);
|
|
|
|
|
|
assert.equal(projects[0].taskCount, 2);
|
2026-09-04 18:03:46 +08:00
|
|
|
|
assert.equal(projects[0].mainChatId, "pgcproj1");
|
2026-08-31 16:35:17 +08:00
|
|
|
|
assert.deepEqual(projects[0].mainGroupMemberIds, ["u1", "u2"]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-17 15:21:24 +08:00
|
|
|
|
test("listProjects filters by project owner when ownerUserid given", async () => {
|
|
|
|
|
|
const db = fakeProjectDb({
|
|
|
|
|
|
projects: [
|
|
|
|
|
|
projectRow({ id: "proj-1", owner_userid: "owner-a" }),
|
|
|
|
|
|
projectRow({ id: "proj-2", owner_userid: "owner-b" }),
|
|
|
|
|
|
projectRow({ id: "proj-3", owner_userid: "" }),
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
const mine = await listProjects(db, "owner-a");
|
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
|
mine.map((item) => item.id),
|
|
|
|
|
|
["proj-1"],
|
|
|
|
|
|
);
|
|
|
|
|
|
const unbound = await listProjects(db, "");
|
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
|
unbound,
|
|
|
|
|
|
[],
|
|
|
|
|
|
"已登录未绑定企微的空范围不应漏出无主项目",
|
|
|
|
|
|
);
|
|
|
|
|
|
const all = await listProjects(db);
|
|
|
|
|
|
assert.equal(all.length, 3);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
test("getProjectDetail returns tasks with owner and mainChat members", async () => {
|
2026-08-31 16:35:17 +08:00
|
|
|
|
const db = fakeProjectDb({
|
|
|
|
|
|
projects: [
|
2026-09-04 18:03:46 +08:00
|
|
|
|
projectRow({
|
2026-08-31 16:35:17 +08:00
|
|
|
|
id: "proj-1",
|
2026-09-04 18:03:46 +08:00
|
|
|
|
main_group_member_ids: '["u1"]',
|
|
|
|
|
|
owner_userid: "owner-a",
|
|
|
|
|
|
main_chat_id: "pgcproj1",
|
|
|
|
|
|
}),
|
2026-08-31 16:35:17 +08:00
|
|
|
|
],
|
2026-09-04 18:03:46 +08:00
|
|
|
|
tasks: [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "t1",
|
|
|
|
|
|
name: "任务一",
|
|
|
|
|
|
project_id: "proj-1",
|
|
|
|
|
|
owner_userid: "owner-a",
|
|
|
|
|
|
task_chat_id: "tgc_t1",
|
|
|
|
|
|
},
|
2026-08-31 16:35:17 +08:00
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
const detail = await getProjectDetail(db, "proj-1");
|
2026-09-04 18:03:46 +08:00
|
|
|
|
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"]);
|
2026-08-31 16:35:17 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("getProjectDetail throws 404 for unknown project", async () => {
|
|
|
|
|
|
const db = fakeProjectDb();
|
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
|
() => getProjectDetail(db, "proj-404"),
|
|
|
|
|
|
(error) => error instanceof ProjectError && error.status === 404,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
test("deleteProject unlinks tasks", async () => {
|
2026-08-31 16:35:17 +08:00
|
|
|
|
const db = fakeProjectDb({
|
2026-09-04 18:03:46 +08:00
|
|
|
|
projects: [projectRow({ id: "proj-1" })],
|
2026-08-31 16:35:17 +08:00
|
|
|
|
tasks: [
|
|
|
|
|
|
{ id: "t1", project_id: "proj-1" },
|
|
|
|
|
|
{ id: "t2", project_id: "proj-2" },
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
await deleteProject(db, "proj-1");
|
|
|
|
|
|
assert.equal(db.projects.length, 0);
|
|
|
|
|
|
assert.equal(db.tasks[0].project_id, "");
|
|
|
|
|
|
assert.equal(db.tasks[1].project_id, "proj-2");
|
|
|
|
|
|
|
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
|
() => deleteProject(db, "proj-404"),
|
|
|
|
|
|
(error) => error instanceof ProjectError && error.status === 404,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-04 18:03:46 +08:00
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-31 16:35:17 +08:00
|
|
|
|
test("saveGroupOwnerPartner inserts and updates group_owner partners", async () => {
|
|
|
|
|
|
const db = fakeProjectDb();
|
|
|
|
|
|
const created = await saveGroupOwnerPartner(db, {
|
|
|
|
|
|
name: "张三",
|
|
|
|
|
|
wecomUserid: "zhangsan",
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.match(created.id, /^partner-go-/);
|
|
|
|
|
|
assert.equal(db.partners[0].partner_type, "group_owner");
|
|
|
|
|
|
|
|
|
|
|
|
const updated = await saveGroupOwnerPartner(db, {
|
|
|
|
|
|
id: created.id,
|
|
|
|
|
|
name: "张三丰",
|
|
|
|
|
|
wecomUserid: "zhangsanfeng",
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(updated.name, "张三丰");
|
|
|
|
|
|
assert.equal(db.partners.length, 1);
|
|
|
|
|
|
|
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
|
() => saveGroupOwnerPartner(db, { name: "李四", wecomUserid: "" }),
|
|
|
|
|
|
(error) => error instanceof ProjectError,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
2026-09-04 18:03:46 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-09-23 11:13:17 +08:00
|
|
|
|
|
|
|
|
|
|
test("saveProject persists parentProjectId on create and update", async () => {
|
|
|
|
|
|
const db = fakeProjectDb({
|
|
|
|
|
|
projects: [projectRow({ id: "proj-root", name: "总项目" })],
|
|
|
|
|
|
});
|
|
|
|
|
|
await saveProject(db, {
|
|
|
|
|
|
name: "子项目",
|
|
|
|
|
|
brand: "b",
|
|
|
|
|
|
mainGroupMemberIds: [],
|
|
|
|
|
|
ownerUserid: "",
|
|
|
|
|
|
parentProjectId: "proj-root",
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(db.projects.length, 2);
|
|
|
|
|
|
const child = db.projects.find((item) => item.name === "子项目");
|
|
|
|
|
|
assert.equal(child.parent_id, "proj-root");
|
|
|
|
|
|
|
|
|
|
|
|
await saveProject(db, {
|
|
|
|
|
|
id: "proj-root",
|
|
|
|
|
|
name: "总项目",
|
|
|
|
|
|
mainGroupMemberIds: [],
|
|
|
|
|
|
parentProjectId: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
assert.equal(db.projects.find((item) => item.id === "proj-root").parent_id, "");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("saveProject rejects invalid parentProjectId (missing / self / descendant)", async () => {
|
|
|
|
|
|
const db = fakeProjectDb({
|
|
|
|
|
|
projects: [
|
|
|
|
|
|
projectRow({ id: "proj-1", name: "一级" }),
|
|
|
|
|
|
projectRow({ id: "proj-2", name: "二级", parent_id: "proj-1" }),
|
|
|
|
|
|
projectRow({ id: "proj-3", name: "三级", parent_id: "proj-2" }),
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
|
() => saveProject(db, { id: "proj-1", name: "一级", parentProjectId: "proj-404" }),
|
|
|
|
|
|
(error) => error instanceof ProjectError && error.status === 404,
|
|
|
|
|
|
);
|
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
|
() => saveProject(db, { id: "proj-1", name: "一级", parentProjectId: "proj-1" }),
|
|
|
|
|
|
(error) => error instanceof ProjectError && /自身/.test(error.message),
|
|
|
|
|
|
);
|
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
|
() => saveProject(db, { id: "proj-1", name: "一级", parentProjectId: "proj-3" }),
|
|
|
|
|
|
(error) =>
|
|
|
|
|
|
error instanceof ProjectError && /上级项目不能是当前项目的子项目/.test(error.message),
|
|
|
|
|
|
);
|
|
|
|
|
|
// 兄弟项目/父项目合法
|
|
|
|
|
|
await saveProject(db, { id: "proj-3", name: "三级", parentProjectId: "proj-1" });
|
|
|
|
|
|
assert.equal(db.projects.find((item) => item.id === "proj-3").parent_id, "proj-1");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("deleteProject refuses while child projects exist", async () => {
|
|
|
|
|
|
const db = fakeProjectDb({
|
|
|
|
|
|
projects: [
|
|
|
|
|
|
projectRow({ id: "proj-1", name: "一级" }),
|
|
|
|
|
|
projectRow({ id: "proj-2", name: "二级", parent_id: "proj-1" }),
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
|
() => deleteProject(db, "proj-1"),
|
|
|
|
|
|
(error) =>
|
|
|
|
|
|
error instanceof ProjectError && /还有子项目/.test(error.message),
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(db.projects.length, 2);
|
|
|
|
|
|
await deleteProject(db, "proj-2");
|
|
|
|
|
|
await deleteProject(db, "proj-1");
|
|
|
|
|
|
assert.equal(db.projects.length, 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test("getProjectDetail returns subprojects and aggregated push tasks", async () => {
|
|
|
|
|
|
const db = fakeProjectDb({
|
|
|
|
|
|
projects: [
|
|
|
|
|
|
projectRow({ id: "proj-1", name: "一级" }),
|
|
|
|
|
|
projectRow({ id: "proj-2", name: "二级", parent_id: "proj-1", main_chat_id: "pgc2" }),
|
|
|
|
|
|
projectRow({ id: "proj-3", name: "三级", parent_id: "proj-2" }),
|
|
|
|
|
|
projectRow({ id: "proj-other", name: "无关项目" }),
|
|
|
|
|
|
],
|
|
|
|
|
|
tasks: [
|
|
|
|
|
|
{ id: "t1", name: "直属任务", project_id: "proj-1" },
|
|
|
|
|
|
{ id: "t2", name: "子项目任务", project_id: "proj-2" },
|
|
|
|
|
|
{ id: "t3", name: "孙项目任务", project_id: "proj-3" },
|
|
|
|
|
|
{ id: "t4", name: "无关任务", project_id: "proj-other" },
|
|
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
const detail = await getProjectDetail(db, "proj-1");
|
|
|
|
|
|
assert.equal(detail.project.parentProjectId, "");
|
|
|
|
|
|
assert.deepEqual(detail.tasks.map((task) => task.id), ["t1"]);
|
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
|
detail.subprojects.map((sub) => sub.id),
|
|
|
|
|
|
["proj-2"],
|
|
|
|
|
|
);
|
|
|
|
|
|
assert.equal(detail.subprojects[0].mainChatId, "pgc2");
|
|
|
|
|
|
assert.equal(detail.subprojects[0].taskCount, 1);
|
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
|
detail.pushTasks.map((task) => `${task.project_name}:${task.name}`).sort(),
|
|
|
|
|
|
["一级:直属任务", "三级:孙项目任务", "二级:子项目任务"],
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|