feat: add paginated project discovery

This commit is contained in:
2026-07-12 01:45:15 +08:00
parent 53d372e29b
commit 816af1abdb
14 changed files with 1505 additions and 237 deletions
@@ -284,4 +284,59 @@ describe("admin explorer API", () => {
});
},
);
it("rejects moving a folder below one of its descendants", async () => {
const parent = await prisma.folder.create({
data: { id: "folder-cycle-parent", organizationId: DEFAULT_ORG_ID, name: "Parent" },
});
const child = await prisma.folder.create({
data: { id: "folder-cycle-child", organizationId: DEFAULT_ORG_ID, parentId: parent.id, name: "Child" },
});
await expect(renameFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: parent.id,
parentId: child.id,
})).rejects.toThrow("folder cannot be moved below its descendant");
});
it("keeps the system Inbox identity immutable", async () => {
const inbox = await prisma.folder.findFirstOrThrow({
where: { organizationId: DEFAULT_ORG_ID, kind: "SYSTEM_INBOX" },
});
await expect(renameFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: inbox.id,
name: "课程",
})).rejects.toThrow("system Inbox cannot be renamed or moved");
await expect(renameFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: inbox.id,
parentId: null,
})).rejects.toThrow("system Inbox cannot be renamed or moved");
await expect(archiveFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: inbox.id,
})).rejects.toThrow("system Inbox cannot be archived");
await expect(prisma.folder.update({
where: { id: inbox.id },
data: { kind: "REGULAR" },
})).rejects.toThrow("system Inbox identity cannot be changed");
await expect(prisma.folder.update({
where: { id: inbox.id },
data: { id: `${inbox.id}-moved` },
})).rejects.toThrow("system Inbox identity cannot be changed");
await expect(prisma.folder.delete({ where: { id: inbox.id } }))
.rejects.toThrow("system Inbox cannot be deleted while its organization exists");
await expect(prisma.folder.create({
data: {
organizationId: DEFAULT_ORG_ID,
name: "Malformed",
kind: "SYSTEM_INBOX",
parentId: inbox.id,
},
})).rejects.toThrow("system Inbox must be an active root folder named Inbox");
});
});
+16 -36
View File
@@ -34,41 +34,20 @@ export const prisma = new PrismaClient({
/** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> {
const tables = [
"OrganizationAgentRoleSkill",
"OrganizationAgentRole",
"OrganizationAgentSkill",
"FeishuEventReceipt",
"FeishuUserIdentity",
"FeishuApplicationCredentialVersion",
"OrganizationFeishuApplicationConnection",
"ProviderCredentialVersion",
"OrganizationProviderConnection",
"AgentFileChange",
"AgentMessage",
"AuditEntry",
"PermissionSettings",
"PermissionGrant",
"RoleTriggerGrant",
"ExternalPrincipalMembership",
"ExternalDirectoryConnection",
"TeamExternalBinding",
"TeamMembership",
"Team",
"ProjectAgentLock",
"AgentRun",
"AgentSession",
"ProjectGroupBinding",
"Folder",
"OrganizationProjectSettings",
"OrganizationMembership",
"PlatformRoleAssignment",
"User",
"Project",
"Organization",
];
// Truncate with CASCADE to wipe dependent rows in one shot.
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`);
// User and Organization are the aggregate roots for all domain rows; their
// declared FK cascades clear projects, search documents, permissions,
// sessions and connections without repeatedly truncating pg_trgm indexes.
// Event receipts and global audit rows are independent roots.
await prisma.$transaction([
prisma.feishuEventReceipt.deleteMany(),
prisma.auditEntry.deleteMany(),
// Permission resource ids are intentionally polymorphic strings, so these
// two tables have no FK to Project and must be cleared explicitly.
prisma.permissionGrant.deleteMany(),
prisma.permissionSettings.deleteMany(),
prisma.user.deleteMany(),
prisma.organization.deleteMany(),
]);
await seedTestOrganization();
}
@@ -91,7 +70,7 @@ export async function seedTestOrganization(
create: { organizationId: id, membersCanCreateProjects: true },
});
const inbox = await prisma.folder.findFirst({
where: { organizationId: id, parentId: null, name: "Inbox", archivedAt: null },
where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
select: { id: true },
});
if (inbox === null) {
@@ -100,6 +79,7 @@ export async function seedTestOrganization(
id: `folder_inbox_${id}`,
organizationId: id,
name: "Inbox",
kind: "SYSTEM_INBOX",
sortKey: "000000",
},
});
@@ -0,0 +1,228 @@
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,
});
}
+167 -8
View File
@@ -249,19 +249,18 @@ describe("trigger full lifecycle (integration)", () => {
projectWorkspaceRoot: await tempWorkspaceRoot(),
});
await trigger(makeEvent("chat-unbound-card", "@_user_1 开始项目", "ou_onboard_card"), rt);
await trigger(makeEvent("chat-unbound-card", "@_user_1", "ou_onboard_card"), rt);
expect(rt.sentCards).toHaveLength(1);
expect(rt.sentTexts.at(-1)).toContain("这个飞书群还没有绑定项目");
const values = cardActionValues(rt.sentCards[0]);
expect(values).toEqual(expect.arrayContaining([
expect.objectContaining({
project_onboarding: expect.objectContaining({
{
project_onboarding: {
action: "create_project_from_chat",
organization_id: DEFAULT_ORG_ID,
folder_id: expect.any(String),
}),
}),
},
},
]));
expect(runAgentCalls).toHaveLength(0);
});
@@ -297,6 +296,34 @@ describe("trigger full lifecycle (integration)", () => {
{ principalType: "FEISHU_CHAT", principalId: "chat-onboard-create", role: "EDIT" },
]));
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已创建并绑定项目");
expect(JSON.stringify(rt.sentPatches.at(-1))).toContain("project_rename_form");
});
it("opens project management at any time and renames through a validated card form", async () => {
await seedProject("project-management", "chat-management", { role: "MANAGE" });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
await trigger(makeEvent("chat-management", "@_user_1 /project"), rt);
expect(cardHeaderTitle(rt.sentCards.at(-1))).toBe("项目管理");
expect(JSON.stringify(rt.sentCards.at(-1))).toContain("project_rename_form");
await trigger.onCardAction(makeOnboardingEvent("chat-management", {
project_onboarding: {
action: "rename_project",
organization_id: DEFAULT_ORG_ID,
project_id: "project-management",
},
}, "ou_test_user", { project_name: "表面张力课程" }), rt);
await expect(prisma.project.findUniqueOrThrow({ where: { id: "project-management" } }))
.resolves.toMatchObject({ name: "表面张力课程" });
await expect(prisma.auditEntry.findFirstOrThrow({
where: { projectId: "project-management", action: "project.renamed" },
})).resolves.toMatchObject({
metadata: { oldName: "Test project-management", newName: "表面张力课程" },
});
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("项目名称已更新");
});
it("binds an existing manageable project from the unbound-chat onboarding card", async () => {
@@ -361,6 +388,40 @@ describe("trigger full lifecycle (integration)", () => {
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已绑定项目");
});
it("rejects a forged bind card targeting a project in another organization", async () => {
await seedOnboardingUser("u-onboard-cross-org", "ou_onboard_cross_org", "MEMBER");
await seedTestOrganization("org-other-card", "other-card");
await prisma.project.create({
data: {
id: "project-other-card",
organizationId: "org-other-card",
name: "Other project",
workspaceDir: join(await tempWorkspaceRoot(), "project-other-card"),
},
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "project-other-card",
principalType: "USER",
principalId: "ou_onboard_cross_org",
role: "MANAGE",
},
});
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
await trigger.onCardAction(makeOnboardingEvent("chat-cross-org", {
project_onboarding: {
action: "bind_project",
organization_id: DEFAULT_ORG_ID,
project_id: "project-other-card",
},
}, "ou_onboard_cross_org"), rt);
await expect(prisma.projectGroupBinding.count({ where: { chatId: "chat-cross-org" } })).resolves.toBe(0);
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("绑定失败");
});
it("uses unbound-chat mention text to search bindable projects", async () => {
await seedOnboardingUser("u-onboard-search", "ou_onboard_search", "OWNER");
const folder = await prisma.folder.create({
@@ -408,6 +469,98 @@ describe("trigger full lifecycle (integration)", () => {
expect(runAgentCalls).toHaveLength(0);
});
it("paginates every manageable search result through card actions", async () => {
await seedOnboardingUser("u-onboard-pages", "ou_onboard_pages", "OWNER");
const workspaceRoot = await tempWorkspaceRoot();
await prisma.project.createMany({
data: Array.from({ length: 9 }, (_, index) => ({
id: `project-page-${index}`,
organizationId: DEFAULT_ORG_ID,
name: `分页项目-${index}`,
workspaceDir: join(workspaceRoot, `project-page-${index}`),
})),
});
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
projectWorkspaceRoot: workspaceRoot,
});
await trigger(makeEvent("chat-onboard-pages", "@_user_1 分页项目", "ou_onboard_pages"), rt);
const firstPageValues = cardActionValues(rt.sentCards[0]);
expect(firstPageValues.filter((value) => JSON.stringify(value).includes('"bind_project"'))).toHaveLength(8);
expect(firstPageValues).toContainEqual({
project_onboarding: {
action: "search_page",
organization_id: DEFAULT_ORG_ID,
search_query: "分页项目",
page: 2,
},
});
await trigger.onCardAction(makeOnboardingEvent("chat-onboard-pages", {
project_onboarding: {
action: "search_page",
organization_id: DEFAULT_ORG_ID,
search_query: "分页项目",
page: 2,
},
}, "ou_onboard_pages"), rt);
const secondPageValues = cardActionValues(rt.sentPatches.at(-1));
expect(secondPageValues.filter((value) => JSON.stringify(value).includes('"bind_project"'))).toHaveLength(1);
expect(JSON.stringify(rt.sentPatches.at(-1))).toContain("第 **2/2** 页");
});
it("navigates the preserved folder tree from the onboarding card", async () => {
await seedOnboardingUser("u-onboard-folders", "ou_onboard_folders", "OWNER");
const root = await prisma.folder.create({
data: { id: "folder-legacy-root", organizationId: DEFAULT_ORG_ID, name: "旧教学资产" },
});
await prisma.folder.create({
data: { id: "folder-physics-child", organizationId: DEFAULT_ORG_ID, parentId: root.id, name: "物理竞赛教研" },
});
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
projectWorkspaceRoot: await tempWorkspaceRoot(),
});
await trigger(makeEvent("chat-onboard-folders", "@_user_1", "ou_onboard_folders"), rt);
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
project_onboarding: {
action: "browse_folder",
organization_id: DEFAULT_ORG_ID,
folder_id: root.id,
page: 1,
},
});
await trigger.onCardAction(makeOnboardingEvent("chat-onboard-folders", {
project_onboarding: {
action: "browse_folder",
organization_id: DEFAULT_ORG_ID,
folder_id: root.id,
page: 1,
},
}, "ou_onboard_folders"), rt);
expect(JSON.stringify(rt.sentPatches.at(-1))).toContain("旧教学资产");
expect(cardActionValues(rt.sentPatches.at(-1))).toContainEqual({
project_onboarding: {
action: "browse_folder",
organization_id: DEFAULT_ORG_ID,
folder_id: "folder-physics-child",
page: 1,
},
});
});
it("continues searching past unauthorized matches for a manageable project", async () => {
await seedOnboardingUser("u-onboard-page", "ou_onboard_page", "MEMBER");
const workspaceRoot = await tempWorkspaceRoot();
@@ -551,6 +704,7 @@ describe("trigger full lifecycle (integration)", () => {
expect(helpText).toContain("可用 slash 命令");
expect(helpText).toContain("/new");
expect(helpText).toContain("/reset");
expect(helpText).toContain("/project");
expect(helpText).toContain("/draft <需求>");
expect(helpText).toContain("/review <需求>");
expect(runAgentCalls).toHaveLength(0);
@@ -1632,10 +1786,15 @@ function makeInterruptEvent(chatId: string, runId: string, openId: string): Card
};
}
function makeOnboardingEvent(chatId: string, value: unknown, openId: string): CardActionEvent {
function makeOnboardingEvent(
chatId: string,
value: unknown,
openId: string,
formValue?: Readonly<Record<string, unknown>>,
): CardActionEvent {
return {
operator: { open_id: openId },
action: { value, tag: "button" },
action: { value, tag: "button", ...(formValue !== undefined ? { form_value: formValue } : {}) },
context: { open_chat_id: chatId, open_message_id: "card-message-1" },
};
}