feat(api): add POST /exam-challenge-completed (#52395)

This commit is contained in:
Tom
2023-12-27 09:10:30 -06:00
committed by GitHub
parent 741ca9338e
commit 1efb22cd34
15 changed files with 842 additions and 64 deletions

View File

@@ -15,8 +15,22 @@ import {
defaultUserEmail,
createSuperRequest
} from '../../jest.utils';
import { completedTrophyChallenges } from '../../__mocks__/exam';
import { GeneratedAnswer } from '../utils/exam-types';
import {
completedExamChallenge2,
completedExamChallenge3,
completedExamChallenge4,
completedTrophyChallenges,
examChallengeId,
mockResults1,
mockResults2,
mockResults3,
mockResults4,
userExam1,
userExam2,
userExam3,
userExam4
} from '../../__mocks__/exam';
import { Answer } from '../utils/exam-types';
jest.mock('./helpers/challenge-helpers', () => {
const originalModule = jest.requireActual<
@@ -138,6 +152,7 @@ describe('challengeRoutes', () => {
setCookies = await devLogin();
superPost = createSuperRequest({ method: 'POST', setCookies });
superGet = createSuperRequest({ method: 'GET', setCookies });
await seedExam();
});
describe('POST /coderoad-challenge-completed', () => {
@@ -986,7 +1001,7 @@ describe('challengeRoutes', () => {
const { generatedExam } = response.body;
expect(Array.isArray(generatedExam)).toBe(true);
expect(generatedExam).toHaveLength(1);
expect(generatedExam).toHaveLength(3);
expect(generatedExam[0]).toHaveProperty('question');
expect(typeof generatedExam[0].question).toBe('string');
@@ -998,7 +1013,7 @@ describe('challengeRoutes', () => {
expect(Array.isArray(generatedExam[0].answers)).toBe(true);
expect(generatedExam[0].answers).toHaveLength(5);
const answers = generatedExam[0].answers as GeneratedAnswer[];
const answers = generatedExam[0].answers as Answer[];
answers.forEach(a => {
expect(a).toHaveProperty('answer');
@@ -1253,6 +1268,375 @@ describe('challengeRoutes', () => {
});
});
});
describe('/exam-challenge-completed', () => {
afterEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { id: defaultUserId },
data: {
completedChallenges: [],
completedExams: [],
progressTimestamps: []
}
});
});
describe('validation', () => {
test('POST rejects requests with no body', async () => {
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
});
expect(response.body).toStrictEqual({
error: `Valid request body not found in attempt to submit exam.`
});
expect(response.statusCode).toBe(400);
});
test('POST rejects requests without valid ObjectID', async () => {
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({ id: 'not-a-valid-id' });
expect(response.body).toStrictEqual({
error: `Valid request body not found in attempt to submit exam.`
});
expect(response.statusCode).toBe(400);
});
test('POST rejects requests with valid, but non existing ID', async () => {
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: '647e22d18acb466c97ccbef0',
challengeType: 17,
userCompletedExam: {
examTimeInSeconds: 111,
userExamQuestions: [
{
id: 'q-id',
question: '?',
answer: {
id: 'a-id',
answer: 'a'
}
}
]
}
});
expect(response.body).toStrictEqual({
error: `An error occurred trying to get the exam from the database.`
});
expect(response.statusCode).toBe(400);
});
test('POST rejects requests without valid userCompletedExam schema', async () => {
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: examChallengeId,
challengeType: 17,
userCompletedExam: ''
});
expect(response.body).toStrictEqual({
error: `Valid request body not found in attempt to submit exam.`
});
expect(response.statusCode).toBe(400);
});
test('POST rejects requests without valid examTimeInSeconds schema', async () => {
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: examChallengeId,
challengeType: 17,
userCompletedExam: { examTimeInSeconds: 'a' }
});
expect(response.body).toStrictEqual({
error: `Valid request body not found in attempt to submit exam.`
});
expect(response.statusCode).toBe(400);
});
test('POST rejects requests without valid userExamQuestions schema', async () => {
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: examChallengeId,
challengeType: 17,
userCompletedExam: { examTimeInSeconds: 11, userExamQuestions: [] }
});
expect(response.body).toStrictEqual({
error: `Valid request body not found in attempt to submit exam.`
});
expect(response.statusCode).toBe(400);
});
test('POST rejects requests with prerequisites not completed', async () => {
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: examChallengeId,
challengeType: 17,
userCompletedExam: {
examTimeInSeconds: 111,
userExamQuestions: [
{
id: 'q-id',
question: '?',
answer: {
id: 'a-id',
answer: 'a'
}
}
]
}
});
expect(response.body).toStrictEqual({
error: `You have not completed the required challenges to start the 'Exam Certification'.`
});
expect(response.statusCode).toBe(403);
});
test('POST rejects requests with invalid userCompletedExam values', async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: 'foo@bar.com' },
data: {
completedChallenges: completedTrophyChallenges
}
});
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: examChallengeId,
challengeType: 17,
userCompletedExam: {
examTimeInSeconds: 111,
userExamQuestions: [
{
id: 'q-id',
question: '?',
answer: {
id: 'a-id',
answer: 'a'
}
}
]
}
});
expect(response.body).toStrictEqual({
error: `An error occurred trying to submit your exam.`
});
expect(response.statusCode).toBe(500);
});
});
describe('handling', () => {
beforeEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { id: defaultUserId },
data: {
completedChallenges: completedTrophyChallenges
}
});
});
afterEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { id: defaultUserId },
data: {
completedChallenges: [],
completedExams: [],
progressTimestamps: []
}
});
});
test('POST handles submitting a failing exam', async () => {
const now = Date.now();
// Submit exam with 0 correct answers
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: examChallengeId,
challengeType: 17,
userCompletedExam: userExam1
});
const {
completedChallenges = [],
completedExams = [],
progressTimestamps = []
} = (await fastifyTestInstance.prisma.user.findFirst({
where: { email: 'foo@bar.com' }
})) || {};
// should have the 1 prerequisite challenge
expect(completedChallenges).toHaveLength(1);
expect(completedExams).toHaveLength(1);
expect(progressTimestamps).toHaveLength(0);
expect(completedChallenges).toMatchObject(completedTrophyChallenges);
expect(completedExams[0]).toMatchObject({
id: '647e22d18acb466c97ccbef8',
challengeType: 17,
examResults: mockResults1
});
expect(completedExams[0]?.completedDate).toBeGreaterThan(now);
expect(response.body).toMatchObject({
points: 0,
alreadyCompleted: false,
examResults: mockResults1
});
expect(response.statusCode).toBe(200);
});
test('POST handles submitting multiple passing exams', async () => {
// Submit exam with 2/3 correct answers
const nowA = Date.now();
const responseA = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: examChallengeId,
challengeType: 17,
userCompletedExam: userExam3
});
const userA = await fastifyTestInstance.prisma.user.findFirst({
where: { id: defaultUserId }
});
const completedChallengesA = userA?.completedChallenges || [];
const completedExamsA = userA?.completedExams || [];
const progressTimestampsA = userA?.progressTimestamps || [];
// should add to completedChallenges
expect(completedChallengesA).toHaveLength(2);
expect(completedChallengesA).toMatchObject([
...completedTrophyChallenges,
completedExamChallenge3
]);
expect(completedChallengesA[1]?.completedDate).toBeGreaterThan(nowA);
// should add to completedExams
expect(completedExamsA).toHaveLength(1);
expect(completedExamsA[0]).toMatchObject(completedExamChallenge3);
expect(completedExamsA[0]?.completedDate).toBeGreaterThan(nowA);
// should add to progressTimestamps
expect(progressTimestampsA).toHaveLength(1);
expect(responseA.body).toMatchObject({
points: 1,
alreadyCompleted: false,
examResults: mockResults3
});
expect(responseA.statusCode).toBe(200);
// Submit exam with 1/3 correct answers (worse exam than already submitted)
const now2 = Date.now();
const response2 = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: examChallengeId,
challengeType: 17,
userCompletedExam: userExam2
});
const user2 = await fastifyTestInstance.prisma.user.findFirst({
where: { id: defaultUserId }
});
const completedChallenges2 = user2?.completedChallenges || [];
const completedExams2 = user2?.completedExams || [];
const progressTimestamps2 = user2?.progressTimestamps || [];
// should not add to or update completedChallenges
expect(completedChallenges2).toHaveLength(2);
expect(completedChallenges2).toMatchObject([
...completedTrophyChallenges,
// should still have old completed challenge (should not update)
completedExamChallenge3
]);
expect(completedChallenges2[1]?.completedDate).toBeLessThan(now2);
// should add to completedExams
expect(completedExams2).toHaveLength(2);
expect(completedExams2[1]).toMatchObject(completedExamChallenge2);
expect(completedExams2[1]?.completedDate).toBeGreaterThan(nowA);
// should not add to progressTimestamps
expect(progressTimestamps2).toHaveLength(1);
expect(response2.body).toMatchObject({
points: 1,
alreadyCompleted: true,
examResults: mockResults2
});
expect(response2.statusCode).toBe(200);
// Submit exam with 3/3 correct answers (better exam than already submitted)
const now3 = Date.now();
const response3 = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: examChallengeId,
challengeType: 17,
userCompletedExam: userExam4
});
const user3 = await fastifyTestInstance.prisma.user.findFirst({
where: { email: 'foo@bar.com' }
});
const completedChallenges3 = user3?.completedChallenges || [];
const completedExams3 = user3?.completedExams || [];
const progressTimestamps3 = user3?.progressTimestamps || [];
// should update existing completedChallenge
expect(completedChallenges3).toHaveLength(2);
expect(completedChallenges3).toMatchObject([
...completedTrophyChallenges,
completedExamChallenge4
]);
expect(completedChallenges3[1]?.completedDate).toBeLessThan(now3);
// should add to completedExams
expect(completedExams3).toHaveLength(3);
expect(completedExams3[2]).toMatchObject(completedExamChallenge4);
expect(completedExams3[2]?.completedDate).toBeGreaterThan(now3);
expect(progressTimestamps3).toHaveLength(1);
expect(response3.body).toMatchObject({
points: 1,
alreadyCompleted: true,
examResults: mockResults4
});
expect(response3.statusCode).toBe(200);
});
});
});
});
describe('Unauthenticated user', () => {
@@ -1271,7 +1655,8 @@ describe('challengeRoutes', () => {
{ path: '/modern-challenge-completed', method: 'POST' },
{ path: '/save-challenge', method: 'POST' },
{ path: '/exam/647e22d18acb466c97ccbef8', method: 'GET' },
{ path: '/ms-trophy-challenge-completed', method: 'POST' }
{ path: '/ms-trophy-challenge-completed', method: 'POST' },
{ path: '/exam-challenge-completed', method: 'POST' }
];
endpoints.forEach(({ path, method }) => {

View File

@@ -1,6 +1,7 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import jwt from 'jsonwebtoken';
import { uniqBy } from 'lodash';
import { CompletedExam, ExamResults } from '@prisma/client';
import { challengeTypes } from '../../../shared/config/challenge-types';
import { schemas } from '../schemas';
@@ -21,9 +22,11 @@ import { getChallenges } from '../utils/get-challenges';
import { ProgressTimestamp, getPoints } from '../utils/progress';
import {
validateExamFromDbSchema,
validateGeneratedExamSchema
validateGeneratedExamSchema,
validateUserCompletedExamSchema,
validateExamResultsSchema
} from '../utils/exam-schemas';
import { generateRandomExam } from '../utils/exam';
import { generateRandomExam, createExamResults } from '../utils/exam';
import {
canSubmitCodeRoadCertProject,
createProject,
@@ -614,5 +617,200 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
}
);
fastify.post(
'/exam-challenge-completed',
{
schema: schemas.examChallengeCompleted,
errorHandler(error, request, reply) {
if (error.validation) {
void reply.code(400);
void reply.send({
error: 'Valid request body not found in attempt to submit exam.'
});
} else {
fastify.errorHandler(error, request, reply);
}
}
},
async (req, reply) => {
try {
const { id: userId } = req.session.user;
const { userCompletedExam, id, challengeType } = req.body;
const { completedChallenges, completedExams, progressTimestamps } =
await fastify.prisma.user.findUniqueOrThrow({
where: { id: userId },
select: {
completedChallenges: true,
completedExams: true,
progressTimestamps: true
}
});
const examFromDb = await fastify.prisma.exam.findUnique({
where: { id }
});
if (!examFromDb) {
void reply.code(400);
return {
error: 'An error occurred trying to get the exam from the database.'
};
}
const validExamFromDbSchema = validateExamFromDbSchema(examFromDb);
if ('error' in validExamFromDbSchema) {
void reply.code(500);
return {
error:
'An error occurred validating the exam information from the database.'
};
}
const { prerequisites, numberOfQuestionsInExam, title } = examFromDb;
const prerequisiteIds = prerequisites.map(p => p.id);
const completedPrerequisites = completedChallenges.filter(c =>
prerequisiteIds.includes(c.id)
);
if (completedPrerequisites.length !== prerequisiteIds.length) {
void reply.code(403);
return {
error: `You have not completed the required challenges to start the '${title}'.`
};
}
const validUserCompletedExam = validateUserCompletedExamSchema(
userCompletedExam,
numberOfQuestionsInExam
);
if ('error' in validUserCompletedExam) {
fastify.log.error(validUserCompletedExam.error);
void reply.code(400);
return {
error: 'An error occurred validating the submitted exam.'
};
}
const examResults = createExamResults(userCompletedExam, examFromDb);
const validExamResults = validateExamResultsSchema(examResults);
if ('error' in validExamResults) {
fastify.log.error(validExamResults.error);
void reply.code(500);
return {
error: 'An error occurred validating the submitted exam.'
};
}
const newCompletedChallenges: CompletedChallenge[] =
completedChallenges;
const newCompletedExams: CompletedExam[] = completedExams;
const newProgressTimeStamps = progressTimestamps as ProgressTimestamp[];
const completedDate = Date.now();
const newCompletedChallenge = {
id,
challengeType,
completedDate,
examResults
};
// Always push to completedExams[] to keep a record of all exams taken.
newCompletedExams.push(newCompletedChallenge);
let addPoint = false;
const alreadyCompletedIndex = completedChallenges.findIndex(
c => c.id === id
);
const alreadyCompleted = alreadyCompletedIndex >= 0;
if (examResults.passed) {
if (alreadyCompleted) {
const { percentCorrect } = examResults;
const oldChallenge = completedChallenges[
alreadyCompletedIndex
] as CompletedChallenge;
const oldResults = oldChallenge?.examResults as ExamResults;
const newChallenge = oldChallenge;
newChallenge ? (newChallenge.examResults = examResults) : null;
// only update if it's a better result
if (percentCorrect > oldResults.percentCorrect) {
const updatedChallege = {
id,
challengeType: oldChallenge.challengeType,
completedDate: oldChallenge.completedDate,
examResults
};
newCompletedChallenges[alreadyCompletedIndex] = updatedChallege;
await fastify.prisma.user.update({
where: { id: userId },
data: {
completedExams: newCompletedExams,
completedChallenges: newCompletedChallenges
}
});
} else {
await fastify.prisma.user.update({
where: { id: userId },
data: {
completedExams: newCompletedExams
}
});
}
// not already completed, push to completedChallenges
} else {
addPoint = true;
newCompletedChallenges.push(newCompletedChallenge);
await fastify.prisma.user.update({
where: { id: userId },
data: {
completedExams: newCompletedExams,
completedChallenges: newCompletedChallenges,
progressTimestamps: [
...newProgressTimeStamps,
newCompletedChallenge.completedDate
]
}
});
}
// exam not passed
} else {
await fastify.prisma.user.update({
where: { id: userId },
data: {
completedExams: newCompletedExams
}
});
}
const points = getPoints(newProgressTimeStamps);
return {
alreadyCompleted,
points: addPoint ? points + 1 : points,
completedDate,
examResults
};
} catch (error) {
fastify.log.error(error);
void reply.code(500);
return {
error: 'An error occurred trying to submit your exam.'
};
}
}
);
done();
};

View File

@@ -25,7 +25,8 @@ const completedChallenges: CompletedChallenge[] = [
files: [],
githubLink: null,
solution: null,
isManuallyApproved: false
isManuallyApproved: false,
examResults: null
}
];

View File

@@ -64,6 +64,7 @@ const testUserData: Prisma.userCreateInput = {
}
],
partiallyCompletedChallenges: [{ id: '123', completedDate: 123 }],
completedExams: [],
githubProfile: 'github.com/foobar',
website: 'https://www.freecodecamp.org',
donationEmails: ['an@add.ress'],
@@ -175,6 +176,7 @@ const publicUserData = {
files: []
}
],
completedExams: testUserData.completedExams,
githubProfile: testUserData.githubProfile,
isApisMicroservicesCert: testUserData.isApisMicroservicesCert,
isBackEndCert: testUserData.isBackEndCert,
@@ -241,6 +243,7 @@ const baseProgressData = {
isRelationalDatabaseCertV8: false,
isCollegeAlgebraPyCertV8: false,
completedChallenges: [],
completedExams: [],
savedChallenges: [],
partiallyCompletedChallenges: [],
needsModeration: false
@@ -589,8 +592,7 @@ describe('userRoutes', () => {
// the following properties are defaults provided if the field is
// missing in the user document.
completedChallenges: [],
// TODO: add completedExams when /generate-exam is implemented
// completedExams: [],
completedExams: [],
partiallyCompletedChallenges: [],
portfolio: [],
savedChallenges: [],

View File

@@ -130,6 +130,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
isRelationalDatabaseCertV8: false,
isCollegeAlgebraPyCertV8: false,
completedChallenges: [],
completedExams: [],
savedChallenges: [],
partiallyCompletedChallenges: [],
needsModeration: false
@@ -396,6 +397,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
about: true,
acceptedPrivacyTerms: true,
completedChallenges: true,
completedExams: true,
currentChallengeId: true,
email: true,
emailVerified: true,

View File

@@ -24,6 +24,15 @@ const saveChallengeBody = Type.Object({
files: Type.Array(file)
});
const examResults = Type.Object({
numberOfCorrectAnswers: Type.Number(),
numberOfQuestionsInExam: Type.Number(),
percentCorrect: Type.Number(),
passingPercent: Type.Number(),
passed: Type.Boolean(),
examTimeInSeconds: Type.Number()
});
export const schemas = {
// Settings:
updateMyProfileUI: {
@@ -307,6 +316,14 @@ export const schemas = {
isManuallyApproved: Type.Optional(Type.Boolean())
})
),
completedExams: Type.Array(
Type.Object({
id: Type.String(),
completedDate: Type.Number(),
challengeType: Type.Optional(Type.Number()),
examResults
})
),
completedChallengeCount: Type.Number(),
currentChallengeId: Type.Optional(Type.String()),
email: Type.String(),
@@ -742,5 +759,42 @@ export const schemas = {
})
])
}
},
examChallengeCompleted: {
body: Type.Object({
id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }),
challengeType: Type.Number(),
userCompletedExam: Type.Object({
examTimeInSeconds: Type.Number(),
userExamQuestions: Type.Array(
Type.Object({
id: Type.String(),
question: Type.String(),
answer: Type.Object({
id: Type.String(),
answer: Type.String()
})
}),
{ minItems: 1 }
)
})
}),
response: {
200: Type.Object({
completedDate: Type.Number(),
points: Type.Number(),
alreadyCompleted: Type.Boolean(),
examResults
}),
400: Type.Object({
error: Type.String()
}),
403: Type.Object({
error: Type.String()
}),
500: Type.Object({
error: Type.String()
})
}
}
};

