feat(api): add /submit-quiz-attempt endpoint (#57201)

This commit is contained in:
Huyen Nguyen
2024-12-07 01:45:12 +07:00
committed by GitHub
parent ce8b971073
commit ba70f5d253
9 changed files with 245 additions and 1 deletions

View File

@@ -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

View File

@@ -12,6 +12,7 @@ export const newUser = (email: string) => ({
acceptedPrivacyTerms: false,
completedChallenges: [],
completedExams: [],
quizAttempts: [],
currentChallengeId: '',
donationEmails: [],
email,

View File

@@ -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', () => {

View File

@@ -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();
};

View File

@@ -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,

View File

@@ -449,6 +449,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
completedChallenges: true,
completedExams: true,
currentChallengeId: true,
quizAttempts: true,
email: true,
emailVerified: true,
githubProfile: true,

View File

@@ -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';

View 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
}
};

View File

@@ -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(),