feat(api): get challenges directly from /curriculum

This commit is contained in:
Oliver Eyton-Williams
2020-06-23 10:01:21 +02:00
parent 2aee480c46
commit 2da8eb23e9
5 changed files with 152 additions and 111 deletions

View File

@ -31,13 +31,16 @@ import { oldDataVizId } from '../../../config/misc';
import certTypes from '../utils/certTypes.json'; import certTypes from '../utils/certTypes.json';
import superBlockCertTypeMap from '../utils/superBlockCertTypeMap'; import superBlockCertTypeMap from '../utils/superBlockCertTypeMap';
import { completeCommitment$ } from '../utils/commit'; import { completeCommitment$ } from '../utils/commit';
import { getChallenges } from '../utils/get-curriculum';
const log = debug('fcc:certification'); const log = debug('fcc:certification');
export default function bootCertificate(app) { export default function bootCertificate(app, done) {
const api = app.loopback.Router(); const api = app.loopback.Router();
// TODO: rather than getting all the challenges, then grabbing the certs,
const certTypeIds = createCertTypeIds(app); // consider just getting the certs.
getChallenges().then(allChallenges => {
const certTypeIds = createCertTypeIds(allChallenges);
const showCert = createShowCert(app); const showCert = createShowCert(app);
const verifyCert = createVerifyCert(certTypeIds, app); const verifyCert = createVerifyCert(certTypeIds, app);
@ -45,6 +48,8 @@ export default function bootCertificate(app) {
api.get('/certificate/showCert/:username/:cert', showCert); api.get('/certificate/showCert/:username/:cert', showCert);
app.use(api); app.use(api);
done();
});
} }
export function getFallbackFrontEndDate(completedChallenges, completedDate) { export function getFallbackFrontEndDate(completedChallenges, completedDate) {
@ -97,33 +102,37 @@ const renderCertifiedEmail = loopback.template(
path.join(__dirname, '..', 'views', 'emails', 'certified.ejs') path.join(__dirname, '..', 'views', 'emails', 'certified.ejs')
); );
function createCertTypeIds(app) { function createCertTypeIds(allChallenges) {
const { Challenge } = app.models;
return { return {
// legacy // legacy
[certTypes.frontEnd]: getIdsForCert$(legacyFrontEndChallengeId, Challenge), [certTypes.frontEnd]: getCertById(legacyFrontEndChallengeId, allChallenges),
[certTypes.backEnd]: getIdsForCert$(legacyBackEndChallengeId, Challenge), [certTypes.backEnd]: getCertById(legacyBackEndChallengeId, allChallenges),
[certTypes.dataVis]: getIdsForCert$(legacyDataVisId, Challenge), [certTypes.dataVis]: getCertById(legacyDataVisId, allChallenges),
[certTypes.infosecQa]: getIdsForCert$(legacyInfosecQaId, Challenge), [certTypes.infosecQa]: getCertById(legacyInfosecQaId, allChallenges),
[certTypes.fullStack]: getIdsForCert$(legacyFullStackId, Challenge), [certTypes.fullStack]: getCertById(legacyFullStackId, allChallenges),
// modern // modern
[certTypes.respWebDesign]: getIdsForCert$(respWebDesignId, Challenge), [certTypes.respWebDesign]: getCertById(respWebDesignId, allChallenges),
[certTypes.frontEndLibs]: getIdsForCert$(frontEndLibsId, Challenge), [certTypes.frontEndLibs]: getCertById(frontEndLibsId, allChallenges),
[certTypes.dataVis2018]: getIdsForCert$(dataVis2018Id, Challenge), [certTypes.dataVis2018]: getCertById(dataVis2018Id, allChallenges),
[certTypes.jsAlgoDataStruct]: getIdsForCert$(jsAlgoDataStructId, Challenge), [certTypes.jsAlgoDataStruct]: getCertById(
[certTypes.apisMicroservices]: getIdsForCert$( jsAlgoDataStructId,
apisMicroservicesId, allChallenges
Challenge
), ),
[certTypes.qaV7]: getIdsForCert$(qaV7Id, Challenge), [certTypes.apisMicroservices]: getCertById(
[certTypes.infosecV7]: getIdsForCert$(infosecV7Id, Challenge), apisMicroservicesId,
[certTypes.sciCompPyV7]: getIdsForCert$(sciCompPyV7Id, Challenge), allChallenges
[certTypes.dataAnalysisPyV7]: getIdsForCert$(dataAnalysisPyV7Id, Challenge), ),
[certTypes.machineLearningPyV7]: getIdsForCert$( [certTypes.qaV7]: getCertById(qaV7Id, allChallenges),
[certTypes.infosecV7]: getCertById(infosecV7Id, allChallenges),
[certTypes.sciCompPyV7]: getCertById(sciCompPyV7Id, allChallenges),
[certTypes.dataAnalysisPyV7]: getCertById(
dataAnalysisPyV7Id,
allChallenges
),
[certTypes.machineLearningPyV7]: getCertById(
machineLearningPyV7Id, machineLearningPyV7Id,
Challenge allChallenges
) )
}; };
} }
@ -188,13 +197,16 @@ const completionHours = {
[certTypes.machineLearningPyV7]: 400 [certTypes.machineLearningPyV7]: 400
}; };
function getIdsForCert$(id, Challenge) { // returns an array with a single element, to be flatMap'd by createdVerifyCert
return observeQuery(Challenge, 'findById', id, { function getCertById(anId, allChallenges) {
id: true, return allChallenges
tests: true, .filter(({ id }) => id === anId)
name: true, .map(({ id, tests, name, challengeType }) => ({
challengeType: true id,
}).shareReplay(); tests,
name,
challengeType
}));
} }
const superBlocks = Object.keys(superBlockCertTypeMap); const superBlocks = Object.keys(superBlockCertTypeMap);

View File

@ -18,6 +18,7 @@ import { ifNoUserSend } from '../utils/middleware';
import { dasherize } from '../../../utils/slugs'; import { dasherize } from '../../../utils/slugs';
import _pathMigrations from '../resources/pathMigration.json'; import _pathMigrations from '../resources/pathMigration.json';
import { fixCompletedChallengeItem } from '../../common/utils'; import { fixCompletedChallengeItem } from '../../common/utils';
import { getChallenges } from '../utils/get-curriculum';
const log = debug('fcc:boot:challenges'); const log = debug('fcc:boot:challenges');
@ -26,7 +27,9 @@ export default async function bootChallenge(app, done) {
const api = app.loopback.Router(); const api = app.loopback.Router();
const router = app.loopback.Router(); const router = app.loopback.Router();
const redirectToLearn = createRedirectToLearn(_pathMigrations); const redirectToLearn = createRedirectToLearn(_pathMigrations);
const challengeUrlResolver = await createChallengeUrlResolver(app); const challengeUrlResolver = await createChallengeUrlResolver(
await getChallenges()
);
const redirectToCurrentChallenge = createRedirectToCurrentChallenge( const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
challengeUrlResolver challengeUrlResolver
); );
@ -148,44 +151,46 @@ export function buildChallengeUrl(challenge) {
return `/learn/${dasherize(superBlock)}/${dasherize(block)}/${dashedName}`; return `/learn/${dasherize(superBlock)}/${dasherize(block)}/${dashedName}`;
} }
export function getFirstChallenge(Challenge) { // this is only called once during boot, so it can be slow.
return new Promise(resolve => { export function getFirstChallenge(allChallenges) {
Challenge.findOne( const first = allChallenges.find(
{ where: { challengeOrder: 0, superOrder: 1, order: 0 } }, ({ challengeOrder, superOrder, order }) =>
(err, challenge) => { challengeOrder === 0 && superOrder === 1 && order === 0
if (err || isEmpty(challenge)) {
return resolve('/learn');
}
return resolve(buildChallengeUrl(challenge));
}
); );
});
return first ? buildChallengeUrl(first) : '/learn';
}
function getChallengeById(allChallenges, targetId) {
return allChallenges.find(({ id }) => id === targetId);
} }
export async function createChallengeUrlResolver( export async function createChallengeUrlResolver(
app, allChallenges,
{ _getFirstChallenge = getFirstChallenge } = {} { _getFirstChallenge = getFirstChallenge } = {}
) { ) {
const { Challenge } = app.models;
const cache = new Map(); const cache = new Map();
const firstChallenge = await _getFirstChallenge(Challenge); const firstChallenge = _getFirstChallenge(allChallenges);
return function resolveChallengeUrl(id) { return function resolveChallengeUrl(id) {
if (isEmpty(id)) { if (isEmpty(id)) {
return Promise.resolve(firstChallenge); return Promise.resolve(firstChallenge);
} } else {
return new Promise(resolve => { return new Promise(resolve => {
if (cache.has(id)) { if (cache.has(id)) {
return resolve(cache.get(id)); resolve(cache.get(id));
}
return Challenge.findById(id, (err, challenge) => {
if (err || isEmpty(challenge)) {
return resolve(firstChallenge);
} }
const challenge = getChallengeById(allChallenges, id);
if (isEmpty(challenge)) {
resolve(firstChallenge);
} else {
const challengeUrl = buildChallengeUrl(challenge); const challengeUrl = buildChallengeUrl(challenge);
cache.set(id, challengeUrl); cache.set(id, challengeUrl);
return resolve(challengeUrl); resolve(challengeUrl);
}); }
}); });
}
}; };
} }

