import { http, HttpResponse } from "msw"; import { afterEach, describe, expect, it, vi } from "vitest"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { apiClient, configureAuthClient } from "./client"; async function advanceReadDeadlineRecovery() { await vi.advanceTimersByTimeAsync(15_000); await vi.advanceTimersByTimeAsync(1000); } async function advanceTransportRecovery() { await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(2000); } describe("apiClient authentication", () => { afterEach(() => { vi.useRealTimers(); }); it("refreshes once and retries an unauthorized protected request", async () => { let token = "expired"; const refresh = vi.fn(() => { token = "fresh"; return Promise.resolve(true); }); const authorization: string[] = []; server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { authorization.push(request.headers.get("Authorization") ?? ""); return authorization.length === 1 ? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }) : HttpResponse.json(resource([])); }), ); configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh }); const { data } = await apiClient.GET("/api/v1/brokerage-accounts"); expect(data?.data).toEqual([]); expect(refresh).toHaveBeenCalledOnce(); expect(authorization).toEqual(["Bearer expired", "Bearer fresh"]); }); it("clears the session when refresh cannot restore it", async () => { const clear = vi.fn(); server.use( http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }), ), ); configureAuthClient({ accessToken: () => "expired", clear, refresh: () => Promise.resolve(false), }); await apiClient.GET("/api/v1/brokerage-accounts"); expect(clear).toHaveBeenCalledOnce(); }); it("does not attach Bearer auth to authentication endpoints", async () => { let authorization: null | string = null; server.use( http.post("*/api/v1/auth/refresh", ({ request }) => { authorization = request.headers.get("Authorization"); return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }); }), ); configureAuthClient({ accessToken: () => "token", clear: vi.fn(), refresh: vi.fn(), }); await apiClient.POST("/api/v1/auth/refresh"); expect(authorization).toBeNull(); }); it("discards a retried request response after another session takes over", async () => { let generation = 0; let calls = 0; const { promise: pending, resolve: release } = Promise.withResolvers(); server.use( http.get("*/api/v1/brokerage-accounts", async () => { if (++calls === 1) return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }); await pending; return HttpResponse.json(resource([{ id: "previous-user-account" }])); }), ); configureAuthClient({ accessToken: () => "synthetic", clear: vi.fn(), generation: () => generation, refresh: () => Promise.resolve(true), }); // eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below. const response = expect( apiClient.GET("/api/v1/brokerage-accounts"), ).rejects.toMatchObject({ name: "AbortError" }); await vi.waitFor(() => expect(calls).toBe(2)); generation += 1; release(); await response; }); }); describe("apiClient transient read recovery", () => { afterEach(() => { vi.useRealTimers(); }); it("recovers a protected GET after transient server failures", async () => { vi.useFakeTimers(); let requests = 0; server.use( http.get("*/api/v1/brokerage-accounts", () => { requests += 1; return requests < 3 ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : HttpResponse.json(resource([{ id: "account-1" }])); }), ); configureAuthClient({ accessToken: () => "token", clear: vi.fn(), refresh: () => Promise.resolve(true), }); const response = apiClient.GET("/api/v1/brokerage-accounts"); await advanceTransportRecovery(); await expect(response).resolves.toMatchObject({ data: { data: [{ id: "account-1" }] }, }); expect(requests).toBe(3); }); it("recovers a protected GET after network failure", async () => { vi.useFakeTimers(); let requests = 0; server.use( http.get("*/api/v1/brokerage-accounts", () => { requests += 1; return requests === 1 ? HttpResponse.error() : HttpResponse.json(resource([{ id: "account-1" }])); }), ); configureAuthClient({ accessToken: () => "token", clear: vi.fn(), refresh: () => Promise.resolve(true), }); const response = apiClient.GET("/api/v1/brokerage-accounts"); await advanceTransportRecovery(); await expect(response).resolves.toMatchObject({ data: { data: [{ id: "account-1" }] }, }); expect(requests).toBe(2); }); it("times out a stalled response body before retrying", async () => { vi.useFakeTimers(); let requests = 0; server.use( http.get("*/api/v1/brokerage-accounts", () => { requests += 1; return requests === 1 ? new Response(new ReadableStream()) : HttpResponse.json(resource([{ id: "account-1" }])); }), ); configureAuthClient({ accessToken: () => "token", clear: vi.fn(), refresh: () => Promise.resolve(true), }); const response = apiClient.GET("/api/v1/brokerage-accounts"); await advanceReadDeadlineRecovery(); await expect(response).resolves.toMatchObject({ data: { data: [{ id: "account-1" }] }, }); expect(requests).toBe(2); }); it("does not retry rate limited reads before the server delay", async () => { let requests = 0; server.use( http.get("*/api/v1/brokerage-accounts", () => { requests += 1; return HttpResponse.json( { code: "RATE_LIMITED" }, { headers: { "Retry-After": "60" }, status: 429 }, ); }), ); configureAuthClient({ accessToken: () => "token", clear: vi.fn(), refresh: () => Promise.resolve(true), }); await apiClient.GET("/api/v1/brokerage-accounts"); expect(requests).toBe(1); }); it("does not retry malformed successful responses as transport failures", async () => { let requests = 0; server.use( http.get("*/api/v1/brokerage-accounts", () => { requests += 1; return new Response("not json", { headers: { "Content-Type": "application/json" }, status: 200, }); }), ); configureAuthClient({ accessToken: () => "token", clear: vi.fn(), refresh: () => Promise.resolve(true), }); await expect( apiClient.GET("/api/v1/brokerage-accounts"), ).rejects.toThrow(SyntaxError); expect(requests).toBe(1); }); it.each([401, 403, 404, 409])( "does not retry permanent HTTP %s responses", async (status) => { let requests = 0; server.use( http.get("*/api/v1/brokerage-accounts", () => { requests += 1; return HttpResponse.json({ code: "FORBIDDEN" }, { status }); }), ); configureAuthClient({ accessToken: () => "token", clear: vi.fn(), refresh: () => Promise.resolve(false), }); await apiClient.GET("/api/v1/brokerage-accounts"); expect(requests).toBe(1); }, ); it("does not retry a read refused with INVALID_TBANK_TOKEN", async () => { let requests = 0; server.use( http.get("*/api/v1/brokerage-accounts", () => { requests += 1; return HttpResponse.json( { code: "INVALID_TBANK_TOKEN", status: 400 }, { status: 400 }, ); }), ); configureAuthClient({ accessToken: () => "token", clear: vi.fn(), refresh: () => Promise.resolve(false), }); const { error, response } = await apiClient.GET( "/api/v1/brokerage-accounts", ); expect(response.status).toBe(400); expect(error).toMatchObject({ code: "INVALID_TBANK_TOKEN" }); expect(requests).toBe(1); }); it("does not retry mutations", async () => { let requests = 0; server.use( http.put("*/api/v1/tbank-connection", () => { requests += 1; return HttpResponse.json( { code: "TBANK_UNAVAILABLE" }, { status: 503 }, ); }), ); configureAuthClient({ accessToken: () => "token", clear: vi.fn(), refresh: () => Promise.resolve(true), }); await apiClient.PUT("/api/v1/tbank-connection", { body: { token: "token" }, }); expect(requests).toBe(1); }); });