Stage M2
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { HttpClient } from './http-client';
|
||||
|
||||
/**
|
||||
* Regression tests for two bugs that only manifested in a browser, and so
|
||||
* survived every server-side and curl-based check.
|
||||
*/
|
||||
describe('HttpClient', () => {
|
||||
const okResponse = () =>
|
||||
new Response(JSON.stringify({ success: true, data: { ok: true }, meta: {} }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
it('calls the default fetch with the global receiver, not the client instance', async () => {
|
||||
// Browsers require `fetch` to be invoked with Window as `this`. Storing
|
||||
// `globalThis.fetch` on the instance and calling `this.fetchImpl(...)`
|
||||
// passes the HttpClient as the receiver and throws "Illegal invocation" —
|
||||
// in the browser only. Node does not care, which is precisely why every
|
||||
// server-side check and every curl passed while the browser was broken.
|
||||
const original = globalThis.fetch;
|
||||
const receivers: unknown[] = [];
|
||||
|
||||
globalThis.fetch = function (this: unknown) {
|
||||
// Pushed rather than assigned to a local: capturing the receiver is the
|
||||
// point of the test, and a plain alias trips `no-this-alias`.
|
||||
receivers.push(this);
|
||||
return Promise.resolve(okResponse());
|
||||
} as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
// No fetchImpl — exercise the default path, which is the one that broke.
|
||||
const client = new HttpClient({ baseUrl: 'http://api.test' });
|
||||
await client.get('/thing');
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
|
||||
expect(receivers).toHaveLength(1);
|
||||
expect(receivers[0]).toBe(globalThis);
|
||||
expect(receivers[0]).not.toBeInstanceOf(HttpClient);
|
||||
});
|
||||
|
||||
it('does not retry a request that opted out, so refresh cannot recurse', async () => {
|
||||
// `onUnauthorized` refreshes by calling the refresh endpoint. If that call
|
||||
// is itself retryable, its own 401 triggers another refresh — an unbounded
|
||||
// loop that hammers the API from the browser.
|
||||
let calls = 0;
|
||||
let refreshes = 0;
|
||||
|
||||
const fetchImpl = (() => {
|
||||
calls += 1;
|
||||
return Promise.resolve(new Response('{}', { status: 401 }));
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new HttpClient({
|
||||
baseUrl: 'http://api.test',
|
||||
fetchImpl,
|
||||
onUnauthorized: () => {
|
||||
refreshes += 1;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.post('/auth/refresh', undefined, { skipAuthRetry: true }),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(calls).toBe(1);
|
||||
expect(refreshes).toBe(0);
|
||||
});
|
||||
|
||||
it('still offers one retry for ordinary requests', async () => {
|
||||
let calls = 0;
|
||||
|
||||
const fetchImpl = (() => {
|
||||
calls += 1;
|
||||
return Promise.resolve(calls === 1 ? new Response('{}', { status: 401 }) : okResponse());
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new HttpClient({
|
||||
baseUrl: 'http://api.test',
|
||||
fetchImpl,
|
||||
onUnauthorized: () => true,
|
||||
});
|
||||
|
||||
await expect(client.get('/protected')).resolves.toEqual({ ok: true });
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user