Compare commits

..

92 Commits

Author SHA1 Message Date
Phillip Thelen
cd94aeeb1a Improve user history logging 2024-09-06 13:47:41 +02:00
Phillip Thelen
86ea34f0be Add script to notify on heroku deploys (#15286)
* add script for heroku to notify about a deploy

* add emoji to server name

* add fallback for when script is run outside of git repo

* make script use bash

* remove exit
2024-09-06 12:00:00 +02:00
Phillip Thelen
0d0a24deac Formatting fix 2024-09-06 11:59:08 +02:00
Phillip Thelen
3cfe099864 Improve hero saving 2024-09-06 11:58:16 +02:00
Phillip Thelen
cdcaca3188 fix armoire buying issue 2024-09-03 16:28:30 +02:00
Sabe Jones
55e39a1ec9 fix broken test (#15304)
Co-authored-by: Phillip Thelen <phillip@habitica.com>
2024-09-03 16:16:17 +02:00
Natalie
992e82cbcf 2024-09 Content Prebuild (#15295)
* 202409 subscriber gear

* 2024-09 enchanted armoire gear

* 2024 fall festival gear

* 2024-09 background

* 2024-09 pet quest

* typos and update featuredItems.js

* quest and typo fix

* fix subscriber set name

* text amendments

* update quest title
# Conflicts:
#	habitica-images
2024-09-03 15:33:54 +02:00
Phillip Thelen
9b33453040 fix lint 2024-09-03 15:30:50 +02:00
Phillip Thelen
cb865b171b fix tests 2024-09-03 15:15:20 +02:00
Phillip Thelen
68560894b9 improve setting client in history 2024-09-03 15:03:33 +02:00
Phillip Thelen
665b934e35 Improve admin panel stats input 2024-09-03 13:11:53 +02:00
Phillip Thelen
ff53a387d4 allow stats to be edited from admin panel 2024-08-28 16:03:29 +02:00
Phillip Thelen
0bd0ba096b show user history in admin panel 2024-08-28 13:42:19 +02:00
Phillip Thelen
38cad7102f log armoire, quoest response and cron events to history 2024-08-28 12:49:00 +02:00
Natalie
62f5b9698a Add rage button to debug menu (#15291)
* + Rage

* tinkering

* remove if statement wrapper and modify error message

* add test cases

* more work on test cases

* adding contexts to test cases

* test(debug): fix up tests

* fix(lint): whisepate

---------

Co-authored-by: Sabe Jones <sabe@habitica.com>
2024-08-28 12:49:00 +02:00
Sabe Jones
8a3824ec02 fix(CI): force apt update before fetching libkrb5-dev 2024-08-28 12:49:00 +02:00
Phillip Thelen
41fa200271 Improve the performance of some frequently used API calls (#15251)
* use lean for getting task lists

* Only load necessary user data for group-plans call

Also don’t make a db request for groups if the user is in none

* Only load necessary user fields for in app rewards

* Optimize updateStore by not checking every item

* Only load necessary user data for task scoring

* improve performance of inbox request calls

* merge fix

* fix scoring task call

* add quests to scoring call

* fix showing official pinned items

* also load achievements
2024-08-28 12:49:00 +02:00
Sabe Jones
91c810925a 5.27.3 2024-08-28 12:49:00 +02:00
Weblate
2887eab3ef Translated using Weblate (French)
Currently translated at 99.7% (792 of 794 strings)

Translated using Weblate (French)

Currently translated at 100.0% (387 of 387 strings)

Translated using Weblate (German)

Currently translated at 91.7% (2892 of 3153 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (182 of 182 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (182 of 182 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (182 of 182 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (182 of 182 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (182 of 182 strings)

Translated using Weblate (Turkish)

Currently translated at 92.2% (154 of 167 strings)

Translated using Weblate (Russian)

Currently translated at 99.5% (425 of 427 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (193 of 193 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.2% (764 of 794 strings)

Translated using Weblate (Russian)

Currently translated at 94.1% (2969 of 3153 strings)

Translated using Weblate (French)

Currently translated at 100.0% (3153 of 3153 strings)

Translated using Weblate (French)

Currently translated at 99.2% (788 of 794 strings)

Translated using Weblate (German)

Currently translated at 98.7% (232 of 235 strings)

Translated using Weblate (German)

Currently translated at 91.6% (2890 of 3153 strings)

Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: Filip Betko <filipbetko@gmail.com>
Co-authored-by: Natalie Luhrs <eilatan@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Vasily Maslyukov <vasily.maslyukov@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: billy <kreideraine@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/character/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/content/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/front/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/de/
Translation: Habitica/Achievements
Translation: Habitica/Character
Translation: Habitica/Content
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Groups
Translation: Habitica/Questscontent
Translation: Habitica/Subscriber
2024-08-28 12:49:00 +02:00
Phillip Thelen
4b1656f61c Correctly load bootstrap radio component (#15293) 2024-08-28 12:49:00 +02:00
Phillip Thelen
b42341e0ae better timezone handling 2024-08-28 12:49:00 +02:00
Phillip Thelen
267221544b fix loading user data in app.vue (#15292)
* fix handling user version

* fix notification display
2024-08-28 12:49:00 +02:00
Phillip Thelen
5ed9528241 load sounds from aws (#15249) 2024-08-28 12:49:00 +02:00
Phillip Thelen
2298f4cf7b Don’t load browser-script again if it’s already the correct language (#15263) 2024-08-28 12:49:00 +02:00
Phillip Thelen
b7cb743f14 Reduce size of client js bundles (#15264)
* add packages

* Only include the needed parts of BootstrapVue

* remove yargs from client

* treeshake validator library

* formatting

* fix import
2024-08-28 12:49:00 +02:00
Phillip Thelen
7f25232218 Refactor the root App to load less data for front page visits (#15265)
* refactor root app to not load everything when visiting landing page

# Conflicts:
#	website/client/src/app.vue

* fix lint

* fix hiding loading screen

* fix showing snackbars when not logged in

* remove console
2024-08-28 12:49:00 +02:00
Phillip Thelen
a7b9a78aa4 Improve rate limiting (#15272)
* Improve rate limiting

* make rate limiter config names more consistent

* fix tests and add new one

* correct math
2024-08-28 12:49:00 +02:00
Sabe Jones
6d7467ccaf Remove and clean up unused invite notif (#15279)
* fix(notifications): remove and clean up unused invite notif

* fix(lint): remove unused const

* refactor(invites): updateMany in migration, don't load inviter unless needed

* fix(lint): remove extra whitespace

* fix(groups): remove more broken inviter logic

---------

Co-authored-by: Sabe Jones <sabe@habitica.com>
2024-08-28 12:49:00 +02:00
Phillip Thelen
b2531ecd0d Fix schedule using wrong month at the beginning hours of month (#15290)
* Fix schedule using wrong month at the beginning hours of month

* fix broken test

* fix switchover for time based matchers

* Fix scheduling issue related to timezones

* Fix end date creating issues
2024-08-28 12:49:00 +02:00
Sabe Jones
83ffa929d5 5.27.2 2024-08-28 12:49:00 +02:00
Weblate
1ad5932d3d Translated using Weblate (French)
Currently translated at 97.8% (184 of 188 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (3153 of 3153 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (188 of 188 strings)

Translated using Weblate (Indonesian)

Currently translated at 96.2% (181 of 188 strings)

Translated using Weblate (Indonesian)

Currently translated at 88.8% (167 of 188 strings)

Translated using Weblate (French)

Currently translated at 100.0% (235 of 235 strings)

Translated using Weblate (French)

Currently translated at 99.8% (3148 of 3153 strings)

Translated using Weblate (Indonesian)

Currently translated at 87.7% (165 of 188 strings)

Translated using Weblate (French)

Currently translated at 99.1% (787 of 794 strings)

Translated using Weblate (German)

Currently translated at 95.3% (757 of 794 strings)

Translated using Weblate (German)

Currently translated at 99.4% (385 of 387 strings)

Translated using Weblate (German)

Currently translated at 100.0% (878 of 878 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (182 of 182 strings)

Translated using Weblate (Russian)

Currently translated at 96.0% (763 of 794 strings)

Translated using Weblate (Portuguese)

Currently translated at 35.1% (66 of 188 strings)

Translated using Weblate (Portuguese)

Currently translated at 25.5% (48 of 188 strings)

Co-authored-by: Catarina Rocha <caticalhau312@gmail.com>
Co-authored-by: Filip Betko <filipbetko@gmail.com>
Co-authored-by: Hanafi <naflizo@gmail.com>
Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
Co-authored-by: Natalie Luhrs <eilatan@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/de/
Translate-URL: https://translate.habitica.com/projects/habitica/content/de/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/es/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/id/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/front/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/es/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/de/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/fr/
Translation: Habitica/Backgrounds
Translation: Habitica/Content
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Questscontent
Translation: Habitica/Subscriber
2024-08-28 12:49:00 +02:00
Natalie
9f2bdaaf55 Update static navbar on mobile (#15289)
* update logo on static pages (mobile)

* remove typo
2024-08-28 12:49:00 +02:00
Phillip Thelen
33a696be4d Add a timeout to mongoldb connections (#15258)
* Add option to set a socket timeout for mongodb requests

* Handle mongodb timeouts better

* add default to config
2024-08-28 12:49:00 +02:00
Natalie
7aeec2e8eb Navigation Bar Visual Improvements (#15278)
* update privacy policy

* Fix serving memoized content

* add missing info to mystery item strings

* add missing string info to July mystery items

* fix food

* melior updates - loading screen & menu bar

* updates to currency tray spacing

* table styling

* one last table style

* add margin adjustment

* fix spacing

* clean up spacing styles

* remove table rounded corners

* correct faq tyops

* spacing updates

* fix web challenge instructions

* beach umbrella you are not shady except when you are

* fix mobile margin weirdness

* update wording on answers

* mobile spacing

* add chart showing details of item 1.4

* revert unrelated changes

* melior centering and icon spacing

* remove unnecessary files

* rejiggering spacing

* remove errant comma

* remove unnecessary files

* remove duplicated class

* remove duplicated class

* ...and more spacing

* notification bubble

* notification icon & currency alignment

* fix(CSS): clean up some unnecessary classes

---------

Co-authored-by: Phillip Thelen <phillip@habitica.com>
Co-authored-by: Sabe Jones <sabe@habitica.com>
2024-08-28 12:49:00 +02:00
Phillip Thelen
187f6a2547 fix date dependatent time travel test (#15285) 2024-08-28 12:48:11 +02:00
Natalie
77a6335706 update armorArmoireBasketballUniformNotes to correct stat display (#15262)
Co-authored-by: Sabe Jones <sabe@habitica.com>
2024-08-28 12:48:11 +02:00
Natalie
db913a1373 FAQ Typos (#15282)
* update privacy policy

* Fix serving memoized content

* add missing info to mystery item strings

* add missing string info to July mystery items

* fix food

* melior updates - loading screen & menu bar

* updates to currency tray spacing

* table styling

* one last table style

* add margin adjustment

* fix spacing

* clean up spacing styles

* remove table rounded corners

* correct faq tyops

* spacing updates

* fix web challenge instructions

* update wording on answers

* revert unrelated changes

---------

Co-authored-by: Phillip Thelen <phillip@habitica.com>
Co-authored-by: Sabe Jones <sabe@habitica.com>
2024-08-28 12:48:11 +02:00
Sabe Jones
13b51dca74 5.27.1 2024-08-28 12:48:11 +02:00
Weblate
30da77022f Translated using Weblate (Portuguese)
Currently translated at 24.4% (46 of 188 strings)

Translated using Weblate (Japanese)

Currently translated at 97.8% (3085 of 3153 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 95.6% (109 of 114 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (259 of 259 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (140 of 140 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (264 of 264 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (22 of 22 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.4% (3105 of 3153 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (240 of 240 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 79.2% (149 of 188 strings)

Translated using Weblate (Portuguese)

Currently translated at 23.4% (44 of 188 strings)

Translated using Weblate (Portuguese)

Currently translated at 23.4% (44 of 188 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 94.7% (752 of 794 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (387 of 387 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (193 of 193 strings)

Translated using Weblate (Portuguese)

Currently translated at 95.3% (184 of 193 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (878 of 878 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (878 of 878 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.2% (764 of 794 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (140 of 140 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (94 of 94 strings)

Translated using Weblate (Indonesian)

Currently translated at 76.5% (144 of 188 strings)

Translated using Weblate (Indonesian)

Currently translated at 76.5% (144 of 188 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (140 of 140 strings)

Co-authored-by: Catarina Rocha <caticalhau312@gmail.com>
Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: Hanafi <naflizo@gmail.com>
Co-authored-by: Muhammad Hanafi <Cytricgame@gmail.com>
Co-authored-by: Márcio Ramos Corrêa <marcio.ramos.correa@gmail.com>
Co-authored-by: Pierre Huang <3541262043@qq.com>
Co-authored-by: Static Meteor <josusantaeufemiaiglesias@gmail.com>
Co-authored-by: TOMA Mitsuru <toma0001@gmail.com>
Co-authored-by: Tetiana <merekka13@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/es/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/character/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/character/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/content/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/id/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/loginincentives/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/quests/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/uk/
Translation: Habitica/Achievements
Translation: Habitica/Backgrounds
Translation: Habitica/Character
Translation: Habitica/Content
Translation: Habitica/Faq
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Limited
Translation: Habitica/Loginincentives
Translation: Habitica/Pets
Translation: Habitica/Quests
Translation: Habitica/Questscontent
Translation: Habitica/Settings
Translation: Habitica/Tasks
2024-08-28 12:48:11 +02:00
Sabe Jones
d2017c276d fix(lint): clean up migration for ongoing use 2024-08-28 12:48:11 +02:00
Sabe Jones
6a9d5a826c chore(migration): set up for Naming Day 2024 2024-08-28 12:48:11 +02:00
Phillip Thelen
f137c472da fix displaying if a pet is hatchable (#15281) 2024-08-28 12:48:03 +02:00
dependabot[bot]
c6086b958e Bump fast-xml-parser from 4.3.4 to 4.4.1 (#15280)
Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) from 4.3.4 to 4.4.1.
- [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases)
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v4.3.4...v4.4.1)

---
updated-dependencies:
- dependency-name: fast-xml-parser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sabe Jones <sabe@habitica.com>
2024-08-28 12:48:03 +02:00
Sabe Jones
9d4a2a1437 fix(customization): restore broken popovers 2024-08-28 12:48:03 +02:00
Sabe Jones
dcddbcbda3 fix(lint): remove console statement 2024-08-28 12:48:03 +02:00
Sabe Jones
9891658da7 Squashed commit of the following:
commit 8ed95731cb
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Jul 23 17:59:56 2024 +0200

    fix hatched dialog

commit 53242ad96c
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Jul 23 17:38:13 2024 +0200

    fix popover not showing

commit ce4bfd25bd
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Jul 3 17:28:30 2024 +0200

    move item popover to own component

commit 2e6a300c46
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Jul 1 18:48:24 2024 +0200

    make scaled sprites look nice

commit a3cbadb8c2
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Jul 1 18:48:17 2024 +0200

    fix hatching dialog

commit 0e5126df5e
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Jul 1 18:48:12 2024 +0200

    fix popover alignment

commit 7362af9236
Author: Phillip Thelen <phillip@habitica.com>
Date:   Fri Jun 28 17:07:03 2024 +0200

    fix item display

commit cf353efdb7
Author: Phillip Thelen <phillip@habitica.com>
Date:   Fri Jun 28 16:59:13 2024 +0200

    fix pet display

commit caf0cba9f2
Author: Phillip Thelen <phillip@habitica.com>
Date:   Fri Jun 28 15:24:39 2024 +0200

    fix background icon display

commit 3b06febc01
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Jun 26 12:30:04 2024 +0200

    fix sprites for notifications

commit 160b2debdc
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Jun 26 10:35:13 2024 +0200

    fix gear display in profile

commit b200a2f17d
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Jun 26 10:28:11 2024 +0200

    fix sprites for keys to the kennel

commit 3614e7a8fb
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Jun 26 10:28:00 2024 +0200

    fix sprites on avatar customization

commit 35f993d055
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Jun 26 09:18:41 2024 +0200

    fix hover icons

commit 28fc80115e
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Jun 11 16:50:37 2024 +0200

    remove console

commit b041c67679
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Jun 11 15:18:44 2024 +0200

    more lint fixes

commit f4261d0440
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Jun 11 15:18:16 2024 +0200

    fix lint

commit 878ee8f77b
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Jun 11 13:23:08 2024 +0200

    support gifs

commit aac24715aa
Author: Phillip Thelen <phillip@habitica.com>
Date:   Tue Jun 11 13:15:52 2024 +0200

    move avatar customization to sprites

commit f4d3663130
Author: Phillip Thelen <phillip@habitica.com>
Date:   Fri Jun 7 17:25:19 2024 +0200

    Move more sprites out of css

commit 6e6b4c981a
Author: Phillip Thelen <phillip@habitica.com>
Date:   Fri Jun 7 16:59:30 2024 +0200

    add new sprite to item and shopItem component

commit 8712413f5d
Author: Phillip Thelen <phillip@habitica.com>
Date:   Fri Jun 7 16:37:24 2024 +0200

    use new sprites for pets list

commit 1172893826
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Jun 5 09:43:51 2024 +0200

    Begin building new system for loading sprites
2024-08-28 12:48:03 +02:00
Phillip Thelen
f506044aa6 Seasonal gear fix (#15255)
* cleanup unneeded season definitions

* assign first winter seasonal gear right season

* add missing winter definition

* Fix enddate for winter galas

* fix lint

* fix halloween sprites

* set season

* fix loading habitoween sprites

* add missing customization shop sprites

* Fix test

* update customization shop sprites
2024-08-28 12:48:02 +02:00
Phillip Thelen
3cbb67c71a Steampunk gear should not have an end date (#15256) 2024-08-28 12:48:02 +02:00
Sabe Jones
d7ed938efc fix(lint): clean up migration for ongoing use 2024-08-28 12:48:02 +02:00
Sabe Jones
134401d153 chore(migration): set up for Naming Day 2024 2024-08-28 12:48:02 +02:00
Sabe Jones
fb29ee22f8 5.27.0 2024-08-28 12:48:02 +02:00
Weblate
bf5825f1a9 Translated using Weblate (French)
Currently translated at 99.7% (386 of 387 strings)

Translated using Weblate (French)

Currently translated at 100.0% (878 of 878 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (235 of 235 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (3153 of 3153 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (794 of 794 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (387 of 387 strings)

Translated using Weblate (Spanish)

Currently translated at 99.7% (876 of 878 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (182 of 182 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (140 of 140 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.9% (129 of 133 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (22 of 22 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.5% (3067 of 3143 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (240 of 240 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 78.1% (147 of 188 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.2% (764 of 794 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (140 of 140 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (234 of 234 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 94.7% (108 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.8% (261 of 264 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (427 of 427 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.2% (3055 of 3143 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 76.5% (144 of 188 strings)

Translated using Weblate (Indonesian)

Currently translated at 39.3% (74 of 188 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.2% (764 of 794 strings)

Translated using Weblate (Spanish)

Currently translated at 99.3% (789 of 794 strings)

Translated using Weblate (Indonesian)

Currently translated at 79.1% (72 of 91 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (384 of 384 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (377 of 377 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (110 of 110 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese)

Currently translated at 97.6% (163 of 167 strings)

Translated using Weblate (Japanese)

Currently translated at 96.8% (760 of 785 strings)

Co-authored-by: Alex Kalergi <nitrogenphospat@proton.me>
Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: Filip Betko <filipbetko@gmail.com>
Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
Co-authored-by: Muhammad Hanafi <Cytricgame@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: TOMA Mitsuru <toma0001@gmail.com>
Co-authored-by: Tetiana <merekka13@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/id/
Translate-URL: https://translate.habitica.com/projects/habitica/content/es/
Translate-URL: https://translate.habitica.com/projects/habitica/content/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/content/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/content/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/id/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/front/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/es/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/loginincentives/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/es/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/es/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/uk/
Translation: Habitica/Achievements
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Communityguidelines
Translation: Habitica/Content
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Loginincentives
Translation: Habitica/Npc
Translation: Habitica/Pets
Translation: Habitica/Questscontent
Translation: Habitica/Subscriber
Translation: Habitica/Tasks
2024-08-28 12:48:02 +02:00
Natalie
56b3a881db August 2024 Content Prebuild (#15277)
* add sprites

* 2024-08 content build

* fixing stuff

* update boss rage effects

* update quest rage text and releaseDates.js

* update shop-featuredItems.js

* fricken fracken beach umbrella

* more beach umbrella

* remove egregious typo
2024-08-28 12:48:02 +02:00
Sabe Jones
569b2fce2d 5.26.3 2024-08-28 12:48:02 +02:00
Weblate
29fe48bb17 Translated using Weblate (German)
Currently translated at 91.8% (2888 of 3143 strings)

Translated using Weblate (Slovak)

Currently translated at 97.2% (177 of 182 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (785 of 785 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (56 of 56 strings)

Translated using Weblate (Slovak)

Currently translated at 92.3% (12 of 13 strings)

Translated using Weblate (German)

Currently translated at 91.7% (2883 of 3143 strings)

Translated using Weblate (Slovak)

Currently translated at 96.7% (176 of 182 strings)

Translated using Weblate (Indonesian)

Currently translated at 33.5% (63 of 188 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 2.6% (5 of 188 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.4% (765 of 785 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (384 of 384 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (377 of 377 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (193 of 193 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (193 of 193 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Indonesian)

Currently translated at 32.9% (62 of 188 strings)

Translated using Weblate (Indonesian)

Currently translated at 29.2% (55 of 188 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (140 of 140 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (140 of 140 strings)

Translated using Weblate (German)

Currently translated at 91.6% (2881 of 3143 strings)

Translated using Weblate (Slovak)

Currently translated at 93.9% (171 of 182 strings)

Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (110 of 110 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (140 of 140 strings)

Translated using Weblate (Turkish)

Currently translated at 80.4% (107 of 133 strings)

Translated using Weblate (Turkish)

Currently translated at 7.9% (15 of 188 strings)

Translated using Weblate (Russian)

Currently translated at 95.6% (751 of 785 strings)

Translated using Weblate (French)

Currently translated at 98.3% (772 of 785 strings)

Translated using Weblate (German)

Currently translated at 91.6% (2879 of 3143 strings)

Translated using Weblate (French)

Currently translated at 98.0% (770 of 785 strings)

Co-authored-by: Adithya Mahadev. B <adithyamahadev2521@gmail.com>
Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: Ece Ekinci <plutonium3613@gmail.com>
Co-authored-by: Filip Betko <filipbetko@gmail.com>
Co-authored-by: Hanafi <naflizo@gmail.com>
Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
Co-authored-by: Natalie Luhrs <eilatan@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Tetiana <merekka13@gmail.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/character/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/character/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/content/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/content/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/id/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/front/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/es/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/spells/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/uk/
Translation: Habitica/Achievements
Translation: Habitica/Challenge
Translation: Habitica/Character
Translation: Habitica/Content
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Npc
Translation: Habitica/Questscontent
Translation: Habitica/Rebirth
Translation: Habitica/Spells
Translation: Habitica/Tasks
2024-08-28 12:48:02 +02:00
Sabe Jones
399d406ebb feat(avatar): popovers for customization 2024-08-28 12:48:02 +02:00
Phillip Thelen
bacb015f62 fix purchasing wacky potions (#15276) 2024-08-28 12:48:02 +02:00
Phillip Thelen
e5cb82e20f Fix some scheduling issues (#15274)
* fix logic for time travelers schedule

* fix potion availability

* fix tests
2024-08-28 12:48:02 +02:00
Sabe Jones
04573ff422 5.26.2 2024-08-28 12:47:56 +02:00
Weblate
173ee9f929 Translated using Weblate (Chinese (Simplified))
Currently translated at 99.6% (872 of 875 strings)

Translated using Weblate (German)

Currently translated at 91.5% (2877 of 3143 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (110 of 110 strings)

Translated using Weblate (Spanish)

Currently translated at 98.2% (771 of 785 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.9% (3046 of 3143 strings)

Translated using Weblate (Portuguese)

Currently translated at 57.0% (1793 of 3143 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.4% (765 of 785 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (13 of 13 strings)

Translated using Weblate (Italian)

Currently translated at 87.5% (7 of 8 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (22 of 22 strings)

Translated using Weblate (Italian)

Currently translated at 86.1% (368 of 427 strings)

Translated using Weblate (Italian)

Currently translated at 11.1% (21 of 188 strings)

Translated using Weblate (Italian)

Currently translated at 94.2% (740 of 785 strings)

Translated using Weblate (Italian)

Currently translated at 54.9% (50 of 91 strings)

Translated using Weblate (Italian)

Currently translated at 95.7% (45 of 47 strings)

Translated using Weblate (Italian)

Currently translated at 97.4% (188 of 193 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Italian)

Currently translated at 74.9% (194 of 259 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (94 of 94 strings)

Translated using Weblate (German)

Currently translated at 91.4% (2875 of 3143 strings)

Translated using Weblate (Italian)

Currently translated at 94.6% (828 of 875 strings)

Translated using Weblate (Italian)

Currently translated at 94.0% (823 of 875 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (427 of 427 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 76.5% (144 of 188 strings)

Translated using Weblate (German)

Currently translated at 91.4% (2873 of 3143 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (94 of 94 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (94 of 94 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (113 of 113 strings)

Translated using Weblate (Czech)

Currently translated at 85.7% (114 of 133 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (427 of 427 strings)

Translated using Weblate (German)

Currently translated at 91.3% (2871 of 3143 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (133 of 133 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (240 of 240 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (22 of 22 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (113 of 113 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (133 of 133 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1 of 1 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (22 of 22 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 95.4% (21 of 22 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (22 of 22 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (427 of 427 strings)

Translated using Weblate (German)

Currently translated at 91.2% (2869 of 3143 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (240 of 240 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (240 of 240 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (188 of 188 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (193 of 193 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (193 of 193 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (110 of 110 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (110 of 110 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (113 of 113 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (133 of 133 strings)

Translated using Weblate (French)

Currently translated at 100.0% (133 of 133 strings)

Translated using Weblate (German)

Currently translated at 98.4% (131 of 133 strings)

Translated using Weblate (French)

Currently translated at 100.0% (264 of 264 strings)

Translated using Weblate (French)

Currently translated at 100.0% (22 of 22 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (427 of 427 strings)

Translated using Weblate (French)

Currently translated at 100.0% (3143 of 3143 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.5% (239 of 240 strings)

Translated using Weblate (French)

Currently translated at 100.0% (240 of 240 strings)

Translated using Weblate (German)

Currently translated at 98.7% (237 of 240 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (384 of 384 strings)

Translated using Weblate (Slovak)

Currently translated at 97.9% (189 of 193 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (110 of 110 strings)

Translated using Weblate (Hebrew)

Currently translated at 92.8% (155 of 167 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (259 of 259 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (182 of 182 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (182 of 182 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.4% (781 of 785 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (259 of 259 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (140 of 140 strings)

Translated using Weblate (Portuguese)

Currently translated at 65.3% (153 of 234 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.8% (261 of 264 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.9% (3046 of 3143 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 76.5% (144 of 188 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 76.5% (144 of 188 strings)

Translated using Weblate (Indonesian)

Currently translated at 28.1% (53 of 188 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.4% (781 of 785 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (377 of 377 strings)

Translated using Weblate (Portuguese)

Currently translated at 98.1% (377 of 384 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (193 of 193 strings)

Translated using Weblate (Portuguese)

Currently translated at 97.6% (163 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (258 of 259 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (140 of 140 strings)

Translated using Weblate (German)

Currently translated at 91.2% (2867 of 3143 strings)

Translated using Weblate (Ukrainian)

Currently translated at 98.4% (773 of 785 strings)

Translated using Weblate (French)

Currently translated at 100.0% (234 of 234 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (94 of 94 strings)

Translated using Weblate (French)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (60 of 60 strings)

Translated using Weblate (Ukrainian)

Currently translated at 61.7% (1940 of 3143 strings)

Translated using Weblate (Portuguese)

Currently translated at 57.0% (1793 of 3143 strings)

Translated using Weblate (German)

Currently translated at 91.1% (2865 of 3143 strings)

Translated using Weblate (Japanese)

Currently translated at 99.4% (3085 of 3102 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (234 of 234 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (264 of 264 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (384 of 384 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (113 of 113 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (114 of 114 strings)

Translated using Weblate (Ukrainian)

Currently translated at 61.7% (1940 of 3143 strings)

Translated using Weblate (German)

Currently translated at 91.0% (2863 of 3143 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (234 of 234 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (113 of 113 strings)

Translated using Weblate (Slovak)

Currently translated at 74.9% (588 of 785 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (113 of 113 strings)

Translated using Weblate (German)

Currently translated at 95.6% (109 of 114 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (427 of 427 strings)

Translated using Weblate (German)

Currently translated at 91.0% (2861 of 3143 strings)

Translated using Weblate (German)

Currently translated at 98.7% (231 of 234 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (113 of 113 strings)

Translated using Weblate (German)

Currently translated at 99.2% (262 of 264 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (234 of 234 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (264 of 264 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (3143 of 3143 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (384 of 384 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (140 of 140 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (233 of 233 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (286 of 286 strings)

Translated using Weblate (German)

Currently translated at 90.9% (2857 of 3143 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.4% (382 of 384 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.4% (382 of 384 strings)

Translated using Weblate (Slovak)

Currently translated at 97.6% (375 of 384 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (German)

Currently translated at 99.7% (383 of 384 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.8% (3043 of 3143 strings)

Translated using Weblate (German)

Currently translated at 90.8% (2855 of 3143 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.7% (3041 of 3143 strings)

Translated using Weblate (French)

Currently translated at 99.6% (3132 of 3143 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (377 of 377 strings)

Translated using Weblate (French)

Currently translated at 100.0% (384 of 384 strings)

Translated using Weblate (Indonesian)

Currently translated at 91.3% (799 of 875 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (188 of 188 strings)

Translated using Weblate (German)

Currently translated at 98.9% (380 of 384 strings)

Translated using Weblate (Indonesian)

Currently translated at 95.3% (184 of 193 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (193 of 193 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.7% (377 of 378 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (47 of 47 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (188 of 188 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (193 of 193 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Indonesian)

Currently translated at 94.0% (157 of 167 strings)

Translated using Weblate (Japanese)

Currently translated at 99.4% (192 of 193 strings)

Translated using Weblate (French)

Currently translated at 99.0% (3114 of 3143 strings)

Translated using Weblate (German)

Currently translated at 90.7% (2853 of 3143 strings)

Translated using Weblate (French)

Currently translated at 100.0% (785 of 785 strings)

Translated using Weblate (Indonesian)

Currently translated at 94.0% (157 of 167 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (188 of 188 strings)

Translated using Weblate (German)

Currently translated at 98.5% (421 of 427 strings)

Translated using Weblate (French)

Currently translated at 98.7% (3105 of 3143 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (188 of 188 strings)

Translated using Weblate (French)

Currently translated at 100.0% (193 of 193 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (193 of 193 strings)

Translated using Weblate (German)

Currently translated at 98.4% (190 of 193 strings)

Translated using Weblate (French)

Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (140 of 140 strings)

Translated using Weblate (Turkish)

Currently translated at 5.8% (11 of 188 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (785 of 785 strings)

Translated using Weblate (German)

Currently translated at 98.4% (773 of 785 strings)

Translated using Weblate (German)

Currently translated at 65.9% (60 of 91 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (189 of 189 strings)

Translated using Weblate (Spanish)

Currently translated at 99.7% (873 of 875 strings)

Translated using Weblate (German)

Currently translated at 100.0% (875 of 875 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (773 of 773 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (868 of 868 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (3143 of 3143 strings)

Co-authored-by: Alex G <powerali18@gmail.com>
Co-authored-by: Antonio Spinelli <tonicospinelli@users.noreply.translate.habitica.com>
Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: Elisa Bailo <bailoelisa5@gmail.com>
Co-authored-by: Eva <kornmann.eva@gmx.de>
Co-authored-by: Filip Betko <filipbetko@gmail.com>
Co-authored-by: Isabela de Carvalho <isabela.c.escritora@gmail.com>
Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
Co-authored-by: Justcallme rye <Blizzardscf32@gmail.com>
Co-authored-by: Miguel Rocha de Macedo Amaral <miguelrmamaral@gmail.com>
Co-authored-by: Natalie Luhrs <eilatan@gmail.com>
Co-authored-by: Paolo Bizzarri <pibizza@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: TOMA Mitsuru <toma0001@gmail.com>
Co-authored-by: Tetiana <merekka13@gmail.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Vinicius Rodrigues <suburbanizar@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Yan <ariamao0802@gmail.com>
Co-authored-by: billypat <kreideraine@gmail.com>
Co-authored-by: diamondmonster09 <diamondmonster09@gmail.com>
Co-authored-by: tamara <tab.bravani@gmail.com>
Co-authored-by: 过客是个铁憨憨 <1811304592@qq.com>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/he/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/id/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/it/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/de/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/id/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/it/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/character/de/
Translate-URL: https://translate.habitica.com/projects/habitica/character/es/
Translate-URL: https://translate.habitica.com/projects/habitica/character/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/character/id/
Translate-URL: https://translate.habitica.com/projects/habitica/character/it/
Translate-URL: https://translate.habitica.com/projects/habitica/character/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/character/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/character/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/character/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/de/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/it/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/content/de/
Translate-URL: https://translate.habitica.com/projects/habitica/content/es/
Translate-URL: https://translate.habitica.com/projects/habitica/content/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/content/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/content/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/content/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/content/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/content/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/contrib/it/
Translate-URL: https://translate.habitica.com/projects/habitica/contrib/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/id/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/it/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/front/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/es/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/de/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/es/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/de/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/it/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/de/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/es/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/id/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/loginincentives/es/
Translate-URL: https://translate.habitica.com/projects/habitica/loginincentives/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/loginincentives/it/
Translate-URL: https://translate.habitica.com/projects/habitica/loginincentives/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/loginincentives/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/loginincentives/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/merch/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/messages/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/cs/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/de/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/es/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/overview/it/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/de/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/es/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/quests/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/de/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/es/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/it/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/it/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/it/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/de/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/es/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/tr/
Translation: Habitica/Achievements
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Character
Translation: Habitica/Communityguidelines
Translation: Habitica/Content
Translation: Habitica/Contrib
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Loginincentives
Translation: Habitica/Merch
Translation: Habitica/Messages
Translation: Habitica/Npc
Translation: Habitica/Overview
Translation: Habitica/Pets
Translation: Habitica/Quests
Translation: Habitica/Questscontent
Translation: Habitica/Rebirth
Translation: Habitica/Settings
Translation: Habitica/Subscriber
Translation: Habitica/Tasks
2024-08-28 12:47:56 +02:00
Phillip Thelen
cf31227df4 Update schedule.js (#15273)
Add SandSculpture for July
2024-08-28 12:47:56 +02:00
Sabe Jones
a5c2cc6c6a Update README.md
fix awkward line breaks
2024-08-28 12:47:56 +02:00
Sabe Jones
4d8380f285 Update README.md 2024-08-28 12:47:56 +02:00
Sabe Jones
5686ecdd16 fix(quests): adjust margins 2024-08-28 12:47:56 +02:00
Sabe Jones
7e8239fb28 fix(modals): various tweks 2024-08-28 12:47:56 +02:00
Sabe Jones
193f783652 fix(quests): modest Bundle revisions 2024-08-28 12:47:56 +02:00
Sabe Jones
a51230c847 fix(schedule): add Oddballs to November 2024-08-28 12:47:56 +02:00
Sabe Jones
bbc07a8abd fix(lint): add missing whitespace for curlies 2024-08-28 12:47:56 +02:00
Sabe Jones
2fb2af20c0 test(chat): coverage for new sunsets 2024-08-28 12:47:56 +02:00
Sabe Jones
ec6bb3ae79 fix(groups): fix TypeError when missing 2024-08-28 12:47:56 +02:00
Sabe Jones
a0520ddb3f fix(api): close lingering deprecated APIs 2024-08-28 12:47:56 +02:00
Sabe Jones
e89bf3e588 fix(memoize): enumerate food 2024-08-28 12:47:56 +02:00
Phillip Thelen
6195071730 remove console 2024-08-22 11:42:51 +02:00
Phillip Thelen
d872ba49fd fix email search 2024-08-22 11:33:52 +02:00
Phillip Thelen
cea9743087 Split email search into multiple queries 2024-08-21 13:16:22 +02:00
Phillip Thelen
b31a6453a3 fix sorting 2024-08-19 17:30:37 +02:00
Phillip Thelen
5fb1e250ee fix loading spinner 2024-08-19 17:19:31 +02:00
Phillip Thelen
baec8273d0 time travel button display fix 2024-08-19 16:15:22 +02:00
Phillip Thelen
e387585d6d search improvements 2024-08-19 16:15:14 +02:00
Phillip Thelen
2b328c37f7 remove autocannon file 2024-08-19 15:19:07 +02:00
Phillip Thelen
9224f58da5 admin panel display improvements 2024-08-19 15:13:32 +02:00
Phillip Thelen
4d64113613 Fix bottom button display 2024-07-30 18:16:25 +02:00
Phillip Thelen
0305ba4269 Unify date preview display 2024-07-30 18:16:14 +02:00
Phillip Thelen
e2f25e34e6 fix checkbox alignment in admin panel 2024-07-29 18:18:31 +02:00
Phillip Thelen
26d5a4503c improve sub styling 2024-07-29 16:12:35 +02:00
Phillip Thelen
abf5629f80 add styling for warning buttons 2024-07-29 16:12:22 +02:00
Phillip Thelen
174e2c0078 load own user when pressing enter on empty field 2024-07-26 14:47:52 +02:00
Phillip Thelen
b104e371ef escape regex for searching users 2024-07-25 17:15:58 +02:00
Phillip Thelen
6bde0e3fd9 fix width of items 2024-07-25 17:01:52 +02:00
Phillip Thelen
7f66cc28e0 fix test 2024-07-17 15:43:38 +02:00
Phillip Thelen
1a2f299e04 Make Admin panel design more consistent 2024-07-17 15:06:11 +02:00
Phillip Thelen
489bd851bb Add option to search for users by email or username in admin panel 2024-07-17 13:32:25 +02:00
1344 changed files with 39711 additions and 48595 deletions

View File

@@ -9,4 +9,4 @@
}
]
]
}
}

View File

@@ -7,14 +7,5 @@ module.exports = {
rules: {
'prefer-regex-literals': 'warn',
'import/no-extraneous-dependencies': 'off',
'require-await': 'error',
},
overrides: [
{
files: ['migrations/**', 'gulp/**'], // Or *.test.js
rules: {
'require-await': 'off',
},
},
],
};

View File

@@ -1,13 +1,6 @@
name: Test
on:
push:
branches-ignore:
- 'phillip/**'
- 'sabrecat/**'
- 'kalista/**'
- 'natalie/**'
pull_request:
on: [push, pull_request]
permissions:
contents: read

2
.gitignore vendored
View File

@@ -47,5 +47,5 @@ webpack.webstorm.config
# mongodb replica set for local dev
mongodb-*.tgz
/mongodb-data*
/mongodb-data
/.nyc_output

View File

@@ -2,4 +2,4 @@
This webpage includes the documentation for version 3 of the [Habitica](https://habitica.com) API.
If you're developing a 3rd party tool that uses the Habitica API, read the [API Usage Guidelines](https://github.com/HabitRPG/habitica/wiki/API-Usage-Guidelines), which describe how to be a responsible user of our server resources!
If you're developing a 3rd party tool that uses the Habitica API you should read the [Guidance for Comrades](https://habitica.fandom.com/wiki/Guidance_for_Comrades) and in particular the section called [Rules for Third-Party Tools](https://habitica.fandom.com/wiki/Guidance_for_Comrades#Rules_for_Third-Party_Tools) which includes suggestions on how to best use the API and the rules to follow when interacting with it.

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",
"NODE_ENV": "development",
"PATH": "bin:node_modules/.bin:/usr/local/bin:/usr/bin:/bin",
"PAYPAL_BILLING_PLANS_basic_12mo": "basic_12mo",
@@ -64,34 +55,43 @@
"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": "YOUR SECRET HERE",
"SESSION_SECRET_IV": "12345678912345678912345678912345",
"SESSION_SECRET_KEY": "1234567891234567891234567891234567891234567891234567891234567891",
"SESSION_SECRET": "YOUR SECRET HERE",
"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",
"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
}

View File

@@ -22,8 +22,7 @@ services:
dockerfile: ./Dockerfile-Dev
command: ["npm", "start"]
depends_on:
mongo:
condition: service_healthy
- mongo
environment:
- NODE_DB_URI=mongodb://mongo/habitrpg
networks:
@@ -34,16 +33,7 @@ services:
- .:/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
image: mongo:3.6
networks:
- habitica
ports:

View File

@@ -64,15 +64,6 @@ function filterFile (file) {
if (file.relative.indexOf('icon_background') === 0) {
return false;
}
if (file.relative.indexOf('notif_') === 0) {
return false;
}
if (file.relative.indexOf('quest_') === 0) {
return false;
}
if (file.relative.indexOf('inventory_quest_') === 0) {
return false;
}
return true;
}

11
gulp/gulp-start.js Normal file
View File

@@ -0,0 +1,11 @@
import gulp from 'gulp';
import nodemon from 'gulp-nodemon';
import pkg from '../package.json';
gulp.task('nodemon', done => {
nodemon({
script: pkg.main,
});
done();
});

View File

@@ -49,6 +49,12 @@ function integrationTestCommand (testDir) {
}
/* Test task definitions */
gulp.task('test:nodemon', gulp.series(done => {
process.env.PORT = TEST_SERVER_PORT; // eslint-disable-line no-process-env
process.env.NODE_DB_URI = TEST_DB_URI; // eslint-disable-line no-process-env
done();
}, 'nodemon'));
gulp.task('test:prepare:mongo', cb => {
const mongooseOptions = getDefaultConnectionOptions();
const connectionUrl = getDevelopmentConnectionUrl(TEST_DB_URI);

View File

@@ -21,6 +21,7 @@ if (process.env.NODE_ENV === 'production') { // eslint-disable-line no-process-e
require('./gulp/gulp-build'); // eslint-disable-line global-require
require('./gulp/gulp-console'); // eslint-disable-line global-require
require('./gulp/gulp-sprites'); // eslint-disable-line global-require
require('./gulp/gulp-start'); // eslint-disable-line global-require
require('./gulp/gulp-tests'); // eslint-disable-line global-require
require('./gulp/gulp-transifex-test'); // eslint-disable-line global-require
require('gulp').task('default', gulp.series('test')); // eslint-disable-line global-require

View File

@@ -1,115 +0,0 @@
/* eslint-disable no-console */
const MIGRATION_NAME = '20241119_gem_caps_hourglasses';
import { model as User } from '../../../website/server/models/user';
const progressCount = 1000;
let count = 0;
async function updateUser (user) {
count += 1;
if (count % progressCount === 0) console.warn(`${count} ${user._id}`);
const { consecutive, customerId, dateTerminated, planId } = user.purchased.plan;
const isRecurring = customerId !== 'Gift' && !dateTerminated;
const updateOp = {
$set: {
migration: MIGRATION_NAME,
'purchased.plan.consecutive.gemCapExtra': Math.max(2 * Math.ceil((consecutive.gemCapExtra + 1) / 2, 26)),
},
$inc: {},
};
let hourglassBonus = 0;
if (isRecurring) {
await user.updateBalance(
5,
'admin_update_balance',
'',
'Subscription Reward Migration',
);
updateOp.$inc.balance = 5;
switch (planId) {
case 'basic':
case 'basic_earned':
case 'group_plan_auto':
hourglassBonus = 2;
break;
case 'basic_3mo':
case 'basic_6mo':
case 'google_6mo':
hourglassBonus = 4;
break;
case 'basic_12mo':
hourglassBonus = 12;
updateOp.$set['purchased.plan.hourglassPromoReceived'] = new Date();
break;
default:
hourglassBonus = 0;
}
if (hourglassBonus) {
updateOp.$inc['purchased.plan.consecutive.trinkets'] = hourglassBonus;
await user.updateHourglasses(
hourglassBonus,
'admin_update_balance',
'',
'Subscription Reward Migration',
);
}
updateOp.$push = {
notifications: {
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_subscriber_reward',
title: 'Thanks for being a subscriber!',
text: 'Enjoy these extra Mystic Hourglasses and Gems to celebrate our new benefits.',
},
seen: false,
},
};
}
return await User.updateOne(
{ _id: user._id },
updateOp,
).exec();
}
export default async function processUsers () {
let query = {
migration: { $ne: MIGRATION_NAME },
'purchased.plan.customerId': { $exists: true },
$or: [
{ 'purchased.plan.dateTerminated': { $exists: false } },
{ 'purchased.plan.dateTerminated': null },
{ 'purchased.plan.dateTerminated': { $gt: new Date() } },
],
};
const fields = {
_id: 1,
purchased: 1,
};
while (true) { // eslint-disable-line no-constant-condition
const users = await User // eslint-disable-line no-await-in-loop
.find(query)
.limit(250)
.sort({_id: 1})
.select(fields)
.exec();
if (users.length === 0) {
console.warn('All appropriate users found and modified.');
console.warn(`\n${count} users processed\n`);
break;
} else {
query._id = {
$gt: users[users.length - 1],
};
}
await Promise.all(users.map(updateUser)); // eslint-disable-line no-await-in-loop
}
};

View File

@@ -37,7 +37,7 @@ let consoleStamp = require('console-stamp');
consoleStamp(console);
// Initialize configuration
require('../../website/server/libs/api-v3/setupNconf').default();
require('../../website/server/libs/api-v3/setupNconf')();
let MONGODB_OLD = nconf.get('MONGODB_OLD');
let MONGODB_NEW = nconf.get('MONGODB_NEW');

View File

@@ -32,7 +32,7 @@ let moment = require('moment');
consoleStamp(console);
// Initialize configuration
require('../../website/server/libs/api-v3/setupNconf').default();
require('../../website/server/libs/api-v3/setupNconf')();
let MONGODB_OLD = nconf.get('MONGODB_OLD');
let MONGODB_NEW = nconf.get('MONGODB_NEW');

View File

@@ -6,11 +6,11 @@ require('@babel/register'); // eslint-disable-line import/no-extraneous-dependen
function setUpServer () {
const nconf = require('nconf'); // eslint-disable-line global-require, no-unused-vars
const mongoose = require('mongoose'); // eslint-disable-line global-require, no-unused-vars
const setupNconf = require('../website/server/libs/setupNconf').default; // eslint-disable-line global-require
const setupNconf = require('../website/server/libs/setupNconf'); // eslint-disable-line global-require
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

View File

@@ -26,7 +26,7 @@ async function updateUser (user) {
[{ name: 'BASE_URL', content: BASE_URL }], // Add variables from template
);
return User.updateOne({ _id: user._id }, { $set: { migration: MIGRATION_NAME } }).exec();
return User.update({ _id: user._id }, { $set: { migration: MIGRATION_NAME } }).exec();
}
export default async function processUsers () {

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) {
@@ -27,13 +27,13 @@ async function updateUser (user) {
if (count % progressCount === 0) console.warn(`${count} ${user._id}`);
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 },
'auth.local.username': 'ExampleHabitican',
'auth.local.lowerCaseUsername': 'olson1',
};
const fields = {

View File

@@ -57,7 +57,7 @@ async function updateUser (user) {
export default async function processUsers () {
const query = {
migration: { $ne: MIGRATION_NAME },
'auth.local.username': 'ExampleHabitican',
'auth.local.username': 'SabreTest',
};
const fields = {

View File

@@ -1,175 +0,0 @@
/*
* Award Habitoween ladder items to participants in this month's Habitoween festivities
*/
/* eslint-disable no-console */
import { model as User } from '../../website/server/models/user';
const MIGRATION_NAME = '20241030_habitoween_ladder'; // Update when running in future years
const progressCount = 1000;
let count = 0;
async function updateUser (user) {
count += 1;
const set = { migration: MIGRATION_NAME };
const inc = {
'items.food.Candy_Skeleton': 1,
'items.food.Candy_Base': 1,
'items.food.Candy_CottonCandyBlue': 1,
'items.food.Candy_CottonCandyPink': 1,
'items.food.Candy_Shade': 1,
'items.food.Candy_White': 1,
'items.food.Candy_Golden': 1,
'items.food.Candy_Zombie': 1,
'items.food.Candy_Desert': 1,
'items.food.Candy_Red': 1,
};
const push = { notifications: { $each: [] } };
if (user && user.items && user.items.mounts && user.items.mounts['JackOLantern-RoyalPurple']) {
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_habitoween_candy',
title: 'Happy Habitoween!',
text: 'For this spooky celebration, you\'ve received an assortment of candy for your Pets!',
destination: '/inventory/stable',
},
seen: false,
});
} else if (user && user.items && user.items.pets && user.items.pets['JackOLantern-RoyalPurple']) {
set['items.mounts.JackOLantern-RoyalPurple'] = true;
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_habitoween_purple_mount',
title: 'Happy Habitoween!',
text: 'For this spooky celebration, you\'ve received a Royal Purple Jack-O-Lantern Mount and an assortment of candy for your Pets!',
destination: '/inventory/stable',
},
seen: false,
});
} else if (user && user.items && user.items.mounts && user.items.mounts['JackOLantern-Glow']) {
set['items.pets.JackOLantern-RoyalPurple'] = 5;
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_habitoween_purple_pet',
title: 'Happy Habitoween!',
text: 'For this spooky celebration, you\'ve received a Royal Purple Jack-O-Lantern Pet and an assortment of candy for your Pets!',
destination: '/inventory/stable',
},
seen: false,
});
} else if (user && user.items && user.items.pets && user.items.pets['JackOLantern-Glow']) {
set['items.mounts.JackOLantern-Glow'] = true;
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_habitoween_glow_mount',
title: 'Happy Habitoween!',
text: 'For this spooky celebration, you\'ve received a Glow-in-the-Dark Jack-O-Lantern Mount and an assortment of candy for your Pets!',
destination: '/inventory/stable',
},
seen: false,
});
} else if (user && user.items && user.items.mounts && user.items.mounts['JackOLantern-Ghost']) {
set['items.pets.JackOLantern-Glow'] = 5;
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_habitoween_glow_pet',
title: 'Happy Habitoween!',
text: 'For this spooky celebration, you\'ve received a Glow-in-the-Dark Jack-O-Lantern Pet and an assortment of candy for your Pets!',
destination: '/inventory/stable',
},
seen: false,
});
} else if (user && user.items && user.items.pets && user.items.pets['JackOLantern-Ghost']) {
set['items.mounts.JackOLantern-Ghost'] = true;
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_habitoween_ghost_mount',
title: 'Happy Habitoween!',
text: 'For this spooky celebration, you\'ve received a Ghost Jack-O-Lantern Mount and an assortment of candy for your Pets!',
destination: '/inventory/stable',
},
seen: false,
});
} else if (user && user.items && user.items.mounts && user.items.mounts['JackOLantern-Base']) {
set['items.pets.JackOLantern-Ghost'] = 5;
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_habitoween_ghost_pet',
title: 'Happy Habitoween!',
text: 'For this spooky celebration, you\'ve received a Ghost Jack-O-Lantern Pet and an assortment of candy for your Pets!',
destination: '/inventory/stable',
},
seen: false,
});
} else if (user && user.items && user.items.pets && user.items.pets['JackOLantern-Base']) {
set['items.mounts.JackOLantern-Base'] = true;
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_habitoween_base_mount',
title: 'Happy Habitoween!',
text: 'For this spooky celebration, you\'ve received a Jack-O-Lantern Mount and an assortment of candy for your Pets!',
destination: '/inventory/stable',
},
seen: false,
});
} else {
set['items.pets.JackOLantern-Base'] = 5;
push.notifications.$each.push({
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_habitoween_base_pet',
title: 'Happy Habitoween!',
text: 'For this spooky celebration, you\'ve received a Jack-O-Lantern Pet and an assortment of candy for your Pets!',
destination: '/inventory/stable',
},
seen: false,
});
}
if (count % progressCount === 0) console.warn(`${count} ${user._id}`);
return User.updateOne({ _id: user._id }, { $inc: inc, $push: push, $set: set }).exec();
}
export default async function processUsers () {
const query = {
migration: { $ne: MIGRATION_NAME },
'auth.timestamps.loggedin': { $gt: new Date('2024-10-01') },
};
const fields = {
_id: 1,
items: 1,
};
while (true) { // eslint-disable-line no-constant-condition
const users = await User // eslint-disable-line no-await-in-loop
.find(query)
.limit(250)
.sort({ _id: 1 })
.select(fields)
.lean()
.exec();
if (users.length === 0) {
console.warn('All appropriate users found and modified.');
console.warn(`\n${count} users processed\n`);
break;
} else {
query._id = {
$gt: users[users.length - 1],
};
}
await Promise.all(users.map(updateUser)); // eslint-disable-line no-await-in-loop
}
}

View File

@@ -1,167 +0,0 @@
/* eslint-disable no-console */
import { model as User } from '../../website/server/models/user';
const MIGRATION_NAME = '20241120_harvest_feast';
const progressCount = 1000;
let count = 0;
async function updateUser (user) {
count += 1;
const updateOp = {
$set: { migration: MIGRATION_NAME },
};
if (typeof user.items.gear.owned.head_special_turkeyHelmGilded !== 'undefined') {
updateOp.$inc = {
'items.food.Pie_Base': 1,
'items.food.Pie_CottonCandyBlue': 1,
'items.food.Pie_CottonCandyPink': 1,
'items.food.Pie_Desert': 1,
'items.food.Pie_Golden': 1,
'items.food.Pie_Red': 1,
'items.food.Pie_Shade': 1,
'items.food.Pie_Skeleton': 1,
'items.food.Pie_Zombie': 1,
'items.food.Pie_White': 1,
};
updateOp.$push = {
notifications: {
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_harvestfeast_pie',
title: 'Happy Harvest Feast!',
text: 'Gobble gobble, you\'ve received an assortment of pie for your Pets!',
destination: '/inventory/stable',
},
seen: false,
},
};
} else if (typeof user.items.gear.owned.armor_special_turkeyArmorBase !== 'undefined') {
updateOp.$set['items.gear.owned.head_special_turkeyHelmGilded'] = true;
updateOp.$set['items.gear.owned.armor_special_turkeyArmorGilded'] = true;
updateOp.$set['items.gear.owned.back_special_turkeyTailGilded'] = true;
updateOp.$push = {
notifications: {
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_harvestfeast_gilded_set',
title: 'Happy Harvest Feast!',
text: 'Gobble gobble, you\'ve received the Gilded Turkey Armor, Helm, and Tail!',
destination: '/inventory/equipment',
},
seen: false,
},
};
} else if (user.items && user.items.mounts && user.items.mounts['Turkey-Gilded']) {
updateOp.$set['items.gear.owned.head_special_turkeyHelmBase'] = true;
updateOp.$set['items.gear.owned.armor_special_turkeyArmorBase'] = true;
updateOp.$set['items.gear.owned.back_special_turkeyTailBase'] = true;
updateOp.$push = {
notifications: {
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_harvestfeast_base_set',
title: 'Happy Harvest Feast!',
text: 'Gobble gobble, you\'ve received the Turkey Armor, Helm, and Tail!',
destination: '/inventory/equipment',
},
seen: false,
},
};
} else if (user.items && user.items.pets && user.items.pets['Turkey-Gilded']) {
updateOp.$set['items.mounts.Turkey-Gilded'] = true;
updateOp.$push = {
notifications: {
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_harvestfeast_gilded_mount',
title: 'Happy Harvest Feast!',
text: 'Gobble gobble, you\'ve received the Gilded Turkey Mount!',
destination: '/inventory/stable',
},
seen: false,
},
};
} else if (user.items && user.items.mounts && user.items.mounts['Turkey-Base']) {
updateOp.$set['items.pets.Turkey-Gilded'] = 5;
updateOp.$push = {
notifications: {
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_harvestfeast_gilded_pet',
title: 'Happy Harvest Feast!',
text: 'Gobble gobble, you\'ve received the Gilded Turkey Pet!',
destination: '/inventory/stable',
},
seen: false,
},
};
} else if (user.items && user.items.pets && user.items.pets['Turkey-Base']) {
updateOp.$set['items.mounts.Turkey-Base'] = true;
updateOp.$push = {
notifications: {
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_harvestfeast_base_mount',
title: 'Happy Harvest Feast!',
text: 'Gobble gobble, you\'ve received the Turkey Mount!',
destination: '/inventory/stable',
},
seen: false,
},
};
} else {
updateOp.$set['items.pets.Turkey-Base'] = 5;
updateOp.$push = {
notifications: {
type: 'ITEM_RECEIVED',
data: {
icon: 'notif_harvestfeast_base_pet',
title: 'Happy Harvest Feast!',
text: 'Gobble gobble, you\'ve received the Turkey Pet!',
destination: '/inventory/stable',
},
seen: false,
},
};
}
if (count % progressCount === 0) console.warn(`${count} ${user._id}`);
return User.updateOne({ _id: user._id }, updateOp).exec();
}
export default async function processUsers () {
const query = {
migration: { $ne: MIGRATION_NAME },
'auth.timestamps.loggedin': { $gt: new Date('2024-10-20') },
};
const fields = {
_id: 1,
items: 1,
};
while (true) { // eslint-disable-line no-constant-condition
const users = await User // eslint-disable-line no-await-in-loop
.find(query)
.limit(250)
.sort({ _id: 1 })
.select(fields)
.lean()
.exec();
if (users.length === 0) {
console.warn('All appropriate users found and modified.');
console.warn(`\n${count} users processed\n`);
break;
} else {
query._id = {
$gt: users[users.length - 1],
};
}
await Promise.all(users.map(updateUser)); // eslint-disable-line no-await-in-loop
}
}

View File

@@ -1,125 +0,0 @@
/* eslint-disable no-console */
import { model as User } from '../../website/server/models/user';
const MIGRATION_NAME = '20231228_nye';
const progressCount = 1000;
let count = 0;
async function updateUser (user) {
count += 1;
const updateOp = {
$set: { migration: MIGRATION_NAME },
$push: { },
};
const data = {
title: 'Happy New Year!',
destination: '/inventory/equipment',
};
if (typeof user.items.gear.owned.head_special_nye2023 !== 'undefined') {
updateOp.$inc = {
'items.food.Candy_Skeleton': 1,
'items.food.Candy_Base': 1,
'items.food.Candy_CottonCandyBlue': 1,
'items.food.Candy_CottonCandyPink': 1,
'items.food.Candy_Shade': 1,
'items.food.Candy_White': 1,
'items.food.Candy_Golden': 1,
'items.food.Candy_Zombie': 1,
'items.food.Candy_Desert': 1,
'items.food.Candy_Red': 1,
};
data.icon = 'notif_candy_nye';
data.text = 'Youve received an assortment of candy to celebrate with your Pets!';
data.destination = '/inventory/stable';
} else if (typeof user.items.gear.owned.head_special_nye2022 !== 'undefined') {
updateOp.$set['items.gear.owned.head_special_nye2023'] = true;
data.icon = 'notif_2023hat_nye';
data.text = 'Take on your resolutions with style in this Ludicrous Party Hat!';
} else if (typeof user.items.gear.owned.head_special_nye2021 !== 'undefined') {
updateOp.$set['items.gear.owned.head_special_nye2022'] = true;
data.icon = 'notif_2022hat_nye';
data.text = 'Take on your resolutions with style in this Fabulous Party Hat!';
} else if (typeof user.items.gear.owned.head_special_nye2020 !== 'undefined') {
updateOp.$set['items.gear.owned.head_special_nye2021'] = true;
data.icon = 'notif_2021hat_nye';
data.text = 'Take on your resolutions with style in this Preposterous Party Hat!';
} else if (typeof user.items.gear.owned.head_special_nye2019 !== 'undefined') {
updateOp.$set['items.gear.owned.head_special_nye2020'] = true;
data.icon = 'notif_2020hat_nye';
data.text = 'Take on your resolutions with style in this Extravagant Party Hat!';
} else if (typeof user.items.gear.owned.head_special_nye2018 !== 'undefined') {
updateOp.$set['items.gear.owned.head_special_nye2019'] = true;
data.icon = 'notif_2019hat_nye';
data.text = 'Take on your resolutions with style in this Outrageous Party Hat!';
} else if (typeof user.items.gear.owned.head_special_nye2017 !== 'undefined') {
updateOp.$set['items.gear.owned.head_special_nye2018'] = true;
data.icon = 'notif_2018hat_nye';
data.text = 'Take on your resolutions with style in this Outlandish Party Hat!';
} else if (typeof user.items.gear.owned.head_special_nye2016 !== 'undefined') {
updateOp.$set['items.gear.owned.head_special_nye2017'] = true;
data.icon = 'notif_2017hat_nye';
data.text = 'Take on your resolutions with style in this Fanciful Party Hat!';
} else if (typeof user.items.gear.owned.head_special_nye2015 !== 'undefined') {
updateOp.$set['items.gear.owned.head_special_nye2016'] = true;
data.icon = 'notif_2016hat_nye';
data.text = 'Take on your resolutions with style in this Whimsical Party Hat!';
} else if (typeof user.items.gear.owned.head_special_nye2014 !== 'undefined') {
updateOp.$set['items.gear.owned.head_special_nye2015'] = true;
data.icon = 'notif_2015hat_nye';
data.text = 'Take on your resolutions with style in this Ridiculous Party Hat!';
} else if (typeof user.items.gear.owned.head_special_nye !== 'undefined') {
updateOp.$set['items.gear.owned.head_special_nye2014'] = true;
data.icon = 'notif_2014hat_nye';
data.text = 'Take on your resolutions with style in this Silly Party Hat!';
} else {
updateOp.$set['items.gear.owned.head_special_nye'] = true;
data.icon = 'notif_2013hat_nye';
data.text = 'Take on your resolutions with style in this Absurd Party Hat!';
}
updateOp.$push.notifications = {
type: 'ITEM_RECEIVED',
data,
seen: false,
};
if (count % progressCount === 0) console.warn(`${count} ${user._id}`);
return User.updateOne({ _id: user._id }, updateOp).exec();
}
export default async function processUsers () {
const query = {
'auth.timestamps.loggedin': { $gt: new Date('2023-12-01') },
migration: { $ne: MIGRATION_NAME },
};
const fields = {
_id: 1,
items: 1,
};
while (true) { // eslint-disable-line no-constant-condition
const users = await User // eslint-disable-line no-await-in-loop
.find(query)
.limit(250)
.sort({ _id: 1 })
.select(fields)
.lean()
.exec();
if (users.length === 0) {
console.warn('All appropriate users found and modified.');
console.warn(`\n${count} users processed\n`);
break;
} else {
query._id = {
$gt: users[users.length - 1],
};
}
await Promise.all(users.map(updateUser)); // eslint-disable-line no-await-in-loop
}
}

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],
};
}

1226
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "5.41.6",
"version": "5.27.3",
"main": "./website/server/index.js",
"dependencies": {
"@babel/core": "^7.22.10",
@@ -17,10 +17,10 @@
"apple-auth": "^1.0.9",
"babel-preset-env": "^1.7.0",
"bcrypt": "^5.1.1",
"body-parser": "^1.20.3",
"body-parser": "^1.20.2",
"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",
@@ -28,7 +28,7 @@
"eslint": "^8.55.0",
"eslint-config-habitrpg": "^6.2.3",
"eslint-plugin-mocha": "^5.0.0",
"express": "^4.21.1",
"express": "^4.19.2",
"express-basic-auth": "^1.2.1",
"express-validator": "^5.2.0",
"firebase-admin": "^12.1.1",
@@ -38,6 +38,7 @@
"gulp-babel": "^8.0.0",
"gulp-filter": "^7.0.0",
"gulp-imagemin": "^7.1.0",
"gulp-nodemon": "^2.5.0",
"gulp.spritesmith": "^6.13.0",
"habitica-markdown": "^3.0.0",
"helmet": "^4.6.0",
@@ -49,12 +50,13 @@
"merge-stream": "^2.0.0",
"method-override": "^3.0.0",
"moment": "^2.29.4",
"moment-recur": "git://github.com/HabitRPG/moment-recur.git#d3e8e6da0806f13b74dd2e4d7d9053e6a63db119",
"mongoose": "^8.9.5",
"morgan": "^1.10.1",
"moment-recur": "^1.0.7",
"mongoose": "^7.6.3",
"morgan": "^1.10.0",
"nconf": "^0.12.1",
"node-gcm": "^1.0.5",
"on-headers": "^1.1.0",
"nodemon": "^2.0.20",
"on-headers": "^1.0.2",
"passport": "^0.5.3",
"passport-facebook": "^3.0.0",
"passport-google-oauth2": "^0.2.0",
@@ -70,6 +72,7 @@
"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",
@@ -97,27 +100,26 @@
"test:sanity": "nyc --silent --no-clean mocha test/sanity --recursive",
"test:common": "nyc --silent --no-clean mocha test/common --recursive",
"test:content": "nyc --silent --no-clean mocha test/content --recursive",
"test:nodemon": "gulp test:nodemon",
"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: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",
"mongo:dev": "run-rs -v 7.0.23 -l ubuntu2214 --keep --dbpath mongodb-data --number 1 --quiet",
"mongo:test": "run-rs -v 7.0.23 -l ubuntu2214 --keep --dbpath mongodb-data-testing --number 1 --quiet",
"start": "gulp nodemon",
"debug": "gulp nodemon --inspect",
"mongo:dev": "run-rs -v 5.0.23 -l ubuntu1804 --keep --dbpath mongodb-data --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"
},
"devDependencies": {
"axios": "^1.8.2",
"axios": "^1.4.0",
"chai": "^4.3.7",
"chai-as-promised": "^7.1.1",
"chai-moment": "^0.1.0",
"chalk": "^5.3.0",
"cross-spawn": "^7.0.5",
"cross-spawn": "^7.0.3",
"mocha": "^5.1.1",
"monk": "^7.3.4",
"nyc": "^15.1.0",

View File

@@ -71,14 +71,15 @@ async function deleteHabiticaData (user, email) {
}
async function processEmailAddress (email) {
const emailRegex = new RegExp(`^${email}$`, 'i');
const localUsers = await User.find(
{ 'auth.local.email': email },
{ 'auth.local.email': emailRegex },
{ _id: 1, apiToken: 1, auth: 1 },
).exec();
const socialUsers = await User.find(
{
'auth.local.email': { $ne: email },
'auth.local.email': { $not: emailRegex },
$or: [
{ 'auth.facebook.emails.value': email },
{ 'auth.google.emails.value': email },

View File

@@ -8,17 +8,7 @@ const TASK_VALUE_CHANGE_FACTOR = 0.9747;
const MIN_TASK_VALUE = -47.27;
async function updateTeamTasks (team) {
if (team.purchased.plan.dateTerminated) {
const dateTerminated = new Date(team.purchased.plan.dateTerminated);
if (dateTerminated < new Date()) {
team.purchased.plan.customerId = undefined;
team.markModified('purchased.plan');
return team.save();
}
}
const toSave = [];
let teamLeader = await User.findOne({ _id: team.leader }, 'preferences').exec();
if (!teamLeader) { // why would this happen?
@@ -103,7 +93,12 @@ async function updateTeamTasks (team) {
export default async function processTeamsCron () {
const activeTeams = await Group.find({
'purchased.plan.customerId': { $exists: true },
}, { cron: 1, leader: 1, purchased: 1 }).exec();
$or: [
{ 'purchased.plan.dateTerminated': { $exists: false } },
{ 'purchased.plan.dateTerminated': null },
{ 'purchased.plan.dateTerminated': { $gt: new Date() } },
],
}).exec();
const cronPromises = activeTeams.map(updateTeamTasks);
return Promise.all(cronPromises);

View File

@@ -1,11 +1,15 @@
/* 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(() => {
@@ -33,6 +37,8 @@ describe('analyticsService', () => {
data;
beforeEach(() => {
Visitor.prototype.event.yields();
eventType = 'Cron';
data = {
category: 'behavior',
@@ -43,11 +49,6 @@ describe('analyticsService', () => {
'x-client': 'habitica-web',
'user-agent': '',
},
user: {
preferences: {
analyticsConsent: true,
},
},
};
});
@@ -294,9 +295,6 @@ describe('analyticsService', () => {
rewards: [{ _id: 'reward' }],
balance: 12,
loginIncentives: 1,
preferences: {
analyticsConsent: true,
},
};
data.user = user;
@@ -328,12 +326,37 @@ describe('analyticsService', () => {
});
});
});
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 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',
@@ -347,11 +370,6 @@ describe('analyticsService', () => {
'x-client': 'habitica-web',
'user-agent': '',
},
user: {
preferences: {
analyticsConsent: true,
},
},
};
});
@@ -515,9 +533,6 @@ describe('analyticsService', () => {
dailys: [{ _id: 'daily' }],
todos: [{ _id: 'todo' }],
rewards: [{ _id: 'reward' }],
preferences: {
analyticsConsent: true,
},
};
data.user = user;
@@ -546,6 +561,26 @@ describe('analyticsService', () => {
});
});
});
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', () => {

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',
@@ -45,6 +44,7 @@ describe('bug-report', () => {
USER_HOURGLASSES: 0,
USER_ID: userId,
USER_LEVEL: 1,
USER_OFFSET_MONTHS: 0,
USER_PAYMENT_PLATFORM: undefined,
USER_SUBSCRIPTION: undefined,
USER_TIMEZONE_OFFSET: 0,

File diff suppressed because it is too large Load Diff

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),
@@ -171,23 +171,23 @@ describe('emails', () => {
expect(got.post).not.to.be.called;
});
it('throws error when mail target is only a string', async () => {
it('throws error when mail target is only a string', () => {
const emailType = 'an email type';
const mailingInfo = 'my email';
await expect(sendTxn(mailingInfo, emailType)).to.be.rejectedWith('Argument Error mailingInfoArray: does not contain email or _id');
expect(sendTxn(mailingInfo, emailType)).to.throw;
});
it('throws error when mail target has no _id or email', async () => {
it('throws error when mail target has no _id or email', () => {
const emailType = 'an email type';
const mailingInfo = {
};
await expect(sendTxn(mailingInfo, emailType)).to.be.rejectedWith('Argument Error mailingInfoArray: does not contain email or _id');
expect(sendTxn(mailingInfo, emailType)).to.throw;
});
it('throws error when variables not an array', async () => {
it('throws error when variables not an array', () => {
const emailType = 'an email type';
const mailingInfo = {
name: 'my name',
@@ -195,10 +195,9 @@ describe('emails', () => {
};
const variables = {};
await expect(sendTxn(mailingInfo, emailType, variables)).to.be.rejectedWith('Argument Error variables: is not an array');
expect(sendTxn(mailingInfo, emailType, variables)).to.throw;
});
it('throws error when variables array not contain name/content', async () => {
it('throws error when variables array not contain name/content', () => {
const emailType = 'an email type';
const mailingInfo = {
name: 'my name',
@@ -210,9 +209,8 @@ describe('emails', () => {
},
];
await expect(sendTxn(mailingInfo, emailType, variables)).to.be.rejectedWith('Argument Error variables: does not contain name or content');
expect(sendTxn(mailingInfo, emailType, variables)).to.throw;
});
it('throws no error when variables array contain name but no content', () => {
const emailType = 'an email type';
const mailingInfo = {
@@ -234,7 +232,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 +252,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'),

View File

@@ -47,12 +47,6 @@ describe('highlightMentions', () => {
expect(result[0]).to.equal('[@user-dash](/profile/444): message [@user_underscore](/profile/555)');
});
it('highlights users with case-insensitive matching', async () => {
const text = '@USER: message @User2 @USER3';
const result = await highlightMentions(text);
expect(result[0]).to.equal('[@USER](/profile/111): message [@User2](/profile/222) [@USER3](/profile/333)');
});
it('doesn\'t highlight nonexisting users', async () => {
const text = '@nouser message';
const result = await highlightMentions(text);

View File

@@ -1,4 +1,5 @@
import os from 'os';
import nconf from 'nconf';
import requireAgain from 'require-again';
const pathToMongoLib = '../../../../website/server/libs/mongodb';
@@ -28,4 +29,22 @@ describe('mongodb', () => {
expect(string).to.equal('mongodb://hostname:3030');
});
});
describe('getDefaultConnectionOptions', () => {
it('returns development config when IS_PROD is false', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false);
const mongoLibOverride = requireAgain(pathToMongoLib);
const options = mongoLibOverride.getDefaultConnectionOptions();
expect(options).to.have.all.keys(['useNewUrlParser', 'useUnifiedTopology']);
});
it('returns production config when IS_PROD is true', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
const mongoLibOverride = requireAgain(pathToMongoLib);
const options = mongoLibOverride.getDefaultConnectionOptions();
expect(options).to.have.all.keys(['useNewUrlParser', 'useUnifiedTopology']);
});
});
});

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;

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, {

View File

@@ -715,7 +715,7 @@ describe('Purchasing a group plan for group', () => {
const mysteryItem = { title: 'item' };
const mysteryItems = [mysteryItem];
const consecutive = {
trinkets: 4,
trinkets: 3,
gemCapExtra: 20,
offset: 1,
count: 13,

View File

@@ -12,7 +12,6 @@ import {
} from '../../../../helpers/api-unit.helper';
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';
describe('payments/index', () => {
let user;
@@ -66,6 +65,7 @@ describe('payments/index', () => {
mysteryItems: [],
consecutive: {
trinkets: 0,
offset: 0,
gemCapExtra: 0,
},
};
@@ -108,8 +108,14 @@ describe('payments/index', () => {
});
it('add a transaction entry to the recipient', async () => {
recipient.purchased.plan = plan;
expect(recipient.purchased.plan.extraMonths).to.eql(0);
await api.createSubscription(data);
expect(recipient.purchased.plan.extraMonths).to.eql(3);
const transactions = await TransactionModel
.find({ userId: recipient._id })
.sort({ createdAt: -1 })
@@ -171,45 +177,6 @@ describe('payments/index', () => {
expect(recipient.purchased.plan.dateUpdated).to.exist;
});
it('does not reset gemCapExtra if they already had one', async () => {
recipient.purchased.plan.consecutive.gemCapExtra = 10;
await api.createSubscription(data);
expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(10);
});
it('sets gemCapExtra to 0 if they receive a 3 month sub', async () => {
data.gift.subscription.key = 'basic_3mo';
data.gift.subscription.months = 3;
await api.createSubscription(data);
expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(0);
});
it('sets gemCapExtra to max if they receive a 12 month sub', async () => {
recipient.purchased.plan.consecutive.gemCapExtra = 10;
data.gift.subscription.key = 'basic_12mo';
data.gift.subscription.months = 12;
await api.createSubscription(data);
expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(26);
});
it('gives user 1 hourglass if they have no active subscription', async () => {
await api.createSubscription(data);
expect(recipient.purchased.plan.consecutive.trinkets).to.eql(1);
});
it('does not give any hourglasses if they have an active subscription', async () => {
recipient.purchased.plan = plan;
await api.createSubscription(data);
expect(recipient.purchased.plan.consecutive.trinkets).to.eql(plan.consecutive.trinkets);
});
it('sets plan.dateUpdated if it did exist but the user has cancelled', async () => {
recipient.purchased.plan.dateUpdated = moment().subtract(1, 'days').toDate();
recipient.purchased.plan.dateTerminated = moment().subtract(1, 'days').toDate();
@@ -268,6 +235,116 @@ describe('payments/index', () => {
expect(recipient.purchased.plan.customerId).to.eql('customer-id');
});
it('sets plan.perkMonthCount to 1 if user is not subscribed', async () => {
recipient.purchased.plan = plan;
recipient.purchased.plan.perkMonthCount = 1;
recipient.purchased.plan.customerId = undefined;
data.sub.key = 'basic_earned';
data.gift.subscription.key = 'basic_earned';
data.gift.subscription.months = 1;
expect(recipient.purchased.plan.perkMonthCount).to.eql(1);
await api.createSubscription(data);
expect(recipient.purchased.plan.perkMonthCount).to.eql(1);
});
it('sets plan.perkMonthCount to 1 if field is not initialized', async () => {
recipient.purchased.plan = plan;
recipient.purchased.plan.perkMonthCount = -1;
recipient.purchased.plan.customerId = undefined;
data.sub.key = 'basic_earned';
data.gift.subscription.key = 'basic_earned';
data.gift.subscription.months = 1;
expect(recipient.purchased.plan.perkMonthCount).to.eql(-1);
await api.createSubscription(data);
expect(recipient.purchased.plan.perkMonthCount).to.eql(1);
});
it('sets plan.perkMonthCount to 1 if user had previous count but lapsed subscription', async () => {
recipient.purchased.plan = plan;
recipient.purchased.plan.perkMonthCount = 2;
recipient.purchased.plan.customerId = undefined;
data.sub.key = 'basic_earned';
data.gift.subscription.key = 'basic_earned';
data.gift.subscription.months = 1;
expect(recipient.purchased.plan.perkMonthCount).to.eql(2);
await api.createSubscription(data);
expect(recipient.purchased.plan.perkMonthCount).to.eql(1);
});
it('adds to plan.perkMonthCount if user is already subscribed', async () => {
recipient.purchased.plan = plan;
recipient.purchased.plan.perkMonthCount = 1;
data.sub.key = 'basic_earned';
data.gift.subscription.key = 'basic_earned';
data.gift.subscription.months = 1;
expect(recipient.purchased.plan.perkMonthCount).to.eql(1);
await api.createSubscription(data);
expect(recipient.purchased.plan.perkMonthCount).to.eql(2);
});
it('awards perks if plan.perkMonthCount reaches 3 with existing subscription', async () => {
recipient.purchased.plan = plan;
recipient.purchased.plan.perkMonthCount = 2;
data.sub.key = 'basic_earned';
data.gift.subscription.key = 'basic_earned';
data.gift.subscription.months = 1;
expect(recipient.purchased.plan.perkMonthCount).to.eql(2);
expect(recipient.purchased.plan.consecutive.trinkets).to.eql(0);
expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(0);
await api.createSubscription(data);
expect(recipient.purchased.plan.perkMonthCount).to.eql(0);
expect(recipient.purchased.plan.consecutive.trinkets).to.eql(1);
expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(5);
});
it('awards perks if plan.perkMonthCount reaches 3 without existing subscription', async () => {
recipient.purchased.plan.perkMonthCount = 0;
expect(recipient.purchased.plan.perkMonthCount).to.eql(0);
expect(recipient.purchased.plan.consecutive.trinkets).to.eql(0);
expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(0);
await api.createSubscription(data);
expect(recipient.purchased.plan.perkMonthCount).to.eql(0);
expect(recipient.purchased.plan.consecutive.trinkets).to.eql(1);
expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(5);
});
it('awards perks if plan.perkMonthCount reaches 3 without initialized field', async () => {
expect(recipient.purchased.plan.perkMonthCount).to.eql(-1);
expect(recipient.purchased.plan.consecutive.trinkets).to.eql(0);
expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(0);
await api.createSubscription(data);
expect(recipient.purchased.plan.perkMonthCount).to.eql(0);
expect(recipient.purchased.plan.consecutive.trinkets).to.eql(1);
expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(5);
});
it('awards perks if plan.perkMonthCount goes over 3', async () => {
recipient.purchased.plan = plan;
recipient.purchased.plan.perkMonthCount = 2;
data.sub.key = 'basic_earned';
expect(recipient.purchased.plan.perkMonthCount).to.eql(2);
expect(recipient.purchased.plan.consecutive.trinkets).to.eql(0);
expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(0);
await api.createSubscription(data);
expect(recipient.purchased.plan.perkMonthCount).to.eql(2);
expect(recipient.purchased.plan.consecutive.trinkets).to.eql(1);
expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(5);
});
it('sets plan.customerId to "Gift" if it does not already exist', async () => {
expect(recipient.purchased.plan.customerId).to.not.exist;
@@ -344,8 +421,8 @@ describe('payments/index', () => {
context('Active Promotion', () => {
beforeEach(() => {
sinon.stub(worldState, 'getCurrentEventList').returns([{
...REPEATING_EVENTS.giftOneGetOne,
event: 'g1g1',
...common.content.events.winter2021Promo,
event: 'winter2021',
}]);
});
@@ -361,30 +438,22 @@ describe('payments/index', () => {
expect(user.purchased.plan.dateTerminated).to.exist;
expect(user.purchased.plan.dateUpdated).to.exist;
expect(user.purchased.plan.dateCreated).to.exist;
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0);
expect(recipient.items.pets['Jackalope-RoyalPurple']).to.eql(5);
expect(recipient.purchased.plan.customerId).to.eql('Gift');
expect(recipient.purchased.plan.dateTerminated).to.exist;
expect(recipient.purchased.plan.dateUpdated).to.exist;
expect(recipient.purchased.plan.dateCreated).to.exist;
expect(recipient.purchased.plan.consecutive.trinkets).to.eql(1);
expect(recipient.purchased.plan.consecutive.gemCapExtra).to.eql(0);
});
it('adds extraMonths to existing subscription for purchaser and creates a gift subscription for recipient without sub', async () => {
user.purchased.plan = plan;
expect(user.purchased.plan.extraMonths).to.eql(0);
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0);
await api.createSubscription(data);
expect(user.purchased.plan.extraMonths).to.eql(3);
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0);
expect(recipient.items.pets['Jackalope-RoyalPurple']).to.eql(5);
expect(recipient.purchased.plan.customerId).to.eql('Gift');
@@ -397,12 +466,10 @@ describe('payments/index', () => {
recipient.purchased.plan = plan;
expect(recipient.purchased.plan.extraMonths).to.eql(0);
expect(recipient.purchased.plan.consecutive.trinkets).to.eql(0);
await api.createSubscription(data);
expect(recipient.purchased.plan.extraMonths).to.eql(3);
expect(recipient.purchased.plan.consecutive.trinkets).to.eql(0);
expect(user.items.pets['Jackalope-RoyalPurple']).to.eql(5);
expect(user.purchased.plan.customerId).to.eql('Gift');
@@ -417,15 +484,11 @@ describe('payments/index', () => {
expect(user.purchased.plan.extraMonths).to.eql(0);
expect(recipient.purchased.plan.extraMonths).to.eql(0);
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
expect(recipient.purchased.plan.consecutive.trinkets).to.eql(0);
await api.createSubscription(data);
expect(user.purchased.plan.extraMonths).to.eql(3);
expect(recipient.purchased.plan.extraMonths).to.eql(3);
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
expect(recipient.purchased.plan.consecutive.trinkets).to.eql(0);
});
it('sends a private message about the promotion', async () => {
@@ -448,6 +511,7 @@ describe('payments/index', () => {
expect(user.purchased.plan.customerId).to.eql('customer-id');
expect(user.purchased.plan.dateUpdated).to.exist;
expect(user.purchased.plan.gemsBought).to.eql(0);
expect(user.purchased.plan.perkMonthCount).to.eql(0);
expect(user.purchased.plan.paymentMethod).to.eql('Payment Method');
expect(user.purchased.plan.extraMonths).to.eql(0);
expect(user.purchased.plan.dateTerminated).to.eql(null);
@@ -485,6 +549,33 @@ describe('payments/index', () => {
expect(user.purchased.plan.dateCurrentTypeCreated).to.not.eql(initialDate);
});
it('keeps plan.perkMonthCount when changing subscription type', async () => {
await api.createSubscription(data);
user.purchased.plan.perkMonthCount = 2;
await api.createSubscription(data);
expect(user.purchased.plan.perkMonthCount).to.eql(2);
});
it('sets plan.perkMonthCount to zero when creating new monthly subscription', async () => {
user.purchased.plan.perkMonthCount = 2;
await api.createSubscription(data);
expect(user.purchased.plan.perkMonthCount).to.eql(0);
});
it('sets plan.perkMonthCount to zero when creating new 3 month subscription', async () => {
user.purchased.plan.perkMonthCount = 2;
await api.createSubscription(data);
expect(user.purchased.plan.perkMonthCount).to.eql(0);
});
it('updates plan.consecutive.offset when changing subscription type', async () => {
await api.createSubscription(data);
expect(user.purchased.plan.consecutive.offset).to.eql(3);
data.sub.key = 'basic_6mo';
await api.createSubscription(data);
expect(user.purchased.plan.consecutive.offset).to.eql(6);
});
it('awards the Royal Purple Jackalope pet', async () => {
await api.createSubscription(data);
@@ -603,7 +694,6 @@ describe('payments/index', () => {
expect(user.purchased.plan.dateCreated).to.eql(created);
expect(user.purchased.plan.dateUpdated).to.not.eql(updated);
expect(user.purchased.plan.customerId).to.eql('customer-id');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(26);
});
});
@@ -651,20 +741,55 @@ describe('payments/index', () => {
});
context('Block subscription perks', () => {
it('adds 26 to plan.consecutive.gemCapExtra for 12 month block', async () => {
data.sub.key = 'basic_12mo';
it('adds block months to plan.consecutive.offset', async () => {
await api.createSubscription(data);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(26);
expect(user.purchased.plan.consecutive.offset).to.eql(3);
});
it('does not raise plan.consecutive.gemCapExtra higher than 26', async () => {
it('does not add to plans.consecutive.offset if 1 month subscription', async () => {
data.sub.key = 'basic_earned';
await api.createSubscription(data);
expect(user.purchased.plan.consecutive.offset).to.eql(0);
});
it('resets plans.consecutive.offset if 1 month subscription', async () => {
user.purchased.plan.consecutive.offset = 1;
await user.save();
data.sub.key = 'basic_earned';
await api.createSubscription(data);
expect(user.purchased.plan.consecutive.offset).to.eql(0);
});
it('adds 5 to plan.consecutive.gemCapExtra for 3 month block', async () => {
await api.createSubscription(data);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(5);
});
it('adds 10 to plan.consecutive.gemCapExtra for 6 month block', async () => {
data.sub.key = 'basic_6mo';
await api.createSubscription(data);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10);
});
it('adds 20 to plan.consecutive.gemCapExtra for 12 month block', async () => {
data.sub.key = 'basic_12mo';
await api.createSubscription(data);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20);
});
it('does not raise plan.consecutive.gemCapExtra higher than 25', async () => {
data.sub.key = 'basic_12mo';
await api.createSubscription(data);
await api.createSubscription(data);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(26);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(25);
});
it('adds a plan.consecutive.trinkets for 3 month block', async () => {
@@ -673,29 +798,20 @@ describe('payments/index', () => {
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
});
it('adds 1 plan.consecutive.trinkets for 6 month block', async () => {
it('adds 2 plan.consecutive.trinkets for 6 month block', async () => {
data.sub.key = 'basic_6mo';
await api.createSubscription(data);
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.purchased.plan.consecutive.trinkets).to.eql(2);
});
it('adds 1 plan.consecutive.trinkets for 12 month block if they had promo', async () => {
user.purchased.plan.hourglassPromoReceived = new Date();
it('adds 4 plan.consecutive.trinkets for 12 month block', async () => {
data.sub.key = 'basic_12mo';
await api.createSubscription(data);
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
});
it('adds 12 plan.consecutive.trinkets for 12 month block', async () => {
data.sub.key = 'basic_12mo';
await api.createSubscription(data);
expect(user.purchased.plan.consecutive.trinkets).to.eql(13);
expect(user.purchased.plan.consecutive.trinkets).to.eql(4);
});
context('Upgrades subscription', () => {
@@ -703,38 +819,70 @@ describe('payments/index', () => {
beforeEach(async () => {
data.updatedFrom = { logic: 'payDifference' };
});
it('Adds 26 to plan.consecutive.gemCapExtra when upgrading from basic_3mo to basic_12mo', async () => {
it('Adds 10 to plan.consecutive.gemCapExtra from basic_earned to basic_6mo', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_earned');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0);
data.sub.key = 'basic_6mo';
data.updatedFrom.key = 'basic_earned';
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10);
});
it('Adds 15 to plan.consecutive.gemCapExtra when upgrading from basic_3mo to basic_12mo', async () => {
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_3mo');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(5);
data.sub.key = 'basic_12mo';
data.updatedFrom.key = 'basic_3mo';
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(26);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20);
});
it('Adds 12 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo', async () => {
it('Adds 2 to plan.consecutive.trinkets from basic_earned to basic_6mo', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_earned');
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
data.sub.key = 'basic_6mo';
data.updatedFrom.key = 'basic_earned';
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(2);
});
it('Adds 2 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.purchased.plan.consecutive.trinkets).to.eql(2);
data.sub.key = 'basic_12mo';
data.updatedFrom.key = 'basic_6mo';
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(13);
expect(user.purchased.plan.consecutive.trinkets).to.eql(4);
});
it('Adds 12 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo', async () => {
it('Adds 3 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo', async () => {
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
@@ -746,7 +894,7 @@ describe('payments/index', () => {
data.updatedFrom.key = 'basic_3mo';
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(13);
expect(user.purchased.plan.consecutive.trinkets).to.eql(4);
});
});
@@ -754,39 +902,70 @@ describe('payments/index', () => {
beforeEach(async () => {
data.updatedFrom = { logic: 'payFull' };
});
it('Adds 10 to plan.consecutive.gemCapExtra from basic_earned to basic_6mo', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
it('Adds 26 to plan.consecutive.gemCapExtra when upgrading from basic_3mo to basic_12mo', async () => {
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_earned');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0);
data.sub.key = 'basic_6mo';
data.updatedFrom.key = 'basic_earned';
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10);
});
it('Adds 20 to plan.consecutive.gemCapExtra when upgrading from basic_3mo to basic_12mo', async () => {
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_3mo');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(5);
data.sub.key = 'basic_12mo';
data.updatedFrom.key = 'basic_3mo';
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(26);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(25);
});
it('Adds 12 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo', async () => {
it('Adds 2 to plan.consecutive.trinkets from basic_earned to basic_6mo', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_earned');
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
data.sub.key = 'basic_6mo';
data.updatedFrom.key = 'basic_earned';
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(2);
});
it('Adds 4 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.purchased.plan.consecutive.trinkets).to.eql(2);
data.sub.key = 'basic_12mo';
data.updatedFrom.key = 'basic_6mo';
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(13);
expect(user.purchased.plan.consecutive.trinkets).to.eql(6);
});
it('Adds 12 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo', async () => {
it('Adds 4 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo', async () => {
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
@@ -798,7 +977,7 @@ describe('payments/index', () => {
data.updatedFrom.key = 'basic_3mo';
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(13);
expect(user.purchased.plan.consecutive.trinkets).to.eql(5);
});
});
@@ -809,13 +988,30 @@ describe('payments/index', () => {
data.updatedFrom = { logic: 'refundAndRepay' };
});
context('Upgrades within first half of subscription', () => {
it('Adds 26 to plan.consecutive.gemCapExtra when upgrading from basic_3mo to basic_12mo', async () => {
it('Adds 10 to plan.consecutive.gemCapExtra from basic_earned to basic_6mo', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_earned');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0);
data.sub.key = 'basic_6mo';
data.updatedFrom.key = 'basic_earned';
clock.restore();
clock = sinon.useFakeTimers(new Date('2022-01-10'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10);
});
it('Adds 15 to plan.consecutive.gemCapExtra when upgrading from basic_3mo to basic_12mo', async () => {
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_3mo');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(5);
data.sub.key = 'basic_12mo';
data.updatedFrom.key = 'basic_3mo';
@@ -823,10 +1019,28 @@ describe('payments/index', () => {
clock = sinon.useFakeTimers(new Date('2022-02-05'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(26);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20);
});
it('Adds 12 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo', async () => {
it('Adds 2 to plan.consecutive.trinkets from basic_earned to basic_6mo', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_earned');
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
data.sub.key = 'basic_6mo';
data.updatedFrom.key = 'basic_earned';
clock.restore();
clock = sinon.useFakeTimers(new Date('2022-01-08'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(2);
});
it('Adds 3 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo', async () => {
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
@@ -840,17 +1054,17 @@ describe('payments/index', () => {
clock = sinon.useFakeTimers(new Date('2022-01-31'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(13);
expect(user.purchased.plan.consecutive.trinkets).to.eql(4);
});
it('Adds 12 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo', async () => {
it('Adds 2 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.purchased.plan.consecutive.trinkets).to.eql(2);
data.sub.key = 'basic_12mo';
data.updatedFrom.key = 'basic_6mo';
@@ -858,17 +1072,35 @@ describe('payments/index', () => {
clock = sinon.useFakeTimers(new Date('2022-01-28'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(13);
expect(user.purchased.plan.consecutive.trinkets).to.eql(4);
});
it('2 plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo after initial cycle', async () => {
it('Adds 2 to plan.consecutive.trinkets from basic_earned to basic_6mo after initial cycle', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_earned');
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
data.sub.key = 'basic_6mo';
data.updatedFrom.key = 'basic_earned';
clock.restore();
clock = sinon.useFakeTimers(new Date('2024-01-08'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(2);
});
it('Adds 2 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo after initial cycle', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.purchased.plan.consecutive.trinkets).to.eql(2);
data.sub.key = 'basic_12mo';
data.updatedFrom.key = 'basic_6mo';
@@ -876,10 +1108,10 @@ describe('payments/index', () => {
clock = sinon.useFakeTimers(new Date('2022-08-28'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(13);
expect(user.purchased.plan.consecutive.trinkets).to.eql(4);
});
it('Adds 12 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo after initial cycle', async () => {
it('Adds 3 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo after initial cycle', async () => {
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
@@ -893,11 +1125,11 @@ describe('payments/index', () => {
clock = sinon.useFakeTimers(new Date('2022-07-31'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(13);
expect(user.purchased.plan.consecutive.trinkets).to.eql(4);
});
});
context('Upgrades within second half of subscription', () => {
it('Adds 0 to plan.consecutive.gemCapExtra from basic_earned to basic_6mo', async () => {
it('Adds 10 to plan.consecutive.gemCapExtra from basic_earned to basic_6mo', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
@@ -912,16 +1144,16 @@ describe('payments/index', () => {
clock = sinon.useFakeTimers(new Date('2022-01-20'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10);
});
it('Adds 26 to plan.consecutive.gemCapExtra when upgrading from basic_3mo to basic_12mo', async () => {
it('Adds 20 to plan.consecutive.gemCapExtra when upgrading from basic_3mo to basic_12mo', async () => {
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_3mo');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(5);
data.sub.key = 'basic_12mo';
data.updatedFrom.key = 'basic_3mo';
@@ -929,17 +1161,17 @@ describe('payments/index', () => {
clock = sinon.useFakeTimers(new Date('2022-02-24'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(26);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(25);
});
it('Adds 0 to plan.consecutive.trinkets from basic_earned to basic_6mo', async () => {
it('Adds 2 to plan.consecutive.trinkets from basic_earned to basic_6mo', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_earned');
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
data.sub.key = 'basic_6mo';
data.updatedFrom.key = 'basic_earned';
@@ -947,17 +1179,17 @@ describe('payments/index', () => {
clock = sinon.useFakeTimers(new Date('2022-01-28'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.purchased.plan.consecutive.trinkets).to.eql(2);
});
it('Adds 12 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo', async () => {
it('Adds 4 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.purchased.plan.consecutive.trinkets).to.eql(2);
data.sub.key = 'basic_12mo';
data.updatedFrom.key = 'basic_6mo';
@@ -965,10 +1197,10 @@ describe('payments/index', () => {
clock = sinon.useFakeTimers(new Date('2022-05-28'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(13);
expect(user.purchased.plan.consecutive.trinkets).to.eql(6);
});
it('Adds 12 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo', async () => {
it('Adds 4 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo', async () => {
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
@@ -982,17 +1214,17 @@ describe('payments/index', () => {
clock = sinon.useFakeTimers(new Date('2022-03-03'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(13);
expect(user.purchased.plan.consecutive.trinkets).to.eql(5);
});
it('Adds 0 to plan.consecutive.trinkets from basic_earned to basic_6mo after initial cycle', async () => {
it('Adds 2 to plan.consecutive.trinkets from basic_earned to basic_6mo after initial cycle', async () => {
data.sub.key = 'basic_earned';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_earned');
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
data.sub.key = 'basic_6mo';
data.updatedFrom.key = 'basic_earned';
@@ -1000,17 +1232,17 @@ describe('payments/index', () => {
clock = sinon.useFakeTimers(new Date('2022-05-28'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.purchased.plan.consecutive.trinkets).to.eql(2);
});
it('Adds 12 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo after initial cycle', async () => {
it('Adds 4 to plan.consecutive.trinkets when upgrading from basic_6mo to basic_12mo after initial cycle', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(1);
expect(user.purchased.plan.consecutive.trinkets).to.eql(2);
data.sub.key = 'basic_12mo';
data.updatedFrom.key = 'basic_6mo';
@@ -1018,10 +1250,10 @@ describe('payments/index', () => {
clock = sinon.useFakeTimers(new Date('2023-05-28'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(13);
expect(user.purchased.plan.consecutive.trinkets).to.eql(6);
});
it('Adds 12 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo after initial cycle', async () => {
it('Adds 4 to plan.consecutive.trinkets when upgrading from basic_3mo to basic_12mo after initial cycle', async () => {
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
@@ -1035,7 +1267,7 @@ describe('payments/index', () => {
clock = sinon.useFakeTimers(new Date('2023-09-03'));
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(13);
expect(user.purchased.plan.consecutive.trinkets).to.eql(5);
});
});
afterEach(async () => {
@@ -1045,6 +1277,22 @@ describe('payments/index', () => {
});
context('Downgrades subscription', () => {
it('does not remove from plan.consecutive.gemCapExtra from basic_6mo to basic_earned', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10);
data.sub.key = 'basic_earned';
data.updatedFrom = { key: 'basic_6mo' };
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_earned');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(10);
});
it('does not remove from plan.consecutive.gemCapExtra from basic_12mo to basic_3mo', async () => {
expect(user.purchased.plan.planId).to.not.exist;
@@ -1052,12 +1300,28 @@ describe('payments/index', () => {
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(26);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20);
data.sub.key = 'basic_3mo';
data.updatedFrom = { key: 'basic_12mo' };
await api.createSubscription(data);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(26);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(20);
});
it('does not remove from plan.consecutive.trinkets from basic_6mo to basic_earned', async () => {
data.sub.key = 'basic_6mo';
expect(user.purchased.plan.planId).to.not.exist;
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_6mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(2);
data.sub.key = 'basic_earned';
data.updatedFrom = { key: 'basic_6mo' };
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_earned');
expect(user.purchased.plan.consecutive.trinkets).to.eql(2);
});
it('does not remove from plan.consecutive.trinkets from basic_12mo to basic_3mo', async () => {
@@ -1067,12 +1331,12 @@ describe('payments/index', () => {
await api.createSubscription(data);
expect(user.purchased.plan.planId).to.eql('basic_12mo');
expect(user.purchased.plan.consecutive.trinkets).to.eql(13);
expect(user.purchased.plan.consecutive.trinkets).to.eql(4);
data.sub.key = 'basic_3mo';
data.updatedFrom = { key: 'basic_12mo' };
await api.createSubscription(data);
expect(user.purchased.plan.consecutive.trinkets).to.eql(13);
expect(user.purchased.plan.consecutive.trinkets).to.eql(4);
});
});
});
@@ -1189,32 +1453,6 @@ describe('payments/index', () => {
expect(user.purchased.plan.extraMonths).to.eql(0);
});
it('does not reset gemCapExtra', async () => {
user.purchased.plan.consecutive.gemCapExtra = 12;
await api.cancelSubscription(data);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(12);
});
it('initializes gemCapExtra', async () => {
await api.cancelSubscription(data);
expect(user.purchased.plan.consecutive.gemCapExtra).to.eql(0);
});
it('initializes hourglasses', async () => {
await api.cancelSubscription(data);
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
});
it('does not reset owned hourglasses', async () => {
user.purchased.plan.consecutive.trinkets = 12;
await api.cancelSubscription(data);
expect(user.purchased.plan.consecutive.trinkets).to.eql(12);
});
it('sends an email', async () => {
await api.cancelSubscription(data);

View File

@@ -51,7 +51,6 @@ describe('Stripe - Checkout', () => {
gift: undefined,
sub: undefined,
gemsBlock: gemsBlockKey,
server_url: BASE_URL,
};
expect(gems.validateGiftMessage).to.not.be.called;
@@ -102,7 +101,6 @@ describe('Stripe - Checkout', () => {
gift: JSON.stringify(gift),
sub: undefined,
gemsBlock: undefined,
server_url: BASE_URL,
};
expect(gems.validateGiftMessage).to.be.calledOnce;
@@ -157,7 +155,6 @@ describe('Stripe - Checkout', () => {
gift: JSON.stringify(gift),
sub: undefined,
gemsBlock: undefined,
server_url: BASE_URL,
};
expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledOnce;
@@ -195,7 +192,6 @@ describe('Stripe - Checkout', () => {
userId: user._id,
gift: undefined,
sub: JSON.stringify(sub),
server_url: BASE_URL,
};
expect(subscriptions.checkSubData).to.be.calledOnce;
@@ -262,7 +258,6 @@ describe('Stripe - Checkout', () => {
userId: user._id,
gift: undefined,
sub: JSON.stringify(sub),
server_url: BASE_URL,
groupId,
};
@@ -333,9 +328,8 @@ describe('Stripe - Checkout', () => {
user.purchased.plan.customerId = customerId;
const metadata = {
type: 'edit-card-user',
userId: user._id,
server_url: BASE_URL,
type: 'edit-card-user',
};
const res = await createEditCardCheckoutSession({ user }, stripe);
@@ -424,7 +418,6 @@ describe('Stripe - Checkout', () => {
const metadata = {
userId: user._id,
type: 'edit-card-group',
server_url: BASE_URL,
groupId,
};
@@ -462,7 +455,6 @@ describe('Stripe - Checkout', () => {
userId: anotherUser._id,
type: 'edit-card-group',
groupId,
server_url: BASE_URL,
};
const res = await createEditCardCheckoutSession({ user: anotherUser, groupId }, stripe);

View File

@@ -308,7 +308,6 @@ describe('Stripe - One Time Payments', () => {
customerId,
paymentMethod: 'Gift',
gift,
autoRenews: false,
gemsBlock: undefined,
});
});

View File

@@ -173,7 +173,6 @@ describe('Stripe - Subscriptions', () => {
paymentMethod: 'Stripe',
sub: sinon.match({ ...sub }),
groupId: null,
autoRenews: true,
});
});
@@ -198,7 +197,6 @@ describe('Stripe - Subscriptions', () => {
paymentMethod: 'Stripe',
sub: sinon.match({ ...sub }),
groupId,
autoRenews: true,
});
});
@@ -233,7 +231,6 @@ describe('Stripe - Subscriptions', () => {
paymentMethod: 'Stripe',
sub: sinon.match({ ...sub }),
groupId,
autoRenews: true,
});
});
});

View File

@@ -16,7 +16,6 @@ import * as subscriptions from '../../../../../../website/server/libs/payments/s
const { i18n } = common;
describe('Stripe - Webhooks', () => {
const BASE_URL = nconf.get('BASE_URL');
const stripe = stripeModule('test');
const endpointSecret = nconf.get('STRIPE_WEBHOOKS_ENDPOINT_SECRET');
const headers = {};
@@ -285,9 +284,7 @@ describe('Stripe - Webhooks', () => {
const session = {};
beforeEach(() => {
session.metadata = {
server_url: BASE_URL,
};
session.metadata = {};
event = { type: eventType, data: { object: session } };
constructEventStub = sandbox.stub(stripe.webhooks, 'constructEvent');
constructEventStub.returns(event);

View File

@@ -1,11 +1,8 @@
import nconf from 'nconf';
import requireAgain from 'require-again';
import {
generateRes,
generateReq,
} from '../../../helpers/api-unit.helper';
const authPath = '../../../../website/server/middlewares/auth';
import { authWithHeaders as authWithHeadersFactory } from '../../../../website/server/middlewares/auth';
describe('auth middleware', () => {
let res; let req; let
@@ -19,7 +16,6 @@ describe('auth middleware', () => {
describe('auth with headers', () => {
it('allows to specify a list of user field that we do not want to load', done => {
const authWithHeadersFactory = requireAgain(authPath).authWithHeaders;
const authWithHeaders = authWithHeadersFactory({
userFieldsToExclude: ['items'],
});
@@ -39,7 +35,6 @@ describe('auth middleware', () => {
});
it('makes sure some fields are always included', done => {
const authWithHeadersFactory = requireAgain(authPath).authWithHeaders;
const authWithHeaders = authWithHeadersFactory({
userFieldsToExclude: [
'items', 'auth.timestamps',
@@ -65,57 +60,5 @@ describe('auth middleware', () => {
return done();
});
});
it('errors with InvalidCredentialsError and code when token is wrong', done => {
const authWithHeadersFactory = requireAgain(authPath).authWithHeaders;
const authWithHeaders = authWithHeadersFactory({ userFieldsToExclude: [] });
req.headers['x-api-user'] = user._id;
req.headers['x-api-key'] = 'totally-wrong-token';
authWithHeaders(req, res, err => {
expect(err).to.exist;
expect(err.name).to.equal('InvalidCredentialsError');
expect(err.code).to.equal('invalid_credentials');
expect(err.message).to.equal(res.t('invalidCredentials'));
return done();
});
});
describe('when ENFORCE_CLIENT_HEADER is true', () => {
let authFactory;
beforeEach(() => {
sandbox.stub(nconf, 'get').withArgs('ENFORCE_CLIENT_HEADER').returns('true');
authFactory = requireAgain(authPath).authWithHeaders;
});
it('errors with missingClientHeader when x-client header is not present', done => {
const authWithHeaders = authFactory({ userFieldsToExclude: [] });
req.headers['x-api-user'] = user._id;
req.headers['x-api-key'] = user;
authWithHeaders(req, res, err => {
expect(err).to.exist;
expect(err.name).to.equal('BadRequest');
expect(err.message).to.equal(res.t('missingClientHeader'));
return done();
});
});
it('allows request to pass when x-client header is present', done => {
const authWithHeaders = authFactory({ userFieldsToExclude: [] });
req.headers['x-api-user'] = user._id;
req.headers['x-api-key'] = user.apiToken;
req.headers['x-client'] = 'habitica-web';
authWithHeaders(req, res, err => {
if (err) return done(err);
expect(res.locals.user).to.exist;
return done();
});
});
});
});
});

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);
});
});
});

View File

@@ -0,0 +1,332 @@
import moment from 'moment';
import { v4 as generateUUID } from 'uuid';
import {
generateRes,
generateReq,
generateTodo,
generateDaily,
} from '../../../helpers/api-unit.helper';
import cronMiddleware from '../../../../website/server/middlewares/cron';
import { model as User } from '../../../../website/server/models/user';
import { model as Group } from '../../../../website/server/models/group';
import * as Tasks from '../../../../website/server/models/task';
import * as analyticsService from '../../../../website/server/libs/analyticsService';
import * as cronLib from '../../../../website/server/libs/cron';
const CRON_TIMEOUT_WAIT = new Date(60 * 60 * 1000).getTime();
const CRON_TIMEOUT_UNIT = new Date(60 * 1000).getTime();
describe('cron middleware', () => {
let res; let
req;
let user;
beforeEach(async () => {
res = generateRes();
req = generateReq();
user = await res.locals.user.save();
res.analytics = analyticsService;
});
afterEach(() => {
sandbox.restore();
});
it('calls next when user is not attached', done => {
res.locals.user = null;
cronMiddleware(req, res, done);
});
it('calls next when days have not been missed', done => {
cronMiddleware(req, res, done);
});
it('should clear todos older than 30 days for free users', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const task = generateTodo(user);
task.dateCompleted = moment(new Date()).subtract({ days: 31 });
task.completed = true;
await task.save();
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
Tasks.Task.findOne({ _id: task }).then(foundTask => {
expect(foundTask).to.not.exist;
resolve();
});
return null;
});
});
});
it('should not clear todos older than 30 days for subscribed users', async () => {
user.purchased.plan.customerId = 'subscribedId';
user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY');
user.lastCron = moment(new Date()).subtract({ days: 2 });
const task = generateTodo(user);
task.dateCompleted = moment(new Date()).subtract({ days: 31 });
task.completed = true;
await task.save();
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
Tasks.Task.findOne({ _id: task }).then(foundTask => {
expect(foundTask).to.exist;
return resolve();
});
return null;
});
});
});
it('should clear todos older than 90 days for subscribed users', async () => {
user.purchased.plan.customerId = 'subscribedId';
user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY');
user.lastCron = moment(new Date()).subtract({ days: 2 });
const task = generateTodo(user);
task.dateCompleted = moment(new Date()).subtract({ days: 91 });
task.completed = true;
await task.save();
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
Tasks.Task.findOne({ _id: task }).then(foundTask => {
expect(foundTask).to.not.exist;
return resolve();
});
return null;
});
});
});
it('should call next if user was not modified after cron', async () => {
const hpBefore = user.stats.hp;
user.lastCron = moment(new Date()).subtract({ days: 2 });
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
expect(hpBefore).to.equal(user.stats.hp);
return resolve();
});
});
});
it('runs cron if previous cron was incomplete', async () => {
user.lastCron = moment(new Date()).subtract({ days: 1 });
user.auth.timestamps.loggedin = moment(new Date()).subtract({ days: 4 });
const now = new Date();
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
expect(moment(now).isSame(user.lastCron, 'day'));
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
return resolve();
});
});
});
it('updates user.auth.timestamps.loggedin and lastCron', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const now = new Date();
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
expect(moment(now).isSame(user.lastCron, 'day'));
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
return resolve();
});
});
});
it('does damage for missing dailies', async () => {
const hpBefore = user.stats.hp;
user.lastCron = moment(new Date()).subtract({ days: 2 });
const daily = generateDaily(user);
daily.startDate = moment(new Date()).subtract({ days: 2 });
await daily.save();
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
return User.findOne({ _id: user._id }).then(updatedUser => {
expect(updatedUser.stats.hp).to.be.lessThan(hpBefore);
return resolve();
});
});
});
});
it('updates tasks', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const todo = generateTodo(user);
const todoValueBefore = todo.value;
await Promise.all([todo.save(), user.save()]);
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
return Tasks.Task.findOne({ _id: todo._id }).then(todoFound => {
expect(todoFound.value).to.be.lessThan(todoValueBefore);
return resolve();
});
});
});
});
it('applies quest progress', async () => {
const hpBefore = user.stats.hp;
user.lastCron = moment(new Date()).subtract({ days: 2 });
const daily = generateDaily(user);
daily.startDate = moment(new Date()).subtract({ days: 2 });
await daily.save();
const questKey = 'dilatory';
user.party.quest.key = questKey;
const party = new Group({
type: 'party',
name: generateUUID(),
leader: user._id,
});
party.quest.members[user._id] = true;
party.quest.key = questKey;
await party.save();
user.party._id = party._id;
await user.save();
party.startQuest(user);
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
return User.findOne({ _id: user._id }).then(updatedUser => {
expect(updatedUser.stats.hp).to.be.lessThan(hpBefore);
return resolve();
});
});
});
});
it('recovers from failed cron and does not error when user is already cronning', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
await user.save();
const updatedUser = user.toObject();
updatedUser.matchedCount = 0;
sandbox.spy(cronLib, 'recoverCron');
sandbox.stub(User, 'updateOne')
.withArgs({
_id: user._id,
$or: [
{ _cronSignature: 'NOT_RUNNING' },
{ _cronSignature: { $lt: sinon.match.number } },
],
})
.returns({
exec () {
return Promise.resolve(updatedUser);
},
});
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
expect(cronLib.recoverCron).to.be.calledOnce;
return resolve();
});
});
});
it('cronSignature less than an hour ago should error', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const now = new Date();
await User.updateOne({
_id: user._id,
}, {
$set: {
_cronSignature: now.getTime() - CRON_TIMEOUT_WAIT + CRON_TIMEOUT_UNIT,
},
}).exec();
await user.save();
const expectedErrMessage = `Impossible to recover from cron for user ${user._id}.`;
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (!err) return reject(new Error('Cron should have failed.'));
expect(err.message).to.be.equal(expectedErrMessage);
return resolve();
});
});
});
it('cronSignature longer than an hour ago should allow cron', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const now = new Date();
await User.updateOne({
_id: user._id,
}, {
$set: {
_cronSignature: now.getTime() - CRON_TIMEOUT_WAIT - CRON_TIMEOUT_UNIT,
},
}).exec();
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
expect(user._cronSignature).to.be.equal('NOT_RUNNING');
return resolve();
});
});
});
it('cron should not run more than once', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
await user.save();
sandbox.spy(cronLib, 'cron');
await Promise.all([new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
return resolve();
});
}), new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
return resolve();
});
}), new Promise((resolve, reject) => {
setTimeout(() => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
return resolve();
});
}, 400);
}),
]);
expect(cronLib.cron).to.be.calledOnce;
});
});

View File

@@ -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);
});
});

View File

@@ -274,13 +274,13 @@ describe('Group Model', () => {
expect(Group.prototype.sendChat).to.be.calledOnce;
expect(Group.prototype.sendChat).to.be.calledWith({
message: '`Participating Member attacks Wailing Whale for 5 damage. Wailing Whale attacks party for 8 damage.`',
message: '`Participating Member attacks Wailing Whale for 5.0 damage. Wailing Whale attacks party for 7.5 damage.`',
info: {
bossDamage: '8',
bossDamage: '7.5',
quest: 'whale',
type: 'boss_damage',
user: 'Participating Member',
userDamage: '5',
userDamage: '5.0',
},
});
});

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);
});
});
});

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);
});
});
@@ -244,18 +238,6 @@ describe('POST /chat', () => {
expect(groupMessages[0].id).to.exist;
});
it('creates a chat with case-insensitive mentions', async () => {
const originalUsername = member.auth.local.username;
const uppercaseUsername = originalUsername.toUpperCase();
const messageWithMentions = `hi @${uppercaseUsername}`;
const newMessage = await user.post(`/groups/${groupWithChat._id}/chat`, { message: messageWithMentions });
const groupMessages = await user.get(`/groups/${groupWithChat._id}/chat`);
expect(newMessage.message.id).to.exist;
expect(newMessage.message.text).to.include(`[@${uppercaseUsername}](/profile/${member._id})`);
expect(groupMessages[0].id).to.exist;
});
it('creates a chat with a max length of 3000 chars', async () => {
const veryLongMessage = `
123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789.

View File

@@ -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'));
});
});
});

View File

@@ -59,7 +59,7 @@ describe('POST /debug/jump-time', () => {
expect(resultDate.getDate()).to.eql(today.getDate());
expect(resultDate.getMonth()).to.eql(today.getMonth());
expect(resultDate.getFullYear()).to.eql(today.getFullYear());
const newResultDate = new Date((await user.post('/debug/jump-time', { offsetDays: 365 })).time);
const newResultDate = new Date((await user.post('/debug/jump-time', { offsetDays: 355 })).time);
expect(newResultDate.getFullYear()).to.eql(today.getFullYear() + 1);
});

View File

@@ -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`, {

View File

@@ -61,12 +61,12 @@ describe('PUT /heroes/:heroId', () => {
expect(heroRes.profile).to.have.all.keys(['name']);
// test response values
expect(heroRes.balance).to.equal(3 + 2.5); // 3+2.5 for first contrib level
expect(heroRes.balance).to.equal(3 + 0.75); // 3+0.75 for first contrib level
expect(heroRes.contributor.level).to.equal(1);
expect(heroRes.purchased.ads).to.equal(true);
// test hero values
await hero.sync();
expect(hero.balance).to.equal(3 + 2.5); // 3+2.5 for first contrib level
expect(hero.balance).to.equal(3 + 0.75); // 3+0.75 for first contrib level
expect(hero.contributor.level).to.equal(1);
expect(hero.purchased.ads).to.equal(true);
expect(hero.auth.blocked).to.equal(prevBlockState);
@@ -137,12 +137,12 @@ describe('PUT /heroes/:heroId', () => {
expect(heroRes.profile).to.have.all.keys(['name']);
// test response values
expect(heroRes.balance).to.equal(15); // 0+15 for sixth contrib level
expect(heroRes.balance).to.equal(1); // 0+1 for sixth contrib level
expect(heroRes.contributor.level).to.equal(6);
expect(heroRes.items.pets['Dragon-Hydra']).to.equal(5);
// test hero values
await hero.sync();
expect(hero.balance).to.equal(15); // 0+15 for sixth contrib level
expect(hero.balance).to.equal(1); // 0+1 for sixth contrib level
expect(hero.contributor.level).to.equal(6);
expect(hero.items.pets['Dragon-Hydra']).to.equal(5);
});

View File

@@ -1,56 +0,0 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
import common from '../../../../../website/common';
describe('GET /members/username/:username', () => {
let user;
before(async () => {
user = await generateUser();
});
it('validates req.params.username', async () => {
await expect(user.get('/members/username/')).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('returns a member\'s public data only', async () => {
// make sure user has all the fields that can be returned by the getMember call
const member = await generateUser({
contributor: { level: 1 },
backer: { tier: 3 },
preferences: {
costume: false,
background: 'volcano',
},
secret: {
text: 'Clark Kent',
},
});
const memberRes = await user.get(`/members/username/${member.auth.local.username}`);
expect(memberRes).to.have.all.keys([ // works as: object has all and only these keys
'_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party',
'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', 'flags',
]);
expect(Object.keys(memberRes.auth)).to.eql(['local', 'timestamps']);
expect(Object.keys(memberRes.preferences).sort()).to.eql([
'size', 'hair', 'skin', 'shirt',
'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses',
].sort());
expect(memberRes.stats.maxMP).to.exist;
expect(memberRes.stats.maxHealth).to.equal(common.maxHealth);
expect(memberRes.stats.toNextLevel).to.equal(common.tnl(memberRes.stats.lvl));
expect(memberRes.inbox.optOut).to.exist;
expect(memberRes.inbox.canReceive).to.exist;
expect(memberRes.inbox.messages).to.not.exist;
expect(memberRes.secret).to.not.exist;
expect(memberRes.blocks).to.not.exist;
});
});

View File

@@ -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);
});
});
});

View File

@@ -101,6 +101,34 @@ describe('GET /tasks/user', () => {
expect(allCompletedTodos[allCompletedTodos.length - 1].text).to.equal('todo to complete 2');
});
it('returns only some completed todos if req.query.type is "completedTodos" or "_allCompletedTodos"', async () => {
const LIMIT = 30;
const numberOfTodos = LIMIT + 1;
const todosInput = [];
for (let i = 0; i < numberOfTodos; i += 1) {
todosInput[i] = { text: `todo to complete ${i}`, type: 'todo' };
}
const todos = await user.post('/tasks/user', todosInput);
await user.sync();
const initialTodoCount = user.tasksOrder.todos.length;
for (let i = 0; i < numberOfTodos; i += 1) {
const id = todos[i]._id;
await user.post(`/tasks/${id}/score/up`); // eslint-disable-line no-await-in-loop
}
await user.sync();
expect(user.tasksOrder.todos.length).to.equal(initialTodoCount - numberOfTodos);
const completedTodos = await user.get('/tasks/user?type=completedTodos');
expect(completedTodos.length).to.equal(LIMIT);
const allCompletedTodos = await user.get('/tasks/user?type=_allCompletedTodos');
expect(allCompletedTodos.length).to.equal(numberOfTodos);
});
it('returns dailies with isDue for the date specified', async () => {
// @TODO Add required format
const startDate = moment().subtract('1', 'days').toISOString();

View File

@@ -125,90 +125,6 @@ describe('POST /tasks/:id/score/:direction', () => {
expect(body.finalLvl).to.eql(user.stats.lvl);
});
});
context('handles drops', async () => {
let randomStub;
afterEach(() => {
randomStub.restore();
});
it('gives user a drop', async () => {
user = await generateUser({
'stats.gp': 100,
'achievements.completedTask': true,
'items.eggs': {
Wolf: 1,
},
});
randomStub = sandbox.stub(Math, 'random').returns(0.1);
const task = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
const res = await user.post(`/tasks/${task.id}/score/up`);
expect(res._tmp.drop).to.be.ok;
});
it('does not give a drop when non-sub drop cap is reached', async () => {
user = await generateUser({
'stats.gp': 100,
'achievements.completedTask': true,
'items.eggs': {
Wolf: 1,
},
'items.lastDrop.count': 5,
});
randomStub = sandbox.stub(Math, 'random').returns(0.1);
const task = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
const res = await user.post(`/tasks/${task.id}/score/up`);
expect(res._tmp.drop).to.be.undefined;
});
it('gives a drop when subscriber is over regular cap but under subscriber cap', async () => {
user = await generateUser({
'stats.gp': 100,
'achievements.completedTask': true,
'items.eggs': {
Wolf: 1,
},
'items.lastDrop.count': 6,
'purchased.plan.customerId': '123',
});
randomStub = sandbox.stub(Math, 'random').returns(0.1);
const task = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
const res = await user.post(`/tasks/${task.id}/score/up`);
expect(res._tmp.drop).to.be.ok;
});
it('does not give a drop when subscriber is at subscriber drop cap', async () => {
user = await generateUser({
'stats.gp': 100,
'achievements.completedTask': true,
'items.eggs': {
Wolf: 1,
},
'items.lastDrop.count': 10,
'purchased.plan.customerId': '123',
});
randomStub = sandbox.stub(Math, 'random').returns(0.1);
const task = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
});
const res = await user.post(`/tasks/${task.id}/score/up`);
expect(res._tmp.drop).to.be.undefined;
});
});
});
context('todos', () => {

View File

@@ -105,9 +105,9 @@ describe('POST /tasks/:taskId/assign/:memberId', () => {
const groupTask = await user.get(`/tasks/group/${guild._id}`);
const lastNotification = member.notifications[member.notifications.length - 1];
expect(lastNotification.type).to.equal('GROUP_TASK_ASSIGNED');
expect(lastNotification.taskId).to.equal(groupTask._id);
expect(member.notifications.length).to.equal(2);
expect(member.notifications[1].type).to.equal('GROUP_TASK_ASSIGNED');
expect(member.notifications[1].taskId).to.equal(groupTask._id);
});
it('assigns a task to multiple users', async () => {

View File

@@ -89,12 +89,10 @@ describe('POST /tasks/:taskId/unassign/:memberId', () => {
});
it('removes task assignment notification from unassigned user', async () => {
await member.sync();
const oldNotificationCount = member.notifications.length;
await user.post(`/tasks/${task._id}/unassign/${member._id}`);
await member.sync();
expect(member.notifications.length).to.equal(oldNotificationCount - 1);
expect(member.notifications.length).to.equal(1); // mystery items
});
it('unassigns a user and only that user from a task', async () => {

View File

@@ -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);

View File

@@ -238,28 +238,6 @@ describe('POST /user/auth/reset-password-set-new-one', () => {
expect(isPassValid).to.equal(true);
});
it('changes the apiToken on password reset', async () => {
const user = await generateUser();
const previousToken = user.apiToken;
const code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({ days: 1 }),
}));
await user.updateOne({
'auth.local.passwordResetCode': code,
});
await api.post(`${endpoint}`, {
newPassword: 'my new password',
confirmPassword: 'my new password',
code,
});
await user.sync();
expect(user.apiToken).to.not.eql(previousToken);
});
it('renders the success page and convert the password from sha1 to bcrypt', async () => {
const user = await generateUser();

View File

@@ -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,

View File

@@ -6,7 +6,6 @@ import {
translate as t,
getProperty,
} from '../../../../../helpers/api-integration/v3';
import apiErrorMessages from '../../../../../../website/common/script/errors/apiErrorMessages';
describe('POST /user/auth/social', () => {
let api;
@@ -65,18 +64,6 @@ describe('POST /user/auth/social', () => {
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a google user');
});
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
@@ -144,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
@@ -210,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);
});
});
});

View File

@@ -27,30 +27,11 @@ describe('PUT /user/auth/update-password', async () => {
newPassword,
confirmPassword: newPassword,
});
expect(response).to.exist;
expect(response.apiToken).to.exist;
expect(response).to.eql({});
await user.sync();
expect(user.auth.local.hashed_password).to.not.eql(previousHashedPassword);
});
it('should change the apiToken on password change', async () => {
const previousToken = user.apiToken;
const response = await user.put(ENDPOINT, {
password,
newPassword,
confirmPassword: newPassword,
});
const newToken = response.apiToken;
expect(newToken).to.exist;
await user.sync();
expect(user.apiToken).to.eql(newToken);
expect(user.apiToken).to.not.eql(previousToken);
});
it('returns an error when confirmPassword does not match newPassword', async () => {
await expect(user.put(ENDPOINT, {
password,

View File

@@ -31,7 +31,7 @@ describe('POST /user/buy-mystery-set/:key', () => {
expect(res.data).to.eql({
items: JSON.parse(JSON.stringify(user.items)), // otherwise dates can't be compared
purchasedPlanConsecutive: JSON.parse(JSON.stringify(user.purchased.plan.consecutive)),
purchasedPlanConsecutive: user.purchased.plan.consecutive,
});
expect(res.message).to.equal(t('hourglassPurchaseSet'));
});

View File

@@ -1,104 +0,0 @@
import find from 'lodash/find';
import {
generateUser,
translate as t,
} from '../../../helpers/api-integration/v4';
/**
* Checks the messages array if the uniqueMessageId has the like flag
* @param {InboxMessage[]} messages
* @param {String} uniqueMessageId
* @param {String} userId
* @param {Boolean} likeStatus
*/
function expectMessagesLikeStatus (messages, uniqueMessageId, userId, likeStatus) {
const messageToCheck = find(messages, { uniqueMessageId });
expect(messageToCheck.likes[userId]).to.equal(likeStatus);
}
// eslint-disable-next-line mocha/no-exclusive-tests
describe('POST /inbox/like-private-message/:messageId', () => {
let userToSendMessage;
const getLikeUrl = messageId => `/inbox/like-private-message/${messageId}`;
before(async () => {
userToSendMessage = await generateUser();
});
it('returns an error when private message is not found', async () => {
await expect(userToSendMessage.post(getLikeUrl('some-unknown-id')))
.to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('messageGroupChatNotFound'),
});
});
it('likes a message', async () => {
const receiver = await generateUser();
const sentMessageResult = await userToSendMessage.post('/members/send-private-message', {
message: 'some message :)',
toUserId: receiver._id,
});
const { uniqueMessageId } = sentMessageResult.message;
const likeResult = await receiver.post(getLikeUrl(uniqueMessageId));
expect(likeResult.likes[receiver._id]).to.equal(true);
const senderMessages = await userToSendMessage.get('/inbox/messages');
expectMessagesLikeStatus(senderMessages, uniqueMessageId, receiver._id, true);
const receiversMessages = await receiver.get('/inbox/messages');
expectMessagesLikeStatus(receiversMessages, uniqueMessageId, receiver._id, true);
});
it('allows a user to like their own private message', async () => {
const receiver = await generateUser();
const sentMessageResult = await userToSendMessage.post('/members/send-private-message', {
message: 'some message :)',
toUserId: receiver._id,
});
const { uniqueMessageId } = sentMessageResult.message;
const likeResult = await userToSendMessage.post(getLikeUrl(uniqueMessageId));
expect(likeResult.likes[userToSendMessage._id]).to.equal(true);
const messages = await userToSendMessage.get('/inbox/messages');
expectMessagesLikeStatus(messages, uniqueMessageId, userToSendMessage._id, true);
const receiversMessages = await receiver.get('/inbox/messages');
expectMessagesLikeStatus(receiversMessages, uniqueMessageId, userToSendMessage._id, true);
});
it('unlikes a message', async () => {
const receiver = await generateUser();
const sentMessageResult = await userToSendMessage.post('/members/send-private-message', {
message: 'some message :)',
toUserId: receiver._id,
});
const { uniqueMessageId } = sentMessageResult.message;
const likeResult = await receiver.post(getLikeUrl(uniqueMessageId));
expect(likeResult.likes[receiver._id]).to.equal(true);
const unlikeResult = await receiver.post(getLikeUrl(uniqueMessageId));
expect(unlikeResult.likes[receiver._id]).to.equal(false);
const messages = await userToSendMessage.get('/inbox/messages');
const messageToCheck = find(messages, { id: sentMessageResult.message.id });
expect(messageToCheck.likes[receiver._id]).to.equal(false);
});
});

View File

@@ -40,24 +40,6 @@ describe('GET /user', () => {
expect(returnedUser.stats).to.not.exist;
});
it('returns when ALWAYS_LOADED paths are requested', async () => {
const returnedUser = await user.get('/user?userFields=_id,notifications,preferences,auth,flags,permissions');
expect(returnedUser._id).to.equal(user._id);
expect(returnedUser.notifications).to.exist;
expect(returnedUser.preferences).to.exist;
expect(returnedUser.auth).to.exist;
expect(returnedUser.flags).to.exist;
expect(returnedUser.permissions).to.exist;
});
it('returns when subpaths paths are requested', async () => {
const returnedUser = await user.get('/user?userFields=auth.local.username');
expect(returnedUser._id).to.equal(user._id);
expect(returnedUser.auth.local.username).to.exist;
});
it('does not return requested private properties', async () => {
const returnedUser = await user.get('/user?userFields=apiToken,secret.text');

View File

@@ -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;
});
});

View File

@@ -183,6 +183,8 @@ describe('cron utility functions', () => {
});
describe('getPlanContext', () => {
const now = new Date(2022, 5, 1);
function baseUserData (count, offset, planId) {
return {
purchased: {
@@ -190,7 +192,7 @@ describe('cron utility functions', () => {
consecutive: {
count,
offset,
gemCapExtra: 26,
gemCapExtra: 25,
trinkets: 19,
},
quantity: 1,
@@ -211,19 +213,52 @@ describe('cron utility functions', () => {
};
}
it('elapsedMonths is 0 if its the same month', () => {
it('monthly plan, next date in 3 months', () => {
const user = baseUserData(60, 0, 'group_plan_auto');
user.purchased.plan.perkMonthCount = 0;
const planContext = getPlanContext(user, new Date(2022, 4, 20));
expect(planContext.elapsedMonths).to.equal(0);
const planContext = getPlanContext(user, now);
expect(planContext.nextHourglassDate)
.to.be.sameMoment('2022-08-10T02:00:00.144Z');
});
it('elapsedMonths is 1 after one month', () => {
const user = baseUserData(60, 0, 'group_plan_auto');
it('monthly plan, next date in 1 month', () => {
const user = baseUserData(62, 0, 'group_plan_auto');
user.purchased.plan.perkMonthCount = 2;
const planContext = getPlanContext(user, new Date(2022, 5, 11));
const planContext = getPlanContext(user, now);
expect(planContext.elapsedMonths).to.equal(1);
expect(planContext.nextHourglassDate)
.to.be.sameMoment('2022-06-10T02:00:00.144Z');
});
it('multi-month plan, no offset', () => {
const user = baseUserData(60, 0, 'basic_3mo');
const planContext = getPlanContext(user, now);
expect(planContext.nextHourglassDate)
.to.be.sameMoment('2022-06-10T02:00:00.144Z');
});
it('multi-month plan with offset', () => {
const user = baseUserData(60, 1, 'basic_3mo');
const planContext = getPlanContext(user, now);
expect(planContext.nextHourglassDate)
.to.be.sameMoment('2022-07-10T02:00:00.144Z');
});
it('multi-month plan with perk count', () => {
const user = baseUserData(60, 1, 'basic_3mo');
user.purchased.plan.perkMonthCount = 2;
const planContext = getPlanContext(user, now);
expect(planContext.nextHourglassDate)
.to.be.sameMoment('2022-07-10T02:00:00.144Z');
});
});
});

View File

@@ -10,7 +10,7 @@ describe('events', () => {
});
it('returns empty array when no events are active', () => {
clock = sinon.useFakeTimers(new Date('2024-01-11'));
clock = sinon.useFakeTimers(new Date('2024-01-06'));
const events = getRepeatingEvents();
expect(events).to.be.empty;
});
@@ -27,14 +27,14 @@ describe('events', () => {
it('returns nye event at beginning of the year', () => {
clock = sinon.useFakeTimers(new Date('2025-01-01'));
const events = getRepeatingEvents();
expect(events).to.have.length(2);
expect(events).to.have.length(1);
expect(events[0].key).to.equal('nye');
});
it('returns nye event at end of the year', () => {
clock = sinon.useFakeTimers(new Date('2024-12-30'));
const events = getRepeatingEvents();
expect(events).to.have.length(2);
expect(events).to.have.length(1);
expect(events[0].key).to.equal('nye');
});
});

View File

@@ -72,7 +72,7 @@ describe('food', () => {
});
it('sets canDrop for pie if it is pie season', () => {
clock = sinon.useFakeTimers(new Date(2024, 2, 15));
clock = sinon.useFakeTimers(new Date(2024, 2, 14));
const datedContent = require('../../website/common/script/content').default;
each(datedContent.food, foodItem => {
if (foodItem.key.indexOf('Pie_') !== -1) {

View File

@@ -131,7 +131,7 @@ describe('content index', () => {
});
it('marks pie as buyable and droppable during pi day', () => {
clock = sinon.useFakeTimers(new Date('2024-03-15'));
clock = sinon.useFakeTimers(new Date('2024-03-14'));
const { food } = content;
Object.keys(food).forEach(key => {
if (key === 'Saddle') {

View File

@@ -1,42 +0,0 @@
import {
each,
} from 'lodash';
import {
expectValidTranslationString,
} from '../helpers/content.helper';
import { quests } from '../../website/common/script/content/quests';
describe('quests', () => {
let clock;
afterEach(() => {
if (clock) {
clock.restore();
}
});
it('contains basic information about each quest', () => {
each(quests, (quest, key) => {
expectValidTranslationString(quest.text);
expectValidTranslationString(quest.notes);
expectValidTranslationString(quest.completion);
expect(quest.key, key).to.equal(key);
expect(quest.category, key).to.be.a('string');
if (quest.boss) {
expectValidTranslationString(quest.boss.name);
expect(quest.boss.hp, key).to.be.a('number');
expect(quest.boss.str, key).to.be.a('number');
}
expect(quest.drop).to.be.an('object');
expect(quest.drop.gp, key).to.be.a('number');
expect(quest.drop.exp, key).to.be.a('number');
if (quest.drop.items) {
quest.drop.items.forEach(drop => {
expectValidTranslationString(drop.text);
expect(drop.type, key).to.exist;
});
}
});
});
});

View File

@@ -19,8 +19,8 @@ describe('releaseDates', () => {
});
describe('armoire', () => {
it('should only contain valid armoire names', () => {
const lastReleaseDate = maxBy(Object.values(ARMOIRE_RELEASE_DATES), value => new Date(`${value.year}-${value.month}-22`));
clock = sinon.useFakeTimers(new Date(`${lastReleaseDate.year}-${lastReleaseDate.month}-22`));
const lastReleaseDate = maxBy(Object.values(ARMOIRE_RELEASE_DATES), value => new Date(`${value.year}-${value.month + 1}-20`));
clock = sinon.useFakeTimers(new Date(`${lastReleaseDate.year}-${lastReleaseDate.month + 1}-20`));
Object.keys(ARMOIRE_RELEASE_DATES).forEach(key => {
expect(find(armoire.all, { set: key }), `${key} is not a valid armoire set`).to.exist;
});
@@ -40,8 +40,8 @@ describe('releaseDates', () => {
describe('eggs', () => {
it('should only contain valid egg names', () => {
const lastReleaseDate = maxBy(Object.values(EGGS_RELEASE_DATES), value => new Date(`${value.year}-${value.month}-${value.day}`));
clock = sinon.useFakeTimers(new Date(`${lastReleaseDate.year}-${lastReleaseDate.month}-${lastReleaseDate.day + 1}`));
const lastReleaseDate = maxBy(Object.values(EGGS_RELEASE_DATES), value => new Date(`${value.year}-${value.month + 1}-${value.day}`));
clock = sinon.useFakeTimers(new Date(`${lastReleaseDate.year}-${lastReleaseDate.month + 1}-${lastReleaseDate.day}`));
Object.keys(EGGS_RELEASE_DATES).forEach(key => {
expect(eggs.all[key], `${key} is not a valid egg name`).to.exist;
});
@@ -61,8 +61,8 @@ describe('releaseDates', () => {
describe('hatchingPotions', () => {
it('should only contain valid potion names', () => {
const lastReleaseDate = maxBy(Object.values(HATCHING_POTIONS_RELEASE_DATES), value => new Date(`${value.year}-${value.month}-${value.day}`));
clock = sinon.useFakeTimers(new Date(`${lastReleaseDate.year}-${lastReleaseDate.month}-${lastReleaseDate.day + 1}`));
const lastReleaseDate = maxBy(Object.values(HATCHING_POTIONS_RELEASE_DATES), value => new Date(`${value.year}-${value.month + 1}-${value.day}`));
clock = sinon.useFakeTimers(new Date(`${lastReleaseDate.year}-${lastReleaseDate.month + 1}-${lastReleaseDate.day}`));
Object.keys(HATCHING_POTIONS_RELEASE_DATES).forEach(key => {
expect(hatchingPotions.all[key], `${key} is not a valid potion name`).to.exist;
});

View File

@@ -1,5 +1,4 @@
// eslint-disable-next-line max-len
import maxBy from 'lodash/maxBy';
import moment from 'moment';
import nconf from 'nconf';
import {
@@ -11,7 +10,6 @@ import QUEST_BUNDLES from '../../website/common/script/content/bundles';
import potions from '../../website/common/script/content/hatching-potions';
import SPELLS from '../../website/common/script/content/spells';
import QUEST_SEASONAL from '../../website/common/script/content/quests/seasonal';
import { HATCHING_POTIONS_RELEASE_DATES } from '../../website/common/script/content/constants/releaseDates';
function validateMatcher (matcher, checkedDate) {
expect(matcher.end).to.be.a('date');
@@ -133,21 +131,15 @@ describe('Content Schedule', () => {
});
it('sets the end date for a gala', () => {
const date = new Date('2024-05-31');
const date = new Date('2024-05-20');
const matchers = getAllScheduleMatchingGroups(date);
expect(matchers.seasonalGear.end).to.eql(moment.utc(`2024-06-01T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
expect(matchers.seasonalGear.end).to.eql(moment.utc(`2024-06-21T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
});
it('sets the end date for a winter gala', () => {
const date = new Date('2025-02-28');
const date = new Date('2024-12-22');
const matchers = getAllScheduleMatchingGroups(date);
expect(matchers.seasonalGear.end).to.eql(moment.utc(`2025-03-01T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
});
it('sets the end date in new year for a winter gala', () => {
const date = new Date('2025-02-28');
const matchers = getAllScheduleMatchingGroups(date);
expect(matchers.seasonalGear.end).to.eql(moment.utc(`2025-03-01T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
expect(matchers.seasonalGear.end).to.eql(moment.utc(`2025-03-21T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
});
it('uses correct date for first hours of the month', () => {
@@ -190,7 +182,7 @@ describe('Content Schedule', () => {
const date = new Date('2024-04-15');
const matchers = getAllScheduleMatchingGroups(date);
expect(matchers.premiumHatchingPotions).to.exist;
expect(matchers.premiumHatchingPotions.items.length).to.equal(6);
expect(matchers.premiumHatchingPotions.items.length).to.equal(5);
expect(matchers.premiumHatchingPotions.items.indexOf('Veggie')).to.not.equal(-1);
expect(matchers.premiumHatchingPotions.items.indexOf('Porcelain')).to.not.equal(-1);
});
@@ -230,8 +222,6 @@ describe('Content Schedule', () => {
});
it('premium hatching potions', () => {
const lastReleaseDate = maxBy(Object.values(HATCHING_POTIONS_RELEASE_DATES), value => new Date(`${value.year}-${value.month}-${value.day}`));
clock = sinon.useFakeTimers(new Date(`${lastReleaseDate.year}-${lastReleaseDate.month}-${lastReleaseDate.day + 1}`));
const potionKeys = Object.keys(potions.premium);
Object.keys(MONTHLY_SCHEDULE).forEach(key => {
const monthlyPotions = MONTHLY_SCHEDULE[key][21].find(item => item.type === 'premiumHatchingPotions');
@@ -272,21 +262,6 @@ describe('Content Schedule', () => {
expect(matcher.match('backgroundkey072024')).to.be.true;
});
it('allows background matching the month for new backgrounds from multiple years', () => {
const date = new Date('2026-07-08');
const matcher = getAllScheduleMatchingGroups(date).backgrounds;
expect(matcher.match('backgroundkey072024')).to.be.true;
expect(matcher.match('backgroundkey072025')).to.be.true;
expect(matcher.match('backgroundkey072026')).to.be.true;
});
it('allows background matching the previous month in the first week for new backgrounds', () => {
const date = new Date('2024-09-02');
const matcher = getAllScheduleMatchingGroups(date).backgrounds;
expect(matcher.match('backgroundkey082024')).to.be.true;
expect(matcher.match('backgroundkey092024')).to.be.false;
});
it('disallows background in the future', () => {
const date = new Date('2024-07-08');
const matcher = getAllScheduleMatchingGroups(date).backgrounds;
@@ -306,26 +281,19 @@ describe('Content Schedule', () => {
expect(matcher.match('backgroundkey022021')).to.be.true;
});
it('allows even yeared backgrounds in first half of year', () => {
it('allows background even yeared backgrounds in first half of year', () => {
const date = new Date('2025-02-08');
const matcher = getAllScheduleMatchingGroups(date).backgrounds;
expect(matcher.match('backgroundkey022024')).to.be.true;
expect(matcher.match('backgroundkey082022')).to.be.true;
});
it('allows odd yeared backgrounds in second half of year', () => {
it('allows background odd yeared backgrounds in second half of year', () => {
const date = new Date('2024-08-08');
const matcher = getAllScheduleMatchingGroups(date).backgrounds;
expect(matcher.match('backgroundkey022023')).to.be.true;
expect(matcher.match('backgroundkey082021')).to.be.true;
});
it('allows odd yeared backgrounds in beginning of january', () => {
const date = new Date('2025-01-06');
const matcher = getAllScheduleMatchingGroups(date).backgrounds;
expect(matcher.match('backgroundkey122024'), 'backgroundkey122024').to.be.true;
expect(matcher.match('backgroundkey062023'), 'backgroundkey062022').to.be.true;
});
});
describe('timeTravelers matcher', () => {

View File

@@ -18,7 +18,7 @@ describe('Shop Featured Items', () => {
});
it('contains the current premium hatching potions', () => {
clock = Sinon.useFakeTimers(new Date('2024-04-09'));
clock = Sinon.useFakeTimers(new Date('2024-04-08'));
const items = featuredItems.market();
expect(_.find(items, item => item.path === 'premiumHatchingPotions.Porcelain')).to.exist;
});

View File

@@ -19,6 +19,6 @@ const sinonStubPromise = require('sinon-stub-promise');
sinonStubPromise(global.sinon);
global.sandbox = sinon.createSandbox();
const setupNconf = require('../../website/server/libs/setupNconf').default;
const setupNconf = require('../../website/server/libs/setupNconf');
setupNconf('./config.json.example');

View File

@@ -74,10 +74,15 @@ export async function getDocument (collectionName, doc) {
}
before(done => {
mongoose.connection.once('open', async err => {
if (err) throw err;
await resetHabiticaDB();
done();
mongoose.connection.on('open', err => {
if (err) return done(err);
return resetHabiticaDB()
.then(() => {
done();
})
.catch(error => {
throw error;
});
});
});

View File

@@ -3,7 +3,7 @@
const nconf = require('nconf');
const mongoose = require('mongoose');
const setupNconf = require('../../website/server/libs/setupNconf').default;
const setupNconf = require('../../website/server/libs/setupNconf');
// fix further imports of require/import syntaxes
require('@babel/register');

View File

@@ -3,12 +3,11 @@ module.exports = {
root: true,
env: {
node: true,
es2021: true,
},
extends: [
'habitrpg/lib/vue',
],
ignorePatterns: ['dist/', 'node_modules/', '*.d.ts'],
ignorePatterns: ['dist/', 'node_modules/'],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
@@ -40,4 +39,7 @@ module.exports = {
order: ['template', 'style', 'script'],
}],
},
parserOptions: {
parser: 'babel-eslint',
},
};

View File

@@ -0,0 +1,9 @@
/* eslint-disable import/no-commonjs */
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset',
],
plugins: [
'@babel/plugin-proposal-optional-chaining',
],
};

File diff suppressed because it is too large Load Diff

View File

@@ -3,26 +3,29 @@
"version": "1.0.0",
"private": true,
"scripts": {
"serve": "vite",
"build": "vite build",
"preview": "vite preview",
"test:unit": "vitest run",
"test:unit:watch": "vitest watch",
"lint": "eslint --ext .js,.vue --ignore-path ../../.gitignore --fix .",
"lint-no-fix": "eslint --ext .js,.vue --no-fix src",
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit --require ./tests/unit/helpers.js",
"lint": "vue-cli-service lint .",
"lint-no-fix": "vue-cli-service lint --no-fix .",
"postinstall": "node ./scripts/npm-postinstall.js"
},
"dependencies": {
"@froxz/vite-plugin-s3": "^1.6.0",
"@vitejs/plugin-vue2": "^2.3.3",
"@vue/cli-plugin-babel": "^5.0.8",
"@vue/cli-plugin-eslint": "^5.0.8",
"@vue/cli-plugin-router": "^5.0.8",
"@vue/cli-plugin-unit-mocha": "^5.0.8",
"@vue/cli-service": "^5.0.8",
"@vue/test-utils": "1.0.0-beta.29",
"amplitude-js": "^8.21.3",
"assert": "^2.1.0",
"autoprefixer": "^10.4.20",
"axios": "^0.28.0",
"axios-progress-bar": "^1.2.0",
"babel-eslint": "^10.1.0",
"bootstrap": "^4.6.0",
"bootstrap-vue": "^2.23.1",
"core-js": "^3.33.1",
"dompurify": "^3.0.3",
"eslint": "7.32.0",
"eslint-config-habitrpg": "6.2.0",
"eslint-plugin-mocha": "5.3.0",
@@ -32,34 +35,32 @@
"intro.js": "^7.2.0",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"markdown-it": "^14.0.0",
"moment": "^2.29.4",
"moment-locales-webpack-plugin": "^1.2.0",
"nconf": "^0.12.1",
"sass": "^1.63.4",
"sass-loader": "^14.1.1",
"sinon": "^17.0.1",
"smartbanner.js": "^1.19.3",
"stopword": "^2.0.8",
"timers-browserify": "^2.0.12",
"uuid": "^9.0.1",
"validator": "^13.9.0",
"vite": "^6.3.6",
"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",
"vue-template-babel-compiler": "^2.0.0",
"vue-template-compiler": "^2.7.10",
"vuedraggable": "^2.24.3",
"vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#153d339e4dbebb73733658aeda1d5b7fcc55b0a0"
},
"devDependencies": {
"@babel/plugin-proposal-optional-chaining": "^7.21.0",
"@vitest/browser": "^3.0.5",
"babel-plugin-lodash": "^3.3.4",
"chai": "^5.1.0",
"inspectpack": "^4.7.1",
"jsdom": "^26.0.0",
"mocha": "^11.1.0",
"playwright": "^1.50.1",
"terser-webpack-plugin": "^5.3.10",
"vitest": "^3.0.5",
"webpack": "^5.94.0"
"webpack": "^5.89.0"
}
}

View File

@@ -7,12 +7,23 @@
<title>Habitica - Gamify Your Life</title>
<meta name="description" content="Habitica is a free habit and productivity app that treats your real life like a game. Habitica can help you achieve your goals to become healthy and happy.">
<meta name="keywords" content="Habits,Goals,Todo,Gamification,Health,Fitness,School,Work">
<meta name="smartbanner:title" content="Habitica">
<meta name="smartbanner:author" content="HabitRPG, Inc.">
<meta name="smartbanner:price" content="FREE">
<meta name="smartbanner:price-suffix-apple" content=" - On the App Store">
<meta name="smartbanner:price-suffix-google" content=" - In Google Play">
<meta name="smartbanner:icon-apple" content="/static/presskit/Logo/iOS.png">
<meta name="smartbanner:icon-google" content="/static/presskit/Logo/Android.png">
<meta name="smartbanner:button" content="VIEW">
<meta name="smartbanner:button-url-apple" content="https://itunes.apple.com/us/app/habitica-gamified-taskmanager/id994882113">
<meta name="smartbanner:button-url-google" content="https://play.google.com/store/apps/details?id=com.habitrpg.android.habitica">
<meta name="smartbanner:enabled-platforms" content="android,ios">
<meta name="smartbanner:hide-ttl" content="2592000000">
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700,700i|Roboto:400,400i,700,700i" rel="stylesheet">
<link rel="shortcut icon" sizes="48x48" href="/static/icons/favicon.ico">
<link rel="shortcut icon" sizes="192x192" href="/static/icons/favicon_192x192.png">
<link rel="mask-icon" href="/static/icons/favicon.ico">
<meta property="og:image" content="/static/emails/images/meta-image.png" />
<script type="module" src="/src/main.js"></script>
</head>
<body>
<div id="loading-screen">
@@ -29,9 +40,10 @@
</div>
<div id="app"></div>
<!-- built files will be auto injected -->
<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/browser-script' vite-ignore></script>
<script type='text/javascript' src='/api/v4/i18n/browser-script'></script>
</body>
</html>

View File

@@ -29,14 +29,12 @@
</div>
<snackbars />
<router-view v-if="!isUserLoggedIn || isStaticPage" />
<div v-else>
<user-main />
</div>
<user-main v-else />
</div>
</template>
<style lang='scss' scoped>
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
#loading-screen-inapp {
#melior {
@@ -92,7 +90,7 @@
</style>
<style lang='scss'>
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
.modal-backdrop {
opacity: .9 !important;
@@ -108,17 +106,18 @@
<script>
import axios from 'axios';
import * as Analytics from '@/libs/analytics';
import { mapState } from '@/libs/store';
import userMain from '@/pages/user-main';
import snackbars from '@/components/snackbars/notifications';
import { LOCALSTORAGE_AUTH_KEY } from '@/libs/auth';
const COMMUNITY_MANAGER_EMAIL = import.meta.env.EMAILS_COMMUNITY_MANAGER_EMAIL;
const COMMUNITY_MANAGER_EMAIL = process.env.EMAILS_COMMUNITY_MANAGER_EMAIL; // eslint-disable-line
export default {
name: 'App',
components: {
snackbars,
userMain: () => import('@/pages/user-main'),
userMain,
},
data () {
return {
@@ -149,6 +148,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,40 +206,26 @@ 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 (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;
const errorMessage = errorData.message || errorData;
const errorCode = errorData.error;
// If 'invalid_credentials' signaled, force logout
if (error.response.status === 401 && errorCode === 'invalid_credentials') {
// Check for conditions to reset the user auth
// TODO use a specific error like NotificationNotFound instead of checking for the string
const invalidUserMessage = [this.$t('invalidCredentials'), 'Missing authentication headers.'];
if (invalidUserMessage.indexOf(errorMessage) !== -1) {
this.$store.dispatch('auth:logout', { redirectToLogin: true });
return null;
}
@@ -279,29 +268,16 @@ export default {
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) document.body.removeChild(loadingScreen);
// Check if we need to show password change success message
if (sessionStorage.getItem('passwordChangeSuccess') === 'true') {
sessionStorage.removeItem('passwordChangeSuccess');
this.$store.dispatch('snackbars:add', {
title: 'Habitica',
text: this.$t('passwordSuccess'),
type: 'success',
timeout: true,
});
if (this.isStaticPage || !this.isUserLoggedIn) {
this.hideLoadingScreen();
}
this.$router.onReady(() => {
if (this.isStaticPage || !this.isUserLoggedIn) {
this.hideLoadingScreen();
}
});
},
methods: {
hideLoadingScreen () {
this.loading = false;
},
checkForBannedUser (error) {
const AUTH_SETTINGS = localStorage.getItem(LOCALSTORAGE_AUTH_KEY);
const AUTH_SETTINGS = localStorage.getItem('habit-mobile-settings');
const parseSettings = JSON.parse(AUTH_SETTINGS);
const errorMessage = error.response.data.message;
@@ -325,3 +301,5 @@ export default {
</script>
<style src="@/assets/scss/index.scss" lang="scss"></style>
<style src="@/assets/scss/sprites.scss" lang="scss"></style>
<style src="smartbanner.js/dist/smartbanner.min.css"></style>

View File

@@ -22,8 +22,7 @@
height: 219px;
}
.Pet_HatchingPotion_Dessert, .Pet_HatchingPotion_Veggie, .Pet_HatchingPotion_Windup,
.Pet_HatchingPotion_VirtualPet, .Pet_HatchingPotion_Fungi, .Pet_HatchingPotion_Cryptid {
.Pet_HatchingPotion_Dessert, .Pet_HatchingPotion_Veggie, .Pet_HatchingPotion_Windup, .Pet_HatchingPotion_VirtualPet, .Pet_HatchingPotion_Fungi {
width: 68px;
height: 68px;
}
@@ -48,10 +47,6 @@
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Fungi.gif") no-repeat;
}
.Pet_HatchingPotion_Cryptid {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Cryptid.gif") no-repeat;
}
.Gems {
display:inline-block;
margin-right:5px;
@@ -177,7 +172,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;
}
@@ -190,14 +185,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.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -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

View File

@@ -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: 26 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

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