import { mkdtemp, rm, stat } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js"; import { archiveFeishuChatBinding, bindFeishuChatToProject, createFolder, createProjectFromFeishuChat, createProjectFromOrgAdmin, setMembersCanCreateProjects, } from "../../src/projectOnboarding.js"; const workspaceRoots: string[] = []; describe("ADR-0021 project onboarding", () => { beforeEach(async () => { await resetDb(); }); afterEach(async () => { while (workspaceRoots.length > 0) { const root = workspaceRoots.pop(); if (root !== undefined) await rm(root, { recursive: true, force: true }); } }); afterAll(async () => { await prisma.$disconnect(); }); it("lets an org admin create an unbound project in a folder", async () => { await seedUser("u-admin", "ou_admin", "ADMIN"); const folder = await createFolder(prisma, { organizationId: DEFAULT_ORG_ID, name: "Grade 7", sortKey: "010000", }); const workspaceRoot = await tempWorkspaceRoot(); const result = await createProjectFromOrgAdmin(prisma, { organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_admin", name: "Newton Lesson", folderId: folder.id, workspaceRoot, }); expect(result.folderId).toBe(folder.id); expect(result.chatId).toBeUndefined(); expect((await stat(result.workspaceDir)).isDirectory()).toBe(true); const grant = await prisma.permissionGrant.findFirst({ where: { resourceType: "PROJECT", resourceId: result.projectId, principalType: "USER", principalId: "ou_admin", revokedAt: null, }, select: { role: true }, }); expect(grant?.role).toBe("MANAGE"); const settings = await prisma.permissionSettings.findUnique({ where: { resourceType_resourceId: { resourceType: "PROJECT", resourceId: result.projectId } }, }); expect(settings?.agentTrigger).toBe("ROLE"); }); it("lets an org member create and bind a project from an unbound Feishu chat", async () => { await seedUser("u-member", "ou_member", "MEMBER"); const workspaceRoot = await tempWorkspaceRoot(); const result = await createProjectFromFeishuChat(prisma, { organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_member", chatId: "chat-onboard", name: "Poetry Workshop", workspaceRoot, }); expect(result.chatId).toBe("chat-onboard"); const binding = await prisma.projectGroupBinding.findFirst({ where: { projectId: result.projectId, chatId: "chat-onboard", archivedAt: null }, }); expect(binding).not.toBeNull(); const grants = await prisma.permissionGrant.findMany({ where: { resourceType: "PROJECT", resourceId: result.projectId, revokedAt: null }, select: { principalType: true, principalId: true, role: true }, orderBy: { principalType: "asc" }, }); expect(grants).toEqual(expect.arrayContaining([ { principalType: "USER", principalId: "ou_member", role: "MANAGE" }, { principalType: "FEISHU_CHAT", principalId: "chat-onboard", role: "EDIT" }, ])); }); it("blocks ordinary Feishu project creation when the org setting is off", async () => { await seedUser("u-member", "ou_member", "MEMBER"); await setMembersCanCreateProjects(prisma, { organizationId: DEFAULT_ORG_ID, enabled: false, }); await expect(createProjectFromFeishuChat(prisma, { organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_member", chatId: "chat-denied", name: "Denied Project", workspaceRoot: await tempWorkspaceRoot(), })).rejects.toThrow(/members cannot create projects/); }); it("binds an existing project once, archives the binding, then allows a new active binding", async () => { await seedUser("u-owner", "ou_owner", "OWNER"); const workspaceRoot = await tempWorkspaceRoot(); const project = await createProjectFromOrgAdmin(prisma, { organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_owner", name: "Reusable Project", workspaceRoot, }); await bindFeishuChatToProject(prisma, { projectId: project.projectId, actorFeishuOpenId: "ou_owner", chatId: "chat-first", }); await expect(bindFeishuChatToProject(prisma, { projectId: project.projectId, actorFeishuOpenId: "ou_owner", chatId: "chat-second", })).rejects.toThrow(/already bound/); await expect(archiveFeishuChatBinding(prisma, { projectId: project.projectId, actorFeishuOpenId: "ou_owner", })).resolves.toMatchObject({ archived: true, chatId: "chat-first" }); await bindFeishuChatToProject(prisma, { projectId: project.projectId, actorFeishuOpenId: "ou_owner", chatId: "chat-second", }); const activeBindings = await prisma.projectGroupBinding.findMany({ where: { projectId: project.projectId, archivedAt: null }, select: { chatId: true }, }); expect(activeBindings).toEqual([{ chatId: "chat-second" }]); const oldChatGrant = await prisma.permissionGrant.findFirst({ where: { resourceType: "PROJECT", resourceId: project.projectId, principalType: "FEISHU_CHAT", principalId: "chat-first", revokedAt: null, }, }); expect(oldChatGrant).toBeNull(); }); }); async function seedUser(id: string, feishuOpenId: string, role: "OWNER" | "ADMIN" | "MEMBER"): Promise { await prisma.user.create({ data: { id, feishuOpenId, displayName: feishuOpenId, organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role } }, }, }); } async function tempWorkspaceRoot(): Promise { const root = await mkdtemp(join(tmpdir(), "cph-onboarding-")); workspaceRoots.push(root); return root; }