import assert from "node:assert/strict"; import test from "node:test"; import { deleteProject, getProjectDetail, listProjects, parseMemberIds, ProjectError, saveGroupOwnerPartner, saveProject, setProjectGroupChats, } from "../lib/project-service.ts"; function fakeProjectDb({ projects = [], links = [], tasks = [], groupChats = [], partners = [], } = {}) { 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], owner_userid: params[4], status: "active", note: params[5], created_at: params[6], }); return 1; } if (/^UPDATE projects/.test(normalized)) { 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], owner_userid: params[3], note: params[4], }); 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 (/^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 = ""; } 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 }); 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; } return null; }, async all() { executed.push({ sql, params }); if (/FROM projects p/.test(sql)) { return { 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, })), }; } if (/FROM tasks WHERE project_id/.test(sql)) { return { results: tasks .filter((task) => task.project_id === params[0]) .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 })) }; } return { results: [] }; }, }); return { projects, links, tasks, groupChats, partners, executed, prepare(sql) { return makeStatement(sql); }, async batch(statements) { const output = []; for (const statement of statements) output.push(await statement.run()); return output; }, }; } 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 "), []); }); test("saveProject inserts with proj- prefixed id and JSON members", async () => { const db = fakeProjectDb(); const project = await saveProject(db, { 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"); }); 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", }, ], }); await saveProject(db, { id: "proj-1", 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"); 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, ); }); test("listProjects aggregates task and group chat counts", async () => { const db = fakeProjectDb({ projects: [ { id: "proj-1", name: "A", brand: "", main_group_member_ids: "[\"u1\",\"u2\"]", status: "active", note: "", created_at: "2026-08-01", }, ], 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.deepEqual(projects[0].mainGroupMemberIds, ["u1", "u2"]); }); test("getProjectDetail marks missing chats and dangling partners", 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: "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, ""); }); 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, ); }); test("setProjectGroupChats replaces links and dedupes chat ids", 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: "" }], 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.links.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, ); }); 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, ); });