import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PrismaClient } from "./client.js"; import { createGroupRepos } from "./groups.js"; import { IsolationError } from "./scope.js"; describe("listSpaceGroupsForSpaces", () => { it("loads and maps compact cross-space group fields", async () => { const findMany = vi.fn(async (_query: { where: unknown; select: Record }) => [ { id: "group-1", spaceId: "workspace-2", kind: "group", name: "Support crew", pinned: true, sectionId: null, updatedAt: new Date("2026-08-20T00:00:00.000Z"), thread: { unread: true, messages: [{ blocks: [{ kind: "text", text: "Escalation pending" }] }], }, members: [ { bot: { id: "bot-1", name: "Triage", color: "#111", runs: [] } }, { bot: { id: "bot-2", name: "Responder", color: "#222", runs: [{ status: "running" }], }, }, ], }, ]); const repos = createGroupRepos({ chatGroup: { findMany } } as unknown as PrismaClient); const actor = { spaceId: "workspace-1", userId: "user-1", email: "user@example.test", isDeploymentOwner: false, }; await expect(repos.listSpaceGroupsForSpaces(actor, ["workspace-2"])).resolves.toEqual([ { id: "group-1", spaceId: "workspace-2", kind: "group", name: "Support crew", pinned: true, sectionId: null, members: [ { botId: "bot-1", name: "Triage", color: "#111", status: "idle" }, { botId: "bot-2", name: "Responder", color: "#222", status: "running" }, ], preview: "Escalation pending", unread: true, updatedAt: "2026-08-20T00:00:00.000Z", }, ]); const query = findMany.mock.calls[0]![0]; expect(query.select).not.toHaveProperty("userId"); expect(query.select).not.toHaveProperty("archivedAt"); expect(query.select).not.toHaveProperty("createdAt"); }); }); describe("group previews", () => { const actor = { spaceId: "workspace-1", userId: "user-1", email: "user@example.test", isDeploymentOwner: false, }; const card = { kind: "choice", step: "rhythm", question: "When should I check in with you?", options: [{ id: "morning", letter: "A", label: "Every morning" }], }; const note = { kind: "intake_note", note: "done", nextCheckInAt: "2026-10-05T06:00:00.000Z", text: "All set. I’ll check in tomorrow at 8:00 AM.", }; const latest: Record = { card: [card], note: [note], text: [{ kind: "text", text: "Hello" }], malformed: [{ kind: "choice", question: 1 }], empty: undefined, }; const records = Object.entries(latest).map(([id, blocks]) => ({ id, spaceId: actor.spaceId, userId: actor.userId, kind: "main", name: "Chief", leadBotId: "bot-1", pinned: false, sectionId: null, archivedAt: null, createdAt: new Date("2026-10-04T00:00:00.000Z"), updatedAt: new Date("2026-10-04T00:00:00.000Z"), thread: { id: `thread-${id}`, unread: false, messages: blocks ? [{ blocks }] : [] }, members: [{ bot: { id: "bot-1", name: "Chief", color: "#111", runs: [] } }], })); it.each(["listGroups", "listSpaceGroupsForSpaces"] as const)( "%s keeps the latest onboarding card or note next to the preview", async (list) => { const repos = createGroupRepos({ chatGroup: { findMany: vi.fn(async () => records) }, } as unknown as PrismaClient); const groups = list === "listGroups" ? await repos.listGroups(actor) : await repos.listSpaceGroupsForSpaces(actor, [actor.spaceId]); expect( groups.map(({ id, preview, previewBlock }) => ({ id, preview, previewBlock })), ).toEqual([ { id: "card", preview: "", previewBlock: card }, { id: "note", preview: note.text, previewBlock: note }, { id: "text", preview: "Hello", previewBlock: undefined }, { id: "malformed", preview: "", previewBlock: undefined }, { id: "empty", preview: "", previewBlock: undefined }, ]); // Everything else leaves the field out, so the wire carries nothing extra. expect(groups.filter((group) => "previewBlock" in group).map(({ id }) => id)).toEqual([ "card", "note", ]); }, ); }); describe("archiveGroup", () => { const actor = { spaceId: "workspace-1", userId: "user-1", email: "user@example.com", isDeploymentOwner: true, }; let queryRaw: ReturnType; let findFirst: ReturnType; let findManyRuns: ReturnType; let findManyComputers: ReturnType; let runUpdateMany: ReturnType; let attemptUpdateMany: ReturnType; let taskUpdateMany: ReturnType; let leaseUpdateMany: ReturnType; let leaseFindMany: ReturnType; let computerUpdateMany: ReturnType; let eventDeleteMany: ReturnType; let groupUpdate: ReturnType; let prisma: PrismaClient; beforeEach(() => { queryRaw = vi.fn().mockResolvedValue([{ id: "group-1" }]); findFirst = vi.fn().mockResolvedValue({ thread: { id: "thread-1" } }); findManyRuns = vi.fn().mockResolvedValue([{ id: "run-1", taskId: "task-1" }]); findManyComputers = vi.fn().mockResolvedValue([ { id: "computer-1", homeKey: "home-1", kind: "fake", providerRef: "computer-1", executionBotId: "bot-1", executionRunId: "run-1", }, ]); runUpdateMany = vi.fn(); attemptUpdateMany = vi.fn(); taskUpdateMany = vi.fn(); leaseUpdateMany = vi.fn(); leaseFindMany = vi .fn() .mockResolvedValue([{ computerId: "computer-1", runId: "run-1", fence: 3 }]); computerUpdateMany = vi.fn(); eventDeleteMany = vi.fn(); groupUpdate = vi.fn(); const tx = { $queryRaw: queryRaw, chatGroup: { findFirst, update: groupUpdate }, run: { findMany: findManyRuns, updateMany: runUpdateMany }, attempt: { updateMany: attemptUpdateMany }, task: { updateMany: taskUpdateMany }, computerExecutionLease: { findMany: leaseFindMany, updateMany: leaseUpdateMany }, computer: { findMany: findManyComputers, updateMany: computerUpdateMany }, event: { deleteMany: eventDeleteMany }, }; prisma = { $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), } as unknown as PrismaClient; }); it("locks the group, archives it, and cancels only that thread's runs", async () => { const repos = createGroupRepos(prisma); await expect(repos.archiveGroup(actor, "group-1")).resolves.toEqual({ cancelledRunIds: ["run-1"], computers: [ { id: "computer-1", homeKey: "home-1", kind: "fake", providerRef: "computer-1", executionBotId: "bot-1", executionRunId: "run-1", executionFence: 3, }, ], }); expect(queryRaw).toHaveBeenCalled(); expect(findManyRuns).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ threadId: "thread-1" }), }), ); expect(findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ id: "group-1", archivedAt: null, }), }), ); expect(leaseUpdateMany).toHaveBeenCalledWith({ where: { runId: { in: ["run-1"] } }, data: { expiresAt: new Date(0) }, }); expect(computerUpdateMany).toHaveBeenCalledWith({ where: { executionRunId: { in: ["run-1"] } }, data: { executionRunId: null, executionBotId: null, executionLeaseExpiresAt: null, }, }); expect(groupUpdate).toHaveBeenCalledWith({ where: { id: "group-1" }, data: expect.objectContaining({ pinned: false, archivedAt: expect.any(Date) }), }); }); it("rejects when the group is already archived or missing", async () => { findFirst.mockResolvedValue(null); const repos = createGroupRepos(prisma); await expect(repos.archiveGroup(actor, "group-1")).rejects.toBeInstanceOf(IsolationError); expect(groupUpdate).not.toHaveBeenCalled(); }); });