feat(frontend): adapter web HTTP+WebSocket derrière les ports (#13)
Lot F1 du chantier server/client mode : nouvel adaptateur web branché derrière les ports d'invocation et de flux live, permettant au frontend de dialoguer avec le backend via HTTP + WebSocket en mode client/serveur. Le mode desktop (Tauri IPC) reste inchangé. - frontend/src/adapters/http : invoker HTTP, client live WebSocket, gateways request/response et stream, frames, garde unsupported (7 fichiers + 2 tests). - frontend/src/app : câblage DI (di.tsx) et son test, typage vite-env.d.ts. Validé : build vert, garde no-direct-invoke verte, 724 tests verts, desktop inchangé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
126
frontend/src/adapters/http/httpInvoker.test.ts
Normal file
126
frontend/src/adapters/http/httpInvoker.test.ts
Normal file
@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 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 } from "./httpInvoker";
|
||||
import { HttpProjectGateway, HttpGitGateway } from "./requestResponseGateways";
|
||||
|
||||
/** 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<string, string> };
|
||||
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<string, string> };
|
||||
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("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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user