Files
canvas-frontend/src/lib/api.test.ts
T
shad 300024964f
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled
fix(login): persona click signs in directly + friendly error copy (#3)
2026-06-14 21:26:01 +00:00

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;
});
});