/** * F1 — the HTTP invoker forwards `{command, args}` to `POST /api/invoke`, * preserves the backend `ErrorDto` on failure, and maps empty bodies to * `undefined`. Also verifies a couple of request/response gateways emit the * exact command + argument envelope of their Tauri siblings. */ import { describe, it, expect, vi } from "vitest"; import type { FetchLike } from "./httpInvoker"; import { HttpInvoker, defaultFetch } from "./httpInvoker"; import { HttpProjectGateway, HttpGitGateway } from "./requestResponseGateways"; /** * Installs a global `fetch` that records its receiver (`this`), runs `body`, then * restores the original. A real browser's `fetch` rejects a non-global receiver * (Firefox: "'fetch' called on an object that does not implement interface * Window"); jsdom does not enforce it, so the receiver is asserted explicitly. */ async function withRecordingGlobalFetch( body: () => Promise, ): Promise { const original = globalThis.fetch; const receivers: unknown[] = []; globalThis.fetch = function (this: unknown) { receivers.push(this); return Promise.resolve({ ok: true, status: 200, json: async () => ({}), text: async () => "{}", }); } as unknown as typeof globalThis.fetch; try { await body(); } finally { globalThis.fetch = original; } return receivers; } /** Builds a fake fetch that records the request and returns `body`. */ function fakeFetch( body: unknown, opts: { ok?: boolean; status?: number } = {}, ): { fetchImpl: FetchLike; calls: { url: string; init: unknown }[] } { const calls: { url: string; init: unknown }[] = []; const fetchImpl: FetchLike = async (url, init) => { calls.push({ url, init }); return { ok: opts.ok ?? true, status: opts.status ?? 200, json: async () => body, text: async () => JSON.stringify(body), }; }; return { fetchImpl, calls }; } describe("HttpInvoker", () => { it("POSTs {command, args} to /api/invoke and returns the parsed body", async () => { const { fetchImpl, calls } = fakeFetch({ ok: true }); const http = new HttpInvoker({ baseUrl: "https://host:9000/", fetchImpl }); const result = await http.invoke<{ ok: boolean }>("health", { request: { note: "hi" }, }); expect(result).toEqual({ ok: true }); expect(calls).toHaveLength(1); expect(calls[0].url).toBe("https://host:9000/api/invoke"); const init = calls[0].init as { method: string; body: string; headers: Record }; expect(init.method).toBe("POST"); expect(init.headers["Content-Type"]).toBe("application/json"); expect(JSON.parse(init.body)).toEqual({ command: "health", args: { request: { note: "hi" } }, }); }); it("sends the pairing token as an Authorization header", async () => { const { fetchImpl, calls } = fakeFetch([]); const http = new HttpInvoker({ baseUrl: "https://host", token: "tok-123", fetchImpl }); await http.invoke("list_projects"); const init = calls[0].init as { headers: Record }; expect(init.headers.Authorization).toBe("Bearer tok-123"); // No args ⇒ empty object envelope. expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({ command: "list_projects", args: {}, }); }); it("rejects with the backend ErrorDto on a non-2xx response", async () => { const { fetchImpl } = fakeFetch( { code: "NOT_FOUND", message: "project x" }, { ok: false, status: 404 }, ); const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl }); await expect(http.invoke("open_project", { projectId: "x" })).rejects.toEqual({ code: "NOT_FOUND", message: "project x", }); }); it("fires onUnauthorized on a 401 (and still throws)", async () => { const { fetchImpl } = fakeFetch( { code: "UNAUTHENTICATED", message: "no session" }, { ok: false, status: 401 }, ); const onUnauthorized = vi.fn(); const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl, onUnauthorized }); await expect(http.invoke("list_projects")).rejects.toMatchObject({ code: "UNAUTHENTICATED", }); expect(onUnauthorized).toHaveBeenCalledOnce(); }); it("does not fire onUnauthorized on a non-401 error", async () => { const { fetchImpl } = fakeFetch({ code: "NOT_FOUND", message: "x" }, { ok: false, status: 404 }); const onUnauthorized = vi.fn(); const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl, onUnauthorized }); await expect(http.invoke("open_project", { projectId: "x" })).rejects.toBeTruthy(); expect(onUnauthorized).not.toHaveBeenCalled(); }); it("wraps a network failure in a TRANSPORT_ERROR GatewayError", async () => { const fetchImpl: FetchLike = async () => { throw new Error("boom"); }; const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl }); await expect(http.invoke("health")).rejects.toMatchObject({ code: "TRANSPORT_ERROR", }); }); // Regression: the default global `fetch` was stored unbound, so calling it as // `this.fetchImpl(…)` handed it the invoker as receiver — a TypeError in // Firefox/Chrome at pairing. It must stay bound to `globalThis`. it("calls the default global fetch with globalThis as receiver", async () => { const receivers = await withRecordingGlobalFetch(async () => { const http = new HttpInvoker({ baseUrl: "https://host" }); await http.invoke("health"); }); expect(receivers).toHaveLength(1); expect(receivers[0]).toBe(globalThis); }); it("leaves an injected fetchImpl untouched (no receiver rebinding)", async () => { const { fetchImpl, calls } = fakeFetch({ ok: true }); const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl }); await http.invoke("health"); expect(calls).toHaveLength(1); }); }); describe("defaultFetch", () => { it("returns the global fetch bound to globalThis", async () => { const receivers = await withRecordingGlobalFetch(async () => { const fn = defaultFetch(); // Called as a bare reference (worst case: no receiver at all). await fn("https://host/x"); }); expect(receivers).toEqual([globalThis]); }); it("passes through a missing global fetch instead of throwing at construction", () => { const original = globalThis.fetch; // @ts-expect-error — simulating an environment without a global fetch. delete globalThis.fetch; try { // Must not throw here; the failure surfaces at call time, as before. expect(defaultFetch()).toBeUndefined(); } finally { globalThis.fetch = original; } }); }); describe("request/response gateways preserve the Tauri command contract", () => { it("HttpProjectGateway.createProject maps to create_project with a request envelope", async () => { const http = new HttpInvoker({ baseUrl: "https://h" }); const spy = vi.spyOn(http, "invoke").mockResolvedValue({ id: "p1" } as never); const gw = new HttpProjectGateway(http); await gw.createProject("My proj", "/abs/root"); expect(spy).toHaveBeenCalledWith("create_project", { request: { name: "My proj", root: "/abs/root" }, }); }); it("HttpGitGateway.stage maps to git_stage with a request envelope", async () => { const http = new HttpInvoker({ baseUrl: "https://h" }); const spy = vi.spyOn(http, "invoke").mockResolvedValue(undefined as never); const gw = new HttpGitGateway(http); await gw.stage("p1", "src/a.ts"); expect(spy).toHaveBeenCalledWith("git_stage", { request: { projectId: "p1", path: "src/a.ts" }, }); }); it("HttpGitGateway.status maps to git_status with a flat projectId", async () => { const http = new HttpInvoker({ baseUrl: "https://h" }); const spy = vi.spyOn(http, "invoke").mockResolvedValue([] as never); const gw = new HttpGitGateway(http); await gw.status("p1"); expect(spy).toHaveBeenCalledWith("git_status", { projectId: "p1" }); }); });