mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-03-18 16:01:35 -04:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user