import test from "node:test"; import assert from "node:assert/strict"; import os from "node:os"; import path from "node:path"; import { mkdtemp, rm } from "node:fs/promises"; import { NextRequest } from "next/server"; let runtimeRoot = ""; let getParticipantsRoute: (typeof import("../src/app/api/v1/projects/[projectId]/participants/route"))["GET"]; let updateParticipantsRoute: (typeof import("../src/app/api/v1/projects/[projectId]/participants/route"))["POST"]; let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; let createProjectGroupChat: (typeof import("../src/lib/boss-data"))["createProjectGroupChat"]; let readState: (typeof import("../src/lib/boss-data"))["readState"]; let writeState: (typeof import("../src/lib/boss-data"))["writeState"]; let AUTH_SESSION_COOKIE = ""; async function setup() { if (runtimeRoot) return; runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-group-repair-")); process.env.BOSS_RUNTIME_ROOT = runtimeRoot; process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); const [participantsModule, data, auth] = await Promise.all([ import("../src/app/api/v1/projects/[projectId]/participants/route.ts"), import("../src/lib/boss-data.ts"), import("../src/lib/boss-auth.ts"), ]); getParticipantsRoute = participantsModule.GET; updateParticipantsRoute = participantsModule.POST; createAuthSession = data.createAuthSession; createProjectGroupChat = data.createProjectGroupChat; readState = data.readState; writeState = data.writeState; AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; } test.after(async () => { if (runtimeRoot) { await rm(runtimeRoot, { recursive: true, force: true }); } }); async function createAuthedRequest(url: string, method: "GET" | "POST", body?: unknown) { const session = await createAuthSession({ account: "17600003315", role: "highest_admin", displayName: "Boss 超级管理员", loginMethod: "password", }); return new NextRequest(url, { method, headers: { cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, ...(body ? { "content-type": "application/json" } : {}), }, body: body ? JSON.stringify(body) : undefined, }); } async function ensureTwoSingleThreadProjects() { const state = await readState(); const singles = state.projects.filter((project) => project.id !== "master-agent" && !project.isGroup); if (singles.length >= 2) { return singles; } assert.ok(singles[0], "expected seeded single-thread project"); const seed = singles[0]; const clone = { ...seed, id: "repair-thread-clone", name: "Repair Thread Clone", deviceIds: ["mac-studio"], threadMeta: { ...seed.threadMeta, projectId: "repair-thread-clone", threadId: "repair-thread-clone", threadDisplayName: "维修回归线程", folderName: "repair-folder", codexThreadRef: "repair-thread-clone", codexFolderRef: "repair-folder", }, }; await writeState({ ...state, projects: [...state.projects, clone], }); const nextState = await readState(); return nextState.projects.filter((project) => project.id !== "master-agent" && !project.isGroup); } test("GET /api/v1/projects/[projectId]/participants marks dirty groups as repair-required", async () => { await setup(); const singles = await ensureTwoSingleThreadProjects(); const groupProject = await createProjectGroupChat({ sourceProjectId: singles[0].id, memberProjectIds: [singles[1].id], createdBy: "17600003315", }); const state = await readState(); await writeState({ ...state, projects: state.projects.map((project) => project.id === groupProject.id ? { ...project, groupMembers: [ { projectId: "master-agent", deviceId: "mac-studio", threadId: "master-agent-thread", threadDisplayName: "主 Agent 汇总", folderName: "主控线程", }, ], } : project, ), }); const response = await getParticipantsRoute( await createAuthedRequest( `http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/participants`, "GET", ), { params: Promise.resolve({ projectId: groupProject.id }) }, ); assert.equal(response.status, 200); const payload = (await response.json()) as { ok: boolean; repairRequired: boolean; validParticipantCount: number; invalidParticipantCount: number; participants: Array<{ status: string; canOpenProject: boolean }>; }; assert.equal(payload.ok, true); assert.equal(payload.repairRequired, true); assert.equal(payload.validParticipantCount, 0); assert.equal(payload.invalidParticipantCount, 1); assert.equal(payload.participants[0]?.status, "invalid_target"); assert.equal(payload.participants[0]?.canOpenProject, true); }); test("POST /api/v1/projects/[projectId]/participants replaces dirty members with real thread participants", async () => { await setup(); const singles = await ensureTwoSingleThreadProjects(); const groupProject = await createProjectGroupChat({ sourceProjectId: singles[0].id, memberProjectIds: [singles[1].id], createdBy: "17600003315", }); const state = await readState(); await writeState({ ...state, projects: state.projects.map((project) => project.id === groupProject.id ? { ...project, groupMembers: [ { projectId: "master-agent", deviceId: "mac-studio", threadId: "master-agent-thread", threadDisplayName: "主 Agent 汇总", folderName: "主控线程", }, ], } : project, ), }); const targetIds = singles.slice(0, 2).map((project) => project.id); const response = await updateParticipantsRoute( await createAuthedRequest( `http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/participants`, "POST", { memberProjectIds: targetIds }, ), { params: Promise.resolve({ projectId: groupProject.id }) }, ); assert.equal(response.status, 200); const payload = (await response.json()) as { ok: boolean; repairRequired: boolean; participants: Array<{ projectId: string; status: string }>; }; assert.equal(payload.ok, true); assert.equal(payload.repairRequired, false); assert.deepEqual( payload.participants.map((participant) => participant.projectId).sort(), [...targetIds].sort(), ); assert.ok(payload.participants.every((participant) => participant.status === "active")); const nextState = await readState(); const nextGroup = nextState.projects.find((project) => project.id === groupProject.id); assert.ok(nextGroup, "expected repaired group to remain present"); assert.deepEqual( nextGroup?.groupMembers.map((member) => member.projectId).sort(), [...targetIds].sort(), ); const repairNotice = nextGroup?.messages.find( (message) => message.kind === "system_notice" && message.body.includes("已更新群成员"), ); assert.ok(repairNotice, "expected a group repair system notice"); });