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
+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" },
};
}