From 037cac399165b363cee25003fad7f9172225e7e1 Mon Sep 17 00:00:00 2001 From: Shaun Hamilton Date: Thu, 30 Oct 2025 13:38:51 +0200 Subject: [PATCH] fix: account for expired attempt without mod record (#63317) --- .../routes/exam-environment.test.ts | 6 +-- .../schemas/exam-environment-exam-attempt.ts | 15 +++++- .../utils/exam-environment.ts | 50 ++++++++++--------- .../Challenges/exam-download/attempts.tsx | 4 ++ client/src/utils/ajax.ts | 2 +- 5 files changed, 48 insertions(+), 29 deletions(-) diff --git a/api/src/exam-environment/routes/exam-environment.test.ts b/api/src/exam-environment/routes/exam-environment.test.ts index 4a1baac7c3e..61b0fd0bc69 100644 --- a/api/src/exam-environment/routes/exam-environment.test.ts +++ b/api/src/exam-environment/routes/exam-environment.test.ts @@ -24,11 +24,9 @@ import { examEnvironmentPostExamGeneratedExam } from '../schemas/index.js'; import * as mock from '../../../__mocks__/exam-environment-exam.js'; -import { - constructUserExam, - ExamAttemptStatus -} from '../utils/exam-environment.js'; +import { constructUserExam } from '../utils/exam-environment.js'; import { JWT_SECRET } from '../../utils/env.js'; +import { ExamAttemptStatus } from '../schemas/exam-environment-exam-attempt.js'; vi.mock('../../utils/env', async importOriginal => { const actual = await importOriginal(); diff --git a/api/src/exam-environment/schemas/exam-environment-exam-attempt.ts b/api/src/exam-environment/schemas/exam-environment-exam-attempt.ts index 47662286168..0c4c0eba105 100644 --- a/api/src/exam-environment/schemas/exam-environment-exam-attempt.ts +++ b/api/src/exam-environment/schemas/exam-environment-exam-attempt.ts @@ -26,6 +26,19 @@ export const examEnvironmentPostExamAttempt = { } }; +export enum ExamAttemptStatus { + // Attempt has not expired yet. + InProgress = 'InProgress', + // Moderation record is not created for practice exam. Also, it might not exist until exam service cron is run. + Expired = 'Expired', + // Attempt has expired && moderation record has been created but not yet moderated + PendingModeration = 'PendingModeration', + // Attempt has been approved + Approved = 'Approved', + // Attempt has been denied + Denied = 'Denied' +} + const examEnvAttempt = Type.Object({ id: Type.String(), examId: Type.String(), @@ -50,7 +63,7 @@ const examEnvAttempt = Type.Object({ }) ]), version: Type.Number(), - status: Type.Enum(['InProgress', 'PendingModeration', 'Approved', 'Denied']) + status: Type.Enum(ExamAttemptStatus) }); export const examEnvironmentGetExamAttempts = { diff --git a/api/src/exam-environment/utils/exam-environment.ts b/api/src/exam-environment/utils/exam-environment.ts index 93f1ee52ea2..c7c4acfadcd 100644 --- a/api/src/exam-environment/utils/exam-environment.ts +++ b/api/src/exam-environment/utils/exam-environment.ts @@ -19,6 +19,7 @@ import { type Static } from '@fastify/type-provider-typebox'; import { omit } from 'lodash-es'; import * as schemas from '../schemas/index.js'; import { mapErr } from '../../utils/index.js'; +import { ExamAttemptStatus } from '../schemas/exam-environment-exam-attempt.js'; import { ERRORS } from './errors.js'; interface CompletedChallengeId { @@ -64,21 +65,35 @@ export function constructUserExam( // Map generated exam to user exam (a.k.a. public exam information for user) const userQuestionSets = generatedExam.questionSets.map(gqs => { // Get matching question from `exam`, but remove `is_correct` from `exam.questions[].answers[]` - const examQuestionSet = exam.questionSets.find(eqs => eqs.id === gqs.id)!; + const examQuestionSet = exam.questionSets.find(eqs => eqs.id === gqs.id); + if (!examQuestionSet) { + throw new Error( + `Unreachable. Generated question set id ${gqs.id} not found in exam ${exam.id}.` + ); + } const { questions } = examQuestionSet; const userQuestions = gqs.questions.map(gq => { - const examQuestion = questions.find(eq => eq.id === gq.id)!; + const examQuestion = questions.find(eq => eq.id === gq.id); + if (!examQuestion) { + throw new Error( + `Unreachable. Generated question id ${gq.id} not found in exam question set ${examQuestionSet.id}.` + ); + } // Remove `isCorrect` from question answers const answers = gq.answers.map(generatedAnswerId => { const examAnswer = examQuestion.answers.find( ea => ea.id === generatedAnswerId - )!; + ); + if (!examAnswer) { + throw new Error( + `Unreachable. Generated answer id ${generatedAnswerId} not found in exam question ${examQuestion.id}.` + ); + } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { isCorrect, ...answer } = examAnswer; + const { isCorrect: _, ...answer } = examAnswer; return answer; }); @@ -768,13 +783,6 @@ export function shuffleArray(array: Array) { } /* eslint-enable jsdoc/require-description-complete-sentence */ -export enum ExamAttemptStatus { - InProgress = 'InProgress', - PendingModeration = 'PendingModeration', - Approved = 'Approved', - Denied = 'Denied' -} - /** * From an exam attempt, construct the attempt with result (if ready). * @@ -862,19 +870,15 @@ export async function constructEnvExamAttempt( const moderation = maybeMod.data; + // Attempt has expired, but moderation record does not exist if (moderation === null) { - const error = { - data: { examAttemptId: attempt.id }, - message: - 'Unreachable. ExamModeration record should exist for expired attempt' - }; - logger.error(error.data, error.message); - fastify.Sentry.captureException(error); return { - error: { - code: 500, - data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(error.message) - } + examEnvironmentExamAttempt: { + ...omitAttemptReferenceIds(attempt), + result: null, + status: ExamAttemptStatus.Expired + }, + error: null }; } diff --git a/client/src/templates/Challenges/exam-download/attempts.tsx b/client/src/templates/Challenges/exam-download/attempts.tsx index ff500984d03..fe6bb3276c7 100644 --- a/client/src/templates/Challenges/exam-download/attempts.tsx +++ b/client/src/templates/Challenges/exam-download/attempts.tsx @@ -59,6 +59,8 @@ export function Attempts({ examChallengeId }: AttemptsProps) { return t('exam.in-progress'); case 'PendingModeration': return t('exam.pending'); + case 'Expired': + return t('exam.pending'); } } @@ -72,6 +74,8 @@ export function Attempts({ examChallengeId }: AttemptsProps) { return t('exam.in-progress'); case 'PendingModeration': return t('exam.pending'); + case 'Expired': + return t('exam.pending'); } } diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index a31d858f47f..32a10127381 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -229,7 +229,7 @@ export type Attempt = { } & ( | { result: null; - status: 'InProgress' | 'PendingModeration' | 'Denied'; + status: 'InProgress' | 'Expired' | 'PendingModeration' | 'Denied'; } | { status: 'Approved';