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).
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user