feat(api): create endpoints for exams (#51062)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>pull/51143/head
parent
8b9ca4c3ab
commit
80dba8fd30
|
@ -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"
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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": []
|
||||
|
|
|
@ -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']);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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')
|
||||
};
|
||||
|
|
|
@ -35,6 +35,10 @@
|
|||
"dataSource": "mail",
|
||||
"public": false
|
||||
},
|
||||
"Exam": {
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
},
|
||||
"Role": {
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
|
|
|
@ -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": {}
|
||||
}
|
|
@ -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
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -4,6 +4,7 @@ export const publicUserProps = [
|
|||
'about',
|
||||
'calendar',
|
||||
'completedChallenges',
|
||||
'completedExams',
|
||||
'githubProfile',
|
||||
'isApisMicroservicesCert',
|
||||
'isBackEndCert',
|
||||
|
|
|
@ -69,6 +69,7 @@ let blocklist = [
|
|||
'donate',
|
||||
'email-signin',
|
||||
'events',
|
||||
'exam',
|
||||
'explorer',
|
||||
'external',
|
||||
'field-guide',
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue