chore(server): Move api-server in to it's own DIR

This commit is contained in:
Bouncey
2018-08-31 16:04:04 +01:00
committed by mrugesh mohapatra
parent 9fba6bce4c
commit 46a217d0a5
369 changed files with 328 additions and 7431 deletions

View File

@ -0,0 +1,95 @@
import _ from 'lodash';
import debug from 'debug';
import dedent from 'dedent';
import fs from 'fs';
import goog from 'googleapis';
import { Observable } from 'rx';
import { timeCache, observeMethod } from './rx';
// one million!
const upperBound = 1000 * 1000;
const scope = 'https://www.googleapis.com/auth/analytics.readonly';
const pathToCred = process.env.GOOGLE_APPLICATION_CREDENTIALS;
const log = debug('fcc:server:utils:about');
const analytics = goog.analytics('v3');
const makeRequest = observeMethod(analytics.data.realtime, 'get');
export const toBoundInt = _.flow(
// first convert string to integer
_.toInteger,
// then we bound the integer to prevent weird things like Infinity
// and negative numbers
// can't wait to the day we need to update this!
_.partialRight(_.clamp, 0, upperBound)
);
export function createActiveUsers() {
const zero = Observable.of(0);
let credentials;
if (!pathToCred) {
// if no path to credentials set to zero;
log(dedent`
no google applications credentials environmental variable found
'GOOGLE_APPLICATION_CREDENTIALS'
'activeUser' api will always return 0
this can safely be ignored during development
`);
return zero;
}
try {
credentials = require(fs.realpathSync(pathToCred));
} catch (err) {
log('google applications credentials file failed to require');
console.error(err);
// if we can't require credentials set to zero;
return zero;
}
if (
!credentials.private_key ||
!credentials.client_email ||
!credentials.viewId
) {
log(dedent`
google applications credentials json should have a
* private_key
* client_email
* viewId
but none were found
`);
return zero;
}
const client = new goog.auth.JWT(
credentials['client_email'],
null,
credentials['private_key'],
[scope],
);
const authorize = observeMethod(client, 'authorize');
const options = {
ids: `ga:${credentials.viewId}`,
auth: client,
metrics: 'rt:activeUsers'
};
return Observable.defer(
// we wait for authorize to complete before attempting to make request
// this ensures our token is initialized and valid
// we defer here to make sure the actual request is done per subscription
// instead of once at startup
() => authorize().flatMap(() => makeRequest(options))
)
// data: Array[body|Object, request: Request]
.map(data => data[0])
.map(
({ totalsForAllResults } = {}) => totalsForAllResults['rt:activeUsers']
)
.map(toBoundInt)
// print errors to error log for logging, duh
.do(null, err => console.error(err))
// always send a number down
.catch(() => Observable.of(0))
// cache for 2 seconds to prevent hitting our daily request limit
::timeCache(2, 'seconds');
}

View File

@ -0,0 +1,56 @@
const githubRegex = (/github/i);
const providerHash = {
facebook: ({ id }) => id,
github: ({ username }) => username,
twitter: ({ username }) => username,
linkedin({ _json }) {
return _json && _json.publicProfileUrl || null;
},
google: ({ id }) => id
};
export function getUsernameFromProvider(provider, profile) {
return typeof providerHash[provider] === 'function' ?
providerHash[provider](profile) :
null;
}
// createProfileAttributes(provider: String, profile: {}) => Object
export function createUserUpdatesFromProfile(provider, profile) {
if (githubRegex.test(provider)) {
return createProfileAttributesFromGithub(profile);
}
return {
[getSocialProvider(provider)]: getUsernameFromProvider(
getSocialProvider(provider),
profile
)
};
}
// createProfileAttributes(profile) => profileUpdate
function createProfileAttributesFromGithub(profile) {
const {
profileUrl: githubProfile,
username,
_json: {
avatar_url: picture,
blog: website,
location,
bio,
name
} = {}
} = profile;
return {
name,
username: username.toLowerCase(),
location,
bio,
website,
picture,
githubProfile
};
}
export function getSocialProvider(provider) {
return provider.split('-')[0];
}

View File

@ -0,0 +1,19 @@
export default {
bg9997c9c79feddfaeb9bdef: '56bbb991ad1ed5201cd392ca',
bg9995c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392cb',
bg9994c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392cc',
bg9996c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392cd',
bg9997c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392ce',
bg9997c9c89feddfaeb9bdef: '56bbb991ad1ed5201cd392cf',
bg9998c9c99feddfaeb9bdef: '56bbb991ad1ed5201cd392d0',
bg9999c9c99feddfaeb9bdef: '56bbb991ad1ed5201cd392d1',
bg9999c9c99feedfaeb9bdef: '56bbb991ad1ed5201cd392d2',
bg9999c9c99fdddfaeb9bdef: '56bbb991ad1ed5201cd392d3',
bb000000000000000000001: '56bbb991ad1ed5201cd392d4',
bc000000000000000000001: '56bbb991ad1ed5201cd392d5',
bb000000000000000000002: '56bbb991ad1ed5201cd392d6',
bb000000000000000000003: '56bbb991ad1ed5201cd392d7',
bb000000000000000000004: '56bbb991ad1ed5201cd392d8',
bb000000000000000000005: '56bbb991ad1ed5201cd392d9',
bb000000000000000000006: '56bbb991ad1ed5201cd392da'
};

View File

@ -0,0 +1,12 @@
{
"frontEnd": "isFrontEndCert",
"backEnd": "isBackEndCert",
"dataVis": "isDataVisCert",
"respWebDesign": "isRespWebDesignCert",
"frontEndLibs": "isFrontEndLibsCert",
"dataVis2018": "is2018DataVisCert",
"jsAlgoDataStruct": "isJsAlgoDataStructCert",
"apisMicroservices": "isApisMicroservicesCert",
"infosecQa": "isInfosecQaCert",
"fullStack": "isFullStackCert"
}

