mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(api): add exam env attempts endpoints and fields (#59634)
Co-authored-by: Tom <20648924+moT01@users.noreply.github.com> Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com> Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { Static } from '@fastify/type-provider-typebox';
|
||||
import {
|
||||
EnvConfig,
|
||||
EnvQuestionType,
|
||||
EnvExamAttempt,
|
||||
EnvExam,
|
||||
EnvGeneratedExam,
|
||||
EnvQuestionSet
|
||||
ExamEnvironmentConfig,
|
||||
ExamEnvironmentQuestionType,
|
||||
ExamEnvironmentExamAttempt,
|
||||
ExamEnvironmentExam,
|
||||
ExamEnvironmentGeneratedExam,
|
||||
ExamEnvironmentQuestionSet
|
||||
} from '@prisma/client';
|
||||
import { ObjectId } from 'mongodb';
|
||||
// import { defaultUserId } from '../jest.utils';
|
||||
@@ -18,28 +18,29 @@ const defaultUserId = '64c7810107dd4782d32baee7';
|
||||
|
||||
export const examId = oid();
|
||||
|
||||
export const config: EnvConfig = {
|
||||
export const config: ExamEnvironmentConfig = {
|
||||
totalTimeInMS: 2 * 60 * 60 * 1000,
|
||||
tags: [],
|
||||
name: 'Test Exam',
|
||||
note: 'Some exam note...',
|
||||
passingPercent: 80,
|
||||
questionSets: [
|
||||
{
|
||||
type: EnvQuestionType.MultipleChoice,
|
||||
type: ExamEnvironmentQuestionType.MultipleChoice,
|
||||
numberOfSet: 1,
|
||||
numberOfQuestions: 1,
|
||||
numberOfCorrectAnswers: 1,
|
||||
numberOfIncorrectAnswers: 1
|
||||
},
|
||||
{
|
||||
type: EnvQuestionType.MultipleChoice,
|
||||
type: ExamEnvironmentQuestionType.MultipleChoice,
|
||||
numberOfSet: 1,
|
||||
numberOfQuestions: 1,
|
||||
numberOfCorrectAnswers: 2,
|
||||
numberOfIncorrectAnswers: 1
|
||||
},
|
||||
{
|
||||
type: EnvQuestionType.Dialogue,
|
||||
type: ExamEnvironmentQuestionType.Dialogue,
|
||||
numberOfSet: 1,
|
||||
numberOfQuestions: 2,
|
||||
numberOfCorrectAnswers: 1,
|
||||
@@ -49,10 +50,10 @@ export const config: EnvConfig = {
|
||||
retakeTimeInMS: 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
export const questionSets: EnvQuestionSet[] = [
|
||||
export const questionSets: ExamEnvironmentQuestionSet[] = [
|
||||
{
|
||||
id: oid(),
|
||||
type: EnvQuestionType.MultipleChoice,
|
||||
type: ExamEnvironmentQuestionType.MultipleChoice,
|
||||
context: null,
|
||||
questions: [
|
||||
{
|
||||
@@ -83,7 +84,7 @@ export const questionSets: EnvQuestionSet[] = [
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
type: EnvQuestionType.MultipleChoice,
|
||||
type: ExamEnvironmentQuestionType.MultipleChoice,
|
||||
context: null,
|
||||
questions: [
|
||||
{
|
||||
@@ -114,7 +115,7 @@ export const questionSets: EnvQuestionSet[] = [
|
||||
},
|
||||
{
|
||||
id: oid(),
|
||||
type: EnvQuestionType.Dialogue,
|
||||
type: ExamEnvironmentQuestionType.Dialogue,
|
||||
context: 'Dialogue 1 context',
|
||||
questions: [
|
||||
{
|
||||
@@ -196,7 +197,7 @@ export const questionSets: EnvQuestionSet[] = [
|
||||
}
|
||||
];
|
||||
|
||||
export const generatedExam: EnvGeneratedExam = {
|
||||
export const generatedExam: ExamEnvironmentGeneratedExam = {
|
||||
examId,
|
||||
id: oid(),
|
||||
deprecated: false,
|
||||
@@ -250,11 +251,10 @@ export const generatedExam: EnvGeneratedExam = {
|
||||
]
|
||||
};
|
||||
|
||||
export const examAttempt: EnvExamAttempt = {
|
||||
export const examAttempt: ExamEnvironmentExamAttempt = {
|
||||
examId,
|
||||
generatedExamId: generatedExam.id,
|
||||
id: oid(),
|
||||
needsRetake: false,
|
||||
questionSets: [
|
||||
{
|
||||
id: generatedExam.questionSets[0]!.id,
|
||||
@@ -335,7 +335,7 @@ export const examAttemptSansSubmissionTimeInMS: Static<
|
||||
]
|
||||
};
|
||||
|
||||
export const exam: EnvExam = {
|
||||
export const exam: ExamEnvironmentExam = {
|
||||
id: examId,
|
||||
config,
|
||||
questionSets,
|
||||
@@ -346,10 +346,10 @@ export const exam: EnvExam = {
|
||||
export async function seedEnvExam() {
|
||||
await clearEnvExam();
|
||||
|
||||
await fastifyTestInstance.prisma.envExam.create({
|
||||
await fastifyTestInstance.prisma.examEnvironmentExam.create({
|
||||
data: exam
|
||||
});
|
||||
await fastifyTestInstance.prisma.envGeneratedExam.create({
|
||||
await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.create({
|
||||
data: generatedExam
|
||||
});
|
||||
|
||||
@@ -359,7 +359,7 @@ export async function seedEnvExam() {
|
||||
// while (numberOfExamsGenerated < 2) {
|
||||
// try {
|
||||
// const generatedExam = generateExam(exam);
|
||||
// await fastifyTestInstance.prisma.envGeneratedExam.create({
|
||||
// await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.create({
|
||||
// data: generatedExam
|
||||
// });
|
||||
// numberOfExamsGenerated++;
|
||||
@@ -370,13 +370,13 @@ export async function seedEnvExam() {
|
||||
}
|
||||
|
||||
export async function clearEnvExam() {
|
||||
await fastifyTestInstance.prisma.envExamAttempt.deleteMany({});
|
||||
await fastifyTestInstance.prisma.envGeneratedExam.deleteMany({});
|
||||
await fastifyTestInstance.prisma.envExam.deleteMany({});
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.deleteMany({});
|
||||
await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.deleteMany({});
|
||||
await fastifyTestInstance.prisma.examEnvironmentExam.deleteMany({});
|
||||
}
|
||||
|
||||
export async function seedEnvExamAttempt() {
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: examAttempt
|
||||
});
|
||||
}
|
||||
+78
-45
@@ -164,58 +164,58 @@ model user {
|
||||
isClassroomAccount Boolean? // Undefined
|
||||
|
||||
// Relations
|
||||
examAttempts EnvExamAttempt[]
|
||||
examAttempts ExamEnvironmentExamAttempt[]
|
||||
examEnvironmentAuthorizationToken ExamEnvironmentAuthorizationToken?
|
||||
}
|
||||
|
||||
// -----------------------------------
|
||||
|
||||
/// An exam for the Exam Environment App as designed by the examiners
|
||||
model EnvExam {
|
||||
model ExamEnvironmentExam {
|
||||
/// Globally unique exam id
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// All questions for a given exam
|
||||
questionSets EnvQuestionSet[]
|
||||
questionSets ExamEnvironmentQuestionSet[]
|
||||
/// Configuration for exam metadata
|
||||
config EnvConfig
|
||||
config ExamEnvironmentConfig
|
||||
/// ObjectIds for required challenges/blocks to take the exam
|
||||
prerequisites String[] @db.ObjectId
|
||||
prerequisites String[] @db.ObjectId
|
||||
/// If `deprecated`, the exam should no longer be considered for users
|
||||
deprecated Boolean
|
||||
|
||||
// Relations
|
||||
generatedExams EnvGeneratedExam[]
|
||||
examAttempts EnvExamAttempt[]
|
||||
generatedExams ExamEnvironmentGeneratedExam[]
|
||||
examAttempts ExamEnvironmentExamAttempt[]
|
||||
}
|
||||
|
||||
/// A grouping of one or more questions of a given type
|
||||
type EnvQuestionSet {
|
||||
type ExamEnvironmentQuestionSet {
|
||||
/// Unique question type id
|
||||
id String @db.ObjectId
|
||||
type EnvQuestionType
|
||||
id String @db.ObjectId
|
||||
type ExamEnvironmentQuestionType
|
||||
/// Content related to all questions in set
|
||||
context String?
|
||||
questions EnvMultipleChoiceQuestion[]
|
||||
questions ExamEnvironmentMultipleChoiceQuestion[]
|
||||
}
|
||||
|
||||
/// A multiple choice question for the Exam Environment App
|
||||
type EnvMultipleChoiceQuestion {
|
||||
type ExamEnvironmentMultipleChoiceQuestion {
|
||||
/// Unique question id
|
||||
id String @db.ObjectId
|
||||
id String @db.ObjectId
|
||||
/// Main question paragraph
|
||||
text String
|
||||
/// Zero or more tags given to categorize a question
|
||||
tags String[]
|
||||
/// Optional audio for a question
|
||||
audio EnvAudio?
|
||||
audio ExamEnvironmentAudio?
|
||||
/// Available possible answers for an exam
|
||||
answers EnvAnswer[]
|
||||
answers ExamEnvironmentAnswer[]
|
||||
/// TODO Possible "deprecated_time" to remove after all exams could possibly have been taken
|
||||
deprecated Boolean
|
||||
}
|
||||
|
||||
/// Audio for an Exam Environment App multiple choice question
|
||||
type EnvAudio {
|
||||
type ExamEnvironmentAudio {
|
||||
/// Optional text for audio
|
||||
captions String?
|
||||
/// URL to audio file
|
||||
@@ -226,7 +226,7 @@ type EnvAudio {
|
||||
}
|
||||
|
||||
/// Type of question for the Exam Environment App
|
||||
enum EnvQuestionType {
|
||||
enum ExamEnvironmentQuestionType {
|
||||
/// Single question with one or more answers
|
||||
MultipleChoice
|
||||
/// Mass text
|
||||
@@ -234,7 +234,7 @@ enum EnvQuestionType {
|
||||
}
|
||||
|
||||
/// Answer for an Exam Environment App multiple choice question
|
||||
type EnvAnswer {
|
||||
type ExamEnvironmentAnswer {
|
||||
/// Unique answer id
|
||||
id String @db.ObjectId
|
||||
/// Whether the answer is correct
|
||||
@@ -244,24 +244,26 @@ type EnvAnswer {
|
||||
}
|
||||
|
||||
/// Configuration for an exam in the Exam Environment App
|
||||
type EnvConfig {
|
||||
type ExamEnvironmentConfig {
|
||||
/// Human-readable exam name
|
||||
name String
|
||||
/// Notes given about exam
|
||||
note String
|
||||
/// Category configuration for question selection
|
||||
tags EnvTagConfig[]
|
||||
tags ExamEnvironmentTagConfig[]
|
||||
/// Total time allocated for exam in milliseconds
|
||||
totalTimeInMS Int
|
||||
/// Configuration for sets of questions
|
||||
questionSets EnvQuestionSetConfig[]
|
||||
questionSets ExamEnvironmentQuestionSetConfig[]
|
||||
/// Duration after exam completion before a retake is allowed in milliseconds
|
||||
retakeTimeInMS Int
|
||||
/// Passing percent for the exam
|
||||
passingPercent Float
|
||||
}
|
||||
|
||||
/// Configuration for a set of questions in the Exam Environment App
|
||||
type EnvQuestionSetConfig {
|
||||
type EnvQuestionType
|
||||
type ExamEnvironmentQuestionSetConfig {
|
||||
type ExamEnvironmentQuestionType
|
||||
/// Number of this grouping of questions per exam
|
||||
numberOfSet Int
|
||||
/// Number of multiple choice questions per grouping matching this set config
|
||||
@@ -275,7 +277,7 @@ type EnvQuestionSetConfig {
|
||||
/// Configuration for tags in the Exam Environment App
|
||||
///
|
||||
/// This configures the number of questions that should resolve to a given tag set criteria.
|
||||
type EnvTagConfig {
|
||||
type ExamEnvironmentTagConfig {
|
||||
/// Group of multiple choice question tags
|
||||
group String[]
|
||||
/// Number of multiple choice questions per exam that should meet the group criteria
|
||||
@@ -283,7 +285,7 @@ type EnvTagConfig {
|
||||
}
|
||||
|
||||
/// An attempt at an exam in the Exam Environment App
|
||||
model EnvExamAttempt {
|
||||
model ExamEnvironmentExamAttempt {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Foriegn key to user
|
||||
userId String @db.ObjectId
|
||||
@@ -292,23 +294,23 @@ model EnvExamAttempt {
|
||||
/// Foreign key to generated exam id
|
||||
generatedExamId String @db.ObjectId
|
||||
|
||||
questionSets EnvQuestionSetAttempt[]
|
||||
questionSets ExamEnvironmentQuestionSetAttempt[]
|
||||
/// Time exam was started as milliseconds since epoch
|
||||
startTimeInMS Int
|
||||
needsRetake Boolean
|
||||
|
||||
// Relations
|
||||
user user @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
exam EnvExam @relation(fields: [examId], references: [id])
|
||||
generatedExam EnvGeneratedExam @relation(fields: [generatedExamId], references: [id])
|
||||
user user @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
exam ExamEnvironmentExam @relation(fields: [examId], references: [id], onDelete: Cascade)
|
||||
generatedExam ExamEnvironmentGeneratedExam @relation(fields: [generatedExamId], references: [id])
|
||||
ExamEnvironmentExamModeration ExamEnvironmentExamModeration[]
|
||||
}
|
||||
|
||||
type EnvQuestionSetAttempt {
|
||||
id String @db.ObjectId
|
||||
questions EnvMultipleChoiceQuestionAttempt[]
|
||||
type ExamEnvironmentQuestionSetAttempt {
|
||||
id String @db.ObjectId
|
||||
questions ExamEnvironmentMultipleChoiceQuestionAttempt[]
|
||||
}
|
||||
|
||||
type EnvMultipleChoiceQuestionAttempt {
|
||||
type ExamEnvironmentMultipleChoiceQuestionAttempt {
|
||||
/// Foreign key to question
|
||||
id String @db.ObjectId
|
||||
/// An array of foreign keys to answers
|
||||
@@ -322,25 +324,25 @@ type EnvMultipleChoiceQuestionAttempt {
|
||||
/// A generated exam for the Exam Environment App
|
||||
///
|
||||
/// This is the user-facing information for an exam.
|
||||
model EnvGeneratedExam {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
model ExamEnvironmentGeneratedExam {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Foreign key to exam
|
||||
examId String @db.ObjectId
|
||||
questionSets EnvGeneratedQuestionSet[]
|
||||
examId String @db.ObjectId
|
||||
questionSets ExamEnvironmentGeneratedQuestionSet[]
|
||||
/// If `deprecated`, the generation should not longer be considered for users
|
||||
deprecated Boolean
|
||||
|
||||
// Relations
|
||||
exam EnvExam @relation(fields: [examId], references: [id])
|
||||
EnvExamAttempt EnvExamAttempt[]
|
||||
exam ExamEnvironmentExam @relation(fields: [examId], references: [id], onDelete: Cascade)
|
||||
EnvExamAttempt ExamEnvironmentExamAttempt[]
|
||||
}
|
||||
|
||||
type EnvGeneratedQuestionSet {
|
||||
id String @db.ObjectId
|
||||
questions EnvGeneratedMultipleChoiceQuestion[]
|
||||
type ExamEnvironmentGeneratedQuestionSet {
|
||||
id String @db.ObjectId
|
||||
questions ExamEnvironmentGeneratedMultipleChoiceQuestion[]
|
||||
}
|
||||
|
||||
type EnvGeneratedMultipleChoiceQuestion {
|
||||
type ExamEnvironmentGeneratedMultipleChoiceQuestion {
|
||||
/// Foreign key to question id
|
||||
id String @db.ObjectId
|
||||
/// Each item is a foreign key to an answer
|
||||
@@ -486,3 +488,34 @@ type SurveyResponse {
|
||||
question String
|
||||
response String
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
|
||||
model ExamEnvironmentExamModeration {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Whether or not the item is approved
|
||||
status ExamEnvironmentExamModerationStatus
|
||||
/// Foreign key to exam attempt
|
||||
examAttemptId String @unique @db.ObjectId
|
||||
/// Optional feedback/note about the moderation decision
|
||||
feedback String?
|
||||
/// Date the exam attempt was moderated
|
||||
moderationDate DateTime?
|
||||
/// Foreign key to moderator. This is `null` until the item is moderated.
|
||||
moderatorId String? @db.ObjectId
|
||||
|
||||
/// Date the exam attempt was added to the moderation queue
|
||||
submissionDate DateTime @default(now()) @db.Date
|
||||
|
||||
// Relations
|
||||
examAttempt ExamEnvironmentExamAttempt @relation(fields: [examAttemptId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum ExamEnvironmentExamModerationStatus {
|
||||
/// Attempt is determined to be valid
|
||||
Approved
|
||||
/// Attempt is determined to be invalid
|
||||
Denied
|
||||
/// Attempt has yet to be moderated
|
||||
Pending
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ExamEnvironmentExamModerationStatus } from '@prisma/client';
|
||||
import { Static } from '@fastify/type-provider-typebox';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
@@ -12,8 +13,8 @@ import {
|
||||
examEnvironmentPostExamAttempt,
|
||||
examEnvironmentPostExamGeneratedExam
|
||||
} from '../schemas';
|
||||
import * as mock from '../../../__mocks__/env-exam';
|
||||
import { constructUserExam } from '../utils/exam';
|
||||
import * as mock from '../../../__mocks__/exam-environment-exam';
|
||||
import { constructUserExam } from '../utils/exam-environment';
|
||||
import { JWT_SECRET } from '../../utils/env';
|
||||
|
||||
jest.mock('../../utils/env', () => {
|
||||
@@ -36,7 +37,6 @@ describe('/exam-environment/', () => {
|
||||
const setCookies = await devLogin();
|
||||
superPost = createSuperRequest({ method: 'POST', setCookies });
|
||||
superGet = createSuperRequest({ method: 'GET', setCookies });
|
||||
await mock.seedEnvExam();
|
||||
// Add exam environment authorization token
|
||||
const res = await superPost('/user/exam-environment/token');
|
||||
expect(res.status).toBe(201);
|
||||
@@ -50,9 +50,13 @@ describe('/exam-environment/', () => {
|
||||
await mock.clearEnvExam();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await mock.seedEnvExam();
|
||||
});
|
||||
|
||||
describe('POST /exam-environment/exam/attempt', () => {
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.envExamAttempt.deleteMany();
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.deleteMany();
|
||||
});
|
||||
|
||||
it('should return an error if there are no current exam attempts matching the given id', async () => {
|
||||
@@ -83,11 +87,10 @@ describe('/exam-environment/', () => {
|
||||
it('should return an error if the given exam id does not match an existing exam', async () => {
|
||||
const examId = mock.oid();
|
||||
// Create exam attempt with bad exam id
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: {
|
||||
examId,
|
||||
generatedExamId: mock.oid(),
|
||||
needsRetake: false,
|
||||
startTimeInMS: Date.now(),
|
||||
userId: defaultUserId
|
||||
}
|
||||
@@ -115,11 +118,10 @@ describe('/exam-environment/', () => {
|
||||
|
||||
it('should return an error if the attempt has expired', async () => {
|
||||
// Create exam attempt with expired time
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: {
|
||||
examId: mock.examId,
|
||||
generatedExamId: mock.oid(),
|
||||
needsRetake: false,
|
||||
startTimeInMS: Date.now() - (1000 * 60 * 60 * 2 + 1000),
|
||||
userId: defaultUserId
|
||||
}
|
||||
@@ -147,11 +149,10 @@ describe('/exam-environment/', () => {
|
||||
|
||||
it('should return an error if there is no matching generated exam', async () => {
|
||||
// Create exam attempt with no matching generated exam
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: {
|
||||
examId: mock.examId,
|
||||
generatedExamId: mock.oid(),
|
||||
needsRetake: false,
|
||||
startTimeInMS: Date.now(),
|
||||
userId: defaultUserId
|
||||
}
|
||||
@@ -178,9 +179,10 @@ describe('/exam-environment/', () => {
|
||||
});
|
||||
|
||||
it('should return an error if the attempt does not match the generated exam', async () => {
|
||||
const attempt = await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: { ...mock.examAttempt, userId: defaultUserId }
|
||||
});
|
||||
const attempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: { ...mock.examAttempt, userId: defaultUserId }
|
||||
});
|
||||
|
||||
attempt.questionSets[0]!.id = mock.oid();
|
||||
|
||||
@@ -202,25 +204,30 @@ describe('/exam-environment/', () => {
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
// Database should mark attempt as `needsRetake`
|
||||
const updatedAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.findUnique({
|
||||
where: { id: attempt.id }
|
||||
});
|
||||
expect(updatedAttempt).toHaveProperty('needsRetake', true);
|
||||
// Database should have moderation record for attempt
|
||||
const examModeration =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamModeration.findUnique(
|
||||
{
|
||||
where: {
|
||||
examAttemptId: attempt.id,
|
||||
status: ExamEnvironmentExamModerationStatus.Pending
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(examModeration).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should return 200 if request is valid, and update attempt in database', async () => {
|
||||
const attempt = await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
data: {
|
||||
userId: defaultUserId,
|
||||
examId: mock.examId,
|
||||
generatedExamId: mock.generatedExam.id,
|
||||
startTimeInMS: Date.now(),
|
||||
questionSets: [],
|
||||
needsRetake: false
|
||||
}
|
||||
});
|
||||
const attempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: {
|
||||
userId: defaultUserId,
|
||||
examId: mock.examId,
|
||||
generatedExamId: mock.generatedExam.id,
|
||||
startTimeInMS: Date.now(),
|
||||
questionSets: []
|
||||
}
|
||||
});
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt: mock.examAttemptSansSubmissionTimeInMS
|
||||
@@ -237,9 +244,11 @@ describe('/exam-environment/', () => {
|
||||
|
||||
// Database should update attempt
|
||||
const updatedAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.findUnique({
|
||||
where: { id: attempt.id }
|
||||
});
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.findUnique(
|
||||
{
|
||||
where: { id: attempt.id }
|
||||
}
|
||||
);
|
||||
|
||||
expect(updatedAttempt).toMatchObject(body.attempt);
|
||||
});
|
||||
@@ -247,7 +256,7 @@ describe('/exam-environment/', () => {
|
||||
|
||||
describe('POST /exam-environment/generated-exam', () => {
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.envExamAttempt.deleteMany();
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.deleteMany();
|
||||
// Add prerequisite id to user completed challenge
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
@@ -258,6 +267,11 @@ describe('/exam-environment/', () => {
|
||||
}
|
||||
});
|
||||
await mock.seedEnvExam();
|
||||
const a =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamModeration.findMany(
|
||||
{}
|
||||
);
|
||||
expect(a).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return an error if the given exam id is invalid', async () => {
|
||||
@@ -305,13 +319,13 @@ describe('/exam-environment/', () => {
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should return an error if the exam has been attempted in the last 24 hours', async () => {
|
||||
it('should return an error if the exam has been attempted too recently to retake', async () => {
|
||||
const recentExamAttempt = {
|
||||
...mock.examAttempt,
|
||||
// Set start time such that exam has just expired
|
||||
startTimeInMS: Date.now() - mock.exam.config.totalTimeInMS
|
||||
};
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: recentExamAttempt
|
||||
});
|
||||
|
||||
@@ -333,15 +347,16 @@ describe('/exam-environment/', () => {
|
||||
}
|
||||
});
|
||||
|
||||
await fastifyTestInstance.prisma.envExamAttempt.update({
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.update({
|
||||
where: {
|
||||
id: recentExamAttempt.id
|
||||
},
|
||||
data: {
|
||||
// Set start time such that exam has expired, but 24 hours - 1s has passed
|
||||
// Set start time such that exam has expired, but retake time -1s has passed
|
||||
startTimeInMS:
|
||||
Date.now() -
|
||||
(mock.exam.config.totalTimeInMS + (24 * 60 * 60 * 1000 - 1000))
|
||||
(mock.exam.config.totalTimeInMS +
|
||||
(mock.exam.config.retakeTimeInMS - 1000))
|
||||
}
|
||||
});
|
||||
|
||||
@@ -371,14 +386,14 @@ describe('/exam-environment/', () => {
|
||||
recentExamAttempt.startTimeInMS =
|
||||
Date.now() -
|
||||
(mock.exam.config.totalTimeInMS + (24 * 60 * 60 * 1000 + 1000));
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: recentExamAttempt
|
||||
});
|
||||
|
||||
// Generate new exam for user to be assigned
|
||||
const newGeneratedExam = structuredClone(mock.generatedExam);
|
||||
newGeneratedExam.id = mock.oid();
|
||||
await fastifyTestInstance.prisma.envGeneratedExam.create({
|
||||
await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.create({
|
||||
data: newGeneratedExam
|
||||
});
|
||||
|
||||
@@ -407,7 +422,7 @@ describe('/exam-environment/', () => {
|
||||
|
||||
it('should return the current attempt if it is still ongoing', async () => {
|
||||
const latestAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: mock.examAttempt
|
||||
});
|
||||
|
||||
@@ -439,7 +454,7 @@ describe('/exam-environment/', () => {
|
||||
24 * 60 * 60 * 1000 -
|
||||
mock.exam.config.totalTimeInMS -
|
||||
1 * 60 * 60 * 1000;
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: submittedAttempt
|
||||
});
|
||||
|
||||
@@ -475,16 +490,20 @@ describe('/exam-environment/', () => {
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const generatedExam =
|
||||
await fastifyTestInstance.prisma.envGeneratedExam.findFirst({
|
||||
where: { examId: mock.examId }
|
||||
});
|
||||
await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.findFirst(
|
||||
{
|
||||
where: { examId: mock.examId }
|
||||
}
|
||||
);
|
||||
|
||||
expect(generatedExam).toBeDefined();
|
||||
|
||||
const examAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.findFirst({
|
||||
where: { generatedExamId: generatedExam!.id }
|
||||
});
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.findFirst(
|
||||
{
|
||||
where: { generatedExamId: generatedExam!.id }
|
||||
}
|
||||
);
|
||||
|
||||
expect(examAttempt).toEqual({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
@@ -493,7 +512,6 @@ describe('/exam-environment/', () => {
|
||||
examId: mock.examId,
|
||||
generatedExamId: generatedExam!.id,
|
||||
questionSets: [],
|
||||
needsRetake: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
startTimeInMS: expect.any(Number)
|
||||
});
|
||||
@@ -501,7 +519,7 @@ describe('/exam-environment/', () => {
|
||||
|
||||
it('should unwind (delete) the exam attempt if the user exam cannot be constructed', async () => {
|
||||
const _mockConstructUserExam = jest
|
||||
.spyOn(await import('../utils/exam'), 'constructUserExam')
|
||||
.spyOn(await import('../utils/exam-environment'), 'constructUserExam')
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
@@ -519,9 +537,11 @@ describe('/exam-environment/', () => {
|
||||
expect(res.status).toBe(500);
|
||||
|
||||
const examAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.findFirst({
|
||||
where: { examId: mock.examId }
|
||||
});
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.findFirst(
|
||||
{
|
||||
where: { examId: mock.examId }
|
||||
}
|
||||
);
|
||||
|
||||
expect(examAttempt).toBeNull();
|
||||
});
|
||||
@@ -540,16 +560,20 @@ describe('/exam-environment/', () => {
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const generatedExam =
|
||||
await fastifyTestInstance.prisma.envGeneratedExam.findFirst({
|
||||
where: { examId: mock.examId }
|
||||
});
|
||||
await fastifyTestInstance.prisma.examEnvironmentGeneratedExam.findFirst(
|
||||
{
|
||||
where: { examId: mock.examId }
|
||||
}
|
||||
);
|
||||
|
||||
expect(generatedExam).toBeDefined();
|
||||
|
||||
const examAttempt =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.findFirst({
|
||||
where: { generatedExamId: generatedExam!.id }
|
||||
});
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.findFirst(
|
||||
{
|
||||
where: { generatedExamId: generatedExam!.id }
|
||||
}
|
||||
);
|
||||
|
||||
const userExam = constructUserExam(generatedExam!, mock.exam);
|
||||
|
||||
@@ -565,7 +589,7 @@ describe('/exam-environment/', () => {
|
||||
|
||||
describe('POST /exam-environment/screenshot', () => {
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.envExamAttempt.deleteMany();
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.deleteMany();
|
||||
});
|
||||
|
||||
it('should return 400 if request is not multipart form data', async () => {
|
||||
@@ -615,7 +639,7 @@ describe('/exam-environment/', () => {
|
||||
});
|
||||
|
||||
it('should return 400 if image is of wrong format', async () => {
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: mock.examAttempt
|
||||
});
|
||||
|
||||
@@ -639,7 +663,7 @@ describe('/exam-environment/', () => {
|
||||
const imageUploadRes = createFetchMock({ ok: true });
|
||||
jest.spyOn(globalThis, 'fetch').mockImplementation(imageUploadRes);
|
||||
|
||||
await fastifyTestInstance.prisma.envExamAttempt.create({
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: mock.examAttempt
|
||||
});
|
||||
|
||||
@@ -670,6 +694,7 @@ describe('/exam-environment/', () => {
|
||||
config: {
|
||||
name: mock.exam.config.name,
|
||||
note: mock.exam.config.note,
|
||||
passingPercent: mock.exam.config.passingPercent,
|
||||
totalTimeInMS: mock.exam.config.totalTimeInMS,
|
||||
retakeTimeInMS: mock.exam.config.retakeTimeInMS
|
||||
},
|
||||
@@ -682,7 +707,7 @@ describe('/exam-environment/', () => {
|
||||
});
|
||||
|
||||
it('should not return any deprecated exams', async () => {
|
||||
await fastifyTestInstance.prisma.envExam.update({
|
||||
await fastifyTestInstance.prisma.examEnvironmentExam.update({
|
||||
where: { id: mock.examId },
|
||||
data: { deprecated: true }
|
||||
});
|
||||
@@ -699,6 +724,270 @@ describe('/exam-environment/', () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /exam-environment/exam/attempt/:attemptId', () => {
|
||||
afterEach(async () => {
|
||||
// If attempt is deleted, moderation record should cascade
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.deleteMany();
|
||||
const moderationRecords =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamModeration.findMany(
|
||||
{}
|
||||
);
|
||||
expect(moderationRecords).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return 404 if the attempt does not exist', async () => {
|
||||
const attemptId = mock.oid();
|
||||
const res = await superGet(
|
||||
`/exam-environment/exam/attempt/${attemptId}`
|
||||
).set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
code: 'FCC_ENOENT_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 404 if the attempt belongs to another user', async () => {
|
||||
const otherUserAttempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: { ...mock.examAttempt, userId: mock.oid() }
|
||||
});
|
||||
const res = await superGet(
|
||||
`/exam-environment/exam/attempt/${otherUserAttempt.id}`
|
||||
).set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
code: 'FCC_ENOENT_EXAM_ENVIRONMENT_EXAM_ATTEMPT', // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 200 with the examEnvironmentExamAttempt if the attempt exists and belongs to the user', async () => {
|
||||
const attempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: { ...mock.examAttempt, userId: defaultUserId }
|
||||
});
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamModeration.create({
|
||||
data: {
|
||||
examAttemptId: attempt.id,
|
||||
status: ExamEnvironmentExamModerationStatus.Pending
|
||||
}
|
||||
});
|
||||
|
||||
const res = await superGet(
|
||||
`/exam-environment/exam/attempt/${attempt.id}`
|
||||
).set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
const examEnvironmentExamAttempt = {
|
||||
result: null,
|
||||
startTimeInMS: attempt.startTimeInMS,
|
||||
questionSets: attempt.questionSets
|
||||
};
|
||||
|
||||
expect(res.body).toEqual(examEnvironmentExamAttempt);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
xit('TODO: (once serialization is serializable) should return 400 if no attempt id is given', async () => {
|
||||
const res = await superGet('/exam-environment/exam/attempt/').set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return the attempt without results, if the attempt has not been moderated', async () => {
|
||||
const attempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: { ...mock.examAttempt, userId: defaultUserId }
|
||||
});
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamModeration.create({
|
||||
data: {
|
||||
examAttemptId: attempt.id,
|
||||
status: ExamEnvironmentExamModerationStatus.Pending
|
||||
}
|
||||
});
|
||||
|
||||
const res = await superGet(
|
||||
`/exam-environment/exam/attempt/${attempt.id}`
|
||||
).set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
const examEnvironmentExamAttempt = {
|
||||
result: null,
|
||||
startTimeInMS: attempt.startTimeInMS,
|
||||
questionSets: attempt.questionSets
|
||||
};
|
||||
|
||||
expect(res.body).toEqual(examEnvironmentExamAttempt);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return the attempt with results, if the attempt has been moderated', async () => {
|
||||
const examAttempt = structuredClone(mock.examAttempt);
|
||||
examAttempt.startTimeInMS = Date.now() - mock.exam.config.totalTimeInMS;
|
||||
const attempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: examAttempt
|
||||
});
|
||||
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamModeration.create({
|
||||
data: {
|
||||
examAttemptId: attempt.id,
|
||||
status: ExamEnvironmentExamModerationStatus.Approved
|
||||
}
|
||||
});
|
||||
|
||||
const res = await superGet(
|
||||
`/exam-environment/exam/attempt/${attempt.id}`
|
||||
).set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
const examEnvironmentExamAttempt = {
|
||||
result: {
|
||||
score: 25,
|
||||
passingPercent: 80
|
||||
},
|
||||
startTimeInMS: attempt.startTimeInMS,
|
||||
questionSets: attempt.questionSets
|
||||
};
|
||||
|
||||
expect(res.body).toEqual(examEnvironmentExamAttempt);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /exam-environment/exam/attempts', () => {
|
||||
afterEach(async () => {
|
||||
// If attempt is deleted, moderation record should cascade
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.deleteMany();
|
||||
const moderationRecords =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamModeration.findMany(
|
||||
{}
|
||||
);
|
||||
expect(moderationRecords).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return 404 if no attempts exist', async () => {
|
||||
const res = await superGet(`/exam-environment/exam/attempts`).set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
expect(res.body).toStrictEqual({
|
||||
code: 'FCC_ENOENT_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
message: expect.any(String)
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 200 with the attempts if they exist and belong to the user', async () => {
|
||||
const attempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: { ...mock.examAttempt, userId: defaultUserId }
|
||||
});
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamModeration.create({
|
||||
data: {
|
||||
examAttemptId: attempt.id,
|
||||
status: ExamEnvironmentExamModerationStatus.Pending
|
||||
}
|
||||
});
|
||||
|
||||
const res = await superGet(`/exam-environment/exam/attempts`).set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
const examEnvironmentExamAttempt = {
|
||||
result: null,
|
||||
startTimeInMS: attempt.startTimeInMS,
|
||||
questionSets: attempt.questionSets
|
||||
};
|
||||
|
||||
expect(res.body).toEqual([examEnvironmentExamAttempt]);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return the attempts without results, if they have not been moderated', async () => {
|
||||
const attempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: { ...mock.examAttempt, userId: defaultUserId }
|
||||
});
|
||||
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamModeration.create({
|
||||
data: {
|
||||
examAttemptId: attempt.id,
|
||||
status: ExamEnvironmentExamModerationStatus.Pending
|
||||
}
|
||||
});
|
||||
|
||||
const res = await superGet(`/exam-environment/exam/attempts`).set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
const examEnvironmentExamAttempt = {
|
||||
result: null,
|
||||
startTimeInMS: attempt.startTimeInMS,
|
||||
questionSets: attempt.questionSets
|
||||
};
|
||||
|
||||
expect(res.body).toEqual([examEnvironmentExamAttempt]);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return the attempts with results, if they have been moderated', async () => {
|
||||
const examAttempt = structuredClone(mock.examAttempt);
|
||||
examAttempt.startTimeInMS = Date.now() - mock.exam.config.totalTimeInMS;
|
||||
const attempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: examAttempt
|
||||
});
|
||||
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamModeration.create({
|
||||
data: {
|
||||
examAttemptId: attempt.id,
|
||||
status: ExamEnvironmentExamModerationStatus.Approved
|
||||
}
|
||||
});
|
||||
|
||||
const res = await superGet(`/exam-environment/exam/attempts`).set(
|
||||
'exam-environment-authorization-token',
|
||||
examEnvironmentAuthorizationToken
|
||||
);
|
||||
|
||||
const examEnvironmentExamAttempt = {
|
||||
result: {
|
||||
score: 25,
|
||||
passingPercent: 80
|
||||
},
|
||||
startTimeInMS: attempt.startTimeInMS,
|
||||
questionSets: attempt.questionSets
|
||||
};
|
||||
|
||||
expect(res.body).toEqual([examEnvironmentExamAttempt]);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authenticated user without exam environment authorization token', () => {
|
||||
@@ -796,5 +1085,26 @@ describe('/exam-environment/', () => {
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /exam-environment/exam/attempt/:attemptId', () => {
|
||||
it('should return 403', async () => {
|
||||
const res = await superGet(
|
||||
`/exam-environment/exam/attempt/${mock.oid()}`
|
||||
).set('exam-environment-authorization-token', 'invalid-token');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /exam-environment/exam/attempts', () => {
|
||||
it('should return 403', async () => {
|
||||
const res = await superGet('/exam-environment/exam/attempts').set(
|
||||
'exam-environment-authorization-token',
|
||||
'invalid-token'
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebo
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { PrismaClientValidationError } from '@prisma/client/runtime/library';
|
||||
import { type FastifyInstance, type FastifyReply } from 'fastify';
|
||||
import { ExamEnvironmentExamModerationStatus } from '@prisma/client';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import * as schemas from '../schemas';
|
||||
@@ -10,10 +11,11 @@ import { mapErr, syncMapErr, UpdateReqType } from '../../utils';
|
||||
import { JWT_SECRET, SCREENSHOT_SERVICE_LOCATION } from '../../utils/env';
|
||||
import {
|
||||
checkPrerequisites,
|
||||
constructEnvExamAttempt,
|
||||
constructUserExam,
|
||||
userAttemptToDatabaseAttemptQuestionSets,
|
||||
validateAttempt
|
||||
} from '../utils/exam';
|
||||
} from '../utils/exam-environment';
|
||||
import { ERRORS } from '../utils/errors';
|
||||
import { isObjectID } from '../../utils/validation';
|
||||
|
||||
@@ -45,6 +47,21 @@ export const examEnvironmentValidatedTokenRoutes: FastifyPluginCallbackTypebox =
|
||||
},
|
||||
postExamAttemptHandler
|
||||
);
|
||||
fastify.get(
|
||||
'/exam-environment/exam/attempts',
|
||||
{
|
||||
schema: schemas.examEnvironmentGetExamAttempts
|
||||
},
|
||||
getExamAttemptsHandler
|
||||
);
|
||||
fastify.get(
|
||||
'/exam-environment/exam/attempt/:attemptId',
|
||||
{
|
||||
schema: schemas.examEnvironmentGetExamAttempt
|
||||
},
|
||||
getExamAttemptHandler
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
@@ -180,7 +197,7 @@ async function postExamGeneratedExamHandler(
|
||||
// Get exam from DB
|
||||
const examId = req.body.examId;
|
||||
const maybeExam = await mapErr(
|
||||
this.prisma.envExam.findUnique({
|
||||
this.prisma.examEnvironmentExam.findUnique({
|
||||
where: {
|
||||
id: examId
|
||||
}
|
||||
@@ -188,12 +205,13 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
if (maybeExam.hasError) {
|
||||
if (maybeExam.error instanceof PrismaClientValidationError) {
|
||||
logger.warn({ examError: maybeExam.error }, 'Invalid exam id given.');
|
||||
logger.warn(maybeExam.error, 'Invalid exam id given.');
|
||||
void reply.code(400);
|
||||
return reply.send(ERRORS.FCC_EINVAL_EXAM_ID(maybeExam.error.message));
|
||||
}
|
||||
|
||||
logger.error({ examError: maybeExam.error });
|
||||
logger.error(maybeExam.error);
|
||||
this.Sentry.captureException(maybeExam.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExam.error))
|
||||
@@ -228,9 +246,10 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
}
|
||||
|
||||
// Check user has not completed exam in last 24 hours
|
||||
// Check user has not completed exam within cooldown period, and
|
||||
// user does not have an existing attempt awaiting grading
|
||||
const maybeExamAttempts = await mapErr(
|
||||
this.prisma.envExamAttempt.findMany({
|
||||
this.prisma.examEnvironmentExamAttempt.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
examId: exam.id
|
||||
@@ -239,10 +258,8 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
|
||||
if (maybeExamAttempts.hasError) {
|
||||
logger.error(
|
||||
{ examAttemptsError: maybeExamAttempts.error },
|
||||
'Unable to query exam attempts.'
|
||||
);
|
||||
logger.error(maybeExamAttempts.error, 'Unable to query exam attempts.');
|
||||
this.Sentry.captureException(maybeExamAttempts.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExamAttempts.error))
|
||||
@@ -258,6 +275,41 @@ async function postExamGeneratedExamHandler(
|
||||
: null;
|
||||
|
||||
if (lastAttempt) {
|
||||
// Camper may not take the exam again, until the previous attempt is graded.
|
||||
const maybeMod = await mapErr(
|
||||
this.prisma.examEnvironmentExamModeration.findFirst({
|
||||
where: {
|
||||
examAttemptId: lastAttempt.id,
|
||||
status: ExamEnvironmentExamModerationStatus.Pending
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeMod.hasError) {
|
||||
logger.error(maybeMod.error);
|
||||
this.Sentry.captureException(maybeMod.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeMod.error))
|
||||
);
|
||||
}
|
||||
|
||||
const moderation = maybeMod.data;
|
||||
|
||||
if (moderation !== null) {
|
||||
logger.warn(
|
||||
{ examAttemptId: lastAttempt.id },
|
||||
'User has an exam attempt awaiting grading.'
|
||||
);
|
||||
void reply.code(403);
|
||||
return reply.send(
|
||||
// TODO: Better error type
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT(
|
||||
'User has an exam attempt awaiting grading.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const examExpirationTime =
|
||||
lastAttempt.startTimeInMS + exam.config.totalTimeInMS;
|
||||
if (examExpirationTime < Date.now()) {
|
||||
@@ -282,7 +334,7 @@ async function postExamGeneratedExamHandler(
|
||||
// This is most likely to happen if the Camper's app closes and is reopened.
|
||||
// Send the Camper back to the exam they were working on.
|
||||
const generated = await mapErr(
|
||||
this.prisma.envGeneratedExam.findFirst({
|
||||
this.prisma.examEnvironmentGeneratedExam.findFirst({
|
||||
where: {
|
||||
id: lastAttempt.generatedExamId
|
||||
}
|
||||
@@ -290,10 +342,8 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
|
||||
if (generated.hasError) {
|
||||
logger.error(
|
||||
{ generatedError: generated.error },
|
||||
'Unable to query generated exam.'
|
||||
);
|
||||
logger.error(generated.error, 'Unable to query generated exam.');
|
||||
this.Sentry.captureException(generated.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(generated.error))
|
||||
@@ -301,16 +351,14 @@ async function postExamGeneratedExamHandler(
|
||||
}
|
||||
|
||||
if (generated.data === null) {
|
||||
logger.error(
|
||||
{ generatedExamId: lastAttempt.generatedExamId },
|
||||
'Generated exam not found.'
|
||||
);
|
||||
const error = {
|
||||
data: { generatedExamId: lastAttempt.generatedExamId },
|
||||
message: 'Unreachable. Generated exam not found.'
|
||||
};
|
||||
logger.error(error.data, error.message);
|
||||
this.Sentry.captureException(error.data);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
|
||||
'Unreachable. Generated exam not found.'
|
||||
)
|
||||
);
|
||||
return reply.send(ERRORS.FCC_ERR_EXAM_ENVIRONMENT(error.message));
|
||||
}
|
||||
|
||||
const userExam = constructUserExam(generated.data, exam);
|
||||
@@ -324,7 +372,7 @@ async function postExamGeneratedExamHandler(
|
||||
|
||||
// Randomly pick a generated exam for user
|
||||
const maybeGeneratedExams = await mapErr(
|
||||
this.prisma.envGeneratedExam.findMany({
|
||||
this.prisma.examEnvironmentGeneratedExam.findMany({
|
||||
where: {
|
||||
// Find generated exams user has not already seen
|
||||
examId: exam.id,
|
||||
@@ -340,7 +388,8 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
|
||||
if (maybeGeneratedExams.hasError) {
|
||||
logger.error({ generatedExamsError: maybeGeneratedExams.error });
|
||||
logger.error(maybeGeneratedExams.error);
|
||||
this.Sentry.captureException(maybeGeneratedExams.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(maybeGeneratedExams.error)
|
||||
@@ -350,17 +399,21 @@ async function postExamGeneratedExamHandler(
|
||||
const generatedExams = maybeGeneratedExams.data;
|
||||
|
||||
if (generatedExams.length === 0) {
|
||||
const errMessage = `Unable to provide a generated exam. Either all generated exams have been exhausted, or all generated exams are deprecated.`;
|
||||
logger.error({ examId: exam.id }, errMessage);
|
||||
const error = {
|
||||
data: { examId: exam.id },
|
||||
message: `Unable to provide a generated exam. Either all generated exams have been exhausted, or all generated exams are deprecated.`
|
||||
};
|
||||
logger.error(error.data, error.message);
|
||||
this.Sentry.captureException(error);
|
||||
void reply.code(500);
|
||||
return reply.send(ERRORS.FCC_ERR_EXAM_ENVIRONMENT(errMessage));
|
||||
return reply.send(ERRORS.FCC_ERR_EXAM_ENVIRONMENT(error.message));
|
||||
}
|
||||
|
||||
const randomGeneratedExam =
|
||||
generatedExams[Math.floor(Math.random() * generatedExams.length)]!;
|
||||
|
||||
const maybeGeneratedExam = await mapErr(
|
||||
this.prisma.envGeneratedExam.findFirst({
|
||||
this.prisma.examEnvironmentGeneratedExam.findFirst({
|
||||
where: {
|
||||
id: randomGeneratedExam.id
|
||||
}
|
||||
@@ -368,7 +421,8 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
|
||||
if (maybeGeneratedExam.hasError) {
|
||||
logger.error({ generatedExamError: maybeGeneratedExam.error });
|
||||
logger.error(maybeGeneratedExam.error);
|
||||
this.Sentry.captureException(maybeGeneratedExam.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
// TODO: Consider more specific code
|
||||
@@ -382,32 +436,32 @@ async function postExamGeneratedExamHandler(
|
||||
const generatedExam = maybeGeneratedExam.data;
|
||||
|
||||
if (generatedExam === null) {
|
||||
logger.error(
|
||||
{ generatedExamId: randomGeneratedExam.id },
|
||||
'Generated exam not found.'
|
||||
);
|
||||
const error = {
|
||||
data: { generatedExamId: randomGeneratedExam.id },
|
||||
message: 'Unreachable. Generated exam not found.'
|
||||
};
|
||||
logger.error(error.data, 'Unreachable. Generated exam not found.');
|
||||
this.Sentry.captureException(error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(`Unable to locate generated exam.`)
|
||||
);
|
||||
return reply.send(ERRORS.FCC_ERR_EXAM_ENVIRONMENT(error.message));
|
||||
}
|
||||
|
||||
// Create exam attempt so, even if user disconnects, their attempt is still recorded:
|
||||
const attempt = await mapErr(
|
||||
this.prisma.envExamAttempt.create({
|
||||
this.prisma.examEnvironmentExamAttempt.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
examId: exam.id,
|
||||
generatedExamId: generatedExam.id,
|
||||
startTimeInMS: Date.now(),
|
||||
questionSets: [],
|
||||
needsRetake: false
|
||||
questionSets: []
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (attempt.hasError) {
|
||||
logger.error({ attemptError: attempt.error });
|
||||
logger.error(attempt.error);
|
||||
this.Sentry.captureException(attempt.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT_CREATE_EXAM_ATTEMPT(
|
||||
@@ -422,12 +476,14 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
|
||||
if (maybeUserExam.hasError) {
|
||||
logger.error({ userExamError: maybeUserExam.error });
|
||||
await this.prisma.envExamAttempt.delete({
|
||||
logger.error(maybeUserExam.error);
|
||||
// TODO: Consider handling this failing
|
||||
await this.prisma.examEnvironmentExamAttempt.delete({
|
||||
where: {
|
||||
id: attempt.data.id
|
||||
}
|
||||
});
|
||||
this.Sentry.captureException(maybeUserExam.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeUserExam.error))
|
||||
@@ -465,7 +521,7 @@ async function postExamAttemptHandler(
|
||||
const user = req.user!;
|
||||
|
||||
const maybeAttempts = await mapErr(
|
||||
this.prisma.envExamAttempt.findMany({
|
||||
this.prisma.examEnvironmentExamAttempt.findMany({
|
||||
where: {
|
||||
examId: attempt.examId,
|
||||
userId: user.id
|
||||
@@ -474,10 +530,8 @@ async function postExamAttemptHandler(
|
||||
);
|
||||
|
||||
if (maybeAttempts.hasError) {
|
||||
logger.error(
|
||||
{ error: maybeAttempts.error },
|
||||
'User attempt cannot be linked to an exam attempt.'
|
||||
);
|
||||
logger.error(maybeAttempts.error);
|
||||
this.Sentry.captureException(maybeAttempts.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempts.error))
|
||||
@@ -501,15 +555,18 @@ async function postExamAttemptHandler(
|
||||
);
|
||||
|
||||
const maybeExam = await mapErr(
|
||||
this.prisma.envExam.findUnique({
|
||||
this.prisma.examEnvironmentExam.findUnique({
|
||||
where: {
|
||||
id: attempt.examId
|
||||
},
|
||||
select: {
|
||||
config: true
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeExam.hasError) {
|
||||
logger.error({ examError: maybeExam.error });
|
||||
logger.error(maybeExam.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExam.error))
|
||||
@@ -544,7 +601,7 @@ async function postExamAttemptHandler(
|
||||
|
||||
// Get generated exam from database
|
||||
const maybeGeneratedExam = await mapErr(
|
||||
this.prisma.envGeneratedExam.findUnique({
|
||||
this.prisma.examEnvironmentGeneratedExam.findUnique({
|
||||
where: {
|
||||
id: latestAttempt.generatedExamId
|
||||
}
|
||||
@@ -552,7 +609,7 @@ async function postExamAttemptHandler(
|
||||
);
|
||||
|
||||
if (maybeGeneratedExam.hasError) {
|
||||
logger.error({ generatedExamError: maybeGeneratedExam.error });
|
||||
logger.error(maybeGeneratedExam.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeGeneratedExam.error))
|
||||
@@ -583,26 +640,19 @@ async function postExamAttemptHandler(
|
||||
validateAttempt(generatedExam, databaseAttemptQuestionSets)
|
||||
);
|
||||
|
||||
// Update attempt in database
|
||||
const maybeUpdatedAttempt = await mapErr(
|
||||
this.prisma.envExamAttempt.update({
|
||||
where: {
|
||||
id: latestAttempt.id
|
||||
},
|
||||
data: {
|
||||
questionSets: databaseAttemptQuestionSets,
|
||||
// If attempt is not valid, immediately flag attempt as needing retake
|
||||
// TODO: If `needsRetake`, prevent further submissions?
|
||||
needsRetake: maybeValidExamAttempt.hasError ? true : undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeValidExamAttempt.hasError) {
|
||||
logger.warn(
|
||||
{ validExamAttemptError: maybeValidExamAttempt.error },
|
||||
'Invalid exam attempt.'
|
||||
);
|
||||
// As attempt is invalid, create moderation record to investigate
|
||||
await this.prisma.examEnvironmentExamModeration.create({
|
||||
data: {
|
||||
examAttemptId: latestAttempt.id,
|
||||
status: ExamEnvironmentExamModerationStatus.Pending
|
||||
}
|
||||
});
|
||||
|
||||
void reply.code(400);
|
||||
const message =
|
||||
maybeValidExamAttempt.error instanceof Error
|
||||
@@ -611,6 +661,18 @@ async function postExamAttemptHandler(
|
||||
return reply.send(ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT(message));
|
||||
}
|
||||
|
||||
// Update attempt in database
|
||||
const maybeUpdatedAttempt = await mapErr(
|
||||
this.prisma.examEnvironmentExamAttempt.update({
|
||||
where: {
|
||||
id: latestAttempt.id
|
||||
},
|
||||
data: {
|
||||
questionSets: databaseAttemptQuestionSets
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeUpdatedAttempt.hasError) {
|
||||
logger.error({ updatedAttemptError: maybeUpdatedAttempt.error });
|
||||
void reply.code(500);
|
||||
@@ -657,28 +719,26 @@ async function postScreenshotHandler(
|
||||
);
|
||||
}
|
||||
|
||||
const maybeAttempt = await mapErr(
|
||||
this.prisma.envExamAttempt.findMany({
|
||||
const maybeAttempts = await mapErr(
|
||||
this.prisma.examEnvironmentExamAttempt.findMany({
|
||||
where: {
|
||||
userId: user.id
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeAttempt.hasError) {
|
||||
logger.error(
|
||||
{ error: maybeAttempt.error },
|
||||
'User screenshot cannot be linked to an exam attempt.'
|
||||
);
|
||||
if (maybeAttempts.hasError) {
|
||||
logger.error(maybeAttempts.error);
|
||||
this.Sentry.captureException(maybeAttempts.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempt.error))
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempts.error))
|
||||
);
|
||||
}
|
||||
|
||||
const attempt = maybeAttempt.data;
|
||||
const attempts = maybeAttempts.data;
|
||||
|
||||
if (attempt.length === 0) {
|
||||
if (attempts.length === 0) {
|
||||
logger.warn('No exam attempts found for user.');
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
@@ -688,6 +748,64 @@ async function postScreenshotHandler(
|
||||
);
|
||||
}
|
||||
|
||||
const latestAttempt = attempts.reduce((latest, current) =>
|
||||
latest.startTimeInMS > current.startTimeInMS ? latest : current
|
||||
);
|
||||
|
||||
const maybeExam = await mapErr(
|
||||
this.prisma.examEnvironmentExam.findUnique({
|
||||
where: {
|
||||
id: latestAttempt.examId
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
config: true
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeExam.hasError) {
|
||||
logger.error(maybeExam.error);
|
||||
this.Sentry.captureException(maybeExam.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExam.error))
|
||||
);
|
||||
}
|
||||
|
||||
const exam = maybeExam.data;
|
||||
|
||||
if (exam === null) {
|
||||
const error = {
|
||||
data: {
|
||||
examId: latestAttempt.examId,
|
||||
attemptId: latestAttempt.id
|
||||
},
|
||||
message: 'Unreachable. Attempt could not be related to an exam.'
|
||||
};
|
||||
logger.error(error.data, error.message);
|
||||
this.Sentry.captureException(error.data);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM(error.message)
|
||||
);
|
||||
}
|
||||
|
||||
const isAttemptExpired =
|
||||
latestAttempt.startTimeInMS + exam.config.totalTimeInMS < Date.now();
|
||||
if (isAttemptExpired) {
|
||||
logger.warn(
|
||||
{ examAttemptId: latestAttempt.id },
|
||||
'Attempt has exceeded submission time.'
|
||||
);
|
||||
void reply.code(403);
|
||||
return reply.send(
|
||||
ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT(
|
||||
'Attempt has exceeded submission time.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const imgBinary = await imgData.toBuffer();
|
||||
|
||||
// Verify image is JPG using magic number
|
||||
@@ -703,7 +821,7 @@ async function postScreenshotHandler(
|
||||
|
||||
const uploadData = {
|
||||
image: imgBinary.toString('base64'),
|
||||
examAttemptId: attempt[0]?.id
|
||||
examAttemptId: latestAttempt.id
|
||||
};
|
||||
|
||||
await fetch(`${SCREENSHOT_SERVICE_LOCATION}/upload`, {
|
||||
@@ -724,16 +842,29 @@ async function getExams(
|
||||
logger.info({ user: req.user });
|
||||
|
||||
const user = req.user!;
|
||||
const exams = await this.prisma.envExam.findMany({
|
||||
where: {
|
||||
deprecated: false
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
config: true,
|
||||
prerequisites: true
|
||||
}
|
||||
});
|
||||
const maybeExams = await mapErr(
|
||||
this.prisma.examEnvironmentExam.findMany({
|
||||
where: {
|
||||
deprecated: false
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
config: true,
|
||||
prerequisites: true
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeExams.hasError) {
|
||||
logger.error(maybeExams.error);
|
||||
this.Sentry.captureException(maybeExams.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExams.error))
|
||||
);
|
||||
}
|
||||
|
||||
const exams = maybeExams.data;
|
||||
|
||||
const availableExams = exams.map(exam => {
|
||||
const isExamPrerequisitesMet = checkPrerequisites(user, exam.prerequisites);
|
||||
@@ -744,7 +875,8 @@ async function getExams(
|
||||
name: exam.config.name,
|
||||
note: exam.config.note,
|
||||
totalTimeInMS: exam.config.totalTimeInMS,
|
||||
retakeTimeInMS: exam.config.retakeTimeInMS
|
||||
retakeTimeInMS: exam.config.retakeTimeInMS,
|
||||
passingPercent: exam.config.passingPercent
|
||||
},
|
||||
canTake: isExamPrerequisitesMet
|
||||
};
|
||||
@@ -754,3 +886,122 @@ async function getExams(
|
||||
exams: availableExams
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all exam attempts owned by authz user.
|
||||
*
|
||||
* If an attempt is completed, the result is included.
|
||||
*/
|
||||
async function getExamAttemptsHandler(
|
||||
this: FastifyInstance,
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentGetExamAttempts>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const logger = this.log.child({ req });
|
||||
logger.info({ userId: req.user?.id });
|
||||
|
||||
const user = req.user!;
|
||||
|
||||
// Send all relevant exam attempts
|
||||
const envExamAttempts = [];
|
||||
const maybeAttempts = await mapErr(
|
||||
this.prisma.examEnvironmentExamAttempt.findMany({
|
||||
where: {
|
||||
userId: user.id
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeAttempts.hasError) {
|
||||
logger.error(maybeAttempts.error);
|
||||
this.Sentry.captureException(maybeAttempts.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempts.error))
|
||||
);
|
||||
}
|
||||
|
||||
const attempts = maybeAttempts.data;
|
||||
|
||||
if (!attempts.length) {
|
||||
logger.warn({ userId: user.id }, 'No exam attempts found.');
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_EXAM_ATTEMPT('No exam attempt found.')
|
||||
);
|
||||
}
|
||||
|
||||
for (const attempt of attempts) {
|
||||
const { error, examEnvironmentExamAttempt } = await constructEnvExamAttempt(
|
||||
this,
|
||||
attempt,
|
||||
logger
|
||||
);
|
||||
if (error) {
|
||||
void reply.code(error.code);
|
||||
return reply.send(error.data);
|
||||
}
|
||||
envExamAttempts.push(examEnvironmentExamAttempt);
|
||||
}
|
||||
|
||||
return reply.send(envExamAttempts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the requested exam attempt by id owned by authz user.
|
||||
*
|
||||
* If the attempt is completed, the result is included.
|
||||
*/
|
||||
async function getExamAttemptHandler(
|
||||
this: FastifyInstance,
|
||||
req: UpdateReqType<typeof schemas.examEnvironmentGetExamAttempt>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const logger = this.log.child({ req });
|
||||
logger.info({ userId: req.user?.id });
|
||||
|
||||
const user = req.user!;
|
||||
const { attemptId } = req.params;
|
||||
|
||||
// If attempt id is given, only return that attempt
|
||||
const maybeAttempt = await mapErr(
|
||||
this.prisma.examEnvironmentExamAttempt.findUnique({
|
||||
where: {
|
||||
id: attemptId,
|
||||
userId: user.id
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeAttempt.hasError) {
|
||||
logger.error(maybeAttempt.error);
|
||||
this.Sentry.captureException(maybeAttempt.error);
|
||||
void reply.code(500);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempt.error))
|
||||
);
|
||||
}
|
||||
|
||||
const attempt = maybeAttempt.data;
|
||||
|
||||
if (!attempt) {
|
||||
logger.warn({ attemptId }, 'No exam attempt found.');
|
||||
void reply.code(404);
|
||||
return reply.send(
|
||||
ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_EXAM_ATTEMPT('No exam attempt found.')
|
||||
);
|
||||
}
|
||||
|
||||
const { error, examEnvironmentExamAttempt } = await constructEnvExamAttempt(
|
||||
this,
|
||||
attempt,
|
||||
logger
|
||||
);
|
||||
|
||||
if (error) {
|
||||
void reply.code(error.code);
|
||||
return reply.send(error.data);
|
||||
}
|
||||
|
||||
return reply.send(examEnvironmentExamAttempt);
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { STANDARD_ERROR } from '../utils/errors';
|
||||
|
||||
export const examEnvironmentPostExamAttempt = {
|
||||
body: Type.Object({
|
||||
attempt: Type.Object({
|
||||
examId: Type.String(),
|
||||
questionSets: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
questions: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
answers: Type.Array(Type.String())
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
}),
|
||||
headers: Type.Object({
|
||||
'exam-environment-authorization-token': Type.String()
|
||||
}),
|
||||
response: {
|
||||
// An empty 200 response cannot be typed 🤷♂️
|
||||
400: STANDARD_ERROR,
|
||||
403: STANDARD_ERROR,
|
||||
404: STANDARD_ERROR,
|
||||
500: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { STANDARD_ERROR } from '../utils/errors';
|
||||
|
||||
export const examEnvironmentPostExamAttempt = {
|
||||
body: Type.Object({
|
||||
attempt: Type.Object({
|
||||
examId: Type.String({ format: 'objectid' }),
|
||||
questionSets: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String({ format: 'objectid' }),
|
||||
questions: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String({ format: 'objectid' }),
|
||||
answers: Type.Array(Type.String({ format: 'objectid' }))
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
}),
|
||||
headers: Type.Object({
|
||||
'exam-environment-authorization-token': Type.String()
|
||||
}),
|
||||
response: {
|
||||
default: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
|
||||
const examEnvAttempt = Type.Object({
|
||||
startTimeInMS: Type.Number(),
|
||||
questionSets: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
questions: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
answers: Type.Array(Type.String()),
|
||||
submissionTimeInMS: Type.Number()
|
||||
})
|
||||
)
|
||||
})
|
||||
),
|
||||
result: Type.Union([
|
||||
Type.Null(),
|
||||
Type.Object({
|
||||
score: Type.Number(),
|
||||
passingPercent: Type.Number()
|
||||
})
|
||||
])
|
||||
});
|
||||
|
||||
export const examEnvironmentGetExamAttempts = {
|
||||
headers: Type.Object({
|
||||
'exam-environment-authorization-token': Type.String()
|
||||
}),
|
||||
response: {
|
||||
200: Type.Array(examEnvAttempt),
|
||||
default: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
|
||||
export const examEnvironmentGetExamAttempt = {
|
||||
params: Type.Object({
|
||||
attemptId: Type.String({ format: 'objectid' })
|
||||
}),
|
||||
headers: Type.Object({
|
||||
'exam-environment-authorization-token': Type.String()
|
||||
}),
|
||||
response: {
|
||||
200: examEnvAttempt,
|
||||
default: STANDARD_ERROR
|
||||
}
|
||||
};
|
||||
+2
-1
@@ -13,7 +13,8 @@ export const examEnvironmentExams = {
|
||||
name: Type.String(),
|
||||
note: Type.String(),
|
||||
totalTimeInMS: Type.Number(),
|
||||
retakeTimeInMS: Type.Number()
|
||||
retakeTimeInMS: Type.Number(),
|
||||
passingPercent: Type.Number()
|
||||
}),
|
||||
canTake: Type.Boolean()
|
||||
})
|
||||
@@ -1,5 +1,9 @@
|
||||
export { examEnvironmentPostExamAttempt } from './exam-attempt';
|
||||
export { examEnvironmentPostExamGeneratedExam } from './exam-generated-exam';
|
||||
export {
|
||||
examEnvironmentPostExamAttempt,
|
||||
examEnvironmentGetExamAttempts,
|
||||
examEnvironmentGetExamAttempt
|
||||
} from './exam-environment-exam-attempt';
|
||||
export { examEnvironmentPostExamGeneratedExam } from './exam-environment-exam-generated-exam';
|
||||
export { examEnvironmentPostScreenshot } from './screenshot';
|
||||
export { examEnvironmentTokenMeta } from './token-meta';
|
||||
export { examEnvironmentExams } from './exams';
|
||||
export { examEnvironmentExams } from './exam-environment-exams';
|
||||
|
||||
@@ -27,6 +27,10 @@ export const ERRORS = {
|
||||
'FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
'%s'
|
||||
),
|
||||
FCC_ENOENT_EXAM_ENVIRONMENT_EXAM_ATTEMPT: createError(
|
||||
'FCC_ENOENT_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
'%s'
|
||||
),
|
||||
FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT: createError(
|
||||
'FCC_ERR_EXAM_ENVIRONMENT_EXAM_ATTEMPT',
|
||||
'%s'
|
||||
|
||||
+79
-3
@@ -1,5 +1,10 @@
|
||||
import { ExamEnvironmentAnswer } from '@prisma/client';
|
||||
import { type Static } from '@fastify/type-provider-typebox';
|
||||
import { exam, examAttempt, generatedExam } from '../../../__mocks__/env-exam';
|
||||
import {
|
||||
exam,
|
||||
examAttempt,
|
||||
generatedExam
|
||||
} from '../../../__mocks__/exam-environment-exam';
|
||||
import * as schemas from '../schemas';
|
||||
import {
|
||||
checkAttemptAgainstGeneratedExam,
|
||||
@@ -7,8 +12,9 @@ import {
|
||||
constructUserExam,
|
||||
generateExam,
|
||||
userAttemptToDatabaseAttemptQuestionSets,
|
||||
validateAttempt
|
||||
} from './exam';
|
||||
validateAttempt,
|
||||
compareAnswers
|
||||
} from './exam-environment';
|
||||
|
||||
// NOTE: Whilst the tests could be run against a single generation of exam,
|
||||
// it is more useful to run the tests against a new generation each time.
|
||||
@@ -308,4 +314,74 @@ describe('Exam Environment', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareAnswers()', () => {
|
||||
it('should return true when only all correct answers are attempted', () => {
|
||||
const examAnswers: ExamEnvironmentAnswer[] = [
|
||||
{
|
||||
id: '0',
|
||||
isCorrect: true,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
isCorrect: true,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
isCorrect: false,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
isCorrect: false,
|
||||
text: ''
|
||||
}
|
||||
];
|
||||
const generatedAnswers = ['0', '1', '2', '3'];
|
||||
const attemptAnswers = ['0', '1'];
|
||||
const isCorrect = compareAnswers(
|
||||
examAnswers,
|
||||
generatedAnswers,
|
||||
attemptAnswers
|
||||
);
|
||||
|
||||
expect(isCorrect).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when any incorrect answers are attempted', () => {
|
||||
const examAnswers: ExamEnvironmentAnswer[] = [
|
||||
{
|
||||
id: '0',
|
||||
isCorrect: true,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
isCorrect: true,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
isCorrect: false,
|
||||
text: ''
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
isCorrect: false,
|
||||
text: ''
|
||||
}
|
||||
];
|
||||
const generatedAnswers = ['0', '1', '2', '3'];
|
||||
const attemptAnswers = ['0', '2'];
|
||||
const isCorrect = compareAnswers(
|
||||
examAnswers,
|
||||
generatedAnswers,
|
||||
attemptAnswers
|
||||
);
|
||||
|
||||
expect(isCorrect).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+321
-32
@@ -2,17 +2,24 @@
|
||||
/* eslint-disable @typescript-eslint/only-throw-error */
|
||||
/* eslint-disable jsdoc/require-returns, jsdoc/require-param */
|
||||
import {
|
||||
EnvAnswer,
|
||||
EnvConfig,
|
||||
EnvExam,
|
||||
EnvExamAttempt,
|
||||
EnvGeneratedExam,
|
||||
EnvMultipleChoiceQuestion,
|
||||
EnvQuestionSet,
|
||||
EnvQuestionSetAttempt
|
||||
ExamEnvironmentAnswer,
|
||||
ExamEnvironmentConfig,
|
||||
ExamEnvironmentExam,
|
||||
ExamEnvironmentExamAttempt,
|
||||
ExamEnvironmentExamModerationStatus,
|
||||
ExamEnvironmentGeneratedExam,
|
||||
ExamEnvironmentGeneratedMultipleChoiceQuestion,
|
||||
ExamEnvironmentMultipleChoiceQuestion,
|
||||
ExamEnvironmentMultipleChoiceQuestionAttempt,
|
||||
ExamEnvironmentQuestionSet,
|
||||
ExamEnvironmentQuestionSetAttempt
|
||||
} from '@prisma/client';
|
||||
import type { FastifyBaseLogger, FastifyInstance } from 'fastify';
|
||||
import { type Static } from '@fastify/type-provider-typebox';
|
||||
import { omit } from 'lodash';
|
||||
import * as schemas from '../schemas';
|
||||
import { mapErr } from '../../utils';
|
||||
import { ERRORS } from './errors';
|
||||
|
||||
interface CompletedChallengeId {
|
||||
completedChallenges: {
|
||||
@@ -25,7 +32,7 @@ interface CompletedChallengeId {
|
||||
*/
|
||||
export function checkPrerequisites(
|
||||
user: CompletedChallengeId,
|
||||
prerequisites: EnvExam['prerequisites']
|
||||
prerequisites: ExamEnvironmentExam['prerequisites']
|
||||
) {
|
||||
return prerequisites.every(p =>
|
||||
user.completedChallenges.some(c => c.id === p)
|
||||
@@ -33,16 +40,16 @@ export function checkPrerequisites(
|
||||
}
|
||||
|
||||
export type UserExam = Omit<
|
||||
EnvExam,
|
||||
ExamEnvironmentExam,
|
||||
'questionSets' | 'config' | 'id' | 'prerequisites' | 'deprecated'
|
||||
> & {
|
||||
config: Omit<EnvExam['config'], 'tags' | 'questionSets'>;
|
||||
questionSets: (Omit<EnvQuestionSet, 'questions'> & {
|
||||
config: Omit<ExamEnvironmentExam['config'], 'tags' | 'questionSets'>;
|
||||
questionSets: (Omit<ExamEnvironmentQuestionSet, 'questions'> & {
|
||||
questions: (Omit<
|
||||
EnvMultipleChoiceQuestion,
|
||||
ExamEnvironmentMultipleChoiceQuestion,
|
||||
'answers' | 'tags' | 'deprecated'
|
||||
> & {
|
||||
answers: Omit<EnvAnswer, 'isCorrect'>[];
|
||||
answers: Omit<ExamEnvironmentAnswer, 'isCorrect'>[];
|
||||
})[];
|
||||
})[];
|
||||
} & { generatedExamId: string; examId: string };
|
||||
@@ -51,8 +58,8 @@ export type UserExam = Omit<
|
||||
* Takes the generated exam and the original exam, and creates the user-facing exam.
|
||||
*/
|
||||
export function constructUserExam(
|
||||
generatedExam: EnvGeneratedExam,
|
||||
exam: EnvExam
|
||||
generatedExam: ExamEnvironmentGeneratedExam,
|
||||
exam: ExamEnvironmentExam
|
||||
): UserExam {
|
||||
// Map generated exam to user exam (a.k.a. public exam information for user)
|
||||
const userQuestionSets = generatedExam.questionSets.map(gqs => {
|
||||
@@ -104,7 +111,8 @@ export function constructUserExam(
|
||||
totalTimeInMS: exam.config.totalTimeInMS,
|
||||
name: exam.config.name,
|
||||
note: exam.config.note,
|
||||
retakeTimeInMS: exam.config.retakeTimeInMS
|
||||
retakeTimeInMS: exam.config.retakeTimeInMS,
|
||||
passingPercent: exam.config.passingPercent
|
||||
};
|
||||
|
||||
const userExam: UserExam = {
|
||||
@@ -121,8 +129,8 @@ export function constructUserExam(
|
||||
* Ensures all questions and answers in the attempt are from the generated exam.
|
||||
*/
|
||||
export function validateAttempt(
|
||||
generatedExam: EnvGeneratedExam,
|
||||
questionSets: EnvExamAttempt['questionSets']
|
||||
generatedExam: ExamEnvironmentGeneratedExam,
|
||||
questionSets: ExamEnvironmentExamAttempt['questionSets']
|
||||
) {
|
||||
for (const attemptQuestionSet of questionSets) {
|
||||
const generatedQuestionSet = generatedExam.questionSets.find(
|
||||
@@ -170,8 +178,8 @@ export function validateAttempt(
|
||||
* @returns Whether or not the attempt can be considered finished.
|
||||
*/
|
||||
export function checkAttemptAgainstGeneratedExam(
|
||||
questionSets: EnvQuestionSetAttempt[],
|
||||
generatedExam: Pick<EnvGeneratedExam, 'questionSets'>
|
||||
questionSets: ExamEnvironmentQuestionSetAttempt[],
|
||||
generatedExam: Pick<ExamEnvironmentGeneratedExam, 'questionSets'>
|
||||
): boolean {
|
||||
// Check all question sets and questions are in generated exam
|
||||
for (const generatedQuestionSet of generatedExam.questionSets) {
|
||||
@@ -215,9 +223,10 @@ export function userAttemptToDatabaseAttemptQuestionSets(
|
||||
userAttempt: Static<
|
||||
typeof schemas.examEnvironmentPostExamAttempt.body.properties.attempt
|
||||
>,
|
||||
latestAttempt: EnvExamAttempt
|
||||
): EnvExamAttempt['questionSets'] {
|
||||
const databaseAttemptQuestionSets: EnvExamAttempt['questionSets'] = [];
|
||||
latestAttempt: ExamEnvironmentExamAttempt
|
||||
): ExamEnvironmentExamAttempt['questionSets'] {
|
||||
const databaseAttemptQuestionSets: ExamEnvironmentExamAttempt['questionSets'] =
|
||||
[];
|
||||
|
||||
for (const questionSet of userAttempt.questionSets) {
|
||||
const latestQuestionSet = latestAttempt.questionSets.find(
|
||||
@@ -266,7 +275,9 @@ export function userAttemptToDatabaseAttemptQuestionSets(
|
||||
/**
|
||||
* Generates an exam for the user, based on the exam configuration.
|
||||
*/
|
||||
export function generateExam(exam: EnvExam): Omit<EnvGeneratedExam, 'id'> {
|
||||
export function generateExam(
|
||||
exam: ExamEnvironmentExam
|
||||
): Omit<ExamEnvironmentGeneratedExam, 'id'> {
|
||||
const examCopy = structuredClone(exam);
|
||||
|
||||
const TIMEOUT_IN_MS = 5_000;
|
||||
@@ -298,7 +309,7 @@ export function generateExam(exam: EnvExam): Omit<EnvGeneratedExam, 'id'> {
|
||||
acc[typeIndex]?.push(curr) ?? acc.push([curr]);
|
||||
return acc;
|
||||
},
|
||||
[] as unknown as [EnvConfig['questionSets']]
|
||||
[] as unknown as [ExamEnvironmentConfig['questionSets']]
|
||||
);
|
||||
|
||||
// Heuristic:
|
||||
@@ -316,7 +327,7 @@ export function generateExam(exam: EnvExam): Omit<EnvGeneratedExam, 'id'> {
|
||||
const questionSetsConfigWithQuestions = sortedQuestionSetsConfig.map(qsc => {
|
||||
return {
|
||||
...qsc,
|
||||
questionSets: [] as EnvQuestionSet[]
|
||||
questionSets: [] as ExamEnvironmentQuestionSet[]
|
||||
};
|
||||
});
|
||||
|
||||
@@ -575,9 +586,107 @@ export function generateExam(exam: EnvExam): Omit<EnvGeneratedExam, 'id'> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the number of correct questions over the number of the total questions given for an attempt.
|
||||
* @returns The score of the exam attempt as a percentage.
|
||||
*/
|
||||
export function calculateScore(
|
||||
exam: ExamEnvironmentExam,
|
||||
generatedExam: ExamEnvironmentGeneratedExam,
|
||||
attempt: ExamEnvironmentExamAttempt
|
||||
) {
|
||||
const attemptQuestionSets = attempt.questionSets;
|
||||
const generatedQuestionSets = generatedExam.questionSets;
|
||||
|
||||
const totalQuestions = generatedQuestionSets.reduce(
|
||||
(total, attemptQuestionSet) => total + attemptQuestionSet.questions.length,
|
||||
0
|
||||
);
|
||||
let correctQuestions = 0;
|
||||
for (const attemptQuestionSet of attemptQuestionSets) {
|
||||
const examQuestionSet = exam.questionSets.find(
|
||||
({ id }) => id === attemptQuestionSet.id
|
||||
);
|
||||
if (!examQuestionSet) {
|
||||
throw new Error(
|
||||
`Attempt question set ${attemptQuestionSet.id} must exist in exam ${exam.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const generatedQuestionSet = generatedQuestionSets.find(
|
||||
({ id }) => id === attemptQuestionSet.id
|
||||
);
|
||||
if (!generatedQuestionSet) {
|
||||
throw new Error(
|
||||
`Generated question set ${attemptQuestionSet.id} must exist in generated exam ${generatedExam.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const attemptQuestions = attemptQuestionSet.questions;
|
||||
const examQuestions = examQuestionSet.questions;
|
||||
const generatedQuestions = generatedQuestionSet.questions;
|
||||
for (const attemptQuestion of attemptQuestions) {
|
||||
const examQuestion = examQuestions.find(
|
||||
({ id }) => id === attemptQuestion.id
|
||||
);
|
||||
if (!examQuestion) {
|
||||
throw new Error(
|
||||
`Attempt question ${attemptQuestion.id} must exist in exam ${exam.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const generatedQuestion = generatedQuestions.find(
|
||||
({ id }) => id === attemptQuestion.id
|
||||
);
|
||||
if (!generatedQuestion) {
|
||||
throw new Error(
|
||||
`Generated question ${attemptQuestion.id} must exist in generated exam ${generatedExam.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const isQuestionCorrect = compareAnswers(
|
||||
examQuestion.answers,
|
||||
generatedQuestion.answers,
|
||||
attemptQuestion.answers
|
||||
);
|
||||
|
||||
if (isQuestionCorrect) {
|
||||
correctQuestions += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (correctQuestions / totalQuestions) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: The answers of an attempt is an array for future-proofing when
|
||||
* checkbox questions are needed.
|
||||
*
|
||||
* This calculation takes x / y , x < y as wholey incorrect.
|
||||
*/
|
||||
export function compareAnswers(
|
||||
examAnswers: ExamEnvironmentAnswer[],
|
||||
generatedAnswers: ExamEnvironmentGeneratedMultipleChoiceQuestion['answers'],
|
||||
attemptAnswers: ExamEnvironmentMultipleChoiceQuestionAttempt['answers']
|
||||
): boolean {
|
||||
const correctGeneratedAnswers = generatedAnswers.filter(generatedAnswer => {
|
||||
return examAnswers.some(
|
||||
examAnswer => examAnswer.isCorrect && examAnswer.id === generatedAnswer
|
||||
);
|
||||
});
|
||||
// Check every attempt question answer == every generated question answer
|
||||
const isQuestionCorrect =
|
||||
correctGeneratedAnswers.every(correctAnswer =>
|
||||
attemptAnswers.includes(correctAnswer)
|
||||
) && correctGeneratedAnswers.length == attemptAnswers.length;
|
||||
|
||||
return isQuestionCorrect;
|
||||
}
|
||||
|
||||
function isQuestionSetConfigFulfilled(
|
||||
questionSetConfig: EnvConfig['questionSets'][number] & {
|
||||
questionSets: EnvQuestionSet[];
|
||||
questionSetConfig: ExamEnvironmentConfig['questionSets'][number] & {
|
||||
questionSets: ExamEnvironmentQuestionSet[];
|
||||
}
|
||||
) {
|
||||
return (
|
||||
@@ -592,9 +701,9 @@ function isQuestionSetConfigFulfilled(
|
||||
* Gets random answers for a question.
|
||||
*/
|
||||
function getRandomAnswers(
|
||||
question: EnvMultipleChoiceQuestion,
|
||||
questionSetConfig: EnvConfig['questionSets'][number]
|
||||
): EnvMultipleChoiceQuestion['answers'] {
|
||||
question: ExamEnvironmentMultipleChoiceQuestion,
|
||||
questionSetConfig: ExamEnvironmentConfig['questionSets'][number]
|
||||
): ExamEnvironmentMultipleChoiceQuestion['answers'] {
|
||||
const { numberOfCorrectAnswers, numberOfIncorrectAnswers } =
|
||||
questionSetConfig;
|
||||
|
||||
@@ -641,3 +750,183 @@ function shuffleArray<T>(array: Array<T>) {
|
||||
return array;
|
||||
}
|
||||
/* eslint-enable jsdoc/require-description-complete-sentence */
|
||||
|
||||
/**
|
||||
* From an exam attempt, construct the attempt with result (if ready).
|
||||
*
|
||||
* @param fastify - Fastify instance.
|
||||
* @param attempt - The exam attempt.
|
||||
* @param logger - Logger instance.
|
||||
* @returns The exam attempt with result or an error.
|
||||
*/
|
||||
export async function constructEnvExamAttempt(
|
||||
fastify: FastifyInstance,
|
||||
attempt: ExamEnvironmentExamAttempt,
|
||||
logger: FastifyBaseLogger
|
||||
) {
|
||||
const maybeExam = await mapErr(
|
||||
fastify.prisma.examEnvironmentExam.findUnique({
|
||||
where: {
|
||||
id: attempt.examId
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeExam.hasError) {
|
||||
logger.error(maybeExam.error);
|
||||
fastify.Sentry.captureException(maybeExam.error);
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeExam.error))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const exam = maybeExam.data;
|
||||
|
||||
if (exam === null) {
|
||||
const error = {
|
||||
data: { examId: attempt.examId, attemptId: attempt.id },
|
||||
message: 'Unreachable. Invalid exam id in attempt.'
|
||||
};
|
||||
logger.error(error.data, error.message);
|
||||
fastify.Sentry.captureException(error);
|
||||
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
data: ERRORS.FCC_ENOENT_EXAM_ENVIRONMENT_MISSING_EXAM(error.message)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// If attempt is still in progress, return without result
|
||||
const isAttemptExpired =
|
||||
attempt.startTimeInMS + exam.config.totalTimeInMS < Date.now();
|
||||
if (!isAttemptExpired) {
|
||||
return {
|
||||
examEnvironmentExamAttempt: {
|
||||
...omitAttemptReferenceIds(attempt),
|
||||
result: null
|
||||
},
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
const maybeMod = await mapErr(
|
||||
fastify.prisma.examEnvironmentExamModeration.findFirst({
|
||||
where: {
|
||||
examAttemptId: attempt.id
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeMod.hasError) {
|
||||
logger.error(maybeMod.error);
|
||||
fastify.Sentry.captureException(maybeMod.error);
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeMod.error))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const moderation = maybeMod.data;
|
||||
|
||||
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)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// If attempt is completed, but has not been graded, return without result
|
||||
if (moderation.status === ExamEnvironmentExamModerationStatus.Pending) {
|
||||
return {
|
||||
examEnvironmentExamAttempt: {
|
||||
...omitAttemptReferenceIds(attempt),
|
||||
result: null
|
||||
},
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
// If attempt is completed, but has been determined to need a retake
|
||||
// TODO: Send moderation.feedback?
|
||||
if (moderation.status === ExamEnvironmentExamModerationStatus.Denied) {
|
||||
return {
|
||||
examEnvironmentExamAttempt: {
|
||||
...omitAttemptReferenceIds(attempt),
|
||||
result: null
|
||||
},
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
const maybeGeneratedExam = await mapErr(
|
||||
fastify.prisma.examEnvironmentGeneratedExam.findUnique({
|
||||
where: {
|
||||
id: attempt.generatedExamId
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (maybeGeneratedExam.hasError) {
|
||||
logger.error(maybeGeneratedExam.error);
|
||||
fastify.Sentry.captureException(maybeGeneratedExam.error);
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(
|
||||
JSON.stringify(maybeGeneratedExam.error)
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const generatedExam = maybeGeneratedExam.data;
|
||||
|
||||
if (!generatedExam) {
|
||||
const error = {
|
||||
data: { attemptId: attempt.id, generatedExamId: attempt.generatedExamId },
|
||||
message:
|
||||
'Unreachable. Unable to find generated exam associated with exam attempt'
|
||||
};
|
||||
logger.error(error.data, error.message);
|
||||
fastify.Sentry.captureException(error);
|
||||
return {
|
||||
error: {
|
||||
code: 500,
|
||||
data: ERRORS.FCC_ERR_EXAM_ENVIRONMENT(error.message)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const score = calculateScore(exam, generatedExam, attempt);
|
||||
|
||||
const result = {
|
||||
score,
|
||||
passingPercent: exam.config.passingPercent
|
||||
};
|
||||
|
||||
const examEnvironmentExamAttempt = {
|
||||
...omitAttemptReferenceIds(attempt),
|
||||
result
|
||||
};
|
||||
return { error: null, examEnvironmentExamAttempt };
|
||||
}
|
||||
|
||||
function omitAttemptReferenceIds(attempt: ExamEnvironmentExamAttempt) {
|
||||
return omit(attempt, ['examId', 'id', 'generatedExamId', 'userId']);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
seedEnvExam,
|
||||
seedEnvExamAttempt,
|
||||
seedExamEnvExamAuthToken
|
||||
} from '../../../__mocks__/env-exam';
|
||||
} from '../../../__mocks__/exam-environment-exam';
|
||||
import { getMsTranscriptApiUrl } from './user';
|
||||
|
||||
const mockedFetch = jest.fn();
|
||||
@@ -441,13 +441,13 @@ describe('userRoutes', () => {
|
||||
await seedEnvExam();
|
||||
await seedEnvExamAttempt();
|
||||
const countBefore =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.count();
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.count();
|
||||
expect(countBefore).toBe(1);
|
||||
|
||||
const res = await superPost('/account/delete');
|
||||
|
||||
const countAfter =
|
||||
await fastifyTestInstance.prisma.envExamAttempt.count();
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.count();
|
||||
expect(countAfter).toBe(0);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ async function main() {
|
||||
console.info('Connected.');
|
||||
|
||||
try {
|
||||
await prisma.envExam.update({
|
||||
await prisma.examEnvironmentExam.update({
|
||||
where: {
|
||||
id: ENV_EXAM_ID
|
||||
},
|
||||
@@ -31,7 +31,7 @@ async function main() {
|
||||
}
|
||||
});
|
||||
console.info(`Exam "${ENV_EXAM_ID}" deprecated...`);
|
||||
const res = await prisma.envGeneratedExam.updateMany({
|
||||
const res = await prisma.examEnvironmentGeneratedExam.updateMany({
|
||||
where: {
|
||||
examId: ENV_EXAM_ID
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { generateExam } from '../../../src/exam-environment/utils/exam';
|
||||
import { generateExam } from '../../../src/exam-environment/utils/exam-environment';
|
||||
import { MONGOHQ_URL } from '../../../src/utils/env';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
@@ -28,7 +28,7 @@ async function main() {
|
||||
await prisma.$connect();
|
||||
console.info('Connected.');
|
||||
|
||||
const exam = await prisma.envExam.findUnique({
|
||||
const exam = await prisma.examEnvironmentExam.findUnique({
|
||||
where: {
|
||||
id: ENV_EXAM_ID
|
||||
}
|
||||
@@ -46,7 +46,7 @@ async function main() {
|
||||
while (numberOfExamsGenerated < NUMBER_OF_EXAMS_TO_GENERATE) {
|
||||
try {
|
||||
const generatedExam = generateExam(exam);
|
||||
await prisma.envGeneratedExam.create({
|
||||
await prisma.examEnvironmentGeneratedExam.create({
|
||||
data: generatedExam
|
||||
});
|
||||
numberOfExamsGenerated++;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import { EnvExam, PrismaClient } from '@prisma/client';
|
||||
import { ExamEnvironmentExam, PrismaClient } from '@prisma/client';
|
||||
import { MONGOHQ_URL } from '../../../src/utils/env';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
@@ -24,10 +24,10 @@ async function main() {
|
||||
|
||||
const exam_str = await readFile(EXAM_JSON_PATH, 'utf-8');
|
||||
|
||||
const exam = JSON.parse(exam_str) as EnvExam;
|
||||
const exam = JSON.parse(exam_str) as ExamEnvironmentExam;
|
||||
|
||||
try {
|
||||
const res = await prisma.envExam.create({
|
||||
const res = await prisma.examEnvironmentExam.create({
|
||||
data: exam
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as mocks from '../../../__mocks__/env-exam';
|
||||
import * as mocks from '../../../__mocks__/exam-environment-exam';
|
||||
import { MONGOHQ_URL } from '../../../src/utils/env';
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
@@ -13,12 +13,14 @@ const prisma = new PrismaClient({
|
||||
async function main() {
|
||||
await prisma.$connect();
|
||||
|
||||
await prisma.envExamAttempt.deleteMany({});
|
||||
await prisma.envGeneratedExam.deleteMany({});
|
||||
await prisma.envExam.deleteMany({});
|
||||
await prisma.examEnvironmentExamAttempt.deleteMany({});
|
||||
await prisma.examEnvironmentGeneratedExam.deleteMany({});
|
||||
await prisma.examEnvironmentExam.deleteMany({});
|
||||
|
||||
await prisma.envExam.create({ data: mocks.exam });
|
||||
await prisma.envGeneratedExam.create({ data: mocks.generatedExam });
|
||||
await prisma.examEnvironmentExam.create({ data: mocks.exam });
|
||||
await prisma.examEnvironmentGeneratedExam.create({
|
||||
data: mocks.generatedExam
|
||||
});
|
||||
}
|
||||
|
||||
void main();
|
||||
|
||||
@@ -17,13 +17,18 @@ const prisma = new PrismaClient({
|
||||
async function main() {
|
||||
await prisma.$connect();
|
||||
try {
|
||||
const envExam = await prisma.envExam.findMany();
|
||||
const envGeneratedExam = await prisma.envGeneratedExam.findMany();
|
||||
const envExamAttempt = await prisma.envExamAttempt.findMany();
|
||||
const examEnvironmentExam = await prisma.examEnvironmentExam.findMany();
|
||||
const examEnvironmentGeneratedExam =
|
||||
await prisma.examEnvironmentGeneratedExam.findMany();
|
||||
const examEnvironmentExamAttempt =
|
||||
await prisma.examEnvironmentExamAttempt.findMany();
|
||||
|
||||
console.log('Number of exams:', envExam.length);
|
||||
console.log('Number of generated exams:', envGeneratedExam.length);
|
||||
console.log('Number of exam attempts:', envExamAttempt.length);
|
||||
console.log('Number of exams:', examEnvironmentExam.length);
|
||||
console.log(
|
||||
'Number of generated exams:',
|
||||
examEnvironmentGeneratedExam.length
|
||||
);
|
||||
console.log('Number of exam attempts:', examEnvironmentExamAttempt.length);
|
||||
// NOTE: This is not strictly true. E.g. If a `Boolean` becomes an `Int`, Prisma converts it instead of throwing.
|
||||
console.log('\nSUCCESS! The database schema matches the Prisma schema.');
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user