From 46375c6dd60a6b1880347b7823c89b864fdb57eb Mon Sep 17 00:00:00 2001 From: Tom <20648924+moT01@users.noreply.github.com> Date: Thu, 26 Oct 2023 03:05:09 -0500 Subject: [PATCH] feat(api): add exam utils and GET /exam/:id endpoint to new api (#51609) --- api/__mocks__/exam.ts | 106 +++++++++++++++++ api/jest.utils.ts | 17 +++ api/package.json | 5 +- api/prisma/schema.prisma | 28 +++++ api/src/routes/challenge.test.ts | 122 +++++++++++++++++++- api/src/routes/challenge.ts | 88 ++++++++++++++ api/src/schemas.ts | 35 ++++++ api/src/utils/exam-schemas.ts | 192 +++++++++++++++++++++++++++++++ api/src/utils/exam-types.ts | 34 ++++++ api/src/utils/exam.test.ts | 55 +++++++++ api/src/utils/exam.ts | 119 +++++++++++++++++++ pnpm-lock.yaml | 3 + 12 files changed, 799 insertions(+), 5 deletions(-) create mode 100644 api/__mocks__/exam.ts create mode 100644 api/src/utils/exam-schemas.ts create mode 100644 api/src/utils/exam-types.ts create mode 100644 api/src/utils/exam.test.ts create mode 100644 api/src/utils/exam.ts diff --git a/api/__mocks__/exam.ts b/api/__mocks__/exam.ts new file mode 100644 index 00000000000..184ce5a257f --- /dev/null +++ b/api/__mocks__/exam.ts @@ -0,0 +1,106 @@ +export const examJson = { + id: '647e22d18acb466c97ccbef8', + title: 'Exam Certification', + numberOfQuestionsInExam: 1, + passingPercent: 70, + prerequisites: [ + { + id: '647f85d407d29547b3bee1bb', + title: 'challenge-title' + } + ], + questions: [ + { + id: '3bbl2mx2mq', + question: 'Question 1?', + wrongAnswers: [ + { id: 'ex7hii9zup', answer: 'Q1: Wrong Answer 1' }, + { id: 'lmr1ew7m67', answer: 'Q1: Wrong Answer 2' }, + { id: 'qh5sz9qdiq', answer: 'Q1: Wrong Answer 3' }, + { id: 'g489kbwn6a', answer: 'Q1: Wrong Answer 4' }, + { id: '7vu84wl4lc', answer: 'Q1: Wrong Answer 5' }, + { id: 'em59kw6avu', answer: 'Q1: Wrong Answer 6' } + ], + correctAnswers: [ + { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' }, + { id: 'f5gk39ske9', answer: 'Q1: Correct Answer 2' } + ] + }, + { + id: 'oqis5gzs0h', + question: 'Question 2?', + wrongAnswers: [ + { id: 'ojhnoxh5r5', answer: 'Q2: Wrong Answer 1' }, + { id: 'onx06if0uh', answer: 'Q2: Wrong Answer 2' }, + { id: 'zbxnsko712', answer: 'Q2: Wrong Answer 3' }, + { id: 'bqv5y68jyp', answer: 'Q2: Wrong Answer 4' }, + { id: 'i5xipitiss', answer: 'Q2: Wrong Answer 5' }, + { id: 'wycrnloajd', answer: 'Q2: Wrong Answer 6' } + ], + correctAnswers: [ + { id: 't9ezcsupdl', answer: 'Q1: Correct Answer 1' }, + { id: 'agert35dk0', answer: 'Q1: Correct Answer 2' } + ] + } + ] +}; + +export const completedTrophyChallenges = [ + { + id: '647f85d407d29547b3bee1bb', + solution: 'challenge-solution', + completedDate: 1695064765244 + } +]; + +// failed +export const userExam1 = { + userExamQuestions: [ + { + id: '3bbl2mx2mq', + question: 'Question 1?', + answer: { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' } + }, + { + id: 'oqis5gzs0h', + question: 'Question 2?', + answer: { id: 'i5xipitiss', answer: 'Q2: Wrong Answer 5' } + } + ], + examTimeInSeconds: 20 +}; + +// passed +export const userExam2 = { + userExamQuestions: [ + { + id: '3bbl2mx2mq', + question: 'Question 1?', + answer: { id: 'dzlokqdc73', answer: 'Q1: Correct Answer 1' } + }, + { + id: 'oqis5gzs0h', + question: 'Question 2?', + answer: { id: 't9ezcsupdl', answer: 'Q1: Correct Answer 1' } + } + ], + examTimeInSeconds: 20 +}; + +export const mockResults1 = { + numberOfCorrectAnswers: 1, + numberOfQuestionsInExam: 2, + percentCorrect: 50, + passingPercent: 70, + passed: false, + examTimeInSeconds: 20 +}; + +export const mockResults2 = { + numberOfCorrectAnswers: 2, + numberOfQuestionsInExam: 2, + percentCorrect: 100, + passingPercent: 70, + passed: true, + examTimeInSeconds: 20 +}; diff --git a/api/jest.utils.ts b/api/jest.utils.ts index 599cf62593a..38846362368 100644 --- a/api/jest.utils.ts +++ b/api/jest.utils.ts @@ -2,6 +2,7 @@ import request from 'supertest'; import { build } from './src/app'; import { createUserInput } from './src/utils/create-user'; +import { examJson } from './__mocks__/exam'; type FastifyTestInstance = Awaited>; @@ -92,3 +93,19 @@ export async function devLogin(): Promise { expect(res.status).toBe(200); return res.get('Set-Cookie'); } + +export async function seedExam(): Promise { + const query = { where: { id: examJson.id } }; + const testExamExists = + await fastifyTestInstance.prisma.exam.findUnique(query); + + if (testExamExists) { + await fastifyTestInstance.prisma.exam.deleteMany(query); + } + + await fastifyTestInstance.prisma.exam.create({ + data: { + ...examJson + } + }); +} diff --git a/api/package.json b/api/package.json index 637b8fdf2e1..a9125b57d16 100644 --- a/api/package.json +++ b/api/package.json @@ -23,6 +23,7 @@ "fastify": "4.21.0", "fastify-auth0-verify": "1.2.0", "fastify-plugin": "4.5.1", + "joi": "17.9.2", "jsonwebtoken": "9.0.2", "lodash": "4.17.21", "mongodb": "4.17.1", @@ -31,8 +32,8 @@ "nodemailer": "6.9.5", "nodemon": "2.0.22", "pino-pretty": "10.2.0", - "stripe": "8.205.0", - "query-string": "7.1.3" + "query-string": "7.1.3", + "stripe": "8.205.0" }, "description": "The freeCodeCamp.org open-source codebase and curriculum", "devDependencies": { diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 49141bc5bf6..d643af3a0b0 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -195,6 +195,34 @@ model sessions { @@index([expires], map: "expires_1") } +model Exam { + id String @id @map("_id") @db.ObjectId + numberOfQuestionsInExam Int @db.Int + passingPercent Int @db.Int + prerequisites Prerequisite[] // undefined | Prerequisite[] + title String + questions Question[] +} + +type Question { + id String + question String + wrongAnswers Answer[] + correctAnswers Answer[] + deprecated Boolean? // undefined +} + +type Answer { + id String + answer String + deprecated Boolean? // undefined +} + +type Prerequisite { + id String @db.ObjectId + title String +} + type DonationEndDate { date DateTime @map("_date") @db.Date when String @map("_when") diff --git a/api/src/routes/challenge.test.ts b/api/src/routes/challenge.test.ts index 5c91c2d757d..e775546304c 100644 --- a/api/src/routes/challenge.test.ts +++ b/api/src/routes/challenge.test.ts @@ -2,7 +2,15 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { omit } from 'lodash'; import { challengeTypes } from '../../../shared/config/challenge-types'; -import { devLogin, setupServer, superRequest } from '../../jest.utils'; +import { + devLogin, + setupServer, + superRequest, + seedExam, + defaultUserEmail +} from '../../jest.utils'; +import { completedTrophyChallenges } from '../../__mocks__/exam'; +import { GeneratedAnswer } from '../utils/exam-types'; const isValidChallengeCompletionErrorMsg = { type: 'error', @@ -987,6 +995,113 @@ describe('challengeRoutes', () => { }); }); }); + + describe('GET /exam/:id', () => { + beforeAll(async () => { + await seedExam(); + }); + + describe('validation', () => { + test('GET rejects requests without id param', async () => { + const response = await superRequest('/exam/', { + method: 'GET', + setCookies + }); + + expect(response.body).toStrictEqual({ + error: `Valid 'id' not found in request parameters.` + }); + expect(response.statusCode).toBe(400); + }); + + test('GET rejects requests when id param is not a 24-character string', async () => { + const response = await superRequest('/exam/fake-id', { + method: 'GET', + setCookies + }); + + expect(response.body).toStrictEqual({ + error: `Valid 'id' not found in request parameters.` + }); + expect(response.statusCode).toBe(400); + }); + + test('GET rejects requests with non-existent id param', async () => { + const response = await superRequest( + '/exam/123412341234123412341234', + { + method: 'GET', + setCookies + } + ); + + expect(response.body).toStrictEqual({ + error: 'An error occurred trying to get the exam from the database.' + }); + expect(response.statusCode).toBe(500); + }); + + test('GET rejects requests where camper has not completed prerequisites', async () => { + const response = await superRequest( + '/exam/647e22d18acb466c97ccbef8', + { + method: 'GET', + setCookies + } + ); + + expect(response.body).toStrictEqual({ + error: `You have not completed the required challenges to start the 'Exam Certification'.` + }); + expect(response.statusCode).toBe(403); + }); + }); + + describe('handling', () => { + test('GET returns a generatedExam array with the correct objects', async () => { + await fastifyTestInstance.prisma.user.updateMany({ + where: { email: defaultUserEmail }, + data: { completedChallenges: completedTrophyChallenges } + }); + + const response = await superRequest( + '/exam/647e22d18acb466c97ccbef8', + { + method: 'GET', + setCookies + } + ); + + expect(response.body).toHaveProperty('generatedExam'); + + const { generatedExam } = response.body; + + expect(Array.isArray(generatedExam)).toBe(true); + expect(generatedExam).toHaveLength(1); + + expect(generatedExam[0]).toHaveProperty('question'); + expect(typeof generatedExam[0].question).toBe('string'); + + expect(generatedExam[0]).toHaveProperty('id'); + expect(typeof generatedExam[0].id).toBe('string'); + + expect(generatedExam[0]).toHaveProperty('answers'); + expect(Array.isArray(generatedExam[0].answers)).toBe(true); + expect(generatedExam[0].answers).toHaveLength(5); + + const answers = generatedExam[0].answers as GeneratedAnswer[]; + + answers.forEach(a => { + expect(a).toHaveProperty('answer'); + expect(typeof a.answer).toBe('string'); + expect(a).toHaveProperty('id'); + expect(typeof a.id).toBe('string'); + }); + + expect(response.statusCode).toBe(200); + }); + }); + }); }); describe('Unauthenticated user', () => { @@ -998,12 +1113,13 @@ describe('challengeRoutes', () => { setCookies = res.get('Set-Cookie'); }); - const endpoints: { path: string; method: 'POST' }[] = [ + const endpoints: { path: string; method: 'POST' | 'GET' }[] = [ { path: '/coderoad-challenge-completed', method: 'POST' }, { path: '/project-completed', method: 'POST' }, { path: '/backend-challenge-completed', method: 'POST' }, { path: '/modern-challenge-completed', method: 'POST' }, - { path: '/save-challenge', method: 'POST' } + { path: '/save-challenge', method: 'POST' }, + { path: '/exam/647e22d18acb466c97ccbef8', method: 'GET' } ]; endpoints.forEach(({ path, method }) => { diff --git a/api/src/routes/challenge.ts b/api/src/routes/challenge.ts index 903b6aed59c..232221223f3 100644 --- a/api/src/routes/challenge.ts +++ b/api/src/routes/challenge.ts @@ -18,6 +18,11 @@ import { } from '../utils/error-formatting'; import { getChallenges } from '../utils/get-challenges'; import { ProgressTimestamp, getPoints } from '../utils/progress'; +import { + validateExamFromDbSchema, + validateGeneratedExamSchema +} from '../utils/exam-schemas'; +import { generateRandomExam } from '../utils/exam'; import { canSubmitCodeRoadCertProject, createProject, @@ -424,5 +429,88 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( } ); + fastify.get( + '/exam/:id', + { + schema: schemas.exam, + errorHandler(error, request, reply) { + if (error.validation) { + void reply.code(400); + return { error: `Valid 'id' not found in request parameters.` }; + } else { + fastify.errorHandler(error, request, reply); + } + } + }, + async (req, reply) => { + try { + const { id } = req.params; + + const { completedChallenges } = + await fastify.prisma.user.findUniqueOrThrow({ + where: { id: req.session.user.id }, + select: { completedChallenges: true } + }); + + const examFromDb = await fastify.prisma.exam.findUnique({ + where: { id } + }); + + if (!examFromDb) { + void reply.code(500); + 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; + + // Validate User has completed prerequisite challenges + 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 randomizedExam = generateRandomExam(examFromDb); + const validGeneratedExamSchema = validateGeneratedExamSchema( + randomizedExam, + numberOfQuestionsInExam + ); + + if ('error' in validGeneratedExamSchema) { + void reply.code(500); + return { error: 'An error occurred trying to randomize the exam.' }; + } + + return { + generatedExam: randomizedExam + }; + } catch (error) { + fastify.log.error(error); + void reply.code(500); + return { + error: 'Something went wrong trying to generate your exam.' + }; + } + } + ); + done(); }; diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 7c1b4504f89..9d57a8ef1ff 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -590,5 +590,40 @@ export const schemas = { ) }) } + }, + exam: { + params: Type.Object({ + id: Type.String({ + format: 'objectid', + maxLength: 24, + minLength: 24 + }) + }), + response: { + 200: Type.Object({ + generatedExam: Type.Array( + Type.Object({ + id: Type.String(), + question: Type.String(), + answers: Type.Array( + Type.Object({ + id: Type.String(), + answer: Type.String() + }) + ) + }) + ) + }), + // TODO: Standardize error responses - e.g. { type, message } + 400: Type.Object({ + error: Type.String() + }), + 403: Type.Object({ + error: Type.String() + }), + 500: Type.Object({ + error: Type.String() + }) + } } }; diff --git a/api/src/utils/exam-schemas.ts b/api/src/utils/exam-schemas.ts new file mode 100644 index 00000000000..23dbce32835 --- /dev/null +++ b/api/src/utils/exam-schemas.ts @@ -0,0 +1,192 @@ +import { Answer, Exam, Question } from '@prisma/client'; +import Joi from 'joi'; +import { ExamResults, GeneratedExam, UserExam } from './exam-types'; + +const nanoIdRE = new RegExp('[a-z0-9]{10}'); +const objectIdRE = new RegExp('^[0-9a-fA-F]{24}$'); + +// Exam from database schema +const DbPrerequisitesJoi = Joi.object().keys({ + id: Joi.string().regex(objectIdRE).required(), + title: Joi.string() +}); + +const DbAnswerJoi = Joi.object().keys({ + id: Joi.string().regex(nanoIdRE).required(), + deprecated: Joi.bool().allow(null), + answer: Joi.string().required() +}); + +const DbQuestionJoi = Joi.object().keys({ + id: Joi.string().regex(nanoIdRE).required(), + question: Joi.string().required(), + deprecated: Joi.bool().allow(null), + wrongAnswers: Joi.array() + .items(DbAnswerJoi) + .required() + .custom((value: Answer[], helpers) => { + const nonDeprecatedCount = value.reduce( + (count: number, answer: Answer) => + answer.deprecated ? count : count + 1, + 0 + ); + const minimumAnswers = 4; + + if (nonDeprecatedCount < minimumAnswers) { + return helpers.message({ + en: `'wrongAnswers' must have at least ${minimumAnswers} non-deprecated answers.` + }); + } + + return value; + }), + correctAnswers: Joi.array() + .items(DbAnswerJoi) + .required() + .custom((value: Answer[], helpers) => { + const nonDeprecatedCount = value.reduce( + (count: number, answer: Answer) => + answer.deprecated ? count : count + 1, + 0 + ); + const minimumAnswers = 1; + + if (nonDeprecatedCount < minimumAnswers) { + return helpers.message({ + en: `'correctAnswers' must have at least ${minimumAnswers} non-deprecated answer.` + }); + } + + return value; + }) +}); + +const examFromDbSchema = Joi.object().keys({ + // TODO: make sure _id and title match what's in the challenge markdown file + id: Joi.string().regex(objectIdRE).required(), + title: Joi.string().required(), + numberOfQuestionsInExam: Joi.number() + .min(1) + .max( + Joi.ref('questions', { + adjust: (questions: Question[]) => { + const nonDeprecatedCount = questions.reduce( + (count: number, question: Question) => + question.deprecated ? count : count + 1, + 0 + ); + return nonDeprecatedCount; + } + }) + ) + .required(), + passingPercent: Joi.number().min(0).max(100).required(), + prerequisites: Joi.array().items(DbPrerequisitesJoi), + questions: Joi.array().items(DbQuestionJoi).min(1).required() +}); + +/** + * Function to validate the exam data from the database. + * + * @param examFromDb The exam object from the database. + * @returns JOI Validation object. + */ +export const validateExamFromDbSchema = (examFromDb: Exam) => { + return examFromDbSchema.validate(examFromDb); +}; + +// Generated Exam Schema +const GeneratedAnswerJoi = Joi.object().keys({ + id: Joi.string().regex(nanoIdRE).required(), + answer: Joi.string().required() +}); + +const GeneratedQuestionJoi = Joi.object().keys({ + id: Joi.string().regex(nanoIdRE).required(), + question: Joi.string().required(), + answers: Joi.array().items(GeneratedAnswerJoi).min(5).required() +}); + +const generatedExamSchema = Joi.array() + .items(GeneratedQuestionJoi) + .min(1) + .required(); + +/** + * Function to validate a generated exam. + * + * @param exam The exam that was generated. + * @param numberOfQuestionsInExam The number of questions in the exam. + * @returns JOI Validation object. + */ +export const validateGeneratedExamSchema = ( + exam: GeneratedExam, + numberOfQuestionsInExam: number +) => { + if (exam.length !== numberOfQuestionsInExam) { + throw new Error( + 'The number of exam questions generated does not match the number of questions required.' + ); + } + + return generatedExamSchema.validate(exam); +}; + +// User Completed Exam Schema +const UserCompletedQuestionJoi = Joi.object().keys({ + id: Joi.string().regex(nanoIdRE).required(), + question: Joi.string().required(), + answer: Joi.object().keys({ + id: Joi.string().regex(nanoIdRE).required(), + answer: Joi.string().required() + }) +}); + +const userCompletedExamSchema = Joi.object().keys({ + userExamQuestions: Joi.array() + .items(UserCompletedQuestionJoi) + .min(1) + .required(), + examTimeInSeconds: Joi.number().min(0) +}); + +/** + * Function to validate a user completed exam. + * + * @param exam The exam the camper completed. + * @param numberOfQuestionsInExam The number of questions in the exam. + * @returns JOI Validation object. + */ +export const validateUserCompletedExamSchema = ( + exam: UserExam, + numberOfQuestionsInExam: number +) => { + // TODO: Validate that the properties exist + if (exam.userExamQuestions.length !== numberOfQuestionsInExam) { + throw new Error( + 'The number of exam questions answered does not match the number of questions required.' + ); + } + + return userCompletedExamSchema.validate(exam); +}; + +// Exam Results Schema +const examResultsSchema = Joi.object().keys({ + numberOfCorrectAnswers: Joi.number().min(0), + numberOfQuestionsInExam: Joi.number().min(0), + percentCorrect: Joi.number().min(0), + passingPercent: Joi.number().min(0).max(100), + passed: Joi.bool(), + examTimeInSeconds: Joi.number().min(0) +}); + +/** + * Function to validate generated exam results after a camper submits their exam. + * + * @param examResults The exam results that were generated. + * @returns JOI Validation object. + */ +export const validateExamResultsSchema = (examResults: ExamResults) => { + return examResultsSchema.validate(examResults); +}; diff --git a/api/src/utils/exam-types.ts b/api/src/utils/exam-types.ts new file mode 100644 index 00000000000..a58070f28ed --- /dev/null +++ b/api/src/utils/exam-types.ts @@ -0,0 +1,34 @@ +// types for a generated exam +export interface GeneratedAnswer { + id: string; + answer: string; +} + +export interface GeneratedQuestion { + id: string; + question: string; + answers: GeneratedAnswer[]; +} + +export type GeneratedExam = GeneratedQuestion[]; + +// types for a user completed exam (from client) +export interface UserQuestion { + id: string; + question: string; + answer: GeneratedAnswer; +} + +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 new file mode 100644 index 00000000000..bfa4ac19b20 --- /dev/null +++ b/api/src/utils/exam.test.ts @@ -0,0 +1,55 @@ +import { Exam, Question } from '@prisma/client'; +import { + examJson, + userExam1, + userExam2, + mockResults1, + mockResults2 +} from '../../__mocks__/exam'; +import { generateRandomExam, createExamResults } from './exam'; +import { GeneratedExam, GeneratedQuestion } 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 five answers', () => { + const firstQuestion = randomizedExam[0] as GeneratedQuestion; + expect(firstQuestion.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); + + const numberOfCorrectAnswers = question.answers.filter(a => + correctIds.includes(a.id) + ); + + expect(numberOfCorrectAnswers).toHaveLength(1); + }); + }); + + describe('createExamResults()', () => { + examJson.numberOfQuestionsInExam = 2; + const examResults1 = createExamResults(userExam1, examJson as Exam); + const examResults2 = createExamResults(userExam2, examJson as Exam); + + it('failing exam should return correct results', () => { + expect(examResults1).toEqual(mockResults1); + }); + + it('passing exam should return correct results', () => { + expect(examResults2).toEqual(mockResults2); + }); + }); +}); diff --git a/api/src/utils/exam.ts b/api/src/utils/exam.ts new file mode 100644 index 00000000000..24621f6a6db --- /dev/null +++ b/api/src/utils/exam.ts @@ -0,0 +1,119 @@ +import { Exam, Question } from '@prisma/client'; +import { UserExam, GeneratedExam } from './exam-types'; + +function shuffleArray(arr: T[]): T[] { + let currentIndex: number = arr.length; + let randomIndex: number; + + while (currentIndex !== 0) { + randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + + [arr[currentIndex], arr[randomIndex]] = [ + arr[randomIndex] as T, + arr[currentIndex] as T + ]; + } + + return arr; +} + +/** + * Remove objects from array with deprecated: true. + * + * @param arr An array. + * @returns The array without objects that have deprecated: true. + */ +export function filterDeprecated( + arr: T[] +): T[] { + return arr.filter(i => !i.deprecated); +} + +function getRandomElement(arr: T[]): T { + const id: number = Math.floor(Math.random() * arr.length); + return arr[id] as T; +} + +/** + * Generates a random exam. + * + * @param examJson Exam from the database converted to JSON. + * @returns An array of randomized questions for the exam. + */ +export function generateRandomExam(examJson: Exam): GeneratedExam { + const { numberOfQuestionsInExam, questions } = examJson; + const numberOfAnswersPerQuestion = 5; + + const availableQuestions = shuffleArray(filterDeprecated(questions)); + const examQuestions = availableQuestions.slice(0, numberOfQuestionsInExam); + + const randomizedExam: GeneratedExam = examQuestions.map( + (question: Question) => { + const availableCorrectAnswers = filterDeprecated(question.correctAnswers); + const availableWrongAnswers = shuffleArray( + filterDeprecated(question.wrongAnswers) + ); + const correctAnswer = getRandomElement(availableCorrectAnswers); + const answers = shuffleArray([ + correctAnswer, + ...availableWrongAnswers.slice(0, numberOfAnswersPerQuestion - 1) + ]).map(({ id, answer }) => ({ id, answer })); + return { + id: question.id, + question: question.question, + answers + }; + } + ); + + return randomizedExam; +} + +/** + * Evaluates a user completed exam. + * + * @param userExam User completed exam. + * @param originalExam Exam from the database converted to JSON. + * @returns An object of the exam results. + */ +export function createExamResults(userExam: UserExam, originalExam: Exam) { + const { userExamQuestions, examTimeInSeconds } = userExam; + const { + questions: originalQuestions, + numberOfQuestionsInExam, + passingPercent + } = originalExam; + + const numberOfCorrectAnswers = userExamQuestions.reduce( + (count, userQuestion) => { + const originalQuestion = originalQuestions.find( + examQuestion => examQuestion.id === userQuestion.id + ); + + if (!originalQuestion) { + throw new Error('An error occurred. Could not find exam question.'); + } + + const isCorrect = originalQuestion.correctAnswers.find( + examAnswer => examAnswer.id === userQuestion.answer.id + ); + return isCorrect ? count + 1 : count; + }, + 0 + ); + + // Percent rounded to one decimal place + const percent = (numberOfCorrectAnswers / numberOfQuestionsInExam) * 100; + const percentCorrect = Math.round(percent * 10) / 10; + const passed = percentCorrect >= passingPercent; + + return { + numberOfCorrectAnswers, + numberOfQuestionsInExam, + percentCorrect, + passingPercent, + passed, + examTimeInSeconds + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf358d7009d..6e43650c179 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,6 +225,9 @@ importers: fastify-plugin: specifier: 4.5.1 version: 4.5.1 + joi: + specifier: 17.9.2 + version: 17.9.2 jsonwebtoken: specifier: 9.0.2 version: 9.0.2