feat(api): reuse exam generations (#62459)

This commit is contained in:
Shaun Hamilton
2025-10-06 11:37:40 +02:00
committed by GitHub
parent 8a6ae7a8ba
commit 9abf0c970c
2 changed files with 99 additions and 34 deletions
@@ -270,8 +270,7 @@ describe('/exam-environment/', () => {
});
describe('POST /exam-environment/generated-exam', () => {
afterEach(async () => {
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.deleteMany();
beforeEach(async () => {
// Add prerequisite id to user completed challenge
await fastifyTestInstance.prisma.user.update({
where: { id: defaultUserId },
@@ -282,6 +281,9 @@ describe('/exam-environment/', () => {
}
});
await mock.seedEnvExam();
});
afterEach(async () => {
await mock.clearEnvExam();
const a =
await fastifyTestInstance.prisma.examEnvironmentExamModeration.findMany(
{}
@@ -471,27 +473,15 @@ describe('/exam-environment/', () => {
});
});
it('should return an error if the database has insufficient generated exams', async () => {
// Add completed attempt for generated exam
const submittedAttempt = structuredClone(mock.examAttempt);
const examTotalTimeInMS = mock.exam.config.totalTimeInS * 1000;
// Long-enough ago to be considered "submitted", and not trigger cooldown
submittedAttempt.startTimeInMS =
Date.now() -
24 * 60 * 60 * 1000 -
examTotalTimeInMS -
1 * 60 * 60 * 1000;
submittedAttempt.startTime = new Date(
Date.now() -
24 * 60 * 60 * 1000 -
examTotalTimeInMS -
1 * 60 * 60 * 1000
);
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
data: submittedAttempt
it('should prioritise not-yet-taken generated exams, and reuse completed ones if necessary', async () => {
// Create a second generated exams for the user
const genExam1 = structuredClone(mock.generatedExam);
genExam1.id = mock.oid();
await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.create({
data: genExam1
});
// Request generated exam, thereby creating an attempt with one of the generated exam ids
const body: Static<typeof examEnvironmentPostExamGeneratedExam.body> = {
examId: mock.examId
};
@@ -502,12 +492,76 @@ describe('/exam-environment/', () => {
examEnvironmentAuthorizationToken
);
expect(res).toMatchObject({
status: 500,
body: {
code: 'FCC_ERR_EXAM_ENVIRONMENT'
expect(res.status).toBe(200);
// Finish attempt
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.update({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
where: { id: res.body.examAttempt.id },
data: {
startTime: new Date(
Date.now() -
mock.exam.config.totalTimeInMS -
mock.exam.config.retakeTimeInMS
)
}
});
// Request generated exam again, which should use the other generated exam
const res2 = await superPost('/exam-environment/exam/generated-exam')
.send(body)
.set(
'exam-environment-authorization-token',
examEnvironmentAuthorizationToken
);
expect(res2.status).toBe(200);
// Expect examEnvironmentExamAttempt to include 2 records
const eas =
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.findMany({
where: {
userId: defaultUserId
}
});
expect(eas).toHaveLength(2);
// Expect eas[].generatedExamId to not be the same
const geIds = eas.map(ea => ea.generatedExamId);
expect(geIds[0]).not.toBe(geIds[1]);
// Finish attempt
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.update({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
where: { id: res2.body.examAttempt.id },
data: {
startTime: new Date(
Date.now() -
mock.exam.config.totalTimeInMS -
mock.exam.config.retakeTimeInMS
)
}
});
// Request generated exam again, which should reuse one of the generated exams
const res3 = await superPost('/exam-environment/exam/generated-exam')
.send(body)
.set(
'exam-environment-authorization-token',
examEnvironmentAuthorizationToken
);
expect(res3.status).toBe(200);
// Expect examEnvironmentExamAttempt to include 3 records, with only 2 unique `generatedExamId`s
const eas2 =
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.findMany({
where: { userId: defaultUserId }
});
expect(eas2).toHaveLength(3);
const geIds2 = eas2.map(ea => ea.generatedExamId);
expect(new Set(geIds2).size).toBe(2);
});
it('should record the fact the user has started an exam by creating an exam attempt', async () => {
@@ -376,11 +376,7 @@ async function postExamGeneratedExamHandler(
const maybeGeneratedExams = await mapErr(
this.prisma.examEnvironmentGeneratedExam.findMany({
where: {
// Find generated exams user has not already seen
examId: exam.id,
id: {
notIn: examAttempts.map(a => a.generatedExamId)
},
deprecated: false
},
select: {
@@ -403,7 +399,7 @@ async function postExamGeneratedExamHandler(
if (generatedExams.length === 0) {
const error = {
data: { examId: exam.id },
message: `Unable to provide a generated exam. Either all generated exams have been exhausted, or all generated exams are deprecated.`
message: `Unable to provide a generated exam. Either no generations exist, or all generated exams are deprecated.`
};
logger.error(error.data, error.message);
this.Sentry.captureException(error);
@@ -411,13 +407,28 @@ async function postExamGeneratedExamHandler(
return reply.send(ERRORS.FCC_ERR_EXAM_ENVIRONMENT(error.message));
}
const randomGeneratedExam =
generatedExams[Math.floor(Math.random() * generatedExams.length)]!;
// Randomly pick an exam from available generations, prioritising generations not already taken
const untakenGeneratedExams = generatedExams.filter(
ge => !examAttempts.find(ea => ea.generatedExamId === ge.id)
);
let randomGeneratedExamId: string;
if (untakenGeneratedExams.length === 0) {
logger.info(
`User has taken all generated exams. Reusing previously taken generated exams.`
);
randomGeneratedExamId =
generatedExams[Math.floor(Math.random() * generatedExams.length)]!.id;
} else {
randomGeneratedExamId =
untakenGeneratedExams[
Math.floor(Math.random() * untakenGeneratedExams.length)
]!.id;
}
const maybeGeneratedExam = await mapErr(
this.prisma.examEnvironmentGeneratedExam.findFirst({
where: {
id: randomGeneratedExam.id
id: randomGeneratedExamId
}
})
);
@@ -439,7 +450,7 @@ async function postExamGeneratedExamHandler(
if (generatedExam === null) {
const error = {
data: { generatedExamId: randomGeneratedExam.id },
data: { generatedExamId: randomGeneratedExamId },
message: 'Unreachable. Generated exam not found.'
};
logger.error(error.data, 'Unreachable. Generated exam not found.');