Fix: Current challenge timeout (#35352)

<!-- Please follow this checklist and put an x in each of the boxes, like this: [x]. It will ensure that our team takes your pull request seriously. -->

- [x] I have read [freeCodeCamp's contribution guidelines](https://github.com/freeCodeCamp/freeCodeCamp/blob/master/CONTRIBUTING.md).
- [x] My pull request has a descriptive title (not a vague title like `Update index.md`)
- [x] My pull request targets the `master` branch of freeCodeCamp.

Closes #35345

This PR resolves an issue where the `/challenges/current-challenge` request times out due to an unresolved promise.
pull/35346/head
Stuart Taylor 2019-02-22 11:49:12 +00:00 committed by mrugesh mohapatra
parent ce2b46926f
commit 675d6a76e4
3 changed files with 538 additions and 338 deletions

View File

@ -18,14 +18,12 @@ before_install:
install: npm ci
before_script:
- git config --global user.email team+camperbot@freeCodeCamp.org
- git config --global user.name "CamperBot"
- npm run ensure-env
jobs:
include:
- stage: Lint javaScript
script:
- npm run ensure-env
- npm run lint
- stage: Unit and Integration tests

View File

@ -5,7 +5,7 @@
*
*/
import { Observable } from 'rx';
import _ from 'lodash';
import { isEmpty, pick, omit, find, uniqBy, last } from 'lodash';
import debug from 'debug';
import accepts from 'accepts';
import dedent from 'dedent';
@ -19,129 +19,14 @@ import { fixCompletedChallengeItem } from '../../common/utils';
const log = debug('fcc:boot:challenges');
const learnURL = `${homeLocation}/learn`;
const jsProjects = [
'aaa48de84e1ecc7c742e1124',
'a7f4d8f2483413a6ce226cac',
'56533eb9ac21ba0edf2244e2',
'aff0395860f5d3034dc0bfc9',
'aa2e6f85cab2ab736c9a9b24'
];
function buildUserUpdate(user, challengeId, _completedChallenge, timezone) {
const { files } = _completedChallenge;
let completedChallenge = {};
if (jsProjects.includes(challengeId)) {
completedChallenge = {
..._completedChallenge,
files: Object.keys(files)
.map(key => files[key])
.map(file =>
_.pick(file, ['contents', 'key', 'index', 'name', 'path', 'ext'])
)
};
} else {
completedChallenge = _.omit(_completedChallenge, ['files']);
}
let finalChallenge;
const updateData = {};
const { timezone: userTimezone, completedChallenges = [] } = user;
const oldChallenge = _.find(
completedChallenges,
({ id }) => challengeId === id
);
const alreadyCompleted = !!oldChallenge;
if (alreadyCompleted) {
finalChallenge = {
...completedChallenge,
completedDate: oldChallenge.completedDate
};
} else {
updateData.$push = {
...updateData.$push,
progressTimestamps: Date.now()
};
finalChallenge = {
...completedChallenge
};
}
updateData.$set = {
completedChallenges: _.uniqBy(
[finalChallenge, ...completedChallenges.map(fixCompletedChallengeItem)],
'id'
)
};
if (
timezone &&
timezone !== 'UTC' &&
(!userTimezone || userTimezone === 'UTC')
) {
updateData.$set = {
...updateData.$set,
timezone: userTimezone
};
}
return {
alreadyCompleted,
updateData,
completedDate: finalChallenge.completedDate
};
}
function buildChallengeUrl(challenge) {
const { superBlock, block, dashedName } = challenge;
return `/learn/${dasherize(superBlock)}/${dasherize(block)}/${dashedName}`;
}
function getFirstChallenge(Challenge) {
return new Promise(resolve => {
Challenge.find(
{ where: { challengeOrder: 0, superOrder: 1, order: 0 } },
(err, challenge) => {
if (err) {
console.log(err);
return resolve('/learn');
}
return resolve(buildChallengeUrl(challenge));
}
);
});
}
async function createChallengeUrlResolver(app) {
const { Challenge } = app.models;
const cache = new Map();
const firstChallenge = await getFirstChallenge(Challenge);
return function resolveChallengeUrl(id) {
return new Promise(resolve => {
if (cache.has(id)) {
return resolve(cache.get(id));
}
return Challenge.findById(id, (err, challenge) => {
if (err) {
console.log(err);
return firstChallenge;
}
const challengeUrl = buildChallengeUrl(challenge);
cache.set(id, challengeUrl);
return resolve(challengeUrl);
});
});
};
}
export default async function bootChallenge(app, done) {
const send200toNonUser = ifNoUserSend(true);
const api = app.loopback.Router();
const router = app.loopback.Router();
const challengeUrlResolver = await createChallengeUrlResolver(app);
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
challengeUrlResolver
);
api.post(
'/modern-challenge-completed',
@ -183,259 +68,381 @@ export default async function bootChallenge(app, done) {
app.use('/external', api);
app.use('/internal', api);
app.use(router);
done();
}
const learnURL = `${homeLocation}/learn`;
function modernChallengeCompleted(req, res, next) {
const type = accepts(req).type('html', 'json', 'text');
req.checkBody('id', 'id must be an ObjectId').isMongoId();
const jsProjects = [
'aaa48de84e1ecc7c742e1124',
'a7f4d8f2483413a6ce226cac',
'56533eb9ac21ba0edf2244e2',
'aff0395860f5d3034dc0bfc9',
'aa2e6f85cab2ab736c9a9b24'
];
const errors = req.validationErrors(true);
if (errors) {
if (type === 'json') {
return res.status(403).send({ errors });
}
function buildUserUpdate(user, challengeId, _completedChallenge, timezone) {
const { files } = _completedChallenge;
let completedChallenge = {};
log('errors', errors);
return res.sendStatus(403);
}
if (jsProjects.includes(challengeId)) {
completedChallenge = {
..._completedChallenge,
files: Object.keys(files)
.map(key => files[key])
.map(file =>
pick(file, ['contents', 'key', 'index', 'name', 'path', 'ext'])
)
};
} else {
completedChallenge = omit(_completedChallenge, ['files']);
}
let finalChallenge;
const updateData = {};
const { timezone: userTimezone, completedChallenges = [] } = user;
const user = req.user;
return user
.getCompletedChallenges$()
.flatMap(() => {
const completedDate = Date.now();
const { id, files } = req.body;
const oldChallenge = find(
completedChallenges,
({ id }) => challengeId === id
);
const alreadyCompleted = !!oldChallenge;
const { alreadyCompleted, updateData } = buildUserUpdate(user, id, {
id,
files,
completedDate
});
const points = alreadyCompleted ? user.points : user.points + 1;
const updatePromise = new Promise((resolve, reject) =>
user.updateAttributes(updateData, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.fromPromise(updatePromise).map(() => {
if (type === 'json') {
return res.json({
points,
alreadyCompleted,
completedDate
});
}
return res.sendStatus(200);
});
})
.subscribe(() => {}, next);
if (alreadyCompleted) {
finalChallenge = {
...completedChallenge,
completedDate: oldChallenge.completedDate
};
} else {
updateData.$push = {
...updateData.$push,
progressTimestamps: Date.now()
};
finalChallenge = {
...completedChallenge
};
}
function completedChallenge(req, res, next) {
req.checkBody('id', 'id must be an ObjectId').isMongoId();
const type = accepts(req).type('html', 'json', 'text');
const errors = req.validationErrors(true);
updateData.$set = {
completedChallenges: uniqBy(
[finalChallenge, ...completedChallenges.map(fixCompletedChallengeItem)],
'id'
)
};
const { user } = req;
if (
timezone &&
timezone !== 'UTC' &&
(!userTimezone || userTimezone === 'UTC')
) {
updateData.$set = {
...updateData.$set,
timezone: userTimezone
};
}
return {
alreadyCompleted,
updateData,
completedDate: finalChallenge.completedDate
};
}
if (errors) {
if (type === 'json') {
return res.status(403).send({ errors });
export function buildChallengeUrl(challenge) {
const { superBlock, block, dashedName } = challenge;
return `/learn/${dasherize(superBlock)}/${dasherize(block)}/${dashedName}`;
}
export function getFirstChallenge(Challenge) {
return new Promise(resolve => {
Challenge.findOne(
{ where: { challengeOrder: 0, superOrder: 1, order: 0 } },
(err, challenge) => {
if (err || isEmpty(challenge)) {
return resolve('/learn');
}
return resolve(buildChallengeUrl(challenge));
}
);
});
}
log('errors', errors);
return res.sendStatus(403);
export async function createChallengeUrlResolver(
app,
{ _getFirstChallenge = getFirstChallenge } = {}
) {
const { Challenge } = app.models;
const cache = new Map();
const firstChallenge = await _getFirstChallenge(Challenge);
return function resolveChallengeUrl(id) {
if (isEmpty(id)) {
return Promise.resolve(firstChallenge);
}
return new Promise(resolve => {
if (cache.has(id)) {
return resolve(cache.get(id));
}
return Challenge.findById(id, (err, challenge) => {
if (err || isEmpty(challenge)) {
return resolve(firstChallenge);
}
const challengeUrl = buildChallengeUrl(challenge);
cache.set(id, challengeUrl);
return resolve(challengeUrl);
});
});
};
}
function modernChallengeCompleted(req, res, next) {
const type = accepts(req).type('html', 'json', 'text');
req.checkBody('id', 'id must be an ObjectId').isMongoId();
const errors = req.validationErrors(true);
if (errors) {
if (type === 'json') {
return res.status(403).send({ errors });
}
return user
.getCompletedChallenges$()
.flatMap(() => {
const completedDate = Date.now();
const { id, solution, timezone, files } = req.body;
const { alreadyCompleted, updateData } = buildUserUpdate(
user,
id,
{ id, solution, completedDate, files },
timezone
);
const points = alreadyCompleted ? user.points : user.points + 1;
const updatePromise = new Promise((resolve, reject) =>
user.updateAttributes(updateData, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.fromPromise(updatePromise).map(() => {
if (type === 'json') {
return res.json({
points,
alreadyCompleted,
completedDate
});
}
return res.sendStatus(200);
});
})
.subscribe(() => {}, next);
log('errors', errors);
return res.sendStatus(403);
}
function projectCompleted(req, res, next) {
const type = accepts(req).type('html', 'json', 'text');
req.checkBody('id', 'id must be an ObjectId').isMongoId();
req.checkBody('challengeType', 'must be a number').isNumber();
req.checkBody('solution', 'solution must be a URL').isURL();
const user = req.user;
return user
.getCompletedChallenges$()
.flatMap(() => {
const completedDate = Date.now();
const { id, files } = req.body;
const errors = req.validationErrors(true);
const { alreadyCompleted, updateData } = buildUserUpdate(user, id, {
id,
files,
completedDate
});
if (errors) {
if (type === 'json') {
return res.status(403).send({ errors });
}
log('errors', errors);
return res.sendStatus(403);
}
const { user, body = {} } = req;
const completedChallenge = _.pick(body, [
'id',
'solution',
'githubLink',
'challengeType',
'files'
]);
completedChallenge.completedDate = Date.now();
if (
!completedChallenge.solution ||
// only basejumps require github links
(completedChallenge.challengeType === 4 && !completedChallenge.githubLink)
) {
req.flash(
'danger',
"You haven't supplied the necessary URLs for us to inspect your work."
const points = alreadyCompleted ? user.points : user.points + 1;
const updatePromise = new Promise((resolve, reject) =>
user.updateAttributes(updateData, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return res.sendStatus(403);
return Observable.fromPromise(updatePromise).map(() => {
if (type === 'json') {
return res.json({
points,
alreadyCompleted,
completedDate
});
}
return res.sendStatus(200);
});
})
.subscribe(() => {}, next);
}
function completedChallenge(req, res, next) {
req.checkBody('id', 'id must be an ObjectId').isMongoId();
const type = accepts(req).type('html', 'json', 'text');
const errors = req.validationErrors(true);
const { user } = req;
if (errors) {
if (type === 'json') {
return res.status(403).send({ errors });
}
return user
.getCompletedChallenges$()
.flatMap(() => {
const { alreadyCompleted, updateData } = buildUserUpdate(
user,
completedChallenge.id,
completedChallenge
);
const updatePromise = new Promise((resolve, reject) =>
user.updateAttributes(updateData, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.fromPromise(updatePromise).doOnNext(() => {
if (type === 'json') {
return res.send({
alreadyCompleted,
points: alreadyCompleted ? user.points : user.points + 1,
completedDate: completedChallenge.completedDate
});
}
return res.status(200).send(true);
});
})
.subscribe(() => {}, next);
log('errors', errors);
return res.sendStatus(403);
}
function backendChallengeCompleted(req, res, next) {
const type = accepts(req).type('html', 'json', 'text');
req.checkBody('id', 'id must be an ObjectId').isMongoId();
req.checkBody('solution', 'solution must be a URL').isURL();
return user
.getCompletedChallenges$()
.flatMap(() => {
const completedDate = Date.now();
const { id, solution, timezone, files } = req.body;
const errors = req.validationErrors(true);
const { alreadyCompleted, updateData } = buildUserUpdate(
user,
id,
{ id, solution, completedDate, files },
timezone
);
if (errors) {
if (type === 'json') {
return res.status(403).send({ errors });
}
log('errors', errors);
return res.sendStatus(403);
const points = alreadyCompleted ? user.points : user.points + 1;
const updatePromise = new Promise((resolve, reject) =>
user.updateAttributes(updateData, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.fromPromise(updatePromise).map(() => {
if (type === 'json') {
return res.json({
points,
alreadyCompleted,
completedDate
});
}
return res.sendStatus(200);
});
})
.subscribe(() => {}, next);
}
function projectCompleted(req, res, next) {
const type = accepts(req).type('html', 'json', 'text');
req.checkBody('id', 'id must be an ObjectId').isMongoId();
req.checkBody('challengeType', 'must be a number').isNumber();
req.checkBody('solution', 'solution must be a URL').isURL();
const errors = req.validationErrors(true);
if (errors) {
if (type === 'json') {
return res.status(403).send({ errors });
}
const { user, body = {} } = req;
const completedChallenge = _.pick(body, ['id', 'solution']);
completedChallenge.completedDate = Date.now();
return user
.getCompletedChallenges$()
.flatMap(() => {
const { alreadyCompleted, updateData } = buildUserUpdate(
user,
completedChallenge.id,
completedChallenge
);
const updatePromise = new Promise((resolve, reject) =>
user.updateAttributes(updateData, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.fromPromise(updatePromise).doOnNext(() => {
if (type === 'json') {
return res.send({
alreadyCompleted,
points: alreadyCompleted ? user.points : user.points + 1,
completedDate: completedChallenge.completedDate
});
}
return res.status(200).send(true);
});
})
.subscribe(() => {}, next);
log('errors', errors);
return res.sendStatus(403);
}
async function redirectToCurrentChallenge(req, res, next) {
const { user, body = {} } = req;
const completedChallenge = pick(body, [
'id',
'solution',
'githubLink',
'challengeType',
'files'
]);
completedChallenge.completedDate = Date.now();
if (
!completedChallenge.solution ||
// only basejumps require github links
(completedChallenge.challengeType === 4 && !completedChallenge.githubLink)
) {
req.flash(
'danger',
"You haven't supplied the necessary URLs for us to inspect your work."
);
return res.sendStatus(403);
}
return user
.getCompletedChallenges$()
.flatMap(() => {
const { alreadyCompleted, updateData } = buildUserUpdate(
user,
completedChallenge.id,
completedChallenge
);
const updatePromise = new Promise((resolve, reject) =>
user.updateAttributes(updateData, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.fromPromise(updatePromise).doOnNext(() => {
if (type === 'json') {
return res.send({
alreadyCompleted,
points: alreadyCompleted ? user.points : user.points + 1,
completedDate: completedChallenge.completedDate
});
}
return res.status(200).send(true);
});
})
.subscribe(() => {}, next);
}
function backendChallengeCompleted(req, res, next) {
const type = accepts(req).type('html', 'json', 'text');
req.checkBody('id', 'id must be an ObjectId').isMongoId();
req.checkBody('solution', 'solution must be a URL').isURL();
const errors = req.validationErrors(true);
if (errors) {
if (type === 'json') {
return res.status(403).send({ errors });
}
log('errors', errors);
return res.sendStatus(403);
}
const { user, body = {} } = req;
const completedChallenge = pick(body, ['id', 'solution']);
completedChallenge.completedDate = Date.now();
return user
.getCompletedChallenges$()
.flatMap(() => {
const { alreadyCompleted, updateData } = buildUserUpdate(
user,
completedChallenge.id,
completedChallenge
);
const updatePromise = new Promise((resolve, reject) =>
user.updateAttributes(updateData, err => {
if (err) {
return reject(err);
}
return resolve();
})
);
return Observable.fromPromise(updatePromise).doOnNext(() => {
if (type === 'json') {
return res.send({
alreadyCompleted,
points: alreadyCompleted ? user.points : user.points + 1,
completedDate: completedChallenge.completedDate
});
}
return res.status(200).send(true);
});
})
.subscribe(() => {}, next);
}
export function createRedirectToCurrentChallenge(
challengeUrlResolver,
{ _homeLocation = homeLocation, _learnUrl = learnURL } = {}
) {
return async function redirectToCurrentChallenge(req, res, next) {
const { user } = req;
if (!user) {
return res.redirect(learnURL);
return res.redirect(_learnUrl);
}
const challengeId = user && user.currentChallengeId;
log(req.user.username);
log(challengeId);
const challengeUrl = await challengeUrlResolver(challengeId).catch(next);
log(challengeUrl);
if (challengeUrl === '/learn') {
// this should normally not be hit if database is properly seeded
throw new Error(dedent`
Attempted to find the url for ${challengeId}'
Attempted to find the url for ${challengeId || 'Unknown ID'}'
but came up empty.
db may not be properly seeded.
`);
}
return res.redirect(`${homeLocation}${challengeUrl}`);
}
function redirectToLearn(req, res) {
const maybeChallenge = _.last(req.path.split('/'));
if (maybeChallenge in pathMigrations) {
const redirectPath = pathMigrations[maybeChallenge];
return res.status(302).redirect(`${learnURL}${redirectPath}`);
}
return res.status(302).redirect(learnURL);
}
done();
return res.redirect(`${_homeLocation}${challengeUrl}`);
};
}
function redirectToLearn(req, res) {
const maybeChallenge = last(req.path.split('/'));
if (maybeChallenge in pathMigrations) {
const redirectPath = pathMigrations[maybeChallenge];
return res.status(302).redirect(`${learnURL}${redirectPath}`);
}
return res.status(302).redirect(learnURL);
}

View File

@ -0,0 +1,195 @@
/* global describe xdescribe it expect */
import { isEqual } from 'lodash';
import sinon from 'sinon';
import { mockReq, mockRes } from 'sinon-express-mock';
import {
buildChallengeUrl,
createChallengeUrlResolver,
createRedirectToCurrentChallenge,
getFirstChallenge
} from '../boot/challenge';
const firstChallengeUrl = '/learn/the/first/challenge';
const requestedChallengeUrl = '/learn/my/actual/challenge';
const mockChallenge = {
id: '123abc',
block: 'actual',
superBlock: 'my',
dashedName: 'challenge'
};
const mockFirstChallenge = {
id: '456def',
block: 'first',
superBlock: 'the',
dashedName: 'challenge'
};
const mockUser = {
username: 'camperbot',
currentChallengeId: '123abc'
};
const mockApp = {
models: {
Challenge: {
find() {
return firstChallengeUrl;
},
findById(id, cb) {
return id === mockChallenge.id
? cb(null, mockChallenge)
: cb(new Error('challenge not found'));
}
}
}
};
const mockGetFirstChallenge = () => firstChallengeUrl;
const firstChallengeQuery = {
where: { challengeOrder: 0, superOrder: 1, order: 0 }
};
describe('boot/challenge', () => {
xdescribe('backendChallengeCompleted');
xdescribe('buildUserUpdate');
describe('buildChallengeUrl', () => {
it('resolves the correct Url for the provided challenge', () => {
const result = buildChallengeUrl(mockChallenge);
expect(result).toEqual(requestedChallengeUrl);
});
it('can handle non-url-complient challenge names', () => {
const challenge = { ...mockChallenge, superBlock: 'my awesome' };
const expected = '/learn/my-awesome/actual/challenge';
const result = buildChallengeUrl(challenge);
expect(result).toEqual(expected);
});
});
describe('challengeUrlResolver', () => {
it('resolves to the first challenge url by default', async () => {
const challengeUrlResolver = await createChallengeUrlResolver(mockApp, {
_getFirstChallenge: mockGetFirstChallenge
});
return challengeUrlResolver().then(url => {
expect(url).toEqual(firstChallengeUrl);
});
});
it('returns the first challenge url if the provided id does not relate to a challenge', async () => {
const challengeUrlResolver = await createChallengeUrlResolver(mockApp, {
_getFirstChallenge: mockGetFirstChallenge
});
return challengeUrlResolver('not-a-real-challenge').then(url => {
expect(url).toEqual(firstChallengeUrl);
});
});
it('resolves the correct url for the requested challenge', async () => {
const challengeUrlResolver = await createChallengeUrlResolver(mockApp, {
_getFirstChallenge: mockGetFirstChallenge
});
return challengeUrlResolver('123abc').then(url => {
expect(url).toEqual(requestedChallengeUrl);
});
});
});
xdescribe('completedChallenge');
describe('getFirstChallenge', () => {
const createMockChallengeModel = success =>
success
? {
findOne(query, cb) {
return isEqual(query, firstChallengeQuery)
? cb(null, mockFirstChallenge)
: cb(new Error('no challenge found'));
}
}
: {
findOne(_, cb) {
return cb(new Error('no challenge found'));
}
};
it('returns the correct challenge url from the model', async () => {
const result = await getFirstChallenge(createMockChallengeModel(true));
expect(result).toEqual(firstChallengeUrl);
});
it('returns the learn base if no challenges found', async () => {
const result = await getFirstChallenge(createMockChallengeModel(false));
expect(result).toEqual('/learn');
});
});
xdescribe('modernChallengeCompleted');
xdescribe('projectcompleted');
describe('redirectToCurrentChallenge', () => {
const mockHomeLocation = 'https://www.example.com';
const mockLearnUrl = `${mockHomeLocation}/learn`;
it('redircts to the learn base url for non-users', async done => {
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
() => {},
{ _homeLocation: mockHomeLocation, _learnUrl: mockLearnUrl }
);
const req = mockReq();
const res = mockRes();
const next = sinon.spy();
await redirectToCurrentChallenge(req, res, next);
expect(res.redirect.calledWith(mockLearnUrl));
done();
});
it('redirects to the url provided by the challengeUrlResolver', async done => {
const challengeUrlResolver = await createChallengeUrlResolver(mockApp, {
_getFirstChallenge: mockGetFirstChallenge
});
const expectedUrl = `${mockHomeLocation}${requestedChallengeUrl}`;
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
challengeUrlResolver,
{ _homeLocation: mockHomeLocation, _learnUrl: mockLearnUrl }
);
const req = mockReq({
user: mockUser
});
const res = mockRes();
const next = sinon.spy();
await redirectToCurrentChallenge(req, res, next);
expect(res.redirect.calledWith(expectedUrl)).toBe(true);
done();
});
it('redirects to the first challenge for users without a currentChallengeId', async done => {
const challengeUrlResolver = await createChallengeUrlResolver(mockApp, {
_getFirstChallenge: mockGetFirstChallenge
});
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
challengeUrlResolver,
{ _homeLocation: mockHomeLocation, _learnUrl: mockLearnUrl }
);
const req = mockReq({
user: { ...mockUser, currentChallengeId: '' }
});
const res = mockRes();
const next = sinon.spy();
await redirectToCurrentChallenge(req, res, next);
const expectedUrl = `${mockHomeLocation}${firstChallengeUrl}`;
expect(res.redirect.calledWith(expectedUrl)).toBe(true);
done();
});
});
xdescribe('redirectToLearn');
});