// Tests the in-browser API client against a stubbed fetch. // We don't hit the real backend here — that's covered by the smoke test // (`pnpm qa:smoke` toggles live mode and asserts the network call resolves). import { describe, it, expect, vi, beforeEach } from "vitest"; import { api } from "./api"; function mockFetch(handlers: Array<(url: string, init: RequestInit) => Response | undefined>) { const calls: Array<{ url: string; init: RequestInit }> = []; globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => { const url = typeof input === "string" ? input : input.toString(); calls.push({ url, init }); for (const h of handlers) { const r = h(url, init); if (r) return r; } return new Response("not stubbed", { status: 500 }); }) as unknown as typeof fetch; // sessionStorage stub const store = new Map(); globalThis.sessionStorage = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => { store.set(k, String(v)); }, removeItem: (k) => { store.delete(k); }, clear: () => store.clear(), key: () => null, length: 0, } as Storage; return calls; } beforeEach(() => { // reset between tests api.clearToken(); }); describe("api client", () => { it("ping() returns ok with email on success", async () => { mockFetch([ (url) => url.endsWith("/api/v1/auth/dev-login") ? new Response(JSON.stringify({ access_token: "T1" }), { status: 200, headers: { "Content-Type": "application/json" } }) : undefined, (url) => url.endsWith("/api/v1/auth/me") ? new Response(JSON.stringify({ user_id: "u1", tenant_id: "t1", email: "dev@flow-master.ai" }), { status: 200, headers: { "Content-Type": "application/json" } }) : undefined, ]); const r = await api.ping(); expect(r.ok).toBe(true); expect(r.user).toBe("dev@flow-master.ai"); }); it("ping() returns ok=false with reason when login fails", async () => { mockFetch([ (url) => url.endsWith("/api/v1/auth/dev-login") ? new Response("nope", { status: 401 }) : undefined, ]); const r = await api.ping(); expect(r.ok).toBe(false); expect(r.reason).toMatch(/rejected|401|sign-in/i); }); it("workItems() returns the items array", async () => { mockFetch([ (url) => url.endsWith("/api/v1/auth/dev-login") ? new Response(JSON.stringify({ access_token: "T2" }), { status: 200 }) : undefined, (url) => url.includes("/api/ea2/work-items") ? new Response(JSON.stringify({ items: [{ transaction_id: "tx1", status: "running", definition_key: "k1" }] }), { status: 200 }) : undefined, ]); const items = await api.workItems(); expect(items.length).toBe(1); expect(items[0].transaction_id).toBe("tx1"); }); it("graph() returns null on 404 (typed-safe failure mode)", async () => { mockFetch([ (url) => url.endsWith("/api/v1/auth/dev-login") ? new Response(JSON.stringify({ access_token: "T3" }), { status: 200 }) : undefined, (url) => url.includes("/api/ea2/process-definitions/") ? new Response("not found", { status: 404 }) : undefined, ]); const g = await api.graph("does-not-exist"); expect(g).toBeNull(); }); it("re-logs in after 401 then succeeds", async () => { let metHits = 0; const calls = mockFetch([ (url) => url.endsWith("/api/v1/auth/dev-login") ? new Response(JSON.stringify({ access_token: `T${metHits + 1}` }), { status: 200 }) : undefined, (url) => { if (url.endsWith("/api/v1/auth/me")) { metHits += 1; if (metHits === 1) return new Response("expired", { status: 401 }); return new Response(JSON.stringify({ user_id: "u", tenant_id: "t", email: "dev@flow-master.ai" }), { status: 200 }); } return undefined; }, ]); const r = await api.ping(); expect(r.ok).toBe(true); expect(calls.filter((c) => c.url.endsWith("/dev-login")).length).toBe(2); // initial + retry login }); }); describe("authedRequest fail-closed when dev-login disabled", () => { beforeEach(() => { import.meta.env.VITE_ENABLE_DEV_LOGIN = "false"; }); it("throws AuthRequiredError instead of silently calling /dev-login when no token", async () => { const calls = mockFetch([ (url) => url.endsWith("/api/v1/auth/me") ? new Response(JSON.stringify({ user_id: "u", tenant_id: "t", email: "dev@flow-master.ai" }), { status: 200 }) : undefined, ]); await expect(api.me()).rejects.toThrow(/Sign in required/); expect(calls.filter((c) => c.url.endsWith("/dev-login")).length).toBe(0); delete (import.meta.env as any).VITE_ENABLE_DEV_LOGIN; }); }); describe("authedRequest 401 retry fail-closed when dev-login disabled", () => { beforeEach(() => { import.meta.env.VITE_ENABLE_DEV_LOGIN = "false"; }); it("throws AuthRequiredError on 401 instead of silently re-logging in", async () => { const calls = mockFetch([ (url) => url.endsWith("/api/v1/auth/me") ? new Response("expired", { status: 401 }) : undefined, ]); sessionStorage.setItem("fm.mc.token.v1", "stale-token"); await expect(api.me()).rejects.toThrow(/Sign in required/); expect(calls.filter((c) => c.url.endsWith("/dev-login")).length).toBe(0); delete (import.meta.env as any).VITE_ENABLE_DEV_LOGIN; }); });