feat: add attempt statuses (#63035)

This commit is contained in:
Shaun Hamilton
2025-10-29 16:20:02 +02:00
committed by GitHub
parent 03c775ac2d
commit 26ca8fee4b
6 changed files with 114 additions and 41 deletions

View File

@@ -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)]);

View File

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

View File

@@ -768,6 +768,13 @@ 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).
*
@@ -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 };
}

View File

@@ -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}}.",

View File

@@ -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 <p>{t('exam.no-attempts-yet')}</p>;
}
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 (
<Table striped>
<thead>
@@ -62,18 +88,8 @@ export function Attempts({ examChallengeId }: AttemptsProps) {
{attempts.map(attempt => (
<tr key={attempt.startTime}>
<td>{new Date(attempt.startTime).toTimeString()}</td>
<td>
{attempt.result
? `${attempt.result.score.toFixed(2)}%`
: t('exam.pending')}
</td>
<td>
{attempt.result
? attempt.result.passed
? t('exam.passed')
: t('exam.failed')
: t('exam.pending')}
</td>
<td>{renderScore(attempt)}</td>
<td>{renderStatus(attempt)}</td>
</tr>
))}
</tbody>

View File

@@ -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<ResponseWithData<{ exams: Exam[] }>> {
return get('/user/exam-environment/exams');