fix: Landing Page throws 404 not 500

* fixes #1033
* fixes #1605
* fixes #1616
* fixes #1673
This commit is contained in:
Matthew Riley MacPherson 2017-01-29 13:56:05 +00:00
Родитель 41acb00abb
Коммит d356292781
31 изменённых файлов: 836 добавлений и 363 удалений

Просмотреть файл

@ -163,6 +163,7 @@
"config": "1.24.0",
"deep-eql": "2.0.1",
"dompurify": "0.8.4",
"es6-error": "4.0.1",
"express": "4.14.0",
"extract-text-webpack-plugin": "1.0.1",
"helmet": "3.4.0",
@ -180,6 +181,7 @@
"react-cookie": "1.0.4",
"react-dom": "15.4.1",
"react-helmet": "4.0.0",
"react-nested-status": "0.1.2",
"react-onclickoutside": "5.8.3",
"react-photoswipe": "1.2.0",
"react-redux": "4.4.6",

Просмотреть файл

@ -0,0 +1,5 @@
@import "~amo/css/inc/vars";
.ErrorPage {
margin: $page-margin;
}

Просмотреть файл

@ -0,0 +1,39 @@
import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import { compose } from 'redux';
import { getErrorComponent } from 'amo/utils';
export class ErrorPageBase extends React.Component {
static propTypes = {
children: PropTypes.node.isRequired,
errorPage: PropTypes.object.isRequired,
}
static defaultProps = {
errorPage: {},
}
render() {
const { children, errorPage } = this.props;
if (errorPage.hasError) {
const ErrorComponent = getErrorComponent(errorPage.statusCode);
return (
<ErrorComponent error={errorPage.error} status={errorPage.statusCode} />
);
}
return children;
}
}
export const mapStateToProps = (state) => ({
errorPage: state.errorPage,
});
export default compose(
connect(mapStateToProps),
)(ErrorPageBase);

Просмотреть файл

