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
co-authored by Oliver Eyton-Williams
parent 3dc1e8b9d8
commit 568840b8d8
16 changed files with 1034 additions and 524 deletions
@@ -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: {