diff --git a/environment/config.js b/environment/config.js index b0c8b3fd..b59b6bec 100644 --- a/environment/config.js +++ b/environment/config.js @@ -37,7 +37,8 @@ define(['/node_modules/@oat-sa/tao-core-libs/dist/pathdefinition.js'], function( 'jquery.mockjax': '/node_modules/jquery-mockjax/dist/jquery.mockjax', 'webcrypto-shim': '/node_modules/webcrypto-shim/webcrypto-shim', 'idb-wrapper': '/node_modules/idb-wrapper/idbstore', - 'fetch-mock': '/node_modules/fetch-mock/es5/client-bundle' + 'fetch-mock': '/node_modules/fetch-mock/es5/client-bundle', + 'xhr-mock': '/node_modules/xhr-mock/dist/xhr-mock' }, libPathDefinition ), diff --git a/package-lock.json b/package-lock.json index 3a13f1d7..d5ee5048 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@oat-sa/tao-core-sdk", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -3160,6 +3160,12 @@ "esutils": "^2.0.2" } }, + "dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==", + "dev": true + }, "dompurify": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.0.tgz", @@ -4152,6 +4158,16 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true }, + "global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "dev": true, + "requires": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, "globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -5170,6 +5186,15 @@ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true }, + "min-document": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", + "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", + "dev": true, + "requires": { + "dom-walk": "^0.1.0" + } + }, "min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -5898,6 +5923,12 @@ "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true + }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -6979,6 +7010,13 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true }, + "svelte": { + "version": "3.59.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.59.2.tgz", + "integrity": "sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA==", + "dev": true, + "peer": true + }, "temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -7176,6 +7214,13 @@ "is-typedarray": "^1.0.0" } }, + "typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "peer": true + }, "uglify-js": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.3.6.tgz", @@ -7274,6 +7319,33 @@ "punycode": "^2.1.0" } }, + "url": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.3.tgz", + "integrity": "sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw==", + "dev": true, + "requires": { + "punycode": "^1.4.1", + "qs": "^6.11.2" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true + }, + "qs": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", + "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", + "dev": true, + "requires": { + "side-channel": "^1.0.4" + } + } + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7428,6 +7500,16 @@ "async-limiter": "~1.0.0" } }, + "xhr-mock": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/xhr-mock/-/xhr-mock-2.5.1.tgz", + "integrity": "sha512-UKOjItqjFgPUwQGPmRAzNBn8eTfIhcGjBVGvKYAWxUQPQsXNGD6KEckGTiHwyaAUp9C9igQlnN1Mp79KWCg7CQ==", + "dev": true, + "requires": { + "global": "^4.3.0", + "url": "^0.11.0" + } + }, "y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/package.json b/package.json index e9053271..9734d538 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@oat-sa/tao-core-sdk", - "version": "3.0.0", + "version": "3.1.0", "displayName": "TAO Core SDK", "description": "Core libraries of TAO", "homepage": "https://github.com/oat-sa/tao-core-sdk-fe#readme", @@ -82,7 +82,8 @@ "rollup-plugin-json": "^4.0.0", "rollup-plugin-node-resolve": "^5.2.0", "select2": "3.5.1", - "tooltip.js": "1.3.3" + "tooltip.js": "1.3.3", + "xhr-mock": "^2.5.1" }, "dependencies": { "fastestsmallesttextencoderdecoder": "1.0.14", diff --git a/src/core/fetchRequest.js b/src/core/fetchRequest.js index 8949ea13..805372a1 100644 --- a/src/core/fetchRequest.js +++ b/src/core/fetchRequest.js @@ -13,12 +13,9 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * - * Copyright (c) 2020-21 (original work) Open Assessment Technologies SA ; + * Copyright (c) 2020-2024 (original work) Open Assessment Technologies SA ; */ - -import ApiError from 'core/error/ApiError'; -import NetworkError from 'core/error/NetworkError'; -import TimeoutError from 'core/error/TimeoutError'; +import httpRequestFlowFactory from 'core/request/flowFactory'; /** * !!! IE11 requires polyfill https://www.npmjs.com/package/whatwg-fetch @@ -41,101 +38,7 @@ const requestFactory = (url, options) => { options ); - let flow = Promise.resolve(); - - if (options.jwtTokenHandler) { - flow = flow - .then(options.jwtTokenHandler.getToken) - .then(token => ({ - Authorization: `Bearer ${token}` - })) - .then(headers => { - options.headers = Object.assign({}, options.headers, headers); - }); - } - - flow = flow.then(() => - Promise.race([ - fetch(url, options), - new Promise((resolve, reject) => { - setTimeout(() => { - reject(new TimeoutError('Timeout', options.timeout)); - }, options.timeout); - }) - ]) - ); - - if (options.jwtTokenHandler) { - flow = flow.then(response => { - if (response.status === 401) { - return options.jwtTokenHandler - .refreshToken() - .then(options.jwtTokenHandler.getToken) - .then(token => { - options.headers.Authorization = `Bearer ${token}`; - return fetch(url, options); - }); - } - - return Promise.resolve(response); - }); - } - - /** - * Stores the original response - */ - let originalResponse; - /** - * Stores the response code - */ - let responseCode; - - flow = flow - .then(response => { - originalResponse = response.clone(); - responseCode = response.status; - - if (options.returnOriginalResponse) { - return originalResponse; - } - return response.json().catch(() => ({})); - }) - .then(response => { - if (responseCode === 204) { - return null; - } - - // successful request - if ((responseCode >= 200 && responseCode < 300) || (response && response.success === true)) { - return response; - } - - // create error - let err; - if (response.errorCode) { - err = new ApiError( - `${response.errorCode} : ${response.errorMsg || response.errorMessage || response.error}`, - response.errorCode, - originalResponse - ); - } else { - err = new NetworkError( - `${responseCode} : Request error`, - responseCode || 0, - originalResponse - ); - } - throw err; - }) - .catch(err => { - if (!err.type) { - //offline, CORS, etc. - return Promise.reject(new NetworkError(err.message, 0)); - } - return Promise.reject(err); - }); - - return flow; + return httpRequestFlowFactory(fetch, url, options); }; export default requestFactory; diff --git a/src/core/request/flowFactory.js b/src/core/request/flowFactory.js new file mode 100644 index 00000000..9137d8e3 --- /dev/null +++ b/src/core/request/flowFactory.js @@ -0,0 +1,127 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2024 (original work) Open Assessment Technologies SA ; + */ +import ApiError from 'core/error/ApiError'; +import NetworkError from 'core/error/NetworkError'; +import TimeoutError from 'core/error/TimeoutError'; + +/** + * @param {(url: string, options: object) => Promise} httpRequest + * @param {string} url + * @param {object} options - fetch request options that implements RequestInit (https://fetch.spec.whatwg.org/#requestinit) + * @param {integer} [options.timeout] - (default: 5000) if timeout reached, the request will be rejected + * @param {object} [options.jwtTokenHandler] - core/jwt/jwtTokenHandler instance that should be used during request + * @param {boolean} [options.returnOriginalResponse] - the full original response should be returned instead of parsing internally (useful for HEAD requests or other empty-response-body requests) + * @returns {Promise} + */ +export default function httpRequestFlowFactory(httpRequest, url, options) { + let flow = Promise.resolve(); + + if (options.jwtTokenHandler) { + flow = flow + .then(options.jwtTokenHandler.getToken) + .then(token => ({ + Authorization: `Bearer ${token}` + })) + .then(headers => { + options.headers = Object.assign({}, options.headers, headers); + }); + } + + flow = flow.then(() => + Promise.race([ + httpRequest(url, options), + new Promise((resolve, reject) => { + setTimeout(() => { + reject(new TimeoutError('Timeout', options.timeout)); + }, options.timeout); + }) + ]) + ); + + if (options.jwtTokenHandler) { + flow = flow.then(response => { + if (response.status === 401) { + return options.jwtTokenHandler + .refreshToken() + .then(options.jwtTokenHandler.getToken) + .then(token => { + options.headers.Authorization = `Bearer ${token}`; + return httpRequest(url, options); + }); + } + + return Promise.resolve(response); + }); + } + + /** + * Stores the original response + */ + let originalResponse; + /** + * Stores the response code + */ + let responseCode; + + flow = flow + .then(response => { + originalResponse = response.clone(); + responseCode = response.status; + + if (options.returnOriginalResponse) { + return originalResponse; + } + return response.json().catch(() => ({})); + }) + .then(response => { + if (responseCode === 204) { + return null; + } + + // successful request + if ((responseCode >= 200 && responseCode < 300) || (response && response.success === true)) { + return response; + } + + // create error + let err; + if (response.errorCode) { + err = new ApiError( + `${response.errorCode} : ${response.errorMsg || response.errorMessage || response.error}`, + response.errorCode, + originalResponse + ); + } else { + err = new NetworkError( + `${responseCode} : Request error`, + responseCode || 0, + originalResponse + ); + } + throw err; + }) + .catch(err => { + if (!err.type) { + //offline, CORS, etc. + return Promise.reject(new NetworkError(err.message, 0)); + } + return Promise.reject(err); + }); + + return flow; +} \ No newline at end of file diff --git a/src/core/xmlHttpRequest.js b/src/core/xmlHttpRequest.js new file mode 100644 index 00000000..f811b4a0 --- /dev/null +++ b/src/core/xmlHttpRequest.js @@ -0,0 +1,113 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2020-2024 (original work) Open Assessment Technologies SA ; + */ +import httpRequestFlowFactory from 'core/request/flowFactory'; + +const XHR_READY_STATE_OPENED = 1; +const XHR_READY_STATE_HEADERS_RECEIVED = 2; +const XHR_READY_STATE_DONE = 4; + +/** + * XHR implementation of Fetch API + * @param {string} url + * @param {Object} options - fetch request options that implements RequestInit (https://fetch.spec.whatwg.org/#requestinit) + * @param {Function} [options.onUploadProgress] + * @param {Function} [options.onDownloadProgress] + * @returns {Promise} + */ +function xhr(url, options) { + return new Promise(resolve => { + const request = new XMLHttpRequest(); + let responseBody = null; + const responseHeaders = new Headers(); + + if (typeof options.onUploadProgress === 'function') { + request.upload.addEventListener('progress', options.onUploadProgress); + } + if (typeof options.onDownloadProgress === 'function') { + request.addEventListener('progress', options.onDownloadProgress); + } + request.addEventListener('readystatechange', () => { + switch (request.readyState) { + case XHR_READY_STATE_OPENED: + // eslint-disable-next-line no-case-declarations + for (const header in options.headers) { + request.setRequestHeader(header, options.headers[header]); + } + break; + case XHR_READY_STATE_HEADERS_RECEIVED: + request.getAllResponseHeaders().trim().split(/[\r\n]+/).forEach((line) => { + const parts = line.split(': '); + const header = parts.shift(); + const value = parts.join(': '); + if (header) { + responseHeaders.append(header, value); + } + }); + break; + case XHR_READY_STATE_DONE: + responseBody = request.response; + // Response with null body status cannot have body + if ([101, 204, 205, 304].includes(request.status)) { + responseBody = null; + } + if (request.responseType === 'json') { + responseBody = JSON.stringify(request.response); + } + // eslint-disable-next-line no-case-declarations + const response = new Response(responseBody, { + status: request.status, + statusText: request.statusText, + headers: responseHeaders + }); + resolve(response); + break; + } + }); + + request.open(options.method || 'GET', url, true); + request.send(options.body); + }); +} + +/** + * Creates an HTTP request to the url based on the provided parameters + * Request is based on fetch API with XMLHttpRequest under the hood, + * so behaviour and parameters are the same, except: + * - every response where response code is not 2xx will be rejected and + * - every response will be parsed as json. + * @param {string} url - url that should be requested + * @param {object} options - fetch request options that implements RequestInit (https://fetch.spec.whatwg.org/#requestinit) + * @param {integer} [options.timeout] - (default: 5000) if timeout reached, the request will be rejected + * @param {object} [options.jwtTokenHandler] - core/jwt/jwtTokenHandler instance that should be used during request + * @param {boolean} [options.returnOriginalResponse] - the full original response should be returned instead of parsing internally (useful for HEAD requests or other empty-response-body requests) + * @param {Function} [options.onUploadProgress] + * @param {Function} [options.onDownloadProgress] + * @returns {Promise} resolves with http Response object + */ +const requestFactory = (url, options) => { + options = Object.assign( + { + timeout: 5000 + }, + options + ); + + return httpRequestFlowFactory(xhr, url, options); +}; + +export default requestFactory; diff --git a/test/core/xmlHttpRequest/test.html b/test/core/xmlHttpRequest/test.html new file mode 100644 index 00000000..7e285082 --- /dev/null +++ b/test/core/xmlHttpRequest/test.html @@ -0,0 +1,21 @@ + + + + + Core - xmlHttpRequest + + + + +
+
+ + diff --git a/test/core/xmlHttpRequest/test.js b/test/core/xmlHttpRequest/test.js new file mode 100644 index 00000000..1f364608 --- /dev/null +++ b/test/core/xmlHttpRequest/test.js @@ -0,0 +1,386 @@ +/** + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; under version 2 + * of the License (non-upgradable). + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * Copyright (c) 2020-2024 (original work) Open Assessment Technologies SA ; + */ + +define([ + 'core/xmlHttpRequest', + 'core/jwt/jwtTokenHandler', + 'xhr-mock', + 'fetch-mock', + 'core/error/ApiError', + 'core/error/NetworkError', + 'core/error/TimeoutError' +], (request, jwtTokenHandlerFactory, xhrMock, fetchMock, ApiError, NetworkError, TimeoutError) => { + 'use strict'; + + fetchMock.config.overwriteRoutes = true; + + QUnit.module('Request', { + beforeEach: function () { + xhrMock.setup(); + this.jwtTokenHandler = jwtTokenHandlerFactory({ refreshTokenUrl: '/refresh-token' }); + }, + afterEach: function (done) { + fetchMock.restore(); + xhrMock.teardown(); + this.jwtTokenHandler.clearStore().then(done); + } + }); + + QUnit.test('request returns with correct response', assert => { + assert.expect(1); + const done = assert.async(); + + const mockResponse = { data: { ok: true } }; + xhrMock.get('/foo', (req, res) => res + .status(200) + .headers({ 'Content-Type': 'application/json' }) + .body(JSON.stringify(mockResponse))); + + request('/foo').then(response => { + assert.deepEqual(response, mockResponse); + done(); + }); + }); + + QUnit.test('request returns with correct response if success true', assert => { + assert.expect(1); + const done = assert.async(); + + xhrMock.get('/foo', (req, res) => res + .status(201) + .headers({ 'Content-Type': 'application/json' }) + .body(JSON.stringify({ success: true, data: { ping: 123 } }))); + + request('/foo').then(response => { + assert.deepEqual(response.data, { ping: 123 }); + done(); + }); + }); + + QUnit.test('request returns with correct response if 204 empty content', assert => { + assert.expect(1); + const done = assert.async(); + + xhrMock.get('/foo', (req, res) => res + .status(204)); + + request('/foo').then(response => { + assert.equal(response, null); + done(); + }); + }); + + QUnit.test('request returns with correct response if status code is 2XX with response payload', assert => { + assert.expect(1); + const done = assert.async(); + + xhrMock.get('/foo', (req, res) => res + .status(206) + .headers({ 'Content-Type': 'application/json' }) + .body(JSON.stringify({ success: true, data: { ping: 123 } }))); + + request('/foo').then(response => { + assert.deepEqual(response.data, { ping: 123 }); + done(); + }); + }); + + QUnit.test('request returns with correct response if status code is 2XX with empty response payload', assert => { + assert.expect(1); + const done = assert.async(); + + xhrMock.get('/foo', (req, res) => res + .status(202) + .headers({ 'Content-Type': 'application/json' }) + .body('{}')); + + request('/foo').then(response => { + assert.deepEqual(response, {}); + done(); + }); + }); + + QUnit.test('request returns with full original response if options.returnOriginalResponse passed', assert => { + assert.expect(6); + const done = assert.async(); + + const payload = { success: true, data: { ping: 123 } }; + + const headers = { + 'content-type': 'text/plain;charset=UTF-8', + 'content-length': '36' + }; + + xhrMock.get('/foo', (req, res) => res + .status(200) + .headers(headers) + .body(JSON.stringify(payload))); + + const options = { returnOriginalResponse: true }; + + request('/foo', options).then(response => { + assert.ok(response.ok); + assert.equal(response.status, 200); + assert.equal(response.bodyUsed, false); + // response.headers is Iterator + for (const pair of response.headers) { + assert.equal(headers[pair[0]], pair[1]); + } + response.json().then(responseData => { + assert.deepEqual(responseData, payload); + done(); + }); + }); + }); + + QUnit.test('request returns with full original response to HEAD request if options.returnOriginalResponse passed', assert => { + assert.expect(5); + const done = assert.async(); + + const headers = { + 'content-length': '0', + 'content-type': 'text/plain;charset=UTF-8' + }; + + xhrMock.use('HEAD', '/foo', (req, res) => res + .status(200) + .headers(headers)); + + const options = { method: 'HEAD', returnOriginalResponse: true }; + + request('/foo', options).then(response => { + assert.ok(response.ok); + assert.equal(response.status, 200); + assert.equal(response.bodyUsed, false); + // response.headers is Iterator + for (const pair of response.headers) { + assert.equal(headers[pair[0]], pair[1]); + } + done(); + }); + }); + + QUnit.test('request returns with a correct error response', assert => { + assert.expect(2); + const done = assert.async(); + + xhrMock.get('/foo', (req, res) => res + .status(404)); + + request('/foo').catch(error => { + assert.equal(error.message, '404 : Request error'); + assert.equal(error.response.status, 404); + done(); + }); + }); + + QUnit.test('error response can be read', assert => { + assert.expect(1); + const done = assert.async(); + xhrMock.get('/foo', (req, res) => res + .status(400) + .body(JSON.stringify({ success: false, errorCode: 'ABC123', errorMessage: 'Cannot trigger ABC' }))); + + request('/foo').catch(error => { + error.response.json().then(err => { + assert.equal(err.errorMessage, 'Cannot trigger ABC'); + done(); + }); + }); + }); + + QUnit.test('request returns with a correct error response if success false', assert => { + assert.expect(4); + const done = assert.async(); + + xhrMock.get('/foo', (req, res) => res + .status(406) + .body(JSON.stringify({ success: false, errorCode: 'ABC123', errorMessage: 'Cannot trigger ABC' }))); + + request('/foo').catch(error => { + assert.ok(error instanceof ApiError); + assert.equal(error.message, 'ABC123 : Cannot trigger ABC'); + assert.equal(error.response.status, 406); + assert.equal(error.errorCode, 'ABC123'); + done(); + }); + }); + + QUnit.test('request sends an auth header', function (assert) { + assert.expect(2); + const done = assert.async(); + + const mockResponse = { foo: 'bar' }; + const accessToken = 'someToken'; + const url = '/bar'; + + xhrMock.get(url, (req, res) => { + assert.equal(req.header('Authorization'), `Bearer ${accessToken}`); + return res + .status(200) + .body(JSON.stringify(mockResponse)); + }); + + this.jwtTokenHandler + .storeAccessToken(accessToken) + .then(() => request(url, { jwtTokenHandler: this.jwtTokenHandler })) + .then(response => { + assert.deepEqual(response, mockResponse); + done(); + }); + }); + + QUnit.test('request refreshes the token when it does not exist', function (assert) { + assert.expect(5); + const done = assert.async(); + + const mockResponse = { response: 2 }; + const refreshToken = 'refreshToken'; + const newAccessToken = 'newAccessToken'; + const url = '/api/request'; + + fetchMock.mock('/refresh-token', (uri, opts) => { + const data = JSON.parse(opts.body); + assert.equal(opts.headers['Content-Type'], 'application/json'); + assert.equal(opts.method, 'POST'); + assert.deepEqual(data, { refreshToken }); + return JSON.stringify({ accessToken: newAccessToken }); + }); + + xhrMock.get(url, (req, res) => { + assert.equal(req.header('Authorization'), `Bearer ${newAccessToken}`); + return res + .status(200) + .body(JSON.stringify(mockResponse)); + }); + + this.jwtTokenHandler + .storeRefreshToken(refreshToken) + .then(() => request(url, { jwtTokenHandler: this.jwtTokenHandler })) + .then(response => { + assert.deepEqual(response, mockResponse); + done(); + }); + }); + + QUnit.test('request refreshes the token when it is not valid', function (assert) { + assert.expect(6); + const done = assert.async(); + + const mockResponse = { response: 2 }; + const refreshToken = 'refreshToken'; + const accessToken = 'invalidAccessToken'; + const newAccessToken = 'newAccessToken'; + const url = '/api/request'; + + let requestCount = 0; + xhrMock.get(url, (req, res) => { + requestCount++; + if (requestCount === 1) { + assert.equal(req.header('Authorization'), `Bearer ${accessToken}`); + return res.status(401); + } else { + assert.equal(req.header('Authorization'), `Bearer ${newAccessToken}`); + return res + .status(200) + .body(JSON.stringify(mockResponse)); + } + }); + + fetchMock.mock('/refresh-token', function (uri, opts) { + const data = JSON.parse(opts.body); + assert.equal(opts.method, 'POST'); + assert.equal(opts.headers['Content-Type'], 'application/json'); + assert.deepEqual(data, { refreshToken }); + return JSON.stringify({ accessToken: newAccessToken }); + }); + + this.jwtTokenHandler + .storeRefreshToken(refreshToken) + .then(() => this.jwtTokenHandler.storeAccessToken(accessToken)) + .then(() => request(url, { jwtTokenHandler: this.jwtTokenHandler })) + .then(response => { + assert.deepEqual(response, mockResponse); + done(); + }); + }); + + QUnit.test('request returns with the correct error response if there are no tokens', function (assert) { + assert.expect(1); + assert.rejects( + request('/foo', { jwtTokenHandler: this.jwtTokenHandler }), + /Token not available and cannot be refreshed/ + ); + }); + + QUnit.test( + 'request returns with the correct error response if token is not valid and cannot be refreshed', + function (assert) { + assert.expect(1); + const done = assert.async(); + + const accessToken = 'invalidAccessToken'; + + xhrMock.get('/', (req, res) => res.status(401)); + + this.jwtTokenHandler.storeAccessToken(accessToken).then(() => { + assert.rejects( + request('/', { jwtTokenHandler: this.jwtTokenHandler }), + /Refresh token is not available/ + ); + done(); + }); + } + ); + + QUnit.test('request fails if token is refreshed and is still not valid', function (assert) { + assert.expect(4); + const done = assert.async(); + + const refreshToken = 'refreshToken'; + const accessToken = 'invalidAccessToken'; + const newAccessToken = 'stillInvalidAccessToken'; + const url = '/api/request'; + + xhrMock.get(url, (req, res) => res.status(401)); + + fetchMock.mock('/refresh-token', JSON.stringify({ accessToken: newAccessToken })); + + this.jwtTokenHandler + .storeRefreshToken(refreshToken) + .then(() => this.jwtTokenHandler.storeAccessToken(accessToken)) + .then(() => request(url, { jwtTokenHandler: this.jwtTokenHandler })) + .catch(error => { + assert.ok(error instanceof NetworkError); + assert.equal(error.message, '401 : Request error'); + assert.equal(error.response.status, 401); + assert.equal(error.errorCode, 401); + done(); + }); + }); + + QUnit.test('request rejects if timeout reached', function (assert) { + assert.expect(1); + xhrMock.get('/', async (req, res) => { + await new Promise(resolve => setTimeout(resolve, 2000)); + return res.status(200); + }); + + assert.rejects(request('/', { timeout: 1000 }), TimeoutError); + }); +});