fix: account for expired attempt without mod record (#63317)

This commit is contained in:
Shaun Hamilton
2025-10-30 13:38:51 +02:00
committed by GitHub
parent 5668f3f770
commit 037cac3991
5 changed files with 48 additions and 29 deletions

View File

@@ -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<typeof import('../../utils/env.js')>();

View File

@@ -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 = {

View File

@@ -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<T>(array: Array<T>) {
}
/* 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
};
}

View File

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

View File

@@ -229,7 +229,7 @@ export type Attempt = {
} & (
| {
result: null;
status: 'InProgress' | 'PendingModeration' | 'Denied';
status: 'InProgress' | 'Expired' | 'PendingModeration' | 'Denied';
}
| {
status: 'Approved';