mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-19 10:07:46 -05:00
feat(api): exam date use + split prisma files (#62344)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -16,8 +16,9 @@ export const oid = () => new ObjectId().toString();
|
||||
|
||||
export const examId = oid();
|
||||
|
||||
export const config: ExamEnvironmentConfig = {
|
||||
export const config = {
|
||||
totalTimeInMS: 2 * 60 * 60 * 1000,
|
||||
totalTimeInS: 2 * 60 * 60,
|
||||
tags: [],
|
||||
name: 'Test Exam',
|
||||
note: 'Some exam note...',
|
||||
@@ -45,8 +46,9 @@ export const config: ExamEnvironmentConfig = {
|
||||
numberOfIncorrectAnswers: 1
|
||||
}
|
||||
],
|
||||
retakeTimeInMS: 24 * 60 * 60 * 1000
|
||||
};
|
||||
retakeTimeInMS: 24 * 60 * 60 * 1000,
|
||||
retakeTimeInS: 24 * 60 * 60
|
||||
} satisfies ExamEnvironmentConfig;
|
||||
|
||||
export const questionSets: ExamEnvironmentQuestionSet[] = [
|
||||
{
|
||||
@@ -247,7 +249,7 @@ export const generatedExam: ExamEnvironmentGeneratedExam = {
|
||||
]
|
||||
}
|
||||
],
|
||||
version: 1
|
||||
version: 2
|
||||
};
|
||||
|
||||
export const examAttempt: ExamEnvironmentExamAttempt = {
|
||||
@@ -261,7 +263,8 @@ export const examAttempt: ExamEnvironmentExamAttempt = {
|
||||
{
|
||||
id: generatedExam.questionSets[0]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[0]!.questions[0]!.answers[0]!],
|
||||
submissionTimeInMS: Date.now()
|
||||
submissionTimeInMS: Date.now(),
|
||||
submissionTime: new Date()
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -271,7 +274,8 @@ export const examAttempt: ExamEnvironmentExamAttempt = {
|
||||
{
|
||||
id: generatedExam.questionSets[1]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[1]!.questions[0]!.answers[1]!],
|
||||
submissionTimeInMS: Date.now()
|
||||
submissionTimeInMS: Date.now(),
|
||||
submissionTime: new Date()
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -281,22 +285,25 @@ export const examAttempt: ExamEnvironmentExamAttempt = {
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.questions[0]!.id,
|
||||
answers: [generatedExam.questionSets[2]!.questions[0]!.answers[1]!],
|
||||
submissionTimeInMS: Date.now()
|
||||
submissionTimeInMS: Date.now(),
|
||||
submissionTime: new Date()
|
||||
},
|
||||
{
|
||||
id: generatedExam.questionSets[2]!.questions[1]!.id,
|
||||
answers: [generatedExam.questionSets[2]!.questions[1]!.answers[0]!],
|
||||
submissionTimeInMS: Date.now()
|
||||
submissionTimeInMS: Date.now(),
|
||||
submissionTime: new Date()
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
startTimeInMS: Date.now(),
|
||||
startTime: new Date(),
|
||||
userId: defaultUserId,
|
||||
version: 1
|
||||
version: 2
|
||||
};
|
||||
|
||||
export const examAttemptSansSubmissionTimeInMS: Static<
|
||||
export const examAttemptSansSubmissionTime: Static<
|
||||
typeof examEnvironmentPostExamAttempt.body
|
||||
>['attempt'] = {
|
||||
examId,
|
||||
@@ -335,20 +342,21 @@ export const examAttemptSansSubmissionTimeInMS: Static<
|
||||
]
|
||||
};
|
||||
|
||||
export const exam: ExamEnvironmentExam = {
|
||||
export const exam = {
|
||||
id: examId,
|
||||
config,
|
||||
questionSets,
|
||||
prerequisites: ['67112fe1c994faa2c26d0b1d'],
|
||||
deprecated: false,
|
||||
version: 1
|
||||
};
|
||||
version: 2
|
||||
} satisfies ExamEnvironmentExam;
|
||||
|
||||
export const examEnvironmentChallenge: ExamEnvironmentChallenge = {
|
||||
id: oid(),
|
||||
examId,
|
||||
// Id of the certified full stack developer exam challenge page
|
||||
challengeId: '645147516c245de4d11eb7ba'
|
||||
challengeId: '645147516c245de4d11eb7ba',
|
||||
version: 1
|
||||
};
|
||||
|
||||
export async function seedEnvExam() {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@fastify/swagger-ui": "5.2.0",
|
||||
"@fastify/type-provider-typebox": "5.1.0",
|
||||
"@growthbook/growthbook": "1.3.1",
|
||||
"@prisma/client": "5.5.2",
|
||||
"@prisma/client": "6.16.2",
|
||||
"@sentry/node": "9.1.0",
|
||||
"@sinclair/typebox": "^0.34.33",
|
||||
"@types/pino": "^7.0.5",
|
||||
@@ -51,7 +51,7 @@
|
||||
"dotenv-cli": "7.3.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"msw": "^2.7.0",
|
||||
"prisma": "5.5.2",
|
||||
"prisma": "6.16.2",
|
||||
"supertest": "6.3.3",
|
||||
"tsx": "4.19.1",
|
||||
"vitest": "^3.2.4"
|
||||
|
||||
5
api/prisma.config.ts
Normal file
5
api/prisma.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { PrismaConfig } from 'prisma';
|
||||
|
||||
export default {
|
||||
schema: 'prisma'
|
||||
} satisfies PrismaConfig;
|
||||
62
api/prisma/exam-creator.prisma
Normal file
62
api/prisma/exam-creator.prisma
Normal file
@@ -0,0 +1,62 @@
|
||||
/// A copy of `ExamEnvironmentExam` used as a staging collection for updates to the curriculum.
|
||||
///
|
||||
/// This collection schema must be kept in sync with `ExamEnvironmentExam`.
|
||||
model ExamCreatorExam {
|
||||
/// Globally unique exam id
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// All questions for a given exam
|
||||
questionSets ExamEnvironmentQuestionSet[]
|
||||
/// Configuration for exam metadata
|
||||
config ExamEnvironmentConfig
|
||||
/// ObjectIds for required challenges/blocks to take the exam
|
||||
prerequisites String[] @db.ObjectId
|
||||
/// If `deprecated`, the exam should no longer be considered for users
|
||||
deprecated Boolean
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(2)
|
||||
|
||||
}
|
||||
|
||||
/// Exam Creator application collection to store authZ users.
|
||||
///
|
||||
/// Currently, this is manually created in order to grant access to the application.
|
||||
model ExamCreatorUser {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
email String
|
||||
/// Unique id from GitHub for an account.
|
||||
///
|
||||
/// Currently, this is unused. Consider removing.
|
||||
github_id Int?
|
||||
name String
|
||||
picture String?
|
||||
/// TODO: After migration, remove optionality
|
||||
settings ExamCreatorUserSettings?
|
||||
version Int @default(1)
|
||||
|
||||
|
||||
ExamCreatorSession ExamCreatorSession[]
|
||||
}
|
||||
|
||||
type ExamCreatorUserSettings {
|
||||
databaseEnvironment ExamCreatorDatabaseEnvironment
|
||||
}
|
||||
|
||||
enum ExamCreatorDatabaseEnvironment {
|
||||
Production
|
||||
Staging
|
||||
}
|
||||
|
||||
/// Exam Creator application collection to store auth sessions.
|
||||
model ExamCreatorSession {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
user_id String @db.ObjectId
|
||||
session_id String
|
||||
/// Expiration date for record.
|
||||
expires_at DateTime
|
||||
|
||||
version Int @default(1)
|
||||
|
||||
|
||||
ExamCreatorUser ExamCreatorUser @relation(fields: [user_id], references: [id])
|
||||
}
|
||||
259
api/prisma/exam-environment.prisma
Normal file
259
api/prisma/exam-environment.prisma
Normal file
@@ -0,0 +1,259 @@
|
||||
/// An exam for the Exam Environment App as designed by the examiners
|
||||
model ExamEnvironmentExam {
|
||||
/// Globally unique exam id
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// All questions for a given exam
|
||||
questionSets ExamEnvironmentQuestionSet[]
|
||||
/// Configuration for exam metadata
|
||||
config ExamEnvironmentConfig
|
||||
/// ObjectIds for required challenges/blocks to take the exam
|
||||
prerequisites String[] @db.ObjectId
|
||||
/// If `deprecated`, the exam should no longer be considered for users
|
||||
deprecated Boolean
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(2)
|
||||
|
||||
|
||||
// Relations
|
||||
generatedExams ExamEnvironmentGeneratedExam[]
|
||||
examAttempts ExamEnvironmentExamAttempt[]
|
||||
ExamEnvironmentChallenge ExamEnvironmentChallenge[]
|
||||
}
|
||||
|
||||
/// A grouping of one or more questions of a given type
|
||||
type ExamEnvironmentQuestionSet {
|
||||
/// Unique question type id
|
||||
id String @db.ObjectId
|
||||
type ExamEnvironmentQuestionType
|
||||
/// Content related to all questions in set
|
||||
context String?
|
||||
questions ExamEnvironmentMultipleChoiceQuestion[]
|
||||
}
|
||||
|
||||
/// A multiple choice question for the Exam Environment App
|
||||
type ExamEnvironmentMultipleChoiceQuestion {
|
||||
/// Unique question id
|
||||
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 ExamEnvironmentAudio?
|
||||
/// Available possible answers for an exam
|
||||
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 ExamEnvironmentAudio {
|
||||
/// Optional text for audio
|
||||
captions String?
|
||||
/// URL to audio file
|
||||
///
|
||||
/// Expected in the format: `<url>#t=<start_time_in_seconds>,<end_time_in_seconds>`
|
||||
/// Where `start_time_in_seconds` and `end_time_in_seconds` are optional floats.
|
||||
url String
|
||||
}
|
||||
|
||||
/// Type of question for the Exam Environment App
|
||||
enum ExamEnvironmentQuestionType {
|
||||
/// Single question with one or more answers
|
||||
MultipleChoice
|
||||
/// Mass text
|
||||
Dialogue
|
||||
}
|
||||
|
||||
/// Answer for an Exam Environment App multiple choice question
|
||||
type ExamEnvironmentAnswer {
|
||||
/// Unique answer id
|
||||
id String @db.ObjectId
|
||||
/// Whether the answer is correct
|
||||
isCorrect Boolean
|
||||
/// Answer paragraph
|
||||
text String
|
||||
}
|
||||
|
||||
/// Configuration for an exam in the Exam Environment App
|
||||
type ExamEnvironmentConfig {
|
||||
/// Human-readable exam name
|
||||
name String
|
||||
/// Notes given about exam
|
||||
note String
|
||||
/// Category configuration for question selection
|
||||
tags ExamEnvironmentTagConfig[]
|
||||
/// Deprecated: use `totalTimeInS` instead
|
||||
totalTimeInMS Int
|
||||
/// Total time allocated for exam in seconds
|
||||
totalTimeInS Int?
|
||||
/// Configuration for sets of questions
|
||||
questionSets ExamEnvironmentQuestionSetConfig[]
|
||||
/// Deprecated: use `retakeTimeInS` instead
|
||||
retakeTimeInMS Int
|
||||
/// Duration after exam completion before a retake is allowed in seconds
|
||||
retakeTimeInS Int?
|
||||
/// Passing percent for the exam
|
||||
passingPercent Float
|
||||
}
|
||||
|
||||
/// Configuration for a set of questions in the Exam Environment App
|
||||
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
|
||||
numberOfQuestions Int
|
||||
/// Number of correct answers given per multiple choice question
|
||||
numberOfCorrectAnswers Int
|
||||
/// Number of incorrect answers given per multiple choice question
|
||||
numberOfIncorrectAnswers Int
|
||||
}
|
||||
|
||||
/// Configuration for tags in the Exam Environment App
|
||||
///
|
||||
/// This configures the number of questions that should resolve to a given tag set criteria.
|
||||
type ExamEnvironmentTagConfig {
|
||||
/// Group of multiple choice question tags
|
||||
group String[]
|
||||
/// Number of multiple choice questions per exam that should meet the group criteria
|
||||
numberOfQuestions Int
|
||||
}
|
||||
|
||||
/// An attempt at an exam in the Exam Environment App
|
||||
model ExamEnvironmentExamAttempt {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Foriegn key to user
|
||||
userId String @db.ObjectId
|
||||
/// Foreign key to exam
|
||||
examId String @db.ObjectId
|
||||
/// Foreign key to generated exam id
|
||||
generatedExamId String @db.ObjectId
|
||||
|
||||
questionSets ExamEnvironmentQuestionSetAttempt[]
|
||||
/// Deprecated: Use `startTime` instead
|
||||
startTimeInMS Int
|
||||
/// Time exam was started
|
||||
startTime DateTime?
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(2)
|
||||
|
||||
|
||||
// Relations
|
||||
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 ExamEnvironmentQuestionSetAttempt {
|
||||
id String @db.ObjectId
|
||||
questions ExamEnvironmentMultipleChoiceQuestionAttempt[]
|
||||
}
|
||||
|
||||
type ExamEnvironmentMultipleChoiceQuestionAttempt {
|
||||
/// Foreign key to question
|
||||
id String @db.ObjectId
|
||||
/// An array of foreign keys to answers
|
||||
answers String[] @db.ObjectId
|
||||
/// Deprecated: Use `submissionTime` instead
|
||||
submissionTimeInMS Int
|
||||
/// Time answers to question were submitted
|
||||
///
|
||||
/// If the question is later revisited, this field is updated
|
||||
submissionTime DateTime?
|
||||
}
|
||||
|
||||
/// A generated exam for the Exam Environment App
|
||||
///
|
||||
/// This is the user-facing information for an exam.
|
||||
model ExamEnvironmentGeneratedExam {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Foreign key to exam
|
||||
examId String @db.ObjectId
|
||||
questionSets ExamEnvironmentGeneratedQuestionSet[]
|
||||
/// If `deprecated`, the generation should not longer be considered for users
|
||||
deprecated Boolean
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(1)
|
||||
|
||||
|
||||
// Relations
|
||||
exam ExamEnvironmentExam @relation(fields: [examId], references: [id], onDelete: Cascade)
|
||||
EnvExamAttempt ExamEnvironmentExamAttempt[]
|
||||
}
|
||||
|
||||
type ExamEnvironmentGeneratedQuestionSet {
|
||||
id String @db.ObjectId
|
||||
questions ExamEnvironmentGeneratedMultipleChoiceQuestion[]
|
||||
}
|
||||
|
||||
type ExamEnvironmentGeneratedMultipleChoiceQuestion {
|
||||
/// Foreign key to question id
|
||||
id String @db.ObjectId
|
||||
/// Each item is a foreign key to an answer
|
||||
answers String[] @db.ObjectId
|
||||
}
|
||||
|
||||
/// A map between challenge ids and exam ids
|
||||
///
|
||||
/// This is expected to be used for relating challenge pages AND/OR certifications to exams
|
||||
model ExamEnvironmentChallenge {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
examId String @db.ObjectId
|
||||
challengeId String @db.ObjectId
|
||||
|
||||
version Int @default(1)
|
||||
|
||||
|
||||
exam ExamEnvironmentExam @relation(fields: [examId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ExamEnvironmentAuthorizationToken {
|
||||
/// An ObjectId is used to provide access to the created timestamp
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Used to set an `expireAt` index to delete documents
|
||||
expireAt DateTime @db.Date
|
||||
userId String @unique @db.ObjectId
|
||||
version Int @default(1)
|
||||
|
||||
|
||||
// Relations
|
||||
user user @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
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
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(1)
|
||||
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -165,229 +165,8 @@ model user {
|
||||
isClassroomAccount Boolean? // Undefined
|
||||
|
||||
// Relations
|
||||
examAttempts ExamEnvironmentExamAttempt[]
|
||||
examEnvironmentAuthorizationToken ExamEnvironmentAuthorizationToken?
|
||||
}
|
||||
|
||||
// -----------------------------------
|
||||
|
||||
/// An exam for the Exam Environment App as designed by the examiners
|
||||
model ExamEnvironmentExam {
|
||||
/// Globally unique exam id
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// All questions for a given exam
|
||||
questionSets ExamEnvironmentQuestionSet[]
|
||||
/// Configuration for exam metadata
|
||||
config ExamEnvironmentConfig
|
||||
/// ObjectIds for required challenges/blocks to take the exam
|
||||
prerequisites String[] @db.ObjectId
|
||||
/// If `deprecated`, the exam should no longer be considered for users
|
||||
deprecated Boolean
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(1)
|
||||
|
||||
// Relations
|
||||
generatedExams ExamEnvironmentGeneratedExam[]
|
||||
examAttempts ExamEnvironmentExamAttempt[]
|
||||
ExamEnvironmentChallenge ExamEnvironmentChallenge[]
|
||||
}
|
||||
|
||||
/// A copy of `ExamEnvironmentExam` used as a staging collection for updates to the curriculum.
|
||||
///
|
||||
/// This collection schema must be kept in sync with `ExamEnvironmentExam`.
|
||||
model ExamCreatorExam {
|
||||
/// Globally unique exam id
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// All questions for a given exam
|
||||
questionSets ExamEnvironmentQuestionSet[]
|
||||
/// Configuration for exam metadata
|
||||
config ExamEnvironmentConfig
|
||||
/// ObjectIds for required challenges/blocks to take the exam
|
||||
prerequisites String[] @db.ObjectId
|
||||
/// If `deprecated`, the exam should no longer be considered for users
|
||||
deprecated Boolean
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(1)
|
||||
}
|
||||
|
||||
/// A grouping of one or more questions of a given type
|
||||
type ExamEnvironmentQuestionSet {
|
||||
/// Unique question type id
|
||||
id String @db.ObjectId
|
||||
type ExamEnvironmentQuestionType
|
||||
/// Content related to all questions in set
|
||||
context String?
|
||||
questions ExamEnvironmentMultipleChoiceQuestion[]
|
||||
}
|
||||
|
||||
/// A multiple choice question for the Exam Environment App
|
||||
type ExamEnvironmentMultipleChoiceQuestion {
|
||||
/// Unique question id
|
||||
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 ExamEnvironmentAudio?
|
||||
/// Available possible answers for an exam
|
||||
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 ExamEnvironmentAudio {
|
||||
/// Optional text for audio
|
||||
captions String?
|
||||
/// URL to audio file
|
||||
///
|
||||
/// Expected in the format: `<url>#t=<start_time_in_seconds>,<end_time_in_seconds>`
|
||||
/// Where `start_time_in_seconds` and `end_time_in_seconds` are optional floats.
|
||||
url String
|
||||
}
|
||||
|
||||
/// Type of question for the Exam Environment App
|
||||
enum ExamEnvironmentQuestionType {
|
||||
/// Single question with one or more answers
|
||||
MultipleChoice
|
||||
/// Mass text
|
||||
Dialogue
|
||||
}
|
||||
|
||||
/// Answer for an Exam Environment App multiple choice question
|
||||
type ExamEnvironmentAnswer {
|
||||
/// Unique answer id
|
||||
id String @db.ObjectId
|
||||
/// Whether the answer is correct
|
||||
isCorrect Boolean
|
||||
/// Answer paragraph
|
||||
text String
|
||||
}
|
||||
|
||||
/// Configuration for an exam in the Exam Environment App
|
||||
type ExamEnvironmentConfig {
|
||||
/// Human-readable exam name
|
||||
name String
|
||||
/// Notes given about exam
|
||||
note String
|
||||
/// Category configuration for question selection
|
||||
tags ExamEnvironmentTagConfig[]
|
||||
/// Total time allocated for exam in milliseconds
|
||||
totalTimeInMS Int
|
||||
/// Configuration for sets of questions
|
||||
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 ExamEnvironmentQuestionSetConfig {
|
||||
type ExamEnvironmentQuestionType
|
||||
/// Number of this grouping of questions per exam
|
||||
numberOfSet Int
|
||||
/// Number of multiple choice questions per grouping matching this set config
|
||||
numberOfQuestions Int
|
||||
/// Number of correct answers given per multiple choice question
|
||||
numberOfCorrectAnswers Int
|
||||
/// Number of incorrect answers given per multiple choice question
|
||||
numberOfIncorrectAnswers Int
|
||||
}
|
||||
|
||||
/// Configuration for tags in the Exam Environment App
|
||||
///
|
||||
/// This configures the number of questions that should resolve to a given tag set criteria.
|
||||
type ExamEnvironmentTagConfig {
|
||||
/// Group of multiple choice question tags
|
||||
group String[]
|
||||
/// Number of multiple choice questions per exam that should meet the group criteria
|
||||
numberOfQuestions Int
|
||||
}
|
||||
|
||||
/// An attempt at an exam in the Exam Environment App
|
||||
model ExamEnvironmentExamAttempt {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Foriegn key to user
|
||||
userId String @db.ObjectId
|
||||
/// Foreign key to exam
|
||||
examId String @db.ObjectId
|
||||
/// Foreign key to generated exam id
|
||||
generatedExamId String @db.ObjectId
|
||||
|
||||
questionSets ExamEnvironmentQuestionSetAttempt[]
|
||||
/// Time exam was started as milliseconds since epoch
|
||||
startTimeInMS Int
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(1)
|
||||
|
||||
// Relations
|
||||
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 ExamEnvironmentQuestionSetAttempt {
|
||||
id String @db.ObjectId
|
||||
questions ExamEnvironmentMultipleChoiceQuestionAttempt[]
|
||||
}
|
||||
|
||||
type ExamEnvironmentMultipleChoiceQuestionAttempt {
|
||||
/// Foreign key to question
|
||||
id String @db.ObjectId
|
||||
/// An array of foreign keys to answers
|
||||
answers String[] @db.ObjectId
|
||||
/// Time answers to question were submitted as milliseconds since epoch
|
||||
///
|
||||
/// If the question is later revisited, this field is updated
|
||||
submissionTimeInMS Int
|
||||
}
|
||||
|
||||
/// A generated exam for the Exam Environment App
|
||||
///
|
||||
/// This is the user-facing information for an exam.
|
||||
model ExamEnvironmentGeneratedExam {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Foreign key to exam
|
||||
examId String @db.ObjectId
|
||||
questionSets ExamEnvironmentGeneratedQuestionSet[]
|
||||
/// If `deprecated`, the generation should not longer be considered for users
|
||||
deprecated Boolean
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(1)
|
||||
|
||||
// Relations
|
||||
exam ExamEnvironmentExam @relation(fields: [examId], references: [id], onDelete: Cascade)
|
||||
EnvExamAttempt ExamEnvironmentExamAttempt[]
|
||||
}
|
||||
|
||||
type ExamEnvironmentGeneratedQuestionSet {
|
||||
id String @db.ObjectId
|
||||
questions ExamEnvironmentGeneratedMultipleChoiceQuestion[]
|
||||
}
|
||||
|
||||
type ExamEnvironmentGeneratedMultipleChoiceQuestion {
|
||||
/// Foreign key to question id
|
||||
id String @db.ObjectId
|
||||
/// Each item is a foreign key to an answer
|
||||
answers String[] @db.ObjectId
|
||||
}
|
||||
|
||||
/// A map between challenge ids and exam ids
|
||||
///
|
||||
/// This is expected to be used for relating challenge pages AND/OR certifications to exams
|
||||
model ExamEnvironmentChallenge {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
examId String @db.ObjectId
|
||||
challengeId String @db.ObjectId
|
||||
|
||||
exam ExamEnvironmentExam @relation(fields: [examId], references: [id], onDelete: Cascade)
|
||||
examAttempts ExamEnvironmentExamAttempt[]
|
||||
examEnvironmentAuthorizationToken ExamEnvironmentAuthorizationToken?
|
||||
}
|
||||
|
||||
// -----------------------------------
|
||||
@@ -433,17 +212,6 @@ model UserToken {
|
||||
@@index([userId], map: "userId_1")
|
||||
}
|
||||
|
||||
model ExamEnvironmentAuthorizationToken {
|
||||
/// An ObjectId is used to provide access to the created timestamp
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
/// Used to set an `expireAt` index to delete documents
|
||||
expireAt DateTime @db.Date
|
||||
userId String @unique @db.ObjectId
|
||||
|
||||
// Relations
|
||||
user user @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model sessions {
|
||||
id String @id @map("_id")
|
||||
expires DateTime @db.Date
|
||||
@@ -556,64 +324,3 @@ type DailyCodingChallengeApiLanguageChallengeFiles {
|
||||
contents String
|
||||
fileKey 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
|
||||
/// Version of the record
|
||||
/// The default must be incremented by 1, if anything in the schema changes
|
||||
version Int @default(1)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Exam Creator application collection to store authZ users.
|
||||
///
|
||||
/// Currently, this is manually created in order to grant access to the application.
|
||||
model ExamCreatorUser {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
email String
|
||||
/// Unique id from GitHub for an account.
|
||||
///
|
||||
/// Currently, this is unused. Consider removing.
|
||||
github_id Int?
|
||||
name String
|
||||
picture String?
|
||||
|
||||
ExamCreatorSession ExamCreatorSession[]
|
||||
}
|
||||
|
||||
/// Exam Creator application collection to store auth sessions.
|
||||
model ExamCreatorSession {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
user_id String @db.ObjectId
|
||||
session_id String
|
||||
/// Expiration date for record.
|
||||
expires_at DateTime
|
||||
|
||||
ExamCreatorUser ExamCreatorUser @relation(fields: [user_id], references: [id])
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
createSuperRequest,
|
||||
defaultUserId,
|
||||
devLogin,
|
||||
serializeDates,
|
||||
setupServer
|
||||
} from '../../../vitest.utils.js';
|
||||
import {
|
||||
@@ -102,6 +103,7 @@ describe('/exam-environment/', () => {
|
||||
examId,
|
||||
generatedExamId: mock.oid(),
|
||||
startTimeInMS: Date.now(),
|
||||
startTime: new Date(),
|
||||
userId: defaultUserId
|
||||
}
|
||||
});
|
||||
@@ -133,6 +135,7 @@ describe('/exam-environment/', () => {
|
||||
examId: mock.examId,
|
||||
generatedExamId: mock.oid(),
|
||||
startTimeInMS: Date.now() - (1000 * 60 * 60 * 2 + 1000),
|
||||
startTime: new Date(Date.now() - (1000 * 60 * 60 * 2 + 1000)),
|
||||
userId: defaultUserId
|
||||
}
|
||||
});
|
||||
@@ -164,6 +167,7 @@ describe('/exam-environment/', () => {
|
||||
examId: mock.examId,
|
||||
generatedExamId: mock.oid(),
|
||||
startTimeInMS: Date.now(),
|
||||
startTime: new Date(),
|
||||
userId: defaultUserId
|
||||
}
|
||||
});
|
||||
@@ -235,12 +239,13 @@ describe('/exam-environment/', () => {
|
||||
examId: mock.examId,
|
||||
generatedExamId: mock.generatedExam.id,
|
||||
startTimeInMS: Date.now(),
|
||||
startTime: new Date(),
|
||||
questionSets: []
|
||||
}
|
||||
});
|
||||
|
||||
const body: Static<typeof examEnvironmentPostExamAttempt.body> = {
|
||||
attempt: mock.examAttemptSansSubmissionTimeInMS
|
||||
attempt: mock.examAttemptSansSubmissionTime
|
||||
};
|
||||
|
||||
const res = await superPost('/exam-environment/exam/attempt')
|
||||
@@ -330,10 +335,13 @@ describe('/exam-environment/', () => {
|
||||
});
|
||||
|
||||
it('should return an error if the exam has been attempted too recently to retake', async () => {
|
||||
const examTotalTimeInMS = mock.exam.config.totalTimeInS * 1000;
|
||||
|
||||
const recentExamAttempt = {
|
||||
...mock.examAttempt,
|
||||
// Set start time such that exam has just expired
|
||||
startTimeInMS: Date.now() - mock.exam.config.totalTimeInMS
|
||||
startTimeInMS: Date.now() - examTotalTimeInMS,
|
||||
startTime: new Date(Date.now() - examTotalTimeInMS)
|
||||
};
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: recentExamAttempt
|
||||
@@ -357,6 +365,8 @@ describe('/exam-environment/', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const examRetakeTimeInMS = mock.exam.config.retakeTimeInS * 1000;
|
||||
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.update({
|
||||
where: {
|
||||
id: recentExamAttempt.id
|
||||
@@ -364,9 +374,10 @@ describe('/exam-environment/', () => {
|
||||
data: {
|
||||
// Set start time such that exam has expired, but retake time -1s has passed
|
||||
startTimeInMS:
|
||||
Date.now() -
|
||||
(mock.exam.config.totalTimeInMS +
|
||||
(mock.exam.config.retakeTimeInMS - 1000))
|
||||
Date.now() - (examTotalTimeInMS + (examRetakeTimeInMS - 1000)),
|
||||
startTime: new Date(
|
||||
Date.now() - (examTotalTimeInMS + (examRetakeTimeInMS - 1000))
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -393,9 +404,14 @@ describe('/exam-environment/', () => {
|
||||
it('should use a new exam attempt if all previous attempts were started > 24 hours ago', async () => {
|
||||
const recentExamAttempt = structuredClone(mock.examAttempt);
|
||||
// Set start time such that exam has expired, but 24 hours + 1s has passed
|
||||
const examTotalTimeInMS = mock.exam.config.totalTimeInS * 1000;
|
||||
|
||||
recentExamAttempt.startTimeInMS =
|
||||
Date.now() -
|
||||
(mock.exam.config.totalTimeInMS + (24 * 60 * 60 * 1000 + 1000));
|
||||
Date.now() - examTotalTimeInMS + (24 * 60 * 60 * 1000 + 1000);
|
||||
recentExamAttempt.startTime = new Date(
|
||||
Date.now() - (examTotalTimeInMS + (24 * 60 * 60 * 1000 + 1000))
|
||||
);
|
||||
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: recentExamAttempt
|
||||
});
|
||||
@@ -450,7 +466,7 @@ describe('/exam-environment/', () => {
|
||||
expect(res).toMatchObject({
|
||||
status: 200,
|
||||
body: {
|
||||
examAttempt: latestAttempt
|
||||
examAttempt: serializeDates(latestAttempt)
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -458,12 +474,20 @@ describe('/exam-environment/', () => {
|
||||
it('should return an error if the database has insufficient generated exams', async () => {
|
||||
// Add completed attempt for generated exam
|
||||
const submittedAttempt = structuredClone(mock.examAttempt);
|
||||
const examTotalTimeInMS = mock.exam.config.totalTimeInS * 1000;
|
||||
// Long-enough ago to be considered "submitted", and not trigger cooldown
|
||||
submittedAttempt.startTimeInMS =
|
||||
Date.now() -
|
||||
24 * 60 * 60 * 1000 -
|
||||
mock.exam.config.totalTimeInMS -
|
||||
examTotalTimeInMS -
|
||||
1 * 60 * 60 * 1000;
|
||||
submittedAttempt.startTime = new Date(
|
||||
Date.now() -
|
||||
24 * 60 * 60 * 1000 -
|
||||
examTotalTimeInMS -
|
||||
1 * 60 * 60 * 1000
|
||||
);
|
||||
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: submittedAttempt
|
||||
});
|
||||
@@ -523,6 +547,8 @@ describe('/exam-environment/', () => {
|
||||
generatedExamId: generatedExam!.id,
|
||||
questionSets: [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
startTime: expect.any(Date),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
startTimeInMS: expect.any(Number),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
version: expect.any(Number)
|
||||
@@ -594,10 +620,12 @@ describe('/exam-environment/', () => {
|
||||
|
||||
const userExam = constructUserExam(generatedExam!, mock.exam);
|
||||
|
||||
expect(res.body).toMatchObject({
|
||||
examAttempt,
|
||||
exam: userExam
|
||||
});
|
||||
expect(res.body).toMatchObject(
|
||||
serializeDates({
|
||||
examAttempt,
|
||||
exam: userExam
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -638,7 +666,9 @@ describe('/exam-environment/', () => {
|
||||
name: mock.exam.config.name,
|
||||
note: mock.exam.config.note,
|
||||
passingPercent: mock.exam.config.passingPercent,
|
||||
totalTimeInS: mock.exam.config.totalTimeInS,
|
||||
totalTimeInMS: mock.exam.config.totalTimeInMS,
|
||||
retakeTimeInS: mock.exam.config.retakeTimeInS,
|
||||
retakeTimeInMS: mock.exam.config.retakeTimeInMS
|
||||
},
|
||||
id: mock.examId
|
||||
@@ -713,7 +743,9 @@ describe('/exam-environment/', () => {
|
||||
name: mock.exam.config.name,
|
||||
note: mock.exam.config.note,
|
||||
passingPercent: mock.exam.config.passingPercent,
|
||||
totalTimeInS: mock.exam.config.totalTimeInS,
|
||||
totalTimeInMS: mock.exam.config.totalTimeInMS,
|
||||
retakeTimeInS: mock.exam.config.retakeTimeInS,
|
||||
retakeTimeInMS: mock.exam.config.retakeTimeInMS
|
||||
},
|
||||
id: mock.examId
|
||||
@@ -725,10 +757,13 @@ describe('/exam-environment/', () => {
|
||||
|
||||
it("should indicate an exam's availability based on the last attempt's start time, and the exam retake time", async () => {
|
||||
// Create a recent exam attempt that's within the retake time
|
||||
const examTotalTimeInMS = mock.exam.config.totalTimeInS * 1000;
|
||||
|
||||
const recentExamAttempt = {
|
||||
...mock.examAttempt,
|
||||
userId: defaultUserId,
|
||||
startTimeInMS: Date.now() - mock.exam.config.totalTimeInMS
|
||||
startTimeInMS: Date.now() - examTotalTimeInMS,
|
||||
startTime: new Date(Date.now() - examTotalTimeInMS)
|
||||
};
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: recentExamAttempt
|
||||
@@ -742,15 +777,17 @@ describe('/exam-environment/', () => {
|
||||
expect(res.body).toMatchObject([{ canTake: false }]);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const examRetakeTimeInMS = mock.exam.config.retakeTimeInS * 1000;
|
||||
|
||||
// Update the attempt to be outside the retake time
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.update({
|
||||
where: { id: recentExamAttempt.id },
|
||||
data: {
|
||||
startTimeInMS:
|
||||
Date.now() -
|
||||
(mock.exam.config.totalTimeInMS +
|
||||
mock.exam.config.retakeTimeInMS +
|
||||
1000)
|
||||
Date.now() - (examTotalTimeInMS + examRetakeTimeInMS + 1000),
|
||||
startTime: new Date(
|
||||
Date.now() - (examTotalTimeInMS + examRetakeTimeInMS + 1000)
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -766,14 +803,16 @@ describe('/exam-environment/', () => {
|
||||
|
||||
it('should indicate an exam is unavailable if there are any pending moderation records for the exam attempts', async () => {
|
||||
// Create an exam attempt that's outside the retake time
|
||||
const examTotalTimeInMS = mock.exam.config.totalTimeInS * 1000;
|
||||
const examRetakeTimeInMS = mock.exam.config.retakeTimeInS * 1000;
|
||||
const examAttempt = {
|
||||
...mock.examAttempt,
|
||||
userId: defaultUserId,
|
||||
startTimeInMS:
|
||||
Date.now() -
|
||||
(mock.exam.config.totalTimeInMS +
|
||||
mock.exam.config.retakeTimeInMS +
|
||||
1000)
|
||||
Date.now() - (examTotalTimeInMS + examRetakeTimeInMS + 1000),
|
||||
startTime: new Date(
|
||||
Date.now() - (examTotalTimeInMS + examRetakeTimeInMS + 1000)
|
||||
)
|
||||
};
|
||||
const createdAttempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
@@ -868,11 +907,12 @@ describe('/exam-environment/', () => {
|
||||
id: attempt.id,
|
||||
examId: mock.exam.id,
|
||||
result: null,
|
||||
startTime: attempt.startTime,
|
||||
startTimeInMS: attempt.startTimeInMS,
|
||||
questionSets: attempt.questionSets
|
||||
};
|
||||
|
||||
expect(res.body).toEqual(examEnvironmentExamAttempt);
|
||||
expect(res.body).toEqual(serializeDates(examEnvironmentExamAttempt));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
@@ -908,17 +948,21 @@ describe('/exam-environment/', () => {
|
||||
id: attempt.id,
|
||||
examId: mock.exam.id,
|
||||
result: null,
|
||||
startTime: attempt.startTime,
|
||||
startTimeInMS: attempt.startTimeInMS,
|
||||
questionSets: attempt.questionSets
|
||||
};
|
||||
|
||||
expect(res.body).toEqual(examEnvironmentExamAttempt);
|
||||
expect(res.body).toEqual(serializeDates(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 examTotalTimeInMS = mock.exam.config.totalTimeInS * 1000;
|
||||
|
||||
examAttempt.startTimeInMS = Date.now() - examTotalTimeInMS;
|
||||
examAttempt.startTime = new Date(Date.now() - examTotalTimeInMS);
|
||||
const attempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: examAttempt
|
||||
@@ -945,11 +989,12 @@ describe('/exam-environment/', () => {
|
||||
score: 25,
|
||||
passingPercent: 80
|
||||
},
|
||||
startTime: attempt.startTime,
|
||||
startTimeInMS: attempt.startTimeInMS,
|
||||
questionSets: attempt.questionSets
|
||||
};
|
||||
|
||||
expect(res.body).toEqual(examEnvironmentExamAttempt);
|
||||
expect(res.body).toEqual(serializeDates(examEnvironmentExamAttempt));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1000,11 +1045,12 @@ describe('/exam-environment/', () => {
|
||||
id: attempt.id,
|
||||
examId: mock.exam.id,
|
||||
result: null,
|
||||
startTime: attempt.startTime,
|
||||
startTimeInMS: attempt.startTimeInMS,
|
||||
questionSets: attempt.questionSets
|
||||
};
|
||||
|
||||
expect(res.body).toEqual([examEnvironmentExamAttempt]);
|
||||
expect(res.body).toEqual([serializeDates(examEnvironmentExamAttempt)]);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
@@ -1030,17 +1076,21 @@ describe('/exam-environment/', () => {
|
||||
id: attempt.id,
|
||||
examId: mock.exam.id,
|
||||
result: null,
|
||||
startTime: attempt.startTime,
|
||||
startTimeInMS: attempt.startTimeInMS,
|
||||
questionSets: attempt.questionSets
|
||||
};
|
||||
|
||||
expect(res.body).toEqual([examEnvironmentExamAttempt]);
|
||||
expect(res.body).toEqual([serializeDates(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 examTotalTimeInMS = mock.exam.config.totalTimeInS * 1000;
|
||||
|
||||
examAttempt.startTimeInMS = Date.now() - examTotalTimeInMS;
|
||||
examAttempt.startTime = new Date(Date.now() - examTotalTimeInMS);
|
||||
const attempt =
|
||||
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
|
||||
data: examAttempt
|
||||
@@ -1065,11 +1115,12 @@ describe('/exam-environment/', () => {
|
||||
score: 25,
|
||||
passingPercent: 80
|
||||
},
|
||||
startTime: attempt.startTime,
|
||||
startTimeInMS: attempt.startTimeInMS,
|
||||
questionSets: attempt.questionSets
|
||||
};
|
||||
|
||||
expect(res.body).toEqual([examEnvironmentExamAttempt]);
|
||||
expect(res.body).toEqual([serializeDates(examEnvironmentExamAttempt)]);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1115,12 +1166,14 @@ describe('/exam-environment/', () => {
|
||||
id: attempt.id,
|
||||
examId: mock.exam.id,
|
||||
result: null,
|
||||
startTime: attempt.startTime,
|
||||
startTimeInMS: attempt.startTimeInMS,
|
||||
questionSets: attempt.questionSets,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
version: expect.any(Number)
|
||||
};
|
||||
expect(res.body).toEqual([examEnvironmentExamAttempt]);
|
||||
|
||||
expect(res.body).toEqual([serializeDates(examEnvironmentExamAttempt)]);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,9 +259,13 @@ async function postExamGeneratedExamHandler(
|
||||
const examAttempts = maybeExamAttempts.data;
|
||||
|
||||
const lastAttempt = examAttempts.length
|
||||
? examAttempts.reduce((latest, current) =>
|
||||
latest.startTimeInMS > current.startTimeInMS ? latest : current
|
||||
)
|
||||
? examAttempts.reduce((latest, current) => {
|
||||
const latestStartTime =
|
||||
latest.startTime?.getTime() ?? latest.startTimeInMS;
|
||||
const currentStartTime =
|
||||
current.startTime?.getTime() ?? current.startTimeInMS;
|
||||
return latestStartTime > currentStartTime ? latest : current;
|
||||
})
|
||||
: null;
|
||||
|
||||
if (lastAttempt) {
|
||||
@@ -300,11 +304,19 @@ async function postExamGeneratedExamHandler(
|
||||
);
|
||||
}
|
||||
|
||||
const examExpirationTime =
|
||||
lastAttempt.startTimeInMS + exam.config.totalTimeInMS;
|
||||
const lastAttemptStartTime =
|
||||
lastAttempt.startTime?.getTime() ?? lastAttempt.startTimeInMS;
|
||||
const examTotalTimeInMS = exam.config.totalTimeInS
|
||||
? exam.config.totalTimeInS * 1000
|
||||
: exam.config.totalTimeInMS;
|
||||
const examExpirationTime = lastAttemptStartTime + examTotalTimeInMS;
|
||||
|
||||
if (examExpirationTime < Date.now()) {
|
||||
const examRetakeTimeInMS = exam.config.retakeTimeInS
|
||||
? exam.config.retakeTimeInS * 1000
|
||||
: exam.config.retakeTimeInMS;
|
||||
const retakeAllowed =
|
||||
examExpirationTime + exam.config.retakeTimeInMS < Date.now();
|
||||
examExpirationTime + examRetakeTimeInMS < Date.now();
|
||||
|
||||
if (!retakeAllowed) {
|
||||
logger.warn(
|
||||
@@ -444,6 +456,7 @@ async function postExamGeneratedExamHandler(
|
||||
examId: exam.id,
|
||||
generatedExamId: generatedExam.id,
|
||||
startTimeInMS: Date.now(),
|
||||
startTime: new Date(),
|
||||
questionSets: []
|
||||
}
|
||||
})
|
||||
@@ -540,9 +553,12 @@ async function postExamAttemptHandler(
|
||||
);
|
||||
}
|
||||
|
||||
const latestAttempt = attempts.reduce((latest, current) =>
|
||||
latest.startTimeInMS > current.startTimeInMS ? latest : current
|
||||
);
|
||||
const latestAttempt = attempts.reduce((latest, current) => {
|
||||
const latestStartTime = latest.startTime?.getTime() ?? latest.startTimeInMS;
|
||||
const currentStartTime =
|
||||
current.startTime?.getTime() ?? current.startTimeInMS;
|
||||
return latestStartTime > currentStartTime ? latest : current;
|
||||
});
|
||||
|
||||
const maybeExam = await mapErr(
|
||||
this.prisma.examEnvironmentExam.findUnique({
|
||||
@@ -573,8 +589,13 @@ async function postExamAttemptHandler(
|
||||
);
|
||||
}
|
||||
|
||||
const latestAttemptStartTime =
|
||||
latestAttempt.startTime?.getTime() ?? latestAttempt.startTimeInMS;
|
||||
const examTotalTimeInMS = exam.config.totalTimeInS
|
||||
? exam.config.totalTimeInS * 1000
|
||||
: exam.config.totalTimeInMS;
|
||||
const isAttemptExpired =
|
||||
latestAttempt.startTimeInMS + exam.config.totalTimeInMS < Date.now();
|
||||
latestAttemptStartTime + examTotalTimeInMS < Date.now();
|
||||
|
||||
if (isAttemptExpired) {
|
||||
logger.warn(
|
||||
@@ -715,7 +736,8 @@ async function getExams(
|
||||
select: {
|
||||
id: true,
|
||||
examId: true,
|
||||
startTimeInMS: true
|
||||
startTimeInMS: true,
|
||||
startTime: true
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -740,7 +762,9 @@ async function getExams(
|
||||
name: exam.config.name,
|
||||
note: exam.config.note,
|
||||
totalTimeInMS: exam.config.totalTimeInMS,
|
||||
totalTimeInS: exam.config.totalTimeInS,
|
||||
retakeTimeInMS: exam.config.retakeTimeInMS,
|
||||
retakeTimeInS: exam.config.retakeTimeInS,
|
||||
passingPercent: exam.config.passingPercent
|
||||
},
|
||||
canTake: false
|
||||
@@ -762,9 +786,13 @@ async function getExams(
|
||||
const attemptsForExam = attempts.filter(a => a.examId === exam.id);
|
||||
|
||||
const lastAttempt = attemptsForExam.length
|
||||
? attemptsForExam.reduce((latest, current) =>
|
||||
latest.startTimeInMS > current.startTimeInMS ? latest : current
|
||||
)
|
||||
? attemptsForExam.reduce((latest, current) => {
|
||||
const latestStartTime =
|
||||
latest.startTime?.getTime() ?? latest.startTimeInMS;
|
||||
const currentStartTime =
|
||||
current.startTime?.getTime() ?? current.startTimeInMS;
|
||||
return latestStartTime > currentStartTime ? latest : current;
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!lastAttempt) {
|
||||
@@ -774,10 +802,16 @@ async function getExams(
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastAttemptStartTime =
|
||||
lastAttempt.startTime?.getTime() ?? lastAttempt.startTimeInMS;
|
||||
const examTotalTimeInMS = exam.config.totalTimeInS
|
||||
? exam.config.totalTimeInS * 1000
|
||||
: exam.config.totalTimeInMS;
|
||||
const examRetakeTimeInMS = exam.config.retakeTimeInS
|
||||
? exam.config.retakeTimeInS * 1000
|
||||
: exam.config.retakeTimeInMS;
|
||||
const retakeDateInMS =
|
||||
lastAttempt.startTimeInMS +
|
||||
exam.config.totalTimeInMS +
|
||||
exam.config.retakeTimeInMS;
|
||||
lastAttemptStartTime + examTotalTimeInMS + examRetakeTimeInMS;
|
||||
const isRetakeTimePassed = Date.now() > retakeDateInMS;
|
||||
|
||||
if (!isRetakeTimePassed) {
|
||||
|
||||
@@ -30,6 +30,7 @@ const examEnvAttempt = Type.Object({
|
||||
id: Type.String(),
|
||||
examId: Type.String(),
|
||||
startTimeInMS: Type.Number(),
|
||||
startTime: Type.String({ format: 'date-time' }),
|
||||
questionSets: Type.Array(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
@@ -37,7 +38,8 @@ const examEnvAttempt = Type.Object({
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
answers: Type.Array(Type.String()),
|
||||
submissionTimeInMS: Type.Number()
|
||||
submissionTimeInMS: Type.Number(),
|
||||
submissionTime: Type.String({ format: 'date-time' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -12,7 +12,9 @@ export const examEnvironmentExams = {
|
||||
name: Type.String(),
|
||||
note: Type.String(),
|
||||
totalTimeInMS: Type.Number(),
|
||||
totalTimeInS: Type.Number(),
|
||||
retakeTimeInMS: Type.Number(),
|
||||
retakeTimeInS: Type.Number(),
|
||||
passingPercent: Type.Number()
|
||||
}),
|
||||
canTake: Type.Boolean()
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('Exam Environment mocked Math.random', () => {
|
||||
const allQuestions = databaseAttemptQuestionSets.flatMap(
|
||||
qs => qs.questions
|
||||
);
|
||||
expect(allQuestions.every(q => q.submissionTimeInMS)).toBe(true);
|
||||
expect(allQuestions.every(q => q.submissionTime)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not change the submission time of any questions that have not changed', () => {
|
||||
@@ -264,7 +264,7 @@ describe('Exam Environment mocked Math.random', () => {
|
||||
userAttemptToDatabaseAttemptQuestionSets(userAttempt, latestAttempt);
|
||||
|
||||
const submissionTimes = databaseAttemptQuestionSets.flatMap(qs =>
|
||||
qs.questions.map(q => q.submissionTimeInMS)
|
||||
qs.questions.map(q => q.submissionTime)
|
||||
);
|
||||
|
||||
const sameAttempt = userAttemptToDatabaseAttemptQuestionSets(
|
||||
@@ -273,7 +273,7 @@ describe('Exam Environment mocked Math.random', () => {
|
||||
);
|
||||
|
||||
const sameSubmissionTimes = sameAttempt.flatMap(qs =>
|
||||
qs.questions.map(q => q.submissionTimeInMS)
|
||||
qs.questions.map(q => q.submissionTime)
|
||||
);
|
||||
|
||||
expect(submissionTimes).toEqual(sameSubmissionTimes);
|
||||
@@ -314,9 +314,9 @@ describe('Exam Environment mocked Math.random', () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
newAttemptQuestionSets[0]?.questions[0]?.submissionTimeInMS
|
||||
newAttemptQuestionSets[0]?.questions[0]?.submissionTime
|
||||
).not.toEqual(
|
||||
databaseAttemptQuestionSets[0]?.questions[0]?.submissionTimeInMS
|
||||
databaseAttemptQuestionSets[0]?.questions[0]?.submissionTime
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -447,8 +447,10 @@ describe('Exam Environment Schema', () => {
|
||||
passingPercent: 0.0,
|
||||
questionSets: configQuestionSets,
|
||||
retakeTimeInMS: 0,
|
||||
retakeTimeInS: 0,
|
||||
tags,
|
||||
totalTimeInMS: 0
|
||||
totalTimeInMS: 0,
|
||||
totalTimeInS: 0
|
||||
};
|
||||
|
||||
const questions = [
|
||||
@@ -522,11 +524,17 @@ describe('Exam Environment Schema', () => {
|
||||
{
|
||||
id: oid(),
|
||||
questions: [
|
||||
{ answers: [oid()], id: oid(), submissionTimeInMS: 0 }
|
||||
{
|
||||
answers: [oid()],
|
||||
id: oid(),
|
||||
submissionTime: new Date(),
|
||||
submissionTimeInMS: Date.now()
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
startTimeInMS: 0,
|
||||
startTimeInMS: Date.now(),
|
||||
startTime: new Date(),
|
||||
userId: oid()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -112,9 +112,11 @@ export function constructUserExam(
|
||||
|
||||
const config = {
|
||||
totalTimeInMS: exam.config.totalTimeInMS,
|
||||
totalTimeInS: exam.config.totalTimeInS,
|
||||
name: exam.config.name,
|
||||
note: exam.config.note,
|
||||
retakeTimeInMS: exam.config.retakeTimeInMS,
|
||||
retakeTimeInS: exam.config.retakeTimeInS,
|
||||
passingPercent: exam.config.passingPercent
|
||||
};
|
||||
|
||||
@@ -241,7 +243,11 @@ export function userAttemptToDatabaseAttemptQuestionSets(
|
||||
databaseAttemptQuestionSets.push({
|
||||
...questionSet,
|
||||
questions: questionSet.questions.map(q => {
|
||||
return { ...q, submissionTimeInMS: Date.now() };
|
||||
return {
|
||||
...q,
|
||||
submissionTime: new Date(),
|
||||
submissionTimeInMS: Date.now()
|
||||
};
|
||||
})
|
||||
});
|
||||
} else {
|
||||
@@ -254,14 +260,22 @@ export function userAttemptToDatabaseAttemptQuestionSets(
|
||||
|
||||
// If no latest question, add submission time
|
||||
if (!latestQuestion) {
|
||||
return { ...q, submissionTimeInMS: Date.now() };
|
||||
return {
|
||||
...q,
|
||||
submissionTime: new Date(),
|
||||
submissionTimeInMS: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
// If answers have changed, add submission time
|
||||
if (
|
||||
JSON.stringify(q.answers) !== JSON.stringify(latestQuestion.answers)
|
||||
) {
|
||||
return { ...q, submissionTimeInMS: Date.now() };
|
||||
return {
|
||||
...q,
|
||||
submissionTime: new Date(),
|
||||
submissionTimeInMS: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
return latestQuestion;
|
||||
@@ -810,8 +824,13 @@ export async function constructEnvExamAttempt(
|
||||
}
|
||||
|
||||
// If attempt is still in progress, return without result
|
||||
const attemptStartTimeInMS =
|
||||
attempt.startTime?.getTime() ?? attempt.startTimeInMS;
|
||||
const examTotalTimeInMS = exam.config.totalTimeInS
|
||||
? exam.config.totalTimeInS * 1000
|
||||
: exam.config.totalTimeInMS;
|
||||
const isAttemptExpired =
|
||||
attempt.startTimeInMS + exam.config.totalTimeInMS < Date.now();
|
||||
attemptStartTimeInMS + examTotalTimeInMS < Date.now();
|
||||
if (!isAttemptExpired) {
|
||||
return {
|
||||
examEnvironmentExamAttempt: {
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
ports:
|
||||
- 27017:27017
|
||||
volumes:
|
||||
- db-data:/data
|
||||
- db-data:/data/db
|
||||
healthcheck:
|
||||
test: ['CMD', 'mongosh', '--eval', "db.adminCommand('ping')"]
|
||||
interval: 2s
|
||||
@@ -31,3 +31,4 @@ services:
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
driver: local
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { getCsrfToken, getCookies } from './vitest.utils.js';
|
||||
import { getCsrfToken, getCookies, serializeDates } from './vitest.utils.js';
|
||||
|
||||
const fakeCookies = [
|
||||
'_csrf=123; Path=/; HttpOnly; SameSite=Strict',
|
||||
@@ -33,3 +33,73 @@ describe('setCookiesToCookies', () => {
|
||||
expect(() => getCookies(['_csrf'])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeDates', () => {
|
||||
function isAsymmetricMatcher(x: unknown): x is typeof expect.any {
|
||||
return (
|
||||
typeof x === 'object' &&
|
||||
x !== null &&
|
||||
typeof (x as { asymmetricMatch?: unknown }).asymmetricMatch === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
test('returns primitives unchanged', () => {
|
||||
expect(serializeDates(42)).toBe(42);
|
||||
expect(serializeDates('hello')).toBe('hello');
|
||||
expect(serializeDates(true)).toBe(true);
|
||||
});
|
||||
|
||||
test('converts Date to ISO string', () => {
|
||||
const d = new Date('2020-01-01T00:00:00.000Z');
|
||||
expect(serializeDates(d)).toBe(d.toISOString());
|
||||
});
|
||||
|
||||
test('recursively converts nested objects with Date', () => {
|
||||
const input = {
|
||||
a: new Date('2021-05-05T05:05:05.000Z'),
|
||||
b: { c: new Date('2022-06-06T06:06:06.000Z') }
|
||||
};
|
||||
const output = serializeDates(input);
|
||||
expect(output).toEqual({
|
||||
a: input.a.toISOString(),
|
||||
b: { c: input.b.c.toISOString() }
|
||||
});
|
||||
});
|
||||
|
||||
test('recursively converts arrays with Date and nested structures', () => {
|
||||
const d1 = new Date('2020-02-02T02:02:02.000Z');
|
||||
const d2 = new Date('2023-03-03T03:03:03.000Z');
|
||||
const d3 = new Date('2024-04-04T04:04:04.000Z');
|
||||
const arr: [Date, { x: Date; y: Date[] }] = [d1, { x: d2, y: [d3] }];
|
||||
const out = serializeDates(arr);
|
||||
expect(out).toEqual([
|
||||
d1.toISOString(),
|
||||
{ x: d2.toISOString(), y: [d3.toISOString()] }
|
||||
]);
|
||||
});
|
||||
|
||||
test('handles null and undefined', () => {
|
||||
expect(serializeDates(null)).toBeNull();
|
||||
expect(serializeDates(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('preserves asymmetric matchers', () => {
|
||||
type MatchersShape = { id: unknown; meta: { when: unknown } };
|
||||
const withMatchers: MatchersShape = {
|
||||
id: expect.any(String) as unknown,
|
||||
meta: { when: expect.stringMatching(/Z$/) as unknown }
|
||||
};
|
||||
const serialized = serializeDates(withMatchers);
|
||||
expect(isAsymmetricMatcher(serialized.id)).toBe(true);
|
||||
expect(isAsymmetricMatcher(serialized.meta.when)).toBe(true);
|
||||
|
||||
// Serializing `Date`s yield strings that match the same patterns
|
||||
const body = {
|
||||
id: 'abc',
|
||||
meta: { when: new Date('2025-01-01T00:00:00.000Z') }
|
||||
};
|
||||
const wrapped = serializeDates(body);
|
||||
expect(typeof wrapped.id).toBe('string');
|
||||
expect(wrapped.meta.when).toMatch(/Z$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,3 +259,52 @@ export function createFetchMock({ ok = true, body = {} } = {}) {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility type to recursively replace `Date` with `string`.
|
||||
*/
|
||||
export type ReplaceDates<T> = T extends Date
|
||||
? string
|
||||
: T extends (infer U)[]
|
||||
? ReplaceDates<U>[]
|
||||
: T extends Record<string, unknown>
|
||||
? { [K in keyof T]: ReplaceDates<T[K]> }
|
||||
: T;
|
||||
|
||||
/**
|
||||
* Recursively finds and converts Date objects to ISO strings while preserving shape.
|
||||
*/
|
||||
export function serializeDates<T>(data: T): ReplaceDates<T> {
|
||||
if (data === null || data === undefined) {
|
||||
return data as ReplaceDates<T>;
|
||||
}
|
||||
|
||||
// Preserve Vitest/Jest asymmetric matchers (e.g., expect.any(Number))
|
||||
if (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
typeof (data as { asymmetricMatch?: unknown }).asymmetricMatch ===
|
||||
'function'
|
||||
) {
|
||||
return data as unknown as ReplaceDates<T>;
|
||||
}
|
||||
|
||||
if (data instanceof Date) {
|
||||
return data.toISOString() as ReplaceDates<T>;
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return (data as unknown[]).map(item =>
|
||||
serializeDates(item)
|
||||
) as ReplaceDates<T>;
|
||||
}
|
||||
|
||||
if (typeof data === 'object') {
|
||||
const entries = Object.entries(data as Record<string, unknown>).map(
|
||||
([key, value]) => [key, serializeDates(value)] as const
|
||||
);
|
||||
return Object.fromEntries(entries) as ReplaceDates<T>;
|
||||
}
|
||||
|
||||
return data as ReplaceDates<T>;
|
||||
}
|
||||
|
||||
531
pnpm-lock.yaml
generated
531
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user