Files
fusion/packages/dashboard/src/__tests__/plugin-routes.routes.test.ts
gsxdsm bce7dbd96f chore: consolidate test files into __tests__/ dirs and clean stray engine artifacts
- Move all co-located *.test.* files into sibling __tests__/ directories so the
  layout is consistent across packages (159 renames + content-rewrite moves).
  Updates relative imports, vi.mock specifiers, and __dirname/import.meta.url
  path resolutions where tests read fixtures from disk.
- Drop tracked tsc-emit alongside engine .ts sources (auth-storage/logger/
  skill-resolver/context-limit-detector/pi.{js,d.ts,*.map}). These were
  accidentally committed in a merge and the stale pi.js was masking a real
  test-mock vs source mismatch (tests imported "../pi.js" and vite preferred
  the stale build over pi.ts).
- Add packages/engine/.gitignore to block future src/*.{js,d.ts,map}.
- Refactor plugin pi-module seams (openclaw/paperclip/hermes) to ESM-import
  createFnAgent / promptWithFallback / describeModel from @fusion/engine
  instead of require()-ing packages/engine/src/pi.js. Adds @fusion/engine to
  the two plugin package.jsons that were missing it; exports describeModel
  from the engine public API.
- Fix engine test mocks now that they run against current pi.ts: add
  ModelRegistry.create static to mocks in pi.test.ts and pi-create-fn-agent
  .test.ts; switch three boundary-result toEqual assertions to toMatchObject
  so the new content/isError fields don't trip exact-match comparison.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:39:20 -07:00

932 lines
29 KiB
TypeScript

// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import type { TaskStore } from "@fusion/core";
import type { PluginInstallation } from "@fusion/core";
import type { PluginStore } from "@fusion/core";
import type { PluginLoader } 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 project store resolver
const mockGetOrCreateProjectStore = vi.fn();
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockImplementation(mockGetOrCreateProjectStore);
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([]),
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 FAKE_PLUGIN: PluginInstallation = {
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
description: "A test plugin",
path: "/path/to/plugin",
enabled: true,
state: "installed",
settings: {},
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
async function GET(app: express.Express, path: string): Promise<{ status: number; body: any }> {
const res = await performGet(app, path);
return { status: res.status, body: res.body };
}
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("GET /plugins", () => {
let store: TaskStore;
let pluginStore: PluginStore;
beforeEach(() => {
pluginStore = createMockPluginStore();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
}));
return app;
}
it("returns empty array when no plugins", async () => {
const res = await GET(buildApp(), "/api/plugins");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(pluginStore.listPlugins).toHaveBeenCalledWith({});
});
it("returns list of plugins", async () => {
(pluginStore.listPlugins as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_PLUGIN]);
const res = await GET(buildApp(), "/api/plugins");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0]).toMatchObject({
id: "test-plugin",
name: "Test Plugin",
});
});
it("filters plugins by enabled status", async () => {
const res = await GET(buildApp(), "/api/plugins?enabled=true");
expect(res.status).toBe(200);
expect(pluginStore.listPlugins).toHaveBeenCalledWith({ enabled: true });
});
it("filters plugins by disabled status", async () => {
const res = await GET(buildApp(), "/api/plugins?enabled=false");
expect(res.status).toBe(200);
expect(pluginStore.listPlugins).toHaveBeenCalledWith({ enabled: false });
});
});
describe("GET /plugins/:id", () => {
let store: TaskStore;
let pluginStore: PluginStore;
beforeEach(() => {
pluginStore = createMockPluginStore();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
}));
return app;
}
it("returns plugin by id", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const res = await GET(buildApp(), "/api/plugins/test-plugin");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
id: "test-plugin",
name: "Test Plugin",
});
});
it("returns 404 for non-existent plugin", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "nonexistent" not found'), { code: "ENOENT" }),
);
const res = await GET(buildApp(), "/api/plugins/nonexistent");
expect(res.status).toBe(404);
expect(res.body).toHaveProperty("error");
});
it("supports projectId query param scoping", async () => {
// Set up mock for scoped store with projectId
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await GET(buildApp(), "/api/plugins/test-plugin?projectId=proj_123");
expect(res.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});
describe("GET /plugins/:id/settings", () => {
let store: TaskStore;
let pluginStore: PluginStore;
beforeEach(() => {
pluginStore = createMockPluginStore();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
}));
return app;
}
it("returns plugin settings by id", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
settings: { apiKey: "secret", enabled: true },
});
const res = await GET(buildApp(), "/api/plugins/test-plugin/settings");
expect(res.status).toBe(200);
expect(res.body).toEqual({ apiKey: "secret", enabled: true });
});
it("returns 404 for non-existent plugin", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "nonexistent" not found'), { code: "ENOENT" }),
);
const res = await GET(buildApp(), "/api/plugins/nonexistent/settings");
expect(res.status).toBe(404);
expect(res.body).toHaveProperty("error");
});
it("supports projectId query param scoping", async () => {
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
settings: { scoped: true },
});
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await GET(buildApp(), "/api/plugins/test-plugin/settings?projectId=proj_123");
expect(res.status).toBe(200);
expect(res.body).toEqual({ scoped: true });
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});
describe("POST /plugins", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
beforeEach(() => {
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;
}
describe("mode: register", () => {
it("registers a plugin with required fields", async () => {
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
});
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
id: "test-plugin",
name: "Test Plugin",
});
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
}),
path: "/path/to/plugin",
}),
);
});
it("registers a plugin with optional fields", async () => {
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
description: "A test plugin",
author: "Test Author",
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
description: "A test plugin",
author: "Test Author",
settings: { apiKey: "secret" },
});
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({
description: "A test plugin",
author: "Test Author",
}),
settings: { apiKey: "secret" },
}),
);
});
it("loads plugin after registration when enabled", async () => {
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
enabled: true,
});
// Plugin should be loaded after registration
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("test-plugin");
});
it("returns 400 when mode is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("mode");
});
it("returns 400 when id is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'id' is required");
});
it("returns 400 when name is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
version: "1.0.0",
path: "/path/to/plugin",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'name' is required");
});
it("returns 400 when version is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
path: "/path/to/plugin",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'version' is required");
});
it("returns 400 when path is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'path' is required");
});
it("returns 409 when plugin is already registered", async () => {
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "test-plugin" is already registered'), { code: "EEXISTS" }),
);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
});
expect(res.status).toBe(409);
});
});
describe("mode: install", () => {
it("returns 400 when plugin loader is not available", 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: "/path/to/plugin",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("not supported");
});
});
describe("invalid mode", () => {
it("returns 400 for unknown mode", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "unknown",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid mode");
});
});
});
describe("POST /plugins/:id/enable", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
beforeEach(() => {
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("enables a plugin and loads it", async () => {
(pluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/enable", {});
expect(res.status).toBe(200);
expect(pluginStore.enablePlugin).toHaveBeenCalledWith("test-plugin");
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("test-plugin");
});
it("supports body-based projectId scoping", async () => {
// Set up mock for scoped store with projectId
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/enable", {
projectId: "proj_123",
});
expect(res.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});
describe("POST /plugins/:id/disable", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
beforeEach(() => {
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("disables a plugin and stops it", async () => {
(pluginStore.disablePlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
enabled: false,
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/disable", {});
expect(res.status).toBe(200);
expect(pluginStore.disablePlugin).toHaveBeenCalledWith("test-plugin");
expect(pluginLoader.stopPlugin).toHaveBeenCalledWith("test-plugin");
});
it("supports body-based projectId scoping", async () => {
// Set up mock for scoped store with projectId
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.disablePlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
enabled: false,
});
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/disable", {
projectId: "proj_123",
});
expect(res.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});
describe("POST /plugins/:id/reload", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginRunner: { getPluginRoutes: ReturnType<typeof vi.fn>; reloadPlugin: ReturnType<typeof vi.fn> };
beforeEach(() => {
pluginStore = createMockPluginStore();
pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([]),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
};
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp(includeRunner = true) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
pluginRunner: includeRunner ? pluginRunner : undefined,
}));
return app;
}
it("reloads a started plugin", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_PLUGIN, state: "started" })
.mockResolvedValueOnce({ ...FAKE_PLUGIN, state: "started", updatedAt: "2026-02-01T00:00:00.000Z" });
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/reload", {});
expect(res.status).toBe(200);
expect(pluginRunner.reloadPlugin).toHaveBeenCalledWith("test-plugin");
expect(res.body.id).toBe("test-plugin");
});
it("returns 404 when plugin is not found", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "nonexistent" not found'), { code: "ENOENT" }),
);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/nonexistent/reload", {});
expect(res.status).toBe(404);
});
it("returns 400 when plugin is not started", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
state: "installed",
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/reload", {});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Use enable instead");
});
it("returns 500 when plugin runner is unavailable", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
state: "started",
});
const res = await REQUEST(buildApp(false), "POST", "/api/plugins/test-plugin/reload", {});
expect(res.status).toBe(500);
expect(res.body.error).toContain("Plugin runner not available");
});
it("returns 500 when reload operation fails", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
state: "started",
});
pluginRunner.reloadPlugin.mockRejectedValueOnce(new Error("boom"));
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/reload", {});
expect(res.status).toBe(500);
expect(res.body.error).toContain("Reload failed: boom");
});
});
describe("PUT /plugins/:id/settings", () => {
let store: TaskStore;
let pluginStore: PluginStore;
beforeEach(() => {
pluginStore = createMockPluginStore();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
}));
return app;
}
it("updates plugin settings", async () => {
(pluginStore.updatePluginSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
settings: { apiKey: "new-secret" },
});
const res = await REQUEST(buildApp(), "PUT", "/api/plugins/test-plugin/settings", {
settings: { apiKey: "new-secret" },
});
expect(res.status).toBe(200);
expect(pluginStore.updatePluginSettings).toHaveBeenCalledWith("test-plugin", { apiKey: "new-secret" });
});
it("returns 400 when settings is missing", async () => {
const res = await REQUEST(buildApp(), "PUT", "/api/plugins/test-plugin/settings", {});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'settings'");
});
it("returns 404 when plugin not found", async () => {
(pluginStore.updatePluginSettings as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "nonexistent" not found'), { code: "ENOENT" }),
);
const res = await REQUEST(buildApp(), "PUT", "/api/plugins/nonexistent/settings", {
settings: { key: "value" },
});
expect(res.status).toBe(404);
});
it("returns 400 when settings validation fails", async () => {
(pluginStore.updatePluginSettings as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Settings validation failed: setting 'apiKey' is required"),
);
const res = await REQUEST(buildApp(), "PUT", "/api/plugins/test-plugin/settings", {
settings: {},
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("validation failed");
});
});
describe("DELETE /plugins/:id", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
beforeEach(() => {
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("unregisters a plugin", async () => {
(pluginStore.unregisterPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const res = await REQUEST(buildApp(), "DELETE", "/api/plugins/test-plugin");
expect(res.status).toBe(204);
expect(pluginStore.unregisterPlugin).toHaveBeenCalledWith("test-plugin");
});
it("stops plugin before unregistering", async () => {
(pluginStore.unregisterPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
await REQUEST(buildApp(), "DELETE", "/api/plugins/test-plugin");
// Should stop first, then unregister
expect(pluginLoader.stopPlugin).toHaveBeenCalledWith("test-plugin");
expect(pluginStore.unregisterPlugin).toHaveBeenCalledWith("test-plugin");
});
it("supports query-based projectId scoping", async () => {
// Set up mock for scoped store with projectId
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.unregisterPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await REQUEST(buildApp(), "DELETE", "/api/plugins/test-plugin?projectId=proj_123");
expect(res.status).toBe(204);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});
describe("Project scoping", () => {
let defaultPluginStore: PluginStore;
let scopedPluginStore: PluginStore;
let store: TaskStore;
let scopedStore: TaskStore;
beforeEach(() => {
defaultPluginStore = createMockPluginStore({
listPlugins: vi.fn().mockResolvedValue([{ ...FAKE_PLUGIN, id: "default-plugin" }]),
});
scopedPluginStore = createMockPluginStore({
listPlugins: vi.fn().mockResolvedValue([{ ...FAKE_PLUGIN, id: "scoped-plugin" }]),
});
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(defaultPluginStore),
});
scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
// Reset the mock
mockGetOrCreateProjectStore.mockReset();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore: defaultPluginStore,
pluginLoader: createMockPluginLoader(),
}));
return app;
}
it("uses default store without projectId", async () => {
mockGetOrCreateProjectStore.mockResolvedValue(store);
const res = await GET(buildApp(), "/api/plugins");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].id).toBe("default-plugin");
});
it("uses scoped store with projectId query param", async () => {
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await GET(buildApp(), "/api/plugins?projectId=proj_123");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].id).toBe("scoped-plugin");
});
it("uses scoped store with projectId in request body", async () => {
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/disable", {
projectId: "proj_123",
});
expect(res.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});