diff --git a/src/components/ui/bsod/BsodMiddleware.js b/src/components/ui/bsod/BsodMiddleware.js index 1289bcc..8a8e922 100644 --- a/src/components/ui/bsod/BsodMiddleware.js +++ b/src/components/ui/bsod/BsodMiddleware.js @@ -8,14 +8,16 @@ const ABORT_ERR = 20; export default function BsodMiddleware(dispatchBsod: Function, logger: Logger) { return { - catch | InternalServerError>(resp?: T): Promise { - if (resp && ( - (resp instanceof InternalServerError - && resp.error.code !== ABORT_ERR - ) || (resp.originalResponse - && /5\d\d/.test((resp.originalResponse.status: string)) - ) - )) { + catch | InternalServerError | Error>(resp?: T): Promise { + const originalResponse: Object = (resp && resp.originalResponse) || {}; + + if ( + resp + && ((resp instanceof InternalServerError + && resp.error.code !== ABORT_ERR) + || (originalResponse + && /5\d\d/.test((originalResponse.status: string)))) + ) { dispatchBsod(); if (!resp.message || !/NetworkError/.test(resp.message)) { @@ -25,7 +27,7 @@ export default function BsodMiddleware(dispatchBsod: Function, logger: Logger) { message = `BSoD: ${resp.message}`; } - logger.warn(message, {resp}); + logger.warn(message, { resp }); } } diff --git a/src/services/request/InternalServerError.js b/src/services/request/InternalServerError.js index ebde179..8a0c8c2 100644 --- a/src/services/request/InternalServerError.js +++ b/src/services/request/InternalServerError.js @@ -1,11 +1,14 @@ // @flow -function InternalServerError(error: Error | string | Object, resp?: Response | Object) { +function InternalServerError( + error: Error | string | Object, + resp?: Response | Object +) { error = error || {}; this.name = 'InternalServerError'; this.message = 'InternalServerError'; this.error = error; - this.stack = (new Error()).stack; + this.stack = new Error().stack; if (resp) { this.originalResponse = resp; diff --git a/src/services/request/RequestAbortedError.js b/src/services/request/RequestAbortedError.js new file mode 100644 index 0000000..33b9be7 --- /dev/null +++ b/src/services/request/RequestAbortedError.js @@ -0,0 +1,23 @@ +// @flow +function RequestAbortedError(error: Error | Response) { + this.name = 'RequestAbortedError'; + this.message = 'RequestAbortedError'; + this.error = error; + this.stack = new Error().stack; + + if (error.message) { + this.message = error.message; + } + + if (typeof error === 'string') { + this.message = error; + } else { + this.error = error; + Object.assign(this, error); + } +} + +RequestAbortedError.prototype = Object.create(Error.prototype); +RequestAbortedError.prototype.constructor = RequestAbortedError; + +export default RequestAbortedError; diff --git a/src/services/request/index.js b/src/services/request/index.js index e92740a..6f461ac 100644 --- a/src/services/request/index.js +++ b/src/services/request/index.js @@ -2,6 +2,7 @@ export { default } from './request'; export type { Resp } from './request'; export { default as InternalServerError } from './InternalServerError'; +export { default as RequestAbortedError } from './RequestAbortedError'; /** * Usage: Query<'requeired'|'keys'|'names'> diff --git a/src/services/request/request.js b/src/services/request/request.js index 76c96a6..d67ffa6 100644 --- a/src/services/request/request.js +++ b/src/services/request/request.js @@ -1,6 +1,7 @@ // @flow import PromiseMiddlewareLayer from './PromiseMiddlewareLayer'; import InternalServerError from './InternalServerError'; +import RequestAbortedError from './RequestAbortedError'; const middlewareLayer = new PromiseMiddlewareLayer(); @@ -17,10 +18,10 @@ type Middleware = { const buildOptions = (method: string, data?: Object, options: Object) => ({ method, headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: buildQuery(data), - ...options, + ...options }); export default { @@ -31,7 +32,11 @@ export default { * * @return {Promise} */ - post(url: string, data?: Object, options: Object = {}): Promise> { + post( + url: string, + data?: Object, + options: Object = {} + ): Promise> { return doFetch(url, buildOptions('POST', data, options)); }, @@ -55,7 +60,11 @@ export default { * * @return {Promise} */ - delete(url: string, data?: Object, options: Object = {}): Promise> { + delete( + url: string, + data?: Object, + options: Object = {} + ): Promise> { return doFetch(url, buildOptions('DELETE', data, options)); }, @@ -93,36 +102,40 @@ export default { */ addMiddleware(middleware: Middleware) { middlewareLayer.add(middleware); - }, + } }; -const checkStatus = (resp: Response) => resp.status >= 200 && resp.status < 300 - ? Promise.resolve(resp) - : Promise.reject(resp); -const toJSON = (resp = {}) => { - if (!resp.json) { - // e.g. 'TypeError: Failed to fetch' due to CORS - throw new InternalServerError(resp); +const checkStatus = (resp: Response) => + resp.status >= 200 && resp.status < 300 + ? Promise.resolve(resp) + : Promise.reject(resp); +const toJSON = (resp: Response) => { + if (!resp.json || resp.status === 0) { + // e.g. 'TypeError: Failed to fetch' due to CORS or request was aborted + throw new RequestAbortedError(resp); } - return resp.json().then((json) => { - json.originalResponse = resp; + return resp.json().then( + (json) => { + json.originalResponse = resp; - return json; - }, (error) => Promise.reject( - new InternalServerError(error, resp) - )); + return json; + }, + (error) => Promise.reject(new InternalServerError(error, resp)) + ); }; -const rejectWithJSON = (resp) => toJSON(resp).then((resp) => { - if (resp.originalResponse.status >= 500) { - throw new InternalServerError(resp, resp.originalResponse); - } +const rejectWithJSON = (resp: Response) => + toJSON(resp).then((resp) => { + if (resp.originalResponse.status >= 500) { + throw new InternalServerError(resp, resp.originalResponse); + } - throw resp; -}); -const handleResponseSuccess = (resp) => resp.success || typeof resp.success === 'undefined' - ? Promise.resolve(resp) - : Promise.reject(resp); + throw resp; + }); +const handleResponseSuccess = (resp) => + resp.success || typeof resp.success === 'undefined' + ? Promise.resolve(resp) + : Promise.reject(resp); async function doFetch(url, options = {}) { // NOTE: we are wrapping fetch, because it is returning @@ -131,14 +144,21 @@ async function doFetch(url, options = {}) { options.headers = options.headers || {}; options.headers.Accept = 'application/json'; - return middlewareLayer.run('before', {url, options}) - .then(({url, options}) => + return middlewareLayer + .run('before', { url, options }) + .then(({ url, options }) => fetch(url, options) .then(checkStatus) .then(toJSON, rejectWithJSON) .then(handleResponseSuccess) - .then((resp) => middlewareLayer.run('then', resp, {url, options})) - .catch((resp) => middlewareLayer.run('catch', resp, {url, options}, () => doFetch(url, options))) + .then((resp) => + middlewareLayer.run('then', resp, { url, options }) + ) + .catch((resp) => + middlewareLayer.run('catch', resp, { url, options }, () => + doFetch(url, options) + ) + ) ); } @@ -174,11 +194,10 @@ function convertQueryValue(value) { */ function buildQuery(data: Object = {}): string { return Object.keys(data) - .map( - (keyName) => - [keyName, convertQueryValue(data[keyName])] - .map(encodeURIComponent) - .join('=') + .map((keyName) => + [keyName, convertQueryValue(data[keyName])] + .map(encodeURIComponent) + .join('=') ) .join('&'); } diff --git a/src/services/request/request.test.js b/src/services/request/request.test.js index 918e58d..36838f9 100644 --- a/src/services/request/request.test.js +++ b/src/services/request/request.test.js @@ -2,7 +2,7 @@ import expect from 'unexpected'; import sinon from 'sinon'; import request from 'services/request'; -import { InternalServerError } from 'services/request'; +import { InternalServerError, RequestAbortedError } from 'services/request'; describe('services/request', () => { beforeEach(() => { @@ -14,42 +14,50 @@ describe('services/request', () => { }); describe('InternalServerError', () => { - it('should wrap fetch error', () => { - const resp = new TypeError('Fetch error'); - - fetch.returns(Promise.reject(resp)); - - return expect(request.get('/foo'), 'to be rejected') - .then((error) => { - expect(error, 'to be an', InternalServerError); - expect(error.originalResponse, 'to be undefined'); - expect(error.message, 'to equal', resp.message); - }); - }); - it('should wrap json errors', () => { - const resp = new Response('bad resp format', {status: 200}); + const resp = new Response('bad resp format', { status: 200 }); fetch.returns(Promise.resolve(resp)); - return expect(request.get('/foo'), 'to be rejected') - .then((error) => { - expect(error, 'to be an', InternalServerError); - expect(error.originalResponse, 'to be', resp); - expect(error.message, 'to contain', 'Unexpected token'); - }); + return expect(request.get('/foo'), 'to be rejected').then((error) => { + expect(error, 'to be an', InternalServerError); + expect(error.originalResponse, 'to be', resp); + expect(error.message, 'to contain', 'Unexpected token'); + }); }); it('should wrap 5xx errors', () => { - const resp = new Response('{}', {status: 500}); + const resp = new Response('{}', { status: 500 }); fetch.returns(Promise.resolve(resp)); - return expect(request.get('/foo'), 'to be rejected') - .then((error) => { - expect(error, 'to be an', InternalServerError); - expect(error.originalResponse, 'to be', resp); - }); + return expect(request.get('/foo'), 'to be rejected').then((error) => { + expect(error, 'to be an', InternalServerError); + expect(error.originalResponse, 'to be', resp); + }); + }); + + it('should wrap aborted errors', () => { + const resp = new Response('{}', { status: 0 }); + + fetch.returns(Promise.resolve(resp)); + + return expect(request.get('/foo'), 'to be rejected').then((error) => { + expect(error, 'to be an', RequestAbortedError); + expect(error.error, 'to be', resp); + }); + }); + + it('should wrap "Failed to fetch" errors', () => { + const resp = new TypeError('Failed to fetch'); + + fetch.returns(Promise.resolve(resp)); + + return expect(request.get('/foo'), 'to be rejected').then((error) => { + expect(error, 'to be an', RequestAbortedError); + expect(error.message, 'to be', resp.message); + expect(error.error, 'to be', resp); + }); }); }); @@ -63,7 +71,8 @@ describe('services/request', () => { positive: true, negative: false }; - const expectedQs = 'notSet=¬Set2=&numeric=1&complexString=sdfgs%20sdfg%20&positive=1&negative=0'; + const expectedQs + = 'notSet=¬Set2=&numeric=1&complexString=sdfgs%20sdfg%20&positive=1&negative=0'; expect(request.buildQuery(data), 'to equal', expectedQs); }); diff --git a/tests-e2e/cypress/integration/invalid-refreshToken.test.js b/tests-e2e/cypress/integration/invalid-refreshToken.test.js index 89c5fa9..8ec2247 100644 --- a/tests-e2e/cypress/integration/invalid-refreshToken.test.js +++ b/tests-e2e/cypress/integration/invalid-refreshToken.test.js @@ -33,8 +33,7 @@ describe('when user\'s token and refreshToken are invalid', () => { ); beforeEach(() => - localStorage.setItem('redux-storage', JSON.stringify(multiAccount)) - ); + localStorage.setItem('redux-storage', JSON.stringify(multiAccount))); it('should ask for password', () => { cy.visit('/'); @@ -52,8 +51,7 @@ describe('when user\'s token and refreshToken are invalid', () => { cy.url().should('include', '/password'); - cy - .get('[data-e2e-toolbar] a') + cy.get('[data-e2e-toolbar] a') .contains('Ely.by') .click(); @@ -70,13 +68,11 @@ describe('when user\'s token and refreshToken are invalid', () => { cy.url().should('include', '/choose-account'); - cy - .get('[data-e2e-content]') + cy.get('[data-e2e-content]') .contains(account2.email) .should('not.exist'); - cy - .get('[data-e2e-content]') + cy.get('[data-e2e-content]') .contains(account1.username) .click(); @@ -85,14 +81,9 @@ describe('when user\'s token and refreshToken are invalid', () => { }); it('it should redirect to login, when one account and clicking back', () => { - cy - .url() - .should(() => - localStorage.setItem( - 'redux-storage', - JSON.stringify(singleAccount) - ) - ); + cy.url().should(() => + localStorage.setItem('redux-storage', JSON.stringify(singleAccount)) + ); cy.visit('/'); cy.url().should('include', '/password'); @@ -107,24 +98,26 @@ describe('when user\'s token and refreshToken are invalid', () => { it('should allow logout', () => { cy.visit('/'); - cy - .get('[data-e2e-toolbar]') + cy.get('@fetch', { timeout: 15000 }).should( + 'be.calledWith', + '/api/accounts/current' + ); + + cy.get('[data-e2e-toolbar]') .contains(account2.username) .click(); - cy - .get('[data-e2e-toolbar]') + cy.get('[data-e2e-toolbar]') .contains('Log out') .click(); - cy - .get('@fetch', { timeout: 15000 }) - .should('be.calledWith', '/api/authentication/logout'); - cy - .get('[data-e2e-toolbar]') + cy.get('@fetch', { timeout: 15000 }).should( + 'be.calledWith', + '/api/authentication/logout' + ); + cy.get('[data-e2e-toolbar]') .contains(account2.email) .should('not.exist'); - cy - .get('[data-e2e-toolbar]') + cy.get('[data-e2e-toolbar]') .contains(account2.username) .should('not.exist'); }); @@ -132,6 +125,11 @@ describe('when user\'s token and refreshToken are invalid', () => { it('should allow enter new login from choose account', () => { cy.visit('/'); + cy.get('@fetch', { timeout: 15000 }).should( + 'be.calledWith', + '/api/accounts/current' + ); + cy.url().should('include', '/password'); cy.get('[data-e2e-go-back]').click(); @@ -169,22 +167,19 @@ describe('when user\'s token and refreshToken are invalid', () => { }); it('should ask for password if selected account with bad token', () => { - cy - .url() - .should(() => - localStorage.setItem( - 'redux-storage', - JSON.stringify(multiAccountWithBadTokens) - ) - ); + cy.url().should(() => + localStorage.setItem( + 'redux-storage', + JSON.stringify(multiAccountWithBadTokens) + ) + ); cy.visit('/'); cy.get('[data-e2e-go-back]').click(); cy.url().should('include', '/choose-account'); - cy - .get('[data-e2e-content]') + cy.get('[data-e2e-content]') .contains(account1.username) .click(); @@ -213,7 +208,7 @@ describe('when user\'s token and refreshToken are invalid', () => { * * @see https://trello.com/c/iINbZ2l2 */ - it('should allow enter register page', () => { + it('should allow enter register page during password request for other account with invalid token', () => { cy.visit('/'); cy.url().should('contain', '/password'); @@ -223,8 +218,6 @@ describe('when user\'s token and refreshToken are invalid', () => { cy.contains('[type=submit]', 'Log into another account').click(); cy.contains('a', 'Create new account').click(); - cy.get('@fetch').should('be.calledWith', '/api/options'); - cy.url().should('contain', '/register'); }); @@ -241,11 +234,9 @@ describe('when user\'s token and refreshToken are invalid', () => { * * @see https://trello.com/c/iINbZ2l2 */ - it('should allow enter register page', () => { + it('should allow enter register page, when current account has invalid token', () => { cy.visit('/register'); - cy.get('@fetch').should('be.calledWith', '/api/options'); - cy.url().should('contain', '/register'); }); @@ -260,8 +251,7 @@ describe('when user\'s token and refreshToken are invalid', () => { cy.url({ timeout: 15000 }).should('contain', '/oauth/choose-account'); - cy - .get('[data-e2e-content]') + cy.get('[data-e2e-content]') .contains(account2.username) .click();