359 lines
8.8 KiB
TypeScript
359 lines
8.8 KiB
TypeScript
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("keeps the session when refresh failed because the server was unreachable", 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),
|
|
unavailable: () => true,
|
|
});
|
|
|
|
await apiClient.GET("/api/v1/brokerage-accounts");
|
|
|
|
expect(clear).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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<void>();
|
|
|
|
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;
|
|
|
|
// Any non-transient code; status alone must prevent the retry.
|
|
return HttpResponse.json(
|
|
{ code: "INTERNAL_SERVER_ERROR", status },
|
|
{ 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);
|
|
});
|
|
});
|