UNPKG

apollo-link-http

Version:
836 lines (742 loc) 24.9 kB
import { Observable, ApolloLink, execute } from 'apollo-link'; import { print } from 'graphql'; import gql from 'graphql-tag'; import * as fetchMock from 'fetch-mock'; const sampleQuery = gql` query SampleQuery { stub { id } } `; const sampleMutation = gql` mutation SampleMutation { stub(param: "value") { id } } `; const makeCallback = (done, body) => { return (...args) => { try { body(...args); done(); } catch (error) { done.fail(error); } }; }; export const sharedHttpTest = ( linkName, createLink, batchedRequests = false, ) => { const convertBatchedBody = body => { const parsed = JSON.parse(body); if (batchedRequests) { expect(Array.isArray(parsed)); expect(parsed.length).toBe(1); return parsed.pop(); } return parsed; }; describe(`SharedHttpTest : ${linkName}`, () => { const data = { data: { hello: 'world' } }; const data2 = { data: { hello: 'everyone' } }; const mockError = { throws: new TypeError('mock me') }; const makePromise = res => new Promise((resolve, reject) => setTimeout(() => resolve(res))); let subscriber; beforeEach(() => { fetchMock.restore(); fetchMock.post('begin:data2', makePromise(data2)); fetchMock.post('begin:data', makePromise(data)); fetchMock.post('begin:error', mockError); fetchMock.post('begin:apollo', makePromise(data)); fetchMock.get('begin:data', makePromise(data)); fetchMock.get('begin:data2', makePromise(data2)); const next = jest.fn(); const error = jest.fn(); const complete = jest.fn(); subscriber = { next, error, complete, }; }); afterEach(() => { fetchMock.restore(); }); it('raises warning if called with concat', () => { const link = createLink(); const _warn = console.warn; console.warn = warning => expect(warning['message']).toBeDefined(); expect(link.concat((operation, forward) => forward(operation))).toEqual( link, ); console.warn = _warn; }); it('does not need any constructor arguments', () => { expect(() => createLink()).not.toThrow(); }); it('calls next and then complete', done => { const next = jest.fn(); const link = createLink({ uri: 'data' }); const observable = execute(link, { query: sampleQuery, }); observable.subscribe({ next, error: error => done.fail(error), complete: makeCallback(done, () => { expect(next).toHaveBeenCalledTimes(1); }), }); }); it('calls error when fetch fails', done => { const link = createLink({ uri: 'error' }); const observable = execute(link, { query: sampleQuery, }); observable.subscribe( result => done.fail('next should not have been called'), makeCallback(done, error => { expect(error).toEqual(mockError.throws); }), () => done.fail('complete should not have been called'), ); }); it('calls error when fetch fails', done => { const link = createLink({ uri: 'error' }); const observable = execute(link, { query: sampleMutation, }); observable.subscribe( result => done.fail('next should not have been called'), makeCallback(done, error => { expect(error).toEqual(mockError.throws); }), () => done.fail('complete should not have been called'), ); }); it('unsubscribes without calling subscriber', done => { const link = createLink({ uri: 'data' }); const observable = execute(link, { query: sampleQuery, }); const subscription = observable.subscribe( result => done.fail('next should not have been called'), error => done.fail(error), () => done.fail('complete should not have been called'), ); subscription.unsubscribe(); expect(subscription.closed).toBe(true); setTimeout(done, 50); }); const verifyRequest = ( link: ApolloLink, after: () => void, includeExtensions: boolean, done: any, ) => { const next = jest.fn(); const context = { info: 'stub' }; const variables = { params: 'stub' }; const observable = execute(link, { query: sampleMutation, context, variables, }); observable.subscribe({ next, error: error => done.fail(error), complete: () => { try { let body = convertBatchedBody(fetchMock.lastCall()[1].body); expect(body.query).toBe(print(sampleMutation)); expect(body.variables).toEqual(variables); expect(body.context).not.toBeDefined(); if (includeExtensions) { expect(body.extensions).toBeDefined(); } else { expect(body.extensions).not.toBeDefined(); } expect(next).toHaveBeenCalledTimes(1); after(); } catch (e) { done.fail(e); } }, }); }; it('passes all arguments to multiple fetch body including extensions', done => { debugger; const link = createLink({ uri: 'data', includeExtensions: true }); verifyRequest( link, () => verifyRequest(link, done, true, done), true, done, ); }); it('passes all arguments to multiple fetch body excluding extensions', done => { const link = createLink({ uri: 'data' }); verifyRequest( link, () => verifyRequest(link, done, false, done), false, done, ); }); it('calls multiple subscribers', done => { const link = createLink({ uri: 'data' }); const context = { info: 'stub' }; const variables = { params: 'stub' }; const observable = execute(link, { query: sampleMutation, context, variables, }); observable.subscribe(subscriber); observable.subscribe(subscriber); setTimeout(() => { expect(subscriber.next).toHaveBeenCalledTimes(2); expect(subscriber.complete).toHaveBeenCalledTimes(2); expect(subscriber.error).not.toHaveBeenCalled(); done(); }, 50); }); it('calls remaining subscribers after unsubscribe', done => { const link = createLink({ uri: 'data' }); const context = { info: 'stub' }; const variables = { params: 'stub' }; const observable = execute(link, { query: sampleMutation, context, variables, }); observable.subscribe(subscriber); const subscription = observable.subscribe(subscriber); subscription.unsubscribe(); setTimeout( makeCallback(done, () => { expect(subscriber.next).toHaveBeenCalledTimes(1); expect(subscriber.complete).toHaveBeenCalledTimes(1); expect(subscriber.error).not.toHaveBeenCalled(); done(); }), 50, ); }); it('allows for dynamic endpoint setting', done => { const variables = { params: 'stub' }; const link = createLink({ uri: 'data' }); execute(link, { query: sampleQuery, variables, context: { uri: 'data2' }, }).subscribe(result => { expect(result).toEqual(data2); done(); }); }); it('adds headers to the request from the context', done => { const variables = { params: 'stub' }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ headers: { authorization: '1234' }, }); return forward(operation).map(result => { const { headers } = operation.getContext(); try { expect(headers).toBeDefined(); } catch (e) { done.fail(e); } return result; }); }); const link = middleware.concat(createLink({ uri: 'data' })); execute(link, { query: sampleQuery, variables }).subscribe( makeCallback(done, result => { const headers = fetchMock.lastCall()[1].headers; expect(headers.authorization).toBe('1234'); expect(headers['content-type']).toBe('application/json'); expect(headers.accept).toBe('*/*'); }), ); }); it('adds headers to the request from the setup', done => { const variables = { params: 'stub' }; const link = createLink({ uri: 'data', headers: { authorization: '1234' }, }); execute(link, { query: sampleQuery, variables }).subscribe( makeCallback(done, result => { const headers = fetchMock.lastCall()[1].headers; expect(headers.authorization).toBe('1234'); expect(headers['content-type']).toBe('application/json'); expect(headers.accept).toBe('*/*'); }), ); }); it('prioritizes context headers over setup headers', done => { const variables = { params: 'stub' }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ headers: { authorization: '1234' }, }); return forward(operation); }); const link = middleware.concat( createLink({ uri: 'data', headers: { authorization: 'no user' } }), ); execute(link, { query: sampleQuery, variables }).subscribe( makeCallback(done, result => { const headers = fetchMock.lastCall()[1].headers; expect(headers.authorization).toBe('1234'); expect(headers['content-type']).toBe('application/json'); expect(headers.accept).toBe('*/*'); }), ); }); it('adds headers to the request from the context on an operation', done => { const variables = { params: 'stub' }; const link = createLink({ uri: 'data' }); const context = { headers: { authorization: '1234' }, }; execute(link, { query: sampleQuery, variables, context, }).subscribe( makeCallback(done, result => { const headers = fetchMock.lastCall()[1].headers; expect(headers.authorization).toBe('1234'); expect(headers['content-type']).toBe('application/json'); expect(headers.accept).toBe('*/*'); }), ); }); it('adds creds to the request from the context', done => { const variables = { params: 'stub' }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ credentials: 'same-team-yo', }); return forward(operation); }); const link = middleware.concat(createLink({ uri: 'data' })); execute(link, { query: sampleQuery, variables }).subscribe( makeCallback(done, result => { const creds = fetchMock.lastCall()[1].credentials; expect(creds).toBe('same-team-yo'); }), ); }); it('adds creds to the request from the setup', done => { const variables = { params: 'stub' }; const link = createLink({ uri: 'data', credentials: 'same-team-yo' }); execute(link, { query: sampleQuery, variables }).subscribe( makeCallback(done, result => { const creds = fetchMock.lastCall()[1].credentials; expect(creds).toBe('same-team-yo'); }), ); }); it('prioritizes creds from the context over the setup', done => { const variables = { params: 'stub' }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ credentials: 'same-team-yo', }); return forward(operation); }); const link = middleware.concat( createLink({ uri: 'data', credentials: 'error' }), ); execute(link, { query: sampleQuery, variables }).subscribe( makeCallback(done, result => { const creds = fetchMock.lastCall()[1].credentials; expect(creds).toBe('same-team-yo'); }), ); }); it('adds uri to the request from the context', done => { const variables = { params: 'stub' }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ uri: 'data', }); return forward(operation); }); const link = middleware.concat(createLink()); execute(link, { query: sampleQuery, variables }).subscribe( makeCallback(done, result => { const uri = fetchMock.lastUrl(); expect(uri).toBe('data'); }), ); }); it('adds uri to the request from the setup', done => { const variables = { params: 'stub' }; const link = createLink({ uri: 'data' }); execute(link, { query: sampleQuery, variables }).subscribe( makeCallback(done, result => { const uri = fetchMock.lastUrl(); expect(uri).toBe('data'); }), ); }); it('prioritizes context uri over setup uri', done => { const variables = { params: 'stub' }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ uri: 'apollo', }); return forward(operation); }); const link = middleware.concat( createLink({ uri: 'data', credentials: 'error' }), ); execute(link, { query: sampleQuery, variables }).subscribe( makeCallback(done, result => { const uri = fetchMock.lastUrl(); expect(uri).toBe('apollo'); }), ); }); it('allows uri to be a function', done => { const variables = { params: 'stub' }; const customFetch = (uri, options) => { const { operationName } = convertBatchedBody(options.body); try { expect(operationName).toBe('SampleQuery'); } catch (e) { done.fail(e); } return fetch('dataFunc', options); }; const link = createLink({ fetch: customFetch }); execute(link, { query: sampleQuery, variables }).subscribe( makeCallback(done, result => { const uri = fetchMock.lastUrl(); expect(fetchMock.lastUrl()).toBe('dataFunc'); }), ); }); it('adds fetchOptions to the request from the setup', done => { const variables = { params: 'stub' }; const link = createLink({ uri: 'data', fetchOptions: { signal: 'foo', mode: 'no-cors' }, }); execute(link, { query: sampleQuery, variables }).subscribe( makeCallback(done, result => { const { signal, mode, headers } = fetchMock.lastCall()[1]; expect(signal).toBe('foo'); expect(mode).toBe('no-cors'); expect(headers['content-type']).toBe('application/json'); }), ); }); it('adds fetchOptions to the request from the context', done => { const variables = { params: 'stub' }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ fetchOptions: { signal: 'foo', }, }); return forward(operation); }); const link = middleware.concat(createLink({ uri: 'data' })); execute(link, { query: sampleQuery, variables }).subscribe( makeCallback(done, result => { const signal = fetchMock.lastCall()[1].signal; expect(signal).toBe('foo'); done(); }), ); }); it('prioritizes context over setup', done => { const variables = { params: 'stub' }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ fetchOptions: { signal: 'foo', }, }); return forward(operation); }); const link = middleware.concat( createLink({ uri: 'data', fetchOptions: { signal: 'bar' } }), ); execute(link, { query: sampleQuery, variables }).subscribe( makeCallback(done, result => { const signal = fetchMock.lastCall()[1].signal; expect(signal).toBe('foo'); }), ); }); it('allows for not sending the query with the request', done => { const variables = { params: 'stub' }; const middleware = new ApolloLink((operation, forward) => { operation.setContext({ http: { includeQuery: false, includeExtensions: true, }, }); operation.extensions.persistedQuery = { hash: '1234' }; return forward(operation); }); const link = middleware.concat(createLink({ uri: 'data' })); execute(link, { query: sampleQuery, variables }).subscribe( makeCallback(done, result => { let body = convertBatchedBody(fetchMock.lastCall()[1].body); expect(body.query).not.toBeDefined(); expect(body.extensions).toEqual({ persistedQuery: { hash: '1234' } }); done(); }), ); }); }); describe('dev warnings', () => { let oldFetch; beforeEach(() => { oldFetch = window.fetch; delete window.fetch; }); afterEach(() => { window.fetch = oldFetch; }); it('warns if fetch is undeclared', done => { try { const link = createLink({ uri: 'data' }); done.fail("warning wasn't called"); } catch (e) { makeCallback(done, () => expect(e.message).toMatch(/fetch is not found globally/), )(); } }); it('warns if fetch is undefined', done => { window.fetch = undefined; try { const link = createLink({ uri: 'data' }); done.fail("warning wasn't called"); } catch (e) { makeCallback(done, () => expect(e.message).toMatch(/fetch is not found globally/), )(); } }); it('does not warn if fetch is undeclared but a fetch is passed', () => { expect(() => { const link = createLink({ uri: 'data', fetch: () => {} }); }).not.toThrow(); }); }); describe('error handling', () => { let responseBody; const text = jest.fn(() => { const responseBodyText = '{}'; responseBody = JSON.parse(responseBodyText); return Promise.resolve(responseBodyText); }); const textWithData = jest.fn(() => { responseBody = { data: { stub: { id: 1 } }, errors: [{ message: 'dangit' }], }; return Promise.resolve(JSON.stringify(responseBody)); }); const textWithErrors = jest.fn(() => { responseBody = { errors: [{ message: 'dangit' }], }; return Promise.resolve(JSON.stringify(responseBody)); }); const fetch = jest.fn((uri, options) => { return Promise.resolve({ text }); }); beforeEach(() => { fetch.mockReset(); }); it('makes it easy to do stuff on a 401', done => { const middleware = new ApolloLink((operation, forward) => { return new Observable(ob => { fetch.mockReturnValueOnce(Promise.resolve({ status: 401, text })); const op = forward(operation); const sub = op.subscribe({ next: ob.next.bind(ob), error: makeCallback(done, e => { expect(e.message).toMatch(/Received status code 401/); expect(e.statusCode).toEqual(401); ob.error(e); }), complete: ob.complete.bind(ob), }); return () => { sub.unsubscribe(); }; }); }); const link = middleware.concat(createLink({ uri: 'data', fetch })); execute(link, { query: sampleQuery }).subscribe( result => { done.fail('next should have been thrown from the network'); }, () => {}, ); }); it('throws an error if response code is > 300', done => { fetch.mockReturnValueOnce(Promise.resolve({ status: 400, text })); const link = createLink({ uri: 'data', fetch }); execute(link, { query: sampleQuery }).subscribe( result => { done.fail('next should have been thrown from the network'); }, makeCallback(done, e => { expect(e.message).toMatch(/Received status code 400/); expect(e.statusCode).toBe(400); expect(e.result).toEqual(responseBody); }), ); }); it('throws an error if response code is > 300 and returns data', done => { fetch.mockReturnValueOnce( Promise.resolve({ status: 400, text: textWithData }), ); const link = createLink({ uri: 'data', fetch }); let called = false; execute(link, { query: sampleQuery }).subscribe( result => { called = true; expect(result).toEqual(responseBody); }, e => { expect(called).toBe(true); expect(e.message).toMatch(/Received status code 400/); expect(e.statusCode).toBe(400); expect(e.result).toEqual(responseBody); done(); }, ); }); it('throws an error if only errors are returned', done => { fetch.mockReturnValueOnce( Promise.resolve({ status: 400, text: textWithErrors }), ); const link = createLink({ uri: 'data', fetch }); let called = false; execute(link, { query: sampleQuery }).subscribe( result => { done.fail('should not have called result because we have no data'); }, e => { expect(e.message).toMatch(/Received status code 400/); expect(e.statusCode).toBe(400); expect(e.result).toEqual(responseBody); done(); }, ); }); it('throws an error if empty response from the server ', done => { fetch.mockReturnValueOnce(Promise.resolve({ text })); text.mockReturnValueOnce(Promise.resolve('{ "body": "boo" }')); const link = createLink({ uri: 'data', fetch }); execute(link, { query: sampleQuery }).subscribe( result => { done.fail('next should have been thrown from the network'); }, makeCallback(done, e => { expect(e.message).toMatch( /Server response was missing for query 'SampleQuery'/, ); }), ); }); it("throws if the body can't be stringified", done => { fetch.mockReturnValueOnce(Promise.resolve({ data: {}, text })); const link = createLink({ uri: 'data', fetch }); let b; const a = { b }; b = { a }; a.b = b; const variables = { a, b, }; execute(link, { query: sampleQuery, variables }).subscribe( result => { done.fail('next should have been thrown from the link'); }, makeCallback(done, e => { expect(e.message).toMatch(/Payload is not serializable/); expect(e.parseError.message).toMatch( /Converting circular structure to JSON/, ); }), ); }); it('supports being cancelled and does not throw', done => { let called; class AbortController { signal: {}; abort = () => { called = true; }; } global.AbortController = AbortController; fetch.mockReturnValueOnce(Promise.resolve({ text })); text.mockReturnValueOnce( Promise.resolve('{ "data": { "hello": "world" } }'), ); const link = createLink({ uri: 'data', fetch }); const sub = execute(link, { query: sampleQuery }).subscribe({ next: result => { done.fail('result should not have been called'); }, error: e => { done.fail(e); }, complete: () => { done.fail('complete should not have been called'); }, }); sub.unsubscribe(); setTimeout( makeCallback(done, () => { delete global.AbortController; expect(called).toBe(true); fetch.mockReset(); text.mockReset(); }), 150, ); }); const body = '{'; const unparsableJson = jest.fn(() => Promise.resolve(body)); it('throws an error if response is unparsable', done => { fetch.mockReturnValueOnce( Promise.resolve({ status: 400, text: unparsableJson }), ); const link = createLink({ uri: 'data', fetch }); execute(link, { query: sampleQuery }).subscribe( result => { done.fail('next should have been thrown from the network'); }, makeCallback(done, e => { expect(e.message).toMatch(/JSON/); expect(e.statusCode).toBe(400); expect(e.response).toBeDefined(); expect(e.bodyText).toBe(body); }), ); }); }); };