Compare commits

..

2 Commits

Author SHA1 Message Date
Hafiz c1ab9cb6ca lint fixes 2025-08-05 13:10:07 -05:00
Hafiz ffa73698a5 Server now matches usernames case insensitively like client
- Preserves original capitalization in mention text
- Fixes profile links not working with wrong case mentions
2025-08-05 12:39:33 -05:00
931 changed files with 19932 additions and 41104 deletions
+8 -18
View File
@@ -82,7 +82,7 @@ jobs:
CI: true
NODE_ENV: test
- run: npm run test:sanity
common:
runs-on: ubuntu-latest
strategy:
@@ -129,13 +129,13 @@ jobs:
CI: true
NODE_ENV: test
- run: npm run test:content
api-unit:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [21.x]
mongodb-version: [7.0]
mongodb-version: [4.2]
steps:
- uses: actions/checkout@v4
with:
@@ -144,13 +144,11 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Start MongoDB ${{ matrix.mongodb-version }} Replica Set
uses: supercharge/mongodb-github-action@1.11.0
uses: supercharge/mongodb-github-action@1.3.0
with:
mongodb-version: ${{ matrix.mongodb-version }}
mongodb-replica-set: rs
- run: sudo apt update
- run: sudo apt -y install libkrb5-dev
- run: cp config.json.example config.json
@@ -160,17 +158,15 @@ jobs:
env:
CI: true
NODE_ENV: test
- run: npm run test:api:unit
env:
REQUIRES_SERVER=true: true
api-v3-integration:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [21.x]
mongodb-version: [7.0]
mongodb-version: [4.2]
steps:
- uses: actions/checkout@v4
with:
@@ -180,11 +176,10 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Start MongoDB ${{ matrix.mongodb-version }} Replica Set
uses: supercharge/mongodb-github-action@1.11.0
uses: supercharge/mongodb-github-action@1.3.0
with:
mongodb-version: ${{ matrix.mongodb-version }}
mongodb-replica-set: rs
- run: sudo apt update
- run: sudo apt -y install libkrb5-dev
- run: cp config.json.example config.json
@@ -194,18 +189,15 @@ jobs:
env:
CI: true
NODE_ENV: test
- run: npm run test:api-v3:integration
env:
REQUIRES_SERVER=true: true
api-v4-integration:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [21.x]
mongodb-version: [7.0]
mongodb-version: [4.2]
steps:
- uses: actions/checkout@v4
with:
@@ -215,11 +207,10 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Start MongoDB ${{ matrix.mongodb-version }} Replica Set
uses: supercharge/mongodb-github-action@1.11.0
uses: supercharge/mongodb-github-action@1.3.0
with:
mongodb-version: ${{ matrix.mongodb-version }}
mongodb-replica-set: rs
- run: sudo apt update
- run: sudo apt -y install libkrb5-dev
- run: cp config.json.example config.json
@@ -229,7 +220,6 @@ jobs:
env:
CI: true
NODE_ENV: test
- run: npm run test:api-v4:integration
env:
REQUIRES_SERVER=true: true
+1 -1
View File
@@ -47,5 +47,5 @@ webpack.webstorm.config
# mongodb replica set for local dev
mongodb-*.tgz
/mongodb-*
/mongodb-data*
/.nyc_output
+28 -26
View File
@@ -8,26 +8,18 @@
"AMAZON_PAYMENTS_SELLER_ID": "SELLER_ID",
"AMPLITUDE_KEY": "AMPLITUDE_KEY",
"AMPLITUDE_SECRET": "AMPLITUDE_SECRET",
"APPLE_AUTH_CLIENT_ID": "",
"APPLE_AUTH_KEY_ID": "",
"APPLE_AUTH_PRIVATE_KEY": "",
"APPLE_TEAM_ID": "",
"BASE_URL": "http://localhost:3000",
"BLOCKED_IPS": "",
"CONTENT_SWITCHOVER_TIME_OFFSET": 8,
"CRON_SAFE_MODE": "false",
"CRON_SEMI_SAFE_MODE": "false",
"DEBUG_ENABLED": "false",
"DISABLE_REQUEST_LOGGING": "true",
"EMAIL_SERVER_AUTH_PASSWORD": "password",
"EMAIL_SERVER_AUTH_USER": "user",
"EMAIL_SERVER_URL": "http://example.com",
"EMAILS_COMMUNITY_MANAGER_EMAIL": "admin@habitica.com",
"EMAILS_PRESS_ENQUIRY_EMAIL": "admin@habitica.com",
"EMAILS_TECH_ASSISTANCE_EMAIL": "admin@habitica.com",
"EMAIL_SERVER_AUTH_PASSWORD": "password",
"EMAIL_SERVER_AUTH_USER": "user",
"EMAIL_SERVER_URL": "http://example.com",
"ENABLE_CONSOLE_LOGS_IN_PROD": "false",
"ENABLE_CONSOLE_LOGS_IN_TEST": "false",
"ENABLE_STACKDRIVER_TRACING": "false",
"FACEBOOK_KEY": "123456789012345",
"FACEBOOK_SECRET": "aaaabbbbccccddddeeeeffff00001111",
"FLAG_REPORT_EMAIL": "email@example.com, email2@example.com",
@@ -37,16 +29,15 @@
"IAP_GOOGLE_KEYDIR": "/path/to/google/public/key/dir/",
"IGNORE_REDIRECT": "true",
"ITUNES_SHARED_SECRET": "aaaabbbbccccddddeeeeffff00001111",
"LIVELINESS_PROBE_KEY": "",
"LOG_AMPLITUDE_EVENTS": "false",
"LOG_REQUESTS_EXCESSIVE_MODE": "false",
"LOGGLY_CLIENT_TOKEN": "token",
"LOGGLY_SUBDOMAIN": "example-subdomain",
"LOGGLY_TOKEN": "example-token",
"LOG_REQUESTS_EXCESSIVE_MODE": "false",
"MAINTENANCE_MODE": "false",
"NODE_DB_URI": "mongodb://localhost:27017/habitica-dev?replicaSet=rs",
"TEST_DB_URI": "mongodb://localhost:27017/habitica-test?replicaSet=rs",
"MONGODB_POOL_SIZE": "10",
"MONGODB_SOCKET_TIMEOUT": "20000",
"NODE_DB_URI": "mongodb://localhost:27017/habitica-dev?replicaSet=rs&directConnection=true&readPreference=secondary",
"NODE_ENV": "development",
"PATH": "bin:node_modules/.bin:/usr/local/bin:/usr/bin:/bin",
"PAYPAL_BILLING_PLANS_basic_12mo": "basic_12mo",
@@ -64,33 +55,44 @@
"PLAY_API_REFRESH_TOKEN": "aaaabbbbccccddddeeeeffff00001111",
"PORT": 3000,
"PUSH_CONFIGS_APN_ENABLED": "false",
"PUSH_CONFIGS_APN_KEY_ID": "xxxxxxxxxx",
"PUSH_CONFIGS_APN_KEY": "xxxxxxxxxx",
"PUSH_CONFIGS_APN_KEY_ID": "xxxxxxxxxx",
"PUSH_CONFIGS_APN_TEAM_ID": "aaabbbcccd",
"PUSH_CONFIGS_FCM_SERVER_API_KEY": "aaabbbcccd",
"RATE_LIMITER_ENABLED": "false",
"REDIS_HOST": "aaabbbcccdddeeefff",
"REDIS_PASSWORD": "12345678",
"REDIS_PORT": "1234",
"S3_ACCESS_KEY_ID": "accessKeyId",
"S3_BUCKET": "bucket",
"S3_SECRET_ACCESS_KEY": "secretAccessKey",
"SESSION_SECRET_KEY": "1234567891234567891234567891234567891234567891234567891234567891",
"SESSION_SECRET": "YOUR SECRET HERE",
"SESSION_SECRET_IV": "12345678912345678912345678912345",
"SESSION_SECRET_KEY": "1234567891234567891234567891234567891234567891234567891234567891",
"SITE_HTTP_AUTH_ENABLED": "false",
"SITE_HTTP_AUTH_PASSWORDS": "password,wordpass,passkey",
"SITE_HTTP_AUTH_USERNAMES": "admin,tester,contributor",
"SKIP_SSL_CHECK_KEY": "key",
"SLACK_FLAGGING_FOOTER_LINK": "https://habitrpg.github.io/flag-o-rama/",
"SLACK_FLAGGING_URL": "https://hooks.slack.com/services/id/id/id",
"SLACK_SUBSCRIPTIONS_URL": "https://hooks.slack.com/services/id/id/id",
"SLACK_URL": "https://hooks.slack.com/services/some-url",
"SLOW_REQUEST_THRESHOLD": 1000,
"STRIPE_API_KEY": "aaaabbbbccccddddeeeeffff00001111",
"STRIPE_PUB_KEY": "22223333444455556666777788889999",
"STRIPE_WEBHOOKS_ENDPOINT_SECRET": "111111",
"TEST_DB_URI": "mongodb://localhost:27017/habitica-test?replicaSet=rs&directConnection=true&readPreference=secondary",
"TIME_TRAVEL_ENABLED": "false",
"TRANSIFEX_SLACK_CHANNEL": "transifex",
"WEB_CONCURRENCY": 1,
"SKIP_SSL_CHECK_KEY": "key",
"ENABLE_STACKDRIVER_TRACING": "false",
"APPLE_AUTH_PRIVATE_KEY": "",
"APPLE_TEAM_ID": "",
"APPLE_AUTH_CLIENT_ID": "",
"APPLE_AUTH_KEY_ID": "",
"BLOCKED_IPS": "",
"LOG_AMPLITUDE_EVENTS": "false",
"RATE_LIMITER_ENABLED": "false",
"LIVELINESS_PROBE_KEY": "",
"REDIS_HOST": "aaabbbcccdddeeefff",
"REDIS_PORT": "1234",
"REDIS_PASSWORD": "12345678",
"TRUSTED_DOMAINS": "localhost,https://habitica.com",
"WEB_CONCURRENCY": 1
"TIME_TRAVEL_ENABLED": "false",
"DEBUG_ENABLED": "false",
"CONTENT_SWITCHOVER_TIME_OFFSET": 8,
"SLOW_REQUEST_THRESHOLD": 1000
}
+53
View File
@@ -0,0 +1,53 @@
services:
client:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "run", "client:dev"]
depends_on:
- server
environment:
- BASE_URL=http://server:3000
networks:
- habitica
ports:
- "8080:8080"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
- /usr/src/habitica/website/client/node_modules
server:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "start"]
depends_on:
mongo:
condition: service_healthy
environment:
- NODE_DB_URI=mongodb://mongo/habitrpg
networks:
- habitica
ports:
- "3000:3000"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
mongo:
image: mongo:5.0.23
restart: unless-stopped
command: ["--replSet", "rs", "--bind_ip_all", "--port", "27017"]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
start_interval: 1s
retries: 30
networks:
- habitica
ports:
- "27017:27017"
networks:
habitica:
driver: bridge
-24
View File
@@ -1,24 +0,0 @@
networks:
mongodb-network:
name: "mongodb-network"
driver: bridge
services:
mongodb:
image: "mongo:7.0"
container_name: "habitica-mongodb-only"
networks:
- mongodb-network
hostname: "mongodb"
ports:
- "27017:27017"
restart: "unless-stopped"
volumes:
- "./mongodb-data-docker:/data/db"
entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs" ]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
retries: 30
-23
View File
@@ -1,23 +0,0 @@
networks:
mongodb-network:
name: "mongodb-network"
driver: bridge
services:
mongodb:
image: "mongo:7.0"
container_name: "habitica-mongodb-test"
networks:
- mongodb-network
hostname: "mongodb"
ports:
- "27017:27017"
restart: "unless-stopped"
volumes:
- "./mongodb-data-docker-testing:/data/db"
entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs" ]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
retries: 30
+20 -41
View File
@@ -1,56 +1,35 @@
version: "3"
services:
client:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "run", "client:dev:docker"]
depends_on:
- server
build: .
networks:
- habitica
environment:
- BASE_URL=http://server:3000
networks:
- habitica
ports:
- "5173:5173"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
- /usr/src/habitica/website/client/node_modules
server:
build:
context: .
dockerfile: ./Dockerfile-Dev
command: ["npm", "start"]
- "8080:8080"
command: ["npm", "run", "client:dev"]
depends_on:
mongo:
condition: service_healthy
environment:
- NODE_DB_URI=mongodb://mongo/habitrpg
networks:
- habitica
- server
server:
build: .
ports:
- "3000:3000"
volumes:
- .:/usr/src/habitica
- /usr/src/habitica/node_modules
mongo:
image: "mongo:7.0"
container_name: "habitica-mongodb"
networks:
- habitica
hostname: "mongodb"
environment:
- NODE_DB_URI=mongodb://mongo/habitrpg
depends_on:
- mongo
mongo:
image: mongo:3.6
ports:
- "27017:27017"
restart: "unless-stopped"
volumes:
- "./mongodb-data-docker:/data/db"
entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "rs" ]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate() }" | mongosh --port 27017 --quiet
interval: 10s
timeout: 30s
start_period: 0s
retries: 30
networks:
- habitica
networks:
habitica:
+9 -12
View File
@@ -5,7 +5,7 @@ import path from 'path';
import babel from 'gulp-babel';
import os from 'os';
import fs from 'fs';
import spawn from 'cross-spawn';
import spawn from 'cross-spawn'; // eslint-disable-line import/no-extraneous-dependencies
import clean from 'rimraf';
gulp.task('build:babel:server', () => gulp.src('website/server/**/*.js')
@@ -35,7 +35,7 @@ gulp.task('build:prod', gulp.series(
// When used on windows `run-rs` must first be run without the `--keep` option
// in order to be setup correctly, afterwards it can be used.
const MONGO_PATH = path.join(__dirname, '/../mongodb-data-docker/');
const MONGO_PATH = path.join(__dirname, '/../mongodb-data/');
gulp.task('build:prepare-mongo', async () => {
if (fs.existsSync(MONGO_PATH)) {
@@ -51,32 +51,29 @@ gulp.task('build:prepare-mongo', async () => {
console.log('MongoDB data folder is missing, setting up.'); // eslint-disable-line no-console
// use run-rs without --keep, kill it as soon as the replica set starts
const dockerMongoProcess = spawn('npm', ['run', 'docker:mongo:dev']);
const runRsProcess = spawn('run-rs', ['-v', '4.1.1', '-l', 'ubuntu1804', '--dbpath', 'mongodb-data', '--number', '1', '--quiet']);
let manuallyStopped = false;
for await (const chunk of dockerMongoProcess.stdout) {
for await (const chunk of runRsProcess.stdout) {
const stringChunk = chunk.toString();
console.log(stringChunk); // eslint-disable-line no-console
// kills the process after the replica set is setup
if (stringChunk.includes('mongod startup complete')) {
if (stringChunk.includes('Started replica set')) {
console.log('MongoDB setup correctly.'); // eslint-disable-line no-console
dockerMongoProcess.kill();
manuallyStopped = true;
runRsProcess.kill();
}
}
let error = '';
for await (const chunk of dockerMongoProcess.stderr) {
for await (const chunk of runRsProcess.stderr) {
const stringChunk = chunk.toString();
error += stringChunk;
}
const exitCode = await new Promise(resolve => {
dockerMongoProcess.on('close', resolve);
runRsProcess.on('close', resolve);
});
if (!manuallyStopped && (exitCode || error.length > 0)) {
if (exitCode || error.length > 0) {
// remove any leftover files
clean.sync(MONGO_PATH);
+10 -45
View File
@@ -6,21 +6,9 @@ gulp.task('cache:content', 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,
getLocalizedContentResponse,
IOS_FILTER,
ANDROID_FILTER,
buildFilterObject,
hashForFilter,
} = 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
const iosHash = hashForFilter(IOS_FILTER);
const iosFilterObj = buildFilterObject(IOS_FILTER);
const androidHash = hashForFilter(ANDROID_FILTER);
const androidFilterObj = buildFilterObject(ANDROID_FILTER);
try {
// create the cache folder (if it doesn't exist)
try {
@@ -38,18 +26,6 @@ gulp.task('cache:content', done => {
getLocalizedContentResponse(langCode),
'utf8',
);
fs.writeFileSync(
`${CONTENT_CACHE_PATH}${langCode}${iosHash}.json`,
getLocalizedContentResponse(langCode, iosFilterObj),
'utf8',
);
fs.writeFileSync(
`${CONTENT_CACHE_PATH}${langCode}${androidHash}.json`,
getLocalizedContentResponse(langCode, androidFilterObj),
'utf8',
);
});
done();
} catch (err) {
@@ -57,37 +33,26 @@ gulp.task('cache:content', done => {
}
});
function safeMkdir (path) {
try {
fs.mkdirSync(path);
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
}
gulp.task('cache:i18n', 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 { BROWSER_SCRIPT_CACHE_PATH, geti18nCoreBrowserScript, geti18nContentBrowserScript } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
const { BROWSER_SCRIPT_CACHE_PATH, geti18nBrowserScript } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
const { langCodes } = require('../website/server/libs/i18n'); // eslint-disable-line global-require
try {
// create the cache folders (if they doesn't exist)
safeMkdir(BROWSER_SCRIPT_CACHE_PATH);
safeMkdir(`${BROWSER_SCRIPT_CACHE_PATH}core/`);
safeMkdir(`${BROWSER_SCRIPT_CACHE_PATH}content/`);
// create the cache folder (if it doesn't exist)
try {
fs.mkdirSync(BROWSER_SCRIPT_CACHE_PATH);
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
// create and save the i18n browser script for each language
langCodes.forEach(languageCode => {
fs.writeFileSync(
`${BROWSER_SCRIPT_CACHE_PATH}core/${languageCode}.js`,
geti18nCoreBrowserScript(languageCode),
'utf8',
);
fs.writeFileSync(
`${BROWSER_SCRIPT_CACHE_PATH}content/${languageCode}.js`,
geti18nContentBrowserScript(languageCode),
`${BROWSER_SCRIPT_CACHE_PATH}${languageCode}.js`,
geti18nBrowserScript(languageCode),
'utf8',
);
});
-5
View File
@@ -53,11 +53,6 @@ gulp.task('test:prepare:mongo', cb => {
const mongooseOptions = getDefaultConnectionOptions();
const connectionUrl = getDevelopmentConnectionUrl(TEST_DB_URI);
console.info({
mongooseOptions,
connectionUrl,
});
mongoose.connect(connectionUrl, mongooseOptions)
.then(() => mongoose.connection.dropDatabase())
.then(() => mongoose.connection.close()).then(() => {
+1 -1
View File
@@ -10,7 +10,7 @@ function setUpServer () {
setupNconf();
// We require src/server and not src/index because
// We require src/server and npt src/index because
// 1. nconf is already setup
// 2. we don't need clustering
require('../website/server/server'); // eslint-disable-line global-require
+1 -1
View File
@@ -11,7 +11,7 @@ const progressCount = 1000;
let count = 0;
/*
* Award every extant piece of equippable gear
* Award users every extant pet and mount
*/
async function updateUser (user) {
+5 -6
View File
@@ -3,8 +3,7 @@ import { v4 as uuid } from 'uuid';
import { model as User } from '../../website/server/models/user';
const MIGRATION_NAME = 'YYYYMMDD_take_this';
const CHALLENGE_ID = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
const MIGRATION_NAME = '20181203_take_this';
const progressCount = 1000;
let count = 0;
@@ -42,15 +41,15 @@ async function updateUser (user) {
if (count % progressCount === 0) console.warn(`${count} ${user._id}`);
if (push) {
return User.updateOne({ _id: user._id }, { $set: set, $push: push }).exec();
return User.update({ _id: user._id }, { $set: set, $push: push }).exec();
}
return User.updateOne({ _id: user._id }, { $set: set }).exec();
return User.update({ _id: user._id }, { $set: set }).exec();
}
export default async function processUsers () {
const query = {
migration: { $ne: MIGRATION_NAME },
challenges: CHALLENGE_ID,
challenges: '00708425-d477-41a5-bf27-6270466e7976',
};
const fields = {
@@ -73,7 +72,7 @@ export default async function processUsers () {
break;
} else {
query._id = {
$gt: users[users.length - 1]._id,
$gt: users[users.length - 1],
};
}
+1098 -805
View File
File diff suppressed because it is too large Load Diff
+13 -16
View File
@@ -1,7 +1,7 @@
{
"name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "5.48.0",
"version": "5.38.0",
"main": "./website/server/index.js",
"dependencies": {
"@babel/core": "^7.22.10",
@@ -19,8 +19,8 @@
"bcrypt": "^5.1.1",
"body-parser": "^1.20.3",
"bootstrap": "^4.6.2",
"compression": "^1.8.1",
"cookie-session": "^2.1.1",
"compression": "^1.7.4",
"cookie-session": "^2.0.0",
"coupon-code": "^0.4.5",
"csv-stringify": "^5.6.5",
"cwait": "^1.1.1",
@@ -39,8 +39,7 @@
"gulp-filter": "^7.0.0",
"gulp-imagemin": "^7.1.0",
"gulp.spritesmith": "^6.13.0",
"habitica-markdown": "^4.1.0",
"heapdump": "^0.3.15",
"habitica-markdown": "^3.0.0",
"helmet": "^4.6.0",
"in-app-purchase": "^1.11.3",
"js2xmlparser": "^5.0.0",
@@ -49,15 +48,13 @@
"lodash": "^4.17.21",
"merge-stream": "^2.0.0",
"method-override": "^3.0.0",
"micromustache": "^8.0.3",
"moment": "^2.29.4",
"moment-recur": "git://github.com/HabitRPG/moment-recur.git#d3e8e6da0806f13b74dd2e4d7d9053e6a63db119",
"mongoose": "^8.23.0",
"morgan": "^1.10.1",
"nan": "^2.25.0",
"mongoose": "^8.9.5",
"morgan": "^1.10.0",
"nconf": "^0.12.1",
"node-gcm": "^1.0.5",
"on-headers": "^1.1.0",
"on-headers": "^1.0.2",
"passport": "^0.5.3",
"passport-facebook": "^3.0.0",
"passport-google-oauth2": "^0.2.0",
@@ -73,9 +70,11 @@
"sinon": "^15.2.0",
"stripe": "^12.18.0",
"superagent": "^8.1.2",
"universal-analytics": "^0.5.3",
"useragent": "^2.1.9",
"uuid": "^9.0.0",
"validator": "^13.11.0",
"webpack-bundle-analyzer": "^4.10.2",
"winston": "^3.10.0",
"winston-loggly-bulk": "^3.3.0",
"xml2js": "^0.6.2"
@@ -102,16 +101,13 @@
"coverage": "nyc report --reporter=html --report-dir coverage/results; open coverage/results/index.html",
"sprites": "gulp sprites:compile",
"client:dev": "cd website/client && npm run serve",
"client:dev:docker": "cd website/client && npm run serve:docker",
"client:build": "cd website/client && npm run build",
"client:unit": "cd website/client && npm run test:unit",
"start": "node --watch ./website/server/index.js",
"start:simple": "node ./website/server/index.js",
"debug": "node --watch --inspect ./website/server/index.js",
"docker:aio": "docker compose -f docker-compose.yml up",
"docker:mongo:dev": "docker compose -f docker-compose.mongo-only.yml up",
"docker:mongo:dev:down": "docker compose -f docker-compose.mongo-only.yml down",
"docker:mongo:test": "docker compose -f docker-compose.mongo-test-local.yml up",
"mongo:test": "node scripts/start-local-mongo.mjs --test-db",
"mongo:dev": "run-rs -v 5.0.23 -l ubuntu1804 --keep --dbpath mongodb-data --number 1 --quiet",
"mongo:test": "run-rs -v 5.0.23 -l ubuntu1804 --keep --dbpath mongodb-data-testing --number 1 --quiet",
"postinstall": "git config --global url.\"https://\".insteadOf git:// && gulp build && cd website/client && npm install",
"apidoc": "gulp apidoc",
"heroku-postbuild": ".heroku/report_deploy.sh"
@@ -127,6 +123,7 @@
"monk": "^7.3.4",
"nyc": "^15.1.0",
"require-again": "^2.0.0",
"run-rs": "^0.7.7",
"sinon-chai": "^3.7.0",
"sinon-stub-promise": "^4.0.0"
}
+595
View File
@@ -0,0 +1,595 @@
/* eslint-disable camelcase */
import nconf from 'nconf';
import Amplitude from 'amplitude';
import { Visitor } from 'universal-analytics';
import * as analyticsService from '../../../../website/server/libs/analyticsService';
describe('analyticsService', () => {
beforeEach(() => {
sandbox.stub(Amplitude.prototype, 'track').returns(Promise.resolve());
sandbox.stub(Visitor.prototype, 'event');
sandbox.stub(Visitor.prototype, 'transaction');
});
afterEach(() => {
sandbox.restore();
});
describe('#getServiceByEnvironment', () => {
it('returns mock methods when not in production', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false);
expect(analyticsService.getAnalyticsServiceByEnvironment())
.to.equal(analyticsService.mockAnalyticsService);
});
it('returns real methods when in production', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
expect(analyticsService.getAnalyticsServiceByEnvironment().track)
.to.equal(analyticsService.track);
expect(analyticsService.getAnalyticsServiceByEnvironment().trackPurchase)
.to.equal(analyticsService.trackPurchase);
});
});
describe('#track', () => {
let eventType; let
data;
beforeEach(() => {
Visitor.prototype.event.yields();
eventType = 'Cron';
data = {
category: 'behavior',
uuid: 'unique-user-id',
resting: true,
cronCount: 5,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
};
});
context('Amplitude', () => {
it('calls out to amplitude', () => analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledOnce;
}));
it('uses a dummy user id if none is provided', () => {
delete data.uuid;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_id: 'no-user-id-was-provided',
});
});
});
context('platform', () => {
it('logs web platform', () => {
data.headers = { 'x-client': 'habitica-web' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Web',
});
});
});
it('logs iOS platform', () => {
data.headers = { 'x-client': 'habitica-ios' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'iOS',
});
});
});
it('logs Android platform', () => {
data.headers = { 'x-client': 'habitica-android' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Android',
});
});
});
it('logs 3rd Party platform', () => {
data.headers = { 'x-client': 'some-third-party' };
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: '3rd Party',
});
});
});
it('logs unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Unknown',
});
});
});
});
context('Operating System', () => {
it('sets default', () => {
data.headers = {
'x-client': 'third-party',
'user-agent': 'foo',
};
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Other',
os_version: '0',
});
});
});
it('sets iOS', () => {
data.headers = {
'x-client': 'habitica-ios',
'user-agent': 'Habitica/148 (iPhone; iOS 9.3; Scale/2.00)',
};
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'iOS',
os_version: '9.3.0',
});
});
});
it('sets Android', () => {
data.headers = {
'x-client': 'habitica-android',
'user-agent': 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19',
};
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Android',
os_version: '4.0.4',
});
});
});
it('sets Unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: undefined,
os_version: undefined,
});
});
});
});
it('sends details about event', () => analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
category: 'behavior',
resting: true,
cronCount: 5,
},
});
}));
it('sends english item name for gear if itemKey is provided', () => {
data.itemKey = 'headAccessory_special_foxEars';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Fox Ears',
},
});
});
});
it('sends english item name for egg if itemKey is provided', () => {
data.itemKey = 'Wolf';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Wolf Egg',
},
});
});
});
it('sends english item name for food if itemKey is provided', () => {
data.itemKey = 'Cake_Skeleton';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Bare Bones Cake',
},
});
});
});
it('sends english item name for hatching potion if itemKey is provided', () => {
data.itemKey = 'Golden';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Golden Hatching Potion',
},
});
});
});
it('sends english item name for quest if itemKey is provided', () => {
data.itemKey = 'atom1';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Attack of the Mundane, Part 1: Dish Disaster!',
},
});
});
});
it('sends english item name for purchased spell if itemKey is provided', () => {
data.itemKey = 'seafoam';
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
itemKey: data.itemKey,
itemName: 'Seafoam',
},
});
});
});
it('sends user data if provided', () => {
const stats = {
class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30,
};
const user = {
stats,
contributor: { level: 1 },
purchased: { plan: { planId: 'foo-plan' } },
flags: { tour: { intro: -2 } },
habits: [{ _id: 'habit' }],
dailys: [{ _id: 'daily' }],
todos: [{ _id: 'todo' }],
rewards: [{ _id: 'reward' }],
balance: 12,
loginIncentives: 1,
};
data.user = user;
return analyticsService.track(eventType, data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_properties: {
Class: 'wizard',
Experience: 5,
Gold: 23,
Health: 10,
Level: 4,
Mana: 30,
tutorialComplete: true,
'Number Of Tasks': {
habits: 1,
dailys: 1,
todos: 1,
rewards: 1,
},
contributorLevel: 1,
subscription: 'foo-plan',
balance: 12,
balanceGemAmount: 48,
loginIncentives: 1,
},
});
});
});
});
context('GA', () => {
it('calls out to GA', () => analyticsService.track(eventType, data)
.then(() => {
expect(Visitor.prototype.event).to.be.calledOnce;
}));
it('sends details about event', () => analyticsService.track(eventType, data)
.then(() => {
expect(Visitor.prototype.event).to.be.calledWith({
ea: 'Cron',
ec: 'behavior',
});
}));
});
});
describe('#trackPurchase', () => {
let data; let
itemSpy;
beforeEach(() => {
Visitor.prototype.event.yields();
itemSpy = sandbox.stub().returnsThis();
Visitor.prototype.transaction.returns({
item: itemSpy,
send: sandbox.stub().yields(),
});
data = {
uuid: 'user-id',
sku: 'paypal-checkout',
paymentMethod: 'PayPal',
itemPurchased: 'Gems',
purchaseValue: 8,
purchaseType: 'checkout',
gift: false,
quantity: 1,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
};
});
context('Amplitude', () => {
it('calls out to amplitude', () => analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledOnce;
}));
it('uses a dummy user id if none is provided', () => {
delete data.uuid;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_id: 'no-user-id-was-provided',
});
});
});
context('platform', () => {
it('logs web platform', () => {
data.headers = { 'x-client': 'habitica-web' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Web',
});
});
});
it('logs iOS platform', () => {
data.headers = { 'x-client': 'habitica-ios' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'iOS',
});
});
});
it('logs Android platform', () => {
data.headers = { 'x-client': 'habitica-android' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Android',
});
});
});
it('logs 3rd Party platform', () => {
data.headers = { 'x-client': 'some-third-party' };
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: '3rd Party',
});
});
});
it('logs unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
platform: 'Unknown',
});
});
});
});
context('Operating System', () => {
it('sets default', () => {
data.headers = {
'x-client': 'third-party',
'user-agent': 'foo',
};
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Other',
os_version: '0',
});
});
});
it('sets iOS', () => {
data.headers = {
'x-client': 'habitica-ios',
'user-agent': 'Habitica/148 (iPhone; iOS 9.3; Scale/2.00)',
};
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'iOS',
os_version: '9.3.0',
});
});
});
it('sets Android', () => {
data.headers = {
'x-client': 'habitica-android',
'user-agent': 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19',
};
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: 'Android',
os_version: '4.0.4',
});
});
});
it('sets Unknown if headers are not passed in', () => {
delete data.headers;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
os_name: undefined,
os_version: undefined,
});
});
});
});
it('sends details about purchase', () => analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
event_properties: {
gift: false,
itemPurchased: 'Gems',
paymentMethod: 'PayPal',
purchaseType: 'checkout',
quantity: 1,
sku: 'paypal-checkout',
},
});
}));
it('sends user data if provided', () => {
const stats = {
class: 'wizard', exp: 5, gp: 23, hp: 10, lvl: 4, mp: 30,
};
const user = {
stats,
contributor: { level: 1 },
purchased: { plan: { planId: 'foo-plan' } },
flags: { tour: { intro: -2 } },
habits: [{ _id: 'habit' }],
dailys: [{ _id: 'daily' }],
todos: [{ _id: 'todo' }],
rewards: [{ _id: 'reward' }],
};
data.user = user;
return analyticsService.trackPurchase(data)
.then(() => {
expect(Amplitude.prototype.track).to.be.calledWithMatch({
user_properties: {
Class: 'wizard',
Experience: 5,
Gold: 23,
Health: 10,
Level: 4,
Mana: 30,
tutorialComplete: true,
'Number Of Tasks': {
habits: 1,
dailys: 1,
todos: 1,
rewards: 1,
},
contributorLevel: 1,
subscription: 'foo-plan',
},
});
});
});
});
context('GA', () => {
it('calls out to GA', () => analyticsService.trackPurchase(data)
.then(() => {
expect(Visitor.prototype.event).to.be.calledOnce;
expect(Visitor.prototype.transaction).to.be.calledOnce;
}));
it('sends details about purchase', () => analyticsService.trackPurchase(data)
.then(() => {
expect(Visitor.prototype.event).to.be.calledWith({
ea: 'checkout',
ec: 'commerce',
el: 'PayPal',
ev: 8,
});
expect(Visitor.prototype.transaction).to.be.calledWith('user-id', 8);
expect(itemSpy).to.be.calledWith(8, 1, 'paypal-checkout', 'Gems', 'checkout');
}));
});
});
describe('mockAnalyticsService', () => {
it('has stubbed track method', () => {
expect(analyticsService.mockAnalyticsService).to.respondTo('track');
});
it('has stubbed trackPurchase method', () => {
expect(analyticsService.mockAnalyticsService).to.respondTo('trackPurchase');
});
});
});
-1
View File
@@ -34,7 +34,6 @@ describe('bug-report', () => {
emailData: {
BROWSER_UA: userAgent,
REPORT_MSG: userMessage,
USER_ANALYTICS: undefined,
USER_CLASS: 'warrior',
USER_CONSECUTIVE_MONTHS: 0,
USER_COSTUME: 'false',
+136 -116
View File
@@ -13,6 +13,7 @@ import { cron, cronWrapper } from '../../../../website/server/libs/cron';
import { model as User } from '../../../../website/server/models/user';
import * as Tasks from '../../../../website/server/models/task';
import common from '../../../../website/common';
import * as analytics from '../../../../website/server/libs/analyticsService';
import { model as Group } from '../../../../website/server/models/group';
const CRON_TIMEOUT_WAIT = new Date(5 * 60 * 1000).getTime();
@@ -40,17 +41,20 @@ describe('cron', async () => {
},
},
});
sinon.spy(analytics, 'track');
});
afterEach(async () => {
if (clock !== null) clock.restore();
analytics.track.restore();
});
it('updates user.preferences.timezoneOffsetAtLastCron', async () => {
const timezoneUtcOffsetFromUserPrefs = -1;
await cron({
user, tasksByType, daysMissed, timezoneUtcOffsetFromUserPrefs,
user, tasksByType, daysMissed, analytics, timezoneUtcOffsetFromUserPrefs,
});
expect(user.preferences.timezoneOffsetAtLastCron).to.equal(1);
@@ -59,7 +63,7 @@ describe('cron', async () => {
it('resets user.items.lastDrop.count', async () => {
user.items.lastDrop.count = 4;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.items.lastDrop.count).to.equal(0);
});
@@ -67,11 +71,26 @@ describe('cron', async () => {
it('increments user cron count', async () => {
const cronCountBefore = user.flags.cronCount;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.flags.cronCount).to.be.greaterThan(cronCountBefore);
});
it('calls analytics', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
});
expect(analytics.track.callCount).to.equal(1);
});
it('calls analytics when user is sleeping', async () => {
user.preferences.sleep = true;
await cron({
user, tasksByType, daysMissed, analytics,
});
expect(analytics.track.callCount).to.equal(1);
});
describe('end of the month perks', async () => {
beforeEach(async () => {
user.purchased.plan.customerId = 'subscribedId';
@@ -82,7 +101,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = new Date('2018-12-11');
clock = sinon.useFakeTimers(new Date('2019-01-29'));
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.mysteryItems.length).to.eql(2);
const filteredNotifications = user.notifications.filter(n => n.type === 'NEW_MYSTERY_ITEMS');
@@ -93,7 +112,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = new Date('2018-11-11');
clock = sinon.useFakeTimers(new Date('2019-01-29'));
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.mysteryItems.length).to.eql(4);
const filteredNotifications = user.notifications.filter(n => n.type === 'NEW_MYSTERY_ITEMS');
@@ -103,7 +122,7 @@ describe('cron', async () => {
it('resets plan.gemsBought on a new month', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.gemsBought).to.equal(0);
});
@@ -112,7 +131,7 @@ describe('cron', async () => {
user.purchased.plan.gemsBought = 10;
user.purchased.plan.dateUpdated = undefined;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.gemsBought).to.equal(0);
});
@@ -123,7 +142,7 @@ describe('cron', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.gemsBought).to.equal(10);
});
@@ -131,7 +150,7 @@ describe('cron', async () => {
it('resets plan.dateUpdated on a new month', async () => {
const currentMonth = moment().startOf('month');
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(moment(user.purchased.plan.dateUpdated).startOf('month').isSame(currentMonth)).to.eql(true);
});
@@ -139,7 +158,7 @@ describe('cron', async () => {
it('increments plan.consecutive.count', async () => {
user.purchased.plan.consecutive.count = 0;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.count).to.equal(1);
});
@@ -147,7 +166,7 @@ describe('cron', async () => {
it('increments plan.cumulativeCount', async () => {
user.purchased.plan.cumulativeCount = 0;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.cumulativeCount).to.equal(1);
});
@@ -156,7 +175,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = moment().subtract(2, 'months').toDate();
user.purchased.plan.consecutive.count = 0;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.count).to.equal(2);
});
@@ -165,7 +184,7 @@ describe('cron', async () => {
user.purchased.plan.dateUpdated = moment().subtract(3, 'months').toDate();
user.purchased.plan.cumulativeCount = 0;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.cumulativeCount).to.equal(3);
});
@@ -177,7 +196,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.trinkets = 1;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.trinkets).to.equal(1);
@@ -187,7 +206,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.gemCapExtra = 26;
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(26);
});
@@ -195,7 +214,7 @@ describe('cron', async () => {
it('does not reset plan stats if we are before the last day of the cancelled month', async () => {
user.purchased.plan.dateTerminated = moment(new Date()).add({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.customerId).to.exist;
});
@@ -206,7 +225,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.customerId).to.not.exist;
@@ -245,7 +264,7 @@ describe('cron', async () => {
// Add 2 days so that we're sure we're not affected by any start-of-month effects
// e.g., from time zone oddness.
await cron({
user: user1, tasksByType, daysMissed,
user: user1, tasksByType, daysMissed, analytics,
});
expect(user1.purchased.plan.consecutive.count).to.equal(1);
expect(user1.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -257,7 +276,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user1, tasksByType, daysMissed,
user: user1, tasksByType, daysMissed, analytics,
});
expect(user1.purchased.plan.consecutive.count).to.equal(10);
expect(user1.purchased.plan.consecutive.trinkets).to.equal(11);
@@ -292,7 +311,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3, tasksByType, daysMissed,
user: user3, tasksByType, daysMissed, analytics,
});
expect(user3.purchased.plan.consecutive.count).to.equal(1);
expect(user3.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -304,7 +323,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3, tasksByType, daysMissed,
user: user3, tasksByType, daysMissed, analytics,
});
expect(user3.purchased.plan.consecutive.count).to.equal(10);
expect(user3.purchased.plan.consecutive.trinkets).to.equal(11);
@@ -339,7 +358,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user6, tasksByType, daysMissed,
user: user6, tasksByType, daysMissed, analytics,
});
expect(user6.purchased.plan.consecutive.count).to.equal(1);
expect(user6.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -372,7 +391,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user12, tasksByType, daysMissed,
user: user12, tasksByType, daysMissed, analytics,
});
expect(user12.purchased.plan.consecutive.count).to.equal(1);
expect(user12.purchased.plan.consecutive.trinkets).to.equal(2);
@@ -384,7 +403,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user12, tasksByType, daysMissed,
user: user12, tasksByType, daysMissed, analytics,
});
expect(user12.purchased.plan.consecutive.count).to.equal(10);
expect(user12.purchased.plan.consecutive.trinkets).to.equal(11);
@@ -420,7 +439,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3g, tasksByType, daysMissed,
user: user3g, tasksByType, daysMissed, analytics,
});
expect(user3g.purchased.plan.consecutive.count).to.equal(1);
expect(user3g.purchased.plan.cumulativeCount).to.equal(1);
@@ -433,7 +452,7 @@ describe('cron', async () => {
.add(2, 'days')
.toDate());
await cron({
user: user3g, tasksByType, daysMissed,
user: user3g, tasksByType, daysMissed, analytics,
});
// subscription has been erased by now
expect(user3g.purchased.plan.consecutive.count).to.equal(0);
@@ -452,7 +471,7 @@ describe('cron', async () => {
it('resets plan.gemsBought on a new month', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.gemsBought).to.equal(0);
});
@@ -463,14 +482,14 @@ describe('cron', async () => {
user.purchased.plan.gemsBought = 10;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.gemsBought).to.equal(10);
});
it('does not reset plan.dateUpdated on a new month', async () => {
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.dateUpdated).to.be.empty;
});
@@ -478,7 +497,7 @@ describe('cron', async () => {
it('does not increment plan.consecutive.count', async () => {
user.purchased.plan.consecutive.count = 0;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.count).to.equal(0);
});
@@ -486,7 +505,7 @@ describe('cron', async () => {
it('does not increment plan.cumulativeCount', async () => {
user.purchased.plan.cumulativeCount = 0;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.cumulativeCount).to.equal(0);
});
@@ -494,7 +513,7 @@ describe('cron', async () => {
it('does not increment plan.consecutive.trinkets when user has reached a month that is a multiple of 3', async () => {
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.trinkets).to.equal(0);
});
@@ -502,7 +521,7 @@ describe('cron', async () => {
it('does not increment plan.consecutive.gemCapExtra when user has reached a month that is a multiple of 3', async () => {
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(0);
});
@@ -511,7 +530,7 @@ describe('cron', async () => {
user.purchased.plan.consecutive.gemCapExtra = 26;
user.purchased.plan.consecutive.count = 5;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(26);
});
@@ -519,7 +538,7 @@ describe('cron', async () => {
it('does nothing to plan stats if we are before the last day of the cancelled month', async () => {
user.purchased.plan.dateTerminated = moment(new Date()).add({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.purchased.plan.customerId).to.not.exist;
});
@@ -545,7 +564,7 @@ describe('cron', async () => {
it('should make uncompleted todos redder', async () => {
const valueBefore = tasksByType.todos[0].value;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.todos[0].value).to.be.lessThan(valueBefore);
});
@@ -554,7 +573,7 @@ describe('cron', async () => {
tasksByType.todos[0].completed = true;
const valueBefore = tasksByType.todos[0].value;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.todos[0].value).to.equal(valueBefore);
});
@@ -563,7 +582,7 @@ describe('cron', async () => {
tasksByType.todos[0].completed = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.history.todos).to.be.lengthOf(1);
@@ -589,7 +608,7 @@ describe('cron', async () => {
expect(user.tasksOrder.todos).to.be.lengthOf(3);
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
// user.tasksOrder.todos should be filtered while tasks by type remains unchanged
@@ -616,7 +635,7 @@ describe('cron', async () => {
const original = user.tasksOrder.todos; // Preserve the original order
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
let listsAreEqual = true;
@@ -656,7 +675,7 @@ describe('cron', async () => {
tasksByType.dailys[0].everyX = 5;
tasksByType.dailys[0].startDate = moment().add(1, 'days').toDate();
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].isDue).to.be.false;
});
@@ -667,7 +686,7 @@ describe('cron', async () => {
tasksByType.dailys[0].everyX = 5;
tasksByType.dailys[0].startDate = moment().toDate();
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].isDue).to.exist;
});
@@ -677,14 +696,14 @@ describe('cron', async () => {
tasksByType.dailys[0].everyX = 5;
tasksByType.dailys[0].startDate = moment().add(1, 'days').toDate();
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].nextDue.length).to.eql(6);
});
it('should add history', async () => {
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].history).to.be.lengthOf(1);
});
@@ -692,7 +711,7 @@ describe('cron', async () => {
it('should set tasks completed to false', async () => {
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].completed).to.be.false;
});
@@ -701,7 +720,7 @@ describe('cron', async () => {
user.preferences.sleep = true;
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].completed).to.be.false;
});
@@ -710,7 +729,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: false });
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].checklist[0].completed).to.be.false;
});
@@ -720,7 +739,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: false });
tasksByType.dailys[0].completed = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].checklist[0].completed).to.be.false;
});
@@ -730,7 +749,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: false });
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.dailys[0].checklist[0].completed).to.be.false;
});
@@ -740,7 +759,7 @@ describe('cron', async () => {
const hpBefore = user.stats.hp;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.hp).to.be.lessThan(hpBefore);
});
@@ -751,7 +770,7 @@ describe('cron', async () => {
const hpBefore = user.stats.hp;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.hp).to.equal(hpBefore);
});
@@ -765,7 +784,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
cronOverride({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.hp).to.equal(hpBefore);
@@ -778,7 +797,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.hp).to.equal(hpBefore);
@@ -789,7 +808,7 @@ describe('cron', async () => {
let hpBefore = user.stats.hp;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
const hpDifferenceOfFullyIncompleteDaily = hpBefore - user.stats.hp;
@@ -797,7 +816,7 @@ describe('cron', async () => {
tasksByType.dailys[0].checklist.push({ title: 'test', completed: true });
tasksByType.dailys[0].checklist.push({ title: 'test2', completed: false });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
const hpDifferenceOfPartiallyIncompleteDaily = hpBefore - user.stats.hp;
@@ -810,7 +829,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
const progress = await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(progress.down).to.equal(-1);
@@ -822,7 +841,7 @@ describe('cron', async () => {
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
const progress = await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(progress.down).to.equal(0);
@@ -843,7 +862,7 @@ describe('cron', async () => {
tasksByType.dailys[1].frequency = 'daily';
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.hp).to.equal(48);
@@ -867,7 +886,7 @@ describe('cron', async () => {
tasksByType.habits[0].down = false;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].value).to.be.lessThan(1);
@@ -878,7 +897,7 @@ describe('cron', async () => {
tasksByType.habits[0].up = false;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].value).to.be.lessThan(1);
@@ -890,7 +909,7 @@ describe('cron', async () => {
tasksByType.habits[0].down = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].value).to.equal(1);
@@ -909,7 +928,7 @@ describe('cron', async () => {
tasksByType.habits[0].counterDown = 1;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -922,7 +941,7 @@ describe('cron', async () => {
tasksByType.habits[0].counterDown = 1;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -936,7 +955,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -945,7 +964,7 @@ describe('cron', async () => {
// should reset
daysMissed = 8;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -969,7 +988,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -983,7 +1002,7 @@ describe('cron', async () => {
// should reset after user CDS
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1007,7 +1026,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1017,7 +1036,7 @@ describe('cron', async () => {
// should reset
daysMissed = 2;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1041,7 +1060,7 @@ describe('cron', async () => {
// should reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1065,7 +1084,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1079,7 +1098,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1088,7 +1107,7 @@ describe('cron', async () => {
// should reset
daysMissed = 32;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1113,7 +1132,7 @@ describe('cron', async () => {
// should reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1137,7 +1156,7 @@ describe('cron', async () => {
// should not reset
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(1);
@@ -1147,7 +1166,7 @@ describe('cron', async () => {
// should reset
daysMissed = 2;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(tasksByType.habits[0].counterUp).to.equal(0);
@@ -1180,7 +1199,7 @@ describe('cron', async () => {
user.stats.lvl = 2;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.history.exp).to.have.lengthOf(1);
@@ -1193,7 +1212,7 @@ describe('cron', async () => {
tasksByType.dailys[0].isDue = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.achievements.perfect).to.equal(1);
@@ -1205,7 +1224,7 @@ describe('cron', async () => {
tasksByType.dailys[0].isDue = false;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.achievements.perfect).to.equal(0);
@@ -1219,7 +1238,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1237,7 +1256,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1261,7 +1280,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1288,7 +1307,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1314,7 +1333,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1341,7 +1360,7 @@ describe('cron', async () => {
};
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.equal(0);
@@ -1362,7 +1381,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
cronOverride({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1382,7 +1401,7 @@ describe('cron', async () => {
const previousBuffs = user.stats.buffs.toObject();
cronOverride({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str);
@@ -1401,7 +1420,7 @@ describe('cron', async () => {
tasksByType.dailys[0].completed = true;
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.mp).to.be.greaterThan(mpBefore);
@@ -1417,7 +1436,7 @@ describe('cron', async () => {
tasksByType.dailys[0].completed = true;
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.mp).to.equal(mpBefore);
@@ -1430,7 +1449,7 @@ describe('cron', async () => {
user.stats.mp = 120;
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.stats.mp).to.equal(common.statsComputed(user).maxMP);
@@ -1463,7 +1482,7 @@ describe('cron', async () => {
it('resets user progress', async () => {
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.party.quest.progress.up).to.equal(0);
expect(user.party.quest.progress.down).to.equal(0);
@@ -1472,7 +1491,7 @@ describe('cron', async () => {
it('applies the user progress', async () => {
const progress = await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(progress.down).to.equal(-1);
});
@@ -1510,19 +1529,19 @@ describe('cron', async () => {
describe('login incentives', async () => {
it('increments incentive counter each cron', async () => {
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(1);
user.lastCron = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(2);
});
it('pushes a notification of the day\'s incentive each cron', async () => {
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.notifications.length).to.eql(1);
expect(user.notifications[0].type).to.eql('LOGIN_INCENTIVE');
@@ -1530,13 +1549,13 @@ describe('cron', async () => {
it('replaces previous notifications', async () => {
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
const filteredNotifications = user.notifications.filter(n => n.type === 'LOGIN_INCENTIVE');
@@ -1547,7 +1566,7 @@ describe('cron', async () => {
it('increments loginIncentives by 1 even if days are skipped in between', async () => {
daysMissed = 3;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(1);
});
@@ -1555,14 +1574,14 @@ describe('cron', async () => {
it('increments loginIncentives by 1 even if user is sleeping', async () => {
user.preferences.sleep = true;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(1);
});
it('awards user bard robes if login incentive is 1', async () => {
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(1);
expect(user.items.gear.owned.armor_special_bardRobes).to.eql(true);
@@ -1572,7 +1591,7 @@ describe('cron', async () => {
it('awards user incentive backgrounds if login incentive is 2', async () => {
user.loginIncentives = 1;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(2);
expect(user.purchased.background.blue).to.eql(true);
@@ -1586,7 +1605,7 @@ describe('cron', async () => {
it('awards user Bard Hat if login incentive is 3', async () => {
user.loginIncentives = 2;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(3);
expect(user.items.gear.owned.head_special_bardHat).to.eql(true);
@@ -1596,7 +1615,7 @@ describe('cron', async () => {
it('awards user RoyalPurple Hatching Potion if login incentive is 4', async () => {
user.loginIncentives = 3;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(4);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1606,7 +1625,7 @@ describe('cron', async () => {
it('awards user a Chocolate, Meat and Pink Contton Candy if login incentive is 5', async () => {
user.loginIncentives = 4;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(5);
@@ -1620,7 +1639,7 @@ describe('cron', async () => {
it('awards user moon quest if login incentive is 7', async () => {
user.loginIncentives = 6;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(7);
expect(user.items.quests.moon1).to.eql(1);
@@ -1630,7 +1649,7 @@ describe('cron', async () => {
it('awards user RoyalPurple Hatching Potion if login incentive is 10', async () => {
user.loginIncentives = 9;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(10);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1640,7 +1659,7 @@ describe('cron', async () => {
it('awards user a Strawberry, Patato and Blue Contton Candy if login incentive is 14', async () => {
user.loginIncentives = 13;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(14);
@@ -1654,7 +1673,7 @@ describe('cron', async () => {
it('awards user a bard instrument if login incentive is 18', async () => {
user.loginIncentives = 17;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(18);
expect(user.items.gear.owned.weapon_special_bardInstrument).to.eql(true);
@@ -1664,7 +1683,7 @@ describe('cron', async () => {
it('awards user second moon quest if login incentive is 22', async () => {
user.loginIncentives = 21;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(22);
expect(user.items.quests.moon2).to.eql(1);
@@ -1674,7 +1693,7 @@ describe('cron', async () => {
it('awards user a RoyalPurple hatching potion if login incentive is 26', async () => {
user.loginIncentives = 25;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(26);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1684,7 +1703,7 @@ describe('cron', async () => {
it('awards user Fish, Milk, Rotten Meat and Honey if login incentive is 30', async () => {
user.loginIncentives = 29;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(30);
@@ -1699,7 +1718,7 @@ describe('cron', async () => {
it('awards user a RoyalPurple hatching potion if login incentive is 35', async () => {
user.loginIncentives = 34;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(35);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1709,7 +1728,7 @@ describe('cron', async () => {
it('awards user the third moon quest if login incentive is 40', async () => {
user.loginIncentives = 39;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(40);
expect(user.items.quests.moon3).to.eql(1);
@@ -1719,7 +1738,7 @@ describe('cron', async () => {
it('awards user a RoyalPurple hatching potion if login incentive is 45', async () => {
user.loginIncentives = 44;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(45);
expect(user.items.hatchingPotions.RoyalPurple).to.eql(1);
@@ -1729,7 +1748,7 @@ describe('cron', async () => {
it('awards user a saddle if login incentive is 50', async () => {
user.loginIncentives = 49;
await cron({
user, tasksByType, daysMissed,
user, tasksByType, daysMissed, analytics,
});
expect(user.loginIncentives).to.eql(50);
expect(user.items.food.Saddle).to.eql(1);
@@ -1747,6 +1766,7 @@ describe('cron wrapper', () => {
res = generateRes();
req = generateReq();
user = await res.locals.user.save();
res.analytics = analytics;
});
afterEach(() => {
+3 -3
View File
@@ -150,7 +150,7 @@ describe('emails', () => {
sendTxn(mailingInfo, emailType);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
@@ -234,7 +234,7 @@ describe('emails', () => {
sendTxn(mailingInfo, emailType);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
@@ -254,7 +254,7 @@ describe('emails', () => {
sendTxn(mailingInfo, emailType, variables);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
json: {
data: {
variables: sinon.match(value => value[0].name === 'BASE_URL', 'matches variables'),
-100
View File
@@ -1,100 +0,0 @@
import nconf from 'nconf';
import requireAgain from 'require-again';
import { model as User } from '../../../../website/server/models/user';
import { RegistrationEventModel } from '../../../../website/server/models/analytics/registrationEvent';
import { SubscriptionEventModel } from '../../../../website/server/models/analytics/subscriptionEvent';
describe('localAnalytics', () => {
let user;
let localAnalytics;
before(() => {
const nconfGetStub = sandbox.stub(nconf, 'get');
nconfGetStub.withArgs('ANALYTICS_DB').returns('analytics');
nconfGetStub.withArgs('DISABLE_LOCAL_ANALYTICS').returns(false);
localAnalytics = requireAgain('../../../../website/server/libs/localAnalytics');
});
beforeEach(async () => {
user = new User({
auth: {
local: {
username: 'username',
email: 'email@example.com',
},
},
registeredThrough: 'habitica-web',
});
});
describe('trackRegistrationEvent', () => {
afterEach(async () => {
await RegistrationEventModel.deleteMany({});
});
it('creates a registration event when a user registers', async () => {
user._id = '00000000-0000-0000-0000-000000000001';
await localAnalytics.trackRegistrationEvent({ user, ipAddress: '127.0.0.1' });
const registrationEvents = await RegistrationEventModel.find({ userId: user._id });
expect(registrationEvents).to.have.lengthOf(1);
expect(registrationEvents[0]).to.have.property('userId', user._id);
expect(registrationEvents[0]).to.have.property('ipAddress', '127.0.0.1');
});
it('saves the correct data to the database', async () => {
user._id = '00000000-0000-0000-0000-000000000002';
user.auth.google = { id: 'abc', emails: [{ value: 'email@example.com' }] };
await localAnalytics.trackRegistrationEvent({ user, ipAddress: '127.0.0.2' });
const registrationEvent = await RegistrationEventModel.findOne({ userId: user._id });
expect(registrationEvent).to.have.property('userId', user._id);
expect(registrationEvent).to.have.property('ipAddress', '127.0.0.2');
expect(registrationEvent).to.have.property('authenticationMethod', 'google');
});
});
describe('trackSubscriptionEvent', () => {
afterEach(async () => {
await SubscriptionEventModel.deleteMany({});
});
it('creates a subscription event when a user subscribes', async () => {
user._id = '00000000-0000-0000-0000-000000000003';
await localAnalytics.trackSubscriptionEvent({
eventType: 'subscribed',
user,
paymentMethod: 'stripe',
customerId: 'cus_123',
planId: 'plan_123',
});
const subscriptionEvents = await SubscriptionEventModel.find({ userId: user._id });
expect(subscriptionEvents).to.have.lengthOf(1);
expect(subscriptionEvents[0]).to.have.property('userId', user._id);
expect(subscriptionEvents[0]).to.have.property('eventType', 'subscribed');
expect(subscriptionEvents[0]).to.have.property('paymentMethod', 'stripe');
expect(subscriptionEvents[0]).to.have.property('customerId', 'cus_123');
expect(subscriptionEvents[0]).to.have.property('planId', 'plan_123');
});
it('creates a subscription event with cancellation reason when a user cancels', async () => {
user._id = '00000000-0000-0000-0000-000000000004';
await localAnalytics.trackSubscriptionEvent({
eventType: 'cancelled',
user,
paymentMethod: 'stripe',
customerId: 'cus_456',
planId: 'plan_456',
cancellationReason: 'No longer needed',
});
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id });
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('eventType', 'cancelled');
expect(subscriptionEvent).to.have.property('paymentMethod', 'stripe');
expect(subscriptionEvent).to.have.property('customerId', 'cus_456');
expect(subscriptionEvent).to.have.property('planId', 'plan_456');
expect(subscriptionEvent).to.have.property('cancellationReason', 'No longer needed');
});
});
});
@@ -66,15 +66,13 @@ describe('Amazon Payments - Cancel Subscription', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'private',
privacy: 'public',
leader: user._id,
});
group.purchased.plan.customerId = 'customer-id';
group.purchased.plan.planId = subKey;
group.purchased.plan.lastBillingDate = new Date();
await group.save();
user.guilds.push(group._id);
await user.save();
subscriptionBlock = common.content.subscriptionBlocks[subKey];
subscriptionLength = subscriptionBlock.months * 30;
@@ -30,14 +30,12 @@ describe('Amazon Payments - Subscribe', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'private',
privacy: 'public',
leader: user._id,
});
group.purchased.plan.customerId = 'customer-id';
group.purchased.plan.planId = subKey;
await group.save();
user.guilds.push(group._id);
await user.save();
amount = common.content.subscriptionBlocks[subKey].price;
billingAgreementId = 'billingAgreementId';
@@ -248,6 +246,11 @@ describe('Amazon Payments - Subscribe', () => {
user.guilds.push(groupId);
await user.save();
// Add existing users
user = new User();
user.guilds.push(groupId);
await user.save();
// Set expected amount
sub.key = 'group_monthly';
sub.price = 9;
+38 -58
View File
@@ -12,33 +12,11 @@ const { i18n } = common;
describe('Apple Payments', () => {
const subKey = 'basic_3mo';
let iapSetupStub;
let iapValidateStub;
let iapIsValidatedStub;
let iapIsCanceledStub;
let iapIsExpiredStub;
let paymentBuySkuStub;
let iapGetPurchaseDataStub;
let validateGiftMessageStub;
let paymentsCreateSubscritionStub;
beforeEach(() => {
iapSetupStub = sinon.stub(iap, 'setup').resolves();
iapValidateStub = sinon.stub(iap, 'validate').resolves({});
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isExpired.restore();
iap.isCanceled.restore();
iap.getPurchaseData.restore();
});
describe('verifyPurchase', () => {
let sku; let user; let token; let receipt; let
headers;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let paymentBuySkuStub; let
iapGetPurchaseDataStub; let validateGiftMessageStub;
beforeEach(() => {
token = 'testToken';
@@ -47,9 +25,13 @@ describe('Apple Payments', () => {
receipt = `{"token": "${token}", "productId": "${sku}"}`;
headers = {};
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({});
iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true);
iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false);
iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(false);
sinon.stub(iap, 'isExpired').returns(false);
sinon.stub(iap, 'isCanceled').returns(false);
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
productId: 'com.habitrpg.ios.Habitica.21gems',
@@ -60,6 +42,12 @@ describe('Apple Payments', () => {
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isExpired.restore();
iap.isCanceled.restore();
iap.getPurchaseData.restore();
payments.buySkuItem.restore();
gems.validateGiftMessage.restore();
});
@@ -221,6 +209,9 @@ describe('Apple Payments', () => {
describe('subscribe', () => {
let sub; let sku; let user; let token; let receipt; let headers; let
nextPaymentProcessing;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub;
let paymentsCreateSubscritionStub; let
iapGetPurchaseDataStub;
beforeEach(() => {
sub = common.content.subscriptionBlocks[subKey];
@@ -232,10 +223,12 @@ describe('Apple Payments', () => {
nextPaymentProcessing = moment.utc().add({ days: 2 });
user = new User();
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({});
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false);
iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(false);
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
expirationDate: moment.utc().subtract({ day: 1 }).toDate(),
@@ -257,6 +250,10 @@ describe('Apple Payments', () => {
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.getPurchaseData.restore();
if (payments.createSubscription.restore) payments.createSubscription.restore();
});
@@ -273,29 +270,6 @@ describe('Apple Payments', () => {
});
});
it('should throw an error if no active subscription is found', async () => {
iap.isCanceled.restore();
iapIsCanceledStub = sinon.stub(iap, 'isCanceled')
.returns(true);
iap.getPurchaseData.restore();
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
expirationDate: moment.utc().add({ day: -2 }).toDate(),
purchaseDate: new Date(),
productId: 'subscription1month',
transactionId: token,
originalTransactionId: token,
}]);
await expect(applePayments.subscribe(user, receipt, headers, nextPaymentProcessing))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: applePayments.constants.RESPONSE_NO_ITEM_PURCHASED,
});
});
const subOptions = [
{
sku: 'subscription1month',
@@ -600,7 +574,8 @@ describe('Apple Payments', () => {
describe('cancelSubscribe ', () => {
let user; let token; let receipt; let headers; let customerId; let
expirationDate;
let paymentCancelSubscriptionSpy;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let iapGetPurchaseDataStub; let
paymentCancelSubscriptionSpy;
beforeEach(async () => {
token = 'test-token';
@@ -609,7 +584,8 @@ describe('Apple Payments', () => {
customerId = 'test-customerId';
expirationDate = moment.utc();
iapValidateStub.restore();
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({
expirationDate,
@@ -617,8 +593,8 @@ describe('Apple Payments', () => {
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{ expirationDate: expirationDate.toDate() }]);
iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true);
iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false);
iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(true);
sinon.stub(iap, 'isCanceled').returns(false);
sinon.stub(iap, 'isExpired').returns(true);
user = new User();
user.profile.name = 'sender';
user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE;
@@ -630,7 +606,13 @@ describe('Apple Payments', () => {
});
afterEach(() => {
paymentCancelSubscriptionSpy.restore();
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isExpired.restore();
iap.isCanceled.restore();
iap.getPurchaseData.restore();
payments.cancelSubscription.restore();
});
it('should throw an error if we are missing a subscription', async () => {
@@ -713,8 +695,6 @@ describe('Apple Payments', () => {
expect(iapIsValidatedStub).to.be.calledWith({
expirationDate,
});
expect(iapIsCanceledStub).to.be.calledOnce;
expect(iapIsExpiredStub).to.be.calledOnce;
expect(iapGetPurchaseDataStub).to.be.calledOnce;
expect(paymentCancelSubscriptionSpy).to.be.calledOnce;
+39 -53
View File
@@ -11,36 +11,12 @@ const { i18n } = common;
describe('Google Payments', () => {
const subKey = 'basic_3mo';
let iapSetupStub;
let iapValidateStub;
let iapIsValidatedStub;
let paymentBuySkuStub;
let validateGiftMessageStub;
beforeEach(() => {
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
sinon.stub(iap, 'isCanceled').returns(false);
sinon.stub(iap, 'isExpired').returns(false);
paymentBuySkuStub = sinon.stub(payments, 'buySkuItem').resolves({});
validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage');
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isCanceled.restore();
iap.isExpired.restore();
payments.buySkuItem.restore();
gems.validateGiftMessage.restore();
});
describe('verifyPurchase', () => {
let sku; let user; let token; let receipt; let signature; let
headers;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let
paymentBuySkuStub; let validateGiftMessageStub;
beforeEach(() => {
sku = 'com.habitrpg.android.habitica.iap.21gems';
@@ -49,7 +25,21 @@ describe('Google Payments', () => {
signature = '';
headers = {};
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate').resolves({ productId: sku });
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
paymentBuySkuStub = sinon.stub(payments, 'buySkuItem').resolves({});
validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage');
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
payments.buySkuItem.restore();
gems.validateGiftMessage.restore();
});
it('should throw an error if receipt is invalid', async () => {
@@ -170,7 +160,8 @@ describe('Google Payments', () => {
describe('subscribe', () => {
let sub; let sku; let user; let token; let receipt; let signature; let headers; let
nextPaymentProcessing;
let paymentsCreateSubscritionStub;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let
paymentsCreateSubscritionStub;
beforeEach(() => {
sub = common.content.subscriptionBlocks[subKey];
@@ -182,12 +173,19 @@ describe('Google Payments', () => {
signature = '';
nextPaymentProcessing = moment.utc().add({ days: 2 });
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({});
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').resolves({});
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
payments.createSubscription.restore();
});
@@ -245,7 +243,7 @@ describe('Google Payments', () => {
describe('cancelSubscribe ', () => {
let user; let token; let receipt; let signature; let headers; let customerId; let
expirationDate;
let iapGetPurchaseDataStub; let
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let iapGetPurchaseDataStub; let
paymentCancelSubscriptionSpy;
beforeEach(async () => {
@@ -255,12 +253,17 @@ describe('Google Payments', () => {
signature = '';
customerId = 'test-customerId';
expirationDate = moment.utc();
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({
expirationDate,
});
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{ expirationDate: expirationDate.toDate(), autoRenewing: false }]);
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
user = new User();
user.profile.name = 'sender';
@@ -273,6 +276,9 @@ describe('Google Payments', () => {
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.getPurchaseData.restore();
payments.cancelSubscription.restore();
});
@@ -302,8 +308,6 @@ describe('Google Payments', () => {
});
it('should cancel a user subscription', async () => {
iap.isCanceled.restore();
iap.isCanceled = sinon.stub(iap, 'isCanceled').returns(true);
await googlePayments.cancelSubscribe(user, headers);
expect(iapSetupStub).to.be.calledOnce;
@@ -328,20 +332,11 @@ describe('Google Payments', () => {
});
it('should cancel a user subscription with multiple inactive subscriptions', async () => {
iap.isCanceled.restore();
iap.isCanceled = sinon.stub(iap, 'isCanceled').returns(true);
const laterDate = moment.utc().add(7, 'days');
iap.getPurchaseData.restore();
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
startTimeMillis: expirationDate.valueOf(),
expirationDate,
autoRenewing: false,
}, {
startTimeMillis: laterDate.valueOf(),
expirationDate: laterDate,
autoRenewing: false,
},
.returns([{ expirationDate, autoRenewing: false },
{ expirationDate: laterDate, autoRenewing: false },
]);
await googlePayments.cancelSubscribe(user, headers);
@@ -370,12 +365,7 @@ describe('Google Payments', () => {
iap.getPurchaseData.restore();
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{ autoRenewing: true }]);
await expect(googlePayments.cancelSubscribe(user, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: googlePayments.constants.RESPONSE_STILL_VALID,
});
await googlePayments.cancelSubscribe(user, headers);
expect(iapSetupStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledOnce;
@@ -398,12 +388,8 @@ describe('Google Payments', () => {
.returns([{ expirationDate, autoRenewing: false },
{ autoRenewing: true },
{ expirationDate, autoRenewing: false }]);
await expect(googlePayments.cancelSubscribe(user, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: googlePayments.constants.RESPONSE_STILL_VALID,
});
await googlePayments.cancelSubscribe(user, headers);
expect(iapSetupStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, {
@@ -128,12 +128,11 @@ describe('Purchasing a group plan for group', () => {
expect(publicGroup.purchased.plan.planId).to.not.exist;
data.groupId = publicGroup._id;
// Public Guilds are no longer even findable
await expect(api.createSubscription(data))
.to.eventually.be.rejected.and.to.eql({
httpCode: 404,
name: 'NotFound',
message: i18n.t('groupNotFound'),
httpCode: 401,
name: 'NotAuthorized',
message: i18n.t('onlyPrivateGuildsCanUpgrade'),
});
const updatedGroup = await Group.findById(publicGroup._id).exec();
+45 -62
View File
@@ -3,6 +3,7 @@ import moment from 'moment';
import * as sender from '../../../../../website/server/libs/email';
import common from '../../../../../website/common';
import api from '../../../../../website/server/libs/payments/payments';
import * as analytics from '../../../../../website/server/libs/analyticsService';
import * as notifications from '../../../../../website/server/libs/pushNotifications';
import { model as User } from '../../../../../website/server/models/user';
import { translate as t } from '../../../../helpers/api-integration/v3';
@@ -12,7 +13,6 @@ import {
import * as worldState from '../../../../../website/server/libs/worldState';
import { TransactionModel } from '../../../../../website/server/models/transaction';
import { REPEATING_EVENTS } from '../../../../../website/common/script/content/constants/events';
import { SubscriptionEventModel } from '../../../../../website/server/models/analytics/subscriptionEvent';
describe('payments/index', () => {
let user;
@@ -36,6 +36,8 @@ describe('payments/index', () => {
sandbox.stub(sender, 'sendTxn');
sandbox.stub(user, 'sendMessage');
sandbox.stub(analytics.mockAnalyticsService, 'trackPurchase');
sandbox.stub(analytics.mockAnalyticsService, 'track');
sandbox.stub(notifications, 'sendNotification');
data = {
@@ -95,16 +97,6 @@ describe('payments/index', () => {
expect(recipient.items.pets['Jackalope-RoyalPurple']).to.eql(5);
});
it('tracks subscription events', async () => {
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: recipient._id });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'subscribed');
expect(subscriptionEvent).to.have.property('userId', recipient._id);
expect(subscriptionEvent).to.have.property('planId', 'basic_3mo');
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
});
it('adds extra months to an existing subscription', async () => {
recipient.purchased.plan = plan;
@@ -306,6 +298,28 @@ describe('payments/index', () => {
expect(notifications.sendNotification).to.be.calledOnce;
});
it('tracks subscription purchase as gift', async () => {
await api.createSubscription(data);
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledOnce;
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledWith({
uuid: user._id,
groupId: undefined,
itemPurchased: 'Subscription',
sku: 'payment method-subscription',
purchaseType: 'subscribe',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: true,
purchaseValue: 15,
firstPurchase: true,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
});
});
context('No Active Promotion', () => {
beforeEach(() => {
sinon.stub(worldState, 'getCurrentEventList').returns([]);
@@ -441,16 +455,6 @@ describe('payments/index', () => {
expect(user.purchased.plan.dateCreated).to.exist;
});
it('tracks subscription events', async () => {
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('ipAddress');
expect(subscriptionEvent).to.have.property('planId', 'basic_3mo');
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
});
it('sets plan.dateCreated if it did not previously exist', async () => {
expect(user.purchased.plan.dateCreated).to.not.exist;
@@ -539,24 +543,29 @@ describe('payments/index', () => {
expect(sender.sendTxn).to.be.calledWith(data.user, 'subscription-begins');
});
context('Upgrades subscription', () => {
it('tracks subscription events', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
it('tracks subscription purchase', async () => {
await api.createSubscription(data);
await api.createSubscription(data);
data.sub.key = 'basic_6mo';
data.updatedFrom = { key: 'basic_earned' };
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id, planId: 'basic_6mo' });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'upgraded');
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledOnce;
expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledWith({
uuid: user._id,
groupId: undefined,
itemPurchased: 'Subscription',
sku: 'payment method-subscription',
purchaseType: 'subscribe',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: false,
purchaseValue: 15,
firstPurchase: true,
headers: {
'x-client': 'habitica-web',
'user-agent': '',
},
});
});
context('Upgrades subscription', () => {
it('from basic_earned to basic_6mo', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
@@ -599,23 +608,6 @@ describe('payments/index', () => {
});
context('Downgrades subscription', () => {
it('tracks subscription events', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
data.sub.key = 'basic_earned';
data.updatedFrom = { key: 'basic_6mo' };
await api.createSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id, planId: 'basic_earned' });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'downgraded');
expect(subscriptionEvent).to.have.property('userId', user._id);
expect(subscriptionEvent).to.have.property('paymentMethod', 'Payment Method');
});
it('from basic_6mo to basic_earned', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
@@ -1144,15 +1136,6 @@ describe('payments/index', () => {
expect(daysTillTermination).to.be.within(29, 30); // 1 month +/- 1 days
});
it('tracks subscription events', async () => {
await api.cancelSubscription(data);
const subscriptionEvent = await SubscriptionEventModel.findOne({ userId: user._id });
expect(subscriptionEvent).to.exist;
expect(subscriptionEvent).to.have.property('eventType', 'cancelled');
expect(subscriptionEvent).to.have.property('userId', user._id);
});
it('adds extraMonths to dateTerminated value', async () => {
user.purchased.plan.extraMonths = 2;
@@ -30,15 +30,13 @@ describe('paypal - subscribeCancel', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'private',
privacy: 'public',
leader: user._id,
});
group.purchased.plan.customerId = groupCustomerId;
group.purchased.plan.planId = subKey;
group.purchased.plan.lastBillingDate = new Date();
await group.save();
user.guilds.push(group._id);
await user.save();
nextBillingDate = new Date();
@@ -236,7 +236,7 @@ describe('Stripe - Checkout', () => {
const group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'private',
privacy: 'public',
leader: user._id,
});
const groupId = group._id;
@@ -376,13 +376,11 @@ describe('Stripe - Checkout', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'private',
privacy: 'public',
leader: user._id,
});
groupId = group._id;
await group.save();
user.guilds.push(group._id);
await user.save();
});
it('throws if user is not allowed to change group plan', async () => {
@@ -136,7 +136,7 @@ describe('Stripe - Subscriptions', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'private',
privacy: 'public',
leader: user._id,
});
groupId = group._id;
@@ -315,14 +315,12 @@ describe('Stripe - Subscriptions', () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'private',
privacy: 'public',
leader: user._id,
});
group.purchased.plan.customerId = 'customer-id';
group.purchased.plan.planId = subKey;
await group.save();
user.guilds.push(group._id);
await user.save();
groupId = group._id;
});
@@ -0,0 +1,50 @@
/* eslint-disable global-require */
import nconf from 'nconf';
import requireAgain from 'require-again';
import {
generateRes,
generateReq,
generateNext,
} from '../../../helpers/api-unit.helper';
import * as analyticsService from '../../../../website/server/libs/analyticsService';
describe('analytics middleware', () => {
let res; let req; let
next;
const pathToAnalyticsMiddleware = '../../../../website/server/middlewares/analytics';
beforeEach(() => {
res = generateRes();
req = generateReq();
next = generateNext();
});
it('attaches analytics object to res', () => {
const attachAnalytics = requireAgain(pathToAnalyticsMiddleware).default;
attachAnalytics(req, res, next);
expect(res.analytics).to.exist;
});
it('attaches stubbed methods for non-prod environments', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false);
const attachAnalytics = requireAgain(pathToAnalyticsMiddleware).default;
attachAnalytics(req, res, next);
expect(res.analytics.track).to.eql(analyticsService.mockAnalyticsService.track);
expect(res.analytics.trackPurchase).to.eql(analyticsService.mockAnalyticsService.trackPurchase);
});
it('attaches real methods for prod environments', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
const attachAnalytics = requireAgain(pathToAnalyticsMiddleware).default;
attachAnalytics(req, res, next);
expect(res.analytics.track).to.eql(analyticsService.track);
expect(res.analytics.trackPurchase).to.eql(analyticsService.trackPurchase);
});
});
-197
View File
@@ -1,197 +0,0 @@
import nconf from 'nconf';
import requireAgain from 'require-again';
import {
generateRes,
generateReq,
generateNext,
} from '../../../helpers/api-unit.helper';
import { Forbidden } from '../../../../website/server/libs/errors';
import { apiError } from '../../../../website/server/libs/apiError';
import { model as Blocker } from '../../../../website/server/models/blocker';
function checkIPBlockedErrorThrown (next) {
expect(next).to.have.been.calledOnce;
const calledWith = next.getCall(0).args;
expect(calledWith[0].message).to.equal(apiError('ipAddressBlocked'));
expect(calledWith[0] instanceof Forbidden).to.equal(true);
}
function checkClientBlockedErrorThrown (next) {
expect(next).to.have.been.calledOnce;
const calledWith = next.getCall(0).args;
expect(calledWith[0].message).to.equal(apiError('clientBlocked'));
expect(calledWith[0] instanceof Forbidden).to.equal(true);
}
function checkErrorNotThrown (next) {
expect(next).to.have.been.calledOnce;
const calledWith = next.getCall(0).args;
expect(typeof calledWith[0] === 'undefined').to.equal(true);
}
describe('Blocker middleware', () => {
const pathToBlocker = '../../../../website/server/middlewares/blocker';
let res; let req; let next;
beforeEach(() => {
res = generateRes();
req = generateReq();
next = generateNext();
});
describe('Blocking IPs', () => {
it('is disabled when the env var is not defined', () => {
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(undefined);
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('is disabled when the env var is an empty string', () => {
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('');
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('is disabled when the env var contains comma separated empty strings', () => {
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(' , , ');
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('does not throw when the ip does not match', () => {
req.ip = '192.168.1.1';
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('192.168.1.2');
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('does not throw when the blocker IP does not match', async () => {
req.ip = '192.168.1.1';
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
if (event === 'change') {
callback({ operation: 'add', blocker: { type: 'ipaddress', area: 'full', value: '192.168.1.2' } });
}
},
});
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('does not throw when a client is blocked', async () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
if (event === 'change') {
callback({ operation: 'add', blocker: { type: 'client', area: 'full', value: '192.168.1.1' } });
}
},
});
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('throws when the blocker IP is blocked', async () => {
req.ip = '192.168.1.1';
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
if (event === 'change') {
callback({ operation: 'add', blocker: { type: 'ipaddress', area: 'full', value: '192.168.1.1' } });
}
},
});
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkIPBlockedErrorThrown(next);
});
});
describe('Blocking clients', () => {
beforeEach(() => {
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('');
req.headers['x-client'] = 'test-client';
});
it('is disabled when no clients are blocked', () => {
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('does not throw when the client does not match', async () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
if (event === 'change') {
callback({ operation: 'add', blocker: { type: 'client', area: 'full', value: 'another-client' } });
}
},
});
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('throws when the client is blocked', async () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
if (event === 'change') {
callback({ operation: 'add', blocker: { type: 'client', area: 'full', value: 'test-client' } });
}
},
});
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkClientBlockedErrorThrown(next);
});
it('does not throw when an ip is blocked', async () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
if (event === 'change') {
callback({ operation: 'add', blocker: { type: 'ipaddress', area: 'full', value: 'test-client' } });
}
},
});
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('updates the list when data changes', async () => {
let blockCallback;
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
blockCallback = callback;
if (event === 'change') {
callback({ operation: 'add', blocker: { type: 'client', area: 'full', value: 'another-client' } });
}
},
});
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
blockCallback({ operation: 'add', blocker: { type: 'client', area: 'full', value: 'test-client' } });
attachBlocker(req, res, next);
expect(next).to.have.been.calledTwice;
const calledWith = next.getCall(1).args;
expect(calledWith[0].message).to.equal(apiError('clientBlocked'));
expect(calledWith[0] instanceof Forbidden).to.equal(true);
});
});
});
@@ -0,0 +1,76 @@
import nconf from 'nconf';
import requireAgain from 'require-again';
import {
generateRes,
generateReq,
generateNext,
} from '../../../helpers/api-unit.helper';
import { Forbidden } from '../../../../website/server/libs/errors';
import { apiError } from '../../../../website/server/libs/apiError';
function checkErrorThrown (next) {
expect(next).to.have.been.calledOnce;
const calledWith = next.getCall(0).args;
expect(calledWith[0].message).to.equal(apiError('ipAddressBlocked'));
expect(calledWith[0] instanceof Forbidden).to.equal(true);
}
function checkErrorNotThrown (next) {
expect(next).to.have.been.calledOnce;
const calledWith = next.getCall(0).args;
expect(typeof calledWith[0] === 'undefined').to.equal(true);
}
describe('ipBlocker middleware', () => {
const pathToIpBlocker = '../../../../website/server/middlewares/ipBlocker';
let res; let req; let next;
beforeEach(() => {
res = generateRes();
req = generateReq();
next = generateNext();
});
it('is disabled when the env var is not defined', () => {
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(undefined);
const attachIpBlocker = requireAgain(pathToIpBlocker).default;
attachIpBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('is disabled when the env var is an empty string', () => {
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('');
const attachIpBlocker = requireAgain(pathToIpBlocker).default;
attachIpBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('is disabled when the env var contains comma separated empty strings', () => {
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(' , , ');
const attachIpBlocker = requireAgain(pathToIpBlocker).default;
attachIpBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('does not throw when the ip does not match', () => {
req.ip = '192.168.1.1';
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('192.168.1.2');
const attachIpBlocker = requireAgain(pathToIpBlocker).default;
attachIpBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('throws when the ip is blocked', () => {
req.ip = '192.168.1.1';
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('192.168.1.1');
const attachIpBlocker = requireAgain(pathToIpBlocker).default;
attachIpBlocker(req, res, next);
checkErrorThrown(next);
});
});
+12 -24
View File
@@ -32,8 +32,7 @@ describe('rateLimiter middleware', () => {
it('is disabled when the env var is not defined', () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns(undefined);
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
attachRateLimiter(req, res, next);
expect(next).to.have.been.calledOnce;
@@ -44,8 +43,7 @@ describe('rateLimiter middleware', () => {
it('is disabled when the env var is an not "true"', () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('false');
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
attachRateLimiter(req, res, next);
expect(next).to.have.been.calledOnce;
@@ -57,8 +55,7 @@ describe('rateLimiter middleware', () => {
it('does not throw when there are available points', async () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('RATE_LIMITER_IP_COST').returns(1);
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
await attachRateLimiter(req, res, next);
expect(next).to.have.been.calledOnce;
@@ -80,8 +77,7 @@ describe('rateLimiter middleware', () => {
sandbox.stub(RateLimiterMemory.prototype, 'consume')
.returns(Promise.reject(new Error('Unknown error.')));
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
await attachRateLimiter(req, res, next);
expect(next).to.have.been.calledOnce;
@@ -96,8 +92,7 @@ describe('rateLimiter middleware', () => {
it('does not throw when LIVELINESS_PROBE_KEY is correct', async () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('LIVELINESS_PROBE_KEY').returns('abc');
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
req.query.liveliness = 'abc';
await attachRateLimiter(req, res, next);
@@ -112,8 +107,7 @@ describe('rateLimiter middleware', () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('LIVELINESS_PROBE_KEY').returns('abc');
nconfGetStub.withArgs('RATE_LIMITER_IP_COST').returns(1);
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
req.query.liveliness = 'das';
await attachRateLimiter(req, res, next);
@@ -130,8 +124,7 @@ describe('rateLimiter middleware', () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('LIVELINESS_PROBE_KEY').returns(undefined);
nconfGetStub.withArgs('RATE_LIMITER_IP_COST').returns(1);
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
await attachRateLimiter(req, res, next);
@@ -147,8 +140,7 @@ describe('rateLimiter middleware', () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('LIVELINESS_PROBE_KEY').returns('');
nconfGetStub.withArgs('RATE_LIMITER_IP_COST').returns(1);
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
req.query.liveliness = '';
await attachRateLimiter(req, res, next);
@@ -164,8 +156,7 @@ describe('rateLimiter middleware', () => {
it('throws when there are no available points remaining', async () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('RATE_LIMITER_IP_COST').returns(1);
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
// call for 31 times
for (let i = 0; i < 31; i += 1) {
@@ -189,8 +180,7 @@ describe('rateLimiter middleware', () => {
it('uses the user id if supplied or the ip address', async () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('RATE_LIMITER_IP_COST').returns(1);
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
req.ip = 1;
await attachRateLimiter(req, res, next);
@@ -220,8 +210,7 @@ describe('rateLimiter middleware', () => {
it('applies increased cost for registration calls with and without user id', async () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('RATE_LIMITER_REGISTRATION_COST').returns(3);
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
req.path = '/api/v4/user/auth/local/register';
req.ip = 1;
@@ -252,8 +241,7 @@ describe('rateLimiter middleware', () => {
it('applies increased cost for unauthenticated API calls', async () => {
nconfGetStub.withArgs('RATE_LIMITER_ENABLED').returns('true');
nconfGetStub.withArgs('RATE_LIMITER_IP_COST').returns(10);
const setupRateLimiter = requireAgain(pathToRateLimiter).default;
const attachRateLimiter = setupRateLimiter();
const attachRateLimiter = requireAgain(pathToRateLimiter).default;
req.ip = 1;
await attachRateLimiter(req, res, next);
-54
View File
@@ -6,8 +6,6 @@ import {
SPAM_MESSAGE_LIMIT,
SPAM_MIN_EXEMPT_CONTRIB_LEVEL,
SPAM_WINDOW_LENGTH,
MAX_CHAT_COUNT,
MAX_SUBBED_GROUP_CHAT_COUNT,
INVITES_LIMIT,
model as Group,
} from '../../../../website/server/models/group';
@@ -20,7 +18,6 @@ import {
import * as email from '../../../../website/server/libs/email';
import { TAVERN_ID } from '../../../../website/common/script/constants';
import shared from '../../../../website/common';
import { chatModel as Chat } from '../../../../website/server/models/message';
describe('Group Model', () => {
let party; let questLeader; let participatingMember;
@@ -1359,29 +1356,6 @@ describe('Group Model', () => {
});
});
describe('#getEffectiveChatLimit', () => {
it('returns the correct chat limit', () => {
const group = new Group();
expect(group.getEffectiveChatLimit()).to.eql(MAX_CHAT_COUNT);
});
it('returns the passed limit if it is lower than the max', () => {
const group = new Group();
expect(group.getEffectiveChatLimit(10)).to.eql(10);
});
it('returns the max if the passed limit is higher', () => {
const group = new Group();
expect(group.getEffectiveChatLimit(MAX_CHAT_COUNT + 10)).to.eql(MAX_CHAT_COUNT);
});
it('returns the max for group plans', () => {
const group = new Group();
group.purchased.plan.customerId = '110002222333';
expect(group.getEffectiveChatLimit()).to.eql(MAX_SUBBED_GROUP_CHAT_COUNT);
});
});
describe('#sendChat', () => {
beforeEach(() => {
sandbox.spy(User, 'updateOne');
@@ -1488,34 +1462,6 @@ describe('Group Model', () => {
});
});
describe('#trimChat', () => {
it('Only checks last message when not enough messages to trim', async () => {
sandbox.spy(Chat, 'find');
sandbox.spy(Chat, 'deleteMany');
await Chat.insertOne({ groupId: party._id, timestamp: new Date() });
await Chat.insertOne({ groupId: party._id, timestamp: new Date() });
await Chat.insertOne({ groupId: party._id, timestamp: new Date() });
await party.trimChat();
expect(Chat.find).to.be.calledOnce;
expect(Chat.deleteMany).to.not.be.called;
expect(await Chat.countDocuments({ groupId: party._id })).to.eql(3);
});
it('Deletes messages over the limit', async () => {
sandbox.spy(Chat, 'find');
sandbox.spy(Chat, 'deleteMany');
await Chat.insertOne({ groupId: party._id, timestamp: new Date() });
await Chat.insertOne({ groupId: party._id, timestamp: new Date() });
await Chat.insertOne({ groupId: party._id, timestamp: new Date() });
await party.trimChat(1);
expect(Chat.find).to.be.calledOnce;
expect(Chat.deleteMany).to.be.calledOnce;
expect(await Chat.countDocuments({ groupId: party._id })).to.eql(1);
});
});
describe('#startQuest', () => {
context('Failure Conditions', () => {
it('throws an error if group is not a party', async () => {
-73
View File
@@ -1,13 +1,9 @@
import moment from 'moment';
import requireAgain from 'require-again';
import { model as User } from '../../../../website/server/models/user';
import { model as NewsPost } from '../../../../website/server/models/newsPost';
import { model as Group } from '../../../../website/server/models/group';
import { model as Blocker } from '../../../../website/server/models/blocker';
import common from '../../../../website/common';
const pathToUserSchema = '../../../../website/server/models/user/schema';
describe('User Model', () => {
describe('.toJSON()', () => {
it('keeps user._tmp when calling .toJSON', () => {
@@ -916,73 +912,4 @@ describe('User Model', () => {
expect(user.toJSON().flags.newStuff).to.equal(true);
});
});
describe('validates email', () => {
it('does not throw an error for a valid email', () => {
const user = new User();
user.auth.local.email = 'hello@example.com';
const errors = user.validateSync();
expect(errors.errors['auth.local.email']).to.not.exist;
});
it('throws an error if email is not valid', () => {
const user = new User();
user.auth.local.email = 'invalid-email';
const errors = user.validateSync();
expect(errors.errors['auth.local.email'].message).to.equal(common.i18n.t('invalidEmail'));
});
it('throws an error if email is using a restricted domain', () => {
const user = new User();
user.auth.local.email = 'scammer@habitica.com';
const errors = user.validateSync();
expect(errors.errors['auth.local.email'].message).to.equal(common.i18n.t('invalidEmailDomain', { domains: 'habitica.com, habitrpg.com' }));
});
it('throws an error if email was blocked specifically', () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: 'blocked@example.com' } });
},
});
const schema = requireAgain(pathToUserSchema).UserSchema;
const valid = schema.paths['auth.local.email'].options.validate.every(v => v.validator('blocked@example.com'));
expect(valid).to.equal(false);
});
it('throws an error if email domain was blocked', () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: '@example.com' } });
},
});
const schema = requireAgain(pathToUserSchema).UserSchema;
const valid = schema.paths['auth.local.email'].options.validate.every(v => v.validator('blocked@example.com'));
expect(valid).to.equal(false);
});
it('throws an error if user portion of email was blocked', () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: 'blocked@' } });
},
});
const schema = requireAgain(pathToUserSchema).UserSchema;
const valid = schema.paths['auth.local.email'].options.validate.every(v => v.validator('blocked@example.com'));
expect(valid).to.equal(false);
});
it('does not throw an error if email is not blocked', () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: '@example.com' } });
callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: 'blocked@' } });
callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: 'bad@test.com' } });
},
});
const schema = requireAgain(pathToUserSchema).UserSchema;
const valid = schema.paths['auth.local.email'].options.validate.every(v => v.validator('good@test.com'));
expect(valid).to.equal(true);
});
});
});
@@ -50,59 +50,5 @@ describe('UserNotification Model', () => {
expect(safeNotifications[0].type).to.equal('NEW_CHAT_MESSAGE');
expect(safeNotifications[0].id).to.equal('123');
});
it('removes duplicate STREAK_ACHIEVEMENT notifications', () => {
// Fixes issue #13325 - Users receiving duplicate streak achievement notifications
const notifications = [
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 123,
data: {},
}),
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 456,
data: {},
}),
new UserNotification({
type: 'CRON',
id: 789,
data: {},
}), // different type, should be kept
];
const safeNotifications = UserNotification.cleanupCorruptData(notifications);
expect(safeNotifications.length).to.equal(2);
expect(safeNotifications[0].type).to.equal('STREAK_ACHIEVEMENT');
expect(safeNotifications[0].id).to.equal('123');
expect(safeNotifications[1].type).to.equal('CRON');
expect(safeNotifications[1].id).to.equal('789');
});
it('handles multiple STREAK_ACHIEVEMENT duplicates correctly', () => {
// Test case: 3 duplicate STREAK_ACHIEVEMENT notifications
const notifications = [
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 111,
data: {},
}),
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 222,
data: {},
}),
new UserNotification({
type: 'STREAK_ACHIEVEMENT',
id: 333,
data: {},
}),
];
const safeNotifications = UserNotification.cleanupCorruptData(notifications);
expect(safeNotifications.length).to.equal(1);
expect(safeNotifications[0].type).to.equal('STREAK_ACHIEVEMENT');
expect(safeNotifications[0].id).to.equal('111'); // Keep first one
});
});
});
@@ -0,0 +1,19 @@
import {
generateUser,
requester,
} from '../../../../helpers/api-integration/v3';
import { mockAnalyticsService as analytics } from '../../../../../website/server/libs/analyticsService';
describe('POST /analytics/track/:eventName', () => {
it('calls res.analytics', async () => {
const user = await generateUser();
sandbox.spy(analytics, 'track');
const requestWithHeaders = requester(user, { 'x-client': 'habitica-web' });
await requestWithHeaders.post('/analytics/track/eventName', { data: 'example' }, { 'x-client': 'habitica-web' });
expect(analytics.track).to.be.calledOnce;
expect(analytics.track).to.be.calledWith('eventName', sandbox.match({ data: 'example' }));
sandbox.restore();
});
});
@@ -5,8 +5,6 @@ import {
createAndPopulateGroup,
translate as t,
} from '../../../../helpers/api-integration/v3';
import { model as Group } from '../../../../../website/server/models/group';
import { TAVERN_ID } from '../../../../../website/common/script/constants';
describe('POST /challenges/:challengeId/join', () => {
it('returns error when challengeId is not a valid UUID', async () => {
@@ -29,37 +27,6 @@ describe('POST /challenges/:challengeId/join', () => {
});
});
context('public Guild', () => {
let group;
let groupLeader;
let members;
let challenge;
before(async () => {
({ group, groupLeader, members } = await createAndPopulateGroup({
groupDetails: {
name: 'test group',
type: 'guild',
privacy: 'private',
},
members: 1,
upgradeToGroupPlan: true,
}));
challenge = await generateChallenge(groupLeader, group);
// Creation API is shut down, we need to simulate an extant public group
await Group.updateOne({ _id: group._id }, { $set: { privacy: 'public' }, $unset: { 'purchased.plan': 1 } }).exec();
});
it('returns error when challengeId is in an old public Guild', async () => {
const authorizedUser = members[0]; // eslint-disable-line prefer-destructuring
await expect(authorizedUser.post(`/challenges/${challenge._id}/join`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('challengeNotFound'),
});
});
});
context('Joining a valid challenge', () => {
let groupLeader;
let group;
@@ -99,15 +66,6 @@ describe('POST /challenges/:challengeId/join', () => {
expect(res.name).to.equal(challenge.name);
});
it('succeeds when it\'s a Tavern challenge, even if the user isn\'t a "member" of Tavern', async () => {
const tavern = await groupLeader.get(`/groups/${TAVERN_ID}`);
const tavernChallenge = await generateChallenge(groupLeader, tavern, { prize: 1 });
const generalUser = await generateUser();
const res = await generalUser.post(`/challenges/${tavernChallenge._id}/join`);
expect(res.name).to.equal(tavernChallenge.name);
});
it('returns challenge data', async () => {
const res = await authorizedUser.post(`/challenges/${challenge._id}/join`);
@@ -62,9 +62,9 @@ describe('GET /groups/:groupId/chat', () => {
it('returns error if user attempts to fetch a sunset Guild', async () => {
await expect(user.get(`/groups/${group._id}/chat`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('groupNotFound'),
code: 400,
error: 'BadRequest',
message: t('featureRetired'),
});
});
});
@@ -121,9 +121,9 @@ describe('POST /chat/:chatId/like', () => {
await expect(user.post(`/groups/${groupWithChat._id}/chat/${message.message.id}/like`))
.to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('groupNotFound'),
code: 400,
error: 'BadRequest',
message: t('featureRetired'),
});
});
});
+13 -19
View File
@@ -9,7 +9,7 @@ import {
import {
SPAM_MIN_EXEMPT_CONTRIB_LEVEL,
} from '../../../../../website/server/models/group';
import { MAX_MESSAGE_LENGTH, CHAT_FLAG_FROM_SHADOW_MUTE } from '../../../../../website/common/script/constants';
import { MAX_MESSAGE_LENGTH } from '../../../../../website/common/script/constants';
import * as email from '../../../../../website/server/libs/email';
describe('POST /chat', () => {
@@ -80,20 +80,17 @@ describe('POST /chat', () => {
member.updateOne({ 'flags.chatRevoked': false });
});
it('errors when chat privileges are revoked when sending a message to a private guild', async () => {
it('does not error when chat privileges are revoked when sending a message to a private guild', async () => {
await member.updateOne({
'flags.chatRevoked': true,
});
await expect(member.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage }))
.to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('chatPrivilegesRevoked'),
});
const message = await member.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage });
expect(message.message.id).to.exist;
});
it('errors when chat privileges are revoked when sending a message to a party', async () => {
it('does not error when chat privileges are revoked when sending a message to a party', async () => {
const { group, members } = await createAndPopulateGroup({
groupDetails: {
name: 'Party',
@@ -109,12 +106,9 @@ describe('POST /chat', () => {
'auth.timestamps.created': new Date('2022-01-01'),
});
await expect(privatePartyMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage }))
.to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('chatPrivilegesRevoked'),
});
const message = await privatePartyMemberWithChatsRevoked.post(`/groups/${group._id}/chat`, { message: testMessage });
expect(message.message.id).to.exist;
});
});
@@ -129,7 +123,7 @@ describe('POST /chat', () => {
member.updateOne({ 'flags.chatShadowMuted': false });
});
it('creates a chat with flagCount set when sending a message to a private guild', async () => {
it('creates a chat with zero flagCount when sending a message to a private guild', async () => {
await member.updateOne({
'flags.chatShadowMuted': true,
});
@@ -137,10 +131,10 @@ describe('POST /chat', () => {
const message = await member.post(`/groups/${groupWithChat._id}/chat`, { message: testMessage });
expect(message.message.id).to.exist;
expect(message.message.flagCount).to.eql(CHAT_FLAG_FROM_SHADOW_MUTE);
expect(message.message.flagCount).to.eql(0);
});
it('creates a chat with flagCount set when sending a message to a party', async () => {
it('creates a chat with zero flagCount when sending a message to a party', async () => {
const { group, members } = await createAndPopulateGroup({
groupDetails: {
name: 'Party',
@@ -159,7 +153,7 @@ describe('POST /chat', () => {
const message = await userWithChatShadowMuted.post(`/groups/${group._id}/chat`, { message: testMessage });
expect(message.message.id).to.exist;
expect(message.message.flagCount).to.eql(CHAT_FLAG_FROM_SHADOW_MUTE);
expect(message.message.flagCount).to.eql(0);
});
});
@@ -1,7 +1,6 @@
import {
requester,
translate as t,
generateUser,
} from '../../../../helpers/api-integration/v3';
import i18n from '../../../../../website/common/script/i18n';
@@ -57,28 +56,4 @@ describe('GET /content', () => {
const res = await requester().get('/content?filter=backgroundsFlat,invalid');
expect(res).to.not.have.property('backgroundsFlat');
});
describe('authenticated user', () => {
let user;
it('returns content in user\'s preferred language when no language parameter is provided', async () => {
user = await generateUser({ 'preferences.language': 'de' });
const res = await user.get('/content');
expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach');
expect(res.backgrounds.backgrounds062014.beach.text).to.equal(i18n.t('backgroundBeachText', 'de'));
});
it('respects language parameter over user\'s preferred language', async () => {
user = await generateUser({ 'preferences.language': 'de' });
const res = await user.get('/content?language=fr');
expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach');
expect(res.backgrounds.backgrounds062014.beach.text).to.equal(i18n.t('backgroundBeachText', 'fr'));
});
it('falls back to English if user\'s preferred language is invalid', async () => {
user = await generateUser({ 'preferences.language': 'invalid_lang' });
const res = await user.get('/content');
expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach');
expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText'));
});
});
});
@@ -0,0 +1,35 @@
import { v4 as generateUUID } from 'uuid';
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
xdescribe('GET /export/avatar-:memberId.html', () => {
let user;
before(async () => {
user = await generateUser();
});
it('validates req.params.memberId', async () => {
await expect(user.get('/export/avatar-:memberId.html')).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('handles non-existing members', async () => {
const dummyId = generateUUID();
await expect(user.get(`/export/avatar-${dummyId}.html`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('userWithIDNotFound', { userId: dummyId }),
});
});
it('returns an html page', async () => {
const res = await user.get(`/export/avatar-${user._id}.html`);
expect(res.substring(0, 100).indexOf('<!DOCTYPE html>')).to.equal(0);
});
});
@@ -0,0 +1,3 @@
// TODO how to test this route since it points to a file on AWS s3?
describe('GET /export/avatar-:memberId.png', () => {});
@@ -38,7 +38,7 @@ describe('GET /export/inbox.html', () => {
it('renders the markdown messages as html', async () => {
const res = await user.get('/export/inbox.html');
expect(res).to.include('😄');
expect(res).to.include('img class="habitica-emoji"');
expect(res).to.include('<h1>Hello!</h1>');
expect(res).to.include('<li>list 1</li>');
});
@@ -46,7 +46,7 @@ describe('GET /export/inbox.html', () => {
it('sorts messages from newest to oldest', async () => {
const res = await user.get('/export/inbox.html');
const emojiPosition = res.indexOf('😄');
const emojiPosition = res.indexOf('img class="habitica-emoji"');
const headingPosition = res.indexOf('<h1>Hello!</h1>');
const listPosition = res.indexOf('<li>list 1</li>');
@@ -61,24 +61,6 @@ describe('Post /groups/:groupId/invite', () => {
});
});
it('fakes sending an invite if user is shadow muted', async () => {
const inviterMuted = await inviter.updateOne({ 'flags.chatShadowMuted': true });
const userToInvite = await generateUser();
const response = await inviterMuted.post(`/groups/${group._id}/invite`, {
usernames: [userToInvite.auth.local.lowerCaseUsername],
});
expect(response).to.be.an('Array');
expect(response[0]).to.have.all.keys(['_id', 'id', 'name', 'inviter']);
expect(response[0]._id).to.be.a('String');
expect(response[0].id).to.eql(group._id);
expect(response[0].name).to.eql(groupName);
expect(response[0].inviter).to.eql(inviter._id);
await expect(userToInvite.get('/user'))
.to.eventually.not.have.nested.property('invitations.parties[0].id', group._id);
});
it('invites a user to a group by username', async () => {
const userToInvite = await generateUser();
@@ -227,24 +209,6 @@ describe('Post /groups/:groupId/invite', () => {
});
});
it('fakes sending an invite if user is shadow muted', async () => {
const inviterMuted = await inviter.updateOne({ 'flags.chatShadowMuted': true });
const userToInvite = await generateUser();
const response = await inviterMuted.post(`/groups/${group._id}/invite`, {
uuids: [userToInvite._id],
});
expect(response).to.be.an('Array');
expect(response[0]).to.have.all.keys(['_id', 'id', 'name', 'inviter']);
expect(response[0]._id).to.be.a('String');
expect(response[0].id).to.eql(group._id);
expect(response[0].name).to.eql(groupName);
expect(response[0].inviter).to.eql(inviter._id);
await expect(userToInvite.get('/user'))
.to.eventually.not.have.nested.property('invitations.parties[0].id', group._id);
});
it('invites a user to a group by uuid', async () => {
const userToInvite = await generateUser();
@@ -317,19 +281,6 @@ describe('Post /groups/:groupId/invite', () => {
});
});
it('fakes sending invite when inviter is shadow muted', async () => {
const inviterMuted = await inviter.updateOne({ 'flags.chatShadowMuted': true });
const res = await inviterMuted.post(`/groups/${group._id}/invite`, {
emails: [testInvite],
inviter: 'inviter name',
});
const updatedUser = await inviterMuted.sync();
expect(res).to.exist;
expect(updatedUser.invitesSent).to.eql(1);
});
it('returns an error when invite is missing an email', async () => {
await expect(inviter.post(`/groups/${group._id}/invite`, {
emails: [{ name: 'test' }],
@@ -454,19 +405,6 @@ describe('Post /groups/:groupId/invite', () => {
});
});
it('fakes sending an invite if user is shadow muted', async () => {
const inviterMuted = await inviter.updateOne({ 'flags.chatShadowMuted': true });
const newUser = await generateUser();
const invite = await inviterMuted.post(`/groups/${group._id}/invite`, {
uuids: [newUser._id],
emails: [{ name: 'test', email: 'test@habitica.com' }],
});
const invitedUser = await newUser.get('/user');
expect(invitedUser.invitations.parties[0]).to.not.exist;
expect(invite).to.exist;
});
it('invites users to a group by uuid and email', async () => {
const newUser = await generateUser();
const invite = await inviter.post(`/groups/${group._id}/invite`, {
@@ -47,7 +47,7 @@ describe('GET /inbox/messages', () => {
it('returns four messages when using page-query ', async () => {
const promises = [];
for (let i = 0; i < 50; i += 1) {
for (let i = 0; i < 10; i += 1) {
promises.push(user.post('/members/send-private-message', {
toUserId: user.id,
message: 'fourth',
@@ -43,7 +43,7 @@ describe('POST /members/send-private-message', () => {
});
});
it('returns error when recipient has blocked the sender', async () => {
it('returns error when to user has blocked the sender', async () => {
const receiver = await generateUser({ 'inbox.blocks': [userToSendMessage._id] });
await expect(userToSendMessage.post('/members/send-private-message', {
@@ -56,7 +56,7 @@ describe('POST /members/send-private-message', () => {
});
});
it('returns error when sender has blocked recipient', async () => {
it('returns error when sender has blocked to user', async () => {
const receiver = await generateUser();
const sender = await generateUser({ 'inbox.blocks': [receiver._id] });
@@ -70,7 +70,7 @@ describe('POST /members/send-private-message', () => {
});
});
it('returns error when recipient has opted out of messaging', async () => {
it('returns error when to user has opted out of messaging', async () => {
const receiver = await generateUser({ 'inbox.optOut': true });
await expect(userToSendMessage.post('/members/send-private-message', {
@@ -174,7 +174,7 @@ describe('POST /members/send-private-message', () => {
expect(notification.data.excerpt).to.equal(messageExcerpt);
});
it('allows admin to send when recipient has blocked the admin', async () => {
it('allows admin to send when sender has blocked the admin', async () => {
userToSendMessage = await generateUser({
'permissions.moderator': true,
});
@@ -202,7 +202,7 @@ describe('POST /members/send-private-message', () => {
expect(sendersMessageInSendersInbox).to.exist;
});
it('allows admin to send when recipient has opted out of messaging', async () => {
it('allows admin to send when to user has opted out of messaging', async () => {
userToSendMessage = await generateUser({
'permissions.moderator': true,
});
@@ -229,58 +229,4 @@ describe('POST /members/send-private-message', () => {
expect(sendersMessageInReceiversInbox).to.exist;
expect(sendersMessageInSendersInbox).to.exist;
});
describe('sender is shadow muted', () => {
beforeEach(async () => {
userToSendMessage = await generateUser({
'flags.chatShadowMuted': true,
});
});
it('does not save the message in the receiver inbox', async () => {
const receiver = await generateUser();
const response = await userToSendMessage.post('/members/send-private-message', {
message: messageToSend,
toUserId: receiver._id,
});
expect(response.message.uuid).to.equal(receiver._id);
const updatedReceiver = await receiver.get('/user');
const updatedSender = await userToSendMessage.get('/user');
const sendersMessageInReceiversInbox = _.find(
updatedReceiver.inbox.messages,
message => message.uuid === userToSendMessage._id && message.text === messageToSend,
);
const sendersMessageInSendersInbox = _.find(
updatedSender.inbox.messages,
message => message.uuid === receiver._id && message.text === messageToSend,
);
expect(sendersMessageInReceiversInbox).to.not.exist;
expect(sendersMessageInSendersInbox).to.exist;
});
it('does not save the message message twice if recipient is sender', async () => {
const response = await userToSendMessage.post('/members/send-private-message', {
message: messageToSend,
toUserId: userToSendMessage._id,
});
expect(response.message.uuid).to.equal(userToSendMessage._id);
const updatedSender = await userToSendMessage.get('/user');
const sendersMessageInSendersInbox = _.find(
updatedSender.inbox.messages,
message => message.uuid === userToSendMessage._id && message.text === messageToSend,
);
expect(sendersMessageInSendersInbox).to.exist;
expect(Object.keys(updatedSender.inbox.messages).length).to.equal(1);
});
});
});
@@ -91,23 +91,6 @@ describe('POST /groups/:groupId/quests/accept', () => {
expect(partyMembers[0].party.quest.RSVPNeeded).to.be.false;
});
it('heals stuck RSVPNeeded when group already has the user accepted', async () => {
await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
await partyMembers[0].updateOne({ 'party.quest.RSVPNeeded': true });
await partyMembers[0].sync();
expect(partyMembers[0].party.quest.RSVPNeeded).to.be.true;
const res = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
expect(res).to.exist;
await partyMembers[0].sync();
await questingGroup.sync();
expect(partyMembers[0].party.quest.RSVPNeeded).to.equal(false);
expect(questingGroup.quest.members[partyMembers[0]._id]).to.equal(true);
});
it('does not accept invite for a quest already underway', async () => {
await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
@@ -100,23 +100,6 @@ describe('POST /groups/:groupId/quests/reject', () => {
expect(partyMembers[0].party.quest.RSVPNeeded).to.be.false;
});
it('heals stuck RSVPNeeded when group already has the user rejected', async () => {
await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`);
await partyMembers[0].updateOne({ 'party.quest.RSVPNeeded': true });
await partyMembers[0].sync();
expect(partyMembers[0].party.quest.RSVPNeeded).to.be.true;
const res = await partyMembers[0].post(`/groups/${questingGroup._id}/quests/reject`);
expect(res).to.exist;
await partyMembers[0].sync();
await questingGroup.sync();
expect(partyMembers[0].party.quest.RSVPNeeded).to.equal(false);
expect(questingGroup.quest.members[partyMembers[0]._id]).to.equal(false);
});
it('return an error when a user rejects an invite already accepted', async () => {
await leader.post(`/groups/${questingGroup._id}/quests/invite/${PET_QUEST}`);
await partyMembers[0].post(`/groups/${questingGroup._id}/quests/accept`);
@@ -1,6 +1,7 @@
import {
generateUser,
} from '../../../../helpers/api-integration/v3';
import { mockAnalyticsService as analytics } from '../../../../../website/server/libs/analyticsService';
describe('POST /user/sleep', () => {
let user;
@@ -22,4 +23,15 @@ describe('POST /user/sleep', () => {
await user.sync();
expect(user.preferences.sleep).to.be.false;
});
it('sends sleep status to analytics service', async () => {
sandbox.spy(analytics, 'track');
await user.post('/user/sleep');
await user.sync();
expect(analytics.track).to.be.calledOnce;
expect(analytics.track).to.be.calledWith('sleep', sandbox.match.has('status', user.preferences.sleep));
sandbox.restore();
});
});
@@ -25,7 +25,7 @@ describe('GET /user/auth/apple', () => {
});
it('registers a new user', async () => {
const response = await api.get(`${appleEndpoint}?allowRegister=true`);
const response = await api.get(appleEndpoint);
expect(response.apiToken).to.exist;
expect(response.id).to.exist;
@@ -35,7 +35,7 @@ describe('GET /user/auth/apple', () => {
});
it('logs an existing user in', async () => {
const registerResponse = await api.get(`${appleEndpoint}?allowRegister=true`);
const registerResponse = await api.get(appleEndpoint);
const response = await api.get(appleEndpoint);
@@ -44,7 +44,7 @@ describe('POST /user/auth/local/login', () => {
})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('accountSuspended', { communityManagerEmail: nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL'), userId: user._id, username: user.auth.local.username }),
message: t('accountSuspended', { communityManagerEmail: nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL'), userId: user._id }),
});
});
@@ -110,18 +110,6 @@ describe('POST /user/auth/local/login', () => {
expect(isValidPassword).to.equal(true);
});
it('sets auth.timestamps.updated', async () => {
const oldUpdated = new Date(user.auth.timestamps.updated);
// login
await api.post(endpoint, {
username: user.auth.local.email,
password,
});
await user.sync();
expect(user.auth.timestamps.updated).to.be.greaterThan(oldUpdated);
});
it('user uses social authentication and has no password', async () => {
await user.unset({
'auth.local.hashed_password': 1,
@@ -9,7 +9,6 @@ import {
} from '../../../../../helpers/api-integration/v3';
import { ApiUser } from '../../../../../helpers/api-integration/api-classes';
import { encrypt } from '../../../../../../website/server/libs/encryption';
import { RegistrationEventModel } from '../../../../../../website/server/models/analytics/registrationEvent';
function generateRandomUserName () {
return (Date.now() + uuid()).substring(0, 20);
@@ -42,25 +41,6 @@ describe('POST /user/auth/local/register', () => {
expect(user.newUser).to.eql(true);
});
it('tracks a registration event', async () => {
const username = generateRandomUserName();
const email = `${username}@example.com`;
const password = 'password';
const user = await api.post('/user/auth/local/register', {
username,
email,
password,
confirmPassword: password,
});
const registrationEvent = await RegistrationEventModel.findOne({ userId: user._id });
expect(registrationEvent).to.exist;
expect(registrationEvent).to.have.property('userId', user._id);
expect(registrationEvent).to.have.property('ipAddress');
expect(registrationEvent).to.have.property('authenticationMethod', 'local');
});
it('registers a new user and sets verifiedUsername to true', async () => {
const username = generateRandomUserName();
const email = `${username}@example.com`;
@@ -6,8 +6,6 @@ import {
translate as t,
getProperty,
} from '../../../../../helpers/api-integration/v3';
import apiErrorMessages from '../../../../../../website/common/script/errors/apiErrorMessages';
import { RegistrationEventModel } from '../../../../../../website/server/models/analytics/registrationEvent';
describe('POST /user/auth/social', () => {
let api;
@@ -66,77 +64,6 @@ describe('POST /user/auth/social', () => {
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a google user');
});
it('tracks a registration event', async () => {
const socialUser = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
});
const registrationEvent = await RegistrationEventModel.findOne({ userId: socialUser.id });
expect(registrationEvent).to.exist;
expect(registrationEvent).to.have.property('userId', socialUser.id);
expect(registrationEvent).to.have.property('ipAddress');
expect(registrationEvent).to.have.property('authenticationMethod', 'google');
});
it('includes sanitized version of provided username', async () => {
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'Google User Name',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.equal('GoogleUserName');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.equal('googleusername');
});
it('generates a random username if provided username contains only disallowed characters', async () => {
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'Áîüè',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.contain('hb-');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.contain('hb-');
});
it('generates a random username if provided username contains a disallowed word', async () => {
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'i am a TESTPLACEHOLDERSLURWORDHERE',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.contain('hb-');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.contain('hb-');
});
it('generates a random username if sanitized username conflicts with an extant user', async () => {
user = await generateUser({ 'auth.local.username': 'GoogleUserName' });
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
username: 'Google User Name',
});
await expect(getProperty('users', response.id, 'auth.local.username')).to.eventually.contain('hb-');
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.eventually.contain('hb-');
});
it('fails if allowRegister is false and user does not exist', async () => {
await expect(api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
allowRegister: false,
})).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: `${apiErrorMessages.socialFlowUserNotFound} ${user.auth.local.username}+google@example.com`,
});
});
it('logs an existing user in', async () => {
const registerResponse = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
@@ -204,36 +131,6 @@ describe('POST /user/auth/social', () => {
expect(response.newUser).to.be.false;
});
it('logs an existing user into their social account if allowRegister is false', async () => {
const registerResponse = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
});
expect(registerResponse.newUser).to.be.true;
// This is important for existing accounts before the new social handling
passport._strategies.google.userProfile.restore();
const expectedResult = {
id: randomGoogleId,
displayName: 'a google user',
emails: [
{ value: user.auth.local.email },
],
};
sandbox.stub(passport._strategies.google, 'userProfile').yields(null, expectedResult);
const response = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
allowRegister: false,
});
expect(response.apiToken).to.eql(registerResponse.apiToken);
expect(response.id).to.eql(registerResponse.id);
expect(response.apiToken).not.to.eql(user.apiToken);
expect(response.id).not.to.eql(user._id);
expect(response.newUser).to.be.false;
});
it('add social auth to an existing user', async () => {
const response = await user.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
@@ -245,17 +142,6 @@ describe('POST /user/auth/social', () => {
expect(response.newUser).to.be.false;
});
it('does not track a registration event for existing users', async () => {
const beforeEvents = await RegistrationEventModel.find({ userId: user._id });
await user.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
});
const registrationEvents = await RegistrationEventModel.find({ userId: user._id });
expect(registrationEvents).to.have.lengthOf(beforeEvents.length);
});
it('does not log into other account if social auth already exists', async () => {
const registerResponse = await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
@@ -281,24 +167,5 @@ describe('POST /user/auth/social', () => {
await expect(getProperty('users', user._id, '_ABtests')).to.eventually.be.a('object');
});
it('sets auth.timestamps.updated', async () => {
let oldUpdated = new Date(user.auth.timestamps.updated);
await user.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
});
await user.sync();
expect(user.auth.timestamps.updated).to.be.greaterThan(oldUpdated);
oldUpdated = new Date(user.auth.timestamps.updated);
// Do it again to ensure it updates even when nothing else changes
await api.post(endpoint, {
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
network,
});
await user.sync();
expect(user.auth.timestamps.updated).to.be.greaterThan(oldUpdated);
});
});
});
@@ -66,7 +66,7 @@ describe('GET /inbox/conversations', () => {
it('returns five messages when using page-query ', async () => {
const promises = [];
for (let i = 0; i < 50; i += 1) {
for (let i = 0; i < 10; i += 1) {
promises.push(user.post('/members/send-private-message', {
toUserId: user.id,
message: 'fourth',
@@ -1,66 +0,0 @@
import {
translate as t,
requester,
generateUser,
} from '../../../../helpers/api-integration/v4';
const ENDPOINT = '/user/auth/check-email';
describe('POST /user/auth/check-email', () => {
const email = 'SOmE-nEw-emAIl_2@example.net';
let api;
beforeEach(async () => {
api = requester();
});
it('returns email if it is not used yet', async () => {
const response = await api.post(ENDPOINT, {
email,
});
expect(response.email).to.eql(email);
expect(response.valid).to.be.true;
});
it('rejects if email is not provided', async () => {
await expect(api.post(ENDPOINT, {
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'Invalid request parameters.',
});
});
it('rejects if email is already taken', async () => {
const user = await generateUser();
const response = await api.post(ENDPOINT, {
email: user.auth.local.email,
});
expect(response).to.eql({
valid: false,
email: user.auth.local.email,
error: t('cannotFulfillReq'),
});
});
it('rejects if casing is different', async () => {
const user = await generateUser();
const response = await api.post(ENDPOINT, {
email: user.auth.local.email.toUpperCase(),
});
expect(response).to.eql({
valid: false,
email: user.auth.local.email.toUpperCase(),
error: t('cannotFulfillReq'),
});
});
it('rejects if email uses restricted domain', async () => {
const response = await api.post(ENDPOINT, {
email: 'fake@habitica.com',
});
expect(response.valid).to.be.false;
});
});
-67
View File
@@ -1,67 +0,0 @@
import md from 'habitica-markdown';
describe('habiticaMarkdown emoji plugin', () => {
it('renders standard emoji as Unicode', () => {
const result = md.render(':smile:');
expect(result).to.include('😄');
expect(result).not.to.include('img');
});
it('renders thumbsup emoji as Unicode', () => {
const result = md.render(':thumbsup:');
expect(result).to.include('👍');
});
it('renders +1 emoji as Unicode', () => {
const result = md.render(':+1:');
expect(result).to.include('👍');
});
it('renders melior as an img tag', () => {
const result = md.render(':melior:');
expect(result).to.include('<img class="habitica-emoji"');
expect(result).to.include('src="https://s3.amazonaws.com/habitica-assets/cdn/emoji/melior.png"');
expect(result).to.include('alt="melior"');
});
it('does NOT convert emoji inside markdown links', () => {
const result = md.render('[:smile: link](http://example.com)');
expect(result).to.include(':smile: link');
expect(result).not.to.include('😄');
});
it('converts emoji outside of links normally', () => {
const result = md.render(':smile: [link](http://example.com)');
expect(result).to.include('😄');
expect(result).to.include('link');
});
it('leaves removed custom emoji (bowtie) as literal text', () => {
const result = md.render(':bowtie:');
expect(result).to.include(':bowtie:');
expect(result).not.to.include('img');
});
it('leaves unknown shortcodes as literal text', () => {
const result = md.render(':nonexistent_emoji_xyz:');
expect(result).to.include(':nonexistent_emoji_xyz:');
});
it('renders new emoji not in the old dataset', () => {
const result = md.render(':yawning_face:');
expect(result).to.include('🥱');
});
it('supports unsafeHTMLRender', () => {
const result = md.unsafeHTMLRender('<b>bold</b> :smile:');
expect(result).to.include('<b>bold</b>');
expect(result).to.include('😄');
});
it('supports renderWithMentions', () => {
const result = md.renderWithMentions(':smile: @testuser', { userName: 'testuser' });
expect(result).to.include('😄');
expect(result).to.include('at-text');
expect(result).to.include('at-highlight');
});
});
+10 -1
View File
@@ -13,6 +13,7 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buy', () => {
let user;
const analytics = { track () {} };
beforeEach(() => {
user = generateUser({
@@ -31,6 +32,12 @@ describe('shared.ops.buy', () => {
},
},
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
it('returns error when key is not provided', async () => {
@@ -44,8 +51,10 @@ describe('shared.ops.buy', () => {
it('buys health potion', async () => {
user.stats.hp = 30;
await buy(user, { params: { key: 'potion' } });
await buy(user, { params: { key: 'potion' } }, analytics);
expect(user.stats.hp).to.eql(45);
expect(analytics.track).to.be.calledOnce;
});
it('adds equipment to inventory', async () => {
+7 -3
View File
@@ -29,9 +29,10 @@ describe('shared.ops.buyArmoire', () => {
const YIELD_EQUIPMENT = 0.5;
const YIELD_FOOD = 0.7;
const YIELD_EXP = 0.9;
const analytics = { track () {} };
async function buyArmoire (_user, _req) {
const buyOp = new BuyArmoireOperation(_user, _req);
async function buyArmoire (_user, _req, _analytics) {
const buyOp = new BuyArmoireOperation(_user, _req, _analytics);
return buyOp.purchase();
}
@@ -49,10 +50,12 @@ describe('shared.ops.buyArmoire', () => {
user.items.food = {};
sandbox.stub(randomValFns, 'trueRandom');
sinon.stub(analytics, 'track');
});
afterEach(() => {
randomValFns.trueRandom.restore();
analytics.track.restore();
});
context('failure conditions', () => {
@@ -144,7 +147,7 @@ describe('shared.ops.buyArmoire', () => {
expect(_.size(user.items.gear.owned)).to.equal(2);
await buyArmoire(user, {});
await buyArmoire(user, {}, analytics);
expect(_.size(user.items.gear.owned)).to.equal(3);
@@ -152,6 +155,7 @@ describe('shared.ops.buyArmoire', () => {
expect(armoireCount).to.eql(_.size(getFullArmoire()) - 2);
expect(user.stats.gp).to.eql(100);
expect(analytics.track).to.be.calledTwice;
});
});
});
+12 -3
View File
@@ -1,5 +1,6 @@
/* eslint-disable camelcase */
import sinon from 'sinon'; // eslint-disable-line no-shadow
import {
generateUser,
} from '../../../helpers/common.helper';
@@ -10,14 +11,15 @@ import i18n from '../../../../website/common/script/i18n';
import { BuyGemOperation } from '../../../../website/common/script/ops/buy/buyGem';
import planGemLimits from '../../../../website/common/script/libs/planGemLimits';
async function buyGem (user, req) {
const buyOp = new BuyGemOperation(user, req);
async function buyGem (user, req, analytics) {
const buyOp = new BuyGemOperation(user, req, analytics);
return buyOp.purchase();
}
describe('shared.ops.buyGem', () => {
let user;
const analytics = { track () {} };
const goldPoints = 40;
const gemsBought = 40;
const userGemAmount = 10;
@@ -33,16 +35,23 @@ describe('shared.ops.buyGem', () => {
},
},
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
context('Gems', () => {
it('purchases gems', async () => {
const [, message] = await buyGem(user, { params: { type: 'gems', key: 'gem' } });
const [, message] = await buyGem(user, { params: { type: 'gems', key: 'gem' } }, analytics);
expect(message).to.equal(i18n.t('plusGem', { count: 1 }));
expect(user.balance).to.equal(userGemAmount + 0.25);
expect(user.purchased.plan.gemsBought).to.equal(1);
expect(user.stats.gp).to.equal(goldPoints - planGemLimits.convRate);
expect(analytics.track).to.be.calledOnce;
});
it('purchases gems with a different language than the default', async () => {
+10 -3
View File
@@ -10,9 +10,10 @@ import i18n from '../../../../website/common/script/i18n';
describe('shared.ops.buyHealthPotion', () => {
let user;
const analytics = { track () {} };
async function buyHealthPotion (_user, _req) {
const buyOp = new BuyHealthPotionOperation(_user, _req);
async function buyHealthPotion (_user, _req, _analytics) {
const buyOp = new BuyHealthPotionOperation(_user, _req, _analytics);
return buyOp.purchase();
}
@@ -31,13 +32,19 @@ describe('shared.ops.buyHealthPotion', () => {
},
stats: { gp: 200 },
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
context('Potion', () => {
it('recovers 15 hp', async () => {
user.stats.hp = 30;
await buyHealthPotion(user, {});
await buyHealthPotion(user, {}, analytics);
expect(user.stats.hp).to.eql(45);
expect(analytics.track).to.be.calledOnce;
});
it('does not increase hp above 50', async () => {
+9 -5
View File
@@ -13,14 +13,15 @@ import {
import i18n from '../../../../website/common/script/i18n';
import { errorMessage } from '../../../../website/common/script/libs/errorMessage';
async function buyGear (user, req) {
const buyOp = new BuyMarketGearOperation(user, req);
async function buyGear (user, req, analytics) {
const buyOp = new BuyMarketGearOperation(user, req, analytics);
return buyOp.purchase();
}
describe('shared.ops.buyMarketGear', () => {
let user;
const analytics = { track () {} };
let clock;
beforeEach(() => {
@@ -46,12 +47,14 @@ describe('shared.ops.buyMarketGear', () => {
sinon.stub(shared, 'randomVal');
sinon.stub(shared.onboarding, 'checkOnboardingStatus');
sinon.stub(shared.fns, 'predictableRandom');
sinon.stub(analytics, 'track');
});
afterEach(() => {
shared.randomVal.restore();
shared.fns.predictableRandom.restore();
shared.onboarding.checkOnboardingStatus.restore();
analytics.track.restore();
if (clock) {
clock.restore();
@@ -62,7 +65,7 @@ describe('shared.ops.buyMarketGear', () => {
it('adds equipment to inventory', async () => {
user.stats.gp = 31;
await buyGear(user, { params: { key: 'armor_warrior_1' } });
await buyGear(user, { params: { key: 'armor_warrior_1' } }, analytics);
expect(user.items.gear.owned).to.eql({
weapon_warrior_0: true,
@@ -89,12 +92,13 @@ describe('shared.ops.buyMarketGear', () => {
eyewear_special_whiteHalfMoon: true,
eyewear_special_yellowHalfMoon: true,
});
expect(analytics.track).to.be.calledOnce;
});
it('adds the onboarding achievement to the user and checks the onboarding status', async () => {
user.stats.gp = 31;
await buyGear(user, { params: { key: 'armor_warrior_1' } });
await buyGear(user, { params: { key: 'armor_warrior_1' } }, analytics);
expect(user.addAchievement).to.be.calledOnce;
expect(user.addAchievement).to.be.calledWith('purchasedEquipment');
@@ -107,7 +111,7 @@ describe('shared.ops.buyMarketGear', () => {
user.stats.gp = 31;
user.achievements.purchasedEquipment = true;
await buyGear(user, { params: { key: 'armor_warrior_1' } });
await buyGear(user, { params: { key: 'armor_warrior_1' } }, analytics);
expect(user.addAchievement).to.not.be.called;
});
+5 -2
View File
@@ -14,6 +14,7 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buyMysterySet', () => {
let user;
const analytics = { track () {} };
let clock;
beforeEach(() => {
@@ -26,9 +27,11 @@ describe('shared.ops.buyMysterySet', () => {
},
},
});
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
if (clock) {
clock.restore();
}
@@ -90,7 +93,7 @@ describe('shared.ops.buyMysterySet', () => {
context('successful purchases', () => {
it('buys Steampunk Accessories Set', async () => {
user.purchased.plan.consecutive.trinkets = 1;
await buyMysterySet(user, { params: { key: '301404' } });
await buyMysterySet(user, { params: { key: '301404' } }, analytics);
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
expect(user.items.gear.owned).to.have.property('weapon_warrior_0', true);
@@ -103,7 +106,7 @@ describe('shared.ops.buyMysterySet', () => {
it('buys mystery set if it is available', async () => {
clock = sinon.useFakeTimers(new Date('2024-01-16'));
user.purchased.plan.consecutive.trinkets = 1;
await buyMysterySet(user, { params: { key: '201601' } });
await buyMysterySet(user, { params: { key: '201601' } }, analytics);
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
expect(user.items.gear.owned).to.have.property('shield_mystery_201601', true);
+5 -2
View File
@@ -12,9 +12,10 @@ describe('shared.ops.buyQuestGems', () => {
let user;
let clock;
const goldPoints = 40;
const analytics = { track () {} };
async function buyQuest (_user, _req) {
const buyOp = new BuyQuestWithGemOperation(_user, _req);
async function buyQuest (_user, _req, _analytics) {
const buyOp = new BuyQuestWithGemOperation(_user, _req, _analytics);
return buyOp.purchase();
}
@@ -24,11 +25,13 @@ describe('shared.ops.buyQuestGems', () => {
});
beforeEach(() => {
sinon.stub(analytics, 'track');
sinon.spy(pinnedGearUtils, 'removeItemByPath');
clock = sinon.useFakeTimers(new Date('2024-01-16'));
});
afterEach(() => {
analytics.track.restore();
pinnedGearUtils.removeItemByPath.restore();
clock.restore();
});
+17 -8
View File
@@ -12,15 +12,21 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buyQuest', () => {
let user;
const analytics = { track () {} };
async function buyQuest (_user, _req) {
const buyOp = new BuyQuestWithGoldOperation(_user, _req);
async function buyQuest (_user, _req, _analytics) {
const buyOp = new BuyQuestWithGoldOperation(_user, _req, _analytics);
return buyOp.purchase();
}
beforeEach(() => {
user = generateUser();
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
it('buys a Quest scroll', async () => {
@@ -29,11 +35,12 @@ describe('shared.ops.buyQuest', () => {
params: {
key: 'dilatoryDistress1',
},
});
}, analytics);
expect(user.items.quests).to.eql({
dilatoryDistress1: 1,
});
expect(user.stats.gp).to.equal(5);
expect(analytics.track).to.be.calledOnce;
});
it('if a user\'s count of a quest scroll is negative, it will be reset to 0 before incrementing when they buy a new one.', async () => {
@@ -42,9 +49,10 @@ describe('shared.ops.buyQuest', () => {
user.items.quests[key] = -1;
await buyQuest(user, {
params: { key },
});
}, analytics);
expect(user.items.quests[key]).to.equal(1);
expect(user.stats.gp).to.equal(5);
expect(analytics.track).to.be.calledOnce;
});
it('buys a Quest scroll with the right quantity if a string is passed for quantity', async () => {
@@ -53,13 +61,13 @@ describe('shared.ops.buyQuest', () => {
params: {
key: 'dilatoryDistress1',
},
});
}, analytics);
await buyQuest(user, {
params: {
key: 'dilatoryDistress1',
},
quantity: '3',
});
}, analytics);
expect(user.items.quests).to.eql({
dilatoryDistress1: 4,
@@ -74,7 +82,7 @@ describe('shared.ops.buyQuest', () => {
key: 'dilatoryDistress1',
},
quantity: 'a',
});
}, analytics);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
@@ -179,11 +187,12 @@ describe('shared.ops.buyQuest', () => {
params: {
key: 'dilatoryDistress3',
},
});
}, analytics);
expect(user.items.quests).to.eql({
dilatoryDistress3: 1,
});
expect(user.stats.gp).to.equal(100);
expect(analytics.track).to.be.calledOnce;
});
});
+11 -5
View File
@@ -14,17 +14,20 @@ import { errorMessage } from '../../../../website/common/script/libs/errorMessag
describe('shared.ops.buySpecialSpell', () => {
let user;
let clock;
const analytics = { track () {} };
async function buySpecialSpell (_user, _req) {
const buyOp = new BuySpellOperation(_user, _req);
async function buySpecialSpell (_user, _req, _analytics) {
const buyOp = new BuySpellOperation(_user, _req, _analytics);
return buyOp.purchase();
}
beforeEach(() => {
user = generateUser();
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
if (clock) {
clock.restore();
}
@@ -75,7 +78,7 @@ describe('shared.ops.buySpecialSpell', () => {
params: {
key: 'thankyou',
},
});
}, analytics);
expect(user.stats.gp).to.equal(1);
expect(user.items.special.thankyou).to.equal(1);
@@ -86,6 +89,7 @@ describe('shared.ops.buySpecialSpell', () => {
expect(message).to.equal(i18n.t('messageBought', {
itemText: item.text(),
}));
expect(analytics.track).to.be.calledOnce;
});
it('buys a limited card when it is available', async () => {
@@ -97,7 +101,7 @@ describe('shared.ops.buySpecialSpell', () => {
params: {
key: 'nye',
},
});
}, analytics);
expect(user.stats.gp).to.equal(1);
expect(user.items.special.nye).to.equal(1);
@@ -108,6 +112,7 @@ describe('shared.ops.buySpecialSpell', () => {
expect(message).to.equal(i18n.t('messageBought', {
itemText: item.text(),
}));
expect(analytics.track).to.be.calledOnce;
});
it('throws an error if the card is not currently available', async () => {
@@ -135,7 +140,7 @@ describe('shared.ops.buySpecialSpell', () => {
params: {
key: 'seafoam',
},
});
}, analytics);
expect(user.stats.gp).to.equal(1);
expect(user.items.special.seafoam).to.equal(1);
@@ -146,6 +151,7 @@ describe('shared.ops.buySpecialSpell', () => {
expect(message).to.equal(i18n.t('messageBought', {
itemText: item.text(),
}));
expect(analytics.track).to.be.calledOnce;
});
it('throws an error if the spell is not currently available', async () => {
+10 -3
View File
@@ -13,15 +13,21 @@ import { BuyHourglassMountOperation } from '../../../../website/common/script/op
describe('common.ops.hourglassPurchase', () => {
let user;
const analytics = { track () {} };
async function buyMount (_user, _req) {
const buyOp = new BuyHourglassMountOperation(_user, _req);
async function buyMount (_user, _req, _analytics) {
const buyOp = new BuyHourglassMountOperation(_user, _req, _analytics);
return buyOp.purchase();
}
beforeEach(() => {
user = generateUser();
sinon.stub(analytics, 'track');
});
afterEach(() => {
analytics.track.restore();
});
context('failure conditions', () => {
@@ -125,11 +131,12 @@ describe('common.ops.hourglassPurchase', () => {
it('buys a pet', async () => {
user.purchased.plan.consecutive.trinkets = 2;
const [, message] = await hourglassPurchase(user, { params: { type: 'pets', key: 'MantisShrimp-Base' } });
const [, message] = await hourglassPurchase(user, { params: { type: 'pets', key: 'MantisShrimp-Base' } }, analytics);
expect(message).to.eql(i18n.t('hourglassPurchase'));
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.items.pets).to.eql({ 'MantisShrimp-Base': 5 });
expect(analytics.track).to.be.calledOnce;
});
it('buys a mount', async () => {
+8 -4
View File
@@ -17,17 +17,20 @@ describe('shared.ops.purchase', () => {
let user;
let clock;
const goldPoints = 40;
const analytics = { track () {} };
before(() => {
user = generateUser({ 'stats.class': 'rogue' });
});
beforeEach(() => {
sinon.stub(analytics, 'track');
sinon.spy(pinnedGearUtils, 'removeItemByPath');
clock = sandbox.useFakeTimers(new Date('2024-01-10'));
});
afterEach(() => {
analytics.track.restore();
pinnedGearUtils.removeItemByPath.restore();
clock.restore();
});
@@ -184,10 +187,11 @@ describe('shared.ops.purchase', () => {
const type = 'eggs';
const key = 'Wolf';
await purchase(user, { params: { type, key } });
await purchase(user, { params: { type, key } }, analytics);
expect(user.items[type][key]).to.equal(1);
expect(pinnedGearUtils.removeItemByPath.notCalled).to.equal(true);
expect(analytics.track).to.be.calledOnce;
});
it('purchases hatchingPotions', async () => {
@@ -328,7 +332,7 @@ describe('shared.ops.purchase', () => {
const key = 'Wolf';
try {
await purchase(user, { params: { type, key }, quantity: 'jamboree' });
await purchase(user, { params: { type, key }, quantity: 'jamboree' }, analytics);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
@@ -341,7 +345,7 @@ describe('shared.ops.purchase', () => {
user.balance = 10;
try {
await purchase(user, { params: { type, key }, quantity: -2 });
await purchase(user, { params: { type, key }, quantity: -2 }, analytics);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
@@ -354,7 +358,7 @@ describe('shared.ops.purchase', () => {
user.balance = 10;
try {
await purchase(user, { params: { type, key }, quantity: 2.9 });
await purchase(user, { params: { type, key }, quantity: 2.9 }, analytics);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('invalidQuantity'));
+8 -17
View File
@@ -211,32 +211,22 @@ describe('shared.ops.rebirth', () => {
expect(user.achievements.rebirthLevel).to.equal(2);
});
it('increments rebirth achievements even when level is lower than previous', async () => {
it('does not increment rebirth achievements when level is lower than previous', async () => {
user.stats.lvl = 2;
user.achievements.rebirths = 1;
user.achievements.rebirthLevel = 3;
await rebirth(user);
expect(user.achievements.rebirths).to.equal(2);
expect(user.achievements.rebirths).to.equal(1);
expect(user.achievements.rebirthLevel).to.equal(3);
});
it('updates rebirthLevel when current level is higher than previous', async () => {
user.stats.lvl = 5;
user.achievements.rebirths = 1;
user.achievements.rebirthLevel = 3;
await rebirth(user);
expect(user.achievements.rebirths).to.equal(2);
expect(user.achievements.rebirthLevel).to.equal(5);
});
it('increments rebirth achievements when level is MAX_LEVEL', async () => {
it('always increments rebirth achievements when level is MAX_LEVEL', async () => {
user.stats.lvl = MAX_LEVEL;
user.achievements.rebirths = 1;
user.achievements.rebirthLevel = MAX_LEVEL;
// this value is not actually possible (actually capped at MAX_LEVEL) but makes a good test
user.achievements.rebirthLevel = MAX_LEVEL + 1;
await rebirth(user);
@@ -244,10 +234,11 @@ describe('shared.ops.rebirth', () => {
expect(user.achievements.rebirthLevel).to.equal(MAX_LEVEL);
});
it('increments rebirth achievements when level is greater than MAX_LEVEL', async () => {
it('always increments rebirth achievements when level is greater than MAX_LEVEL', async () => {
user.stats.lvl = MAX_LEVEL + 1;
user.achievements.rebirths = 1;
user.achievements.rebirthLevel = MAX_LEVEL;
// this value is not actually possible (actually capped at MAX_LEVEL) but makes a good test
user.achievements.rebirthLevel = MAX_LEVEL + 2;
await rebirth(user);
+1 -9
View File
@@ -20,9 +20,6 @@ describe('shared.ops.unlock', () => {
beforeEach(() => {
user = generateUser();
user.balance = usersStartingGems;
user.pinnedItems.push({ type: 'background', path: 'backgrounds.backgrounds042016.giant_florals' });
user.pinnedItems.push({ type: 'haircolor', path: 'hair.color.rainbow' });
user.pinnedItems.push({ type: 'shirt', path: 'shirt.convict' });
clock = sandbox.useFakeTimers(new Date('2024-04-10'));
});
@@ -275,7 +272,6 @@ describe('shared.ops.unlock', () => {
});
it('unlocks an item (appearance)', async () => {
expect(user.pinnedItems.findIndex(item => item.type === 'shirt')).to.not.equal(-1);
const path = unlockPath.split(',')[0];
const initialShirts = Object.keys(user.purchased.shirt).length;
const [, message] = await unlock(user, { query: { path } });
@@ -286,12 +282,11 @@ describe('shared.ops.unlock', () => {
);
expect(get(user.purchased, path)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 0.5);
expect(user.pinnedItems.findIndex(item => item.type === 'shirt')).to.equal(-1);
});
it('unlocks an item (hair color)', async () => {
user.purchased.hair.color = {};
expect(user.pinnedItems.findIndex(item => item.type === 'haircolor')).to.not.equal(-1);
const path = hairUnlockPath.split(',')[0];
const initialColorHair = Object.keys(user.purchased.hair.color).length;
const [, message] = await unlock(user, { query: { path } });
@@ -302,7 +297,6 @@ describe('shared.ops.unlock', () => {
);
expect(get(user.purchased, path)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 0.5);
expect(user.pinnedItems.findIndex(item => item.type === 'haircolor')).to.equal(-1);
});
it('unlocks an item (facial hair)', async () => {
@@ -340,7 +334,6 @@ describe('shared.ops.unlock', () => {
it('unlocks an item (background)', async () => {
const initialBackgrounds = Object.keys(user.purchased.background).length;
expect(user.pinnedItems.findIndex(item => item.type === 'background')).to.not.equal(-1);
const [, message] = await unlock(user, {
query: { path: backgroundUnlockPath },
});
@@ -351,7 +344,6 @@ describe('shared.ops.unlock', () => {
);
expect(get(user.purchased, backgroundUnlockPath)).to.be.true;
expect(user.balance).to.equal(usersStartingGems - 1.75);
expect(user.pinnedItems.findIndex(item => item.type === 'background')).to.equal(-1);
});
it('handles an invalid hair path gracefully', async () => {
-41
View File
@@ -1,41 +0,0 @@
import { getMatchingSwap, makeSubstitutionMap } from '../../website/common/script/content/constants/aprilFools';
describe('April Fools', () => {
describe('getMatchingSwap', () => {
it('returns Veggie for 2020', () => {
const swap = getMatchingSwap(new Date('2020-04-01'));
expect(swap).to.equal('Veggie');
});
it('returns Alien for 2026', () => {
const swap = getMatchingSwap(new Date('2026-04-01'));
expect(swap).to.equal('Alien');
});
it('Cycles through swaps correctly', () => {
const swap = getMatchingSwap(new Date('2027-04-01'));
expect(swap).to.equal('Veggie');
});
});
describe('makeSubstitutionMap', () => {
it('returns correct substitution for Veggie', () => {
const substitutions = makeSubstitutionMap('Veggie');
expect(substitutions.pets['Pet-Wolf-']).to.equal('Pet-Wolf-Veggie');
expect(substitutions.pets['Pet-TigerCub-']).to.equal('Pet-TigerCub-Veggie');
expect(substitutions.pets['Pet-Yarn-']).to.equal('Pet-BearCub-Veggie');
expect(substitutions.pets.default).to.equal('Pet-Dragon-Veggie');
expect(substitutions.pets.noPet).to.equal('Pet-Wolf-Veggie');
expect(substitutions.pets.noPetIOS).to.equal('Pet-TigerCub-Veggie');
expect(substitutions.pets.noPetAndroid).to.equal('Pet-Cactus-Veggie');
});
it('returns correct substitution for Cryptid', () => {
const substitutions = makeSubstitutionMap('Cryptid');
expect(substitutions.pets['Pet-Fox-']).to.equal('Pet-Fox-Cryptid');
expect(substitutions.pets['Pet-FlyingPig-']).to.equal('Pet-FlyingPig-Cryptid');
expect(substitutions.pets['Pet-Yarn-']).to.equal('Pet-BearCub-Cryptid');
expect(substitutions.pets.default).to.equal('Pet-Dragon-Cryptid');
expect(substitutions.pets.noPet).to.equal('Pet-Wolf-Cryptid');
expect(substitutions.pets.noPetAndroid).to.equal('Pet-Cactus-Cryptid');
});
});
});
+15
View File
@@ -54,4 +54,19 @@ describe('armoire', () => {
const febuaryItems = armoire.all;
expect(febuaryItems.length).to.equal(384);
});
it('sets have at least 2 items', () => {
const setMap = {};
forEach(armoire.all, item => {
// Gotta have one outlier
if (!item.set || item.set.startsWith('armoire-')) return;
if (setMap[item.set] === undefined) {
setMap[item.set] = 0;
}
setMap[item.set] += 1;
});
Object.keys(setMap).forEach(set => {
expect(setMap[set], set).to.be.at.least(2);
});
});
});
+6 -1
View File
@@ -1,7 +1,12 @@
import { STRING_DOES_NOT_EXIST_MSG } from '../helpers/content.helper';
import { STRING_ERROR_MSG, STRING_DOES_NOT_EXIST_MSG } from '../helpers/content.helper';
import translator from '../../website/common/script/content/translation';
describe('Translator', () => {
it('returns error message if string is not properly formatted', () => {
const improperlyFormattedString = translator('petName', { attr: 0 })();
expect(improperlyFormattedString).to.match(STRING_ERROR_MSG);
});
it('returns an error message if string does not exist', () => {
const stringDoesNotExist = translator('stringDoesNotExist')();
expect(stringDoesNotExist).to.match(STRING_DOES_NOT_EXIST_MSG);
@@ -40,6 +40,7 @@ function _requestMaker (user, method, additionalSets = {}) {
|| route.indexOf('/paypal') === 0
|| route.indexOf('/amazon') === 0
|| route.indexOf('/stripe') === 0
|| route.indexOf('/analytics') === 0
) {
url += `${route}`;
} else {
+2 -2
View File
@@ -1,8 +1,8 @@
import i18n from '../../website/common/script/i18n';
import './globals.helper';
import { contentTranslations } from '../../website/server/libs/i18n';
import { translations } from '../../website/server/libs/i18n';
i18n.translations = contentTranslations;
i18n.translations = translations;
export const STRING_ERROR_MSG = /^Error processing the string ".*". Please see Help > Report a Bug.$/;
export const STRING_DOES_NOT_EXIST_MSG = /^String '.*' not found.$/;
-1
View File
@@ -21,7 +21,6 @@ export async function getProperty (collectionName, id, path) {
// Specifically helpful for the GET /groups tests,
// resets the db to an empty state and creates a tavern document
export async function resetHabiticaDB () {
console.info('Resetting Habitica DB');
const groups = mongoose.connection.db.collection('groups');
const users = mongoose.connection.db.collection('users');
return mongoose.connection.dropDatabase()
+8
View File
@@ -12,12 +12,20 @@ module.exports = {
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
// TODO find a way to let eslint understand webpack aliases
'import/no-unresolved': 'off',
'import/no-extraneous-dependencies': 'off',
'import/extensions': 'off',
'prefer-regex-literals': 'warn',
'vue/no-v-html': 'off',
'vue/no-mutating-props': 'warn',
// this creates issues with the current way we have to push the process.env vars to webpack
// https://github.com/eslint/eslint/issues/14918
// https://github.com/webpack/webpack/issues/5392
// off for now, because any eslint --fix will then still do it anyway
// maybe this can be turned on again once we switch to newer vue/vite
// Important! process.env.XYZ should not be destructured
'prefer-destructuring': 'off',
'vue/html-self-closing': ['error', {
html: {
void: 'never',
+1 -1
View File
@@ -32,6 +32,6 @@
<script type="text/javascript" src="//cloudfront.loggly.com/js/loggly.tracker-latest.min.js" async></script>
<!-- Translations -->
<script type='text/javascript' src='/api/v4/i18n/core' vite-ignore></script>
<script type='text/javascript' src='/api/v4/i18n/browser-script' vite-ignore></script>
</body>
</html>
+3027 -4697
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -4,7 +4,6 @@
"private": true,
"scripts": {
"serve": "vite",
"serve:docker": "npx vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"test:unit": "vitest run",
@@ -28,13 +27,12 @@
"eslint-config-habitrpg": "6.2.0",
"eslint-plugin-mocha": "5.3.0",
"eslint-plugin-vue": "7.20.0",
"habitica-markdown": "^4.0.0",
"habitica-markdown": "^3.0.0",
"hellojs": "^1.20.0",
"intro.js": "^7.2.0",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"markdown-it": "^14.0.0",
"micromustache": "^8.0.3",
"moment": "^2.29.4",
"nconf": "^0.12.1",
"sass": "^1.63.4",
@@ -43,9 +41,10 @@
"timers-browserify": "^2.0.12",
"uuid": "^9.0.1",
"validator": "^13.9.0",
"vite": "^6.3.6",
"vite": "^6.0.0",
"vite-plugin-compression2": "^1.3.3",
"vue": "^2.7.10",
"vue-fragment": "^1.6.0",
"vue-mugen-scroll": "^0.2.6",
"vue-router": "^3.6.5",
"vuedraggable": "^2.24.3",
@@ -59,6 +58,8 @@
"jsdom": "^26.0.0",
"mocha": "^11.1.0",
"playwright": "^1.50.1",
"vitest": "^3.0.5"
"terser-webpack-plugin": "^5.3.10",
"vitest": "^3.0.5",
"webpack": "^5.94.0"
}
}
+10 -25
View File
@@ -108,6 +108,7 @@
<script>
import axios from 'axios';
import * as Analytics from '@/libs/analytics';
import { mapState } from '@/libs/store';
import snackbars from '@/components/snackbars/notifications';
import { LOCALSTORAGE_AUTH_KEY } from '@/libs/auth';
@@ -149,6 +150,10 @@ export default {
this.hideLoadingScreen();
}
});
this.$nextTick(() => {
// Load external scripts after the app has been rendered
Analytics.load();
});
axios.interceptors.response.use(response => { // Set up Response interceptors
// Verify that the user was not updated from another browser/app/client
@@ -203,37 +208,17 @@ export default {
return response;
}, error => { // Set up Error interceptors
if (!error.response) {
return Promise.reject(error);
}
if (error.response.status >= 400) {
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 deleted their account,
// Don't show errors from getting user details. These users have delete their account,
// but their chat message still exists.
const configExists = Boolean(error.response) && Boolean(error.response.config);
if (configExists) {
if (error.response.config.method === 'get' && error.response.config.url.indexOf('/api/v4/members/') !== -1) {
// @TODO: We resolve the promise because we need our caching to cache this user as tried
// Chat paging should help this, but maybe we can also find another solution..
return Promise.resolve(error);
}
// Also, a 404 occurs during routine attempt to log in with social,
// when we check for account already existing.
if (error.response.config.method === 'post' && (error.response.config.url.indexOf('/api/v4/user/auth/social') !== -1
|| error.response.config.url.indexOf('/api/v4/user/auth/apple') !== -1)) {
const socialEmail = error.response.data.message.split(': ')[1];
if (socialEmail) {
window.sessionStorage.setItem('social-email', socialEmail);
}
return Promise.resolve(error);
}
if (error.response.status === 404
&& error.response.config.method === 'get'
&& error.response.config.url.indexOf('/api/v4/groups/party') !== -1) {
return Promise.reject(error);
}
if (configExists && error.response.config.method === 'get' && error.response.config.url.indexOf('/api/v4/members/') !== -1) {
// @TODO: We resolve the promise because we need our caching to cache this user as tried
// Chat paging should help this, but maybe we can also find another solution..
return Promise.resolve(error);
}
const errorData = error.response.data;
+2 -21
View File
@@ -22,15 +22,8 @@
height: 219px;
}
.quest_alien {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/quest_alien.gif") no-repeat;
width: 219px;
height: 219px;
}
.Pet_HatchingPotion_Dessert, .Pet_HatchingPotion_Veggie, .Pet_HatchingPotion_Windup,
.Pet_HatchingPotion_VirtualPet, .Pet_HatchingPotion_Fungi, .Pet_HatchingPotion_Cryptid,
.Pet_HatchingPotion_Alien {
.Pet_HatchingPotion_VirtualPet, .Pet_HatchingPotion_Fungi, .Pet_HatchingPotion_Cryptid {
width: 68px;
height: 68px;
}
@@ -59,10 +52,6 @@
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Cryptid.gif") no-repeat;
}
.Pet_HatchingPotion_Alien {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Alien.gif") no-repeat;
}
.Gems {
display:inline-block;
margin-right:5px;
@@ -188,7 +177,7 @@
height: 96px;
}
.Mount_Head_Gryphon-Gryphatrice, .Mount_Body_Gryphon-Gryphatrice, .Mount_Head_Dragon-Hydra, .Mount_Body_Dragon-Hydra {
.Mount_Head_Gryphon-Gryphatrice, .Mount_Body_Gryphon-Gryphatrice {
width: 135px;
height: 135px;
}
@@ -201,14 +190,6 @@
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Mount-Body-Gryphatrice.gif") no-repeat;
}
.Mount_Head_Dragon-Hydra {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Mount_Head_Dragon-Hydra.gif") no-repeat;
}
.Mount_Body_Dragon-Hydra {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Mount_Body_Dragon-Hydra.gif") no-repeat;
}
.background_airship, .background_clocktower, .background_steamworks {
width: 141px;
height: 147px;
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -1,9 +0,0 @@
<svg width="378" height="176" viewBox="0 0 378 176" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0H378V174C378 175.105 377.105 176 376 176H1.99999C0.895423 176 0 175.105 0 174V0Z" fill="url(#paint0_linear_2257_239)"/>
<defs>
<linearGradient id="paint0_linear_2257_239" x1="378" y1="0" x2="0" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#925CF3"/>
<stop offset="1" stop-color="#34B5C1"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 448 B

@@ -1,37 +0,0 @@
<svg width="48" height="96" viewBox="0 0 48 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M-3.10104 12.0483C-2.82088 9.43721 -3.53422 6.57214 -5.6115 5.24584C-7.68877 3.91954 -9.89543 4.92709 -10.1422 6.808C-10.3891 8.68891 -9.06061 9.83066 -4.97737 13.9337C-3.81821 15.0985 -3.3812 14.6594 -3.10104 12.0483Z" stroke="#FFA624" stroke-width="4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.34089 15.2054C4.45116 13.6561 7.27707 12.8443 9.45877 13.9889C11.6405 15.1334 11.8754 17.5575 10.3778 18.7127C8.88016 19.868 7.23193 19.2828 1.65411 17.781C0.0706697 17.3546 0.230624 16.7548 2.34089 15.2054Z" stroke="#FFBE5D" stroke-width="4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.549002 12.0098C-3.61871 9.59194 -3.87667 15.8322 -2.20457 16.8023C-0.532473 17.7724 4.71671 14.4277 0.549002 12.0098Z" fill="#EE9109"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-1.76917 16.0445L13.637 24.9825L9.18965 32.7229L-6.21656 23.785L-1.76917 16.0445Z" fill="#F8F9F9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-6.90457 13.0652L3.36623 19.0238L-1.08116 26.7643L-11.352 20.8057L-6.90457 13.0652Z" fill="#FFBE5D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-1.76917 16.0445L3.36623 19.0238L1.88377 21.604L-3.25163 18.6247L-1.76917 16.0445Z" fill="#FFA624"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-6.21656 23.785L6.62195 31.2333L-3.75529 49.2944L-16.5938 41.8461L-6.21656 23.785Z" fill="#F8F9F9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-3.64886 25.2747L6.62195 31.2333L5.13948 33.8134L-5.13132 27.8548L-3.64886 25.2747Z" fill="#DDF3F3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.401307 24.1842L10.6721 30.1428L9.18965 32.7229L-1.08116 26.7643L0.401307 24.1842Z" fill="#DDF3F3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.7924 38.4607L17.9387 42.0519L21.31 40.5834L24.8838 41.4413L23.4225 38.0537L24.2762 34.4625L20.9049 35.9309L17.3311 35.0731L18.7924 38.4607Z" fill="white" fill-opacity="0.5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-3.93867 71.2331L-4.79238 74.8243L-1.42111 73.3559L2.15271 74.2137L0.691383 70.8261L1.54509 67.2349L-1.82618 68.7033L-5.4 67.8455L-3.93867 71.2331Z" fill="white" fill-opacity="0.5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.8949 25.3807L35.0583 29.8802L37.9424 26.2452L42.4202 25.0761L38.8028 22.178L37.6393 17.6786L34.7552 21.3135L30.2775 22.4826L33.8949 25.3807Z" fill="white" fill-opacity="0.5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.2596 71.999L40.579 68.1435L45.9507 88.2881L31.6312 92.1436L26.2596 71.999Z" fill="#F8F9F9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9401 75.8545L26.2589 71.9966L31.6273 92.1421L17.3084 96L11.9401 75.8545Z" fill="#DDF3F3"/>
<rect width="2.96589" height="20.8485" transform="matrix(0.965611 -0.25999 0.257652 0.966238 23.3957 72.7701)" fill="#FFA624"/>
<rect width="2.96589" height="20.8485" transform="matrix(0.965611 -0.25999 0.257652 0.966238 26.2596 71.999)" fill="#FFBE5D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.9999 90.0369L30.8638 89.2658L31.6312 92.1436L28.7673 92.9147L27.9999 90.0369Z" fill="#EE9109"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.3957 72.7701L26.2596 71.999L27.0269 74.8768L24.163 75.6479L23.3957 72.7701Z" fill="#EE9109"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9401 75.8545L23.3951 72.7682L24.162 75.6461L12.707 78.7325L11.9401 75.8545Z" fill="#C1E9E9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.5443 93.1213L27.9999 90.0369L28.7673 92.9147L17.3117 95.9991L16.5443 93.1213Z" fill="#C1E9E9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.1235 71.2279L40.579 68.1435L41.3464 71.0213L29.8908 74.1057L29.1235 71.2279Z" fill="#DDF3F3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.7277 88.4947L45.1833 85.4103L45.9507 88.2881L34.4951 91.3725L33.7277 88.4947Z" fill="#DDF3F3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.8638 89.2658L33.7277 88.4947L34.4951 91.3725L31.6312 92.1436L30.8638 89.2658Z" fill="#FFA624"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.2596 71.999L29.1235 71.2279L29.8908 74.1057L27.0269 74.8768L26.2596 71.999Z" fill="#FFA624"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.5224 56.3076C25.8087 53.7812 24.0792 51.3933 21.6588 50.9455C19.2383 50.4977 17.5679 52.2625 18.0403 54.0994C18.5126 55.9363 20.17 56.4948 25.4855 58.7621C26.9945 59.4057 27.236 58.834 26.5224 56.3076Z" stroke="#FFA624" stroke-width="4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.745 57.1864C34.124 54.9555 36.4415 53.1391 38.8911 53.3791C41.3406 53.6191 42.4621 55.7782 41.5042 57.413C40.5463 59.0479 38.7999 59.1258 33.0684 59.8329C31.4413 60.0337 31.366 59.4173 32.745 57.1864Z" stroke="#FFBE5D" stroke-width="4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.8923 54.898C25.1267 54.225 27.2139 60.108 29.1258 60.378C31.0378 60.648 34.6579 55.571 29.8923 54.898Z" fill="#EE9109"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.247 59.5115L46.8635 61.9994L45.6255 70.8503L28.0091 68.3625L29.247 59.5115Z" fill="#F8F9F9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.6306 57.0236L29.247 59.5114L28.0091 68.3624L10.3927 65.8745L11.6306 57.0236Z" fill="#DDF3F3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.3749 58.6822L35.1192 60.3408L33.8813 69.1917L22.137 67.5332L23.3749 58.6822Z" fill="#FFBE5D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.3749 58.6822L29.247 59.5115L28.0091 68.3625L22.137 67.5332L23.3749 58.6822Z" fill="#FFA624"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.247 59.5115L35.1192 60.3408L34.7065 63.2911L28.8344 62.4618L29.247 59.5115Z" fill="#FFA624"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.3749 58.6822L29.247 59.5115L28.8344 62.4618L22.9622 61.6326L23.3749 58.6822Z" fill="#EE9109"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.8053 62.9241L22.5496 64.5827L22.137 67.533L10.3927 65.8745L10.8053 62.9241Z" fill="#C1E9E9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.2939 66.2414L46.0382 67.9L45.6255 70.8503L33.8813 69.1917L34.2939 66.2414Z" fill="#DDF3F3"/>
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 B

+16 -29
View File
@@ -10,7 +10,7 @@
box-shadow: 0 1px 3px 0 rgba($black, 0.12), 0 1px 2px 0 rgba($black, 0.24);
color: $white;
&:hover:not(:disabled):not(.disabled), &:focus {
&:hover, &:focus {
box-shadow: 0 3px 6px 0 rgba($black, 0.16), 0 3px 6px 0 rgba($black, 0.24);
&.btn-flat {
@@ -28,25 +28,14 @@
&:disabled, &.disabled {
cursor: default;
color: $gray-200;
opacity: 1;
background-color: transparent;
color: $gray-50;
opacity: 0.75;
box-shadow: none;
background-color: $gray-700;
border: 2px solid transparent;
box-shadow:
0 1px 3px 0 rgba($black, 0.12),
0 1px 2px 0 rgba($black, 0.24);
font-family: 'Roboto', sans-serif;
font-weight: 700;
font-size: 14px;
line-height: 24px;
padding: 4px 12px;
min-height: 32px;
max-height: 32px;
gap: 8px;
border-radius: 4px;
.svg {
color: $gray-200;
color: $gray-300;
}
}
@@ -175,6 +164,7 @@
border: 2px solid transparent;
box-shadow: 0 1px 3px 0 rgba($black, 0.16), 0 1px 3px 0 rgba($black, 0.24);
&:hover:not(:disabled):not(.disabled) {
background: $maroon-100;
border: 2px solid transparent;
@@ -252,32 +242,29 @@
}
.btn-info {
background-color: $blue-100;
color: $black;
font-weight: 700;
background: $blue-50;
border: 2px solid transparent;
box-shadow: 0 1px 3px 0 rgba($black, 0.16), 0 1px 2px 0 rgba($black, 0.24);
box-shadow: 0 1px 3px 0 rgba($black, 0.16), 0 1px 3px 0 rgba($black, 0.24);
&:disabled {
background-color: $white;
background: $blue-50;
box-shadow: none;
}
&:hover:not(:disabled):not(.disabled) {
&:hover {
border: 2px solid transparent;
box-shadow: 0 3px 6px 0 rgba($black, 0.16), 0 3px 6px 0 rgba($black, 0.24);
}
&:focus {
background: $blue-100;
border: 2px solid $purple-400;
box-shadow: 0 3px 6px 0 rgba($black, 0.16), 0 3px 6px 0 rgba($black, 0.24);
color: $black;
}
&:hover:not(:disabled):not(.disabled) {
background-color: $blue-50;
color: $black;
background-color: $blue-100;
box-shadow: 0 3px 6px 0 rgba($black, 0.16), 0 3px 6px 0 rgba($black, 0.24);
}
&:active:not(:disabled):not(.disabled), &.active:not(:disabled):not(.disabled) {
-1
View File
@@ -42,7 +42,6 @@ ul {
font-weight: 400;
line-height: 1.75;
color: $purple-200;
cursor: pointer;
}
h4 {
-168
View File
@@ -1,168 +0,0 @@
// Inputs and textareas
input, textarea, input.form-control, textarea.form-control {
border-radius: 3px;
font-size: 14px;
line-height: 1.714;
padding: 4px 12px;
color: $gray-50;
border: 1px solid $gray-400;
&:hover:not(:disabled):not(:read-only):not(:focus):not(:disabled):not(.input-valid):not(.input-invalid):not(.dark) {
border-color: $gray-200;
}
&:active:not(:disabled):not(:read-only), &:focus:not(:disabled):not(:read-only),
&:active:not(:disabled):not(:read-only).dark, &:focus:not(:disabled):not(:read-only).dark {
border: 1px solid $purple-400;
outline: 1px solid $purple-400;
box-shadow: none;
}
&.input-valid {
padding-right: 27px;
background-image: url(@/assets/svg/for-css/check.svg);
background-size: 1rem;
border-color: $green-10;
}
&.input-invalid, .input-invalid:hover {
padding-right: 40px;
background-image: url(@/assets/svg/for-css/alert.svg);
background-size: 16px 16px;
border-color: $red-100;
}
&::-webkit-input-placeholder { /* Chrome/Opera/Safari */
color: $gray-200;
}
&::-moz-placeholder { /* Firefox 19+ */
color: $gray-200;
}
&:-ms-input-placeholder { /* IE 10+ */
color: $gray-200;
}
&:-moz-placeholder { /* Firefox 18- */
color: $gray-200;
}
&::placeholder { // Standard browsers
color: $gray-200;
}
.input-invalid.input-with-error {
margin-bottom: 0.5em;
}
&.dark {
background-color: $purple-100;
color: $white;
&:not(.input-valid):not(.input-invalid) {
border: 1px solid $purple-300;
}
&:focus {
background-color: $purple-100;
color: $white;
}
&:hover:not(:focus):not(:disabled):not(.input-valid):not(.input-invalid) {
border-color: $purple-400;
}
&::-webkit-input-placeholder { /* Chrome/Opera/Safari */
color: $purple-500;
}
&::-moz-placeholder { /* Firefox 19+ */
color: $purple-500;
}
&:-ms-input-placeholder { /* IE 10+ */
color: $purple-500;
}
&:-moz-placeholder { /* Firefox 18- */
color: $purple-500;
}
&::placeholder { // Standard browsers
color: $purple-500;
}
}
}
.input-error {
font-size: 12px;
line-height: 1.33;
color: $maroon-500;
}
// checkboxes
.custom-checkbox {
.custom-control-label::before {
border-radius: 2px;
}
.custom-control-input {
&:hover~.custom-control-label::before {
border-color: $gray-100;
}
&:checked~.custom-control-label::before {
background-color: $purple-300;
border-color: $purple-300;
}
&:hover:checked:not(:disabled)~.custom-control-label::before,
&:active:not(:disabled)~.custom-control-label::before {
background-color: $purple-400;
border-color: $purple-400;
}
&:checked~.custom-control-label::after {
width: 18px;
height: 18px;
background-image: url(@/assets/svg/for-css/checkbox-white.svg);
background-size: 13px 10px;
}
&:checked:disabled~.custom-control-label::after {
background-image: url(@/assets/svg/for-css/checkbox-gray.svg);
}
&:active~.custom-control-label::before {
background-color: inherit;
}
&:focus~.custom-control-label::before,
&:active~.custom-control-label::before {
border-color: $purple-400;
box-shadow: none;
}
&:disabled~.custom-control-label::before, &:disabled:checked~.custom-control-label::before {
border-color: $gray-400;
background-color: $gray-400;
}
&.dark {
~.custom-control-label::before {
border-color: $purple-100;
}
&:hover~.custom-control-label::before,
&:active~.custom-control-label::before {
border-color: $purple-50;
}
&:checked~.custom-control-label::before {
background-color: $purple-100;
border-color: $purple-100;
}
&:focus~.custom-control-label::before,
&:active~.custom-control-label::before {
border-color: $purple-400;
box-shadow: none;
}
&:disabled~.custom-control-label::before, &:disabled:checked~.custom-control-label::before {
border-color: $gray-400;
background-color: $gray-400;
}
}
}
}
@@ -58,11 +58,6 @@ h3.markdown {
img {
max-width: 100%;
}
.emoji-native {
font-size: 0.85em;
vertical-align: middle;
}
blockquote {
padding: 0 16px;
-4
View File
@@ -16,10 +16,6 @@
border-bottom: 0;
}
.d-content {
display: contents;
}
* {
transition: none;
}
@@ -1,16 +0,0 @@
.privacy-banner {
position: fixed;
bottom: 24px;
border-radius: 8px;
background-color: $white;
z-index: 5;
box-shadow: 0px 3px 6px 0px rgba(26, 24, 29, 0.16), 0px 3px 6px 0px rgba(26, 24, 29, 0.24);
width: calc(66vw + 96px);
@media only screen and (max-width: 992px) {
margin: auto 12.5%;
}
@media only screen and (min-width: 992px) {
margin: auto 14.5%;
}
}
+1 -1
View File
@@ -14,7 +14,7 @@
color: $purple-200;
}
li, p:not(.purple-600) {
li, p {
font-size: 16px;
}
@@ -111,10 +111,6 @@ h4 {
background-color: $green-100 !important;
}
.bg-purple-50 {
background-color: $purple-50 !important;
}
.bg-purple-100 {
background-color: $purple-100 !important;
}
@@ -123,10 +119,6 @@ h4 {
background-color: $purple-300 !important;
}
.bg-yellow-50 {
background-color: $yellow-50 !important;
}
.bg-white {
background-color: $white !important;
}
@@ -139,10 +131,6 @@ h4 {
color: $gray-50 !important;
}
.gray-100 {
color: $gray-100 !important;
}
.gray-200 {
color: $gray-200 !important;
}
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g fill="#FFFFFF" fill-rule="nonzero">
<polygon points="12.1973467 2 14 3.80265326 9.80187117 8 14 12.1973467 12.1973467 14 8 9.80187117 3.80265326 14 2 12.1973467 6.19812883 8 2 3.80265326 3.80265326 2 8 6.19812883"></polygon>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 504 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 13 10">
<path fill="#878190" fill-rule="evenodd" d="M4.662 9.832c-.312 0-.61-.123-.83-.344L0 5.657l1.662-1.662 2.934 2.934L10.534 0l1.785 1.529-6.764 7.893a1.182 1.182 0 0 1-.848.409l-.045.001"/>
</svg>

Before

Width:  |  Height:  |  Size: 261 B

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