mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-02-21 11:04:47 -05:00
feat(api): add /submit-quiz-attempt endpoint (#57201)
This commit is contained in:
@@ -69,6 +69,12 @@ type SavedChallenge {
|
||||
lastSavedDate Float
|
||||
}
|
||||
|
||||
type QuizAttempt {
|
||||
challengeId String
|
||||
quizId String
|
||||
timestamp Float
|
||||
}
|
||||
|
||||
/// Corresponds to the `user` collection.
|
||||
model user {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
@@ -76,6 +82,7 @@ model user {
|
||||
acceptedPrivacyTerms Boolean
|
||||
completedChallenges CompletedChallenge[]
|
||||
completedExams CompletedExam[] // Undefined
|
||||
quizAttempts QuizAttempt[] // Undefined
|
||||
currentChallengeId String?
|
||||
donationEmails String[] // Undefined | String[] (only possible for built in Types like String)
|
||||
email String
|
||||
|
||||
@@ -12,6 +12,7 @@ export const newUser = (email: string) => ({
|
||||
acceptedPrivacyTerms: false,
|
||||
completedChallenges: [],
|
||||
completedExams: [],
|
||||
quizAttempts: [],
|
||||
currentChallengeId: '',
|
||||
donationEmails: [],
|
||||
email,
|
||||
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
import { Answer } from '../../utils/exam-types';
|
||||
import type { getSessionUser } from '../../schemas/user/get-session-user';
|
||||
|
||||
const EXISTING_COMPLETED_DATE = new Date('2024-11-08').getTime();
|
||||
const DATE_NOW = Date.now();
|
||||
|
||||
jest.mock('../helpers/challenge-helpers', () => {
|
||||
const originalModule = jest.requireActual<
|
||||
typeof import('../helpers/challenge-helpers')
|
||||
@@ -1676,6 +1679,143 @@ describe('challengeRoutes', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/submit-quiz-attempt', () => {
|
||||
describe('validation', () => {
|
||||
test('POST rejects requests without challengeId', async () => {
|
||||
const response = await superPost('/submit-quiz-attempt').send({
|
||||
quizId: 'id'
|
||||
});
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
type: 'error',
|
||||
message:
|
||||
'That does not appear to be a valid quiz attempt submission.'
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
test('POST rejects requests without quizId', async () => {
|
||||
const response = await superPost('/submit-quiz-attempt').send({
|
||||
challengeId: '66df3b712c41c499e9d31e5b'
|
||||
});
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
type: 'error',
|
||||
message:
|
||||
'That does not appear to be a valid quiz attempt submission.'
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
test('POST rejects requests without valid ObjectID', async () => {
|
||||
const response = await superPost('/submit-quiz-attempt').send({
|
||||
challengeId: 'not-a-valid-id'
|
||||
});
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
type: 'error',
|
||||
message:
|
||||
'That does not appear to be a valid quiz attempt submission.'
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handling', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers({
|
||||
doNotFake: ['nextTick']
|
||||
});
|
||||
jest.setSystemTime(DATE_NOW);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: 'foo@bar.com' },
|
||||
data: {
|
||||
completedChallenges: [],
|
||||
quizAttempts: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('POST adds new attempt to quizAttempts', async () => {
|
||||
const response = await superPost('/submit-quiz-attempt').send({
|
||||
challengeId: '66df3b712c41c499e9d31e5b',
|
||||
quizId: '0'
|
||||
});
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
|
||||
where: { email: 'foo@bar.com' }
|
||||
});
|
||||
|
||||
expect(user).toMatchObject({
|
||||
quizAttempts: [
|
||||
{
|
||||
challengeId: '66df3b712c41c499e9d31e5b',
|
||||
quizId: '0',
|
||||
timestamp: DATE_NOW
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toStrictEqual({});
|
||||
});
|
||||
|
||||
test('POST updates the timestamp of the existing attempt', async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
quizAttempts: [
|
||||
{
|
||||
challengeId: '66df3b712c41c499e9d31e5b', // quiz-basic-html
|
||||
quizId: '0',
|
||||
timestamp: EXISTING_COMPLETED_DATE
|
||||
},
|
||||
{
|
||||
challengeId: '66ed903cf45ce3ece4053ebe', // quiz-semantic-html
|
||||
quizId: '1',
|
||||
timestamp: EXISTING_COMPLETED_DATE
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const response = await superPost('/submit-quiz-attempt').send({
|
||||
challengeId: '66df3b712c41c499e9d31e5b',
|
||||
quizId: '1'
|
||||
});
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
|
||||
where: { email: 'foo@bar.com' }
|
||||
});
|
||||
|
||||
expect(user).toMatchObject({
|
||||
quizAttempts: [
|
||||
{
|
||||
challengeId: '66df3b712c41c499e9d31e5b',
|
||||
quizId: '1',
|
||||
timestamp: DATE_NOW
|
||||
},
|
||||
{
|
||||
challengeId: '66ed903cf45ce3ece4053ebe',
|
||||
quizId: '1',
|
||||
timestamp: EXISTING_COMPLETED_DATE
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unauthenticated user', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { uniqBy, matches } from 'lodash';
|
||||
import { CompletedExam, ExamResults } from '@prisma/client';
|
||||
import isURL from 'validator/lib/isURL';
|
||||
|
||||
@@ -776,5 +776,56 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/submit-quiz-attempt',
|
||||
{
|
||||
schema: schemas.submitQuizAttempt,
|
||||
errorHandler(error, request, reply) {
|
||||
if (error.validation) {
|
||||
void reply.code(400);
|
||||
void reply.send({
|
||||
type: 'error',
|
||||
message:
|
||||
'That does not appear to be a valid quiz attempt submission.'
|
||||
});
|
||||
} else {
|
||||
fastify.errorHandler(error, request, reply);
|
||||
}
|
||||
}
|
||||
},
|
||||
async req => {
|
||||
const { challengeId, quizId } = req.body;
|
||||
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.user?.id },
|
||||
select: {
|
||||
id: true,
|
||||
quizAttempts: true
|
||||
}
|
||||
});
|
||||
|
||||
const existingAttempt = user.quizAttempts.find(matches({ challengeId }));
|
||||
|
||||
const newAttempt = {
|
||||
challengeId,
|
||||
quizId,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
quizAttempts: existingAttempt
|
||||
? {
|
||||
updateMany: { where: { challengeId }, data: newAttempt }
|
||||
}
|
||||
: { push: newAttempt }
|
||||
}
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
@@ -78,6 +78,13 @@ const testUserData: Prisma.userCreateInput = {
|
||||
],
|
||||
partiallyCompletedChallenges: [{ id: '123', completedDate: 123 }],
|
||||
completedExams: [],
|
||||
quizAttempts: [
|
||||
{
|
||||
challengeId: '66df3b712c41c499e9d31e5b',
|
||||
quizId: '0',
|
||||
timestamp: 1731924665902
|
||||
}
|
||||
],
|
||||
githubProfile: 'github.com/foobar',
|
||||
website: 'https://www.freecodecamp.org',
|
||||
donationEmails: ['an@add.ress'],
|
||||
@@ -211,6 +218,7 @@ const publicUserData = {
|
||||
],
|
||||
completedExams: testUserData.completedExams,
|
||||
completedSurveys: [], // TODO: add surveys
|
||||
quizAttempts: testUserData.quizAttempts,
|
||||
githubProfile: testUserData.githubProfile,
|
||||
is2018DataVisCert: testUserData.is2018DataVisCert,
|
||||
is2018FullStackCert: testUserData.is2018FullStackCert, // TODO: should this be returned? The client doesn't use it at the moment.
|
||||
@@ -717,6 +725,7 @@ describe('userRoutes', () => {
|
||||
partiallyCompletedChallenges: [],
|
||||
portfolio: [],
|
||||
savedChallenges: [],
|
||||
quizAttempts: [],
|
||||
yearsTopContributor: [],
|
||||
is2018DataVisCert: false,
|
||||
is2018FullStackCert: false,
|
||||
|
||||
@@ -449,6 +449,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
completedChallenges: true,
|
||||
completedExams: true,
|
||||
currentChallengeId: true,
|
||||
quizAttempts: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
githubProfile: true,
|
||||
|
||||
@@ -10,6 +10,7 @@ export { modernChallengeCompleted } from './schemas/challenge/modern-challenge-c
|
||||
export { msTrophyChallengeCompleted } from './schemas/challenge/ms-trophy-challenge-completed';
|
||||
export { projectCompleted } from './schemas/challenge/project-completed';
|
||||
export { saveChallenge } from './schemas/challenge/save-challenge';
|
||||
export { submitQuizAttempt } from './schemas/challenge/submit-quiz-attempt';
|
||||
export { deprecatedEndpoints } from './schemas/deprecated';
|
||||
export { addDonation } from './schemas/donate/add-donation';
|
||||
export { chargeStripeCard } from './schemas/donate/charge-stripe-card';
|
||||
|
||||
23
api/src/schemas/challenge/submit-quiz-attempt.ts
Normal file
23
api/src/schemas/challenge/submit-quiz-attempt.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
import { genericError } from '../types';
|
||||
|
||||
export const submitQuizAttempt = {
|
||||
body: Type.Object({
|
||||
challengeId: Type.String({
|
||||
format: 'objectid',
|
||||
maxLength: 24,
|
||||
minLength: 24
|
||||
}),
|
||||
quizId: Type.String()
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({}),
|
||||
400: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal(
|
||||
'That does not appear to be a valid quiz attempt submission.'
|
||||
)
|
||||
}),
|
||||
default: genericError
|
||||
}
|
||||
};
|
||||
@@ -41,6 +41,17 @@ export const getSessionUser = {
|
||||
examResults
|
||||
})
|
||||
),
|
||||
quizAttempts: Type.Array(
|
||||
Type.Object({
|
||||
challengeId: Type.String({
|
||||
format: 'objectid',
|
||||
maxLength: 24,
|
||||
minLength: 24
|
||||
}),
|
||||
quizId: Type.String(),
|
||||
timestamp: Type.Number()
|
||||
})
|
||||
),
|
||||
completedChallengeCount: Type.Number(),
|
||||
currentChallengeId: Type.String(),
|
||||
email: Type.String(),
|
||||
|
||||
Reference in New Issue
Block a user