Merged develop

This commit is contained in:
Keith Holliday
2017-05-08 08:32:03 -06:00
1057 changed files with 73646 additions and 59862 deletions

View File

@@ -1,21 +1,19 @@
[//]: # (Before logging this issue, look through common problems at https://github.com/HabitRPG/habitrpg/issues If you find your issue there, read at least the first post to see if there is a workaround for you)
[//]: # (Before logging this issue, please post to the Report a Bug guild from the Habitica website's Help menu. Most bugs can be handled quickly there. If a GitHub issue is needed, you will be advised of that by a moderator or staff member -- a player with a dark blue or purple name. It is recommended that you don't create a new issue unless advised to.)
[//]: # (Github is primarily used for reporting bugs. If you have a feature request, use "Help > Request a Feature" so that the feature request can be vetted by the larger Habitica community)
[//]: # (Bugs in the mobile apps can also be reported there.)
[//]: # (To report a bug in one of the mobile apps, please report it in the correct repository. Android: https://github.com/HabitRPG/habitrpg-android, iOS: https://github.com/HabitRPG/habitrpg-ios)
[//]: # (If you have a feature request, use "Help > Request a Feature", not GitHub or the Report a Bug guild.)
[//]: # (For more guidelines see https://github.com/HabitRPG/habitrpg/issues/2760)
[//]: # (Fill out relevant information - UUID is found in Settings -> API)
General Info
### General Info
* UUID:
* Browser:
* OS:
### Description
[//]: # (Describe bug in detail here. Include pictures if helpful.)
[//]: # (Describe bug in detail here. Include screenshots if helpful.)
#### Console Errors
[//]: # (Include any JavaScript console errors here.)

View File

@@ -12,22 +12,24 @@ before_install:
- $CXX --version
- npm install -g npm@4
- if [ $REQUIRES_SERVER ]; then sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 7F0CEB10; echo 'deb http://downloads-distro.mongodb.org/repo/ubuntu-upstart dist 10gen' | sudo tee /etc/apt/sources.list.d/mongodb.list; sudo apt-get update; sudo apt-get install mongodb-org-server; fi
install:
- npm install &> npm.install.log || (cat npm.install.log; false)
before_script:
- npm run test:build
- cp config.json.example config.json
- if [ $REQUIRES_SERVER ]; then until nc -z localhost 27017; do echo Waiting for MongoDB; sleep 1; done; export DISPLAY=:99; fi
after_script:
- ./node_modules/.bin/lcov-result-merger 'coverage/**/*.info' | ./node_modules/coveralls/bin/coveralls.js
script: npm run $TEST
script:
- npm run $TEST
- if [ $COVERAGE ]; then ./node_modules/.bin/lcov-result-merger 'coverage/**/*.info' | ./node_modules/coveralls/bin/coveralls.js; fi
env:
global:
- CXX=g++-4.8
- DISABLE_REQUEST_LOGGING=true
matrix:
- TEST="lint"
- TEST="test:api-v3" REQUIRES_SERVER=true
- TEST="test:api-v3" REQUIRES_SERVER=true COVERAGE=true
- TEST="test:sanity"
- TEST="test:content"
- TEST="test:common"
- TEST="test:karma"
- TEST="client:unit"
- TEST="test:content" COVERAGE=true
- TEST="test:common" COVERAGE=true
- TEST="test:karma" COVERAGE=true
- TEST="client:unit" COVERAGE=true

View File

@@ -1,43 +1,16 @@
FROM ubuntu:trusty
MAINTAINER Sabe Jones <sabe@habitica.com>
# Avoid ERROR: invoke-rc.d: policy-rc.d denied execution of start.
RUN echo -e '#!/bin/sh\nexit 0' > /usr/sbin/policy-rc.d
# Install prerequisites
RUN apt-get update
RUN apt-get install -y \
build-essential \
curl \
git \
libfontconfig1 \
libfreetype6 \
libkrb5-dev \
python
# Install NodeJS
RUN curl -sL https://deb.nodesource.com/setup_6.x | sudo -E bash -
RUN apt-get install -y nodejs
# Install npm@latest
RUN curl -sL https://www.npmjs.org/install.sh | sh
# Clean up package management
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/*
FROM node:boron
# Install global packages
RUN npm install -g gulp grunt-cli bower mocha
# Clone Habitica repo and install dependencies
WORKDIR /habitrpg
RUN git clone https://github.com/HabitRPG/habitica.git /habitrpg
RUN mkdir -p /usr/src/habitrpg
WORKDIR /usr/src/habitrpg
RUN git clone https://github.com/HabitRPG/habitica.git /usr/src/habitrpg
RUN npm install
RUN bower install --allow-root
# Create environment config file and build directory
RUN cp config.json.example config.json
# Create Build dir
RUN mkdir -p ./website/build
# Start Habitica

View File

@@ -76,6 +76,11 @@
"APN_ENABLED": "false",
"FCM_SERVER_API_KEY": ""
},
"SITE_HTTP_AUTH": {
"ENABLED": "false",
"USERNAME": "admin",
"PASSWORD": "password"
},
"PUSHER": {
"ENABLED": "false",
"APP_ID": "appId",
@@ -87,5 +92,14 @@
"FLAGGING_FOOTER_LINK": "https://habitrpg.github.io/flag-o-rama/",
"SUBSCRIPTIONS_URL": "https://hooks.slack.com/services/id/id/id"
},
"ITUNES_SHARED_SECRET": "aaaabbbbccccddddeeeeffff00001111"
"ITUNES_SHARED_SECRET": "aaaabbbbccccddddeeeeffff00001111",
"EMAILS" : {
"COMMUNITY_MANAGER_EMAIL" : "leslie@habitica.com",
"TECH_ASSISTANCE_EMAIL" : "admin@habitica.com",
"PRESS_ENQUIRY_EMAIL" : "leslie@habitica.com"
},
"LOGGLY" : {
"TOKEN" : "example-token",
"SUBDOMAIN" : "exmaple-subdomain"
}
}

View File

@@ -280,7 +280,7 @@ gulp.task('test:e2e:safe', ['test:prepare', 'test:prepare:server'], (cb) => {
gulp.task('test:api-v3:unit', (done) => {
let runner = exec(
testBin('mocha test/api/v3/unit --recursive --require ./test/helpers/start-server'),
testBin('node_modules/.bin/istanbul cover --dir coverage/api-v3-unit --report lcovonly node_modules/mocha/bin/_mocha -- test/api/v3/unit --recursive --require ./test/helpers/start-server'),
(err, stdout, stderr) => {
if (err) {
process.exit(1);
@@ -298,7 +298,7 @@ gulp.task('test:api-v3:unit:watch', () => {
gulp.task('test:api-v3:integration', (done) => {
let runner = exec(
testBin('mocha test/api/v3/integration --recursive --require ./test/helpers/start-server'),
testBin('node_modules/.bin/istanbul cover --dir coverage/api-v3-integration --report lcovonly node_modules/mocha/bin/_mocha -- test/api/v3/integration --recursive --require ./test/helpers/start-server'),
{maxBuffer: 500 * 1024},
(err, stdout, stderr) => {
if (err) {

View File

@@ -0,0 +1,88 @@
var migrationName = '20170418_subscriber_jackalopes.js';
var authorName = 'Sabe'; // in case script author needs to know when their ...
var authorUuid = '7f14ed62-5408-4e1b-be83-ada62d504931'; //... own data is done
/*
* Award Royal Purple Jackalope pet to all current subscribers
*/
var monk = require('monk');
var connectionString = 'mongodb://localhost:27017/habitrpg?auto_reconnect=true'; // FOR TEST DATABASE
var dbUsers = monk(connectionString).get('users', { castIds: false });
var now = new Date();
function processUsers(lastId) {
// specify a query to limit the affected users (empty for all users):
var query = {
'purchased.plan.customerId': {$type: 2},
$or: [
{'purchased.plan.dateTerminated': null},
{'purchased.plan.dateTerminated': {$exists: false}},
{'purchased.plan.dateTerminated': {$gt: now}},
]
};
if (lastId) {
query._id = {
$gt: lastId
}
}
dbUsers.find(query, {
sort: {_id: 1},
limit: 250,
fields: [] // specify fields we are interested in to limit retrieved data (empty if we're not reading data):
})
.then(updateUsers)
.catch(function (err) {
console.log(err);
return exiting(1, 'ERROR! ' + err);
});
}
var progressCount = 1000;
var count = 0;
function updateUsers (users) {
if (!users || users.length === 0) {
console.warn('All appropriate users found and modified.');
displayData();
return;
}
var userPromises = users.map(updateUser);
var lastUser = users[users.length - 1];
return Promise.all(userPromises)
.then(function () {
processUsers(lastUser._id);
});
}
function updateUser (user) {
count++;
var set = {'items.pets.Jackalope-RoyalPurple': 5};
dbUsers.update({_id: user._id}, {$set:set});
if (count % progressCount == 0) console.warn(count + ' ' + user._id);
if (user._id == authorUuid) console.warn(authorName + ' processed');
}
function displayData() {
console.warn('\n' + count + ' users processed\n');
return exiting(0);
}
function exiting(code, msg) {
code = code || 0; // 0 = success
if (code && !msg) { msg = 'ERROR!'; }
if (msg) {
if (code) { console.error(msg); }
else { console.log( msg); }
}
process.exit(code);
}
module.exports = processUsers;

View File

@@ -0,0 +1,207 @@
var migrationName = '20170425_missing_incentives';
var authorName = 'Sabe'; // in case script author needs to know when their ...
var authorUuid = '7f14ed62-5408-4e1b-be83-ada62d504931'; //... own data is done
/*
* Award missing Royal Purple Hatching Potion to users with 55+ check-ins
* Reduce users with impossible check-in counts to a reasonable number
*/
import monk from 'monk';
import common from '../website/common';
var connectionString = 'mongodb://localhost:27017/habitrpg?auto_reconnect=true'; // FOR TEST DATABASE
var dbUsers = monk(connectionString).get('users', { castIds: false });
function processUsers(lastId) {
// specify a query to limit the affected users (empty for all users):
var query = {
'loginIncentives': {$gt:99},
'migration': {$ne: migrationName},
};
if (lastId) {
query._id = {
$gt: lastId
}
}
dbUsers.find(query, {
sort: {_id: 1},
limit: 250,
fields: [] // specify fields we are interested in to limit retrieved data (empty if we're not reading data):
})
.then(updateUsers)
.catch(function (err) {
console.log(err);
return exiting(1, 'ERROR! ' + err);
});
}
var progressCount = 1000;
var count = 0;
function updateUsers (users) {
if (!users || users.length === 0) {
console.warn('All appropriate users found and modified.');
displayData();
return;
}
var userPromises = users.map(updateUser);
var lastUser = users[users.length - 1];
return Promise.all(userPromises)
.then(function () {
processUsers(lastUser._id);
});
}
function updateUser (user) {
count++;
var language = user.preferences.language || 'en';
var set = {'migration': migrationName};
var inc = {
'items.eggs.BearCub': 0,
'items.eggs.Cactus': 0,
'items.eggs.Dragon': 0,
'items.eggs.FlyingPig': 0,
'items.eggs.Fox': 0,
'items.eggs.LionCub': 0,
'items.eggs.PandaCub': 0,
'items.eggs.TigerCub': 0,
'items.eggs.Wolf': 0,
'items.food.Chocolate': 0,
'items.food.CottonCandyBlue': 0,
'items.food.CottonCandyPink': 0,
'items.food.Fish': 0,
'items.food.Honey': 0,
'items.food.Meat': 0,
'items.food.Milk': 0,
'items.food.Potatoe': 0,
'items.food.RottenMeat': 0,
'items.food.Strawberry': 0,
'items.hatchingPotions.Base': 0,
'items.hatchingPotions.CottonCandyBlue': 0,
'items.hatchingPotions.CottonCandyPink': 0,
'items.hatchingPotions.Desert': 0,
'items.hatchingPotions.Golden': 0,
'items.hatchingPotions.Red': 0,
'items.hatchingPotions.RoyalPurple': 0,
'items.hatchingPotions.Shade': 0,
'items.hatchingPotions.Skeleton': 0,
'items.hatchingPotions.White': 0,
'items.hatchingPotions.Zombie': 0,
};
var nextReward;
if (user.loginIncentives >= 105) {
inc['items.hatchingPotions.RoyalPurple'] += 1;
nextReward = 110;
}
if (user.loginIncentives >= 110) {
inc['items.eggs.BearCub'] += 1;
inc['items.eggs.Cactus'] += 1;
inc['items.eggs.Dragon'] += 1;
inc['items.eggs.FlyingPig'] += 1;
inc['items.eggs.Fox'] += 1;
inc['items.eggs.LionCub'] += 1;
inc['items.eggs.PandaCub'] += 1;
inc['items.eggs.TigerCub'] += 1;
inc['items.eggs.Wolf'] += 1;
nextReward = 115;
}
if (user.loginIncentives >= 115) {
inc['items.hatchingPotions.RoyalPurple'] += 1;
nextReward = 120;
}
if (user.loginIncentives >= 120) {
inc['items.hatchingPotions.Base'] += 1;
inc['items.hatchingPotions.CottonCandyBlue'] += 1;
inc['items.hatchingPotions.CottonCandyPink'] += 1;
inc['items.hatchingPotions.Desert'] += 1;
inc['items.hatchingPotions.Golden'] += 1;
inc['items.hatchingPotions.Red'] += 1;
inc['items.hatchingPotions.Shade'] += 1;
inc['items.hatchingPotions.Skeleton'] += 1;
inc['items.hatchingPotions.White'] += 1;
inc['items.hatchingPotions.Zombie'] += 1;
nextReward = 125;
}
if (user.loginIncentives >= 125) {
inc['items.hatchingPotions.RoyalPurple'] += 1;
nextReward = 130;
}
if (user.loginIncentives >= 130) {
inc['items.food.Chocolate'] += 3;
inc['items.food.CottonCandyBlue'] += 3;
inc['items.food.CottonCandyPink'] += 3;
inc['items.food.Fish'] += 3;
inc['items.food.Honey'] += 3;
inc['items.food.Meat'] += 3;
inc['items.food.Milk'] += 3;
inc['items.food.Potatoe'] += 3;
inc['items.food.RottenMeat'] += 3;
inc['items.food.Strawberry'] += 3;
}
if (user.loginIncentives >= 135) {
inc['items.hatchingPotions.RoyalPurple'] += 1;
nextReward = 140;
}
if (user.loginIncentives >= 140) {
set['items.gear.owned.weapon_special_skeletonKey'] = true;
set['items.gear.owned.shield_special_lootBag'] = true;
nextReward = 145;
}
if (user.loginIncentives >= 145) {
inc['items.hatchingPotions.RoyalPurple'] += 1;
nextReward = 150;
}
if (user.loginIncentives >= 150) {
set['items.gear.owned.head_special_clandestineCowl'] = true;
set['items.gear.owned.armor_special_sneakthiefRobes'] = true;
nextReward = 155;
}
if (user.loginIncentives > 155) {
set.loginIncentives = 155;
nextReward = 160;
}
var push = {
'notifications': {
'type': 'LOGIN_INCENTIVE',
'data': {
'nextRewardAt': nextReward,
'rewardKey': [
'shop_armoire',
],
'rewardText': common.i18n.t('checkInRewards', language),
'reward': [],
'message': common.i18n.t('backloggedCheckInRewards', language),
},
'id': common.uuid(),
}
};
dbUsers.update({_id: user._id}, {$set:set, $push:push, $inc:inc});
if (count % progressCount == 0) console.warn(count + ' ' + user._id);
if (user._id == authorUuid) console.warn(authorName + ' processed');
}
function displayData() {
console.warn('\n' + count + ' users processed\n');
return exiting(0);
}
function exiting(code, msg) {
code = code || 0; // 0 = success
if (code && !msg) { msg = 'ERROR!'; }
if (msg) {
if (code) { console.error(msg); }
else { console.log( msg); }
}
process.exit(code);
}
module.exports = processUsers;

View File

@@ -0,0 +1,47 @@
import Bluebird from 'Bluebird';
import { model as Challenges } from '../../website/server/models/challenge';
import { model as User } from '../../website/server/models/user';
async function syncChallengeToMembers (challenges) {
let challengSyncPromises = challenges.map(async function (challenge) {
let users = await User.find({
// _id: '',
challenges: challenge._id,
}).exec();
let promises = [];
users.forEach(function (user) {
promises.push(challenge.syncToUser(user));
promises.push(challenge.save());
promises.push(user.save());
});
return Bluebird.all(promises);
});
return await Bluebird.all(challengSyncPromises);
}
async function syncChallenges (lastChallengeDate) {
let query = {
// _id: '',
};
if (lastChallengeDate) {
query.createdOn = { $lte: lastChallengeDate };
}
let challengesFound = await Challenges.find(query)
.limit(10)
.sort('-createdAt')
.exec();
let syncedChallenges = await syncChallengeToMembers(challengesFound)
.catch(reason => console.error(reason));
let lastChallenge = challengesFound[challengesFound.length - 1];
if (lastChallenge) syncChallenges(lastChallenge.createdAt);
return syncedChallenges;
};
module.exports = syncChallenges;

View File

@@ -5,11 +5,12 @@ var authorUuid = ''; //... own data is done
/*
* This migrations will add a free subscription to a specified group
*/
import moment from 'moment';
import { model as Group } from '../../website/server/models/group';
// @TODO: this should probably be a GroupManager library method
async function addUnlimitedSubscription (groupId) {
async function addUnlimitedSubscription (groupId, dateTerminated) {
let group = await Group.findById(groupId);
group.purchased.plan.customerId = "group-unlimited";
@@ -18,6 +19,10 @@ async function addUnlimitedSubscription (groupId) {
group.purchased.plan.paymentMethod = "Group Unlimited";
group.purchased.plan.planId = "group_monthly";
group.purchased.plan.dateTerminated = null;
if (dateTerminated) {
let dateToEnd = moment(dateTerminated).toDate();
group.purchased.plan.dateTerminated = dateToEnd;
}
// group.purchased.plan.owner = ObjectId();
group.purchased.plan.subscriptionId = "";
@@ -29,5 +34,7 @@ module.exports = async function addUnlimitedSubscriptionCreator () {
if (!groupId) throw Error('Group ID is required');
let result = await addUnlimitedSubscription(groupId)
let dateTerminated = process.argv[3];
let result = await addUnlimitedSubscription(groupId, dateTerminated);
};

View File

@@ -2,7 +2,7 @@ var _id = '';
var update = {
$addToSet: {
'purchased.plan.mysteryItems':{
$each:['head_mystery_201702','back_mystery_201702']
$each:['back_mystery_201704','armor_mystery_201704']
}
}
};

View File

@@ -1,4 +1,4 @@
var migrationName = '20170201_takeThis.js'; // Update per month
var migrationName = '20170502_takeThis.js'; // Update per month
var authorName = 'Sabe'; // in case script author needs to know when their ...
var authorUuid = '7f14ed62-5408-4e1b-be83-ada62d504931'; //... own data is done
@@ -14,7 +14,7 @@ function processUsers(lastId) {
// specify a query to limit the affected users (empty for all users):
var query = {
'migration':{$ne:migrationName},
'challenges':{$in:['b1d436b5-c784-42e3-9b07-7072479a6f8e']} // Update per month
'challenges':{$in:['69999331-d4ea-45a0-8c3f-f725d22b56c8']} // Update per month
};
if (lastId) {

2209
npm-shrinkwrap.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "3.80.0",
"version": "3.89.0",
"main": "./website/server/index.js",
"dependencies": {
"@slack/client": "^3.8.1",
@@ -41,6 +41,7 @@
"domain-middleware": "~0.1.0",
"estraverse": "^4.1.1",
"express": "~4.14.0",
"express-basic-auth": "^1.0.1",
"express-csv": "~0.6.0",
"express-validator": "^2.18.0",
"extract-text-webpack-plugin": "^2.0.0-rc.3",
@@ -55,7 +56,7 @@
"grunt-contrib-stylus": "~0.20.0",
"grunt-contrib-uglify": "~0.6.0",
"grunt-contrib-watch": "~0.6.1",
"grunt-hashres": "~0.4.1",
"grunt-hashres": "habitrpg/grunt-hashres#v0.4.2",
"gulp": "^3.9.0",
"gulp-babel": "^6.1.2",
"gulp-grunt": "^0.5.2",
@@ -123,6 +124,7 @@
"webpack": "^2.2.1",
"webpack-merge": "^2.6.1",
"winston": "^2.1.0",
"winston-loggly-bulk": "^1.4.2",
"xml2js": "^0.4.4"
},
"private": true,
@@ -138,9 +140,9 @@
"test:api-v3:unit": "gulp test:api-v3:unit",
"test:api-v3:integration": "gulp test:api-v3:integration",
"test:api-v3:integration:separate-server": "NODE_ENV=test gulp test:api-v3:integration:separate-server",
"test:sanity": "mocha test/sanity --recursive",
"test:common": "mocha test/common --recursive",
"test:content": "mocha test/content --recursive",
"test:sanity": "istanbul cover --dir coverage/sanity --report lcovonly node_modules/mocha/bin/_mocha -- test/sanity --recursive",
"test:common": "istanbul cover --dir coverage/common --report lcovonly node_modules/mocha/bin/_mocha -- test/common --recursive",
"test:content": "istanbul cover --dir coverage/content --report lcovonly node_modules/mocha/bin/_mocha -- test/content --recursive",
"test:karma": "karma start test/client-old/spec/karma.conf.js --single-run",
"test:karma:watch": "karma start test/client-old/spec/karma.conf.js",
"test:prepare:webdriver": "webdriver-manager update",
@@ -182,7 +184,7 @@
"grunt-karma": "~0.12.1",
"http-proxy-middleware": "^0.17.0",
"inject-loader": "^3.0.0-beta4",
"istanbul": "^0.3.14",
"istanbul": "^1.1.0-alpha.1",
"karma": "^1.3.0",
"karma-babel-preprocessor": "^6.0.1",
"karma-chai-plugins": "~0.6.0",
@@ -197,7 +199,7 @@
"karma-webpack": "^2.0.2",
"lcov-result-merger": "^1.0.2",
"lolex": "^1.4.0",
"mocha": "^2.3.3",
"mocha": "^3.2.0",
"mongodb": "^2.0.46",
"mongoskin": "~2.1.0",
"monk": "^4.0.0",

View File

@@ -4,11 +4,17 @@ import {
sleep,
server,
} from '../../../../helpers/api-v3-integration.helper';
import {
SPAM_MESSAGE_LIMIT,
SPAM_MIN_EXEMPT_CONTRIB_LEVEL,
TAVERN_ID,
} from '../../../../../website/server/models/group';
import { v4 as generateUUID } from 'uuid';
describe('POST /chat', () => {
let user, groupWithChat, userWithChatRevoked, member;
let user, groupWithChat, member, additionalMember;
let testMessage = 'Test Message';
let testBannedWordMessage = 'TEST_PLACEHOLDER_SWEAR_WORD_HERE';
before(async () => {
let { group, groupLeader, members } = await createAndPopulateGroup({
@@ -22,8 +28,8 @@ describe('POST /chat', () => {
user = groupLeader;
groupWithChat = group;
userWithChatRevoked = await members[0].update({'flags.chatRevoked': true});
member = members[0];
additionalMember = members[1];
});
it('Returns an error when no message is provided', async () => {
@@ -62,6 +68,7 @@ describe('POST /chat', () => {
});
it('returns an error when chat privileges are revoked when sending a message to a public guild', async () => {
let userWithChatRevoked = await member.update({'flags.chatRevoked': true});
await expect(userWithChatRevoked.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage})).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
@@ -69,6 +76,96 @@ describe('POST /chat', () => {
});
});
context('banned word', () => {
it('returns an error when chat message contains a banned word in tavern', async () => {
await expect(user.post('/groups/habitrpg/chat', { message: testBannedWordMessage}))
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('bannedWordUsed'),
});
});
it('errors when word is part of a phrase', async () => {
let wordInPhrase = `phrase ${testBannedWordMessage} end`;
await expect(user.post('/groups/habitrpg/chat', { message: wordInPhrase}))
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('bannedWordUsed'),
});
});
it('errors when word is surrounded by non alphabet characters', async () => {
let wordInPhrase = `_!${testBannedWordMessage}@_`;
await expect(user.post('/groups/habitrpg/chat', { message: wordInPhrase}))
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('bannedWordUsed'),
});
});
it('does not error when bad word is suffix of a word', async () => {
let wordAsSuffix = `prefix${testBannedWordMessage}`;
let message = await user.post('/groups/habitrpg/chat', { message: wordAsSuffix});
expect(message.message.id).to.exist;
});
it('does not error when bad word is prefix of a word', async () => {
let wordAsPrefix = `${testBannedWordMessage}suffix`;
let message = await user.post('/groups/habitrpg/chat', { message: wordAsPrefix});
expect(message.message.id).to.exist;
});
it('does not error when sending a chat message containing a banned word to a party', async () => {
let { group, members } = await createAndPopulateGroup({
groupDetails: {
name: 'Party',
type: 'party',
privacy: 'private',
},
members: 1,
});
let message = await members[0].post(`/groups/${group._id}/chat`, { message: testBannedWordMessage});
expect(message.message.id).to.exist;
});
it('does not error when sending a chat message containing a banned word to a public guild', async () => {
let { group, members } = await createAndPopulateGroup({
groupDetails: {
name: 'public guild',
type: 'guild',
privacy: 'public',
},
members: 1,
});
let message = await members[0].post(`/groups/${group._id}/chat`, { message: testBannedWordMessage});
expect(message.message.id).to.exist;
});
it('does not error when sending a chat message containing a banned word to a private guild', async () => {
let { group, members } = await createAndPopulateGroup({
groupDetails: {
name: 'private guild',
type: 'guild',
privacy: 'private',
},
members: 1,
});
let message = await members[0].post(`/groups/${group._id}/chat`, { message: testBannedWordMessage});
expect(message.message.id).to.exist;
});
});
it('does not error when sending a message to a private guild with a user with revoked chat', async () => {
let { group, members } = await createAndPopulateGroup({
groupDetails: {
@@ -173,4 +270,30 @@ describe('POST /chat', () => {
expect(message.message.id).to.exist;
expect(memberWithNotification.newMessages[`${group._id}`]).to.exist;
});
context('Spam prevention', () => {
it('Returns an error when the user has been posting too many messages', async () => {
// Post as many messages are needed to reach the spam limit
for (let i = 0; i < SPAM_MESSAGE_LIMIT; i++) {
let result = await additionalMember.post(`/groups/${TAVERN_ID}/chat`, { message: testMessage }); // eslint-disable-line no-await-in-loop
expect(result.message.id).to.exist;
}
await expect(additionalMember.post(`/groups/${TAVERN_ID}/chat`, { message: testMessage })).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('messageGroupChatSpam'),
});
});
it('contributor should not receive spam alert', async () => {
let userSocialite = await member.update({'contributor.level': SPAM_MIN_EXEMPT_CONTRIB_LEVEL, 'flags.chatRevoked': false});
// Post 1 more message than the spam limit to ensure they do not reach the limit
for (let i = 0; i < SPAM_MESSAGE_LIMIT + 1; i++) {
let result = await userSocialite.post(`/groups/${TAVERN_ID}/chat`, { message: testMessage }); // eslint-disable-line no-await-in-loop
expect(result.message.id).to.exist;
}
});
});
});

View File

@@ -63,15 +63,17 @@ describe('GET /groups/:groupId/invites', () => {
});
it('returns only first 30 invites', async () => {
let group = await generateGroup(user, {type: 'party', name: generateUUID()});
let leader = await generateUser({balance: 4});
let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()});
let invitesToGenerate = [];
for (let i = 0; i < 31; i++) {
invitesToGenerate.push(generateUser());
}
let generatedInvites = await Promise.all(invitesToGenerate);
await user.post(`/groups/${group._id}/invite`, {uuids: generatedInvites.map(invite => invite._id)});
await leader.post(`/groups/${group._id}/invite`, {uuids: generatedInvites.map(invite => invite._id)});
let res = await user.get('/groups/party/invites');
let res = await leader.get(`/groups/${group._id}/invites`);
expect(res.length).to.equal(30);
res.forEach(member => {
expect(member).to.have.all.keys(['_id', 'id', 'profile']);

View File

@@ -0,0 +1,93 @@
import {
createAndPopulateGroup,
translate as t,
} from '../../../../helpers/api-v3-integration.helper';
import { find } from 'lodash';
describe('POST /group/:groupId/remove-manager', () => {
let leader, nonLeader, groupToUpdate;
let groupName = 'Test Public Guild';
let groupType = 'guild';
let nonManager;
function findAssignedTask (memberTask) {
return memberTask.group.id === groupToUpdate._id;
}
beforeEach(async () => {
let { group, groupLeader, members } = await createAndPopulateGroup({
groupDetails: {
name: groupName,
type: groupType,
privacy: 'public',
},
members: 1,
});
groupToUpdate = group;
leader = groupLeader;
nonLeader = members[0];
nonManager = members[0];
});
it('returns an error when a non group leader tries to add member', async () => {
await expect(nonLeader.post(`/groups/${groupToUpdate._id}/remove-manager`, {
managerId: nonLeader._id,
})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('messageGroupOnlyLeaderCanUpdate'),
});
});
it('returns an error when manager does not exist', async () => {
await expect(leader.post(`/groups/${groupToUpdate._id}/remove-manager`, {
managerId: nonManager._id,
})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('userIsNotManager'),
});
});
it('allows a leader to remove managers', async () => {
await leader.post(`/groups/${groupToUpdate._id}/add-manager`, {
managerId: nonLeader._id,
});
let updatedGroup = await leader.post(`/groups/${groupToUpdate._id}/remove-manager`, {
managerId: nonLeader._id,
});
expect(updatedGroup.managers[nonLeader._id]).to.not.exist;
});
it('removes group approval notifications from a manager that is removed', async () => {
await leader.post(`/groups/${groupToUpdate._id}/add-manager`, {
managerId: nonLeader._id,
});
let task = await leader.post(`/tasks/group/${groupToUpdate._id}`, {
text: 'test todo',
type: 'todo',
requiresApproval: true,
});
await nonLeader.post(`/tasks/${task._id}/assign/${leader._id}`);
let memberTasks = await leader.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
await expect(leader.post(`/tasks/${syncedTask._id}/score/up`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('taskApprovalHasBeenRequested'),
});
let updatedGroup = await leader.post(`/groups/${groupToUpdate._id}/remove-manager`, {
managerId: nonLeader._id,
});
await nonLeader.sync();
expect(nonLeader.notifications.length).to.equal(0);
expect(updatedGroup.managers[nonLeader._id]).to.not.exist;
});
});

View File

@@ -4,8 +4,11 @@ import {
translate as t,
} from '../../../../helpers/api-v3-integration.helper';
import { v4 as generateUUID } from 'uuid';
import nconf from 'nconf';
const INVITES_LIMIT = 100;
const PARTY_LIMIT_MEMBERS = 30;
const MAX_EMAIL_INVITES_BY_USER = 200;
describe('Post /groups/:groupId/invite', () => {
let inviter;
@@ -204,13 +207,37 @@ describe('Post /groups/:groupId/invite', () => {
});
});
it('returns an error when a user has sent the max number of email invites', async () => {
let inviterWithMax = await generateUser({
invitesSent: MAX_EMAIL_INVITES_BY_USER,
balance: 4,
});
let tmpGroup = await inviterWithMax.post('/groups', {
name: groupName,
type: 'guild',
});
await expect(inviterWithMax.post(`/groups/${tmpGroup._id}/invite`, {
emails: [testInvite],
inviter: 'inviter name',
}))
.to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('inviteLimitReached', {techAssistanceEmail: nconf.get('EMAILS:TECH_ASSISTANCE_EMAIL')}),
});
});
it('invites a user to a group by email', async () => {
let res = await inviter.post(`/groups/${group._id}/invite`, {
emails: [testInvite],
inviter: 'inviter name',
});
let updatedUser = await inviter.sync();
expect(res).to.exist;
expect(updatedUser.invitesSent).to.eql(1);
});
it('invites multiple users to a group by email', async () => {
@@ -218,7 +245,10 @@ describe('Post /groups/:groupId/invite', () => {
emails: [testInvite, {name: 'test2', email: 'test2@habitica.com'}],
});
let updatedUser = await inviter.sync();
expect(res).to.exist;
expect(updatedUser.invitesSent).to.eql(2);
});
});
@@ -321,6 +351,19 @@ describe('Post /groups/:groupId/invite', () => {
});
});
it('allows 30+ members in a guild', async () => {
let invitesToGenerate = [];
// Generate 30 users to invite (30 + leader = 31 members)
for (let i = 0; i < PARTY_LIMIT_MEMBERS; i++) {
invitesToGenerate.push(generateUser());
}
let generatedInvites = await Promise.all(invitesToGenerate);
// Invite users
expect(await inviter.post(`/groups/${group._id}/invite`, {
uuids: generatedInvites.map(invite => invite._id),
})).to.be.an('array');
});
// @TODO: Add this after we are able to mock the group plan route
xit('returns an error when a non-leader invites to a group plan', async () => {
let userToInvite = await generateUser();
@@ -410,5 +453,36 @@ describe('Post /groups/:groupId/invite', () => {
});
expect((await userToInvite.get('/user')).invitations.party.id).to.equal(party._id);
});
it('allows 30 members in a party', async () => {
let invitesToGenerate = [];
// Generate 29 users to invite (29 + leader = 30 members)
for (let i = 0; i < PARTY_LIMIT_MEMBERS - 1; i++) {
invitesToGenerate.push(generateUser());
}
let generatedInvites = await Promise.all(invitesToGenerate);
// Invite users
expect(await inviter.post(`/groups/${party._id}/invite`, {
uuids: generatedInvites.map(invite => invite._id),
})).to.be.an('array');
});
it('does not allow 30+ members in a party', async () => {
let invitesToGenerate = [];
// Generate 30 users to invite (30 + leader = 31 members)
for (let i = 0; i < PARTY_LIMIT_MEMBERS; i++) {
invitesToGenerate.push(generateUser());
}
let generatedInvites = await Promise.all(invitesToGenerate);
// Invite users
await expect(inviter.post(`/groups/${party._id}/invite`, {
uuids: generatedInvites.map(invite => invite._id),
}))
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('partyExceedsMembersLimit', {maxMembersParty: PARTY_LIMIT_MEMBERS}),
});
});
});
});

View File

@@ -0,0 +1,85 @@
import {
generateUser,
createAndPopulateGroup,
translate as t,
} from '../../../../helpers/api-v3-integration.helper';
describe('POST /group/:groupId/add-manager', () => {
let leader, nonLeader, groupToUpdate;
let groupName = 'Test Public Guild';
let groupType = 'guild';
let nonMember;
context('Guilds', () => {
beforeEach(async () => {
let { group, groupLeader, members } = await createAndPopulateGroup({
groupDetails: {
name: groupName,
type: groupType,
privacy: 'public',
},
members: 1,
});
groupToUpdate = group;
leader = groupLeader;
nonLeader = members[0];
nonMember = await generateUser();
});
it('returns an error when a non group leader tries to add member', async () => {
await expect(nonLeader.post(`/groups/${groupToUpdate._id}/add-manager`, {
managerId: nonLeader._id,
})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('messageGroupOnlyLeaderCanUpdate'),
});
});
it('returns an error when trying to promote a non member', async () => {
await expect(leader.post(`/groups/${groupToUpdate._id}/add-manager`, {
managerId: nonMember._id,
})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('userMustBeMember'),
});
});
it('allows a leader to add managers', async () => {
let updatedGroup = await leader.post(`/groups/${groupToUpdate._id}/add-manager`, {
managerId: nonLeader._id,
});
expect(updatedGroup.managers[nonLeader._id]).to.be.true;
});
});
context('Party', () => {
let party, partyLeader, partyNonLeader;
beforeEach(async () => {
let { group, groupLeader, members } = await createAndPopulateGroup({
groupDetails: {
name: groupName,
type: 'party',
privacy: 'private',
},
members: 1,
});
party = group;
partyLeader = groupLeader;
partyNonLeader = members[0];
});
it('allows leader of party to add managers', async () => {
let updatedGroup = await partyLeader.post(`/groups/${party._id}/add-manager`, {
managerId: partyNonLeader._id,
});
expect(updatedGroup.managers[partyNonLeader._id]).to.be.true;
});
});
});

View File

@@ -4,7 +4,7 @@ import {
} from '../../../../../helpers/api-integration/v3';
import { find } from 'lodash';
describe('DELETE /tasks/:id', () => {
describe('Groups DELETE /tasks/:id', () => {
let user, guild, member, member2, task;
function findAssignedTask (memberTask) {
@@ -48,6 +48,21 @@ describe('DELETE /tasks/:id', () => {
});
});
it('allows a manager to delete a group task', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: member2._id,
});
await member2.del(`/tasks/${task._id}`);
await expect(user.get(`/tasks/${task._id}`))
.to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('taskNotFound'),
});
});
it('unlinks assigned user', async () => {
await user.del(`/tasks/${task._id}`);

View File

@@ -55,4 +55,13 @@ describe('GET /approvals/group/:groupId', () => {
let approvals = await user.get(`/approvals/group/${guild._id}`);
expect(approvals[0]._id).to.equal(syncedTask._id);
});
it('allows managers to get a list of task that need approval', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: member._id,
});
let approvals = await member.get(`/approvals/group/${guild._id}`);
expect(approvals[0]._id).to.equal(syncedTask._id);
});
});

