From a27f8d670575b9cb3fd3a3994358f68c9ba879f7 Mon Sep 17 00:00:00 2001 From: Will Date: Fri, 19 Jun 2015 00:38:46 -0700 Subject: [PATCH 1/9] add profile info and computed stats --- website/src/controllers/user.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/website/src/controllers/user.js b/website/src/controllers/user.js index 8ad45d5e6d..4afd909ed6 100644 --- a/website/src/controllers/user.js +++ b/website/src/controllers/user.js @@ -117,12 +117,23 @@ api.score = function(req, res, next) { }, saved.toJSON().stats)); // Webhooks + var userData = _.pick(user.toJSON(), ['_id', '_tmp', 'stats', 'profile']); // raaaage + userData.stats.toNextLevel = shared.tnl(user.stats.lvl); + userData.stats.maxHealth = 50; + userData.stats.maxMP = user._statsComputed.maxMP; + console.log(user._statsComputed); _.each(user.preferences.webhooks, function(h){ if (!h.enabled || !validator.isURL(h.url)) return; request.post({ url: h.url, //form: {task: task, delta: delta, user: _.pick(user, ['stats', '_tmp'])} // this is causing "Maximum Call Stack Exceeded" - body: {direction:direction, task: task, delta: delta, user: _.pick(user, ['_id', 'stats', '_tmp'])}, json:true + body: { + direction:direction, + task: task, + delta: delta, + user: userData + }, + json:true }); }); From f3829c8ce4ac6f32c49b8a98a3976c83f3fcaba4 Mon Sep 17 00:00:00 2001 From: Will Date: Fri, 19 Jun 2015 08:11:56 -0700 Subject: [PATCH 2/9] code cleanup and commenting --- website/src/controllers/user.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/website/src/controllers/user.js b/website/src/controllers/user.js index 4afd909ed6..d1e3345238 100644 --- a/website/src/controllers/user.js +++ b/website/src/controllers/user.js @@ -117,21 +117,24 @@ api.score = function(req, res, next) { }, saved.toJSON().stats)); // Webhooks - var userData = _.pick(user.toJSON(), ['_id', '_tmp', 'stats', 'profile']); // raaaage + + // Select character data to send + var userData = _.pick(user.toJSON(), ['_id', '_tmp', 'stats', 'profile']); // user.toJSON to copy-by-value userData.stats.toNextLevel = shared.tnl(user.stats.lvl); userData.stats.maxHealth = 50; userData.stats.maxMP = user._statsComputed.maxMP; - console.log(user._statsComputed); + + // for each webhook _.each(user.preferences.webhooks, function(h){ if (!h.enabled || !validator.isURL(h.url)) return; request.post({ url: h.url, //form: {task: task, delta: delta, user: _.pick(user, ['stats', '_tmp'])} // this is causing "Maximum Call Stack Exceeded" body: { - direction:direction, - task: task, + direction: direction, // direction of change + task: task, // task object delta: delta, - user: userData + user: userData // character/profile data }, json:true }); From b3ae6eef17802d2392c52b482b0f2bd0dfb370db Mon Sep 17 00:00:00 2001 From: Will Date: Fri, 3 Jul 2015 13:16:43 -0700 Subject: [PATCH 3/9] maxHEalth uses new shared.maxHealth --- website/src/controllers/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/user.js b/website/src/controllers/user.js index d1e3345238..12244d735f 100644 --- a/website/src/controllers/user.js +++ b/website/src/controllers/user.js @@ -121,7 +121,7 @@ api.score = function(req, res, next) { // Select character data to send var userData = _.pick(user.toJSON(), ['_id', '_tmp', 'stats', 'profile']); // user.toJSON to copy-by-value userData.stats.toNextLevel = shared.tnl(user.stats.lvl); - userData.stats.maxHealth = 50; + userData.stats.maxHealth = shared.maxHealth; userData.stats.maxMP = user._statsComputed.maxMP; // for each webhook From 4e5d630a87540d023541b43064fa3e210d982297 Mon Sep 17 00:00:00 2001 From: Will Date: Fri, 3 Jul 2015 14:05:50 -0700 Subject: [PATCH 4/9] remove profile object from webhooks --- website/src/controllers/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/user.js b/website/src/controllers/user.js index 12244d735f..0c95d49d99 100644 --- a/website/src/controllers/user.js +++ b/website/src/controllers/user.js @@ -119,7 +119,7 @@ api.score = function(req, res, next) { // Webhooks // Select character data to send - var userData = _.pick(user.toJSON(), ['_id', '_tmp', 'stats', 'profile']); // user.toJSON to copy-by-value + var userData = _.pick(user.toJSON(), ['_id', '_tmp', 'stats']); // user.toJSON to copy-by-value userData.stats.toNextLevel = shared.tnl(user.stats.lvl); userData.stats.maxHealth = shared.maxHealth; userData.stats.maxMP = user._statsComputed.maxMP; From 7245ffa94a587e510b23f374c87c6c349724ed7a Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 30 Jul 2015 00:21:41 -0700 Subject: [PATCH 5/9] refactor user.toJSON() --- website/src/controllers/user.js | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/website/src/controllers/user.js b/website/src/controllers/user.js index 0c95d49d99..b42eab8200 100644 --- a/website/src/controllers/user.js +++ b/website/src/controllers/user.js @@ -109,32 +109,39 @@ api.score = function(req, res, next) { user.save(function(err,saved){ if (err) return next(err); + + // Convert Mongoose model to JS object and store copy of stats object to send to client + var userStats = saved.toJSON().stats; + // TODO this should be return {_v,task,stats,_tmp}, instead of merging everything togther at top-level response // However, this is the most commonly used API route, and changing it will mess with all 3rd party consumers. Bad idea :( res.json(200, _.extend({ delta: delta, _tmp: user._tmp - }, saved.toJSON().stats)); + }, userStats)); // Webhooks - // Select character data to send - var userData = _.pick(user.toJSON(), ['_id', '_tmp', 'stats']); // user.toJSON to copy-by-value - userData.stats.toNextLevel = shared.tnl(user.stats.lvl); - userData.stats.maxHealth = shared.maxHealth; - userData.stats.maxMP = user._statsComputed.maxMP; + var userData = { + _id: user._id, + _tmp: user._tmp, + stats: _.extend({}, userStats, { // send stats as well as exp tnl, max health, and max mp + toNextLevel: shared.tnl(user.stats.lvl), + maxHealth: shared.maxHealth, + maxMP: user._statsComputed.maxMP + }) + }; - // for each webhook _.each(user.preferences.webhooks, function(h){ if (!h.enabled || !validator.isURL(h.url)) return; request.post({ url: h.url, //form: {task: task, delta: delta, user: _.pick(user, ['stats', '_tmp'])} // this is causing "Maximum Call Stack Exceeded" body: { - direction: direction, // direction of change - task: task, // task object + direction: direction, + task: task, delta: delta, - user: userData // character/profile data + user: userData }, json:true }); From 1001cc88890a9a7081287e03cc35e9cd68e3f71d Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Thu, 30 Jul 2015 08:19:22 -0500 Subject: [PATCH 6/9] Refactor webhook section to be cleaner Added note to put webhook into own module --- website/src/controllers/user.js | 35 +++++++++++++++++---------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/website/src/controllers/user.js b/website/src/controllers/user.js index b42eab8200..333c9f28a1 100644 --- a/website/src/controllers/user.js +++ b/website/src/controllers/user.js @@ -109,34 +109,32 @@ api.score = function(req, res, next) { user.save(function(err,saved){ if (err) return next(err); - - // Convert Mongoose model to JS object and store copy of stats object to send to client - var userStats = saved.toJSON().stats; - - // TODO this should be return {_v,task,stats,_tmp}, instead of merging everything togther at top-level response - // However, this is the most commonly used API route, and changing it will mess with all 3rd party consumers. Bad idea :( - res.json(200, _.extend({ - delta: delta, - _tmp: user._tmp - }, userStats)); - // Webhooks + var userStats = saved.stats.toJSON(); + + var resJsonData = _.extend({ delta: delta, _tmp: user._tmp }, userStats); + res.json(200, resJsonData); + + // ==================================================== + // Webhooks - @TODO: Webhooks should be their own module + // ==================================================== + var extendedStats = _.extend(userStats, { + toNextLevel: shared.tnl(user.stats.lvl), + maxHealth: shared.maxHealth, + maxMP: user._statsComputed.maxMP + }); var userData = { _id: user._id, _tmp: user._tmp, - stats: _.extend({}, userStats, { // send stats as well as exp tnl, max health, and max mp - toNextLevel: shared.tnl(user.stats.lvl), - maxHealth: shared.maxHealth, - maxMP: user._statsComputed.maxMP - }) + stats: extendedStats }; _.each(user.preferences.webhooks, function(h){ if (!h.enabled || !validator.isURL(h.url)) return; + request.post({ url: h.url, - //form: {task: task, delta: delta, user: _.pick(user, ['stats', '_tmp'])} // this is causing "Maximum Call Stack Exceeded" body: { direction: direction, task: task, @@ -146,6 +144,9 @@ api.score = function(req, res, next) { json:true }); }); + // ==================================================== + // End Webhooks section + // ==================================================== if ( (!task.challenge || !task.challenge.id || task.challenge.broken) // If it's a challenge task, sync the score. Do it in the background, we've already sent down a response and the user doesn't care what happens back there From b567a0d9a6866770d48bc900093e9ec916f5f20b Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 1 Aug 2015 14:03:45 -0500 Subject: [PATCH 7/9] Refactor webhook and add tests --- test/server_side/controllers/user.test.js | 555 ++++++++++++++++++++++ test/server_side/webhooks.test.js | 139 ++++++ website/src/controllers/user.js | 66 ++- website/src/webhook.js | 24 + 4 files changed, 749 insertions(+), 35 deletions(-) create mode 100644 test/server_side/controllers/user.test.js create mode 100644 test/server_side/webhooks.test.js create mode 100644 website/src/webhook.js diff --git a/test/server_side/controllers/user.test.js b/test/server_side/controllers/user.test.js new file mode 100644 index 0000000000..ec3b447630 --- /dev/null +++ b/test/server_side/controllers/user.test.js @@ -0,0 +1,555 @@ +var sinon = require('sinon'); +var chai = require("chai") +chai.use(require("sinon-chai")) +var expect = chai.expect +var rewire = require('rewire'); + +var userController = rewire('../../../website/src/controllers/user'); + +describe('User Controller', function() { + + describe('score', function() { + var req, res, user; + + beforeEach(function() { + user = { + _id: 'user-id', + _tmp: { + drop: true + }, + _statsComputed: { + maxMP: 100 + }, + ops: { + score: sinon.stub(), + addTask: sinon.stub() + }, + stats: { + lvl: 10, + hp: 43, + mp: 50 + }, + preferences: { + webhooks: { + 'some-id': { + sort: 0, + id: 'some-id', + enabled: true, + url: 'http://example.org/endpoint' + } + } + }, + save: sinon.stub(), + tasks: { + task_id: { + id: 'task_id', + type: 'todo' + } + } + }; + req = { + language: 'en', + params: { + id: 'task_id', + direction: 'up' + } + }; + res = { + locals: { user: user }, + json: sinon.spy() + }; + }); + + context('early return conditions', function() { + it('sends an error when no id is provided', function() { + delete req.params.id; + + userController.score(req, res); + + expect(res.json).to.be.calledOnce; + expect(res.json).to.be.calledWith(400, {err: ':id required'}); + }); + + it('sends an error when no direction is provided', function() { + delete req.params.direction; + + userController.score(req, res); + + expect(res.json).to.be.calledOnce; + expect(res.json).to.be.calledWith(400, {err: ":direction must be 'up' or 'down'"}); + }); + + it('calls next when direction is "unlink"', function() { + req.params.direction = 'unlink'; + var nextSpy = sinon.spy(); + + userController.score(req, res, nextSpy); + + expect(nextSpy).to.be.calledOnce; + }); + + it('calls next when direction is "sort"', function() { + req.params.direction = 'sort'; + var nextSpy = sinon.spy(); + + userController.score(req, res, nextSpy); + + expect(nextSpy).to.be.calledOnce; + }); + }); + + context('task exists', function() { + it('sets todo to completed if direction is "up"', function() { + req.params.direction = 'up'; + req.params.id = 'todo_id'; + user.tasks.todo_id = { + _id: 'todo_id', + type: 'todo', + completed: false + }; + + userController.score(req, res); + + expect(user.tasks.todo_id.completed).to.eql(true); + }); + + it('sets todo to not completed if direction is "down"', function() { + req.params.direction = 'down'; + req.params.id = 'todo_id'; + user.tasks.todo_id = { + _id: 'todo_id', + type: 'todo', + completed: true + }; + + userController.score(req, res); + + expect(user.tasks.todo_id.completed).to.eql(false); + }); + + it('sets daily to completed if direction is "up"', function() { + req.params.direction = 'up'; + req.params.id = 'daily_id'; + user.tasks.daily_id = { + _id: 'daily_id', + type: 'daily', + completed: false + }; + + userController.score(req, res); + + expect(user.tasks.daily_id.completed).to.eql(true); + }); + + it('sets daily to not completed if direction is "down"', function() { + req.params.direction = 'down'; + req.params.id = 'daily_id'; + user.tasks.daily_id = { + _id: 'daily_id', + type: 'daily', + completed: true + }; + + userController.score(req, res); + + expect(user.tasks.daily_id.completed).to.eql(false); + }); + }); + + context('task does not exist', function() { + it('creates the task', function() { + user.ops.addTask.returns({id: 'an-id-that-does-not-exist'}); + + req.params.id = 'an-id-that-does-not-exist-yet'; + req.body = { + type: 'todo', + text: 'some todo', + notes: 'some notes' + } + + userController.score(req, res); + + expect(user.ops.addTask).to.be.calledOnce; + expect(user.ops.addTask).to.be.calledWith({ + body: { + id: 'an-id-that-does-not-exist-yet', + completed: true, + type: 'todo', + text: 'some todo', + notes: 'some notes' + } + }); + }); + + it('provides a default note if no note is provided', function() { + user.ops.addTask.returns({id: 'an-id-that-does-not-exist'}); + + req.params.id = 'an-id-that-does-not-exist-yet'; + req.body = { + type: 'todo', + text: 'some todo' + } + + userController.score(req, res); + + expect(user.ops.addTask).to.be.calledOnce; + expect(user.ops.addTask).to.be.calledWith({ + body: { + id: 'an-id-that-does-not-exist-yet', + completed: true, + type: 'todo', + text: 'some todo', + notes: "This task was created by a third-party service. Feel free to edit, it won't harm the connection to that service. Additionally, multiple services may piggy-back off this task." + } + }); + }); + + it('todo task is completed if direction is "up"', function() { + user.ops.addTask.returns({id: 'an-id-that-does-not-exist'}); + + req.params.direction = 'up'; + req.params.id = 'an-id-that-does-not-exist-yet'; + req.body = { + type: 'todo', + text: 'some todo', + notes: 'some notes' + } + + userController.score(req, res); + + expect(user.ops.addTask).to.be.calledOnce; + expect(user.ops.addTask).to.be.calledWith({ + body: { + id: 'an-id-that-does-not-exist-yet', + completed: true, + type: 'todo', + text: 'some todo', + notes: 'some notes' + } + }); + }); + + it('todo task is not completed if direction is "down"', function() { + user.ops.addTask.returns({id: 'an-id-that-does-not-exist'}); + + req.params.direction = 'down'; + req.params.id = 'an-id-that-does-not-exist-yet'; + req.body = { + type: 'todo', + text: 'some todo', + notes: 'some notes' + } + + userController.score(req, res); + + expect(user.ops.addTask).to.be.calledOnce; + expect(user.ops.addTask).to.be.calledWith({ + body: { + id: 'an-id-that-does-not-exist-yet', + completed: false, + type: 'todo', + text: 'some todo', + notes: 'some notes' + } + }); + }); + + it('daily task is completed if direction is "up"', function() { + user.ops.addTask.returns({id: 'an-id-that-does-not-exist'}); + + req.params.direction = 'up'; + req.params.id = 'an-id-that-does-not-exist-yet'; + req.body = { + type: 'daily', + text: 'some daily', + notes: 'some notes' + } + + userController.score(req, res); + + expect(user.ops.addTask).to.be.calledOnce; + expect(user.ops.addTask).to.be.calledWith({ + body: { + id: 'an-id-that-does-not-exist-yet', + completed: true, + type: 'daily', + text: 'some daily', + notes: 'some notes' + } + }); + }); + + it('daily task is not completed if direction is "down"', function() { + user.ops.addTask.returns({id: 'an-id-that-does-not-exist'}); + + req.params.direction = 'down'; + req.params.id = 'an-id-that-does-not-exist-yet'; + req.body = { + type: 'daily', + text: 'some daily', + notes: 'some notes' + } + + userController.score(req, res); + + expect(user.ops.addTask).to.be.calledOnce; + expect(user.ops.addTask).to.be.calledWith({ + body: { + id: 'an-id-that-does-not-exist-yet', + completed: false, + type: 'daily', + text: 'some daily', + notes: 'some notes' + } + }); + }); + }); + + context('whether task exists or it does not exist', function() { + it('calls user.ops.score', function() { + userController.score(req, res); + + expect(user.ops.score).to.be.calledOnce; + expect(user.ops.score).to.be.calledWith({ + params: {id: 'task_id', direction: 'up'}, + language: 'en' + }); + }); + + it('saves user', function() { + userController.score(req, res); + + expect(user.save).to.be.calledOnce; + }); + }); + + context('user.save callback', function() { + var savedUser; + beforeEach(function() { + savedUser = { + stats: user.stats + } + + user.save.yields(null, savedUser); + + user.ops.score.returns(1.5); + }); + + it('calls next if saving yields an error', function() { + var nextSpy = sinon.spy(); + user.save.yields('an error'); + + userController.score(req, res, nextSpy); + + expect(nextSpy).to.be.calledOnce; + expect(nextSpy).to.be.calledWith('an error'); + }); + + it('sends some user data with res.json', function() { + userController.score(req, res); + + expect(res.json).to.be.calledOnce; + expect(res.json).to.be.calledWith(200, { + delta: 1.5, + _tmp: user._tmp, + lvl: 10, + hp: 43, + mp: 50 + }); + }); + + it('sends webhooks', function() { + var webhook = require('../../../website/src/webhook'); + sinon.spy(webhook, 'sendTaskWebhook'); + + userController.score(req, res); + + expect(webhook.sendTaskWebhook).to.be.calledOnce; + expect(webhook.sendTaskWebhook).to.be.calledWith( + user.preferences.webhooks, + { + task: { + delta: 1.5, + details: { completed: true, id: "task_id", type: "todo" }, + direction: "up" + }, + user: { + _id: "user-id", + _tmp: { drop: true }, + stats: { hp: 43, lvl: 10, maxHealth: 50, maxMP: 100, mp: 50, toNextLevel: 260 } + } + } + ); + }); + }); + + context('save callback dealing with non challenge tasks', function() { + var Challenge = require('../../../website/src/models/challenge').model; + + beforeEach(function() { + user.save.yields(null, user); + sinon.stub(Challenge, 'findById'); + req.params.id = 'non_active_challenge_task'; + user.tasks.non_active_challenge_task = { + id: 'non_active_challenge_task', + challenge: { id: 'some-id' }, + type: 'todo' + } + }); + + afterEach(function() { + Challenge.findById.restore(); + }); + + it('returns early if not a challenge', function() { + delete user.tasks.non_active_challenge_task.challenge; + + userController.score(req, res); + + expect(Challenge.findById).to.not.be.called; + }); + + it('returns early if no challenge id', function() { + delete user.tasks.non_active_challenge_task.challenge.id; + + userController.score(req, res); + + expect(Challenge.findById).to.not.be.called; + }); + + it('returns early if challenge is broken', function() { + user.tasks.non_active_challenge_task.challenge.broken = true; + + userController.score(req, res); + + expect(Challenge.findById).to.not.be.called; + }); + + it('returns early if task is a reward', function() { + user.tasks.non_active_challenge_task.type = 'reward'; + + userController.score(req, res); + + expect(Challenge.findById).to.not.be.called; + }); + + it('calls next if there is an error looking up challenge', function() { + Challenge.findById.yields('an error'); + var nextSpy = sinon.spy(); + + userController.score(req, res, nextSpy); + + expect(Challenge.findById).to.be.calledOnce; + expect(nextSpy).to.be.calledOnce; + expect(nextSpy).to.be.calledWith('an error'); + }); + }); + + context('save callback dealing with challenge tasks', function() { + var Challenge = require('../../../website/src/models/challenge').model; + var chal; + + beforeEach(function() { + chal = { + id: 'id', + tasks: { + active_challenge_task: { id: 'active_challenge_task', value: 1 } + }, + syncToUser: sinon.spy(), + save: sinon.spy() + }; + user.save.yields(null, user); + user.ops.score.returns(1.4); + req.params.id = 'active_challenge_task'; + user.tasks.active_challenge_task = { + id: 'active_challenge_task', + challenge: { id: 'challenge_id' }, + type: 'todo' + }; + + sinon.stub(Challenge, 'findById'); + }); + + afterEach(function() { + Challenge.findById.restore(); + }); + + xit('sets challenge as broken if no challenge can be found', function() { + Challenge.findById.yields(null, null); + + userController.score(req, res); + + expect(Challenge.findById).to.be.calledOnce; + expect(user.tasks.active_challenge_task.challenge.broken).to.eql('CHALLENGE_DELETED'); + }); + + it('notifies user if task has been deleted from challenge', function() { + delete chal.tasks.active_challenge_task; + Challenge.findById.yields(null, chal); + + userController.score(req, res); + + expect(Challenge.findById).to.be.calledOnce; + expect(chal.syncToUser).to.be.calledOnce; + }); + + it('changes task value by delta', function() { + Challenge.findById.yields(null, chal); + + userController.score(req, res); + + expect(Challenge.findById).to.be.calledOnce; + expect(chal.tasks.active_challenge_task.value).to.be.eql(2.4); + }); + + it('adds history if task is a habit', function() { + chal.tasks.active_challenge_task = { + id: 'active_challenge_task', + type: 'habit', + value: 1, + history: [{value: 1, date: 1234}] + }; + + Challenge.findById.yields(null, chal); + + userController.score(req, res); + + expect(Challenge.findById).to.be.calledOnce; + + var historyEvent = chal.tasks.active_challenge_task.history[1]; + + expect(historyEvent.value).to.eql(2.4); + expect(historyEvent.date).to.be.closeTo(+new Date, 10); + }); + + it('adds history if task is a daily', function() { + chal.tasks.active_challenge_task = { + id: 'active_challenge_task', + type: 'daily', + value: 1, + history: [{value: 1, date: 1234}] + }; + + Challenge.findById.yields(null, chal); + + userController.score(req, res); + + expect(Challenge.findById).to.be.calledOnce; + + var historyEvent = chal.tasks.active_challenge_task.history[1]; + + expect(historyEvent.value).to.eql(2.4); + expect(historyEvent.date).to.be.closeTo(+new Date, 10); + }); + + it('saves the challenge data', function() { + Challenge.findById.yields(null, chal); + + userController.score(req, res); + + expect(Challenge.findById).to.be.calledOnce; + expect(chal.save).to.be.calledOnce; + }); + }); + }); +}); diff --git a/test/server_side/webhooks.test.js b/test/server_side/webhooks.test.js new file mode 100644 index 0000000000..596e441620 --- /dev/null +++ b/test/server_side/webhooks.test.js @@ -0,0 +1,139 @@ +var sinon = require('sinon'); +var chai = require("chai") +chai.use(require("sinon-chai")) +var expect = chai.expect +var rewire = require('rewire'); + +var webhook = rewire('../../website/src/webhook'); + +describe('webhooks', function() { + var postSpy; + + beforeEach(function() { + postSpy = sinon.stub(); + webhook.__set__('request.post', postSpy); + }); + + describe('sendTaskWebhook', function() { + var task = { + details: { _id: 'task-id' }, + delta: 1.4, + direction: 'up' + }; + + var data = { + task: task, + user: { _id: 'user-id' } + }; + + it('does not send if no webhook endpoints exist', function() { + var webhooks = { }; + + webhook.sendTaskWebhook(webhooks, data); + + expect(postSpy).to.not.be.called; + }); + + it('does not send if no webhooks are enabled', function() { + var webhooks = { + 'some-id': { + sort: 0, + id: 'some-id', + enabled: false, + url: 'http://example.org/endpoint' + } + }; + + webhook.sendTaskWebhook(webhooks, data); + + expect(postSpy).to.not.be.called; + }); + + it('does not send if webhook url is not valid', function() { + var webhooks = { + 'some-id': { + sort: 0, + id: 'some-id', + enabled: true, + url: 'http://malformedurl/endpoint' + } + }; + + webhook.sendTaskWebhook(webhooks, data); + + expect(postSpy).to.not.be.called; + }); + + it('sends task direction, task, task delta, and abridged user data', function() { + var webhooks = { + 'some-id': { + sort: 0, + id: 'some-id', + enabled: true, + url: 'http://example.org/endpoint' + } + }; + + webhook.sendTaskWebhook(webhooks, data); + + expect(postSpy).to.be.calledOnce; + expect(postSpy).to.be.calledWith({ + url: 'http://example.org/endpoint', + body: { + direction: 'up', + task: { _id: 'task-id' }, + delta: 1.4, + user: { + _id: 'user-id' + } + }, + json: true + }); + }); + + it('sends a post request for each webhook endpoint', function() { + var webhooks = { + 'some-id': { + sort: 0, + id: 'some-id', + enabled: true, + url: 'http://example.org/endpoint' + }, + 'second-webhook': { + sort: 1, + id: 'second-webhook', + enabled: true, + url: 'http://example.com/2/endpoint' + } + }; + + webhook.sendTaskWebhook(webhooks, data); + + expect(postSpy).to.be.calledTwice; + expect(postSpy).to.be.calledWith({ + url: 'http://example.org/endpoint', + body: { + direction: 'up', + task: { _id: 'task-id' }, + delta: 1.4, + user: { + _id: 'user-id' + } + }, + json: true + }); + expect(postSpy).to.be.calledWith({ + url: 'http://example.com/2/endpoint', + body: { + direction: 'up', + task: { _id: 'task-id' }, + delta: 1.4, + user: { + _id: 'user-id' + } + }, + json: true + }); + }); + }); +}); diff --git a/website/src/controllers/user.js b/website/src/controllers/user.js index 333c9f28a1..b238e4bc09 100644 --- a/website/src/controllers/user.js +++ b/website/src/controllers/user.js @@ -16,8 +16,7 @@ var logging = require('./../logging'); var acceptablePUTPaths; var api = module.exports; var qs = require('qs'); -var request = require('request'); -var validator = require('validator'); +var webhook = require('../webhook'); // api.purchase // Shared.ops @@ -110,43 +109,15 @@ api.score = function(req, res, next) { user.save(function(err,saved){ if (err) return next(err); - var userStats = saved.stats.toJSON(); + var userStats = _.cloneDeep(saved.stats); var resJsonData = _.extend({ delta: delta, _tmp: user._tmp }, userStats); res.json(200, resJsonData); - // ==================================================== - // Webhooks - @TODO: Webhooks should be their own module - // ==================================================== - var extendedStats = _.extend(userStats, { - toNextLevel: shared.tnl(user.stats.lvl), - maxHealth: shared.maxHealth, - maxMP: user._statsComputed.maxMP - }); - - var userData = { - _id: user._id, - _tmp: user._tmp, - stats: extendedStats - }; - - _.each(user.preferences.webhooks, function(h){ - if (!h.enabled || !validator.isURL(h.url)) return; - - request.post({ - url: h.url, - body: { - direction: direction, - task: task, - delta: delta, - user: userData - }, - json:true - }); - }); - // ==================================================== - // End Webhooks section - // ==================================================== + var webhookData = _generateWebhookTaskData( + task, direction, delta, userStats, user + ); + webhook.sendTaskWebhook(user.preferences.webhooks, webhookData); if ( (!task.challenge || !task.challenge.id || task.challenge.broken) // If it's a challenge task, sync the score. Do it in the background, we've already sent down a response and the user doesn't care what happens back there @@ -634,3 +605,28 @@ api.batchUpdate = function(req, res, next) { } }); }; + +function _generateWebhookTaskData(task, direction, delta, stats, user) { + var extendedStats = _.extend(stats, { + toNextLevel: shared.tnl(user.stats.lvl), + maxHealth: shared.maxHealth, + maxMP: user._statsComputed.maxMP + }); + + var userData = { + _id: user._id, + _tmp: user._tmp, + stats: extendedStats + }; + + var taskData = { + details: task, + direction: direction, + delta: delta + } + + return { + task: taskData, + user: userData + } +} diff --git a/website/src/webhook.js b/website/src/webhook.js new file mode 100644 index 0000000000..847c9e08b3 --- /dev/null +++ b/website/src/webhook.js @@ -0,0 +1,24 @@ +var _ = require('lodash'); +var request = require('request'); +var validator = require('validator'); + +function sendTaskWebhook(webhooks, data) { + _.each(webhooks, function(hook){ + if (!hook.enabled || !validator.isURL(hook.url)) return; + + request.post({ + url: hook.url, + body: { + direction: data.task.direction, + task: data.task.details, + delta: data.task.delta, + user: data.user + }, + json: true + }); + }); +} + +module.exports = { + sendTaskWebhook: sendTaskWebhook +}; From c7e751d662f7a0c44887ad8d6866f1466b71f7b5 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 1 Aug 2015 14:04:33 -0500 Subject: [PATCH 8/9] Adjsut formatting --- website/src/controllers/user.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/website/src/controllers/user.js b/website/src/controllers/user.js index b238e4bc09..a9cf67299b 100644 --- a/website/src/controllers/user.js +++ b/website/src/controllers/user.js @@ -23,14 +23,14 @@ var webhook = require('../webhook'); api.getContent = function(req, res, next) { var language = 'en'; - if(typeof req.query.language != 'undefined') + if (typeof req.query.language != 'undefined') language = req.query.language.toString(); //|| 'en' in i18n var content = _.cloneDeep(shared.content); var walk = function(obj, lang){ _.each(obj, function(item, key, source){ - if(_.isPlainObject(item) || _.isArray(item)) return walk(item, lang); - if(_.isFunction(item) && item.i18nLangFunc) source[key] = item(lang); + if (_.isPlainObject(item) || _.isArray(item)) return walk(item, lang); + if (_.isFunction(item) && item.i18nLangFunc) source[key] = item(lang); }); } walk(content, language); @@ -123,7 +123,7 @@ api.score = function(req, res, next) { (!task.challenge || !task.challenge.id || task.challenge.broken) // If it's a challenge task, sync the score. Do it in the background, we've already sent down a response and the user doesn't care what happens back there || (task.type == 'reward') // we don't want to update the reward GP cost ) return clearMemory(); - Challenge.findById(task.challenge.id, 'habits dailys todos rewards', function(err, chal){ + Challenge.findById(task.challenge.id, 'habits dailys todos rewards', function(err, chal) { if (err) return next(err); if (!chal) { task.challenge.broken = 'CHALLENGE_DELETED'; @@ -136,6 +136,7 @@ api.score = function(req, res, next) { chal.syncToUser(user); return clearMemory(); } + t.value += delta; if (t.type == 'habit' || t.type == 'daily') t.history.push({value: t.value, date: +new Date}); @@ -493,9 +494,9 @@ api.sessionPartyInvite = function(req,res,next){ return cb(); } - if(group.type == 'guild'){ + if (group.type == 'guild'){ inv.guilds.push(req.session.partyInvite); - }else{ + } else{ //req.body.type in 'guild', 'party' inv.party = req.session.partyInvite; } @@ -592,7 +593,7 @@ api.batchUpdate = function(req, res, next) { res.json(200, {_tmp: {drop: response._tmp.drop}, _v: response._v}); // Fetch full user object - }else if(response.wasModified){ + } else if (response.wasModified){ // Preen 3-day past-completed To-Dos from Angular & mobile app response.todos = _.where(response.todos, function(t) { return !t.completed || (t.challenge && t.challenge.id) || moment(t.dateCompleted).isAfter(moment().subtract({days:3})); @@ -600,7 +601,7 @@ api.batchUpdate = function(req, res, next) { res.json(200, response); // return only the version number - }else{ + } else{ res.json(200, {_v: response._v}); } }); From a960f5ef72fa9117a8d3d5e2beed5c805e6343d5 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Sat, 1 Aug 2015 16:08:54 -0500 Subject: [PATCH 9/9] Revert back to toJSON() --- website/src/controllers/user.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/src/controllers/user.js b/website/src/controllers/user.js index a9cf67299b..89ea68ef7b 100644 --- a/website/src/controllers/user.js +++ b/website/src/controllers/user.js @@ -106,11 +106,10 @@ api.score = function(req, res, next) { } var delta = user.ops.score({params:{id:task.id, direction:direction}, language: req.language}); - user.save(function(err,saved){ + user.save(function(err, saved){ if (err) return next(err); - var userStats = _.cloneDeep(saved.stats); - + var userStats = saved.toJSON().stats; var resJsonData = _.extend({ delta: delta, _tmp: user._tmp }, userStats); res.json(200, resJsonData); @@ -123,6 +122,7 @@ api.score = function(req, res, next) { (!task.challenge || !task.challenge.id || task.challenge.broken) // If it's a challenge task, sync the score. Do it in the background, we've already sent down a response and the user doesn't care what happens back there || (task.type == 'reward') // we don't want to update the reward GP cost ) return clearMemory(); + Challenge.findById(task.challenge.id, 'habits dailys todos rewards', function(err, chal) { if (err) return next(err); if (!chal) {