diff --git a/api-server/package.json b/api-server/package.json index 9793883009c..ebd61d039df 100644 --- a/api-server/package.json +++ b/api-server/package.json @@ -82,6 +82,8 @@ "@babel/plugin-proposal-optional-chaining": "7.17.12", "@babel/preset-env": "7.18.0", "@babel/register": "7.17.7", + "joi": "17.9.2", + "joi-objectid": "3.0.1", "loopback-component-explorer": "6.4.0", "nodemon": "2.0.16", "smee-client": "1.2.3" diff --git a/api-server/src/common/models/user.js b/api-server/src/common/models/user.js index a60249a9503..526e363a140 100644 --- a/api-server/src/common/models/user.js +++ b/api-server/src/common/models/user.js @@ -1037,6 +1037,21 @@ export default function initializeUser(User) { }); }; + User.prototype.getCompletedExams$ = function getCompletedExams$() { + if (Array.isArray(this.completedExams) && this.completedExams.length) { + return Observable.of(this.completedExams); + } + const id = this.getId(); + const filter = { + where: { id }, + fields: { completedExams: true } + }; + return this.constructor.findOne$(filter).map(user => { + this.completedExams = user.completedExams; + return user.completedExams; + }); + }; + User.getMessages = messages => Promise.resolve(messages); User.remoteMethod('getMessages', { diff --git a/api-server/src/common/models/user.json b/api-server/src/common/models/user.json index eeca76455e7..b06665b2b13 100644 --- a/api-server/src/common/models/user.json +++ b/api-server/src/common/models/user.json @@ -299,6 +299,26 @@ ], "default": [] }, + "completedExams": { + "type": [ + { + "completedDate": "number", + "id": "string", + "challengeType": "number", + "examResults": { + "type": { + "numberOfCorrectAnswers": "number", + "numberOfQuestionsInExam": "number", + "percentCorrect": "number", + "passingPercent": "number", + "passed": "boolean", + "examTimeInSeconds": "number" + } + } + } + ], + "default": [] + }, "portfolio": { "type": "array", "default": [] diff --git a/api-server/src/common/utils/index.js b/api-server/src/common/utils/index.js index 556c2f839a6..6c03879dc51 100644 --- a/api-server/src/common/utils/index.js +++ b/api-server/src/common/utils/index.js @@ -18,7 +18,8 @@ export const fixCompletedChallengeItem = obj => 'githubLink', 'challengeType', 'files', - 'isManuallyApproved' + 'isManuallyApproved', + 'examResults' ]); export const fixSavedChallengeItem = obj => @@ -26,3 +27,6 @@ export const fixSavedChallengeItem = obj => export const fixPartiallyCompletedChallengeItem = obj => pick(obj, ['id', 'completedDate']); + +export const fixCompletedExamItem = obj => + pick(obj, ['id', 'completedDate', 'challengeType', 'examResults']); diff --git a/api-server/src/server/boot/challenge.js b/api-server/src/server/boot/challenge.js index 9926ff584aa..b8e1a116fe7 100644 --- a/api-server/src/server/boot/challenge.js +++ b/api-server/src/server/boot/challenge.js @@ -18,7 +18,10 @@ import jwt from 'jsonwebtoken'; import { jwtSecret } from '../../../../config/secrets'; -import { fixPartiallyCompletedChallengeItem } from '../../common/utils'; +import { + fixPartiallyCompletedChallengeItem, + fixCompletedExamItem +} from '../../common/utils'; import { getChallenges } from '../utils/get-curriculum'; import { ifNoUserSend } from '../utils/middleware'; import { @@ -26,6 +29,13 @@ import { normalizeParams, getPrefixedLandingPath } from '../utils/redirection'; +import { generateRandomExam, createExamResults } from '../utils/exam'; +import { + validateExamFromDbSchema, + validateExamResultsSchema, + validateGeneratedExamSchema, + validateUserCompletedExamSchema +} from '../utils/exam-schemas'; import { isMicrosoftLearnLink } from '../../../../utils/validate'; import { getApiUrlFromTrophy } from '../utils/ms-learn-utils'; @@ -65,10 +75,15 @@ export default async function bootChallenge(app, done) { backendChallengeCompleted ); + const generateExam = createGenerateExam(app); + + api.get('/exam/:id', send200toNonUser, generateExam); + + const examChallengeCompleted = createExamChallengeCompleted(app); + api.post( '/exam-challenge-completed', send200toNonUser, - isValidChallengeCompletion, examChallengeCompleted ); @@ -207,6 +222,62 @@ export function buildUserUpdate( }; } +export function buildExamUserUpdate(user, _completedChallenge) { + const { + id, + challengeType, + completedDate = Date.now(), + examResults + } = _completedChallenge; + + let finalChallenge = { id, challengeType, completedDate, examResults }; + + const { completedChallenges = [] } = user; + const $push = {}, + $set = {}; + + // Always push to completedExams[] to keep a record of all submissions, it may come in handy. + $push.completedExams = fixCompletedExamItem(_completedChallenge); + + let alreadyCompleted = false; + let addPoint = false; + + // completedChallenges[] should have their best exam + if (examResults.passed) { + const alreadyCompletedIndex = completedChallenges.findIndex( + challenge => challenge.id === id + ); + + alreadyCompleted = alreadyCompletedIndex !== -1; + + if (alreadyCompleted) { + const { percentCorrect } = examResults; + const oldChallenge = completedChallenges[alreadyCompletedIndex]; + const oldResults = oldChallenge.examResults; + + // only update if it's a better result + if (percentCorrect > oldResults.percentCorrect) { + finalChallenge.completedDate = oldChallenge.completedDate; + $set[`completedChallenges.${alreadyCompletedIndex}`] = finalChallenge; + } + } else { + addPoint = true; + $push.completedChallenges = finalChallenge; + } + } + + const updateData = {}; + if (!isEmpty($set)) updateData.$set = $set; + if (!isEmpty($push)) updateData.$push = $push; + + return { + alreadyCompleted, + addPoint, + updateData, + completedDate: finalChallenge.completedDate + }; +} + export function buildChallengeUrl(challenge) { const { superBlock, block, dashedName } = challenge; return `/learn/${superBlock}/${block}/${dashedName}`; @@ -452,35 +523,178 @@ async function backendChallengeCompleted(req, res, next) { }); } -async function examChallengeCompleted(req, res, next) { - // TODO: verify shape of exam results - const { user, body = {} } = req; - const completedChallenge = pick(body, ['id', 'examResults']); - completedChallenge.completedDate = Date.now(); +// TODO: send flash message keys to client so they can be i18n +function createGenerateExam(app) { + const { Exam } = app.models; - try { - await user.getCompletedChallenges$().toPromise(); - } catch (e) { - return next(e); - } + return async function generateExam(req, res, next) { + const { + user, + params: { id } + } = req; - const { alreadyCompleted, updateData } = buildUserUpdate( - user, - completedChallenge.id, - completedChallenge - ); - - user.updateAttributes(updateData, err => { - if (err) { - return next(err); + try { + await user.getCompletedChallenges$().toPromise(); + } catch (e) { + return next(e); } - return res.json({ - alreadyCompleted, - points: alreadyCompleted ? user.points : user.points + 1, - completedDate: completedChallenge.completedDate - }); - }); + try { + const examFromDb = await Exam.findById(id); + if (!examFromDb) { + res.status(500); + throw new Error( + `An error occurred trying to get the exam from the database.` + ); + } + + // This is cause there was struggles validating the exam directly from the db/loopback + const examJson = JSON.parse(JSON.stringify(examFromDb)); + + const validExamFromDbSchema = validateExamFromDbSchema(examJson); + + if (validExamFromDbSchema.error) { + res.status(500); + log(validExamFromDbSchema.error); + throw new Error( + `An error occurred validating the exam information from the database.` + ); + } + + const { prerequisites, numberOfQuestionsInExam, title } = examJson; + + // Validate User has completed prerequisite challenges + prerequisites?.forEach(prerequisite => { + const prerequisiteCompleted = user.completedChallenges.find( + challenge => challenge.id === prerequisite.id + ); + + if (!prerequisiteCompleted) { + res.status(403); + throw new Error( + `You have not completed the required challenges to start the '${title}'.` + ); + } + }); + + const randomizedExam = generateRandomExam(examJson); + + const validGeneratedExamSchema = validateGeneratedExamSchema( + randomizedExam, + numberOfQuestionsInExam + ); + + if (validGeneratedExamSchema.error) { + res.status(500); + log(validGeneratedExamSchema.error); + throw new Error(`An error occurred trying to randomize the exam.`); + } + + return res.send({ generatedExam: randomizedExam }); + } catch (err) { + log(err); + return res.send({ error: err.message }); + } + }; +} + +function createExamChallengeCompleted(app) { + const { Exam } = app.models; + + return async function examChallengeCompleted(req, res, next) { + const { body = {}, user } = req; + + try { + await user.getCompletedChallenges$().toPromise(); + } catch (e) { + return next(e); + } + + const { userCompletedExam = [], id } = body; + + try { + const examFromDb = await Exam.findById(id); + if (!examFromDb) { + res.status(500); + throw new Error( + `An error occurred tryng to get the exam from the database.` + ); + } + + // This is cause there was struggles validating the exam directly from the db/loopback + const examJson = JSON.parse(JSON.stringify(examFromDb)); + + const validExamFromDbSchema = validateExamFromDbSchema(examJson); + if (validExamFromDbSchema.error) { + res.status(500); + log(validExamFromDbSchema.error); + throw new Error( + `An error occurred validating the exam information from the database.` + ); + } + + const { prerequisites, numberOfQuestionsInExam, title } = examJson; + + // Validate User has completed prerequisite challenges + prerequisites?.forEach(prerequisite => { + const prerequisiteCompleted = user.completedChallenges.find( + challenge => challenge.id === prerequisite.id + ); + + if (!prerequisiteCompleted) { + res.status(403); + throw new Error( + `You have not completed the required challenges to start the '${title}'.` + ); + } + }); + + // Validate user completed exam + const validUserCompletedExam = validateUserCompletedExamSchema( + userCompletedExam, + numberOfQuestionsInExam + ); + if (validUserCompletedExam.error) { + res.status(400); + log(validUserCompletedExam.error); + throw new Error(`An error occurred validating the submitted exam.`); + } + + const examResults = createExamResults(userCompletedExam, examJson); + + const validExamResults = validateExamResultsSchema(examResults); + if (validExamResults.error) { + res.status(500); + log(validExamResults.error); + throw new Error(`An error occurred validating the submitted exam.`); + } + + const completedChallenge = pick(body, ['id', 'challengeType']); + completedChallenge.completedDate = Date.now(); + completedChallenge.examResults = examResults; + + const { addPoint, alreadyCompleted, updateData, completedDate } = + buildExamUserUpdate(user, completedChallenge); + + user.updateAttributes(updateData, err => { + if (err) { + return next(err); + } + + const points = addPoint ? user.points + 1 : user.points; + + return res.json({ + alreadyCompleted, + points, + completedDate, + examResults + }); + }); + } catch (err) { + log(err); + return res.send({ error: err.message }); + } + }; } async function saveChallenge(req, res, next) { diff --git a/api-server/src/server/boot/user.js b/api-server/src/server/boot/user.js index 005ebc89afc..7f76564b8b8 100644 --- a/api-server/src/server/boot/user.js +++ b/api-server/src/server/boot/user.js @@ -5,6 +5,7 @@ import { pick } from 'lodash'; import { fixCompletedChallengeItem, + fixCompletedExamItem, fixPartiallyCompletedChallengeItem, fixSavedChallengeItem } from '../../common/utils'; @@ -120,12 +121,14 @@ function createReadSessionUser(app) { try { const [ completedChallenges, + completedExams, partiallyCompletedChallenges, progressTimestamps, savedChallenges ] = await Promise.all( [ queryUser.getCompletedChallenges$(), + queryUser.getCompletedExams$(), queryUser.getPartiallyCompletedChallenges$(), queryUser.getPoints$(), queryUser.getSavedChallenges$() @@ -137,6 +140,7 @@ function createReadSessionUser(app) { ...queryUser.toJSON(), calendar, completedChallenges: completedChallenges.map(fixCompletedChallengeItem), + completedExams: completedExams.map(fixCompletedExamItem), partiallyCompletedChallenges: partiallyCompletedChallenges.map( fixPartiallyCompletedChallengeItem ), @@ -248,6 +252,7 @@ function postResetProgress(req, res, next) { isRelationalDatabaseCertV8: false, isCollegeAlgebraPyCertV8: false, completedChallenges: [], + completedExams: [], savedChallenges: [], partiallyCompletedChallenges: [], needsModeration: false diff --git a/api-server/src/server/boot_tests/challenge.test.js b/api-server/src/server/boot_tests/challenge.test.js index 2088a62a2ef..cff54f02153 100644 --- a/api-server/src/server/boot_tests/challenge.test.js +++ b/api-server/src/server/boot_tests/challenge.test.js @@ -2,6 +2,7 @@ import { find } from 'lodash'; import { buildUserUpdate, + buildExamUserUpdate, buildChallengeUrl, createChallengeUrlResolver, createRedirectToCurrentChallenge, @@ -15,10 +16,15 @@ import { mockAllChallenges, mockChallenge, mockUser, + mockUser2, mockGetFirstChallenge, mockCompletedChallenge, mockCompletedChallengeNoFiles, - mockCompletedChallenges + mockCompletedChallenges, + mockFailingExamChallenge, + mockPassingExamChallenge, + mockBetterPassingExamChallenge, + mockWorsePassingExamChallenge } from './fixtures'; export const mockReq = opts => { @@ -145,6 +151,71 @@ describe('boot/challenge', () => { }); }); + describe('buildExamUserUpdate', () => { + it('should $push exam results to completedExams[]', () => { + const { + updateData: { + $push: { completedExams } + } + } = buildExamUserUpdate(mockUser, mockFailingExamChallenge); + expect(completedExams).toEqual(mockFailingExamChallenge); + }); + + it('should not add failing exams to completedChallenges[]', () => { + const { alreadyCompleted, addPoint, updateData } = buildExamUserUpdate( + mockUser, + mockFailingExamChallenge + ); + + expect(updateData).not.toHaveProperty('$push.completedChallenges'); + expect(updateData).not.toHaveProperty('$set.completedChallenges'); + expect(addPoint).toBe(false); + expect(alreadyCompleted).toBe(false); + }); + + it('should $push newly passed exams to completedChallenge[]', () => { + const { + alreadyCompleted, + addPoint, + updateData: { + $push: { completedChallenges } + } + } = buildExamUserUpdate(mockUser, mockPassingExamChallenge); + + expect(completedChallenges).toEqual(mockPassingExamChallenge); + expect(addPoint).toBe(true); + expect(alreadyCompleted).toBe(false); + }); + + it('should not update passed exams with worse results in completedChallenge[]', () => { + const { alreadyCompleted, addPoint, updateData } = buildExamUserUpdate( + mockUser2, + mockWorsePassingExamChallenge + ); + + expect(updateData).not.toHaveProperty('$push.completedChallenges'); + expect(updateData).not.toHaveProperty('$set.completedChallenges'); + expect(addPoint).toBe(false); + expect(alreadyCompleted).toBe(true); + }); + + it('should update passed exams with better results in completedChallenge[]', () => { + const { + alreadyCompleted, + addPoint, + completedDate, + updateData: { $set } + } = buildExamUserUpdate(mockUser2, mockBetterPassingExamChallenge); + + expect($set['completedChallenges.4'].examResults).toEqual( + mockBetterPassingExamChallenge.examResults + ); + expect(addPoint).toBe(false); + expect(alreadyCompleted).toBe(true); + expect(completedDate).toBe(1538052380328); + }); + }); + describe('buildChallengeUrl', () => { it('resolves the correct Url for the provided challenge', () => { const result = buildChallengeUrl(mockChallenge); diff --git a/api-server/src/server/boot_tests/fixtures.js b/api-server/src/server/boot_tests/fixtures.js index 81f202b4c90..3e155feabb3 100644 --- a/api-server/src/server/boot_tests/fixtures.js +++ b/api-server/src/server/boot_tests/fixtures.js @@ -42,6 +42,62 @@ export const mockCompletedChallengeNoFiles = { completedDate: Date.now() }; +export const mockFailingExamChallenge = { + id: '647e22d18acb466c97ccbef8', + challengeType: 17, + completedDate: Date.now(), + examResults: { + "numberOfCorrectAnswers" : 5, + "numberOfQuestionsInExam" : 10, + "percentCorrect" : 50, + "passingPercent" : 70, + "passed" : false, + "examTimeInSeconds" : 1200 + } +} + +export const mockPassingExamChallenge = { + id: '647e22d18acb466c97ccbef8', + challengeType: 17, + completedDate: 1538052380328, + examResults: { + "numberOfCorrectAnswers" : 9, + "numberOfQuestionsInExam" : 10, + "percentCorrect" : 90, + "passingPercent" : 70, + "passed" : true, + "examTimeInSeconds" : 1200 + } +} + +export const mockBetterPassingExamChallenge = { + id: '647e22d18acb466c97ccbef8', + challengeType: 17, + completedDate: Date.now(), + examResults: { + "numberOfCorrectAnswers" : 10, + "numberOfQuestionsInExam" : 10, + "percentCorrect" : 100, + "passingPercent" : 70, + "passed" : true, + "examTimeInSeconds" : 1200 + } +} + +export const mockWorsePassingExamChallenge = { + id: '647e22d18acb466c97ccbef8', + challengeType: 17, + completedDate: Date.now(), + examResults: { + "numberOfCorrectAnswers" : 8, + "numberOfQuestionsInExam" : 10, + "percentCorrect" : 80, + "passingPercent" : 70, + "passed" : true, + "examTimeInSeconds" : 1200 + } +} + export const mockCompletedChallenges = [ { id: 'bd7123c8c441eddfaeb5bdef', @@ -96,6 +152,9 @@ export const mockUser = { updateAttributes: updateUserAttr }; +export const mockUser2 = JSON.parse(JSON.stringify(mockUser)); +mockUser2.completedChallenges.push(mockPassingExamChallenge); + const mockObservable = { toPromise: () => Promise.resolve('result') }; diff --git a/api-server/src/server/model-config.json b/api-server/src/server/model-config.json index 62382112c35..d7720094385 100644 --- a/api-server/src/server/model-config.json +++ b/api-server/src/server/model-config.json @@ -35,6 +35,10 @@ "dataSource": "mail", "public": false }, + "Exam": { + "dataSource": "db", + "public": false + }, "Role": { "dataSource": "db", "public": false diff --git a/api-server/src/server/models/exam.json b/api-server/src/server/models/exam.json new file mode 100644 index 00000000000..cb0792e168b --- /dev/null +++ b/api-server/src/server/models/exam.json @@ -0,0 +1,70 @@ +{ + "name": "Exam", + "description": "Exam questions for exam style challenges", + "base": "PersistedModel", + "idInjection": true, + "options": { + "strict": true + }, + "properties": { + "numberOfQuestionsInExam": { + "type": "number", + "required": true + }, + "passingPercent": { + "type": "number", + "required": true + }, + "prerequisites": { + "type": [ + { + "id": "string", + "title": "string" + } + ] + }, + "questions": { + "type": [ + { + "id": "string", + "question": "string", + "wrongAnswers": { + "type": [ + { + "id": "string", + "answer": "string" + } + ], + "required": true + }, + "correctAnswers": { + "type": [ + { + "id": "string", + "answer": "string" + } + ], + "required": true + } + } + ], + "required": true, + "itemType": "Question" + }, + "title": { + "type": "string", + "required": true + } + }, + "validations": [], + "relations": {}, + "acls": [ + { + "accessType": "*", + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "DENY" + } + ], + "methods": {} +} diff --git a/api-server/src/server/utils/__mocks__/exam.js b/api-server/src/server/utils/__mocks__/exam.js new file mode 100644 index 00000000000..13c524a6a44 --- /dev/null +++ b/api-server/src/server/utils/__mocks__/exam.js @@ -0,0 +1,91 @@ +export const examJson = { + id: 1, + numberOfQuestionsInExam: 1, + passingPercent: 70, + 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' } + ] + } + ] +}; + +// 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-server/src/server/utils/exam-schemas.js b/api-server/src/server/utils/exam-schemas.js new file mode 100644 index 00000000000..eb67f3d1c99 --- /dev/null +++ b/api-server/src/server/utils/exam-schemas.js @@ -0,0 +1,160 @@ +import Joi from 'joi'; +import JoiObjectId from 'joi-objectid'; + +Joi.objectId = JoiObjectId(Joi); + +const nanoIdRE = new RegExp('[a-z0-9]{10}'); + +// Exam from database schema +const DbPrerequisitesJoi = Joi.object().keys({ + id: Joi.objectId().required(), + title: Joi.string() +}); + +const DbAnswerJoi = Joi.object().keys({ + id: Joi.string().regex(nanoIdRE).required(), + deprecated: Joi.bool(), + answer: Joi.string().required() +}); + +const DbQuestionJoi = Joi.object().keys({ + id: Joi.string().regex(nanoIdRE).required(), + question: Joi.string().required(), + deprecated: Joi.bool(), + wrongAnswers: Joi.array() + .items(DbAnswerJoi) + .required() + .custom((value, helpers) => { + const nonDeprecatedCount = value.reduce( + (count, answer) => (answer.deprecated ? count : count + 1), + 0 + ); + const minimumAnswers = 4; + + if (nonDeprecatedCount < minimumAnswers) { + return helpers.message( + `'wrongAnswers' must have at least ${minimumAnswers} non-deprecated answers.` + ); + } + + return value; + }), + correctAnswers: Joi.array() + .items(DbAnswerJoi) + .required() + .custom((value, helpers) => { + const nonDeprecatedCount = value.reduce( + (count, answer) => (answer.deprecated ? count : count + 1), + 0 + ); + const minimumAnswers = 1; + + if (nonDeprecatedCount < minimumAnswers) { + return helpers.message( + `'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.objectId().required(), + title: Joi.string().required(), + numberOfQuestionsInExam: Joi.number() + .min(1) + .max( + Joi.ref('questions', { + adjust: questions => { + const nonDeprecatedCount = questions.reduce( + (count, 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() +}); + +export const validateExamFromDbSchema = exam => { + return examFromDbSchema.validate(exam); +}; + +// 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(); + +export const validateGeneratedExamSchema = (exam, numberOfQuestionsInExam) => { + 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) +}); + +export const validateUserCompletedExamSchema = ( + exam, + numberOfQuestionsInExam +) => { + // TODO: Validate that the properties exist + if (!exam.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) +}); + +export const validateExamResultsSchema = examResults => { + return examResultsSchema.validate(examResults); +}; diff --git a/api-server/src/server/utils/exam.js b/api-server/src/server/utils/exam.js new file mode 100644 index 00000000000..38262fc8f04 --- /dev/null +++ b/api-server/src/server/utils/exam.js @@ -0,0 +1,103 @@ +function shuffleArray(arr) { + let currentIndex = arr.length, + randomIndex; + + while (currentIndex != 0) { + randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + [arr[currentIndex], arr[randomIndex]] = [ + arr[randomIndex], + arr[currentIndex] + ]; + } + + return arr; +} + +function filterDeprecated(arr) { + return arr.filter(i => !i.deprecated); +} + +function getRandomElement(arr) { + const id = Math.floor(Math.random() * arr.length); + return arr[id]; +} + +// Used to generate a random exam +export function generateRandomExam(examJson) { + const { numberOfQuestionsInExam, questions } = examJson; + const numberOfAnswersPerQuestion = 5; + + const availableQuestions = shuffleArray(filterDeprecated(questions)); + const examQuestions = availableQuestions.slice(0, numberOfQuestionsInExam); + + const randomizedExam = examQuestions.map(question => { + const { correctAnswers, wrongAnswers } = question; + const availableCorrectAnswers = filterDeprecated(correctAnswers); + const availableWrongAnswers = shuffleArray(filterDeprecated(wrongAnswers)); + const correctAnswer = getRandomElement(availableCorrectAnswers); + const answers = shuffleArray([ + correctAnswer, + ...availableWrongAnswers.slice(0, numberOfAnswersPerQuestion - 1) + ]); + return { + id: question.id, + question: question.question, + answers + }; + }); + + return randomizedExam; +} + +// Used to evaluate user completed exams +export function createExamResults(userExam, originalExam) { + const { userExamQuestions, examTimeInSeconds } = userExam; + /** + * Potential Bug: + * numberOfQuestionsInExam and passingPercent come from the exam in the database. + * If either changes between the time a camper starts and submits, it could skew + * the scores. The alternative is to send those to the client and then get them + * back from the client - but then they could be manipulated to cheat. So I think + * this is the way to go. They are unlikely to change, as that would be unfair. We + * could get numberOfQuestionsInExam from userExamQuestions.length - so only the + * passingPercent would come from the database. Maybe that would be better. + */ + 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/api-server/src/server/utils/exam.test.js b/api-server/src/server/utils/exam.test.js new file mode 100644 index 00000000000..d810734f620 --- /dev/null +++ b/api-server/src/server/utils/exam.test.js @@ -0,0 +1,54 @@ +import { generateRandomExam, createExamResults } from './exam'; + +import { + examJson, + userExam1, + userExam2, + mockResults1, + mockResults2 +} from './__mocks__/exam'; + +describe('Exam helpers', () => { + describe('generateRandomExam()', () => { + const randomizedExam = generateRandomExam(examJson); + + it('should have one question', () => { + expect(randomizedExam.length).toBe(1); + }); + + it('should have five answers', () => { + const firstQuestion = randomizedExam[0]; + expect(firstQuestion.answers.length).toBe(5); + }); + + it('should have exactly one correct answer', () => { + const question = randomizedExam[0]; + const questionId = question.id; + const originalQuestion = examJson.questions.find( + q => q.id === questionId + ); + 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); + const examResults2 = createExamResults(userExam2, examJson); + + 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-server/src/server/utils/publicUserProps.js b/api-server/src/server/utils/publicUserProps.js index e621b8cb829..51262165e3c 100644 --- a/api-server/src/server/utils/publicUserProps.js +++ b/api-server/src/server/utils/publicUserProps.js @@ -4,6 +4,7 @@ export const publicUserProps = [ 'about', 'calendar', 'completedChallenges', + 'completedExams', 'githubProfile', 'isApisMicroservicesCert', 'isBackEndCert', diff --git a/config/constants.js b/config/constants.js index 27a564357f0..19934b77d64 100644 --- a/config/constants.js +++ b/config/constants.js @@ -69,6 +69,7 @@ let blocklist = [ 'donate', 'email-signin', 'events', + 'exam', 'explorer', 'external', 'field-guide', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 293870d69b6..172ea27c24a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -437,6 +437,12 @@ importers: '@babel/register': specifier: 7.17.7 version: 7.17.7(@babel/core@7.18.0) + joi: + specifier: 17.9.2 + version: 17.9.2 + joi-objectid: + specifier: 3.0.1 + version: 3.0.1 loopback-component-explorer: specifier: 6.4.0 version: 6.4.0