mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-01-06 06:01:31 -05:00
feat(api): add POST /exam-challenge-completed (#52395)
This commit is contained in:
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -25,7 +25,8 @@ const completedChallenges: CompletedChallenge[] = [
|
||||
files: [],
|
||||
githubLink: null,
|
||||
solution: null,
|
||||
isManuallyApproved: false
|
||||
isManuallyApproved: false,
|
||||
examResults: null
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}$');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +95,7 @@ describe('normalize', () => {
|
||||
solution: null,
|
||||
githubLink: null,
|
||||
isManuallyApproved: null,
|
||||
examResults: null,
|
||||
files: [
|
||||
{
|
||||
contents: 'test',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user