From 9abf0c970c725715ae3c34ad4e27a21468c05920 Mon Sep 17 00:00:00 2001 From: Shaun Hamilton Date: Mon, 6 Oct 2025 11:37:40 +0200 Subject: [PATCH] feat(api): reuse exam generations (#62459) --- .../routes/exam-environment.test.ts | 104 +++++++++++++----- .../routes/exam-environment.ts | 29 +++-- 2 files changed, 99 insertions(+), 34 deletions(-) diff --git a/api/src/exam-environment/routes/exam-environment.test.ts b/api/src/exam-environment/routes/exam-environment.test.ts index 190701edb69..809342f7827 100644 --- a/api/src/exam-environment/routes/exam-environment.test.ts +++ b/api/src/exam-environment/routes/exam-environment.test.ts @@ -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 = { 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 () => { diff --git a/api/src/exam-environment/routes/exam-environment.ts b/api/src/exam-environment/routes/exam-environment.ts index 35ab0bb39e0..327c78f8d1e 100644 --- a/api/src/exam-environment/routes/exam-environment.ts +++ b/api/src/exam-environment/routes/exam-environment.ts @@ -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.');