feat(api): modern challenge submission endpoint (#50998)

Co-authored-by: Naomi Carrigan <nhcarrigan@gmail.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
Co-authored-by: Muhammed Mustafa <muhammed@freecodecamp.org>
This commit is contained in:
Niraj Nandish
2023-08-31 16:58:58 +04:00
committed by GitHub
parent f6b970fbee
commit b0022fc45f
4 changed files with 456 additions and 11 deletions

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { omit } from 'lodash';
import { challengeTypes } from '../../../config/challenge-types';
import { devLogin, setupServer, superRequest } from '../../jest.utils';
@@ -37,6 +38,69 @@ const backendChallengeBody2 = {
id: backendChallengeId2
};
// /modern-challenge-completed
const HtmlChallengeId = '5dc174fcf86c76b9248c6eb2';
const JsProjectId = '56533eb9ac21ba0edf2244e2';
const multiFileCertProjectId = 'bd7158d8c242eddfaeb5bd13';
const HtmlChallengeBody = {
challengeType: challengeTypes.html,
id: HtmlChallengeId
};
const JsProjectBody = {
challengeType: challengeTypes.jsProject,
id: JsProjectId,
files: [
{
contents: 'console.log("Hello There!")',
key: 'scriptjs',
ext: 'js',
name: 'script',
history: ['script.js']
}
]
};
const multiFileCertProjectBody = {
challengeType: challengeTypes.multifileCertProject,
id: multiFileCertProjectId,
files: [
{
contents: '<h1>Multi File Project v1</h1>',
key: 'indexhtml',
ext: 'html',
name: 'index',
history: ['index.html']
},
{
contents: '.hello-there { general: kenobi; }',
key: 'stylescss',
ext: 'css',
name: 'styles',
history: ['styles.css']
}
]
};
const updatedMultiFileCertProjectBody = {
challengeType: challengeTypes.multifileCertProject,
id: multiFileCertProjectId,
files: [
{
contents: '<h1>Multi File Project v2</h1>',
key: 'indexhtml',
ext: 'html',
name: 'index',
history: ['index.html']
},
{
contents: '.wibbly-wobbly { timey: wimey; }',
key: 'stylescss',
ext: 'css',
name: 'styles',
history: ['styles.css']
}
]
};
describe('challengeRoutes', () => {
setupServer();
describe('Authenticated user', () => {
@@ -603,6 +667,254 @@ describe('challengeRoutes', () => {
});
});
});
describe('/modern-challenge-completed', () => {
describe('validation', () => {
test('POST rejects requests without ids', async () => {
const response = await superRequest('/modern-challenge-completed', {
method: 'POST',
setCookies
});
expect(response.statusCode).toBe(400);
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
});
test('POST rejects requests without valid ObjectIDs', async () => {
const response = await superRequest('/modern-challenge-completed', {
method: 'POST',
setCookies
}).send({ id: 'not-a-valid-id' });
expect(response.statusCode).toBe(400);
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
});
});
describe('handling', () => {
afterEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: 'foo@bar.com' },
data: {
completedChallenges: [],
savedChallenges: [],
progressTimestamps: []
}
});
});
// HTML(0), JS(1), Modern(6), Video(11), The Odin Project(15)
test('POST accepts challenges without files present', async () => {
const now = Date.now();
const response = await superRequest('/modern-challenge-completed', {
method: 'POST',
setCookies
}).send(HtmlChallengeBody);
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
expect(user).toMatchObject({
completedChallenges: [
{
id: HtmlChallengeId,
completedDate: expect.any(Number)
}
]
});
const completedDate = user.completedChallenges[0]?.completedDate;
expect(completedDate).toBeGreaterThanOrEqual(now);
expect(completedDate).toBeLessThanOrEqual(now + 1000);
expect(response.statusCode).toBe(200);
expect(response.body).toStrictEqual({
alreadyCompleted: false,
points: 1,
completedDate,
savedChallenges: []
});
});
// JS Project(5), Multi-file Cert Project(14)
test('POST accepts challenges with files present', async () => {
const now = Date.now();
const response = await superRequest('/modern-challenge-completed', {
method: 'POST',
setCookies
}).send(JsProjectBody);
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
const file = omit(JsProjectBody.files[0], 'history');
expect(user).toMatchObject({
completedChallenges: [
{
id: JsProjectId,
challengeType: JsProjectBody.challengeType,
files: [file],
completedDate: expect.any(Number)
}
]
});
const completedDate = user.completedChallenges[0]?.completedDate;
expect(completedDate).toBeGreaterThanOrEqual(now);
expect(completedDate).toBeLessThanOrEqual(now + 1000);
expect(response.statusCode).toBe(200);
expect(response.body).toStrictEqual({
alreadyCompleted: false,
points: 1,
completedDate,
savedChallenges: []
});
});
test('POST accepts challenges with saved solutions', async () => {
const now = Date.now();
const response = await superRequest('/modern-challenge-completed', {
method: 'POST',
setCookies
}).send(multiFileCertProjectBody);
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
const testFiles = multiFileCertProjectBody.files.map(
({ history: _history, ...rest }) => rest
);
expect(user).toMatchObject({
needsModeration: true,
completedChallenges: [
{
id: multiFileCertProjectId,
challengeType: multiFileCertProjectBody.challengeType,
files: testFiles,
completedDate: expect.any(Number),
isManuallyApproved: true
}
],
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: expect.any(Number),
files: multiFileCertProjectBody.files
}
]
});
const completedDate = user.completedChallenges[0]?.completedDate;
expect(completedDate).toBeGreaterThanOrEqual(now);
expect(completedDate).toBeLessThanOrEqual(now + 1000);
expect(response.statusCode).toBe(200);
expect(response.body).toStrictEqual({
alreadyCompleted: false,
points: 1,
completedDate,
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: completedDate,
files: multiFileCertProjectBody.files
}
]
});
});
test('POST correctly handles multiple requests', async () => {
const resOriginal = await superRequest(
'/modern-challenge-completed',
{
method: 'POST',
setCookies
}
).send(multiFileCertProjectBody);
await superRequest('/modern-challenge-completed', {
method: 'POST',
setCookies
}).send(HtmlChallengeBody);
const resUpdate = await superRequest('/modern-challenge-completed', {
method: 'POST',
setCookies
}).send(updatedMultiFileCertProjectBody);
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
const expectedProgressTimestamps = user.completedChallenges.map(
challenge => challenge.completedDate
);
const testFiles = updatedMultiFileCertProjectBody.files.map(file =>
omit(file, 'history')
);
expect(user).toMatchObject({
needsModeration: true,
completedChallenges: [
{
id: multiFileCertProjectId,
challengeType: updatedMultiFileCertProjectBody.challengeType,
files: testFiles,
completedDate: expect.any(Number),
isManuallyApproved: true
},
{
id: HtmlChallengeId,
completedDate: expect.any(Number)
}
],
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: expect.any(Number),
files: updatedMultiFileCertProjectBody.files
}
],
progressTimestamps: expectedProgressTimestamps
});
expect(
resUpdate.body.savedChallenges[0].lastSavedDate
).toBeGreaterThan(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
resOriginal.body.savedChallenges[0].lastSavedDate
);
expect(resUpdate.statusCode).toBe(200);
expect(resUpdate.body).toStrictEqual({
alreadyCompleted: true,
points: 2,
completedDate: expect.any(Number),
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: expect.any(Number),
files: updatedMultiFileCertProjectBody.files
}
]
});
});
});
});
});
describe('Unauthenticated user', () => {
@@ -644,5 +956,14 @@ describe('challengeRoutes', () => {
expect(response.statusCode).toBe(401);
});
test('POST /modern-challenge-completed returns 401 status code with error message', async () => {
const response = await superRequest('/modern-challenge-completed', {
method: 'POST',
setCookies
});
expect(response?.statusCode).toBe(401);
});
});
});

