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) {
return {
catch<T: Resp<*> | InternalServerError>(resp?: T): Promise<T> {
if (resp && (
(resp instanceof InternalServerError
&& resp.error.code !== ABORT_ERR
) || (resp.originalResponse
&& /5\d\d/.test((resp.originalResponse.status: string))
)
)) {
catch<T: Resp<*> | InternalServerError | Error>(resp?: T): Promise<T> {
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 });
}
}

View File

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

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 type { Resp } from './request';
export { default as InternalServerError } from './InternalServerError';
export { default as RequestAbortedError } from './RequestAbortedError';
/**
* Usage: Query<'requeired'|'keys'|'names'>

View File

@ -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<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));
},
@ -55,7 +60,11 @@ export default {
*
* @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));
},
@ -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('&');
}

View File

@ -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=&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);
});

View File

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