feat: add quiet reading with rare confirmations
Part of the quiet-user-feedback change; docs and specs land in their own repositories.
This commit is contained in:
@@ -1,12 +1,26 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
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";
|
||||
|
||||
@@ -109,3 +123,186 @@ describe("apiClient authentication", () => {
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user