View File

@ -0,0 +1,11 @@
{
"frontEndCert": "Front End Development Certification",
"backEndCert": "Back End Development Certification",
"fullStackCert": "Full Stack Development Certification",
"respWebDesign": "Responsive Web Design Certification",
"frontEndLibs": "Front End Libraries Certification",
"jsAlgoDataStruct": "JavaScript Algorithms and Data Structures Certification",
"dataVis": "Data Visualisation Certification",
"apisMicroservices": "APIs and Microservices Certification",
"infosecQa": "Information Security and Quality Assurance Certification"
}

View File

@ -0,0 +1,55 @@
import dedent from 'dedent';
import debugFactory from 'debug';
import { Observable } from 'rx';
import commitGoals from './commit-goals.json';
const debug = debugFactory('fcc:utils/commit');
export { commitGoals };
export function completeCommitment$(user) {
const {
isFrontEndCert,
isBackEndCert,
isFullStackCert,
isRespWebDesignCert,
isFrontEndLibsCert,
isJsAlgoDataStructCert,
isDataVisCert,
isApisMicroservicesCert,
isInfosecQaCert
} = user;
return Observable.fromNodeCallback(user.pledge, user)()
.flatMap(pledge => {
if (!pledge) {
return Observable.just('No pledge found');
}
const { goal } = pledge;
if (
(isFrontEndCert && goal === commitGoals.frontEndCert) ||
(isBackEndCert && goal === commitGoals.backEndCert) ||
(isFullStackCert && goal === commitGoals.fullStackCert) ||
(isRespWebDesignCert && goal === commitGoals.respWebDesignCert) ||
(isFrontEndLibsCert && goal === commitGoals.frontEndLibsCert) ||
(isJsAlgoDataStructCert && goal === commitGoals.jsAlgoDataStructCert) ||
(isDataVisCert && goal === commitGoals.dataVisCert) ||
(isApisMicroservicesCert &&
goal === commitGoals.apisMicroservicesCert) ||
(isInfosecQaCert && goal === commitGoals.infosecQaCert)
) {
debug('marking goal complete');
pledge.isCompleted = true;
pledge.dateEnded = new Date();
pledge.formerUserId = pledge.userId;
pledge.userId = null;
return Observable.fromNodeCallback(pledge.save, pledge)();
}
return Observable.just(dedent`
You have not yet reached your goal of completing the ${goal}
Please retry when you have met the requirements.
`);
});
}

View File

@ -0,0 +1,50 @@
[
{
"name": "girl develop it",
"displayName": "Girl Develop It",
"donateUrl": "https://www.girldevelopit.com/donate",
"description": "Girl Develop It provides in-person classes for women to learn to code.",
"imgAlt": "Girl Develop It participants coding at tables.",
"imgUrl": "https://i.imgur.com/U1CyEuA.jpg"
},
{
"name": "black girls code",
"displayName": "Black Girls CODE",
"donateUrl": "http://www.blackgirlscode.com/",
"description": "Black Girls CODE is devoted to showing the world that black girls can code, and do so much more.",
"imgAlt": "Girls developing code with instructor",
"imgUrl": "https://i.imgur.com/HBVrdaj.jpg"
},
{
"name": "coderdojo",
"displayName": "CoderDojo",
"donateUrl": "https://www.globalgiving.org/projects/coderdojo-start-a-dojo-support/",
"description": "CoderDojo is the global network of free computer programming clubs for young people.",
"imgAlt": "Two adults help several kids program on their laptops.",
"imgUrl": "https://i.imgur.com/701RLfV.jpg"
},
{
"name": "women who code",
"displayName": "Women Who Code",
"donateUrl": "https://www.womenwhocode.com/donate",
"description": "Women Who Code (WWCode) is a global leader in propelling women in the tech industry.",
"imgAlt": "Four women sitting in a classroom together learning to code.",
"imgUrl": "https://i.imgur.com/tKUi6Rf.jpg"
},
{
"name": "girls who code",
"displayName": "Girls Who Code",
"donateUrl": "http://girlswhocode.com/",
"description": "Girls Who Code programs work to inspire, educate, and equip girls with the computing skills to pursue 21st century opportunities.",
"imgAlt": "Three women smiling while they code on a computer together.",
"imgUrl": "https://i.imgur.com/op8BVph.jpg"
},
{
"name": "hack club",
"displayName": "Hack Club",
"donateUrl": "https://hackclub.com/donate",
"description": "Hack Club helps high schoolers start after-school coding clubs.",
"imgAlt": "A bunch of high school students posing for a photo in their Hack Club.",
"imgUrl": "https://i.imgur.com/G2YvPHf.jpg"
}
]

View File

@ -0,0 +1,15 @@
{
"gitHubUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1521.3 Safari/537.36",
"legacyFrontEndChallengeId": "561add10cb82ac38a17513be",
"legacyBackEndChallengeId": "660add10cb82ac38a17513be",
"legacyDataVisId": "561add10cb82ac39a17513bc",
"respWebDesignId": "561add10cb82ac38a17513bc",
"frontEndLibsId": "561acd10cb82ac38a17513bc",
"dataVis2018Id": "5a553ca864b52e1d8bceea14",
"jsAlgoDataStructId": "561abd10cb81ac38a17513bc",
"apisMicroservicesId": "561add10cb82ac38a17523bc",
"infosecQaId": "561add10cb82ac38a17213bc",
"fullStackId": "561add10cb82ac38a17213bd"
}

View File

