Files
freeCodeCamp/api/src/routes/protected/challenge.test.ts
2025-09-19 13:28:06 +05:30

2432 lines
78 KiB
TypeScript

import {
describe,
test,
expect,
beforeAll,
afterEach,
beforeEach,
afterAll,
vi
} from 'vitest';
vi.mock('../helpers/challenge-helpers', async () => {
const originalModule = await vi.importActual<
typeof import('../helpers/challenge-helpers.js')
>('../helpers/challenge-helpers');
return {
__esModule: true,
...originalModule,
verifyTrophyWithMicrosoft: vi.fn()
};
});
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { omit } from 'lodash-es';
import { Static } from '@fastify/type-provider-typebox';
import { DailyCodingChallengeLanguage } from '@prisma/client';
import request from 'supertest';
import { challengeTypes } from '../../../../shared/config/challenge-types.js';
import {
defaultUserId,
devLogin,
setupServer,
superRequest,
seedExam,
defaultUserEmail,
createSuperRequest,
defaultUsername
} from '../../../vitest.utils.js';
import {
completedExamChallengeOneCorrect,
completedExamChallengeTwoCorrect,
completedExamChallengeAllCorrect,
completedTrophyChallenges,
examChallengeId,
mockResultsZeroCorrect,
mockResultsTwoCorrect,
mockResultsAllCorrect,
examWithZeroCorrect,
examWithOneCorrect,
examWithTwoCorrect,
examWithAllCorrect,
type ExamSubmission
} from '../../../__mocks__/exam.js';
import { Answer } from '../../utils/exam-types.js';
import type { getSessionUser } from '../../schemas/user/get-session-user.js';
import { verifyTrophyWithMicrosoft } from '../helpers/challenge-helpers.js';
const mockVerifyTrophyWithMicrosoft = vi.mocked(verifyTrophyWithMicrosoft);
const EXISTING_COMPLETED_DATE = new Date('2024-11-08').getTime();
const DATE_NOW = Date.now();
vi.mock('../helpers/challenge-helpers.js', async () => {
const originalModule = await vi.importActual<
typeof import('../helpers/challenge-helpers.js')
>('../helpers/challenge-helpers');
return {
__esModule: true,
...originalModule,
verifyTrophyWithMicrosoft: vi.fn()
};
});
const isValidChallengeCompletionErrorMsg = {
type: 'error',
message: 'That does not appear to be a valid challenge submission.'
};
// /project-completed
const id1 = 'bd7123c8c441eddfaeb5bdef';
const id2 = 'bd7123c8c441eddfaeb5bdec';
const codeallyProject = {
id: id1,
challengeType: challengeTypes.codeAllyCert,
solution: 'https://any.valid/url'
};
const backendProject = {
id: id2,
challengeType: challengeTypes.backEndProject,
solution: 'https://any.valid/url',
githubLink: 'https://github.com/anything/valid/'
};
const partialCompletion = { id: id1, completedDate: 1 };
// /backend-challenge-completed
const backendChallengeId1 = '587d7fb1367417b2b2512bf4';
const backendChallengeId2 = '587d7fb2367417b2b2512bf8';
const backendChallengeBody1 = {
id: backendChallengeId1
};
const backendChallengeBody2 = {
id: backendChallengeId2
};
// /modern-challenge-completed
const HtmlChallengeId = '5dc174fcf86c76b9248c6eb2';
const JsProjectId = '56533eb9ac21ba0edf2244e2';
const multiFileCertProjectId = 'bd7158d8c242eddfaeb5bd13';
const HtmlChallengeBody = {
challengeType: challengeTypes.html,
id: HtmlChallengeId
};
const baseJsProjectBody = {
challengeType: challengeTypes.jsProject,
id: JsProjectId
};
const jsFiles = [
{
contents: 'console.log("Hello There!")',
key: 'scriptjs',
ext: 'js',
name: 'script',
history: ['script.js']
}
];
const encodedJsFiles = [
{
contents: btoa('console.log("Hello There!")'),
key: 'scriptjs',
ext: 'js',
name: 'script',
history: ['script.js']
}
];
const baseMultiFileCertProjectBody = {
challengeType: challengeTypes.multifileCertProject,
id: multiFileCertProjectId
};
const multiFiles = [
{
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 updatedMultiFiles = [
{
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']
}
];
const encodedMultiFiles = [
{
contents: btoa('<h1>Multi File Project v1</h1>'),
key: 'indexhtml',
ext: 'html',
name: 'index',
history: ['index.html']
},
{
contents: btoa('.hello-there { general: kenobi; }'),
key: 'stylescss',
ext: 'css',
name: 'styles',
history: ['styles.css']
}
];
const encodedUpdatedMultiFiles = [
{
contents: btoa('<h1>Multi File Project v2</h1>'),
key: 'indexhtml',
ext: 'html',
name: 'index',
history: ['index.html']
},
{
contents: btoa('.wibbly-wobbly { timey: wimey; }'),
key: 'stylescss',
ext: 'css',
name: 'styles',
history: ['styles.css']
}
];
const dailyCodingChallengeId = '5900f36e1000cf542c50fe80';
const dailyCodingChallengeBody = {
id: dailyCodingChallengeId,
language: DailyCodingChallengeLanguage.javascript
};
describe('challengeRoutes', () => {
setupServer();
describe('Authenticated user', () => {
let setCookies: string[];
let superPost: ReturnType<typeof createSuperRequest>;
let superGet: ReturnType<typeof createSuperRequest>;
// Authenticate user
beforeAll(async () => {
setCookies = await devLogin();
superPost = createSuperRequest({ method: 'POST', setCookies });
superGet = createSuperRequest({ method: 'GET', setCookies });
await seedExam();
});
describe('POST /coderoad-challenge-completed', () => {
test('should return 400 if no tutorialId', async () => {
const response = await superPost('/coderoad-challenge-completed');
expect(response.body).toEqual({
msg: `'tutorialId' not found in request body`,
type: 'error'
});
expect(response.status).toBe(400);
});
test('should return 400 if no user token', async () => {
const response = await superPost('/coderoad-challenge-completed').send({
tutorialId: 'freeCodeCamp/learn-bash-by-building-a-boilerplate:v1.0.0'
});
expect(response.body).toEqual({
msg: `'Coderoad-User-Token' not found in request headers`,
type: 'error'
});
expect(response.status).toBe(400);
});
test('should return 400 if invalid user token', async () => {
const response = await superPost('/coderoad-challenge-completed')
.set('coderoad-user-token', 'invalid')
.send({
tutorialId:
'freeCodeCamp/learn-bash-by-building-a-boilerplate:v1.0.0'
});
expect(response.body).toEqual({
msg: 'invalid user token',
type: 'error'
});
expect(response.status).toBe(400);
});
test('should return 400 if invalid tutorialId', async () => {
const tokenResponse = await superPost('/user/user-token');
expect(tokenResponse.body).toHaveProperty('userToken');
expect(tokenResponse.status).toBe(200);
const token = (tokenResponse.body as { userToken: string }).userToken;
const response = await superPost('/coderoad-challenge-completed')
.set('coderoad-user-token', token)
.send({ tutorialId: 'invalid' });
expect(response.body).toEqual({
msg: 'Tutorial not hosted on freeCodeCamp GitHub account',
type: 'error'
});
expect(response.status).toBe(400);
});
test('should return 400 if invalid tutorialId but is hosted on freeCodeCamp', async () => {
const tokenResponse = await superPost('/user/user-token');
expect(tokenResponse.body).toHaveProperty('userToken');
expect(tokenResponse.status).toBe(200);
const token = (tokenResponse.body as { userToken: string }).userToken;
const response = await superPost('/coderoad-challenge-completed')
.set('coderoad-user-token', token)
.send({ tutorialId: 'freeCodeCamp/invalid:V1.0.0' });
expect(response.body).toEqual({
msg: 'Tutorial name is not valid',
type: 'error'
});
expect(response.status).toBe(400);
});
test('Should complete challenge with code 200', async () => {
const tokenResponse = await superPost('/user/user-token');
expect(tokenResponse.body).toHaveProperty('userToken');
expect(tokenResponse.status).toBe(200);
const token = (tokenResponse.body as { userToken: string }).userToken;
// This route is special since it does not have CSRF protection OR authN
// protection. As such, we use a normal `request` to send the bare
// minimum (no extra headers or cookies).
const response = await request(fastifyTestInstance.server)
.post('/coderoad-challenge-completed')
.set('coderoad-user-token', token)
.send({
tutorialId:
'freeCodeCamp/learn-bash-by-building-a-boilerplate:v1.0.0'
});
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);
expect(response.status).toBe(200);
});
test('Should complete project with code 200', async () => {
const tokenResponse = await superPost('/user/user-token');
expect(tokenResponse.body).toHaveProperty('userToken');
expect(tokenResponse.status).toBe(200);
const token = (tokenResponse.body as { userToken: string }).userToken;
const response = await superPost('/coderoad-challenge-completed')
.set('coderoad-user-token', token)
.send({
tutorialId: 'freeCodeCamp/learn-celestial-bodies-database:v1.0.0'
});
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: 'foo@bar.com' }
});
const projectCompleted = user?.partiallyCompletedChallenges.some(
project => {
return project.id === '5f1a4ef5d5d6b5ab580fc6ae';
}
);
expect(response.body).toEqual({
msg: 'Successfully submitted challenge',
type: 'success'
});
expect(projectCompleted).toBe(true);
expect(response.status).toBe(200);
});
// This has to be the last test since vi.mockRestore replaces the original
// function with undefined when restoring a prisma function (for some
// reason)
test('Should return an error response if something goes wrong', async () => {
vi.spyOn(
fastifyTestInstance.prisma.userToken,
'findUnique'
).mockImplementationOnce(() => {
throw new Error('Database error');
});
const tokenResponse = await superPost('/user/user-token');
const token = (tokenResponse.body as { userToken: string }).userToken;
const response = await superPost('/coderoad-challenge-completed')
.set('coderoad-user-token', token)
.send({
tutorialId: 'freeCodeCamp/learn-celestial-bodies-database:v1.0.0'
});
expect(response.body).toEqual({
msg: 'An error occurred trying to submit the challenge',
type: 'error'
});
expect(response.status).toBe(500);
});
afterAll(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: 'foo@bar.com' },
data: {
completedChallenges: [],
progressTimestamps: []
}
});
});
});
describe('/project-completed', () => {
describe('validation', () => {
test('POST rejects requests without ids', async () => {
const response = await superPost('/project-completed').send({});
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response.statusCode).toBe(400);
});
test('POST rejects requests without valid ObjectIDs', async () => {
const response = await superPost(
'/project-completed'
// 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);
});
test('POST rejects requests with invalid challengeTypes', async () => {
const response = await superPost('/project-completed').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);
});
test('POST rejects requests without solutions', async () => {
const response = await superPost('/project-completed').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);
});
test('POST rejects requests with solutions that are not urls', async () => {
const response = await superPost('/project-completed').send({
id: id1,
challengeType: 3,
solution: 'not-a-valid-solution'
});
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response.statusCode).toBe(403);
});
test('POST rejects backendProject requests without URL githubLinks', async () => {
const response = await superPost('/project-completed').send({
id: id1,
challengeType: challengeTypes.backEndProject,
// Solution is allowed to be localhost for backEndProject
solution: 'http://localhost:3000'
});
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response.statusCode).toBe(403);
const response_2 = await superPost('/project-completed').send({
id: id1,
challengeType: challengeTypes.backEndProject,
solution: 'http://localhost:3000',
githubLink: 'not-a-valid-url'
});
expect(response_2.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response_2.statusCode).toBe(403);
});
test('POST rejects CodeRoad/CodeAlly projects when the user has not completed the required challenges', async () => {
const response = await superPost('/project-completed').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: [],
savedChallenges: [],
progressTimestamps: []
}
});
});
afterEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: 'foo@bar.com' },
data: {
partiallyCompletedChallenges: [],
completedChallenges: [],
savedChallenges: [],
progressTimestamps: []
}
});
});
test('POST accepts CodeRoad/CodeAlly projects when the user has completed the required challenges', async () => {
const now = Date.now();
const response =
await superPost('/project-completed').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);
});
test('POST accepts backend projects', async () => {
const now = Date.now();
const response =
await superPost('/project-completed').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);
});
test('POST correctly handles multiple requests', async () => {
const resOriginal =
await superPost('/project-completed').send(codeallyProject);
const resBackend =
await superPost('/project-completed').send(backendProject);
// sending backendProject again should update its solution, but not
// progressTimestamps or its completedDate
const resUpdate = await superPost('/project-completed').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)
});
// If a challenge has already been completed, it should return the
// original completedDate
expect(resUpdate.body.completedDate).toBe(
resOriginal.body.completedDate
);
expect(resUpdate.statusCode).toBe(200);
});
});
});
describe('/backend-challenge-completed', () => {
describe('validation', () => {
test('POST rejects requests without ids', async () => {
const response = await superPost('/backend-challenge-completed');
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response.statusCode).toBe(400);
});
test('POST rejects requests without valid ObjectIDs', async () => {
const response = await superPost('/backend-challenge-completed').send(
{ id: 'not-a-valid-id', solution: '' }
);
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response.statusCode).toBe(400);
});
});
describe('handling', () => {
afterEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: 'foo@bar.com' },
data: {
completedChallenges: [],
progressTimestamps: []
}
});
});
test('POST accepts backend challenges', async () => {
const now = Date.now();
const response = await superPost('/backend-challenge-completed').send(
backendChallengeBody1
);
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: 'foo@bar.com' }
});
expect(user).toMatchObject({
completedChallenges: [
{
...backendChallengeBody1,
completedDate: expect.any(Number)
}
]
});
const completedDate = user?.completedChallenges[0]?.completedDate;
expect(completedDate).toBeGreaterThanOrEqual(now);
expect(completedDate).toBeLessThanOrEqual(now + 1000);
expect(response.body).toStrictEqual({
alreadyCompleted: false,
points: 1,
completedDate
});
expect(response.statusCode).toBe(200);
});
test('POST correctly handles multiple requests', async () => {
const resOriginal = await superPost(
'/backend-challenge-completed'
).send(backendChallengeBody1);
await superPost('/backend-challenge-completed').send(
backendChallengeBody2
);
const resUpdated = await superPost(
'/backend-challenge-completed'
).send({
...backendChallengeBody1
});
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: 'foo@bar.com' }
});
const expectedProgressTimestamps = user?.completedChallenges.map(
challenge => challenge.completedDate
);
expect(user).toMatchObject({
completedChallenges: [
{
...backendChallengeBody1,
completedDate: expect.any(Number)
},
{
...backendChallengeBody2,
completedDate: expect.any(Number)
}
],
progressTimestamps: expectedProgressTimestamps
});
expect(resUpdated.body.completedDate).not.toBe(
resOriginal.body.completedDate
);
expect(resUpdated.body).toStrictEqual({
alreadyCompleted: true,
points: 2,
completedDate: expect.any(Number)
});
expect(resUpdated.statusCode).toBe(200);
});
});
});
describe('/modern-challenge-completed', () => {
describe('validation', () => {
test('POST rejects requests without ids', async () => {
const response = await superPost('/modern-challenge-completed');
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response.statusCode).toBe(400);
});
test('POST rejects requests without valid ObjectIDs', async () => {
const response = await superPost('/modern-challenge-completed').send({
id: 'not-a-valid-id'
});
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response.statusCode).toBe(400);
});
});
describe('handling', () => {
beforeEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: 'foo@bar.com' },
data: {
completedChallenges: [],
savedChallenges: [],
progressTimestamps: []
}
});
});
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 superPost('/modern-challenge-completed').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 superPost('/modern-challenge-completed').send({
...baseJsProjectBody,
files: jsFiles
});
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
const file = omit(jsFiles[0], 'history');
expect(user).toMatchObject({
completedChallenges: [
{
id: JsProjectId,
challengeType: baseJsProjectBody.challengeType,
files: [file],
completedDate: expect.any(Number)
}
]
});
const completedDate = user.completedChallenges[0]?.completedDate;
expect(completedDate).toBeGreaterThanOrEqual(now);
expect(completedDate).toBeLessThanOrEqual(now + 1000);
expect(response.body).toStrictEqual({
alreadyCompleted: false,
points: 1,
completedDate,
savedChallenges: []
});
expect(response.statusCode).toBe(200);
});
test('POST accepts challenges with saved solutions', async () => {
const now = Date.now();
const response = await superPost('/modern-challenge-completed').send({
...baseMultiFileCertProjectBody,
files: multiFiles
});
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
const testFiles = multiFiles.map(
({ history: _history, ...rest }) => rest
);
expect(user).toMatchObject({
needsModeration: true,
completedChallenges: [
{
id: multiFileCertProjectId,
challengeType: baseMultiFileCertProjectBody.challengeType,
files: testFiles,
completedDate: expect.any(Number),
isManuallyApproved: false
}
],
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: expect.any(Number),
files: multiFiles
}
]
});
const completedDate = user.completedChallenges[0]?.completedDate;
expect(completedDate).toBeGreaterThanOrEqual(now);
expect(completedDate).toBeLessThanOrEqual(now + 1000);
expect(response.body).toStrictEqual({
alreadyCompleted: false,
points: 1,
completedDate,
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: completedDate,
files: multiFiles
}
]
});
expect(response.statusCode).toBe(200);
});
test('POST correctly handles multiple requests', async () => {
const resOriginal = await superPost(
'/modern-challenge-completed'
).send({ ...baseMultiFileCertProjectBody, files: multiFiles });
await superPost('/modern-challenge-completed').send(
HtmlChallengeBody
);
const resUpdate = await superPost('/modern-challenge-completed').send(
{ ...baseMultiFileCertProjectBody, files: updatedMultiFiles }
);
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
const expectedProgressTimestamps = user.completedChallenges.map(
challenge => challenge.completedDate
);
const testFiles = updatedMultiFiles.map(file =>
omit(file, 'history')
);
expect(user).toMatchObject({
needsModeration: true,
completedChallenges: [
{
id: multiFileCertProjectId,
challengeType: baseMultiFileCertProjectBody.challengeType,
files: testFiles,
completedDate: expect.any(Number),
isManuallyApproved: false
},
{
id: HtmlChallengeId,
completedDate: expect.any(Number)
}
],
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: expect.any(Number),
files: updatedMultiFiles
}
],
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.body).toStrictEqual({
alreadyCompleted: true,
points: 2,
completedDate: expect.any(Number),
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: expect.any(Number),
files: updatedMultiFiles
}
]
});
expect(resUpdate.statusCode).toBe(200);
});
});
});
describe('/encoded/modern-challenge-completed', () => {
beforeEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: 'foo@bar.com' },
data: {
completedChallenges: [],
savedChallenges: [],
progressTimestamps: []
}
});
});
afterEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: 'foo@bar.com' },
data: {
completedChallenges: [],
savedChallenges: [],
progressTimestamps: []
}
});
});
// JS Project(5), Multi-file Cert Project(14)
test('POST accepts challenges with files present', async () => {
const now = Date.now();
const response = await superPost(
'/encoded/modern-challenge-completed'
).send({ ...baseJsProjectBody, files: encodedJsFiles });
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
const file = omit(jsFiles[0], 'history');
expect(user).toMatchObject({
completedChallenges: [
{
id: JsProjectId,
challengeType: baseJsProjectBody.challengeType,
files: [file],
completedDate: expect.any(Number)
}
]
});
const completedDate = user.completedChallenges[0]?.completedDate;
expect(completedDate).toBeGreaterThanOrEqual(now);
expect(completedDate).toBeLessThanOrEqual(now + 1000);
expect(response.body).toStrictEqual({
alreadyCompleted: false,
points: 1,
completedDate,
savedChallenges: []
});
expect(response.statusCode).toBe(200);
});
test('POST accepts challenges with saved solutions', async () => {
const now = Date.now();
const response = await superPost(
'/encoded/modern-challenge-completed'
).send({
...baseMultiFileCertProjectBody,
files: encodedUpdatedMultiFiles
});
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
const testFiles = updatedMultiFiles.map(
({ history: _history, ...rest }) => rest
);
expect(user).toMatchObject({
needsModeration: true,
completedChallenges: [
{
id: multiFileCertProjectId,
challengeType: baseMultiFileCertProjectBody.challengeType,
files: testFiles,
completedDate: expect.any(Number),
isManuallyApproved: false
}
],
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: expect.any(Number),
files: updatedMultiFiles
}
]
});
const completedDate = user.completedChallenges[0]?.completedDate;
expect(completedDate).toBeGreaterThanOrEqual(now);
expect(completedDate).toBeLessThanOrEqual(now + 1000);
expect(response.body).toStrictEqual({
alreadyCompleted: false,
points: 1,
completedDate,
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: completedDate,
files: updatedMultiFiles
}
]
});
expect(response.statusCode).toBe(200);
});
test('POST correctly handles multiple requests', async () => {
const resOriginal = await superPost(
'/encoded/modern-challenge-completed'
).send({
...baseMultiFileCertProjectBody,
files: encodedMultiFiles
});
await superPost('/encoded/modern-challenge-completed').send(
HtmlChallengeBody
);
const resUpdate = await superPost(
'/encoded/modern-challenge-completed'
).send({
...baseMultiFileCertProjectBody,
files: encodedUpdatedMultiFiles
});
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
const expectedProgressTimestamps = user.completedChallenges.map(
challenge => challenge.completedDate
);
const testFiles = updatedMultiFiles.map(file => omit(file, 'history'));
expect(user).toMatchObject({
needsModeration: true,
completedChallenges: [
{
id: multiFileCertProjectId,
challengeType: baseMultiFileCertProjectBody.challengeType,
files: testFiles,
completedDate: expect.any(Number),
isManuallyApproved: false
},
{
id: HtmlChallengeId,
completedDate: expect.any(Number)
}
],
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: expect.any(Number),
files: updatedMultiFiles
}
],
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.body).toStrictEqual({
alreadyCompleted: true,
points: 2,
completedDate: expect.any(Number),
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: expect.any(Number),
files: updatedMultiFiles
}
]
});
expect(resUpdate.statusCode).toBe(200);
});
});
describe('/daily-coding-challenge-completed', () => {
describe('validation', () => {
test('POST rejects requests without an id', async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, ...noIdReqBody } = dailyCodingChallengeBody;
const response = await superPost(
'/daily-coding-challenge-completed'
).send(noIdReqBody);
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response.statusCode).toBe(400);
});
test('POST rejects requests without a language', async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { language, ...noLanguageReqBody } = dailyCodingChallengeBody;
const response = await superPost(
'/daily-coding-challenge-completed'
).send(noLanguageReqBody);
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response.statusCode).toBe(400);
});
test('POST rejects requests without valid ObjectIDs', async () => {
const response = await superPost(
'/daily-coding-challenge-completed'
).send({
...dailyCodingChallengeBody,
id: 'not-a-valid-id'
});
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response.statusCode).toBe(400);
});
test('POST rejects requests without valid coding language', async () => {
const response = await superPost(
'/daily-coding-challenge-completed'
).send({
...dailyCodingChallengeBody,
language: 'not-a-valid-language'
});
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response.statusCode).toBe(400);
});
});
describe('handling', () => {
afterEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: 'foo@bar.com' },
data: {
completedDailyCodingChallenges: [],
progressTimestamps: []
}
});
});
test('POST correctly handles multiple requests', async () => {
const now = Date.now();
const res1 = await superPost(
'/daily-coding-challenge-completed'
).send(dailyCodingChallengeBody);
const user1 = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
const completedDate =
user1.completedDailyCodingChallenges[0]?.completedDate;
// should have correct completedDate
expect(completedDate).toBeGreaterThanOrEqual(now);
expect(completedDate).toBeLessThanOrEqual(now + 1000);
expect(user1).toMatchObject({
// should add completedDailyCodingChallenge to database with correct info
completedDailyCodingChallenges: [
{
id: dailyCodingChallengeId,
completedDate,
languages: [DailyCodingChallengeLanguage.javascript]
}
],
// should add to progressTimestamps
progressTimestamps: [completedDate]
});
// should have correct response
expect(res1.statusCode).toBe(200);
expect(res1.body).toStrictEqual({
alreadyCompleted: false,
points: 1,
completedDate,
completedDailyCodingChallenges: [
{
id: dailyCodingChallengeId,
completedDate,
languages: [DailyCodingChallengeLanguage.javascript]
}
]
});
const res2 = await superPost(
'/daily-coding-challenge-completed'
).send(dailyCodingChallengeBody);
const user2 = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
// should not add 'javascript' again, should not update completedDate
expect(user2).toMatchObject({
completedDailyCodingChallenges: [
{
id: dailyCodingChallengeId,
completedDate,
languages: [DailyCodingChallengeLanguage.javascript]
}
],
// should not add to progressTimestamps
progressTimestamps: [completedDate]
});
// should have correct response
expect(res2.statusCode).toBe(200);
expect(res2.body).toStrictEqual({
alreadyCompleted: true,
points: 1,
completedDate,
completedDailyCodingChallenges: [
{
id: dailyCodingChallengeId,
completedDate,
languages: [DailyCodingChallengeLanguage.javascript]
}
]
});
const res3 = await superPost(
'/daily-coding-challenge-completed'
).send({
...dailyCodingChallengeBody,
language: 'python'
});
const user3 = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
// should add 'python' to languages + should not update completedDate
expect(user3).toMatchObject({
completedDailyCodingChallenges: [
{
id: dailyCodingChallengeId,
completedDate,
languages: [
DailyCodingChallengeLanguage.javascript,
DailyCodingChallengeLanguage.python
]
}
],
// should not add to progressTimestamps
progressTimestamps: [completedDate]
});
// should have correct response
expect(res3.statusCode).toBe(200);
expect(res3.body).toStrictEqual({
alreadyCompleted: true,
points: 1,
completedDate,
completedDailyCodingChallenges: [
{
id: dailyCodingChallengeId,
completedDate,
languages: [
DailyCodingChallengeLanguage.javascript,
DailyCodingChallengeLanguage.python
]
}
]
});
});
});
});
describe('POST /save-challenge', () => {
describe('validation', () => {
test('returns 400 status for unsavable challenges', async () => {
const response = await superPost('/save-challenge').send({
savedChallenges: {
// valid mongo id, but not a saveable one
id: 'aaaaaaaaaaaaaaaaaaaaaaa',
files: multiFiles
}
});
expect(response.body).toEqual({
message: 'That does not appear to be a valid challenge submission.',
type: 'error'
});
expect(response.statusCode).toBe(400);
});
});
describe('handling', () => {
afterEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: 'foo@bar.com' },
data: {
savedChallenges: []
}
});
});
test('rejects requests for challenges that cannot be saved', async () => {
const response = await superPost('/save-challenge').send({
id: '66ebd4ae2812430bb883c786',
files: multiFiles
});
const { savedChallenges } =
await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
expect(response.statusCode).toBe(400);
expect(response.text).toEqual('That challenge type is not saveable.');
expect(savedChallenges).toHaveLength(0);
});
test('update the user savedchallenges and return them', async () => {
const response = await superPost('/save-challenge').send({
id: multiFileCertProjectId,
files: updatedMultiFiles
});
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
const savedDate = user.savedChallenges[0]?.lastSavedDate;
expect(user).toMatchObject({
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: savedDate,
files: updatedMultiFiles
}
]
});
expect(response.body).toEqual({
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: savedDate,
files: updatedMultiFiles
}
]
});
expect(response.statusCode).toBe(200);
});
});
});
describe('POST /encoded/save-challenge', () => {
test('rejects requests for challenges that cannot be saved', async () => {
const response = await superPost('/encoded/save-challenge').send({
id: '66ebd4ae2812430bb883c786',
files: encodedUpdatedMultiFiles
});
const { savedChallenges } =
await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
expect(response.statusCode).toBe(400);
expect(response.text).toEqual('That challenge type is not saveable.');
expect(savedChallenges).toHaveLength(0);
});
test('update the user savedchallenges and return them', async () => {
const response = await superPost('/encoded/save-challenge').send({
id: multiFileCertProjectId,
files: encodedUpdatedMultiFiles
});
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
const savedDate = user.savedChallenges[0]?.lastSavedDate;
expect(user).toMatchObject({
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: savedDate,
files: updatedMultiFiles
}
]
});
expect(response.body).toEqual({
savedChallenges: [
{
id: multiFileCertProjectId,
lastSavedDate: savedDate,
files: updatedMultiFiles
}
]
});
expect(response.statusCode).toBe(200);
});
});
describe('GET /exam/:id', () => {
beforeAll(async () => {
await seedExam();
});
describe('validation', () => {
test('GET rejects requests without id param', async () => {
const response = await superGet('/exam/');
expect(response.body).toStrictEqual({
error: `Valid 'id' not found in request parameters.`
});
expect(response.statusCode).toBe(400);
});
test('GET rejects requests when id param is not a 24-character string', async () => {
const response = await superGet('/exam/fake-id');
expect(response.body).toStrictEqual({
error: `Valid 'id' not found in request parameters.`
});
expect(response.statusCode).toBe(400);
});
test('GET rejects requests with non-existent id param', async () => {
const response = await superGet('/exam/123412341234123412341234');
expect(response.body).toStrictEqual({
error: 'An error occurred trying to get the exam from the database.'
});
expect(response.statusCode).toBe(500);
});
test('GET rejects requests where camper has not completed prerequisites', async () => {
const response = await superGet('/exam/647e22d18acb466c97ccbef8');
expect(response.body).toStrictEqual({
error: `You have not completed the required challenges to start the 'Exam Certification'.`
});
expect(response.statusCode).toBe(403);
});
});
describe('handling', () => {
test('GET returns a generatedExam array with the correct objects', async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: defaultUserEmail },
data: { completedChallenges: completedTrophyChallenges }
});
const response = await superGet('/exam/647e22d18acb466c97ccbef8');
expect(response.body).toHaveProperty('generatedExam');
const { generatedExam } = response.body;
expect(Array.isArray(generatedExam)).toBe(true);
expect(generatedExam).toHaveLength(3);
expect(generatedExam[0]).toHaveProperty('question');
expect(typeof generatedExam[0].question).toBe('string');
expect(generatedExam[0]).toHaveProperty('id');
expect(typeof generatedExam[0].id).toBe('string');
expect(generatedExam[0]).toHaveProperty('answers');
expect(Array.isArray(generatedExam[0].answers)).toBe(true);
expect(generatedExam[0].answers).toHaveLength(5);
const answers = generatedExam[0].answers as Answer[];
answers.forEach(a => {
expect(a).toHaveProperty('answer');
expect(typeof a.answer).toBe('string');
expect(a).toHaveProperty('id');
expect(typeof a.id).toBe('string');
});
expect(response.statusCode).toBe(200);
});
});
describe('/ms-trophy-challenge-completed', () => {
const msUserId = 'abc123';
// Add Logic to C# Console Applications's id:
const trophyChallengeId = '647f882207d29547b3bee1c0';
// Create and Run Simple C# Console Applications's id:
const trophyChallengeId2 = '647f87dc07d29547b3bee1bf';
const nonTrophyChallengeId = 'bd7123c8c441eddfaeb5bdef';
const solutionUrl = `https://learn.microsoft.com/api/achievements/user/${msUserId}`;
const idIsMissingOrInvalid = {
type: 'error',
message: 'flash.ms.trophy.err-2'
} as const;
const userHasNotLinkedTheirAccount = {
type: 'error',
message: 'flash.ms.trophy.err-1'
} as const;
const unexpectedError = {
type: 'error',
message: 'flash.ms.trophy.err-5'
} as const;
describe('validation', () => {
test('POST rejects requests without valid ids', async () => {
const resNoId = await superPost('/ms-trophy-challenge-completed');
expect(resNoId.body).toStrictEqual(idIsMissingOrInvalid);
expect(resNoId.statusCode).toBe(400);
const resBadId = await superPost(
'/ms-trophy-challenge-completed'
).send({ id: nonTrophyChallengeId });
expect(resBadId.body).toStrictEqual(idIsMissingOrInvalid);
expect(resBadId.statusCode).toBe(400);
});
// TODO(Post-MVP): give a more specific error message
test('POST rejects requests without valid ObjectIDs', async () => {
const response = await superPost(
'/ms-trophy-challenge-completed'
).send({ id: 'not-a-valid-id' });
expect(response.body).toStrictEqual(idIsMissingOrInvalid);
expect(response.statusCode).toBe(400);
});
});
describe('handling', () => {
async function createMSUsernameRecord(msUsername: string) {
await fastifyTestInstance.prisma.msUsername.create({
data: {
msUsername,
ttl: 123,
userId: defaultUserId
}
});
}
afterEach(async () => {
await fastifyTestInstance.prisma.msUsername.deleteMany({
where: { userId: defaultUserId }
});
await fastifyTestInstance.prisma.user.updateMany({
where: { id: defaultUserId },
data: {
completedChallenges: [],
progressTimestamps: []
}
});
});
test('POST rejects requests if the user does not have a Microsoft username', async () => {
const res = await superPost('/ms-trophy-challenge-completed').send({
id: trophyChallengeId
});
expect(res.body).toStrictEqual(userHasNotLinkedTheirAccount);
expect(res.statusCode).toBe(403);
});
test("POST rejects requests if Microsoft's api responds with an error", async () => {
const msUsername = 'ANRandom';
await createMSUsernameRecord(msUsername);
// This can be any error that the route can serialize. Other than
// that, the details do not matter, since whatever
// verifyTrophyWithMicrosoft returns will be returned by the route.
const verifyError = {
type: 'error' as const,
message: 'flash.ms.profile.err' as const,
variables: {
msUsername
}
};
mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() =>
Promise.resolve(verifyError)
);
const res = await superPost('/ms-trophy-challenge-completed').send({
id: trophyChallengeId
});
expect(res.body).toStrictEqual(verifyError);
expect(res.statusCode).toBe(403);
});
test('POST handles unexpected errors', async () => {
mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() => {
throw new Error('Network error');
});
const msUsername = 'ANRandom';
await createMSUsernameRecord(msUsername);
const res = await superPost('/ms-trophy-challenge-completed').send({
id: trophyChallengeId
});
expect(res.body).toStrictEqual(unexpectedError);
expect(res.statusCode).toBe(500);
});
test('POST updates the user record with a new completed challenge', async () => {
mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() =>
Promise.resolve({
type: 'success',
msUserAchievementsApiUrl: solutionUrl
})
);
const msUsername = 'ANRandom';
await createMSUsernameRecord(msUsername);
const now = Date.now();
const res = await superPost('/ms-trophy-challenge-completed').send({
id: trophyChallengeId
});
const user =
await fastifyTestInstance.prisma.user.findUniqueOrThrow({
where: { id: defaultUserId }
});
const completedDate = user.completedChallenges[0]?.completedDate;
expect(res.body).toStrictEqual({
alreadyCompleted: false,
points: 1,
completedDate
});
expect(completedDate).toBeGreaterThan(now);
expect(completedDate).toBeLessThan(now + 1000);
expect(res.statusCode).toBe(200);
expect(user).toMatchObject({
completedChallenges: [
{
id: trophyChallengeId,
solution: solutionUrl,
completedDate: expect.any(Number)
}
]
});
});
test('POST correctly handles multiple requests', async () => {
mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() =>
Promise.resolve({
type: 'success',
msUserAchievementsApiUrl: solutionUrl
})
);
const msUsername = 'ANRandom';
await createMSUsernameRecord(msUsername);
const resOne = await superPost(
'/ms-trophy-challenge-completed'
).send({ id: trophyChallengeId });
mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() =>
Promise.resolve({
type: 'success',
msUserAchievementsApiUrl: solutionUrl
})
);
const resTwo = await superPost(
'/ms-trophy-challenge-completed'
).send({ id: trophyChallengeId2 });
// sending the second trophy challenge again should not change
// anything
mockVerifyTrophyWithMicrosoft.mockImplementationOnce(() =>
Promise.resolve({
type: 'success',
msUserAchievementsApiUrl: solutionUrl
})
);
const resUpdate = await superPost(
'/ms-trophy-challenge-completed'
).send({ id: trophyChallengeId2 });
const { completedChallenges, progressTimestamps } =
await fastifyTestInstance.prisma.user.findUniqueOrThrow({
where: { id: defaultUserId }
});
expect(completedChallenges).toHaveLength(2);
expect(completedChallenges).toStrictEqual(
expect.arrayContaining([
expect.objectContaining({
id: trophyChallengeId,
solution: solutionUrl,
completedDate: resOne.body.completedDate
}),
expect.objectContaining({
id: trophyChallengeId2,
solution: solutionUrl,
completedDate: resTwo.body.completedDate
})
])
);
const expectedProgressTimestamps = completedChallenges.map(
challenge => challenge.completedDate
);
expect(progressTimestamps).toStrictEqual(
expectedProgressTimestamps
);
expect(resUpdate.body).toStrictEqual({
alreadyCompleted: true,
points: 2,
completedDate: expect.any(Number)
});
// If a challenge has already been completed, it should return the
// original completedDate
expect(resUpdate.body.completedDate).toBe(
resTwo.body.completedDate
);
expect(resUpdate.statusCode).toBe(200);
});
});
});
});
describe('/exam-challenge-completed', () => {
afterEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { id: defaultUserId },
data: {
completedChallenges: [],
completedExams: [],
progressTimestamps: []
}
});
});
describe('validation', () => {
test('POST rejects requests with no body', async () => {
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
});
expect(response.body).toStrictEqual({
error: `Valid request body not found in attempt to submit exam.`
});
expect(response.statusCode).toBe(400);
});
test('POST rejects requests without valid ObjectID', async () => {
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({ id: 'not-a-valid-id' });
expect(response.body).toStrictEqual({
error: `Valid request body not found in attempt to submit exam.`
});
expect(response.statusCode).toBe(400);
});
test('POST rejects requests with valid, but non existing ID', async () => {
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: '647e22d18acb466c97ccbef0',
challengeType: 17,
userCompletedExam: {
examTimeInSeconds: 111,
userExamQuestions: [
{
id: 'q-id',
question: '?',
answer: {
id: 'a-id',
answer: 'a'
}
}
]
}
});
expect(response.body).toStrictEqual({
error: `An error occurred trying to get the exam from the database.`
});
expect(response.statusCode).toBe(400);
});
test('POST rejects requests without valid userCompletedExam schema', async () => {
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: examChallengeId,
challengeType: 17,
userCompletedExam: ''
});
expect(response.body).toStrictEqual({
error: `Valid request body not found in attempt to submit exam.`
});
expect(response.statusCode).toBe(400);
});
test('POST rejects requests without valid examTimeInSeconds schema', async () => {
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: examChallengeId,
challengeType: 17,
userCompletedExam: { examTimeInSeconds: 'a' }
});
expect(response.body).toStrictEqual({
error: `Valid request body not found in attempt to submit exam.`
});
expect(response.statusCode).toBe(400);
});
test('POST rejects requests without valid userExamQuestions schema', async () => {
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: examChallengeId,
challengeType: 17,
userCompletedExam: { examTimeInSeconds: 11, userExamQuestions: [] }
});
expect(response.body).toStrictEqual({
error: `Valid request body not found in attempt to submit exam.`
});
expect(response.statusCode).toBe(400);
});
test('POST rejects requests with prerequisites not completed', async () => {
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: examChallengeId,
challengeType: 17,
userCompletedExam: {
examTimeInSeconds: 111,
userExamQuestions: [
{
id: 'q-id',
question: '?',
answer: {
id: 'a-id',
answer: 'a'
}
}
]
}
});
expect(response.body).toStrictEqual({
error: `You have not completed the required challenges to start the 'Exam Certification'.`
});
expect(response.statusCode).toBe(403);
});
test('POST rejects requests with invalid userCompletedExam values', async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: 'foo@bar.com' },
data: {
completedChallenges: completedTrophyChallenges
}
});
const response = await superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: examChallengeId,
challengeType: 17,
userCompletedExam: {
examTimeInSeconds: 111,
userExamQuestions: [
{
id: 'q-id',
question: '?',
answer: {
id: 'a-id',
answer: 'a'
}
}
]
}
});
expect(response.body).toStrictEqual({
error: `An error occurred trying to submit your exam.`
});
expect(response.statusCode).toBe(500);
});
});
describe('handling', () => {
beforeEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { id: defaultUserId },
data: {
completedChallenges: completedTrophyChallenges
}
});
});
afterEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { id: defaultUserId },
data: {
completedChallenges: [],
completedExams: [],
progressTimestamps: []
}
});
});
const submitExam = async (exam: ExamSubmission) => {
return superRequest('/exam-challenge-completed', {
method: 'POST',
setCookies
}).send({
id: examChallengeId,
challengeType: 17,
userCompletedExam: exam
});
};
test('POST handles submitting a failing exam', async () => {
const now = Date.now();
// Submit exam with 0 correct answers
const response = await submitExam(examWithZeroCorrect);
type GetSessionUserResponseBody = Static<
(typeof getSessionUser)['response']['200']
>['user'];
const res = (await superGet('/user/get-session-user')).body as {
user: GetSessionUserResponseBody;
};
const { completedChallenges, completedExams, calendar } =
res.user[defaultUsername]!;
// should have the 1 prerequisite challenge
expect(completedChallenges).toHaveLength(1);
expect(completedExams).toHaveLength(1);
expect(calendar).toStrictEqual({});
expect(completedChallenges).toEqual(completedTrophyChallenges);
expect(completedExams[0]).toEqual({
id: '647e22d18acb466c97ccbef8',
challengeType: 17,
completedDate: expect.any(Number),
examResults: mockResultsZeroCorrect
});
expect(completedExams[0]?.completedDate).toBeGreaterThan(now);
expect(response.body).toMatchObject({
points: 0,
alreadyCompleted: false,
examResults: mockResultsZeroCorrect
});
expect(response.statusCode).toBe(200);
});
test("POST always adds to the user's completedExams", async () => {
let now = Date.now();
// The first exam should be stored in the user's completedExams
await submitExam(examWithAllCorrect);
let { completedExams } =
await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { id: defaultUserId }
});
expect(completedExams).toHaveLength(1);
expect(completedExams[0]).toEqual(completedExamChallengeAllCorrect);
expect(completedExams[0]?.completedDate).toBeGreaterThan(now);
expect(completedExams[0]?.completedDate).toBeLessThan(Date.now());
now = Date.now();
// the second exam should be added to the exams, not replace the first
await submitExam(examWithOneCorrect);
completedExams = (
await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { id: defaultUserId }
})
).completedExams;
expect(completedExams).toHaveLength(2);
expect(completedExams).toEqual(
expect.arrayContaining([
completedExamChallengeAllCorrect,
completedExamChallengeOneCorrect
])
);
expect(completedExams[1]?.completedDate).toBeGreaterThan(now);
expect(completedExams[1]?.completedDate).toBeLessThan(Date.now());
});
test('POST updates user progress if they have not completed the exam before', async () => {
// Submit exam with 2/3 correct answers
const now = Date.now();
const res = await submitExam(examWithTwoCorrect);
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { id: defaultUserId }
});
// should add to completedChallenges
expect(user.completedChallenges).toHaveLength(2);
expect(user.completedChallenges).toMatchObject([
...completedTrophyChallenges,
completedExamChallengeTwoCorrect
]);
expect(user.completedChallenges[1]?.completedDate).toBeGreaterThan(
now
);
// should add to progressTimestamps
expect(user.progressTimestamps).toHaveLength(1);
expect(res.body).toMatchObject({
points: 1,
alreadyCompleted: false,
examResults: mockResultsTwoCorrect
});
expect(res.statusCode).toBe(200);
});
test('POST does not update user progress if new exam is not an improvement', async () => {
// Submit exam with 2/3 correct answers
await submitExam(examWithTwoCorrect);
const user1 = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { id: defaultUserId }
});
// Submit exam with 2/3 correct answers (no improvement)
const res2 = await submitExam(examWithTwoCorrect);
const user2 = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { id: defaultUserId }
});
// should not update user progress
expect(user2.completedChallenges).toEqual(user1.completedChallenges);
expect(user2.progressTimestamps).toEqual(user1.progressTimestamps);
expect(res2.body).toMatchObject({
points: 1,
alreadyCompleted: true,
examResults: mockResultsTwoCorrect
});
expect(res2.statusCode).toBe(200);
});
test('POST updates user progress if exam is an improvement', async () => {
// Submit exam with 2/3 correct answers
await submitExam(examWithTwoCorrect);
const user1 = await fastifyTestInstance.prisma.user.findUniqueOrThrow(
{
where: { id: defaultUserId }
}
);
// Submit improved exam
const res = await submitExam(examWithAllCorrect);
const user2 = await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: 'foo@bar.com' }
});
// should update existing completedChallenge
expect(user2.completedChallenges).toHaveLength(2);
expect(user2.completedChallenges).toMatchObject([
...completedTrophyChallenges,
completedExamChallengeAllCorrect
]);
expect(user2.completedChallenges[1]?.completedDate).toEqual(
user1.completedChallenges[1]?.completedDate
);
// they have not completed anything new, so progressTimestamps should
// remain the same
expect(user2.progressTimestamps).toEqual(user1.progressTimestamps);
expect(res.body).toMatchObject({
points: 1,
alreadyCompleted: true,
examResults: mockResultsAllCorrect
});
expect(res.statusCode).toBe(200);
});
});
});
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(() => {
vi.useFakeTimers({
// toFake: ['Date']
});
vi.setSystemTime(DATE_NOW);
});
afterAll(() => {
vi.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', () => {
let setCookies: string[];
// Get the CSRF cookies from an unprotected route
beforeAll(async () => {
const res = await superRequest('/status/ping', { method: 'GET' });
setCookies = res.get('Set-Cookie');
});
const endpoints: { path: string; method: 'POST' | 'GET' }[] = [
// { path: '/coderoad-challenge-completed', method: 'POST' },
{ path: '/project-completed', method: 'POST' },
{ path: '/backend-challenge-completed', method: 'POST' },
{ path: '/modern-challenge-completed', method: 'POST' },
{ path: '/daily-coding-challenge-completed', method: 'POST' },
{ path: '/save-challenge', method: 'POST' },
{ path: '/exam/647e22d18acb466c97ccbef8', method: 'GET' },
{ path: '/ms-trophy-challenge-completed', method: 'POST' },
{ path: '/exam-challenge-completed', method: 'POST' }
];
endpoints.forEach(({ path, method }) => {
test(`${method} ${path} returns 401 status code with error message`, async () => {
const response = await superRequest(path, {
method,
setCookies
});
expect(response.statusCode).toBe(401);
});
});
});
});