View File

@@ -5,7 +5,7 @@ import {
import { find } from 'lodash';
describe('POST /tasks/:id/approve/:userId', () => {
let user, guild, member, task;
let user, guild, member, member2, task;
function findAssignedTask (memberTask) {
return memberTask.group.id === guild._id;
@@ -17,12 +17,13 @@ describe('POST /tasks/:id/approve/:userId', () => {
name: 'Test Guild',
type: 'guild',
},
members: 1,
members: 2,
});
guild = group;
user = groupLeader;
member = members[0];
member2 = members[1];
task = await user.post(`/tasks/group/${guild._id}`, {
text: 'test todo',
@@ -69,4 +70,74 @@ describe('POST /tasks/:id/approve/:userId', () => {
expect(syncedTask.group.approval.approvingUser).to.equal(user._id);
expect(syncedTask.group.approval.dateApproved).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type
});
it('allows a manager to approve an assigned user', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: member2._id,
});
await member2.post(`/tasks/${task._id}/assign/${member._id}`);
await member2.post(`/tasks/${task._id}/approve/${member._id}`);
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
await member.sync();
expect(member.notifications.length).to.equal(2);
expect(member.notifications[0].type).to.equal('GROUP_TASK_APPROVED');
expect(member.notifications[0].data.message).to.equal(t('yourTaskHasBeenApproved', {taskText: task.text}));
expect(member.notifications[1].type).to.equal('SCORED_TASK');
expect(member.notifications[1].data.message).to.equal(t('yourTaskHasBeenApproved', {taskText: task.text}));
expect(syncedTask.group.approval.approved).to.be.true;
expect(syncedTask.group.approval.approvingUser).to.equal(member2._id);
expect(syncedTask.group.approval.dateApproved).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type
});
it('removes approval pending notifications from managers', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: member2._id,
});
await member2.post(`/tasks/${task._id}/assign/${member._id}`);
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
await expect(member.post(`/tasks/${syncedTask._id}/score/up`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('taskApprovalHasBeenRequested'),
});
await user.sync();
await member2.sync();
expect(user.notifications.length).to.equal(1);
expect(user.notifications[0].type).to.equal('GROUP_TASK_APPROVAL');
expect(member2.notifications.length).to.equal(1);
expect(member2.notifications[0].type).to.equal('GROUP_TASK_APPROVAL');
await member2.post(`/tasks/${task._id}/approve/${member._id}`);
await user.sync();
await member2.sync();
expect(user.notifications.length).to.equal(0);
expect(member2.notifications.length).to.equal(0);
});
it('prevents double approval on a task', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: member2._id,
});
await member2.post(`/tasks/${task._id}/assign/${member._id}`);
await member2.post(`/tasks/${task._id}/approve/${member._id}`);
await expect(user.post(`/tasks/${task._id}/approve/${member._id}`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('canOnlyApproveTaskOnce'),
});
});
});

