Files
fusion/packages/dashboard/src/plugin-routes.test.ts
2026-04-16 13:05:12 -07:00

791 lines
27 KiB
TypeScript

/**
* 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<typeof import("@fusion/core")>("@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<void>>().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<string>>().mockRejectedValue(new Error("not found"));
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
existsSync: (...args: Parameters<typeof actual.existsSync>) => mockExistsSync(args[0] as string),
statSync: (...args: Parameters<typeof actual.statSync>) => mockStatSync(args[0] as string),
};
});
vi.mock("node:fs/promises", async () => {
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
return {
...actual,
access: (...args: Parameters<typeof actual.access>) => mockAccess(args[0] as string),
stat: (...args: Parameters<typeof actual.stat>) => mockStat(args[0] as string),
readFile: (...args: Parameters<typeof actual.readFile>) =>
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> = {}): 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> = {}): 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([]),
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> = {}): 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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/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<typeof vi.fn>).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",
},
},
{
pluginId: "test-plugin",
slot: {
slotId: "header-action",
label: "Header Action",
icon: "Plus",
componentPath: "./components/HeaderAction.js",
},
},
];
(pluginLoader.getPluginUiSlots as ReturnType<typeof vi.fn>).mockReturnValue(mockSlots);
const res = await performGet(buildApp(), "/api/plugins/ui-slots");
expect(res.status).toBe(200);
expect(res.body).toEqual(mockSlots);
expect(res.body).toHaveLength(2);
expect(res.body[0].pluginId).toBe("test-plugin");
expect(res.body[0].slot.slotId).toBe("task-detail-tab");
expect(res.body[1].slot.icon).toBe("Plus");
});
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<typeof vi.fn>).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");
});
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<typeof vi.fn>).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([]);
});
});