feat(client): re-enable failed update re-submission (#46064)

* refactor: return response with data from ajax

Because we still need to manipulate the data coming back from the server
(files -> challengeFiles) and we want to keep ajax.ts as the interface
between client and server we need to return the manipulated data with
the response.

* feat: re-enable failed updates flushing

* test: failed updates get resubmitted and flushed

* fix: convert settings requests to use { data }

* refactor: use preserveSession
This commit is contained in:
Oliver Eyton-Williams
2022-06-07 16:52:23 +02:00
committed by GitHub
parent fcf2dd7254
commit f0f44ca315
16 changed files with 231 additions and 129 deletions

View File

@@ -8,10 +8,10 @@ import { acceptTermsComplete, acceptTermsError } from './';
function* acceptTermsSaga({ payload: quincyEmails }) {
try {
const response = yield call(putUserAcceptsTerms, quincyEmails);
const { data } = yield call(putUserAcceptsTerms, quincyEmails);
yield put(acceptTermsComplete(quincyEmails));
yield put(createFlashMessage(response));
yield put(createFlashMessage(data));
} catch (e) {
yield put(acceptTermsError(e));
}

View File

@@ -22,10 +22,10 @@ function* tryToShowCodeAllySaga() {
yield put(showCodeAlly());
} else {
try {
const response = yield call(postUserToken);
const { data } = yield call(postUserToken);
if (response?.userToken) {
yield put(updateUserToken(response.userToken));
if (data?.userToken) {
yield put(updateUserToken(data.userToken));
yield put(showCodeAlly());
} else {
yield put(createFlashMessage(startProjectErrMessage));

View File

@@ -103,7 +103,9 @@ function* postChargeStripeCardSaga({
}) {
try {
const optimizedPayload = { paymentMethodId, amount, duration };
const { error } = yield call(postChargeStripeCard, optimizedPayload);
const {
data: { error }
} = yield call(postChargeStripeCard, optimizedPayload);
if (error) {
yield stripeCardErrorHandler(
error,

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-unused-vars */
import { ofType } from 'redux-observable';
import { merge, empty } from 'rxjs';
import {
@@ -51,15 +50,14 @@ function failedUpdateEpic(action$, state$) {
filter(() => store.get(key)),
filter(() => isServerOnlineSelector(state$.value)),
tap(() => {
let failures = store.get(key) || [];
let failures = store.get(key);
failures = Array.isArray(failures) ? failures : [];
let submitableFailures = failures.filter(isSubmitable);
// delete unsubmitable failed challenges
if (submitableFailures.length !== failures.length) {
store.set(key, submitableFailures);
failures = submitableFailures;
}
store.set(key, submitableFailures);
failures = submitableFailures;
let delayTime = 100;
const batch = failures.map((update, i) => {
@@ -77,11 +75,8 @@ function failedUpdateEpic(action$, state$) {
return delay(delayTime, () =>
postUpdate$(update)
.pipe(
switchMap(response => {
if (
response &&
(response.message || isGoodXHRStatus(response.status))
) {
switchMap(({ response, data }) => {
if (data?.message || isGoodXHRStatus(response?.status)) {
console.info(`${update.id} succeeded`);
// the request completed successfully
const failures = store.get(key) || [];
@@ -104,8 +99,7 @@ function failedUpdateEpic(action$, state$) {
ignoreElements()
);
return storeUpdates;
// return merge(storeUpdates, flushUpdates);
return merge(storeUpdates, flushUpdates);
}
export default failedUpdateEpic;

View File

@@ -8,8 +8,7 @@ jest.mock('../analytics');
const key = 'fcc-failed-updates';
// TODO: re-enable once we start flushing failed updates again
describe.skip('failed-updates-epic', () => {
describe('failed-updates-epic', () => {
it('should remove faulty backend challenges from localStorage', async () => {
store.set(key, failedSubmissions);

View File

@@ -16,15 +16,14 @@ function* fetchSessionUser() {
}
try {
const {
user = {},
result = '',
sessionMeta = {}
data: { user = {}, result = '', sessionMeta = {} }
} = yield call(getSessionUser);
const appUser = user[result] || {};
yield put(
fetchUserComplete({ user: appUser, username: result, sessionMeta })
);
} catch (e) {
console.log('failed to fetch user', e);
yield put(fetchUserError(e));
}
}
@@ -33,10 +32,9 @@ function* fetchOtherUser({ payload: maybeUser = '' }) {
try {
const maybeUserLC = maybeUser.toLowerCase();
const { entities: { user = {} } = {}, result = '' } = yield call(
getUserProfile,
maybeUserLC
);
const {
data: { entities: { user = {} } = {}, result = '' }
} = yield call(getUserProfile, maybeUserLC);
const otherUser = user[result] || {};
yield put(
fetchProfileForUserComplete({ user: otherUser, username: result })

View File

@@ -8,10 +8,10 @@ import { reportUserComplete, reportUserError } from './';
function* reportUserSaga({ payload }) {
try {
const response = yield call(postReportUser, payload);
const { data } = yield call(postReportUser, payload);
yield put(reportUserComplete());
yield put(createFlashMessage(response));
yield put(createFlashMessage(data));
} catch (e) {
yield put(reportUserError(e));
}

View File

@@ -46,14 +46,14 @@ export function* saveChallengeSaga() {
);
} else {
try {
const response = yield call(postSaveChallenge, body);
const { data } = yield call(postSaveChallenge, body);
if (response?.message) {
yield put(createFlashMessage(response));
} else if (response?.savedChallenges) {
if (data?.message) {
yield put(createFlashMessage(data));
} else if (data?.savedChallenges) {
yield put(
saveChallengeComplete(
mapFilesToChallengeFiles(response.savedChallenges)
mapFilesToChallengeFiles(data.savedChallenges)
)
);
yield put(

View File

@@ -55,9 +55,9 @@ import {
function* submitNewAboutSaga({ payload }) {
try {
const response = yield call(putUpdateMyAbout, payload);
yield put(submitNewAboutComplete({ ...response, payload }));
yield put(createFlashMessage(response));
const { data } = yield call(putUpdateMyAbout, payload);
yield put(submitNewAboutComplete({ ...data, payload }));
yield put(createFlashMessage(data));
} catch (e) {
yield put(submitNewAboutError(e));
}
@@ -65,9 +65,9 @@ function* submitNewAboutSaga({ payload }) {
function* submitNewUsernameSaga({ payload: username }) {
try {
const response = yield call(putUpdateMyUsername, username);
yield put(submitNewUsernameComplete({ ...response, username }));
yield put(createFlashMessage(response));
const { data } = yield call(putUpdateMyUsername, username);
yield put(submitNewUsernameComplete({ ...data, username }));
yield put(createFlashMessage(data));
} catch (e) {
yield put(submitNewUsernameError(e));
}
@@ -75,9 +75,9 @@ function* submitNewUsernameSaga({ payload: username }) {
function* submitProfileUISaga({ payload }) {
try {
const response = yield call(putUpdateMyProfileUI, payload);
yield put(submitProfileUIComplete({ ...response, payload }));
yield put(createFlashMessage(response));
const { data } = yield call(putUpdateMyProfileUI, payload);
yield put(submitProfileUIComplete({ ...data, payload }));
yield put(createFlashMessage(data));
} catch (e) {
yield put(submitProfileUIError);
}
@@ -85,10 +85,10 @@ function* submitProfileUISaga({ payload }) {
function* updateUserFlagSaga({ payload: update }) {
try {
const response = yield call(putUpdateUserFlag, update);
yield put(updateUserFlagComplete({ ...response, payload: update }));
const { data } = yield call(putUpdateUserFlag, update);
yield put(updateUserFlagComplete({ ...data, payload: update }));
yield put(
createFlashMessage({ ...response, variables: { theme: update.theme } })
createFlashMessage({ ...data, variables: { theme: update.theme } })
);
} catch (e) {
yield put(updateUserFlagError(e));
@@ -97,9 +97,9 @@ function* updateUserFlagSaga({ payload: update }) {
function* updateMySocialsSaga({ payload: update }) {
try {
const response = yield call(putUpdateMySocials, update);
yield put(updateMySocialsComplete({ ...response, payload: update }));
yield put(createFlashMessage({ ...response }));
const { data } = yield call(putUpdateMySocials, update);
yield put(updateMySocialsComplete({ ...data, payload: update }));
yield put(createFlashMessage({ ...data }));
} catch (e) {
yield put(updateMySocialsError);
}
@@ -108,9 +108,9 @@ function* updateMySocialsSaga({ payload: update }) {
function* updateMySoundSaga({ payload: update }) {
try {
store.set('fcc-sound', !!update.sound);
const response = yield call(putUpdateMySound, update);
yield put(updateMySoundComplete({ ...response, payload: update }));
yield put(createFlashMessage({ ...response }));
const { data } = yield call(putUpdateMySound, update);
yield put(updateMySoundComplete({ ...data, payload: update }));
yield put(createFlashMessage({ ...data }));
} catch (e) {
yield put(updateMySoundError);
}
@@ -118,9 +118,9 @@ function* updateMySoundSaga({ payload: update }) {
function* updateMyThemeSaga({ payload: update }) {
try {
const response = yield call(putUpdateMyTheme, update);
yield put(updateMyThemeComplete({ ...response, payload: update }));
yield put(createFlashMessage({ ...response }));
const { data } = yield call(putUpdateMyTheme, update);
yield put(updateMyThemeComplete({ ...data, payload: update }));
yield put(createFlashMessage({ ...data }));
} catch (e) {
yield put(updateMyThemeError);
}
@@ -128,9 +128,9 @@ function* updateMyThemeSaga({ payload: update }) {
function* updateMyHonestySaga({ payload: update }) {
try {
const response = yield call(putUpdateMyHonesty, update);
yield put(updateMyHonestyComplete({ ...response, payload: update }));
yield put(createFlashMessage({ ...response }));
const { data } = yield call(putUpdateMyHonesty, update);
yield put(updateMyHonestyComplete({ ...data, payload: update }));
yield put(createFlashMessage({ ...data }));
} catch (e) {
yield put(updateMyHonestyError);
}
@@ -138,9 +138,9 @@ function* updateMyHonestySaga({ payload: update }) {
function* updateMyQuincyEmailSaga({ payload: update }) {
try {
const response = yield call(putUpdateMyQuincyEmail, update);
yield put(updateMyQuincyEmailComplete({ ...response, payload: update }));
yield put(createFlashMessage({ ...response }));
const { data } = yield call(putUpdateMyQuincyEmail, update);
yield put(updateMyQuincyEmailComplete({ ...data, payload: update }));
yield put(createFlashMessage({ ...data }));
} catch (e) {
yield put(updateMyQuincyEmailError);
}
@@ -148,9 +148,9 @@ function* updateMyQuincyEmailSaga({ payload: update }) {
function* updateMyPortfolioSaga({ payload: update }) {
try {
const response = yield call(putUpdateMyPortfolio, update);
yield put(updateMyPortfolioComplete({ ...response, payload: update }));
yield put(createFlashMessage({ ...response }));
const { data } = yield call(putUpdateMyPortfolio, update);
yield put(updateMyPortfolioComplete({ ...data, payload: update }));
yield put(createFlashMessage({ ...data }));
} catch (e) {
yield put(updateMyPortfolioError);
}
@@ -158,7 +158,9 @@ function* updateMyPortfolioSaga({ payload: update }) {
function* validateUsernameSaga({ payload }) {
try {
const { exists } = yield call(getUsernameExists, payload);
const {
data: { exists }
} = yield call(getUsernameExists, payload);
yield put(validateUsernameComplete(exists));
} catch (e) {
yield put(validateUsernameError(e));
@@ -189,10 +191,9 @@ function* verifyCertificationSaga({ payload }) {
// redux says challenges are complete, call back end
try {
const { response, isCertMap, completedChallenges } = yield call(
putVerifyCert,
payload
);
const {
data: { response, isCertMap, completedChallenges }
} = yield call(putVerifyCert, payload);
yield put(
verifyCertComplete({
...response,

View File

@@ -13,14 +13,14 @@ function* updateMyEmailSaga({ payload: email = '' }) {
return;
}
try {
const response = yield call(putUserUpdateEmail, email);
const { data } = yield call(putUserUpdateEmail, email);
yield put(
updateMyEmailComplete({
...response,
...data,
payload: { email, isEmailVerified: false }
})
);
yield put(createFlashMessage(response));
yield put(createFlashMessage(data));
} catch (e) {
yield put(updateMyEmailError(e));
}

View File

@@ -7,8 +7,8 @@ import { showCertComplete, showCertError } from '.';
function* getShowCertSaga({ payload: { username, certSlug } }) {
try {
const response = yield call(getShowCert, username, certSlug);
const { messages } = response;
const { data } = yield call(getShowCert, username, certSlug);
const { messages } = data;
if (messages && messages.length) {
for (let i = 0; i < messages.length; i++) {
yield put(createFlashMessage(messages[i]));
@@ -16,7 +16,7 @@ function* getShowCertSaga({ payload: { username, certSlug } }) {
yield call(navigate, '/');
return;
}
yield put(showCertComplete(response));
yield put(showCertComplete(data));
} catch (e) {
yield put(showCertError(e));
}

View File

@@ -17,12 +17,9 @@ const message = {
function* deleteUserTokenSaga() {
try {
const response = yield call(deleteUserToken);
const { data } = yield call(deleteUserToken);
if (
response &&
Object.prototype.hasOwnProperty.call(response, 'userToken')
) {
if (data && Object.prototype.hasOwnProperty.call(data, 'userToken')) {
yield put(deleteUserTokenComplete());
yield put(createFlashMessage(message.deleted));
} else {

View File

@@ -14,7 +14,8 @@ export function getGuideUrl({ forumTopicId, title = '' }: GuideData): string {
: `${forumLocation}/search?q=${title}%20in%3Atitle%20order%3Aviews`;
}
export function isGoodXHRStatus(status: string): boolean {
export function isGoodXHRStatus(status?: string): boolean {
if (!status) return false;
const statusInt = parseInt(status, 10);
return (statusInt >= 200 && statusInt < 400) || statusInt === 402;
}

View File

@@ -1,5 +1,6 @@
import { from, Observable } from 'rxjs';
import { post } from '../../../utils/ajax';
import type { ResponseWithData } from '../../../utils/ajax';
interface PostData {
endpoint: string;
@@ -9,6 +10,6 @@ interface PostData {
export default function postUpdate$({
endpoint,
payload
}: PostData): Observable<void> {
}: PostData): Observable<ResponseWithData<void>> {
return from(post(endpoint, payload));
}

View File

@@ -26,21 +26,42 @@ function getCSRFToken() {
return token ?? '';
}
// TODO: Might want to handle flash messages as close to the request as possible
// to make use of the Response object (message, status, etc)
async function get<T>(path: string): Promise<T> {
return fetch(`${base}${path}`, defaultOptions).then<T>(res => res.json());
export interface ResponseWithData<T> {
response: Response;
data: T;
}
export function post<T = void>(path: string, body: unknown): Promise<T> {
// TODO: Might want to handle flash messages as close to the request as possible
// to make use of the Response object (message, status, etc)
async function get<T>(path: string): Promise<ResponseWithData<T>> {
const response = await fetch(`${base}${path}`, defaultOptions);
return combineDataWithResponse(response);
}
async function combineDataWithResponse<T>(response: Response) {
const data = (await response.json()) as T;
return { response, data };
}
export function post<T = void>(
path: string,
body: unknown
): Promise<ResponseWithData<T>> {
return request('POST', path, body);
}
function put<T = void>(path: string, body: unknown): Promise<T> {
function put<T = void>(
path: string,
body: unknown
): Promise<ResponseWithData<T>> {
return request('PUT', path, body);
}
function deleteRequest<T = void>(path: string, body: unknown): Promise<T> {
function deleteRequest<T = void>(
path: string,
body: unknown
): Promise<ResponseWithData<T>> {
return request('DELETE', path, body);
}
@@ -48,7 +69,7 @@ async function request<T>(
method: 'POST' | 'PUT' | 'DELETE',
path: string,
body: unknown
): Promise<T> {
): Promise<ResponseWithData<T>> {
const options: RequestInit = {
...defaultOptions,
method,
@@ -58,7 +79,9 @@ async function request<T>(
},
body: JSON.stringify(body)
};
return fetch(`${base}${path}`, options).then<T>(res => res.json());
const response = await fetch(`${base}${path}`, options);
return combineDataWithResponse(response);
}
/** GET **/
@@ -128,17 +151,20 @@ function mapKeyToFileKey<K>(
return files.map(({ key, ...rest }) => ({ ...rest, fileKey: key }));
}
export function getSessionUser(): Promise<SessionUser> {
const response: Promise<ApiUser & ApiSessionResponse> = get(
'/user/get-session-user'
);
export function getSessionUser(): Promise<ResponseWithData<SessionUser>> {
const responseWithData: Promise<
ResponseWithData<ApiUser & ApiSessionResponse>
> = get('/user/get-session-user');
// TODO: Once DB is migrated, no longer need to parse `files` -> `challengeFiles` etc.
return response.then(data => {
return responseWithData.then(({ response, data }) => {
const { result, user } = parseApiResponseToClientUser(data);
return {
sessionMeta: data.sessionMeta,
result,
user
response,
data: {
sessionMeta: data.sessionMeta,
result,
user
}
};
});
}
@@ -147,18 +173,23 @@ type UserProfileResponse = {
entities: Omit<UserResponse, 'result'>;
result: string | undefined;
};
export function getUserProfile(username: string): Promise<UserProfileResponse> {
const response: Promise<{ entities?: ApiUser; result?: string }> = get(
export function getUserProfile(
username: string
): Promise<ResponseWithData<UserProfileResponse>> {
const responseWithData = get<{ entities?: ApiUser; result?: string }>(
`/api/users/get-public-profile?username=${username}`
);
return response.then(data => {
return responseWithData.then(({ response, data }) => {
const { result, user } = parseApiResponseToClientUser({
user: data.entities?.user ?? {},
result: data.result
});
return {
entities: { user },
result
response,
data: {
entities: { user },
result
}
};
});
}
@@ -169,11 +200,16 @@ interface Cert {
date: Date;
completionTime: string;
}
export function getShowCert(username: string, certSlug: string): Promise<Cert> {
export function getShowCert(
username: string,
certSlug: string
): Promise<ResponseWithData<Cert>> {
return get(`/certificate/showCert/${username}/${certSlug}`);
}
export function getUsernameExists(username: string): Promise<boolean> {
export function getUsernameExists(
username: string
): Promise<ResponseWithData<boolean>> {
return get(`/api/users/exists?username=${username}`);
}
@@ -190,44 +226,48 @@ interface Donation {
}
// TODO: Verify if the body has and needs this Donation type. The api seems to
// just need the body to exist, but doesn't seem to use the properties.
export function addDonation(body: Donation): Promise<void> {
export function addDonation(body: Donation): Promise<ResponseWithData<void>> {
return post('/donate/add-donation', body);
}
export function postChargeStripe(body: Donation): Promise<void> {
export function postChargeStripe(
body: Donation
): Promise<ResponseWithData<void>> {
return post('/donate/charge-stripe', body);
}
export function postChargeStripeCard(body: Donation): Promise<void> {
export function postChargeStripeCard(
body: Donation
): Promise<ResponseWithData<void>> {
return post('/donate/charge-stripe-card', body);
}
interface Report {
username: string;
reportDescription: string;
}
export function postReportUser(body: Report): Promise<void> {
export function postReportUser(body: Report): Promise<ResponseWithData<void>> {
return post('/user/report-user', body);
}
// Both are called without a payload in danger-zone-saga,
// which suggests both are sent without any body
// TODO: Convert to DELETE
export function postDeleteAccount(): Promise<void> {
export function postDeleteAccount(): Promise<ResponseWithData<void>> {
return post('/account/delete', {});
}
export function postResetProgress(): Promise<void> {
export function postResetProgress(): Promise<ResponseWithData<void>> {
return post('/account/reset-progress', {});
}
export function postUserToken(): Promise<void> {
export function postUserToken(): Promise<ResponseWithData<void>> {
return post('/user/user-token', {});
}
export function postSaveChallenge(body: {
id: string;
files: ChallengeFiles;
}): Promise<void> {
}): Promise<ResponseWithData<void>> {
return post('/save-challenge', body);
}
@@ -239,17 +279,21 @@ interface MyAbout {
about: string;
picture: string;
}
export function putUpdateMyAbout(values: MyAbout): Promise<void> {
export function putUpdateMyAbout(
values: MyAbout
): Promise<ResponseWithData<void>> {
return put('/update-my-about', { ...values });
}
export function putUpdateMyUsername(username: string): Promise<void> {
export function putUpdateMyUsername(
username: string
): Promise<ResponseWithData<void>> {
return put('/update-my-username', { username });
}
export function putUpdateMyProfileUI(
profileUI: User['profileUI']
): Promise<void> {
): Promise<ResponseWithData<void>> {
return put('/update-my-profileui', { profileUI });
}
@@ -258,59 +302,65 @@ export function putUpdateMyProfileUI(
// https://stackoverflow.com/a/60807986
export function putUpdateUserFlag(
update: Record<string, string>
): Promise<void> {
): Promise<ResponseWithData<void>> {
return put('/update-user-flag', update);
}
export function putUpdateMySocials(
update: Record<string, string>
): Promise<void> {
): Promise<ResponseWithData<void>> {
return put('/update-my-socials', update);
}
export function putUpdateMySound(
update: Record<string, string>
): Promise<void> {
): Promise<ResponseWithData<void>> {
return put('/update-my-sound', update);
}
export function putUpdateMyTheme(
update: Record<string, string>
): Promise<void> {
): Promise<ResponseWithData<void>> {
return put('/update-my-theme', update);
}
export function putUpdateMyHonesty(
update: Record<string, string>
): Promise<void> {
): Promise<ResponseWithData<void>> {
return put('/update-my-honesty', update);
}
export function putUpdateMyQuincyEmail(
update: Record<string, string>
): Promise<void> {
): Promise<ResponseWithData<void>> {
return put('/update-my-quincy-email', update);
}
export function putUpdateMyPortfolio(
update: Record<string, string>
): Promise<void> {
): Promise<ResponseWithData<void>> {
return put('/update-my-portfolio', update);
}
export function putUserAcceptsTerms(quincyEmails: boolean): Promise<void> {
export function putUserAcceptsTerms(
quincyEmails: boolean
): Promise<ResponseWithData<void>> {
return put('/update-privacy-terms', { quincyEmails });
}
export function putUserUpdateEmail(email: string): Promise<void> {
export function putUserUpdateEmail(
email: string
): Promise<ResponseWithData<void>> {
return put('/update-my-email', { email });
}
export function putVerifyCert(certSlug: string): Promise<void> {
export function putVerifyCert(
certSlug: string
): Promise<ResponseWithData<void>> {
return put('/certificate/verify', { certSlug });
}
/** DELETE **/
export function deleteUserToken(): Promise<void> {
export function deleteUserToken(): Promise<ResponseWithData<void>> {
return deleteRequest('/user/user-token', {});
}

View File

@@ -0,0 +1,59 @@
const store = require('store');
const failedUpdates = [
{
endpoint: '/modern-challenge-completed',
payload: { id: '5dc1798ff86c76b9248c6eb3', challengeType: 0 },
id: '4bd1d704-cfaa-44f7-92a3-bc0d857dbaa6'
},
{
endpoint: '/modern-challenge-completed',
payload: { id: '5dc17d3bf86c76b9248c6eb4', challengeType: 0 },
id: 'ea289e2f-a5d2-45e0-b795-0f9f4afc5124'
}
];
const failedUpdatesKey = 'fcc-failed-updates';
describe('failed update flushing', () => {
before(() => {
cy.exec('npm run seed');
cy.login();
});
beforeEach(() => {
cy.preserveSession();
});
it('should resubmit failed updates, check they are stored, then flush', () => {
store.set(failedUpdatesKey, failedUpdates);
cy.request('http://localhost:3000/user/get-session-user')
.its('body.user.developmentuser.completedChallenges')
.then(completedChallenges => {
const completedIds = completedChallenges.map(challenge => challenge.id);
failedUpdates.forEach(failedUpdate => {
expect(completedIds).not.to.include(failedUpdate.payload.id);
});
});
cy.intercept('http://localhost:3000/modern-challenge-completed').as(
'completed'
);
cy.wrap(store.get(failedUpdatesKey)).should('deep.equal', failedUpdates);
cy.reload();
cy.wait('@completed');
// if we don't wait for both requests to complete, we have a race condition
cy.wait('@completed');
cy.request('http://localhost:3000/user/get-session-user')
.its('body.user.developmentuser.completedChallenges')
.then(completedChallenges => {
const completedIds = completedChallenges.map(challenge => challenge.id);
failedUpdates.forEach(failedUpdate => {
expect(completedIds).to.include(failedUpdate.payload.id);
});
expect(store.get(failedUpdatesKey)).to.be.empty;
});
});
});