View File

@@ -5,7 +5,7 @@ import {
import { find } from 'lodash';
describe('POST /tasks/:id/score/:direction', () => {
let user, guild, member, task;
let user, guild, member, member2, task;
function findAssignedTask (memberTask) {
return memberTask.group.id === guild._id;
@@ -17,12 +17,13 @@ describe('POST /tasks/:id/score/:direction', () => {
name: 'Test Guild',
type: 'guild',
},
members: 1,
members: 2,
});
guild = group;
user = groupLeader;
member = members[0];
member2 = members[1];
task = await user.post(`/tasks/group/${guild._id}`, {
text: 'test todo',
@@ -56,6 +57,7 @@ describe('POST /tasks/:id/score/:direction', () => {
expect(user.notifications[0].data.message).to.equal(t('userHasRequestedTaskApproval', {
user: member.auth.local.username,
taskName: updatedTask.text,
taskId: updatedTask._id,
}, 'cs')); // This test only works if we have the notification translated
expect(user.notifications[0].data.groupId).to.equal(guild._id);
@@ -63,6 +65,42 @@ describe('POST /tasks/:id/score/:direction', () => {
expect(updatedTask.group.approval.requestedDate).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type
});
it('sends notifications to all managers', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: member2._id,
});
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
await expect(member.post(`/tasks/${syncedTask._id}/score/up`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('taskApprovalHasBeenRequested'),
});
let updatedTask = await member.get(`/tasks/${syncedTask._id}`);
await user.sync();
await member2.sync();
expect(user.notifications.length).to.equal(1);
expect(user.notifications[0].type).to.equal('GROUP_TASK_APPROVAL');
expect(user.notifications[0].data.message).to.equal(t('userHasRequestedTaskApproval', {
user: member.auth.local.username,
taskName: updatedTask.text,
taskId: updatedTask._id,
}));
expect(user.notifications[0].data.groupId).to.equal(guild._id);
expect(member2.notifications.length).to.equal(1);
expect(member2.notifications[0].type).to.equal('GROUP_TASK_APPROVAL');
expect(member2.notifications[0].data.message).to.equal(t('userHasRequestedTaskApproval', {
user: member.auth.local.username,
taskName: updatedTask.text,
taskId: updatedTask._id,
}));
expect(member2.notifications[0].data.groupId).to.equal(guild._id);
});
it('errors when approval has already been requested', async () => {
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);

View File

@@ -1,16 +1,29 @@
import {
generateUser,
generateGroup,
createAndPopulateGroup,
translate as t,
} from '../../../../../helpers/api-v3-integration.helper';
import { v4 as generateUUID } from 'uuid';
describe('POST /tasks/group/:groupid', () => {
let user, guild;
let user, guild, manager;
let groupName = 'Test Public Guild';
let groupType = 'guild';
beforeEach(async () => {
user = await generateUser({balance: 1});
guild = await generateGroup(user, {type: 'guild'});
let { group, groupLeader, members } = await createAndPopulateGroup({
groupDetails: {
name: groupName,
type: groupType,
privacy: 'private',
},
members: 1,
});
guild = group;
user = groupLeader;
manager = members[0];
});
it('returns error when group is not found', async () => {
@@ -116,4 +129,27 @@ describe('POST /tasks/group/:groupid', () => {
expect(task.everyX).to.eql(5);
expect(new Date(task.startDate)).to.eql(now);
});
it('allows a manager to add a group task', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: manager._id,
});
let task = await manager.post(`/tasks/group/${guild._id}`, {
text: 'test habit',
type: 'habit',
up: false,
down: true,
notes: 1976,
});
let groupTask = await manager.get(`/tasks/group/${guild._id}`);
expect(groupTask[0].group.id).to.equal(guild._id);
expect(task.text).to.eql('test habit');
expect(task.notes).to.eql('1976');
expect(task.type).to.eql('habit');
expect(task.up).to.eql(false);
expect(task.down).to.eql(true);
});
});

View File

@@ -6,7 +6,7 @@ import {
import { v4 as generateUUID } from 'uuid';
import { find } from 'lodash';
describe('POST /tasks/:taskId', () => {
describe('POST /tasks/:taskId/assign/:memberId', () => {
let user, guild, member, member2, task;
function findAssignedTask (memberTask) {
@@ -130,4 +130,19 @@ describe('POST /tasks/:taskId', () => {
expect(member1SyncedTask).to.exist;
expect(member2SyncedTask).to.exist;
});
it('allows a manager to assign tasks', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: member2._id,
});
await member2.post(`/tasks/${task._id}/assign/${member._id}`);
let groupTask = await member2.get(`/tasks/group/${guild._id}`);
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
expect(groupTask[0].group.assignedUsers).to.contain(member._id);
expect(syncedTask).to.exist;
});
});

View File

@@ -114,4 +114,19 @@ describe('POST /tasks/:taskId/unassign/:memberId', () => {
expect(groupTask[0].group.assignedUsers).to.contain(member2._id);
expect(member2SyncedTask).to.exist;
});
it('allows a manager to unassign a user from a task', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: member2._id,
});
await member2.post(`/tasks/${task._id}/unassign/${member._id}`);
let groupTask = await member2.get(`/tasks/group/${guild._id}`);
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
expect(groupTask[0].group.assignedUsers).to.not.contain(member._id);
expect(syncedTask).to.not.exist;
});
});

View File

@@ -89,4 +89,25 @@ describe('PUT /tasks/:id', () => {
expect(member2SyncedTask.up).to.eql(false);
expect(member2SyncedTask.down).to.eql(false);
});
it('updates the linked tasks', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: member2._id,
});
await member2.put(`/tasks/${task._id}`, {
text: 'some new text',
up: false,
down: false,
notes: 'some new notes',
});
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
expect(syncedTask.text).to.eql('some new text');
expect(syncedTask.up).to.eql(false);
expect(syncedTask.down).to.eql(false);
});
});

View File

@@ -9,6 +9,8 @@ import {
sha1Encrypt as sha1EncryptPassword,
} from '../../../../../../website/server/libs/password';
import nconf from 'nconf';
describe('POST /user/auth/local/login', () => {
let api;
let user;
@@ -43,7 +45,7 @@ describe('POST /user/auth/local/login', () => {
})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('accountSuspended', { userId: user._id }),
message: t('accountSuspended', { communityManagerEmail: nconf.get('EMAILS:COMMUNITY_MANAGER_EMAIL'), userId: user._id }),
});
});

View File

