Skip to content

Commit e4010f5

Browse files
Merge pull request #702 from freeCodeCamp/main
Create a new pull request by comparing changes across two branches
2 parents 8df148c + 7410ed0 commit e4010f5

File tree

47 files changed

+582
-193
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+582
-193
lines changed

api/src/routes/donate.test.ts

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import {
44
devLogin,
55
setupServer,
66
superRequest,
7-
defaultUserEmail
7+
defaultUserEmail,
8+
defaultUserId
89
} from '../../jest.utils';
910
import { createUserInput } from '../utils/create-user';
1011

@@ -42,6 +43,21 @@ const userWithProgress: Prisma.userCreateInput = {
4243
}
4344
]
4445
};
46+
const donationMock = {
47+
endDate: null,
48+
startDate: {
49+
date: '2024-07-17T10:20:56.076Z',
50+
when: '2024-07-17T10:20:56.076+00:00'
51+
},
52+
id: '66979a414748aa2f3ba36d41',
53+
amount: 500,
54+
customerId: 'cust_test_id',
55+
duration: 'month',
56+
57+
provider: 'stripe',
58+
subscriptionId: 'sub_test_id',
59+
userId: defaultUserId
60+
};
4561
const sharedDonationReqBody = {
4662
amount: 500,
4763
duration: 'month'
@@ -93,6 +109,9 @@ const mockSubRetrieveObj = {
93109
status: 'active'
94110
};
95111
const mockSubRetrieve = jest.fn(() => Promise.resolve(mockSubRetrieveObj));
112+
const mockCheckoutSessionCreate = jest.fn(() =>
113+
Promise.resolve({ id: 'checkout_session_id' })
114+
);
96115
const mockCustomerUpdate = jest.fn();
97116
const generateMockSubCreate = (status: string) => () =>
98117
Promise.resolve({
@@ -120,15 +139,22 @@ jest.mock('stripe', () => {
120139
subscriptions: {
121140
create: mockSubCreate,
122141
retrieve: mockSubRetrieve
142+
},
143+
checkout: {
144+
sessions: {
145+
create: mockCheckoutSessionCreate
146+
}
123147
}
124148
};
125149
});
126150
});
127151