@ -0,0 +1,75 @@
let alphabet = '';
for (let i = 0; i < 26; i++) {
alphabet = alphabet.concat(String.fromCharCode(97 + i));
}
export const blacklistedUsernames = [
...alphabet.split(''),
'about',
'academic-honesty',
'account',
'agile',
'all-stories',
'api',
'backend-challenge-completed',
'bonfire',
'cats.json',
'challenge',
'challenge-completed',
'challenges',
'chat',
'coding-bootcamp-cost-calculator',
'completed-bonfire',
'completed-challenge',
'completed-field-guide',
'completed-zipline-or-basejump',
'donate',
'events',
'explorer',
'external',
'field-guide',
'forgot',
'forum',
'get-help',
'get-help',
'get-pai',
'guide',
'how-nonprofit-projects-work',
'internal',
'jobs',
'jobs-form',
'learn-to-code',
'login',
'logout',
'map',
'modern-challenge-completed',
'news',
'nonprofits',
'nonproifts-form',
'open-api',
'pmi-acp-agile-project-managers',
'pmi-acp-agile-project-managers-form',
'privacy',
'privacy',
'project-completed',
'reset',
'services',
'signin',
'signout',
'sitemap.xml',
'software-resources-for-nonprofits',
'stories',
'terms',
'the-fastest-web-page-on-the-internet',
'twitch',
'unsubscribe',
'unsubscribed',
'update-my-portfolio',
'update-my-profile-ui',
'update-my-projects',
'update-my-theme',
'update-my-username',
'user',
'wiki'
];

View File

@ -0,0 +1,32 @@
const _handledError = Symbol('handledError');
export function isHandledError(err) {
return !!err[_handledError];
}
export function unwrapHandledError(err) {
return err[_handledError] || {};
}
export function wrapHandledError(err, {
type,
message,
redirectTo,
status = 200
}) {
err[_handledError] = { type, message, redirectTo, status };
return err;
}
// for use with express-validator error formatter
export const createValidatorErrorFormatter = (type, redirectTo) =>
({ msg }) => wrapHandledError(
new Error(msg),
{
type,
message: msg,
redirectTo,
// we default to 400 as these are malformed requests
status: 400
}
);

View File

@ -0,0 +1,11 @@
import moment from 'moment-timezone';
// day count between two epochs (inclusive)
export function dayCount([head, tail], timezone = 'UTC') {
return Math.ceil(
moment(moment(head).tz(timezone).endOf('day')).diff(
moment(tail).tz(timezone).startOf('day'),
'days',
true)
);
}

View File

