mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-04-30 16:01:14 -04:00
feat: add attempt statuses (#63035)
This commit is contained in:
@@ -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)]);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}}.",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user