Files
boss/tests/group-participants-repair.test.ts

265 lines
8.9 KiB
TypeScript

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;
}
const generatedProjects = Array.from({ length: 2 - singles.length }, (_, index) => ({
id: `repair-thread-${index + 1}`,
name: `Repair Thread ${index + 1}`,
pinned: false,
systemPinned: false,
deviceIds: ["mac-studio"],
preview: "用于群成员修复 contract 的测试线程。",
updatedAt: "2026-03-30T10:00:00+08:00",
lastMessageAt: "2026-03-30T10:00:00+08:00",
isGroup: false,
threadMeta: {
projectId: `repair-thread-${index + 1}`,
threadId: `repair-thread-${index + 1}`,
threadDisplayName: `维修回归线程 ${index + 1}`,
folderName: "repair-folder",
activityIconCount: 0,
updatedAt: "2026-03-30T10:00:00+08:00",
codexThreadRef: `repair-thread-${index + 1}`,
codexFolderRef: "repair-folder",
},
groupMembers: [],
createdByAgent: true,
collaborationMode: "development" as const,
approvalState: "not_required" as const,
unreadCount: 0,
riskLevel: "low" as const,
messages: [
{
id: `msg-repair-thread-${index + 1}`,
sender: "device" as const,
senderLabel: "Win GPU / Codex",
body: "用于群成员修复 contract 的测试线程。",
sentAt: "2026-03-30T10:00:00+08:00",
kind: "text" as const,
},
],
goals: [],
versions: [],
}));
await writeState({
...state,
projects: [...state.projects, ...generatedProjects],
});
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");
});
test("POST /api/v1/projects/[projectId]/participants maps stale member errors to readable copy", async () => {
await setup();
const singles = await ensureTwoSingleThreadProjects();
const groupProject = await createProjectGroupChat({
sourceProjectId: singles[0].id,
memberProjectIds: [singles[1].id],
createdBy: "17600003315",
});
const response = await updateParticipantsRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/participants`,
"POST",
{ memberProjectIds: [singles[0].id, "missing-thread-project"] },
),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(response.status, 400);
const payload = (await response.json()) as { ok: boolean; message: string };
assert.equal(payload.ok, false);
assert.equal(payload.message, "有线程已经不存在,请刷新后重新选择。");
assert.notEqual(payload.message, "GROUP_CHAT_MEMBER_NOT_FOUND");
});