forked from EduCraft/curriculum-project-hub
feat: add paginated project discovery
This commit is contained in:
@@ -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" },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user