From ba70f5d2535be55cedda193bcbed94bbca34a621 Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Sat, 7 Dec 2024 01:45:12 +0700 Subject: [PATCH] feat(api): add /submit-quiz-attempt endpoint (#57201) --- api/prisma/schema.prisma | 7 + api/src/plugins/__fixtures__/user.ts | 1 + api/src/routes/protected/challenge.test.ts | 140 ++++++++++++++++++ api/src/routes/protected/challenge.ts | 53 ++++++- api/src/routes/protected/user.test.ts | 9 ++ api/src/routes/protected/user.ts | 1 + api/src/schemas.ts | 1 + .../schemas/challenge/submit-quiz-attempt.ts | 23 +++ api/src/schemas/user/get-session-user.ts | 11 ++ 9 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 api/src/schemas/challenge/submit-quiz-attempt.ts diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 99064a35afd..d31ac2e1de0 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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 diff --git a/api/src/plugins/__fixtures__/user.ts b/api/src/plugins/__fixtures__/user.ts index a8ae025324d..d41de813934 100644 --- a/api/src/plugins/__fixtures__/user.ts +++ b/api/src/plugins/__fixtures__/user.ts @@ -12,6 +12,7 @@ export const newUser = (email: string) => ({ acceptedPrivacyTerms: false, completedChallenges: [], completedExams: [], + quizAttempts: [], currentChallengeId: '', donationEmails: [], email, diff --git a/api/src/routes/protected/challenge.test.ts b/api/src/routes/protected/challenge.test.ts index c9f464a0212..e4ad65b7fed 100644 --- a/api/src/routes/protected/challenge.test.ts +++ b/api/src/routes/protected/challenge.test.ts @@ -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', () => { diff --git a/api/src/routes/protected/challenge.ts b/api/src/routes/protected/challenge.ts index 99848b7385c..829b225e20c 100644 --- a/api/src/routes/protected/challenge.ts +++ b/api/src/routes/protected/challenge.ts @@ -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(); }; diff --git a/api/src/routes/protected/user.test.ts b/api/src/routes/protected/user.test.ts index 8299ae7a02d..e8f068ae65d 100644 --- a/api/src/routes/protected/user.test.ts +++ b/api/src/routes/protected/user.test.ts @@ -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, diff --git a/api/src/routes/protected/user.ts b/api/src/routes/protected/user.ts index a54de1a25e7..adb063d97eb 100644 --- a/api/src/routes/protected/user.ts +++ b/api/src/routes/protected/user.ts @@ -449,6 +449,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( completedChallenges: true, completedExams: true, currentChallengeId: true, + quizAttempts: true, email: true, emailVerified: true, githubProfile: true, diff --git a/api/src/schemas.ts b/api/src/schemas.ts index d359ed7d3f3..78ac4034d22 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -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'; diff --git a/api/src/schemas/challenge/submit-quiz-attempt.ts b/api/src/schemas/challenge/submit-quiz-attempt.ts new file mode 100644 index 00000000000..07ed7e1bf49 --- /dev/null +++ b/api/src/schemas/challenge/submit-quiz-attempt.ts @@ -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 + } +}; diff --git a/api/src/schemas/user/get-session-user.ts b/api/src/schemas/user/get-session-user.ts index a1abab67ea3..fcab27ca7cf 100644 --- a/api/src/schemas/user/get-session-user.ts +++ b/api/src/schemas/user/get-session-user.ts @@ -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(),