import DefaultFixtures from '../../__utils__/fixtures/defaults' import { BASE_URL } from '../../__utils__/handlers' import MockServer from '../../__utils__/server' import { FailedResponse } from '../../types' import Client from '../../utils/client/client' const APP_BASE_URL = BASE_URL let client: Client let clientWithDefaultHeaders: Client let fetchSpy: jest.SpyInstance beforeAll(() => MockServer.server().listen()) afterEach(() => MockServer.server().resetHandlers()) afterAll(() => MockServer.server().close()) const onUnauthorizedResponse = jest.fn() beforeEach(() => { jest.clearAllMocks() fetchSpy = jest.spyOn(globalThis, 'fetch') client = new Client({ version: '2018-11-01', root: APP_BASE_URL, onUnauthorizedResponse, }) clientWithDefaultHeaders = new Client({ version: '2018-11-01', root: APP_BASE_URL, defaultHeaders: { 'X-Custom-Default-Header': 'important data', }, onUnauthorizedResponse, }) }) describe('get', () => { const url = '/records' it('throws an error without `fields`', async () => { // @ts-expect-error await expect(() => client.get({ url, data: {} })).rejects.toThrow( `Must pass fields for request: ${APP_BASE_URL + url}` ) }) it('throws an error if params are in the initial `url`', async () => { await expect( client.get({ url: `${url}?per_page=1`, data: { fields: { Record: ['id'] }, where: { id: '1' } }, }) ).rejects.toThrow('Must pass query params as data arg') }) it('opens a GET with a url built from `url` and `data`', async () => { await client.get({ url, data: { fields: { Record: ['id'] }, where: { id: '1' } }, }) expect(fetchSpy).toHaveBeenCalledWith( APP_BASE_URL + '/records?fields%5BRecord%5D=id&per_page=100&where%5Bid%5D=1', expect.any(Object) ) }) it('respects opting out of pagination walks', async () => { await client.get({ url, data: { fields: { Record: ['id', 'name'] }, perPage: 1, walk: false }, }) expect(fetchSpy).toHaveBeenCalledTimes(1) }) it('walks pagination when walk = true', async () => { const expectUrl = APP_BASE_URL + '/records?fields%5BRecord%5D=id&per_page=1' const nextUrl = expectUrl + '&offset=1' MockServer.get(url, { ...DefaultFixtures.records(), links: { next: nextUrl } }, 200, { once: true, }) await client.get({ url, data: { fields: { Record: ['id', 'name'] }, perPage: 1, walk: true }, }) // We only mock one page of data, so we should only have 2 requests expect(fetchSpy).toHaveBeenCalledTimes(2) }) it('does not overwrite params in `url` when walking', async () => { const expectUrl = APP_BASE_URL + '/records?fields%5BRecord%5D=id' const nextUrl = expectUrl + '&offset=1&per_page=1' MockServer.get(url, { ...DefaultFixtures.records(), links: { next: nextUrl } }, 200, { once: true, }) await client.get({ url, data: { perPage: 1, fields: { Record: ['id'] }, walk: true }, }) expect(fetchSpy).toHaveBeenNthCalledWith(1, expectUrl + '&per_page=1', expect.any(Object)) expect(fetchSpy).toHaveBeenNthCalledWith(2, nextUrl, expect.any(Object)) }) it('does not remove custom attributes from `meta` when not walking', async () => { const meta = { total_count: 3, non_standard_meta_key: 'non_standard_meta_value', } MockServer.get(url, { ...DefaultFixtures.records(), meta }, 200, { once: true, }) const results = await client.get({ url, data: { perPage: 1, fields: { Record: ['id'] }, walk: false }, }) expect(results.meta).toEqual({ totalCount: 3, nonStandardMetaKey: 'non_standard_meta_value', }) }) it('returns attributes from request', async () => { const links = { next: 'http://example.com/next', } const mockPayload = { ...DefaultFixtures.records(), links } MockServer.get(url, mockPayload, 200, { once: true, }) const results = await client.get({ url, data: { perPage: 1, fields: { Record: ['id'] }, walk: false }, }) expect(results.links).toEqual(links) const returnedKeys = Object.keys(results) const expectedKeys = Object.keys(mockPayload).filter(n => n !== 'included') expectedKeys.forEach(key => { expect(returnedKeys).toContain(key) }) }) it('takes custom headers', async () => { await client.get({ url, data: { fields: { Record: ['id'] } }, headers: { 'X-Custom-Header': 'value!', 'Content-Type': 'text/html' }, }) const { headers } = fetchSpy.mock.calls[0][1] expect(headers['X-Custom-Header']).toEqual('value!') expect(headers['Content-Type']).toEqual('text/html') }) it('uses client default headers', async () => { MockServer.get(url, DefaultFixtures.records(), 200, { once: true }) await clientWithDefaultHeaders.get({ url, data: { fields: { Record: ['id'] } }, headers: { 'X-Custom-Header': 'value!' }, }) const { headers } = fetchSpy.mock.calls[0][1] expect(headers['X-Custom-Header']).toEqual('value!') expect(headers['X-Custom-Default-Header']).toEqual('important data') }) it('throws an error if response is not ok', async () => { MockServer.get( url, { errors: [{ status: '403', detail: 'Zoinks, your token is borked' }] }, 403, { once: true } ) await expect( client.get({ url, data: { fields: { Record: ['id'] } }, }) ).rejects.toHaveProperty('status', 403) }) }) describe('post', () => { const url = '/records' it('throws an error if params are in the initial `url`', async () => { await expect(client.post({ url: `${url}?per_page=1` })).rejects.toThrow( 'Must pass query params as data arg' ) }) it('sends a POST with data', async () => { const payload = { data: { type: 'Record', id: 1, attributes: { name: 'name' }, }, } await client.post({ url, data: payload }) const { body } = fetchSpy.mock.calls[0][1] expect(body).toEqual(JSON.stringify(payload)) }) it('takes custom headers', async () => { await client.post({ url, data: { fields: { Record: 'id' } }, headers: { 'X-Custom-Header': 'value!', 'Content-Type': 'text/html' }, }) const { headers } = fetchSpy.mock.calls[0][1] expect(headers['X-Custom-Header']).toEqual('value!') expect(headers['Content-Type']).toEqual('text/html') }) it('uses client default headers', async () => { MockServer.post('https://api-staging.planningcenteronline.com/chat/v2/records/:id', {}, 200, { once: true, }) await clientWithDefaultHeaders.post({ url, data: { fields: { Record: 'id' } }, headers: { 'X-Custom-Header': 'value!' }, }) const { headers } = fetchSpy.mock.calls[0][1] expect(headers['X-Custom-Header']).toEqual('value!') expect(headers['X-Custom-Default-Header']).toEqual('important data') }) }) describe('patch', () => { const url = '/records/3' const payload = { data: { type: 'Record', attributes: { name: 'new name' } }, } it('throws an error if params are in the initial `url`', async () => { await expect(() => client.patch({ url: `${url}?per_page=1` })).rejects.toThrow( 'Must pass query params as data arg' ) }) it('opens a PATCH with a url built from `url`', async () => { await client.patch({ url, data: payload }) expect(fetchSpy.mock.calls[0][0]).toBe(BASE_URL + '/records/3') }) it('sends a PATCH with data`', async () => { await client.patch({ url, data: payload }) const { body } = fetchSpy.mock.calls[0][1] expect(body).toEqual(JSON.stringify(payload)) }) it('takes custom headers', async () => { await client.patch({ url, data: { fields: { Record: 'id' } }, headers: { 'X-Custom-Header': 'value!', 'Content-Type': 'text/html' }, }) const { headers } = fetchSpy.mock.calls[0][1] expect(headers['X-Custom-Header']).toEqual('value!') expect(headers['Content-Type']).toEqual('text/html') }) it('uses client default headers', async () => { MockServer.patch('https://api-staging.planningcenteronline.com/chat/v2/records/:id', {}, 200, { once: true, }) await clientWithDefaultHeaders.patch({ url, data: { fields: { Record: 'id' } }, headers: { 'X-Custom-Header': 'value!' }, }) const { headers } = fetchSpy.mock.calls[0][1] expect(headers['X-Custom-Header']).toEqual('value!') expect(headers['X-Custom-Default-Header']).toEqual('important data') }) }) describe('delete', () => { const url = '/records/3' it('sends DELETE with a url built from `url`', async () => { await client.delete({ url }) expect(fetchSpy).toHaveBeenCalledWith(APP_BASE_URL + url, expect.any(Object)) }) it('takes custom headers', async () => { await client.delete({ url, headers: { 'X-Custom-Header': 'value!', 'Content-Type': 'text/html' }, }) const { headers } = fetchSpy.mock.calls[0][1] expect(headers['X-Custom-Header']).toEqual('value!') expect(headers['Content-Type']).toEqual('text/html') }) it('uses client default headers', async () => { MockServer.delete( 'https://api-staging.planningcenteronline.com/chat/v2/records/:id', undefined, 200, { once: true, } ) await clientWithDefaultHeaders.delete({ url, headers: { 'X-Custom-Header': 'value!' }, }) const { headers } = fetchSpy.mock.calls[0][1] expect(headers['X-Custom-Header']).toEqual('value!') expect(headers['X-Custom-Default-Header']).toEqual('important data') }) }) describe('error handling', () => { describe('401 errors', () => { it('defaults', async () => { await test401({ secretWord: '' }) expect(onUnauthorizedResponse).toHaveBeenCalled() }) const test401 = async ({ secretWord }: { secretWord: string }) => { const url = '/records' const expiredTokenResource: Partial = { errors: [ { detail: `Zoinks, your token is borked ( ${secretWord} )`, title: 'Unauthorized', status: '401', }, ], } MockServer.get(url, expiredTokenResource, 401, { once: true }) await expect( client.get({ url, data: { fields: { Record: ['id'] } }, }) ).rejects.toHaveProperty('status', 401) } }) describe('non-401 errors', () => { it('attaches the parsed body so the message survives to the UI', async () => { const url = '/records' MockServer.post( url, { errors: [ { status: '422', title: 'Unprocessable Entity', detail: 'Conversations including a minor must have at least two confirmed adults.', }, ], }, 422, { once: true } ) const rejection = (await client .post({ url, data: { data: { type: 'Record', attributes: {} } } }) .catch(e => e)) as FailedResponse expect(rejection).toHaveProperty('status', 422) expect(rejection.errors?.[0]?.detail).toEqual( 'Conversations including a minor must have at least two confirmed adults.' ) // A 422 must not be mistaken for an auth failure expect(onUnauthorizedResponse).not.toHaveBeenCalled() }) }) })