Files

229 lines
8.1 KiB
TypeScript

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<void> {
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,
});
}