View File

@@ -1,4 +1,4 @@
import { user } from '@prisma/client';
import { ExamResults, user } from '@prisma/client';
import { FastifyInstance } from 'fastify';
import { omit, pick } from 'lodash';
import { challengeTypes } from '../../../shared/config/challenge-types';
@@ -63,6 +63,7 @@ export type CompletedChallenge = {
completedDate: number;
isManuallyApproved?: boolean | null;
files?: CompletedChallengeFile[];
examResults?: ExamResults | null;
};
/**

View File

@@ -18,6 +18,7 @@ export function createUserInput(email: string): Prisma.userCreateInput {
about: '',
acceptedPrivacyTerms: false,
completedChallenges: [], // TODO(Post-MVP): Omit this from the document? (prisma will always return [])
completedExams: [], // TODO(Post-MVP): Omit this from the document? (prisma will always return [])
currentChallengeId: '',
donationEmails: [], // TODO(Post-MVP): Omit this from the document? (prisma will always return [])
email,

View File

@@ -1,6 +1,6 @@
import { Answer, Exam, Question } from '@prisma/client';
import { Answer, Exam, Question, ExamResults } from '@prisma/client';
import Joi from 'joi';
import { ExamResults, GeneratedExam, UserExam } from './exam-types';
import { GeneratedExam, UserExam } from './exam-types';
const nanoIdRE = new RegExp('[a-z0-9]{10}');
const objectIdRE = new RegExp('^[0-9a-fA-F]{24}$');

View File

@@ -1,13 +1,13 @@
// types for a generated exam
export interface GeneratedAnswer {
export interface Answer {
id: string;
answer: string;
}
// types for a generated exam
export interface GeneratedQuestion {
id: string;
question: string;
answers: GeneratedAnswer[];
answers: Answer[];
}
export type GeneratedExam = GeneratedQuestion[];
@@ -16,19 +16,10 @@ export type GeneratedExam = GeneratedQuestion[];
export interface UserQuestion {
id: string;
question: string;
answer: GeneratedAnswer;
answer: Answer;
}
export interface UserExam {
userExamQuestions: UserQuestion[];
examTimeInSeconds: number;
}
export interface ExamResults {
numberOfCorrectAnswers: number;
numberOfQuestionsInExam: number;
percentCorrect: number;
passingPercent: number;
passed: boolean;
examTimeInSeconds: number;
}

View File

@@ -3,46 +3,52 @@ import {
examJson,
userExam1,
userExam2,
userExam3,
userExam4,
mockResults1,
mockResults2
mockResults2,
mockResults3,
mockResults4
} from '../../__mocks__/exam';
import { generateRandomExam, createExamResults } from './exam';
import { GeneratedExam, GeneratedQuestion } from './exam-types';
import { GeneratedExam } from './exam-types';
describe('Exam helpers', () => {
describe('generateRandomExam()', () => {
const randomizedExam: GeneratedExam = generateRandomExam(examJson as Exam);
it('should have one question', () => {
expect(randomizedExam.length).toBe(1);
it('should have three questions', () => {
expect(randomizedExam.length).toBe(3);
});
it('should have five answers', () => {
const firstQuestion = randomizedExam[0] as GeneratedQuestion;
expect(firstQuestion.answers.length).toBe(5);
it('should have five answers per question', () => {
randomizedExam.forEach(question => {
expect(question.answers.length).toBe(5);
});
});
it('should have exactly one correct answer', () => {
const question = randomizedExam[0] as GeneratedQuestion;
const questionId = question.id;
const originalQuestion = examJson.questions.find(
q => q.id === questionId
) as Question;
const originalCorrectAnswer = originalQuestion.correctAnswers;
const correctIds = originalCorrectAnswer.map(a => a.id);
it('should have exactly one correct answer per question', () => {
randomizedExam.forEach(question => {
const originalQuestion = examJson.questions.find(
q => q.id === question.id
) as Question;
const originalCorrectAnswer = originalQuestion.correctAnswers;
const correctIds = originalCorrectAnswer.map(a => a.id);
const numberOfCorrectAnswers = question.answers.filter(a =>
correctIds.includes(a.id)
);
const numberOfCorrectAnswers = question.answers.filter(a =>
correctIds.includes(a.id)
);
expect(numberOfCorrectAnswers).toHaveLength(1);
expect(numberOfCorrectAnswers).toHaveLength(1);
});
});
});
describe('createExamResults()', () => {
examJson.numberOfQuestionsInExam = 2;
const examResults1 = createExamResults(userExam1, examJson as Exam);
const examResults2 = createExamResults(userExam2, examJson as Exam);
const examResults3 = createExamResults(userExam3, examJson as Exam);
const examResults4 = createExamResults(userExam4, examJson as Exam);
it('failing exam should return correct results', () => {
expect(examResults1).toEqual(mockResults1);
@@ -50,6 +56,8 @@ describe('Exam helpers', () => {
it('passing exam should return correct results', () => {
expect(examResults2).toEqual(mockResults2);
expect(examResults3).toEqual(mockResults3);
expect(examResults4).toEqual(mockResults4);
});
});
});

View File

@@ -95,6 +95,7 @@ describe('normalize', () => {
solution: null,
githubLink: null,
isManuallyApproved: null,
examResults: null,
files: [
{
contents: 'test',

View File

@@ -1,6 +1,6 @@
/* This module's job is to parse the database output and prepare it for
serialization */
import { ProfileUI, CompletedChallenge } from '@prisma/client';
import { ProfileUI, CompletedChallenge, ExamResults } from '@prisma/client';
import _ from 'lodash';
type NullToUndefined<T> = T extends null ? undefined : T;
@@ -81,6 +81,7 @@ type NormalizedChallenge = {
id: string;
isManuallyApproved?: boolean;
solution?: string;
examResults?: ExamResults;
};
/**