mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-20 12:03:11 -04:00
fix(api): check attempts and moderations records for available exams (#61302)
Co-authored-by: Tom <20648924+moT01@users.noreply.github.com> Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com> Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com>
This commit is contained in:
@@ -587,27 +587,48 @@ describe('/exam-environment/', () => {
|
||||
});
|
||||
|
||||
describe('GET /exam-environment/exams', () => {
|
||||
beforeEach(async () => {
|
||||
// Reset user prerequisites
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
completedChallenges: [
|
||||
{ id: mock.exam.prerequisites.at(0)!, completedDate: Date.now() }
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up exam attempts and moderation records
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.deleteMany();
|
||||
|
||||
// Reset exam deprecated status
|
||||
await fastifyTestInstance.prisma.examEnvironmentExam.update({
|
||||
where: { id: mock.examId },
|
||||
data: { deprecated: false }
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 200', async () => {
|
||||
const res = await superGet('/exam-environment/exams').set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
exams: [
|
||||
{
|
||||
canTake: true,
|
||||
config: {
|
||||
name: mock.exam.config.name,
|
||||
note: mock.exam.config.note,
|
||||
passingPercent: mock.exam.config.passingPercent,
|
||||
totalTimeInMS: mock.exam.config.totalTimeInMS,
|
||||
retakeTimeInMS: mock.exam.config.retakeTimeInMS
|
||||
},
|
||||
id: mock.examId
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(res.body).toStrictEqual([
|
||||
{
|
||||
canTake: true,
|
||||
config: {
|
||||
name: mock.exam.config.name,
|
||||
note: mock.exam.config.note,
|
||||
passingPercent: mock.exam.config.passingPercent,
|
||||
totalTimeInMS: mock.exam.config.totalTimeInMS,
|
||||
retakeTimeInMS: mock.exam.config.retakeTimeInMS
|
||||
},
|
||||
id: mock.examId
|
||||
}
|
||||
]);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
@@ -623,10 +644,141 @@ describe('/exam-environment/', () => {
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
exams: []
|
||||
expect(res.body).toStrictEqual([]);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should indicate an exam's availability based on prerequisites", async () => {
|
||||
// Remove prerequisites from user
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
completedChallenges: []
|
||||
}
|
||||
});
|
||||
|
||||
const res = await superGet('/exam-environment/exams').set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.body).toMatchObject([{ canTake: false }]);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Add prerequisites back to user
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
completedChallenges: [
|
||||
{ id: mock.exam.prerequisites.at(0)!, completedDate: Date.now() }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const res2 = await superGet('/exam-environment/exams').set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res2.body).toMatchObject([{ canTake: true }]);
|
||||
expect(res2.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should indicate an exam may be taken if the user has no prior attempts', async () => {
|
||||
const res = await superGet('/exam-environment/exams').set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.body).toStrictEqual([
|
||||
{
|
||||
canTake: true,
|
||||
config: {
|
||||
name: mock.exam.config.name,
|
||||
note: mock.exam.config.note,
|
||||
passingPercent: mock.exam.config.passingPercent,
|
||||
totalTimeInMS: mock.exam.config.totalTimeInMS,
|
||||
retakeTimeInMS: mock.exam.config.retakeTimeInMS
|
||||
},
|
||||
id: mock.examId
|
||||
}
|
||||
]);
|
||||
expect(res.body).toMatchObject([{ canTake: true }]);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should indicate an exam's availability based on the last attempt's start time, and the exam retake time", async () => {
|
||||
// Create a recent exam attempt that's within the retake time
|
||||
const recentExamAttempt = {
|
||||
...mock.examAttempt,
|
||||
userId: defaultUserId,
|
||||
startTimeInMS: Date.now() - mock.exam.config.totalTimeInMS
|
||||
};
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: recentExamAttempt
|
||||
});
|
||||
|
||||
const res = await superGet('/exam-environment/exams').set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.body).toMatchObject([{ canTake: false }]);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Update the attempt to be outside the retake time
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.update({
|
||||
where: { id: recentExamAttempt.id },
|
||||
data: {
|
||||
startTimeInMS:
|
||||
Date.now() -
|
||||
(mock.exam.config.totalTimeInMS +
|
||||
mock.exam.config.retakeTimeInMS +
|
||||
1000)
|
||||
}
|
||||
});
|
||||
|
||||
const res2 = await superGet('/exam-environment/exams').set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res2.body).toMatchObject([{ canTake: true }]);
|
||||
|
||||
expect(res2.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should indicate an exam is unavailable if there are any pending moderation records for the exam attempts', async () => {
|
||||
// Create an exam attempt that's outside the retake time
|
||||
const examAttempt = {
|
||||
...mock.examAttempt,
|
||||
userId: defaultUserId,
|
||||
startTimeInMS:
|
||||
Date.now() -
|
||||
(mock.exam.config.totalTimeInMS +
|
||||
mock.exam.config.retakeTimeInMS +
|
||||
1000)
|
||||
};
|
||||
const createdAttempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: examAttempt
|
||||
});
|
||||
|
||||
// Create a pending moderation record for the attempt
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamModeration.create({
|
||||
data: {
|
||||
examAttemptId: createdAttempt.id,
|
||||
status: ExamEnvironmentExamModerationStatus.Pending
|
||||
}
|
||||
});
|
||||
|
||||
const res = await superGet('/exam-environment/exams').set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.body).toMatchObject([{ canTake: false }]);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -666,7 +666,7 @@ async function getExams(
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const logger = this.log.child({ req });
|
||||
logger.info({ user: req.user });
|
||||
logger.info({ userId: req.user?.id });
|
||||
|
||||
const user = req.user!;
|
||||
const maybeExams = await mapErr(
|
||||
@@ -693,10 +693,34 @@ async function getExams(
|
||||
|
||||
const exams = maybeExams.data;
|
||||
|
||||
const availableExams = exams.map(exam => {
|
||||
const isExamPrerequisitesMet = checkPrerequisites(user, exam.prerequisites);
|
||||
const maybeAttempts = await mapErr(
|
||||
this.prisma.examEnvironmentExamAttempt.findMany({
|
||||
where: {
|
||||
userId: user.id
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
examId: true,
|
||||
startTimeInMS: true
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
if (maybeAttempts.hasError) {
|
||||
logger.error(maybeAttempts.error);
|
||||
this.Sentry.captureException(maybeAttempts.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempts.error))
|
||||
);
|
||||
}
|
||||
|
||||
const attempts = maybeAttempts.data;
|
||||
|
||||
const availableExams = [];
|
||||
|
||||
for (const exam of exams) {
|
||||
const availableExam = {
|
||||
id: exam.id,
|
||||
config: {
|
||||
name: exam.config.name,
|
||||
@@ -705,13 +729,82 @@ async function getExams(
|
||||
retakeTimeInMS: exam.config.retakeTimeInMS,
|
||||
passingPercent: exam.config.passingPercent
|
||||
},
|
||||
canTake: isExamPrerequisitesMet
|
||||
canTake: false
|
||||
};
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
exams: availableExams
|
||||
});
|
||||
const isExamPrerequisitesMet = checkPrerequisites(user, exam.prerequisites);
|
||||
logger.info(
|
||||
`Prerequisites for exam ${exam.id} ${isExamPrerequisitesMet ? 'met' : 'unmet'}.`
|
||||
);
|
||||
|
||||
if (!isExamPrerequisitesMet) {
|
||||
availableExam.canTake = false;
|
||||
availableExams.push(availableExam);
|
||||
continue;
|
||||
}
|
||||
// Latest attempt must be:
|
||||
// a) Moderated
|
||||
// b) Past exam config retake time
|
||||
const attemptsForExam = attempts.filter(a => a.examId === exam.id);
|
||||
|
||||
const lastAttempt = attemptsForExam.length
|
||||
? attemptsForExam.reduce((latest, current) =>
|
||||
latest.startTimeInMS > current.startTimeInMS ? latest : current
|
||||
)
|
||||
: null;
|
||||
|
||||
if (!lastAttempt) {
|
||||
logger.info(`No prior attempts for exam ${exam.id}`);
|
||||
availableExam.canTake = true;
|
||||
availableExams.push(availableExam);
|
||||
continue;
|
||||
}
|
||||
|
||||
const retakeDateInMS =
|
||||
lastAttempt.startTimeInMS +
|
||||
exam.config.totalTimeInMS +
|
||||
exam.config.retakeTimeInMS;
|
||||
const isRetakeTimePassed = Date.now() > retakeDateInMS;
|
||||
|
||||
if (!isRetakeTimePassed) {
|
||||
logger.info(`Time until retake: ${retakeDateInMS - Date.now()} [ms]`);
|
||||
availableExam.canTake = false;
|
||||
availableExams.push(availableExam);
|
||||
continue;
|
||||
}
|
||||
|
||||
const maybeModerations = await mapErr(
|
||||
this.prisma.examEnvironmentExamModeration.findMany({
|
||||
where: {
|
||||
examAttemptId: { in: attemptsForExam.map(a => a.id) },
|
||||
status: ExamEnvironmentExamModerationStatus.Pending
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeModerations.hasError) {
|
||||
logger.error(maybeModerations.error);
|
||||
this.Sentry.captureException(maybeModerations.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeModerations.error))
|
||||
);
|
||||
}
|
||||
|
||||
const moderations = maybeModerations.data;
|
||||
|
||||
if (moderations.length > 0) {
|
||||
logger.info(`Exam Moderation records found: ${moderations.length}`);
|
||||
availableExam.canTake = false;
|
||||
availableExams.push(availableExam);
|
||||
continue;
|
||||
}
|
||||
|
||||
availableExam.canTake = true;
|
||||
availableExams.push(availableExam);
|
||||
}
|
||||
|
||||
return reply.send(availableExams);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,21 +5,19 @@ export const examEnvironmentExams = {
|
||||
'exam-environment-authorization-token': Type.String()
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({
|
||||
exams: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
config: Type.Object({
|
||||
name: Type.String(),
|
||||
note: Type.String(),
|
||||
totalTimeInMS: Type.Number(),
|
||||
retakeTimeInMS: Type.Number(),
|
||||
passingPercent: Type.Number()
|
||||
}),
|
||||
canTake: Type.Boolean()
|
||||
})
|
||||
)
|
||||
}),
|
||||
200: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
config: Type.Object({
|
||||
name: Type.String(),
|
||||
note: Type.String(),
|
||||
totalTimeInMS: Type.Number(),
|
||||
retakeTimeInMS: Type.Number(),
|
||||
passingPercent: Type.Number()
|
||||
}),
|
||||
canTake: Type.Boolean()
|
||||
})
|
||||
),
|
||||
500: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user