128152
describe('Donate', () => {
153+
let setCookies: string[];
129154
setupServer();
130155
describe('Authenticated User', () => {
131156
let superPost: ReturnType<typeof createSuperRequest>;
157+
let superPut: ReturnType<typeof createSuperRequest>;
132158
const verifyUpdatedUserAndNewDonation = async (email: string) => {
133159
const user = await fastifyTestInstance.prisma.user.findFirst({
134160
where: { email }
@@ -162,8 +188,9 @@ describe('Donate', () => {
162188
};
163189

164190
beforeEach(async () => {
165-
const setCookies = await devLogin();
191+
setCookies = await devLogin();
166192
superPost = createSuperRequest({ method: 'POST', setCookies });
193+
superPut = createSuperRequest({ method: 'PUT', setCookies });
167194
await fastifyTestInstance.prisma.user.updateMany({
168195
where: { email: userWithProgress.email },
169196
data: userWithProgress
@@ -302,6 +329,39 @@ describe('Donate', () => {
302329
});
303330
});
304331

332+
describe('PUT /donate/update-stripe-card', () => {
333+
it('should return 200 and return session id', async () => {
334+
await fastifyTestInstance.prisma.donation.create({
335+
data: donationMock
336+
});
337+
const response = await superPut('/donate/update-stripe-card').send({});
338+
expect(mockCheckoutSessionCreate).toHaveBeenCalledWith({
339+
cancel_url: 'http://localhost:8000/update-stripe-card',
340+
customer: 'cust_test_id',
341+
mode: 'setup',
342+
payment_method_types: ['card'],
343+
setup_intent_data: {
344+
metadata: {
345+
customer_id: 'cust_test_id',
346+
subscription_id: 'sub_test_id'
347+
}
348+
},
349+
success_url:
350+
'http://localhost:8000/update-stripe-card?session_id={CHECKOUT_SESSION_ID}'
351+
});
352+
expect(response.body).toEqual({ sessionId: 'checkout_session_id' });
353+
expect(response.status).toBe(200);
354+
});
355+
it('should return 500 if there is no donation record', async () => {
356+
const response = await superPut('/donate/update-stripe-card').send({});
357+
expect(response.body).toEqual({
358+
message: 'flash.generic-error',
359+
type: 'danger'
360+
});
361+
expect(response.status).toBe(500);
362+
});
363+
});
364+
305365
describe('POST /donate/create-stripe-payment-intent', () => {
306366
it('should return 200 and call stripe api properly', async () => {
307367
mockSubCreate.mockImplementationOnce(
@@ -432,16 +492,16 @@ describe('Donate', () => {
432492
});
433493

434494
describe('Unauthenticated User', () => {
435-
let setCookies: string[];
436495
// Get the CSRF cookies from an unprotected route
437496
beforeAll(async () => {
438497
const res = await superRequest('/status/ping', { method: 'GET' });
439498
setCookies = res.get('Set-Cookie');
440499
});
441500

442-
const endpoints: { path: string; method: 'POST' }[] = [
501+
const endpoints: { path: string; method: 'POST' | 'PUT' }[] = [
443502
{ path: '/donate/add-donation', method: 'POST' },
444-
{ path: '/donate/charge-stripe-card', method: 'POST' }
503+
{ path: '/donate/charge-stripe-card', method: 'POST' },
504+
{ path: '/donate/update-stripe-card', method: 'PUT' }
445505
];
446506

447507
endpoints.forEach(({ path, method }) => {

api/src/routes/donate.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
allStripeProductIdsArray
99
} from '../../../shared/config/donation-settings';
1010
import * as schemas from '../schemas';
11-
import { STRIPE_SECRET_KEY } from '../utils/env';
11+
import { STRIPE_SECRET_KEY, HOME_LOCATION } from '../utils/env';
1212
import { inLastFiveMinutes } from '../utils/validate-donation';
1313
import { findOrCreateUser } from './helpers/auth-helpers';
1414

@@ -30,6 +30,35 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
3030
typescript: true
3131
});
3232

33+
fastify.put(
34+
'/donate/update-stripe-card',
35+
{
36+
schema: schemas.updateStripeCard
37+
},
38+
async req => {
39+
const donation = await fastify.prisma.donation.findFirst({
40+
where: { userId: req.user?.id, provider: 'stripe' }
41+
});
42+
if (!donation)
43+
throw Error(`Stripe donation record not found: ${req.user?.id}`);
44+
const { customerId, subscriptionId } = donation;
45+
const session = await stripe.checkout.sessions.create({
46+
payment_method_types: ['card'],
47+
mode: 'setup',
48+
customer: customerId,
49+
setup_intent_data: {
50+
metadata: {
51+
customer_id: customerId,
52+
subscription_id: subscriptionId
53+
}
54+
},
55+
success_url: `${HOME_LOCATION}/update-stripe-card?session_id={CHECKOUT_SESSION_ID}`,
56+
cancel_url: `${HOME_LOCATION}/update-stripe-card`
57+
});
58+
return { sessionId: session.id } as const;
59+
}
60+
);
61+
3362
fastify.post(
3463
'/donate/add-donation',
3564
{

api/src/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export { deprecatedEndpoints } from './schemas/deprecated';
1414
export { chargeStripeCard } from './schemas/donate/charge-stripe-card';
1515
export { chargeStripe } from './schemas/donate/charge-stripe';
1616
export { createStripePaymentIntent } from './schemas/donate/create-stripe-payment-intent';
17+
export { updateStripeCard } from './schemas/donate/update-stripe-card';
1718
export { resubscribe } from './schemas/email-subscription/resubscribe';
1819
export { unsubscribe } from './schemas/email-subscription/unsubscribe';
1920
export { updateMyAbout } from './schemas/settings/update-my-about';
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Type } from '@fastify/type-provider-typebox';
2+
import { genericError } from '../types';
3+
4+
export const updateStripeCard = {
5+
body: Type.Object({}),
6+
response: {
7+
200: Type.Object({
8+
sessionId: Type.String()
9+
}),
10+
default: genericError
11+
}
12+
};

client/i18n/locales/japanese/translations.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@
5555
"reset-lesson": "レッスンをリセット",
5656
"run": "実行",
5757
"run-test": "テスト実行 (Ctrl + Enter)",
58-
"check-code": "Check Your Code",
59-
"check-code-ctrl": "Check Your Code (Ctrl + Enter)",
60-
"check-code-cmd": "Check Your Code (Command + Enter)",
58+
"check-code": "コードをチェック",
59+
"check-code-ctrl": "コードをチェック (Ctrl + Enter)",
60+
"check-code-cmd": "コードをチェック (Command + Enter)",
6161
"reset": "リセット",
6262
"reset-step": "ステップをリセット",
6363
"help": "ヘルプ",
@@ -72,11 +72,11 @@
7272
"update-email": "メールアドレスを更新",
7373
"verify-email": "メールアドレスの確認",
7474
"submit-and-go": "提出して次のチャレンジに進む",
75-
"submit-and-go-ctrl": "Submit and go to next challenge (Ctrl + Enter)",
76-
"submit-and-go-cmd": "Submit and go to next challenge (Command + Enter)",
75+
"submit-and-go-ctrl": "提出して次のチャレンジに進む (Ctrl + Enter)",
76+
"submit-and-go-cmd": "提出して次のチャレンジに進む (Command + Enter)",
7777
"go-to-next": "次のチャレンジに進む",
78-
"go-to-next-ctrl": "Go to next challenge (Ctrl + Enter)",
79-
"go-to-next-cmd": "Go to next challenge (Command + Enter)",
78+
"go-to-next-ctrl": "次のチャレンジに進む (Ctrl + Enter)",
79+
"go-to-next-cmd": "次のチャレンジに進む (Command + Enter)",
8080
"ask-later": "後で",
8181
"start-coding": "コーディングを始めましょう!",
8282
"go-to-settings": "設定へ移動して認定証を取得",

client/i18n/locales/ukrainian/translations.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@
5555
"reset-lesson": "Скинути цей урок",
5656
"run": "Запустити",
5757
"run-test": "Запустити тест (Ctrl + Enter)",
58-
"check-code": "Check Your Code",
59-
"check-code-ctrl": "Check Your Code (Ctrl + Enter)",
60-
"check-code-cmd": "Check Your Code (Command + Enter)",
58+
"check-code": "Перевірити код",
59+
"check-code-ctrl": "Перевірити код (Ctrl + Enter)",
60+
"check-code-cmd": "Перевірити код (Command + Enter)",
6161
"reset": "Скинути",
6262
"reset-step": "Скинути цей крок",
6363
"help": "Допомога",
@@ -72,11 +72,11 @@
7272
"update-email": "Оновити мою адресу електронної пошти",
7373
"verify-email": "Підтвердити адресу електронної пошти",
7474
"submit-and-go": "Відправити та перейти до наступного завдання",
75-
"submit-and-go-ctrl": "Submit and go to next challenge (Ctrl + Enter)",
76-
"submit-and-go-cmd": "Submit and go to next challenge (Command + Enter)",
75+
"submit-and-go-ctrl": "Надіслати та перейти до наступного завдання (Ctrl + Enter)",
76+
"submit-and-go-cmd": "Надіслати та перейти до наступного завдання (Command + Enter)",
7777
"go-to-next": "Перейти до наступного завдання",
78-
"go-to-next-ctrl": "Go to next challenge (Ctrl + Enter)",
79-
"go-to-next-cmd": "Go to next challenge (Command + Enter)",
78+
"go-to-next-ctrl": "Перейти до наступного завдання (Ctrl + Enter)",
79+
"go-to-next-cmd": "Перейти до наступного завдання (Command + Enter)",
8080
"ask-later": "Нагадати пізніше",
8181
"start-coding": "Розпочати програмувати!",
8282
"go-to-settings": "Перейдіть до налаштувань, щоб отримати сертифікацію",

client/src/components/layouts/default.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -258,18 +258,18 @@ function DefaultLayout({
258258
/>
259259
) : null}
260260
<SignoutModal />
261-
{isChallenge && !examInProgress && isRenderBreadcrumb ? (
262-
<div className='breadcrumbs-demo'>
263-
<BreadCrumb
264-
block={block as string}
265-
superBlock={superBlock as string}
266-
/>
267-
</div>
268-
) : isExSmallViewportHeight ? (
269-
<Spacer size='xxSmall' />
270-
) : (
271-
<Spacer size='small' />
272-
)}
261+
{isChallenge &&
262+
!examInProgress &&
263+
(isRenderBreadcrumb ? (
264+
<div className='breadcrumbs-demo'>
265+
<BreadCrumb
266+
block={block as string}
267+
superBlock={superBlock as string}
268+
/>
269+
</div>
270+
) : (
271+
<Spacer size={isExSmallViewportHeight ? 'xxSmall' : 'small'} />
272+
))}
273273
{fetchState.complete && children}
274274
</div>
275275
{showFooter && <Footer />}

client/src/components/settings/certification.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,21 @@ const LegacyFullStack = (props: CertificationSettingsProps) => {
231231
onClick={createClickHandler(certSlug)}
232232
target='_blank'
233233
>
234-
{isFullStackCert ? t('buttons.show-cert') : t('buttons.claim-cert')}
234+
{isFullStackCert ? (
235+
<>
236+
{t('buttons.show-cert')}{' '}
237+
<span className='sr-only'>
238+
{t('certification.title.Legacy Full Stack')}
239+
</span>
240+
</>
241+
) : (
242+
<>
243+
{t('buttons.claim-cert')}{' '}
244+
<span className='sr-only'>
245+
{t('certification.title.Legacy Full Stack')}
246+
</span>
247+
</>
248+
)}
235249
</Button>
236250
) : (
237251
<Button
@@ -241,7 +255,10 @@ const LegacyFullStack = (props: CertificationSettingsProps) => {
241255
disabled={true}
242256
id={'button-' + certSlug}
243257
>
244-
{t('buttons.claim-cert')}
258+
{t('buttons.claim-cert')}{' '}
259+
<span className='sr-only'>
260+
{t('certification.title.Legacy Full Stack')}
261+
</span>
245262
</Button>
246263
)}
247264
</div>

client/src/templates/Challenges/redux/execute-challenge-saga.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,9 +255,7 @@ export function* previewChallengeSaga(action) {
255255
yield put(initLogs());
256256
yield put(initConsole(''));
257257
}
258-
// long enough so that holding down a key will only send one request, but not
259-
// so long that it feels unresponsive
260-
yield delay(30);
258+
yield delay(700);
261259

262260
const logProxy = yield channel();
263261
const proxyLogger = args => logProxy.put(args);

client/src/templates/Challenges/redux/selectors.js

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ export const challengeFilesSelector = state => state[ns].challengeFiles;
1616
export const challengeMetaSelector = state => state[ns].challengeMeta;
1717
export const challengeTestsSelector = state => state[ns].challengeTests;
1818
export const consoleOutputSelector = state => state[ns].consoleOut;
19-
export const completedChallengesIdsSelector = state =>
20-
completedChallengesSelector(state).map(node => node.id);
19+
export const completedChallengesIdsSelector = createSelector(
20+
completedChallengesSelector,
21+
completedChallenges => completedChallenges.map(node => node.id)
22+
);
2123
export const isChallengeCompletedSelector = state => {
2224
const completedChallenges = completedChallengesSelector(state);
2325
const { id: currentChallengeId } = challengeMetaSelector(state);
@@ -119,17 +121,13 @@ export const currentBlockIdsSelector = createSelector(
119121
}
120122
);
121123

122-
export const completedChallengesInBlockSelector = state => {
123-
const completedChallengesIds = completedChallengesIdsSelector(state);
124-
const currentBlockIds = currentBlockIdsSelector(state);
125-
const { id } = challengeMetaSelector(state);
126-
127-
return getCompletedChallengesInBlock(
128-
completedChallengesIds,
129-
currentBlockIds,
130-
id
131-
);
132-
};
124+
export const completedChallengesInBlockSelector = createSelector(
125+
completedChallengesIdsSelector,
126+
currentBlockIdsSelector,
127+
challengeMetaSelector,
128+
(completedChallengesIds, currentBlockIds, { id }) =>
129+
getCompletedChallengesInBlock(completedChallengesIds, currentBlockIds, id)
130+
);
133131

134132
export const completedPercentageSelector = createSelector(
135133
isSignedInSelector,

0 commit comments

Comments
 (0)