mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-09-24 23:01:18 +08:00
feat(api): add exam utils and GET /exam/:id endpoint to new api (#51609)
This commit is contained in:
@@ -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
|
||||
};
|
||||
@@ -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<ReturnType<typeof build>>;
|
||||
|
||||
@@ -92,3 +93,19 @@ export async function devLogin(): Promise<string[]> {
|
||||
expect(res.status).toBe(200);
|
||||
return res.get('Set-Cookie');
|
||||
}
|
||||
|
||||
export async function seedExam(): Promise<void> {
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+3
-2
@@ -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": {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Exam, Question } from '@prisma/client';
|
||||
import { UserExam, GeneratedExam } from './exam-types';
|
||||
|
||||
function shuffleArray<T>(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<T extends { deprecated: boolean | null }>(
|
||||
arr: T[]
|
||||
): T[] {
|
||||
return arr.filter(i => !i.deprecated);
|
||||
}
|
||||
|
||||
function getRandomElement<T>(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
|
||||
};
|
||||
}
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user