141 lines
5.3 KiB
TypeScript
141 lines
5.3 KiB
TypeScript
// 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<string, string>();
|
|
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;
|
|
});
|
|
});
|