View File

@ -1,5 +1,5 @@
/* global describe xdescribe it expect */ /* global describe xdescribe it expect */
import { isEqual, first, find } from 'lodash'; import { first, find } from 'lodash';
import sinon from 'sinon'; import sinon from 'sinon';
import { mockReq, mockRes } from 'sinon-express-mock'; import { mockReq, mockRes } from 'sinon-express-mock';
@ -16,12 +16,10 @@ import {
import { import {
firstChallengeUrl, firstChallengeUrl,
requestedChallengeUrl, requestedChallengeUrl,
mockAllChallenges,
mockChallenge, mockChallenge,
mockFirstChallenge,
mockUser, mockUser,
mockApp,
mockGetFirstChallenge, mockGetFirstChallenge,
firstChallengeQuery,
mockCompletedChallenge, mockCompletedChallenge,
mockCompletedChallenges, mockCompletedChallenges,
mockPathMigrationMap mockPathMigrationMap
@ -175,20 +173,26 @@ describe('boot/challenge', () => {
describe('challengeUrlResolver', () => { describe('challengeUrlResolver', () => {
it('resolves to the first challenge url by default', async () => { it('resolves to the first challenge url by default', async () => {
const challengeUrlResolver = await createChallengeUrlResolver(mockApp, { const challengeUrlResolver = await createChallengeUrlResolver(
mockAllChallenges,
{
_getFirstChallenge: mockGetFirstChallenge _getFirstChallenge: mockGetFirstChallenge
}); }
);
return challengeUrlResolver().then(url => { return challengeUrlResolver().then(url => {
expect(url).toEqual(firstChallengeUrl); expect(url).toEqual(firstChallengeUrl);
}); });
}); }, 10000);
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
it('returns the first challenge url if the provided id does not relate to a challenge', async () => { it('returns the first challenge url if the provided id does not relate to a challenge', async () => {
const challengeUrlResolver = await createChallengeUrlResolver(mockApp, { const challengeUrlResolver = await createChallengeUrlResolver(
mockAllChallenges,
{
_getFirstChallenge: mockGetFirstChallenge _getFirstChallenge: mockGetFirstChallenge
}); }
);
return challengeUrlResolver('not-a-real-challenge').then(url => { return challengeUrlResolver('not-a-real-challenge').then(url => {
expect(url).toEqual(firstChallengeUrl); expect(url).toEqual(firstChallengeUrl);
@ -196,9 +200,12 @@ describe('boot/challenge', () => {
}); });
it('resolves the correct url for the requested challenge', async () => { it('resolves the correct url for the requested challenge', async () => {
const challengeUrlResolver = await createChallengeUrlResolver(mockApp, { const challengeUrlResolver = await createChallengeUrlResolver(
mockAllChallenges,
{
_getFirstChallenge: mockGetFirstChallenge _getFirstChallenge: mockGetFirstChallenge
}); }
);
return challengeUrlResolver('123abc').then(url => { return challengeUrlResolver('123abc').then(url => {
expect(url).toEqual(requestedChallengeUrl); expect(url).toEqual(requestedChallengeUrl);
@ -207,28 +214,14 @@ describe('boot/challenge', () => {
}); });
describe('getFirstChallenge', () => { 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 () => { it('returns the correct challenge url from the model', async () => {
const result = await getFirstChallenge(createMockChallengeModel(true)); const result = await getFirstChallenge(mockAllChallenges);
expect(result).toEqual(firstChallengeUrl); expect(result).toEqual(firstChallengeUrl);
}); });
it('returns the learn base if no challenges found', async () => { it('returns the learn base if no challenges found', async () => {
const result = await getFirstChallenge(createMockChallengeModel(false)); const result = await getFirstChallenge([]);
expect(result).toEqual('/learn'); expect(result).toEqual('/learn');
}); });
@ -356,9 +349,12 @@ describe('boot/challenge', () => {
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
it('redirects to the url provided by the challengeUrlResolver', async done => { it('redirects to the url provided by the challengeUrlResolver', async done => {
const challengeUrlResolver = await createChallengeUrlResolver(mockApp, { const challengeUrlResolver = await createChallengeUrlResolver(
mockAllChallenges,
{
_getFirstChallenge: mockGetFirstChallenge _getFirstChallenge: mockGetFirstChallenge
}); }
);
const expectedUrl = `${mockHomeLocation}${requestedChallengeUrl}`; const expectedUrl = `${mockHomeLocation}${requestedChallengeUrl}`;
const redirectToCurrentChallenge = createRedirectToCurrentChallenge( const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
challengeUrlResolver, challengeUrlResolver,
@ -377,9 +373,12 @@ describe('boot/challenge', () => {
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
it('redirects to the first challenge for users without a currentChallengeId', async done => { it('redirects to the first challenge for users without a currentChallengeId', async done => {
const challengeUrlResolver = await createChallengeUrlResolver(mockApp, { const challengeUrlResolver = await createChallengeUrlResolver(
mockAllChallenges,
{
_getFirstChallenge: mockGetFirstChallenge _getFirstChallenge: mockGetFirstChallenge
}); }
);
const redirectToCurrentChallenge = createRedirectToCurrentChallenge( const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
challengeUrlResolver, challengeUrlResolver,
{ _homeLocation: mockHomeLocation, _learnUrl: mockLearnUrl } { _homeLocation: mockHomeLocation, _learnUrl: mockLearnUrl }

View File

@ -16,7 +16,10 @@ export const mockFirstChallenge = {
id: '456def', id: '456def',
block: 'first', block: 'first',
superBlock: 'the', superBlock: 'the',
dashedName: 'challenge' dashedName: 'challenge',
challengeOrder: 0,
superOrder: 1,
order: 0
}; };
export const mockCompletedChallenge = { export const mockCompletedChallenge = {
@ -117,16 +120,6 @@ export function createNewUserFromEmail(email) {
export const mockApp = { export const mockApp = {
models: { models: {
Challenge: {
find() {
return firstChallengeUrl;
},
findById(id, cb) {
return id === mockChallenge.id
? cb(null, mockChallenge)
: cb(new Error('challenge not found'));
}
},
Donation: { Donation: {
findOne(query, cb) { findOne(query, cb) {
return isEqual(query, matchSubscriptionIdQuery) return isEqual(query, matchSubscriptionIdQuery)
@ -157,6 +150,8 @@ export const mockApp = {
} }
}; };
export const mockAllChallenges = [mockFirstChallenge, mockChallenge];
export const mockGetFirstChallenge = () => firstChallengeUrl; export const mockGetFirstChallenge = () => firstChallengeUrl;
export const matchEmailQuery = { export const matchEmailQuery = {

View File

@ -0,0 +1,30 @@
import { flatten } from 'lodash';
import { getChallengesForLang } from '../../../curriculum/getChallenges';
// TODO: this caching is handy if we want to field requests that need to 'query'
// the curriculum, but if we force the client to handle
// redirectToCurrentChallenge and, instead, only report the current challenge
// id via the user object, then we should *not* store this so it can be garbage
// collected.
let curriculum;
export async function getCurriculum() {
curriculum = curriculum
? curriculum
: getChallengesForLang(process.env.LOCALE);
return curriculum;
}
export async function getChallenges() {
return getCurriculum().then(curriculum => {
return Object.keys(curriculum)
.map(key => curriculum[key].blocks)
.reduce((challengeArray, superBlock) => {
const challengesForBlock = Object.keys(superBlock).map(
key => superBlock[key].challenges
);
return [...challengeArray, ...flatten(challengesForBlock)];
}, []);
});
}