Skip to content

Commit

Permalink
Update api, add some better deduplication handling
Browse files Browse the repository at this point in the history
  • Loading branch information
thostetler committed Dec 20, 2024
1 parent 9e21e02 commit 4f2145e
Show file tree
Hide file tree
Showing 2 changed files with 124 additions and 110 deletions.
103 changes: 53 additions & 50 deletions src/api/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,27 @@ global.alert = vi.fn();

const API_USER = '/api/user';

const mockUserData: Pick<IBootstrapPayload, 'username' | 'access_token' | 'anonymous' | 'expire_in'> = {
const mockUserData: Pick<IBootstrapPayload, 'username' | 'access_token' | 'anonymous' | 'expires_at'> = {
username: 'anonymous@ads',
access_token: 'foo_access_token',
anonymous: true,
expire_in: '2099-03-22T14:50:07.712037',
expires_at: '99999999999999999',
};
const invalidMockUserData: Pick<IBootstrapPayload, 'username' | 'access_token' | 'anonymous' | 'expire_in'> = {
const invalidMockUserData: Pick<IBootstrapPayload, 'username' | 'access_token' | 'anonymous' | 'expires_at'> = {
username: 'anonymous@ads',
access_token: '',
anonymous: true,
expire_in: '',
expires_at: '',
};

const testHandler = rest.get('*test', (_, res, ctx) => {
const testHandlerWith200 = rest.get('*test', (_, res, ctx) => {
return res(ctx.status(200), ctx.json({ ok: true }));
});

const unAuthorizedHandler = rest.get('*test', (_, res, ctx) =>
const testHandlerWith401 = rest.get('*test', (_, res, ctx) =>
res(ctx.status(401), ctx.json({ message: 'User unauthorized' })),
);

const unAuthorizedRequest = () => api.request({ method: 'GET', url: '/test' });

const testRequest = (params?: Record<string, string>, config: Partial<ApiRequestConfig> = {}) =>
api.request({
method: 'GET',
Expand All @@ -57,7 +55,7 @@ beforeEach(() => {

test('User data is found and used if set directly on api instance', async ({ server }: TestContext) => {
const { onRequest: onReq } = createServerListenerMocks(server);
server.use(testHandler);
server.use(testHandlerWith200);

// user data can be found if
api.setUserData({ ...mockUserData, access_token: 'from-memory' });
Expand All @@ -68,7 +66,7 @@ test('User data is found and used if set directly on api instance', async ({ ser

test('User data is found and used if set in local storage', async ({ server }: TestContext) => {
const { onRequest: onReq } = createServerListenerMocks(server);
server.use(testHandler);
server.use(testHandlerWith200);

// user data located in local storage
localStorage.setItem(
Expand All @@ -82,7 +80,7 @@ test('User data is found and used if set in local storage', async ({ server }: T

test('Attempts to get user data from server without refresh', async ({ server }: TestContext) => {
const { onRequest: onReq } = createServerListenerMocks(server);
server.use(testHandler);
server.use(testHandlerWith200);
server.use(
rest.get(`*${API_USER}`, (_, res, ctx) => {
return res(ctx.status(200), ctx.json({ user: { ...mockUserData, access_token: 'from-session' } }));
Expand All @@ -105,7 +103,7 @@ test('Unauthenticated request with no previous session, will force a token refre
server,
}: TestContext) => {
const { onRequest: onReq } = createServerListenerMocks(server);
server.use(testHandler);
server.use(testHandlerWith200);
server.use(
rest.get(`*${API_USER}`, (_, res, ctx) => {
return res.once(ctx.status(500), ctx.json({ error: 'Server Error' }));
Expand Down Expand Up @@ -139,11 +137,9 @@ test('Fallback to bootstrapping directly if the /api/user endpoint continuously
server,
}: TestContext) => {
const { onRequest: onReq } = createServerListenerMocks(server);
server.use(testHandler);
server.use(
rest.get(`*${API_USER}`, (_, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Server Error' }));
}),
testHandlerWith200,
rest.get(`*${API_USER}`, (_, res, ctx) => res(ctx.status(500), ctx.json({ error: 'Server Error' }))),
);

await testRequest();
Expand All @@ -165,14 +161,15 @@ test('Fallback to bootstrapping directly if the /api/user endpoint continuously
]);

// the refresh header was added to force a new session
expect(onReq.mock.calls[1][0].headers.get('x-refresh-token')).toMatchInlineSnapshot('"1"');
expect(onReq.mock.calls[3][0].headers.get('authorization')).toMatchInlineSnapshot(
'"Bearer ------ mocked token ---------"',
);
});

test('passing token initially skips bootstrap', async ({ server }: TestContext) => {
const { onRequest: onReq } = createServerListenerMocks(server);
server.use(testHandler);
server.use(testHandlerWith200);
api.setUserData(mockUserData);
await testRequest();

Expand All @@ -184,44 +181,38 @@ test('passing token initially skips bootstrap', async ({ server }: TestContext)

test('expired userdata causes bootstrap', async ({ server }: TestContext) => {
const { onRequest: onReq } = createServerListenerMocks(server);
server.use(testHandler);
api.setUserData({ ...mockUserData, expire_in: '1977-03-22T14:50:07.712037' });
server.use(testHandlerWith200);
api.setUserData({ ...mockUserData, expires_at: '999' });
await testRequest();

expect(onReq).toBeCalledTimes(2);
expect(urls(onReq)[0]).toEqual(API_USER);
expect(urls(onReq)).toStrictEqual([API_USER, API_USER, ApiTargets.BOOTSTRAP, '/test']);
});

test('401 response refreshes token properly', async ({ server }: TestContext) => {
const { onRequest: onReq } = createServerListenerMocks(server);
server.use(testHandler);
server.use(
testHandlerWith200,
rest.get('*test', (_, res, ctx) => {
return res.once(ctx.status(401), ctx.json({ error: 'Not Authorized' }));
}),
);

const { data } = await testRequest();
expect(data).toEqual({ ok: true });
expect(onReq).toBeCalledTimes(4);
expect(urls(onReq)).toEqual([
expect(urls(onReq)).toStrictEqual([
// initial bootstrap because we don't have any userData stored
API_USER,

// this request will fail, triggering a refresh
'/test',

// refresh and retry original request
API_USER,
ApiTargets.BOOTSTRAP,
'/test',
]);

expect(onReq.mock.calls[2][0].headers.get('x-refresh-token')).toEqual('1');
expect(onReq.mock.calls[1][0].headers.get('x-refresh-token')).toEqual('1');
});

test('401 does not cause infinite loop if refresh repeatedly fails', async ({ server }: TestContext) => {
const { onRequest: onReq } = createServerListenerMocks(server);
server.use(testHandler);
server.use(testHandlerWith200);
server.use(
rest.get('*test', (_, res, ctx) => {
return res.once(ctx.status(401), ctx.json({ error: 'Not Authorized' }));
Expand Down Expand Up @@ -262,38 +253,32 @@ test('401 does not cause infinite loop if refresh repeatedly fails', async ({ se
*/
test('repeated 401s do not cause infinite loop', async ({ server }: TestContext) => {
const { onRequest: onReq } = createServerListenerMocks(server);
server.use(unAuthorizedHandler);

await expect(unAuthorizedRequest).rejects.toThrowError();

expect(onReq).toBeCalledTimes(4);
expect(urls(onReq)).toEqual([
// successful
API_USER,

// 401
'/test',
// everything returns a 401
server.use(
rest.get(`*${API_USER}`, (_, res, ctx) => res(ctx.status(401), ctx.json({ error: 'Not Authorized' }))),
rest.get(`*${ApiTargets.BOOTSTRAP}`, (_, res, ctx) => res(ctx.status(401), ctx.json({ error: 'Not Authorized' }))),
rest.get('*test', (_, res, ctx) => res(ctx.status(401), ctx.json({ message: 'Not Authorized' }))),
);

// successful
API_USER,
await expect(testRequest).rejects.toThrowError();

// 401 again, this should throw an error and abort re-bootstrapping
'/test',
]);
expect(onReq).toBeCalledTimes(3);
expect(urls(onReq)).toEqual([API_USER, API_USER, ApiTargets.BOOTSTRAP]);
});

test('request fails without a response body are rejected', async ({ server }: TestContext) => {
server.use(rest.get('*test', (_, res, ctx) => res(ctx.delay('infinite'), ctx.status(400, 'error'))));

// simulates a timeout, by aborting the request after a timeout
const control = new AbortController();
setTimeout(() => control.abort(), 50);
await expect(testRequest({}, { signal: control.signal })).rejects.toThrowError();
setTimeout(() => control.abort(), 500);
await expect(testRequest({}, { signal: control.signal })).rejects.toThrowErrorMatchingInlineSnapshot('"canceled"');
});

test('request rejects if the refreshed user data is not valid', async ({ server }: TestContext) => {
server.use(
unAuthorizedHandler,
testHandlerWith401,
rest.get(`*${API_USER}`, (_, res, ctx) => {
return res.once(ctx.status(200), ctx.json({ user: invalidMockUserData, isAuthenticated: false }));
}),
Expand All @@ -309,10 +294,28 @@ test('request rejects if the refreshed user data is not valid', async ({ server

api.setUserData(mockUserData);

await expect(testRequest).rejects.toThrowError();
await expect(testRequest).rejects.toThrowErrorMatchingInlineSnapshot('"Unable to obtain API access"');

// after the 401 from `test` we try to bootstrap, it's invalid so we reject
expect(onReq).toBeCalledTimes(3);
expect(urls(onReq)).toStrictEqual(['/test', API_USER, ApiTargets.BOOTSTRAP]);
expect(onReq.mock.calls[1][0].headers.get('x-refresh-token')).toEqual('1');
});

test('duplicate requests are provided the same promise', async ({ server }: TestContext) => {
const { onRequest: onReq } = createServerListenerMocks(server);
server.use(rest.get('*test', (_, res, ctx) => res(ctx.status(200), ctx.delay(100), ctx.json({ ok: true }))));

// fire off 100 test requests
const prom = Promise.race(Array.from({ length: 100 }, () => testRequest()));

// should have a single promise
expect(Array.from(api.getPendingRequests())).toHaveLength(1);
await prom;

// should have been cleaned up
expect(Array.from(api.getPendingRequests())).toHaveLength(0);

// all the requests shared the promise, so only a single flow actually went through
expect(urls(onReq)).toStrictEqual([API_USER, API_USER, ApiTargets.BOOTSTRAP, '/test']);
});
Loading

0 comments on commit 4f2145e

Please sign in to comment.