diff --git a/api/src/routes/challenge.test.ts b/api/src/routes/challenge.test.ts
index b8ab313ffc6..03b3cad10a2 100644
--- a/api/src/routes/challenge.test.ts
+++ b/api/src/routes/challenge.test.ts
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+import { omit } from 'lodash';
import { challengeTypes } from '../../../config/challenge-types';
import { devLogin, setupServer, superRequest } from '../../jest.utils';
@@ -37,6 +38,69 @@ const backendChallengeBody2 = {
id: backendChallengeId2
};
+// /modern-challenge-completed
+const HtmlChallengeId = '5dc174fcf86c76b9248c6eb2';
+const JsProjectId = '56533eb9ac21ba0edf2244e2';
+const multiFileCertProjectId = 'bd7158d8c242eddfaeb5bd13';
+
+const HtmlChallengeBody = {
+ challengeType: challengeTypes.html,
+ id: HtmlChallengeId
+};
+const JsProjectBody = {
+ challengeType: challengeTypes.jsProject,
+ id: JsProjectId,
+ files: [
+ {
+ contents: 'console.log("Hello There!")',
+ key: 'scriptjs',
+ ext: 'js',
+ name: 'script',
+ history: ['script.js']
+ }
+ ]
+};
+const multiFileCertProjectBody = {
+ challengeType: challengeTypes.multifileCertProject,
+ id: multiFileCertProjectId,
+ files: [
+ {
+ contents: '
Multi File Project v1
',
+ key: 'indexhtml',
+ ext: 'html',
+ name: 'index',
+ history: ['index.html']
+ },
+ {
+ contents: '.hello-there { general: kenobi; }',
+ key: 'stylescss',
+ ext: 'css',
+ name: 'styles',
+ history: ['styles.css']
+ }
+ ]
+};
+const updatedMultiFileCertProjectBody = {
+ challengeType: challengeTypes.multifileCertProject,
+ id: multiFileCertProjectId,
+ files: [
+ {
+ contents: 'Multi File Project v2
',
+ key: 'indexhtml',
+ ext: 'html',
+ name: 'index',
+ history: ['index.html']
+ },
+ {
+ contents: '.wibbly-wobbly { timey: wimey; }',
+ key: 'stylescss',
+ ext: 'css',
+ name: 'styles',
+ history: ['styles.css']
+ }
+ ]
+};
+
describe('challengeRoutes', () => {
setupServer();
describe('Authenticated user', () => {
@@ -603,6 +667,254 @@ describe('challengeRoutes', () => {
});
});
});
+
+ describe('/modern-challenge-completed', () => {
+ describe('validation', () => {
+ test('POST rejects requests without ids', async () => {
+ const response = await superRequest('/modern-challenge-completed', {
+ method: 'POST',
+ setCookies
+ });
+
+ expect(response.statusCode).toBe(400);
+ expect(response.body).toStrictEqual(
+ isValidChallengeCompletionErrorMsg
+ );
+ });
+
+ test('POST rejects requests without valid ObjectIDs', async () => {
+ const response = await superRequest('/modern-challenge-completed', {
+ method: 'POST',
+ setCookies
+ }).send({ id: 'not-a-valid-id' });
+
+ expect(response.statusCode).toBe(400);
+ expect(response.body).toStrictEqual(
+ isValidChallengeCompletionErrorMsg
+ );
+ });
+ });
+
+ describe('handling', () => {
+ afterEach(async () => {
+ await fastifyTestInstance.prisma.user.updateMany({
+ where: { email: 'foo@bar.com' },
+ data: {
+ completedChallenges: [],
+ savedChallenges: [],
+ progressTimestamps: []
+ }
+ });
+ });
+
+ // HTML(0), JS(1), Modern(6), Video(11), The Odin Project(15)
+ test('POST accepts challenges without files present', async () => {
+ const now = Date.now();
+
+ const response = await superRequest('/modern-challenge-completed', {
+ method: 'POST',
+ setCookies
+ }).send(HtmlChallengeBody);
+
+ const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
+ where: { email: 'foo@bar.com' }
+ });
+
+ expect(user).toMatchObject({
+ completedChallenges: [
+ {
+ id: HtmlChallengeId,
+ completedDate: expect.any(Number)
+ }
+ ]
+ });
+
+ const completedDate = user.completedChallenges[0]?.completedDate;
+ expect(completedDate).toBeGreaterThanOrEqual(now);
+ expect(completedDate).toBeLessThanOrEqual(now + 1000);
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body).toStrictEqual({
+ alreadyCompleted: false,
+ points: 1,
+ completedDate,
+ savedChallenges: []
+ });
+ });
+
+ // JS Project(5), Multi-file Cert Project(14)
+ test('POST accepts challenges with files present', async () => {
+ const now = Date.now();
+
+ const response = await superRequest('/modern-challenge-completed', {
+ method: 'POST',
+ setCookies
+ }).send(JsProjectBody);
+
+ const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
+ where: { email: 'foo@bar.com' }
+ });
+
+ const file = omit(JsProjectBody.files[0], 'history');
+
+ expect(user).toMatchObject({
+ completedChallenges: [
+ {
+ id: JsProjectId,
+ challengeType: JsProjectBody.challengeType,
+ files: [file],
+ completedDate: expect.any(Number)
+ }
+ ]
+ });
+
+ const completedDate = user.completedChallenges[0]?.completedDate;
+ expect(completedDate).toBeGreaterThanOrEqual(now);
+ expect(completedDate).toBeLessThanOrEqual(now + 1000);
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body).toStrictEqual({
+ alreadyCompleted: false,
+ points: 1,
+ completedDate,
+ savedChallenges: []
+ });
+ });
+
+ test('POST accepts challenges with saved solutions', async () => {
+ const now = Date.now();
+
+ const response = await superRequest('/modern-challenge-completed', {
+ method: 'POST',
+ setCookies
+ }).send(multiFileCertProjectBody);
+
+ const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
+ where: { email: 'foo@bar.com' }
+ });
+
+ const testFiles = multiFileCertProjectBody.files.map(
+ ({ history: _history, ...rest }) => rest
+ );
+
+ expect(user).toMatchObject({
+ needsModeration: true,
+ completedChallenges: [
+ {
+ id: multiFileCertProjectId,
+ challengeType: multiFileCertProjectBody.challengeType,
+ files: testFiles,
+ completedDate: expect.any(Number),
+ isManuallyApproved: true
+ }
+ ],
+ savedChallenges: [
+ {
+ id: multiFileCertProjectId,
+ lastSavedDate: expect.any(Number),
+ files: multiFileCertProjectBody.files
+ }
+ ]
+ });
+
+ const completedDate = user.completedChallenges[0]?.completedDate;
+ expect(completedDate).toBeGreaterThanOrEqual(now);
+ expect(completedDate).toBeLessThanOrEqual(now + 1000);
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body).toStrictEqual({
+ alreadyCompleted: false,
+ points: 1,
+ completedDate,
+ savedChallenges: [
+ {
+ id: multiFileCertProjectId,
+ lastSavedDate: completedDate,
+ files: multiFileCertProjectBody.files
+ }
+ ]
+ });
+ });
+
+ test('POST correctly handles multiple requests', async () => {
+ const resOriginal = await superRequest(
+ '/modern-challenge-completed',
+ {
+ method: 'POST',
+ setCookies
+ }
+ ).send(multiFileCertProjectBody);
+
+ await superRequest('/modern-challenge-completed', {
+ method: 'POST',
+ setCookies
+ }).send(HtmlChallengeBody);
+
+ const resUpdate = await superRequest('/modern-challenge-completed', {
+ method: 'POST',
+ setCookies
+ }).send(updatedMultiFileCertProjectBody);
+
+ const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
+ where: { email: 'foo@bar.com' }
+ });
+
+ const expectedProgressTimestamps = user.completedChallenges.map(
+ challenge => challenge.completedDate
+ );
+
+ const testFiles = updatedMultiFileCertProjectBody.files.map(file =>
+ omit(file, 'history')
+ );
+
+ expect(user).toMatchObject({
+ needsModeration: true,
+ completedChallenges: [
+ {
+ id: multiFileCertProjectId,
+ challengeType: updatedMultiFileCertProjectBody.challengeType,
+ files: testFiles,
+ completedDate: expect.any(Number),
+ isManuallyApproved: true
+ },
+ {
+ id: HtmlChallengeId,
+ completedDate: expect.any(Number)
+ }
+ ],
+ savedChallenges: [
+ {
+ id: multiFileCertProjectId,
+ lastSavedDate: expect.any(Number),
+ files: updatedMultiFileCertProjectBody.files
+ }
+ ],
+ progressTimestamps: expectedProgressTimestamps
+ });
+
+ expect(
+ resUpdate.body.savedChallenges[0].lastSavedDate
+ ).toBeGreaterThan(
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+ resOriginal.body.savedChallenges[0].lastSavedDate
+ );
+
+ expect(resUpdate.statusCode).toBe(200);
+ expect(resUpdate.body).toStrictEqual({
+ alreadyCompleted: true,
+ points: 2,
+ completedDate: expect.any(Number),
+ savedChallenges: [
+ {
+ id: multiFileCertProjectId,
+ lastSavedDate: expect.any(Number),
+ files: updatedMultiFileCertProjectBody.files
+ }
+ ]
+ });
+ });
+ });
+ });
});
describe('Unauthenticated user', () => {
@@ -644,5 +956,14 @@ describe('challengeRoutes', () => {
expect(response.statusCode).toBe(401);
});
+
+ test('POST /modern-challenge-completed returns 401 status code with error message', async () => {
+ const response = await superRequest('/modern-challenge-completed', {
+ method: 'POST',
+ setCookies
+ });
+
+ expect(response?.statusCode).toBe(401);
+ });
});
});
diff --git a/api/src/routes/challenge.ts b/api/src/routes/challenge.ts
index 362f67c66f8..2bc84d9f893 100644
--- a/api/src/routes/challenge.ts
+++ b/api/src/routes/challenge.ts
@@ -1,13 +1,18 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import jwt from 'jsonwebtoken';
import { uniqBy } from 'lodash';
-import { getChallenges } from '../utils/get-challenges';
-import { updateUserChallengeData } from '../utils/common-challenge-functions';
-import { formatValidationError } from '../utils/error-formatting';
-import { schemas } from '../schemas';
-import { getPoints, ProgressTimestamp } from '../utils/progress';
-import { JWT_SECRET } from '../utils/env';
import { challengeTypes } from '../../../config/challenge-types';
+import { schemas } from '../schemas';
+import {
+ jsCertProjectIds,
+ multifileCertProjectIds,
+ updateUserChallengeData,
+ type CompletedChallenge
+} from '../utils/common-challenge-functions';
+import { JWT_SECRET } from '../utils/env';
+import { formatValidationError } from '../utils/error-formatting';
+import { getChallenges } from '../utils/get-challenges';
+import { ProgressTimestamp, getPoints } from '../utils/progress';
import {
canSubmitCodeRoadCertProject,
createProject,
@@ -310,5 +315,69 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
}
);
+ fastify.post(
+ '/modern-challenge-completed',
+ {
+ schema: schemas.modernChallengeCompleted,
+ errorHandler(error, request, reply) {
+ if (error.validation) {
+ void reply.code(400);
+ return formatValidationError(error.validation);
+ } else {
+ fastify.errorHandler(error, request, reply);
+ }
+ }
+ },
+ async (req, reply) => {
+ try {
+ const { id, files, challengeType } = req.body;
+
+ const user = await fastify.prisma.user.findUniqueOrThrow({
+ where: { id: req.session.user.id }
+ });
+ const RawProgressTimestamp = user.progressTimestamps as
+ | ProgressTimestamp[]
+ | null;
+ const points = getPoints(RawProgressTimestamp);
+
+ const completedChallenge: CompletedChallenge = {
+ id,
+ files,
+ completedDate: Date.now()
+ };
+
+ if (challengeType === challengeTypes.multifileCertProject) {
+ completedChallenge.isManuallyApproved = true;
+ user.needsModeration = true;
+ }
+
+ if (
+ jsCertProjectIds.includes(id) ||
+ multifileCertProjectIds.includes(id)
+ ) {
+ completedChallenge.challengeType = challengeType;
+ }
+
+ const { alreadyCompleted, userSavedChallenges: savedChallenges } =
+ await updateUserChallengeData(fastify, user, id, completedChallenge);
+
+ return {
+ alreadyCompleted,
+ points: alreadyCompleted ? points : points + 1,
+ completedDate: completedChallenge.completedDate,
+ savedChallenges
+ };
+ } catch (error) {
+ fastify.log.error(error);
+ void reply.code(500);
+ return {
+ message:
+ 'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.',
+ type: 'danger'
+ } as const;
+ }
+ }
+ );
+
done();
};
diff --git a/api/src/schemas.ts b/api/src/schemas.ts
index c15789bf3a8..0570117218a 100644
--- a/api/src/schemas.ts
+++ b/api/src/schemas.ts
@@ -423,5 +423,60 @@ export const schemas = {
)
})
}
+ },
+ modernChallengeCompleted: {
+ body: Type.Object({
+ id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }),
+ challengeType: Type.Number(),
+ files: Type.Optional(
+ Type.Array(
+ Type.Object({
+ contents: Type.String(),
+ key: Type.String(),
+ ext: Type.String(),
+ name: Type.String(),
+ history: Type.Array(Type.String())
+ })
+ )
+ )
+ }),
+ response: {
+ 200: Type.Object({
+ completedDate: Type.Number(),
+ points: Type.Number(),
+ alreadyCompleted: Type.Boolean(),
+ savedChallenges: Type.Array(
+ Type.Object({
+ id: Type.String({
+ format: 'objectid',
+ maxLength: 24,
+ minLength: 24
+ }),
+ lastSavedDate: Type.Number(),
+ files: Type.Array(
+ Type.Object({
+ contents: Type.String(),
+ key: Type.String(),
+ ext: Type.String(),
+ name: Type.String(),
+ history: Type.Array(Type.String())
+ })
+ )
+ })
+ )
+ }),
+ 400: Type.Object({
+ type: Type.Literal('error'),
+ message: Type.Literal(
+ 'That does not appear to be a valid challenge submission.'
+ )
+ }),
+ 500: Type.Object({
+ type: Type.Literal('danger'),
+ message: Type.Literal(
+ 'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.'
+ )
+ })
+ }
}
};
diff --git a/api/src/utils/common-challenge-functions.ts b/api/src/utils/common-challenge-functions.ts
index b1831b059b8..b615beaa9cf 100644
--- a/api/src/utils/common-challenge-functions.ts
+++ b/api/src/utils/common-challenge-functions.ts
@@ -4,7 +4,7 @@ import { omit, pick } from 'lodash';
import { challengeTypes } from '../../../config/challenge-types';
import { getChallenges } from './get-challenges';
-const jsCertProjectIds = [
+export const jsCertProjectIds = [
'aaa48de84e1ecc7c742e1124',
'a7f4d8f2483413a6ce226cac',
'56533eb9ac21ba0edf2244e2',
@@ -12,7 +12,7 @@ const jsCertProjectIds = [
'aa2e6f85cab2ab736c9a9b24'
];
-const multifileCertProjectIds = getChallenges()
+export const multifileCertProjectIds = getChallenges()
.filter(c => c.challengeType === challengeTypes.multifileCertProject)
.map(c => c.id);
@@ -24,14 +24,14 @@ type SavedChallengeFile = {
key: string;
ext: string; // NOTE: This is Ext type in client
name: string;
- history?: string[];
+ history: string[];
contents: string;
};
type SavedChallenge = {
id: string;
lastSavedDate: number;
- files?: SavedChallengeFile[];
+ files: SavedChallengeFile[];
};
// TODO: Confirm this type - read comments below
@@ -55,7 +55,7 @@ type CompletedChallengeFile = {
path?: string | null;
};
-type CompletedChallenge = {
+export type CompletedChallenge = {
id: string;
solution?: string | null;
githubLink?: string | null;