Compare commits
184 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be86812900 | |||
| c879560445 | |||
| 188023b197 | |||
| c4343379a1 | |||
| ce07d06c15 | |||
| a0041221be | |||
| ebfb6f96b3 | |||
| 22d696219a | |||
| 9014943a86 | |||
| 6a70487fa6 | |||
| 4fa381f153 | |||
| 97209e40ad | |||
| 2e97f9864e | |||
| 6d6adfd919 | |||
| 13123c0bae | |||
| 577e6f005e | |||
| 1361fea2d4 | |||
| cbcc7cd479 | |||
| 0e36c1aa0f | |||
| 5b4505ac62 | |||
| aa3d972cb4 | |||
| d4b729c95e | |||
| d4b867acc3 | |||
| 15c09691af | |||
| 27263e9b2f | |||
| 2d9715b657 | |||
| 8ca5ee99b0 | |||
| 9f9da5632d | |||
| 9364cdc2b4 | |||
| 2896cf77e0 | |||
| 936d3ffc98 | |||
| 9608b9fa9f | |||
| 657327edd7 | |||
| 9706a9c8be | |||
| 484bae40cd | |||
| c0b6353ded | |||
| f71062e86c | |||
| 8da36bf27c | |||
| 1800fabaaa | |||
| 193e7062c3 | |||
| cda5c6fbb0 | |||
| 495d01f386 | |||
| 24e1bfdfba | |||
| f757e645b7 | |||
| bf492933cc | |||
| 86d2fed76e | |||
| 2b7fe7c1d5 | |||
| 999b62df43 | |||
| cc7dac47c4 | |||
| c8189360d6 | |||
| 88183149c5 | |||
| a8f397c674 | |||
| c5aeab652d | |||
| cc04761c24 | |||
| 29dccdd148 | |||
| 186b929e59 | |||
| 551abf292c | |||
| 1e1c058d82 | |||
| 2ea9070a9b | |||
| 168a3a6e89 | |||
| 67bd2d9130 | |||
| cb0280ca8b | |||
| 259b15877b | |||
| dd31f559c7 | |||
| 6c29ea8c4c | |||
| dc316ad1c8 | |||
| a5b37fcc02 | |||
| 16f1c7286b | |||
| d5b3e4ec9f | |||
| 47eb9bf3ff | |||
| 2977346ccb | |||
| a545cc72d0 | |||
| 82548f69e5 | |||
| 5aacae3ead | |||
| 9f8162b82c | |||
| 98a749b239 | |||
| 0cef9eff87 | |||
| 3189a944d7 | |||
| 79066165e2 | |||
| c6f6df3f14 | |||
| 7bfd6ceca1 | |||
| 400f4dfb01 | |||
| 57a9fb5241 | |||
| 08c63f94fc | |||
| 9c6c90a2e9 | |||
| 39765895ee | |||
| 2a8fc7aea2 | |||
| 0a86d04a15 | |||
| 3afa7e6da5 | |||
| 0ac0723be5 | |||
| 0f2d2ddad6 | |||
| e19837f58e | |||
| 0ea1ce9758 | |||
| 9d16ab7dba | |||
| 776e9834f3 | |||
| 1d98929453 | |||
| 80664b6c5f | |||
| 01123367b1 | |||
| f6a80d18b6 | |||
| 71af306f02 | |||
| 5ccd2ae262 | |||
| a1a1fd939d | |||
| 9ac7840940 | |||
| 100275f460 | |||
| 489b9c4019 | |||
| ba61da4acb | |||
| 903851f1fd | |||
| 371a1542e7 | |||
| 0c640f07d1 | |||
| cbc6e7aa0c | |||
| b6eab67e6a | |||
| 99afefe953 | |||
| 9fea01b1f4 | |||
| 978b78e57a | |||
| f920a441a5 | |||
| 12a9eec540 | |||
| f83d86b7f3 | |||
| 0410c97001 | |||
| 4482b734a5 | |||
| 0131cc07bf | |||
| 70a9f66dcd | |||
| 3057fdbd4a | |||
| cc48479c66 | |||
| 8ad644ec3a | |||
| 6f2bf5659d | |||
| f4e573f684 | |||
| a2e59d0920 | |||
| 39fe86c688 | |||
| 78ceb427a3 | |||
| 7c17a32bbb | |||
| 9a34c16fa2 | |||
| 38c24763fa | |||
| 776f3d288b | |||
| b7448e2cfe | |||
| 0bc836b490 | |||
| fdf7e3a665 | |||
| 00d12e83bd | |||
| e4661c3763 | |||
| 5fcf3fba88 | |||
| 156d15c540 | |||
| 7d9b8a5ceb | |||
| 23b19853b4 | |||
| c6839c4478 | |||
| 167f4f07b8 | |||
| 95c9dbaa73 | |||
| c88af182c9 | |||
| d9f7772453 | |||
| 931d8814b6 | |||
| 25e5183370 | |||
| 8e559da200 | |||
| 8efed37241 | |||
| ed25afb0b1 | |||
| a2c91aae70 | |||
| 3ac69d5e75 | |||
| 87b26c4cfb | |||
| 28bc843779 | |||
| e92ff9737a | |||
| 66422d4235 | |||
| c3343c9412 | |||
| e9100c7132 | |||
| 11235685ca | |||
| 49d6691f7d | |||
| db4c4e6493 | |||
| 56b1f6371f | |||
| 9213181ca2 | |||
| 3e50469ed5 | |||
| 7e9d3062f3 | |||
| 6376e57614 | |||
| 84ad270436 | |||
| 7a51b7593f | |||
| 1bfc55ece1 | |||
| e21f64edaf | |||
| a08b419411 | |||
| 4a752c3347 | |||
| 8609aae1b4 | |||
| d35fd9d90d | |||
| 11103813f5 | |||
| 1a75c6a696 | |||
| 6bef105cf6 | |||
| 3fffe7aa5c | |||
| 9ab9b0f553 | |||
| a00add46a7 | |||
| b9d8da44de | |||
| eb4e382ecf |
@@ -37,6 +37,7 @@ yarn.lock
|
||||
.elasticbeanstalk/*
|
||||
!.elasticbeanstalk/*.cfg.yml
|
||||
!.elasticbeanstalk/*.global.yml
|
||||
/.vscode
|
||||
|
||||
# webstorm fake webpack for path intellisense
|
||||
webpack.webstorm.config
|
||||
|
||||
@@ -9,3 +9,4 @@ test/**
|
||||
*.swp
|
||||
*.swx
|
||||
website/raw_sprites/**
|
||||
content_cache/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
FROM node:12
|
||||
|
||||
ENV ADMIN_EMAIL admin@habitica.com
|
||||
ENV EMAILS_COMMUNITY_MANAGER_EMAIL admin@habitica.com
|
||||
ENV AMAZON_PAYMENTS_CLIENT_ID amzn1.application-oa2-client.68ed9e6904ef438fbc1bf86bf494056e
|
||||
ENV AMAZON_PAYMENTS_SELLER_ID AMQ3SB4SG5E91
|
||||
ENV AMPLITUDE_KEY e8d4c24b3d6ef3ee73eeba715023dd43
|
||||
@@ -11,6 +12,7 @@ ENV GOOGLE_CLIENT_ID 1035232791481-32vtplgnjnd1aufv3mcu1lthf31795fq.apps.googleu
|
||||
ENV LOGGLY_CLIENT_TOKEN ab5663bf-241f-4d14-8783-7d80db77089a
|
||||
ENV NODE_ENV production
|
||||
ENV STRIPE_PUB_KEY pk_85fQ0yMECHNfHTSsZoxZXlPSwSNfA
|
||||
ENV APPLE_AUTH_CLIENT_ID 9Q9SMRMCNN.com.habitrpg.ios.Habitica
|
||||
|
||||
# Install global packages
|
||||
RUN npm install -g gulp-cli mocha
|
||||
|
||||
@@ -75,5 +75,10 @@
|
||||
"WEB_CONCURRENCY": 1,
|
||||
"SKIP_SSL_CHECK_KEY": "key",
|
||||
"ENABLE_STACKDRIVER_TRACING": "false",
|
||||
"BLOCKED_IPS": ""
|
||||
"APPLE_AUTH_PRIVATE_KEY": "",
|
||||
"APPLE_TEAM_ID": "",
|
||||
"APPLE_AUTH_CLIENT_ID": "",
|
||||
"APPLE_AUTH_KEY_ID": "",
|
||||
"BLOCKED_IPS": "",
|
||||
"LOG_AMPLITUDE_EVENTS": "false"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ gulp.task('content:cache', done => {
|
||||
// Requiring at runtime because these files access `common`
|
||||
// code which in production works only if transpiled so after
|
||||
// gulp build:babel:common has run
|
||||
const { CONTENT_CACHE_PATH, getLocalizedContent } = require('../website/server/libs/content'); // eslint-disable-line global-require
|
||||
const { CONTENT_CACHE_PATH, getLocalizedContentResponse } = require('../website/server/libs/content'); // eslint-disable-line global-require
|
||||
const { langCodes } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
|
||||
|
||||
try {
|
||||
@@ -23,7 +23,7 @@ gulp.task('content:cache', done => {
|
||||
langCodes.forEach(langCode => {
|
||||
fs.writeFileSync(
|
||||
`${CONTENT_CACHE_PATH}${langCode}.json`,
|
||||
getLocalizedContent(langCode),
|
||||
getLocalizedContentResponse(langCode),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/* eslint-disable no-console */
|
||||
const MIGRATION_NAME = '20200402_webhooks_add_protocol';
|
||||
import { model as User } from '../../../website/server/models/user';
|
||||
|
||||
const progressCount = 1000;
|
||||
let count = 0;
|
||||
|
||||
async function updateUser (user) {
|
||||
count++;
|
||||
|
||||
const set = {
|
||||
migration: MIGRATION_NAME,
|
||||
};
|
||||
|
||||
if (user && user.webhooks && user.webhooks.length > 0) {
|
||||
user.webhooks.forEach(webhook => {
|
||||
// Make sure the protocol is set and valid
|
||||
if (webhook.url.startsWith('ftp')) {
|
||||
webhook.url = webhook.url.replace('ftp', 'https');
|
||||
}
|
||||
|
||||
if (!webhook.url.startsWith('http://') && !webhook.url.startsWith('https://')) {
|
||||
// the default in got 9 was https
|
||||
// see https://github.com/sindresorhus/got/commit/92bc8082137d7d085750359bbd76c801e213d7d2#diff-0730bb7c2e8f9ea2438b52e419dd86c9L111
|
||||
webhook.url = `https://${webhook.url}`;
|
||||
}
|
||||
});
|
||||
|
||||
set.webhooks = user.webhooks;
|
||||
}
|
||||
|
||||
if (count % progressCount === 0) console.warn(`${count} ${user._id}`);
|
||||
|
||||
return await User.update({ _id: user._id }, { $set: set }).exec();
|
||||
}
|
||||
|
||||
module.exports = async function processUsers () {
|
||||
let query = {
|
||||
migration: { $ne: MIGRATION_NAME },
|
||||
webhooks: { $exists: true, $not: { $size: 0 } },
|
||||
};
|
||||
|
||||
const fields = {
|
||||
_id: 1,
|
||||
webhooks: 1,
|
||||
};
|
||||
|
||||
while (true) { // eslint-disable-line no-constant-condition
|
||||
const users = await User // eslint-disable-line no-await-in-loop
|
||||
.find(query)
|
||||
.limit(250)
|
||||
.sort({_id: 1})
|
||||
.select(fields)
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
if (users.length === 0) {
|
||||
console.warn('All appropriate users found and modified.');
|
||||
console.warn(`\n${count} users processed\n`);
|
||||
break;
|
||||
} else {
|
||||
query._id = {
|
||||
$gt: users[users.length - 1]._id,
|
||||
};
|
||||
}
|
||||
|
||||
await Promise.all(users.map(updateUser)); // eslint-disable-line no-await-in-loop
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
/* eslint-disable no-console */
|
||||
const MIGRATION_NAME = '20200402_webhooks_reenable';
|
||||
import { model as User } from '../../../website/server/models/user';
|
||||
|
||||
const progressCount = 1000;
|
||||
let count = 0;
|
||||
|
||||
async function updateUser (user) {
|
||||
count++;
|
||||
|
||||
const set = {
|
||||
migration: MIGRATION_NAME,
|
||||
};
|
||||
|
||||
if (user && user.webhooks && user.webhooks.length > 0) {
|
||||
user.webhooks.forEach(webhook => {
|
||||
// Re-enable webhooks disabled because of too many failures
|
||||
if (webhook.enabled === false && webhook.lastFailureAt === null) {
|
||||
webhook.enabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
set.webhooks = user.webhooks;
|
||||
}
|
||||
|
||||
if (count % progressCount === 0) console.warn(`${count} ${user._id}`);
|
||||
|
||||
return await User.update({ _id: user._id }, { $set: set }).exec();
|
||||
}
|
||||
|
||||
module.exports = async function processUsers () {
|
||||
let query = {
|
||||
migration: { $ne: MIGRATION_NAME },
|
||||
webhooks: { $exists: true, $not: { $size: 0 } },
|
||||
};
|
||||
|
||||
const fields = {
|
||||
_id: 1,
|
||||
webhooks: 1,
|
||||
};
|
||||
|
||||
while (true) { // eslint-disable-line no-constant-condition
|
||||
const users = await User // eslint-disable-line no-await-in-loop
|
||||
.find(query)
|
||||
.limit(250)
|
||||
.sort({_id: 1})
|
||||
.select(fields)
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
if (users.length === 0) {
|
||||
console.warn('All appropriate users found and modified.');
|
||||
console.warn(`\n${count} users processed\n`);
|
||||
break;
|
||||
} else {
|
||||
query._id = {
|
||||
$gt: users[users.length - 1]._id,
|
||||
};
|
||||
}
|
||||
|
||||
await Promise.all(users.map(updateUser)); // eslint-disable-line no-await-in-loop
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/* eslint-disable no-console */
|
||||
import each from 'lodash/each';
|
||||
import keys from 'lodash/keys';
|
||||
import content from '../../website/common/script/content/index';
|
||||
|
||||
import { model as User } from '../../website/server/models/user';
|
||||
|
||||
const MIGRATION_NAME = 'full-gear';
|
||||
|
||||
const progressCount = 1000;
|
||||
let count = 0;
|
||||
|
||||
/*
|
||||
* Award users every extant pet and mount
|
||||
*/
|
||||
|
||||
async function updateUser (user) {
|
||||
count += 1;
|
||||
|
||||
const set = {};
|
||||
|
||||
set.migration = MIGRATION_NAME;
|
||||
|
||||
each(keys(content.gear.flat), gearItem => {
|
||||
set[`items.gear.owned.${gearItem}`] = true;
|
||||
});
|
||||
|
||||
if (count % progressCount === 0) console.warn(`${count} ${user._id}`);
|
||||
|
||||
return User.update({ _id: user._id }, { $set: set }).exec();
|
||||
}
|
||||
|
||||
export default async function processUsers () {
|
||||
const query = {
|
||||
migration: { $ne: MIGRATION_NAME },
|
||||
'auth.local.lowerCaseUsername': 'olson1',
|
||||
};
|
||||
|
||||
const fields = {
|
||||
_id: 1,
|
||||
};
|
||||
|
||||
while (true) { // eslint-disable-line no-constant-condition
|
||||
const users = await User // eslint-disable-line no-await-in-loop
|
||||
.find(query)
|
||||
.limit(250)
|
||||
.sort({ _id: 1 })
|
||||
.select(fields)
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
if (users.length === 0) {
|
||||
console.warn('All appropriate users found and modified.');
|
||||
console.warn(`\n${count} users processed\n`);
|
||||
break;
|
||||
} else {
|
||||
query._id = {
|
||||
$gt: users[users.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
await Promise.all(users.map(updateUser)); // eslint-disable-line no-await-in-loop
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"name": "habitica",
|
||||
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
|
||||
"version": "4.138.4",
|
||||
"version": "4.140.10",
|
||||
"main": "./website/server/index.js",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.9.0",
|
||||
"@babel/preset-env": "^7.9.0",
|
||||
"@babel/preset-env": "^7.9.5",
|
||||
"@babel/register": "^7.9.0",
|
||||
"@google-cloud/trace-agent": "^4.2.5",
|
||||
"@slack/client": "^3.8.1",
|
||||
"@slack/client": "^4.12.0",
|
||||
"accepts": "^1.3.5",
|
||||
"amazon-payments": "^0.2.8",
|
||||
"amplitude": "^3.5.0",
|
||||
"apidoc": "^0.17.5",
|
||||
"apn": "^2.2.0",
|
||||
"aws-sdk": "^2.648.0",
|
||||
"apple-auth": "^1.0.5",
|
||||
"bcrypt": "^3.0.8",
|
||||
"body-parser": "^1.18.3",
|
||||
"compression": "^1.7.4",
|
||||
@@ -30,7 +30,7 @@
|
||||
"express-basic-auth": "^1.1.5",
|
||||
"express-validator": "^5.2.0",
|
||||
"glob": "^7.1.6",
|
||||
"got": "^9.0.0",
|
||||
"got": "^10.7.0",
|
||||
"gulp": "^4.0.0",
|
||||
"gulp-babel": "^8.0.0",
|
||||
"gulp-imagemin": "^6.2.0",
|
||||
@@ -41,19 +41,20 @@
|
||||
"image-size": "^0.8.3",
|
||||
"in-app-purchase": "^1.11.3",
|
||||
"js2xmlparser": "^4.0.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"jwks-rsa": "^1.8.0",
|
||||
"lodash": "^4.17.15",
|
||||
"merge-stream": "^2.0.0",
|
||||
"method-override": "^3.0.0",
|
||||
"moment": "^2.24.0",
|
||||
"moment-recur": "^1.0.7",
|
||||
"mongoose": "^5.9.6",
|
||||
"mongoose": "^5.9.9",
|
||||
"morgan": "^1.10.0",
|
||||
"nconf": "^0.10.0",
|
||||
"node-gcm": "^1.0.2",
|
||||
"pageres": "^5.1.0",
|
||||
"on-headers": "^1.0.2",
|
||||
"passport": "^0.4.1",
|
||||
"passport-facebook": "^3.0.0",
|
||||
"passport-google-oauth2": "^0.2.0",
|
||||
"passport-google-oauth20": "1.0.0",
|
||||
"paypal-ipn": "3.0.0",
|
||||
"paypal-rest-sdk": "^1.8.1",
|
||||
@@ -70,7 +71,7 @@
|
||||
"validator": "^11.0.0",
|
||||
"vinyl-buffer": "^1.0.1",
|
||||
"winston": "^3.2.1",
|
||||
"winston-loggly-bulk": "^3.0.1",
|
||||
"winston-loggly-bulk": "^3.1.0",
|
||||
"xml2js": "^0.4.23"
|
||||
},
|
||||
"private": true,
|
||||
@@ -110,7 +111,7 @@
|
||||
"expect.js": "^0.3.1",
|
||||
"istanbul": "^1.1.0-alpha.1",
|
||||
"mocha": "^5.1.1",
|
||||
"monk": "^7.1.2",
|
||||
"monk": "^7.2.0",
|
||||
"require-again": "^2.0.0",
|
||||
"sinon": "^7.2.4",
|
||||
"sinon-chai": "^3.5.0",
|
||||
|
||||
@@ -8,9 +8,9 @@ describe('contentLib', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLocalizedContent', () => {
|
||||
describe('getLocalizedContentResponse', () => {
|
||||
it('clones, not modify, the original content data', () => {
|
||||
contentLib.getLocalizedContent();
|
||||
contentLib.getLocalizedContentResponse();
|
||||
expect(typeof content.backgrounds.backgrounds062014.beach.text).to.equal('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,16 @@ function getUser () {
|
||||
value: 'email@facebook',
|
||||
}],
|
||||
},
|
||||
google: {
|
||||
emails: [{
|
||||
value: 'email@google',
|
||||
}],
|
||||
},
|
||||
apple: {
|
||||
emails: [{
|
||||
value: 'email@apple',
|
||||
}],
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
name: 'profile name',
|
||||
@@ -58,6 +68,8 @@ describe('emails', () => {
|
||||
const user = getUser();
|
||||
delete user.profile.name;
|
||||
delete user.auth.local.email;
|
||||
delete user.auth.google.emails;
|
||||
delete user.auth.apple.emails;
|
||||
|
||||
const data = getUserInfo(user, ['name', 'email', '_id', 'canSend']);
|
||||
|
||||
@@ -67,12 +79,48 @@ describe('emails', () => {
|
||||
expect(data).to.have.property('canSend', true);
|
||||
});
|
||||
|
||||
it('returns correct user data [google users]', () => {
|
||||
const attachEmail = requireAgain(pathToEmailLib);
|
||||
const { getUserInfo } = attachEmail;
|
||||
const user = getUser();
|
||||
delete user.profile.name;
|
||||
delete user.auth.local.email;
|
||||
delete user.auth.facebook.emails;
|
||||
delete user.auth.apple.emails;
|
||||
|
||||
const data = getUserInfo(user, ['name', 'email', '_id', 'canSend']);
|
||||
|
||||
expect(data).to.have.property('name', user.auth.local.username);
|
||||
expect(data).to.have.property('email', user.auth.google.emails[0].value);
|
||||
expect(data).to.have.property('_id', user._id);
|
||||
expect(data).to.have.property('canSend', true);
|
||||
});
|
||||
|
||||
it('returns correct user data [apple users]', () => {
|
||||
const attachEmail = requireAgain(pathToEmailLib);
|
||||
const { getUserInfo } = attachEmail;
|
||||
const user = getUser();
|
||||
delete user.profile.name;
|
||||
delete user.auth.local.email;
|
||||
delete user.auth.google.emails;
|
||||
delete user.auth.facebook.emails;
|
||||
|
||||
const data = getUserInfo(user, ['name', 'email', '_id', 'canSend']);
|
||||
|
||||
expect(data).to.have.property('name', user.auth.local.username);
|
||||
expect(data).to.have.property('email', user.auth.apple.emails[0].value);
|
||||
expect(data).to.have.property('_id', user._id);
|
||||
expect(data).to.have.property('canSend', true);
|
||||
});
|
||||
|
||||
it('has fallbacks for missing data', () => {
|
||||
const attachEmail = requireAgain(pathToEmailLib);
|
||||
const { getUserInfo } = attachEmail;
|
||||
const user = getUser();
|
||||
delete user.auth.local.email;
|
||||
delete user.auth.facebook;
|
||||
delete user.auth.google;
|
||||
delete user.auth.apple;
|
||||
|
||||
const data = getUserInfo(user, ['name', 'email', '_id', 'canSend']);
|
||||
|
||||
@@ -121,8 +169,7 @@ describe('emails', () => {
|
||||
|
||||
sendTxnEmail(mailingInfo, emailType);
|
||||
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
|
||||
json: true,
|
||||
body: {
|
||||
json: {
|
||||
data: {
|
||||
emailType: sinon.match.same(emailType),
|
||||
to: sinon.match(value => Array.isArray(value) && value[0].name === mailingInfo.name, 'matches mailing info array'),
|
||||
@@ -154,8 +201,7 @@ describe('emails', () => {
|
||||
|
||||
sendTxnEmail(mailingInfo, emailType);
|
||||
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
|
||||
json: true,
|
||||
body: {
|
||||
json: {
|
||||
data: {
|
||||
emailType: sinon.match.same(emailType),
|
||||
to: sinon.match(val => val[0]._id === mailingInfo._id),
|
||||
@@ -177,8 +223,7 @@ describe('emails', () => {
|
||||
|
||||
sendTxnEmail(mailingInfo, emailType, variables);
|
||||
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
|
||||
json: true,
|
||||
body: {
|
||||
json: {
|
||||
data: {
|
||||
variables: sinon.match(value => value[0].name === 'BASE_URL', 'matches variables'),
|
||||
personalVariables: sinon.match(value => value[0].rcpt === mailingInfo.email
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import mongoose from 'mongoose';
|
||||
import {
|
||||
highlightMentions,
|
||||
} from '../../../../website/server/libs/highlightMentions';
|
||||
|
||||
describe('highlightMentions', () => {
|
||||
beforeEach(() => {
|
||||
const mockFind = {
|
||||
select () {
|
||||
return this;
|
||||
},
|
||||
lean () {
|
||||
return this;
|
||||
},
|
||||
exec () {
|
||||
return Promise.resolve([{
|
||||
auth: { local: { username: 'user' } }, _id: '111',
|
||||
}, { auth: { local: { username: 'user2' } }, _id: '222' }, { auth: { local: { username: 'user3' } }, _id: '333' }, { auth: { local: { username: 'user-dash' } }, _id: '444' }, { auth: { local: { username: 'user_underscore' } }, _id: '555' },
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
sinon.stub(mongoose.Model, 'find').returns(mockFind);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('doesn\'t change text without mentions', async () => {
|
||||
const text = 'some chat text';
|
||||
const result = await highlightMentions(text);
|
||||
expect(result[0]).to.equal(text);
|
||||
});
|
||||
it('highlights existing users', async () => {
|
||||
const text = '@user: message';
|
||||
const result = await highlightMentions(text);
|
||||
expect(result[0]).to.equal('[@user](/profile/111): message');
|
||||
});
|
||||
it('highlights special characters', async () => {
|
||||
const text = '@user-dash: message @user_underscore';
|
||||
const result = await highlightMentions(text);
|
||||
expect(result[0]).to.equal('[@user-dash](/profile/444): message [@user_underscore](/profile/555)');
|
||||
});
|
||||
it('doesn\'t highlight nonexisting users', async () => {
|
||||
const text = '@nouser message';
|
||||
const result = await highlightMentions(text);
|
||||
expect(result[0]).to.equal('@nouser message');
|
||||
});
|
||||
it('highlights multiple existing users', async () => {
|
||||
const text = '@user message (@user2) @user3 @user';
|
||||
const result = await highlightMentions(text);
|
||||
expect(result[0]).to.equal('[@user](/profile/111) message ([@user2](/profile/222)) [@user3](/profile/333) [@user](/profile/111)');
|
||||
});
|
||||
it('doesn\'t highlight more than 5 users', async () => {
|
||||
const text = '@user @user2 @user3 @user4 @user5 @user6';
|
||||
const result = await highlightMentions(text);
|
||||
expect(result[0]).to.equal(text);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import mongoose from 'mongoose';
|
||||
import highlightMentions from '../../../../website/server/libs/highlightMentions';
|
||||
|
||||
describe('highlightMentions', () => {
|
||||
beforeEach(() => {
|
||||
const mockFind = {
|
||||
select () {
|
||||
return this;
|
||||
},
|
||||
lean () {
|
||||
return this;
|
||||
},
|
||||
exec () {
|
||||
return Promise.resolve([
|
||||
{ auth: { local: { username: 'user' } }, _id: '111' },
|
||||
{ auth: { local: { username: 'user2' } }, _id: '222' },
|
||||
{ auth: { local: { username: 'user3' } }, _id: '333' },
|
||||
{ auth: { local: { username: 'user-dash' } }, _id: '444' },
|
||||
{ auth: { local: { username: 'user_underscore' } }, _id: '555' },
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
sinon.stub(mongoose.Model, 'find').returns(mockFind);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('doesn\'t change text without mentions', async () => {
|
||||
const text = 'some chat text';
|
||||
const result = await highlightMentions(text);
|
||||
expect(result[0]).to.equal(text);
|
||||
});
|
||||
|
||||
it('highlights existing users', async () => {
|
||||
const text = '@user: message';
|
||||
const result = await highlightMentions(text);
|
||||
expect(result[0]).to.equal('[@user](/profile/111): message');
|
||||
});
|
||||
|
||||
it('highlights special characters', async () => {
|
||||
const text = '@user-dash: message @user_underscore';
|
||||
const result = await highlightMentions(text);
|
||||
expect(result[0]).to.equal('[@user-dash](/profile/444): message [@user_underscore](/profile/555)');
|
||||
});
|
||||
|
||||
it('doesn\'t highlight nonexisting users', async () => {
|
||||
const text = '@nouser message';
|
||||
const result = await highlightMentions(text);
|
||||
expect(result[0]).to.equal('@nouser message');
|
||||
});
|
||||
|
||||
it('highlights multiple existing users', async () => {
|
||||
const text = '@user message (@user2) @user3 @user';
|
||||
const result = await highlightMentions(text);
|
||||
expect(result[0]).to.equal('[@user](/profile/111) message ([@user2](/profile/222)) [@user3](/profile/333) [@user](/profile/111)');
|
||||
});
|
||||
|
||||
it('doesn\'t highlight more than 5 users', async () => {
|
||||
const text = '@user @user2 @user3 @user4 @user5 @user6';
|
||||
const result = await highlightMentions(text);
|
||||
expect(result[0]).to.equal(text);
|
||||
});
|
||||
|
||||
describe('exceptions in code blocks', () => {
|
||||
it('doesn\'t highlight user in inline code block', async () => {
|
||||
const text = '`@user`';
|
||||
|
||||
const result = await highlightMentions(text);
|
||||
|
||||
expect(result[0]).to.equal(text);
|
||||
});
|
||||
|
||||
it('doesn\'t highlight user in fenced code block', async () => {
|
||||
const text = 'Text\n\n```\n// code referencing @user\n```\n\nText';
|
||||
|
||||
const result = await highlightMentions(text);
|
||||
|
||||
expect(result[0]).to.equal(text);
|
||||
});
|
||||
|
||||
it('doesn\'t highlight user in indented code block', async () => {
|
||||
const text = ' @user';
|
||||
|
||||
const result = await highlightMentions(text);
|
||||
|
||||
expect(result[0]).to.equal(text);
|
||||
});
|
||||
|
||||
it('does highlight user that\'s after in-line code block', async () => {
|
||||
const text = '`<code />` for @user';
|
||||
|
||||
const result = await highlightMentions(text);
|
||||
|
||||
expect(result[0]).to.equal('`<code />` for [@user](/profile/111)');
|
||||
});
|
||||
|
||||
it('does highlight same content properly', async () => {
|
||||
const text = '@user `@user`';
|
||||
|
||||
const result = await highlightMentions(text);
|
||||
|
||||
expect(result[0]).to.equal('[@user](/profile/111) `@user`');
|
||||
});
|
||||
});
|
||||
|
||||
it('github issue 12118, method crashes when square brackets are used', async () => {
|
||||
const text = '[test]';
|
||||
|
||||
let err;
|
||||
|
||||
try {
|
||||
await highlightMentions(text);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
|
||||
expect(err).to.be.undefined;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
getLanguageFromBrowser,
|
||||
getLanguageFromUser,
|
||||
} from '../../../../website/server/libs/language';
|
||||
import {
|
||||
generateReq,
|
||||
} from '../../../helpers/api-unit.helper';
|
||||
|
||||
describe('language lib', () => {
|
||||
let req;
|
||||
|
||||
beforeEach(() => {
|
||||
req = generateReq();
|
||||
});
|
||||
|
||||
describe('getLanguageFromUser', () => {
|
||||
it('uses the user preferred language if avalaible', () => {
|
||||
const user = {
|
||||
preferences: {
|
||||
language: 'it',
|
||||
},
|
||||
};
|
||||
|
||||
expect(getLanguageFromUser(user, req)).to.equal('it');
|
||||
});
|
||||
|
||||
it('falls back to english if the user preferred language is not avalaible', () => {
|
||||
const user = {
|
||||
preferences: {
|
||||
language: 'bla',
|
||||
},
|
||||
};
|
||||
|
||||
expect(getLanguageFromUser(user, req)).to.equal('en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLanguageFromBrowser', () => {
|
||||
it('uses browser specificed language', () => {
|
||||
req.headers['accept-language'] = 'pt';
|
||||
|
||||
expect(getLanguageFromBrowser(req)).to.equal('pt');
|
||||
});
|
||||
|
||||
it('uses first language in series if browser specifies multiple', () => {
|
||||
req.headers['accept-language'] = 'he, pt, it';
|
||||
|
||||
expect(getLanguageFromBrowser(req)).to.equal('he');
|
||||
});
|
||||
|
||||
it('skips invalid lanaguages and uses first language in series if browser specifies multiple', () => {
|
||||
req.headers['accept-language'] = 'blah, he, pt, it';
|
||||
|
||||
expect(getLanguageFromBrowser(req)).to.equal('he');
|
||||
});
|
||||
|
||||
it('uses normal version of language if specialized locale is passed in', () => {
|
||||
req.headers['accept-language'] = 'fr-CA';
|
||||
|
||||
expect(getLanguageFromBrowser(req)).to.equal('fr');
|
||||
});
|
||||
|
||||
it('uses normal version of language if specialized locale is passed in', () => {
|
||||
req.headers['accept-language'] = 'fr-CA';
|
||||
|
||||
expect(getLanguageFromBrowser(req)).to.equal('fr');
|
||||
});
|
||||
|
||||
it('uses es if es is passed in', () => {
|
||||
req.headers['accept-language'] = 'es';
|
||||
|
||||
expect(getLanguageFromBrowser(req)).to.equal('es');
|
||||
});
|
||||
|
||||
it('uses es_419 if applicable es-languages are passed in', () => {
|
||||
req.headers['accept-language'] = 'es-mx';
|
||||
|
||||
expect(getLanguageFromBrowser(req)).to.equal('es_419');
|
||||
});
|
||||
|
||||
it('uses es_419 if multiple es languages are passed in', () => {
|
||||
req.headers['accept-language'] = 'es-GT, es-MX, es-CR';
|
||||
|
||||
expect(getLanguageFromBrowser(req)).to.equal('es_419');
|
||||
});
|
||||
|
||||
it('zh', () => {
|
||||
req.headers['accept-language'] = 'zh-TW';
|
||||
|
||||
expect(getLanguageFromBrowser(req)).to.equal('zh_TW');
|
||||
});
|
||||
|
||||
it('uses english if browser specified language is not compatible', () => {
|
||||
req.headers['accept-language'] = 'blah';
|
||||
|
||||
expect(getLanguageFromBrowser(req)).to.equal('en');
|
||||
});
|
||||
|
||||
it('uses english if browser does not specify', () => {
|
||||
req.headers['accept-language'] = '';
|
||||
|
||||
expect(getLanguageFromBrowser(req)).to.equal('en');
|
||||
});
|
||||
|
||||
it('uses english if browser does not supply an accept-language header', () => {
|
||||
delete req.headers['accept-language'];
|
||||
|
||||
expect(getLanguageFromBrowser(req)).to.equal('en');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import faq from '../../../../website/common/script/content/faq';
|
||||
import common from '../../../../website/common';
|
||||
import { localizeContentData } from '../../../../website/server/libs/content';
|
||||
|
||||
const { i18n } = common;
|
||||
|
||||
describe('localizeContentData', () => {
|
||||
it('Should take a an object with localization identifiers and '
|
||||
+ 'return an object with actual translations in English', () => {
|
||||
const faqInEnglish = localizeContentData(faq, 'en');
|
||||
|
||||
expect(faqInEnglish).to.have.property('stillNeedHelp');
|
||||
expect(faqInEnglish.stillNeedHelp.ios).to.equal(i18n.t('iosFaqStillNeedHelp', 'en'));
|
||||
});
|
||||
it('Should take an object with localization identifiers and '
|
||||
+ 'return an object with actual translations in German', () => {
|
||||
const faqInEnglish = localizeContentData(faq, 'de');
|
||||
|
||||
expect(faqInEnglish).to.have.property('stillNeedHelp');
|
||||
expect(faqInEnglish.stillNeedHelp.ios).to.equal(i18n.t('iosFaqStillNeedHelp', 'de'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import moment from 'moment';
|
||||
import { calculateSubscriptionTerminationDate } from '../../../../../../website/server/libs/payments/util';
|
||||
import api from '../../../../../../website/server/libs/payments/payments';
|
||||
|
||||
describe('#calculateSubscriptionTerminationDate', () => {
|
||||
let plan;
|
||||
let nextBill;
|
||||
|
||||
beforeEach(() => {
|
||||
plan = {
|
||||
customerId: 'customer-id',
|
||||
extraMonths: 0,
|
||||
};
|
||||
nextBill = moment();
|
||||
});
|
||||
it('should extend date to the exact amount of days left before the next bill will occur', () => {
|
||||
nextBill = moment()
|
||||
.add(5, 'days');
|
||||
const expectedTerminationDate = moment()
|
||||
.add(5, 'days');
|
||||
|
||||
const terminationDate = calculateSubscriptionTerminationDate(nextBill, plan, api.constants);
|
||||
expect(expectedTerminationDate.diff(terminationDate, 'days')).to.eql(0);
|
||||
});
|
||||
it('if nextBill is null, add 30 days to termination date', () => {
|
||||
nextBill = null;
|
||||
const expectedTerminationDate = moment()
|
||||
.add(30, 'days');
|
||||
const terminationDate = calculateSubscriptionTerminationDate(nextBill, plan, api.constants);
|
||||
|
||||
expect(expectedTerminationDate.diff(terminationDate, 'days')).to.eql(0);
|
||||
});
|
||||
it('if nextBill is null and it\'s a group plan, add 2 days instead of 30', () => {
|
||||
nextBill = null;
|
||||
plan.customerId = api.constants.GROUP_PLAN_CUSTOMER_ID;
|
||||
const expectedTerminationDate = moment()
|
||||
.add(2, 'days');
|
||||
|
||||
const terminationDate = calculateSubscriptionTerminationDate(nextBill, plan, api.constants);
|
||||
expect(expectedTerminationDate.diff(terminationDate, 'days')).to.eql(0);
|
||||
});
|
||||
it('should add 30.5 days for each extraMonth', () => {
|
||||
plan.extraMonths = 4;
|
||||
const expectedTerminationDate = moment()
|
||||
.add(30.5 * 4, 'days');
|
||||
|
||||
const terminationDate = calculateSubscriptionTerminationDate(nextBill, plan, api.constants);
|
||||
expect(expectedTerminationDate.diff(terminationDate, 'days')).to.eql(0);
|
||||
});
|
||||
it('should round up if total days gained by extraMonth is a decimal number', () => {
|
||||
plan.extraMonths = 5;
|
||||
const expectedTerminationDate = moment()
|
||||
.add(Math.ceil(30.5 * 5), 'days');
|
||||
|
||||
const terminationDate = calculateSubscriptionTerminationDate(nextBill, plan, api.constants);
|
||||
expect(expectedTerminationDate.diff(terminationDate, 'days')).to.eql(0);
|
||||
});
|
||||
it('behaves like extraMonths is 0 if it\'s set to a negative number', () => {
|
||||
plan.extraMonths = -5;
|
||||
const expectedTerminationDate = moment();
|
||||
const terminationDate = calculateSubscriptionTerminationDate(nextBill, plan, api.constants);
|
||||
expect(expectedTerminationDate.diff(terminationDate, 'days')).to.eql(0);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
import requireAgain from 'require-again';
|
||||
import apn from 'apn/mock';
|
||||
import _ from 'lodash';
|
||||
import nconf from 'nconf';
|
||||
import gcmLib from 'node-gcm'; // works with FCM notifications too
|
||||
import { model as User } from '../../../../website/server/models/user';
|
||||
import {
|
||||
sendNotification as sendPushNotification,
|
||||
MAX_MESSAGE_LENGTH,
|
||||
} from '../../../../website/server/libs/pushNotifications';
|
||||
|
||||
describe('pushNotifications', () => {
|
||||
let user;
|
||||
let sendPushNotification;
|
||||
const pathToPushNotifications = '../../../../website/server/libs/pushNotifications';
|
||||
let fcmSendSpy;
|
||||
let apnSendSpy;
|
||||
|
||||
@@ -28,8 +30,6 @@ describe('pushNotifications', () => {
|
||||
on: () => null,
|
||||
send: apnSendSpy,
|
||||
});
|
||||
|
||||
sendPushNotification = requireAgain(pathToPushNotifications).sendNotification;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -86,6 +86,67 @@ describe('pushNotifications', () => {
|
||||
expect(apnSendSpy).to.not.have.been.called;
|
||||
});
|
||||
|
||||
it('cuts the message to 300 chars', () => {
|
||||
const longMessage = `12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345`;
|
||||
|
||||
expect(longMessage.length > MAX_MESSAGE_LENGTH).to.equal(true);
|
||||
|
||||
const details = {
|
||||
identifier,
|
||||
title,
|
||||
message: longMessage,
|
||||
payload: {
|
||||
message: longMessage,
|
||||
},
|
||||
};
|
||||
|
||||
sendPushNotification(user, details);
|
||||
|
||||
expect(details.message).to.equal(_.truncate(longMessage, { length: MAX_MESSAGE_LENGTH }));
|
||||
expect(details.payload.message)
|
||||
.to.equal(_.truncate(longMessage, { length: MAX_MESSAGE_LENGTH }));
|
||||
|
||||
expect(details.message.length).to.equal(MAX_MESSAGE_LENGTH);
|
||||
expect(details.payload.message.length).to.equal(MAX_MESSAGE_LENGTH);
|
||||
});
|
||||
|
||||
it('cuts the message to 300 chars (no payload)', () => {
|
||||
const longMessage = `12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345`;
|
||||
|
||||
expect(longMessage.length > MAX_MESSAGE_LENGTH).to.equal(true);
|
||||
|
||||
const details = {
|
||||
identifier,
|
||||
title,
|
||||
message: longMessage,
|
||||
};
|
||||
|
||||
sendPushNotification(user, details);
|
||||
|
||||
expect(details.message).to.equal(_.truncate(longMessage, { length: MAX_MESSAGE_LENGTH }));
|
||||
expect(details.message.length).to.equal(MAX_MESSAGE_LENGTH);
|
||||
});
|
||||
|
||||
// TODO disabled because APN relies on a Promise
|
||||
xit('uses APN for iOS devices', () => {
|
||||
user.pushDevices.push({
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
defer,
|
||||
sleep,
|
||||
} from '../../../helpers/api-unit.helper';
|
||||
|
||||
import logger from '../../../../website/server/libs/logger';
|
||||
|
||||
describe('webhooks', () => {
|
||||
let webhooks; let
|
||||
@@ -101,8 +101,7 @@ describe('webhooks', () => {
|
||||
expect(WebhookSender.defaultTransformData).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
||||
json: true,
|
||||
body,
|
||||
json: body,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,7 +121,7 @@ describe('webhooks', () => {
|
||||
expect(sendWebhook.attachDefaultData).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
||||
json: true,
|
||||
json: body,
|
||||
});
|
||||
|
||||
expect(body).to.eql({
|
||||
@@ -153,8 +152,7 @@ describe('webhooks', () => {
|
||||
expect(WebhookSender.defaultTransformData).to.not.be.called;
|
||||
expect(got.post).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
||||
json: true,
|
||||
body: {
|
||||
json: {
|
||||
foo: 'bar',
|
||||
baz: 'biz',
|
||||
},
|
||||
@@ -271,8 +269,7 @@ describe('webhooks', () => {
|
||||
|
||||
expect(got.post).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
||||
body,
|
||||
json: true,
|
||||
json: body,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,8 +289,7 @@ describe('webhooks', () => {
|
||||
|
||||
expect(got.post).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
||||
body,
|
||||
json: true,
|
||||
json: body,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,12 +312,10 @@ describe('webhooks', () => {
|
||||
|
||||
expect(got.post).to.be.calledTwice;
|
||||
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
||||
body,
|
||||
json: true,
|
||||
json: body,
|
||||
});
|
||||
expect(got.post).to.be.calledWithMatch('http://other-url.com', {
|
||||
body,
|
||||
json: true,
|
||||
json: body,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -351,8 +345,7 @@ describe('webhooks', () => {
|
||||
|
||||
expect(got.post).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
||||
json: true,
|
||||
body,
|
||||
json: body,
|
||||
});
|
||||
|
||||
await sleep(0.1);
|
||||
@@ -363,13 +356,13 @@ describe('webhooks', () => {
|
||||
});
|
||||
|
||||
it('records failures', async () => {
|
||||
sinon.stub(logger, 'error');
|
||||
const body = {};
|
||||
sendWebhook.send(user, body);
|
||||
|
||||
expect(got.post).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
|
||||
json: true,
|
||||
body,
|
||||
json: body,
|
||||
});
|
||||
|
||||
await sleep(0.1);
|
||||
@@ -377,6 +370,9 @@ describe('webhooks', () => {
|
||||
|
||||
expect(user.webhooks[0].failures).to.equal(1);
|
||||
expect((Date.now() - user.webhooks[0].lastFailureAt.getTime()) < 10000).to.be.true;
|
||||
|
||||
expect(logger.error).to.be.calledOnce;
|
||||
logger.error.restore();
|
||||
});
|
||||
|
||||
it('disables a webhook after 10 failures', async () => {
|
||||
@@ -459,8 +455,7 @@ describe('webhooks', () => {
|
||||
|
||||
expect(got.post).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledWithMatch(webhooks[0].url, {
|
||||
json: true,
|
||||
body: {
|
||||
json: {
|
||||
type: 'scored',
|
||||
webhookType: 'taskActivity',
|
||||
user: {
|
||||
@@ -497,8 +492,7 @@ describe('webhooks', () => {
|
||||
|
||||
expect(got.post).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledWithMatch('http://global-activity.com', {
|
||||
json: true,
|
||||
body: {
|
||||
json: {
|
||||
type: 'scored',
|
||||
webhookType: 'taskActivity',
|
||||
user: {
|
||||
@@ -551,8 +545,7 @@ describe('webhooks', () => {
|
||||
|
||||
expect(got.post).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledWithMatch(webhooks[0].url, {
|
||||
json: true,
|
||||
body: {
|
||||
json: {
|
||||
type,
|
||||
webhookType: 'taskActivity',
|
||||
user: {
|
||||
@@ -592,8 +585,7 @@ describe('webhooks', () => {
|
||||
|
||||
expect(got.post).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledWithMatch(webhooks[0].url, {
|
||||
json: true,
|
||||
body: {
|
||||
json: {
|
||||
webhookType: 'taskActivity',
|
||||
user: {
|
||||
_id: user._id,
|
||||
@@ -633,8 +625,7 @@ describe('webhooks', () => {
|
||||
|
||||
expect(got.post).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledWithMatch(webhooks[2].url, {
|
||||
json: true,
|
||||
body: {
|
||||
json: {
|
||||
type,
|
||||
webhookType: 'userActivity',
|
||||
user: {
|
||||
@@ -680,8 +671,7 @@ describe('webhooks', () => {
|
||||
|
||||
expect(got.post).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledWithMatch(webhooks[1].url, {
|
||||
json: true,
|
||||
body: {
|
||||
json: {
|
||||
type,
|
||||
webhookType: 'questActivity',
|
||||
user: {
|
||||
@@ -727,8 +717,7 @@ describe('webhooks', () => {
|
||||
|
||||
expect(got.post).to.be.calledOnce;
|
||||
expect(got.post).to.be.calledWithMatch(webhooks[webhooks.length - 1].url, {
|
||||
json: true,
|
||||
body: {
|
||||
json: {
|
||||
webhookType: 'groupChatReceived',
|
||||
user: {
|
||||
_id: user._id,
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('analytics middleware', () => {
|
||||
next = generateNext();
|
||||
});
|
||||
|
||||
it('attaches analytics object res.locals', () => {
|
||||
it('attaches analytics object to res', () => {
|
||||
const attachAnalytics = requireAgain(pathToAnalyticsMiddleware).default;
|
||||
|
||||
attachAnalytics(req, res, next);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
generateRes,
|
||||
generateReq,
|
||||
generateNext,
|
||||
} from '../../../helpers/api-unit.helper';
|
||||
import {
|
||||
disableCache,
|
||||
} from '../../../../website/server/middlewares/cache';
|
||||
|
||||
describe('cache middlewares', () => {
|
||||
let res; let req; let
|
||||
next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = generateReq();
|
||||
res = generateRes();
|
||||
next = generateNext();
|
||||
});
|
||||
|
||||
describe('disableCache', () => {
|
||||
it('sets the correct headers', () => {
|
||||
disableCache(req, res, next);
|
||||
expect(res.set).to.have.been.calledWith('Cache-Control', 'no-store');
|
||||
expect(next).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
xit('removes the etag header', () => {
|
||||
// @TODO how to stub onHeaders
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,28 +21,11 @@ describe('cron middleware', () => {
|
||||
req;
|
||||
let user;
|
||||
|
||||
beforeEach(done => {
|
||||
beforeEach(async () => {
|
||||
res = generateRes();
|
||||
req = generateReq();
|
||||
user = new User({
|
||||
auth: {
|
||||
local: {
|
||||
username: 'username',
|
||||
lowerCaseUsername: 'username',
|
||||
email: 'email@email.email',
|
||||
salt: 'salt',
|
||||
hashed_password: 'hashed_password', // eslint-disable-line camelcase
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
user.save()
|
||||
.then(savedUser => {
|
||||
res.locals.user = savedUser;
|
||||
res.analytics = analyticsService;
|
||||
done();
|
||||
})
|
||||
.catch(done);
|
||||
user = await res.locals.user.save();
|
||||
res.analytics = analyticsService;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -170,6 +170,7 @@ describe('errorHandler', () => {
|
||||
originalUrl: req.originalUrl,
|
||||
headers: req.headers,
|
||||
body: req.body,
|
||||
query: req.query,
|
||||
httpCode: 400,
|
||||
isHandledError: true,
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@ import { model as User } from '../../../../website/server/models/user';
|
||||
|
||||
const { i18n } = common;
|
||||
|
||||
// TODO some of the checks here can be simplified to simply check
|
||||
// that the right parameters are passed to the functions in libs/language
|
||||
|
||||
describe('language middleware', () => {
|
||||
describe('res.t', () => {
|
||||
let res; let req; let
|
||||
@@ -19,6 +22,8 @@ describe('language middleware', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
res = generateRes();
|
||||
// remove the defaul user
|
||||
res.locals.user = undefined;
|
||||
req = generateReq();
|
||||
next = generateNext();
|
||||
|
||||
@@ -57,6 +62,8 @@ describe('language middleware', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
res = generateRes();
|
||||
// remove the defaul user
|
||||
res.locals.user = undefined;
|
||||
req = generateReq();
|
||||
next = generateNext();
|
||||
attachTranslateFunction(req, res, next);
|
||||
@@ -88,7 +95,7 @@ describe('language middleware', () => {
|
||||
lang: 'es',
|
||||
};
|
||||
|
||||
req.locals = {
|
||||
res.locals = {
|
||||
user: {
|
||||
preferences: {
|
||||
language: 'it',
|
||||
@@ -108,7 +115,7 @@ describe('language middleware', () => {
|
||||
|
||||
context('authorized request', () => {
|
||||
it('uses the user preferred language if avalaible', () => {
|
||||
req.locals = {
|
||||
res.locals = {
|
||||
user: {
|
||||
preferences: {
|
||||
language: 'it',
|
||||
@@ -122,7 +129,7 @@ describe('language middleware', () => {
|
||||
});
|
||||
|
||||
it('falls back to english if the user preferred language is not avalaible', done => {
|
||||
req.locals = {
|
||||
res.locals = {
|
||||
user: {
|
||||
preferences: {
|
||||
language: 'bla',
|
||||
@@ -138,7 +145,7 @@ describe('language middleware', () => {
|
||||
});
|
||||
|
||||
it('uses the user preferred language even if a session is included in request', () => {
|
||||
req.locals = {
|
||||
res.locals = {
|
||||
user: {
|
||||
preferences: {
|
||||
language: 'it',
|
||||
|
||||
@@ -538,52 +538,74 @@ describe('Group Model', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('sends a chat message if no progress is made on quest with multiple items', async () => {
|
||||
progress.collectedItems = 0;
|
||||
party.quest.key = 'dilatoryDistress1';
|
||||
party.quest.active = false;
|
||||
describe('collection quests with multiple items', () => {
|
||||
it('sends a chat message if no progress is made on quest with multiple items', async () => {
|
||||
progress.collectedItems = 0;
|
||||
party.quest.key = 'dilatoryDistress1';
|
||||
party.quest.active = false;
|
||||
|
||||
await party.startQuest(questLeader);
|
||||
Group.prototype.sendChat.resetHistory();
|
||||
await party.save();
|
||||
await party.startQuest(questLeader);
|
||||
Group.prototype.sendChat.resetHistory();
|
||||
await party.save();
|
||||
|
||||
await Group.processQuestProgress(participatingMember, progress);
|
||||
await Group.processQuestProgress(participatingMember, progress);
|
||||
|
||||
party = await Group.findOne({ _id: party._id });
|
||||
party = await Group.findOne({ _id: party._id });
|
||||
|
||||
expect(Group.prototype.sendChat).to.be.calledOnce;
|
||||
expect(Group.prototype.sendChat).to.be.calledWith({
|
||||
message: '`Participating Member found 0 Fire Coral, 0 Blue Fins.`',
|
||||
info: {
|
||||
items: { blueFins: 0, fireCoral: 0 },
|
||||
quest: 'dilatoryDistress1',
|
||||
type: 'user_found_items',
|
||||
user: 'Participating Member',
|
||||
},
|
||||
expect(Group.prototype.sendChat).to.be.calledOnce;
|
||||
expect(Group.prototype.sendChat).to.be.calledWith({
|
||||
message: '`Participating Member found 0 Fire Coral, 0 Blue Fins.`',
|
||||
info: {
|
||||
items: { blueFins: 0, fireCoral: 0 },
|
||||
quest: 'dilatoryDistress1',
|
||||
type: 'user_found_items',
|
||||
user: 'Participating Member',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('handles collection quests with multiple items', async () => {
|
||||
progress.collectedItems = 10;
|
||||
party.quest.key = 'evilsanta2';
|
||||
party.quest.active = false;
|
||||
it('handles correctly', async () => {
|
||||
progress.collectedItems = 10;
|
||||
party.quest.key = 'evilsanta2';
|
||||
party.quest.active = false;
|
||||
|
||||
await party.startQuest(questLeader);
|
||||
Group.prototype.sendChat.resetHistory();
|
||||
await party.save();
|
||||
await party.startQuest(questLeader);
|
||||
Group.prototype.sendChat.resetHistory();
|
||||
await party.save();
|
||||
|
||||
await Group.processQuestProgress(participatingMember, progress);
|
||||
await Group.processQuestProgress(participatingMember, progress);
|
||||
|
||||
party = await Group.findOne({ _id: party._id });
|
||||
party = await Group.findOne({ _id: party._id });
|
||||
|
||||
expect(Group.prototype.sendChat).to.be.calledOnce;
|
||||
expect(Group.prototype.sendChat).to.be.calledWithMatch({
|
||||
message: sinon.match(/`Participating Member found/).and(sinon.match(/\d* (Tracks|Broken Twigs)/)),
|
||||
info: {
|
||||
quest: 'evilsanta2',
|
||||
type: 'user_found_items',
|
||||
user: 'Participating Member',
|
||||
},
|
||||
expect(Group.prototype.sendChat).to.be.calledOnce;
|
||||
expect(Group.prototype.sendChat).to.be.calledWithMatch({
|
||||
message: sinon.match(/`Participating Member found/).and(sinon.match(/\d* (Tracks|Broken Twigs)/)),
|
||||
info: {
|
||||
quest: 'evilsanta2',
|
||||
type: 'user_found_items',
|
||||
user: 'Participating Member',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('cannot collect excess items', async () => {
|
||||
// Make sure the quest progress isn't erased
|
||||
sandbox.stub(Group.prototype, 'finishQuest').returns(Promise.resolve());
|
||||
|
||||
progress.collectedItems = 500;
|
||||
party.quest.key = 'evilsanta2';
|
||||
party.quest.active = false;
|
||||
|
||||
await party.startQuest(questLeader);
|
||||
await party.save();
|
||||
|
||||
await Group.processQuestProgress(participatingMember, progress);
|
||||
party = await Group.findOne({ _id: party._id });
|
||||
|
||||
expect(party.quest.progress.collect.tracks)
|
||||
.to.eql(questScrolls.evilsanta2.collect.tracks.count);
|
||||
expect(party.quest.progress.collect.branches)
|
||||
.to.eql(questScrolls.evilsanta2.collect.branches.count);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2380,29 +2402,29 @@ describe('Group Model', () => {
|
||||
});
|
||||
});
|
||||
|
||||
context('isSubscribed', () => {
|
||||
context('hasActiveGroupPlan', () => {
|
||||
it('returns false if group does not have customer id', () => {
|
||||
expect(party.isSubscribed()).to.be.undefined;
|
||||
expect(party.hasActiveGroupPlan()).to.be.undefined;
|
||||
});
|
||||
|
||||
it('returns true if group does not have plan.dateTerminated', () => {
|
||||
party.purchased.plan.customerId = 'test-id';
|
||||
|
||||
expect(party.isSubscribed()).to.be.true;
|
||||
expect(party.hasActiveGroupPlan()).to.be.true;
|
||||
});
|
||||
|
||||
it('returns true if group if plan.dateTerminated is after today', () => {
|
||||
party.purchased.plan.customerId = 'test-id';
|
||||
party.purchased.plan.dateTerminated = moment().add(1, 'days').toDate();
|
||||
|
||||
expect(party.isSubscribed()).to.be.true;
|
||||
expect(party.hasActiveGroupPlan()).to.be.true;
|
||||
});
|
||||
|
||||
it('returns false if group if plan.dateTerminated is before today', () => {
|
||||
party.purchased.plan.customerId = 'test-id';
|
||||
party.purchased.plan.dateTerminated = moment().subtract(1, 'days').toDate();
|
||||
|
||||
expect(party.isSubscribed()).to.be.false;
|
||||
expect(party.hasActiveGroupPlan()).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { v4 as generateUUID } from 'uuid';
|
||||
import {
|
||||
each,
|
||||
} from 'lodash';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
generateChallenge,
|
||||
checkExistence,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from '../../../../helpers/api-integration/v3';
|
||||
import { model as User } from '../../../../../website/server/models/user';
|
||||
import payments from '../../../../../website/server/libs/payments/payments';
|
||||
import { calculateSubscriptionTerminationDate } from '../../../../../website/server/libs/payments/util';
|
||||
|
||||
describe('POST /groups/:groupId/leave', () => {
|
||||
const typesOfGroups = {
|
||||
@@ -338,4 +340,48 @@ describe('POST /groups/:groupId/leave', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
each(typesOfGroups, (groupDetails, groupType) => {
|
||||
context(`Leaving a group with extraMonths left plan when the group is a ${groupType}`, () => {
|
||||
const extraMonths = 12;
|
||||
let groupWithPlan;
|
||||
let member;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { group, members } = await createAndPopulateGroup({
|
||||
groupDetails,
|
||||
members: 1,
|
||||
upgradeToGroupPlan: true,
|
||||
});
|
||||
[member] = members;
|
||||
groupWithPlan = group;
|
||||
await member.update({
|
||||
'purchased.plan.extraMonths': extraMonths,
|
||||
});
|
||||
});
|
||||
it('calculates dateTerminated and sets extraMonths to zero after user leaves the group', async () => {
|
||||
const userBeforeLeave = await User.findById(member._id).exec();
|
||||
|
||||
await member.post(`/groups/${groupWithPlan._id}/leave`);
|
||||
const userAfterLeave = await User.findById(member._id).exec();
|
||||
|
||||
const dateTerminatedBefore = userBeforeLeave.purchased.plan.dateTerminated;
|
||||
const extraMonthsBefore = userBeforeLeave.purchased.plan.extraMonths;
|
||||
const dateTerminatedAfter = userAfterLeave.purchased.plan.dateTerminated;
|
||||
const extraMonthsAfter = userAfterLeave.purchased.plan.extraMonths;
|
||||
|
||||
const expectedTerminationDate = calculateSubscriptionTerminationDate(null, {
|
||||
customerId: payments.constants.GROUP_PLAN_CUSTOMER_ID,
|
||||
extraMonths,
|
||||
}, payments.constants);
|
||||
|
||||
expect(extraMonthsBefore).to.gte(12);
|
||||
expect(extraMonthsAfter).to.equal(0);
|
||||
expect(dateTerminatedBefore).to.be.null;
|
||||
expect(dateTerminatedAfter).to.exist;
|
||||
|
||||
expect(moment(dateTerminatedAfter).diff(expectedTerminationDate, 'days')).to.equal(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe('POST /groups/:groupId/quests/accept', () => {
|
||||
|
||||
await Promise.all([partyMembers[0].sync(), questingGroup.sync()]);
|
||||
expect(leader.party.quest.RSVPNeeded).to.equal(false);
|
||||
expect(questingGroup.quest.members[partyMembers[0]._id]);
|
||||
expect(questingGroup.quest.members[partyMembers[0]._id]).to.equal(true);
|
||||
});
|
||||
|
||||
it('does not begin the quest if pending invitations remain', async () => {
|
||||
|
||||
@@ -346,4 +346,23 @@ describe('DELETE /user', () => {
|
||||
await expect(checkExistence('users', user._id)).to.eventually.eql(false);
|
||||
});
|
||||
});
|
||||
|
||||
context('user with Apple auth', async () => {
|
||||
beforeEach(async () => {
|
||||
user = await generateUser({
|
||||
auth: {
|
||||
apple: {
|
||||
id: 'apple-id',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a Apple user', async () => {
|
||||
await user.del('/user', {
|
||||
password: DELETE_CONFIRMATION,
|
||||
});
|
||||
await expect(checkExistence('users', user._id)).to.eventually.eql(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,4 +95,42 @@ describe('DELETE social registration', () => {
|
||||
expect(user.auth.goodl).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
context('Apple', () => {
|
||||
it('fails if user does not have an alternative registration method', async () => {
|
||||
await user.update({
|
||||
'auth.apple.id': 'some-apple-id',
|
||||
'auth.local': { ok: true },
|
||||
});
|
||||
await expect(user.del('/user/auth/social/apple')).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('cantDetachSocial'),
|
||||
});
|
||||
});
|
||||
|
||||
it('succeeds if user has a local registration', async () => {
|
||||
await user.update({
|
||||
'auth.apple.id': 'some-apple-id',
|
||||
});
|
||||
|
||||
const response = await user.del('/user/auth/social/apple');
|
||||
expect(response).to.eql({});
|
||||
await user.sync();
|
||||
expect(user.auth.apple).to.be.undefined;
|
||||
});
|
||||
|
||||
it('succeeds if user has a facebook registration', async () => {
|
||||
await user.update({
|
||||
'auth.apple.id': 'some-apple-id',
|
||||
'auth.facebook.id': 'some-facebook-id',
|
||||
'auth.local': { ok: true },
|
||||
});
|
||||
|
||||
const response = await user.del('/user/auth/social/apple');
|
||||
expect(response).to.eql({});
|
||||
await user.sync();
|
||||
expect(user.auth.goodl).to.be.undefined;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
generateUser,
|
||||
requester,
|
||||
getProperty,
|
||||
} from '../../../../../helpers/api-integration/v3';
|
||||
import * as appleAuth from '../../../../../../website/server/libs/auth/apple';
|
||||
|
||||
describe('GET /user/auth/apple', () => {
|
||||
let api;
|
||||
let user;
|
||||
const appleEndpoint = '/user/auth/apple';
|
||||
|
||||
before(async () => {
|
||||
const expectedResult = { id: 'appleId', name: 'an apple user' };
|
||||
sandbox.stub(appleAuth, 'appleProfile').returns(Promise.resolve(expectedResult));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
api = requester();
|
||||
user = await generateUser();
|
||||
});
|
||||
|
||||
it('registers a new user', async () => {
|
||||
const response = await api.get(appleEndpoint);
|
||||
|
||||
expect(response.apiToken).to.exist;
|
||||
expect(response.id).to.exist;
|
||||
expect(response.newUser).to.be.true;
|
||||
await expect(getProperty('users', response.id, 'auth.apple.id')).to.eventually.equal('appleId');
|
||||
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('an apple user');
|
||||
});
|
||||
|
||||
it('logs an existing user in', async () => {
|
||||
const registerResponse = await api.get(appleEndpoint);
|
||||
|
||||
const response = await api.get(appleEndpoint);
|
||||
|
||||
expect(response.apiToken).to.eql(registerResponse.apiToken);
|
||||
expect(response.id).to.eql(registerResponse.id);
|
||||
expect(response.newUser).to.be.false;
|
||||
});
|
||||
|
||||
it('add social auth to an existing user', async () => {
|
||||
const response = await user.get(appleEndpoint);
|
||||
|
||||
expect(response.apiToken).to.exist;
|
||||
expect(response.id).to.exist;
|
||||
expect(response.newUser).to.be.false;
|
||||
});
|
||||
});
|
||||
@@ -492,6 +492,74 @@ describe('POST /user/auth/local/register', () => {
|
||||
});
|
||||
});
|
||||
|
||||
context('attach to google user', () => {
|
||||
let user;
|
||||
const email = 'some@email-google.net';
|
||||
const username = 'some-username-google';
|
||||
const password = 'some-password';
|
||||
beforeEach(async () => {
|
||||
user = await generateUser();
|
||||
});
|
||||
it('checks onlySocialAttachLocal', async () => {
|
||||
await expect(user.post('/user/auth/local/register', {
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
confirmPassword: password,
|
||||
})).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('onlySocialAttachLocal'),
|
||||
});
|
||||
});
|
||||
it('succeeds', async () => {
|
||||
await user.update({ 'auth.google.id': 'some-google-id', 'auth.local': { ok: true } });
|
||||
await user.post('/user/auth/local/register', {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
confirmPassword: password,
|
||||
});
|
||||
await user.sync();
|
||||
expect(user.auth.local.username).to.eql(username);
|
||||
expect(user.auth.local.email).to.eql(email);
|
||||
});
|
||||
});
|
||||
|
||||
context('attach to apple user', () => {
|
||||
let user;
|
||||
const email = 'some@email-apple.net';
|
||||
const username = 'some-username-apple';
|
||||
const password = 'some-password';
|
||||
beforeEach(async () => {
|
||||
user = await generateUser();
|
||||
});
|
||||
it('checks onlySocialAttachLocal', async () => {
|
||||
await expect(user.post('/user/auth/local/register', {
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
confirmPassword: password,
|
||||
})).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('onlySocialAttachLocal'),
|
||||
});
|
||||
});
|
||||
it('succeeds', async () => {
|
||||
await user.update({ 'auth.apple.id': 'some-apple-id', 'auth.local': { ok: true } });
|
||||
await user.post('/user/auth/local/register', {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
confirmPassword: password,
|
||||
});
|
||||
await user.sync();
|
||||
expect(user.auth.local.username).to.eql(username);
|
||||
expect(user.auth.local.email).to.eql(email);
|
||||
});
|
||||
});
|
||||
|
||||
context('login is already taken', () => {
|
||||
let username; let email; let
|
||||
api;
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('POST /user/auth/social', () => {
|
||||
|
||||
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a facebook user');
|
||||
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.exist;
|
||||
await expect(getProperty('users', response.id, 'auth.facebook.id')).to.eventually.equal(facebookId);
|
||||
});
|
||||
|
||||
it('logs an existing user in', async () => {
|
||||
@@ -106,6 +107,7 @@ describe('POST /user/auth/social', () => {
|
||||
expect(response.apiToken).to.exist;
|
||||
expect(response.id).to.exist;
|
||||
expect(response.newUser).to.be.true;
|
||||
await expect(getProperty('users', response.id, 'auth.google.id')).to.eventually.equal(googleId);
|
||||
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a google user');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
requester,
|
||||
translate,
|
||||
} from '../../../helpers/api-integration/v4';
|
||||
import i18n from '../../../../website/common/script/i18n';
|
||||
|
||||
describe('GET /faq', () => {
|
||||
describe('language parameter', () => {
|
||||
it('returns faq (and does not require authentication)', async () => {
|
||||
const res = await requester().get('/faq');
|
||||
|
||||
expect(res).to.have.property('stillNeedHelp');
|
||||
expect(res.stillNeedHelp.ios).to.equal(translate('iosFaqStillNeedHelp'));
|
||||
expect(res).to.have.property('questions');
|
||||
expect(res.questions[0].question).to.equal(translate('faqQuestion0'));
|
||||
});
|
||||
|
||||
it('returns faq not in English', async () => {
|
||||
const res = await requester().get('/faq?language=de');
|
||||
expect(res).to.have.nested.property('stillNeedHelp.ios');
|
||||
expect(res.stillNeedHelp.ios).to.equal(i18n.t('iosFaqStillNeedHelp', 'de'));
|
||||
});
|
||||
|
||||
it('falls back to English if the desired language is not found', async () => {
|
||||
const res = await requester().get('/faq?language=wrong');
|
||||
expect(res).to.have.nested.property('stillNeedHelp.ios');
|
||||
expect(res.stillNeedHelp.ios).to.equal(translate('iosFaqStillNeedHelp'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('platform parameter', () => {
|
||||
it('returns faq with answers for ios platform only', async () => {
|
||||
const res = await requester().get('/faq?platform=ios');
|
||||
|
||||
expect(res).to.have.property('stillNeedHelp');
|
||||
expect(res.stillNeedHelp).to.eql({ ios: translate('iosFaqStillNeedHelp') });
|
||||
|
||||
expect(res).to.have.property('questions');
|
||||
expect(res.questions[0]).to.eql({
|
||||
question: translate('faqQuestion0'),
|
||||
ios: translate('iosFaqAnswer0'),
|
||||
});
|
||||
});
|
||||
it('returns an error when invalid platform parameter is specified', async () => {
|
||||
const request = requester().get('/faq?platform=wrong');
|
||||
await expect(request)
|
||||
.to.eventually.be.rejected.and.eql({
|
||||
code: 400,
|
||||
error: 'BadRequest',
|
||||
message: i18n.t('invalidReqParams'),
|
||||
});
|
||||
});
|
||||
it('falls back to "web" description if there is no description for specified platform', async () => {
|
||||
const res = await requester().get('/faq?platform=android');
|
||||
expect(res).to.have.property('stillNeedHelp');
|
||||
expect(res.stillNeedHelp).to.eql({ web: translate('webFaqStillNeedHelp') });
|
||||
|
||||
expect(res).to.have.property('questions');
|
||||
expect(res.questions[0]).to.eql({
|
||||
question: translate('faqQuestion0'),
|
||||
android: translate('androidFaqAnswer0'),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -457,6 +457,74 @@ describe('POST /user/auth/local/register', () => {
|
||||
});
|
||||
});
|
||||
|
||||
context('attach to google user', () => {
|
||||
let user;
|
||||
const email = 'some-google@email.net';
|
||||
const username = 'some-username-google';
|
||||
const password = 'some-password';
|
||||
beforeEach(async () => {
|
||||
user = await generateUser();
|
||||
});
|
||||
it('checks onlySocialAttachLocal', async () => {
|
||||
await expect(user.post('/user/auth/local/register', {
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
confirmPassword: password,
|
||||
})).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('onlySocialAttachLocal'),
|
||||
});
|
||||
});
|
||||
it('succeeds', async () => {
|
||||
await user.update({ 'auth.google.id': 'some-google-id', 'auth.local': { ok: true } });
|
||||
await user.post('/user/auth/local/register', {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
confirmPassword: password,
|
||||
});
|
||||
await user.sync();
|
||||
expect(user.auth.local.username).to.eql(username);
|
||||
expect(user.auth.local.email).to.eql(email);
|
||||
});
|
||||
});
|
||||
|
||||
context('attach to apple user', () => {
|
||||
let user;
|
||||
const email = 'some-apple@email.net';
|
||||
const username = 'some-username-apple';
|
||||
const password = 'some-password';
|
||||
beforeEach(async () => {
|
||||
user = await generateUser();
|
||||
});
|
||||
it('checks onlySocialAttachLocal', async () => {
|
||||
await expect(user.post('/user/auth/local/register', {
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
confirmPassword: password,
|
||||
})).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('onlySocialAttachLocal'),
|
||||
});
|
||||
});
|
||||
it('succeeds', async () => {
|
||||
await user.update({ 'auth.apple.id': 'some-apple-id', 'auth.local': { ok: true } });
|
||||
await user.post('/user/auth/local/register', {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
confirmPassword: password,
|
||||
});
|
||||
await user.sync();
|
||||
expect(user.auth.local.username).to.eql(username);
|
||||
expect(user.auth.local.email).to.eql(email);
|
||||
});
|
||||
});
|
||||
|
||||
context('login is already taken', () => {
|
||||
let username; let email; let
|
||||
api;
|
||||
|
||||
@@ -89,6 +89,18 @@ describe('shared.ops.feed', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not allow feeding of wacky pets', done => {
|
||||
user.items.pets['Wolf-Veggie'] = 5;
|
||||
user.items.food.Meat = 1;
|
||||
try {
|
||||
feed(user, { params: { pet: 'Wolf-Veggie', food: 'Meat' } });
|
||||
} catch (err) {
|
||||
expect(err).to.be.an.instanceof(NotAuthorized);
|
||||
expect(err.message).to.equal(i18n.t('messageCannotFeedPet'));
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not allow feeding of mounts', done => {
|
||||
user.items.pets['Wolf-Base'] = -1;
|
||||
user.items.mounts['Wolf-Base'] = true;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { v4 as generateUUID } from 'uuid';
|
||||
import { ApiUser, ApiGroup, ApiChallenge } from '../api-classes';
|
||||
import { requester } from '../requester';
|
||||
import * as Tasks from '../../../../website/server/models/task';
|
||||
import payments from '../../../../website/server/libs/payments/payments';
|
||||
import { model as User } from '../../../../website/server/models/user';
|
||||
|
||||
// Creates a new user and returns it
|
||||
// If you need the user to have specific requirements,
|
||||
@@ -83,14 +85,35 @@ export async function generateGroup (leader, details = {}, update = {}) {
|
||||
return apiGroup;
|
||||
}
|
||||
|
||||
async function _upgradeToGroupPlan (groupLeader, group) {
|
||||
const groupLeaderModel = await User.findById(groupLeader._id).exec();
|
||||
|
||||
// Create subscription
|
||||
const paymentData = {
|
||||
user: groupLeaderModel,
|
||||
groupId: group._id,
|
||||
sub: {
|
||||
key: 'basic_3mo',
|
||||
},
|
||||
customerId: 'customer-id',
|
||||
paymentMethod: 'Payment Method',
|
||||
headers: {
|
||||
'x-client': 'habitica-web',
|
||||
'user-agent': '',
|
||||
},
|
||||
};
|
||||
await payments.createSubscription(paymentData);
|
||||
}
|
||||
|
||||
// This is generate group + the ability to create
|
||||
// real users to populate it. The settings object
|
||||
// takes in:
|
||||
// members: Number - the number of group members to create.
|
||||
// Defaults to 0. Does not include group leader.
|
||||
// inivtes: Number - the number of users to create and invite to the group. Defaults to 0.
|
||||
// invites: Number - the number of users to create and invite to the group. Defaults to 0.
|
||||
// groupDetails: Object - how to initialize the group
|
||||
// leaderDetails: Object - defaults for the leader, defaults with a gem balance so the user
|
||||
// addGroupPlan: boolean - will add group plan with basic subscription. Defaults to false
|
||||
// can create the group
|
||||
//
|
||||
// Returns an object with
|
||||
@@ -101,6 +124,7 @@ export async function generateGroup (leader, details = {}, update = {}) {
|
||||
export async function createAndPopulateGroup (settings = {}) {
|
||||
const numberOfMembers = settings.members || 0;
|
||||
const numberOfInvites = settings.invites || 0;
|
||||
const upgradeToGroupPlan = settings.upgradeToGroupPlan || false;
|
||||
const { groupDetails } = settings;
|
||||
const leaderDetails = settings.leaderDetails || { balance: 10 };
|
||||
|
||||
@@ -130,6 +154,10 @@ export async function createAndPopulateGroup (settings = {}) {
|
||||
|
||||
await Promise.all(invitees.map(invitee => invitee.sync()));
|
||||
|
||||
if (upgradeToGroupPlan) {
|
||||
await _upgradeToGroupPlan(groupLeader, group);
|
||||
}
|
||||
|
||||
return {
|
||||
groupLeader,
|
||||
group,
|
||||
|
||||
@@ -13,25 +13,25 @@
|
||||
"test:unit": "vue-cli-service test:unit --require ./tests/unit/helpers.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vue/cli-plugin-babel": "^4.2.3",
|
||||
"@vue/cli-plugin-eslint": "^4.2.3",
|
||||
"@vue/cli-plugin-router": "^4.2.3",
|
||||
"@vue/cli-plugin-unit-mocha": "^4.2.3",
|
||||
"@vue/cli-service": "^4.2.3",
|
||||
"@storybook/addon-actions": "^5.3.17",
|
||||
"@storybook/addon-knobs": "^5.3.17",
|
||||
"@storybook/addon-links": "^5.3.17",
|
||||
"@storybook/addon-notes": "^5.3.17",
|
||||
"@storybook/vue": "^5.3.17",
|
||||
"@vue/cli-plugin-babel": "^4.3.1",
|
||||
"@vue/cli-plugin-eslint": "^4.3.1",
|
||||
"@vue/cli-plugin-router": "^4.3.1",
|
||||
"@vue/cli-plugin-unit-mocha": "^4.3.1",
|
||||
"@vue/cli-service": "^4.3.1",
|
||||
"@storybook/addon-actions": "^5.3.18",
|
||||
"@storybook/addon-knobs": "^5.3.18",
|
||||
"@storybook/addon-links": "^5.3.18",
|
||||
"@storybook/addon-notes": "^5.3.18",
|
||||
"@storybook/vue": "^5.3.18",
|
||||
"@vue/test-utils": "1.0.0-beta.29",
|
||||
"amplitude-js": "^5.10.0",
|
||||
"amplitude-js": "^5.11.0",
|
||||
"axios": "^0.19.2",
|
||||
"axios-progress-bar": "^1.2.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"bootstrap": "^4.4.1",
|
||||
"bootstrap-vue": "^2.9.0",
|
||||
"bootstrap-vue": "^2.11.0",
|
||||
"chai": "^4.1.2",
|
||||
"core-js": "^3.6.4",
|
||||
"core-js": "^3.6.5",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-config-habitrpg": "^6.2.0",
|
||||
"eslint-plugin-mocha": "^5.3.0",
|
||||
@@ -40,7 +40,7 @@
|
||||
"hellojs": "^1.18.4",
|
||||
"inspectpack": "^4.4.0",
|
||||
"intro.js": "^2.9.3",
|
||||
"jquery": "^3.4.1",
|
||||
"jquery": "^3.5.0",
|
||||
"lodash": "^4.17.15",
|
||||
"moment": "^2.24.0",
|
||||
"nconf": "^0.10.0",
|
||||
@@ -58,7 +58,6 @@
|
||||
"vue-mugen-scroll": "^0.2.6",
|
||||
"vue-router": "^3.1.6",
|
||||
"vue-template-compiler": "^2.6.11",
|
||||
"vue2-perfect-scrollbar": "^1.4.0",
|
||||
"vuedraggable": "^2.23.1",
|
||||
"vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#5d237615463a84a23dd6f3f77c6ab577d68593ec",
|
||||
"webpack": "^4.42.1"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
The files in the following subfolders:
|
||||
|
||||
- audio
|
||||
- emails
|
||||
- icons
|
||||
- merch
|
||||
- presskit
|
||||
|
||||
are not processed by Webpack so their filenames don't get hashed, but given that they almost never change, they're still cached for 1 week.
|
||||
|
||||
In case one of the files needs to be updated the filename should be changed if possible.
|
||||
|
||||
For more information see `website/server/middlewares/static.js`.
|
||||
@@ -20,10 +20,6 @@
|
||||
</svg>
|
||||
<!-- eslint-enable max-len -->
|
||||
</div>
|
||||
<div class="col-12 text-center">
|
||||
<h2>{{ $t('tipTitle', {tipNumber: currentTipNumber}) }}</h2>
|
||||
<p>{{ currentTip }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -33,7 +29,7 @@
|
||||
'resting': showRestingBanner
|
||||
}"
|
||||
>
|
||||
<banned-account-modal />
|
||||
<!-- <banned-account-modal /> -->
|
||||
<amazon-payments-modal v-if="!isStaticPage" />
|
||||
<payments-success-modal />
|
||||
<sub-cancel-modal-confirm v-if="isUserLoaded" />
|
||||
@@ -266,7 +262,6 @@ import {
|
||||
} from '@/libs/userlocalManager';
|
||||
|
||||
import svgClose from '@/assets/svg/close.svg';
|
||||
import bannedAccountModal from '@/components/bannedAccountModal';
|
||||
|
||||
const COMMUNITY_MANAGER_EMAIL = process.env.EMAILS_COMMUNITY_MANAGER_EMAIL; // eslint-disable-line
|
||||
|
||||
@@ -281,7 +276,6 @@ export default {
|
||||
BuyModal,
|
||||
SelectMembersModal,
|
||||
amazonPaymentsModal,
|
||||
bannedAccountModal,
|
||||
paymentsSuccessModal,
|
||||
subCancelModalConfirm,
|
||||
subCanceledModal,
|
||||
@@ -299,7 +293,6 @@ export default {
|
||||
audioSuffix: null,
|
||||
|
||||
loading: true,
|
||||
currentTipNumber: 0,
|
||||
bannerHidden: false,
|
||||
};
|
||||
},
|
||||
@@ -312,15 +305,6 @@ export default {
|
||||
castingSpell () {
|
||||
return this.$store.state.spellOptions.castingSpell;
|
||||
},
|
||||
currentTip () {
|
||||
const numberOfTips = 35 + 1;
|
||||
const min = 1;
|
||||
const randomNumber = Math.random() * (numberOfTips - min) + min;
|
||||
const tipNumber = Math.floor(randomNumber);
|
||||
this.currentTipNumber = tipNumber; // eslint-disable-line vue/no-side-effects-in-computed-properties, max-len
|
||||
|
||||
return this.$t(`tip${tipNumber}`);
|
||||
},
|
||||
showRestingBanner () {
|
||||
return !this.bannerHidden && this.user && this.user.preferences.sleep;
|
||||
},
|
||||
@@ -385,7 +369,8 @@ export default {
|
||||
return response;
|
||||
}, error => {
|
||||
if (error.response.status >= 400) {
|
||||
this.checkForBannedUser(error);
|
||||
const isBanned = this.checkForBannedUser(error);
|
||||
if (isBanned === true) return null; // eslint-disable-line consistent-return
|
||||
|
||||
// Don't show errors from getting user details. These users have delete their account,
|
||||
// but their chat message still exists.
|
||||
@@ -403,7 +388,8 @@ export default {
|
||||
// TODO use a specific error like NotificationNotFound instead of checking for the string
|
||||
const invalidUserMessage = [this.$t('invalidCredentials'), 'Missing authentication headers.'];
|
||||
if (invalidUserMessage.indexOf(errorMessage) !== -1) {
|
||||
this.$store.dispatch('auth:logout');
|
||||
this.$store.dispatch('auth:logout', { redirectToLogin: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Most server errors should return is click to dismiss errors, with some exceptions
|
||||
@@ -553,7 +539,7 @@ export default {
|
||||
|
||||
// Case where user is not logged in
|
||||
if (!parseSettings) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const bannedMessage = this.$t('accountSuspended', {
|
||||
@@ -561,9 +547,10 @@ export default {
|
||||
userId: parseSettings.auth.apiId,
|
||||
});
|
||||
|
||||
if (errorMessage !== bannedMessage) return;
|
||||
if (errorMessage !== bannedMessage) return false;
|
||||
|
||||
this.$root.$emit('bv::show::modal', 'banned-account');
|
||||
this.$store.dispatch('auth:logout', { redirectToLogin: true });
|
||||
return true;
|
||||
},
|
||||
initializeModalStack () {
|
||||
// Manage modals
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
height: 219px;
|
||||
}
|
||||
|
||||
.Pet_HatchingPotion_Dessert {
|
||||
background: url("~@/assets/images/animated/Pet_HatchingPotion_Dessert.gif") no-repeat;
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
}
|
||||
|
||||
.Pet_HatchingPotion_Veggie {
|
||||
background: url("~@/assets/images/animated/Pet_HatchingPotion_Veggie.gif") no-repeat;
|
||||
width: 68px;
|
||||
|
||||
@@ -1,114 +1,96 @@
|
||||
.promo_achievement_CottonCandyPink {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -952px -740px;
|
||||
width: 204px;
|
||||
height: 102px;
|
||||
}
|
||||
.promo_april_fools_2020 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -952px 0px;
|
||||
background-position: -445px -184px;
|
||||
width: 423px;
|
||||
height: 147px;
|
||||
}
|
||||
.promo_armoire_backgrounds_202003 {
|
||||
.promo_armoire_backgrounds_202004 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -340px -659px;
|
||||
background-position: -433px -337px;
|
||||
width: 423px;
|
||||
height: 147px;
|
||||
}
|
||||
.promo_cosplay {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: 0px 0px;
|
||||
width: 623px;
|
||||
height: 167px;
|
||||
}
|
||||
.promo_egg_quest {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -952px -444px;
|
||||
background-position: 0px -648px;
|
||||
width: 354px;
|
||||
height: 147px;
|
||||
}
|
||||
.promo_hugabug_bundle {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -952px -296px;
|
||||
width: 420px;
|
||||
height: 147px;
|
||||
}
|
||||
.promo_mystery_202003 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -952px -592px;
|
||||
width: 282px;
|
||||
height: 147px;
|
||||
}
|
||||
.promo_mystery_202004 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -624px -277px;
|
||||
background-position: 0px -796px;
|
||||
width: 282px;
|
||||
height: 147px;
|
||||
}
|
||||
.promo_pi_day {
|
||||
.promo_pastel_skin_hair {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -316px -316px;
|
||||
width: 273px;
|
||||
background-position: -355px -648px;
|
||||
width: 354px;
|
||||
height: 147px;
|
||||
}
|
||||
.customize-option.promo_pastel_skin_hair {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -380px -663px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
.promo_seasonal_shop_spring {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -764px -659px;
|
||||
background-position: -875px -524px;
|
||||
width: 162px;
|
||||
height: 138px;
|
||||
}
|
||||
.promo_shiny_seeds {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -424px -500px;
|
||||
width: 360px;
|
||||
height: 147px;
|
||||
}
|
||||
.promo_spring_2019 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -430px -475px;
|
||||
background-position: 0px -337px;
|
||||
width: 432px;
|
||||
height: 162px;
|
||||
}
|
||||
.promo_spring_2020 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: 0px -475px;
|
||||
background-position: -445px 0px;
|
||||
width: 429px;
|
||||
height: 183px;
|
||||
}
|
||||
.promo_spring_potions_2020 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -952px -148px;
|
||||
background-position: 0px -500px;
|
||||
width: 423px;
|
||||
height: 147px;
|
||||
}
|
||||
.promo_take_this {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -1235px -592px;
|
||||
background-position: -875px -663px;
|
||||
width: 96px;
|
||||
height: 69px;
|
||||
}
|
||||
.scene_QuartzFox {
|
||||
.scene_citrusella {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: 0px -870px;
|
||||
width: 141px;
|
||||
height: 147px;
|
||||
background-position: -875px -196px;
|
||||
width: 152px;
|
||||
height: 176px;
|
||||
}
|
||||
.scene_dailies {
|
||||
.scene_hat_guild {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -624px 0px;
|
||||
width: 327px;
|
||||
height: 276px;
|
||||
background-position: 0px 0px;
|
||||
width: 444px;
|
||||
height: 336px;
|
||||
}
|
||||
.scene_gaining_achievement {
|
||||
.scene_meditation {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: 0px -659px;
|
||||
width: 339px;
|
||||
height: 210px;
|
||||
background-position: -875px -373px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
.scene_shanaqui {
|
||||
.scene_tasks {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: -316px -168px;
|
||||
width: 282px;
|
||||
height: 147px;
|
||||
}
|
||||
.scene_tough_times {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-largeSprites-0.png');
|
||||
background-position: 0px -168px;
|
||||
width: 315px;
|
||||
height: 306px;
|
||||
background-position: -875px 0px;
|
||||
width: 240px;
|
||||
height: 195px;
|
||||
}
|
||||
|
||||
@@ -1,48 +1,72 @@
|
||||
.quest_TEMPLATE_FOR_MISSING_IMAGE {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -719px -1546px;
|
||||
background-position: -502px -1519px;
|
||||
width: 221px;
|
||||
height: 39px;
|
||||
}
|
||||
.quest_dolphin {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -220px 0px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_dustbunnies {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -440px 0px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_egg {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1762px -362px;
|
||||
width: 165px;
|
||||
height: 207px;
|
||||
}
|
||||
.quest_evilsanta {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1762px -1023px;
|
||||
width: 118px;
|
||||
height: 131px;
|
||||
}
|
||||
.quest_evilsanta2 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -443px 0px;
|
||||
background-position: 0px -232px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_falcon {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1323px -660px;
|
||||
background-position: -220px -232px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_ferret {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -663px -220px;
|
||||
background-position: -440px -232px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_frog {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1543px 0px;
|
||||
background-position: -1540px 0px;
|
||||
width: 221px;
|
||||
height: 213px;
|
||||
}
|
||||
.quest_ghost_stag {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -220px -232px;
|
||||
background-position: -660px 0px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_goldenknight1 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -440px -232px;
|
||||
background-position: -660px -220px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_goldenknight2 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -217px -1546px;
|
||||
background-position: 0px -1519px;
|
||||
width: 250px;
|
||||
height: 150px;
|
||||
}
|
||||
@@ -54,343 +78,319 @@
|
||||
}
|
||||
.quest_gryphon {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1314px -1332px;
|
||||
background-position: -443px -1332px;
|
||||
width: 216px;
|
||||
height: 177px;
|
||||
}
|
||||
.quest_guineapig {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -220px -452px;
|
||||
background-position: 0px -452px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_harpy {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -440px -452px;
|
||||
background-position: -220px -452px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_hedgehog {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -220px -1332px;
|
||||
background-position: 0px -1332px;
|
||||
width: 219px;
|
||||
height: 186px;
|
||||
}
|
||||
.quest_hippo {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -883px 0px;
|
||||
background-position: -440px -452px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_horse {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -883px -220px;
|
||||
background-position: -660px -452px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_kangaroo {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -883px -440px;
|
||||
background-position: -880px 0px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_kraken {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1097px -1332px;
|
||||
background-position: -660px -1332px;
|
||||
width: 216px;
|
||||
height: 177px;
|
||||
}
|
||||
.quest_lostMasterclasser1 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -220px -672px;
|
||||
background-position: -880px -220px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_lostMasterclasser2 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -440px -672px;
|
||||
background-position: -880px -440px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_lostMasterclasser3 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -660px -672px;
|
||||
background-position: 0px -672px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_mayhemMistiflying1 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1765px -688px;
|
||||
background-position: -1762px -570px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
.quest_mayhemMistiflying2 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1103px 0px;
|
||||
background-position: -220px -672px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_mayhemMistiflying3 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1103px -220px;
|
||||
background-position: -440px -672px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_monkey {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1103px -440px;
|
||||
background-position: -660px -672px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_moon1 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1543px -214px;
|
||||
background-position: -1540px -214px;
|
||||
width: 216px;
|
||||
height: 216px;
|
||||
}
|
||||
.quest_moon2 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: 0px -892px;
|
||||
background-position: -880px -672px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_moon3 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -220px -892px;
|
||||
background-position: -1100px 0px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_moonstone1 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -440px -892px;
|
||||
background-position: -1100px -220px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_moonstone2 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -660px -892px;
|
||||
background-position: -1100px -440px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_moonstone3 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -880px -892px;
|
||||
background-position: -1100px -660px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_nudibranch {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1543px -1082px;
|
||||
background-position: -1540px -431px;
|
||||
width: 216px;
|
||||
height: 216px;
|
||||
}
|
||||
.quest_octopus {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -440px -1332px;
|
||||
background-position: -220px -1332px;
|
||||
width: 222px;
|
||||
height: 177px;
|
||||
}
|
||||
.quest_owl {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1323px -220px;
|
||||
background-position: 0px -892px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_peacock {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1543px -865px;
|
||||
background-position: -1540px -648px;
|
||||
width: 216px;
|
||||
height: 216px;
|
||||
}
|
||||
.quest_penguin {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1765px -353px;
|
||||
background-position: -1762px -178px;
|
||||
width: 190px;
|
||||
height: 183px;
|
||||
}
|
||||
.quest_pterodactyl {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1323px -880px;
|
||||
background-position: -220px -892px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_rat {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: 0px -1112px;
|
||||
background-position: -440px -892px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_robot {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -220px -1112px;
|
||||
background-position: -660px -892px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_rock {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1543px -431px;
|
||||
background-position: -1540px -865px;
|
||||
width: 216px;
|
||||
height: 216px;
|
||||
}
|
||||
.quest_rooster {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1765px 0px;
|
||||
background-position: -1528px -1332px;
|
||||
width: 213px;
|
||||
height: 174px;
|
||||
}
|
||||
.quest_ruby {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -880px -1112px;
|
||||
background-position: -880px -892px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_sabretooth {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1100px -1112px;
|
||||
background-position: -1100px -892px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_seaserpent {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1320px -1112px;
|
||||
background-position: -1320px 0px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_sheep {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -660px -1112px;
|
||||
background-position: -1320px -220px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_silver {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -440px -1112px;
|
||||
background-position: -1320px -440px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_slime {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1323px -440px;
|
||||
background-position: -1320px -660px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_sloth {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1323px 0px;
|
||||
background-position: -1320px -880px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_snail {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: 0px -1332px;
|
||||
background-position: -1320px -1112px;
|
||||
width: 219px;
|
||||
height: 213px;
|
||||
}
|
||||
.quest_snake {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -663px -1332px;
|
||||
background-position: -877px -1332px;
|
||||
width: 216px;
|
||||
height: 177px;
|
||||
}
|
||||
.quest_spider {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -468px -1546px;
|
||||
background-position: -251px -1519px;
|
||||
width: 250px;
|
||||
height: 150px;
|
||||
}
|
||||
.quest_squirrel {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1100px -892px;
|
||||
background-position: 0px -1112px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_stoikalmCalamity1 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1765px -839px;
|
||||
background-position: -1762px -721px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
.quest_stoikalmCalamity2 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1103px -660px;
|
||||
background-position: -220px -1112px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_stoikalmCalamity3 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -880px -672px;
|
||||
background-position: -440px -1112px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_taskwoodsTerror1 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1765px -537px;
|
||||
background-position: -1762px -872px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
.quest_taskwoodsTerror2 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1543px -648px;
|
||||
background-position: -1540px -1082px;
|
||||
width: 216px;
|
||||
height: 216px;
|
||||
}
|
||||
.quest_taskwoodsTerror3 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: 0px -672px;
|
||||
background-position: -660px -1112px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_treeling {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1531px -1332px;
|
||||
background-position: -1094px -1332px;
|
||||
width: 216px;
|
||||
height: 177px;
|
||||
}
|
||||
.quest_trex {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -1765px -175px;
|
||||
background-position: -1762px 0px;
|
||||
width: 204px;
|
||||
height: 177px;
|
||||
}
|
||||
.quest_trex_undead {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: 0px -1546px;
|
||||
background-position: -1311px -1332px;
|
||||
width: 216px;
|
||||
height: 177px;
|
||||
}
|
||||
.quest_triceratops {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -660px -452px;
|
||||
background-position: -880px -1112px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_turtle {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: 0px -452px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_unicorn {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -663px 0px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
.quest_velociraptor {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -220px 0px;
|
||||
width: 222px;
|
||||
height: 225px;
|
||||
}
|
||||
.quest_vice1 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: -880px -1332px;
|
||||
width: 216px;
|
||||
height: 177px;
|
||||
}
|
||||
.quest_vice2 {
|
||||
background-image: url('~@/assets/images/sprites/spritesmith-main-13.png');
|
||||
background-position: 0px -232px;
|
||||
background-position: -1100px -1112px;
|
||||
width: 219px;
|
||||
height: 219px;
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 473 KiB After Width: | Height: | Size: 470 KiB |
|
Before Width: | Height: | Size: 658 KiB After Width: | Height: | Size: 643 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 426 KiB After Width: | Height: | Size: 424 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 133 KiB |