@@ -34,9 +34,11 @@ describe('POST /user/auth/local/register', () => {
expect(user.apiToken).to.exist;
expect(user.auth.local.username).to.eql(username);
expect(user.profile.name).to.eql(username);
expect(user.newUser).to.eql(true);
});
it('provides default tags and tasks', async () => {
context('provides default tags and tasks', async () => {
it('for a generic API consumer', async () => {
let username = generateRandomUserName();
let email = `${username}@example.com`;
let password = 'password';
@@ -48,11 +50,193 @@ describe('POST /user/auth/local/register', () => {
confirmPassword: password,
});
expect(user.tags).to.have.a.lengthOf(7);
expect(user.tasksOrder.todos).to.have.a.lengthOf(1);
expect(user.tasksOrder.dailys).to.have.a.lengthOf(0);
expect(user.tasksOrder.rewards).to.have.a.lengthOf(0);
expect(user.tasksOrder.habits).to.have.a.lengthOf(0);
let requests = new ApiUser(user);
let habits = await requests.get('/tasks/user?type=habits');
let dailys = await requests.get('/tasks/user?type=dailys');
let todos = await requests.get('/tasks/user?type=todos');
let rewards = await requests.get('/tasks/user?type=rewards');
let tags = await requests.get('/tags');
expect(habits).to.have.a.lengthOf(0);
expect(dailys).to.have.a.lengthOf(0);
expect(todos).to.have.a.lengthOf(1);
expect(todos[0].text).to.eql(t('defaultTodo1Text'));
expect(todos[0].notes).to.eql(t('defaultTodoNotes'));
expect(rewards).to.have.a.lengthOf(0);
expect(tags).to.have.a.lengthOf(7);
expect(tags[0].name).to.eql(t('defaultTag1'));
expect(tags[1].name).to.eql(t('defaultTag2'));
expect(tags[2].name).to.eql(t('defaultTag3'));
expect(tags[3].name).to.eql(t('defaultTag4'));
expect(tags[4].name).to.eql(t('defaultTag5'));
expect(tags[5].name).to.eql(t('defaultTag6'));
expect(tags[6].name).to.eql(t('defaultTag7'));
});
it('for Web', async () => {
api = requester(
null,
{'x-client': 'habitica-web'},
);
let username = generateRandomUserName();
let email = `${username}@example.com`;
let password = 'password';
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
let requests = new ApiUser(user);
let habits = await requests.get('/tasks/user?type=habits');
let dailys = await requests.get('/tasks/user?type=dailys');
let todos = await requests.get('/tasks/user?type=todos');
let rewards = await requests.get('/tasks/user?type=rewards');
let tags = await requests.get('/tags');
expect(habits).to.have.a.lengthOf(3);
expect(habits[0].text).to.eql(t('defaultHabit1Text'));
expect(habits[0].notes).to.eql('');
expect(habits[1].text).to.eql(t('defaultHabit2Text'));
expect(habits[1].notes).to.eql('');
expect(habits[2].text).to.eql(t('defaultHabit3Text'));
expect(habits[2].notes).to.eql('');
expect(dailys).to.have.a.lengthOf(0);
expect(todos).to.have.a.lengthOf(1);
expect(todos[0].text).to.eql(t('defaultTodo1Text'));
expect(todos[0].notes).to.eql(t('defaultTodoNotes'));
expect(rewards).to.have.a.lengthOf(1);
expect(rewards[0].text).to.eql(t('defaultReward1Text'));
expect(rewards[0].notes).to.eql('');
expect(tags).to.have.a.lengthOf(7);
expect(tags[0].name).to.eql(t('defaultTag1'));
expect(tags[1].name).to.eql(t('defaultTag2'));
expect(tags[2].name).to.eql(t('defaultTag3'));
expect(tags[3].name).to.eql(t('defaultTag4'));
expect(tags[4].name).to.eql(t('defaultTag5'));
expect(tags[5].name).to.eql(t('defaultTag6'));
expect(tags[6].name).to.eql(t('defaultTag7'));
});
it('for Android', async () => {
api = requester(
null,
{'x-client': 'habitica-android'},
);
let username = generateRandomUserName();
let email = `${username}@example.com`;
let password = 'password';
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
let requests = new ApiUser(user);
let habits = await requests.get('/tasks/user?type=habits');
let dailys = await requests.get('/tasks/user?type=dailys');
let todos = await requests.get('/tasks/user?type=todos');
let rewards = await requests.get('/tasks/user?type=rewards');
let tags = await requests.get('/tags');
expect(habits).to.have.a.lengthOf(2);
expect(habits[0].text).to.eql(t('defaultHabit4Text'));
expect(habits[0].notes).to.eql(t('defaultHabit4Notes'));
expect(habits[1].text).to.eql(t('defaultHabit5Text'));
expect(habits[1].notes).to.eql(t('defaultHabit5Notes'));
expect(dailys).to.have.a.lengthOf(1);
expect(dailys[0].text).to.eql(t('defaultDaily1Text'));
expect(dailys[0].notes).to.eql('');
expect(todos).to.have.a.lengthOf(2);
expect(todos[0].text).to.eql(t('defaultTodo1Text'));
expect(todos[0].notes).to.eql(t('defaultTodoNotes'));
expect(todos[1].text).to.eql(t('defaultTodo2Text'));
expect(todos[1].notes).to.eql(t('defaultTodo2Notes'));
expect(rewards).to.have.a.lengthOf(1);
expect(rewards[0].text).to.eql(t('defaultReward2Text'));
expect(rewards[0].notes).to.eql(t('defaultReward2Notes'));
expect(tags).to.have.a.lengthOf(7);
expect(tags[0].name).to.eql(t('defaultTag1'));
expect(tags[1].name).to.eql(t('defaultTag2'));
expect(tags[2].name).to.eql(t('defaultTag3'));
expect(tags[3].name).to.eql(t('defaultTag4'));
expect(tags[4].name).to.eql(t('defaultTag5'));
expect(tags[5].name).to.eql(t('defaultTag6'));
expect(tags[6].name).to.eql(t('defaultTag7'));
});
it('for iOS', async () => {
api = requester(
null,
{'x-client': 'habitica-ios'},
);
let username = generateRandomUserName();
let email = `${username}@example.com`;
let password = 'password';
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
let requests = new ApiUser(user);
let habits = await requests.get('/tasks/user?type=habits');
let dailys = await requests.get('/tasks/user?type=dailys');
let todos = await requests.get('/tasks/user?type=todos');
let rewards = await requests.get('/tasks/user?type=rewards');
let tags = await requests.get('/tags');
expect(habits).to.have.a.lengthOf(2);
expect(habits[0].text).to.eql(t('defaultHabit4Text'));
expect(habits[0].notes).to.eql(t('defaultHabit4Notes'));
expect(habits[1].text).to.eql(t('defaultHabit5Text'));
expect(habits[1].notes).to.eql(t('defaultHabit5Notes'));
expect(dailys).to.have.a.lengthOf(1);
expect(dailys[0].text).to.eql(t('defaultDaily1Text'));
expect(dailys[0].notes).to.eql('');
expect(todos).to.have.a.lengthOf(2);
expect(todos[0].text).to.eql(t('defaultTodo1Text'));
expect(todos[0].notes).to.eql(t('defaultTodoNotes'));
expect(todos[1].text).to.eql(t('defaultTodo2Text'));
expect(todos[1].notes).to.eql(t('defaultTodo2Notes'));
expect(rewards).to.have.a.lengthOf(1);
expect(rewards[0].text).to.eql(t('defaultReward2Text'));
expect(rewards[0].notes).to.eql(t('defaultReward2Notes'));
expect(tags).to.have.a.lengthOf(7);
expect(tags[0].name).to.eql(t('defaultTag1'));
expect(tags[1].name).to.eql(t('defaultTag2'));
expect(tags[2].name).to.eql(t('defaultTag3'));
expect(tags[3].name).to.eql(t('defaultTag4'));
expect(tags[4].name).to.eql(t('defaultTag5'));
expect(tags[5].name).to.eql(t('defaultTag6'));
expect(tags[6].name).to.eql(t('defaultTag7'));
});
});
it('enrolls new users in an A/B test', async () => {
@@ -71,6 +255,23 @@ describe('POST /user/auth/local/register', () => {
await expect(getProperty('users', user._id, '_ABtests')).to.eventually.be.a('object');
});
it('includes items awarded by default when creating a new user', async () => {
let username = generateRandomUserName();
let email = `${username}@example.com`;
let password = 'password';
let user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
expect(user.items.quests.dustbunnies).to.equal(1);
expect(user.purchased.background.violet).to.be.ok;
expect(user.preferences.background).to.equal('violet');
});
it('requires password and confirmPassword to match', async () => {
let username = generateRandomUserName();
let email = `${username}@example.com`;

View File

@@ -8,6 +8,8 @@ import {
sha1Encrypt as sha1EncryptPassword,
} from '../../../../../../website/server/libs/password';
import nconf from 'nconf';
const ENDPOINT = '/user/auth/update-email';
describe('PUT /user/auth/update-email', () => {
@@ -68,7 +70,7 @@ describe('PUT /user/auth/update-email', () => {
})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('cannotFulfillReq'),
message: t('cannotFulfillReq', { techAssistanceEmail: nconf.get('EMAILS:TECH_ASSISTANCE_EMAIL') }),
});
});

View File

@@ -83,6 +83,12 @@ describe('payments/index', () => {
};
});
it('awards the Royal Purple Jackalope pet', async () => {
await api.createSubscription(data);
expect(recipient.items.pets['Jackalope-RoyalPurple']).to.eql(5);
});
it('adds extra months to an existing subscription', async () => {
recipient.purchased.plan = plan;
@@ -241,6 +247,12 @@ describe('payments/index', () => {
expect(user.purchased.plan.dateCreated).to.exist;
});
it('awards the Royal Purple Jackalope pet', async () => {
await api.createSubscription(data);
expect(user.items.pets['Jackalope-RoyalPurple']).to.eql(5);
});
it('sets extraMonths if plan has dateTerminated date', async () => {
user.purchased.plan = plan;
user.purchased.plan.dateTerminated = moment(new Date()).add(2, 'months');
@@ -633,5 +645,13 @@ describe('payments/index', () => {
expect(updatedUser.purchased.plan.lastBillingDate).to.not.exist;
expect(updatedUser.purchased.plan.dateCreated).to.exist;
});
it('awards the Royal Purple Jackalope pet', async () => {
await api.addSubToGroupUser(user, group);
let updatedUser = await User.findById(user._id).exec();
expect(updatedUser.items.pets['Jackalope-RoyalPurple']).to.eql(5);
});
});
});

View File

@@ -603,6 +603,64 @@ describe('Purchasing a subscription for group', () => {
expect(updatedUser.purchased.plan.dateCreated).to.exist;
});
it('does not modify a user with a Google subscription', async () => {
plan.customerId = 'random';
plan.paymentMethod = api.constants.GOOGLE_PAYMENT_METHOD;
let recipient = new User();
recipient.profile.name = 'recipient';
recipient.purchased.plan = plan;
recipient.guilds.push(group._id);
await recipient.save();
user.guilds.push(group._id);
await user.save();
data.groupId = group._id;
await api.createSubscription(data);
let updatedUser = await User.findById(recipient._id).exec();
expect(updatedUser.purchased.plan.planId).to.eql('basic_3mo');
expect(updatedUser.purchased.plan.customerId).to.eql('random');
expect(updatedUser.purchased.plan.dateUpdated).to.exist;
expect(updatedUser.purchased.plan.gemsBought).to.eql(0);
expect(updatedUser.purchased.plan.paymentMethod).to.eql(api.constants.GOOGLE_PAYMENT_METHOD);
expect(updatedUser.purchased.plan.extraMonths).to.eql(0);
expect(updatedUser.purchased.plan.dateTerminated).to.eql(null);
expect(updatedUser.purchased.plan.lastBillingDate).to.exist;
expect(updatedUser.purchased.plan.dateCreated).to.exist;
});
it('does not modify a user with an iOS subscription', async () => {
plan.customerId = 'random';
plan.paymentMethod = api.constants.IOS_PAYMENT_METHOD;
let recipient = new User();
recipient.profile.name = 'recipient';
recipient.purchased.plan = plan;
recipient.guilds.push(group._id);
await recipient.save();
user.guilds.push(group._id);
await user.save();
data.groupId = group._id;
await api.createSubscription(data);
let updatedUser = await User.findById(recipient._id).exec();
expect(updatedUser.purchased.plan.planId).to.eql('basic_3mo');
expect(updatedUser.purchased.plan.customerId).to.eql('random');
expect(updatedUser.purchased.plan.dateUpdated).to.exist;
expect(updatedUser.purchased.plan.gemsBought).to.eql(0);
expect(updatedUser.purchased.plan.paymentMethod).to.eql(api.constants.IOS_PAYMENT_METHOD);
expect(updatedUser.purchased.plan.extraMonths).to.eql(0);
expect(updatedUser.purchased.plan.dateTerminated).to.eql(null);
expect(updatedUser.purchased.plan.lastBillingDate).to.exist;
expect(updatedUser.purchased.plan.dateCreated).to.exist;
});
it('updates a user with a cancelled but active group subscription', async () => {
plan.key = 'basic_earned';
plan.customerId = api.constants.GROUP_PLAN_CUSTOMER_ID;

View File

@@ -10,6 +10,9 @@ import { model as Coupon } from '../../../../../website/server/models/coupon';
import stripePayments from '../../../../../website/server/libs/stripePayments';
import payments from '../../../../../website/server/libs/payments';
import common from '../../../../../website/common';
import logger from '../../../../../website/server/libs/logger';
import { v4 as uuid } from 'uuid';
import moment from 'moment';
const i18n = common.i18n;
@@ -759,4 +762,245 @@ describe('Stripe Payments', () => {
expect(updatedGroup.purchased.plan.quantity).to.eql(4);
});
});
describe('handleWebhooks', () => {
describe('all events', () => {
const eventType = 'account.updated';
const event = {id: 123};
const eventRetrieved = {type: eventType};
beforeEach(() => {
sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves(eventRetrieved);
sinon.stub(logger, 'error');
});
afterEach(() => {
stripe.events.retrieve.restore();
logger.error.restore();
});
it('logs an error if an unsupported webhook event is passed', async () => {
const error = new Error(`Missing handler for Stripe webhook ${eventType}`);
await stripePayments.handleWebhooks({requestBody: event}, stripe);
expect(logger.error).to.have.been.called.once;
expect(logger.error).to.have.been.calledWith(error, {event: eventRetrieved});
});
it('retrieves and validates the event from Stripe', async () => {
await stripePayments.handleWebhooks({requestBody: event}, stripe);
expect(stripe.events.retrieve).to.have.been.called.once;
expect(stripe.events.retrieve).to.have.been.calledWith(event.id);
});
});
describe('customer.subscription.deleted', () => {
const eventType = 'customer.subscription.deleted';
beforeEach(() => {
sinon.stub(stripe.customers, 'del').returnsPromise().resolves({});
sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({});
});
afterEach(() => {
stripe.customers.del.restore();
payments.cancelSubscription.restore();
});
it('does not do anything if event.request is null (subscription cancelled manually)', async () => {
sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({
id: 123,
type: eventType,
request: 123,
});
await stripePayments.handleWebhooks({requestBody: {}}, stripe);
expect(stripe.events.retrieve).to.have.been.called.once;
expect(stripe.customers.del).to.not.have.been.called;
expect(payments.cancelSubscription).to.not.have.been.called;
stripe.events.retrieve.restore();
});
describe('user subscription', () => {
it('throws an error if the user is not found', async () => {
const customerId = 456;
sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({
id: 123,
type: eventType,
data: {
object: {
plan: {
id: 'basic_earned',
},
customer: customerId,
},
},
request: null,
});
await expect(stripePayments.handleWebhooks({requestBody: {}}, stripe)).to.eventually.be.rejectedWith({
message: i18n.t('userNotFound'),
httpCode: 404,
name: 'NotFound',
});
expect(stripe.customers.del).to.not.have.been.called;
expect(payments.cancelSubscription).to.not.have.been.called;
stripe.events.retrieve.restore();
});
it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => {
const customerId = '456';
let subscriber = new User();
subscriber.purchased.plan.customerId = customerId;
subscriber.purchased.plan.paymentMethod = 'Stripe';
await subscriber.save();
sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({
id: 123,
type: eventType,
data: {
object: {
plan: {
id: 'basic_earned',
},
customer: customerId,
},
},
request: null,
});
await stripePayments.handleWebhooks({requestBody: {}}, stripe);
expect(stripe.customers.del).to.have.been.calledOnce;
expect(stripe.customers.del).to.have.been.calledWith(customerId);
expect(payments.cancelSubscription).to.have.been.calledOnce;
let cancelSubscriptionOpts = payments.cancelSubscription.lastCall.args[0];
expect(cancelSubscriptionOpts.user._id).to.equal(subscriber._id);
expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe');
expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3);
expect(cancelSubscriptionOpts.groupId).to.be.undefined;
stripe.events.retrieve.restore();
});
});
describe('group plan subscription', () => {
it('throws an error if the group is not found', async () => {
const customerId = 456;
sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({
id: 123,
type: eventType,
data: {
object: {
plan: {
id: 'group_monthly',
},
customer: customerId,
},
},
request: null,
});
await expect(stripePayments.handleWebhooks({requestBody: {}}, stripe)).to.eventually.be.rejectedWith({
message: i18n.t('groupNotFound'),
httpCode: 404,
name: 'NotFound',
});
expect(stripe.customers.del).to.not.have.been.called;
expect(payments.cancelSubscription).to.not.have.been.called;
stripe.events.retrieve.restore();
});
it('throws an error if the group leader is not found', async () => {
const customerId = 456;
let subscriber = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
leader: uuid(),
});
subscriber.purchased.plan.customerId = customerId;
subscriber.purchased.plan.paymentMethod = 'Stripe';
await subscriber.save();
sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({
id: 123,
type: eventType,
data: {
object: {
plan: {
id: 'group_monthly',
},
customer: customerId,
},
},
request: null,
});
await expect(stripePayments.handleWebhooks({requestBody: {}}, stripe)).to.eventually.be.rejectedWith({
message: i18n.t('userNotFound'),
httpCode: 404,
name: 'NotFound',
});
expect(stripe.customers.del).to.not.have.been.called;
expect(payments.cancelSubscription).to.not.have.been.called;
stripe.events.retrieve.restore();
});
it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => {
const customerId = '456';
let leader = new User();
await leader.save();
let subscriber = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
leader: leader._id,
});
subscriber.purchased.plan.customerId = customerId;
subscriber.purchased.plan.paymentMethod = 'Stripe';
await subscriber.save();
sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({
id: 123,
type: eventType,
data: {
object: {
plan: {
id: 'group_monthly',
},
customer: customerId,
},
},
request: null,
});
await stripePayments.handleWebhooks({requestBody: {}}, stripe);
expect(stripe.customers.del).to.have.been.calledOnce;
expect(stripe.customers.del).to.have.been.calledWith(customerId);
expect(payments.cancelSubscription).to.have.been.calledOnce;
let cancelSubscriptionOpts = payments.cancelSubscription.lastCall.args[0];
expect(cancelSubscriptionOpts.user._id).to.equal(leader._id);
expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe');
expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3);
expect(cancelSubscriptionOpts.groupId).to.equal(subscriber._id);
stripe.events.retrieve.restore();
});
});
});
});
});

