import { Log } from '@servicetitan/log-service'; import { Provider, symbolToken, useDependencies } from '@servicetitan/react-ioc'; import '@testing-library/jest-dom'; import { renderHook, waitFor } from '@testing-library/react'; import axios, { AxiosRequestConfig, CanceledError } from 'axios'; import { delay, http, HttpResponse } from 'msw'; import { FC, PropsWithChildren } from 'react'; import mock from 'xhr-mock'; import { server } from '../__mocks__/server'; import { AxiosRequestConfigAuthentication, BearerTokenAuth, RETRY_LIMIT, TokenServerAuthError, withMicroservice, WithMicroserviceOptions, } from '../with-microservice'; describe(`[ajax-handlers] ${withMicroservice.name}`, () => { const expectedUsername = 'foo'; const defaultEndpoint = '/user'; const baseURL = 'http://localhost'; const authURL = `${baseURL}/refresh_token/${expectedUsername}`; const UnwrappedComponent: FC = ({ children }) =>
{children}
; let requestCounts: Record; // tracks how many times each request is made let abortController: AbortController; let axiosRequestConfig: | (AxiosRequestConfig & AxiosRequestConfigAuthentication) | (AxiosRequestConfig & AxiosRequestConfigAuthentication)[] | undefined; let options: WithMicroserviceOptions; let requestsCallback: () => Promise; let singletons: Parameters[0]['singletons']; beforeAll(() => { mock.teardown(); server.listen(); server.events.on('request:start', ({ request }: { request: Request }) => { const path = new URL(request.url).pathname; requestCounts[path] = (requestCounts[path] ?? 0) + 1; }); }); afterAll(() => { server.close(); server.events.removeAllListeners('request:start'); }); beforeEach(() => { jest.clearAllMocks(); requestCounts = {}; abortController = new AbortController(); axiosRequestConfig = undefined; options = { baseURL, authURL, component: UnwrappedComponent }; requestsCallback = makeRequests; singletons = undefined; }); afterEach(() => { server.resetHandlers(); }); function getRequestCount() { return requestCounts[defaultEndpoint] ?? 0; } function getAuthRequestCount() { return requestCounts[new URL(authURL).pathname] ?? 0; } function createAxiosRequestConfig( config: Partial = {} ) { return { baseURL: options.baseURL, url: defaultEndpoint, method: 'GET', signal: abortController.signal, ...config, }; } async function makeRequests() { const config = axiosRequestConfig ?? createAxiosRequestConfig(); const result = await Promise.all( [config].flat().map(opt => (options.axiosInstance ?? axios).request(opt)) ); return result.length === 1 ? result[0].data : result.map(({ data }) => data); } const subject = async () => { const Wrapper: FC = ({ children }) => ( {withMicroservice(options)({ children })} ); const hook = renderHook(() => () => requestsCallback(), { wrapper: Wrapper }); await waitFor(() => { expect(hook.result.current).not.toBeNull(); }); return { hook, getUser: hook.result.current }; }; test('makes a request to the authURL to get the auth token, and then uses the auth token in the original request to get the requested data', async () => { const { username } = await (await subject()).getUser(); expect(getRequestCount()).toBe(2); expect(getAuthRequestCount()).toBe(1); expect(username).toBe(expectedUsername); }); describe('when multiple adapters are registered for the same baseURL', () => { const authAdapter = new BearerTokenAuth({ baseURL, authURL }); beforeEach(async () => { await authAdapter.authenticate(); options.authAdapter = () => authAdapter; options.component = withMicroservice(options); jest.spyOn(authAdapter, 'interceptRequest'); }); test('sends request to only one adapter', async () => { await (await subject()).getUser(); expect(jest.mocked(authAdapter.interceptRequest)).toHaveBeenCalledTimes(1); }); }); describe('when skipAuth is true', () => { beforeEach(() => (axiosRequestConfig = createAxiosRequestConfig({ skipAuth: true }))); test('does not make auth request', async () => { await expect((await subject()).getUser()).rejects.toEqual( expect.objectContaining({ message: expect.stringMatching(/401/) }) ); }); }); test(`throws ${CanceledError.name} when the request is canceled`, async () => { const request = (await subject()).getUser(); abortController.abort(); await expect(request).rejects.toBeInstanceOf(CanceledError); }); test('removes axios interceptors when component is unmounted', async () => { const { hook } = await subject(); hook.unmount(); const requestHandlers = axios.interceptors.request.handlers!.filter(h => !!h); const responseHandlers = axios.interceptors.response.handlers!.filter(h => !!h); expect(requestHandlers).toHaveLength(0); expect(responseHandlers).toHaveLength(0); }); test('rejects invalid authAdapter', () => { jest.spyOn(console, 'error').mockImplementation(jest.fn()); // suppress uncaught exception error expect(() => renderHook(() => null, { wrapper: withMicroservice({ ...options, authAdapter: 'foo', preAuthenticate: true, } as any), }) ).toThrow(/unrecognized authAdapter/); }); describe('when two requests are made simultaneously', () => { beforeEach(() => { axiosRequestConfig = [createAxiosRequestConfig(), createAxiosRequestConfig()]; }); test('makes one auth request', async () => { await (await subject()).getUser(); expect(getAuthRequestCount()).toBe(1); }); test('makes four requests (1st and 2nd fail then retry)', async () => { await (await subject()).getUser(); expect(getRequestCount()).toBe(4); }); }); describe('when two requests are made sequentially', () => { const setup = async () => { const { getUser } = await subject(); await getUser(); await getUser(); }; test('makes one auth request', async () => { await setup(); expect(getAuthRequestCount()).toBe(1); }); test('makes three requests (1st fails and retries, 2nd succeeds)', async () => { await setup(); expect(getRequestCount()).toBe(3); }); }); describe('when a second request is made while the auth request is in flight', () => { beforeEach(() => (options.authURL = `${authURL}?delay=200`)); async function makeRequestsWithDelay(delay: number) { const { getUser, hook } = await subject(); const promises = [ getUser(), new Promise(resolve => setTimeout(() => resolve(getUser()), delay)), ]; return { promises, hook }; } const setup = async () => { return Promise.all((await makeRequestsWithDelay(100)).promises); }; test('makes one auth request', async () => { await setup(); expect(getAuthRequestCount()).toBe(1); }); test('makes three requests (1st fails and retries, 2nd waits for auth)', async () => { await setup(); expect(getRequestCount()).toBe(3); }); describe('when the component is unmounted while the auth request is in flight', () => { beforeEach(() => (options.authURL = `${authURL}?delay=100`)); const unMountSetup = async () => { const { hook, promises } = await makeRequestsWithDelay(40); setTimeout(() => hook.unmount(), 80); return Promise.allSettled(promises); }; /** * First api request fires, is 401, authentication fires * Second api request fires, waits while authentication is in flight * Component is unmounted, all requests are canceled */ test('cancels all requests', async () => { const errors = (await unMountSetup()) as PromiseRejectedResult[]; for (let i = 0; i < 2; i++) { expect(errors[i].reason).toBeInstanceOf(CanceledError); } expect(getRequestCount()).toBe(1); }); }); describe('when the authentication response is a 401', () => { const setup = async () => { return Promise.allSettled((await makeRequestsWithDelay(100)).promises); }; beforeEach(() => { server.use( http.get('/refresh_token/:username', async () => { await delay(300); return new HttpResponse(null, { status: 401 }); }) ); }); /** * First api request fires, is 401, authentication fires * Second api request fires, waits while authentication is in flight * Authentication is 401, first api request errors with 401 * Waiting requests are canceled */ test('first request errors with 401 and second is canceled', async () => { const errors = (await setup()) as PromiseRejectedResult[]; expect(errors[0].reason).toEqual( expect.objectContaining({ message: expect.stringMatching(/401/), response: expect.objectContaining({ config: expect.objectContaining({ url: defaultEndpoint, }), }), }) ); expect(errors[1].reason).toBeInstanceOf(CanceledError); expect(getRequestCount()).toBe(1); expect(getAuthRequestCount()).toBe(1); }); }); }); describe('when no baseURL is provided in the Axios config, but the requested url starts with the withMicroservice baseURL', () => { beforeEach(() => { axiosRequestConfig = createAxiosRequestConfig({ baseURL: undefined, url: `${baseURL}${defaultEndpoint}`, }); }); test('makes auth request', async () => { await (await subject()).getUser(); expect(getAuthRequestCount()).toBe(1); }); }); describe('when the requested url does not match, nor is part of, the withMicroservice baseURL', () => { beforeEach(() => { axiosRequestConfig = createAxiosRequestConfig({ baseURL: 'http://foobar', url: '/user', }); server.use( http.get('http://foobar/user', () => new HttpResponse(null, { status: 401 })) ); }); test('throws a 401 error, ignoring the interceptors added for the withMicroservice baseURL', async () => { await expect((await subject()).getUser()).rejects.toEqual( expect.objectContaining({ message: expect.stringMatching(/401/) }) ); expect(getAuthRequestCount()).toBe(0); }); }); describe('when a request is made with an invalid url', () => { beforeEach(() => (axiosRequestConfig = createAxiosRequestConfig({ url: '/broken' }))); test('throws a Network Error', async () => { await expect((await subject()).getUser()).rejects.toEqual( expect.objectContaining({ message: 'Network Error' }) ); }); }); describe('when the auth url receives 401 response', () => { beforeEach(() => { server.use( http.get('/refresh_token/:username', () => new HttpResponse(null, { status: 401 })) ); }); test("throws the original request's 401 error", async () => { await expect((await subject()).getUser()).rejects.toEqual( expect.objectContaining({ message: expect.stringMatching(/401/), request: expect.objectContaining({ responseURL: `${baseURL}${defaultEndpoint}`, }), }) ); }); }); describe('when simultaneous requests are made without an auth token', () => { function createRequestConfigsWithDelay(...delays: number[]) { return delays.map(delay => createAxiosRequestConfig({ url: `/user?delay=${delay}` })); } beforeEach(() => (axiosRequestConfig = createRequestConfigsWithDelay(0, 0, 0))); test('installs only one request interceptor', async () => { await subject(); const handlers = axios.interceptors.request.handlers!.filter(h => !!h); expect(handlers).toHaveLength(1); }); test('installs only one response interceptor', async () => { await subject(); const handlers = axios.interceptors.response.handlers!.filter(h => !!h); expect(handlers).toHaveLength(1); }); describe('with the other requests taking longer than the first', () => { beforeEach(() => (axiosRequestConfig = createRequestConfigsWithDelay(50, 25, 0))); test('makes only one auth request', async () => { await (await subject()).getUser(); expect(getAuthRequestCount()).toBe(1); }); }); describe('when preAuthenticate is true', () => { beforeEach(() => (options.preAuthenticate = true)); /* * Since the auth request will happen on mount, and all other requests wait until * it is done, requests will only be made once each instead of twice (totalling 3) */ test('fetches each of the 3 requests only once', async () => { await (await subject()).getUser(); expect(getRequestCount()).toBe(3); }); describe('when preAuthenticateCallback is provided', () => { beforeEach(() => (options.preAuthenticateCallback = jest.fn())); test('calls preAuthenticateCallback', async () => { await (await subject()).getUser(); expect(options.preAuthenticateCallback).toHaveBeenCalled(); }); }); describe('when the preauth request fails', () => { const category = 'AjaxHandlers.PreAuthenticate'; const message = expect.stringMatching(/unexpected error/); const error = expect.objectContaining({ message: expect.stringMatching(/401/) }); let consoleSpy: jest.SpyInstance; beforeEach(() => { consoleSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn()); server.use( http.get( '/refresh_token/:username', () => new HttpResponse(null, { status: 401 }) ) ); }); test('logs console error', async () => { await subject(); expect(consoleSpy).toHaveBeenCalledWith(category, message, { error }); }); describe('when Log is provided', () => { const mockLog = { error: jest.fn() }; beforeEach(() => (singletons = [{ provide: Log, useValue: mockLog }])); test('calls Log.error instead', async () => { await subject(); expect(consoleSpy).not.toHaveBeenCalled(); expect(mockLog.error).toHaveBeenCalledWith({ category, message, error }); }); }); describe('when preAuthenticateCallback is provided', () => { beforeEach(() => (options.preAuthenticateCallback = jest.fn())); test('calls preAuthenticateCallback instead', async () => { await subject(); expect(consoleSpy).not.toHaveBeenCalled(); expect(options.preAuthenticateCallback).toHaveBeenCalledWith(error); }); }); [CanceledError, TokenServerAuthError].forEach(Kind => { describe(`when request fails due to ${Kind.name}`, () => { const mockAdapter = { authenticate: () => { return Promise.reject(new Kind('Oops', null)); }, }; beforeEach(() => { options.authAdapter = () => mockAdapter; }); test('does not log error', async () => { await subject(); expect(consoleSpy).not.toHaveBeenCalled(); }); }); }); }); }); describe('when 401 responses are queued for authentication while authentication is occurring', () => { beforeEach(() => { options.authURL = `${authURL}?delay=500`; requestsCallback = () => { const config = axiosRequestConfig as AxiosRequestConfig[]; return Promise.allSettled(config.map(opt => axios.request(opt))); }; }); /** * First, second, and third api request fire * First api request is 401, authentication fires * Second and third api requests are 401, they are queued * Component is unmounted, all requests are canceled */ test('rejects all requests CanceledError when the auth request is canceled', async () => { const { getUser, hook } = await subject(); setTimeout(() => hook.unmount(), 150); const errors = (await getUser()) as PromiseRejectedResult[]; for (let i = 0; i < 3; i++) { expect(errors[i].reason).toBeInstanceOf(CanceledError); } expect(getRequestCount()).toBe(3); }); }); }); describe('when wrapped component is unmounted while a request is in flight', () => { const setup = async () => { const { hook, getUser } = await subject(); const user = getUser(); // Takes 100ms to resolve hook.unmount(); return user; }; test('ignores interceptor workflow and auth url is not requested', async () => { await waitFor(() => expect(setup()).rejects.toEqual( expect.objectContaining({ message: expect.stringMatching(/401/) }) ) ); }); }); describe('when the first auth url returns a 401 response', () => { beforeEach(() => { server.use( http.get( '/refresh_token/:username', () => new HttpResponse(null, { status: 401 }), { once: true } ) ); }); test('requests the auth url each time when sequential requests are made without an auth token', async () => { await expect((await subject()).getUser()).rejects.toEqual( expect.objectContaining({ message: expect.stringMatching(/401/) }) ); await (await subject()).getUser(); expect(getAuthRequestCount()).toBe(2); }); }); describe("when token is retrieved successfully but it's invalid", () => { beforeEach(() => { server.use(http.get('/user', () => HttpResponse.json(null, { status: 401 }))); }); test('rejects request as 401 after the retry limit is reached', async () => { jest.spyOn(console, 'error').mockImplementation(jest.fn()); // suppress "retry limit reached" error await expect((await subject()).getUser()).rejects.toEqual( expect.objectContaining({ message: expect.stringMatching(/401/) }) ); expect(getRequestCount()).toBe(1 + RETRY_LIMIT); }); }); describe('when tokens are provided', () => { const BASE_URL_TOKEN = symbolToken('BASE_URL_TOKEN', false); beforeEach(() => (options.tokens = [BASE_URL_TOKEN])); const subject = async () => { const { result } = renderHook(() => useDependencies(BASE_URL_TOKEN), { wrapper: withMicroservice(options), }); await waitFor(() => { expect(result.current).not.toBeNull(); }); return result.current[0]; }; it('assigns the baseURL to the dependency retrieved from that token', async () => { const baseUrlFromToken = await subject(); expect(baseUrlFromToken).toBe(baseURL); }); }); describe('when an axios instance is provided with a baseURL', () => { const baseURL = '/api'; const authURL = `${baseURL}/refresh_token/${expectedUsername}`; beforeEach(() => { options.axiosInstance = axios.create({ baseURL }); options.baseURL = baseURL; options.authURL = authURL; server.use( http.get( `${baseURL}${defaultEndpoint}`, () => { return new HttpResponse(null, { status: 401 }); }, { once: true } ), http.get(`${baseURL}${defaultEndpoint}`, () => { return new HttpResponse(null, { status: 200 }); }), http.get(authURL, () => { return new HttpResponse(null, { status: 200 }); }) ); }); test('makes a request to the endpoint, then authURL, then endpoint', async () => { await (await subject()).getUser(); expect(requestCounts[`${baseURL}${defaultEndpoint}`]).toBe(2); expect(requestCounts[authURL]).toBe(1); }); }); });