/** * Route-level regression tests for plugin install mode. * * Covers: * - POST /api/plugins mode:"install" with package-root path (manifest.json present) * - POST /api/plugins mode:"install" with dist-folder path (manifest.json present) * - Negative: missing manifest.json * - Negative: invalid JSON manifest * - Negative: manifest missing required fields * - Negative: empty / missing path * - Negative: missing mode discriminator */ // @vitest-environment node import { describe, it, expect, vi, beforeEach } from "vitest"; import express from "express"; import type { TaskStore, PluginStore, PluginLoader, PluginInstallation } from "@fusion/core"; import { createApiRoutes } from "../routes.js"; import { get as performGet, request as performRequest } from "../test-request.js"; import * as projectStoreResolver from "../project-store-resolver.js"; // ── Mock @fusion/core ───────────────────────────────────────────── const mockCentralInit = vi.fn().mockResolvedValue(undefined); const mockCentralClose = vi.fn().mockResolvedValue(undefined); vi.mock("@fusion/core", async () => { const actual = await vi.importActual("@fusion/core"); return { ...actual, CentralCore: vi.fn().mockImplementation(() => ({ init: mockCentralInit, close: mockCentralClose, })), }; }); // ── Mock node:fs (used by install mode) ────────────────────────── const mockExistsSync = vi.fn<(p: string) => boolean>().mockReturnValue(false); const mockStatSync = vi.fn<(p: string) => { isDirectory: () => boolean }>().mockReturnValue({ isDirectory: () => true }); const mockAccess = vi.fn<(p: string) => Promise>().mockRejectedValue(new Error("not found")); const mockStat = vi.fn<(p: string) => Promise<{ isDirectory: () => boolean }>>().mockResolvedValue({ isDirectory: () => true }); const mockReadFile = vi.fn<(p: string, enc: string) => Promise>().mockRejectedValue(new Error("not found")); vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); return { ...actual, existsSync: (...args: Parameters) => mockExistsSync(args[0] as string), statSync: (...args: Parameters) => mockStatSync(args[0] as string), }; }); vi.mock("node:fs/promises", async () => { const actual = await vi.importActual("node:fs/promises"); return { ...actual, access: (...args: Parameters) => mockAccess(args[0] as string), stat: (...args: Parameters) => mockStat(args[0] as string), readFile: (...args: Parameters) => mockReadFile(args[0] as string, (args[1] ?? "utf-8") as string), }; }); // ── Mock project store resolver ────────────────────────────────── const mockGetOrCreateProjectStore = vi.fn(); vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockImplementation(mockGetOrCreateProjectStore); // ── Helpers ────────────────────────────────────────────────────── function createMockPluginStore(overrides: Partial = {}): PluginStore { return { listPlugins: vi.fn().mockResolvedValue([]), getPlugin: vi.fn(), registerPlugin: vi.fn(), unregisterPlugin: vi.fn(), enablePlugin: vi.fn(), disablePlugin: vi.fn(), updatePluginSettings: vi.fn(), updatePluginState: vi.fn(), updatePlugin: vi.fn(), ...overrides, } as unknown as PluginStore; } function createMockPluginLoader(overrides: Partial = {}): PluginLoader { return { loadPlugin: vi.fn().mockResolvedValue(undefined), stopPlugin: vi.fn().mockResolvedValue(undefined), getPlugin: vi.fn(), getLoadedPlugins: vi.fn().mockReturnValue([]), getPluginTools: vi.fn().mockReturnValue([]), getPluginRoutes: vi.fn().mockReturnValue([]), getPluginUiSlots: vi.fn().mockReturnValue([]), getPluginDashboardViews: vi.fn().mockReturnValue([]), loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }), stopAllPlugins: vi.fn().mockResolvedValue(undefined), invokeHook: vi.fn().mockResolvedValue(undefined), reloadPlugin: vi.fn().mockResolvedValue(undefined), ...overrides, } as unknown as PluginLoader; } function createMockTaskStore(overrides: Partial = {}): TaskStore { return { getTask: vi.fn(), listTasks: vi.fn().mockResolvedValue([]), searchTasks: vi.fn().mockResolvedValue([]), createTask: vi.fn(), moveTask: vi.fn(), updateTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), archiveTask: vi.fn(), unarchiveTask: vi.fn(), getSettings: vi.fn().mockResolvedValue({}), getSettingsFast: vi.fn().mockResolvedValue({}), updateSettings: vi.fn(), updateGlobalSettings: vi.fn(), getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }), getGlobalSettingsStore: vi.fn().mockReturnValue({ getSettings: vi.fn().mockResolvedValue({}), updateSettings: vi.fn().mockResolvedValue({}), }), logEntry: vi.fn().mockResolvedValue(undefined), getAgentLogs: vi.fn().mockResolvedValue([]), getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]), addSteeringComment: vi.fn(), addTaskComment: vi.fn(), updateTaskComment: vi.fn(), deleteTaskComment: vi.fn(), updatePrInfo: vi.fn().mockResolvedValue(undefined), updateIssueInfo: vi.fn().mockResolvedValue(undefined), getRootDir: vi.fn().mockReturnValue("/fake/root"), getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"), listWorkflowSteps: vi.fn().mockResolvedValue([]), createWorkflowStep: vi.fn(), getWorkflowStep: vi.fn(), updateWorkflowStep: vi.fn(), deleteWorkflowStep: vi.fn(), getMissionStore: vi.fn().mockReturnValue({ listMissions: vi.fn().mockReturnValue([]), createMission: vi.fn(), getMissionWithHierarchy: vi.fn(), updateMission: vi.fn(), getMission: vi.fn(), deleteMission: vi.fn(), listMilestonesByMission: vi.fn().mockReturnValue([]), createMilestone: vi.fn(), updateMilestone: vi.fn(), getMilestone: vi.fn(), deleteMilestone: vi.fn(), listTasksByMilestone: vi.fn().mockReturnValue([]), createMissionTask: vi.fn(), updateMissionTask: vi.fn(), getMissionTask: vi.fn(), deleteMissionTask: vi.fn(), }), getPluginStore: vi.fn(), ...overrides, } as unknown as TaskStore; } const VALID_MANIFEST = { id: "my-plugin", name: "My Plugin", version: "1.0.0", description: "A valid plugin", }; const INSTALLED_PLUGIN: PluginInstallation = { id: "my-plugin", name: "My Plugin", version: "1.0.0", description: "A valid plugin", path: "/home/user/plugins/my-plugin", enabled: true, state: "installed", settings: {}, dependencies: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }; async function REQUEST( app: express.Express, method: string, path: string, body?: unknown, ): Promise<{ status: number; body: any }> { const res = await performRequest( app, method, path, body ? JSON.stringify(body) : undefined, body ? { "content-type": "application/json" } : undefined, ); return { status: res.status, body: res.body }; } // ══════════════════════════════════════════════════════════════════ describe("POST /api/plugins mode:install — package root path", () => { let pluginStore: PluginStore; let pluginLoader: PluginLoader; let store: TaskStore; beforeEach(() => { vi.clearAllMocks(); pluginStore = createMockPluginStore(); pluginLoader = createMockPluginLoader(); store = createMockTaskStore({ getPluginStore: vi.fn().mockReturnValue(pluginStore), }); }); function buildApp() { const app = express(); app.use(express.json()); app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader })); return app; } it("accepts a package root with valid manifest.json and returns 201", async () => { const pkgRoot = "/home/user/plugins/my-plugin"; mockAccess.mockImplementation((p: string) => { if (p === pkgRoot || p === `${pkgRoot}/manifest.json`) return Promise.resolve(); return Promise.reject(new Error("not found")); }); mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST)); (pluginStore.registerPlugin as ReturnType).mockResolvedValue(INSTALLED_PLUGIN); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: pkgRoot, }); expect(res.status).toBe(201); expect(res.body).toMatchObject({ id: "my-plugin", name: "My Plugin" }); expect(pluginStore.registerPlugin).toHaveBeenCalledWith( expect.objectContaining({ manifest: expect.objectContaining({ id: "my-plugin" }), path: pkgRoot, }), ); }); it("accepts a dist folder path with valid manifest.json and returns 201", async () => { const distPath = "/home/user/plugins/my-plugin/dist"; mockAccess.mockImplementation((p: string) => { if (p === distPath || p === `${distPath}/manifest.json`) return Promise.resolve(); return Promise.reject(new Error("not found")); }); mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST)); (pluginStore.registerPlugin as ReturnType).mockResolvedValue({ ...INSTALLED_PLUGIN, path: distPath, }); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: distPath, }); expect(res.status).toBe(201); expect(res.body).toMatchObject({ id: "my-plugin" }); expect(pluginStore.registerPlugin).toHaveBeenCalledWith( expect.objectContaining({ path: distPath }), ); }); it("loads plugin after registration when enabled", async () => { const pkgRoot = "/some/path"; mockAccess.mockReturnValue(Promise.resolve()); mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST)); (pluginStore.registerPlugin as ReturnType).mockResolvedValue({ ...INSTALLED_PLUGIN, enabled: true, }); await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: pkgRoot, }); expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("my-plugin"); }); }); // ══════════════════════════════════════════════════════════════════ describe("POST /api/plugins mode:install — negative paths", () => { let pluginStore: PluginStore; let pluginLoader: PluginLoader; let store: TaskStore; beforeEach(() => { vi.clearAllMocks(); pluginStore = createMockPluginStore(); pluginLoader = createMockPluginLoader(); store = createMockTaskStore({ getPluginStore: vi.fn().mockReturnValue(pluginStore), }); }); function buildApp() { const app = express(); app.use(express.json()); app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader })); return app; } it("returns 404 when path does not exist", async () => { mockAccess.mockRejectedValue(new Error("not found")); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: "/nonexistent/dir", }); expect(res.status).toBe(404); expect(res.body.error).toContain("does not exist"); }); it("returns 404 when directory exists but manifest.json is missing", async () => { // Directory exists, but no manifest.json inside it mockAccess.mockImplementation((p: string) => { if (p === "/empty/dir") return Promise.resolve(); return Promise.reject(new Error("not found")); }); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: "/empty/dir", }); expect(res.status).toBe(404); expect(res.body.error).toContain("manifest"); }); it("returns 400 when manifest.json is not valid JSON", async () => { mockAccess.mockReturnValue(Promise.resolve()); mockReadFile.mockResolvedValue("not valid json {{{"); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: "/bad/json", }); expect(res.status).toBe(400); expect(res.body.error).toContain("Invalid JSON"); }); it("returns 400 when manifest is missing required 'id' field", async () => { mockAccess.mockReturnValue(Promise.resolve()); mockReadFile.mockResolvedValue( JSON.stringify({ name: "No Id", version: "1.0.0" }), ); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: "/missing/id", }); expect(res.status).toBe(400); expect(res.body.error).toContain("Invalid plugin manifest"); expect(res.body.error).toMatch(/id/i); }); it("returns 400 when manifest is missing required 'name' field", async () => { mockAccess.mockReturnValue(Promise.resolve()); mockReadFile.mockResolvedValue( JSON.stringify({ id: "no-name", version: "1.0.0" }), ); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: "/missing/name", }); expect(res.status).toBe(400); expect(res.body.error).toContain("Invalid plugin manifest"); expect(res.body.error).toMatch(/name/i); }); it("returns 400 when manifest is missing required 'version' field", async () => { mockAccess.mockReturnValue(Promise.resolve()); mockReadFile.mockResolvedValue( JSON.stringify({ id: "no-ver", name: "No Version" }), ); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: "/missing/version", }); expect(res.status).toBe(400); expect(res.body.error).toContain("Invalid plugin manifest"); expect(res.body.error).toMatch(/version/i); }); it("returns 400 when path is empty string", async () => { const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: " ", }); expect(res.status).toBe(400); expect(res.body.error).toContain("path"); }); it("returns 400 when path is missing entirely", async () => { const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", }); expect(res.status).toBe(400); expect(res.body.error).toContain("path"); }); it("returns 400 when mode is missing", async () => { const res = await REQUEST(buildApp(), "POST", "/api/plugins", { path: "/some/path", }); expect(res.status).toBe(400); expect(res.body.error).toContain("mode"); }); it("returns 400 for unknown mode value", async () => { const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "magic", path: "/some/path", }); expect(res.status).toBe(400); expect(res.body.error).toContain("Invalid mode"); }); it("returns 409 when plugin is already registered", async () => { mockAccess.mockReturnValue(Promise.resolve()); mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST)); (pluginStore.registerPlugin as ReturnType).mockRejectedValue( new Error('Plugin "my-plugin" is already registered'), ); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: "/dup/plugin", }); expect(res.status).toBe(409); expect(res.body.error).toContain("already registered"); }); it("returns 400 when plugin loader is not available (install mode)", async () => { const app = express(); app.use(express.json()); app.use("/api", createApiRoutes(store, { pluginStore /* no pluginLoader */ })); const res = await REQUEST(app, "POST", "/api/plugins", { mode: "install", path: "/some/path", }); expect(res.status).toBe(400); expect(res.body.error).toContain("not supported"); }); }); // ══════════════════════════════════════════════════════════════════ describe("POST /api/plugins mode:install — manifest validation edge cases", () => { let pluginStore: PluginStore; let pluginLoader: PluginLoader; let store: TaskStore; beforeEach(() => { vi.clearAllMocks(); pluginStore = createMockPluginStore(); pluginLoader = createMockPluginLoader(); store = createMockTaskStore({ getPluginStore: vi.fn().mockReturnValue(pluginStore), }); }); function buildApp() { const app = express(); app.use(express.json()); app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader })); return app; } it("rejects manifest with invalid id format (uppercase)", async () => { mockAccess.mockReturnValue(Promise.resolve()); mockReadFile.mockResolvedValue( JSON.stringify({ id: "BadId", name: "Bad", version: "1.0.0" }), ); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: "/bad/id-format", }); expect(res.status).toBe(400); expect(res.body.error).toContain("Invalid plugin manifest"); }); it("rejects manifest that is an array", async () => { mockAccess.mockReturnValue(Promise.resolve()); mockReadFile.mockResolvedValue(JSON.stringify([1, 2, 3])); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: "/array/manifest", }); expect(res.status).toBe(400); expect(res.body.error).toContain("Invalid plugin manifest"); }); it("accepts a fully valid manifest with optional fields", async () => { const fullManifest = { id: "full-plugin", name: "Full Plugin", version: "2.0.0", description: "Has everything", author: "Test", homepage: "https://example.com", }; mockAccess.mockReturnValue(Promise.resolve()); mockReadFile.mockResolvedValue(JSON.stringify(fullManifest)); (pluginStore.registerPlugin as ReturnType).mockResolvedValue({ ...INSTALLED_PLUGIN, id: "full-plugin", name: "Full Plugin", version: "2.0.0", }); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: "/full/plugin", }); expect(res.status).toBe(201); expect(pluginStore.registerPlugin).toHaveBeenCalledWith( expect.objectContaining({ manifest: expect.objectContaining({ id: "full-plugin", description: "Has everything", author: "Test", }), }), ); }); }); // ══════════════════════════════════════════════════════════════════ describe("POST /api/plugins mode:install — dist-folder parent resolution", () => { let pluginStore: PluginStore; let pluginLoader: PluginLoader; let store: TaskStore; beforeEach(() => { vi.clearAllMocks(); pluginStore = createMockPluginStore({ registerPlugin: vi.fn().mockResolvedValue(INSTALLED_PLUGIN), }); pluginLoader = createMockPluginLoader(); store = createMockTaskStore({ getPluginStore: vi.fn().mockReturnValue(pluginStore), }); }); function buildApp() { const app = express(); app.use(express.json()); app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader })); return app; } it("resolves manifest from parent when dist/ folder is selected", async () => { const distPath = "/home/user/plugins/my-plugin/dist"; const parentPath = "/home/user/plugins/my-plugin"; // dist exists, no manifest in dist, but manifest in parent mockAccess.mockImplementation((p: string) => { if (p === distPath || p === `${parentPath}/manifest.json`) return Promise.resolve(); return Promise.reject(new Error("not found")); }); mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST)); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: distPath, }); expect(res.status).toBe(201); expect(pluginStore.registerPlugin).toHaveBeenCalledWith( expect.objectContaining({ path: parentPath }), ); }); it("resolves manifest from parent when build/ folder is selected", async () => { const buildPath = "/home/user/plugins/my-plugin/build"; const parentPath = "/home/user/plugins/my-plugin"; mockAccess.mockImplementation((p: string) => { if (p === buildPath || p === `${parentPath}/manifest.json`) return Promise.resolve(); return Promise.reject(new Error("not found")); }); mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST)); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: buildPath, }); expect(res.status).toBe(201); expect(pluginStore.registerPlugin).toHaveBeenCalledWith( expect.objectContaining({ path: parentPath }), ); }); it("resolves manifest from parent when lib/ folder is selected", async () => { const libPath = "/home/user/plugins/my-plugin/lib"; const parentPath = "/home/user/plugins/my-plugin"; mockAccess.mockImplementation((p: string) => { if (p === libPath || p === `${parentPath}/manifest.json`) return Promise.resolve(); return Promise.reject(new Error("not found")); }); mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST)); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: libPath, }); expect(res.status).toBe(201); expect(pluginStore.registerPlugin).toHaveBeenCalledWith( expect.objectContaining({ path: parentPath }), ); }); it("does NOT look in parent for non-dist directories like src/", async () => { const srcPath = "/home/user/plugins/my-plugin/src"; const parentPath = "/home/user/plugins/my-plugin"; mockAccess.mockImplementation((p: string) => { if (p === srcPath || p === `${parentPath}/manifest.json`) return Promise.resolve(); return Promise.reject(new Error("not found")); }); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: srcPath, }); expect(res.status).toBe(404); expect(res.body.error).toContain("manifest"); }); it("prefers manifest in selected dir over parent", async () => { const distPath = "/home/user/plugins/my-plugin/dist"; const parentPath = "/home/user/plugins/my-plugin"; // Both dist and parent have manifest.json mockAccess.mockImplementation((p: string) => { if (p === distPath || p === `${distPath}/manifest.json` || p === `${parentPath}/manifest.json`) return Promise.resolve(); return Promise.reject(new Error("not found")); }); const distManifest = { ...VALID_MANIFEST, id: "dist-manifest" }; mockReadFile.mockResolvedValue(JSON.stringify(distManifest)); const res = await REQUEST(buildApp(), "POST", "/api/plugins", { mode: "install", path: distPath, }); expect(res.status).toBe(201); // Should use the dist dir path since it has its own manifest expect(pluginStore.registerPlugin).toHaveBeenCalledWith( expect.objectContaining({ path: distPath }), ); }); }); // ══════════════════════════════════════════════════════════════════ describe("GET /api/plugins/dashboard-views", () => { let pluginStore: PluginStore; let pluginLoader: PluginLoader; let store: TaskStore; beforeEach(() => { vi.clearAllMocks(); pluginStore = createMockPluginStore(); pluginLoader = createMockPluginLoader(); store = createMockTaskStore({ getPluginStore: vi.fn().mockReturnValue(pluginStore), }); }); function buildApp() { const app = express(); app.use(express.json()); app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader })); return app; } it("returns 200 with empty array when no plugins have dashboard views", async () => { (pluginLoader.getPluginDashboardViews as ReturnType).mockReturnValue([]); const res = await performGet(buildApp(), "/api/plugins/dashboard-views"); expect(res.status).toBe(200); expect(res.body).toEqual([]); }); it("returns aggregated dashboard views with pluginId and view", async () => { const mockViews = [ { pluginId: "dep-graph", view: { viewId: "graph", label: "Graph", componentPath: "./views/Graph.js", icon: "Network", placement: "more", }, }, ]; (pluginLoader.getPluginDashboardViews as ReturnType).mockReturnValue(mockViews); const res = await performGet(buildApp(), "/api/plugins/dashboard-views"); expect(res.status).toBe(200); expect(res.body).toEqual(mockViews); }); }); describe("GET /api/plugins/ui-slots", () => { let pluginStore: PluginStore; let pluginLoader: PluginLoader; let store: TaskStore; beforeEach(() => { vi.clearAllMocks(); pluginStore = createMockPluginStore(); pluginLoader = createMockPluginLoader(); store = createMockTaskStore({ getPluginStore: vi.fn().mockReturnValue(pluginStore), }); }); function buildApp() { const app = express(); app.use(express.json()); app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader })); return app; } it("returns 200 with empty array when no plugins have uiSlots", async () => { (pluginLoader.getPluginUiSlots as ReturnType).mockReturnValue([]); const res = await performGet(buildApp(), "/api/plugins/ui-slots"); expect(res.status).toBe(200); expect(res.body).toEqual([]); }); it("returns 200 with aggregated slots when plugins have uiSlots", async () => { const mockSlots = [ { pluginId: "test-plugin", slot: { slotId: "task-detail-tab", label: "Task Details", componentPath: "./components/TaskDetailTab.js", order: 10, }, }, { pluginId: "test-plugin", slot: { slotId: "header-action", label: "Header Action", icon: "Plus", componentPath: "./components/HeaderAction.js", order: 1, }, }, ]; (pluginLoader.getPluginUiSlots as ReturnType).mockReturnValue(mockSlots); const res = await performGet(buildApp(), "/api/plugins/ui-slots"); expect(res.status).toBe(200); expect(res.body).toHaveLength(2); expect(res.body[0].pluginId).toBe("test-plugin"); expect(res.body[0].slot.slotId).toBe("header-action"); expect(res.body[0].slot.surface).toBe("header-action"); expect(res.body[1].slot.slotId).toBe("task-detail-tab"); expect(res.body[1].slot.surface).toBe("task-detail-tab"); expect(res.body[1].slot.order).toBe(10); }); it("response shape is Array<{ pluginId: string; slot: PluginUiSlotDefinition }>", async () => { const mockSlots = [ { pluginId: "plugin-a", slot: { slotId: "custom-slot", label: "Custom Slot", componentPath: "./components/CustomSlot.js", }, }, ]; (pluginLoader.getPluginUiSlots as ReturnType).mockReturnValue(mockSlots); const res = await performGet(buildApp(), "/api/plugins/ui-slots"); expect(res.status).toBe(200); // Verify response shape expect(Array.isArray(res.body)).toBe(true); expect(res.body[0]).toHaveProperty("pluginId"); expect(typeof res.body[0].pluginId).toBe("string"); expect(res.body[0]).toHaveProperty("slot"); expect(res.body[0].slot).toHaveProperty("slotId"); expect(res.body[0].slot).toHaveProperty("label"); expect(res.body[0].slot).toHaveProperty("componentPath"); expect(res.body[0].slot).toHaveProperty("surface"); expect(res.body[0].slot).toHaveProperty("order"); }); it("returns empty array when pluginLoader is not available", async () => { // Build app without pluginLoader const app = express(); app.use(express.json()); app.use("/api", createApiRoutes(store, { pluginStore })); const res = await performGet(app, "/api/plugins/ui-slots"); expect(res.status).toBe(200); expect(res.body).toEqual([]); }); it("does not conflict with /plugins/:id route", async () => { // Verify that /plugins/ui-slots doesn't get matched by /plugins/:id (pluginLoader.getPluginUiSlots as ReturnType).mockReturnValue([]); const res = await performGet(buildApp(), "/api/plugins/ui-slots"); // Should return 200, not 404 (which would happen if :id = "ui-slots" matched) expect(res.status).toBe(200); expect(res.body).toEqual([]); }); });