feat(frontend): client web read-only pairing + snapshot état (#13)
Lot F2 du chantier server/client mode : client web read-only complétant le premier incrément livrable — pairing, liste des projets, ouverture et snapshot de l'état, sans PTY. - frontend/src/adapters/http/webSession.ts : session web (pairing/cookie). - frontend/src/features/web : PairingScreen, WebWorkspace, WebApp, index. - Câblage main.tsx et adaptations httpInvoker.ts / index.ts (cas 401). - Tests : webSession.test.ts, WebApp.test.tsx, cas 401 dans httpInvoker.test.ts. Validé : frontend 736 tests verts, build vert, garde no-direct-invoke verte, contrat B4↔F2 aligné, desktop non régressé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
92
frontend/src/adapters/http/webSession.test.ts
Normal file
92
frontend/src/adapters/http/webSession.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* F2 — the web session: pairing handshake (success marks the flag; wrong code
|
||||
* rejects with a clear message), and the 401 → unauthorized transition that
|
||||
* clears the flag and notifies subscribers.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import type { FetchLike } from "./httpInvoker";
|
||||
import { WebSession, type FlagStore } from "./webSession";
|
||||
|
||||
function memStore(): FlagStore {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => map.get(k) ?? null,
|
||||
setItem: (k, v) => void map.set(k, v),
|
||||
removeItem: (k) => void map.delete(k),
|
||||
};
|
||||
}
|
||||
|
||||
function fetchReturning(status: number, body: unknown): {
|
||||
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: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
};
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
describe("WebSession pairing", () => {
|
||||
it("POSTs the code to /api/pair and marks the flag on success", async () => {
|
||||
const store = memStore();
|
||||
const { fetchImpl, calls } = fetchReturning(200, { ok: true });
|
||||
const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store });
|
||||
|
||||
expect(session.isPaired()).toBe(false);
|
||||
await session.pair("4821-93");
|
||||
|
||||
expect(session.isPaired()).toBe(true);
|
||||
expect(calls[0].url).toBe("https://host/api/pair");
|
||||
expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({ code: "4821-93" });
|
||||
});
|
||||
|
||||
it("rejects a wrong code with a clear message and stays unpaired", async () => {
|
||||
const store = memStore();
|
||||
const { fetchImpl } = fetchReturning(401, { code: "INVALID", message: "bad code" });
|
||||
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store });
|
||||
|
||||
await expect(session.pair("nope")).rejects.toMatchObject({ code: "INVALID_PAIRING_CODE" });
|
||||
expect(session.isPaired()).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to a default message when the server sends no body", async () => {
|
||||
const { fetchImpl } = fetchReturning(403, undefined);
|
||||
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
|
||||
|
||||
await expect(session.pair("x")).rejects.toMatchObject({
|
||||
code: "INVALID_PAIRING_CODE",
|
||||
message: "Code d'appairage invalide.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSession unauthorized handling", () => {
|
||||
it("clears the flag and notifies subscribers on notifyUnauthorized", () => {
|
||||
const store = memStore();
|
||||
const session = new WebSession({ baseUrl: "https://host", store });
|
||||
session.markPaired();
|
||||
const handler = vi.fn();
|
||||
session.onUnauthorized(handler);
|
||||
|
||||
session.notifyUnauthorized();
|
||||
|
||||
expect(session.isPaired()).toBe(false);
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("forget() clears the flag (best-effort local sign-out)", () => {
|
||||
const store = memStore();
|
||||
const session = new WebSession({ baseUrl: "https://host", store });
|
||||
session.markPaired();
|
||||
session.forget();
|
||||
expect(session.isPaired()).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user