mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(api): project-completed (#50701)
Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com> Co-authored-by: Niraj Nandish <nirajnandish@icloud.com>
This commit is contained in:
committed by
GitHub
parent
42a2712041
commit
5482650dd0
+5
-3
@@ -12,14 +12,17 @@
|
||||
"@fastify/swagger-ui": "^1.5.0",
|
||||
"@immobiliarelabs/fastify-sentry": "^6.0.0",
|
||||
"@prisma/client": "4.16.2",
|
||||
"connect-mongo": "4.6.0",
|
||||
"ajv": "8.12.0",
|
||||
"ajv-formats": "^2.1.1",
|
||||
"bad-words": "3.0.4",
|
||||
"connect-mongo": "4.6.0",
|
||||
"fast-uri": "2.2.0",
|
||||
"fastify": "4.19.2",
|
||||
"fastify-auth0-verify": "^1.0.0",
|
||||
"fastify-plugin": "^4.3.0",
|
||||
"jsonwebtoken": "9.0.1",
|
||||
"mongodb": "^4.16.0",
|
||||
"nanoid": "3",
|
||||
"mongodb": "4",
|
||||
"nodemon": "2.0.22",
|
||||
"query-string": "^7.1.3"
|
||||
},
|
||||
@@ -30,7 +33,6 @@
|
||||
"@types/express-session": "1.17.7",
|
||||
"@types/jsonwebtoken": "^9.0.2",
|
||||
"@types/supertest": "2.0.12",
|
||||
"ajv": "8.12.0",
|
||||
"dotenv-cli": "7.2.1",
|
||||
"jest": "29.6.1",
|
||||
"pino-pretty": "10.0.1",
|
||||
|
||||
@@ -7,6 +7,7 @@ import Fastify, {
|
||||
RawRequestDefaultExpression,
|
||||
RawServerDefault
|
||||
} from 'fastify';
|
||||
import Ajv from 'ajv';
|
||||
import middie from '@fastify/middie';
|
||||
import fastifySession from '@fastify/session';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
@@ -16,6 +17,8 @@ import fastifySwagger from '@fastify/swagger';
|
||||
import fastifySwaggerUI from '@fastify/swagger-ui';
|
||||
import fastifyCsrfProtection from '@fastify/csrf-protection';
|
||||
import fastifySentry from '@immobiliarelabs/fastify-sentry';
|
||||
import uriResolver from 'fast-uri';
|
||||
import addFormats from 'ajv-formats';
|
||||
|
||||
import cors from './plugins/cors';
|
||||
import jwtAuthz from './plugins/fastify-jwt-authz';
|
||||
@@ -40,10 +43,12 @@ import {
|
||||
FCC_ENABLE_DEV_LOGIN_MODE,
|
||||
SENTRY_DSN
|
||||
} from './utils/env';
|
||||
import { challengeRoutes } from './routes/challenge';
|
||||
import { userRoutes } from './routes/user';
|
||||
import { donateRoutes } from './routes/donate';
|
||||
import { statusRoute } from './routes/status';
|
||||
import { unsubscribeDeprecated } from './routes/deprecated-unsubscribe';
|
||||
import { isObjectID } from './utils/validation';
|
||||
|
||||
export type FastifyInstanceWithTypeProvider = FastifyInstance<
|
||||
RawServerDefault,
|
||||
@@ -53,6 +58,25 @@ export type FastifyInstanceWithTypeProvider = FastifyInstance<
|
||||
TypeBoxTypeProvider
|
||||
>;
|
||||
|
||||
// Options that fastify uses
|
||||
const ajv = new Ajv({
|
||||
coerceTypes: 'array', // change data type of data to match type keyword
|
||||
useDefaults: true, // replace missing properties and items with the values from corresponding default keyword
|
||||
removeAdditional: true, // remove additional properties
|
||||
uriResolver,
|
||||
addUsedSchema: false,
|
||||
// Explicitly set allErrors to `false`.
|
||||
// When set to `true`, a DoS attack is possible.
|
||||
allErrors: false
|
||||
});
|
||||
|
||||
// add the default formatters from avj-formats
|
||||
addFormats(ajv);
|
||||
ajv.addFormat('objectid', {
|
||||
type: 'string',
|
||||
validate: (str: string) => isObjectID(str)
|
||||
});
|
||||
|
||||
export const build = async (
|
||||
options: FastifyHttpOptions<RawServerDefault, FastifyBaseLogger> = {}
|
||||
): Promise<FastifyInstanceWithTypeProvider> => {
|
||||
@@ -60,6 +84,8 @@ export const build = async (
|
||||
// Watch when implementing in client
|
||||
const fastify = Fastify(options).withTypeProvider<TypeBoxTypeProvider>();
|
||||
|
||||
fastify.setValidatorCompiler(({ schema }) => ajv.compile(schema));
|
||||
|
||||
void fastify.register(security);
|
||||
|
||||
fastify.get('/', async (_request, _reply) => {
|
||||
@@ -169,6 +195,7 @@ export const build = async (
|
||||
if (FCC_ENABLE_DEV_LOGIN_MODE) {
|
||||
void fastify.register(devLoginCallback, { prefix: '/auth' });
|
||||
}
|
||||
void fastify.register(challengeRoutes);
|
||||
void fastify.register(settingRoutes);
|
||||
void fastify.register(donateRoutes);
|
||||
void fastify.register(userRoutes);
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { setupServer, superRequest } from '../../jest.utils';
|
||||
|
||||
const isValidChallengeCompletionErrorMsg = {
|
||||
type: 'error',
|
||||
message: 'That does not appear to be a valid challenge submission.'
|
||||
};
|
||||
|
||||
const id1 = 'bd7123c8c441eddfaeb5bdef';
|
||||
const id2 = 'bd7123c8c441eddfaeb5bdec';
|
||||
|
||||
const codeallyProject = {
|
||||
id: id1,
|
||||
challengeType: 13,
|
||||
solution: 'https://any.valid/url'
|
||||
};
|
||||
const backendProject = {
|
||||
id: id2,
|
||||
challengeType: 4,
|
||||
solution: 'https://any.valid/url',
|
||||
githubLink: 'https://github.com/anything/valid/'
|
||||
};
|
||||
|
||||
const partialCompletion = { id: id1, completedDate: 1 };
|
||||
|
||||
describe('challengeRoutes', () => {
|
||||
setupServer();
|
||||
describe('Authenticated user', () => {
|
||||
let setCookies: string[];
|
||||
|
||||
// Authenticate user
|
||||
beforeAll(async () => {
|
||||
const res = await superRequest('/auth/dev-callback', { method: 'GET' });
|
||||
expect(res.status).toBe(200);
|
||||
setCookies = res.get('Set-Cookie');
|
||||
});
|
||||
|
||||
describe('/project-completed', () => {
|
||||
describe('validation', () => {
|
||||
it('POST rejects requests without ids', async () => {
|
||||
const response = await superRequest('/project-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send({});
|
||||
|
||||
expect(response.body).toStrictEqual(
|
||||
isValidChallengeCompletionErrorMsg
|
||||
);
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('POST rejects requests without valid ObjectIDs', async () => {
|
||||
const response = await superRequest('/project-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
// This is a departure from api-server, which does not require a
|
||||
// solution to give this error. However, the validator will reject
|
||||
// based on the missing solution before it gets to the invalid id.
|
||||
}).send({ id: 'not-a-valid-id', solution: '' });
|
||||
|
||||
expect(response.body).toStrictEqual(
|
||||
isValidChallengeCompletionErrorMsg
|
||||
);
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('POST rejects requests with invalid challengeTypes', async () => {
|
||||
const response = await superRequest('/project-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send({
|
||||
id: id1,
|
||||
challengeType: 'not-a-valid-challenge-type',
|
||||
// TODO(Post-MVP): drop these comments, since the api-server will not
|
||||
// exist.
|
||||
|
||||
// a solution is required, because otherwise the request will be
|
||||
// rejected before it gets to the challengeType validation. NOTE: this
|
||||
// is a departure from the api-server, but only in the message sent.
|
||||
solution: ''
|
||||
});
|
||||
|
||||
expect(response.body).toStrictEqual(
|
||||
isValidChallengeCompletionErrorMsg
|
||||
);
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('POST rejects requests without solutions', async () => {
|
||||
const response = await superRequest('/project-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send({
|
||||
id: id1,
|
||||
challengeType: 3
|
||||
});
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
type: 'error',
|
||||
message:
|
||||
'You have not provided the valid links for us to inspect your work.'
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('POST rejects requests with solutions that are not urls', async () => {
|
||||
const response = await superRequest('/project-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send({
|
||||
id: id1,
|
||||
challengeType: 3,
|
||||
solution: 'not-a-valid-solution'
|
||||
});
|
||||
|
||||
expect(response.body).toStrictEqual(
|
||||
isValidChallengeCompletionErrorMsg
|
||||
);
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('POST rejects CodeRoad/CodeAlly projects when the user has not completed the required challenges', async () => {
|
||||
const response = await superRequest('/project-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send({
|
||||
id: id1, // not a codeally challenge id, but does not matter
|
||||
challengeType: 13, // this does matter, however, since there's special logic for that challenge type
|
||||
solution: 'https://any.valid/url'
|
||||
});
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
type: 'error',
|
||||
message:
|
||||
'You have to complete the project before you can submit a URL.'
|
||||
});
|
||||
// It's not really a bad request, since the client is sending a valid
|
||||
// body. It's just that the user is not allowed to do this - hence 403.
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handling', () => {
|
||||
beforeEach(async () => {
|
||||
// setup: complete the challenges that codeally projects require
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: 'foo@bar.com' },
|
||||
data: {
|
||||
partiallyCompletedChallenges: [{ id: id1, completedDate: 1 }],
|
||||
completedChallenges: [],
|
||||
progressTimestamps: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.updateMany({
|
||||
where: { email: 'foo@bar.com' },
|
||||
data: {
|
||||
partiallyCompletedChallenges: [],
|
||||
completedChallenges: [],
|
||||
savedChallenges: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('POST accepts CodeRoad/CodeAlly projects when the user has completed the required challenges', async () => {
|
||||
const now = Date.now();
|
||||
const response = await superRequest('/project-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(codeallyProject);
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email: 'foo@bar.com' }
|
||||
});
|
||||
|
||||
expect(user).toMatchObject({
|
||||
partiallyCompletedChallenges: [],
|
||||
completedChallenges: [
|
||||
{
|
||||
...codeallyProject,
|
||||
completedDate: expect.any(Number)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const completedDate = user?.completedChallenges[0]?.completedDate;
|
||||
|
||||
// TODO: use a custom matcher for this
|
||||
expect(completedDate).toBeGreaterThan(now);
|
||||
expect(completedDate).toBeLessThan(now + 1000);
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
alreadyCompleted: false,
|
||||
points: 1,
|
||||
completedDate
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('POST accepts backend projects', async () => {
|
||||
const now = Date.now();
|
||||
|
||||
const response = await superRequest('/project-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(backendProject);
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email: 'foo@bar.com' }
|
||||
});
|
||||
|
||||
expect(user).toMatchObject({
|
||||
partiallyCompletedChallenges: [partialCompletion],
|
||||
completedChallenges: [
|
||||
{
|
||||
...backendProject,
|
||||
completedDate: expect.any(Number)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const completedDate = user?.completedChallenges[0]?.completedDate;
|
||||
|
||||
// TODO: use a custom matcher for this
|
||||
expect(completedDate).toBeGreaterThan(now);
|
||||
expect(completedDate).toBeLessThan(now + 1000);
|
||||
|
||||
expect(response.body).toStrictEqual({
|
||||
alreadyCompleted: false,
|
||||
points: 1,
|
||||
completedDate
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('POST correctly handles multiple requests', async () => {
|
||||
const resOriginal = await superRequest('/project-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(codeallyProject);
|
||||
|
||||
const resBackend = await superRequest('/project-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send(backendProject);
|
||||
|
||||
// sending backendProject again should update its solution, but not
|
||||
// progressTimestamps or its completedDate
|
||||
|
||||
const resUpdate = await superRequest('/project-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send({ ...codeallyProject, solution: 'https://any.other/url' });
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||
where: { email: 'foo@bar.com' }
|
||||
});
|
||||
|
||||
const expectedProgressTimestamps = user?.completedChallenges.map(
|
||||
challenge => challenge.completedDate
|
||||
);
|
||||
|
||||
expect(user).toMatchObject({
|
||||
completedChallenges: [
|
||||
{
|
||||
...codeallyProject,
|
||||
solution: 'https://any.other/url',
|
||||
completedDate: resOriginal.body.completedDate
|
||||
},
|
||||
{
|
||||
...backendProject,
|
||||
completedDate: resBackend.body.completedDate
|
||||
}
|
||||
],
|
||||
progressTimestamps: expectedProgressTimestamps
|
||||
});
|
||||
|
||||
expect(resUpdate.body).toStrictEqual({
|
||||
alreadyCompleted: true,
|
||||
points: 2,
|
||||
completedDate: expect.any(Number)
|
||||
});
|
||||
|
||||
// It should return an updated completedDate
|
||||
expect(resUpdate.body.completedDate).not.toBe(
|
||||
resOriginal.body.completedDate
|
||||
);
|
||||
expect(resUpdate.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Unauthenticated user', () => {
|
||||
let setCookies: string[];
|
||||
|
||||
// Get the CSRF cookies from an unprotected route
|
||||
beforeAll(async () => {
|
||||
const res = await superRequest('/', { method: 'GET' });
|
||||
setCookies = res.get('Set-Cookie');
|
||||
});
|
||||
|
||||
describe('/project-completed', () => {
|
||||
test('POST returns 401 status code with error message', async () => {
|
||||
const response = await superRequest('/project-completed', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
});
|
||||
|
||||
expect(response?.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
|
||||
import { formatValidationError } from '../utils/error-formatting';
|
||||
import { ProgressTimestamp, getPoints } from '../utils/progress';
|
||||
import { schemas } from '../schemas';
|
||||
import {
|
||||
canSubmitCodeRoadCertProject,
|
||||
createProject,
|
||||
updateProject
|
||||
} from './helpers/challenge-helpers';
|
||||
|
||||
export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
fastify.addHook('onRequest', fastify.csrfProtection);
|
||||
fastify.addHook('onRequest', fastify.authenticateSession);
|
||||
|
||||
fastify.post(
|
||||
'/project-completed',
|
||||
{
|
||||
schema: schemas.projectCompleted,
|
||||
errorHandler(error, request, reply) {
|
||||
if (error.validation) {
|
||||
void reply.code(400);
|
||||
return formatValidationError(error.validation);
|
||||
} else {
|
||||
fastify.errorHandler(error, request, reply);
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const { id: projectId, challengeType, solution, githubLink } = req.body;
|
||||
const userId = req.session.user.id;
|
||||
|
||||
try {
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
completedChallenges: true,
|
||||
partiallyCompletedChallenges: true,
|
||||
progressTimestamps: true
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
challengeType === 13 &&
|
||||
!canSubmitCodeRoadCertProject(projectId, user)
|
||||
) {
|
||||
void reply.code(403);
|
||||
return {
|
||||
type: 'error',
|
||||
message:
|
||||
'You have to complete the project before you can submit a URL.'
|
||||
} as const;
|
||||
}
|
||||
|
||||
const completedDate = Date.now();
|
||||
const oldChallenge = user.completedChallenges?.find(
|
||||
({ id }) => id === projectId
|
||||
);
|
||||
const updatedChallenge = {
|
||||
challengeType,
|
||||
solution,
|
||||
githubLink
|
||||
};
|
||||
const newChallenge = {
|
||||
...updatedChallenge,
|
||||
id: projectId,
|
||||
completedDate
|
||||
};
|
||||
const alreadyCompleted = !!oldChallenge;
|
||||
const progressTimestamps =
|
||||
user.progressTimestamps as ProgressTimestamp[];
|
||||
const points = getPoints(progressTimestamps);
|
||||
|
||||
const data = alreadyCompleted
|
||||
? updateProject(projectId, updatedChallenge)
|
||||
: createProject(projectId, newChallenge, progressTimestamps);
|
||||
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data
|
||||
});
|
||||
|
||||
return {
|
||||
alreadyCompleted,
|
||||
// TODO(Post-MVP): audit the client and remove this if the client does
|
||||
// not use it.
|
||||
completedDate,
|
||||
points: alreadyCompleted ? points : points + 1
|
||||
};
|
||||
} catch (err) {
|
||||
// TODO: send to Sentry
|
||||
fastify.log.error(err);
|
||||
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();
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
PartiallyCompletedChallenge,
|
||||
CompletedChallenge
|
||||
} from '@prisma/client';
|
||||
|
||||
import { canSubmitCodeRoadCertProject } from './challenge-helpers';
|
||||
|
||||
const id = 'abc';
|
||||
|
||||
const partiallyCompletedChallenges: PartiallyCompletedChallenge[] = [
|
||||
{
|
||||
id,
|
||||
completedDate: 1
|
||||
}
|
||||
];
|
||||
const completedChallenges: CompletedChallenge[] = [
|
||||
{
|
||||
id,
|
||||
completedDate: 1,
|
||||
challengeType: 1,
|
||||
files: [],
|
||||
githubLink: null,
|
||||
solution: null,
|
||||
isManuallyApproved: false
|
||||
}
|
||||
];
|
||||
|
||||
describe('Challenge Helpers', () => {
|
||||
describe('canSubmitCodeRoadCertProject', () => {
|
||||
it('returns true if the user has completed the required challenges or partially completed them', () => {
|
||||
expect(
|
||||
canSubmitCodeRoadCertProject(id, {
|
||||
partiallyCompletedChallenges,
|
||||
completedChallenges
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canSubmitCodeRoadCertProject(id, {
|
||||
partiallyCompletedChallenges: [],
|
||||
completedChallenges
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canSubmitCodeRoadCertProject(id, {
|
||||
partiallyCompletedChallenges,
|
||||
completedChallenges: []
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if the user has not completed the required challenges', () => {
|
||||
expect(
|
||||
canSubmitCodeRoadCertProject(id, {
|
||||
partiallyCompletedChallenges: [],
|
||||
completedChallenges: []
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if the id is undefined', () => {
|
||||
expect(
|
||||
canSubmitCodeRoadCertProject(undefined, {
|
||||
partiallyCompletedChallenges,
|
||||
completedChallenges
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import type { ProgressTimestamp } from '../../utils/progress';
|
||||
|
||||
export const canSubmitCodeRoadCertProject = (
|
||||
id: string | undefined,
|
||||
{
|
||||
partiallyCompletedChallenges,
|
||||
completedChallenges
|
||||
}: {
|
||||
partiallyCompletedChallenges: { id: string }[];
|
||||
completedChallenges: { id: string }[];
|
||||
}
|
||||
) => {
|
||||
if (partiallyCompletedChallenges.some(c => c.id === id)) return true;
|
||||
if (completedChallenges.some(c => c.id === id)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const updateProject = (
|
||||
id: string,
|
||||
newChallenge: Prisma.CompletedChallengeUpdateInput
|
||||
) => ({
|
||||
completedChallenges: {
|
||||
updateMany: { where: { id }, data: newChallenge }
|
||||
},
|
||||
partiallyCompletedChallenges: { deleteMany: { where: { id } } }
|
||||
});
|
||||
|
||||
export const createProject = (
|
||||
id: string,
|
||||
newChallenge: Prisma.CompletedChallengeCreateInput,
|
||||
progressTimestamps: ProgressTimestamp[]
|
||||
) => ({
|
||||
completedChallenges: { push: newChallenge },
|
||||
partiallyCompletedChallenges: { deleteMany: { where: { id } } },
|
||||
progressTimestamps: [...progressTimestamps, newChallenge.completedDate]
|
||||
});
|
||||
+51
-12
@@ -1,5 +1,12 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
const generic500 = Type.Object({
|
||||
message: Type.Literal(
|
||||
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.'
|
||||
),
|
||||
type: Type.Literal('danger')
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
// Settings:
|
||||
updateMyProfileUI: {
|
||||
@@ -165,23 +172,13 @@ export const schemas = {
|
||||
deleteMyAccount: {
|
||||
response: {
|
||||
200: Type.Object({}),
|
||||
500: Type.Object({
|
||||
message: Type.Literal(
|
||||
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.'
|
||||
),
|
||||
type: Type.Literal('danger')
|
||||
})
|
||||
500: generic500
|
||||
}
|
||||
},
|
||||
resetMyProgress: {
|
||||
response: {
|
||||
200: Type.Object({}),
|
||||
500: Type.Object({
|
||||
message: Type.Literal(
|
||||
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.'
|
||||
),
|
||||
type: Type.Literal('danger')
|
||||
})
|
||||
500: generic500
|
||||
}
|
||||
},
|
||||
getSessionUser: {
|
||||
@@ -324,5 +321,47 @@ export const schemas = {
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
// Challenges:
|
||||
projectCompleted: {
|
||||
body: Type.Object({
|
||||
id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }),
|
||||
challengeType: Type.Optional(Type.Number()),
|
||||
solution: Type.String({ format: 'url', maxLength: 1024 }),
|
||||
// TODO(Post-MVP): require format: 'url' for githubLink
|
||||
githubLink: Type.Optional(Type.String())
|
||||
}),
|
||||
response: {
|
||||
200: Type.Object({
|
||||
// TODO(Post-MVP): delete completedDate and alreadyCompleted? As far as
|
||||
// I can tell, they are not used anywhere
|
||||
completedDate: Type.Number(),
|
||||
points: Type.Number(),
|
||||
alreadyCompleted: Type.Boolean()
|
||||
}),
|
||||
400: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Union([
|
||||
Type.Literal(
|
||||
'That does not appear to be a valid challenge submission.'
|
||||
),
|
||||
Type.Literal(
|
||||
'You have not provided the valid links for us to inspect your work.'
|
||||
)
|
||||
])
|
||||
}),
|
||||
403: Type.Object({
|
||||
type: Type.Literal('error'),
|
||||
message: Type.Literal(
|
||||
'You have to complete the project before you can submit a URL.'
|
||||
)
|
||||
}),
|
||||
500: Type.Object({
|
||||
message: Type.Literal(
|
||||
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.'
|
||||
),
|
||||
type: Type.Literal('danger')
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ErrorObject } from 'ajv';
|
||||
import { formatValidationError } from './error-formatting';
|
||||
|
||||
const missingSolutionError = {
|
||||
instancePath: '',
|
||||
schemaPath: '#/required',
|
||||
keyword: 'required',
|
||||
params: { missingProperty: 'solution' },
|
||||
message: "must have required property 'solution'"
|
||||
};
|
||||
|
||||
const missingIdError = {
|
||||
instancePath: '',
|
||||
schemaPath: '#/required',
|
||||
keyword: 'required',
|
||||
params: { missingProperty: 'id' },
|
||||
message: "must have required property 'id'"
|
||||
};
|
||||
|
||||
describe('Error formatting', () => {
|
||||
describe('formatValidationError', () => {
|
||||
it('should handle missing solutions', () => {
|
||||
const formattedError = formatValidationError([missingSolutionError]);
|
||||
|
||||
expect(formattedError).toStrictEqual({
|
||||
type: 'error',
|
||||
message:
|
||||
'You have not provided the valid links for us to inspect your work.'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing ids', () => {
|
||||
const formattedError = formatValidationError([missingIdError]);
|
||||
|
||||
expect(formattedError).toStrictEqual({
|
||||
type: 'error',
|
||||
|
||||
message: 'That does not appear to be a valid challenge submission.'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return a generic error message for other errors', () => {
|
||||
const formattedError = formatValidationError([
|
||||
{
|
||||
...missingSolutionError,
|
||||
params: { missingProperty: 'notSolution' }
|
||||
}
|
||||
]);
|
||||
|
||||
expect(formattedError).toStrictEqual({
|
||||
type: 'error',
|
||||
message: 'That does not appear to be a valid challenge submission.'
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw if passed zero errors', () => {
|
||||
expect(() => formatValidationError([] as ErrorObject[])).toThrow(
|
||||
Error(
|
||||
'Bad Argument: the array of errors must have exactly one element.'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if passed more than one error', () => {
|
||||
expect(() => formatValidationError([{}, {}] as ErrorObject[])).toThrow(
|
||||
Error(
|
||||
'Bad Argument: the array of errors must have exactly one element.'
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ErrorObject } from 'ajv';
|
||||
|
||||
export type FormattedError = {
|
||||
type: 'error';
|
||||
message:
|
||||
| 'You have not provided the valid links for us to inspect your work.'
|
||||
| 'That does not appear to be a valid challenge submission.'
|
||||
// the next isn't generated here, but the type is more general.
|
||||
| 'You have to complete the project before you can submit a URL.';
|
||||
};
|
||||
|
||||
// This only formats invalid challenge submission for now.
|
||||
export const formatValidationError = (
|
||||
errors: ErrorObject[]
|
||||
): FormattedError => {
|
||||
if (errors.length !== 1) {
|
||||
throw new Error(
|
||||
'Bad Argument: the array of errors must have exactly one element.'
|
||||
);
|
||||
}
|
||||
|
||||
return errors[0]?.params.missingProperty === 'solution'
|
||||
? {
|
||||
type: 'error',
|
||||
message:
|
||||
'You have not provided the valid links for us to inspect your work.'
|
||||
}
|
||||
: {
|
||||
type: 'error',
|
||||
message: 'That does not appear to be a valid challenge submission.'
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isObjectID } from './validation';
|
||||
|
||||
describe('Validation', () => {
|
||||
describe('isObjectID', () => {
|
||||
it('returns true for valid ObjectIDs', () => {
|
||||
expect(isObjectID('5f1e0f3b5d2c12b0b8f7a6b9')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for invalid ObjectIDs', () => {
|
||||
expect(isObjectID('5f1e0f3b5d2c12b0b8f7a6b')).toBe(false);
|
||||
expect(isObjectID('5f1e0f3b5d2c12b0b8f7a6b99')).toBe(false);
|
||||
expect(isObjectID('5f1e0f3b5d2c12b0b8f7a6b-')).toBe(false);
|
||||
expect(isObjectID(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ObjectId } from 'mongodb';
|
||||
|
||||
// This is trivial, but makes it simple to refactor if we swap monogodb for
|
||||
// bson, say.
|
||||
export const isObjectID = (id?: string): boolean =>
|
||||
id ? ObjectId.isValid(id) : false;
|
||||
Generated
+11
-15
@@ -195,12 +195,21 @@ importers:
|
||||
'@prisma/client':
|
||||
specifier: 4.16.2
|
||||
version: 4.16.2(prisma@4.16.2)
|
||||
ajv:
|
||||
specifier: 8.12.0
|
||||
version: 8.12.0
|
||||
ajv-formats:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1(ajv@8.12.0)
|
||||
bad-words:
|
||||
specifier: 3.0.4
|
||||
version: 3.0.4
|
||||
connect-mongo:
|
||||
specifier: 4.6.0
|
||||
version: 4.6.0(express-session@1.17.3)(mongodb@4.16.0)
|
||||
fast-uri:
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0
|
||||
fastify:
|
||||
specifier: 4.19.2
|
||||
version: 4.19.2
|
||||
@@ -214,7 +223,7 @@ importers:
|
||||
specifier: 9.0.1
|
||||
version: 9.0.1
|
||||
mongodb:
|
||||
specifier: '4'
|
||||
specifier: ^4.16.0
|
||||
version: 4.16.0
|
||||
nanoid:
|
||||
specifier: '3'
|
||||
@@ -241,9 +250,6 @@ importers:
|
||||
'@types/supertest':
|
||||
specifier: 2.0.12
|
||||
version: 2.0.12
|
||||
ajv:
|
||||
specifier: 8.12.0
|
||||
version: 8.12.0
|
||||
dotenv-cli:
|
||||
specifier: 7.2.1
|
||||
version: 7.2.1
|
||||
@@ -20767,23 +20773,13 @@ packages:
|
||||
dependencies:
|
||||
'@types/express': 4.17.17
|
||||
'@types/http-proxy': 1.17.10
|
||||
http-proxy: 1.18.1
|
||||
http-proxy: 1.18.1(debug@3.2.7)
|
||||
is-glob: 4.0.3
|
||||
is-plain-obj: 3.0.0
|
||||
micromatch: 4.0.5
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
/http-proxy@1.18.1:
|
||||
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
dependencies:
|
||||
eventemitter3: 4.0.7
|
||||
follow-redirects: 1.15.2(debug@4.3.4)
|
||||
requires-port: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
/http-proxy@1.18.1(debug@3.2.7):
|
||||
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
Reference in New Issue
Block a user