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;