feat(api): exam date use + split prisma files (#62344)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Shaun Hamilton
2025-10-02 14:28:36 +02:00
committed by GitHub
parent 3dc1e8b9d8
commit 568840b8d8
16 changed files with 1034 additions and 524 deletions

View File

@@ -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() {

View File

@@ -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
View File

@@ -0,0 +1,5 @@
import type { PrismaConfig } from 'prisma';
export default {
schema: 'prisma'
} satisfies PrismaConfig;

View 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])
}

View 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
}

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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' })
})
)
})

View File

@@ -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()

View File

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

View File

@@ -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: {

View File

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

View File

@@ -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$/);
});
});

View File

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

File diff suppressed because it is too large Load Diff