mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(api): add exam->challenge map and routes (#61683)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
// }
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user