From 92d6901c2f7ad0e9597373f1eb61dbeb71e93a3d Mon Sep 17 00:00:00 2001 From: Shaun Hamilton Date: Thu, 18 Sep 2025 15:32:44 +0200 Subject: [PATCH] feat(api): add exam->challenge map and routes (#61683) Co-authored-by: Oliver Eyton-Williams --- api/__mocks__/exam-environment-exam.ts | 13 ++- api/prisma/schema.prisma | 16 ++- .../routes/exam-environment.test.ts | 82 +++++++++++++ .../routes/exam-environment.ts | 108 ++++++++++++++++++ .../exam-environment/schemas/challenges.ts | 12 ++ .../schemas/exam-environment-exam-attempt.ts | 15 +++ api/src/exam-environment/schemas/index.ts | 4 +- api/src/routes/protected/user.ts | 8 ++ api/tools/exam-environment/seed/index.ts | 6 + api/vitest.utils.ts | 3 +- 10 files changed, 260 insertions(+), 7 deletions(-) create mode 100644 api/src/exam-environment/schemas/challenges.ts diff --git a/api/__mocks__/exam-environment-exam.ts b/api/__mocks__/exam-environment-exam.ts index e7e75569b54..a91d83e4397 100644 --- a/api/__mocks__/exam-environment-exam.ts +++ b/api/__mocks__/exam-environment-exam.ts @@ -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(); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index f193ac6bf2a..0e765ffdac4 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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 { diff --git a/api/src/exam-environment/routes/exam-environment.test.ts b/api/src/exam-environment/routes/exam-environment.test.ts index 40887abb349..9ea214794fe 100644 --- a/api/src/exam-environment/routes/exam-environment.test.ts +++ b/api/src/exam-environment/routes/exam-environment.test.ts @@ -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); + }); + }); }); }); diff --git a/api/src/exam-environment/routes/exam-environment.ts b/api/src/exam-environment/routes/exam-environment.ts index 5c0adc3cbd7..a607523b56a 100644 --- a/api/src/exam-environment/routes/exam-environment.ts +++ b/api/src/exam-environment/routes/exam-environment.ts @@ -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, + 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); +} diff --git a/api/src/exam-environment/schemas/challenges.ts b/api/src/exam-environment/schemas/challenges.ts new file mode 100644 index 00000000000..18377824421 --- /dev/null +++ b/api/src/exam-environment/schemas/challenges.ts @@ -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 + // } +}; diff --git a/api/src/exam-environment/schemas/exam-environment-exam-attempt.ts b/api/src/exam-environment/schemas/exam-environment-exam-attempt.ts index 8a142be4dd6..b296f234ef7 100644 --- a/api/src/exam-environment/schemas/exam-environment-exam-attempt.ts +++ b/api/src/exam-environment/schemas/exam-environment-exam-attempt.ts @@ -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 + // } +}; diff --git a/api/src/exam-environment/schemas/index.ts b/api/src/exam-environment/schemas/index.ts index e80221db7b9..294fb3846ca 100644 --- a/api/src/exam-environment/schemas/index.ts +++ b/api/src/exam-environment/schemas/index.ts @@ -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'; diff --git a/api/src/routes/protected/user.ts b/api/src/routes/protected/user.ts index c2daa636846..c35cdc8663d 100644 --- a/api/src/routes/protected/user.ts +++ b/api/src/routes/protected/user.ts @@ -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(); }; diff --git a/api/tools/exam-environment/seed/index.ts b/api/tools/exam-environment/seed/index.ts index 5ec864c0f64..adc7a67b04a 100644 --- a/api/tools/exam-environment/seed/index.ts +++ b/api/tools/exam-environment/seed/index.ts @@ -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(); diff --git a/api/vitest.utils.ts b/api/vitest.utils.ts index 2a90d0fb193..9894bdb771b 100644 --- a/api/vitest.utils.ts +++ b/api/vitest.utils.ts @@ -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';