View File

@@ -0,0 +1,182 @@
import {
generateRes,
generateReq,
generateNext,
} from '../../../../helpers/api-unit.helper';
import nconf from 'nconf';
import requireAgain from 'require-again';
describe('redirects middleware', () => {
let res, req, next;
let pathToRedirectsMiddleware = '../../../../../website/server/middlewares/redirects';
beforeEach(() => {
res = generateRes();
req = generateReq();
next = generateNext();
});
context('forceSSL', () => {
it('sends http requests to https', () => {
let nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('BASE_URL').returns('https://habitica.com');
nconfStub.withArgs('IS_PROD').returns(true);
req.header = sandbox.stub().withArgs('x-forwarded-proto').returns('http');
req.originalUrl = '/static/front';
let attachRedirects = requireAgain(pathToRedirectsMiddleware);
attachRedirects.forceSSL(req, res, next);
expect(res.redirect).to.be.calledOnce;
expect(res.redirect).to.be.calledWith('https://habitica.com/static/front');
});
it('does not redirect https forwarded requests', () => {
let nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('BASE_URL').returns('https://habitica.com');
nconfStub.withArgs('IS_PROD').returns(true);
req.header = sandbox.stub().withArgs('x-forwarded-proto').returns('https');
req.originalUrl = '/static/front';
let attachRedirects = requireAgain(pathToRedirectsMiddleware);
attachRedirects.forceSSL(req, res, next);
expect(res.redirect).to.be.notCalled;
});
it('does not redirect outside of production environments', () => {
let nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('BASE_URL').returns('https://habitica.com');
nconfStub.withArgs('IS_PROD').returns(false);
req.header = sandbox.stub().withArgs('x-forwarded-proto').returns('http');
req.originalUrl = '/static/front';
let attachRedirects = requireAgain(pathToRedirectsMiddleware);
attachRedirects.forceSSL(req, res, next);
expect(res.redirect).to.be.notCalled;
});
it('does not redirect if base URL is not https', () => {
let nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('BASE_URL').returns('http://habitica.com');
nconfStub.withArgs('IS_PROD').returns(true);
req.header = sandbox.stub().withArgs('x-forwarded-proto').returns('http');
req.originalUrl = '/static/front';
let attachRedirects = requireAgain(pathToRedirectsMiddleware);
attachRedirects.forceSSL(req, res, next);
expect(res.redirect).to.be.notCalled;
});
});
context('forceHabitica', () => {
it('sends requests with differing hostname to base URL host', () => {
let nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('BASE_URL').returns('https://habitica.com');
nconfStub.withArgs('IGNORE_REDIRECT').returns('false');
nconfStub.withArgs('IS_PROD').returns(true);
req.hostname = 'www.habitica.com';
req.method = 'GET';
req.originalUrl = '/static/front';
req.url = '/static/front';
let attachRedirects = requireAgain(pathToRedirectsMiddleware);
attachRedirects.forceHabitica(req, res, next);
expect(res.redirect).to.be.calledOnce;
expect(res.redirect).to.be.calledWith(301, 'https://habitica.com/static/front');
});
it('does not redirect outside of production environments', () => {
let nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('BASE_URL').returns('https://habitica.com');
nconfStub.withArgs('IGNORE_REDIRECT').returns('false');
nconfStub.withArgs('IS_PROD').returns(false);
req.hostname = 'www.habitica.com';
req.method = 'GET';
req.originalUrl = '/static/front';
req.url = '/static/front';
let attachRedirects = requireAgain(pathToRedirectsMiddleware);
attachRedirects.forceHabitica(req, res, next);
expect(res.redirect).to.be.notCalled;
});
it('does not redirect if env is set to ignore redirection', () => {
let nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('BASE_URL').returns('https://habitica.com');
nconfStub.withArgs('IGNORE_REDIRECT').returns('true');
nconfStub.withArgs('IS_PROD').returns(true);
req.hostname = 'www.habitica.com';
req.method = 'GET';
req.originalUrl = '/static/front';
req.url = '/static/front';
let attachRedirects = requireAgain(pathToRedirectsMiddleware);
attachRedirects.forceHabitica(req, res, next);
expect(res.redirect).to.be.notCalled;
});
it('does not redirect if request hostname matches base URL host', () => {
let nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('BASE_URL').returns('https://habitica.com');
nconfStub.withArgs('IGNORE_REDIRECT').returns('false');
nconfStub.withArgs('IS_PROD').returns(true);
req.hostname = 'habitica.com';
req.method = 'GET';
req.originalUrl = '/static/front';
req.url = '/static/front';
let attachRedirects = requireAgain(pathToRedirectsMiddleware);
attachRedirects.forceHabitica(req, res, next);
expect(res.redirect).to.be.notCalled;
});
it('does not redirect if request is an API URL', () => {
let nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('BASE_URL').returns('https://habitica.com');
nconfStub.withArgs('IGNORE_REDIRECT').returns('false');
nconfStub.withArgs('IS_PROD').returns(true);
req.hostname = 'www.habitica.com';
req.method = 'GET';
req.originalUrl = '/api/v3/challenges';
req.url = '/api/v3/challenges';
let attachRedirects = requireAgain(pathToRedirectsMiddleware);
attachRedirects.forceHabitica(req, res, next);
expect(res.redirect).to.be.notCalled;
});
it('does not redirect if request method is not GET', () => {
let nconfStub = sandbox.stub(nconf, 'get');
nconfStub.withArgs('BASE_URL').returns('https://habitica.com');
nconfStub.withArgs('IGNORE_REDIRECT').returns('false');
nconfStub.withArgs('IS_PROD').returns(true);
req.hostname = 'www.habitica.com';
req.method = 'POST';
req.originalUrl = '/static/front';
req.url = '/static/front';
let attachRedirects = requireAgain(pathToRedirectsMiddleware);
attachRedirects.forceHabitica(req, res, next);
expect(res.redirect).to.be.notCalled;
});
});
});

View File

