Files
CIAgent/apps/web/tests/api-client.test.ts
T
saksham 1a4c80958f Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring,
source collection, LLM-based change analysis, enrichment, and account
security (Turnstile, escalating lockout, email verification).
2026-08-05 10:48:20 -04:00

97 lines
3.2 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
beforeEach(() => {
window.localStorage.clear();
vi.unstubAllGlobals();
vi.resetModules();
});
// Fresh module import per test so the internal `refreshPromise` module-level
// state (shared across concurrent 401s) never leaks between tests.
async function loadApiClient() {
return import("@/lib/api-client");
}
describe("api client automatic token refresh", () => {
it("transparently retries once after a 401 when the refresh succeeds", async () => {
const { api, setTokens, getAccessToken } = await loadApiClient();
setTokens("expired-access", "valid-refresh");
const fetchMock = vi
.fn()
// The actual request, with the now-expired access token -> 401.
.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ detail: "Invalid or expired token" }),
})
// POST /auth/refresh -> a fresh token pair.
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
access_token: "new-access",
refresh_token: "new-refresh",
token_type: "bearer",
expires_in_minutes: 15,
}),
})
// The retried original request, now with the new access token.
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: "1", email: "[email protected]" }),
});
vi.stubGlobal("fetch", fetchMock);
const result = await api.me();
expect(result).toEqual({ id: "1", email: "[email protected]" });
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(getAccessToken()).toBe("new-access");
const retryCall = fetchMock.mock.calls[2];
const retryHeaders = retryCall?.[1]?.headers as Headers;
expect(retryHeaders.get("Authorization")).toBe("Bearer new-access");
});
it("clears tokens and reports the original 401 when the refresh itself also fails", async () => {
const { api, setTokens, getAccessToken, getRefreshToken, ApiError } = await loadApiClient();
setTokens("expired-access", "expired-refresh");
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ detail: "Invalid or expired token" }),
})
.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ detail: "Invalid or expired refresh token" }),
});
vi.stubGlobal("fetch", fetchMock);
await expect(api.me()).rejects.toBeInstanceOf(ApiError);
expect(fetchMock).toHaveBeenCalledTimes(2); // original request + failed refresh, no retry
expect(getAccessToken()).toBeNull();
expect(getRefreshToken()).toBeNull();
});
it("does not attempt a refresh when there is no refresh token at all", async () => {
const { api, clearTokens } = await loadApiClient();
clearTokens();
const fetchMock = vi.fn().mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ detail: "Not authenticated" }),
});
vi.stubGlobal("fetch", fetchMock);
await expect(api.me()).rejects.toThrow();
expect(fetchMock).toHaveBeenCalledTimes(1); // no refresh attempt made
});
});