Compare commits
	
		
			369 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | abf371ba12 | ||
|  | f02b7c16e1 | ||
|  | cec01f49bd | ||
|  | b88389787d | ||
|  | 04e366d498 | ||
|  | 2d5ddfdd87 | ||
|  | 2c2b776968 | ||
|  | 1dc8be4842 | ||
|  | 9d93821758 | ||
|  | 14e99c6e6c | ||
|  | 7433eeb883 | ||
|  | 68ecc2eec4 | ||
|  | c68c8536de | ||
|  | fce8028e29 | ||
|  | 845fcd41b4 | ||
|  | f172670aa4 | ||
|  | 405f744770 | ||
|  | 7222ee0a10 | ||
|  | 6d4da01d36 | ||
|  | 84b184617f | ||
|  | 87e46da6a3 | ||
|  | d8199f71a8 | ||
|  | 20a6103457 | ||
|  | 539ffbf08a | ||
|  | b2adc29bf6 | ||
|  | 920ee56bdf | ||
|  | 1f077451d2 | ||
|  | 595a31db99 | ||
|  | 7e0d7a4ba0 | ||
|  | 1c66b80424 | ||
|  | 5274ec2cc9 | ||
|  | 8060422991 | ||
|  | 69e40e2114 | ||
|  | 17d918a172 | ||
|  | bc74e40280 | ||
|  | 4487363171 | ||
|  | dc72faad6a | ||
|  | 7c0b3612f0 | ||
|  | 9a32eabb47 | ||
|  | 6fe0d5568a | ||
|  | 2c44f766cd | ||
|  | c79e3bea05 | ||
|  | b56f0cfeeb | ||
|  | fbf1849148 | ||
|  | a493bb69ce | ||
|  | 7b20d02449 | ||
|  | 207d1b7eaa | ||
|  | f33aed661b | ||
|  | 7f1d6ffef0 | ||
|  | 75f198789b | ||
|  | 6ad20e7abb | ||
|  | 2705539a70 | ||
|  | 8f05fc250a | ||
|  | a21295c0e1 | ||
|  | 24e13ddd18 | ||
|  | 4d9e03b6d2 | ||
|  | 8a64def893 | ||
|  | e6f40aee43 | ||
|  | dc34db98b4 | ||
|  | bd228d5d69 | ||
|  | 5d054a0acd | ||
|  | 9a1fbc2d2f | ||
|  | 7b0f43b61c | ||
|  | bc115bb1f6 | ||
|  | cd790e7228 | ||
|  | bc1c637023 | ||
|  | eadbdeb7b8 | ||
|  | 585359e22f | ||
|  | f0d6204968 | ||
|  | 14fed0eb43 | ||
|  | 427654d8bd | ||
|  | cdb5ccf76a | ||
|  | bf28a46803 | ||
|  | 84c10eb92a | ||
|  | fa197e1b57 | ||
|  | 993c5552e8 | ||
|  | 1eba72dd36 | ||
|  | 4457b081fa | ||
|  | 395e1c25d4 | ||
|  | fdef94e826 | ||
|  | b3cfb57933 | ||
|  | b4551088c1 | ||
|  | 26e6e583ab | ||
|  | 81c7036cdb | ||
|  | 23906d8f94 | ||
|  | 2d091fc667 | ||
|  | 6d34319455 | ||
|  | 7072fbdd06 | ||
|  | 73cd6aec59 | ||
|  | d05e535c7e | ||
|  | e8960f1b0b | ||
|  | 943ea1d8f3 | ||
|  | 61172f82a3 | ||
|  | e7faebbf40 | ||
|  | c800f36178 | ||
|  | f2fff946a4 | ||
|  | e6b0bdb05e | ||
|  | 709fcd5ae6 | ||
|  | 1b0251a492 | ||
|  | c5cd20de22 | ||
|  | 32d3da9310 | ||
|  | 9ebf435c82 | ||
|  | f06fefe9c0 | ||
|  | 4bdaa58592 | ||
|  | bcae464955 | ||
|  | 0d1643eb17 | ||
|  | ccae075a4d | ||
|  | 5d275aa2a5 | ||
|  | 26940e4054 | ||
|  | 119502f285 | ||
|  | 5c8817c4ef | ||
|  | 7c081c4607 | ||
|  | f2b53f651e | ||
|  | 0e1011b875 | ||
|  | cb999d9277 | ||
|  | dcdb3efdc2 | ||
|  | f2281efe99 | ||
|  | ff93dd9159 | ||
|  | 0568e4f2ae | ||
|  | fe109f0e00 | ||
|  | 53f19c4da3 | ||
|  | fa0f60d3a6 | ||
|  | 74ebcf919e | ||
|  | 0701fa4286 | ||
|  | 25d9102674 | ||
|  | 5daf96bbf5 | ||
|  | 62498ab646 | ||
|  | cf435ad007 | ||
|  | 55f4b8ae87 | ||
|  | dab9a9b6cb | ||
|  | 4db0002d5d | ||
|  | 4a1c532cd3 | ||
|  | 3c1d7fce5f | ||
|  | e840661da8 | ||
|  | ec912a6dda | ||
|  | 5d6583936d | ||
|  | bc181819f4 | ||
|  | 8434d727bd | ||
|  | c055d43895 | ||
|  | 2018962eb5 | ||
|  | 4aa51db5ec | ||
|  | 1ed2ebb04d | ||
|  | f0123a1571 | ||
|  | e4e200c32c | ||
|  | 637048af0b | ||
|  | d6be92b346 | ||
|  | 2ed15f58e9 | ||
|  | 42de4397a1 | ||
|  | 661c7b23a1 | ||
|  | f077e40c4c | ||
|  | 3ce182d0dc | ||
|  | 6a658c45b5 | ||
|  | 7057797ed3 | ||
|  | d3eb9fe230 | ||
|  | 51b50bfa3c | ||
|  | 8c953ea8cb | ||
|  | c5b644b892 | ||
|  | e65bd4164d | ||
|  | c9ef21aa7e | ||
|  | f41eae3bf3 | ||
|  | c984c04e46 | ||
|  | 4e9a2de527 | ||
|  | 7745a0e65a | ||
|  | 6b3a6eb59f | ||
|  | 103b4cb8a8 | ||
|  | de5473c71e | ||
|  | f31370d7ba | ||
|  | 3b7bf5de75 | ||
|  | 496950e97d | ||
|  | 281d7b4fe2 | ||
|  | 8d2ecaffb0 | ||
|  | 00f66b3824 | ||
|  | c4f6644c3a | ||
|  | 58b5af0d4c | ||
|  | 9b76f9831e | ||
|  | 8bd2e09bde | ||
|  | 670843c395 | ||
|  | a81e6932fb | ||
|  | 4219bcbffa | ||
|  | e499666f64 | ||
|  | b5a25d74df | ||
|  | d5408b89b2 | ||
|  | 4c69bf2090 | ||
|  | 54e2874990 | ||
|  | 278ed4d2df | ||
|  | f7b4d25657 | ||
|  | f44b331680 | ||
|  | 284cfde935 | ||
|  | c19c39d72d | ||
|  | 809398f607 | ||
|  | 5791e87132 | ||
|  | 4a93201f50 | ||
|  | a8ac927030 | ||
|  | 5327827ef7 | ||
|  | df1e4af7fc | ||
|  | d0c9c2917f | ||
|  | 50e009efff | ||
|  | 8c98b7127e | ||
|  | cb96ff84d1 | ||
|  | 705ae93292 | ||
|  | 1c089c33a0 | ||
|  | 4481217b90 | ||
|  | d3ba0346af | ||
|  | 41de90e578 | ||
|  | a863e79214 | ||
|  | 6ed1353e31 | ||
|  | c748477546 | ||
|  | 365f9c0aa7 | ||
|  | 23e717353d | ||
|  | d5517ffc5f | ||
|  | 7b6f63958a | ||
|  | b5d8bcc0fe | ||
|  | 181b33101e | ||
|  | 4319bd5ad1 | ||
|  | f2c6838e95 | ||
|  | a2cd79f20e | ||
|  | 6a367d3697 | ||
|  | ef0b19f17e | ||
|  | 27129754cd | ||
|  | 983aae7f87 | ||
|  | 174ac6d7e3 | ||
|  | 2e59260149 | ||
|  | 997cc9f3c5 | ||
|  | 8a7b4db5ee | ||
|  | 7adb33887e | ||
|  | 85d2e21510 | ||
|  | 868a8a4e77 | ||
|  | b61425078a | ||
|  | 03876b86bb | ||
|  | 8ac48406e9 | ||
|  | a6e96f7ad9 | ||
|  | 43b8368f42 | ||
|  | 5362058f35 | ||
|  | 9d6fb2ca26 | ||
|  | 9213ee92de | ||
|  | 3a80875505 | ||
|  | 2d6802ae87 | ||
|  | 497c023995 | ||
|  | b97d514c68 | ||
|  | 142fdfe743 | ||
|  | ee5a2b0e36 | ||
|  | 9455f996ef | ||
|  | 5cc3f6e8aa | ||
|  | 8bad3d1184 | ||
|  | 3f65353974 | ||
|  | ab7c4015a2 | ||
|  | 6d509ae1f8 | ||
|  | 6375bc1d59 | ||
|  | 14714f9e1c | ||
|  | 60b3f2b860 | ||
|  | 965df326ed | ||
|  | 3c49db9bfe | ||
|  | 72bd104567 | ||
|  | 2cbd376cf6 | ||
|  | a7477e137e | ||
|  | f6cb57c22f | ||
|  | 76fa4dfd7f | ||
|  | aceaeacbf0 | ||
|  | b2cb5f83de | ||
|  | d09dea5185 | ||
|  | 6fa7fbce3f | ||
|  | b17c9a33a5 | ||
|  | 31d0cb5a91 | ||
|  | d678eb920f | ||
|  | 163eb55dbb | ||
|  | 8661716c23 | ||
|  | ea11e302c6 | ||
|  | afdd5af7a9 | ||
|  | a09e56048e | ||
|  | fd37ee90da | ||
|  | a4cfb97dbf | ||
|  | ea766251c2 | ||
|  | f196ff2e24 | ||
|  | cc901fe085 | ||
|  | f257488b39 | ||
|  | 48dbe547c0 | ||
|  | b15462596b | ||
|  | 2a98b5b7bf | ||
|  | f7667fcf79 | ||
|  | 05aaad8743 | ||
|  | 5e1f2c16f8 | ||
|  | 82b6a14d5b | ||
|  | f9cfd9fb5e | ||
|  | 60bef56577 | ||
|  | af87185bfa | ||
|  | f8c8be4f4c | ||
|  | 5d220544e0 | ||
|  | c4fd9daa90 | ||
|  | fc8f9cbaa0 | ||
|  | e39d3e52e2 | ||
|  | 625b4a4ad7 | ||
|  | f2bcdd21de | ||
|  | ad51675ac6 | ||
|  | 869d2df4fa | ||
|  | 734e997345 | ||
|  | 4fc260e552 | ||
|  | fa22a47b7a | ||
|  | 0366245fab | ||
|  | f342eff70b | ||
|  | 84eb39fde2 | ||
|  | d6fe2c76e2 | ||
|  | f19e9dd57e | ||
|  | 13566e8a39 | ||
|  | 7d3dd9f157 | ||
|  | 92f283f6a2 | ||
|  | 5d24d584d4 | ||
|  | 0320827f7e | ||
|  | 79c1a5d9c1 | ||
|  | faa49b1412 | ||
|  | c3decc3951 | ||
|  | 084adf8b0d | ||
|  | 04574dad69 | ||
|  | 6526a6317e | ||
|  | c778e5e84e | ||
|  | b5dacdf9ea | ||
|  | e05d1dae43 | ||
|  | 864db644e3 | ||
|  | 5ddd4ec564 | ||
|  | 2d7d4af2b8 | ||
|  | bad3f82dfb | ||
|  | 8595641d12 | ||
|  | 9bb3e17995 | ||
|  | 8b955e2c5e | ||
|  | 1d7e02428b | ||
|  | eee04255f8 | ||
|  | 7f30385c09 | ||
|  | 5b6eeef290 | ||
|  | 9953c9346d | ||
|  | d62930b9da | ||
|  | 67c607216f | ||
|  | 672fd43ad0 | ||
|  | 69281f80ea | ||
|  | 093bcbb715 | ||
|  | 1f5992ccc5 | ||
|  | 2d598c9933 | ||
|  | b4be8286e2 | ||
|  | 96b985a191 | ||
|  | a196a0cae2 | ||
|  | 8f9043e4f1 | ||
|  | 1cace198a1 | ||
|  | ec0e5024a7 | ||
|  | 0275636f46 | ||
|  | ea7ae4bb2f | ||
|  | 9f32a879e3 | ||
|  | 665200b49d | ||
|  | c63e92c8f2 | ||
|  | ba5b79855f | ||
|  | 97051bb3ae | ||
|  | 29e3cae0a6 | ||
|  | 274c582af8 | ||
|  | 4a7f40ea37 | ||
|  | 97ad0fae26 | ||
|  | b7b91caef1 | ||
|  | daa3458760 | ||
|  | 8e6ce39d64 | ||
|  | 617832f02d | ||
|  | 2cfc765e39 | ||
|  | 7cd9f800cc | ||
|  | 9a1cadd49f | ||
|  | b3285db5b0 | ||
|  | 8b1c009990 | ||
|  | 9061a59fc2 | ||
|  | cbfed9c0d3 | ||
|  | 77447f7096 | ||
|  | 361bb92b3b | ||
|  | e50d5f514c | ||
|  | a7ac4633a8 | ||
|  | 31b53fd6ed | ||
|  | 3e31223812 | 
| @@ -1,2 +1 @@ | ||||
| https://github.com/heroku/heroku-buildpack-nodejs.git | ||||
| https://github.com/stomita/heroku-buildpack-phantomjs.git | ||||
| https://github.com/heroku/heroku-buildpack-nodejs.git | ||||
							
								
								
									
										18
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -7,7 +7,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node-version: [12.x] | ||||
|         node-version: [14.x] | ||||
|     steps: | ||||
|     - uses: actions/checkout@v1 | ||||
|       with: | ||||
| @@ -28,7 +28,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node-version: [12.x] | ||||
|         node-version: [14.x] | ||||
|     steps: | ||||
|     - uses: actions/checkout@v1 | ||||
|       with: | ||||
| @@ -49,7 +49,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node-version: [12.x] | ||||
|         node-version: [14.x] | ||||
|     steps: | ||||
|     - uses: actions/checkout@v1 | ||||
|       with: | ||||
| @@ -71,7 +71,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node-version: [12.x] | ||||
|         node-version: [14.x] | ||||
|     steps: | ||||
|     - uses: actions/checkout@v1 | ||||
|       with: | ||||
| @@ -92,7 +92,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node-version: [12.x] | ||||
|         node-version: [14.x] | ||||
|     steps: | ||||
|     - uses: actions/checkout@v1 | ||||
|       with: | ||||
| @@ -114,7 +114,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node-version: [12.x] | ||||
|         node-version: [14.x] | ||||
|         mongodb-version: [4.2] | ||||
|     steps: | ||||
|     - uses: actions/checkout@v1 | ||||
| @@ -143,7 +143,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node-version: [12.x] | ||||
|         node-version: [14.x] | ||||
|         mongodb-version: [4.2] | ||||
|     steps: | ||||
|     - uses: actions/checkout@v1 | ||||
| @@ -172,7 +172,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node-version: [12.x] | ||||
|         node-version: [14.x] | ||||
|         mongodb-version: [4.2] | ||||
|     steps: | ||||
|     - uses: actions/checkout@v1 | ||||
| @@ -202,7 +202,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       matrix: | ||||
|         node-version: [12.x] | ||||
|         node-version: [14.x] | ||||
|     steps: | ||||
|     - uses: actions/checkout@v1 | ||||
|       with: | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| FROM node:12 | ||||
| FROM node:14 | ||||
|  | ||||
| ENV ADMIN_EMAIL admin@habitica.com | ||||
| ENV EMAILS_COMMUNITY_MANAGER_EMAIL admin@habitica.com | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| FROM node:12 | ||||
| FROM node:14 | ||||
|  | ||||
| # Install global packages | ||||
| RUN npm install -g gulp-cli mocha | ||||
|   | ||||
| @@ -71,6 +71,7 @@ | ||||
|   "SLACK_URL": "https://hooks.slack.com/services/some-url", | ||||
|   "STRIPE_API_KEY": "aaaabbbbccccddddeeeeffff00001111", | ||||
|   "STRIPE_PUB_KEY": "22223333444455556666777788889999", | ||||
|   "STRIPE_WEBHOOKS_ENDPOINT_SECRET": "111111", | ||||
|   "TRANSIFEX_SLACK_CHANNEL": "transifex", | ||||
|   "WEB_CONCURRENCY": 1, | ||||
|   "SKIP_SSL_CHECK_KEY": "key", | ||||
|   | ||||
							
								
								
									
										82
									
								
								migrations/archive/2020/20201020_pet_color_achievements.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,82 @@ | ||||
