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