@ -5,6 +5,7 @@ import { asyncConnect } from 'redux-connect';
import { connect } from 'react-redux';
import LandingAddonsCard from 'amo/components/LandingAddonsCard';
import NotFound from 'amo/components/NotFound';
import { loadLandingAddons } from 'amo/utils';
import {
ADDON_TYPE_EXTENSION,
@ -12,7 +13,12 @@ import {
SEARCH_SORT_POPULAR,
SEARCH_SORT_TOP_RATED,
} from 'core/constants';
import { apiAddonType, visibleAddonType } from 'core/utils';
import { AddonTypeNotFound } from 'core/errors';
import log from 'core/logger';
import {
apiAddonType as getApiAddonType,
visibleAddonType as getVisibleAddonType,
} from 'core/utils';
import translate from 'core/i18n/translate';
import './LandingPage.scss';
@ -20,21 +26,29 @@ import './LandingPage.scss';
export class LandingPageBase extends React.Component {
static propTypes = {
addonType: PropTypes.string.isRequired,
apiAddonType: PropTypes.func.isRequired,
featuredAddons: PropTypes.array,
highlyRatedAddons: PropTypes.array,
popularAddons: PropTypes.array,
i18n: PropTypes.object.isRequired,
params: PropTypes.objectOf({
visibleAddonType: PropTypes.string.isRequired,
}).isRequired,
}
contentForType(addonType) {
const { i18n } = this.props;
static defaultProps = {
apiAddonType: getApiAddonType,
}
contentForType(visibleAddonType) {
const { apiAddonType, i18n } = this.props;
const addonType = apiAddonType(visibleAddonType);
const contentForTypes = {
[ADDON_TYPE_EXTENSION]: {
featuredHeader: i18n.gettext('Featured extensions'),
featuredFooterLink: {
pathname: `/${visibleAddonType(ADDON_TYPE_EXTENSION)}/featured/`,
pathname: `/${getVisibleAddonType(ADDON_TYPE_EXTENSION)}/featured/`,
query: { addonType: ADDON_TYPE_EXTENSION },
},
featuredFooterText: i18n.gettext('More featured extensions'),
@ -54,7 +68,7 @@ export class LandingPageBase extends React.Component {
[ADDON_TYPE_THEME]: {
featuredHeader: i18n.gettext('Featured themes'),
featuredFooterLink: {
pathname: `/${visibleAddonType(ADDON_TYPE_THEME)}/featured/`,
pathname: `/${getVisibleAddonType(ADDON_TYPE_THEME)}/featured/`,
query: { addonType: ADDON_TYPE_THEME },
},
featuredFooterText: i18n.gettext('More featured themes'),
@ -73,19 +87,26 @@ export class LandingPageBase extends React.Component {
},
};
if (contentForTypes[addonType]) {
return contentForTypes[addonType];
}
throw new Error(`No LandingPage content for addonType: ${addonType}`);
return { addonType, html: contentForTypes[addonType] };
}
render() {
const {
addonType, featuredAddons, highlyRatedAddons, popularAddons,
} = this.props;
const { featuredAddons, highlyRatedAddons, popularAddons } = this.props;
const { visibleAddonType } = this.props.params;
const html = this.contentForType(addonType);
let content;
try {
content = this.contentForType(visibleAddonType);
} catch (err) {
if (err instanceof AddonTypeNotFound) {
log.info('Rendering <NotFound /> for error:', err);
return <NotFound />;
}
throw err;
}
const { addonType, html } = content;
return (
<div className={classNames('LandingPage', `LandingPage-${addonType}`)}>
@ -108,9 +129,8 @@ export class LandingPageBase extends React.Component {
}
}
export function mapStateToProps(state, ownProps) {
export function mapStateToProps(state) {
return {
addonType: apiAddonType(ownProps.params.visibleAddonType),
featuredAddons: state.landing.featured.results,
highlyRatedAddons: state.landing.highlyRated.results,
popularAddons: state.landing.popular.results,

Просмотреть файл

@ -0,0 +1,51 @@
import React, { PropTypes } from 'react';
import { oneLine } from 'common-tags';
import { compose } from 'redux';
import NestedStatus from 'react-nested-status';
import translate from 'core/i18n/translate';
import { sanitizeHTML } from 'core/utils';
import Card from 'ui/components/Card';
import 'amo/components/ErrorPage/ErrorPage.scss';
export class NotFoundBase extends React.Component {
static propTypes = {
i18n: PropTypes.object.isRequired,
}
render() {
const { i18n } = this.props;
const fileAnIssueText = i18n.sprintf(i18n.gettext(oneLine`
If you are signed in and think this message is an error, please
<a href="%(url)s">file an issue</a>. Tell us where you came from
and what you were trying to access, and we'll fix the issue.`),
{ url: 'https://github.com/mozilla/addons-frontend/issues/new/' });
// TODO: Check for signed in state and offer different messages.
// TODO: Offer a sign in link/button inside the error page.
/* eslint-disable react/no-danger */
return (
<NestedStatus code={401}>
<Card className="ErrorPage NotAuthorized"
header={i18n.gettext('Not Authorized')}>
<p>
{i18n.gettext(oneLine`
Sorry, but you aren't authorized to access this page. If you
aren't signed in, try signing in using the link at the top
of the page.`)}
</p>
<p dangerouslySetInnerHTML={sanitizeHTML(fileAnIssueText, ['a'])} />
</Card>
</NestedStatus>
);
/* eslint-enable react/no-danger */
}
}
export default compose(
translate({ withRef: true }),
)(NotFoundBase);

Просмотреть файл

@ -0,0 +1,72 @@
import React, { PropTypes } from 'react';
import { oneLine } from 'common-tags';
import { compose } from 'redux';
import NestedStatus from 'react-nested-status';
import Link from 'amo/components/Link';
import { ADDON_TYPE_EXTENSION, ADDON_TYPE_THEME } from 'core/constants';
import translate from 'core/i18n/translate';
import { sanitizeHTML, visibleAddonType } from 'core/utils';
import Card from 'ui/components/Card';
import 'amo/components/ErrorPage/ErrorPage.scss';
export class NotFoundBase extends React.Component {
static propTypes = {
i18n: PropTypes.object.isRequired,
}
render() {
const { i18n } = this.props;
const fileAnIssueText = i18n.sprintf(i18n.gettext(oneLine`
If you followed a link from somewhere, please
<a href="%(url)s">file an issue</a>. Tell us where you came from and
what you were looking for, and we'll do our best to fix it.`),
{ url: 'https://github.com/mozilla/addons-frontend/issues/new/' });
/* eslint-disable react/no-danger */
return (
<NestedStatus code={404}>
<Card className="ErrorPage NotFound"
header={i18n.gettext('Page not found')}>
<p>
{i18n.gettext(oneLine`
Sorry, but we can't find anything at the address you entered.
If you followed a link to an add-on, it's possible that add-on
has been removed by its author.`)}
</p>
<h2>{i18n.gettext('Suggested Pages')}</h2>
<ul>
<li>
<Link to={`/${visibleAddonType(ADDON_TYPE_EXTENSION)}/featured/`}>
{i18n.gettext('Browse all extensions')}
</Link>
</li>
<li>
<Link to={`/${visibleAddonType(ADDON_TYPE_THEME)}/featured/`}>
{i18n.gettext('Browse all themes')}
</Link>
</li>
<li>
<Link to="/">
{i18n.gettext('Add-ons Home Page')}
</Link>
</li>
</ul>
<p dangerouslySetInnerHTML={sanitizeHTML(fileAnIssueText, ['a'])} />
</Card>
</NestedStatus>
);
/* eslint-enable react/no-danger */
}
}
export default compose(
translate({ withRef: true }),
)(NotFoundBase);

Просмотреть файл

@ -0,0 +1,71 @@
import React, { PropTypes } from 'react';
import { oneLine } from 'common-tags';
import { compose } from 'redux';
import NestedStatus from 'react-nested-status';
import Link from 'amo/components/Link';
import { ADDON_TYPE_EXTENSION, ADDON_TYPE_THEME } from 'core/constants';
import translate from 'core/i18n/translate';
import { sanitizeHTML, visibleAddonType } from 'core/utils';
import Card from 'ui/components/Card';
import 'amo/components/ErrorPage/ErrorPage.scss';
export class ServerErrorBase extends React.Component {
static propTypes = {
i18n: PropTypes.object.isRequired,
}
render() {
const { i18n } = this.props;
const fileAnIssueText = i18n.gettext(oneLine`
If you have additional information that would help us you can
<a href="https://github.com/mozilla/addons-frontend/issues/new/">file an
issue</a>. Tell us what steps you took that lead to the error and we'll
do our best to fix it.`);
/* eslint-disable react/no-danger */
return (
<NestedStatus code={500}>
<Card className="ErrorPage ServerError"
header={i18n.gettext('Server Error')}>
<p>
{i18n.gettext(oneLine`
Sorry, but there was an error with our server and we couldn't
complete your request. We have logged this error and will
investigate it.`)}
</p>
<h2>{i18n.gettext('Suggested Pages')}</h2>
<ul>
<li>
<Link to={`/${visibleAddonType(ADDON_TYPE_EXTENSION)}/featured/`}>
{i18n.gettext('Browse all extensions')}
</Link>
</li>
<li>
<Link to={`/${visibleAddonType(ADDON_TYPE_THEME)}/featured/`}>
{i18n.gettext('Browse all themes')}
</Link>
</li>
<li>
<Link to="/">
{i18n.gettext('Add-ons Home Page')}
</Link>
</li>
</ul>
<p dangerouslySetInnerHTML={sanitizeHTML(fileAnIssueText, ['a'])} />
</Card>
</NestedStatus>
);
/* eslint-enable react/no-danger */
}
}
export default compose(
translate({ withRef: true }),
)(ServerErrorBase);

Просмотреть файл

@ -9,10 +9,10 @@ import { compose } from 'redux';
import 'core/fonts/fira.scss';
import 'amo/css/App.scss';
import SearchForm from 'amo/components/SearchForm';
import DefaultErrorPage from 'amo/components/ErrorPage';
import { addChangeListeners } from 'core/addonManager';
import { INSTALL_STATE } from 'core/constants';
import InfoDialog from 'core/containers/InfoDialog';
import { handleResourceErrors } from 'core/resourceErrors/decorator';
import translate from 'core/i18n/translate';
import Footer from 'amo/components/Footer';
import MastHead from 'amo/components/MastHead';
@ -20,6 +20,7 @@ import MastHead from 'amo/components/MastHead';
export class AppBase extends React.Component {
static propTypes = {
ErrorPage: PropTypes.node.isRequired,
FooterComponent: PropTypes.node.isRequired,
InfoDialogComponent: PropTypes.node.isRequired,
MastHeadComponent: PropTypes.node.isRequired,
@ -34,6 +35,7 @@ export class AppBase extends React.Component {
}
static defaultProps = {
ErrorPage: DefaultErrorPage,
FooterComponent: Footer,
InfoDialogComponent: InfoDialog,
MastHeadComponent: MastHead,
@ -57,6 +59,7 @@ export class AppBase extends React.Component {
render() {
const {
ErrorPage,
FooterComponent,
InfoDialogComponent,
MastHeadComponent,
@ -66,6 +69,7 @@ export class AppBase extends React.Component {
lang,
location,
} = this.props;
const isHomePage = Boolean(location.pathname && location.pathname.match(
new RegExp(`^\\/${lang}\\/${clientApp}\\/?$`)));
const query = location.query ? location.query.q : null;
@ -77,7 +81,7 @@ export class AppBase extends React.Component {
SearchFormComponent={SearchForm} isHomePage={isHomePage} location={location}
query={query} ref={(ref) => { this.mastHead = ref; }} />
<div className="App-content">
{children}
<ErrorPage>{children}</ErrorPage>
</div>
<FooterComponent handleViewDesktop={this.onViewDesktop}
location={location} />
@ -100,7 +104,6 @@ export function mapDispatchToProps(dispatch) {
}
export default compose(
handleResourceErrors,
connect(mapStateToProps, mapDispatchToProps),
translate({ withRef: true }),
)(AppBase);

Просмотреть файл

@ -14,3 +14,5 @@ $breakpoints: (
medium: '(min-width: 500px)',
large: '(min-width: 720px)'
);
$page-margin: 20px 10px;

Просмотреть файл

@ -1,3 +1,4 @@
import config from 'config';
import React from 'react';
import { IndexRoute, Route } from 'react-router';
@ -12,7 +13,10 @@ import FeaturedAddons from './components/FeaturedAddons';
import LandingPage from './components/LandingPage';
import Home from './containers/Home';
import DetailPage from './containers/DetailPage';
import NotAuthorized from './components/NotAuthorized';
import NotFound from './components/NotFound';
import SearchPage from './containers/SearchPage';
import ServerError from './components/ServerError';
export default (
@ -26,6 +30,12 @@ export default (
<Route path=":visibleAddonType/:slug/" component={CategoryPage} />
<Route path="fxa-authenticate" component={HandleLogin} />
<Route path="search/" component={SearchPage} />
<Route path="401/"
component={config.get('isDevelopment') ? NotAuthorized : NotFound} />
<Route path="404/" component={NotFound} />
<Route path="500/"
component={config.get('isDevelopment') ? ServerError : NotFound} />
<Route path=":visibleAddonType/" component={LandingPage} />
<Route path="*" component={NotFound} />
</Route>
);

Просмотреть файл

@ -9,6 +9,7 @@ import api from 'core/reducers/api';
import auth from 'core/reducers/authentication';
import categories from 'core/reducers/categories';
import errors from 'core/reducers/errors';
import errorPage from 'core/reducers/errorPage';
import infoDialog from 'core/reducers/infoDialog';
import installations from 'core/reducers/installations';
import search from 'core/reducers/search';
@ -23,6 +24,7 @@ export default function createStore(initialState = {}) {
auth,
categories,
errors,
errorPage,
featured,
infoDialog,
installations,

Просмотреть файл

@ -4,6 +4,9 @@ import {
} from 'amo/constants';
import { getFeatured, loadFeatured } from 'amo/actions/featured';
import { getLanding, loadLanding, failLanding } from 'amo/actions/landing';
import NotAuthorized from 'amo/components/NotAuthorized';
import NotFound from 'amo/components/NotFound';
import ServerError from 'amo/components/ServerError';
import { featured as featuredAPI, search } from 'core/api';
import { SEARCH_SORT_POPULAR, SEARCH_SORT_TOP_RATED } from 'core/constants';
import { apiAddonType } from 'core/utils';
@ -50,7 +53,23 @@ export function fetchLandingAddons({ addonType, api, dispatch }) {
export function loadLandingAddons({ store: { dispatch, getState }, params }) {
const state = getState();
const addonType = apiAddonType(params.visibleAddonType);
try {
const addonType = apiAddonType(params.visibleAddonType);
return fetchLandingAddons({ addonType, api: state.api, dispatch });
return fetchLandingAddons({ addonType, api: state.api, dispatch });
} catch (err) {
return Promise.reject(err);
}
}
export function getErrorComponent(status) {
switch (status) {
case 401:
return NotAuthorized;
case 404:
return NotFound;
case 500:
default:
return ServerError;
}
}

Просмотреть файл

@ -25,6 +25,11 @@ export const validInstallStates = [
UNKNOWN,
];
// redux-connect action types; we watch for these in our `errorPage`
// reducer to display error pages.
export const REDUX_CONNECT_END_GLOBAL_LOAD = '@redux-conn/END_GLOBAL_LOAD';
export const REDUX_CONNECT_LOAD_FAIL = '@redux-conn/LOAD_FAIL';
// Add-on error states.
export const DOWNLOAD_FAILED = 'DOWNLOAD_FAILED';
export const INSTALL_FAILED = 'INSTALL_FAILED';

4
src/core/errors.js Normal file
Просмотреть файл

@ -0,0 +1,4 @@
import ExtendableError from 'es6-error';
export class AddonTypeNotFound extends ExtendableError {}

Просмотреть файл

@ -0,0 +1,42 @@
import {
REDUX_CONNECT_END_GLOBAL_LOAD,
REDUX_CONNECT_LOAD_FAIL,
} from 'core/constants';
export const initialState = {
clearOnNext: false,
error: null,
hasError: false,
statusCode: null,
};
export default function errorPage(state = initialState, action) {
const { payload } = action;
switch (action.type) {
case REDUX_CONNECT_END_GLOBAL_LOAD:
if (state.clearOnNext) {
return initialState;
}
return { ...state, clearOnNext: true };
case REDUX_CONNECT_LOAD_FAIL: {
// Default to a 500 error if we don't have a status code from our
// response. See:
// github.com/mozilla/addons-frontend/pull/1685#discussion_r99243105
let statusCode = 500;
if (payload.error && payload.error.response &&
payload.error.response.status
) {
statusCode = payload.error.response.status;
}
return {
...state,
error: payload.error,
hasError: true,
statusCode,
};
}
default:
return state;
}
}

Просмотреть файл

@ -1,65 +0,0 @@
/* eslint-disable react/prop-types */
import React from 'react';
import { compose } from 'redux';
import { connect } from 'react-redux';
import { getReduxConnectError } from './reduxConnectErrors';
/*
* Display resource errors such as a 404, 500, etc.
*
* Currently, the only way to produce a resource error is if an
* asyncConnect callback returns one from its fetch() promise.
*/
function ResourceError(props) {
const {
reduxAsyncConnect,
WrappedComponent,
...componentProps
} = props;
if (reduxAsyncConnect && reduxAsyncConnect.loadState) {
const reduxResult = getReduxConnectError(reduxAsyncConnect.loadState);
if (reduxResult.error) {
// TODO: This will be prettier once we implement real error pages.
// https://github.com/mozilla/addons-frontend/issues/1033
return <div>{reduxResult.error}</div>;
}
}
return <WrappedComponent {...componentProps} />;
}
/*
* If a resource error occurs, render a ResourceError, otherwise render
* the wrapped component.
*
* You only need to use this once in the top-level App component, like this:
*
import { handleResourceErrors } from 'core/resourceErrors/decorator';
class App extends React.Component {
render() {
// renders the top-level App with routes and all.
}
}
export default compose(
handleResourceErrors,
)(App);
*
* This is complementary to server side rendering which already renders
* resource errors by default. This decorator handles the case where client
* side navigation might result in the same server 404 scenario.
*/
export function handleResourceErrors(WrappedComponent) {
const mapStateToProps = (state) => ({
reduxAsyncConnect: state.reduxAsyncConnect,
WrappedComponent,
});
return compose(
connect(mapStateToProps),
)(ResourceError);
}

Просмотреть файл

@ -1,35 +0,0 @@
import log from 'core/logger';
const errorPageText = {
401: 'Unauthorized',
404: 'Not Found',
500: 'Internal Server Error',
};
export function getErrorMsg(statusCode) {
const statusKey = statusCode.toString();
// TODO: I guess when we make a real error page handler we'll map out
// all possible statuses.
return errorPageText[statusKey] || 'Unexpected Error';
}
export function getReduxConnectError(reduxConnectLoadState) {
// Create a list of any apiErrors detected.
const apiErrors = Object.keys(reduxConnectLoadState)
.map((item) => reduxConnectLoadState[item].error)
.filter((item) => Boolean(item));
let status;
if (apiErrors.length === 1) {
// If we have a single API error reflect that in the page's response.
status = apiErrors[0].response.status;
} else if (apiErrors.length > 1) {
// Otherwise we have multiple api errors it should be logged
// and throw a 500.
log.error(apiErrors);
status = 500;
}
return { status, error: status ? getErrorMsg(status) : undefined };
}

Просмотреть файл

@ -10,16 +10,15 @@ import helmet from 'helmet';
import cookie from 'react-cookie';
import React from 'react';
import ReactDOM from 'react-dom/server';
import NestedStatus from 'react-nested-status';
import { Provider } from 'react-redux';
import { match } from 'react-router';
import { ReduxAsyncConnect, loadOnServer } from 'redux-connect';
import { loadFail } from 'redux-connect/lib/store';
import WebpackIsomorphicTools from 'webpack-isomorphic-tools';
import { createApiError } from 'core/api';
import ServerHtml from 'core/containers/ServerHtml';
import {
getErrorMsg,
getReduxConnectError,
} from 'core/resourceErrors/reduxConnectErrors';
import { prefixMiddleWare } from 'core/middleware';
import { convertBoolean } from 'core/utils';
import { setClientApp, setLang, setJwt } from 'core/actions';
@ -54,17 +53,55 @@ function getNoScriptStyles({ appName }) {
return undefined;
}
function showErrorPage(res, status) {
let adjustedStatus = status;
let error = getErrorMsg(adjustedStatus);
if (!error) {
adjustedStatus = 500;
error = getErrorMsg(adjustedStatus);
const appName = config.get('appName');
function getPageProps({ noScriptStyles = '', store, req, res }) {
// Get SRI for deployed services only.
const sriData = (isDeployed) ? JSON.parse(
fs.readFileSync(path.join(config.get('basePath'), 'dist/sri.json'))
) : {};
// Check the lang supplied by res.locals.lang for validity
// or fall-back to the default.
const lang = isValidLang(res.locals.lang) ?
res.locals.lang : config.get('defaultLang');
const dir = getDirection(lang);
store.dispatch(setLang(lang));
if (res.locals.clientApp) {
store.dispatch(setClientApp(res.locals.clientApp));
} else if (req && req.url) {
log.warn(`No clientApp for this URL: ${req.url}`);
} else {
log.warn('No clientApp (error)');
}
return res.status(adjustedStatus).end(error);
return {
appName,
assets: webpackIsomorphicTools.assets(),
htmlLang: lang,
htmlDir: dir,
includeSri: isDeployed,
noScriptStyles,
sriData,
store,
trackingEnabled: convertBoolean(config.get('trackingEnabled')),
};
}
const appName = config.get('appName');
function showErrorPage({ createStore, error = {}, req, res, status }) {
const store = createStore();
const pageProps = getPageProps({ store, req, res });
const apiError = createApiError({ response: { status } });
store.dispatch(loadFail('ServerBase', { ...apiError, ...error }));
const HTML = ReactDOM.renderToString(
<ServerHtml {...pageProps} />);
const httpStatus = NestedStatus.rewind();
return res.status(status || httpStatus)
.send(`<!DOCTYPE html>\n${HTML}`)
.end();
}
function logRequests(req, res, next) {
const start = new Date();
@ -160,55 +197,30 @@ function baseServer(routes, createStore, { appInstanceName = appName } = {}) {
) => {
cookie.plugToRequest(req, res);
if (err) {
log.error({ err, req });
return showErrorPage(res, 500);
}
if (!renderProps) {
return showErrorPage(res, 404);
}
const store = createStore();
const token = cookie.load(config.get('cookieName'));
if (token) {
store.dispatch(setJwt(token));
}
// Get SRI for deployed services only.
const sriData = (isDeployed) ? JSON.parse(
fs.readFileSync(path.join(config.get('basePath'), 'dist/sri.json'))
) : {};
// Check the lang supplied by res.locals.lang for validity
// or fall-back to the default.
const lang = isValidLang(res.locals.lang) ?
res.locals.lang : config.get('defaultLang');
const dir = getDirection(lang);
const locale = langToLocale(lang);
store.dispatch(setLang(lang));
if (res.locals.clientApp) {
store.dispatch(setClientApp(res.locals.clientApp));
} else {
log.warn(`No clientApp for this URL: ${req.url}`);
// github.com/mozilla/addons-frontend/pull/1685#discussion_r99705186
if (err) {
return showErrorPage({ createStore, status: 500, req, res });
}
function hydrateOnClient(props = {}) {
const pageProps = {
appName: appInstanceName,
assets: webpackIsomorphicTools.assets(),
htmlLang: lang,
htmlDir: dir,
includeSri: isDeployed,
noScriptStyles,
sriData,
store,
trackingEnabled: convertBoolean(config.get('trackingEnabled')),
...props,
};
if (!renderProps) {
return showErrorPage({ createStore, status: 404, req, res });
}
const pageProps = getPageProps({ noScriptStyles, store, req, res });
const { htmlLang } = pageProps;
const locale = langToLocale(htmlLang);
function hydrateOnClient(props = {}) {
const HTML = ReactDOM.renderToString(
<ServerHtml {...pageProps} />);
res.send(`<!DOCTYPE html>\n${HTML}`);
<ServerHtml {...pageProps} {...props} />);
const httpStatus = NestedStatus.rewind();
res.status(httpStatus).send(`<!DOCTYPE html>\n${HTML}`);
}
// Set disableSSR to true to debug
@ -233,7 +245,7 @@ function baseServer(routes, createStore, { appInstanceName = appName } = {}) {
log.info(
`Falling back to default lang: "${config.get('defaultLang')}".`);
}
const i18n = makeI18n(i18nData, lang);
const i18n = makeI18n(i18nData, htmlLang);
const InitialComponent = (
<I18nProvider i18n={i18n}>
@ -243,25 +255,30 @@ function baseServer(routes, createStore, { appInstanceName = appName } = {}) {
</I18nProvider>
);
const asyncConnectLoadState = store.getState().reduxAsyncConnect.loadState || {};
const reduxResult = getReduxConnectError(asyncConnectLoadState);
if (reduxResult.status) {
return showErrorPage(res, reduxResult.status);
const errorPage = store.getState().errorPage;
if (errorPage && errorPage.hasError) {
return showErrorPage({
createStore,
error: errorPage.error,
req,
res,
status: errorPage.statusCode,
});
}
return hydrateOnClient({ component: InitialComponent });
})
.catch((error) => {
log.error({ err: error });
return showErrorPage(res, 500);
return showErrorPage({ createStore, error, status: 500, req, res });
});
});
});
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
log.error({ err });
return showErrorPage(res, 500);
app.use((error, req, res, next) => {
log.error({ err: error });
return showErrorPage({ createStore, error, status: 500, req, res });
});
return app;

Просмотреть файл

@ -4,6 +4,7 @@ import { applyMiddleware, compose } from 'redux';
import createLogger from 'redux-logger';
import config from 'config';
/*
* Enhance a redux store with common middleware.
*

Просмотреть файл

@ -13,6 +13,7 @@ import {
API_ADDON_TYPES_MAPPING,
VISIBLE_ADDON_TYPES_MAPPING,
} from 'core/constants';
import { AddonTypeNotFound } from 'core/errors';
import log from 'core/logger';
import purify from 'core/purify';
@ -163,7 +164,8 @@ export function apiAddonType(addonType) {
if (!Object.prototype.hasOwnProperty.call(
API_ADDON_TYPES_MAPPING, addonType
)) {
throw new Error(`"${addonType}" not found in API_ADDON_TYPES_MAPPING`);
throw new AddonTypeNotFound(
`"${addonType}" not found in API_ADDON_TYPES_MAPPING`);
}
return API_ADDON_TYPES_MAPPING[addonType];
}
@ -172,7 +174,8 @@ export function visibleAddonType(addonType) {
if (!Object.prototype.hasOwnProperty.call(
VISIBLE_ADDON_TYPES_MAPPING, addonType
)) {
throw new Error(`"${addonType}" not found in VISIBLE_ADDON_TYPES_MAPPING`);
throw new AddonTypeNotFound(
`"${addonType}" not found in VISIBLE_ADDON_TYPES_MAPPING`);
}
return VISIBLE_ADDON_TYPES_MAPPING[addonType];
}

Просмотреть файл

@ -0,0 +1,55 @@
import React from 'react';
import {
renderIntoDocument,
findRenderedComponentWithType,
} from 'react-addons-test-utils';
import { findDOMNode } from 'react-dom';
import { Provider } from 'react-redux';
import { loadFail } from 'redux-connect/lib/store';
import ErrorPage, { mapStateToProps } from 'amo/components/ErrorPage';
import createStore from 'amo/store';
import { createApiError } from 'core/api';
import { getFakeI18nInst } from 'tests/client/helpers';
import I18nProvider from 'core/i18n/Provider';
import { signedInApiState } from 'tests/client/amo/helpers';
describe('<ErrorPage />', () => {
function render({ ...props }, store = createStore(signedInApiState)) {
return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
<Provider store={store}>
<I18nProvider i18n={getFakeI18nInst()}>
<ErrorPage {...props} />
</I18nProvider>
</Provider>
), ErrorPage));
}
it('renders children when there are no errors', () => {
const rootNode = render({ children: <div>hello</div> });
assert.equal(rootNode.textContent, 'hello');
});
it('renders an error page on error', () => {
const store = createStore(signedInApiState);
const error = createApiError({
apiURL: 'http://test.com',
response: { status: 404 },
});
store.dispatch(loadFail('ReduxKey', error));
const rootNode = render({ children: <div>hello</div> }, store);
assert.notEqual(rootNode.textContent, 'hello');
assert.include(rootNode.textContent, 'Page not found');
});
});
describe('<ErrorPage mapStateToProps />', () => {
it('returns errorPage from state', () => {
assert.deepEqual(
mapStateToProps({ errorPage: 'howdy' }), { errorPage: 'howdy' });
});
});

Просмотреть файл

@ -11,6 +11,7 @@ import { LandingPageBase, mapStateToProps } from 'amo/components/LandingPage';
import createStore from 'amo/store';
import { ADDON_TYPE_EXTENSION, ADDON_TYPE_THEME } from 'core/constants';
import I18nProvider from 'core/i18n/Provider';
import { visibleAddonType } from 'core/utils';
import { fakeAddon } from 'tests/client/amo/helpers';
import { getFakeI18nInst } from 'tests/client/helpers';
@ -30,7 +31,7 @@ describe('<LandingPage />', () => {
it('renders a LandingPage with no addons set', () => {
const root = render({
addonType: ADDON_TYPE_EXTENSION,
params: { visibleAddonType: visibleAddonType(ADDON_TYPE_EXTENSION) },
});
assert.include(root.textContent, 'Featured extensions');
@ -39,7 +40,7 @@ describe('<LandingPage />', () => {
it('renders a LandingPage with themes HTML', () => {
const root = render({
addonType: ADDON_TYPE_THEME,
params: { visibleAddonType: visibleAddonType(ADDON_TYPE_THEME) },
});
assert.include(root.textContent, 'Featured themes');
@ -90,8 +91,8 @@ describe('<LandingPage />', () => {
},
}));
const root = render({
...mapStateToProps(
store.getState(), { params: { visibleAddonType: 'themes' } }),
...mapStateToProps(store.getState()),
params: { visibleAddonType: visibleAddonType(ADDON_TYPE_THEME) },
});
assert.deepEqual(
@ -101,9 +102,17 @@ describe('<LandingPage />', () => {
);
});
it('throws if add-on type is not supported', () => {
it('renders not found if add-on type is not supported', () => {
const root = render({ params: { visibleAddonType: 'XUL' } });
assert.include(root.textContent, 'Page not found');
});
it('throws for any error other than an unknown addonType', () => {
assert.throws(() => {
render({ addonType: 'XUL' });
}, 'No LandingPage content for addonType: XUL');
render({
apiAddonType: () => { throw new Error('Ice cream'); },
params: { visibleAddonType: 'doesnotmatter' },
});
}, 'Ice cream');
});
});

Просмотреть файл

@ -0,0 +1,41 @@
import React from 'react';
import {
renderIntoDocument,
findRenderedComponentWithType,
} from 'react-addons-test-utils';
import { findDOMNode } from 'react-dom';
import { Provider } from 'react-redux';
import { loadFail } from 'redux-connect/lib/store';
import NotAuthorized from 'amo/components/NotAuthorized';
import createStore from 'amo/store';
import { createApiError } from 'core/api';
import I18nProvider from 'core/i18n/Provider';
import { signedInApiState } from 'tests/client/amo/helpers';
import { getFakeI18nInst } from 'tests/client/helpers';
describe('<NotAuthorized />', () => {
function render({ ...props }) {
const store = createStore(signedInApiState);
const error = createApiError({
apiURL: 'http://test.com',
response: { status: 401 },
});
store.dispatch(loadFail('ReduxKey', error));
return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
<Provider store={store}>
<I18nProvider i18n={getFakeI18nInst()}>
<NotAuthorized {...props} />
</I18nProvider>
</Provider>
), NotAuthorized));
}
it('renders a not authorized error', () => {
const rootNode = render();
assert.include(rootNode.textContent, 'Not Authorized');
});
});

Просмотреть файл

@ -0,0 +1,41 @@
import React from 'react';
import {
renderIntoDocument,
findRenderedComponentWithType,
} from 'react-addons-test-utils';
import { findDOMNode } from 'react-dom';
import { Provider } from 'react-redux';
import { loadFail } from 'redux-connect/lib/store';
import NotFound from 'amo/components/NotFound';
import createStore from 'amo/store';
import { createApiError } from 'core/api';
import I18nProvider from 'core/i18n/Provider';
import { signedInApiState } from 'tests/client/amo/helpers';
import { getFakeI18nInst } from 'tests/client/helpers';
describe('<NotFound />', () => {
function render({ ...props }) {
const store = createStore(signedInApiState);
const error = createApiError({
apiURL: 'http://test.com',
response: { status: 404 },
});
store.dispatch(loadFail('ReduxKey', error));
return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
<Provider store={store}>
<I18nProvider i18n={getFakeI18nInst()}>
<NotFound {...props} />
</I18nProvider>
</Provider>
), NotFound));
}
it('renders a not found error', () => {
const rootNode = render();
assert.include(rootNode.textContent, 'Page not found');
});
});

Просмотреть файл

@ -0,0 +1,42 @@
import React from 'react';
import {
renderIntoDocument,
findRenderedComponentWithType,
} from 'react-addons-test-utils';
import { findDOMNode } from 'react-dom';
import { Provider } from 'react-redux';
import { loadFail } from 'redux-connect/lib/store';
import ServerError from 'amo/components/ServerError';
import createStore from 'amo/store';
import { createApiError } from 'core/api';
import I18nProvider from 'core/i18n/Provider';
import { signedInApiState } from 'tests/client/amo/helpers';
import { getFakeI18nInst } from 'tests/client/helpers';
describe('<ServerError />', () => {
function render({ ...props }) {
const store = createStore(signedInApiState);
const error = createApiError({
apiURL: 'http://test.com',
response: { status: 500 },
});
store.dispatch(loadFail('ReduxKey', error));
return findDOMNode(findRenderedComponentWithType(renderIntoDocument(
<Provider store={store}>
<I18nProvider i18n={getFakeI18nInst()}>
<ServerError {...props} />
</I18nProvider>
</Provider>
), ServerError));
}
it('renders a server error', () => {
const rootNode = render();
assert.include(rootNode.textContent,
'but there was an error with our server and');
});
});

Просмотреть файл

@ -1,23 +1,35 @@
import React from 'react';
import { findDOMNode } from 'react-dom';
import { renderIntoDocument } from 'react-addons-test-utils';
import { loadFail as reduxConnectLoadFail } from 'redux-connect/lib/store';
import {
renderIntoDocument,
findRenderedComponentWithType,
} from 'react-addons-test-utils';
import { Provider } from 'react-redux';
import { loadFail } from 'redux-connect/lib/store';
import {
// eslint-disable-next-line import/no-named-default
default as WrappedApp,
AppBase,
mapDispatchToProps,
mapStateToProps,
} from 'amo/containers/App';
import createStore from 'amo/store';
import DefaultErrorPage from 'amo/components/ErrorPage';
import { setClientApp, setLang } from 'core/actions';
import * as api from 'core/api';
import { createApiError } from 'core/api';
import { INSTALL_STATE } from 'core/constants';
import I18nProvider from 'core/i18n/Provider';
import { getFakeI18nInst } from 'tests/client/helpers';
describe('App', () => {
class FakeErrorPageComponent extends React.Component {
render() {
// eslint-disable-next-line react/prop-types
return <div>{this.props.children}</div>;
}
}
// eslint-disable-next-line react/no-multi-comp
class FakeFooterComponent extends React.Component {
render() {
return <footer />;
@ -46,18 +58,24 @@ describe('App', () => {
i18n: getFakeI18nInst(),
location: sinon.stub(),
isAuthenticated: true,
store: createStore(),
...customProps,
};
return renderIntoDocument(
<AppBase
FooterComponent={FakeFooterComponent}
InfoDialogComponent={FakeInfoDialogComponent}
MastHeadComponent={FakeMastHeadComponent}
SearchFormComponent={FakeSearchFormComponent}
{...props}>
{children}
</AppBase>
);
return findRenderedComponentWithType(renderIntoDocument(
<Provider store={props.store}>
<I18nProvider i18n={props.i18n}>
<AppBase
FooterComponent={FakeFooterComponent}
InfoDialogComponent={FakeInfoDialogComponent}
MastHeadComponent={FakeMastHeadComponent}
SearchFormComponent={FakeSearchFormComponent}
ErrorPage={FakeErrorPageComponent}
{...props}>
{children}
</AppBase>
</I18nProvider>
</Provider>
), AppBase);
}
it('renders its children', () => {
@ -95,36 +113,22 @@ describe('App', () => {
it('sets isHomePage to true when on the root path', () => {
const location = { pathname: '/en-GB/android/' };
const root = renderIntoDocument(<AppBase i18n={getFakeI18nInst()}
FooterComponent={FakeFooterComponent}
InfoDialogComponent={FakeInfoDialogComponent}
MastHeadComponent={FakeMastHeadComponent}
SearchFormComponent={FakeSearchFormComponent}
clientApp="android" lang="en-GB" location={location} />);
const root = render({ clientApp: 'android', lang: 'en-GB', location });
assert.isTrue(root.mastHead.props.isHomePage);
});
it('sets isHomePage to true when on the root path without a slash', () => {
const location = { pathname: '/en-GB/android' };
const root = renderIntoDocument(<AppBase i18n={getFakeI18nInst()}
FooterComponent={FakeFooterComponent}
InfoDialogComponent={FakeInfoDialogComponent}
MastHeadComponent={FakeMastHeadComponent}
SearchFormComponent={FakeSearchFormComponent}
clientApp="android" lang="en-GB" location={location} />);
const root = render({ clientApp: 'android', lang: 'en-GB', location });
assert.isTrue(root.mastHead.props.isHomePage);
});
it('sets isHomePage to false when not on the root path', () => {
const location = { pathname: '/en-GB/android/404/' };
const root = renderIntoDocument(<AppBase i18n={getFakeI18nInst()}
FooterComponent={FakeFooterComponent}
InfoDialogComponent={FakeInfoDialogComponent}
MastHeadComponent={FakeMastHeadComponent}
SearchFormComponent={FakeSearchFormComponent}
clientApp="android" lang="en-GB" location={location} />);
const root = render({
clientApp: 'android', lang: 'en-GB', location });
assert.isFalse(root.mastHead.props.isHomePage);
});
@ -137,24 +141,6 @@ describe('App', () => {
assert.ok(dispatch.calledWith({ type: INSTALL_STATE, payload }));
});
it('renders redux-connect errors', () => {
// This is just a sanity check to make sure the default component
// is wrapped in handleResourceErrors
const store = createStore();
const apiError = api.createApiError({
apiURL: 'https://some-url',
response: { status: 404 },
});
store.dispatch(reduxConnectLoadFail('someKey', apiError));
const root = renderIntoDocument(
<WrappedApp store={store} />
);
const rootNode = findDOMNode(root);
assert.include(rootNode.textContent, 'Not Found');
});
it('sets the clientApp as props', () => {
const store = createStore();
store.dispatch(setClientApp('android'));
@ -168,4 +154,25 @@ describe('App', () => {
const { lang } = mapStateToProps(store.getState());
assert.equal(lang, 'de');
});
it('renders an error component on error', () => {
const store = createStore();
const apiError = createApiError({
apiURL: 'https://some-url',
response: { status: 404 },
});
store.dispatch(loadFail('App', apiError));
const root = render({
ErrorPage: DefaultErrorPage,
clientApp: 'android',
lang: 'en-GB',
location: { pathname: '/en-GB/android/' },
store,
});
const rootNode = findDOMNode(root);
assert.include(rootNode.textContent, 'Page not found');
});
});

Просмотреть файл

@ -10,6 +10,7 @@ describe('amo createStore', () => {
'api',
'auth',
'categories',
'errorPage',
'errors',
'featured',
'infoDialog',

Просмотреть файл

@ -1,3 +1,6 @@
import NotAuthorized from 'amo/components/NotAuthorized';
import NotFound from 'amo/components/NotFound';
import ServerError from 'amo/components/ServerError';
import createStore from 'amo/store';
import * as featuredActions from 'amo/actions/featured';
import * as landingActions from 'amo/actions/landing';
@ -9,22 +12,20 @@ import {
SEARCH_SORT_TOP_RATED,
} from 'core/constants';
import {
getErrorComponent,
loadFeaturedAddons,
loadLandingAddons,
} from 'amo/utils';
import { unexpectedSuccess } from 'tests/client/helpers';
describe('amo/utils', () => {
let ownProps;
beforeEach(() => {
ownProps = {
params: {
application: 'android',
visibleAddonType: 'extensions',
},
};
});
const ownProps = {
params: {
application: 'android',
visibleAddonType: 'extensions',
},
};
describe('loadFeaturedAddons()', () => {
it('requests a large page of featured add-ons', () => {
@ -51,7 +52,6 @@ describe('amo/utils', () => {
describe('loadLandingAddons()', () => {
it('calls featured and search APIs to collect results', () => {
const addonType = ADDON_TYPE_THEME;
ownProps.params.visibleAddonType = 'themes';
const store = createStore({ application: 'android' });
store.dispatch(landingActions.getLanding({ addonType }));
const mockApi = sinon.mock(api);
@ -82,10 +82,47 @@ describe('amo/utils', () => {
})
.returns(Promise.resolve({ entities, result }));
return loadLandingAddons({ store, params: ownProps.params })
return loadLandingAddons({
store,
params: { ...ownProps.params, visibleAddonType: 'themes' },
})
.then(() => {
mockApi.verify();
});
});
it('returns a rejected Promise if the addonsType is wrong', () => {
const store = createStore({ application: 'android' });
return loadLandingAddons({
store,
params: { ...ownProps.params, visibleAddonType: 'addon-with-a-typo' },
})
.then(unexpectedSuccess)
.catch((err) => {
assert.equal(
err.message,
'"addon-with-a-typo" not found in API_ADDON_TYPES_MAPPING'
);
});
});
});
describe('getErrorComponent', () => {
it('returns a NotAuthorized component for 401 errors', () => {
assert.deepEqual(getErrorComponent(401), NotAuthorized);
});
it('returns a NotFound component for 404 errors', () => {
assert.deepEqual(getErrorComponent(404), NotFound);
});
it('returns a ServerError component for 500 errors', () => {
assert.deepEqual(getErrorComponent(500), ServerError);
});
it('returns a ServerError component by default', () => {
assert.deepEqual(getErrorComponent(501), ServerError);
});
});
});

Просмотреть файл

@ -0,0 +1,81 @@
import { createStore, combineReducers } from 'redux';
import { endGlobalLoad, loadFail } from 'redux-connect/lib/store';
import { createApiError } from 'core/api';
import errorPage, { initialState } from 'core/reducers/errorPage';
function getErrorPageState(store) {
return store.getState().errorPage;
}
describe('errorPage reducer', () => {
let store;
beforeEach(() => {
store = createStore(combineReducers({ errorPage }));
});
it('defaults to no error and nothing to clear', () => {
const state = errorPage(initialState, { type: 'unrelated' });
assert.deepEqual(state, initialState);
});
describe('REDUX_CONNECT_END_GLOBAL_LOAD', () => {
it('sets clearOnNext then clears it next time', () => {
store.dispatch({ type: 'unrelated', payload: {} });
let state = getErrorPageState(store);
assert.equal(state.clearOnNext, false);
store.dispatch(endGlobalLoad());
state = getErrorPageState(store);
assert.equal(state.clearOnNext, true);
store.dispatch(endGlobalLoad());
state = getErrorPageState(store);
assert.deepEqual(state.statusCode, initialState.statusCode);
});
});
describe('REDUX_CONNECT_LOAD_FAIL', () => {
it('sets an error on load fail; is cleared after the next request', () => {
store.dispatch({ type: 'unrelated', payload: {} });
let state = getErrorPageState(store);
assert.equal(state.error, null);
const error = createApiError({
apiURL: 'http://test.com',
response: { status: 404 },
});
store.dispatch(loadFail('ReduxKey', error));
state = getErrorPageState(store);
assert.equal(state.hasError, true);
assert.equal(state.statusCode, error.response.status);
assert.deepEqual(state.error, error);
store.dispatch(endGlobalLoad());
state = getErrorPageState(store);
assert.equal(state.clearOnNext, true);
store.dispatch(endGlobalLoad());
state = getErrorPageState(store);
assert.equal(state.clearOnNext, false);
assert.equal(state.hasError, false);
});
it('sets a 500 statusCode when no response is present', () => {
store.dispatch({ type: 'unrelated', payload: {} });
let state = getErrorPageState(store);
const error = { invalid: 'yup' };
store.dispatch(loadFail('ReduxKey', error));
state = getErrorPageState(store);
assert.equal(state.hasError, true);
assert.equal(state.statusCode, 500);
});
});
});

Просмотреть файл

@ -1,55 +0,0 @@
import React, { PropTypes } from 'react';
import { findDOMNode } from 'react-dom';
import { renderIntoDocument } from 'react-addons-test-utils';
import { loadFail as reduxConnectLoadFail } from 'redux-connect/lib/store';
import { createApiError } from 'core/api';
import { handleResourceErrors } from 'core/resourceErrors/decorator';
import createStore from 'amo/store';
class SomeComponentBase extends React.Component {
static propTypes = {
counter: PropTypes.number,
}
render() {
if (this.props.counter !== undefined) {
return <div>Counter: {this.props.counter}</div>;
}
return <div>Static Content</div>;
}
}
function renderToDOM({ store = createStore(), ...props } = {}) {
const SomeComponent = handleResourceErrors(SomeComponentBase);
const root = renderIntoDocument(
<SomeComponent store={store} {...props} />
);
return findDOMNode(root);
}
describe('core/resourceErrors/decorator', () => {
describe('handleResourceErrors', () => {
it('renders the wrapped component in lieu of errors', () => {
const rootNode = renderToDOM();
assert.include(rootNode.textContent, 'Static Content');
});
it('passes through arbitrary properties', () => {
const rootNode = renderToDOM({ counter: 2 });
assert.include(rootNode.textContent, 'Counter: 2');
});
it('renders redux-connect errors as resource errors', () => {
const store = createStore();
const apiError = createApiError({
apiURL: 'https://some-url',
response: { status: 404 },
});
store.dispatch(reduxConnectLoadFail('someKey', apiError));
const rootNode = renderToDOM({ store });
assert.include(rootNode.textContent, 'Not Found');
});
});
});

Просмотреть файл

@ -1,54 +0,0 @@
import { loadFail as reduxConnectLoadFail } from 'redux-connect/lib/store';
import createStore from 'amo/store';
import { createApiError } from 'core/api';
import { getReduxConnectError } from 'core/resourceErrors/reduxConnectErrors';
describe('core/reduxConnectErrors', () => {
describe('getReduxConnectError', () => {
let store;
function errorWithStatus(status) {
return createApiError({
apiURL: 'https://some-url',
response: { status },
});
}
function _getReduxConnectError() {
const loadState = store.getState().reduxAsyncConnect.loadState;
return getReduxConnectError(loadState);
}
beforeEach(() => {
store = createStore();
});
it('returns null when there are no redux-connect errors', () => {
assert.deepEqual(_getReduxConnectError(),
{ status: undefined, error: undefined });
});
it('returns 404 status info', () => {
store.dispatch(reduxConnectLoadFail('someKey', errorWithStatus(404)));
assert.deepEqual(_getReduxConnectError(),
{ status: 404, error: 'Not Found' });
});
it('returns 500 for multiple errors', () => {
store.dispatch(reduxConnectLoadFail('someKey', errorWithStatus(404)));
store.dispatch(reduxConnectLoadFail('anotherKey', errorWithStatus(404)));
assert.deepEqual(_getReduxConnectError(),
{ status: 500, error: 'Internal Server Error' });
});
it('preserves status for unexpected errors', () => {
store.dispatch(reduxConnectLoadFail('someKey', errorWithStatus(419)));
assert.deepEqual(_getReduxConnectError(),
{ status: 419, error: 'Unexpected Error' });
});
});
});