View File

@@ -1,13 +1,18 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import jwt from 'jsonwebtoken';
import { uniqBy } from 'lodash';
import { getChallenges } from '../utils/get-challenges';
import { updateUserChallengeData } from '../utils/common-challenge-functions';
import { formatValidationError } from '../utils/error-formatting';
import { schemas } from '../schemas';
import { getPoints, ProgressTimestamp } from '../utils/progress';
import { JWT_SECRET } from '../utils/env';
import { challengeTypes } from '../../../config/challenge-types';
import { schemas } from '../schemas';
import {
jsCertProjectIds,
multifileCertProjectIds,
updateUserChallengeData,
type CompletedChallenge
} from '../utils/common-challenge-functions';
import { JWT_SECRET } from '../utils/env';
import { formatValidationError } from '../utils/error-formatting';
import { getChallenges } from '../utils/get-challenges';
import { ProgressTimestamp, getPoints } from '../utils/progress';
import {
canSubmitCodeRoadCertProject,
createProject,
@@ -310,5 +315,69 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
}
);
fastify.post(
'/modern-challenge-completed',
{
schema: schemas.modernChallengeCompleted,
errorHandler(error, request, reply) {
if (error.validation) {
void reply.code(400);
return formatValidationError(error.validation);
} else {
fastify.errorHandler(error, request, reply);
}
}
},
async (req, reply) => {
try {
const { id, files, challengeType } = req.body;
const user = await fastify.prisma.user.findUniqueOrThrow({
where: { id: req.session.user.id }
});
const RawProgressTimestamp = user.progressTimestamps as
| ProgressTimestamp[]
| null;
const points = getPoints(RawProgressTimestamp);
const completedChallenge: CompletedChallenge = {
id,
files,
completedDate: Date.now()
};
if (challengeType === challengeTypes.multifileCertProject) {
completedChallenge.isManuallyApproved = true;
user.needsModeration = true;
}
if (
jsCertProjectIds.includes(id) ||
multifileCertProjectIds.includes(id)
) {
completedChallenge.challengeType = challengeType;
}
const { alreadyCompleted, userSavedChallenges: savedChallenges } =
await updateUserChallengeData(fastify, user, id, completedChallenge);
return {
alreadyCompleted,
points: alreadyCompleted ? points : points + 1,
completedDate: completedChallenge.completedDate,
savedChallenges
};
} catch (error) {
fastify.log.error(error);
void reply.code(500);
return {
message:
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.',
type: 'danger'
} as const;
}
}
);
done();
};

