diff --git a/api/src/exam-environment/routes/exam-environment.test.ts b/api/src/exam-environment/routes/exam-environment.test.ts index 1ce93ba12b6..637fc72b387 100644 --- a/api/src/exam-environment/routes/exam-environment.test.ts +++ b/api/src/exam-environment/routes/exam-environment.test.ts @@ -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); }); }); diff --git a/api/src/exam-environment/routes/exam-environment.ts b/api/src/exam-environment/routes/exam-environment.ts index f5f0babaec8..cf4be5f401c 100644 --- a/api/src/exam-environment/routes/exam-environment.ts +++ b/api/src/exam-environment/routes/exam-environment.ts @@ -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); } /** diff --git a/api/src/exam-environment/schemas/exam-environment-exams.ts b/api/src/exam-environment/schemas/exam-environment-exams.ts index ed386b9a347..7189f49cf1e 100644 --- a/api/src/exam-environment/schemas/exam-environment-exams.ts +++ b/api/src/exam-environment/schemas/exam-environment-exams.ts @@ -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 } };