Add RequestAbortedError and fix current e2e tests

This commit is contained in:
SleepWalker 2018-11-04 10:09:42 +02:00
parent a9d67bc3e7
commit 71bfc01e4d
7 changed files with 167 additions and 120 deletions

View File

@ -8,14 +8,16 @@ const ABORT_ERR = 20;
export default function BsodMiddleware(dispatchBsod: Function, logger: Logger) { export default function BsodMiddleware(dispatchBsod: Function, logger: Logger) {
return { return {
catch<T: Resp<*> | InternalServerError>(resp?: T): Promise<T> { catch<T: Resp<*> | InternalServerError | Error>(resp?: T): Promise<T> {
if (resp && ( const originalResponse: Object = (resp && resp.originalResponse) || {};
(resp instanceof InternalServerError
&& resp.error.code !== ABORT_ERR if (
) || (resp.originalResponse resp
&& /5\d\d/.test((resp.originalResponse.status: string)) && ((resp instanceof InternalServerError
) && resp.error.code !== ABORT_ERR)
)) { || (originalResponse
&& /5\d\d/.test((originalResponse.status: string))))
) {
dispatchBsod(); dispatchBsod();
if (!resp.message || !/NetworkError/.test(resp.message)) { if (!resp.message || !/NetworkError/.test(resp.message)) {
@ -25,7 +27,7 @@ export default function BsodMiddleware(dispatchBsod: Function, logger: Logger) {
message = `BSoD: ${resp.message}`; message = `BSoD: ${resp.message}`;
} }
logger.warn(message, {resp}); logger.warn(message, { resp });
} }
} }

View File

@ -1,11 +1,14 @@
// @flow // @flow
function InternalServerError(error: Error | string | Object, resp?: Response | Object) { function InternalServerError(
error: Error | string | Object,
resp?: Response | Object
) {
error = error || {}; error = error || {};
this.name = 'InternalServerError'; this.name = 'InternalServerError';
this.message = 'InternalServerError'; this.message = 'InternalServerError';
this.error = error; this.error = error;
this.stack = (new Error()).stack; this.stack = new Error().stack;
if (resp) { if (resp) {
this.originalResponse = resp; this.originalResponse = resp;

View File

@ -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;

View File

@ -2,6 +2,7 @@
export { default } from './request'; export { default } from './request';
export type { Resp } from './request'; export type { Resp } from './request';
export { default as InternalServerError } from './InternalServerError'; export { default as InternalServerError } from './InternalServerError';
export { default as RequestAbortedError } from './RequestAbortedError';
/** /**
* Usage: Query<'requeired'|'keys'|'names'> * Usage: Query<'requeired'|'keys'|'names'>

View File

@ -1,6 +1,7 @@
// @flow // @flow
import PromiseMiddlewareLayer from './PromiseMiddlewareLayer'; import PromiseMiddlewareLayer from './PromiseMiddlewareLayer';
import InternalServerError from './InternalServerError'; import InternalServerError from './InternalServerError';
import RequestAbortedError from './RequestAbortedError';
const middlewareLayer = new PromiseMiddlewareLayer(); const middlewareLayer = new PromiseMiddlewareLayer();
@ -17,10 +18,10 @@ type Middleware = {
const buildOptions = (method: string, data?: Object, options: Object) => ({ const buildOptions = (method: string, data?: Object, options: Object) => ({
method, method,
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}, },
body: buildQuery(data), body: buildQuery(data),
...options, ...options
}); });
export default { export default {
@ -31,7 +32,11 @@ export default {
* *
* @return {Promise} * @return {Promise}
*/ */
post<T>(url: string, data?: Object, options: Object = {}): Promise<Resp<T>> { post<T>(
url: string,
data?: Object,
options: Object = {}
): Promise<Resp<T>> {
return doFetch(url, buildOptions('POST', data, options)); return doFetch(url, buildOptions('POST', data, options));
}, },
@ -55,7 +60,11 @@ export default {
* *
* @return {Promise} * @return {Promise}
*/ */
delete<T>(url: string, data?: Object, options: Object = {}): Promise<Resp<T>> { delete<T>(
url: string,
data?: Object,
options: Object = {}
): Promise<Resp<T>> {
return doFetch(url, buildOptions('DELETE', data, options)); return doFetch(url, buildOptions('DELETE', data, options));
}, },
@ -93,36 +102,40 @@ export default {
*/ */
addMiddleware(middleware: Middleware) { addMiddleware(middleware: Middleware) {
middlewareLayer.add(middleware); middlewareLayer.add(middleware);
}, }
}; };
const checkStatus = (resp: Response) => resp.status >= 200 && resp.status < 300 const checkStatus = (resp: Response) =>
? Promise.resolve(resp) resp.status >= 200 && resp.status < 300
: Promise.reject(resp); ? Promise.resolve(resp)
const toJSON = (resp = {}) => { : Promise.reject(resp);
if (!resp.json) { const toJSON = (resp: Response) => {
// e.g. 'TypeError: Failed to fetch' due to CORS if (!resp.json || resp.status === 0) {
throw new InternalServerError(resp); // e.g. 'TypeError: Failed to fetch' due to CORS or request was aborted
throw new RequestAbortedError(resp);
} }
return resp.json().then((json) => { return resp.json().then(
json.originalResponse = resp; (json) => {
json.originalResponse = resp;
return json; return json;
}, (error) => Promise.reject( },
new InternalServerError(error, resp) (error) => Promise.reject(new InternalServerError(error, resp))
)); );
}; };
const rejectWithJSON = (resp) => toJSON(resp).then((resp) => { const rejectWithJSON = (resp: Response) =>
if (resp.originalResponse.status >= 500) { toJSON(resp).then((resp) => {
throw new InternalServerError(resp, resp.originalResponse); if (resp.originalResponse.status >= 500) {
} throw new InternalServerError(resp, resp.originalResponse);
}
throw resp; throw resp;
}); });
const handleResponseSuccess = (resp) => resp.success || typeof resp.success === 'undefined' const handleResponseSuccess = (resp) =>
? Promise.resolve(resp) resp.success || typeof resp.success === 'undefined'
: Promise.reject(resp); ? Promise.resolve(resp)
: Promise.reject(resp);
async function doFetch(url, options = {}) { async function doFetch(url, options = {}) {
// NOTE: we are wrapping fetch, because it is returning // NOTE: we are wrapping fetch, because it is returning
@ -131,14 +144,21 @@ async function doFetch(url, options = {}) {
options.headers = options.headers || {}; options.headers = options.headers || {};
options.headers.Accept = 'application/json'; options.headers.Accept = 'application/json';
return middlewareLayer.run('before', {url, options}) return middlewareLayer
.then(({url, options}) => .run('before', { url, options })
.then(({ url, options }) =>
fetch(url, options) fetch(url, options)
.then(checkStatus) .then(checkStatus)
.then(toJSON, rejectWithJSON) .then(toJSON, rejectWithJSON)
.then(handleResponseSuccess) .then(handleResponseSuccess)
.then((resp) => middlewareLayer.run('then', resp, {url, options})) .then((resp) =>
.catch((resp) => middlewareLayer.run('catch', resp, {url, options}, () => doFetch(url, options))) 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 { function buildQuery(data: Object = {}): string {
return Object.keys(data) return Object.keys(data)
.map( .map((keyName) =>
(keyName) => [keyName, convertQueryValue(data[keyName])]
[keyName, convertQueryValue(data[keyName])] .map(encodeURIComponent)
.map(encodeURIComponent) .join('=')
.join('=')
) )
.join('&'); .join('&');
} }

View File

@ -2,7 +2,7 @@ import expect from 'unexpected';
import sinon from 'sinon'; import sinon from 'sinon';
import request from 'services/request'; import request from 'services/request';
import { InternalServerError } from 'services/request'; import { InternalServerError, RequestAbortedError } from 'services/request';
describe('services/request', () => { describe('services/request', () => {
beforeEach(() => { beforeEach(() => {
@ -14,42 +14,50 @@ describe('services/request', () => {
}); });
describe('InternalServerError', () => { 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', () => { 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)); fetch.returns(Promise.resolve(resp));
return expect(request.get('/foo'), 'to be rejected') return expect(request.get('/foo'), 'to be rejected').then((error) => {
.then((error) => { expect(error, 'to be an', InternalServerError);
expect(error, 'to be an', InternalServerError); expect(error.originalResponse, 'to be', resp);
expect(error.originalResponse, 'to be', resp); expect(error.message, 'to contain', 'Unexpected token');
expect(error.message, 'to contain', 'Unexpected token'); });
});
}); });
it('should wrap 5xx errors', () => { it('should wrap 5xx errors', () => {
const resp = new Response('{}', {status: 500}); const resp = new Response('{}', { status: 500 });
fetch.returns(Promise.resolve(resp)); fetch.returns(Promise.resolve(resp));
return expect(request.get('/foo'), 'to be rejected') return expect(request.get('/foo'), 'to be rejected').then((error) => {
.then((error) => { expect(error, 'to be an', InternalServerError);
expect(error, 'to be an', InternalServerError); expect(error.originalResponse, 'to be', resp);
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, positive: true,
negative: false negative: false
}; };
const expectedQs = 'notSet=&notSet2=&numeric=1&complexString=sdfgs%20sdfg%20&positive=1&negative=0'; const expectedQs
= 'notSet=&notSet2=&numeric=1&complexString=sdfgs%20sdfg%20&positive=1&negative=0';
expect(request.buildQuery(data), 'to equal', expectedQs); expect(request.buildQuery(data), 'to equal', expectedQs);
}); });

View File

@ -33,8 +33,7 @@ describe('when user\'s token and refreshToken are invalid', () => {
); );
beforeEach(() => beforeEach(() =>
localStorage.setItem('redux-storage', JSON.stringify(multiAccount)) localStorage.setItem('redux-storage', JSON.stringify(multiAccount)));
);
it('should ask for password', () => { it('should ask for password', () => {
cy.visit('/'); cy.visit('/');
@ -52,8 +51,7 @@ describe('when user\'s token and refreshToken are invalid', () => {
cy.url().should('include', '/password'); cy.url().should('include', '/password');
cy cy.get('[data-e2e-toolbar] a')
.get('[data-e2e-toolbar] a')
.contains('Ely.by') .contains('Ely.by')
.click(); .click();
@ -70,13 +68,11 @@ describe('when user\'s token and refreshToken are invalid', () => {
cy.url().should('include', '/choose-account'); cy.url().should('include', '/choose-account');
cy cy.get('[data-e2e-content]')
.get('[data-e2e-content]')
.contains(account2.email) .contains(account2.email)
.should('not.exist'); .should('not.exist');
cy cy.get('[data-e2e-content]')
.get('[data-e2e-content]')
.contains(account1.username) .contains(account1.username)
.click(); .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', () => { it('it should redirect to login, when one account and clicking back', () => {
cy cy.url().should(() =>
.url() localStorage.setItem('redux-storage', JSON.stringify(singleAccount))
.should(() => );
localStorage.setItem(
'redux-storage',
JSON.stringify(singleAccount)
)
);
cy.visit('/'); cy.visit('/');
cy.url().should('include', '/password'); cy.url().should('include', '/password');
@ -107,24 +98,26 @@ describe('when user\'s token and refreshToken are invalid', () => {
it('should allow logout', () => { it('should allow logout', () => {
cy.visit('/'); cy.visit('/');
cy cy.get('@fetch', { timeout: 15000 }).should(
.get('[data-e2e-toolbar]') 'be.calledWith',
'/api/accounts/current'
);
cy.get('[data-e2e-toolbar]')
.contains(account2.username) .contains(account2.username)
.click(); .click();
cy cy.get('[data-e2e-toolbar]')
.get('[data-e2e-toolbar]')
.contains('Log out') .contains('Log out')
.click(); .click();
cy cy.get('@fetch', { timeout: 15000 }).should(
.get('@fetch', { timeout: 15000 }) 'be.calledWith',
.should('be.calledWith', '/api/authentication/logout'); '/api/authentication/logout'
cy );
.get('[data-e2e-toolbar]') cy.get('[data-e2e-toolbar]')
.contains(account2.email) .contains(account2.email)
.should('not.exist'); .should('not.exist');
cy cy.get('[data-e2e-toolbar]')
.get('[data-e2e-toolbar]')
.contains(account2.username) .contains(account2.username)
.should('not.exist'); .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', () => { it('should allow enter new login from choose account', () => {
cy.visit('/'); cy.visit('/');
cy.get('@fetch', { timeout: 15000 }).should(
'be.calledWith',
'/api/accounts/current'
);
cy.url().should('include', '/password'); cy.url().should('include', '/password');
cy.get('[data-e2e-go-back]').click(); 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', () => { it('should ask for password if selected account with bad token', () => {
cy cy.url().should(() =>
.url() localStorage.setItem(
.should(() => 'redux-storage',
localStorage.setItem( JSON.stringify(multiAccountWithBadTokens)
'redux-storage', )
JSON.stringify(multiAccountWithBadTokens) );
)
);
cy.visit('/'); cy.visit('/');
cy.get('[data-e2e-go-back]').click(); cy.get('[data-e2e-go-back]').click();
cy.url().should('include', '/choose-account'); cy.url().should('include', '/choose-account');
cy cy.get('[data-e2e-content]')
.get('[data-e2e-content]')
.contains(account1.username) .contains(account1.username)
.click(); .click();
@ -213,7 +208,7 @@ describe('when user\'s token and refreshToken are invalid', () => {
* *
* @see https://trello.com/c/iINbZ2l2 * @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.visit('/');
cy.url().should('contain', '/password'); 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('[type=submit]', 'Log into another account').click();
cy.contains('a', 'Create new account').click(); cy.contains('a', 'Create new account').click();
cy.get('@fetch').should('be.calledWith', '/api/options');
cy.url().should('contain', '/register'); cy.url().should('contain', '/register');
}); });
@ -241,11 +234,9 @@ describe('when user\'s token and refreshToken are invalid', () => {
* *
* @see https://trello.com/c/iINbZ2l2 * @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.visit('/register');
cy.get('@fetch').should('be.calledWith', '/api/options');
cy.url().should('contain', '/register'); 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.url({ timeout: 15000 }).should('contain', '/oauth/choose-account');
cy cy.get('[data-e2e-content]')
.get('[data-e2e-content]')
.contains(account2.username) .contains(account2.username)
.click(); .click();