@ -0,0 +1,75 @@
import moment from 'moment-timezone';
import { dayCount } from './date-utils';
import test from 'tape';
const PST = 'America/Los_Angeles';
test('Day count between two epochs (inclusive) calculation', function(t) {
t.plan(7);
t.equal(
dayCount([
moment.utc('8/3/2015 3:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf()
]),
1,
'should return 1 day given epochs of the same day'
);
t.equal(
dayCount([
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf()
]),
1,
'should return 1 day given same epochs'
);
t.equal(
dayCount([
moment.utc('8/4/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf()
]),
2,
'should return 2 days when there is a 24 hours difference'
);
t.equal(
dayCount([
moment.utc('8/4/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('8/3/2015 23:00', 'M/D/YYYY H:mm').valueOf()
]),
2,
'should return 2 days when the diff is less than 24h but different in UTC'
);
t.equal(
dayCount([
moment.utc('8/4/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('8/3/2015 23:00', 'M/D/YYYY H:mm').valueOf()
], PST),
1,
'should return 1 day when the diff is less than 24h ' +
'and days are different in UTC, but given PST'
);
t.equal(
dayCount([
moment.utc('10/27/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('5/12/1982 1:00', 'M/D/YYYY H:mm').valueOf()
]),
12222,
'should return correct count when there is very big period'
);
t.equal(
dayCount([
moment.utc('8/4/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf()
]),
2,
'should return 2 days when there is a 24 hours difference ' +
'between dates given `undefined` timezone'
);
});

View File

@ -0,0 +1,52 @@
function getCompletedCertCount(user) {
return [
'isApisMicroservicesCert',
'is2018DataVisCert',
'isFrontEndLibsCert',
'isInfosecQaCert',
'isJsAlgoDataStructCert',
'isRespWebDesignCert'
].reduce((sum, key) => user[key] ? sum + 1 : sum, 0);
}
function getLegacyCertCount(user) {
return [
'isFrontEndCert',
'isBackEndCert',
'isDataVisCert'
].reduce((sum, key) => user[key] ? sum + 1 : sum, 0);
}
export default function populateUser(db, user) {
return new Promise((resolve, reject) => {
let populatedUser = {...user};
db.collection('user')
.aggregate([
{ $match: { _id: user.id } },
{ $project: { points: { $size: '$progressTimestamps' } } }
], function(err, [{ points = 1 } = {}]) {
if (err) { return reject(err); }
user.points = points;
let completedChallengeCount = 0;
let completedProjectCount = 0;
if ('completedChallenges' in user) {
completedChallengeCount = user.completedChallenges.length;
user.completedChallenges.forEach(item => {
if (
'challengeType' in item &&
(item.challengeType === 3 || item.challengeType === 4)
) {
completedProjectCount++;
}
});
}
populatedUser.completedChallengeCount = completedChallengeCount;
populatedUser.completedProjectCount = completedProjectCount;
populatedUser.completedCertCount = getCompletedCertCount(user);
populatedUser.completedLegacyCertCount = getLegacyCertCount(user);
populatedUser.completedChallenges = [];
return resolve(populatedUser);
});
});
}

View File

@ -0,0 +1,26 @@
export function dasherize(name) {
return ('' + name)
.toLowerCase()
.replace(/\s/g, '-')
.replace(/[^a-z0-9\-\.]/gi, '')
.replace(/\:/g, '');
}
export function nameify(str) {
return ('' + str)
.replace(/[^a-zA-Z0-9\s]/g, '')
.replace(/\:/g, '');
}
export function unDasherize(name) {
return ('' + name)
// replace dash with space
.replace(/\-/g, ' ')
// strip nonalphanumarics chars except whitespace
.replace(/[^a-zA-Z\d\s]/g, '')
.trim();
}
export function addPlaceholderImage(name) {
return `https://identicon.org?t=${name}&s=256`;
}

View File

@ -0,0 +1,8 @@
export default [
'auth',
'services',
'link'
].reduce((throughs, route) => {
throughs[route] = true;
return throughs;
}, {});

View File

@ -0,0 +1,241 @@
import _ from 'lodash';
import { Observable } from 'rx';
import { unDasherize, nameify } from '../utils';
import {
addNameIdMap as _addNameIdToMap,
checkMapData,
getFirstChallenge as _getFirstChallenge
} from '../../common/utils/map.js';
const isDev = process.env.NODE_ENV !== 'production';
const isBeta = !!process.env.BETA;
const challengesRegex = /^(bonfire|waypoint|zipline|basejump|checkpoint)/i;
const addNameIdMap = _.once(_addNameIdToMap);
const getFirstChallenge = _.once(_getFirstChallenge);
/*
* interface ChallengeMap {
* result: {
* superBlocks: [ ...superBlockDashedName: String ]
* },
* entities: {
* superBlock: {
* [ ...superBlockDashedName ]: SuperBlock
* },
* block: {
* [ ...blockDashedNameg ]: Block,
* challenge: {
* [ ...challengeDashedNameg ]: Challenge
* }
* }
* }
*/
export function _cachedMap({ Block, Challenge }) {
const challenges = Challenge.find$({
order: [ 'order ASC', 'suborder ASC' ],
where: { isPrivate: false }
});
const challengeMap = challenges
.map(
challenges => challenges
.map(challenge => challenge.toJSON())
.reduce((hash, challenge) => {
hash[challenge.dashedName] = challenge;
return hash;
}, {})
);
const blocks = Block.find$({
order: [ 'superOrder ASC', 'order ASC' ],
where: { isPrivate: false }
});
const blockMap = Observable.combineLatest(
blocks.map(
blocks => blocks
.map(block => block.toJSON())
.reduce((hash, block) => {
hash[block.dashedName] = block;
return hash;
}, {})
),
challenges
)
.map(([ blocksMap, challenges ]) => {
return challenges.reduce((blocksMap, challenge) => {
if (blocksMap[challenge.block].challenges) {
blocksMap[challenge.block].challenges.push(challenge.dashedName);
} else {
blocksMap[challenge.block] = {
...blocksMap[challenge.block],
challenges: [ challenge.dashedName ]
};
}
return blocksMap;
}, blocksMap);
});
const superBlockMap = blocks.map(blocks => blocks.reduce((map, block) => {
if (
map[block.superBlock] &&
map[block.superBlock].blocks
) {
map[block.superBlock].blocks.push(block.dashedName);
} else {
map[block.superBlock] = {
title: _.startCase(block.superBlock),
order: block.superOrder,
name: nameify(_.startCase(block.superBlock)),
dashedName: block.superBlock,
blocks: [block.dashedName],
message: block.superBlockMessage
};
}
return map;
}, {}));
const superBlocks = superBlockMap.map(superBlockMap => {
return Object.keys(superBlockMap)
.map(key => superBlockMap[key])
.map(({ dashedName }) => dashedName);
});
return Observable.combineLatest(
superBlockMap,
blockMap,
challengeMap,
superBlocks,
(superBlock, block, challenge, superBlocks) => ({
entities: {
superBlock,
block,
challenge
},
result: {
superBlocks
}
})
)
.do(checkMapData)
.shareReplay();
}
export const cachedMap = _.once(_cachedMap);
// type ObjectId: String;
// getChallengeById(
// map: Observable[map],
// id: ObjectId
// ) => Observable[Challenge] | Void;
export function getChallengeById(map, id) {
return Observable.if(
() => !id,
map.map(getFirstChallenge),
map.map(addNameIdMap)
.map(map => {
const {
entities: { challenge: challengeMap, challengeIdToName }
} = map;
let finalChallenge;
const dashedName = challengeIdToName[id];
finalChallenge = challengeMap[dashedName];
if (!finalChallenge) {
finalChallenge = getFirstChallenge(map);
}
return finalChallenge;
})
);
}
export function getChallengeInfo(map) {
return map.map(addNameIdMap)
.map(({
entities: {
challenge: challengeMap,
challengeIdToName
}
}) => ({
challengeMap,
challengeIdToName
}));
}
// if challenge is not isComingSoon or isBeta => load
// if challenge is ComingSoon we are in beta||dev => load
// if challenge is beta and we are in beta||dev => load
// else hide
function loadComingSoonOrBetaChallenge({
isComingSoon,
isBeta: challengeIsBeta
}) {
return !(isComingSoon || challengeIsBeta) || isDev || isBeta;
}
// this is a hard search
// falls back to soft search
export function getChallenge(
challengeDashedName,
blockDashedName,
map) {
return map
.flatMap(({ entities, result: { superBlocks } }) => {
const superBlock = entities.superBlock;
const block = entities.block[blockDashedName];
const challenge = entities.challenge[challengeDashedName];
return Observable.if(
() => (
!blockDashedName ||
!block ||
!challenge ||
!loadComingSoonOrBetaChallenge(challenge)
),
getChallengeByDashedName(challengeDashedName, map),
Observable.just({ block, challenge })
)
.map(({ challenge, block }) => ({
redirect: challenge.block !== blockDashedName ?
`/challenges/${block.dashedName}/${challenge.dashedName}` :
false,
entities: {
superBlock,
challenge: {
[challenge.dashedName]: challenge
}
},
result: {
block: block.dashedName,
challenge: challenge.dashedName,
superBlocks
}
}));
});
}
export function getBlockForChallenge(map, challenge) {
return map.map(({ entities: { block } }) => block[challenge.block]);
}
export function getChallengeByDashedName(dashedName, map) {
const challengeName = unDasherize(dashedName)
.replace(challengesRegex, '');
const testChallengeName = new RegExp(challengeName, 'i');
return map
.map(({ entities }) => entities.challenge)
.flatMap(challengeMap => {
return Observable.from(Object.keys(challengeMap))
.map(key => challengeMap[key]);
})
.filter(challenge => {
return loadComingSoonOrBetaChallenge(challenge) &&
testChallengeName.test(challenge.name);
})
.last({ defaultValue: null })
.flatMap(challengeOrNull => {
return Observable.if(
() => !!challengeOrNull,
Observable.just(challengeOrNull),
map.map(getFirstChallenge)
);
})
.flatMap(challenge => {
return getBlockForChallenge(map, challenge)
.map(block => ({ challenge, block }));
});
}

View File

@ -0,0 +1,74 @@
import dedent from 'dedent';
import { validationResult } from 'express-validator/check';
import { createValidatorErrorFormatter } from './create-handled-error.js';
export function ifNoUserRedirectTo(url, message, type = 'errors') {
return function(req, res, next) {
const { path } = req;
if (req.user) {
return next();
}
req.flash(type, message || `You must be signed in to access ${path}`);
return res.redirect(url);
};
}
export function ifNoUserSend(sendThis) {
return function(req, res, next) {
if (req.user) {
return next();
}
return res.status(200).send(sendThis);
};
}
export function ifNoUser401(req, res, next) {
if (req.user) {
return next();
}
return res.status(401).end();
}
export function ifNotVerifiedRedirectToUpdateEmail(req, res, next) {
const { user } = req;
if (!user) {
return next();
}
if (!user.emailVerified) {
req.flash(
'danger',
dedent`
We do not have your verified email address on record,
please add it in the settings to continue with your request.
`
);
return res.redirect('/settings');
}
return next();
}
export function ifUserRedirectTo(path = '/', status) {
status = status === 302 ? 302 : 301;
return (req, res, next) => {
if (req.user) {
return res.status(status).redirect(path);
}
return next();
};
}
// for use with express-validator error formatter
export const createValidatorErrorHandler = (...args) => (req, res, next) => {
const validation = validationResult(req)
.formatWith(createValidatorErrorFormatter(...args));
if (!validation.isEmpty()) {
const errors = validation.array();
return next(errors.pop());
}
return next();
};

View File

@ -0,0 +1,79 @@
import { isURL } from 'validator';
import { addPlaceholderImage } from './';
import {
prepUniqueDaysByHours,
calcCurrentStreak,
calcLongestStreak
} from '../utils/user-stats';
export const publicUserProps = [
'about',
'calendar',
'completedChallenges',
'githubProfile',
'isApisMicroservicesCert',
'isBackEndCert',
'isCheater',
'isDonating',
'is2018DataVisCert',
'isDataVisCert',
'isFrontEndCert',
'isFullStackCert',
'isFrontEndLibsCert',
'isHonest',
'isInfosecQaCert',
'isJsAlgoDataStructCert',
'isRespWebDesignCert',
'linkedin',
'location',
'name',
'points',
'portfolio',
'profileUI',
'projects',
'streak',
'twitter',
'username',
'website',
'yearsTopContributor'
];
export const userPropsForSession = [
...publicUserProps,
'currentChallengeId',
'email',
'emailVerified',
'id',
'sendQuincyEmail',
'theme',
'completedChallengeCount',
'completedProjectCount',
'completedCertCount',
'completedLegacyCertCount',
'acceptedPrivacyTerms'
];
export function normaliseUserFields(user) {
const about = user.bio && !user.about ? user.bio : user.about;
const picture = user.picture || addPlaceholderImage(user.username);
const twitter = user.twitter && isURL(user.twitter) ?
user.twitter :
user.twitter && `https://www.twitter.com/${user.twitter.replace(/^@/, '')}`;
return { about, picture, twitter };
}
export function getProgress(progressTimestamps, timezone = 'EST') {
const calendar = progressTimestamps
.filter(Boolean)
.reduce((data, timestamp) => {
data[Math.floor(timestamp / 1000)] = 1;
return data;
}, {});
const uniqueHours = prepUniqueDaysByHours(progressTimestamps, timezone);
const streak = {
longest: calcLongestStreak(uniqueHours, timezone),
current: calcCurrentStreak(uniqueHours, timezone)
};
return { calendar, streak };
}

6
api-server/server/utils/react.js vendored Normal file
View File

@ -0,0 +1,6 @@
export const errorThrowerMiddleware = () => next => action => {
if (action.error) {
throw action.payload;
}
return next(action);
};

View File

@ -0,0 +1,125 @@
{
"verbs": [
"aced",
"nailed",
"rocked",
"destroyed",
"owned",
"crushed",
"conquered",
"shredded",
"demolished",
"devoured",
"banished",
"wrangled"
],
"compliments": [
"Over the top!",
"Down the rabbit hole we go!",
"Bring that rain!",
"Target acquired!",
"Feel that need for speed!",
"You've got guts!",
"We have liftoff!",
"To infinity and beyond!",
"Encore!",
"Onward, ho!",
"Challenge destroyed!",
"It's on like Donkey Kong!",
"Power level? It's over 9000!",
"Coding spree!",
"Code long and prosper.",
"The crowd goes wild!",
"One for the guinness book!",
"Flawless victory!",
"Most efficient!",
"Party on, Wayne!",
"You've got the touch!",
"You're on fire!",
"Don't hurt 'em, Hammer!",
"The town is now red!",
"To the nines!",
"The world rejoices!",
"That's the way it's done!",
"You rock!",
"Woo-hoo!",
"We knew you could do it!",
"Hyper Combo Finish!",
"Nothing but net!",
"Boom-shakalaka!",
"You're a shooting star!",
"You're unstoppable!",
"Way cool!",
"You're king of the world!",
"Walk on that sunshine!",
"Keep on trucking!",
"Off the charts!",
"There is no spoon!",
"Cranked it up to 11!",
"Escape velocity reached!",
"You make this look easy!",
"Passed with flying colors!",
"You've got this!",
"Happy, happy, joy, joy!",
"Tomorrow, the world!",
"Your powers combined!",
"A winner is you!",
"It's alive. It's alive!",
"Sonic Boom!",
"Here's looking at you, Code!",
"Ride like the wind!",
"Legen - wait for it - dary!",
"Ludicrous Speed! Go!",
"Yes we can!",
"Most triumphant!",
"One loop to rule them all!",
"By the power of Grayskull!",
"You did it!",
"Storm that castle!",
"Face-melting guitar solo!",
"Checkmate!",
"Bodacious!",
"Tubular!",
"You're outta sight!",
"Keep calm and code on!",
"Even sad panda smiles!",
"Even grumpy cat approves!",
"Kool-Aid Man says oh yeah!",
"Bullseye!",
"Far out!",
"You're heating up!",
"Hasta la vista, challenge!",
"Terminated.",
"Off the hook!",
"Thundercats, Hooo!",
"Shiver me timbers!",
"Raise the roof!",
"I also live dangerously.",
"Get to the choppa!",
"Bingo!",
"And you're all out of gum.",
"Even honeybadger cares!",
"Helm, Warp Nine. Engage!",
"Gotta code 'em all!",
"Spool up the FTL drive!",
"Cool beans!",
"They're in another castle.",
"Power UP!",
"Pikachu chooses you!",
"We're gonna pump you up!",
"I gotta have more cow bell."
],
"phrases": [
"Shout it from on top of a mountain",
"Tell everyone and their dogs",
"Show them. Show them all!",
"Inspire your friends",
"Tell the world of your greatness",
"Look accomplished on social media",
"Share news of your grand endeavor",
"Establish your alibi for the past two hours",
"Prove to mom that computers aren't just for games",
"With coding power comes sharing responsibility",
"Have you told your friends of your coding powers?"
]
}

View File

@ -0,0 +1,58 @@
import Rx, { AsyncSubject, Observable } from 'rx';
import moment from 'moment';
import debugFactory from 'debug';
const debug = debugFactory('fcc:rxUtils');
export function saveInstance(instance) {
return new Rx.Observable.create(function(observer) {
if (!instance || typeof instance.save !== 'function') {
debug('no instance or save method');
observer.onNext();
return observer.onCompleted();
}
return instance.save(function(err, savedInstance) {
if (err) {
return observer.onError(err);
}
debug('instance saved');
observer.onNext(savedInstance);
return observer.onCompleted();
});
});
}
// alias saveInstance
export const saveUser = saveInstance;
// observeQuery(Model: Object, methodName: String, query: Any) => Observable
export function observeQuery(Model, methodName, query) {
return Rx.Observable.fromNodeCallback(Model[methodName], Model)(query);
}
// observeMethod(
// context: Object, methodName: String
// ) => (query: Any) => Observable
export function observeMethod(context, methodName) {
return Rx.Observable.fromNodeCallback(context[methodName], context);
}
// must be bound to an observable instance
// timeCache(amount: Number, unit: String) => Observable
export function timeCache(time, unit) {
const source = this;
let cache;
let expireCacheAt;
return Observable.create(observable => {
// if there is no expire time set
// or if expireCacheAt is smaller than now,
// set new expire time in MS and create new subscription to source
if (!expireCacheAt || expireCacheAt < Date.now()) {
// set expire in ms;
expireCacheAt = moment().add(time, unit).valueOf();
cache = new AsyncSubject();
source.subscribe(cache);
}
return cache.subscribe(observable);
});
}

View File

@ -0,0 +1,19 @@
import certTypes from './certTypes.json';
const superBlockCertTypeMap = {
// legacy
'legacy-front-end': certTypes.frontEnd,
'legacy-back-end': certTypes.backEnd,
'legacy-data-visualization': certTypes.dataVis,
// modern
'responsive-web-design': certTypes.respWebDesign,
'javascript-algorithms-and-data-structures': certTypes.jsAlgoDataStruct,
'front-end-libraries': certTypes.frontEndLibs,
'data-visualization': certTypes.dataVis2018,
'apis-and-microservices': certTypes.apisMicroservices,
'information-security-and-quality-assurance': certTypes.infosecQa,
'full-stack': certTypes.fullStack
};
export default superBlockCertTypeMap;

View File

@ -0,0 +1,37 @@
const isDev = process.env.NODE_ENV !== 'production';
const isBeta = !!process.env.BETA;
export function getEmailSender() {
return process.env.SES_MAIL_FROM || 'team@freecodecamp.org';
}
export function getPort() {
if (!isDev) {
return '443';
}
return process.env.SYNC_PORT || '3000';
}
export function getProtocol() {
return isDev ? 'http' : 'https';
}
export function getHost() {
if (isDev) {
return process.env.HOST || 'localhost';
}
return isBeta ? 'beta.freecodecamp.org' : 'www.freecodecamp.org';
}
export function getServerFullURL() {
if (!isDev) {
return getProtocol()
+ '://'
+ getHost();
}
return getProtocol()
+ '://'
+ getHost()
+ ':'
+ getPort();
}

View File

@ -0,0 +1,91 @@
import compose from 'lodash/fp/compose';
import map from 'lodash/fp/map';
import sortBy from 'lodash/fp/sortBy';
import trans from 'lodash/fp/transform';
import last from 'lodash/fp/last';
import forEachRight from 'lodash/fp/forEachRight';
import moment from 'moment-timezone';
import { dayCount } from '../utils/date-utils';
const transform = trans.convert({ cap: false });
const hoursBetween = 24;
const hoursDay = 24;
export function prepUniqueDaysByHours(cals, tz = 'UTC') {
let prev = null;
// compose goes bottom to top (map > sortBy > transform)
return compose(
transform((data, cur, i) => {
if (i < 1) {
data.push(cur);
prev = cur;
} else if (
moment(cur)
.tz(tz)
.diff(moment(prev).tz(tz).startOf('day'), 'hours')
>= hoursDay
) {
data.push(cur);
prev = cur;
}
}, []),
sortBy(e => e),
map(ts => moment(ts).tz(tz).startOf('hours').valueOf())
)(cals);
}
export function calcCurrentStreak(cals, tz = 'UTC') {
let prev = last(cals);
if (
moment()
.tz(tz)
.startOf('day')
.diff(moment(prev).tz(tz), 'hours')
> hoursBetween
) {
return 0;
}
let currentStreak = 0;
let streakContinues = true;
forEachRight(cur => {
if (
moment(prev)
.tz(tz)
.startOf('day')
.diff(moment(cur).tz(tz), 'hours')
<= hoursBetween
) {
prev = cur;
currentStreak++;
} else {
// current streak found
streakContinues = false;
}
return streakContinues;
})(cals);
return currentStreak;
}
export function calcLongestStreak(cals, tz = 'UTC') {
let tail = cals[0];
const longest = cals.reduce((longest, head, index) => {
const last = cals[index === 0 ? 0 : index - 1];
// is streak broken
if (moment(head).tz(tz).startOf('day').diff(moment(last).tz(tz), 'hours')
> hoursBetween) {
tail = head;
}
if (dayCount(longest, tz) < dayCount([head, tail], tz)) {
return [head, tail];
}
return longest;
}, [cals[0], cals[0]]);
return dayCount(longest, tz);
}

View File

@ -0,0 +1,429 @@
import test from 'tape';
import moment from 'moment-timezone';
import sinon from 'sinon';
import {
prepUniqueDaysByHours,
calcCurrentStreak,
calcLongestStreak
} from './user-stats';
// setting now to 2016-02-03T11:00:00 (PST)
const clock = sinon.useFakeTimers(1454526000000);
const PST = 'America/Los_Angeles';
test('Prepare calendar items', function(t) {
t.plan(5);
t.deepEqual(
prepUniqueDaysByHours([
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('8/3/2015 14:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('8/3/2015 20:00', 'M/D/YYYY H:mm').valueOf()
]),
[1438567200000],
'should return correct epoch when all entries fall into one day in UTC'
);
t.deepEqual(
prepUniqueDaysByHours([
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf()
]),
[1438567200000],
'should return correct epoch when given two identical dates'
);
t.deepEqual(
prepUniqueDaysByHours([
// 8/2/2015 in America/Los_Angeles
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('8/3/2015 14:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('8/3/2015 20:00', 'M/D/YYYY H:mm').valueOf()
], PST),
[1438567200000, 1438610400000],
'should return 2 epochs when dates fall into two days in PST'
);
t.deepEqual(
prepUniqueDaysByHours([
moment.utc('8/1/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('3/3/2015 14:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/30/2014 20:00', 'M/D/YYYY H:mm').valueOf()
]),
[1412107200000, 1425391200000, 1438394400000],
'should return 3 epochs when dates fall into three days'
);
t.deepEqual(
prepUniqueDaysByHours([
1438387200000, 1425340800000, 1412035200000
]),
[1412035200000, 1425340800000, 1438387200000],
'should return same but sorted array if all input dates are start of day'
);
});
test('Current streak calculation', function(t) {
t.plan(11);
t.equal(
calcCurrentStreak(
prepUniqueDaysByHours([
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
])
),
1,
'should return 1 day when today one challenge was completed'
);
t.equal(
calcCurrentStreak(
prepUniqueDaysByHours([
moment.utc(moment.utc().subtract(1, 'hours')).valueOf(),
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
])
),
1,
'should return 1 day when today more than one challenge was completed'
);
t.equal(
calcCurrentStreak(
prepUniqueDaysByHours([
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf()
])
),
0,
'should return 0 day when today 0 challenges were completed'
);
t.equal(
calcCurrentStreak(
prepUniqueDaysByHours([
moment.utc(moment.utc().subtract(1, 'days')).valueOf(),
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
])
),
2,
'should return 2 days when today and yesterday challenges were completed'
);
t.equal(
calcCurrentStreak(
prepUniqueDaysByHours([
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/12/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/12/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/12/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/12/2015 3:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/13/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/14/2015 5:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc(moment.utc().subtract(2, 'days')).valueOf(),
moment.utc(moment.utc().subtract(1, 'days')).valueOf(),
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
])
),
3,
'should return 3 when today and for two days before user was activity'
);
t.equal(
calcCurrentStreak(
prepUniqueDaysByHours([
moment.utc(moment.utc().subtract(47, 'hours')).valueOf(),
moment.utc(moment.utc().subtract(11, 'hours')).valueOf()
])
),
1,
'should return 1 when there is 1.5 days long break and ' +
'dates fall into two days separated by third'
);
t.equal(
calcCurrentStreak(
prepUniqueDaysByHours([
moment.utc(moment.utc().subtract(40, 'hours')).valueOf(),
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
])
),
2,
'should return 2 when the break is more than 1.5 days ' +
'but dates fall into two consecutive days'
);
t.equal(
calcCurrentStreak(
prepUniqueDaysByHours([
moment.utc(moment.utc().subtract(1, 'hours')).valueOf(),
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
])
),
1,
'should return correct count in default timezone UTC ' +
'given `undefined` timezone'
);
t.equal(
calcCurrentStreak(
prepUniqueDaysByHours([
moment.utc(moment.utc().subtract(1, 'days')).valueOf(),
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
], PST),
PST
),
2,
'should return 2 days when today and yesterday ' +
'challenges were completed given PST'
);
t.equal(
calcCurrentStreak(
prepUniqueDaysByHours([
1453174506164, 1453175436725, 1453252466853, 1453294968225,
1453383782844, 1453431903117, 1453471373080, 1453594733026,
1453645014058, 1453746762747, 1453747659197, 1453748029416,
1453818029213, 1453951796007, 1453988570615, 1454069704441,
1454203673979, 1454294055498, 1454333545125, 1454415163903,
1454519128123, moment.tz(PST).valueOf()
], PST),
PST
),
17,
'should return 17 when there is no break in given timezone ' +
'(but would be the break if in UTC)'
);
t.equal(
calcCurrentStreak(
prepUniqueDaysByHours([
1453174506164, 1453175436725, 1453252466853, 1453294968225,
1453383782844, 1453431903117, 1453471373080, 1453594733026,
1453645014058, 1453746762747, 1453747659197, 1453748029416,
1453818029213, 1453951796007, 1453988570615, 1454069704441,
1454203673979, 1454294055498, 1454333545125, 1454415163903,
1454519128123, moment.utc().valueOf()
])
),
4,
'should return 4 when there is a break in UTC ' +
'(but would be no break in PST)'
);
});
test('Longest streak calculation', function(t) {
t.plan(14);
t.equal(
calcLongestStreak(
prepUniqueDaysByHours([
moment.utc('9/12/2015 4:00', 'M/D/YYYY H:mm').valueOf()
])
),
1,
'should return 1 when there is the only one one-day-long streak available'
);
t.equal(
calcLongestStreak(
prepUniqueDaysByHours([
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/12/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/13/2015 3:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/14/2015 1:00', 'M/D/YYYY H:mm').valueOf()
])
),
4,
'should return 4 when there is the only one ' +
'more-than-one-days-long streak available'
);
t.equal(
calcLongestStreak(
prepUniqueDaysByHours([
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
])
),
1,
'should return 1 when there is only one one-day-long streak and it is today'
);
t.equal(
calcLongestStreak(
prepUniqueDaysByHours([
moment.utc(moment.utc().subtract(1, 'days')).valueOf(),
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
])
),
2,
'should return 2 when yesterday and today makes longest streak'
);
t.equal(
calcLongestStreak(
prepUniqueDaysByHours([
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/12/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('10/4/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('10/5/2015 5:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('10/6/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('10/7/2015 5:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('11/3/2015 2:00', 'M/D/YYYY H:mm').valueOf()
])
),
4,
'should return 4 when there is a month long break'
);
t.equal(
calcLongestStreak(
prepUniqueDaysByHours([
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/12/2015 15:30', 'M/D/YYYY H:mm').valueOf(),
moment.utc(moment.utc('9/12/2015 15:30', 'M/D/YYYY H:mm')
.add(37, 'hours')).valueOf(),
moment.utc('9/14/2015 22:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/15/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('10/3/2015 2:00', 'M/D/YYYY H:mm').valueOf()
])
),
2,
'should return 2 when there is a more than 1.5 days ' +
'long break of (36 hours)'
);
t.equal(
calcLongestStreak(
prepUniqueDaysByHours([
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/12/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/12/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/12/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/12/2015 3:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/13/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/14/2015 5:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc(moment.utc().subtract(2, 'days')).valueOf(),
moment.utc(moment.utc().subtract(1, 'days')).valueOf(),
moment.utc().valueOf()
])
),
4,
'should return 4 when the longest streak consist of ' +
'several same day timestamps'
);
t.equal(
calcLongestStreak(
prepUniqueDaysByHours([
moment.utc('8/3/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/12/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/13/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/14/2015 5:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('10/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('10/12/2015 1:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('10/13/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('10/14/2015 5:00', 'M/D/YYYY H:mm').valueOf()
])
),
4,
'should return 4 when there are several longest streaks (same length)'
);
let cals = [];
const n = 100;
for (var i = 0; i < n; i++) {
cals.push(moment.utc(moment.utc().subtract(i, 'days')).valueOf());
}
t.equal(
calcLongestStreak(prepUniqueDaysByHours(cals)),
n,
'should return correct longest streak when there is a very long period'
);
t.equal(
calcLongestStreak(
prepUniqueDaysByHours([
moment.utc(moment.utc().subtract(1, 'days')).valueOf(),
moment.utc(moment.utc().subtract(1, 'hours')).valueOf()
])
),
2,
'should return correct longest streak in default timezone ' +
'UTC given `undefined` timezone'
);
t.equal(
calcLongestStreak(
prepUniqueDaysByHours([
moment.utc('9/11/2015 4:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/12/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/13/2015 3:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/14/2015 1:00', 'M/D/YYYY H:mm').valueOf()
]), PST
),
4,
'should return 4 when there is the only one more-than-one-days-long ' +
'streak available given PST'
);
t.equal(
calcLongestStreak(
prepUniqueDaysByHours([
moment.utc('9/11/2015 23:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/12/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/13/2015 2:00', 'M/D/YYYY H:mm').valueOf(),
moment.utc('9/14/2015 6:00', 'M/D/YYYY H:mm').valueOf()
], PST), PST
),
3,
'should return 3 when longest streak is 3 in PST ' +
'(but would be different in default timezone UTC)'
);
t.equal(
calcLongestStreak(
prepUniqueDaysByHours([
1453174506164, 1453175436725, 1453252466853, 1453294968225,
1453383782844, 1453431903117, 1453471373080, 1453594733026,
1453645014058, 1453746762747, 1453747659197, 1453748029416,
1453818029213, 1453951796007, 1453988570615, 1454069704441,
1454203673979, 1454294055498, 1454333545125, 1454415163903,
1454519128123, moment.tz(PST).valueOf()
], PST),
PST
),
17,
'should return 17 when there is no break in PST ' +
'(but would be break in UTC) and it is current'
);
t.equal(
calcLongestStreak(
prepUniqueDaysByHours([
1453174506164, 1453175436725, 1453252466853, 1453294968225,
1453383782844, 1453431903117, 1453471373080, 1453594733026,
1453645014058, 1453746762747, 1453747659197, 1453748029416,
1453818029213, 1453951796007, 1453988570615, 1454069704441,
1454203673979, 1454294055498, 1454333545125, 1454415163903,
1454519128123, moment.utc().valueOf()
])
),
4,
'should return 4 when there is a break in UTC (but no break in PST)'
);
});
test.onFinish(() => {
clock.restore();
});