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:
Shaun Hamilton
2025-07-28 12:05:28 +02:00
committed by GitHub
parent f25f38133a
commit 10c8733641
3 changed files with 284 additions and 41 deletions

View File

@@ -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);
});
});

View File

@@ -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);
}
/**

View File

@@ -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
}
};