diff --git a/.changeset/fn-9217-project-scoped-routine-list.md b/.changeset/fn-9217-project-scoped-routine-list.md new file mode 100644 index 0000000000..5b3d31ae8a --- /dev/null +++ b/.changeset/fn-9217-project-scoped-routine-list.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Show project-scoped Automations and Routines instead of an empty list. +category: fix +dev: GET /routines and GET /automations now let scope=project bypass legacy global-store guards and resolve the project store. diff --git a/packages/dashboard/src/__tests__/routes-automation.test.ts b/packages/dashboard/src/__tests__/routes-automation.test.ts index 54a8ed66a8..c20c9864c8 100644 --- a/packages/dashboard/src/__tests__/routes-automation.test.ts +++ b/packages/dashboard/src/__tests__/routes-automation.test.ts @@ -747,6 +747,26 @@ describe("Automation routes", () => { expect(res.status).toBe(200); expect(res.body).toEqual([]); }); + + it("reads project-scoped schedules from an engine-backed store without a global store", async () => { + const automationStore = createMockAutomationStore(); + const projectSchedule = { ...FAKE_SCHEDULE, id: "sched-project-1", scope: "project" as const }; + const globalSchedule = { ...FAKE_SCHEDULE, id: "sched-global-1", scope: "global" as const }; + automationStore.listSchedules.mockResolvedValue([projectSchedule, globalSchedule]); + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(createMockStore(), { + engineManager: { + getEngine: vi.fn().mockReturnValue({ getAutomationStore: () => automationStore }), + }, + } as any)); + + const res = await GET(app, "/api/automations?scope=project&projectId=proj-1"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([projectSchedule]); + expect(automationStore.listSchedules).toHaveBeenCalledTimes(1); + }); }); describe("POST /automations", () => { @@ -1900,16 +1920,33 @@ describe("Automation routes", () => { expect(res.body.some((s: any) => s.scope === "global")).toBe(false); }); - it("returns empty array when automation store unavailable (scope=project) - legacy fallback", async () => { - // Build app WITHOUT automationStore option - routes return empty array for backward compatibility - const store = createMockStore(); + it("returns 503 when no automation store is resolvable for scope=project", async () => { const app = express(); app.use(express.json()); - app.use("/api", createApiRoutes(store)); + app.use("/api", createApiRoutes(createMockStore())); + + const res = await GET(app, "/api/automations?scope=project&projectId=proj-1"); + + expect(res.status).toBe(503); + expect(res.body.error).toContain("Automation store not available"); + }); + + it("returns an empty project list from the engine-backed automation store", async () => { + const automationStore = createMockAutomationStore(); + automationStore.listSchedules.mockResolvedValue([]); + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(createMockStore(), { + engineManager: { + getEngine: vi.fn().mockReturnValue({ getAutomationStore: () => automationStore }), + }, + } as any)); + + const res = await GET(app, "/api/automations?scope=project&projectId=proj-1"); - const res = await GET(app, "/api/automations?scope=project"); expect(res.status).toBe(200); expect(res.body).toEqual([]); + expect(automationStore.listSchedules).toHaveBeenCalledTimes(1); }); it("returns empty array when automation store unavailable (scope=global) - legacy fallback", async () => { @@ -2030,6 +2067,53 @@ describe("Routine routes", () => { expect(res.status).toBe(200); expect(res.body).toEqual([]); }); + + it("reads duplicate project-scoped routines from an engine-backed store without a global store", async () => { + const routineStore = createMockRoutineStore(); + const projectRoutineOne = { ...FAKE_ROUTINE, id: "routine-project-1", scope: "project" as const }; + const projectRoutineTwo = { ...FAKE_ROUTINE, id: "routine-project-2", scope: "project" as const }; + const globalRoutine = { ...FAKE_ROUTINE, id: "routine-global-1", scope: "global" as const }; + routineStore.listRoutines.mockResolvedValue([projectRoutineOne, projectRoutineTwo, globalRoutine]); + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(createMockStore(), { + engineManager: { + getEngine: vi.fn().mockReturnValue({ getRoutineStore: () => routineStore }), + }, + } as any)); + + const res = await GET(app, "/api/routines?scope=project&projectId=proj-1"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([projectRoutineOne, projectRoutineTwo]); + expect(routineStore.listRoutines).toHaveBeenCalledTimes(1); + }); + + it("uses the same engine-backed routine store for project-scoped creation and listing", async () => { + const routineStore = createMockRoutineStore(); + const createdRoutine = { ...FAKE_ROUTINE, id: "routine-created-1", name: "Created Project Routine" }; + routineStore.createRoutine.mockResolvedValue(createdRoutine); + routineStore.listRoutines.mockResolvedValue([createdRoutine]); + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(createMockStore(), { + engineManager: { + getEngine: vi.fn().mockReturnValue({ getRoutineStore: () => routineStore }), + }, + } as any)); + + const create = await REQUEST(app, "POST", "/api/routines?scope=project&projectId=proj-1", JSON.stringify({ + name: "Created Project Routine", + trigger: { type: "cron", cronExpression: "0 * * * *" }, + }), { "Content-Type": "application/json" }); + const list = await GET(app, "/api/routines?scope=project&projectId=proj-1"); + + expect(create.status).toBe(201); + expect(routineStore.createRoutine).toHaveBeenCalledTimes(1); + expect(list.status).toBe(200); + expect(list.body).toEqual([createdRoutine]); + expect(routineStore.listRoutines).toHaveBeenCalledTimes(1); + }); }); describe("POST /routines", () => { @@ -3022,16 +3106,33 @@ describe("Routine routes", () => { expect(res.body.some((r: any) => r.scope === "global")).toBe(false); }); - it("returns empty array when routine store unavailable (scope=project) - legacy fallback", async () => { - // Build app WITHOUT routineStore option - routes return empty array for backward compatibility - const store = createMockStore(); + it("returns 503 when no routine store is resolvable for scope=project", async () => { const app = express(); app.use(express.json()); - app.use("/api", createApiRoutes(store)); + app.use("/api", createApiRoutes(createMockStore())); + + const res = await GET(app, "/api/routines?scope=project&projectId=proj-1"); + + expect(res.status).toBe(503); + expect(res.body.error).toContain("Routine store not available"); + }); + + it("returns an empty project list from the engine-backed routine store", async () => { + const routineStore = createMockRoutineStore(); + routineStore.listRoutines.mockResolvedValue([]); + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(createMockStore(), { + engineManager: { + getEngine: vi.fn().mockReturnValue({ getRoutineStore: () => routineStore }), + }, + } as any)); + + const res = await GET(app, "/api/routines?scope=project&projectId=proj-1"); - const res = await GET(app, "/api/routines?scope=project"); expect(res.status).toBe(200); expect(res.body).toEqual([]); + expect(routineStore.listRoutines).toHaveBeenCalledTimes(1); }); it("returns empty array when routine store unavailable (scope=global) - legacy fallback", async () => { diff --git a/packages/dashboard/src/routes/register-plugins-automation.ts b/packages/dashboard/src/routes/register-plugins-automation.ts index 905e3c5152..eda3601407 100644 --- a/packages/dashboard/src/routes/register-plugins-automation.ts +++ b/packages/dashboard/src/routes/register-plugins-automation.ts @@ -46,13 +46,16 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu // GET /automations — list all scheduled tasks (optionally filtered by scope) router.get("/automations", async (req: Request, res: Response) => { - // Return empty array when no store available (legacy backward-compatible behavior) - if (!options?.automationStore) { - return res.json([]); - } - try { const scope = parseScopeParam(req); + /* + FNXC:PluginsAutomationRoutes 2026-08-27-14:40: + The legacy empty-list fallback applies only to omitted/global reads. Project reads must + resolve the engine-backed store used by creates, surfacing 503 when none is available. + */ + if (!options?.automationStore && scope !== "project") { + return res.json([]); + } const automationStore = resolveAutomationStore(req, scope); // Get all schedules and filter by scope if specified @@ -403,13 +406,16 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu // GET /routines — list all routines (optionally filtered by scope) router.get("/routines", async (req: Request, res: Response) => { - // Return empty array when no store available (legacy backward-compatible behavior) - if (!options?.routineStore) { - return res.json([]); - } - try { const scope = parseScopeParam(req); + /* + FNXC:PluginsAutomationRoutes 2026-08-27-14:40: + The legacy empty-list fallback applies only to omitted/global reads. Project reads must + resolve the engine-backed store used by creates, surfacing 503 when none is available. + */ + if (!options?.routineStore && scope !== "project") { + return res.json([]); + } const routineStore = resolveRoutineStore(req, scope); // Get all routines and filter by scope if specified