import { afterAll, beforeEach, describe, expect, it } from "vitest"; import { browseBindableFolder, discoverBindableProjects, normalizeProjectSearchQuery, } from "../../src/projectDiscovery.js"; import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; describe("project discovery", () => { beforeEach(async () => { await resetDb(); await seedUser("owner", "ou_owner", "OWNER"); await seedUser("member", "ou_member", "MEMBER"); }); afterAll(async () => prisma.$disconnect()); it("normalizes project codes across punctuation, width and case", () => { expect(normalizeProjectSearchQuery(" TH-141 ")).toBe("th141"); expect(normalizeProjectSearchQuery("th_141")).toBe("th141"); }); it("searches normalized codes, Chinese names and complete folder breadcrumbs", async () => { const root = await createFolder("root-legacy", null, "旧教学资产"); const subject = await createFolder("folder-physics", root.id, "物理竞赛教研"); const thermal = await createFolder("folder-thermal", subject.id, "TH_热学专题"); await createProject("project-th141", thermal.id, "TH-141_表面张力的严肃理论"); await createProject("project-fullwidth", thermal.id, "TH-142_全角编号"); await createProject("project-explicit-code", thermal.id, "表面课程", "PHY-001"); await createProject("project-other", thermal.id, "TH-211_理想气体静力学"); const byCode = await discover("TH141"); expect(byCode.items[0]).toMatchObject({ projectId: "project-th141", breadcrumb: "旧教学资产 / 物理竞赛教研 / TH_热学专题", }); await expect(discover("表面张力")).resolves.toMatchObject({ totalItems: 1, items: [{ projectId: "project-th141" }], }); await expect(discover("TH142")).resolves.toMatchObject({ items: [expect.objectContaining({ projectId: "project-fullwidth" })], }); await expect(discover("PHY001")).resolves.toMatchObject({ items: [expect.objectContaining({ projectId: "project-explicit-code" })], }); const byPath = await discover("物理竞赛教研"); expect(byPath.items.map((item) => item.projectId)).toEqual(expect.arrayContaining([ "project-th141", "project-other", ])); await expect(discover("物理竞赛 TH141")).resolves.toMatchObject({ items: [expect.objectContaining({ projectId: "project-th141" })], }); await expect(discover("%")).resolves.toMatchObject({ totalItems: 0 }); }); it("refreshes descendant breadcrumbs after a folder rename", async () => { const root = await createFolder("root-before", null, "旧目录"); const child = await createFolder("child", root.id, "子目录"); await createProject("project-refresh", child.id, "项目"); await prisma.folder.update({ where: { id: root.id }, data: { name: "新目录" } }); await expect(discover("新目录")).resolves.toMatchObject({ totalItems: 1, items: [{ projectId: "project-refresh", breadcrumb: "新目录 / 子目录" }], }); await expect(discover("旧目录")).resolves.toMatchObject({ totalItems: 0 }); }); it("filters authorization before counting and paginating", async () => { for (let index = 0; index < 12; index += 1) { await createProject(`project-${index}`, null, `TH-${index}`); } await prisma.permissionGrant.create({ data: { resourceType: "PROJECT", resourceId: "project-11", principalType: "USER", principalId: "ou_member", role: "MANAGE", }, }); const result = await discoverBindableProjects({ prisma, organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_member", isOrgAdmin: false, query: "TH", page: 2, pageSize: 5, }); expect(result).toMatchObject({ page: 1, totalItems: 1, totalPages: 1 }); expect(result.items.map((item) => item.projectId)).toEqual(["project-11"]); }); it("browses the preserved folder tree and paginates projects", async () => { const root = await createFolder("root", null, "旧教学资产"); const child = await createFolder("child", root.id, "物理竞赛教研"); await createProject("project-root", root.id, "根目录项目"); const result = await browseBindableFolder({ prisma, organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_owner", isOrgAdmin: true, folderId: root.id, }); expect(result.breadcrumb).toBe("旧教学资产"); expect(result.parentFolderId).toBeNull(); expect(result.childFolders).toEqual([{ folderId: child.id, name: "物理竞赛教研" }]); expect(result.projects).toEqual([{ projectId: "project-root", name: "根目录项目", breadcrumb: "旧教学资产", }]); }); it("hides the system Inbox at root and presents its projects as unclassified", async () => { const inbox = await prisma.folder.findFirstOrThrow({ where: { organizationId: DEFAULT_ORG_ID, kind: "SYSTEM_INBOX" }, }); const businessRoot = await createFolder("business-root", null, "业务目录"); const businessInbox = await createFolder("business-inbox", businessRoot.id, "Inbox"); await createProject("project-inbox", inbox.id, "新项目"); const result = await browseBindableFolder({ prisma, organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_owner", isOrgAdmin: true, folderId: null, }); expect(result.childFolders.map((folder) => folder.folderId)).not.toContain(inbox.id); expect(result.projects).toContainEqual({ projectId: "project-inbox", name: "新项目", breadcrumb: "未分类", }); const businessResult = await browseBindableFolder({ prisma, organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_owner", isOrgAdmin: true, folderId: businessRoot.id, }); expect(businessResult.childFolders).toContainEqual({ folderId: businessInbox.id, name: "Inbox" }); }); it("shares one card page budget across folders and projects", async () => { const root = await createFolder("folder-page-root", null, "目录分页"); for (let index = 0; index < 6; index += 1) { await createFolder(`folder-page-${index}`, root.id, `目录-${index}`); await createProject(`project-page-${index}`, root.id, `项目-${index}`); } const first = await browseBindableFolder({ prisma, organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_owner", isOrgAdmin: true, folderId: root.id, pageSize: 8, }); const second = await browseBindableFolder({ prisma, organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_owner", isOrgAdmin: true, folderId: root.id, page: 2, pageSize: 8, }); expect(first).toMatchObject({ page: 1, totalPages: 2, totalFolders: 6, totalProjects: 6 }); expect(first.childFolders.length + first.projects.length).toBe(8); expect(second.childFolders.length + second.projects.length).toBe(4); expect([...first.projects, ...second.projects].map((project) => project.projectId).sort()).toEqual( Array.from({ length: 6 }, (_, index) => `project-page-${index}`), ); }); }); async function seedUser(id: string, feishuOpenId: string, role: "OWNER" | "MEMBER"): Promise { await prisma.user.create({ data: { id, feishuOpenId, displayName: id, organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role } }, }, }); } async function createFolder(id: string, parentId: string | null, name: string) { return prisma.folder.create({ data: { id, organizationId: DEFAULT_ORG_ID, parentId, name }, }); } async function createProject(id: string, folderId: string | null, name: string, code?: string) { return prisma.project.create({ data: { id, organizationId: DEFAULT_ORG_ID, folderId, ...(code !== undefined ? { code } : {}), name, workspaceDir: `/tmp/${id}`, }, }); } function discover(query: string) { return discoverBindableProjects({ prisma, organizationId: DEFAULT_ORG_ID, actorFeishuOpenId: "ou_owner", isOrgAdmin: true, query, }); }