Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions apps/array/src/api/fetcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildApiFetcher } from "./fetcher";

describe("buildApiFetcher", () => {
const mockFetch = vi.fn();
const mockInput = {
method: "get" as const,
url: new URL("https://api.example.com/test"),
path: "/test",
};
const ok = (data = {}) => ({
ok: true,
status: 200,
json: () => Promise.resolve(data),
});
const err = (status: number) => ({
ok: false,
status,
json: () => Promise.resolve({ error: status }),
});

beforeEach(() => {
vi.resetAllMocks();
vi.stubGlobal("fetch", mockFetch);
});

it("makes request with bearer token", async () => {
mockFetch.mockResolvedValueOnce(ok());
const fetcher = buildApiFetcher({ apiToken: "my-token" });

await fetcher.fetch(mockInput);

expect(mockFetch.mock.calls[0][1].headers.get("Authorization")).toBe(
"Bearer my-token",
);
});

it("retries with new token on 401", async () => {
const onTokenRefresh = vi.fn().mockResolvedValue("new-token");
mockFetch.mockResolvedValueOnce(err(401)).mockResolvedValueOnce(ok());

const fetcher = buildApiFetcher({ apiToken: "old-token", onTokenRefresh });
const response = await fetcher.fetch(mockInput);

expect(response.ok).toBe(true);
expect(onTokenRefresh).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[1][1].headers.get("Authorization")).toBe(
"Bearer new-token",
);
});

it("uses refreshed token for subsequent requests", async () => {
const onTokenRefresh = vi.fn().mockResolvedValue("refreshed-token");
mockFetch
.mockResolvedValueOnce(err(401))
.mockResolvedValueOnce(ok())
.mockResolvedValueOnce(ok());

const fetcher = buildApiFetcher({
apiToken: "initial-token",
onTokenRefresh,
});
await fetcher.fetch(mockInput);
await fetcher.fetch(mockInput);

expect(mockFetch.mock.calls[2][1].headers.get("Authorization")).toBe(
"Bearer refreshed-token",
);
});

it("does not refresh on non-401 errors", async () => {
const onTokenRefresh = vi.fn();
mockFetch.mockResolvedValueOnce(err(403));

const fetcher = buildApiFetcher({ apiToken: "token", onTokenRefresh });

await expect(fetcher.fetch(mockInput)).rejects.toThrow("[403]");
expect(onTokenRefresh).not.toHaveBeenCalled();
});

it("throws on 401 without refresh callback", async () => {
mockFetch.mockResolvedValueOnce(err(401));
const fetcher = buildApiFetcher({ apiToken: "token" });

await expect(fetcher.fetch(mockInput)).rejects.toThrow("[401]");
});

it("throws when refresh fails", async () => {
const onTokenRefresh = vi.fn().mockRejectedValue(new Error("failed"));
mockFetch.mockResolvedValueOnce(err(401));

const fetcher = buildApiFetcher({ apiToken: "token", onTokenRefresh });

await expect(fetcher.fetch(mockInput)).rejects.toThrow("[401]");
});

it("handles network errors", async () => {
mockFetch.mockRejectedValueOnce(new Error("Network failure"));
const fetcher = buildApiFetcher({ apiToken: "token" });

await expect(fetcher.fetch(mockInput)).rejects.toThrow(
"Network request failed",
);
});
});
7 changes: 5 additions & 2 deletions apps/array/src/api/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export const buildApiFetcher: (config: {
apiToken: string;
onTokenRefresh?: () => Promise<string>;
}) => Parameters<typeof createApiClient>[0] = (config) => {
let currentToken = config.apiToken;

const makeRequest = async (
input: Parameters<Parameters<typeof createApiClient>[0]["fetch"]>[0],
token: string,
Expand Down Expand Up @@ -51,13 +53,14 @@ export const buildApiFetcher: (config: {

return {
fetch: async (input) => {
let response = await makeRequest(input, config.apiToken);
let response = await makeRequest(input, currentToken);

// Handle 401 with automatic token refresh
if (!response.ok && response.status === 401 && config.onTokenRefresh) {
try {
const newToken = await config.onTokenRefresh();
response = await makeRequest(input, newToken);
currentToken = newToken;
response = await makeRequest(input, currentToken);
} catch {
// Token refresh failed - throw the original 401 error
const errorResponse = await response.json();
Expand Down
Loading