@@ -104,6 +104,40 @@ describe('Challenge Model', () => {
expect(updatedNewMember.tags[7].id).to.equal(challenge._id);
expect(updatedNewMember.tags[7].name).to.equal(challenge.shortName);
expect(syncedTask).to.exist;
expect(syncedTask.attribute).to.eql('str');
});
it('syncs a challenge to a user with the existing task', async () => {
await challenge.addTasks([task]);
let updatedLeader = await User.findOne({_id: leader._id});
let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}});
let syncedTask = find(updatedLeadersTasks, function findNewTask (updatedLeadersTask) {
return updatedLeadersTask.challenge.taskId === task._id;
});
let createdAtBefore = syncedTask.createdAt;
let attributeBefore = syncedTask.attribute;
let newTitle = 'newName';
task.text = newTitle;
task.attribute = 'int';
await task.save();
await challenge.syncToUser(leader);
updatedLeader = await User.findOne({_id: leader._id});
updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}});
syncedTask = find(updatedLeadersTasks, function findNewTask (updatedLeadersTask) {
return updatedLeadersTask.challenge.taskId === task._id;
});
let createdAtAfter = syncedTask.createdAt;
let attributeAfter = syncedTask.attribute;
expect(createdAtBefore).to.eql(createdAtAfter);
expect(attributeBefore).to.eql(attributeAfter);
expect(syncedTask.text).to.eql(newTitle);
});
it('updates tasks to challenge and challenge members', async () => {

View File

@@ -2,11 +2,14 @@ import moment from 'moment';
import { v4 as generateUUID } from 'uuid';
import validator from 'validator';
import { sleep } from '../../../../helpers/api-unit.helper';
import { model as Group, INVITES_LIMIT } from '../../../../../website/server/models/group';
import { model as User } from '../../../../../website/server/models/user';
import {
BadRequest,
} from '../../../../../website/server/libs/errors';
SPAM_MESSAGE_LIMIT,
SPAM_MIN_EXEMPT_CONTRIB_LEVEL,
SPAM_WINDOW_LENGTH,
INVITES_LIMIT,
model as Group,
} from '../../../../../website/server/models/group';
import { model as User } from '../../../../../website/server/models/user';
import { quests as questScrolls } from '../../../../../website/common/script/content';
import { groupChatReceivedWebhook } from '../../../../../website/server/libs/webhook';
import * as email from '../../../../../website/server/libs/email';
@@ -460,73 +463,67 @@ describe('Group Model', () => {
};
});
it('throws an error if no uuids or emails are passed in', (done) => {
try {
Group.validateInvitations(null, null, res);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
it('throws an error if no uuids or emails are passed in', async () => {
await expect(Group.validateInvitations(null, null, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('canOnlyInviteEmailUuid');
done();
}
});
it('throws an error if only uuids are passed in, but they are not an array', (done) => {
try {
Group.validateInvitations({ uuid: 'user-id'}, null, res);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
it('throws an error if only uuids are passed in, but they are not an array', async () => {
await expect(Group.validateInvitations({ uuid: 'user-id'}, null, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('uuidsMustBeAnArray');
done();
}
});
it('throws an error if only emails are passed in, but they are not an array', (done) => {
try {
Group.validateInvitations(null, { emails: 'user@example.com'}, res);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
it('throws an error if only emails are passed in, but they are not an array', async () => {
await expect(Group.validateInvitations(null, { emails: 'user@example.com'}, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('emailsMustBeAnArray');
done();
}
});
it('throws an error if emails are not passed in, and uuid array is empty', (done) => {
try {
Group.validateInvitations([], null, res);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
it('throws an error if emails are not passed in, and uuid array is empty', async () => {
await expect(Group.validateInvitations([], null, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('inviteMissingUuid');
done();
}
});
it('throws an error if uuids are not passed in, and email array is empty', (done) => {
try {
Group.validateInvitations(null, [], res);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
it('throws an error if uuids are not passed in, and email array is empty', async () => {
await expect(Group.validateInvitations(null, [], res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('inviteMissingEmail');
done();
}
});
it('throws an error if uuids and emails are passed in as empty arrays', (done) => {
try {
Group.validateInvitations([], [], res);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
it('throws an error if uuids and emails are passed in as empty arrays', async () => {
await expect(Group.validateInvitations([], [], res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('inviteMustNotBeEmpty');
done();
}
});
it('throws an error if total invites exceed max invite constant', (done) => {
it('throws an error if total invites exceed max invite constant', async () => {
let uuids = [];
let emails = [];
@@ -537,17 +534,16 @@ describe('Group Model', () => {
uuids.push('one-more-uuid'); // to put it over the limit
try {
Group.validateInvitations(uuids, emails, res);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
await expect(Group.validateInvitations(uuids, emails, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT });
done();
}
});
it('does not throw error if number of invites matches max invite limit', () => {
it('does not throw error if number of invites matches max invite limit', async () => {
let uuids = [];
let emails = [];
@@ -556,49 +552,33 @@ describe('Group Model', () => {
emails.push(`user-${i}@example.com`);
}
expect(function () {
Group.validateInvitations(uuids, emails, res);
}).to.not.throw();
});
it('does not throw an error if only user ids are passed in', () => {
expect(function () {
Group.validateInvitations(['user-id', 'user-id2'], null, res);
}).to.not.throw();
await Group.validateInvitations(uuids, emails, res);
expect(res.t).to.not.be.called;
});
it('does not throw an error if only emails are passed in', () => {
expect(function () {
Group.validateInvitations(null, ['user1@example.com', 'user2@example.com'], res);
}).to.not.throw();
it('does not throw an error if only user ids are passed in', async () => {
await Group.validateInvitations(['user-id', 'user-id2'], null, res);
expect(res.t).to.not.be.called;
});
it('does not throw an error if both uuids and emails are passed in', () => {
expect(function () {
Group.validateInvitations(['user-id', 'user-id2'], ['user1@example.com', 'user2@example.com'], res);
}).to.not.throw();
it('does not throw an error if only emails are passed in', async () => {
await Group.validateInvitations(null, ['user1@example.com', 'user2@example.com'], res);
expect(res.t).to.not.be.called;
});
it('does not throw an error if uuids are passed in and emails are an empty array', () => {
expect(function () {
Group.validateInvitations(['user-id', 'user-id2'], [], res);
}).to.not.throw();
it('does not throw an error if both uuids and emails are passed in', async () => {
await Group.validateInvitations(['user-id', 'user-id2'], ['user1@example.com', 'user2@example.com'], res);
expect(res.t).to.not.be.called;
});
it('does not throw an error if emails are passed in and uuids are an empty array', () => {
expect(function () {
Group.validateInvitations([], ['user1@example.com', 'user2@example.com'], res);
}).to.not.throw();
it('does not throw an error if uuids are passed in and emails are an empty array', async () => {
await Group.validateInvitations(['user-id', 'user-id2'], [], res);
expect(res.t).to.not.be.called;
});
it('does not throw an error if emails are passed in and uuids are an empty array', async () => {
await Group.validateInvitations([], ['user1@example.com', 'user2@example.com'], res);
expect(res.t).to.not.be.called;
});
});
@@ -621,6 +601,99 @@ describe('Group Model', () => {
});
});
describe('#checkChatSpam', () => {
let testUser, testTime, tavern;
let testUserID = '1';
beforeEach(async () => {
testTime = Date.now();
tavern = new Group({
name: 'test tavern',
type: 'guild',
privacy: 'public',
});
tavern._id = TAVERN_ID;
testUser = {
_id: testUserID,
};
});
function generateTestMessage (overrides = {}) {
return Object.assign({}, {
text: 'test message',
uuid: testUserID,
timestamp: testTime,
}, overrides);
}
it('group that is not the tavern returns false, while tavern returns true', async () => {
for (let i = 0; i < SPAM_MESSAGE_LIMIT; i++) {
party.chat.push(generateTestMessage());
}
expect(party.checkChatSpam(testUser)).to.eql(false);
for (let i = 0; i < SPAM_MESSAGE_LIMIT; i++) {
tavern.chat.push(generateTestMessage());
}
expect(tavern.checkChatSpam(testUser)).to.eql(true);
});
it('high enough contributor returns false', async () => {
let highContributor = testUser;
highContributor.contributor = {
level: SPAM_MIN_EXEMPT_CONTRIB_LEVEL,
};
for (let i = 0; i < SPAM_MESSAGE_LIMIT; i++) {
tavern.chat.push(generateTestMessage());
}
expect(tavern.checkChatSpam(highContributor)).to.eql(false);
});
it('chat with no messages returns false', async () => {
expect(tavern.chat.length).to.eql(0);
expect(tavern.checkChatSpam(testUser)).to.eql(false);
});
it('user has not reached limit but another one has returns false', async () => {
let otherUserID = '2';
for (let i = 0; i < SPAM_MESSAGE_LIMIT; i++) {
tavern.chat.push(generateTestMessage({uuid: otherUserID}));
}
expect(tavern.checkChatSpam(testUser)).to.eql(false);
});
it('user messages is less than the limit returns false', async () => {
for (let i = 0; i < SPAM_MESSAGE_LIMIT - 1; i++) {
tavern.chat.push(generateTestMessage());
}
expect(tavern.checkChatSpam(testUser)).to.eql(false);
});
it('user has reached the message limit outside of window returns false', async () => {
for (let i = 0; i < SPAM_MESSAGE_LIMIT - 1; i++) {
tavern.chat.push(generateTestMessage());
}
let earlierTimestamp = testTime - SPAM_WINDOW_LENGTH - 1;
tavern.chat.push(generateTestMessage({timestamp: earlierTimestamp}));
expect(tavern.checkChatSpam(testUser)).to.eql(false);
});
it('user has posted too many messages in limit returns true', async () => {
for (let i = 0; i < SPAM_MESSAGE_LIMIT; i++) {
tavern.chat.push(generateTestMessage());
}
expect(tavern.checkChatSpam(testUser)).to.eql(true);
});
});
describe('#leaveGroup', () => {
it('removes user from group quest', async () => {
party.quest.members = {

View File

@@ -334,4 +334,34 @@ describe('Settings Controller', function () {
});
});
});
context('Fixing character values', function () {
describe('#restore', function () {
var blankRestoreValues = {
stats: {
hp: 0,
exp: 0,
gp: 0,
lvl: 0,
mp: 0,
},
achievements: {
streak: 0,
},
};
it('doesn\'t update character values when level is less than 1', function () {
scope.restoreValues = blankRestoreValues;
scope.restore();
expect(User.set).to.not.be.called;
});
it('updates character values when level is at least 1', function () {
scope.restoreValues = blankRestoreValues;
scope.restoreValues.stats.lvl = 1;
scope.restore();
expect(User.set).to.be.called;
});
});
});
});

View File

@@ -0,0 +1,69 @@
'use strict';
describe('User Controller', function() {
var $rootScope, $window, User, shared, scope, ctrl, content;
beforeEach(function() {
module(function ($provide) {
var user = specHelper.newUser();
User = {user: user}
$provide.value('Guide', sandbox.stub());
$provide.value('User', User);
$provide.value('Achievement', sandbox.stub());
$provide.value('Social', sandbox.stub());
$provide.value('Shared', {
achievements: {
getAchievementsForProfile: sandbox.stub()
},
shops: {
getBackgroundShopSets: sandbox.stub()
}
});
$provide.value('Content', {
loginIncentives: sandbox.stub()
})
});
inject(function($rootScope, $controller, User, Content) {
scope = $rootScope.$new();
content = Content;
$controller('RootCtrl', { $scope: scope, User: User});
ctrl = $controller('UserCtrl', { $scope: scope, User: User, $window: $window});
});
});
describe('getProgressDisplay', function() {
beforeEach(() => {
sandbox.stub(window.env, 't');
window.env.t.onFirstCall().returns('Progress until next');
});
it('should return initial progress', function() {
scope.profile.loginIncentives = 0;
content.loginIncentives = [{
nextRewardAt: 1,
reward: true
}];
var actual = scope.getProgressDisplay();
expect(actual.trim()).to.eql('Progress until next 0/1');
});
it('should return progress between next reward and current reward', function() {
scope.profile.loginIncentives = 1;
content.loginIncentives = [{
nextRewardAt: 1,
reward: true
}, {
prevRewardAt: 0,
nextRewardAt: 2,
reward: true
}, {
prevRewardAt: 1,
nextRewardAt: 3
}];
var actual = scope.getProgressDisplay();
expect(actual.trim()).to.eql('Progress until next 0/1');
});
});
});

View File

@@ -0,0 +1,35 @@
describe('task Directive', () => {
var compile, scope, directiveElem, $modal;
beforeEach(function(){
module(function($provide) {
$modal = {
open: sandbox.spy(),
};
$provide.value('$modal', $modal);
});
inject(function($compile, $rootScope, $templateCache) {
compile = $compile;
scope = $rootScope.$new();
$templateCache.put('templates/task.html', '<div>Task</div>');
});
directiveElem = getCompiledElement();
});
function getCompiledElement(){
var element = angular.element('<task></task>');
var compiledElement = compile(element)(scope);
scope.$digest();
return compiledElement;
}
xit('opens task note modal', () => {
scope.showNoteDetails();
expect($modal.open).to.be.calledOnce;
});
})

View File

@@ -81,8 +81,11 @@ module.exports = function karmaConfig (config) {
},
coverageReporter: {
type: 'lcov',
dir: 'coverage/karma',
reporters: [
{ type: 'lcov', subdir: '.' },
{ type: 'text-summary' },
],
dir: '../../../coverage/karma',
},
// Enable mocha-style reporting, for better test visibility

View File

@@ -28,7 +28,7 @@ module.exports = function (config) {
noInfo: true,
},
coverageReporter: {
dir: './coverage',
dir: '../../../coverage/client-unit',
reporters: [
{ type: 'lcov', subdir: '.' },
{ type: 'text-summary' },

View File

@@ -0,0 +1,151 @@
import { asyncResourceFactory, loadAsyncResource } from 'client/libs/asyncResource';
import axios from 'axios';
import generateStore from 'client/store';
import { sleep } from '../../../../helpers/sleep';
describe('async resource', () => {
it('asyncResourceFactory', () => {
const resource = asyncResourceFactory();
expect(resource.loadingStatus).to.equal('NOT_LOADED');
expect(resource.data).to.equal(null);
expect(resource).to.not.equal(asyncResourceFactory);
});
describe('loadAsyncResource', () => {
context('errors', () => {
it('store is missing', () => {
expect(() => loadAsyncResource({})).to.throw;
});
it('path is missing', () => {
expect(() => loadAsyncResource({
store: 'store',
})).to.throw;
});
it('url is missing', () => {
expect(() => loadAsyncResource({
store: 'store',
path: 'path',
})).to.throw;
});
it('deserialize is missing', () => {
expect(() => loadAsyncResource({
store: 'store',
path: 'path',
url: 'url',
})).to.throw;
});
it('resource not found', () => {
const store = generateStore();
expect(() => loadAsyncResource({
store,
path: 'not existing path',
url: 'url',
deserialize: 'deserialize',
})).to.throw;
});
it('invalid loading status', () => {
const store = generateStore();
store.state.user.loadingStatus = 'INVALID';
expect(loadAsyncResource({
store,
path: 'user',
url: 'url',
deserialize: 'deserialize',
})).to.eventually.be.rejected;
});
});
it('returns the resource if it is already loaded and forceLoad is false', async () => {
const store = generateStore();
store.state.user.loadingStatus = 'LOADED';
store.state.user.data = {_id: 1};
sandbox.stub(axios, 'get');
const resource = await loadAsyncResource({
store,
path: 'user',
url: 'url',
deserialize: 'deserialize',
});
expect(resource).to.equal(store.state.user);
expect(axios.get).to.not.have.been.called;
});
it('load the resource if it is not loaded', async () => {
const store = generateStore();
store.state.user = asyncResourceFactory();
sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: {_id: 1}}}));
const resource = await loadAsyncResource({
store,
path: 'user',
url: '/api/v3/user',
deserialize (response) {
return response.data.data;
},
});
expect(resource).to.equal(store.state.user);
expect(resource.loadingStatus).to.equal('LOADED');
expect(resource.data._id).to.equal(1);
expect(axios.get).to.have.been.calledOnce;
});
it('load the resource if it is loaded but forceLoad is true', async () => {
const store = generateStore();
store.state.user.loadingStatus = 'LOADED';
sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: {_id: 1}}}));
const resource = await loadAsyncResource({
store,
path: 'user',
url: '/api/v3/user',
deserialize (response) {
return response.data.data;
},
forceLoad: true,
});
expect(resource).to.equal(store.state.user);
expect(resource.loadingStatus).to.equal('LOADED');
expect(resource.data._id).to.equal(1);
expect(axios.get).to.have.been.calledOnce;
});
it('does not send multiple requests if the resource is being loaded', async () => {
const store = generateStore();
store.state.user.loadingStatus = 'LOADING';
sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: {_id: 1}}}));
const resourcePromise = loadAsyncResource({
store,
path: 'user',
url: '/api/v3/user',
deserialize (response) {
return response.data.data;
},
forceLoad: true,
});
await sleep(0.1);
const userData = {_id: 1};
expect(store.state.user.loadingStatus).to.equal('LOADING');
expect(axios.get).to.not.have.been.called;
store.state.user.data = userData;
store.state.user.loadingStatus = 'LOADED';
const result = await resourcePromise;
expect(axios.get).to.not.have.been.called;
expect(result).to.equal(store.state.user);
});
});
});

View File

@@ -1,7 +1,7 @@
import deepFreeze from 'client/libs/deepFreeze';
describe('deepFreeze', () => {
it('works as expected', () => {
it('deeply freezes an object', () => {
let obj = {
a: 1,
b () {

View File

@@ -1,10 +1,10 @@
import i18n from 'client/plugins/i18n';
import i18n from 'client/libs/i18n';
import commoni18n from 'common/script/i18n';
import Vue from 'vue';
describe('i18n plugin', () => {
before(() => {
i18n.install(Vue);
Vue.use(i18n);
});
it('adds $t to Vue.prototype', () => {

View File

@@ -0,0 +1,178 @@
import Vue from 'vue';
import StoreModule, { mapState, mapGetters, mapActions } from 'client/libs/store';
import { flattenAndNamespace } from 'client/libs/store/helpers/internals';
describe('Store', () => {
let store;
beforeEach(() => {
store = new StoreModule({ // eslint-disable-line babel/new-cap
state: {
name: 'test',
nested: {
name: 'nested state test',
},
},
getters: {
computedName ({ state }) {
return `${state.name} computed!`;
},
...flattenAndNamespace({
nested: {
computedName ({ state }) {
return `${state.name} computed!`;
},
},
}),
},
actions: {
getName ({ state }, ...args) {
return [state.name, ...args];
},
...flattenAndNamespace({
nested: {
getName ({ state }, ...args) {
return [state.name, ...args];
},
},
}),
},
});
Vue.use(StoreModule);
});
it('injects itself in all component', (done) => {
new Vue({ // eslint-disable-line no-new
store,
created () {
expect(this.$store).to.equal(store);
done();
},
});
});
it('can watch a function on the state', (done) => {
store.watch(state => state.name, (newName) => {
expect(newName).to.equal('test updated');
done();
});
store.state.name = 'test updated';
});
describe('getters', () => {
it('supports getters', () => {
expect(store.getters.computedName).to.equal('test computed!');
store.state.name = 'test updated';
expect(store.getters.computedName).to.equal('test updated computed!');
});
it('supports nested getters', () => {
expect(store.getters['nested:computedName']).to.equal('test computed!');
store.state.name = 'test updated';
expect(store.getters['nested:computedName']).to.equal('test updated computed!');
});
});
describe('actions', () => {
it('can dispatch an action', () => {
expect(store.dispatch('getName', 1, 2, 3)).to.deep.equal(['test', 1, 2, 3]);
});
it('can dispatch a nested action', () => {
expect(store.dispatch('nested:getName', 1, 2, 3)).to.deep.equal(['test', 1, 2, 3]);
});
it('throws an error is the action doesn\'t exists', () => {
expect(() => store.dispatched('wrong')).to.throw;
});
});
describe('helpers', () => {
it('mapState', (done) => {
new Vue({ // eslint-disable-line no-new
store,
data: {
title: 'internal',
},
computed: {
...mapState(['name']),
...mapState({
nameComputed (state, getters) {
return `${this.title} ${getters.computedName} ${state.name}`;
},
}),
...mapState({nestedTest: 'nested.name'}),
},
created () {
expect(this.name).to.equal('test');
expect(this.nameComputed).to.equal('internal test computed! test');
expect(this.nestedTest).to.equal('nested state test');
done();
},
});
});
it('mapGetters', (done) => {
new Vue({ // eslint-disable-line no-new
store,
data: {
title: 'internal',
},
computed: {
...mapGetters(['computedName']),
...mapGetters({
nameComputedTwice: 'computedName',
}),
},
created () {
expect(this.computedName).to.equal('test computed!');
expect(this.nameComputedTwice).to.equal('test computed!');
done();
},
});
});
it('mapActions', (done) => {
new Vue({ // eslint-disable-line no-new
store,
data: {
title: 'internal',
},
methods: {
...mapActions(['getName']),
...mapActions({
getNameRenamed: 'getName',
}),
},
created () {
expect(this.getName('123')).to.deep.equal(['test', '123']);
expect(this.getNameRenamed('123')).to.deep.equal(['test', '123']);
done();
},
});
});
it('flattenAndNamespace', () => {
let result = flattenAndNamespace({
nested: {
computed ({ state }, ...args) {
return [state.name, ...args];
},
getName ({ state }, ...args) {
return [state.name, ...args];
},
},
nested2: {
getName ({ state }, ...args) {
return [state.name, ...args];
},
},
});
expect(Object.keys(result).length).to.equal(3);
expect(Object.keys(result).sort()).to.deep.equal(['nested2:getName', 'nested:computed', 'nested:getName']);
});
});
});

View File

@@ -1,17 +0,0 @@
import { fetchAll as fetchAllGuilds } from 'client/store/actions/guilds';
import axios from 'axios';
import store from 'client/store';
describe('guilds actions', () => {
it('fetchAll', async () => {
const guilds = [{_id: 1}];
sandbox
.stub(axios, 'get')
.withArgs('/api/v3/groups?type=publicGuilds')
.returns(Promise.resolve({data: {data: guilds}}));
await fetchAllGuilds(store);
expect(store.state.guilds).to.equal(guilds);
});
});

View File

@@ -1,14 +1,54 @@
import { fetchUserTasks } from 'client/store/actions/tasks';
import axios from 'axios';
import store from 'client/store';
import generateStore from 'client/store';
describe('tasks actions', () => {
it('fetchUserTasks', async () => {
let store;
beforeEach(() => {
store = generateStore();
});
describe('fetchUserTasks', () => {
it('fetches user tasks', async () => {
expect(store.state.tasks.loadingStatus).to.equal('NOT_LOADED');
const tasks = [{_id: 1}];
sandbox.stub(axios, 'get').withArgs('/api/v3/tasks/user').returns(Promise.resolve({data: {data: tasks}}));
await fetchUserTasks(store);
await store.dispatch('tasks:fetchUserTasks');
expect(store.state.tasks).to.equal(tasks);
expect(store.state.tasks.data).to.equal(tasks);
expect(store.state.tasks.loadingStatus).to.equal('LOADED');
});
it('does not reload tasks by default', async () => {
const originalTask = [{_id: 1}];
store.state.tasks = {
loadingStatus: 'LOADED',
data: originalTask,
};
const tasks = [{_id: 2}];
sandbox.stub(axios, 'get').withArgs('/api/v3/tasks/user').returns(Promise.resolve({data: {data: tasks}}));
await store.dispatch('tasks:fetchUserTasks');
expect(store.state.tasks.data).to.equal(originalTask);
expect(store.state.tasks.loadingStatus).to.equal('LOADED');
});
it('can reload tasks if forceLoad is true', async () => {
store.state.tasks = {
loadingStatus: 'LOADED',
data: [{_id: 1}],
};
const tasks = [{_id: 2}];
sandbox.stub(axios, 'get').withArgs('/api/v3/tasks/user').returns(Promise.resolve({data: {data: tasks}}));
await store.dispatch('tasks:fetchUserTasks', true);
expect(store.state.tasks.data).to.equal(tasks);
expect(store.state.tasks.loadingStatus).to.equal('LOADED');
});
});
});

View File

@@ -1,14 +1,54 @@
import { fetch as fetchUser } from 'client/store/actions/user';
import axios from 'axios';
import store from 'client/store';
import generateStore from 'client/store';
describe('user actions', () => {
it('fetch', async () => {
describe('tasks actions', () => {
let store;
beforeEach(() => {
store = generateStore();
});
describe('fetch', () => {
it('loads the user', async () => {
expect(store.state.user.loadingStatus).to.equal('NOT_LOADED');
const user = {_id: 1};
sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: user}}));
await fetchUser(store);
await store.dispatch('user:fetch');
expect(store.state.user).to.equal(user);
expect(store.state.user.data).to.equal(user);
expect(store.state.user.loadingStatus).to.equal('LOADED');
});
it('does not reload user by default', async () => {
const originalUser = {_id: 1};
store.state.user = {
loadingStatus: 'LOADED',
data: originalUser,
};
const user = {_id: 2};
sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: user}}));
await store.dispatch('user:fetch');
expect(store.state.user.data).to.equal(originalUser);
expect(store.state.user.loadingStatus).to.equal('LOADED');
});
it('can reload user if forceLoad is true', async () => {
store.state.user = {
loadingStatus: 'LOADED',
data: {_id: 1},
};
const user = {_id: 2};
sandbox.stub(axios, 'get').withArgs('/api/v3/user').returns(Promise.resolve({data: {data: user}}));
await store.dispatch('user:fetch', true);
expect(store.state.user.data).to.equal(user);
expect(store.state.user.loadingStatus).to.equal('LOADED');
});
});
});

View File

@@ -0,0 +1,16 @@
import generateStore from 'client/store';
describe('getTagsFor getter', () => {
it('returns the tags for a task', () => {
const store = generateStore();
store.state.user.data = {
tags: [
{id: 1, name: 'tag 1'},
{id: 2, name: 'tag 2'},
],
};
const task = {tags: [2]};
expect(store.getters['tasks:getTagsFor'](task)).to.deep.equal(['tag 2']);
});
});

View File

@@ -5,7 +5,7 @@ describe('userGems getter', () => {
expect(userGems({
state: {
user: {
balance: 4.5,
data: {balance: 4.5},
},
},
})).to.equal(18);

View File

@@ -1,168 +1,8 @@
import Vue from 'vue';
import storeInjector from 'inject-loader?-vue!client/store';
import { mapState, mapGetters, mapActions } from 'client/store';
import { flattenAndNamespace } from 'client/store/helpers/internals';
import generateStore from 'client/store';
import Store from 'client/libs/store';
describe('Store', () => {
let injectedStore;
beforeEach(() => {
injectedStore = storeInjector({ // eslint-disable-line babel/new-cap
'./state': {
name: 'test',
},
'./getters': {
computedName ({ state }) {
return `${state.name} computed!`;
},
...flattenAndNamespace({
nested: {
computedName ({ state }) {
return `${state.name} computed!`;
},
},
}),
},
'./actions': {
getName ({ state }, ...args) {
return [state.name, ...args];
},
...flattenAndNamespace({
nested: {
getName ({ state }, ...args) {
return [state.name, ...args];
},
},
}),
},
}).default;
});
it('injects itself in all component', (done) => {
new Vue({ // eslint-disable-line no-new
created () {
expect(this.$store).to.equal(injectedStore);
done();
},
});
});
it('can watch a function on the state', (done) => {
injectedStore.watch(state => state.name, (newName) => {
expect(newName).to.equal('test updated');
done();
});
injectedStore.state.name = 'test updated';
});
describe('getters', () => {
it('supports getters', () => {
expect(injectedStore.getters.computedName).to.equal('test computed!');
injectedStore.state.name = 'test updated';
expect(injectedStore.getters.computedName).to.equal('test updated computed!');
});
it('supports nested getters', () => {
expect(injectedStore.getters['nested:computedName']).to.equal('test computed!');
injectedStore.state.name = 'test updated';
expect(injectedStore.getters['nested:computedName']).to.equal('test updated computed!');
});
});
describe('actions', () => {
it('can dispatch an action', () => {
expect(injectedStore.dispatch('getName', 1, 2, 3)).to.deep.equal(['test', 1, 2, 3]);
});
it('can dispatch a nested action', () => {
expect(injectedStore.dispatch('nested:getName', 1, 2, 3)).to.deep.equal(['test', 1, 2, 3]);
});
it('throws an error is the action doesn\'t exists', () => {
expect(() => injectedStore.dispatched('wrong')).to.throw;
});
});
describe('helpers', () => {
it('mapState', (done) => {
new Vue({ // eslint-disable-line no-new
data: {
title: 'internal',
},
computed: {
...mapState(['name']),
...mapState({
nameComputed (state, getters) {
return `${this.title} ${getters.computedName} ${state.name}`;
},
}),
},
created () {
expect(this.name).to.equal('test');
expect(this.nameComputed).to.equal('internal test computed! test');
done();
},
});
});
it('mapGetters', (done) => {
new Vue({ // eslint-disable-line no-new
data: {
title: 'internal',
},
computed: {
...mapGetters(['computedName']),
...mapGetters({
nameComputedTwice: 'computedName',
}),
},
created () {
expect(this.computedName).to.equal('test computed!');
expect(this.nameComputedTwice).to.equal('test computed!');
done();
},
});
});
it('mapActions', (done) => {
new Vue({ // eslint-disable-line no-new
data: {
title: 'internal',
},
methods: {
...mapActions(['getName']),
...mapActions({
getNameRenamed: 'getName',
}),
},
created () {
expect(this.getName('123')).to.deep.equal(['test', '123']);
expect(this.getNameRenamed('123')).to.deep.equal(['test', '123']);
done();
},
});
});
it('flattenAndNamespace', () => {
let result = flattenAndNamespace({
nested: {
computed ({ state }, ...args) {
return [state.name, ...args];
},
getName ({ state }, ...args) {
return [state.name, ...args];
},
},
nested2: {
getName ({ state }, ...args) {
return [state.name, ...args];
},
},
});
expect(Object.keys(result).length).to.equal(3);
expect(Object.keys(result).sort()).to.deep.equal(['nested2:getName', 'nested:computed', 'nested:getName']);
});
describe('Application store', () => {
it('is an instance of Store', () => {
expect(generateStore()).to.be.an.instanceof(Store);
});
});

View File

@@ -1,6 +1,7 @@
import changeClass from '../../../website/common/script/ops/changeClass';
import {
NotAuthorized,
BadRequest,
} from '../../../website/common/script/libs/errors';
import i18n from '../../../website/common/script/i18n';
import {
@@ -27,6 +28,19 @@ describe('shared.ops.changeClass', () => {
}
});
it('req.query.class is an invalid class', (done) => {
user.flags.classSelected = false;
user.preferences.disableClasses = false;
try {
changeClass(user, {query: {class: 'cellist'}});
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidClass'));
done();
}
});
context('req.query.class is a valid class', () => {
it('errors if user.stats.flagSelected is true and user.balance < 0.75', (done) => {
user.flags.classSelected = true;

View File

@@ -7,6 +7,7 @@ describe('shouldDo', () => {
let options = {};
beforeEach(() => {
options = {};
day = new Date();
dailyTask = {
completed: 'false',
@@ -38,6 +39,164 @@ describe('shouldDo', () => {
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
context('Timezone variations', () => {
beforeEach(() => {
dailyTask.frequency = 'daily';
});
context('User timezone is UTC', () => {
beforeEach(() => {
options.timezoneOffset = 0;
});
it('returns true if Start Date is before today', () => {
dailyTask.startDate = moment().subtract(1, 'days').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
it('returns true if Start Date is today', () => {
dailyTask.startDate = moment().toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
});
context('User timezone is between UTC-12 and UTC (0~720)', () => {
beforeEach(() => {
options.timezoneOffset = 600;
});
it('returns true if Start Date is before today', () => {
dailyTask.startDate = moment().subtract(1, 'days').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
it('returns true if Start Date is today', () => {
dailyTask.startDate = moment().startOf('day').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
it('returns true if the user\'s current time is after start date and CDS', () => {
options.dayStart = 4;
day = moment().utcOffset(options.timezoneOffset).startOf('day').add(6, 'hours').toDate();
dailyTask.startDate = moment().utcOffset(options.timezoneOffset).subtract(1, 'day').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
it('returns false if the user\'s current time is before CDS', () => {
options.dayStart = 8;
day = moment().utcOffset(options.timezoneOffset).startOf('day').add(2, 'hours').toDate();
dailyTask.startDate = moment().utcOffset(options.timezoneOffset).startOf('day').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
});
context('User timezone is between UTC and GMT+14 (-840~0)', () => {
beforeEach(() => {
options.timezoneOffset = -420;
});
it('returns true if Start Date is before today', () => {
dailyTask.startDate = moment().subtract(1, 'days').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
it('returns true if Start Date is today', () => {
dailyTask.startDate = moment().startOf('day').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
it('returns true if the user\'s current time is after CDS', () => {
options.dayStart = 4;
day = moment().utcOffset(options.timezoneOffset).startOf('day').add(6, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
it('returns false if the user\'s current time is before CDS', () => {
options.dayStart = 8;
day = moment().utcOffset(options.timezoneOffset).startOf('day').add(2, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
});
});
context('CDS variations', () => {
beforeEach(() => {
// Daily is due every 2 days, and start today
dailyTask.frequency = 'daily';
dailyTask.everyX = 2;
dailyTask.startDate = new Date();
});
context('CDS is midnight (Default dayStart=0)', () => {
beforeEach(() => {
options.dayStart = 0;
});
context('Current Date is yesterday', () => {
it('should not be due yesterday', () => {
day = moment(day).subtract(1, 'days').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
});
context('Current Date is today', () => {
it('returns false if current time is before midnight', () => {
day = moment(day).startOf('day').subtract(1, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
it('returns true if current time is after midnight', () => {
day = moment(day).startOf('day').add(1, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
});
context('Current Date is tomorrow', () => {
it('should not be due tomorrow', () => {
day = moment(day).add(1, 'days').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
});
});
context('CDS is 0 <= n < 24', () => {
beforeEach(() => {
options.dayStart = 7;
});
context('Current Date is yesterday', () => {
it('should not be due yesterday', () => {
day = moment(day).subtract(1, 'days').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
});
context('Current Date is today', () => {
it('returns false if current hour is before CDS', () => {
day = moment(day).startOf('day').add(1, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
it('returns true if current hour is after CDS', () => {
day = moment(day).startOf('day').add(9, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
});
context('Current Date is tomorrow', () => {
it('returns true if current hour is before CDS', () => {
day = moment(day).endOf('day').add(1, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
it('returns false if current hour is after CDS', () => {
day = moment(day).endOf('day').add(9, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
});
});
});
context('Every X Days', () => {
beforeEach(() => {
dailyTask.frequency = 'daily';
@@ -56,7 +215,14 @@ describe('shouldDo', () => {
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
it('returns true on multiples of x', () => {
it('returns true on the Start Date', () => {
dailyTask.startDate = moment().toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
context('On multiples of x', () => {
it('returns true when CDS is midnight', () => {
dailyTask.startDate = moment().subtract(7, 'days').toDate();
dailyTask.everyX = 7;
@@ -68,6 +234,56 @@ describe('shouldDo', () => {
day = moment(day).add(7, 'days');
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
it('returns true when current time is after CDS', () => {
dailyTask.startDate = moment().subtract(5, 'days').toDate();
dailyTask.everyX = 5;
options.dayStart = 3;
day = moment(day).startOf('day').add(8, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
it('returns false when current time is before CDS', () => {
dailyTask.startDate = moment().subtract(5, 'days').toDate();
dailyTask.everyX = 5;
options.dayStart = 14;
day = moment(day).startOf('day').add(7, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
});
context('If number of X days is zero', () => {
beforeEach(() => {
dailyTask.everyX = 0;
});
it('returns false on the Start Date', () => {
dailyTask.startDate = moment().subtract(4, 'days').toDate();
day = moment().subtract(4, 'days').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
it('returns false on the day before Start Date', () => {
dailyTask.startDate = moment().subtract(4, 'days').toDate();
day = moment().subtract(5, 'days').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
it('returns false on the day after Start Date', () => {
dailyTask.startDate = moment().subtract(4, 'days').toDate();
day = moment().subtract(3, 'days').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
it('returns false for today', () => {
dailyTask.startDate = moment().toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
});
});
context('Certain Days of the Week', () => {
@@ -134,6 +350,135 @@ describe('shouldDo', () => {
it('returns true if Daily on matching days of the week', () => {
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
context('Day of the week matches', () => {
const weekdayMap = {
1: 'm',
2: 't',
3: 'w',
4: 'th',
5: 'f',
6: 's',
7: 'su',
};
beforeEach(() => {
// Set repeat day to current weekday
const currentWeekday = moment().isoWeekday();
dailyTask.startDate = moment().startOf('day').toDate();
dailyTask.repeat = {
su: false,
s: false,
f: false,
th: false,
w: false,
t: false,
m: false,
};
dailyTask.repeat[weekdayMap[currentWeekday]] = true;
});
context('CDS is midnight (Default dayStart=0)', () => {
beforeEach(() => {
options.dayStart = 0;
});
context('Current Date is one day before the matching day', () => {
it('should return false', () => {
day = moment(day).subtract(1, 'days').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
});
context('Current Date is on the matching day', () => {
it('returns false if current time is before midnight', () => {
day = moment(day).startOf('day').subtract(1, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
it('returns true if current time is after midnight', () => {
day = moment(day).startOf('day').add(1, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
});
context('Current Date is one day after the matching day', () => {
it('should not be due tomorrow', () => {
day = moment(day).add(1, 'days').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
});
});
context('CDS is 0 <= n < 24', () => {
beforeEach(() => {
options.dayStart = 7;
});
context('Current Date is one day before the matching day', () => {
it('should not be due', () => {
day = moment(day).subtract(1, 'days').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
});
context('Current Date is on the matching day', () => {
it('returns false if current hour is before CDS', () => {
day = moment(day).startOf('day').add(1, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
it('returns true if current hour is after CDS', () => {
day = moment(day).startOf('day').add(9, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
});
context('Current Date is one day after the matching day', () => {
it('returns true if current hour is before CDS', () => {
day = moment(day).endOf('day').add(1, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
it('returns false if current hour is after CDS', () => {
day = moment(day).endOf('day').add(9, 'hours').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
});
});
});
context('No days of the week is selected', () => {
beforeEach(() => {
dailyTask.repeat = {
su: false,
s: false,
f: false,
th: false,
w: false,
t: false,
m: false,
};
});
it('returns false for a day before the Start Date', () => {
day = moment().subtract(1, 'days').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
it('returns false for the Start Date', () => {
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
it('returns false for a day after the Start Date', () => {
day = moment().add(1, 'days').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
it('returns false for today', () => {
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
});
});
// context('Every X Weeks', () => {

View File

@@ -30,16 +30,17 @@ export function generateChallenge (options = {}) {
export function generateRes (options = {}) {
let defaultRes = {
render: sandbox.stub(),
send: sandbox.stub(),
status: sandbox.stub().returnsThis(),
sendStatus: sandbox.stub().returnsThis(),
json: sandbox.stub(),
locals: {
user: generateUser(options.localsUser),
group: generateGroup(options.localsGroup),
},
redirect: sandbox.stub(),
render: sandbox.stub(),
send: sandbox.stub(),
sendStatus: sandbox.stub().returnsThis(),
set: sandbox.stub(),
status: sandbox.stub().returnsThis(),
t (string) {
return i18n.t(string);
},

4
vagrant_scripts/install_node.sh Executable file → Normal file
View File

@@ -16,7 +16,7 @@ nvm use
nvm alias default current
echo Update npm...
npm install -g npm@3
npm install -g npm@4
echo Installing global modules...
npm install -g gulp bower grunt-cli mocha
npm install -g gulp bower grunt-cli mocha node-pre-gyp

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,36 +1,36 @@
.promo_android {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1651px -180px;
background-position: -1715px -176px;
width: 175px;
height: 175px;
}
.promo_backgrounds_armoire_201602 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -565px -600px;
background-position: -1573px -295px;
width: 141px;
height: 294px;
}
.promo_backgrounds_armoire_201603 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -707px -600px;
background-position: -1573px 0px;
width: 141px;
height: 294px;
}
.promo_backgrounds_armoire_201604 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: 0px -1041px;
background-position: -1150px -442px;
width: 140px;
height: 441px;
}
.promo_backgrounds_armoire_201605 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -141px -1041px;
background-position: -1291px -442px;
width: 140px;
height: 441px;
}
.promo_backgrounds_armoire_201606 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -699px 0px;
background-position: -699px -148px;
width: 140px;
height: 447px;
}
@@ -42,49 +42,67 @@
}
.promo_backgrounds_armoire_201608 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -142px -600px;
background-position: -710px -600px;
width: 140px;
height: 439px;
}
.promo_backgrounds_armoire_201609 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -283px -600px;
background-position: -851px -600px;
width: 139px;
height: 438px;
}
.promo_backgrounds_armoire_201610 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1124px -442px;
background-position: -1432px 0px;
width: 140px;
height: 441px;
}
.promo_backgrounds_armoire_201611 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1265px 0px;
background-position: 0px -1042px;
width: 140px;
height: 441px;
}
.promo_backgrounds_armoire_201612 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1265px -442px;
background-position: -141px -1042px;
width: 140px;
height: 441px;
}
.promo_backgrounds_armoire_201701 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1406px 0px;
background-position: -282px -1042px;
width: 140px;
height: 441px;
}
.promo_backgrounds_armoire_201702 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -840px 0px;
background-position: -284px -600px;
width: 141px;
height: 441px;
}
.promo_backgrounds_armoire_201703 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -982px 0px;
background-position: -840px -148px;
width: 141px;
height: 441px;
}
.promo_backgrounds_armoire_201704 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -426px -600px;
width: 141px;
height: 441px;
}
.promo_backgrounds_armoire_201705 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: 0px -600px;
width: 141px;
height: 441px;
}
.promo_bees {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -142px -600px;
width: 141px;
height: 441px;
}
@@ -96,151 +114,163 @@
}
.promo_chairs_glasses {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1757px -532px;
background-position: -1821px -352px;
width: 51px;
height: 210px;
}
.promo_checkin_incentives {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -423px -600px;
background-position: -991px -600px;
width: 141px;
height: 294px;
}
.promo_classes_fall_2014 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1207px -1337px;
background-position: -201px -1484px;
width: 321px;
height: 100px;
}
.promo_classes_fall_2015 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -423px -895px;
background-position: -703px -1338px;
width: 377px;
height: 99px;
}
.promo_classes_fall_2016 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1547px 0px;
background-position: -1573px -590px;
width: 103px;
height: 348px;
}
.promo_coffee_mug {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1651px 0px;
background-position: 0px -1484px;
width: 200px;
height: 179px;
}
.promo_contrib_spotlight_Keith {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1547px -1118px;
background-position: -1715px -1536px;
width: 87px;
height: 111px;
}
.promo_contrib_spotlight_beffymaroo {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1406px -884px;
background-position: -1715px -626px;
width: 114px;
height: 147px;
}
.promo_contrib_spotlight_blade {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1547px -1006px;
background-position: -1715px -1424px;
width: 89px;
height: 111px;
}
.promo_contrib_spotlight_cantras {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1547px -1230px;
background-position: -1803px -1536px;
width: 87px;
height: 109px;
}
.promo_contrib_spotlight_dewines {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1573px -939px;
width: 89px;
height: 108px;
}
.promo_contrib_spotlight_megan {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1547px -894px;
background-position: -1715px -1200px;
width: 90px;
height: 111px;
}
.promo_contrib_spotlight_shanaqui {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1547px -782px;
background-position: -1715px -1312px;
width: 90px;
height: 111px;
}
.promo_cow {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -282px -1041px;
background-position: -1432px -442px;
width: 140px;
height: 441px;
}
.promo_cupid_potions {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -705px -1041px;
background-position: -564px -1042px;
width: 138px;
height: 441px;
}
.promo_dilatoryDistress {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1286px -1644px;
background-position: -1474px -1664px;
width: 90px;
height: 90px;
}
.promo_egg_mounts {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -844px -1041px;
background-position: -703px -1190px;
width: 280px;
height: 147px;
}
.promo_enchanted_armoire {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: 0px -1483px;
background-position: -1081px -1338px;
width: 374px;
height: 76px;
}
.promo_enchanted_armoire_201507 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -507px -1644px;
background-position: -1341px -1042px;
width: 217px;
height: 90px;
}
.promo_enchanted_armoire_201508 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1651px -1342px;
background-position: -468px -1664px;
width: 180px;
height: 90px;
}
.promo_enchanted_armoire_201509 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -614px -1735px;
background-position: -928px -1664px;
width: 90px;
height: 90px;
}
.promo_enchanted_armoire_201511 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -982px -442px;
background-position: -1715px -1018px;
width: 122px;
height: 90px;
}
.promo_enchanted_armoire_201601 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -887px -1735px;
background-position: -364px -1767px;
width: 90px;
height: 90px;
}
.promo_fairy_potions {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -982px -148px;
width: 141px;
height: 441px;
}
.promo_floral_potions {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1651px -532px;
background-position: -1715px -352px;
width: 105px;
height: 273px;
}
.promo_ghost_potions {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1406px -442px;
background-position: -1291px 0px;
width: 140px;
height: 441px;
}
.promo_habitica {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1651px -356px;
background-position: -1715px 0px;
width: 175px;
height: 175px;
}
@@ -252,217 +282,223 @@
}
.promo_habitoween_2016 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -423px -1041px;
background-position: -1150px 0px;
width: 140px;
height: 441px;
}
.promo_haunted_hair {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1547px -644px;
background-position: -1715px -774px;
width: 100px;
height: 137px;
}
.promo_holly_potions {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: 0px -600px;
background-position: -568px -600px;
width: 141px;
height: 440px;
}
.promo_item_notif {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: 0px -1735px;
background-position: 0px -1664px;
width: 249px;
height: 102px;
}
.promo_jackalope {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1124px -1189px;
background-position: -1264px -1190px;
width: 276px;
height: 147px;
}
.promo_more_checkin_incentives {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -699px 0px;
width: 450px;
height: 147px;
}
.promo_mystery_201405 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1650px -1644px;
background-position: 0px -1767px;
width: 90px;
height: 90px;
}
.promo_mystery_201406 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1401px -1189px;
background-position: -1432px -884px;
width: 90px;
height: 96px;
}
.promo_mystery_201407 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1809px -669px;
background-position: -1821px -563px;
width: 42px;
height: 62px;
}
.promo_mystery_201408 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1051px -812px;
background-position: -1821px -912px;
width: 60px;
height: 71px;
}
.promo_mystery_201409 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1195px -1644px;
background-position: -182px -1767px;
width: 90px;
height: 90px;
}
.promo_mystery_201410 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -982px -533px;
background-position: -1806px -1200px;
width: 72px;
height: 63px;
}
.promo_mystery_201411 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1468px -1644px;
background-position: -837px -1664px;
width: 90px;
height: 90px;
}
.promo_mystery_201412 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1809px -602px;
background-position: -1836px -1109px;
width: 42px;
height: 66px;
}
.promo_mystery_201501 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1792px -806px;
background-position: -1830px -708px;
width: 48px;
height: 63px;
}
.promo_mystery_201502 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -341px -1735px;
background-position: -91px -1767px;
width: 90px;
height: 90px;
}
.promo_mystery_201503 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -432px -1735px;
background-position: -1656px -1664px;
width: 90px;
height: 90px;
}
.promo_mystery_201504 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -375px -1483px;
background-position: -1806px -1312px;
width: 60px;
height: 69px;
}
.promo_mystery_201505 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -796px -1735px;
background-position: -1110px -1664px;
width: 90px;
height: 90px;
}
.promo_mystery_201506 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1809px -532px;
background-position: -1838px -1018px;
width: 42px;
height: 69px;
}
.promo_mystery_201507 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -990px -600px;
background-position: -1573px -1260px;
width: 90px;
height: 105px;
}
.promo_mystery_201508 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -819px -1644px;
background-position: -1291px -884px;
width: 93px;
height: 90px;
}
.promo_mystery_201509 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1377px -1644px;
background-position: -273px -1767px;
width: 90px;
height: 90px;
}
.promo_mystery_201510 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -725px -1644px;
background-position: -1150px -884px;
width: 93px;
height: 90px;
}
.promo_mystery_201511 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1559px -1644px;
background-position: -1019px -1664px;
width: 90px;
height: 90px;
}
.promo_mystery_201512 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -990px -812px;
background-position: -1830px -626px;
width: 60px;
height: 81px;
}
.promo_mystery_201601 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -840px -442px;
background-position: -1715px -1109px;
width: 120px;
height: 90px;
}
.promo_mystery_201602 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -250px -1735px;
background-position: -1292px -1664px;
width: 90px;
height: 90px;
}
.promo_mystery_201603 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1741px -1644px;
background-position: -1383px -1664px;
width: 90px;
height: 90px;
}
.promo_mystery_201604 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1101px -1644px;
background-position: -991px -895px;
width: 93px;
height: 90px;
}
.promo_mystery_201605 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -523px -1735px;
background-position: -1565px -1664px;
width: 90px;
height: 90px;
}
.promo_mystery_201606 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -699px -448px;
background-position: -1573px -1048px;
width: 90px;
height: 105px;
}
.promo_mystery_201607 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -705px -1735px;
background-position: -1747px -1664px;
width: 90px;
height: 90px;
}
.promo_mystery_201608 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -913px -1644px;
background-position: -649px -1664px;
width: 93px;
height: 90px;
}
.promo_mystery_201609 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1007px -1644px;
background-position: -743px -1664px;
width: 93px;
height: 90px;
}
.promo_mystery_201610 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1771px -1194px;
background-position: -1816px -774px;
width: 63px;
height: 84px;
}
.promo_mystery_201611 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1405px -1041px;
background-position: -1573px -1366px;
width: 90px;
height: 99px;
}
@@ -474,151 +510,73 @@
}
.promo_mystery_201701 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -990px -706px;
background-position: -1573px -1154px;
width: 90px;
height: 105px;
}
.promo_mystery_201702 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1125px -1041px;
background-position: -984px -1190px;
width: 279px;
height: 147px;
}
.promo_mystery_201703 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1058px -1042px;
width: 282px;
height: 147px;
}
.promo_mystery_201704 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1201px -1664px;
width: 90px;
height: 90px;
}
.promo_mystery_3014 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -289px -1644px;
background-position: -250px -1664px;
width: 217px;
height: 90px;
}
.promo_new_hair_fall2016 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -564px -1041px;
background-position: -423px -1042px;
width: 140px;
height: 441px;
}
.promo_orca {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1124px -884px;
background-position: -1715px -912px;
width: 105px;
height: 105px;
}
.promo_partyhats {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1651px -1433px;
background-position: -1432px -981px;
width: 115px;
height: 47px;
}
.promo_pastel_skin {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: 0px -1560px;
background-position: -523px -1484px;
width: 330px;
height: 83px;
}
.customize-option.promo_pastel_skin {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -25px -1575px;
background-position: -548px -1499px;
width: 60px;
height: 60px;
}
.promo_peppermint_flame {
.promo_pastel_skin_hair {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1651px -954px;
width: 140px;
background-position: -703px -1042px;
width: 354px;
height: 147px;
}
.promo_pet_skins {
.customize-option.promo_pastel_skin_hair {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1651px -806px;
width: 140px;
height: 147px;
}
.customize-option.promo_pet_skins {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1676px -821px;
background-position: -728px -1057px;
width: 60px;
height: 60px;
}
.promo_pyromancer {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1265px -884px;
width: 113px;
height: 113px;
}
.promo_rainbow_armor {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1547px -1340px;
width: 92px;
height: 103px;
}
.promo_seasonal_shop_fall_2016 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -844px -1189px;
width: 279px;
height: 147px;
}
.promo_shimmer_hair {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -331px -1560px;
width: 330px;
height: 83px;
}
.promo_splashyskins {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1651px -1102px;
width: 198px;
height: 91px;
}
.customize-option.promo_splashyskins {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1676px -1117px;
width: 60px;
height: 60px;
}
.promo_spooky_sparkles_fall_2016 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -849px -600px;
width: 140px;
height: 294px;
}
.promo_spring_classes_2016 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -844px -1337px;
width: 362px;
height: 102px;
}
.promo_springclasses2014 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -801px -895px;
width: 288px;
height: 90px;
}
.promo_springclasses2015 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: 0px -1644px;
width: 288px;
height: 90px;
}
.promo_staff_spotlight_Lemoness {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1547px -349px;
width: 102px;
height: 146px;
}
.promo_staff_spotlight_Viirus {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1651px -1194px;
width: 119px;
height: 147px;
}
.promo_staff_spotlight_paglias {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1547px -496px;
width: 99px;
height: 147px;
}
.promo_steampunk_3017 {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -1124px 0px;
width: 140px;
height: 441px;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -1,198 +1,330 @@
.promo_peppermint_flame {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1629px -934px;
width: 140px;
height: 147px;
}
.promo_pet_skins {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1488px -934px;
width: 140px;
height: 147px;
}
.customize-option.promo_pet_skins {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1513px -949px;
width: 60px;
height: 60px;
}
.promo_pyromancer {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1639px -632px;
width: 113px;
height: 113px;
}
.promo_rainbow_armor {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1358px -556px;
width: 92px;
height: 103px;
}
.promo_seasonal_shop_fall_2016 {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -301px -1207px;
width: 279px;
height: 147px;
}
.promo_shimmer_hair {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -581px -1207px;
width: 330px;
height: 83px;
}
.promo_shimmer_potions {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -934px 0px;
width: 141px;
height: 441px;
}
.promo_shinySeeds {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -792px 0px;
width: 141px;
height: 441px;
}
.promo_splashyskins {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1488px -1230px;
width: 198px;
height: 91px;
}
.customize-option.promo_splashyskins {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1513px -1245px;
width: 60px;
height: 60px;
}
.promo_spooky_sparkles_fall_2016 {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1217px -377px;
width: 140px;
height: 294px;
}
.promo_spring_classes_2016 {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -282px -1091px;
width: 362px;
height: 102px;
}
.promo_spring_classes_2017 {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -899px -916px;
width: 309px;
height: 147px;
}
.promo_springclasses2014 {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1488px -91px;
width: 288px;
height: 90px;
}
.promo_springclasses2015 {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1488px 0px;
width: 288px;
height: 90px;
}
.promo_staff_spotlight_Lemoness {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1639px -330px;
width: 102px;
height: 146px;
}
.promo_staff_spotlight_Viirus {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1645px -182px;
width: 119px;
height: 147px;
}
.promo_staff_spotlight_paglias {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1639px -481px;
width: 99px;
height: 147px;
}
.promo_steampunk_3017 {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -141px -765px;
width: 140px;
height: 441px;
}
.promo_summer_classes_2014 {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -885px -591px;
background-position: -340px -220px;
width: 429px;
height: 102px;
}
.promo_summer_classes_2015 {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -553px -615px;
background-position: 0px -1358px;
width: 300px;
height: 88px;
}
.promo_summer_classes_2016 {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -452px -145px;
background-position: -282px -765px;
width: 400px;
height: 150px;
}
.promo_takeThis_gear {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -361px -882px;
background-position: -1639px -783px;
width: 114px;
height: 87px;
}
.promo_takethis_armor {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -476px -882px;
background-position: -1358px -377px;
width: 114px;
height: 87px;
}
.promo_task_planning {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -885px -96px;
background-position: -1217px -181px;
width: 240px;
height: 195px;
}
.promo_turkey_day_2016 {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -141px -440px;
background-position: -1076px 0px;
width: 140px;
height: 441px;
}
.promo_unconventional_armor {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1315px -591px;
background-position: -1639px -871px;
width: 60px;
height: 60px;
}
.promo_unconventional_armor2 {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1283px -694px;
background-position: -1687px -1230px;
width: 70px;
height: 74px;
}
.promo_updos {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1195px -292px;
background-position: -1488px -182px;
width: 156px;
height: 147px;
}
.promo_veteran_pets {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -706px -704px;
background-position: -1627px -1082px;
width: 146px;
height: 75px;
}
.promo_winter_classes_2016 {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: 0px -882px;
background-position: -645px -1091px;
width: 360px;
height: 90px;
}
.promo_winter_classes_2017 {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -452px 0px;
background-position: 0px -620px;
width: 432px;
height: 144px;
}
.promo_winter_fireworks {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -302px -973px;
background-position: -1488px -1082px;
width: 138px;
height: 147px;
}
.promo_winterclasses2015 {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -452px -296px;
background-position: -433px -620px;
width: 325px;
height: 110px;
}
.promo_wintery_skins {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: 0px -440px;
background-position: 0px -765px;
width: 140px;
height: 441px;
}
.customize-option.promo_wintery_skins {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -25px -455px;
background-position: -25px -780px;
width: 60px;
height: 60px;
}
.promo_winteryhair {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -553px -704px;
background-position: -1488px -1322px;
width: 152px;
height: 75px;
}
.avatar_variety {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -885px 0px;
background-position: -683px -765px;
width: 498px;
height: 95px;
}
.npc_viirus {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -441px -973px;
background-position: -1358px -465px;
width: 108px;
height: 90px;
}
.party_preview {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: 0px 0px;
background-position: -340px 0px;
width: 451px;
height: 219px;
}
.promo_backtoschool {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1186px -440px;
background-position: -1488px -632px;
width: 150px;
height: 150px;
}
.promo_cooking {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: 0px -220px;
background-position: -328px -343px;
width: 396px;
height: 219px;
}
.promo_startingover {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1132px -694px;
background-position: -1488px -783px;
width: 150px;
height: 150px;
}
.promo_valentines {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -885px -292px;
background-position: -589px -916px;
width: 309px;
height: 147px;
}
.promo_working_out {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -885px -440px;
background-position: 0px -1207px;
width: 300px;
height: 150px;
}
.scene_coding {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -151px -973px;
background-position: -1488px -481px;
width: 150px;
height: 150px;
}
.scene_dailies {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: 0px -343px;
width: 327px;
height: 276px;
}
.scene_eco_friendly {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -589px -440px;
background-position: -1217px -1004px;
width: 222px;
height: 171px;
}
.scene_habits {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -282px -440px;
background-position: -282px -916px;
width: 306px;
height: 174px;
}
.scene_phone_peek {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: 0px -973px;
background-position: -1488px -330px;
width: 150px;
height: 150px;
}
.scene_video_games {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: 0px 0px;
width: 339px;
height: 342px;
}
.welcome_basic_avatars {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -885px -694px;
background-position: -1217px -838px;
width: 246px;
height: 165px;
}
.welcome_promo_party {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -282px -615px;
background-position: -1217px 0px;
width: 270px;
height: 180px;
}
.welcome_sample_tasks {
background-image: url(/static/sprites/spritesmith-largeSprites-1.png);
background-position: -1126px -96px;
background-position: -1217px -672px;
width: 246px;
height: 165px;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 340 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

After

Width:  |  Height:  |  Size: 507 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 72 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 141 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 138 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 158 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 146 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 154 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 177 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 159 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 67 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 110 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 153 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 195 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

After

Width:  |  Height:  |  Size: 440 KiB

Some files were not shown because too many files have changed in this diff Show More