View File

@@ -423,5 +423,60 @@ export const schemas = {
)
})
}
},
modernChallengeCompleted: {
body: Type.Object({
id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }),
challengeType: Type.Number(),
files: Type.Optional(
Type.Array(
Type.Object({
contents: Type.String(),
key: Type.String(),
ext: Type.String(),
name: Type.String(),
history: Type.Array(Type.String())
})
)
)
}),
response: {
200: Type.Object({
completedDate: Type.Number(),
points: Type.Number(),
alreadyCompleted: Type.Boolean(),
savedChallenges: Type.Array(
Type.Object({
id: Type.String({
format: 'objectid',
maxLength: 24,
minLength: 24
}),
lastSavedDate: Type.Number(),
files: Type.Array(
Type.Object({
contents: Type.String(),
key: Type.String(),
ext: Type.String(),
name: Type.String(),
history: Type.Array(Type.String())
})
)
})
)
}),
400: Type.Object({
type: Type.Literal('error'),
message: Type.Literal(
'That does not appear to be a valid challenge submission.'
)
}),
500: Type.Object({
type: Type.Literal('danger'),
message: Type.Literal(
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.'
)
})
}
}
};

View File

@@ -4,7 +4,7 @@ import { omit, pick } from 'lodash';
import { challengeTypes } from '../../../config/challenge-types';
import { getChallenges } from './get-challenges';
const jsCertProjectIds = [
export const jsCertProjectIds = [
'aaa48de84e1ecc7c742e1124',
'a7f4d8f2483413a6ce226cac',
'56533eb9ac21ba0edf2244e2',
@@ -12,7 +12,7 @@ const jsCertProjectIds = [
'aa2e6f85cab2ab736c9a9b24'
];
const multifileCertProjectIds = getChallenges()
export const multifileCertProjectIds = getChallenges()
.filter(c => c.challengeType === challengeTypes.multifileCertProject)
.map(c => c.id);
@@ -24,14 +24,14 @@ type SavedChallengeFile = {
key: string;
ext: string; // NOTE: This is Ext type in client
name: string;
history?: string[];
history: string[];
contents: string;
};
type SavedChallenge = {
id: string;
lastSavedDate: number;
files?: SavedChallengeFile[];
files: SavedChallengeFile[];
};
// TODO: Confirm this type - read comments below
@@ -55,7 +55,7 @@ type CompletedChallengeFile = {
path?: string | null;
};
type CompletedChallenge = {
export type CompletedChallenge = {
id: string;
solution?: string | null;
githubLink?: string | null;