import assert from "node:assert/strict"; import test from "node:test"; import { projectMainChatId, deleteProject, ensureProjectMainChat, effectiveProjectMembers, findOrCreateGroupOwnerPartner, getProjectDetail, listProjects, parseMemberIds, ProjectError, saveGroupOwnerPartner, saveProject, } from "../lib/project-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 ?? []); 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(); 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], 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; 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 (/^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; } 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() { executed.push({ sql, params }); if (/FROM projects p/.test(sql)) { const ownerFiltered = /WHERE p\.owner_userid = \?/.test(sql); let rows = projects; if (ownerFiltered) { rows = rows.filter((item) => item.owner_userid === params[0]); } return { results: rows.map((item) => ({ ...item, task_count: tasks.filter((task) => task.project_id === item.id).length, })), }; } if (/FROM tasks( t)? WHERE (t\.)?project_id/.test(sql)) { return { results: tasks .filter((task) => task.project_id === params[0]) .map((task) => ({ recovery_enabled: 1, recovery_due_at: "", recovery_submitted: 0, ...task, })), }; } 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; }, }; } 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"]); assert.deepEqual(parseMemberIds("a,b;c,d"), ["a", "b", "c", "d"]); assert.deepEqual(parseMemberIds(" \n "), []); }); 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, mainChat } = 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", "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: [projectRow({ id: "proj-1", name: "旧名" })], }); 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", "owner-li", ]); 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("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", 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: "" }, ], }); const projects = await listProjects(db); assert.equal(projects.length, 1); assert.equal(projects[0].taskCount, 2); assert.equal(projects[0].mainChatId, "pgcproj1"); assert.deepEqual(projects[0].mainGroupMemberIds, ["u1", "u2"]); }); 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); }); test("getProjectDetail returns tasks with owner and mainChat members", async () => { const db = fakeProjectDb({ projects: [ projectRow({ id: "proj-1", 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", recovery_enabled: 0, recovery_due_at: "2026-08-10", recovery_submitted: 2, }, ], }); const detail = await getProjectDetail(db, "proj-1"); 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.tasks[0].recovery_enabled, 0); assert.equal(detail.tasks[0].recovery_due_at, "2026-08-10"); assert.equal(detail.tasks[0].recovery_submitted, 2); assert.equal(detail.mainChat.chatId, "pgcproj1"); assert.deepEqual(detail.mainChat.members, ["u1", "owner-a"]); }); 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("deleteProject unlinks tasks", async () => { const db = fakeProjectDb({ projects: [projectRow({ id: "proj-1" })], 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, ); }); 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, { 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, ); }); 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); });