mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-10-24 12:13:31 +08:00
feat: coderoad endpoint (#50636)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com> Co-authored-by: Tom <20648924+moT01@users.noreply.github.com> Co-authored-by: Naomi Carrigan <nhcarrigan@gmail.com> Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com>
This commit is contained in:
co-authored by
Oliver Eyton-Williams
Tom
Naomi Carrigan
Mrugesh Mohapatra
parent
d435706822
commit
d31e910f3a
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { challengeTypes } from '../../../config/challenge-types';
|
||||
@@ -46,6 +47,175 @@ describe('challengeRoutes', () => {
|
||||
setCookies = await devLogin();
|
||||
});
|
||||
|
||||
describe('POST /coderoad-challenge-completed', () => {
|
||||
test('should return 500 if no tutorialId', async () => {
|
||||
const response = await superRequest('/coderoad-challenge-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
test('should return 400 if no user token', async () => {
|
||||
const response = await superRequest('/coderoad-challenge-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send({
|
||||
tutorialId: 'freeCodeCamp/learn-bash-by-building-a-boilerplate:v1.0.0'
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
msg: `'Coderoad-User-Token' not found in request headers`,
|
||||
type: 'error'
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 400 if invalid user token', async () => {
|
||||
const response = await superRequest('/coderoad-challenge-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
})
|
||||
.set('coderoad-user-token', 'invalid')
|
||||
.send({
|
||||
tutorialId:
|
||||
'freeCodeCamp/learn-bash-by-building-a-boilerplate:v1.0.0'
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
msg: 'invalid user token',
|
||||
type: 'error'
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 400 if invalid tutorialId', async () => {
|
||||
const tokenResponse = await superRequest('/user/user-token', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
});
|
||||
expect(tokenResponse.status).toBe(200);
|
||||
expect(tokenResponse.body).toHaveProperty('userToken');
|
||||
|
||||
const token = (tokenResponse.body as { userToken: string }).userToken;
|
||||
|
||||
const response = await superRequest('/coderoad-challenge-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
})
|
||||
.set('coderoad-user-token', token)
|
||||
.send({ tutorialId: 'invalid' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
msg: 'Tutorial not hosted on freeCodeCamp GitHub account',
|
||||
type: 'error'
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 400 if invalid tutorialId but is hosted on freeCodeCamp', async () => {
|
||||
const tokenResponse = await superRequest('/user/user-token', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
});
|
||||
expect(tokenResponse.status).toBe(200);
|
||||
expect(tokenResponse.body).toHaveProperty('userToken');
|
||||
|
||||
const token = (tokenResponse.body as { userToken: string }).userToken;
|
||||
|
||||
const response = await superRequest('/coderoad-challenge-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
})
|
||||
.set('coderoad-user-token', token)
|
||||
.send({ tutorialId: 'freeCodeCamp/invalid:V1.0.0' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
msg: 'Tutorial name is not valid',
|
||||
type: 'error'
|
||||
});
|
||||
});
|
||||
|
||||
test('Should complete challenge with code 200', async () => {
|
||||
const tokenResponse = await superRequest('/user/user-token', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
});
|
||||
expect(tokenResponse.status).toBe(200);
|
||||
expect(tokenResponse.body).toHaveProperty('userToken');
|
||||
|
||||
const token = (tokenResponse.body as { userToken: string }).userToken;
|
||||
|
||||
const response = await superRequest('/coderoad-challenge-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
})
|
||||
.set('coderoad-user-token', token)
|
||||
.send({
|
||||
tutorialId:
|
||||
'freeCodeCamp/learn-bash-by-building-a-boilerplate:v1.0.0'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
msg: 'Successfully submitted challenge',
|
||||
type: 'success'
|
||||
});
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email: 'foo@bar.com' }
|
||||
});
|
||||
|
||||
const challengeCompleted = user?.completedChallenges.some(challenge => {
|
||||
return challenge.id === '5ea8adfab628f68d805bfc5e';
|
||||
});
|
||||
|
||||
expect(challengeCompleted).toBe(true);
|
||||
});
|
||||
|
||||
test('Should complete project with code 200', async () => {
|
||||
const tokenResponse = await superRequest('/user/user-token', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
});
|
||||
expect(tokenResponse.status).toBe(200);
|
||||
expect(tokenResponse.body).toHaveProperty('userToken');
|
||||
|
||||
const token = (tokenResponse.body as { userToken: string }).userToken;
|
||||
|
||||
const response = await superRequest('/coderoad-challenge-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
})
|
||||
.set('coderoad-user-token', token)
|
||||
.send({
|
||||
tutorialId: 'freeCodeCamp/learn-celestial-bodies-database:v1.0.0'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email: 'foo@bar.com' }
|
||||
});
|
||||
|
||||
const projectCompleted = user?.partiallyCompletedChallenges.some(
|
||||
project => {
|
||||
return project.id === '5f1a4ef5d5d6b5ab580fc6ae';
|
||||
}
|
||||
);
|
||||
|
||||
expect(projectCompleted).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: 'foo@bar.com' },
|
||||
data: {
|
||||
completedChallenges: [],
|
||||
progressTimestamps: []
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('/project-completed', () => {
|
||||
describe('validation', () => {
|
||||
it('POST rejects requests without ids', async () => {
|
||||
@@ -444,6 +614,17 @@ describe('challengeRoutes', () => {
|
||||
setCookies = res.get('Set-Cookie');
|
||||
});
|
||||
|
||||
describe('/coderoad-challenge-completed', () => {
|
||||
test('POST returns 401 status code with error message', async () => {
|
||||
const response = await superRequest('/coderoad-challenge-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
});
|
||||
|
||||
expect(response?.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/project-completed', () => {
|
||||
test('POST returns 401 status code with error message', async () => {
|
||||
const response = await superRequest('/project-completed', {
|
||||
@@ -453,15 +634,15 @@ describe('challengeRoutes', () => {
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test('POST /backend-challenge-completed returns 401 status code for un-authenticated-user', async () => {
|
||||
const response = await superRequest('/backend-challenge-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
test('POST /backend-challenge-completed returns 401 status code for un-authenticated-user', async () => {
|
||||
const response = await superRequest('/backend-challenge-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+141
-2
@@ -1,8 +1,11 @@
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
|
||||
import { schemas } from '../schemas';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { jwtSecret } from '../../../config/secrets';
|
||||
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 { challengeTypes } from '../../../config/challenge-types';
|
||||
import {
|
||||
@@ -11,6 +14,10 @@ import {
|
||||
updateProject
|
||||
} from './helpers/challenge-helpers';
|
||||
|
||||
interface JwtPayload {
|
||||
userToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin for the challenge submission endpoints.
|
||||
*
|
||||
@@ -23,10 +30,142 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
const challenges = getChallenges();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
fastify.addHook('onRequest', fastify.csrfProtection);
|
||||
fastify.addHook('onRequest', fastify.authenticateSession);
|
||||
|
||||
fastify.post(
|
||||
'/coderoad-challenge-completed',
|
||||
{
|
||||
schema: schemas.coderoadChallengeCompleted,
|
||||
attachValidation: true
|
||||
},
|
||||
async (req, reply) => {
|
||||
let userToken;
|
||||
|
||||
const { 'coderoad-user-token': encodedUserToken } = req.headers;
|
||||
const { tutorialId } = req.body;
|
||||
|
||||
if (!tutorialId) {
|
||||
void reply.code(400);
|
||||
return {
|
||||
type: 'error',
|
||||
msg: `'tutorialId' not found in request body`
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (!encodedUserToken) {
|
||||
void reply.code(400);
|
||||
return {
|
||||
type: 'error',
|
||||
msg: `'Coderoad-User-Token' not found in request headers`
|
||||
} as const;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof encodedUserToken === 'string' && jwtSecret) {
|
||||
const payload = jwt.verify(encodedUserToken, jwtSecret) as JwtPayload;
|
||||
userToken = payload.userToken;
|
||||
}
|
||||
} catch {
|
||||
void reply.code(400);
|
||||
return { type: 'error', msg: `invalid user token` } as const;
|
||||
}
|
||||
|
||||
const tutorialRepo = tutorialId?.split(':')[0];
|
||||
const tutorialOrg = tutorialRepo?.split('/')?.[0];
|
||||
|
||||
if (tutorialOrg !== 'freeCodeCamp') {
|
||||
void reply.code(400);
|
||||
return {
|
||||
type: 'error',
|
||||
msg: `Tutorial not hosted on freeCodeCamp GitHub account`
|
||||
} as const;
|
||||
}
|
||||
|
||||
const codeRoadChallenges = challenges.filter(
|
||||
({ challengeType }) => challengeType === 12 || challengeType === 13
|
||||
);
|
||||
|
||||
const challenge = codeRoadChallenges.find(challenge => {
|
||||
return tutorialRepo && challenge.url?.endsWith(tutorialRepo);
|
||||
});
|
||||
|
||||
if (!challenge) {
|
||||
void reply.code(400);
|
||||
return { type: 'error', msg: 'Tutorial name is not valid' } as const;
|
||||
}
|
||||
|
||||
const { id: challengeId, challengeType } = challenge;
|
||||
try {
|
||||
const tokenInfo = await fastify.prisma.userToken.findUnique({
|
||||
where: { id: userToken }
|
||||
});
|
||||
|
||||
if (!tokenInfo) {
|
||||
void reply.code(400);
|
||||
return { type: 'error', msg: 'User token not found' } as const;
|
||||
}
|
||||
|
||||
const { userId } = tokenInfo;
|
||||
|
||||
const user = await fastify.prisma.user.findFirstOrThrow({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
void reply.code(400);
|
||||
return {
|
||||
type: 'error',
|
||||
msg: 'User for user token not found'
|
||||
} as const;
|
||||
}
|
||||
|
||||
const completedDate = Date.now();
|
||||
const { completedChallenges = [], partiallyCompletedChallenges = [] } =
|
||||
user;
|
||||
|
||||
const isCompleted = completedChallenges.some(
|
||||
challenge => challenge.id === challengeId
|
||||
);
|
||||
|
||||
if (challengeType === 13 && !isCompleted) {
|
||||
const finalChallenge = {
|
||||
id: challengeId,
|
||||
completedDate
|
||||
};
|
||||
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
data: {
|
||||
partiallyCompletedChallenges: uniqBy(
|
||||
[finalChallenge, ...partiallyCompletedChallenges],
|
||||
'id'
|
||||
)
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await updateUserChallengeData(fastify, user, challengeId, {
|
||||
id: challengeId,
|
||||
completedDate
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
void reply.code(400);
|
||||
return {
|
||||
type: 'error',
|
||||
msg: 'An error occurred trying to submit the challenge'
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
type: 'success',
|
||||
msg: 'Successfully submitted challenge'
|
||||
} as const;
|
||||
}
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/project-completed',
|
||||
{
|
||||
|
||||
@@ -381,6 +381,25 @@ export const schemas = {
|
||||
})
|
||||
}
|
||||
},
|
||||
coderoadChallengeCompleted: {
|
||||
body: Type.Object({
|
||||
tutorialId: Type.String()
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({
|
||||
type: Type.Literal('success'),
|
||||
msg: Type.String()
|
||||
}),
|
||||
400: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
msg: Type.String()
|
||||
}),
|
||||
500: Type.Object({
|
||||
type: Type.Literal('danger'),
|
||||
msg: Type.String()
|
||||
})
|
||||
}
|
||||
},
|
||||
backendChallengeCompleted: {
|
||||
body: Type.Object({
|
||||
id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 })
|
||||
|
||||
@@ -13,6 +13,7 @@ interface Block {
|
||||
id: string;
|
||||
tests?: { id?: string }[];
|
||||
challengeType: number;
|
||||
url?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user