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:
Shaun Hamilton
2025-07-16 18:35:12 +02:00
committed by GitHub
parent 8eee5ca8bf
commit 92b6ca5343
18 changed files with 1333 additions and 316 deletions
@@ -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
View File
@@ -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
}
};
@@ -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()
})
+7 -3
View File
@@ -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';
+4
View File
@@ -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'
@@ -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);
});
});
});
@@ -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']);
}
+3 -3
View File
@@ -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
},
+3 -3
View File
@@ -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
});
+8 -6
View File
@@ -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();
+11 -6
View File
@@ -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) {