From f0f44ca3157ab71e32ae3c8a8fac76645841c882 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Tue, 7 Jun 2022 16:52:23 +0200 Subject: [PATCH] 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 --- client/src/redux/accept-terms-saga.js | 4 +- client/src/redux/codeally-saga.js | 6 +- client/src/redux/donation-saga.js | 4 +- client/src/redux/failed-updates-epic.js | 20 +-- client/src/redux/failed-updates-epic.test.js | 3 +- client/src/redux/fetch-user-saga.js | 12 +- client/src/redux/report-user-saga.js | 4 +- client/src/redux/save-challenge-saga.js | 10 +- client/src/redux/settings/settings-sagas.js | 71 ++++----- .../src/redux/settings/update-email-saga.js | 6 +- client/src/redux/show-cert-saga.js | 6 +- client/src/redux/user-token-saga.js | 7 +- .../src/templates/Challenges/utils/index.ts | 3 +- .../templates/Challenges/utils/post-update.ts | 3 +- client/src/utils/ajax.ts | 142 ++++++++++++------ .../learn/challenges/failed-updates.js | 59 ++++++++ 16 files changed, 231 insertions(+), 129 deletions(-) create mode 100644 cypress/integration/learn/challenges/failed-updates.js diff --git a/client/src/redux/accept-terms-saga.js b/client/src/redux/accept-terms-saga.js index b97bf59a601..0c8092efec0 100644 --- a/client/src/redux/accept-terms-saga.js +++ b/client/src/redux/accept-terms-saga.js @@ -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)); } diff --git a/client/src/redux/codeally-saga.js b/client/src/redux/codeally-saga.js index cc5c6a09221..820e6600410 100644 --- a/client/src/redux/codeally-saga.js +++ b/client/src/redux/codeally-saga.js @@ -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)); diff --git a/client/src/redux/donation-saga.js b/client/src/redux/donation-saga.js index 79860f4a2c2..8ec0ca55f95 100644 --- a/client/src/redux/donation-saga.js +++ b/client/src/redux/donation-saga.js @@ -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, diff --git a/client/src/redux/failed-updates-epic.js b/client/src/redux/failed-updates-epic.js index 0aad82e75cd..f8f78feee1a 100644 --- a/client/src/redux/failed-updates-epic.js +++ b/client/src/redux/failed-updates-epic.js @@ -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; diff --git a/client/src/redux/failed-updates-epic.test.js b/client/src/redux/failed-updates-epic.test.js index c7eb49d76c5..f677d5f290f 100644 --- a/client/src/redux/failed-updates-epic.test.js +++ b/client/src/redux/failed-updates-epic.test.js @@ -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); diff --git a/client/src/redux/fetch-user-saga.js b/client/src/redux/fetch-user-saga.js index 5d60b990b8b..a77a72a74d2 100644 --- a/client/src/redux/fetch-user-saga.js +++ b/client/src/redux/fetch-user-saga.js @@ -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 }) diff --git a/client/src/redux/report-user-saga.js b/client/src/redux/report-user-saga.js index 34906e11151..f3c89c91a02 100644 --- a/client/src/redux/report-user-saga.js +++ b/client/src/redux/report-user-saga.js @@ -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)); } diff --git a/client/src/redux/save-challenge-saga.js b/client/src/redux/save-challenge-saga.js index 0c87196bb26..02d938fe2f2 100644 --- a/client/src/redux/save-challenge-saga.js +++ b/client/src/redux/save-challenge-saga.js @@ -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( diff --git a/client/src/redux/settings/settings-sagas.js b/client/src/redux/settings/settings-sagas.js index b704019de14..62c3d7b7f1a 100644 --- a/client/src/redux/settings/settings-sagas.js +++ b/client/src/redux/settings/settings-sagas.js @@ -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, diff --git a/client/src/redux/settings/update-email-saga.js b/client/src/redux/settings/update-email-saga.js index a1dce59b0ca..9108d8c5f9e 100644 --- a/client/src/redux/settings/update-email-saga.js +++ b/client/src/redux/settings/update-email-saga.js @@ -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)); } diff --git a/client/src/redux/show-cert-saga.js b/client/src/redux/show-cert-saga.js index 682aafe20d2..2cb1a4be3c6 100644 --- a/client/src/redux/show-cert-saga.js +++ b/client/src/redux/show-cert-saga.js @@ -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)); } diff --git a/client/src/redux/user-token-saga.js b/client/src/redux/user-token-saga.js index e99f9f0694b..0928aff1b24 100644 --- a/client/src/redux/user-token-saga.js +++ b/client/src/redux/user-token-saga.js @@ -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 { diff --git a/client/src/templates/Challenges/utils/index.ts b/client/src/templates/Challenges/utils/index.ts index 2b916adf977..2d6e96dee8b 100644 --- a/client/src/templates/Challenges/utils/index.ts +++ b/client/src/templates/Challenges/utils/index.ts @@ -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; } diff --git a/client/src/templates/Challenges/utils/post-update.ts b/client/src/templates/Challenges/utils/post-update.ts index e588bbe73d9..642d01e396d 100644 --- a/client/src/templates/Challenges/utils/post-update.ts +++ b/client/src/templates/Challenges/utils/post-update.ts @@ -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 { +}: PostData): Observable> { return from(post(endpoint, payload)); } diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index 922204ba69a..a76d7797c3c 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -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(path: string): Promise { - return fetch(`${base}${path}`, defaultOptions).then(res => res.json()); +export interface ResponseWithData { + response: Response; + data: T; } -export function post(path: string, body: unknown): Promise { +// 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(path: string): Promise> { + const response = await fetch(`${base}${path}`, defaultOptions); + + return combineDataWithResponse(response); +} + +async function combineDataWithResponse(response: Response) { + const data = (await response.json()) as T; + return { response, data }; +} + +export function post( + path: string, + body: unknown +): Promise> { return request('POST', path, body); } -function put(path: string, body: unknown): Promise { +function put( + path: string, + body: unknown +): Promise> { return request('PUT', path, body); } -function deleteRequest(path: string, body: unknown): Promise { +function deleteRequest( + path: string, + body: unknown +): Promise> { return request('DELETE', path, body); } @@ -48,7 +69,7 @@ async function request( method: 'POST' | 'PUT' | 'DELETE', path: string, body: unknown -): Promise { +): Promise> { const options: RequestInit = { ...defaultOptions, method, @@ -58,7 +79,9 @@ async function request( }, body: JSON.stringify(body) }; - return fetch(`${base}${path}`, options).then(res => res.json()); + + const response = await fetch(`${base}${path}`, options); + return combineDataWithResponse(response); } /** GET **/ @@ -128,17 +151,20 @@ function mapKeyToFileKey( return files.map(({ key, ...rest }) => ({ ...rest, fileKey: key })); } -export function getSessionUser(): Promise { - const response: Promise = get( - '/user/get-session-user' - ); +export function getSessionUser(): Promise> { + const responseWithData: Promise< + ResponseWithData + > = 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; result: string | undefined; }; -export function getUserProfile(username: string): Promise { - const response: Promise<{ entities?: ApiUser; result?: string }> = get( +export function getUserProfile( + username: string +): Promise> { + 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 { +export function getShowCert( + username: string, + certSlug: string +): Promise> { return get(`/certificate/showCert/${username}/${certSlug}`); } -export function getUsernameExists(username: string): Promise { +export function getUsernameExists( + username: string +): Promise> { 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 { +export function addDonation(body: Donation): Promise> { return post('/donate/add-donation', body); } -export function postChargeStripe(body: Donation): Promise { +export function postChargeStripe( + body: Donation +): Promise> { return post('/donate/charge-stripe', body); } -export function postChargeStripeCard(body: Donation): Promise { +export function postChargeStripeCard( + body: Donation +): Promise> { return post('/donate/charge-stripe-card', body); } interface Report { username: string; reportDescription: string; } -export function postReportUser(body: Report): Promise { +export function postReportUser(body: Report): Promise> { 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 { +export function postDeleteAccount(): Promise> { return post('/account/delete', {}); } -export function postResetProgress(): Promise { +export function postResetProgress(): Promise> { return post('/account/reset-progress', {}); } -export function postUserToken(): Promise { +export function postUserToken(): Promise> { return post('/user/user-token', {}); } export function postSaveChallenge(body: { id: string; files: ChallengeFiles; -}): Promise { +}): Promise> { return post('/save-challenge', body); } @@ -239,17 +279,21 @@ interface MyAbout { about: string; picture: string; } -export function putUpdateMyAbout(values: MyAbout): Promise { +export function putUpdateMyAbout( + values: MyAbout +): Promise> { return put('/update-my-about', { ...values }); } -export function putUpdateMyUsername(username: string): Promise { +export function putUpdateMyUsername( + username: string +): Promise> { return put('/update-my-username', { username }); } export function putUpdateMyProfileUI( profileUI: User['profileUI'] -): Promise { +): Promise> { return put('/update-my-profileui', { profileUI }); } @@ -258,59 +302,65 @@ export function putUpdateMyProfileUI( // https://stackoverflow.com/a/60807986 export function putUpdateUserFlag( update: Record -): Promise { +): Promise> { return put('/update-user-flag', update); } export function putUpdateMySocials( update: Record -): Promise { +): Promise> { return put('/update-my-socials', update); } export function putUpdateMySound( update: Record -): Promise { +): Promise> { return put('/update-my-sound', update); } export function putUpdateMyTheme( update: Record -): Promise { +): Promise> { return put('/update-my-theme', update); } export function putUpdateMyHonesty( update: Record -): Promise { +): Promise> { return put('/update-my-honesty', update); } export function putUpdateMyQuincyEmail( update: Record -): Promise { +): Promise> { return put('/update-my-quincy-email', update); } export function putUpdateMyPortfolio( update: Record -): Promise { +): Promise> { return put('/update-my-portfolio', update); } -export function putUserAcceptsTerms(quincyEmails: boolean): Promise { +export function putUserAcceptsTerms( + quincyEmails: boolean +): Promise> { return put('/update-privacy-terms', { quincyEmails }); } -export function putUserUpdateEmail(email: string): Promise { +export function putUserUpdateEmail( + email: string +): Promise> { return put('/update-my-email', { email }); } -export function putVerifyCert(certSlug: string): Promise { +export function putVerifyCert( + certSlug: string +): Promise> { return put('/certificate/verify', { certSlug }); } /** DELETE **/ -export function deleteUserToken(): Promise { +export function deleteUserToken(): Promise> { return deleteRequest('/user/user-token', {}); } diff --git a/cypress/integration/learn/challenges/failed-updates.js b/cypress/integration/learn/challenges/failed-updates.js new file mode 100644 index 00000000000..4e7e8389e02 --- /dev/null +++ b/cypress/integration/learn/challenges/failed-updates.js @@ -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; + }); + }); +});