| /* eslint-disable no-console */ | ||||
| const MIGRATION_NAME = '20201020_pet_color_achievements'; | ||||
| import { model as User } from '../../../website/server/models/user'; | ||||
|  | ||||
| const progressCount = 1000; | ||||
| let count = 0; | ||||
|  | ||||
| async function updateUser (user) { | ||||
|   count++; | ||||
|  | ||||
|   const set = { | ||||
|     migration: MIGRATION_NAME, | ||||
|   }; | ||||
|  | ||||
|   if (user && user.items && user.items.pets) { | ||||
|     const pets = user.items.pets; | ||||
|     if (pets['Wolf-Golden'] > 0 | ||||
|       && pets['TigerCub-Skeleton'] > 0 | ||||
|       && pets['PandaCub-Skeleton'] > 0 | ||||
|       && pets['LionCub-Skeleton'] > 0 | ||||
|       && pets['Fox-Skeleton'] > 0 | ||||
|       && pets['FlyingPig-Skeleton'] > 0 | ||||
|       && pets['Dragon-Skeleton'] > 0 | ||||
|       && pets['Cactus-Skeleton'] > 0 | ||||
|       && pets['BearCub-Skeleton'] > 0) { | ||||
|         set['achievements.boneCollector'] = true; | ||||
|       } | ||||
|   } | ||||
|  | ||||
|   if (user && user.items && user.items.mounts) { | ||||
|     const mounts = user.items.mounts; | ||||
|     if (mounts['Wolf-Skeleton'] | ||||
|       && mounts['TigerCub-Skeleton'] | ||||
|       && mounts['PandaCub-Skeleton'] | ||||
|       && mounts['LionCub-Skeleton'] | ||||
|       && mounts['Fox-Skeleton'] | ||||
|       && mounts['FlyingPig-Skeleton'] | ||||
|       && mounts['Dragon-Skeleton'] | ||||
|       && mounts['Cactus-Skeleton'] | ||||
|       && mounts['BearCub-Skeleton'] ) { | ||||
|         set['achievements.skeletonCrew'] = true; | ||||
|       } | ||||
|   } | ||||
|  | ||||
|   if (count % progressCount === 0) console.warn(`${count} ${user._id}`); | ||||
|  | ||||
|   return await User.update({ _id: user._id }, { $set: set }).exec(); | ||||
| } | ||||
|  | ||||
| module.exports = async function processUsers () { | ||||
|   let query = { | ||||
|     migration: { $ne: MIGRATION_NAME }, | ||||
|     'auth.timestamps.loggedin': { $gt: new Date('2020-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]._id, | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     await Promise.all(users.map(updateUser)); // eslint-disable-line no-await-in-loop | ||||
|   } | ||||
| }; | ||||
							
								
								
									
										84
									
								
								migrations/archive/2020/20201029_habitoween_ladder.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,84 @@ | ||||
| /* | ||||
|  * Award Habitoween ladder items to participants in this month's Habitoween festivities | ||||
|  */ | ||||
| /* eslint-disable no-console */ | ||||
|  | ||||
| const MIGRATION_NAME = '20201029_habitoween_ladder'; // Update when running in future years | ||||
|  | ||||
| import { model as User } from '../../../website/server/models/user'; | ||||
|  | ||||
| const progressCount = 1000; | ||||
| let count = 0; | ||||
|  | ||||
| async function updateUser (user) { | ||||
|   count++; | ||||
|  | ||||
|   const set = {}; | ||||
|   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, | ||||
|   }; | ||||
|  | ||||
|   set.migration = MIGRATION_NAME; | ||||
|  | ||||
|   if (user && user.items && user.items.mounts && user.items.mounts['JackOLantern-Glow']) { | ||||
|     set['items.pets.JackOLantern-RoyalPurple'] = 5; | ||||
|   } else if (user && user.items && user.items.pets && user.items.pets['JackOLantern-Glow']) { | ||||
|     set['items.mounts.JackOLantern-Glow'] = true; | ||||
|   } else if (user && user.items && user.items.mounts && user.items.mounts['JackOLantern-Ghost']) { | ||||
|     set['items.pets.JackOLantern-Glow'] = 5; | ||||
|   } else if (user && user.items && user.items.pets && user.items.pets['JackOLantern-Ghost']) { | ||||
|     set['items.mounts.JackOLantern-Ghost'] = true; | ||||
|   } else if (user && user.items && user.items.mounts && user.items.mounts['JackOLantern-Base']) { | ||||
|     set['items.pets.JackOLantern-Ghost'] = 5; | ||||
|   } else if (user && user.items && user.items.pets && user.items.pets['JackOLantern-Base']) { | ||||
|     set['items.mounts.JackOLantern-Base'] = true; | ||||
|   } else { | ||||
|     set['items.pets.JackOLantern-Base'] = 5; | ||||
|   } | ||||
|  | ||||
|   if (count % progressCount === 0) console.warn(`${count} ${user._id}`); | ||||
|   return await User.update({_id: user._id}, {$inc: inc, $set: set}).exec(); | ||||
| } | ||||
|  | ||||
| module.exports = async function processUsers () { | ||||
|   let query = { | ||||
|     migration: {$ne: MIGRATION_NAME}, | ||||
|     'auth.timestamps.loggedin': {$gt: new Date('2020-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 | ||||
|   } | ||||
| }; | ||||
							
								
								
									
										58
									
								
								migrations/archive/2020/20201102_fix_habitoween.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,58 @@ | ||||
| /* | ||||
|  * Fix JackOLantern-Base for users that signed up recently | ||||
|  */ | ||||
| /* eslint-disable no-console */ | ||||
|  | ||||
| const MIGRATION_NAME = '20201102_fix_habitoween'; // Update when running in future years | ||||
|  | ||||
| import { model as User } from '../../../website/server/models/user'; | ||||
|  | ||||
| const progressCount = 1000; | ||||
| let count = 0; | ||||
|  | ||||
| async function updateUser (user) { | ||||
|   count++; | ||||
|  | ||||
|   const set = {}; | ||||
|  | ||||
|   set.migration = MIGRATION_NAME; | ||||
|   set['items.pets.JackOLantern-Base'] = 5; | ||||
|  | ||||
|   if (count % progressCount === 0) console.warn(`${count} ${user._id}`); | ||||
|   return await User.update({_id: user._id}, {$inc: inc, $set: set}).exec(); | ||||
| } | ||||
|  | ||||
| module.exports = async function processUsers () { | ||||
|   let query = { | ||||
|     migration: {$ne: MIGRATION_NAME}, | ||||
|     'auth.timestamps.created': {$gt: new Date('2020-10-26')}, | ||||
|     'items.pets.JackOLantern-Base': true, | ||||
|   }; | ||||
|  | ||||
|   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 | ||||
|   } | ||||
| }; | ||||
							
								
								
									
										62
									
								
								migrations/archive/2020/20201103_drop_cap_ab_tweaks.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,62 @@ | ||||
| /* | ||||
|  * All web users should be enrolled in the Drop Cap AB Test | ||||
|  */ | ||||
| /* eslint-disable no-console */ | ||||
|  | ||||
| const MIGRATION_NAME = '20201103_drop_cap_ab_tweaks'; | ||||
|  | ||||
| import { model as User } from '../../../website/server/models/user'; | ||||
|  | ||||
| const progressCount = 1000; | ||||
| let count = 0; | ||||
|  | ||||
| async function updateUser (user) { | ||||
|   count++; | ||||
|  | ||||
|   const set = {}; | ||||
|  | ||||
|   set.migration = MIGRATION_NAME; | ||||
|  | ||||
|   const testGroup = Math.random(); | ||||
|   // Enroll 100% of users, splitting them 50/50 | ||||
|   const value = testGroup <= 0.50 ? 'drop-cap-notif-enabled' : 'drop-cap-notif-disabled'; | ||||
|   set['_ABtests.dropCapNotif'] = value; | ||||
|  | ||||
|   if (count % progressCount === 0) console.warn(`${count} ${user._id}`); | ||||
|   return await User.update({_id: user._id}, {$set: set}).exec(); | ||||
| } | ||||
|  | ||||
| module.exports = async function processUsers () { | ||||
|   let query = { | ||||
|     migration: {$ne: MIGRATION_NAME}, | ||||
|     'auth.timestamps.loggedin': {$gt: new Date('2020-10-10')}, | ||||
|     '_ABtests.dropCapNotif': 'drop-cap-notif-not-enrolled', | ||||
|   }; | ||||
|  | ||||
|   const fields = { | ||||
|     _id: 1, | ||||
|     _ABtests: 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 | ||||
|   } | ||||
| }; | ||||
							
								
								
									
										62
									
								
								migrations/archive/2020/20201111_api_date.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,62 @@ | ||||
| /* | ||||
|  * Fix dates in the database that were stored as $type string instead of Date | ||||
|  */ | ||||
| /* eslint-disable no-console */ | ||||
|  | ||||
| const MIGRATION_NAME = '20201111_api_date'; | ||||
|  | ||||
| import * as Tasks from '../../../website/server/models/task'; | ||||
|  | ||||
| const progressCount = 1000; | ||||
| let count = 0; | ||||
|  | ||||
| async function updateUser (todo) { | ||||
|   count++; | ||||
|  | ||||
|   if (count % progressCount === 0) console.warn(`${count} ${todo._id}`); | ||||
|  | ||||
|   const newDate = new Date(todo.date); | ||||
|   if (isValidDate(newDate)) return; | ||||
|  | ||||
|   return await Tasks.Task.update({_id: todo._id, type: 'todo'}, {$unset: {date: ''}}).exec(); | ||||
| } | ||||
|  | ||||
| module.exports = async function processUsers () { | ||||
|   let query = { | ||||
|     type: 'todo', | ||||
|     date: {$exists: true}, | ||||
|     updatedAt: {$gt: new Date('2020-11-23')}, | ||||
|   }; | ||||
|  | ||||
|   const fields = { | ||||
|     _id: 1, | ||||
|     type: 1, | ||||
|     date: 1, | ||||
|   }; | ||||
|  | ||||
|   while (true) { // eslint-disable-line no-constant-condition | ||||
|     const users = await Tasks.Task // eslint-disable-line no-await-in-loop | ||||
|       .find(query) | ||||
|       .select(fields) | ||||
|       .limit(250) | ||||
|       .sort({_id: 1}) | ||||
|       .lean() | ||||
|       .exec(); | ||||
|  | ||||
|     if (users.length === 0) { | ||||
|       console.warn('All appropriate tasks found and modified.'); | ||||
|       console.warn(`\n${count} tasks processed\n`); | ||||
|       break; | ||||
|     } else { | ||||
|       query._id = { | ||||
|         $gt: users[users.length - 1], | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     await Promise.all(users.map(updateUser)); // eslint-disable-line no-await-in-loop | ||||
|   } | ||||
| }; | ||||
|  | ||||
| function isValidDate(d) { | ||||
|   return !isNaN(d.getTime()); | ||||
| } | ||||
							
								
								
									
										82
									
								
								migrations/archive/2020/20201124_pet_color_achievements.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,82 @@ | ||||
| /* eslint-disable no-console */ | ||||
| const MIGRATION_NAME = '20201124_pet_color_achievements'; | ||||
| import { model as User } from '../../../website/server/models/user'; | ||||
|  | ||||
| const progressCount = 1000; | ||||
| let count = 0; | ||||
|  | ||||
| async function updateUser (user) { | ||||
|   count++; | ||||
|  | ||||
|   const set = { | ||||
|     migration: MIGRATION_NAME, | ||||
|   }; | ||||
|  | ||||
|   if (user && user.items && user.items.pets) { | ||||
|     const pets = user.items.pets; | ||||
|     if (pets['Wolf-Red'] > 0 | ||||
|       && pets['TigerCub-Red'] > 0 | ||||
|       && pets['PandaCub-Red'] > 0 | ||||
|       && pets['LionCub-Red'] > 0 | ||||
|       && pets['Fox-Red'] > 0 | ||||
|       && pets['FlyingPig-Red'] > 0 | ||||
|       && pets['Dragon-Red'] > 0 | ||||
|       && pets['Cactus-Red'] > 0 | ||||
|       && pets['BearCub-Red'] > 0) { | ||||
|         set['achievements.seeingRed'] = true; | ||||
|       } | ||||
|   } | ||||
|  | ||||
|   if (user && user.items && user.items.mounts) { | ||||
|     const mounts = user.items.mounts; | ||||
|     if (mounts['Wolf-Red'] | ||||
|       && mounts['TigerCub-Red'] | ||||
|       && mounts['PandaCub-Red'] | ||||
|       && mounts['LionCub-Red'] | ||||
|       && mounts['Fox-Red'] | ||||
|       && mounts['FlyingPig-Red'] | ||||
|       && mounts['Dragon-Red'] | ||||
|       && mounts['Cactus-Red'] | ||||
|       && mounts['BearCub-Red'] ) { | ||||
|         set['achievements.redLetterDay'] = true; | ||||
|       } | ||||
|   } | ||||
|  | ||||
|   if (count % progressCount === 0) console.warn(`${count} ${user._id}`); | ||||
|  | ||||
|   return await User.update({ _id: user._id }, { $set: set }).exec(); | ||||
| } | ||||
|  | ||||
| module.exports = async function processUsers () { | ||||
|   let query = { | ||||
|     migration: { $ne: MIGRATION_NAME }, | ||||
|     'auth.timestamps.loggedin': { $gt: new Date('2020-11-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]._id, | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     await Promise.all(users.map(updateUser)); // eslint-disable-line no-await-in-loop | ||||
|   } | ||||
| }; | ||||
							
								
								
									
										126
									
								
								migrations/archive/2020/20201126_harvest_feast.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,126 @@ | ||||
| /* eslint-disable no-console */ | ||||
| const MIGRATION_NAME = '20201126_harvest_feast'; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import { model as User } from '../../../website/server/models/user'; | ||||
|  | ||||
| const progressCount = 1000; | ||||
| let count = 0; | ||||
|  | ||||
| async function updateUser (user) { | ||||
|   count++; | ||||
|  | ||||
|   const set = {}; | ||||
|   let inc; | ||||
|   let push; | ||||
|  | ||||
|   set.migration = MIGRATION_NAME; | ||||
|  | ||||
|   if (typeof user.items.gear.owned.head_special_turkeyHelmGilded !== 'undefined') { | ||||
|     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, | ||||
|     } | ||||
|   } else if (typeof user.items.gear.owned.armor_special_turkeyArmorBase !== 'undefined') { | ||||
|     set['items.gear.owned.head_special_turkeyHelmGilded'] = false; | ||||
|     set['items.gear.owned.armor_special_turkeyArmorGilded'] = false; | ||||
|     set['items.gear.owned.back_special_turkeyTailGilded'] = false; | ||||
|     push = [ | ||||
|       { | ||||
|         type: 'marketGear', | ||||
|         path: 'gear.flat.head_special_turkeyHelmGilded', | ||||
|         _id: uuid(), | ||||
|       }, | ||||
|       { | ||||
|         type: 'marketGear', | ||||
|         path: 'gear.flat.armor_special_turkeyArmorGilded', | ||||
|         _id: uuid(), | ||||
|       }, | ||||
|       { | ||||
|         type: 'marketGear', | ||||
|         path: 'gear.flat.back_special_turkeyTailGilded', | ||||
|         _id: uuid(), | ||||
|       }, | ||||
|     ]; | ||||
|   } else if (user.items && user.items.mounts && user.items.mounts['Turkey-Gilded']) { | ||||
|     set['items.gear.owned.head_special_turkeyHelmBase'] = false; | ||||
|     set['items.gear.owned.armor_special_turkeyArmorBase'] = false; | ||||
|     set['items.gear.owned.back_special_turkeyTailBase'] = false; | ||||
|     push = [ | ||||
|       { | ||||
|         type: 'marketGear', | ||||
|         path: 'gear.flat.head_special_turkeyHelmBase', | ||||
|         _id: uuid(), | ||||
|       }, | ||||
|       { | ||||
|         type: 'marketGear', | ||||
|         path: 'gear.flat.armor_special_turkeyArmorBase', | ||||
|         _id: uuid(), | ||||
|       }, | ||||
|       { | ||||
|         type: 'marketGear', | ||||
|         path: 'gear.flat.back_special_turkeyTailBase', | ||||
|         _id: uuid(), | ||||
|       }, | ||||
|     ]; | ||||
|   } else if (user.items && user.items.pets && user.items.pets['Turkey-Gilded']) { | ||||
|     set['items.mounts.Turkey-Gilded'] = true; | ||||
|   } else if (user.items && user.items.mounts && user.items.mounts['Turkey-Base']) { | ||||
|     set['items.pets.Turkey-Gilded'] = 5; | ||||
|   } else if (user.items && user.items.pets && user.items.pets['Turkey-Base']) { | ||||
|     set['items.mounts.Turkey-Base'] = true; | ||||
|   } else { | ||||
|     set['items.pets.Turkey-Base'] = 5; | ||||
|   } | ||||
|  | ||||
|   if (count % progressCount === 0) console.warn(`${count} ${user._id}`); | ||||
|  | ||||
|   if (inc) { | ||||
|     return await User.update({_id: user._id}, {$inc: inc, $set: set}).exec(); | ||||
|   } else if (push) { | ||||
|     return await User.update({_id: user._id}, {$set: set, $push: {pinnedItems: {$each: push}}}).exec(); | ||||
|   } else { | ||||
|     return await User.update({_id: user._id}, {$set: set}).exec(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default async function processUsers () { | ||||
|   let query = { | ||||
|     migration: {$ne: MIGRATION_NAME}, | ||||
|     'auth.timestamps.loggedin': {$gt: new Date('2019-11-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 | ||||
|   } | ||||
| }; | ||||
							
								
								
									
										126
									
								
								migrations/archive/2020/20201229_nye.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,126 @@ | ||||
| /* eslint-disable no-console */ | ||||
| const MIGRATION_NAME = '20201229_nye'; | ||||
| import { model as User } from '../../../website/server/models/user'; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
|  | ||||
| const progressCount = 1000; | ||||
| let count = 0; | ||||
|  | ||||
| async function updateUser (user) { | ||||
|   count++; | ||||
|  | ||||
|   const set = { migration: MIGRATION_NAME }; | ||||
|   let push; | ||||
|  | ||||
|   if (typeof user.items.gear.owned.head_special_nye2019 !== 'undefined') { | ||||
|     set['items.gear.owned.head_special_nye2020'] = false; | ||||
|     push = [ | ||||
|       { | ||||
|         type: 'marketGear', | ||||
|         path: 'gear.flat.head_special_nye2020', | ||||
|         _id: uuid(), | ||||
|       }, | ||||
|     ]; | ||||
|   } else if (typeof user.items.gear.owned.head_special_nye2018 !== 'undefined') { | ||||
|     set['items.gear.owned.head_special_nye2019'] = false; | ||||
|     push = [ | ||||
|       { | ||||
|         type: 'marketGear', | ||||
|         path: 'gear.flat.head_special_nye2019', | ||||
|         _id: uuid(), | ||||
|       }, | ||||
|     ]; | ||||
|   } else if (typeof user.items.gear.owned.head_special_nye2017 !== 'undefined') { | ||||
|     set['items.gear.owned.head_special_nye2018'] = false; | ||||
|     push = [ | ||||
|       { | ||||
|         type: 'marketGear', | ||||
|         path: 'gear.flat.head_special_nye2018', | ||||
|         _id: uuid(), | ||||
|       }, | ||||
|     ]; | ||||
|   } else if (typeof user.items.gear.owned.head_special_nye2016 !== 'undefined') { | ||||
|     set['items.gear.owned.head_special_nye2017'] = false; | ||||
|     push = [ | ||||
|       { | ||||
|         type: 'marketGear', | ||||
|         path: 'gear.flat.head_special_nye2017', | ||||
|         _id: uuid(), | ||||
|       }, | ||||
|     ]; | ||||
|   } else if (typeof user.items.gear.owned.head_special_nye2015 !== 'undefined') { | ||||
|     set['items.gear.owned.head_special_nye2016'] = false; | ||||
|     push = [ | ||||
|       { | ||||
|         type: 'marketGear', | ||||
|         path: 'gear.flat.head_special_nye2016', | ||||
|         _id: uuid(), | ||||
|       }, | ||||
|     ]; | ||||
|   } else if (typeof user.items.gear.owned.head_special_nye2014 !== 'undefined') { | ||||
|     set['items.gear.owned.head_special_nye2015'] = false; | ||||
|     push = [ | ||||
|       { | ||||
|         type: 'marketGear', | ||||
|         path: 'gear.flat.head_special_nye2015', | ||||
|         _id: uuid(), | ||||
|       }, | ||||
|     ]; | ||||
|   } else if (typeof user.items.gear.owned.head_special_nye !== 'undefined') { | ||||
|     set['items.gear.owned.head_special_nye2014'] = false; | ||||
|     push = [ | ||||
|       { | ||||
|         type: 'marketGear', | ||||
|         path: 'gear.flat.head_special_nye2014', | ||||
|         _id: uuid(), | ||||
|       }, | ||||
|     ]; | ||||
|   } else { | ||||
|     set['items.gear.owned.head_special_nye'] = false; | ||||
|     push = [ | ||||
|       { | ||||
|         type: 'marketGear', | ||||
|         path: 'gear.flat.head_special_nye', | ||||
|         _id: uuid(), | ||||
|       }, | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   if (count % progressCount === 0) console.warn(`${count} ${user._id}`); | ||||
|  | ||||
|   return await User.update({_id: user._id}, {$set: set, $push: {pinnedItems: {$each: push}}}).exec(); | ||||
| } | ||||
|  | ||||
| export default async function processUsers () { | ||||
|   let query = { | ||||
|     'auth.timestamps.loggedin': {$gt: new Date('2020-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 | ||||
|   } | ||||
| }; | ||||
							
								
								
									
										1891
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
							
								
								
									
										46
									
								
								package.json
									
									
									
									
									
								
							
							
						
						| @@ -1,26 +1,26 @@ | ||||
| { | ||||
|   "name": "habitica", | ||||
|   "description": "A habit tracker app which treats your goals like a Role Playing Game.", | ||||
|   "version": "4.165.1", | ||||
|   "version": "4.178.2", | ||||
|   "main": "./website/server/index.js", | ||||
|   "dependencies": { | ||||
|     "@babel/core": "^7.11.6", | ||||
|     "@babel/preset-env": "^7.11.5", | ||||
|     "@babel/register": "^7.11.5", | ||||
|     "@babel/core": "^7.12.10", | ||||
|     "@babel/preset-env": "^7.12.11", | ||||
|     "@babel/register": "^7.12.10", | ||||
|     "@google-cloud/trace-agent": "^5.1.1", | ||||
|     "@slack/client": "^4.12.0", | ||||
|     "@parse/node-apn": "^4.0.0", | ||||
|     "@slack/webhook": "^5.0.3", | ||||
|     "accepts": "^1.3.5", | ||||
|     "amazon-payments": "^0.2.8", | ||||
|     "amplitude": "^3.5.0", | ||||
|     "amplitude": "^5.1.4", | ||||
|     "apidoc": "^0.25.0", | ||||
|     "apn": "^2.2.0", | ||||
|     "apple-auth": "^1.0.6", | ||||
|     "bcrypt": "^5.0.0", | ||||
|     "body-parser": "^1.18.3", | ||||
|     "compression": "^1.7.4", | ||||
|     "cookie-session": "^1.4.0", | ||||
|     "coupon-code": "^0.4.5", | ||||
|     "csv-stringify": "^5.5.1", | ||||
|     "csv-stringify": "^5.6.0", | ||||
|     "cwait": "^1.1.1", | ||||
|     "domain-middleware": "~0.1.0", | ||||
|     "eslint": "^6.8.0", | ||||
| @@ -30,27 +30,27 @@ | ||||
|     "express-basic-auth": "^1.1.5", | ||||
|     "express-validator": "^5.2.0", | ||||
|     "glob": "^7.1.6", | ||||
|     "got": "^11.7.0", | ||||
|     "got": "^11.8.1", | ||||
|     "gulp": "^4.0.0", | ||||
|     "gulp-babel": "^8.0.0", | ||||
|     "gulp-imagemin": "^7.1.0", | ||||
|     "gulp-nodemon": "^2.5.0", | ||||
|     "gulp.spritesmith": "^6.9.0", | ||||
|     "habitica-markdown": "^3.0.0", | ||||
|     "helmet": "^3.23.3", | ||||
|     "image-size": "^0.9.1", | ||||
|     "helmet": "^4.3.1", | ||||
|     "image-size": "^0.9.3", | ||||
|     "in-app-purchase": "^1.11.3", | ||||
|     "js2xmlparser": "^4.0.1", | ||||
|     "jsonwebtoken": "^8.5.1", | ||||
|     "jwks-rsa": "^1.10.1", | ||||
|     "jwks-rsa": "^1.12.1", | ||||
|     "lodash": "^4.17.20", | ||||
|     "merge-stream": "^2.0.0", | ||||
|     "method-override": "^3.0.0", | ||||
|     "moment": "^2.29.1", | ||||
|     "moment-recur": "^1.0.7", | ||||
|     "mongoose": "^5.10.9", | ||||
|     "mongoose": "^5.11.10", | ||||
|     "morgan": "^1.10.0", | ||||
|     "nconf": "^0.10.0", | ||||
|     "nconf": "^0.11.0", | ||||
|     "node-gcm": "^1.0.3", | ||||
|     "on-headers": "^1.0.2", | ||||
|     "passport": "^0.4.1", | ||||
| @@ -60,18 +60,18 @@ | ||||
|     "paypal-rest-sdk": "^1.8.1", | ||||
|     "pp-ipn": "^1.1.0", | ||||
|     "ps-tree": "^1.0.0", | ||||
|     "rate-limiter-flexible": "^2.1.10", | ||||
|     "rate-limiter-flexible": "^2.1.16", | ||||
|     "redis": "^3.0.2", | ||||
|     "regenerator-runtime": "^0.13.7", | ||||
|     "remove-markdown": "^0.3.0", | ||||
|     "rimraf": "^3.0.2", | ||||
|     "short-uuid": "^3.0.0", | ||||
|     "stripe": "^7.15.0", | ||||
|     "short-uuid": "^4.1.0", | ||||
|     "stripe": "^8.129.0", | ||||
|     "superagent": "^6.1.0", | ||||
|     "universal-analytics": "^0.4.23", | ||||
|     "useragent": "^2.1.9", | ||||
|     "uuid": "^8.3.1", | ||||
|     "validator": "^13.1.17", | ||||
|     "uuid": "^8.3.2", | ||||
|     "validator": "^13.5.2", | ||||
|     "vinyl-buffer": "^1.0.1", | ||||
|     "winston": "^3.3.3", | ||||
|     "winston-loggly-bulk": "^3.1.1", | ||||
| @@ -79,7 +79,7 @@ | ||||
|   }, | ||||
|   "private": true, | ||||
|   "engines": { | ||||
|     "node": "^12", | ||||
|     "node": "^14", | ||||
|     "npm": "^6" | ||||
|   }, | ||||
|   "scripts": { | ||||
| @@ -109,7 +109,7 @@ | ||||
|     "apidoc": "gulp apidoc" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "axios": "^0.19.2", | ||||
|     "axios": "^0.21.1", | ||||
|     "chai": "^4.1.2", | ||||
|     "chai-as-promised": "^7.1.1", | ||||
|     "chai-moment": "^0.1.0", | ||||
| @@ -120,8 +120,8 @@ | ||||
|     "mocha": "^5.1.1", | ||||
|     "monk": "^7.3.2", | ||||
|     "require-again": "^2.0.0", | ||||
|     "run-rs": "^0.6.2", | ||||
|     "sinon": "^9.2.0", | ||||
|     "run-rs": "^0.7.3", | ||||
|     "sinon": "^9.2.2", | ||||
|     "sinon-chai": "^3.5.0", | ||||
|     "sinon-stub-promise": "^4.0.0" | ||||
|   }, | ||||
|   | ||||
| @@ -35,13 +35,12 @@ async function deleteAmplitudeData (userId, email) { | ||||
| } | ||||
|  | ||||
| async function deleteHabiticaData (user, email) { | ||||
|   const truncatedEmail = email.slice(0, email.indexOf('@')); | ||||
|   const set = { | ||||
|     'auth.blocked': false, | ||||
|     'auth.local.hashed_password': '$2a$10$QDnNh1j1yMPnTXDEOV38xOePEWFd4X8DSYwAM8XTmqmacG5X0DKjW', | ||||
|     'auth.local.passwordHashMethod': 'bcrypt', | ||||
|   }; | ||||
|   if (!user.auth.local.email) set['auth.local.email'] = `${truncatedEmail}-gdpr@example.com`; | ||||
|   if (!user.auth.local.email) set['auth.local.email'] = `${user._id}@example.com`; | ||||
|   await User.update( | ||||
|     { _id: user._id }, | ||||
|     { $set: set }, | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| /* eslint-disable camelcase */ | ||||
| import nconf from 'nconf'; | ||||
| import Amplitude from 'amplitude'; | ||||
| import { Visitor } from 'universal-analytics'; | ||||
| import * as analyticsService from '../../../../website/server/libs/analyticsService'; | ||||
| @@ -15,6 +16,22 @@ describe('analyticsService', () => { | ||||
|     sandbox.restore(); | ||||
|   }); | ||||
|  | ||||
|   describe('#getServiceByEnvironment', () => { | ||||
|     it('returns mock methods when not in production', () => { | ||||
|       sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false); | ||||
|       expect(analyticsService.getAnalyticsServiceByEnvironment()) | ||||
|         .to.equal(analyticsService.mockAnalyticsService); | ||||
|     }); | ||||
|  | ||||
|     it('returns real methods when in production', () => { | ||||
|       sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true); | ||||
|       expect(analyticsService.getAnalyticsServiceByEnvironment().track) | ||||
|         .to.equal(analyticsService.track); | ||||
|       expect(analyticsService.getAnalyticsServiceByEnvironment().trackPurchase) | ||||
|         .to.equal(analyticsService.trackPurchase); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('#track', () => { | ||||
|     let eventType; let | ||||
|       data; | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import amzLib from '../../../../../../website/server/libs/payments/amazon'; | ||||
| import payments from '../../../../../../website/server/libs/payments/payments'; | ||||
| import common from '../../../../../../website/common'; | ||||
| import apiError from '../../../../../../website/server/libs/apiError'; | ||||
| import * as gems from '../../../../../../website/server/libs/payments/gems'; | ||||
|  | ||||
| const { i18n } = common; | ||||
|  | ||||
| @@ -88,6 +89,7 @@ describe('Amazon Payments - Checkout', () => { | ||||
|     paymentCreateSubscritionStub.resolves({}); | ||||
|  | ||||
|     sinon.stub(common, 'uuid').returns('uuid-generated'); | ||||
|     sandbox.stub(gems, 'validateGiftMessage'); | ||||
|   }); | ||||
|  | ||||
|   afterEach(() => { | ||||
| @@ -111,7 +113,10 @@ describe('Amazon Payments - Checkout', () => { | ||||
|     if (gift) { | ||||
|       expectedArgs.gift = gift; | ||||
|       expectedArgs.gemsBlock = undefined; | ||||
|       expect(gems.validateGiftMessage).to.be.calledOnce; | ||||
|       expect(gems.validateGiftMessage).to.be.calledWith(gift, user); | ||||
|     } else { | ||||
|       expect(gems.validateGiftMessage).to.not.be.called; | ||||
|       expectedArgs.gemsBlock = gemsBlock; | ||||
|     } | ||||
|     expect(paymentBuyGemsStub).to.be.calledWith(expectedArgs); | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import applePayments from '../../../../../website/server/libs/payments/apple'; | ||||
| import iap from '../../../../../website/server/libs/inAppPurchases'; | ||||
| import { model as User } from '../../../../../website/server/models/user'; | ||||
| import common from '../../../../../website/common'; | ||||
| import * as gems from '../../../../../website/server/libs/payments/gems'; | ||||
|  | ||||
| const { i18n } = common; | ||||
|  | ||||
| @@ -15,7 +16,7 @@ describe('Apple Payments', () => { | ||||
|     let sku; let user; let token; let receipt; let | ||||
|       headers; | ||||
|     let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let paymentBuyGemsStub; let | ||||
|       iapGetPurchaseDataStub; | ||||
|       iapGetPurchaseDataStub; let validateGiftMessageStub; | ||||
|  | ||||
|     beforeEach(() => { | ||||
|       token = 'testToken'; | ||||
| @@ -36,6 +37,7 @@ describe('Apple Payments', () => { | ||||
|           transactionId: token, | ||||
|         }]); | ||||
|       paymentBuyGemsStub = sinon.stub(payments, 'buyGems').resolves({}); | ||||
|       validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage'); | ||||
|     }); | ||||
|  | ||||
|     afterEach(() => { | ||||
| @@ -44,6 +46,7 @@ describe('Apple Payments', () => { | ||||
|       iap.isValidated.restore(); | ||||
|       iap.getPurchaseData.restore(); | ||||
|       payments.buyGems.restore(); | ||||
|       gems.validateGiftMessage.restore(); | ||||
|     }); | ||||
|  | ||||
|     it('should throw an error if receipt is invalid', async () => { | ||||
| @@ -143,6 +146,7 @@ describe('Apple Payments', () => { | ||||
|         expect(iapIsValidatedStub).to.be.calledOnce; | ||||
|         expect(iapIsValidatedStub).to.be.calledWith({}); | ||||
|         expect(iapGetPurchaseDataStub).to.be.calledOnce; | ||||
|         expect(validateGiftMessageStub).to.not.be.called; | ||||
|  | ||||
|         expect(paymentBuyGemsStub).to.be.calledOnce; | ||||
|         expect(paymentBuyGemsStub).to.be.calledWith({ | ||||
| @@ -180,6 +184,9 @@ describe('Apple Payments', () => { | ||||
|       expect(iapIsValidatedStub).to.be.calledWith({}); | ||||
|       expect(iapGetPurchaseDataStub).to.be.calledOnce; | ||||
|  | ||||
|       expect(validateGiftMessageStub).to.be.calledOnce; | ||||
|       expect(validateGiftMessageStub).to.be.calledWith(gift, user); | ||||
|  | ||||
|       expect(paymentBuyGemsStub).to.be.calledOnce; | ||||
|       expect(paymentBuyGemsStub).to.be.calledWith({ | ||||
|         user, | ||||
|   | ||||
| @@ -1,5 +1,11 @@ | ||||
| import common from '../../../../../website/common'; | ||||
| import { getGemsBlock } from '../../../../../website/server/libs/payments/gems'; | ||||
| import { | ||||
|   getGemsBlock, | ||||
|   validateGiftMessage, | ||||
| } from '../../../../../website/server/libs/payments/gems'; | ||||
| import { model as User } from '../../../../../website/server/models/user'; | ||||
|  | ||||
| const { i18n } = common; | ||||
|  | ||||
| describe('payments/gems', () => { | ||||
|   describe('#getGemsBlock', () => { | ||||
| @@ -11,4 +17,50 @@ describe('payments/gems', () => { | ||||
|       expect(getGemsBlock('21gems')).to.equal(common.content.gems['21gems']); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('#validateGiftMessage', () => { | ||||
|     let user; | ||||
|     let gift; | ||||
|  | ||||
|     beforeEach(() => { | ||||
|       user = new User(); | ||||
|  | ||||
|       gift = { | ||||
|         message: (` // exactly 201 chars | ||||
| A gift message that is over the 200 chars limit. | ||||
| A gift message that is over the 200 chars limit. | ||||
| A gift message that is over the 200 chars limit. | ||||
| A gift message that is over the 200 chars limit. 1 | ||||
|         `).trim().substring(0, 201), | ||||
|       }; | ||||
|  | ||||
|       expect(gift.message.length).to.equal(201); | ||||
|     }); | ||||
|  | ||||
|     it('throws if the gift message is too long', () => { | ||||
|       let expectedErr; | ||||
|  | ||||
|       try { | ||||
|         validateGiftMessage(gift, user); | ||||
|       } catch (err) { | ||||
|         expectedErr = err; | ||||
|       } | ||||
|  | ||||
|       expect(expectedErr).to.exist; | ||||
|       expect(expectedErr).to.eql({ | ||||
|         httpCode: 400, | ||||
|         name: 'BadRequest', | ||||
|         message: i18n.t('giftMessageTooLong', { maxGiftMessageLength: 200 }), | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('does not throw if the gift message is not too long', () => { | ||||
|       gift.message = gift.message.substring(0, 200); | ||||
|       expect(() => validateGiftMessage(gift, user)).to.not.throw; | ||||
|     }); | ||||
|  | ||||
|     it('does not throw if it is not a gift', () => { | ||||
|       expect(() => validateGiftMessage(null, user)).to.not.throw; | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import googlePayments from '../../../../../website/server/libs/payments/google'; | ||||
| import iap from '../../../../../website/server/libs/inAppPurchases'; | ||||
| import { model as User } from '../../../../../website/server/models/user'; | ||||
| import common from '../../../../../website/common'; | ||||
| import * as gems from '../../../../../website/server/libs/payments/gems'; | ||||
|  | ||||
| const { i18n } = common; | ||||
|  | ||||
| @@ -15,7 +16,7 @@ describe('Google Payments', () => { | ||||
|     let sku; let user; let token; let receipt; let signature; let | ||||
|       headers; const gemsBlock = common.content.gems['21gems']; | ||||
|     let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let | ||||
|       paymentBuyGemsStub; | ||||
|       paymentBuyGemsStub; let validateGiftMessageStub; | ||||
|  | ||||
|     beforeEach(() => { | ||||
|       sku = 'com.habitrpg.android.habitica.iap.21gems'; | ||||
| @@ -31,6 +32,7 @@ describe('Google Payments', () => { | ||||
|       iapIsValidatedStub = sinon.stub(iap, 'isValidated') | ||||
|         .returns(true); | ||||
|       paymentBuyGemsStub = sinon.stub(payments, 'buyGems').resolves({}); | ||||
|       validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage'); | ||||
|     }); | ||||
|  | ||||
|     afterEach(() => { | ||||
| @@ -38,6 +40,7 @@ describe('Google Payments', () => { | ||||
|       iap.validate.restore(); | ||||
|       iap.isValidated.restore(); | ||||
|       payments.buyGems.restore(); | ||||
|       gems.validateGiftMessage.restore(); | ||||
|     }); | ||||
|  | ||||
|     it('should throw an error if receipt is invalid', async () => { | ||||
| @@ -89,6 +92,8 @@ describe('Google Payments', () => { | ||||
|         user, receipt, signature, headers, | ||||
|       }); | ||||
|  | ||||
|       expect(validateGiftMessageStub).to.not.be.called; | ||||
|  | ||||
|       expect(iapSetupStub).to.be.calledOnce; | ||||
|       expect(iapValidateStub).to.be.calledOnce; | ||||
|       expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, { | ||||
| @@ -119,6 +124,9 @@ describe('Google Payments', () => { | ||||
|         user, gift, receipt, signature, headers, | ||||
|       }); | ||||
|  | ||||
|       expect(validateGiftMessageStub).to.be.calledOnce; | ||||
|       expect(validateGiftMessageStub).to.be.calledWith(gift, user); | ||||
|  | ||||
|       expect(iapSetupStub).to.be.calledOnce; | ||||
|       expect(iapValidateStub).to.be.calledOnce; | ||||
|       expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, { | ||||
|   | ||||
| @@ -32,8 +32,8 @@ describe('payments/index', () => { | ||||
|  | ||||
|     sandbox.stub(sender, 'sendTxn'); | ||||
|     sandbox.stub(user, 'sendMessage'); | ||||
|     sandbox.stub(analytics, 'trackPurchase'); | ||||
|     sandbox.stub(analytics, 'track'); | ||||
|     sandbox.stub(analytics.mockAnalyticsService, 'trackPurchase'); | ||||
|     sandbox.stub(analytics.mockAnalyticsService, 'track'); | ||||
|     sandbox.stub(notifications, 'sendNotification'); | ||||
|  | ||||
|     data = { | ||||
| @@ -209,17 +209,6 @@ describe('payments/index', () => { | ||||
|         expect(user.purchased.txnCount).to.eql(1); | ||||
|       }); | ||||
|  | ||||
|       it('sends a private message about the gift', async () => { | ||||
|         await api.createSubscription(data); | ||||
|         const msg = '`Hello recipient, sender has sent you 3 months of subscription!`'; | ||||
|  | ||||
|         expect(user.sendMessage).to.be.calledOnce; | ||||
|         expect(user.sendMessage).to.be.calledWith( | ||||
|           recipient, | ||||
|           { receiverMsg: msg, senderMsg: msg, save: false }, | ||||
|         ); | ||||
|       }); | ||||
|  | ||||
|       it('sends an email about the gift', async () => { | ||||
|         await api.createSubscription(data); | ||||
|  | ||||
| @@ -237,8 +226,8 @@ describe('payments/index', () => { | ||||
|       it('tracks subscription purchase as gift', async () => { | ||||
|         await api.createSubscription(data); | ||||
|  | ||||
|         expect(analytics.trackPurchase).to.be.calledOnce; | ||||
|         expect(analytics.trackPurchase).to.be.calledWith({ | ||||
|         expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledOnce; | ||||
|         expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledWith({ | ||||
|           uuid: user._id, | ||||
|           groupId: undefined, | ||||
|           itemPurchased: 'Subscription', | ||||
| @@ -255,6 +244,109 @@ describe('payments/index', () => { | ||||
|           }, | ||||
|         }); | ||||
|       }); | ||||
|  | ||||
|       context('No Active Promotion', () => { | ||||
|         beforeEach(() => { | ||||
|           sinon.stub(worldState, 'getCurrentEvent').returns(null); | ||||
|         }); | ||||
|  | ||||
|         afterEach(() => { | ||||
|           worldState.getCurrentEvent.restore(); | ||||
|         }); | ||||
|  | ||||
|         it('sends a private message about the gift', async () => { | ||||
|           await api.createSubscription(data); | ||||
|           const msg = '`Hello recipient, sender has sent you 3 months of subscription!`'; | ||||
|  | ||||
|           expect(user.sendMessage).to.be.calledOnce; | ||||
|           expect(user.sendMessage).to.be.calledWith( | ||||
|             recipient, | ||||
|             { receiverMsg: msg, senderMsg: msg, save: false }, | ||||
|           ); | ||||
|         }); | ||||
|       }); | ||||
|  | ||||
|       context('Active Promotion', () => { | ||||
|         beforeEach(() => { | ||||
|           sinon.stub(worldState, 'getCurrentEvent').returns({ | ||||
|             ...common.content.events.winter2021Promo, | ||||
|             event: 'winter2021', | ||||
|           }); | ||||
|         }); | ||||
|  | ||||
|         afterEach(() => { | ||||
|           worldState.getCurrentEvent.restore(); | ||||
|         }); | ||||
|  | ||||
|         it('creates a gift subscription for purchaser and recipient if none exist', async () => { | ||||
|           await api.createSubscription(data); | ||||
|  | ||||
|           expect(user.items.pets['Jackalope-RoyalPurple']).to.eql(5); | ||||
|           expect(user.purchased.plan.customerId).to.eql('Gift'); | ||||
|           expect(user.purchased.plan.dateTerminated).to.exist; | ||||
|           expect(user.purchased.plan.dateUpdated).to.exist; | ||||
|           expect(user.purchased.plan.dateCreated).to.exist; | ||||
|  | ||||
|           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; | ||||
|         }); | ||||
|  | ||||
|         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); | ||||
|  | ||||
|           await api.createSubscription(data); | ||||
|  | ||||
|           expect(user.purchased.plan.extraMonths).to.eql(3); | ||||
|  | ||||
|           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; | ||||
|         }); | ||||
|  | ||||
|         it('adds extraMonths to existing subscription for recipient and creates a gift subscription for purchaser without sub', 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); | ||||
|  | ||||
|           expect(user.items.pets['Jackalope-RoyalPurple']).to.eql(5); | ||||
|           expect(user.purchased.plan.customerId).to.eql('Gift'); | ||||
|           expect(user.purchased.plan.dateTerminated).to.exist; | ||||
|           expect(user.purchased.plan.dateUpdated).to.exist; | ||||
|           expect(user.purchased.plan.dateCreated).to.exist; | ||||
|         }); | ||||
|  | ||||
|         it('adds extraMonths to existing subscriptions for purchaser and recipient', async () => { | ||||
|           user.purchased.plan = plan; | ||||
|           recipient.purchased.plan = plan; | ||||
|  | ||||
|           expect(user.purchased.plan.extraMonths).to.eql(0); | ||||
|           expect(recipient.purchased.plan.extraMonths).to.eql(0); | ||||
|  | ||||
|           await api.createSubscription(data); | ||||
|  | ||||
|           expect(user.purchased.plan.extraMonths).to.eql(3); | ||||
|           expect(recipient.purchased.plan.extraMonths).to.eql(3); | ||||
|         }); | ||||
|  | ||||
|         it('sends a private message about the promotion', async () => { | ||||
|           await api.createSubscription(data); | ||||
|           const msg = '`Hello sender, you received 3 months of subscription as part of our holiday gift-giving promotion!`'; | ||||
|  | ||||
|           expect(user.sendMessage).to.be.calledTwice; | ||||
|           expect(user.sendMessage).to.be.calledWith(user, { receiverMsg: msg, save: false }); | ||||
|         }); | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     context('Purchasing a subscription for self', () => { | ||||
| @@ -335,8 +427,8 @@ describe('payments/index', () => { | ||||
|       it('tracks subscription purchase', async () => { | ||||
|         await api.createSubscription(data); | ||||
|  | ||||
|         expect(analytics.trackPurchase).to.be.calledOnce; | ||||
|         expect(analytics.trackPurchase).to.be.calledWith({ | ||||
|         expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledOnce; | ||||
|         expect(analytics.mockAnalyticsService.trackPurchase).to.be.calledWith({ | ||||
|           uuid: user._id, | ||||
|           groupId: undefined, | ||||
|           itemPurchased: 'Subscription', | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import paypalPayments from '../../../../../../website/server/libs/payments/paypa | ||||
| import { model as User } from '../../../../../../website/server/models/user'; | ||||
| import common from '../../../../../../website/common'; | ||||
| import apiError from '../../../../../../website/server/libs/apiError'; | ||||
| import * as gems from '../../../../../../website/server/libs/payments/gems'; | ||||
|  | ||||
| const BASE_URL = nconf.get('BASE_URL'); | ||||
| const { i18n } = common; | ||||
| @@ -48,6 +49,7 @@ describe('paypal - checkout', () => { | ||||
|       .resolves({ | ||||
|         links: [{ rel: 'approval_url', href: approvalHerf }], | ||||
|       }); | ||||
|     sandbox.stub(gems, 'validateGiftMessage'); | ||||
|   }); | ||||
|  | ||||
|   afterEach(() => { | ||||
| @@ -57,6 +59,7 @@ describe('paypal - checkout', () => { | ||||
|   it('creates a link for gem purchases', async () => { | ||||
|     const link = await paypalPayments.checkout({ user: new User(), gemsBlock: gemsBlockKey }); | ||||
|  | ||||
|     expect(gems.validateGiftMessage).to.not.be.called; | ||||
|     expect(paypalPaymentCreateStub).to.be.calledOnce; | ||||
|     expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems', 4.99)); | ||||
|     expect(link).to.eql(approvalHerf); | ||||
| @@ -105,6 +108,7 @@ describe('paypal - checkout', () => { | ||||
|   }); | ||||
|  | ||||
|   it('creates a link for gifting gems', async () => { | ||||
|     const user = new User(); | ||||
|     const receivingUser = new User(); | ||||
|     await receivingUser.save(); | ||||
|     const gift = { | ||||
| @@ -115,14 +119,17 @@ describe('paypal - checkout', () => { | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const link = await paypalPayments.checkout({ gift }); | ||||
|     const link = await paypalPayments.checkout({ user, gift }); | ||||
|  | ||||
|     expect(gems.validateGiftMessage).to.be.calledOnce; | ||||
|     expect(gems.validateGiftMessage).to.be.calledWith(gift, user); | ||||
|     expect(paypalPaymentCreateStub).to.be.calledOnce; | ||||
|     expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems (Gift)', '4.00')); | ||||
|     expect(link).to.eql(approvalHerf); | ||||
|   }); | ||||
|  | ||||
|   it('creates a link for gifting a subscription', async () => { | ||||
|     const user = new User(); | ||||
|     const receivingUser = new User(); | ||||
|     receivingUser.save(); | ||||
|     const gift = { | ||||
| @@ -133,7 +140,10 @@ describe('paypal - checkout', () => { | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const link = await paypalPayments.checkout({ gift }); | ||||
|     const link = await paypalPayments.checkout({ user, gift }); | ||||
|  | ||||
|     expect(gems.validateGiftMessage).to.be.calledOnce; | ||||
|     expect(gems.validateGiftMessage).to.be.calledWith(gift, user); | ||||
|  | ||||
|     expect(paypalPaymentCreateStub).to.be.calledOnce; | ||||
|     expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('mo. Habitica Subscription (Gift)', '15.00')); | ||||
|   | ||||
| @@ -1,149 +0,0 @@ | ||||
| import stripeModule from 'stripe'; | ||||
|  | ||||
| import { | ||||
|   generateGroup, | ||||
| } from '../../../../../helpers/api-unit.helper'; | ||||
| import { model as User } from '../../../../../../website/server/models/user'; | ||||
| import stripePayments from '../../../../../../website/server/libs/payments/stripe'; | ||||
| import payments from '../../../../../../website/server/libs/payments/payments'; | ||||
| import common from '../../../../../../website/common'; | ||||
|  | ||||
| const { i18n } = common; | ||||
|  | ||||
| describe('stripe - cancel subscription', () => { | ||||
|   const subKey = 'basic_3mo'; | ||||
|   const stripe = stripeModule('test'); | ||||
|   let user; let groupId; let | ||||
|     group; | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     user = new User(); | ||||
|     user.profile.name = 'sender'; | ||||
|     user.purchased.plan.customerId = 'customer-id'; | ||||
|     user.purchased.plan.planId = subKey; | ||||
|     user.purchased.plan.lastBillingDate = new Date(); | ||||
|  | ||||
|     group = generateGroup({ | ||||
|       name: 'test group', | ||||
|       type: 'guild', | ||||
|       privacy: 'public', | ||||
|       leader: user._id, | ||||
|     }); | ||||
|     group.purchased.plan.customerId = 'customer-id'; | ||||
|     group.purchased.plan.planId = subKey; | ||||
|     await group.save(); | ||||
|  | ||||
|     groupId = group._id; | ||||
|   }); | ||||
|  | ||||
|   it('throws an error if there is no customer id', async () => { | ||||
|     user.purchased.plan.customerId = undefined; | ||||
|  | ||||
|     await expect(stripePayments.cancelSubscription({ | ||||
|       user, | ||||
|       groupId: undefined, | ||||
|     })) | ||||
|       .to.eventually.be.rejected.and.to.eql({ | ||||
|         httpCode: 401, | ||||
|         name: 'NotAuthorized', | ||||
|         message: i18n.t('missingSubscription'), | ||||
|       }); | ||||
|   }); | ||||
|  | ||||
|   it('throws an error if the group is not found', async () => { | ||||
|     await expect(stripePayments.cancelSubscription({ | ||||
|       user, | ||||
|       groupId: 'fake-group', | ||||
|     })) | ||||
|       .to.eventually.be.rejected.and.to.eql({ | ||||
|         httpCode: 404, | ||||
|         name: 'NotFound', | ||||
|         message: i18n.t('groupNotFound'), | ||||
|       }); | ||||
|   }); | ||||
|  | ||||
|   it('throws an error if user is not the group leader', async () => { | ||||
|     const nonLeader = new User(); | ||||
|     nonLeader.guilds.push(groupId); | ||||
|     await nonLeader.save(); | ||||
|  | ||||
|     await expect(stripePayments.cancelSubscription({ | ||||
|       user: nonLeader, | ||||
|       groupId, | ||||
|     })) | ||||
|       .to.eventually.be.rejected.and.to.eql({ | ||||
|         httpCode: 401, | ||||
|         name: 'NotAuthorized', | ||||
|         message: i18n.t('onlyGroupLeaderCanManageSubscription'), | ||||
|       }); | ||||
|   }); | ||||
|  | ||||
|   describe('success', () => { | ||||
|     let stripeDeleteCustomerStub; let paymentsCancelSubStub; | ||||
|     let stripeRetrieveStub; let subscriptionId; let | ||||
|       currentPeriodEndTimeStamp; | ||||
|  | ||||
|     beforeEach(() => { | ||||
|       subscriptionId = 'subId'; | ||||
|       stripeDeleteCustomerStub = sinon.stub(stripe.customers, 'del').resolves({}); | ||||
|       paymentsCancelSubStub = sinon.stub(payments, 'cancelSubscription').resolves({}); | ||||
|  | ||||
|       currentPeriodEndTimeStamp = (new Date()).getTime(); | ||||
|       stripeRetrieveStub = sinon.stub(stripe.customers, 'retrieve') | ||||
|         .resolves({ | ||||
|           subscriptions: { | ||||
|             data: [{ | ||||
|               id: subscriptionId, | ||||
|               current_period_end: currentPeriodEndTimeStamp, | ||||
|             }], // eslint-disable-line camelcase | ||||
|           }, | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     afterEach(() => { | ||||
|       stripe.customers.del.restore(); | ||||
|       stripe.customers.retrieve.restore(); | ||||
|       payments.cancelSubscription.restore(); | ||||
|     }); | ||||
|  | ||||
|     it('cancels a user subscription', async () => { | ||||
|       await stripePayments.cancelSubscription({ | ||||
|         user, | ||||
|         groupId: undefined, | ||||
|       }, stripe); | ||||
|  | ||||
|       expect(stripeDeleteCustomerStub).to.be.calledOnce; | ||||
|       expect(stripeDeleteCustomerStub).to.be.calledWith(user.purchased.plan.customerId); | ||||
|       expect(stripeRetrieveStub).to.be.calledOnce; | ||||
|       expect(stripeRetrieveStub).to.be.calledWith(user.purchased.plan.customerId); | ||||
|       expect(paymentsCancelSubStub).to.be.calledOnce; | ||||
|       expect(paymentsCancelSubStub).to.be.calledWith({ | ||||
|         user, | ||||
|         groupId: undefined, | ||||
|         nextBill: currentPeriodEndTimeStamp * 1000, // timestamp in seconds | ||||
|         paymentMethod: 'Stripe', | ||||
|         cancellationReason: undefined, | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('cancels a group subscription', async () => { | ||||
|       await stripePayments.cancelSubscription({ | ||||
|         user, | ||||
|         groupId, | ||||
|       }, stripe); | ||||
|  | ||||
|       expect(stripeDeleteCustomerStub).to.be.calledOnce; | ||||
|       expect(stripeDeleteCustomerStub).to.be.calledWith(group.purchased.plan.customerId); | ||||
|       expect(stripeRetrieveStub).to.be.calledOnce; | ||||
|       expect(stripeRetrieveStub).to.be.calledWith(user.purchased.plan.customerId); | ||||
|       expect(paymentsCancelSubStub).to.be.calledOnce; | ||||
|       expect(paymentsCancelSubStub).to.be.calledWith({ | ||||
|         user, | ||||
|         groupId, | ||||
|         nextBill: currentPeriodEndTimeStamp * 1000, // timestamp in seconds | ||||
|         paymentMethod: 'Stripe', | ||||
|         cancellationReason: undefined, | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @@ -1,321 +0,0 @@ | ||||
| import stripeModule from 'stripe'; | ||||
| import cc from 'coupon-code'; | ||||
|  | ||||
| import { | ||||
|   generateGroup, | ||||
| } from '../../../../../helpers/api-unit.helper'; | ||||
| import { model as User } from '../../../../../../website/server/models/user'; | ||||
| import { model as Coupon } from '../../../../../../website/server/models/coupon'; | ||||
| import stripePayments from '../../../../../../website/server/libs/payments/stripe'; | ||||
| import payments from '../../../../../../website/server/libs/payments/payments'; | ||||
| import common from '../../../../../../website/common'; | ||||
|  | ||||
| const { i18n } = common; | ||||
|  | ||||
| describe('stripe - checkout with subscription', () => { | ||||
|   const subKey = 'basic_3mo'; | ||||
|   const stripe = stripeModule('test'); | ||||
|   let user; let group; let data; let gift; let sub; | ||||
|   let groupId; let email; let headers; let coupon; | ||||
|   let customerIdResponse; let subscriptionId; let | ||||
|     token; | ||||
|   let spy; | ||||
|   let stripeCreateCustomerSpy; | ||||
|   let stripePaymentsCreateSubSpy; | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     user = new User(); | ||||
|     user.profile.name = 'sender'; | ||||
|     user.purchased.plan.customerId = 'customer-id'; | ||||
|     user.purchased.plan.planId = subKey; | ||||
|     user.purchased.plan.lastBillingDate = new Date(); | ||||
|  | ||||
|     group = generateGroup({ | ||||
|       name: 'test group', | ||||
|       type: 'guild', | ||||
|       privacy: 'public', | ||||
|       leader: user._id, | ||||
|     }); | ||||
|     group.purchased.plan.customerId = 'customer-id'; | ||||
|     group.purchased.plan.planId = subKey; | ||||
|     await group.save(); | ||||
|  | ||||
|     sub = { | ||||
|       key: 'basic_3mo', | ||||
|     }; | ||||
|  | ||||
|     data = { | ||||
|       user, | ||||
|       sub, | ||||
|       customerId: 'customer-id', | ||||
|       paymentMethod: 'Payment Method', | ||||
|     }; | ||||
|  | ||||
|     email = 'example@example.com'; | ||||
|     customerIdResponse = 'test-id'; | ||||
|     subscriptionId = 'test-sub-id'; | ||||
|     token = 'test-token'; | ||||
|  | ||||
|     spy = sinon.stub(stripe.subscriptions, 'update'); | ||||
|     spy.resolves; | ||||
|  | ||||
|     stripeCreateCustomerSpy = sinon.stub(stripe.customers, 'create'); | ||||
|     const stripCustomerResponse = { | ||||
|       id: customerIdResponse, | ||||
|       subscriptions: { | ||||
|         data: [{ id: subscriptionId }], | ||||
|       }, | ||||
|     }; | ||||
|     stripeCreateCustomerSpy.resolves(stripCustomerResponse); | ||||
|  | ||||
|     stripePaymentsCreateSubSpy = sinon.stub(payments, 'createSubscription'); | ||||
|     stripePaymentsCreateSubSpy.resolves({}); | ||||
|  | ||||
|     data.groupId = group._id; | ||||
|     data.sub.quantity = 3; | ||||
|   }); | ||||
|  | ||||
|   afterEach(() => { | ||||
|     stripe.subscriptions.update.restore(); | ||||
|     stripe.customers.create.restore(); | ||||
|     payments.createSubscription.restore(); | ||||
|   }); | ||||
|  | ||||
|   it('should throw an error if we are missing a token', async () => { | ||||
|     await expect(stripePayments.checkout({ | ||||
|       user, | ||||
|       gift, | ||||
|       sub, | ||||
|       groupId, | ||||
|       email, | ||||
|       headers, | ||||
|       coupon, | ||||
|     })) | ||||
|       .to.eventually.be.rejected.and.to.eql({ | ||||
|         httpCode: 400, | ||||
|         name: 'BadRequest', | ||||
|         message: 'Missing req.body.id', | ||||
|       }); | ||||
|   }); | ||||
|  | ||||
|   it('should throw an error when coupon code is missing', async () => { | ||||
|     sub.discount = 40; | ||||
|  | ||||
|     await expect(stripePayments.checkout({ | ||||
|       token, | ||||
|       user, | ||||
|       gift, | ||||
|       sub, | ||||
|       groupId, | ||||
|       email, | ||||
|       headers, | ||||
|       coupon, | ||||
|     })) | ||||
|       .to.eventually.be.rejected.and.to.eql({ | ||||
|         httpCode: 400, | ||||
|         name: 'BadRequest', | ||||
|         message: i18n.t('couponCodeRequired'), | ||||
|       }); | ||||
|   }); | ||||
|  | ||||
|   it('should throw an error when coupon code is invalid', async () => { | ||||
|     sub.discount = 40; | ||||
|     sub.key = 'google_6mo'; | ||||
|     coupon = 'example-coupon'; | ||||
|  | ||||
|     const couponModel = new Coupon(); | ||||
|     couponModel.event = 'google_6mo'; | ||||
|     await couponModel.save(); | ||||
|  | ||||
|     sinon.stub(cc, 'validate').returns('invalid'); | ||||
|  | ||||
|     await expect(stripePayments.checkout({ | ||||
|       token, | ||||
|       user, | ||||
|       gift, | ||||
|       sub, | ||||
|       groupId, | ||||
|       email, | ||||
|       headers, | ||||
|       coupon, | ||||
|     })) | ||||
|       .to.eventually.be.rejected.and.to.eql({ | ||||
|         httpCode: 400, | ||||
|         name: 'BadRequest', | ||||
|         message: i18n.t('invalidCoupon'), | ||||
|       }); | ||||
|     cc.validate.restore(); | ||||
|   }); | ||||
|  | ||||
|   it('subscribes with stripe with a coupon', async () => { | ||||
|     sub.discount = 40; | ||||
|     sub.key = 'google_6mo'; | ||||
|     coupon = 'example-coupon'; | ||||
|  | ||||
|     const couponModel = new Coupon(); | ||||
|     couponModel.event = 'google_6mo'; | ||||
|     const updatedCouponModel = await couponModel.save(); | ||||
|  | ||||
|     sinon.stub(cc, 'validate').returns(updatedCouponModel._id); | ||||
|  | ||||
|     await stripePayments.checkout({ | ||||
|       token, | ||||
|       user, | ||||
|       gift, | ||||
|       sub, | ||||
|       groupId, | ||||
|       email, | ||||
|       headers, | ||||
|       coupon, | ||||
|     }, stripe); | ||||
|  | ||||
|     expect(stripeCreateCustomerSpy).to.be.calledOnce; | ||||
|     expect(stripeCreateCustomerSpy).to.be.calledWith({ | ||||
|       email, | ||||
|       metadata: { uuid: user._id }, | ||||
|       card: token, | ||||
|       plan: sub.key, | ||||
|     }); | ||||
|  | ||||
|     expect(stripePaymentsCreateSubSpy).to.be.calledOnce; | ||||
|     expect(stripePaymentsCreateSubSpy).to.be.calledWith({ | ||||
|       user, | ||||
|       customerId: customerIdResponse, | ||||
|       paymentMethod: 'Stripe', | ||||
|       sub, | ||||
|       headers, | ||||
|       groupId: undefined, | ||||
|       subscriptionId: undefined, | ||||
|     }); | ||||
|  | ||||
|     cc.validate.restore(); | ||||
|   }); | ||||
|  | ||||
|   it('subscribes a user', async () => { | ||||
|     sub = data.sub; | ||||
|  | ||||
|     await stripePayments.checkout({ | ||||
|       token, | ||||
|       user, | ||||
|       gift, | ||||
|       sub, | ||||
|       groupId, | ||||
|       email, | ||||
|       headers, | ||||
|       coupon, | ||||
|     }, stripe); | ||||
|  | ||||
|     expect(stripeCreateCustomerSpy).to.be.calledOnce; | ||||
|     expect(stripeCreateCustomerSpy).to.be.calledWith({ | ||||
|       email, | ||||
|       metadata: { uuid: user._id }, | ||||
|       card: token, | ||||
|       plan: sub.key, | ||||
|     }); | ||||
|  | ||||
|     expect(stripePaymentsCreateSubSpy).to.be.calledOnce; | ||||
|     expect(stripePaymentsCreateSubSpy).to.be.calledWith({ | ||||
|       user, | ||||
|       customerId: customerIdResponse, | ||||
|       paymentMethod: 'Stripe', | ||||
|       sub, | ||||
|       headers, | ||||
|       groupId: undefined, | ||||
|       subscriptionId: undefined, | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   it('subscribes a group', async () => { | ||||
|     token = 'test-token'; | ||||
|     sub = data.sub; | ||||
|     groupId = group._id; | ||||
|     email = 'test@test.com'; | ||||
|  | ||||
|     // Add user to group | ||||
|     user.guilds.push(groupId); | ||||
|     await user.save(); | ||||
|  | ||||
|     headers = {}; | ||||
|  | ||||
|     await stripePayments.checkout({ | ||||
|       token, | ||||
|       user, | ||||
|       gift, | ||||
|       sub, | ||||
|       groupId, | ||||
|       email, | ||||
|       headers, | ||||
|       coupon, | ||||
|     }, stripe); | ||||
|  | ||||
|     expect(stripeCreateCustomerSpy).to.be.calledOnce; | ||||
|     expect(stripeCreateCustomerSpy).to.be.calledWith({ | ||||
|       email, | ||||
|       metadata: { uuid: user._id }, | ||||
|       card: token, | ||||
|       plan: sub.key, | ||||
|       quantity: 3, | ||||
|     }); | ||||
|  | ||||
|     expect(stripePaymentsCreateSubSpy).to.be.calledOnce; | ||||
|     expect(stripePaymentsCreateSubSpy).to.be.calledWith({ | ||||
|       user, | ||||
|       customerId: customerIdResponse, | ||||
|       paymentMethod: 'Stripe', | ||||
|       sub, | ||||
|       headers, | ||||
|       groupId, | ||||
|       subscriptionId, | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   it('subscribes a group with the correct number of group members', async () => { | ||||
|     token = 'test-token'; | ||||
|     sub = data.sub; | ||||
|     groupId = group._id; | ||||
|     email = 'test@test.com'; | ||||
|     headers = {}; | ||||
|  | ||||
|     // Add user to group | ||||
|     user.guilds.push(groupId); | ||||
|     await user.save(); | ||||
|  | ||||
|     user = new User(); | ||||
|     user.guilds.push(groupId); | ||||
|     await user.save(); | ||||
|  | ||||
|     group.memberCount = 2; | ||||
|     await group.save(); | ||||
|  | ||||
|     await stripePayments.checkout({ | ||||
|       token, | ||||
|       user, | ||||
|       gift, | ||||
|       sub, | ||||
|       groupId, | ||||
|       email, | ||||
|       headers, | ||||
|       coupon, | ||||
|     }, stripe); | ||||
|  | ||||
|     expect(stripeCreateCustomerSpy).to.be.calledOnce; | ||||
|     expect(stripeCreateCustomerSpy).to.be.calledWith({ | ||||
|       email, | ||||
|       metadata: { uuid: user._id }, | ||||
|       card: token, | ||||
|       plan: sub.key, | ||||
|       quantity: 4, | ||||
|     }); | ||||
|  | ||||
|     expect(stripePaymentsCreateSubSpy).to.be.calledOnce; | ||||
|     expect(stripePaymentsCreateSubSpy).to.be.calledWith({ | ||||
|       user, | ||||
|       customerId: customerIdResponse, | ||||
|       paymentMethod: 'Stripe', | ||||
|       sub, | ||||
|       headers, | ||||
|       groupId, | ||||
|       subscriptionId, | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @@ -1,235 +1,482 @@ | ||||
| import stripeModule from 'stripe'; | ||||
|  | ||||
| import { model as User } from '../../../../../../website/server/models/user'; | ||||
| import stripePayments from '../../../../../../website/server/libs/payments/stripe'; | ||||
| import payments from '../../../../../../website/server/libs/payments/payments'; | ||||
| import nconf from 'nconf'; | ||||
| import common from '../../../../../../website/common'; | ||||
| import apiError from '../../../../../../website/server/libs/apiError'; | ||||
| import * as subscriptions from '../../../../../../website/server/libs/payments/stripe/subscriptions'; | ||||
| import * as oneTimePayments from '../../../../../../website/server/libs/payments/stripe/oneTimePayments'; | ||||
| import { | ||||
|   createCheckoutSession, | ||||
|   createEditCardCheckoutSession, | ||||
| } from '../../../../../../website/server/libs/payments/stripe/checkout'; | ||||
| import { | ||||
|   generateGroup, | ||||
| } from '../../../../../helpers/api-unit.helper'; | ||||
| import { model as User } from '../../../../../../website/server/models/user'; | ||||
| import { model as Group } from '../../../../../../website/server/models/group'; | ||||
| import * as gems from '../../../../../../website/server/libs/payments/gems'; | ||||
|  | ||||
| const { i18n } = common; | ||||
|  | ||||
| describe('stripe - checkout', () => { | ||||
|   const subKey = 'basic_3mo'; | ||||
| describe('Stripe - Checkout', () => { | ||||
|   const stripe = stripeModule('test'); | ||||
|   let stripeChargeStub; let paymentBuyGemsStub; let | ||||
|     paymentCreateSubscritionStub; | ||||
|   let user; let gift; let groupId; let email; let headers; let coupon; let customerIdResponse; let | ||||
|     token; const gemsBlockKey = '21gems'; const gemsBlock = common.content.gems[gemsBlockKey]; | ||||
|   const BASE_URL = nconf.get('BASE_URL'); | ||||
|   const redirectUrls = { | ||||
|     success_url: `${BASE_URL}/redirect/stripe-success-checkout`, | ||||
|     cancel_url: `${BASE_URL}/redirect/stripe-error-checkout`, | ||||
|   }; | ||||
|  | ||||
|   beforeEach(() => { | ||||
|     user = new User(); | ||||
|     user.profile.name = 'sender'; | ||||
|     user.purchased.plan.customerId = 'customer-id'; | ||||
|     user.purchased.plan.planId = subKey; | ||||
|     user.purchased.plan.lastBillingDate = new Date(); | ||||
|   describe('createCheckoutSession', () => { | ||||
|     let user; | ||||
|     const sessionId = 'session-id'; | ||||
|  | ||||
|     token = 'test-token'; | ||||
|     beforeEach(() => { | ||||
|       user = new User(); | ||||
|       sandbox.stub(stripe.checkout.sessions, 'create').returns(sessionId); | ||||
|       sandbox.stub(gems, 'validateGiftMessage'); | ||||
|     }); | ||||
|  | ||||
|     customerIdResponse = 'example-customerIdResponse'; | ||||
|     const stripCustomerResponse = { | ||||
|       id: customerIdResponse, | ||||
|     }; | ||||
|     stripeChargeStub = sinon.stub(stripe.charges, 'create').resolves(stripCustomerResponse); | ||||
|     paymentBuyGemsStub = sinon.stub(payments, 'buyGems').resolves({}); | ||||
|     paymentCreateSubscritionStub = sinon.stub(payments, 'createSubscription').resolves({}); | ||||
|   }); | ||||
|  | ||||
|   afterEach(() => { | ||||
|     stripe.charges.create.restore(); | ||||
|     payments.buyGems.restore(); | ||||
|     payments.createSubscription.restore(); | ||||
|   }); | ||||
|  | ||||
|   it('should error if there is no token', async () => { | ||||
|     await expect(stripePayments.checkout({ | ||||
|       user, | ||||
|       gift, | ||||
|       groupId, | ||||
|       email, | ||||
|       headers, | ||||
|       coupon, | ||||
|     }, stripe)) | ||||
|       .to.eventually.be.rejected.and.to.eql({ | ||||
|         httpCode: 400, | ||||
|         message: 'Missing req.body.id', | ||||
|         name: 'BadRequest', | ||||
|     it('gems', async () => { | ||||
|       const amount = 999; | ||||
|       const gemsBlockKey = '21gems'; | ||||
|       sandbox.stub(oneTimePayments, 'getOneTimePaymentInfo').returns({ | ||||
|         amount, | ||||
|         gemsBlock: common.content.gems[gemsBlockKey], | ||||
|       }); | ||||
|   }); | ||||
|  | ||||
|   it('should error if gem amount is too low', async () => { | ||||
|     const receivingUser = new User(); | ||||
|     receivingUser.save(); | ||||
|     gift = { | ||||
|       type: 'gems', | ||||
|       gems: { | ||||
|         amount: 0, | ||||
|         uuid: receivingUser._id, | ||||
|       }, | ||||
|     }; | ||||
|       const res = await createCheckoutSession({ user, gemsBlock: gemsBlockKey }, stripe); | ||||
|       expect(res).to.equal(sessionId); | ||||
|  | ||||
|     await expect(stripePayments.checkout({ | ||||
|       token, | ||||
|       user, | ||||
|       gift, | ||||
|       groupId, | ||||
|       email, | ||||
|       headers, | ||||
|       coupon, | ||||
|     }, stripe)) | ||||
|       .to.eventually.be.rejected.and.to.eql({ | ||||
|         httpCode: 400, | ||||
|         message: 'Amount must be at least 1.', | ||||
|         name: 'BadRequest', | ||||
|       const metadata = { | ||||
|         type: 'gems', | ||||
|         userId: user._id, | ||||
|         gift: undefined, | ||||
|         sub: undefined, | ||||
|         gemsBlock: gemsBlockKey, | ||||
|       }; | ||||
|  | ||||
|       expect(gems.validateGiftMessage).to.not.be.called; | ||||
|       expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledOnce; | ||||
|       expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledWith(gemsBlockKey, undefined, user); | ||||
|       expect(stripe.checkout.sessions.create).to.be.calledOnce; | ||||
|       expect(stripe.checkout.sessions.create).to.be.calledWith({ | ||||
|         payment_method_types: ['card'], | ||||
|         metadata, | ||||
|         line_items: [{ | ||||
|           price_data: { | ||||
|             product_data: { | ||||
|               name: common.i18n.t('nGems', { nGems: 21 }), | ||||
|             }, | ||||
|             unit_amount: amount, | ||||
|             currency: 'usd', | ||||
|           }, | ||||
|           quantity: 1, | ||||
|         }], | ||||
|         mode: 'payment', | ||||
|         ...redirectUrls, | ||||
|       }); | ||||
|   }); | ||||
|  | ||||
|   it('should error if user cannot get gems', async () => { | ||||
|     gift = undefined; | ||||
|     sinon.stub(user, 'canGetGems').resolves(false); | ||||
|  | ||||
|     await expect(stripePayments.checkout({ | ||||
|       token, | ||||
|       user, | ||||
|       gemsBlock: gemsBlockKey, | ||||
|       gift, | ||||
|       groupId, | ||||
|       email, | ||||
|       headers, | ||||
|       coupon, | ||||
|     }, stripe)).to.eventually.be.rejected.and.to.eql({ | ||||
|       httpCode: 401, | ||||
|       message: i18n.t('groupPolicyCannotGetGems'), | ||||
|       name: 'NotAuthorized', | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   it('should error if the gems block is invalid', async () => { | ||||
|     gift = undefined; | ||||
|  | ||||
|     await expect(stripePayments.checkout({ | ||||
|       token, | ||||
|       user, | ||||
|       gemsBlock: 'invalid', | ||||
|       gift, | ||||
|       groupId, | ||||
|       email, | ||||
|       headers, | ||||
|       coupon, | ||||
|     }, stripe)).to.eventually.be.rejected.and.to.eql({ | ||||
|       httpCode: 400, | ||||
|       message: apiError('invalidGemsBlock'), | ||||
|       name: 'BadRequest', | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   it('should purchase gems', async () => { | ||||
|     gift = undefined; | ||||
|     sinon.stub(user, 'canGetGems').resolves(true); | ||||
|  | ||||
|     await stripePayments.checkout({ | ||||
|       token, | ||||
|       user, | ||||
|       gemsBlock: gemsBlockKey, | ||||
|       gift, | ||||
|       groupId, | ||||
|       email, | ||||
|       headers, | ||||
|       coupon, | ||||
|     }, stripe); | ||||
|  | ||||
|     expect(stripeChargeStub).to.be.calledOnce; | ||||
|     expect(stripeChargeStub).to.be.calledWith({ | ||||
|       amount: 499, | ||||
|       currency: 'usd', | ||||
|       card: token, | ||||
|     }); | ||||
|  | ||||
|     expect(paymentBuyGemsStub).to.be.calledOnce; | ||||
|     expect(paymentBuyGemsStub).to.be.calledWith({ | ||||
|       user, | ||||
|       customerId: customerIdResponse, | ||||
|       paymentMethod: 'Stripe', | ||||
|       gift, | ||||
|       gemsBlock, | ||||
|     }); | ||||
|     expect(user.canGetGems).to.be.calledOnce; | ||||
|     user.canGetGems.restore(); | ||||
|   }); | ||||
|     it('gems gift', async () => { | ||||
|       const receivingUser = new User(); | ||||
|       await receivingUser.save(); | ||||
|  | ||||
|   it('should gift gems', async () => { | ||||
|     const receivingUser = new User(); | ||||
|     await receivingUser.save(); | ||||
|     gift = { | ||||
|       type: 'gems', | ||||
|       uuid: receivingUser._id, | ||||
|       gems: { | ||||
|         amount: 16, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     await stripePayments.checkout({ | ||||
|       token, | ||||
|       user, | ||||
|       gift, | ||||
|       groupId, | ||||
|       email, | ||||
|       headers, | ||||
|       coupon, | ||||
|     }, stripe); | ||||
|  | ||||
|     expect(stripeChargeStub).to.be.calledOnce; | ||||
|     expect(stripeChargeStub).to.be.calledWith({ | ||||
|       amount: '400', | ||||
|       currency: 'usd', | ||||
|       card: token, | ||||
|     }); | ||||
|  | ||||
|     expect(paymentBuyGemsStub).to.be.calledOnce; | ||||
|     expect(paymentBuyGemsStub).to.be.calledWith({ | ||||
|       user, | ||||
|       customerId: customerIdResponse, | ||||
|       paymentMethod: 'Gift', | ||||
|       gift, | ||||
|       gemsBlock: undefined, | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   it('should gift a subscription', async () => { | ||||
|     const receivingUser = new User(); | ||||
|     receivingUser.save(); | ||||
|     gift = { | ||||
|       type: 'subscription', | ||||
|       subscription: { | ||||
|         key: subKey, | ||||
|       const gift = { | ||||
|         type: 'gems', | ||||
|         uuid: receivingUser._id, | ||||
|       }, | ||||
|     }; | ||||
|         gems: { | ||||
|           amount: 4, | ||||
|         }, | ||||
|       }; | ||||
|       const amount = 100; | ||||
|       sandbox.stub(oneTimePayments, 'getOneTimePaymentInfo').returns({ | ||||
|         amount, | ||||
|         gemsBlock: null, | ||||
|       }); | ||||
|  | ||||
|     await stripePayments.checkout({ | ||||
|       token, | ||||
|       user, | ||||
|       gift, | ||||
|       groupId, | ||||
|       email, | ||||
|       headers, | ||||
|       coupon, | ||||
|     }, stripe); | ||||
|       const res = await createCheckoutSession({ user, gift }, stripe); | ||||
|       expect(res).to.equal(sessionId); | ||||
|  | ||||
|     gift.member = receivingUser; | ||||
|     expect(stripeChargeStub).to.be.calledOnce; | ||||
|     expect(stripeChargeStub).to.be.calledWith({ | ||||
|       amount: '1500', | ||||
|       currency: 'usd', | ||||
|       card: token, | ||||
|       const metadata = { | ||||
|         type: 'gift-gems', | ||||
|         userId: user._id, | ||||
|         gift: JSON.stringify(gift), | ||||
|         sub: undefined, | ||||
|         gemsBlock: undefined, | ||||
|       }; | ||||
|  | ||||
|       expect(gems.validateGiftMessage).to.be.calledOnce; | ||||
|       expect(gems.validateGiftMessage).to.be.calledWith(gift, user); | ||||
|  | ||||
|       expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledOnce; | ||||
|       expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledWith(undefined, gift, user); | ||||
|       expect(stripe.checkout.sessions.create).to.be.calledOnce; | ||||
|       expect(stripe.checkout.sessions.create).to.be.calledWith({ | ||||
|         payment_method_types: ['card'], | ||||
|         metadata, | ||||
|         line_items: [{ | ||||
|           price_data: { | ||||
|             product_data: { | ||||
|               name: common.i18n.t('nGemsGift', { nGems: 4 }), | ||||
|             }, | ||||
|             unit_amount: amount, | ||||
|             currency: 'usd', | ||||
|           }, | ||||
|           quantity: 1, | ||||
|         }], | ||||
|         mode: 'payment', | ||||
|         ...redirectUrls, | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     expect(paymentCreateSubscritionStub).to.be.calledOnce; | ||||
|     expect(paymentCreateSubscritionStub).to.be.calledWith({ | ||||
|       user, | ||||
|       customerId: customerIdResponse, | ||||
|       paymentMethod: 'Gift', | ||||
|       gift, | ||||
|       gemsBlock: undefined, | ||||
|     it('subscription gift', async () => { | ||||
|       const receivingUser = new User(); | ||||
|       await receivingUser.save(); | ||||
|       const subKey = 'basic_3mo'; | ||||
|  | ||||
|       const gift = { | ||||
|         type: 'subscription', | ||||
|         uuid: receivingUser._id, | ||||
|         subscription: { | ||||
|           key: subKey, | ||||
|         }, | ||||
|       }; | ||||
|       const amount = 1500; | ||||
|       sandbox.stub(oneTimePayments, 'getOneTimePaymentInfo').returns({ | ||||
|         amount, | ||||
|         gemsBlock: null, | ||||
|         subscription: common.content.subscriptionBlocks[subKey], | ||||
|       }); | ||||
|  | ||||
|       const res = await createCheckoutSession({ user, gift }, stripe); | ||||
|       expect(res).to.equal(sessionId); | ||||
|  | ||||
|       const metadata = { | ||||
|         type: 'gift-sub', | ||||
|         userId: user._id, | ||||
|         gift: JSON.stringify(gift), | ||||
|         sub: undefined, | ||||
|         gemsBlock: undefined, | ||||
|       }; | ||||
|  | ||||
|       expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledOnce; | ||||
|       expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledWith(undefined, gift, user); | ||||
|       expect(stripe.checkout.sessions.create).to.be.calledOnce; | ||||
|       expect(stripe.checkout.sessions.create).to.be.calledWith({ | ||||
|         payment_method_types: ['card'], | ||||
|         metadata, | ||||
|         line_items: [{ | ||||
|           price_data: { | ||||
|             product_data: { | ||||
|               name: common.i18n.t('nMonthsSubscriptionGift', { nMonths: 3 }), | ||||
|             }, | ||||
|             unit_amount: amount, | ||||
|             currency: 'usd', | ||||
|           }, | ||||
|           quantity: 1, | ||||
|         }], | ||||
|         mode: 'payment', | ||||
|         ...redirectUrls, | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('subscription', async () => { | ||||
|       const subKey = 'basic_3mo'; | ||||
|       const coupon = null; | ||||
|       sandbox.stub(subscriptions, 'checkSubData').returns(undefined); | ||||
|       const sub = common.content.subscriptionBlocks[subKey]; | ||||
|  | ||||
|       const res = await createCheckoutSession({ user, sub, coupon }, stripe); | ||||
|       expect(res).to.equal(sessionId); | ||||
|  | ||||
|       const metadata = { | ||||
|         type: 'subscription', | ||||
|         userId: user._id, | ||||
|         gift: undefined, | ||||
|         sub: JSON.stringify(sub), | ||||
|       }; | ||||
|  | ||||
|       expect(subscriptions.checkSubData).to.be.calledOnce; | ||||
|       expect(subscriptions.checkSubData).to.be.calledWith(sub, false, coupon); | ||||
|       expect(stripe.checkout.sessions.create).to.be.calledOnce; | ||||
|       expect(stripe.checkout.sessions.create).to.be.calledWith({ | ||||
|         payment_method_types: ['card'], | ||||
|         metadata, | ||||
|         line_items: [{ | ||||
|           price: sub.key, | ||||
|           quantity: 1, | ||||
|           // @TODO proper copy | ||||
|         }], | ||||
|         mode: 'subscription', | ||||
|         ...redirectUrls, | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('throws if group does not exists', async () => { | ||||
|       const groupId = 'invalid'; | ||||
|       sandbox.stub(Group.prototype, 'getMemberCount').resolves(4); | ||||
|  | ||||
|       const subKey = 'group_monthly'; | ||||
|       const coupon = null; | ||||
|       const sub = common.content.subscriptionBlocks[subKey]; | ||||
|  | ||||
|       await expect(createCheckoutSession({ | ||||
|         user, sub, coupon, groupId, | ||||
|       }, stripe)) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 404, | ||||
|           name: 'NotFound', | ||||
|           message: i18n.t('groupNotFound'), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('group plan', async () => { | ||||
|       const group = generateGroup({ | ||||
|         name: 'test group', | ||||
|         type: 'guild', | ||||
|         privacy: 'public', | ||||
|         leader: user._id, | ||||
|       }); | ||||
|       const groupId = group._id; | ||||
|       await group.save(); | ||||
|       sandbox.stub(Group.prototype, 'getMemberCount').resolves(4); | ||||
|  | ||||
|       // Add user to group | ||||
|       user.guilds.push(groupId); | ||||
|       await user.save(); | ||||
|  | ||||
|       const subKey = 'group_monthly'; | ||||
|       const coupon = null; | ||||
|       sandbox.stub(subscriptions, 'checkSubData').returns(undefined); | ||||
|       const sub = common.content.subscriptionBlocks[subKey]; | ||||
|  | ||||
|       const res = await createCheckoutSession({ | ||||
|         user, sub, coupon, groupId, | ||||
|       }, stripe); | ||||
|       expect(res).to.equal(sessionId); | ||||
|  | ||||
|       const metadata = { | ||||
|         type: 'subscription', | ||||
|         userId: user._id, | ||||
|         gift: undefined, | ||||
|         sub: JSON.stringify(sub), | ||||
|         groupId, | ||||
|       }; | ||||
|  | ||||
|       expect(Group.prototype.getMemberCount).to.be.calledOnce; | ||||
|       expect(subscriptions.checkSubData).to.be.calledOnce; | ||||
|       expect(subscriptions.checkSubData).to.be.calledWith(sub, true, coupon); | ||||
|       expect(stripe.checkout.sessions.create).to.be.calledOnce; | ||||
|       expect(stripe.checkout.sessions.create).to.be.calledWith({ | ||||
|         payment_method_types: ['card'], | ||||
|         metadata, | ||||
|         line_items: [{ | ||||
|           price: sub.key, | ||||
|           quantity: 6, | ||||
|           // @TODO proper copy | ||||
|         }], | ||||
|         mode: 'subscription', | ||||
|         ...redirectUrls, | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     // no gift, sub or gem payment | ||||
|     it('throws if type is invalid', async () => { | ||||
|       await expect(createCheckoutSession({ user }, stripe)) | ||||
|         .to.eventually.be.rejected; | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('createEditCardCheckoutSession', () => { | ||||
|     let user; | ||||
|     const sessionId = 'session-id'; | ||||
|     const customerId = 'customerId'; | ||||
|     const subscriptionId = 'subscription-id'; | ||||
|     let subscriptionsListStub; | ||||
|  | ||||
|     beforeEach(() => { | ||||
|       user = new User(); | ||||
|       sandbox.stub(stripe.checkout.sessions, 'create').returns(sessionId); | ||||
|       subscriptionsListStub = sandbox.stub(stripe.subscriptions, 'list'); | ||||
|       subscriptionsListStub.resolves({ data: [{ id: subscriptionId }] }); | ||||
|     }); | ||||
|  | ||||
|     it('throws if no valid data is supplied', async () => { | ||||
|       await expect(createEditCardCheckoutSession({}, stripe)) | ||||
|         .to.eventually.be.rejected; | ||||
|     }); | ||||
|  | ||||
|     it('throws if customer does not exists', async () => { | ||||
|       await expect(createEditCardCheckoutSession({ user }, stripe)) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 401, | ||||
|           name: 'NotAuthorized', | ||||
|           message: i18n.t('missingSubscription'), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('throws if subscription does not exists', async () => { | ||||
|       user.purchased.plan.customerId = customerId; | ||||
|       subscriptionsListStub.resolves({ data: [] }); | ||||
|  | ||||
|       await expect(createEditCardCheckoutSession({ user }, stripe)) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 401, | ||||
|           name: 'NotAuthorized', | ||||
|           message: i18n.t('missingSubscription'), | ||||
|         }); | ||||
|     }); | ||||
|     it('change card for user subscription', async () => { | ||||
|       user.purchased.plan.customerId = customerId; | ||||
|  | ||||
|       const metadata = { | ||||
|         userId: user._id, | ||||
|         type: 'edit-card-user', | ||||
|       }; | ||||
|  | ||||
|       const res = await createEditCardCheckoutSession({ user }, stripe); | ||||
|       expect(res).to.equal(sessionId); | ||||
|       expect(subscriptionsListStub).to.be.calledOnce; | ||||
|       expect(subscriptionsListStub).to.be.calledWith({ customer: customerId }); | ||||
|  | ||||
|       expect(stripe.checkout.sessions.create).to.be.calledOnce; | ||||
|       expect(stripe.checkout.sessions.create).to.be.calledWith({ | ||||
|         mode: 'setup', | ||||
|         payment_method_types: ['card'], | ||||
|         metadata, | ||||
|         customer: customerId, | ||||
|         setup_intent_data: { | ||||
|           metadata: { | ||||
|             customer_id: customerId, | ||||
|             subscription_id: subscriptionId, | ||||
|           }, | ||||
|         }, | ||||
|         ...redirectUrls, | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('throws if group does not exists', async () => { | ||||
|       const groupId = 'invalid'; | ||||
|  | ||||
|       await expect(createEditCardCheckoutSession({ user, groupId }, stripe)) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 404, | ||||
|           name: 'NotFound', | ||||
|           message: i18n.t('groupNotFound'), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('with group', () => { | ||||
|       let group; let groupId; | ||||
|       beforeEach(async () => { | ||||
|         group = generateGroup({ | ||||
|           name: 'test group', | ||||
|           type: 'guild', | ||||
|           privacy: 'public', | ||||
|           leader: user._id, | ||||
|         }); | ||||
|         groupId = group._id; | ||||
|         await group.save(); | ||||
|       }); | ||||
|  | ||||
|       it('throws if user is not allowed to change group plan', async () => { | ||||
|         const anotherUser = new User(); | ||||
|         anotherUser.guilds.push(groupId); | ||||
|         await anotherUser.save(); | ||||
|  | ||||
|         await expect(createEditCardCheckoutSession({ user: anotherUser, groupId }, stripe)) | ||||
|           .to.eventually.be.rejected.and.to.eql({ | ||||
|             httpCode: 401, | ||||
|             name: 'NotAuthorized', | ||||
|             message: i18n.t('onlyGroupLeaderCanManageSubscription'), | ||||
|           }); | ||||
|       }); | ||||
|  | ||||
|       it('throws if customer does not exists (group)', async () => { | ||||
|         await expect(createEditCardCheckoutSession({ user, groupId }, stripe)) | ||||
|           .to.eventually.be.rejected.and.to.eql({ | ||||
|             httpCode: 401, | ||||
|             name: 'NotAuthorized', | ||||
|             message: i18n.t('missingSubscription'), | ||||
|           }); | ||||
|       }); | ||||
|  | ||||
|       it('throws if subscription does not exists (group)', async () => { | ||||
|         group.purchased.plan.customerId = customerId; | ||||
|         subscriptionsListStub.resolves({ data: [] }); | ||||
|  | ||||
|         await expect(createEditCardCheckoutSession({ user, groupId }, stripe)) | ||||
|           .to.eventually.be.rejected.and.to.eql({ | ||||
|             httpCode: 401, | ||||
|             name: 'NotAuthorized', | ||||
|             message: i18n.t('missingSubscription'), | ||||
|           }); | ||||
|       }); | ||||
|  | ||||
|       it('change card for group plans - leader', async () => { | ||||
|         group.purchased.plan.customerId = customerId; | ||||
|         await group.save(); | ||||
|  | ||||
|         const metadata = { | ||||
|           userId: user._id, | ||||
|           type: 'edit-card-group', | ||||
|           groupId, | ||||
|         }; | ||||
|  | ||||
|         const res = await createEditCardCheckoutSession({ user, groupId }, stripe); | ||||
|         expect(res).to.equal(sessionId); | ||||
|         expect(subscriptionsListStub).to.be.calledOnce; | ||||
|         expect(subscriptionsListStub).to.be.calledWith({ customer: customerId }); | ||||
|  | ||||
|         expect(stripe.checkout.sessions.create).to.be.calledOnce; | ||||
|         expect(stripe.checkout.sessions.create).to.be.calledWith({ | ||||
|           mode: 'setup', | ||||
|           payment_method_types: ['card'], | ||||
|           metadata, | ||||
|           customer: customerId, | ||||
|           setup_intent_data: { | ||||
|             metadata: { | ||||
|               customer_id: customerId, | ||||
|               subscription_id: subscriptionId, | ||||
|             }, | ||||
|           }, | ||||
|           ...redirectUrls, | ||||
|         }); | ||||
|       }); | ||||
|  | ||||
|       it('change card for group plans - plan owner', async () => { | ||||
|         const anotherUser = new User(); | ||||
|         anotherUser.guilds.push(groupId); | ||||
|         await anotherUser.save(); | ||||
|  | ||||
|         group.purchased.plan.customerId = customerId; | ||||
|         group.purchased.plan.owner = anotherUser._id; | ||||
|         await group.save(); | ||||
|  | ||||
|         const metadata = { | ||||
|           userId: anotherUser._id, | ||||
|           type: 'edit-card-group', | ||||
|           groupId, | ||||
|         }; | ||||
|  | ||||
|         const res = await createEditCardCheckoutSession({ user: anotherUser, groupId }, stripe); | ||||
|         expect(res).to.equal(sessionId); | ||||
|         expect(subscriptionsListStub).to.be.calledOnce; | ||||
|         expect(subscriptionsListStub).to.be.calledWith({ customer: customerId }); | ||||
|  | ||||
|         expect(stripe.checkout.sessions.create).to.be.calledOnce; | ||||
|         expect(stripe.checkout.sessions.create).to.be.calledWith({ | ||||
|           mode: 'setup', | ||||
|           payment_method_types: ['card'], | ||||
|           metadata, | ||||
|           customer: customerId, | ||||
|           setup_intent_data: { | ||||
|             metadata: { | ||||
|               customer_id: customerId, | ||||
|               subscription_id: subscriptionId, | ||||
|             }, | ||||
|           }, | ||||
|           ...redirectUrls, | ||||
|         }); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -1,151 +0,0 @@ | ||||
| import stripeModule from 'stripe'; | ||||
|  | ||||
| import { | ||||
|   generateGroup, | ||||
| } from '../../../../../helpers/api-unit.helper'; | ||||
| import { model as User } from '../../../../../../website/server/models/user'; | ||||
| import stripePayments from '../../../../../../website/server/libs/payments/stripe'; | ||||
| import common from '../../../../../../website/common'; | ||||
|  | ||||
| const { i18n } = common; | ||||
|  | ||||
| describe('stripe - edit subscription', () => { | ||||
|   const subKey = 'basic_3mo'; | ||||
|   const stripe = stripeModule('test'); | ||||
|   let user; let groupId; let group; let | ||||
|     token; | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     user = new User(); | ||||
|     user.profile.name = 'sender'; | ||||
|     user.purchased.plan.customerId = 'customer-id'; | ||||
|     user.purchased.plan.planId = subKey; | ||||
|     user.purchased.plan.lastBillingDate = new Date(); | ||||
|  | ||||
|     group = generateGroup({ | ||||
|       name: 'test group', | ||||
|       type: 'guild', | ||||
|       privacy: 'public', | ||||
|       leader: user._id, | ||||
|     }); | ||||
|     group.purchased.plan.customerId = 'customer-id'; | ||||
|     group.purchased.plan.planId = subKey; | ||||
|     await group.save(); | ||||
|  | ||||
|     groupId = group._id; | ||||
|  | ||||
|     token = 'test-token'; | ||||
|   }); | ||||
|  | ||||
|   it('throws an error if there is no customer id', async () => { | ||||
|     user.purchased.plan.customerId = undefined; | ||||
|  | ||||
|     await expect(stripePayments.editSubscription({ | ||||
|       user, | ||||
|       groupId: undefined, | ||||
|     })) | ||||
|       .to.eventually.be.rejected.and.to.eql({ | ||||
|         httpCode: 401, | ||||
|         name: 'NotAuthorized', | ||||
|         message: i18n.t('missingSubscription'), | ||||
|       }); | ||||
|   }); | ||||
|  | ||||
|   it('throws an error if a token is not provided', async () => { | ||||
|     await expect(stripePayments.editSubscription({ | ||||
|       user, | ||||
|       groupId: undefined, | ||||
|     })) | ||||
|       .to.eventually.be.rejected.and.to.eql({ | ||||
|         httpCode: 400, | ||||
|         name: 'BadRequest', | ||||
|         message: 'Missing req.body.id', | ||||
|       }); | ||||
|   }); | ||||
|  | ||||
|   it('throws an error if the group is not found', async () => { | ||||
|     await expect(stripePayments.editSubscription({ | ||||
|       token, | ||||
|       user, | ||||
|       groupId: 'fake-group', | ||||
|     })) | ||||
|       .to.eventually.be.rejected.and.to.eql({ | ||||
|         httpCode: 404, | ||||
|         name: 'NotFound', | ||||
|         message: i18n.t('groupNotFound'), | ||||
|       }); | ||||
|   }); | ||||
|  | ||||
|   it('throws an error if user is not the group leader', async () => { | ||||
|     const nonLeader = new User(); | ||||
|     nonLeader.guilds.push(groupId); | ||||
|     await nonLeader.save(); | ||||
|  | ||||
|     await expect(stripePayments.editSubscription({ | ||||
|       token, | ||||
|       user: nonLeader, | ||||
|       groupId, | ||||
|     })) | ||||
|       .to.eventually.be.rejected.and.to.eql({ | ||||
|         httpCode: 401, | ||||
|         name: 'NotAuthorized', | ||||
|         message: i18n.t('onlyGroupLeaderCanManageSubscription'), | ||||
|       }); | ||||
|   }); | ||||
|  | ||||
|   describe('success', () => { | ||||
|     let stripeListSubscriptionStub; let stripeUpdateSubscriptionStub; let | ||||
|       subscriptionId; | ||||
|  | ||||
|     beforeEach(() => { | ||||
|       subscriptionId = 'subId'; | ||||
|       stripeListSubscriptionStub = sinon.stub(stripe.subscriptions, 'list') | ||||
|         .resolves({ | ||||
|           data: [{ id: subscriptionId }], | ||||
|         }); | ||||
|  | ||||
|       stripeUpdateSubscriptionStub = sinon.stub(stripe.subscriptions, 'update').resolves({}); | ||||
|     }); | ||||
|  | ||||
|     afterEach(() => { | ||||
|       stripe.subscriptions.list.restore(); | ||||
|       stripe.subscriptions.update.restore(); | ||||
|     }); | ||||
|  | ||||
|     it('edits a user subscription', async () => { | ||||
|       await stripePayments.editSubscription({ | ||||
|         token, | ||||
|         user, | ||||
|         groupId: undefined, | ||||
|       }, stripe); | ||||
|  | ||||
|       expect(stripeListSubscriptionStub).to.be.calledOnce; | ||||
|       expect(stripeListSubscriptionStub).to.be.calledWith({ | ||||
|         customer: user.purchased.plan.customerId, | ||||
|       }); | ||||
|       expect(stripeUpdateSubscriptionStub).to.be.calledOnce; | ||||
|       expect(stripeUpdateSubscriptionStub).to.be.calledWith( | ||||
|         subscriptionId, | ||||
|         { card: token }, | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     it('edits a group subscription', async () => { | ||||
|       await stripePayments.editSubscription({ | ||||
|         token, | ||||
|         user, | ||||
|         groupId, | ||||
|       }, stripe); | ||||
|  | ||||
|       expect(stripeListSubscriptionStub).to.be.calledOnce; | ||||
|       expect(stripeListSubscriptionStub).to.be.calledWith({ | ||||
|         customer: group.purchased.plan.customerId, | ||||
|       }); | ||||
|       expect(stripeUpdateSubscriptionStub).to.be.calledOnce; | ||||
|       expect(stripeUpdateSubscriptionStub).to.be.calledWith( | ||||
|         subscriptionId, | ||||
|         { card: token }, | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										316
									
								
								test/api/unit/libs/payments/stripe/oneTimePayments.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,316 @@ | ||||
| import apiError from '../../../../../../website/server/libs/apiError'; | ||||
| import common from '../../../../../../website/common'; | ||||
| import { | ||||
|   getOneTimePaymentInfo, | ||||
|   applyGemPayment, | ||||
| } from '../../../../../../website/server/libs/payments/stripe/oneTimePayments'; | ||||
| import * as subscriptions from '../../../../../../website/server/libs/payments/stripe/subscriptions'; | ||||
| import { model as User } from '../../../../../../website/server/models/user'; | ||||
| import payments from '../../../../../../website/server/libs/payments/payments'; | ||||
|  | ||||
| const { i18n } = common; | ||||
|  | ||||
| describe('Stripe - One Time Payments', () => { | ||||
|   describe('getOneTimePaymentInfo', () => { | ||||
|     let user; | ||||
|  | ||||
|     beforeEach(() => { | ||||
|       user = new User(); | ||||
|       sandbox.stub(subscriptions, 'checkSubData'); | ||||
|     }); | ||||
|  | ||||
|     describe('gemsBlock', () => { | ||||
|       it('returns the gemsBlock and amount', async () => { | ||||
|         const { gemsBlock, amount, subscription } = await getOneTimePaymentInfo('21gems', null, user); | ||||
|         expect(gemsBlock).to.equal(common.content.gems['21gems']); | ||||
|         expect(amount).to.equal(gemsBlock.price); | ||||
|         expect(amount).to.equal(499); | ||||
|         expect(subscription).to.be.null; | ||||
|         expect(subscriptions.checkSubData).to.not.be.called; | ||||
|       }); | ||||
|  | ||||
|       it('throws if the gemsBlock does not exist', async () => { | ||||
|         await expect(getOneTimePaymentInfo('not existant', null, user)) | ||||
|           .to.eventually.be.rejected.and.to.eql({ | ||||
|             httpCode: 400, | ||||
|             name: 'BadRequest', | ||||
|             message: apiError('invalidGemsBlock'), | ||||
|           }); | ||||
|       }); | ||||
|  | ||||
|       it('throws if the user cannot receive gems', async () => { | ||||
|         sandbox.stub(user, 'canGetGems').resolves(false); | ||||
|         await expect(getOneTimePaymentInfo('21gems', null, user)) | ||||
|           .to.eventually.be.rejected.and.to.eql({ | ||||
|             httpCode: 401, | ||||
|             name: 'NotAuthorized', | ||||
|             message: i18n.t('groupPolicyCannotGetGems'), | ||||
|           }); | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     describe('gift', () => { | ||||
|       it('throws if the receiver does not exist', async () => { | ||||
|         const gift = { | ||||
|           type: 'gems', | ||||
|           uuid: 'invalid', | ||||
|           gems: { | ||||
|             amount: 3, | ||||
|           }, | ||||
|         }; | ||||
|  | ||||
|         await expect(getOneTimePaymentInfo(null, gift, user)) | ||||
|           .to.eventually.be.rejected.and.to.eql({ | ||||
|             httpCode: 404, | ||||
|             name: 'NotFound', | ||||
|             message: i18n.t('userWithIDNotFound', { userId: 'invalid' }), | ||||
|           }); | ||||
|       }); | ||||
|  | ||||
|       it('throws if the user cannot receive gems', async () => { | ||||
|         const receivingUser = new User(); | ||||
|         await receivingUser.save(); | ||||
|         sandbox.stub(User.prototype, 'canGetGems').resolves(false); | ||||
|  | ||||
|         const gift = { | ||||
|           type: 'gems', | ||||
|           uuid: receivingUser._id, | ||||
|           gems: { | ||||
|             amount: 2, | ||||
|           }, | ||||
|         }; | ||||
|  | ||||
|         await expect(getOneTimePaymentInfo(null, gift, user)) | ||||
|           .to.eventually.be.rejected.and.to.eql({ | ||||
|             httpCode: 401, | ||||
|             name: 'NotAuthorized', | ||||
|             message: i18n.t('groupPolicyCannotGetGems'), | ||||
|           }); | ||||
|       }); | ||||
|  | ||||
|       it('throws if the amount of gems is <= 0', async () => { | ||||
|         const receivingUser = new User(); | ||||
|         await receivingUser.save(); | ||||
|         const gift = { | ||||
|           type: 'gems', | ||||
|           uuid: receivingUser._id, | ||||
|           gems: { | ||||
|             amount: 0, | ||||
|           }, | ||||
|         }; | ||||
|  | ||||
|         await expect(getOneTimePaymentInfo(null, gift, user)) | ||||
|           .to.eventually.be.rejected.and.to.eql({ | ||||
|             httpCode: 400, | ||||
|             name: 'BadRequest', | ||||
|             message: i18n.t('badAmountOfGemsToPurchase'), | ||||
|           }); | ||||
|       }); | ||||
|  | ||||
|       it('throws if the subscription block does not exist', async () => { | ||||
|         const receivingUser = new User(); | ||||
|         await receivingUser.save(); | ||||
|         const gift = { | ||||
|           type: 'subscription', | ||||
|           uuid: receivingUser._id, | ||||
|           subscription: { | ||||
|             key: 'invalid', | ||||
|           }, | ||||
|         }; | ||||
|  | ||||
|         await expect(getOneTimePaymentInfo(null, gift, user)) | ||||
|           .to.eventually.throw; | ||||
|       }); | ||||
|  | ||||
|       it('returns the amount (gems)', async () => { | ||||
|         const receivingUser = new User(); | ||||
|         await receivingUser.save(); | ||||
|         const gift = { | ||||
|           type: 'gems', | ||||
|           uuid: receivingUser._id, | ||||
|           gems: { | ||||
|             amount: 4, | ||||
|           }, | ||||
|         }; | ||||
|  | ||||
|         expect(subscriptions.checkSubData).to.not.be.called; | ||||
|  | ||||
|         const { gemsBlock, amount, subscription } = await getOneTimePaymentInfo(null, gift, user); | ||||
|         expect(gemsBlock).to.equal(null); | ||||
|         expect(amount).to.equal('100'); | ||||
|         expect(subscription).to.be.null; | ||||
|       }); | ||||
|  | ||||
|       it('returns the amount (subscription)', async () => { | ||||
|         const receivingUser = new User(); | ||||
|         await receivingUser.save(); | ||||
|         const gift = { | ||||
|           type: 'subscription', | ||||
|           uuid: receivingUser._id, | ||||
|           subscription: { | ||||
|             key: 'basic_3mo', | ||||
|           }, | ||||
|         }; | ||||
|         const sub = common.content.subscriptionBlocks['basic_3mo']; // eslint-disable-line dot-notation | ||||
|  | ||||
|         const { gemsBlock, amount, subscription } = await getOneTimePaymentInfo(null, gift, user); | ||||
|  | ||||
|         expect(subscriptions.checkSubData).to.be.calledOnce; | ||||
|         expect(subscriptions.checkSubData).to.be.calledWith(sub, false, null); | ||||
|  | ||||
|         expect(gemsBlock).to.equal(null); | ||||
|         expect(amount).to.equal('1500'); | ||||
|         expect(Number(amount)).to.equal(sub.price * 100); | ||||
|         expect(subscription).to.equal(sub); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('applyGemPayment', () => { | ||||
|     let user; | ||||
|     let customerId; | ||||
|     let subKey; | ||||
|     let userFindByIdStub; | ||||
|     let paymentsCreateSubSpy; | ||||
|     let paymentBuyGemsStub; | ||||
|  | ||||
|     beforeEach(async () => { | ||||
|       subKey = 'basic_3mo'; | ||||
|  | ||||
|       user = new User(); | ||||
|       await user.save(); | ||||
|  | ||||
|       customerId = 'test-id'; | ||||
|  | ||||
|       paymentsCreateSubSpy = sandbox.stub(payments, 'createSubscription'); | ||||
|       paymentsCreateSubSpy.resolves({}); | ||||
|  | ||||
|       paymentBuyGemsStub = sandbox.stub(payments, 'buyGems'); | ||||
|       paymentBuyGemsStub.resolves({}); | ||||
|     }); | ||||
|  | ||||
|     it('throws if the user does not exist', async () => { | ||||
|       const metadata = { userId: 'invalid' }; | ||||
|       const session = { metadata, customer: customerId }; | ||||
|  | ||||
|       await expect(applyGemPayment(session)) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 404, | ||||
|           name: 'NotFound', | ||||
|           message: i18n.t('userWithIDNotFound', { userId: metadata.userId }), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('throws if the receiving user does not exist', async () => { | ||||
|       const metadata = { userId: 'invalid' }; | ||||
|       const session = { metadata, customer: customerId }; | ||||
|  | ||||
|       await expect(applyGemPayment(session)) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 404, | ||||
|           name: 'NotFound', | ||||
|           message: i18n.t('userWithIDNotFound', { userId: metadata.userId }), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('throws if the gems block does not exist', async () => { | ||||
|       const gift = { | ||||
|         type: 'gems', | ||||
|         uuid: 'invalid', | ||||
|         gems: { | ||||
|           amount: 16, | ||||
|         }, | ||||
|       }; | ||||
|  | ||||
|       const metadata = { userId: user._id, gift: JSON.stringify(gift) }; | ||||
|       const session = { metadata, customer: customerId }; | ||||
|  | ||||
|       await expect(applyGemPayment(session)) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 404, | ||||
|           name: 'NotFound', | ||||
|           message: i18n.t('userWithIDNotFound', { userId: 'invalid' }), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('with existing user', () => { | ||||
|       beforeEach(() => { | ||||
|         const execStub = sandbox.stub().resolves(user); | ||||
|         userFindByIdStub = sandbox.stub(User, 'findById'); | ||||
|         userFindByIdStub.withArgs(user._id).returns({ exec: execStub }); | ||||
|       }); | ||||
|  | ||||
|       it('buys gems', async () => { | ||||
|         const metadata = { userId: user._id, gemsBlock: '21gems' }; | ||||
|         const session = { metadata, customer: customerId }; | ||||
|  | ||||
|         await applyGemPayment(session); | ||||
|  | ||||
|         expect(paymentBuyGemsStub).to.be.calledOnce; | ||||
|         expect(paymentBuyGemsStub).to.be.calledWith({ | ||||
|           user, | ||||
|           customerId, | ||||
|           paymentMethod: 'Stripe', | ||||
|           gift: undefined, | ||||
|           gemsBlock: common.content.gems['21gems'], | ||||
|         }); | ||||
|       }); | ||||
|  | ||||
|       it('gift gems', async () => { | ||||
|         const receivingUser = new User(); | ||||
|         const execStub = sandbox.stub().resolves(receivingUser); | ||||
|         userFindByIdStub.withArgs(receivingUser._id).returns({ exec: execStub }); | ||||
|         const gift = { | ||||
|           type: 'gems', | ||||
|           uuid: receivingUser._id, | ||||
|           gems: { | ||||
|             amount: 16, | ||||
|           }, | ||||
|         }; | ||||
|  | ||||
|         sandbox.stub(JSON, 'parse').returns(gift); | ||||
|         const metadata = { userId: user._id, gift: JSON.stringify(gift) }; | ||||
|         const session = { metadata, customer: customerId }; | ||||
|  | ||||
|         await applyGemPayment(session); | ||||
|  | ||||
|         expect(paymentBuyGemsStub).to.be.calledOnce; | ||||
|         expect(paymentBuyGemsStub).to.be.calledWith({ | ||||
|           user, | ||||
|           customerId, | ||||
|           paymentMethod: 'Gift', | ||||
|           gift, | ||||
|           gemsBlock: undefined, | ||||
|         }); | ||||
|       }); | ||||
|  | ||||
|       it('gift sub', async () => { | ||||
|         const receivingUser = new User(); | ||||
|         const execStub = sandbox.stub().resolves(receivingUser); | ||||
|         userFindByIdStub.withArgs(receivingUser._id).returns({ exec: execStub }); | ||||
|         const gift = { | ||||
|           type: 'subscription', | ||||
|           uuid: receivingUser._id, | ||||
|           subscription: { | ||||
|             key: subKey, | ||||
|           }, | ||||
|         }; | ||||
|  | ||||
|         sandbox.stub(JSON, 'parse').returns(gift); | ||||
|         const metadata = { userId: user._id, gift: JSON.stringify(gift) }; | ||||
|         const session = { metadata, customer: customerId }; | ||||
|  | ||||
|         await applyGemPayment(session); | ||||
|  | ||||
|         expect(paymentsCreateSubSpy).to.be.calledOnce; | ||||
|         expect(paymentsCreateSubSpy).to.be.calledWith({ | ||||
|           user, | ||||
|           customerId, | ||||
|           paymentMethod: 'Gift', | ||||
|           gift, | ||||
|           gemsBlock: undefined, | ||||
|         }); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										436
									
								
								test/api/unit/libs/payments/stripe/subscriptions.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,436 @@ | ||||
| import cc from 'coupon-code'; | ||||
| import stripeModule from 'stripe'; | ||||
|  | ||||
| import { model as Coupon } from '../../../../../../website/server/models/coupon'; | ||||
| import common from '../../../../../../website/common'; | ||||
| import { | ||||
|   checkSubData, | ||||
|   applySubscription, | ||||
|   chargeForAdditionalGroupMember, | ||||
|   handlePaymentMethodChange, | ||||
| } from '../../../../../../website/server/libs/payments/stripe/subscriptions'; | ||||
| import { | ||||
|   generateGroup, | ||||
| } from '../../../../../helpers/api-unit.helper'; | ||||
| import { model as User } from '../../../../../../website/server/models/user'; | ||||
| import payments from '../../../../../../website/server/libs/payments/payments'; | ||||
| import stripePayments from '../../../../../../website/server/libs/payments/stripe'; | ||||
|  | ||||
| const { i18n } = common; | ||||
|  | ||||
| describe('Stripe - Subscriptions', () => { | ||||
|   describe('checkSubData', () => { | ||||
|     it('does not throw if the subscription can be used', async () => { | ||||
|       const sub = common.content.subscriptionBlocks['basic_3mo']; // eslint-disable-line dot-notation | ||||
|       const res = await checkSubData(sub); | ||||
|       expect(res).to.equal(undefined); | ||||
|     }); | ||||
|  | ||||
|     it('throws if the subscription does not exists', async () => { | ||||
|       await expect(checkSubData()) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 400, | ||||
|           name: 'BadRequest', | ||||
|           message: i18n.t('missingSubscriptionCode'), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('throws if the subscription can\'t be used', async () => { | ||||
|       const sub = common.content.subscriptionBlocks['group_plan_auto']; // eslint-disable-line dot-notation | ||||
|       await expect(checkSubData(sub, true)) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 400, | ||||
|           name: 'BadRequest', | ||||
|           message: i18n.t('missingSubscriptionCode'), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('throws if the subscription targets a group and an user is making the request', async () => { | ||||
|       const sub = common.content.subscriptionBlocks['group_monthly']; // eslint-disable-line dot-notation | ||||
|       await expect(checkSubData(sub, false)) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 400, | ||||
|           name: 'BadRequest', | ||||
|           message: i18n.t('missingSubscriptionCode'), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('throws if the subscription targets an user and a group is making the request', async () => { | ||||
|       const sub = common.content.subscriptionBlocks['basic_3mo']; // eslint-disable-line dot-notation | ||||
|       await expect(checkSubData(sub, true)) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 400, | ||||
|           name: 'BadRequest', | ||||
|           message: i18n.t('missingSubscriptionCode'), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('throws if the coupon is required but not passed', async () => { | ||||
|       const sub = common.content.subscriptionBlocks['google_6mo']; // eslint-disable-line dot-notation | ||||
|       await expect(checkSubData(sub, false)) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 400, | ||||
|           name: 'BadRequest', | ||||
|           message: i18n.t('couponCodeRequired'), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('throws if the coupon is required but does not exist', async () => { | ||||
|       const coupon = 'not-valid'; | ||||
|       const sub = common.content.subscriptionBlocks['google_6mo']; // eslint-disable-line dot-notation | ||||
|       await expect(checkSubData(sub, false, coupon)) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 400, | ||||
|           name: 'BadRequest', | ||||
|           message: i18n.t('invalidCoupon'), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('throws if the coupon is required but is invalid', async () => { | ||||
|       const couponModel = new Coupon(); | ||||
|       couponModel.event = 'google_6mo'; | ||||
|       await couponModel.save(); | ||||
|  | ||||
|       sandbox.stub(cc, 'validate').returns('invalid'); | ||||
|  | ||||
|       const sub = common.content.subscriptionBlocks['google_6mo']; // eslint-disable-line dot-notation | ||||
|       await expect(checkSubData(sub, false, couponModel._id)) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 400, | ||||
|           name: 'BadRequest', | ||||
|           message: i18n.t('invalidCoupon'), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('works if the coupon is required and valid', async () => { | ||||
|       const couponModel = new Coupon(); | ||||
|       couponModel.event = 'google_6mo'; | ||||
|       await couponModel.save(); | ||||
|  | ||||
|       sandbox.stub(cc, 'validate').returns(couponModel._id); | ||||
|  | ||||
|       const sub = common.content.subscriptionBlocks['google_6mo']; // eslint-disable-line dot-notation | ||||
|       await checkSubData(sub, false, couponModel._id); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('applySubscription', () => { | ||||
|     let user; let group; let sub; | ||||
|     let groupId; | ||||
|     let customerId; let subscriptionId; | ||||
|     let subKey; | ||||
|     let userFindByIdStub; | ||||
|     let stripePaymentsCreateSubSpy; | ||||
|  | ||||
|     beforeEach(async () => { | ||||
|       subKey = 'basic_3mo'; | ||||
|       sub = common.content.subscriptionBlocks[subKey]; | ||||
|  | ||||
|       user = new User(); | ||||
|       await user.save(); | ||||
|  | ||||
|       const execStub = sandbox.stub().resolves(user); | ||||
|       userFindByIdStub = sandbox.stub(User, 'findById'); | ||||
|       userFindByIdStub.withArgs(user._id).returns({ exec: execStub }); | ||||
|  | ||||
|       group = generateGroup({ | ||||
|         name: 'test group', | ||||
|         type: 'guild', | ||||
|         privacy: 'public', | ||||
|         leader: user._id, | ||||
|       }); | ||||
|       groupId = group._id; | ||||
|       await group.save(); | ||||
|  | ||||
|       // Add user to group | ||||
|       user.guilds.push(groupId); | ||||
|       await user.save(); | ||||
|  | ||||
|       customerId = 'test-id'; | ||||
|       subscriptionId = 'test-sub-id'; | ||||
|  | ||||
|       stripePaymentsCreateSubSpy = sandbox.stub(payments, 'createSubscription'); | ||||
|       stripePaymentsCreateSubSpy.resolves({}); | ||||
|     }); | ||||
|  | ||||
|     it('subscribes a user', async () => { | ||||
|       await applySubscription({ | ||||
|         customer: customerId, | ||||
|         subscription: subscriptionId, | ||||
|         metadata: { | ||||
|           sub: JSON.stringify(sub), | ||||
|           userId: user._id, | ||||
|           groupId: null, | ||||
|         }, | ||||
|         user, | ||||
|       }); | ||||
|  | ||||
|       expect(stripePaymentsCreateSubSpy).to.be.calledOnce; | ||||
|       expect(stripePaymentsCreateSubSpy).to.be.calledWith({ | ||||
|         user, | ||||
|         customerId, | ||||
|         subscriptionId, | ||||
|         paymentMethod: 'Stripe', | ||||
|         sub: sinon.match({ ...sub }), | ||||
|         groupId: null, | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('subscribes a group', async () => { | ||||
|       sub = common.content.subscriptionBlocks['group_monthly']; // eslint-disable-line dot-notation | ||||
|       await applySubscription({ | ||||
|         customer: customerId, | ||||
|         subscription: subscriptionId, | ||||
|         metadata: { | ||||
|           sub: JSON.stringify(sub), | ||||
|           userId: user._id, | ||||
|           groupId, | ||||
|         }, | ||||
|         user, | ||||
|       }); | ||||
|  | ||||
|       expect(stripePaymentsCreateSubSpy).to.be.calledOnce; | ||||
|       expect(stripePaymentsCreateSubSpy).to.be.calledWith({ | ||||
|         user, | ||||
|         customerId, | ||||
|         subscriptionId, | ||||
|         paymentMethod: 'Stripe', | ||||
|         sub: sinon.match({ ...sub }), | ||||
|         groupId, | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('subscribes a group with multiple users', async () => { | ||||
|       const user2 = new User(); | ||||
|       user2.guilds.push(groupId); | ||||
|       await user2.save(); | ||||
|  | ||||
|       const execStub2 = sandbox.stub().resolves(user); | ||||
|       userFindByIdStub.withArgs(user2._id).returns({ exec: execStub2 }); | ||||
|  | ||||
|       group.memberCount = 2; | ||||
|       await group.save(); | ||||
|  | ||||
|       sub = common.content.subscriptionBlocks['group_monthly']; // eslint-disable-line dot-notation | ||||
|       await applySubscription({ | ||||
|         customer: customerId, | ||||
|         subscription: subscriptionId, | ||||
|         metadata: { | ||||
|           sub: JSON.stringify(sub), | ||||
|           userId: user._id, | ||||
|           groupId, | ||||
|         }, | ||||
|         user, | ||||
|       }); | ||||
|  | ||||
|       expect(stripePaymentsCreateSubSpy).to.be.calledOnce; | ||||
|       expect(stripePaymentsCreateSubSpy).to.be.calledWith({ | ||||
|         user, | ||||
|         customerId, | ||||
|         subscriptionId, | ||||
|         paymentMethod: 'Stripe', | ||||
|         sub: sinon.match({ ...sub }), | ||||
|         groupId, | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('handlePaymentMethodChange', () => { | ||||
|     const stripe = stripeModule('test'); | ||||
|  | ||||
|     it('updates the plan quantity based on the number of group members', async () => { | ||||
|       const stripeIntentRetrieveStub = sandbox.stub(stripe.setupIntents, 'retrieve').resolves({ | ||||
|         payment_method: 1, | ||||
|         metadata: { | ||||
|           subscription_id: 2, | ||||
|         }, | ||||
|       }); | ||||
|       const stripeSubUpdateStub = sandbox.stub(stripe.subscriptions, 'update'); | ||||
|  | ||||
|       await handlePaymentMethodChange({}, stripe); | ||||
|       expect(stripeIntentRetrieveStub).to.be.calledOnce; | ||||
|       expect(stripeSubUpdateStub).to.be.calledOnce; | ||||
|       expect(stripeSubUpdateStub).to.be.calledWith(2, { | ||||
|         default_payment_method: 1, | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('chargeForAdditionalGroupMember', () => { | ||||
|     const stripe = stripeModule('test'); | ||||
|     let stripeUpdateSubStub; | ||||
|     const plan = common.content.subscriptionBlocks['group_monthly']; // eslint-disable-line dot-notation | ||||
|  | ||||
|     let user; let group; | ||||
|  | ||||
|     beforeEach(async () => { | ||||
|       user = new User(); | ||||
|  | ||||
|       group = generateGroup({ | ||||
|         name: 'test group', | ||||
|         type: 'guild', | ||||
|         privacy: 'public', | ||||
|         leader: user._id, | ||||
|       }); | ||||
|       group.purchased.plan.customerId = 'customer-id'; | ||||
|       group.purchased.plan.planId = plan.key; | ||||
|       group.purchased.plan.subscriptionId = 'sub-id'; | ||||
|       await group.save(); | ||||
|  | ||||
|       stripeUpdateSubStub = sandbox.stub(stripe.subscriptions, 'update').resolves({}); | ||||
|     }); | ||||
|  | ||||
|     it('updates the plan quantity based on the number of group members', async () => { | ||||
|       group.memberCount = 4; | ||||
|       const newQuantity = group.memberCount + plan.quantity - 1; | ||||
|  | ||||
|       await chargeForAdditionalGroupMember(group, stripe); | ||||
|       expect(stripeUpdateSubStub).to.be.calledWithMatch( | ||||
|         group.purchased.plan.subscriptionId, | ||||
|         sinon.match({ | ||||
|           plan: group.purchased.plan.planId, | ||||
|           quantity: newQuantity, | ||||
|         }), | ||||
|       ); | ||||
|       expect(group.purchased.plan.quantity).to.equal(newQuantity); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('cancelSubscription', () => { | ||||
|     const subKey = 'basic_3mo'; | ||||
|     const stripe = stripeModule('test'); | ||||
|     let user; let groupId; let | ||||
|       group; | ||||
|  | ||||
|     beforeEach(async () => { | ||||
|       user = new User(); | ||||
|       user.profile.name = 'sender'; | ||||
|       user.purchased.plan.customerId = 'customer-id'; | ||||
|       user.purchased.plan.planId = subKey; | ||||
|       user.purchased.plan.lastBillingDate = new Date(); | ||||
|  | ||||
|       group = generateGroup({ | ||||
|         name: 'test group', | ||||
|         type: 'guild', | ||||
|         privacy: 'public', | ||||
|         leader: user._id, | ||||
|       }); | ||||
|       group.purchased.plan.customerId = 'customer-id'; | ||||
|       group.purchased.plan.planId = subKey; | ||||
|       await group.save(); | ||||
|  | ||||
|       groupId = group._id; | ||||
|     }); | ||||
|  | ||||
|     it('throws an error if there is no customer id', async () => { | ||||
|       user.purchased.plan.customerId = undefined; | ||||
|  | ||||
|       await expect(stripePayments.cancelSubscription({ | ||||
|         user, | ||||
|         groupId: undefined, | ||||
|       })) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 401, | ||||
|           name: 'NotAuthorized', | ||||
|           message: i18n.t('missingSubscription'), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('throws an error if the group is not found', async () => { | ||||
|       await expect(stripePayments.cancelSubscription({ | ||||
|         user, | ||||
|         groupId: 'fake-group', | ||||
|       })) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 404, | ||||
|           name: 'NotFound', | ||||
|           message: i18n.t('groupNotFound'), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('throws an error if user is not the group leader', async () => { | ||||
|       const nonLeader = new User(); | ||||
|       nonLeader.guilds.push(groupId); | ||||
|       await nonLeader.save(); | ||||
|  | ||||
|       await expect(stripePayments.cancelSubscription({ | ||||
|         user: nonLeader, | ||||
|         groupId, | ||||
|       })) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 401, | ||||
|           name: 'NotAuthorized', | ||||
|           message: i18n.t('onlyGroupLeaderCanManageSubscription'), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('success', () => { | ||||
|       let stripeDeleteCustomerStub; let paymentsCancelSubStub; | ||||
|       let stripeRetrieveStub; let subscriptionId; let | ||||
|         currentPeriodEndTimeStamp; | ||||
|  | ||||
|       beforeEach(() => { | ||||
|         subscriptionId = 'subId'; | ||||
|         stripeDeleteCustomerStub = sinon.stub(stripe.customers, 'del').resolves({}); | ||||
|         paymentsCancelSubStub = sinon.stub(payments, 'cancelSubscription').resolves({}); | ||||
|  | ||||
|         currentPeriodEndTimeStamp = (new Date()).getTime(); | ||||
|         stripeRetrieveStub = sinon.stub(stripe.customers, 'retrieve') | ||||
|           .resolves({ | ||||
|             subscriptions: { | ||||
|               data: [{ | ||||
|                 id: subscriptionId, | ||||
|                 current_period_end: currentPeriodEndTimeStamp, | ||||
|               }], // eslint-disable-line camelcase | ||||
|             }, | ||||
|           }); | ||||
|       }); | ||||
|  | ||||
|       afterEach(() => { | ||||
|         stripe.customers.del.restore(); | ||||
|         stripe.customers.retrieve.restore(); | ||||
|         payments.cancelSubscription.restore(); | ||||
|       }); | ||||
|  | ||||
|       it('cancels a user subscription', async () => { | ||||
|         await stripePayments.cancelSubscription({ | ||||
|           user, | ||||
|           groupId: undefined, | ||||
|         }, stripe); | ||||
|  | ||||
|         expect(stripeDeleteCustomerStub).to.be.calledOnce; | ||||
|         expect(stripeDeleteCustomerStub).to.be.calledWith(user.purchased.plan.customerId); | ||||
|         expect(stripeRetrieveStub).to.be.calledOnce; | ||||
|         expect(stripeRetrieveStub).to.be.calledWith(user.purchased.plan.customerId); | ||||
|         expect(paymentsCancelSubStub).to.be.calledOnce; | ||||
|         expect(paymentsCancelSubStub).to.be.calledWith({ | ||||
|           user, | ||||
|           groupId: undefined, | ||||
|           nextBill: currentPeriodEndTimeStamp * 1000, // timestamp in seconds | ||||
|           paymentMethod: 'Stripe', | ||||
|           cancellationReason: undefined, | ||||
|         }); | ||||
|       }); | ||||
|  | ||||
|       it('cancels a group subscription', async () => { | ||||
|         await stripePayments.cancelSubscription({ | ||||
|           user, | ||||
|           groupId, | ||||
|         }, stripe); | ||||
|  | ||||
|         expect(stripeDeleteCustomerStub).to.be.calledOnce; | ||||
|         expect(stripeDeleteCustomerStub).to.be.calledWith(group.purchased.plan.customerId); | ||||
|         expect(stripeRetrieveStub).to.be.calledOnce; | ||||
|         expect(stripeRetrieveStub).to.be.calledWith(user.purchased.plan.customerId); | ||||
|         expect(paymentsCancelSubStub).to.be.calledOnce; | ||||
|         expect(paymentsCancelSubStub).to.be.calledWith({ | ||||
|           user, | ||||
|           groupId, | ||||
|           nextBill: currentPeriodEndTimeStamp * 1000, // timestamp in seconds | ||||
|           paymentMethod: 'Stripe', | ||||
|           cancellationReason: undefined, | ||||
|         }); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @@ -1,70 +0,0 @@ | ||||
| import stripeModule from 'stripe'; | ||||
|  | ||||
| import { | ||||
|   generateGroup, | ||||
| } from '../../../../../helpers/api-unit.helper'; | ||||
| import { model as User } from '../../../../../../website/server/models/user'; | ||||
| import { model as Group } from '../../../../../../website/server/models/group'; | ||||
| import stripePayments from '../../../../../../website/server/libs/payments/stripe'; | ||||
| import payments from '../../../../../../website/server/libs/payments/payments'; | ||||
|  | ||||
| describe('Stripe - Upgrade Group Plan', () => { | ||||
|   const stripe = stripeModule('test'); | ||||
|   let spy; let data; let user; let | ||||
|     group; | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     user = new User(); | ||||
|     user.profile.name = 'sender'; | ||||
|  | ||||
|     data = { | ||||
|       user, | ||||
|       sub: { | ||||
|         key: 'basic_3mo', // @TODO: Validate that this is group | ||||
|       }, | ||||
|       customerId: 'customer-id', | ||||
|       paymentMethod: 'Payment Method', | ||||
|       headers: { | ||||
|         'x-client': 'habitica-web', | ||||
|         'user-agent': '', | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     group = generateGroup({ | ||||
|       name: 'test group', | ||||
|       type: 'guild', | ||||
|       privacy: 'private', | ||||
|       leader: user._id, | ||||
|     }); | ||||
|     await group.save(); | ||||
|  | ||||
|     user.guilds.push(group._id); | ||||
|     await user.save(); | ||||
|  | ||||
|     spy = sinon.stub(stripe.subscriptions, 'update'); | ||||
|     spy.resolves([]); | ||||
|     data.groupId = group._id; | ||||
|     data.sub.quantity = 3; | ||||
|     stripePayments.setStripeApi(stripe); | ||||
|   }); | ||||
|  | ||||
|   afterEach(() => { | ||||
|     stripe.subscriptions.update.restore(); | ||||
|   }); | ||||
|  | ||||
|   it('updates a group plan quantity', async () => { | ||||
|     data.paymentMethod = 'Stripe'; | ||||
|     await payments.createSubscription(data); | ||||
|  | ||||
|     const updatedGroup = await Group.findById(group._id).exec(); | ||||
|     expect(updatedGroup.purchased.plan.quantity).to.eql(3); | ||||
|  | ||||
|     updatedGroup.memberCount += 1; | ||||
|     await updatedGroup.save(); | ||||
|  | ||||
|     await stripePayments.chargeForAdditionalGroupMember(updatedGroup); | ||||
|  | ||||
|     expect(spy.calledOnce).to.be.true; | ||||
|     expect(updatedGroup.purchased.plan.quantity).to.eql(4); | ||||
|   }); | ||||
| }); | ||||
| @@ -1,5 +1,5 @@ | ||||
| import stripeModule from 'stripe'; | ||||
| 
 | ||||
| import nconf from 'nconf'; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import moment from 'moment'; | ||||
| import { | ||||
| @@ -10,76 +10,102 @@ import stripePayments from '../../../../../../website/server/libs/payments/strip | ||||
| import payments from '../../../../../../website/server/libs/payments/payments'; | ||||
| import common from '../../../../../../website/common'; | ||||
| import logger from '../../../../../../website/server/libs/logger'; | ||||
| import * as oneTimePayments from '../../../../../../website/server/libs/payments/stripe/oneTimePayments'; | ||||
| import * as subscriptions from '../../../../../../website/server/libs/payments/stripe/subscriptions'; | ||||
| 
 | ||||
| const { i18n } = common; | ||||
| 
 | ||||
| describe('Stripe - Webhooks', () => { | ||||
|   const stripe = stripeModule('test'); | ||||
|   const endpointSecret = nconf.get('STRIPE_WEBHOOKS_ENDPOINT_SECRET'); | ||||
|   const headers = {}; | ||||
|   const body = {}; | ||||
| 
 | ||||
|   describe('all events', () => { | ||||
|     const eventType = 'account.updated'; | ||||
|     const event = { id: 123 }; | ||||
|     const eventRetrieved = { type: eventType }; | ||||
|     let event; | ||||
|     let constructEventStub; | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|       sinon.stub(stripe.events, 'retrieve').resolves(eventRetrieved); | ||||
|       sinon.stub(logger, 'error'); | ||||
|       event = { type: 'payment_intent.created' }; | ||||
|       constructEventStub = sandbox.stub(stripe.webhooks, 'constructEvent'); | ||||
|       constructEventStub.returns(event); | ||||
|       sandbox.stub(logger, 'error'); | ||||
|     }); | ||||
| 
 | ||||
|     afterEach(() => { | ||||
|       stripe.events.retrieve.restore(); | ||||
|       logger.error.restore(); | ||||
|     it('throws if the event can\'t be validated', async () => { | ||||
|       const err = new Error('fail'); | ||||
|       constructEventStub.throws(err); | ||||
|       await expect(stripePayments.handleWebhooks({ body: event, headers }, stripe)) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 400, | ||||
|           name: 'BadRequest', | ||||
|           message: `Webhook Error: ${err.message}`, | ||||
|         }); | ||||
| 
 | ||||
|       expect(logger.error).to.have.been.calledOnce; | ||||
|       const calledWith = logger.error.getCall(0).args; | ||||
|       expect(calledWith[0].message).to.equal('Error verifying Stripe webhook'); | ||||
|       expect(calledWith[1]).to.eql({ err }); | ||||
|     }); | ||||
| 
 | ||||
|     it('logs an error if an unsupported webhook event is passed', async () => { | ||||
|       const error = new Error(`Missing handler for Stripe webhook ${eventType}`); | ||||
|       await stripePayments.handleWebhooks({ requestBody: event }, stripe); | ||||
|       expect(logger.error).to.have.been.calledOnce; | ||||
|       event.type = 'account.updated'; | ||||
|       await expect(stripePayments.handleWebhooks({ body, headers }, stripe)) | ||||
|         .to.eventually.be.rejected.and.to.eql({ | ||||
|           httpCode: 400, | ||||
|           name: 'BadRequest', | ||||
|           message: `Missing handler for Stripe webhook ${event.type}`, | ||||
|         }); | ||||
| 
 | ||||
|       expect(logger.error).to.have.been.calledOnce; | ||||
|       const calledWith = logger.error.getCall(0).args; | ||||
|       expect(calledWith[0].message).to.equal(error.message); | ||||
|       expect(calledWith[1].event).to.equal(eventRetrieved); | ||||
|       expect(calledWith[0].message).to.equal('Error handling Stripe webhook'); | ||||
|       expect(calledWith[1].event).to.eql(event); | ||||
|       expect(calledWith[1].err.message).to.eql(`Missing handler for Stripe webhook ${event.type}`); | ||||
|     }); | ||||
| 
 | ||||
|     it('retrieves and validates the event from Stripe', async () => { | ||||
|       await stripePayments.handleWebhooks({ requestBody: event }, stripe); | ||||
|       expect(stripe.events.retrieve).to.have.been.calledOnce; | ||||
|       expect(stripe.events.retrieve).to.have.been.calledWith(event.id); | ||||
|       await stripePayments.handleWebhooks({ body, headers }, stripe); | ||||
|       expect(stripe.webhooks.constructEvent).to.have.been.calledOnce; | ||||
|       expect(stripe.webhooks.constructEvent) | ||||
|         .to.have.been.calledWith(body, undefined, endpointSecret); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('customer.subscription.deleted', () => { | ||||
|     const eventType = 'customer.subscription.deleted'; | ||||
|     let event; | ||||
|     let constructEventStub; | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|       sinon.stub(stripe.customers, 'del').resolves({}); | ||||
|       sinon.stub(payments, 'cancelSubscription').resolves({}); | ||||
|       event = { type: eventType }; | ||||
|       constructEventStub = sandbox.stub(stripe.webhooks, 'constructEvent'); | ||||
|       constructEventStub.returns(event); | ||||
|     }); | ||||
| 
 | ||||
|     afterEach(() => { | ||||
|       stripe.customers.del.restore(); | ||||
|       payments.cancelSubscription.restore(); | ||||
|     beforeEach(() => { | ||||
|       sandbox.stub(stripe.customers, 'del').resolves({}); | ||||
|       sandbox.stub(payments, 'cancelSubscription').resolves({}); | ||||
|     }); | ||||
| 
 | ||||
|     it('does not do anything if event.request is null (subscription cancelled manually)', async () => { | ||||
|       sinon.stub(stripe.events, 'retrieve').resolves({ | ||||
|     it('does not do anything if event.request is not null (subscription cancelled manually)', async () => { | ||||
|       constructEventStub.returns({ | ||||
|         id: 123, | ||||
|         type: eventType, | ||||
|         request: 123, | ||||
|         request: { id: 123 }, | ||||
|       }); | ||||
| 
 | ||||
|       await stripePayments.handleWebhooks({ requestBody: {} }, stripe); | ||||
|       await stripePayments.handleWebhooks({ body, headers }, stripe); | ||||
| 
 | ||||
|       expect(stripe.events.retrieve).to.have.been.calledOnce; | ||||
|       expect(stripe.webhooks.constructEvent).to.have.been.calledOnce; | ||||
|       expect(stripe.customers.del).to.not.have.been.called; | ||||
|       expect(payments.cancelSubscription).to.not.have.been.called; | ||||
|       stripe.events.retrieve.restore(); | ||||
|     }); | ||||
| 
 | ||||
|     describe('user subscription', () => { | ||||
|       it('throws an error if the user is not found', async () => { | ||||
|         const customerId = 456; | ||||
|         sinon.stub(stripe.events, 'retrieve').resolves({ | ||||
|         constructEventStub.returns({ | ||||
|           id: 123, | ||||
|           type: eventType, | ||||
|           data: { | ||||
| @@ -90,10 +116,10 @@ describe('Stripe - Webhooks', () => { | ||||
|               customer: customerId, | ||||
|             }, | ||||
|           }, | ||||
|           request: null, | ||||
|           request: { id: null }, | ||||
|         }); | ||||
| 
 | ||||
|         await expect(stripePayments.handleWebhooks({ requestBody: {} }, stripe)) | ||||
|         await expect(stripePayments.handleWebhooks({ body, headers }, stripe)) | ||||
|           .to.eventually.be.rejectedWith({ | ||||
|             message: i18n.t('userNotFound'), | ||||
|             httpCode: 404, | ||||
| @@ -102,8 +128,6 @@ describe('Stripe - Webhooks', () => { | ||||
| 
 | ||||
|         expect(stripe.customers.del).to.not.have.been.called; | ||||
|         expect(payments.cancelSubscription).to.not.have.been.called; | ||||
| 
 | ||||
|         stripe.events.retrieve.restore(); | ||||
|       }); | ||||
| 
 | ||||
|       it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => { | ||||
| @@ -114,7 +138,7 @@ describe('Stripe - Webhooks', () => { | ||||
|         subscriber.purchased.plan.paymentMethod = 'Stripe'; | ||||
|         await subscriber.save(); | ||||
| 
 | ||||
|         sinon.stub(stripe.events, 'retrieve').resolves({ | ||||
|         constructEventStub.returns({ | ||||
|           id: 123, | ||||
|           type: eventType, | ||||
|           data: { | ||||
| @@ -125,10 +149,10 @@ describe('Stripe - Webhooks', () => { | ||||
|               customer: customerId, | ||||
|             }, | ||||
|           }, | ||||
|           request: null, | ||||
|           request: { id: null }, | ||||
|         }); | ||||
| 
 | ||||
|         await stripePayments.handleWebhooks({ requestBody: {} }, stripe); | ||||
|         await stripePayments.handleWebhooks({ body, headers }, stripe); | ||||
| 
 | ||||
|         expect(stripe.customers.del).to.have.been.calledOnce; | ||||
|         expect(stripe.customers.del).to.have.been.calledWith(customerId); | ||||
| @@ -139,15 +163,13 @@ describe('Stripe - Webhooks', () => { | ||||
|         expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe'); | ||||
|         expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3); | ||||
|         expect(cancelSubscriptionOpts.groupId).to.be.undefined; | ||||
| 
 | ||||
|         stripe.events.retrieve.restore(); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('group plan subscription', () => { | ||||
|       it('throws an error if the group is not found', async () => { | ||||
|         const customerId = 456; | ||||
|         sinon.stub(stripe.events, 'retrieve').resolves({ | ||||
|         constructEventStub.returns({ | ||||
|           id: 123, | ||||
|           type: eventType, | ||||
|           data: { | ||||
| @@ -158,10 +180,10 @@ describe('Stripe - Webhooks', () => { | ||||
|               customer: customerId, | ||||
|             }, | ||||
|           }, | ||||
|           request: null, | ||||
|           request: { id: null }, | ||||
|         }); | ||||
| 
 | ||||
|         await expect(stripePayments.handleWebhooks({ requestBody: {} }, stripe)) | ||||
|         await expect(stripePayments.handleWebhooks({ body, headers }, stripe)) | ||||
|           .to.eventually.be.rejectedWith({ | ||||
|             message: i18n.t('groupNotFound'), | ||||
|             httpCode: 404, | ||||
| @@ -170,8 +192,6 @@ describe('Stripe - Webhooks', () => { | ||||
| 
 | ||||
|         expect(stripe.customers.del).to.not.have.been.called; | ||||
|         expect(payments.cancelSubscription).to.not.have.been.called; | ||||
| 
 | ||||
|         stripe.events.retrieve.restore(); | ||||
|       }); | ||||
| 
 | ||||
|       it('throws an error if the group leader is not found', async () => { | ||||
| @@ -187,7 +207,7 @@ describe('Stripe - Webhooks', () => { | ||||
|         subscriber.purchased.plan.paymentMethod = 'Stripe'; | ||||
|         await subscriber.save(); | ||||
| 
 | ||||
|         sinon.stub(stripe.events, 'retrieve').resolves({ | ||||
|         constructEventStub.returns({ | ||||
|           id: 123, | ||||
|           type: eventType, | ||||
|           data: { | ||||
| @@ -198,10 +218,10 @@ describe('Stripe - Webhooks', () => { | ||||
|               customer: customerId, | ||||
|             }, | ||||
|           }, | ||||
|           request: null, | ||||
|           request: { id: null }, | ||||
|         }); | ||||
| 
 | ||||
|         await expect(stripePayments.handleWebhooks({ requestBody: {} }, stripe)) | ||||
|         await expect(stripePayments.handleWebhooks({ body, headers }, stripe)) | ||||
|           .to.eventually.be.rejectedWith({ | ||||
|             message: i18n.t('userNotFound'), | ||||
|             httpCode: 404, | ||||
| @@ -210,8 +230,6 @@ describe('Stripe - Webhooks', () => { | ||||
| 
 | ||||
|         expect(stripe.customers.del).to.not.have.been.called; | ||||
|         expect(payments.cancelSubscription).to.not.have.been.called; | ||||
| 
 | ||||
|         stripe.events.retrieve.restore(); | ||||
|       }); | ||||
| 
 | ||||
|       it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => { | ||||
| @@ -230,7 +248,7 @@ describe('Stripe - Webhooks', () => { | ||||
|         subscriber.purchased.plan.paymentMethod = 'Stripe'; | ||||
|         await subscriber.save(); | ||||
| 
 | ||||
|         sinon.stub(stripe.events, 'retrieve').resolves({ | ||||
|         constructEventStub.returns({ | ||||
|           id: 123, | ||||
|           type: eventType, | ||||
|           data: { | ||||
| @@ -241,10 +259,10 @@ describe('Stripe - Webhooks', () => { | ||||
|               customer: customerId, | ||||
|             }, | ||||
|           }, | ||||
|           request: null, | ||||
|           request: { id: null }, | ||||
|         }); | ||||
| 
 | ||||
|         await stripePayments.handleWebhooks({ requestBody: {} }, stripe); | ||||
|         await stripePayments.handleWebhooks({ body, headers }, stripe); | ||||
| 
 | ||||
|         expect(stripe.customers.del).to.have.been.calledOnce; | ||||
|         expect(stripe.customers.del).to.have.been.calledWith(customerId); | ||||
| @@ -255,9 +273,65 @@ describe('Stripe - Webhooks', () => { | ||||
|         expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe'); | ||||
|         expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3); | ||||
|         expect(cancelSubscriptionOpts.groupId).to.equal(subscriber._id); | ||||
| 
 | ||||
|         stripe.events.retrieve.restore(); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('checkout.session.completed', () => { | ||||
|     const eventType = 'checkout.session.completed'; | ||||
|     let event; | ||||
|     let constructEventStub; | ||||
|     const session = {}; | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|       session.metadata = {}; | ||||
|       event = { type: eventType, data: { object: session } }; | ||||
|       constructEventStub = sandbox.stub(stripe.webhooks, 'constructEvent'); | ||||
|       constructEventStub.returns(event); | ||||
| 
 | ||||
|       sandbox.stub(oneTimePayments, 'applyGemPayment').resolves({}); | ||||
|       sandbox.stub(subscriptions, 'applySubscription').resolves({}); | ||||
|       sandbox.stub(subscriptions, 'handlePaymentMethodChange').resolves({}); | ||||
|     }); | ||||
| 
 | ||||
|     it('handles changing an user sub', async () => { | ||||
|       session.metadata.type = 'edit-card-user'; | ||||
| 
 | ||||
|       await stripePayments.handleWebhooks({ body, headers }, stripe); | ||||
| 
 | ||||
|       expect(stripe.webhooks.constructEvent).to.have.been.calledOnce; | ||||
|       expect(subscriptions.handlePaymentMethodChange).to.have.been.calledOnce; | ||||
|       expect(subscriptions.handlePaymentMethodChange).to.have.been.calledWith(session); | ||||
|     }); | ||||
| 
 | ||||
|     it('handles changing a group sub', async () => { | ||||
|       session.metadata.type = 'edit-card-group'; | ||||
| 
 | ||||
|       await stripePayments.handleWebhooks({ body, headers }, stripe); | ||||
| 
 | ||||
|       expect(stripe.webhooks.constructEvent).to.have.been.calledOnce; | ||||
|       expect(subscriptions.handlePaymentMethodChange).to.have.been.calledOnce; | ||||
|       expect(subscriptions.handlePaymentMethodChange).to.have.been.calledWith(session); | ||||
|     }); | ||||
| 
 | ||||
|     it('applies a subscription', async () => { | ||||
|       session.metadata.type = 'subscription'; | ||||
| 
 | ||||
|       await stripePayments.handleWebhooks({ body, headers }, stripe); | ||||
| 
 | ||||
|       expect(stripe.webhooks.constructEvent).to.have.been.calledOnce; | ||||
|       expect(subscriptions.applySubscription).to.have.been.calledOnce; | ||||
|       expect(subscriptions.applySubscription).to.have.been.calledWith(session); | ||||
|     }); | ||||
| 
 | ||||
|     it('handles a one time payment', async () => { | ||||
|       session.metadata.type = 'something else'; | ||||
| 
 | ||||
|       await stripePayments.handleWebhooks({ body, headers }, stripe); | ||||
| 
 | ||||
|       expect(stripe.webhooks.constructEvent).to.have.been.calledOnce; | ||||
|       expect(oneTimePayments.applyGemPayment).to.have.been.calledOnce; | ||||
|       expect(oneTimePayments.applyGemPayment).to.have.been.calledWith(session); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @@ -1,4 +1,4 @@ | ||||
| import apn from 'apn/mock'; | ||||
| import apn from '@parse/node-apn/mock'; | ||||
| import _ from 'lodash'; | ||||
| import nconf from 'nconf'; | ||||
| import gcmLib from 'node-gcm'; // works with FCM notifications too | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| /* eslint-disable camelcase */ | ||||
| import { IncomingWebhook } from '@slack/client'; | ||||
| import { IncomingWebhook } from '@slack/webhook'; | ||||
| import requireAgain from 'require-again'; | ||||
| import nconf from 'nconf'; | ||||
| import moment from 'moment'; | ||||
| @@ -12,7 +12,7 @@ describe('slack', () => { | ||||
|     let data; | ||||
|  | ||||
|     beforeEach(() => { | ||||
|       sandbox.stub(IncomingWebhook.prototype, 'send'); | ||||
|       sandbox.stub(IncomingWebhook.prototype, 'send').returns(Promise.resolve()); | ||||
|       data = { | ||||
|         authorEmail: 'author@example.com', | ||||
|         flagger: { | ||||
| @@ -112,6 +112,7 @@ describe('slack', () => { | ||||
|  | ||||
|     it('noops if no flagging url is provided', () => { | ||||
|       sandbox.stub(nconf, 'get').withArgs('SLACK_FLAGGING_URL').returns(''); | ||||
|       nconf.get.withArgs('IS_TEST').returns(true); | ||||
|       sandbox.stub(logger, 'error'); | ||||
|       const reRequiredSlack = requireAgain('../../../../website/server/libs/slack'); | ||||
|  | ||||
|   | ||||
| @@ -8,5 +8,10 @@ describe('stringUtils', () => { | ||||
|       const matches = getMatchesByWordArray(message, bannedWords); | ||||
|       expect(matches.length).to.equal(bannedWords.length); | ||||
|     }); | ||||
|     it('doesn\'t flag names with accented characters', () => { | ||||
|       const name = 'TESTPLACEHOLDERSWEARWORDHEREé'; | ||||
|       const matches = getMatchesByWordArray(name, bannedWords); | ||||
|       expect(matches.length).to.equal(0); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -311,25 +311,8 @@ describe('cron middleware', () => { | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('does not enroll 50% of users', async () => { | ||||
|       sandbox.stub(Math, 'random').returns(0.6); | ||||
|       user.lastCron = moment(new Date()).subtract({ days: 2 }); | ||||
|       await user.save(); | ||||
|       req.headers['x-client'] = 'habitica-web'; | ||||
|  | ||||
|       await new Promise((resolve, reject) => { | ||||
|         cronMiddleware(req, res, async err => { | ||||
|           if (err) return reject(err); | ||||
|           user = await User.findById(user._id).exec(); | ||||
|           expect(user._ABtests.dropCapNotif).to.be.equal('drop-cap-notif-not-enrolled'); | ||||
|  | ||||
|           return resolve(); | ||||
|         }); | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('enables the new notification for 25% of users', async () => { | ||||
|       sandbox.stub(Math, 'random').returns(0.25); | ||||
|     it('enables the new notification for 50% of users', async () => { | ||||
|       sandbox.stub(Math, 'random').returns(0.5); | ||||
|       user.lastCron = moment(new Date()).subtract({ days: 2 }); | ||||
|       await user.save(); | ||||
|       req.headers['x-client'] = 'habitica-web'; | ||||
| @@ -345,8 +328,8 @@ describe('cron middleware', () => { | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('disables the new notification for 25% of users', async () => { | ||||
|       sandbox.stub(Math, 'random').returns(0.5); | ||||
|     it('disables the new notification for 50% of users', async () => { | ||||
|       sandbox.stub(Math, 'random').returns(0.51); | ||||
|       user.lastCron = moment(new Date()).subtract({ days: 2 }); | ||||
|       await user.save(); | ||||
|       req.headers['x-client'] = 'habitica-web'; | ||||
|   | ||||
| @@ -592,6 +592,50 @@ describe('User Model', () => { | ||||
|   }); | ||||
|  | ||||
|   context('pre-save hook', () => { | ||||
|     it('enrolls users that signup through web in the Drop Cap AB test', async () => { | ||||
|       let user = new User(); | ||||
|       user.registeredThrough = 'habitica-web'; | ||||
|       user = await user.save(); | ||||
|       expect(user._ABtests.dropCapNotif).to.exist; | ||||
|     }); | ||||
|  | ||||
|     it('does not enroll users that signup through modal in the Drop Cap AB test', async () => { | ||||
|       let user = new User(); | ||||
|       user.registeredThrough = 'habitica-ios'; | ||||
|       user = await user.save(); | ||||
|       expect(user._ABtests.dropCapNotif).to.not.exist; | ||||
|     }); | ||||
|  | ||||
|     it('marks the last news post as read for new users', async () => { | ||||
|       const lastNewsPost = { _id: '1' }; | ||||
|       sandbox.stub(NewsPost, 'lastNewsPost').returns(lastNewsPost); | ||||
|  | ||||
|       let user = new User(); | ||||
|       expect(user.isNew).to.equal(true); | ||||
|       user = await user.save(); | ||||
|  | ||||
|       expect(user.checkNewStuff()).to.equal(false); | ||||
|       expect(user.toJSON().flags.newStuff).to.equal(false); | ||||
|       expect(user.flags.lastNewStuffRead).to.equal(lastNewsPost._id); | ||||
|     }); | ||||
|  | ||||
|     it('does not mark the last news post as read for existing users', async () => { | ||||
|       const lastNewsPost = { _id: '1' }; | ||||
|       const lastNewsPostStub = sandbox.stub(NewsPost, 'lastNewsPost'); | ||||
|       lastNewsPostStub.returns(lastNewsPost); | ||||
|  | ||||
|       let user = new User(); | ||||
|       user = await user.save(); | ||||
|  | ||||
|       expect(user.isNew).to.equal(false); | ||||
|       user.profile.name = 'new name'; | ||||
|  | ||||
|       lastNewsPostStub.returns({ _id: '2' }); | ||||
|       user = await user.save(); | ||||
|  | ||||
|       expect(user.flags.lastNewStuffRead).to.equal(lastNewsPost._id); // not _id: 2 | ||||
|     }); | ||||
|  | ||||
|     it('does not try to award achievements when achievements or items not selected in query', async () => { | ||||
|       let user = new User(); | ||||
|       user = await user.save(); // necessary for user.isSelected to work correctly | ||||
|   | ||||
| @@ -0,0 +1,19 @@ | ||||
| import { | ||||
|   generateUser, | ||||
|   requester, | ||||
| } from '../../../../helpers/api-integration/v3'; | ||||
| import { mockAnalyticsService as analytics } from '../../../../../website/server/libs/analyticsService'; | ||||
|  | ||||
| describe('POST /analytics/track/:eventName', () => { | ||||
|   it('calls res.analytics', async () => { | ||||
|     const user = await generateUser(); | ||||
|     sandbox.spy(analytics, 'track'); | ||||
|  | ||||
|     const requestWithHeaders = requester(user, { 'x-client': 'habitica-web' }); | ||||
|     await requestWithHeaders.post('/analytics/track/eventName', { data: 'example' }, { 'x-client': 'habitica-web' }); | ||||
|     expect(analytics.track).to.be.calledOnce; | ||||
|     expect(analytics.track).to.be.calledWith('eventName', sandbox.match({ data: 'example' })); | ||||
|  | ||||
|     sandbox.restore(); | ||||
|   }); | ||||
| }); | ||||
| @@ -117,26 +117,7 @@ describe('GET /challenges/:challengeId/members', () => { | ||||
|     expect(res[0].profile).to.have.all.keys(['name']); | ||||
|   }); | ||||
|  | ||||
|   it('returns only first 30 members if req.query.includeAllMembers is not true and req.query.limit is undefined', async () => { | ||||
|     const group = await generateGroup(user, { type: 'party', name: generateUUID() }); | ||||
|     const challenge = await generateChallenge(user, group); | ||||
|     await user.post(`/challenges/${challenge._id}/join`); | ||||
|  | ||||
|     const usersToGenerate = []; | ||||
|     for (let i = 0; i < 31; i += 1) { | ||||
|       usersToGenerate.push(generateUser({ challenges: [challenge._id] })); | ||||
|     } | ||||
|     await Promise.all(usersToGenerate); | ||||
|  | ||||
|     const res = await user.get(`/challenges/${challenge._id}/members?includeAllMembers=not-true`); | ||||
|     expect(res.length).to.equal(30); | ||||
|     res.forEach(member => { | ||||
|       expect(member).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']); | ||||
|       expect(member.profile).to.have.all.keys(['name']); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   it('returns only first 30 members if req.query.includeAllMembers is not defined and req.query.limit is undefined', async () => { | ||||
|   it('returns only first 30 members if req.query.limit is undefined', async () => { | ||||
|     const group = await generateGroup(user, { type: 'party', name: generateUUID() }); | ||||
|     const challenge = await generateChallenge(user, group); | ||||
|     await user.post(`/challenges/${challenge._id}/join`); | ||||
| @@ -217,25 +198,6 @@ describe('GET /challenges/:challengeId/members', () => { | ||||
|     }); | ||||
|   }).timeout(30000); | ||||
|  | ||||
|   it('returns all members if req.query.includeAllMembers is true', async () => { | ||||
|     const group = await generateGroup(user, { type: 'party', name: generateUUID() }); | ||||
|     const challenge = await generateChallenge(user, group); | ||||
|     await user.post(`/challenges/${challenge._id}/join`); | ||||
|  | ||||
|     const usersToGenerate = []; | ||||
|     for (let i = 0; i < 31; i += 1) { | ||||
|       usersToGenerate.push(generateUser({ challenges: [challenge._id] })); | ||||
|     } | ||||
|     await Promise.all(usersToGenerate); | ||||
|  | ||||
|     const res = await user.get(`/challenges/${challenge._id}/members?includeAllMembers=true`); | ||||
|     expect(res.length).to.equal(32); | ||||
|     res.forEach(member => { | ||||
|       expect(member).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']); | ||||
|       expect(member.profile).to.have.all.keys(['name']); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   it('supports using req.query.lastId to get more members', async function test () { | ||||
|     this.timeout(30000); // @TODO: times out after 8 seconds | ||||
|     const group = await generateGroup(user, { type: 'party', name: generateUUID() }); | ||||
| @@ -259,6 +221,34 @@ describe('GET /challenges/:challengeId/members', () => { | ||||
|     expect(resIds).to.eql(expectedIds.sort()); | ||||
|   }); | ||||
|  | ||||
|   it('supports using req.query.includeTasks in order to add challenge-related tasks of all members', async () => { | ||||
|     const group = await generateGroup(user, { type: 'party', name: generateUUID() }); | ||||
|     const challenge = await generateChallenge(user, group); | ||||
|     await user.post(`/challenges/${challenge._id}/join`); | ||||
|  | ||||
|     const usersToGenerate = []; | ||||
|     for (let i = 0; i < 8; i += 1) { | ||||
|       usersToGenerate.push(generateUser({ challenges: [challenge._id] })); | ||||
|     } | ||||
|     await Promise.all(usersToGenerate); | ||||
|     await user.post(`/tasks/challenge/${challenge._id}`, [{ type: 'habit', text: 'Some task' }]); | ||||
|     await user.post(`/tasks/challenge/${challenge._id}`, [{ type: 'daily', text: 'Some different task' }]); | ||||
|  | ||||
|     const res = await user.get(`/challenges/${challenge._id}/members?includeTasks=true`); | ||||
|     expect(res.length).to.equal(9); | ||||
|     res.forEach(member => { | ||||
|       expect(member).to.have.property('tasks'); | ||||
|       expect(member.tasks).to.be.an('array'); | ||||
|       expect(member.tasks).to.have.lengthOf(2); | ||||
|       member.tasks.forEach(task => { | ||||
|         expect(task).to.include.all.keys(['type', 'value', 'priority', 'text', '_id', 'userId']); | ||||
|         expect(task).to.not.have.any.keys(['tags', 'checklist']); | ||||
|         expect(task.challenge.id).to.be.equal(challenge._id); | ||||
|         expect(task.userId).to.be.equal(member._id); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   it('supports using req.query.search to get search members', async () => { | ||||
|     const group = await generateGroup(user, { type: 'party', name: generateUUID() }); | ||||
|     const challenge = await generateChallenge(user, group); | ||||
|   | ||||
| @@ -116,7 +116,6 @@ describe('GET /challenges/:challengeId/members/:memberId', () => { | ||||
|     }]); | ||||
|  | ||||
|     const memberProgress = await user.get(`/challenges/${challenge._id}/members/${user._id}`); | ||||
|     expect(memberProgress.tasks[0]).not.to.have.key('tags'); | ||||
|     expect(memberProgress.tasks[0].checklist).to.eql([]); | ||||
|     expect(memberProgress.tasks[0]).to.not.have.any.keys(['tags', 'checklist']); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -56,7 +56,7 @@ describe('GET challenges/user', () => { | ||||
|     }); | ||||
|     context('all challenges', () => { | ||||
|       it('should return challenges user has joined', async () => { | ||||
|         const challenges = await nonMember.get('/challenges/user'); | ||||
|         const challenges = await nonMember.get('/challenges/user?page=0'); | ||||
|  | ||||
|         const foundChallenge = _.find(challenges, { _id: challenge._id }); | ||||
|         expect(foundChallenge).to.exist; | ||||
| @@ -65,14 +65,14 @@ describe('GET challenges/user', () => { | ||||
|       }); | ||||
|  | ||||
|       it('should not return challenges a non-member has not joined', async () => { | ||||
|         const challenges = await nonMember.get('/challenges/user'); | ||||
|         const challenges = await nonMember.get('/challenges/user?page=0'); | ||||
|  | ||||
|         const foundChallenge2 = _.find(challenges, { _id: challenge2._id }); | ||||
|         expect(foundChallenge2).to.not.exist; | ||||
|       }); | ||||
|  | ||||
|       it('should return challenges user has created', async () => { | ||||
|         const challenges = await user.get('/challenges/user'); | ||||
|         const challenges = await user.get('/challenges/user?page=0'); | ||||
|  | ||||
|         const foundChallenge1 = _.find(challenges, { _id: challenge._id }); | ||||
|         expect(foundChallenge1).to.exist; | ||||
| @@ -85,7 +85,7 @@ describe('GET challenges/user', () => { | ||||
|       }); | ||||
|  | ||||
|       it('should return challenges in user\'s group', async () => { | ||||
|         const challenges = await member.get('/challenges/user'); | ||||
|         const challenges = await member.get('/challenges/user?page=0'); | ||||
|  | ||||
|         const foundChallenge1 = _.find(challenges, { _id: challenge._id }); | ||||
|         expect(foundChallenge1).to.exist; | ||||
| @@ -98,7 +98,7 @@ describe('GET challenges/user', () => { | ||||
|       }); | ||||
|  | ||||
|       it('should return newest challenges first', async () => { | ||||
|         let challenges = await user.get('/challenges/user'); | ||||
|         let challenges = await user.get('/challenges/user?page=0'); | ||||
|  | ||||
|         let foundChallengeIndex = _.findIndex(challenges, { _id: challenge2._id }); | ||||
|         expect(foundChallengeIndex).to.eql(0); | ||||
| @@ -106,7 +106,7 @@ describe('GET challenges/user', () => { | ||||
|         const newChallenge = await generateChallenge(user, publicGuild); | ||||
|         await user.post(`/challenges/${newChallenge._id}/join`); | ||||
|  | ||||
|         challenges = await user.get('/challenges/user'); | ||||
|         challenges = await user.get('/challenges/user?page=0'); | ||||
|  | ||||
|         foundChallengeIndex = _.findIndex(challenges, { _id: newChallenge._id }); | ||||
|         expect(foundChallengeIndex).to.eql(0); | ||||
| @@ -125,7 +125,7 @@ describe('GET challenges/user', () => { | ||||
|         const privateChallenge = await generateChallenge(groupLeader, group); | ||||
|         await groupLeader.post(`/challenges/${privateChallenge._id}/join`); | ||||
|  | ||||
|         const challenges = await nonMember.get('/challenges/user'); | ||||
|         const challenges = await nonMember.get('/challenges/user?page=0'); | ||||
|  | ||||
|         const foundChallenge = _.find(challenges, { _id: privateChallenge._id }); | ||||
|         expect(foundChallenge).to.not.exist; | ||||
| @@ -149,7 +149,7 @@ describe('GET challenges/user', () => { | ||||
|         }); | ||||
|         await groupLeader.post(`/challenges/${privateChallenge._id}/join`); | ||||
|  | ||||
|         const challenges = await nonMember.get('/challenges/user?categories=academics&owned=not_owned'); | ||||
|         const challenges = await nonMember.get('/challenges/user?page=0&categories=academics&owned=not_owned'); | ||||
|  | ||||
|         const foundChallenge = _.find(challenges, { _id: privateChallenge._id }); | ||||
|         expect(foundChallenge).to.not.exist; | ||||
| @@ -158,7 +158,7 @@ describe('GET challenges/user', () => { | ||||
|  | ||||
|     context('my challenges', () => { | ||||
|       it('should return challenges user has joined', async () => { | ||||
|         const challenges = await nonMember.get(`/challenges/user?member=${true}`); | ||||
|         const challenges = await nonMember.get(`/challenges/user?page=0&member=${true}`); | ||||
|  | ||||
|         const foundChallenge = _.find(challenges, { _id: challenge._id }); | ||||
|         expect(foundChallenge).to.exist; | ||||
| @@ -167,7 +167,7 @@ describe('GET challenges/user', () => { | ||||
|       }); | ||||
|  | ||||
|       it('should return challenges user has created', async () => { | ||||
|         const challenges = await user.get(`/challenges/user?member=${true}`); | ||||
|         const challenges = await user.get(`/challenges/user?page=0&member=${true}`); | ||||
|  | ||||
|         const foundChallenge1 = _.find(challenges, { _id: challenge._id }); | ||||
|         expect(foundChallenge1).to.exist; | ||||
| @@ -180,7 +180,7 @@ describe('GET challenges/user', () => { | ||||
|       }); | ||||
|  | ||||
|       it('should return challenges user has created if filter by owned', async () => { | ||||
|         const challenges = await user.get(`/challenges/user?member=${true}&owned=owned`); | ||||
|         const challenges = await user.get(`/challenges/user?member=${true}&owned=owned&page=0`); | ||||
|  | ||||
|         const foundChallenge1 = _.find(challenges, { _id: challenge._id }); | ||||
|         expect(foundChallenge1).to.exist; | ||||
| @@ -193,7 +193,7 @@ describe('GET challenges/user', () => { | ||||
|       }); | ||||
|  | ||||
|       it('should not return challenges user has created if filter by not owned', async () => { | ||||
|         const challenges = await user.get(`/challenges/user?owned=not_owned&member=${true}`); | ||||
|         const challenges = await user.get(`/challenges/user?page=0&owned=not_owned&member=${true}`); | ||||
|  | ||||
|         const foundChallenge1 = _.find(challenges, { _id: challenge._id }); | ||||
|         expect(foundChallenge1).to.not.exist; | ||||
| @@ -202,7 +202,7 @@ describe('GET challenges/user', () => { | ||||
|       }); | ||||
|  | ||||
|       it('should not return challenges in user groups', async () => { | ||||
|         const challenges = await member.get(`/challenges/user?member=${true}`); | ||||
|         const challenges = await member.get(`/challenges/user?page=0&member=${true}`); | ||||
|  | ||||
|         const foundChallenge1 = _.find(challenges, { _id: challenge._id }); | ||||
|         expect(foundChallenge1).to.not.exist; | ||||
| @@ -253,7 +253,7 @@ describe('GET challenges/user', () => { | ||||
|     }); | ||||
|  | ||||
|     it('should return official challenges first', async () => { | ||||
|       const challenges = await user.get('/challenges/user'); | ||||
|       const challenges = await user.get('/challenges/user?page=0'); | ||||
|  | ||||
|       const foundChallengeIndex = _.findIndex(challenges, { _id: officialChallenge._id }); | ||||
|       expect(foundChallengeIndex).to.eql(0); | ||||
| @@ -274,7 +274,7 @@ describe('GET challenges/user', () => { | ||||
|       const newChallenge = await generateChallenge(user, publicGuild); | ||||
|       await user.post(`/challenges/${newChallenge._id}/join`); | ||||
|  | ||||
|       challenges = await user.get('/challenges/user'); | ||||
|       challenges = await user.get('/challenges/user?page=0'); | ||||
|  | ||||
|       const foundChallengeIndex = _.findIndex(challenges, { _id: newChallenge._id }); | ||||
|       expect(foundChallengeIndex).to.eql(1); | ||||
| @@ -314,18 +314,12 @@ describe('GET challenges/user', () => { | ||||
|     it('returns public guilds filtered by category', async () => { | ||||
|       const categoryChallenge = await generateChallenge(user, guild, { categories }); | ||||
|       await user.post(`/challenges/${categoryChallenge._id}/join`); | ||||
|       const challenges = await user.get(`/challenges/user?categories=${categories[0].slug}`); | ||||
|       const challenges = await user.get(`/challenges/user?page=0&categories=${categories[0].slug}`); | ||||
|  | ||||
|       expect(challenges[0]._id).to.eql(categoryChallenge._id); | ||||
|       expect(challenges.length).to.eql(1); | ||||
|     }); | ||||
|  | ||||
|     it('does not page challenges if page parameter is absent', async () => { | ||||
|       const challenges = await user.get('/challenges/user'); | ||||
|  | ||||
|       expect(challenges.length).to.be.above(11); | ||||
|     }); | ||||
|  | ||||
|     it('paginates challenges', async () => { | ||||
|       const challenges = await user.get('/challenges/user?page=0'); | ||||
|       const challengesPaged = await user.get('/challenges/user?page=1&owned=owned'); | ||||
| @@ -335,7 +329,7 @@ describe('GET challenges/user', () => { | ||||
|     }); | ||||
|  | ||||
|     it('filters by owned', async () => { | ||||
|       const challenges = await member.get('/challenges/user?owned=owned'); | ||||
|       const challenges = await member.get('/challenges/user?page=0&owned=owned'); | ||||
|  | ||||
|       expect(challenges.length).to.eql(0); | ||||
|     }); | ||||
|   | ||||
| @@ -103,7 +103,15 @@ describe('POST /challenges/:challengeId/winner/:winnerId', () => { | ||||
|       await expect(winningUser.sync()).to.eventually.have.nested.property('achievements.challenges').to.include(challenge.name); | ||||
|       // 2 because winningUser just joined the challenge, which now awards an achievement | ||||
|       expect(winningUser.notifications.length).to.equal(2); | ||||
|       expect(winningUser.notifications[1].type).to.equal('WON_CHALLENGE'); | ||||
|  | ||||
|       const notif = winningUser.notifications[1]; | ||||
|       expect(notif.type).to.equal('WON_CHALLENGE'); | ||||
|       expect(notif.data).to.eql({ | ||||
|         id: challenge._id, | ||||
|         name: challenge.name, | ||||
|         prize: challenge.prize, | ||||
|         leader: challenge.leader, | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('gives winner gems as reward', async () => { | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { find } from 'lodash'; | ||||
| import moment from 'moment'; | ||||
| import nconf from 'nconf'; | ||||
| import { IncomingWebhook } from '@slack/client'; | ||||
| import { IncomingWebhook } from '@slack/webhook'; | ||||
| import { | ||||
|   generateUser, | ||||
|   translate as t, | ||||
| @@ -20,7 +20,7 @@ describe('POST /chat/:chatId/flag', () => { | ||||
|     admin = await generateUser({ balance: 1, 'contributor.admin': true }); | ||||
|     anotherUser = await generateUser({ 'auth.timestamps.created': moment().subtract(USER_AGE_FOR_FLAGGING + 1, 'days').toDate() }); | ||||
|     newUser = await generateUser({ 'auth.timestamps.created': moment().subtract(1, 'days').toDate() }); | ||||
|     sandbox.stub(IncomingWebhook.prototype, 'send'); | ||||
|     sandbox.stub(IncomingWebhook.prototype, 'send').returns(Promise.resolve()); | ||||
|  | ||||
|     group = await user.post('/groups', { | ||||
|       name: 'Test Guild', | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { IncomingWebhook } from '@slack/client'; | ||||
| import { IncomingWebhook } from '@slack/webhook'; | ||||
| import nconf from 'nconf'; | ||||
| import { v4 as generateUUID } from 'uuid'; | ||||
| import { | ||||
| @@ -133,7 +133,7 @@ describe('POST /chat', () => { | ||||
|   describe('shadow-mute user', () => { | ||||
|     beforeEach(() => { | ||||
|       sandbox.spy(email, 'sendTxn'); | ||||
|       sandbox.stub(IncomingWebhook.prototype, 'send'); | ||||
|       sandbox.stub(IncomingWebhook.prototype, 'send').returns(Promise.resolve()); | ||||
|     }); | ||||
|  | ||||
|     afterEach(() => { | ||||
| @@ -355,7 +355,7 @@ describe('POST /chat', () => { | ||||
|   context('banned slur', () => { | ||||
|     beforeEach(() => { | ||||
|       sandbox.spy(email, 'sendTxn'); | ||||
|       sandbox.stub(IncomingWebhook.prototype, 'send'); | ||||
|       sandbox.stub(IncomingWebhook.prototype, 'send').returns(Promise.resolve()); | ||||
|     }); | ||||
|  | ||||
|     afterEach(() => { | ||||
|   | ||||
| @@ -251,6 +251,29 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => { | ||||
|       expect(party.quest.members[partyMember._id]).to.not.exist; | ||||
|     }); | ||||
|  | ||||
|     it('prevents user from being removed if they are the quest owner', async () => { | ||||
|       const petQuest = 'whale'; | ||||
|       await partyMember.update({ | ||||
|         [`items.quests.${petQuest}`]: 1, | ||||
|       }); | ||||
|  | ||||
|       await partyMember.post(`/groups/${party._id}/quests/invite/${petQuest}`); | ||||
|       await partyLeader.post(`/groups/${party._id}/quests/accept`); | ||||
|  | ||||
|       await party.sync(); | ||||
|  | ||||
|       expect(party.quest.members[partyLeader._id]).to.be.true; | ||||
|       expect(party.quest.members[partyMember._id]).to.be.true; | ||||
|  | ||||
|       await party.sync(); | ||||
|  | ||||
|       expect(leader.post(`/groups/${party._id}/removeMember/${partyMember._id}`)) | ||||
|         .to.eventually.be.rejected.and.eql({ | ||||
|           code: 401, | ||||
|           text: t('cannotRemoveQuestOwner'), | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('sends email to user with rescinded invite', async () => { | ||||
|       await partyLeader.post(`/groups/${party._id}/removeMember/${partyInvitedUser._id}`); | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,45 @@ | ||||
| import { | ||||
|   generateUser, | ||||
| } from '../../../../../helpers/api-integration/v3'; | ||||
| import stripePayments from '../../../../../../website/server/libs/payments/stripe'; | ||||
| import common from '../../../../../../website/common'; | ||||
|  | ||||
| describe('payments - stripe - #createCheckoutSession', () => { | ||||
|   const endpoint = '/stripe/checkout-session'; | ||||
|   let user; const groupId = 'groupId'; | ||||
|   const gift = {}; const subKey = 'basic_3mo'; | ||||
|   const gemsBlock = '21gems'; const coupon = 'coupon'; | ||||
|   let stripeCreateCheckoutSessionStub; const sessionId = 'sessionId'; | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     user = await generateUser(); | ||||
|     stripeCreateCheckoutSessionStub = sinon | ||||
|       .stub(stripePayments, 'createCheckoutSession') | ||||
|       .resolves({ id: sessionId }); | ||||
|   }); | ||||
|  | ||||
|   afterEach(() => { | ||||
|     stripePayments.createCheckoutSession.restore(); | ||||
|   }); | ||||
|  | ||||
|   it('works', async () => { | ||||
|     const res = await user.post(endpoint, { | ||||
|       groupId, | ||||
|       gift, | ||||
|       sub: subKey, | ||||
|       gemsBlock, | ||||
|       coupon, | ||||
|     }); | ||||
|  | ||||
|     expect(res.sessionId).to.equal(sessionId); | ||||
|  | ||||
|     expect(stripeCreateCheckoutSessionStub).to.be.calledOnce; | ||||
|     expect(stripeCreateCheckoutSessionStub.args[0][0].user._id).to.eql(user._id); | ||||
|     expect(stripeCreateCheckoutSessionStub.args[0][0].groupId).to.eql(groupId); | ||||
|     expect(stripeCreateCheckoutSessionStub.args[0][0].gift).to.eql(gift); | ||||
|     expect(stripeCreateCheckoutSessionStub.args[0][0].sub) | ||||
|       .to.eql(common.content.subscriptionBlocks[subKey]); | ||||
|     expect(stripeCreateCheckoutSessionStub.args[0][0].gemsBlock).to.eql(gemsBlock); | ||||
|     expect(stripeCreateCheckoutSessionStub.args[0][0].coupon).to.eql(coupon); | ||||
|   }); | ||||
| }); | ||||
| @@ -1,79 +0,0 @@ | ||||
| import { | ||||
|   generateUser, | ||||
|   generateGroup, | ||||
| } from '../../../../../helpers/api-integration/v3'; | ||||
| import stripePayments from '../../../../../../website/server/libs/payments/stripe'; | ||||
|  | ||||
| describe('payments - stripe - #checkout', () => { | ||||
|   const endpoint = '/stripe/checkout'; | ||||
|   let user; let | ||||
|     group; | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     user = await generateUser(); | ||||
|   }); | ||||
|  | ||||
|   it('verifies credentials', async () => { | ||||
|     await expect(user.post( | ||||
|       `${endpoint}?gemsBlock=4gems`, | ||||
|       { id: 123 }, | ||||
|     )).to.eventually.be.rejected.and.include({ | ||||
|       code: 401, | ||||
|       error: 'Error', | ||||
|       // message: 'Invalid API Key provided: aaaabbbb********************1111', | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('success', () => { | ||||
|     let stripeCheckoutSubscriptionStub; | ||||
|  | ||||
|     beforeEach(async () => { | ||||
|       stripeCheckoutSubscriptionStub = sinon.stub(stripePayments, 'checkout').resolves({}); | ||||
|     }); | ||||
|  | ||||
|     afterEach(() => { | ||||
|       stripePayments.checkout.restore(); | ||||
|     }); | ||||
|  | ||||
|     it('creates a user subscription', async () => { | ||||
|       user = await generateUser({ | ||||
|         'profile.name': 'sender', | ||||
|         'purchased.plan.customerId': 'customer-id', | ||||
|         'purchased.plan.planId': 'basic_3mo', | ||||
|         'purchased.plan.lastBillingDate': new Date(), | ||||
|         balance: 2, | ||||
|       }); | ||||
|  | ||||
|       await user.post(endpoint); | ||||
|  | ||||
|       expect(stripeCheckoutSubscriptionStub).to.be.calledOnce; | ||||
|       expect(stripeCheckoutSubscriptionStub.args[0][0].user._id).to.eql(user._id); | ||||
|       expect(stripeCheckoutSubscriptionStub.args[0][0].groupId).to.eql(undefined); | ||||
|     }); | ||||
|  | ||||
|     it('creates a group subscription', async () => { | ||||
|       user = await generateUser({ | ||||
|         'profile.name': 'sender', | ||||
|         'purchased.plan.customerId': 'customer-id', | ||||
|         'purchased.plan.planId': 'basic_3mo', | ||||
|         'purchased.plan.lastBillingDate': new Date(), | ||||
|         balance: 2, | ||||
|       }); | ||||
|  | ||||
|       group = await generateGroup(user, { | ||||
|         name: 'test group', | ||||
|         type: 'guild', | ||||
|         privacy: 'public', | ||||
|         'purchased.plan.customerId': 'customer-id', | ||||
|         'purchased.plan.planId': 'basic_3mo', | ||||
|         'purchased.plan.lastBillingDate': new Date(), | ||||
|       }); | ||||
|  | ||||
|       await user.post(`${endpoint}?groupId=${group._id}`); | ||||
|  | ||||
|       expect(stripeCheckoutSubscriptionStub).to.be.calledOnce; | ||||
|       expect(stripeCheckoutSubscriptionStub.args[0][0].user._id).to.eql(user._id); | ||||
|       expect(stripeCheckoutSubscriptionStub.args[0][0].groupId).to.eql(group._id); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @@ -1,79 +1,31 @@ | ||||
| import { | ||||
|   generateUser, | ||||
|   generateGroup, | ||||
|   translate as t, | ||||
| } from '../../../../../helpers/api-integration/v3'; | ||||
| import stripePayments from '../../../../../../website/server/libs/payments/stripe'; | ||||
|  | ||||
| describe('payments - stripe - #subscribeEdit', () => { | ||||
|   const endpoint = '/stripe/subscribe/edit'; | ||||
|   let user; let | ||||
|     group; | ||||
|   let user; const groupId = 'groupId'; | ||||
|   let stripeEditSubscriptionStub; | ||||
|   const sessionId = 'sessionId'; | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     user = await generateUser(); | ||||
|     stripeEditSubscriptionStub = sinon | ||||
|       .stub(stripePayments, 'createEditCardCheckoutSession') | ||||
|       .resolves({ id: sessionId }); | ||||
|   }); | ||||
|  | ||||
|   it('verifies credentials', async () => { | ||||
|     await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ | ||||
|       code: 401, | ||||
|       error: 'NotAuthorized', | ||||
|       message: t('missingSubscription'), | ||||
|     }); | ||||
|   afterEach(() => { | ||||
|     stripePayments.createEditCardCheckoutSession.restore(); | ||||
|   }); | ||||
|  | ||||
|   describe('success', () => { | ||||
|     let stripeEditSubscriptionStub; | ||||
|   it('works', async () => { | ||||
|     const res = await user.post(endpoint, { groupId }); | ||||
|     expect(res.sessionId).to.equal(sessionId); | ||||
|  | ||||
|     beforeEach(async () => { | ||||
|       stripeEditSubscriptionStub = sinon.stub(stripePayments, 'editSubscription').resolves({}); | ||||
|     }); | ||||
|  | ||||
|     afterEach(() => { | ||||
|       stripePayments.editSubscription.restore(); | ||||
|     }); | ||||
|  | ||||
|     it('cancels a user subscription', async () => { | ||||
|       user = await generateUser({ | ||||
|         'profile.name': 'sender', | ||||
|         'purchased.plan.customerId': 'customer-id', | ||||
|         'purchased.plan.planId': 'basic_3mo', | ||||
|         'purchased.plan.lastBillingDate': new Date(), | ||||
|         balance: 2, | ||||
|       }); | ||||
|  | ||||
|       await user.post(endpoint); | ||||
|  | ||||
|       expect(stripeEditSubscriptionStub).to.be.calledOnce; | ||||
|       expect(stripeEditSubscriptionStub.args[0][0].user._id).to.eql(user._id); | ||||
|       expect(stripeEditSubscriptionStub.args[0][0].groupId).to.eql(undefined); | ||||
|     }); | ||||
|  | ||||
|     it('cancels a group subscription', async () => { | ||||
|       user = await generateUser({ | ||||
|         'profile.name': 'sender', | ||||
|         'purchased.plan.customerId': 'customer-id', | ||||
|         'purchased.plan.planId': 'basic_3mo', | ||||
|         'purchased.plan.lastBillingDate': new Date(), | ||||
|         balance: 2, | ||||
|       }); | ||||
|  | ||||
|       group = await generateGroup(user, { | ||||
|         name: 'test group', | ||||
|         type: 'guild', | ||||
|         privacy: 'public', | ||||
|         'purchased.plan.customerId': 'customer-id', | ||||
|         'purchased.plan.planId': 'basic_3mo', | ||||
|         'purchased.plan.lastBillingDate': new Date(), | ||||
|       }); | ||||
|  | ||||
|       await user.post(endpoint, { | ||||
|         groupId: group._id, | ||||
|       }); | ||||
|  | ||||
|       expect(stripeEditSubscriptionStub).to.be.calledOnce; | ||||
|       expect(stripeEditSubscriptionStub.args[0][0].user._id).to.eql(user._id); | ||||
|       expect(stripeEditSubscriptionStub.args[0][0].groupId).to.eql(group._id); | ||||
|     }); | ||||
|     expect(stripeEditSubscriptionStub).to.be.calledOnce; | ||||
|     expect(stripeEditSubscriptionStub.args[0][0].user._id).to.eql(user._id); | ||||
|     expect(stripeEditSubscriptionStub.args[0][0].groupId).to.eql(groupId); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -0,0 +1,30 @@ | ||||
| import { | ||||
|   generateUser, | ||||
| } from '../../../../../helpers/api-integration/v3'; | ||||
| import stripePayments from '../../../../../../website/server/libs/payments/stripe'; | ||||
|  | ||||
| describe('payments - stripe - #handleWebhooks', () => { | ||||
|   const endpoint = '/stripe/webhooks'; | ||||
|   let user; const body = '{"key": "val"}'; | ||||
|   let stripeHandleWebhooksStub; | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     user = await generateUser(); | ||||
|     stripeHandleWebhooksStub = sinon | ||||
|       .stub(stripePayments, 'handleWebhooks') | ||||
|       .resolves({}); | ||||
|   }); | ||||
|  | ||||
|   afterEach(() => { | ||||
|     stripePayments.handleWebhooks.restore(); | ||||
|   }); | ||||
|  | ||||
|   it('works', async () => { | ||||
|     const res = await user.post(endpoint, body); | ||||
|     expect(res).to.eql({}); | ||||
|  | ||||
|     expect(stripeHandleWebhooksStub).to.be.calledOnce; | ||||
|     expect(stripeHandleWebhooksStub.args[0][0].body).to.exist; | ||||
|     expect(stripeHandleWebhooksStub.args[0][0].headers).to.exist; | ||||
|   }); | ||||
| }); | ||||
| @@ -83,22 +83,6 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('does not issue invites if the user is of insufficient Level', async () => { | ||||
|       const LEVELED_QUEST = 'atom1'; | ||||
|       const LEVELED_QUEST_REQ = questScrolls[LEVELED_QUEST].lvl; | ||||
|       const leaderUpdate = {}; | ||||
|       leaderUpdate[`items.quests.${LEVELED_QUEST}`] = 1; | ||||
|       leaderUpdate['stats.lvl'] = LEVELED_QUEST_REQ - 1; | ||||
|  | ||||
|       await leader.update(leaderUpdate); | ||||
|  | ||||
|       await expect(leader.post(`/groups/${questingGroup._id}/quests/invite/${LEVELED_QUEST}`)).to.eventually.be.rejected.and.eql({ | ||||
|         code: 401, | ||||
|         error: 'NotAuthorized', | ||||
|         message: t('questLevelTooHigh', { level: LEVELED_QUEST_REQ }), | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('does not issue invites if a quest is already underway', async () => { | ||||
|       const QUEST_IN_PROGRESS = 'atom1'; | ||||
|       const leaderUpdate = {}; | ||||
| @@ -212,6 +196,18 @@ describe('POST /groups/:groupId/quests/invite/:questKey', () => { | ||||
|       expect(returnedGroup.chat[0]._meta).to.be.undefined; | ||||
|     }); | ||||
|  | ||||
|     it('successfully issues a quest invitation when quest level is higher than user level', async () => { | ||||
|       const LEVELED_QUEST = 'atom1'; | ||||
|       const LEVELED_QUEST_REQ = questScrolls[LEVELED_QUEST].lvl; | ||||
|       const leaderUpdate = {}; | ||||
|       leaderUpdate[`items.quests.${LEVELED_QUEST}`] = 1; | ||||
|       leaderUpdate['stats.lvl'] = LEVELED_QUEST_REQ - 1; | ||||
|  | ||||
|       await leader.update(leaderUpdate); | ||||
|  | ||||
|       await leader.post(`/groups/${questingGroup._id}/quests/invite/${LEVELED_QUEST}`); | ||||
|     }); | ||||
|  | ||||
|     context('sending quest activity webhooks', () => { | ||||
|       before(async () => { | ||||
|         await server.start(); | ||||
|   | ||||
| @@ -91,7 +91,9 @@ describe('POST /tasks/:taskId/move/to/:position', () => { | ||||
|  | ||||
|     const taskToMove = tasks[1]; | ||||
|     expect(taskToMove.text).to.equal('habit 2'); | ||||
|     const newOrder = await user.post(`/tasks/${tasks[1]._id}/move/to/-1`); | ||||
|     await user.post(`/tasks/${tasks[1]._id}/move/to/-1`); | ||||
|     await user.sync(); | ||||
|     const newOrder = user.tasksOrder.habits; | ||||
|     expect(newOrder[4]).to.equal(taskToMove._id); | ||||
|     expect(newOrder.length).to.equal(5); | ||||
|   }); | ||||
|   | ||||
| @@ -220,6 +220,18 @@ describe('POST /tasks/user', () => { | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   it('errors if todo due date supplied is an invalid date', async () => { | ||||
|     await expect(user.post('/tasks/user', { | ||||
|       type: 'todo', | ||||
|       text: 'todo text', | ||||
|       date: 'invalid date', | ||||
|     })).to.eventually.be.rejected.and.eql({ | ||||
|       code: 400, | ||||
|       error: 'BadRequest', | ||||
|       message: 'todo validation failed', | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   context('sending task activity webhooks', () => { | ||||
|     before(async () => { | ||||
|       await server.start(); | ||||
|   | ||||
							
								
								
									
										52
									
								
								test/api/v4/user/POST-user_unequip.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,52 @@ | ||||
| import { | ||||
|   generateUser, | ||||
| } from '../../../helpers/api-integration/v4'; | ||||
| import { UNEQUIP_EQUIPPED } from '../../../../website/common/script/ops/unequip'; | ||||
|  | ||||
| describe('POST /user/unequip', () => { | ||||
|   let user; | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     user = await generateUser({ | ||||
|       preferences: { | ||||
|         background: 'violet', | ||||
|       }, | ||||
|       items: { | ||||
|         currentMount: 'BearCub-Base', | ||||
|         currentPet: 'BearCub-Base', | ||||
|         gear: { | ||||
|           owned: { | ||||
|             weapon_warrior_0: true, | ||||
|             weapon_warrior_1: true, | ||||
|             weapon_warrior_2: true, | ||||
|             weapon_wizard_1: true, | ||||
|             weapon_wizard_2: true, | ||||
|             shield_base_0: true, | ||||
|             shield_warrior_1: true, | ||||
|           }, | ||||
|           equipped: { | ||||
|             weapon: 'weapon_warrior_2', | ||||
|             shield: 'shield_warrior_1', | ||||
|           }, | ||||
|           costume: { | ||||
|             weapon: 'weapon_warrior_2', | ||||
|             shield: 'shield_warrior_1', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       stats: { gp: 200 }, | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   // More tests in common code unit tests | ||||
|  | ||||
|   context('Gear', () => { | ||||
|     it('should unequip all battle gear items', async () => { | ||||
|       await user.post(`/user/unequip/${UNEQUIP_EQUIPPED}`); | ||||
|       await user.sync(); | ||||
|  | ||||
|       expect(user.items.gear.equipped.weapon).to.eq('weapon_base_0'); | ||||
|       expect(user.items.gear.equipped.shield).to.eq('shield_base_0'); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										100
									
								
								test/common/ops/unequip.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,100 @@ | ||||
| /* eslint-disable camelcase */ | ||||
|  | ||||
| import { | ||||
|   generateUser, | ||||
| } from '../../helpers/common.helper'; | ||||
| import { | ||||
|   UNEQUIP_ALL, | ||||
|   UNEQUIP_BACKGROUND, | ||||
|   UNEQUIP_COSTUME, | ||||
|   UNEQUIP_EQUIPPED, | ||||
|   UNEQUIP_PET_MOUNT, | ||||
|   unEquipByType, | ||||
| } from '../../../website/common/script/ops/unequip'; | ||||
|  | ||||
| describe('shared.ops.unequip', () => { | ||||
|   let user; | ||||
|  | ||||
|   beforeEach(() => { | ||||
|     user = generateUser({ | ||||
|       preferences: { | ||||
|         background: 'violet', | ||||
|       }, | ||||
|       items: { | ||||
|         currentMount: 'BearCub-Base', | ||||
|         currentPet: 'BearCub-Base', | ||||
|         gear: { | ||||
|           owned: { | ||||
|             weapon_warrior_0: true, | ||||
|             weapon_warrior_1: true, | ||||
|             weapon_warrior_2: true, | ||||
|             weapon_wizard_1: true, | ||||
|             weapon_wizard_2: true, | ||||
|             shield_base_0: true, | ||||
|             shield_warrior_1: true, | ||||
|           }, | ||||
|           equipped: { | ||||
|             weapon: 'weapon_warrior_2', | ||||
|             shield: 'shield_warrior_1', | ||||
|           }, | ||||
|           costume: { | ||||
|             weapon: 'weapon_warrior_2', | ||||
|             shield: 'shield_warrior_1', | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|       stats: { gp: 200 }, | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   context('Gear', () => { | ||||
|     it('should unequip all battle gear items', () => { | ||||
|       unEquipByType(user, { params: { type: UNEQUIP_EQUIPPED } }); | ||||
|  | ||||
|       expect(user.items.gear.equipped.weapon).to.eq('weapon_base_0'); | ||||
|       expect(user.items.gear.equipped.shield).to.eq('shield_base_0'); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   context('Costume', () => { | ||||
|     it('should unequip all costume items', () => { | ||||
|       unEquipByType(user, { params: { type: UNEQUIP_COSTUME } }); | ||||
|  | ||||
|       expect(user.items.gear.costume.weapon).to.eq('weapon_base_0'); | ||||
|       expect(user.items.gear.costume.shield).to.eq('shield_base_0'); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   context('Pet and Mount', () => { | ||||
|     it('should unequip Pet and Mount', () => { | ||||
|       unEquipByType(user, { params: { type: UNEQUIP_PET_MOUNT } }); | ||||
|  | ||||
|       expect(user.items.currentMount).to.eq(''); | ||||
|       expect(user.items.currentPet).to.eq(''); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   context('Background', () => { | ||||
|     it('should unequip Background', () => { | ||||
|       unEquipByType(user, { params: { type: UNEQUIP_BACKGROUND } }); | ||||
|  | ||||
|       expect(user.preferences.background).to.eq(''); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   context('All Items', () => { | ||||
|     it('should unequip all Items', () => { | ||||
|       unEquipByType(user, { params: { type: UNEQUIP_ALL } }); | ||||
|  | ||||
|       expect(user.items.gear.equipped.weapon).to.eq('weapon_base_0'); | ||||
|       expect(user.items.gear.equipped.shield).to.eq('shield_base_0'); | ||||
|  | ||||
|       expect(user.items.gear.costume.weapon).to.eq('weapon_base_0'); | ||||
|       expect(user.items.gear.costume.shield).to.eq('shield_base_0'); | ||||
|  | ||||
|       expect(user.items.currentMount).to.eq(''); | ||||
|       expect(user.items.currentPet).to.eq(''); | ||||
|       expect(user.preferences.background).to.eq(''); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @@ -41,6 +41,7 @@ function _requestMaker (user, method, additionalSets = {}) { | ||||
|       || route.indexOf('/amazon') === 0 | ||||
|       || route.indexOf('/stripe') === 0 | ||||
|       || route.indexOf('/qr-code') === 0 | ||||
|       || route.indexOf('/analytics') === 0 | ||||
|     ) { | ||||
|       url += `${route}`; | ||||
|     } else { | ||||
|   | ||||
| @@ -50,4 +50,5 @@ function loadStories () { | ||||
|   req.keys().forEach(filename => req(filename)); | ||||
| } | ||||
|  | ||||
|  | ||||
| configure(loadStories, module); | ||||
|   | ||||
							
								
								
									
										2055
									
								
								website/client/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						| @@ -18,20 +18,20 @@ | ||||
|     "@storybook/addon-links": "^5.3.19", | ||||
|     "@storybook/addon-notes": "^5.3.21", | ||||
|     "@storybook/vue": "^5.3.19", | ||||
|     "@vue/cli-plugin-babel": "^4.5.7", | ||||
|     "@vue/cli-plugin-eslint": "^4.5.7", | ||||
|     "@vue/cli-plugin-router": "^4.5.7", | ||||
|     "@vue/cli-plugin-unit-mocha": "^4.5.7", | ||||
|     "@vue/cli-service": "^4.5.7", | ||||
|     "@vue/cli-plugin-babel": "^4.5.9", | ||||
|     "@vue/cli-plugin-eslint": "^4.5.9", | ||||
|     "@vue/cli-plugin-router": "^4.5.9", | ||||
|     "@vue/cli-plugin-unit-mocha": "^4.5.9", | ||||
|     "@vue/cli-service": "^4.5.9", | ||||
|     "@vue/test-utils": "1.0.0-beta.29", | ||||
|     "amplitude-js": "^7.2.2", | ||||
|     "axios": "^0.19.2", | ||||
|     "amplitude-js": "^7.3.3", | ||||
|     "axios": "^0.21.1", | ||||
|     "axios-progress-bar": "^1.2.0", | ||||
|     "babel-eslint": "^10.1.0", | ||||
|     "bootstrap": "^4.5.2", | ||||
|     "bootstrap-vue": "^2.17.3", | ||||
|     "bootstrap": "^4.5.3", | ||||
|     "bootstrap-vue": "^2.21.2", | ||||
|     "chai": "^4.1.2", | ||||
|     "core-js": "^3.6.5", | ||||
|     "core-js": "^3.8.2", | ||||
|     "eslint": "^6.8.0", | ||||
|     "eslint-config-habitrpg": "^6.2.0", | ||||
|     "eslint-plugin-mocha": "^5.3.0", | ||||
| @@ -43,22 +43,22 @@ | ||||
|     "jquery": "^3.5.1", | ||||
|     "lodash": "^4.17.20", | ||||
|     "moment": "^2.29.1", | ||||
|     "nconf": "^0.10.0", | ||||
|     "sass": "^1.27.0", | ||||
|     "nconf": "^0.11.0", | ||||
|     "sass": "^1.32.0", | ||||
|     "sass-loader": "^8.0.2", | ||||
|     "smartbanner.js": "^1.16.0", | ||||
|     "svg-inline-loader": "^0.8.2", | ||||
|     "svg-url-loader": "^6.0.0", | ||||
|     "svgo": "^1.3.2", | ||||
|     "svgo-loader": "^2.2.1", | ||||
|     "uuid": "^8.3.1", | ||||
|     "validator": "^13.1.17", | ||||
|     "uuid": "^8.3.2", | ||||
|     "validator": "^13.5.2", | ||||
|     "vue": "^2.6.12", | ||||
|     "vue-cli-plugin-storybook": "^0.6.1", | ||||
|     "vue-mugen-scroll": "^0.2.6", | ||||
|     "vue-router": "^3.4.6", | ||||
|     "vue-router": "^3.4.9", | ||||
|     "vue-template-compiler": "^2.6.12", | ||||
|     "vuedraggable": "^2.24.1", | ||||
|     "vuedraggable": "^2.24.3", | ||||
|     "vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#153d339e4dbebb73733658aeda1d5b7fcc55b0a0", | ||||
|     "webpack": "^4.44.2" | ||||
|   } | ||||
|   | ||||
| Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB | 
| Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB | 
| Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB | 
| Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB | 
| Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB | 
| Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB | 
| Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB | 
| Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB | 
| Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB | 
| Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB | 
| Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB | 
| Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB | 
| Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB | 
| Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB | 
| Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.7 KiB | 
| Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB | 
| Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB | 
| Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB | 
| Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB | 
| Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB | 
| Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB | 
| Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB | 
| Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB | 
| Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB | 
| Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.9 KiB | 
| Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB | 
| Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB | 
| Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB | 
| After Width: | Height: | Size: 21 KiB | 
| After Width: | Height: | Size: 9.8 KiB | 
| Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB | 
| Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								website/client/public/static/npc/normal/npc_justin.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 18 KiB | 
| Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB | 
| Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB | 
| Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.9 KiB | 
| Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB | 
| Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB | 
| Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.6 KiB | 
| Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB | 
| Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB | 
| Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |