diff --git a/jest.config.js b/jest.config.js index f56a6c59..4cd7f907 100644 --- a/jest.config.js +++ b/jest.config.js @@ -28,10 +28,10 @@ module.exports = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 0, - functions: 0, - lines: 0, - statements: 0 + branches: 6, + functions: 12, + lines: 8, + statements: 8 } }, diff --git a/resources/skins.vector.search/fetch.js b/resources/skins.vector.search/fetch.js new file mode 100644 index 00000000..86192957 --- /dev/null +++ b/resources/skins.vector.search/fetch.js @@ -0,0 +1,53 @@ +/** + * @typedef {Object} AbortableFetch + * @property {Promise} fetch + * @property {Function} abort + */ + +/** + * @typedef {Object} NullableAbortController + * @property {AbortSignal | undefined} signal + * @property {Function} abort + */ +const nullAbortController = { + signal: undefined, + abort: () => {} // Do nothing (no-op) +}; + +/** + * A wrapper which combines native fetch() in browsers and the following json() call. + * + * @param {string} resource + * @param {RequestInit} [init] + * @return {AbortableFetch} + */ +function fetchJson( resource, init ) { + // As of 2020, browser support for AbortController is limited: + // https://caniuse.com/abortcontroller + // so replacing it with no-op if it doesn't exist. + /* eslint-disable compat/compat */ + const controller = window.AbortController ? + new AbortController() : + nullAbortController; + /* eslint-enable compat/compat */ + + const getJson = fetch( resource, $.extend( init, { + signal: controller.signal + } ) ).then( ( response ) => { + if ( !response.ok ) { + return Promise.reject( + 'Network request failed with HTTP code ' + response.status + ); + } + return response.json(); + } ); + + return { + fetch: getJson, + abort: () => { + controller.abort(); + } + }; +} + +module.exports = fetchJson; diff --git a/resources/skins.vector.search/restSearchClient.js b/resources/skins.vector.search/restSearchClient.js index 0b88e965..0af251ab 100644 --- a/resources/skins.vector.search/restSearchClient.js +++ b/resources/skins.vector.search/restSearchClient.js @@ -1,56 +1,6 @@ /** @module restSearchClient */ -/** - * @typedef {Object} AbortableFetch - * @property {Promise} fetch - * @property {Function} abort - */ - -/** - * @typedef {Object} NullableAbortController - * @property {AbortSignal | undefined} signal - * @property {Function} abort - */ -const nullAbortController = { - signal: undefined, - abort: () => {} // Do nothing (no-op) -}; - -/** - * A wrapper which combines native fetch() in browsers and the following json() call. - * - * @param {string} resource - * @param {RequestInit} [init] - * @return {AbortableFetch} - */ -function fetchJson( resource, init ) { - // As of 2020, browser support for AbortController is limited: - // https://caniuse.com/abortcontroller - // so replacing it with no-op if it doesn't exist. - /* eslint-disable compat/compat */ - const controller = window.AbortController ? - new AbortController() : - nullAbortController; - /* eslint-enable compat/compat */ - - const getJson = fetch( resource, $.extend( init, { - signal: controller.signal - } ) ).then( ( response ) => { - if ( !response.ok ) { - return Promise.reject( - 'Network request failed with HTTP code ' + response.status - ); - } - return response.json(); - } ); - - return { - fetch: getJson, - abort: () => { - controller.abort(); - } - }; -} +const fetchJson = require( './fetch.js' ); /** * @typedef {Object} RestResponse diff --git a/tests/jest/fetch.test.js b/tests/jest/fetch.test.js new file mode 100644 index 00000000..3e22f6bd --- /dev/null +++ b/tests/jest/fetch.test.js @@ -0,0 +1,99 @@ +/* global fetchMock */ +const fetchJson = require( '../../resources/skins.vector.search/fetch.js' ); +const jestFetchMock = require( 'jest-fetch-mock' ); + +const mockedRequests = !process.env.TEST_LIVE_REQUESTS; +const url = '//en.wikipedia.org/w/rest.php/v1/search/title?q=jfgkdajgioj&limit=10'; + +describe( 'abort() using AbortController', () => { + test( 'Aborting an unfinished request throws an AbortError', async () => { + expect.assertions( 1 ); + + const { abort, fetch } = fetchJson( url ); + + abort(); + + return fetch.catch( ( e ) => { + expect( e.name ).toStrictEqual( 'AbortError' ); + } ); + } ); +} ); + +describe( 'fetch() using window.fetch', () => { + beforeAll( () => { + jestFetchMock.enableFetchMocks(); + } ); + + afterAll( () => { + jestFetchMock.disableFetchMocks(); + } ); + + beforeEach( () => { + fetchMock.resetMocks(); + if ( !mockedRequests ) { + fetchMock.disableMocks(); + } + fetchMock.mockIf( /^\/\/en.wikipedia.org\//, async ( req ) => { + if ( req.url === url ) { + return { + body: JSON.stringify( { pages: [] } ), + headers: { + 'Content-Type': 'application/json' + } + }; + } else { + return { + status: 404, + body: 'Page not found' + }; + } + } ); + } ); + + test( '200 without init param passed', async () => { + const { fetch } = fetchJson( url ); + const json = await fetch; + + // eslint-disable-next-line compat/compat + const controller = new AbortController(); + expect( json ).toStrictEqual( { pages: [] } ); + + if ( mockedRequests ) { + expect( fetchMock ).toHaveBeenCalledTimes( 1 ); + expect( fetchMock ).toHaveBeenCalledWith( url, { signal: controller.signal } ); + } + } ); + + test( '200 with init param passed', async () => { + const { fetch } = fetchJson( url, { mode: 'cors' } ); + const json = await fetch; + + expect( json ).toStrictEqual( { pages: [] } ); + + if ( mockedRequests ) { + expect( fetchMock ).toHaveBeenCalledTimes( 1 ); + expect( fetchMock ).toHaveBeenCalledWith( + url, + expect.objectContaining( { mode: 'cors' } ) + ); + } + } ); + + test( '404 response', async () => { + expect.assertions( 1 ); + const { fetch } = fetchJson( '//en.wikipedia.org/doesNotExist' ); + + await expect( fetch ) + .rejects.toStrictEqual( 'Network request failed with HTTP code 404' ); + + if ( mockedRequests ) { + const controller = new AbortController(); + expect.assertions( 3 ); + expect( fetchMock ).toHaveBeenCalledTimes( 1 ); + expect( fetchMock ).toHaveBeenCalledWith( + '//en.wikipedia.org/doesNotExist', { signal: controller.signal } + ); + } + } ); + +} );