diff --git a/api/src/exam-environment/routes/exam-environment.test.ts b/api/src/exam-environment/routes/exam-environment.test.ts index aace3af5076..4a1baac7c3e 100644 --- a/api/src/exam-environment/routes/exam-environment.test.ts +++ b/api/src/exam-environment/routes/exam-environment.test.ts @@ -24,7 +24,10 @@ import { examEnvironmentPostExamGeneratedExam } from '../schemas/index.js'; import * as mock from '../../../__mocks__/exam-environment-exam.js'; -import { constructUserExam } from '../utils/exam-environment.js'; +import { + constructUserExam, + ExamAttemptStatus +} from '../utils/exam-environment.js'; import { JWT_SECRET } from '../../utils/env.js'; vi.mock('../../utils/env', async importOriginal => { @@ -921,9 +924,12 @@ describe('/exam-environment/', () => { }); it('should return 200 with the examEnvironmentExamAttempt if the attempt exists and belongs to the user', async () => { + const startTime = new Date( + Date.now() - mock.exam.config.totalTimeInS * 1000 + ); const attempt = await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({ - data: { ...mock.examAttempt, userId: defaultUserId } + data: { ...mock.examAttempt, userId: defaultUserId, startTime } }); await fastifyTestInstance.prisma.examEnvironmentExamModeration.create({ data: { @@ -944,7 +950,10 @@ describe('/exam-environment/', () => { examId: mock.exam.id, result: null, startTime: attempt.startTime, - questionSets: attempt.questionSets + questionSets: attempt.questionSets, + status: ExamAttemptStatus.PendingModeration, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + version: expect.any(Number) }; expect(res.body).toEqual(serializeDates(examEnvironmentExamAttempt)); @@ -961,9 +970,12 @@ describe('/exam-environment/', () => { }); it('should return the attempt without results, if the attempt has not been moderated', async () => { + const startTime = new Date( + Date.now() - mock.exam.config.totalTimeInS * 1000 + ); const attempt = await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({ - data: { ...mock.examAttempt, userId: defaultUserId } + data: { ...mock.examAttempt, userId: defaultUserId, startTime } }); await fastifyTestInstance.prisma.examEnvironmentExamModeration.create({ data: { @@ -984,7 +996,10 @@ describe('/exam-environment/', () => { examId: mock.exam.id, result: null, startTime: attempt.startTime, - questionSets: attempt.questionSets + questionSets: attempt.questionSets, + status: ExamAttemptStatus.PendingModeration, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + version: expect.any(Number) }; expect(res.body).toEqual(serializeDates(examEnvironmentExamAttempt)); @@ -1023,7 +1038,10 @@ describe('/exam-environment/', () => { passingPercent: 80 }, startTime: attempt.startTime, - questionSets: attempt.questionSets + questionSets: attempt.questionSets, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + version: expect.any(Number), + status: ExamAttemptStatus.Approved }; expect(res.body).toEqual(serializeDates(examEnvironmentExamAttempt)); @@ -1057,9 +1075,12 @@ describe('/exam-environment/', () => { }); it('should return 200 with the attempts if they exist and belong to the user', async () => { + const startTime = new Date( + Date.now() - mock.exam.config.totalTimeInS * 1000 + ); const attempt = await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({ - data: { ...mock.examAttempt, userId: defaultUserId } + data: { ...mock.examAttempt, userId: defaultUserId, startTime } }); await fastifyTestInstance.prisma.examEnvironmentExamModeration.create({ data: { @@ -1078,7 +1099,10 @@ describe('/exam-environment/', () => { examId: mock.exam.id, result: null, startTime: attempt.startTime, - questionSets: attempt.questionSets + questionSets: attempt.questionSets, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + version: expect.any(Number), + status: ExamAttemptStatus.PendingModeration }; expect(res.body).toEqual([serializeDates(examEnvironmentExamAttempt)]); @@ -1086,9 +1110,12 @@ describe('/exam-environment/', () => { }); it('should return the attempts without results, if they have not been moderated', async () => { + const startTime = new Date( + Date.now() - mock.exam.config.totalTimeInS * 1000 + ); const attempt = await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({ - data: { ...mock.examAttempt, userId: defaultUserId } + data: { ...mock.examAttempt, userId: defaultUserId, startTime } }); await fastifyTestInstance.prisma.examEnvironmentExamModeration.create({ @@ -1108,7 +1135,10 @@ describe('/exam-environment/', () => { examId: mock.exam.id, result: null, startTime: attempt.startTime, - questionSets: attempt.questionSets + questionSets: attempt.questionSets, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + version: expect.any(Number), + status: ExamAttemptStatus.PendingModeration }; expect(res.body).toEqual([serializeDates(examEnvironmentExamAttempt)]); @@ -1145,7 +1175,10 @@ describe('/exam-environment/', () => { passingPercent: 80 }, startTime: attempt.startTime, - questionSets: attempt.questionSets + questionSets: attempt.questionSets, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + version: expect.any(Number), + status: ExamAttemptStatus.Approved }; expect(res.body).toEqual([serializeDates(examEnvironmentExamAttempt)]); @@ -1197,7 +1230,8 @@ describe('/exam-environment/', () => { startTime: attempt.startTime, questionSets: attempt.questionSets, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - version: expect.any(Number) + version: expect.any(Number), + status: ExamAttemptStatus.InProgress }; expect(res.body).toEqual([serializeDates(examEnvironmentExamAttempt)]); 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 f891254dfdc..47662286168 100644 --- a/api/src/exam-environment/schemas/exam-environment-exam-attempt.ts +++ b/api/src/exam-environment/schemas/exam-environment-exam-attempt.ts @@ -48,7 +48,9 @@ const examEnvAttempt = Type.Object({ score: Type.Number(), passingPercent: Type.Number() }) - ]) + ]), + version: Type.Number(), + status: Type.Enum(['InProgress', 'PendingModeration', 'Approved', 'Denied']) }); export const examEnvironmentGetExamAttempts = { @@ -86,9 +88,9 @@ export const examEnvironmentGetExamAttemptsByExamId = { // Optional, because the handler is used in both the `/user/` base and `/exam-environment/` base. // If it is missing, auth will catch. 'exam-environment-authorization-token': Type.Optional(Type.String()) - }) - // response: { - // 200: Type.Array(examEnvAttempt), - // default: STANDARD_ERROR - // } + }), + response: { + 200: Type.Array(examEnvAttempt) + // default: STANDARD_ERROR + } }; diff --git a/api/src/exam-environment/utils/exam-environment.ts b/api/src/exam-environment/utils/exam-environment.ts index 66af49e0bf5..93f1ee52ea2 100644 --- a/api/src/exam-environment/utils/exam-environment.ts +++ b/api/src/exam-environment/utils/exam-environment.ts @@ -768,6 +768,13 @@ 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). * @@ -827,7 +834,8 @@ export async function constructEnvExamAttempt( return { examEnvironmentExamAttempt: { ...omitAttemptReferenceIds(attempt), - result: null + result: null, + status: ExamAttemptStatus.InProgress }, error: null }; @@ -875,7 +883,8 @@ export async function constructEnvExamAttempt( return { examEnvironmentExamAttempt: { ...omitAttemptReferenceIds(attempt), - result: null + result: null, + status: ExamAttemptStatus.PendingModeration }, error: null }; @@ -887,7 +896,8 @@ export async function constructEnvExamAttempt( return { examEnvironmentExamAttempt: { ...omitAttemptReferenceIds(attempt), - result: null + result: null, + status: ExamAttemptStatus.Denied }, error: null }; @@ -941,7 +951,8 @@ export async function constructEnvExamAttempt( const examEnvironmentExamAttempt = { ...omitAttemptReferenceIds(attempt), - result + result, + status: ExamAttemptStatus.Approved }; return { error: null, examEnvironmentExamAttempt }; } diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index df1f2b64307..35dabc90c3e 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -388,6 +388,8 @@ "pending": "Pending", "passed": "Passed", "failed": "Failed", + "in-progress": "In Progress", + "denied": "Retake Required", "download-header": "Download the freeCodeCamp Exam Environment App", "explanation": "To earn a certification, you must take an exam to test your understanding of the material you have learned. Taking the exam is absolutely free of charge.", "version": "The latest version of our app is: {{version}}.", diff --git a/client/src/templates/Challenges/exam-download/attempts.tsx b/client/src/templates/Challenges/exam-download/attempts.tsx index acdeb2bae22..ff500984d03 100644 --- a/client/src/templates/Challenges/exam-download/attempts.tsx +++ b/client/src/templates/Challenges/exam-download/attempts.tsx @@ -3,7 +3,7 @@ import { Table } from '@freecodecamp/ui'; import { useTranslation } from 'react-i18next'; import { Loader } from '../../../components/helpers'; -import { examAttempts } from '../../../utils/ajax'; +import { Attempt, examAttempts } from '../../../utils/ajax'; interface AttemptsProps { examChallengeId: string; @@ -49,6 +49,32 @@ export function Attempts({ examChallengeId }: AttemptsProps) { return

{t('exam.no-attempts-yet')}

; } + function renderScore(attempt: Attempt) { + switch (attempt.status) { + case 'Approved': + return `${attempt.result.score.toFixed(2)}%`; + case 'Denied': + return t('exam.denied'); + case 'InProgress': + return t('exam.in-progress'); + case 'PendingModeration': + return t('exam.pending'); + } + } + + function renderStatus(attempt: Attempt) { + switch (attempt.status) { + case 'Approved': + return attempt.result.passed ? t('exam.passed') : t('exam.failed'); + case 'Denied': + return t('exam.denied'); + case 'InProgress': + return t('exam.in-progress'); + case 'PendingModeration': + return t('exam.pending'); + } + } + return ( @@ -62,18 +88,8 @@ export function Attempts({ examChallengeId }: AttemptsProps) { {attempts.map(attempt => ( - - + + ))} diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index 97a8b31da36..a31d858f47f 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -220,17 +220,25 @@ export interface Exam { }; } -export interface Attempt { +export type Attempt = { id: string; examId: string; // ISO 8601 string startTime: string; questionSets: unknown[]; - result?: { - passed: boolean; - score: number; - }; -} +} & ( + | { + result: null; + status: 'InProgress' | 'PendingModeration' | 'Denied'; + } + | { + status: 'Approved'; + result: { + passed: boolean; + score: number; + }; + } +); export function getExams(): Promise> { return get('/user/exam-environment/exams');
{new Date(attempt.startTime).toTimeString()} - {attempt.result - ? `${attempt.result.score.toFixed(2)}%` - : t('exam.pending')} - - {attempt.result - ? attempt.result.passed - ? t('exam.passed') - : t('exam.failed') - : t('exam.pending')} - {renderScore(attempt)}{renderStatus(attempt)}