summaryrefslogtreecommitdiffhomepage
path: root/src/features/plugins/adapter/http.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-03 18:05:29 +0900
committerAdam Malczewski <[email protected]>2026-09-03 18:05:29 +0900
commit3a4922a05930b0dc22740bf5621910803e8f275e (patch)
treed5f831c3d3838cfff346e378f5370a9c58bc2ed3 /src/features/plugins/adapter/http.test.ts
parent33c7245f18346553f660434bc3ac589cff3e80c2 (diff)
downloaddispatch-web-3a4922a05930b0dc22740bf5621910803e8f275e.tar.gz
dispatch-web-3a4922a05930b0dc22740bf5621910803e8f275e.zip
feat(plugin-system): plan 07 frontend plugin manager ui
Diffstat (limited to 'src/features/plugins/adapter/http.test.ts')
-rw-r--r--src/features/plugins/adapter/http.test.ts177
1 files changed, 177 insertions, 0 deletions
diff --git a/src/features/plugins/adapter/http.test.ts b/src/features/plugins/adapter/http.test.ts
new file mode 100644
index 0000000..b1fe477
--- /dev/null
+++ b/src/features/plugins/adapter/http.test.ts
@@ -0,0 +1,177 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createPluginsHttp } from "./http";
+
+/** A stub fetch that records requests and replies from a handler. */
+function stubFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
+ const impl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ return await handler(url, init);
+ });
+ return impl as unknown as typeof fetch;
+}
+
+const okJson = (body: unknown): Response =>
+ ({ ok: true, status: 200, json: async () => body }) as unknown as Response;
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("createPluginsHttp — manager routes (plan 05)", () => {
+ it("GET /plugins returns the parsed list (null on failure)", async () => {
+ const fetch1 = stubFetch(() =>
+ okJson({ loaded: [], disabledWithReason: [], discovered: [], missing: [] }),
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ await expect(http.list()).resolves.toEqual({
+ loaded: [],
+ disabledWithReason: [],
+ discovered: [],
+ missing: [],
+ });
+
+ const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response);
+ await expect(createPluginsHttp("http://x", fetch2).list()).resolves.toBeNull();
+ });
+
+ it("POST /plugins/scan posts to the scan route", async () => {
+ let seenUrl = "";
+ let seenInit: RequestInit | undefined;
+ const fetch1 = stubFetch((url, init) => {
+ seenUrl = url;
+ seenInit = init;
+ return okJson({ root: "/plugins", newPlugins: [], invalid: [], incompatible: [] });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.scan();
+ expect(result.ok).toBe(true);
+ expect(seenUrl).toBe("http://x/plugins/scan");
+ expect(seenInit?.method).toBe("POST");
+ });
+
+ it("POST /plugins/:id/unload sends the force flag in the body", async () => {
+ let seenBody: string | undefined;
+ const fetch1 = stubFetch((_url, init) => {
+ seenBody = init?.body as string;
+ return okJson({ ok: true, action: "unload", id: "mcp" });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ await http.unload("mcp", true);
+ expect(JSON.parse(seenBody ?? "{}")).toEqual({ force: true });
+ });
+
+ it("maps a blocked mutation (409) to ok:false with reason + dependents", async () => {
+ const fetch1 = stubFetch(
+ () =>
+ ({
+ ok: false,
+ status: 409,
+ json: async () => ({
+ ok: false,
+ action: "unload",
+ id: "heartbeat",
+ reason: "cascade-needs-force",
+ message: "blocked",
+ dependents: ["transport-http"],
+ }),
+ }) as unknown as Response,
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.unload("heartbeat", false);
+ expect(result).toEqual({
+ ok: false,
+ error: "blocked",
+ reason: "cascade-needs-force",
+ dependents: ["transport-http"],
+ });
+ });
+
+ it("load and reload hit their routes with the encoded id", async () => {
+ const urls: string[] = [];
+ const fetch1 = stubFetch((url) => {
+ urls.push(url);
+ return okJson({ ok: true, action: "load", id: "a/b" });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ await http.load("a/b");
+ await http.reload("a/b");
+ expect(urls).toEqual(["http://x/plugins/a%2Fb/load", "http://x/plugins/a%2Fb/reload"]);
+ });
+});
+
+describe("createPluginsHttp — workspace routes (plan 06 §6.6)", () => {
+ it("GET /workspaces/:id/plugins returns the parsed response (null on failure)", async () => {
+ const fetch1 = stubFetch(() =>
+ okJson({ workspaceId: "ws", plugins: [], enabledProviderCount: 2 }),
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ await expect(http.workspacePlugins("ws")).resolves.toEqual({
+ workspaceId: "ws",
+ plugins: [],
+ enabledProviderCount: 2,
+ });
+
+ const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response);
+ await expect(createPluginsHttp("http://x", fetch2).workspacePlugins("ws")).resolves.toBeNull();
+ });
+
+ it("PUT /workspaces/:id/plugins/:pluginId sends the state body", async () => {
+ let seenUrl = "";
+ let seenBody: string | undefined;
+ const fetch1 = stubFetch((url, init) => {
+ seenUrl = url;
+ seenBody = init?.body as string;
+ return okJson({ workspaceId: "ws", pluginId: "mcp", state: "enabled" });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.setWorkspaceState("ws", "mcp", "enabled");
+ expect(result.ok).toBe(true);
+ expect(seenUrl).toBe("http://x/workspaces/ws/plugins/mcp");
+ expect(JSON.parse(seenBody ?? "{}")).toEqual({ state: "enabled" });
+ });
+
+ it("maps a 409 refusal (core-locked) to ok:false with the reason", async () => {
+ const fetch1 = stubFetch(
+ () =>
+ ({
+ ok: false,
+ status: 409,
+ json: async () => ({
+ workspaceId: "ws",
+ pluginId: "kernel",
+ reason: "core-locked",
+ message: "core is locked",
+ }),
+ }) as unknown as Response,
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.setWorkspaceState("ws", "kernel", "disabled");
+ expect(result).toEqual({ ok: false, error: "core is locked", reason: "core-locked" });
+ });
+
+ it("GET providers-status returns the parsed response", async () => {
+ let seenUrl = "";
+ const fetch1 = stubFetch((url) => {
+ seenUrl = url;
+ return okJson({ workspaceId: "ws", enabledProviderCount: 0 });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ await expect(http.providersStatus("ws")).resolves.toEqual({
+ workspaceId: "ws",
+ enabledProviderCount: 0,
+ });
+ expect(seenUrl).toBe("http://x/workspaces/ws/plugins/providers-status");
+ });
+
+ it("network throws degrade reads to null and mutations to ok:false", async () => {
+ const boom = vi.fn(async () => {
+ throw new Error("down");
+ }) as unknown as typeof fetch;
+ const http = createPluginsHttp("http://x", boom);
+ await expect(http.list()).resolves.toBeNull();
+ await expect(http.providersStatus("ws")).resolves.toBeNull();
+ await expect(http.setWorkspaceState("ws", "mcp", "enabled")).resolves.toMatchObject({
+ ok: false,
+ });
+ });
+});