diff --git a/api/__mocks__/exam.ts b/api/__mocks__/exam.ts index 184ce5a257f..cc766e9a25c 100644 --- a/api/__mocks__/exam.ts +++ b/api/__mocks__/exam.ts @@ -1,8 +1,10 @@ +export const examChallengeId = '647e22d18acb466c97ccbef8'; + export const examJson = { - id: '647e22d18acb466c97ccbef8', + id: examChallengeId, title: 'Exam Certification', - numberOfQuestionsInExam: 1, - passingPercent: 70, + numberOfQuestionsInExam: 3, + passingPercent: 10, prerequisites: [ { id: '647f85d407d29547b3bee1bb', @@ -38,8 +40,24 @@ export const examJson = { { id: 'wycrnloajd', answer: 'Q2: Wrong Answer 6' } ], correctAnswers: [ - { id: 't9ezcsupdl', answer: 'Q1: Correct Answer 1' }, - { id: 'agert35dk0', answer: 'Q1: Correct Answer 2' } + { id: 't9ezcsupdl', answer: 'Q2: Correct Answer 1' }, + { id: 'agert35dk0', answer: 'Q2: Correct Answer 2' } + ] + }, + { + id: 'oqis5gzs0a', + question: 'Question 3?', + wrongAnswers: [ + { id: 'ojhnoxh5ra', answer: 'Q3: Wrong Answer 1' }, + { id: 'onx06if0ub', answer: 'Q3: Wrong Answer 2' }, + { id: 'zbxnsko71c', answer: 'Q3: Wrong Answer 3' }, + { id: 'bqv5y68jyd', answer: 'Q3: Wrong Answer 4' }, + { id: 'i5xipitise', answer: 'Q3: Wrong Answer 5' }, + { id: 'wycrnloajf', answer: 'Q3: Wrong Answer 6' } + ], + correctAnswers: [ + { id: 't9ezcsupda', answer: 'Q3: Correct Answer 1' }, + { id: 'agert35dkb', answer: 'Q3: Correct Answer 2' } ] } ] @@ -53,24 +71,29 @@ export const completedTrophyChallenges = [ } ]; -// failed +// failed: 0 correct export const userExam1 = { userExamQuestions: [ { id: '3bbl2mx2mq', question: 'Question 1?', - answer: { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' } + answer: { id: 'g489kbwn6a', answer: 'Q1: Wrong Answer 4' } }, { id: 'oqis5gzs0h', question: 'Question 2?', answer: { id: 'i5xipitiss', answer: 'Q2: Wrong Answer 5' } + }, + { + id: 'oqis5gzs0a', + question: 'Question 3?', + answer: { id: 'ojhnoxh5ra', answer: 'Q3: Wrong Answer 1' } } ], examTimeInSeconds: 20 }; -// passed +// passed: 1 correct export const userExam2 = { userExamQuestions: [ { @@ -81,26 +104,118 @@ export const userExam2 = { { id: 'oqis5gzs0h', question: 'Question 2?', - answer: { id: 't9ezcsupdl', answer: 'Q1: Correct Answer 1' } + answer: { id: 'i5xipitiss', answer: 'Q2: Wrong Answer 5' } + }, + { + id: 'oqis5gzs0a', + question: 'Question 3?', + answer: { id: 'ojhnoxh5ra', answer: 'Q3: Wrong Answer 1' } + } + ], + examTimeInSeconds: 20 +}; + +// passed: 2 correct +export const userExam3 = { + userExamQuestions: [ + { + id: '3bbl2mx2mq', + question: 'Question 1?', + answer: { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' } + }, + { + id: 'oqis5gzs0h', + question: 'Question 2?', + answer: { id: 't9ezcsupdl', answer: 'Q2: Correct Answer 1' } + }, + { + id: 'oqis5gzs0a', + question: 'Question 3?', + answer: { id: 'ojhnoxh5ra', answer: 'Q3: Wrong Answer 1' } + } + ], + examTimeInSeconds: 20 +}; + +// passed: 3 correct +export const userExam4 = { + userExamQuestions: [ + { + id: '3bbl2mx2mq', + question: 'Question 1?', + answer: { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' } + }, + { + id: 'oqis5gzs0h', + question: 'Question 2?', + answer: { id: 't9ezcsupdl', answer: 'Q2: Correct Answer 1' } + }, + { + id: 'oqis5gzs0a', + question: 'Question 3?', + answer: { id: 'agert35dkb', answer: 'Q3: Correct Answer 2' } } ], examTimeInSeconds: 20 }; export const mockResults1 = { - numberOfCorrectAnswers: 1, - numberOfQuestionsInExam: 2, - percentCorrect: 50, - passingPercent: 70, + numberOfCorrectAnswers: 0, + numberOfQuestionsInExam: 3, + percentCorrect: 0, + passingPercent: 10, passed: false, examTimeInSeconds: 20 }; export const mockResults2 = { - numberOfCorrectAnswers: 2, - numberOfQuestionsInExam: 2, - percentCorrect: 100, - passingPercent: 70, + numberOfCorrectAnswers: 1, + numberOfQuestionsInExam: 3, + percentCorrect: 33.3, + passingPercent: 10, passed: true, examTimeInSeconds: 20 }; + +export const mockResults3 = { + numberOfCorrectAnswers: 2, + numberOfQuestionsInExam: 3, + percentCorrect: 66.7, + passingPercent: 10, + passed: true, + examTimeInSeconds: 20 +}; + +export const mockResults4 = { + numberOfCorrectAnswers: 3, + numberOfQuestionsInExam: 3, + percentCorrect: 100, + passingPercent: 10, + passed: true, + examTimeInSeconds: 20 +}; + +const completedExamChallenge = { + id: examChallengeId, + challengeType: 17 +}; + +export const completedExamChallenge1 = { + ...completedExamChallenge, + examResults: mockResults1 +}; + +export const completedExamChallenge2 = { + ...completedExamChallenge, + examResults: mockResults2 +}; + +export const completedExamChallenge3 = { + ...completedExamChallenge, + examResults: mockResults3 +}; + +export const completedExamChallenge4 = { + ...completedExamChallenge, + examResults: mockResults4 +}; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index a179d213ad7..33e5f155f1b 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -26,6 +26,7 @@ type CompletedChallenge { id String isManuallyApproved Boolean? // Undefined solution String? // Null | Undefined + examResults ExamResults? // Undefined } type PartiallyCompletedChallenge { @@ -79,6 +80,7 @@ model user { acceptedPrivacyTerms Boolean // badges Json? // Undefined | { coreTeam [][] } removed, as new API never uses it. completedChallenges CompletedChallenge[] + completedExams CompletedExam[] // Undefined currentChallengeId String? donationEmails String[] // Undefined | String[] (only possible for built in Types like String) email String @@ -213,6 +215,22 @@ model Exam { questions Question[] } +type CompletedExam { + id String + challengeType Int + completedDate Float // TODO(Post-MVP): Change to DateTime? + examResults ExamResults +} + +type ExamResults { + numberOfCorrectAnswers Int + numberOfQuestionsInExam Int + percentCorrect Float + passingPercent Int + passed Boolean + examTimeInSeconds Int +} + type Question { id String question String diff --git a/api/src/routes/challenge.test.ts b/api/src/routes/challenge.test.ts index 39b51e74ac7..45469f0a4d6 100644 --- a/api/src/routes/challenge.test.ts +++ b/api/src/routes/challenge.test.ts @@ -15,8 +15,22 @@ import { defaultUserEmail, createSuperRequest } from '../../jest.utils'; -import { completedTrophyChallenges } from '../../__mocks__/exam'; -import { GeneratedAnswer } from '../utils/exam-types'; +import { + completedExamChallenge2, + completedExamChallenge3, + completedExamChallenge4, + completedTrophyChallenges, + examChallengeId, + mockResults1, + mockResults2, + mockResults3, + mockResults4, + userExam1, + userExam2, + userExam3, + userExam4 +} from '../../__mocks__/exam'; +import { Answer } from '../utils/exam-types'; jest.mock('./helpers/challenge-helpers', () => { const originalModule = jest.requireActual< @@ -138,6 +152,7 @@ describe('challengeRoutes', () => { setCookies = await devLogin(); superPost = createSuperRequest({ method: 'POST', setCookies }); superGet = createSuperRequest({ method: 'GET', setCookies }); + await seedExam(); }); describe('POST /coderoad-challenge-completed', () => { @@ -986,7 +1001,7 @@ describe('challengeRoutes', () => { const { generatedExam } = response.body; expect(Array.isArray(generatedExam)).toBe(true); - expect(generatedExam).toHaveLength(1); + expect(generatedExam).toHaveLength(3); expect(generatedExam[0]).toHaveProperty('question'); expect(typeof generatedExam[0].question).toBe('string'); @@ -998,7 +1013,7 @@ describe('challengeRoutes', () => { expect(Array.isArray(generatedExam[0].answers)).toBe(true); expect(generatedExam[0].answers).toHaveLength(5); - const answers = generatedExam[0].answers as GeneratedAnswer[]; + const answers = generatedExam[0].answers as Answer[]; answers.forEach(a => { expect(a).toHaveProperty('answer'); @@ -1253,6 +1268,375 @@ describe('challengeRoutes', () => { }); }); }); + + describe('/exam-challenge-completed', () => { + afterEach(async () => { + await fastifyTestInstance.prisma.user.updateMany({ + where: { id: defaultUserId }, + data: { + completedChallenges: [], + completedExams: [], + progressTimestamps: [] + } + }); + }); + + describe('validation', () => { + test('POST rejects requests with no body', async () => { + const response = await superRequest('/exam-challenge-completed', { + method: 'POST', + setCookies + }); + + expect(response.body).toStrictEqual({ + error: `Valid request body not found in attempt to submit exam.` + }); + expect(response.statusCode).toBe(400); + }); + + test('POST rejects requests without valid ObjectID', async () => { + const response = await superRequest('/exam-challenge-completed', { + method: 'POST', + setCookies + }).send({ id: 'not-a-valid-id' }); + + expect(response.body).toStrictEqual({ + error: `Valid request body not found in attempt to submit exam.` + }); + expect(response.statusCode).toBe(400); + }); + + test('POST rejects requests with valid, but non existing ID', async () => { + const response = await superRequest('/exam-challenge-completed', { + method: 'POST', + setCookies + }).send({ + id: '647e22d18acb466c97ccbef0', + challengeType: 17, + userCompletedExam: { + examTimeInSeconds: 111, + userExamQuestions: [ + { + id: 'q-id', + question: '?', + answer: { + id: 'a-id', + answer: 'a' + } + } + ] + } + }); + + expect(response.body).toStrictEqual({ + error: `An error occurred trying to get the exam from the database.` + }); + expect(response.statusCode).toBe(400); + }); + + test('POST rejects requests without valid userCompletedExam schema', async () => { + const response = await superRequest('/exam-challenge-completed', { + method: 'POST', + setCookies + }).send({ + id: examChallengeId, + challengeType: 17, + userCompletedExam: '' + }); + + expect(response.body).toStrictEqual({ + error: `Valid request body not found in attempt to submit exam.` + }); + expect(response.statusCode).toBe(400); + }); + + test('POST rejects requests without valid examTimeInSeconds schema', async () => { + const response = await superRequest('/exam-challenge-completed', { + method: 'POST', + setCookies + }).send({ + id: examChallengeId, + challengeType: 17, + userCompletedExam: { examTimeInSeconds: 'a' } + }); + + expect(response.body).toStrictEqual({ + error: `Valid request body not found in attempt to submit exam.` + }); + expect(response.statusCode).toBe(400); + }); + + test('POST rejects requests without valid userExamQuestions schema', async () => { + const response = await superRequest('/exam-challenge-completed', { + method: 'POST', + setCookies + }).send({ + id: examChallengeId, + challengeType: 17, + userCompletedExam: { examTimeInSeconds: 11, userExamQuestions: [] } + }); + + expect(response.body).toStrictEqual({ + error: `Valid request body not found in attempt to submit exam.` + }); + expect(response.statusCode).toBe(400); + }); + + test('POST rejects requests with prerequisites not completed', async () => { + const response = await superRequest('/exam-challenge-completed', { + method: 'POST', + setCookies + }).send({ + id: examChallengeId, + challengeType: 17, + userCompletedExam: { + examTimeInSeconds: 111, + userExamQuestions: [ + { + id: 'q-id', + question: '?', + answer: { + id: 'a-id', + answer: 'a' + } + } + ] + } + }); + + expect(response.body).toStrictEqual({ + error: `You have not completed the required challenges to start the 'Exam Certification'.` + }); + expect(response.statusCode).toBe(403); + }); + + test('POST rejects requests with invalid userCompletedExam values', async () => { + await fastifyTestInstance.prisma.user.updateMany({ + where: { email: 'foo@bar.com' }, + data: { + completedChallenges: completedTrophyChallenges + } + }); + + const response = await superRequest('/exam-challenge-completed', { + method: 'POST', + setCookies + }).send({ + id: examChallengeId, + challengeType: 17, + userCompletedExam: { + examTimeInSeconds: 111, + userExamQuestions: [ + { + id: 'q-id', + question: '?', + answer: { + id: 'a-id', + answer: 'a' + } + } + ] + } + }); + + expect(response.body).toStrictEqual({ + error: `An error occurred trying to submit your exam.` + }); + expect(response.statusCode).toBe(500); + }); + }); + + describe('handling', () => { + beforeEach(async () => { + await fastifyTestInstance.prisma.user.updateMany({ + where: { id: defaultUserId }, + data: { + completedChallenges: completedTrophyChallenges + } + }); + }); + + afterEach(async () => { + await fastifyTestInstance.prisma.user.updateMany({ + where: { id: defaultUserId }, + data: { + completedChallenges: [], + completedExams: [], + progressTimestamps: [] + } + }); + }); + + test('POST handles submitting a failing exam', async () => { + const now = Date.now(); + + // Submit exam with 0 correct answers + const response = await superRequest('/exam-challenge-completed', { + method: 'POST', + setCookies + }).send({ + id: examChallengeId, + challengeType: 17, + userCompletedExam: userExam1 + }); + + const { + completedChallenges = [], + completedExams = [], + progressTimestamps = [] + } = (await fastifyTestInstance.prisma.user.findFirst({ + where: { email: 'foo@bar.com' } + })) || {}; + + // should have the 1 prerequisite challenge + expect(completedChallenges).toHaveLength(1); + expect(completedExams).toHaveLength(1); + expect(progressTimestamps).toHaveLength(0); + expect(completedChallenges).toMatchObject(completedTrophyChallenges); + expect(completedExams[0]).toMatchObject({ + id: '647e22d18acb466c97ccbef8', + challengeType: 17, + examResults: mockResults1 + }); + + expect(completedExams[0]?.completedDate).toBeGreaterThan(now); + expect(response.body).toMatchObject({ + points: 0, + alreadyCompleted: false, + examResults: mockResults1 + }); + expect(response.statusCode).toBe(200); + }); + + test('POST handles submitting multiple passing exams', async () => { + // Submit exam with 2/3 correct answers + const nowA = Date.now(); + const responseA = await superRequest('/exam-challenge-completed', { + method: 'POST', + setCookies + }).send({ + id: examChallengeId, + challengeType: 17, + userCompletedExam: userExam3 + }); + + const userA = await fastifyTestInstance.prisma.user.findFirst({ + where: { id: defaultUserId } + }); + + const completedChallengesA = userA?.completedChallenges || []; + const completedExamsA = userA?.completedExams || []; + const progressTimestampsA = userA?.progressTimestamps || []; + + // should add to completedChallenges + expect(completedChallengesA).toHaveLength(2); + expect(completedChallengesA).toMatchObject([ + ...completedTrophyChallenges, + completedExamChallenge3 + ]); + expect(completedChallengesA[1]?.completedDate).toBeGreaterThan(nowA); + + // should add to completedExams + expect(completedExamsA).toHaveLength(1); + expect(completedExamsA[0]).toMatchObject(completedExamChallenge3); + expect(completedExamsA[0]?.completedDate).toBeGreaterThan(nowA); + + // should add to progressTimestamps + expect(progressTimestampsA).toHaveLength(1); + + expect(responseA.body).toMatchObject({ + points: 1, + alreadyCompleted: false, + examResults: mockResults3 + }); + expect(responseA.statusCode).toBe(200); + + // Submit exam with 1/3 correct answers (worse exam than already submitted) + const now2 = Date.now(); + const response2 = await superRequest('/exam-challenge-completed', { + method: 'POST', + setCookies + }).send({ + id: examChallengeId, + challengeType: 17, + userCompletedExam: userExam2 + }); + + const user2 = await fastifyTestInstance.prisma.user.findFirst({ + where: { id: defaultUserId } + }); + + const completedChallenges2 = user2?.completedChallenges || []; + const completedExams2 = user2?.completedExams || []; + const progressTimestamps2 = user2?.progressTimestamps || []; + + // should not add to or update completedChallenges + expect(completedChallenges2).toHaveLength(2); + expect(completedChallenges2).toMatchObject([ + ...completedTrophyChallenges, + // should still have old completed challenge (should not update) + completedExamChallenge3 + ]); + expect(completedChallenges2[1]?.completedDate).toBeLessThan(now2); + + // should add to completedExams + expect(completedExams2).toHaveLength(2); + expect(completedExams2[1]).toMatchObject(completedExamChallenge2); + expect(completedExams2[1]?.completedDate).toBeGreaterThan(nowA); + + // should not add to progressTimestamps + expect(progressTimestamps2).toHaveLength(1); + + expect(response2.body).toMatchObject({ + points: 1, + alreadyCompleted: true, + examResults: mockResults2 + }); + expect(response2.statusCode).toBe(200); + + // Submit exam with 3/3 correct answers (better exam than already submitted) + const now3 = Date.now(); + const response3 = await superRequest('/exam-challenge-completed', { + method: 'POST', + setCookies + }).send({ + id: examChallengeId, + challengeType: 17, + userCompletedExam: userExam4 + }); + + const user3 = await fastifyTestInstance.prisma.user.findFirst({ + where: { email: 'foo@bar.com' } + }); + + const completedChallenges3 = user3?.completedChallenges || []; + const completedExams3 = user3?.completedExams || []; + const progressTimestamps3 = user3?.progressTimestamps || []; + + // should update existing completedChallenge + expect(completedChallenges3).toHaveLength(2); + expect(completedChallenges3).toMatchObject([ + ...completedTrophyChallenges, + completedExamChallenge4 + ]); + expect(completedChallenges3[1]?.completedDate).toBeLessThan(now3); + + // should add to completedExams + expect(completedExams3).toHaveLength(3); + expect(completedExams3[2]).toMatchObject(completedExamChallenge4); + expect(completedExams3[2]?.completedDate).toBeGreaterThan(now3); + + expect(progressTimestamps3).toHaveLength(1); + + expect(response3.body).toMatchObject({ + points: 1, + alreadyCompleted: true, + examResults: mockResults4 + }); + expect(response3.statusCode).toBe(200); + }); + }); + }); }); describe('Unauthenticated user', () => { @@ -1271,7 +1655,8 @@ describe('challengeRoutes', () => { { path: '/modern-challenge-completed', method: 'POST' }, { path: '/save-challenge', method: 'POST' }, { path: '/exam/647e22d18acb466c97ccbef8', method: 'GET' }, - { path: '/ms-trophy-challenge-completed', method: 'POST' } + { path: '/ms-trophy-challenge-completed', method: 'POST' }, + { path: '/exam-challenge-completed', method: 'POST' } ]; endpoints.forEach(({ path, method }) => { diff --git a/api/src/routes/challenge.ts b/api/src/routes/challenge.ts index d81f2c989d4..d28da3d314f 100644 --- a/api/src/routes/challenge.ts +++ b/api/src/routes/challenge.ts @@ -1,6 +1,7 @@ import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; import jwt from 'jsonwebtoken'; import { uniqBy } from 'lodash'; +import { CompletedExam, ExamResults } from '@prisma/client'; import { challengeTypes } from '../../../shared/config/challenge-types'; import { schemas } from '../schemas'; @@ -21,9 +22,11 @@ import { getChallenges } from '../utils/get-challenges'; import { ProgressTimestamp, getPoints } from '../utils/progress'; import { validateExamFromDbSchema, - validateGeneratedExamSchema + validateGeneratedExamSchema, + validateUserCompletedExamSchema, + validateExamResultsSchema } from '../utils/exam-schemas'; -import { generateRandomExam } from '../utils/exam'; +import { generateRandomExam, createExamResults } from '../utils/exam'; import { canSubmitCodeRoadCertProject, createProject, @@ -614,5 +617,200 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( } ); + fastify.post( + '/exam-challenge-completed', + { + schema: schemas.examChallengeCompleted, + errorHandler(error, request, reply) { + if (error.validation) { + void reply.code(400); + void reply.send({ + error: 'Valid request body not found in attempt to submit exam.' + }); + } else { + fastify.errorHandler(error, request, reply); + } + } + }, + async (req, reply) => { + try { + const { id: userId } = req.session.user; + const { userCompletedExam, id, challengeType } = req.body; + + const { completedChallenges, completedExams, progressTimestamps } = + await fastify.prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { + completedChallenges: true, + completedExams: true, + progressTimestamps: true + } + }); + + const examFromDb = await fastify.prisma.exam.findUnique({ + where: { id } + }); + + if (!examFromDb) { + void reply.code(400); + return { + error: 'An error occurred trying to get the exam from the database.' + }; + } + + const validExamFromDbSchema = validateExamFromDbSchema(examFromDb); + if ('error' in validExamFromDbSchema) { + void reply.code(500); + return { + error: + 'An error occurred validating the exam information from the database.' + }; + } + + const { prerequisites, numberOfQuestionsInExam, title } = examFromDb; + + const prerequisiteIds = prerequisites.map(p => p.id); + const completedPrerequisites = completedChallenges.filter(c => + prerequisiteIds.includes(c.id) + ); + + if (completedPrerequisites.length !== prerequisiteIds.length) { + void reply.code(403); + return { + error: `You have not completed the required challenges to start the '${title}'.` + }; + } + + const validUserCompletedExam = validateUserCompletedExamSchema( + userCompletedExam, + numberOfQuestionsInExam + ); + if ('error' in validUserCompletedExam) { + fastify.log.error(validUserCompletedExam.error); + void reply.code(400); + return { + error: 'An error occurred validating the submitted exam.' + }; + } + + const examResults = createExamResults(userCompletedExam, examFromDb); + + const validExamResults = validateExamResultsSchema(examResults); + if ('error' in validExamResults) { + fastify.log.error(validExamResults.error); + void reply.code(500); + return { + error: 'An error occurred validating the submitted exam.' + }; + } + + const newCompletedChallenges: CompletedChallenge[] = + completedChallenges; + const newCompletedExams: CompletedExam[] = completedExams; + const newProgressTimeStamps = progressTimestamps as ProgressTimestamp[]; + const completedDate = Date.now(); + + const newCompletedChallenge = { + id, + challengeType, + completedDate, + examResults + }; + + // Always push to completedExams[] to keep a record of all exams taken. + newCompletedExams.push(newCompletedChallenge); + + let addPoint = false; + + const alreadyCompletedIndex = completedChallenges.findIndex( + c => c.id === id + ); + + const alreadyCompleted = alreadyCompletedIndex >= 0; + + if (examResults.passed) { + if (alreadyCompleted) { + const { percentCorrect } = examResults; + const oldChallenge = completedChallenges[ + alreadyCompletedIndex + ] as CompletedChallenge; + const oldResults = oldChallenge?.examResults as ExamResults; + + const newChallenge = oldChallenge; + newChallenge ? (newChallenge.examResults = examResults) : null; + + // only update if it's a better result + if (percentCorrect > oldResults.percentCorrect) { + const updatedChallege = { + id, + challengeType: oldChallenge.challengeType, + completedDate: oldChallenge.completedDate, + examResults + }; + + newCompletedChallenges[alreadyCompletedIndex] = updatedChallege; + + await fastify.prisma.user.update({ + where: { id: userId }, + data: { + completedExams: newCompletedExams, + completedChallenges: newCompletedChallenges + } + }); + } else { + await fastify.prisma.user.update({ + where: { id: userId }, + data: { + completedExams: newCompletedExams + } + }); + } + + // not already completed, push to completedChallenges + } else { + addPoint = true; + newCompletedChallenges.push(newCompletedChallenge); + + await fastify.prisma.user.update({ + where: { id: userId }, + data: { + completedExams: newCompletedExams, + completedChallenges: newCompletedChallenges, + progressTimestamps: [ + ...newProgressTimeStamps, + newCompletedChallenge.completedDate + ] + } + }); + } + + // exam not passed + } else { + await fastify.prisma.user.update({ + where: { id: userId }, + data: { + completedExams: newCompletedExams + } + }); + } + + const points = getPoints(newProgressTimeStamps); + + return { + alreadyCompleted, + points: addPoint ? points + 1 : points, + completedDate, + examResults + }; + } catch (error) { + fastify.log.error(error); + void reply.code(500); + return { + error: 'An error occurred trying to submit your exam.' + }; + } + } + ); + done(); }; diff --git a/api/src/routes/helpers/challenge-helpers.test.ts b/api/src/routes/helpers/challenge-helpers.test.ts index 22848c992b4..825692eb762 100644 --- a/api/src/routes/helpers/challenge-helpers.test.ts +++ b/api/src/routes/helpers/challenge-helpers.test.ts @@ -25,7 +25,8 @@ const completedChallenges: CompletedChallenge[] = [ files: [], githubLink: null, solution: null, - isManuallyApproved: false + isManuallyApproved: false, + examResults: null } ]; diff --git a/api/src/routes/user.test.ts b/api/src/routes/user.test.ts index 83313166770..75bc78a8b03 100644 --- a/api/src/routes/user.test.ts +++ b/api/src/routes/user.test.ts @@ -64,6 +64,7 @@ const testUserData: Prisma.userCreateInput = { } ], partiallyCompletedChallenges: [{ id: '123', completedDate: 123 }], + completedExams: [], githubProfile: 'github.com/foobar', website: 'https://www.freecodecamp.org', donationEmails: ['an@add.ress'], @@ -175,6 +176,7 @@ const publicUserData = { files: [] } ], + completedExams: testUserData.completedExams, githubProfile: testUserData.githubProfile, isApisMicroservicesCert: testUserData.isApisMicroservicesCert, isBackEndCert: testUserData.isBackEndCert, @@ -241,6 +243,7 @@ const baseProgressData = { isRelationalDatabaseCertV8: false, isCollegeAlgebraPyCertV8: false, completedChallenges: [], + completedExams: [], savedChallenges: [], partiallyCompletedChallenges: [], needsModeration: false @@ -589,8 +592,7 @@ describe('userRoutes', () => { // the following properties are defaults provided if the field is // missing in the user document. completedChallenges: [], - // TODO: add completedExams when /generate-exam is implemented - // completedExams: [], + completedExams: [], partiallyCompletedChallenges: [], portfolio: [], savedChallenges: [], diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts index 9e3dcf8e30f..2f8be5219d2 100644 --- a/api/src/routes/user.ts +++ b/api/src/routes/user.ts @@ -130,6 +130,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = ( isRelationalDatabaseCertV8: false, isCollegeAlgebraPyCertV8: false, completedChallenges: [], + completedExams: [], savedChallenges: [], partiallyCompletedChallenges: [], needsModeration: false @@ -396,6 +397,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( about: true, acceptedPrivacyTerms: true, completedChallenges: true, + completedExams: true, currentChallengeId: true, email: true, emailVerified: true, diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 223649cd146..1ac10dc67a9 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -24,6 +24,15 @@ const saveChallengeBody = Type.Object({ files: Type.Array(file) }); +const examResults = Type.Object({ + numberOfCorrectAnswers: Type.Number(), + numberOfQuestionsInExam: Type.Number(), + percentCorrect: Type.Number(), + passingPercent: Type.Number(), + passed: Type.Boolean(), + examTimeInSeconds: Type.Number() +}); + export const schemas = { // Settings: updateMyProfileUI: { @@ -307,6 +316,14 @@ export const schemas = { isManuallyApproved: Type.Optional(Type.Boolean()) }) ), + completedExams: Type.Array( + Type.Object({ + id: Type.String(), + completedDate: Type.Number(), + challengeType: Type.Optional(Type.Number()), + examResults + }) + ), completedChallengeCount: Type.Number(), currentChallengeId: Type.Optional(Type.String()), email: Type.String(), @@ -742,5 +759,42 @@ export const schemas = { }) ]) } + }, + examChallengeCompleted: { + body: Type.Object({ + id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }), + challengeType: Type.Number(), + userCompletedExam: Type.Object({ + examTimeInSeconds: Type.Number(), + userExamQuestions: Type.Array( + Type.Object({ + id: Type.String(), + question: Type.String(), + answer: Type.Object({ + id: Type.String(), + answer: Type.String() + }) + }), + { minItems: 1 } + ) + }) + }), + response: { + 200: Type.Object({ + completedDate: Type.Number(), + points: Type.Number(), + alreadyCompleted: Type.Boolean(), + examResults + }), + 400: Type.Object({ + error: Type.String() + }), + 403: Type.Object({ + error: Type.String() + }), + 500: Type.Object({ + error: Type.String() + }) + } } }; diff --git a/api/src/utils/common-challenge-functions.ts b/api/src/utils/common-challenge-functions.ts index 385fb54d2b8..0613c810c7d 100644 --- a/api/src/utils/common-challenge-functions.ts +++ b/api/src/utils/common-challenge-functions.ts @@ -1,4 +1,4 @@ -import { user } from '@prisma/client'; +import { ExamResults, user } from '@prisma/client'; import { FastifyInstance } from 'fastify'; import { omit, pick } from 'lodash'; import { challengeTypes } from '../../../shared/config/challenge-types'; @@ -63,6 +63,7 @@ export type CompletedChallenge = { completedDate: number; isManuallyApproved?: boolean | null; files?: CompletedChallengeFile[]; + examResults?: ExamResults | null; }; /** diff --git a/api/src/utils/create-user.ts b/api/src/utils/create-user.ts index 0c24d9df4b2..10ab199ce36 100644 --- a/api/src/utils/create-user.ts +++ b/api/src/utils/create-user.ts @@ -18,6 +18,7 @@ export function createUserInput(email: string): Prisma.userCreateInput { about: '', acceptedPrivacyTerms: false, completedChallenges: [], // TODO(Post-MVP): Omit this from the document? (prisma will always return []) + completedExams: [], // TODO(Post-MVP): Omit this from the document? (prisma will always return []) currentChallengeId: '', donationEmails: [], // TODO(Post-MVP): Omit this from the document? (prisma will always return []) email, diff --git a/api/src/utils/exam-schemas.ts b/api/src/utils/exam-schemas.ts index 23dbce32835..8b8092e1f75 100644 --- a/api/src/utils/exam-schemas.ts +++ b/api/src/utils/exam-schemas.ts @@ -1,6 +1,6 @@ -import { Answer, Exam, Question } from '@prisma/client'; +import { Answer, Exam, Question, ExamResults } from '@prisma/client'; import Joi from 'joi'; -import { ExamResults, GeneratedExam, UserExam } from './exam-types'; +import { GeneratedExam, UserExam } from './exam-types'; const nanoIdRE = new RegExp('[a-z0-9]{10}'); const objectIdRE = new RegExp('^[0-9a-fA-F]{24}$'); diff --git a/api/src/utils/exam-types.ts b/api/src/utils/exam-types.ts index a58070f28ed..575236b35ff 100644 --- a/api/src/utils/exam-types.ts +++ b/api/src/utils/exam-types.ts @@ -1,13 +1,13 @@ -// types for a generated exam -export interface GeneratedAnswer { +export interface Answer { id: string; answer: string; } +// types for a generated exam export interface GeneratedQuestion { id: string; question: string; - answers: GeneratedAnswer[]; + answers: Answer[]; } export type GeneratedExam = GeneratedQuestion[]; @@ -16,19 +16,10 @@ export type GeneratedExam = GeneratedQuestion[]; export interface UserQuestion { id: string; question: string; - answer: GeneratedAnswer; + answer: Answer; } export interface UserExam { userExamQuestions: UserQuestion[]; examTimeInSeconds: number; } - -export interface ExamResults { - numberOfCorrectAnswers: number; - numberOfQuestionsInExam: number; - percentCorrect: number; - passingPercent: number; - passed: boolean; - examTimeInSeconds: number; -} diff --git a/api/src/utils/exam.test.ts b/api/src/utils/exam.test.ts index bfa4ac19b20..e5cc92ae435 100644 --- a/api/src/utils/exam.test.ts +++ b/api/src/utils/exam.test.ts @@ -3,46 +3,52 @@ import { examJson, userExam1, userExam2, + userExam3, + userExam4, mockResults1, - mockResults2 + mockResults2, + mockResults3, + mockResults4 } from '../../__mocks__/exam'; import { generateRandomExam, createExamResults } from './exam'; -import { GeneratedExam, GeneratedQuestion } from './exam-types'; +import { GeneratedExam } from './exam-types'; describe('Exam helpers', () => { describe('generateRandomExam()', () => { const randomizedExam: GeneratedExam = generateRandomExam(examJson as Exam); - it('should have one question', () => { - expect(randomizedExam.length).toBe(1); + it('should have three questions', () => { + expect(randomizedExam.length).toBe(3); }); - it('should have five answers', () => { - const firstQuestion = randomizedExam[0] as GeneratedQuestion; - expect(firstQuestion.answers.length).toBe(5); + it('should have five answers per question', () => { + randomizedExam.forEach(question => { + expect(question.answers.length).toBe(5); + }); }); - it('should have exactly one correct answer', () => { - const question = randomizedExam[0] as GeneratedQuestion; - const questionId = question.id; - const originalQuestion = examJson.questions.find( - q => q.id === questionId - ) as Question; - const originalCorrectAnswer = originalQuestion.correctAnswers; - const correctIds = originalCorrectAnswer.map(a => a.id); + it('should have exactly one correct answer per question', () => { + randomizedExam.forEach(question => { + const originalQuestion = examJson.questions.find( + q => q.id === question.id + ) as Question; + const originalCorrectAnswer = originalQuestion.correctAnswers; + const correctIds = originalCorrectAnswer.map(a => a.id); - const numberOfCorrectAnswers = question.answers.filter(a => - correctIds.includes(a.id) - ); + const numberOfCorrectAnswers = question.answers.filter(a => + correctIds.includes(a.id) + ); - expect(numberOfCorrectAnswers).toHaveLength(1); + expect(numberOfCorrectAnswers).toHaveLength(1); + }); }); }); describe('createExamResults()', () => { - examJson.numberOfQuestionsInExam = 2; const examResults1 = createExamResults(userExam1, examJson as Exam); const examResults2 = createExamResults(userExam2, examJson as Exam); + const examResults3 = createExamResults(userExam3, examJson as Exam); + const examResults4 = createExamResults(userExam4, examJson as Exam); it('failing exam should return correct results', () => { expect(examResults1).toEqual(mockResults1); @@ -50,6 +56,8 @@ describe('Exam helpers', () => { it('passing exam should return correct results', () => { expect(examResults2).toEqual(mockResults2); + expect(examResults3).toEqual(mockResults3); + expect(examResults4).toEqual(mockResults4); }); }); }); diff --git a/api/src/utils/normalize.test.ts b/api/src/utils/normalize.test.ts index 2bb18962ca0..c815de5b5a8 100644 --- a/api/src/utils/normalize.test.ts +++ b/api/src/utils/normalize.test.ts @@ -95,6 +95,7 @@ describe('normalize', () => { solution: null, githubLink: null, isManuallyApproved: null, + examResults: null, files: [ { contents: 'test', diff --git a/api/src/utils/normalize.ts b/api/src/utils/normalize.ts index 4f00c11f994..ae61830993e 100644 --- a/api/src/utils/normalize.ts +++ b/api/src/utils/normalize.ts @@ -1,6 +1,6 @@ /* This module's job is to parse the database output and prepare it for serialization */ -import { ProfileUI, CompletedChallenge } from '@prisma/client'; +import { ProfileUI, CompletedChallenge, ExamResults } from '@prisma/client'; import _ from 'lodash'; type NullToUndefined = T extends null ? undefined : T; @@ -81,6 +81,7 @@ type NormalizedChallenge = { id: string; isManuallyApproved?: boolean; solution?: string; + examResults?: ExamResults; }; /**