diff --git a/api-server/server/boot/certificate.js b/api-server/server/boot/certificate.js index 614ef516f7..5c416214d0 100644 --- a/api-server/server/boot/certificate.js +++ b/api-server/server/boot/certificate.js @@ -31,20 +31,25 @@ import { oldDataVizId } from '../../../config/misc'; import certTypes from '../utils/certTypes.json'; import superBlockCertTypeMap from '../utils/superBlockCertTypeMap'; import { completeCommitment$ } from '../utils/commit'; +import { getChallenges } from '../utils/get-curriculum'; const log = debug('fcc:certification'); -export default function bootCertificate(app) { +export default function bootCertificate(app, done) { const api = app.loopback.Router(); + // TODO: rather than getting all the challenges, then grabbing the certs, + // consider just getting the certs. + getChallenges().then(allChallenges => { + const certTypeIds = createCertTypeIds(allChallenges); + const showCert = createShowCert(app); + const verifyCert = createVerifyCert(certTypeIds, app); - const certTypeIds = createCertTypeIds(app); - const showCert = createShowCert(app); - const verifyCert = createVerifyCert(certTypeIds, app); + api.put('/certificate/verify', ifNoUser401, ifNoSuperBlock404, verifyCert); + api.get('/certificate/showCert/:username/:cert', showCert); - api.put('/certificate/verify', ifNoUser401, ifNoSuperBlock404, verifyCert); - api.get('/certificate/showCert/:username/:cert', showCert); - - app.use(api); + app.use(api); + done(); + }); } export function getFallbackFrontEndDate(completedChallenges, completedDate) { @@ -97,33 +102,37 @@ const renderCertifiedEmail = loopback.template( path.join(__dirname, '..', 'views', 'emails', 'certified.ejs') ); -function createCertTypeIds(app) { - const { Challenge } = app.models; - +function createCertTypeIds(allChallenges) { return { // legacy - [certTypes.frontEnd]: getIdsForCert$(legacyFrontEndChallengeId, Challenge), - [certTypes.backEnd]: getIdsForCert$(legacyBackEndChallengeId, Challenge), - [certTypes.dataVis]: getIdsForCert$(legacyDataVisId, Challenge), - [certTypes.infosecQa]: getIdsForCert$(legacyInfosecQaId, Challenge), - [certTypes.fullStack]: getIdsForCert$(legacyFullStackId, Challenge), + [certTypes.frontEnd]: getCertById(legacyFrontEndChallengeId, allChallenges), + [certTypes.backEnd]: getCertById(legacyBackEndChallengeId, allChallenges), + [certTypes.dataVis]: getCertById(legacyDataVisId, allChallenges), + [certTypes.infosecQa]: getCertById(legacyInfosecQaId, allChallenges), + [certTypes.fullStack]: getCertById(legacyFullStackId, allChallenges), // modern - [certTypes.respWebDesign]: getIdsForCert$(respWebDesignId, Challenge), - [certTypes.frontEndLibs]: getIdsForCert$(frontEndLibsId, Challenge), - [certTypes.dataVis2018]: getIdsForCert$(dataVis2018Id, Challenge), - [certTypes.jsAlgoDataStruct]: getIdsForCert$(jsAlgoDataStructId, Challenge), - [certTypes.apisMicroservices]: getIdsForCert$( - apisMicroservicesId, - Challenge + [certTypes.respWebDesign]: getCertById(respWebDesignId, allChallenges), + [certTypes.frontEndLibs]: getCertById(frontEndLibsId, allChallenges), + [certTypes.dataVis2018]: getCertById(dataVis2018Id, allChallenges), + [certTypes.jsAlgoDataStruct]: getCertById( + jsAlgoDataStructId, + allChallenges ), - [certTypes.qaV7]: getIdsForCert$(qaV7Id, Challenge), - [certTypes.infosecV7]: getIdsForCert$(infosecV7Id, Challenge), - [certTypes.sciCompPyV7]: getIdsForCert$(sciCompPyV7Id, Challenge), - [certTypes.dataAnalysisPyV7]: getIdsForCert$(dataAnalysisPyV7Id, Challenge), - [certTypes.machineLearningPyV7]: getIdsForCert$( + [certTypes.apisMicroservices]: getCertById( + apisMicroservicesId, + allChallenges + ), + [certTypes.qaV7]: getCertById(qaV7Id, allChallenges), + [certTypes.infosecV7]: getCertById(infosecV7Id, allChallenges), + [certTypes.sciCompPyV7]: getCertById(sciCompPyV7Id, allChallenges), + [certTypes.dataAnalysisPyV7]: getCertById( + dataAnalysisPyV7Id, + allChallenges + ), + [certTypes.machineLearningPyV7]: getCertById( machineLearningPyV7Id, - Challenge + allChallenges ) }; } @@ -188,13 +197,16 @@ const completionHours = { [certTypes.machineLearningPyV7]: 400 }; -function getIdsForCert$(id, Challenge) { - return observeQuery(Challenge, 'findById', id, { - id: true, - tests: true, - name: true, - challengeType: true - }).shareReplay(); +// returns an array with a single element, to be flatMap'd by createdVerifyCert +function getCertById(anId, allChallenges) { + return allChallenges + .filter(({ id }) => id === anId) + .map(({ id, tests, name, challengeType }) => ({ + id, + tests, + name, + challengeType + })); } const superBlocks = Object.keys(superBlockCertTypeMap); diff --git a/api-server/server/boot/challenge.js b/api-server/server/boot/challenge.js index 7a0b01fac9..fafb53b29c 100644 --- a/api-server/server/boot/challenge.js +++ b/api-server/server/boot/challenge.js @@ -18,6 +18,7 @@ import { ifNoUserSend } from '../utils/middleware'; import { dasherize } from '../../../utils/slugs'; import _pathMigrations from '../resources/pathMigration.json'; import { fixCompletedChallengeItem } from '../../common/utils'; +import { getChallenges } from '../utils/get-curriculum'; const log = debug('fcc:boot:challenges'); @@ -26,7 +27,9 @@ export default async function bootChallenge(app, done) { const api = app.loopback.Router(); const router = app.loopback.Router(); const redirectToLearn = createRedirectToLearn(_pathMigrations); - const challengeUrlResolver = await createChallengeUrlResolver(app); + const challengeUrlResolver = await createChallengeUrlResolver( + await getChallenges() + ); const redirectToCurrentChallenge = createRedirectToCurrentChallenge( challengeUrlResolver ); @@ -148,44 +151,46 @@ export function buildChallengeUrl(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)); - } - ); - }); +// this is only called once during boot, so it can be slow. +export function getFirstChallenge(allChallenges) { + const first = allChallenges.find( + ({ challengeOrder, superOrder, order }) => + challengeOrder === 0 && superOrder === 1 && order === 0 + ); + + return first ? buildChallengeUrl(first) : '/learn'; +} + +function getChallengeById(allChallenges, targetId) { + return allChallenges.find(({ id }) => id === targetId); } export async function createChallengeUrlResolver( - app, + allChallenges, { _getFirstChallenge = getFirstChallenge } = {} ) { - const { Challenge } = app.models; const cache = new Map(); - const firstChallenge = await _getFirstChallenge(Challenge); + const firstChallenge = _getFirstChallenge(allChallenges); + 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); + } else { + return new Promise(resolve => { + if (cache.has(id)) { + resolve(cache.get(id)); + } + + const challenge = getChallengeById(allChallenges, id); + if (isEmpty(challenge)) { + resolve(firstChallenge); + } else { + const challengeUrl = buildChallengeUrl(challenge); + cache.set(id, challengeUrl); + resolve(challengeUrl); } - const challengeUrl = buildChallengeUrl(challenge); - cache.set(id, challengeUrl); - return resolve(challengeUrl); }); - }); + } }; } diff --git a/api-server/server/boot_tests/challenge.test.js b/api-server/server/boot_tests/challenge.test.js index a481fdd23d..7d83ea260e 100644 --- a/api-server/server/boot_tests/challenge.test.js +++ b/api-server/server/boot_tests/challenge.test.js @@ -1,5 +1,5 @@ /* global describe xdescribe it expect */ -import { isEqual, first, find } from 'lodash'; +import { first, find } from 'lodash'; import sinon from 'sinon'; import { mockReq, mockRes } from 'sinon-express-mock'; @@ -16,12 +16,10 @@ import { import { firstChallengeUrl, requestedChallengeUrl, + mockAllChallenges, mockChallenge, - mockFirstChallenge, mockUser, - mockApp, mockGetFirstChallenge, - firstChallengeQuery, mockCompletedChallenge, mockCompletedChallenges, mockPathMigrationMap @@ -175,20 +173,26 @@ describe('boot/challenge', () => { describe('challengeUrlResolver', () => { it('resolves to the first challenge url by default', async () => { - const challengeUrlResolver = await createChallengeUrlResolver(mockApp, { - _getFirstChallenge: mockGetFirstChallenge - }); + const challengeUrlResolver = await createChallengeUrlResolver( + mockAllChallenges, + { + _getFirstChallenge: mockGetFirstChallenge + } + ); return challengeUrlResolver().then(url => { expect(url).toEqual(firstChallengeUrl); }); - }); + }, 10000); // eslint-disable-next-line max-len it('returns the first challenge url if the provided id does not relate to a challenge', async () => { - const challengeUrlResolver = await createChallengeUrlResolver(mockApp, { - _getFirstChallenge: mockGetFirstChallenge - }); + const challengeUrlResolver = await createChallengeUrlResolver( + mockAllChallenges, + { + _getFirstChallenge: mockGetFirstChallenge + } + ); return challengeUrlResolver('not-a-real-challenge').then(url => { expect(url).toEqual(firstChallengeUrl); @@ -196,9 +200,12 @@ describe('boot/challenge', () => { }); it('resolves the correct url for the requested challenge', async () => { - const challengeUrlResolver = await createChallengeUrlResolver(mockApp, { - _getFirstChallenge: mockGetFirstChallenge - }); + const challengeUrlResolver = await createChallengeUrlResolver( + mockAllChallenges, + { + _getFirstChallenge: mockGetFirstChallenge + } + ); return challengeUrlResolver('123abc').then(url => { expect(url).toEqual(requestedChallengeUrl); @@ -207,28 +214,14 @@ describe('boot/challenge', () => { }); 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)); + const result = await getFirstChallenge(mockAllChallenges); expect(result).toEqual(firstChallengeUrl); }); it('returns the learn base if no challenges found', async () => { - const result = await getFirstChallenge(createMockChallengeModel(false)); + const result = await getFirstChallenge([]); expect(result).toEqual('/learn'); }); @@ -356,9 +349,12 @@ describe('boot/challenge', () => { // eslint-disable-next-line max-len it('redirects to the url provided by the challengeUrlResolver', async done => { - const challengeUrlResolver = await createChallengeUrlResolver(mockApp, { - _getFirstChallenge: mockGetFirstChallenge - }); + const challengeUrlResolver = await createChallengeUrlResolver( + mockAllChallenges, + { + _getFirstChallenge: mockGetFirstChallenge + } + ); const expectedUrl = `${mockHomeLocation}${requestedChallengeUrl}`; const redirectToCurrentChallenge = createRedirectToCurrentChallenge( challengeUrlResolver, @@ -377,9 +373,12 @@ describe('boot/challenge', () => { // eslint-disable-next-line max-len it('redirects to the first challenge for users without a currentChallengeId', async done => { - const challengeUrlResolver = await createChallengeUrlResolver(mockApp, { - _getFirstChallenge: mockGetFirstChallenge - }); + const challengeUrlResolver = await createChallengeUrlResolver( + mockAllChallenges, + { + _getFirstChallenge: mockGetFirstChallenge + } + ); const redirectToCurrentChallenge = createRedirectToCurrentChallenge( challengeUrlResolver, { _homeLocation: mockHomeLocation, _learnUrl: mockLearnUrl } diff --git a/api-server/server/boot_tests/fixtures.js b/api-server/server/boot_tests/fixtures.js index 5c1b3d939c..d958246644 100644 --- a/api-server/server/boot_tests/fixtures.js +++ b/api-server/server/boot_tests/fixtures.js @@ -16,7 +16,10 @@ export const mockFirstChallenge = { id: '456def', block: 'first', superBlock: 'the', - dashedName: 'challenge' + dashedName: 'challenge', + challengeOrder: 0, + superOrder: 1, + order: 0 }; export const mockCompletedChallenge = { @@ -117,16 +120,6 @@ export function createNewUserFromEmail(email) { export const mockApp = { models: { - Challenge: { - find() { - return firstChallengeUrl; - }, - findById(id, cb) { - return id === mockChallenge.id - ? cb(null, mockChallenge) - : cb(new Error('challenge not found')); - } - }, Donation: { findOne(query, cb) { return isEqual(query, matchSubscriptionIdQuery) @@ -157,6 +150,8 @@ export const mockApp = { } }; +export const mockAllChallenges = [mockFirstChallenge, mockChallenge]; + export const mockGetFirstChallenge = () => firstChallengeUrl; export const matchEmailQuery = { diff --git a/api-server/server/utils/get-curriculum.js b/api-server/server/utils/get-curriculum.js new file mode 100644 index 0000000000..dfc0ae415c --- /dev/null +++ b/api-server/server/utils/get-curriculum.js @@ -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)]; + }, []); + }); +}