feat(api): add exam->challenge map and routes (#61683)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Shaun Hamilton
2025-09-18 15:32:44 +02:00
committed by GitHub
parent a6d186a73d
commit 92d6901c2f
10 changed files with 260 additions and 7 deletions
+10 -3
View File
@@ -5,15 +5,15 @@ import {
ExamEnvironmentExamAttempt,
ExamEnvironmentExam,
ExamEnvironmentGeneratedExam,
ExamEnvironmentQuestionSet
ExamEnvironmentQuestionSet,
ExamEnvironmentChallenge
} from '@prisma/client';
import { ObjectId } from 'mongodb';
import { defaultUserId } from '../vitest.utils';
import { examEnvironmentPostExamAttempt } from '../src/exam-environment/schemas';
export const oid = () => new ObjectId().toString();
const defaultUserId = '64c7810107dd4782d32baee7';
export const examId = oid();
export const config: ExamEnvironmentConfig = {
@@ -344,6 +344,13 @@ export const exam: ExamEnvironmentExam = {
version: 1
};
export const examEnvironmentChallenge: ExamEnvironmentChallenge = {
id: oid(),
examId,
// Id of the certified full stack developer exam challenge page
challengeId: '645147516c245de4d11eb7ba'
};
export async function seedEnvExam() {
await clearEnvExam();
+14 -2
View File
@@ -188,8 +188,9 @@ model ExamEnvironmentExam {
version Int @default(1)
// Relations
generatedExams ExamEnvironmentGeneratedExam[]
examAttempts ExamEnvironmentExamAttempt[]
generatedExams ExamEnvironmentGeneratedExam[]
examAttempts ExamEnvironmentExamAttempt[]
ExamEnvironmentChallenge ExamEnvironmentChallenge[]
}
/// A copy of `ExamEnvironmentExam` used as a staging collection for updates to the curriculum.
@@ -378,6 +379,17 @@ type ExamEnvironmentGeneratedMultipleChoiceQuestion {
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)
}
// -----------------------------------
model AccessToken {
@@ -1070,6 +1070,57 @@ describe('/exam-environment/', () => {
expect(res.status).toBe(200);
});
});
describe('GET /exam-environment/exams/:examId/attempts', () => {
afterEach(async () => {
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.deleteMany();
});
it('should return 200 if no attempts exist for the exam and user', async () => {
const res = await superGet(
`/exam-environment/exams/${mock.examId}/attempts`
).set(
'exam-environment-authorization-token',
examEnvironmentAuthorizationToken
);
expect(res.body).toEqual([]);
expect(res.status).toBe(200);
});
it('should return 200 with attempts for the given examId and user', async () => {
const attempt =
await fastifyTestInstance.prisma.examEnvironmentExamAttempt.create({
data: {
...mock.examAttempt,
userId: defaultUserId,
examId: mock.examId
}
});
await fastifyTestInstance.prisma.examEnvironmentExamModeration.create({
data: {
examAttemptId: attempt.id,
status: ExamEnvironmentExamModerationStatus.Pending
}
});
const res = await superGet(
`/exam-environment/exams/${mock.examId}/attempts`
).set(
'exam-environment-authorization-token',
examEnvironmentAuthorizationToken
);
const examEnvironmentExamAttempt = {
id: attempt.id,
examId: mock.exam.id,
result: null,
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.status).toBe(200);
});
});
});
describe('Authenticated user without exam environment authorization token', () => {
@@ -1177,5 +1228,36 @@ describe('/exam-environment/', () => {
expect(res.status).toBe(403);
});
});
describe('GET /exam-environment/challenges/:challengeId/exam-mappings', () => {
afterAll(async () => {
await fastifyTestInstance.prisma.examEnvironmentChallenge.deleteMany(
{}
);
});
it('should return 200 and an empty array if no exams are mapped to the challenge', async () => {
const challengeId = mock.oid();
const res = await superGet(
`/exam-environment/challenges/${challengeId}/exam-mappings`
);
expect(res.body).toStrictEqual([]);
expect(res.status).toBe(200);
});
it('should return 200 and a list of exams mapped to the challenge', async () => {
await fastifyTestInstance.prisma.examEnvironmentChallenge.create({
data: mock.examEnvironmentChallenge
});
const res = await superGet(
`/exam-environment/challenges/${mock.examEnvironmentChallenge.challengeId}/exam-mappings`
);
expect(res.body).toEqual(
expect.arrayContaining([
expect.objectContaining({ examId: mock.examId })
])
);
expect(res.status).toBe(200);
});
});
});
});
@@ -60,6 +60,13 @@ export const examEnvironmentValidatedTokenRoutes: FastifyPluginCallbackTypebox =
},
getExamAttemptHandler
);
fastify.get(
'/exam-environment/exams/:examId/attempts',
{
schema: schemas.examEnvironmentGetExamAttemptsByExamId
},
getExamAttemptsByExamIdHandler
);
done();
};
@@ -81,6 +88,13 @@ export const examEnvironmentOpenRoutes: FastifyPluginCallbackTypebox = (
},
tokenMetaHandler
);
fastify.get(
'/exam-environment/challenges/:challengeId/exam-mappings',
{
schema: schemas.examEnvironmentGetExamMappingsByChallengeId
},
getExamMappingsByChallengeId
);
done();
};
@@ -925,3 +939,97 @@ export async function getExamAttemptHandler(
return reply.send(examEnvironmentExamAttempt);
}
/**
* Gets the requested exam attempt by id owned by authz user.
*
* If the attempt is completed, the result is included.
*/
export async function getExamAttemptsByExamIdHandler(
this: FastifyInstance,
req: UpdateReqType<typeof schemas.examEnvironmentGetExamAttemptsByExamId>,
reply: FastifyReply
) {
const logger = this.log.child({ req });
const user = req.user!;
const { examId } = req.params;
logger.info({ examId, userId: user.id });
// If attempt id is given, only return that attempt
const maybeAttempts = await mapErr(
this.prisma.examEnvironmentExamAttempt.findMany({
where: {
examId: examId,
userId: user.id
}
})
);
if (maybeAttempts.hasError) {
logger.error(maybeAttempts.error);
this.Sentry.captureException(maybeAttempts.error);
void reply.code(500);
return reply.send(
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeAttempts.error))
);
}
const attempts = maybeAttempts.data;
const examEnvironmentExamAttempts = [];
for (const attempt of attempts) {
const { error, examEnvironmentExamAttempt } = await constructEnvExamAttempt(
this,
attempt,
logger
);
if (error) {
void reply.code(error.code);
return reply.send(error.data);
}
examEnvironmentExamAttempts.push(examEnvironmentExamAttempt);
}
return reply.send(examEnvironmentExamAttempts);
}
/**
* Gets all the relations for a given challenge and exam(s).
*/
export async function getExamMappingsByChallengeId(
this: FastifyInstance,
req: UpdateReqType<
typeof schemas.examEnvironmentGetExamMappingsByChallengeId
>,
reply: FastifyReply
) {
const logger = this.log.child({ req });
const { challengeId } = req.params;
logger.info({ challengeId });
const maybeData = await mapErr(
this.prisma.examEnvironmentChallenge.findMany({
where: {
challengeId
}
})
);
if (maybeData.hasError) {
logger.error(maybeData.error);
this.Sentry.captureException(maybeData.error);
void reply.code(500);
return reply.send(
ERRORS.FCC_ERR_EXAM_ENVIRONMENT(JSON.stringify(maybeData.error))
);
}
const data = maybeData.data;
return reply.send(data);
}
@@ -0,0 +1,12 @@
import { Type } from '@fastify/type-provider-typebox';
// import { STANDARD_ERROR } from '../utils/errors';
export const examEnvironmentGetExamMappingsByChallengeId = {
params: Type.Object({
challengeId: Type.String({ format: 'objectid' })
})
// response: {
// 200: examEnvAttempt,
// default: STANDARD_ERROR
// }
};
@@ -77,3 +77,18 @@ export const examEnvironmentGetExamAttempt = {
default: STANDARD_ERROR
}
};
export const examEnvironmentGetExamAttemptsByExamId = {
params: Type.Object({
examId: Type.String({ format: 'objectid' })
}),
headers: Type.Object({
// Optional, because the handler is used in both the `/user/` base and `/exam-environment/` base.
// If it is missing, auth will catch.
'exam-environment-authorization-token': Type.Optional(Type.String())
})
// response: {
// 200: Type.Array(examEnvAttempt),
// default: STANDARD_ERROR
// }
};
+3 -1
View File
@@ -1,8 +1,10 @@
export {
examEnvironmentPostExamAttempt,
examEnvironmentGetExamAttempts,
examEnvironmentGetExamAttempt
examEnvironmentGetExamAttempt,
examEnvironmentGetExamAttemptsByExamId
} from './exam-environment-exam-attempt';
export { examEnvironmentPostExamGeneratedExam } from './exam-environment-exam-generated-exam';
export { examEnvironmentTokenMeta } from './token-meta';
export { examEnvironmentExams } from './exam-environment-exams';
export { examEnvironmentGetExamMappingsByChallengeId } from './challenges';
+8
View File
@@ -30,6 +30,7 @@ import {
import { DEPLOYMENT_ENV, JWT_SECRET } from '../../utils/env';
import {
getExamAttemptHandler,
getExamAttemptsByExamIdHandler,
getExamAttemptsHandler
} from '../../exam-environment/routes/exam-environment';
import { ERRORS } from '../../exam-environment/utils/errors';
@@ -495,6 +496,13 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
},
getExamAttemptHandler
);
fastify.get(
'/user/exam-environment/exams/:examId/attempts',
{
schema: examEnvironmentSchemas.examEnvironmentGetExamAttemptsByExamId
},
getExamAttemptsByExamIdHandler
);
done();
};
+6
View File
@@ -16,11 +16,17 @@ async function main() {
await prisma.examEnvironmentExamAttempt.deleteMany({});
await prisma.examEnvironmentGeneratedExam.deleteMany({});
await prisma.examEnvironmentExam.deleteMany({});
await prisma.examEnvironmentChallenge.deleteMany({});
await prisma.examEnvironmentExam.create({ data: mocks.exam });
await prisma.examEnvironmentGeneratedExam.create({
data: mocks.generatedExam
});
await prisma.examEnvironmentExamAttempt.create({ data: mocks.examAttempt });
await prisma.examEnvironmentChallenge.create({
data: mocks.examEnvironmentChallenge
});
}
void main();
+2 -1
View File
@@ -211,7 +211,8 @@ If you are seeing this error, the root cause is likely an error thrown in the be
});
}
export const defaultUserId = '64c7810107dd4782d32baee7';
// demoUser _id to allow testing with mock data
export const defaultUserId = '5bd30e0f1caf6ac3ddddddb5';
export const defaultUserEmail = 'foo@bar.com';
export const defaultUsername = 'fcc-test-user';