From 66a675c5b0637db06fe70271f96f4890f9047436 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 6 Dec 2015 17:29:14 +0100 Subject: [PATCH 01/29] simplify some tests, replace tags when updating tags (like for checklist) --- .../v3/integration/tasks/POST-tasks.test.js | 16 +++---------- .../v3/integration/tasks/PUT-tasks_id.test.js | 24 ++++--------------- website/src/controllers/api-v3/tasks.js | 7 ++++++ website/src/models/task.js | 4 ++-- 4 files changed, 17 insertions(+), 34 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index e7e72e1389..b8da4a32d3 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -50,19 +50,9 @@ describe('POST /tasks', () => { }); }); - it('ignores setting history field'); - - it('ignores setting createdAt field'); - - it('ignores setting updatedAt field'); - - it('ignores setting challenge field'); - - it('ignores setting completed field'); - - it('ignores setting streak field'); - - it('ignores setting dateCompleted field'); + it(`ignores setting userId, history, createdAt, + updatedAt, challenge, completed, streak, + dateCompleted fields`); it('ignores invalid fields'); }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index dc7143decf..815d8f1368 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -22,25 +22,9 @@ describe('PUT /tasks/:id', () => { // task = createdTask }); - it('ignores setting type field'); - - it('ignores setting userId field'); - - it('ignores setting history field'); - - it('ignores setting createdAt field'); - - it('ignores setting updatedAt field'); - - it('ignores setting challenge field'); - - it('ignores setting value field'); - - it('ignores setting completed field'); - - it('ignores setting streak field'); - - it('ignores setting dateCompleted field'); + it(`ignores setting _id, type, userId, history, createdAt, + updatedAt, challenge, completed, streak, + dateCompleted fields`); it('ignores invalid fields'); }); @@ -97,6 +81,7 @@ describe('PUT /tasks/:id', () => { }); it('can update checklists'); // Can it? + it('can update tags'); // Can it? }); context('dailys', () => { @@ -129,6 +114,7 @@ describe('PUT /tasks/:id', () => { }); it('can update checklists'); // Can it? + it('can update tags'); // Can it? it('updates repeat, even if frequency is set to daily'); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 509aafbe02..8a32f66194 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -174,6 +174,13 @@ api.updateTask = { delete req.body.checklist; task.checklist = req.body.checklist; } + + // If tags are updated -> replace the original ones + if (req.body.tags) { + delete req.body.tags; + task.tags = req.body.tags; + } + // TODO merge goes deep into objects, it's ok? // TODO also check that array and mixed fields are updated correctly without marking modified _.merge(task, Tasks.Task.sanitizeUpdate(req.body)); diff --git a/website/src/models/task.js b/website/src/models/task.js index 77cd0cfa2f..3ba4f21b22 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -46,13 +46,13 @@ TaskSchema.plugin(baseModel, { }); // A list of additional fields that cannot be set on creation (but can be set on updare) -let noCreate = ['completed']; +let noCreate = ['completed']; // TODO completed should be removed for updates too? TaskSchema.statics.sanitizeCreate = function sanitizeCreate (createObj) { return Task.sanitize(createObj, noCreate); // eslint-disable-line no-use-before-define }; // A list of additional fields that cannot be updated (but can be set on creation) -let noUpdate = ['_id', 'type']; // TODO should prevent changes to checlist.*.id +let noUpdate = ['_id', 'type']; TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { return Task.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define }; From af1946dd7c396b8041420efed7d82b8d99eba9a0 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 6 Dec 2015 18:03:51 +0100 Subject: [PATCH 02/29] starts implementing scoreTask --- website/src/controllers/api-v3/tasks.js | 50 ++++++++++++++++++++++++- website/src/models/user.js | 2 +- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 8a32f66194..b9d9cc4519 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -1,10 +1,12 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import webhook from '../../libs/api-v3/webhook'; import * as Tasks from '../../models/task'; import { NotFound, NotAuthorized, BadRequest, } from '../../libs/api-v3/errors'; +import shared from '../../../../common'; import Q from 'q'; import _ from 'lodash'; @@ -191,6 +193,31 @@ api.updateTask = { }, }; +function _generateWebhookTaskData (task, direction, delta, stats, user) { + let extendedStats = _.extend(stats, { + toNextLevel: shared.tnl(user.stats.lvl), + maxHealth: shared.maxHealth, + maxMP: user._statsComputed.maxMP, // TODO refactor as method not getter + }); + + let userData = { + _id: user._id, + _tmp: user._tmp, + stats: extendedStats, + }; + + let taskData = { + details: task, + direction, + delta, + }; + + return { + task: taskData, + user: userData, + }; +} + /** * @api {put} /tasks/score/:taskId/:direction Score a task * @apiVersion 3.0.0 @@ -214,6 +241,7 @@ api.scoreTask = { if (validationErrors) return next(validationErrors); let user = res.locals.user; + let direction = req.params.direction; Tasks.Task.findOne({ _id: req.params.taskId, @@ -221,8 +249,28 @@ api.scoreTask = { }).exec() .then((task) => { if (!task) throw new NotFound(res.t('taskNotFound')); + + if (task.type === 'daily' || task.type === 'todo') { + task.completed = direction === 'up'; + } + + let delta = user.ops.score({params: {id: task._id, direction}, language: req.language}); + + return Q.all([ + user.save(), + task.save(), + ]).then((results) => { + let savedUser = results[0]; + + let userStats = savedUser.toJSON().stats; + let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats); + res.respond(200, resJsonData); + + webhook.sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); + + // TODO sync challenge + }); }) - .then(() => res.respond(200, {})) // TODO what to return .catch(next); }, }; diff --git a/website/src/models/user.js b/website/src/models/user.js index 03f6a132a6..0810bbfa62 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -373,7 +373,7 @@ export let schema = new Schema({ toolbarCollapsed: {type: Boolean, default: false}, background: String, displayInviteToPartyWhenPartyIs1: {type: Boolean, default: true}, - webhooks: {type: Schema.Types.Mixed, default: {}}, + webhooks: {type: Schema.Types.Mixed, default: {}}, // TODO array? and proper controller... unless VersionError becomes problematic // For the following fields make sure to use strict comparison when searching for falsey values (=== false) // As users who didn't login after these were introduced may have them undefined/null emailNotifications: { From 0272a36bac90d49326ae266b6f680c335c93ac4d Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 7 Dec 2015 14:22:54 +0100 Subject: [PATCH 03/29] finish implementing tests for POST-tasks.test.js --- .../v3/integration/tasks/POST-tasks.test.js | 193 ++++++++++++++++-- .../src/middlewares/api-v3/errorHandler.js | 2 +- 2 files changed, 180 insertions(+), 15 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index b8da4a32d3..395721032d 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -37,9 +37,24 @@ describe('POST /tasks', () => { }); }); - it('returns an error if req.body.text is absent'); + it('returns an error if req.body.text is absent', () => { + return expect(api.post('/tasks', { + type: 'habit', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + }); + }); - it('ignores setting userId field'); + it('ignores setting userId field', () => { + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + userId: 123, + }).then((task) => { + expect(task.userId).to.equal(user._id); + }); + }); it('automatically sets "task.userId" to user\'s uuid', () => { return api.post('/tasks', { @@ -52,9 +67,39 @@ describe('POST /tasks', () => { it(`ignores setting userId, history, createdAt, updatedAt, challenge, completed, streak, - dateCompleted fields`); + dateCompleted fields`, () => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + userId: 123, + history: [123], + createdAt: 'yesterday', + updatedAt: 'tomorrow', + challenge: 'no', + completed: true, + streak: 25, + dateCompleted: 'never', + }).then((task) => { + expect(task.userId).to.equal(user._id); + expect(task.history).to.eql([]); + expect(task.createdAt).not.to.equal('yesterday'); + expect(task.updatedAt).not.to.equal('tomorrow'); + expect(task.challenge).not.to.equal('no'); + expect(task.completed).to.equal(false); + expect(task.streak).to.equal(0); + expect(task.streak).not.to.equal('never'); + }); + }); - it('ignores invalid fields'); + it('ignores invalid fields', () => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + notValid: true, + }).then((task) => { + expect(task).not.to.have.property('notValid'); + }); + }); }); context('habits', () => { @@ -75,9 +120,28 @@ describe('POST /tasks', () => { }); }); - it('defaults to setting up and down to true'); + it('defaults to setting up and down to true', () => { + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + notes: 1976, + }).then((task) => { + expect(task.up).to.eql(true); + expect(task.down).to.eql(true); + }); + }); - it('cannot create checklists'); + it('cannot create checklists', () => { + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + checklist: [ + {_id: 123, completed: false, text: 'checklist'}, + ], + }).then((task) => { + expect(task).not.to.have.property('checklist'); + }); + }); }); context('todos', () => { @@ -94,7 +158,22 @@ describe('POST /tasks', () => { }); }); - it('can create checklists'); + it('can create checklists', () => { + return api.post('/tasks', { + text: 'test todo', + type: 'todo', + checklist: [ + {completed: false, text: 'checklist'}, + ], + }).then((task) => { + expect(task.checklist).to.be.an('array'); + expect(task.checklist.length).to.eql(1); + expect(task.checklist[0]).to.be.an('object'); + expect(task.checklist[0].text).to.eql('checklist'); + expect(task.checklist[0].completed).to.eql(false); + expect(task.checklist[0]._id).to.be.a('string'); + }); + }); }); context('dailys', () => { @@ -119,13 +198,74 @@ describe('POST /tasks', () => { }); }); - it('defaults to a weekly frequency, with every day set'); + it('defaults to a weekly frequency, with every day set', () => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + }).then((task) => { + expect(task.frequency).to.eql('weekly'); + expect(task.everyX).to.eql(1); + expect(task.repeat).to.eql({ + m: true, + t: true, + w: true, + th: true, + f: true, + s: true, + su: true, + }); + }); + }); - it('allows repeat field to be configured'); + it('allows repeat field to be configured', () => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + repeat: { + m: false, + w: false, + su: false, + }, + }).then((task) => { + expect(task.repeat).to.eql({ + m: false, + t: true, + w: false, + th: true, + f: true, + s: true, + su: false, + }); + }); + }); - it('defaults startDate to today'); + it('defaults startDate to today', () => { + let today = (new Date()).getDay(); - it('can create checklists'); + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + }).then((task) => { + expect((new Date(task.startDate)).getDay()).to.eql(today); + }); + }); + + it('can create checklists', () => { + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + checklist: [ + {completed: false, text: 'checklist'}, + ], + }).then((task) => { + expect(task.checklist).to.be.an('array'); + expect(task.checklist.length).to.eql(1); + expect(task.checklist[0]).to.be.an('object'); + expect(task.checklist[0].text).to.eql('checklist'); + expect(task.checklist[0].completed).to.eql(false); + expect(task.checklist[0]._id).to.be.a('string'); + }); + }); }); context('rewards', () => { @@ -144,10 +284,35 @@ describe('POST /tasks', () => { }); }); - it('defaults to a 0 value'); + it('defaults to a 0 value', () => { + return api.post('/tasks', { + text: 'test reward', + type: 'reward', + }).then((task) => { + expect(task.value).to.eql(0); + }); + }); - it('requires value to be coerced into a number'); + it('requires value to be coerced into a number', () => { + return api.post('/tasks', { + text: 'test reward', + type: 'reward', + value: "10", + }).then((task) => { + expect(task.value).to.eql(10); + }); + }); - it('cannot create checklists'); + it('cannot create checklists', () => { + return api.post('/tasks', { + text: 'test reward', + type: 'reward', + checklist: [ + {_id: 123, completed: false, text: 'checklist'}, + ], + }).then((task) => { + expect(task).not.to.have.property('checklist'); + }); + }); }); }); diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index e0ad096d1c..f3b4f65cdb 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -48,7 +48,7 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l // Handle mongoose validation errors if (err.name === 'ValidationError') { - responseErr = new BadRequest(err.message); + responseErr = new BadRequest(err.message); // TODO standard message? translate? responseErr.errors = map(err.errors, (mongooseErr) => { return { message: mongooseErr.message, From 3c4491606ba5ae14d90083087f18cae229b01630 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 7 Dec 2015 21:00:15 +0100 Subject: [PATCH 04/29] finish PUT-tasks_id tests and fix some edge bugs --- .../v3/integration/tasks/POST-tasks.test.js | 11 +- .../v3/integration/tasks/PUT-tasks_id.test.js | 170 ++++++++++++++++-- website/src/controllers/api-v3/tasks.js | 13 +- 3 files changed, 163 insertions(+), 31 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks.test.js b/test/api/v3/integration/tasks/POST-tasks.test.js index 395721032d..09ca58be01 100644 --- a/test/api/v3/integration/tasks/POST-tasks.test.js +++ b/test/api/v3/integration/tasks/POST-tasks.test.js @@ -43,16 +43,7 @@ describe('POST /tasks', () => { })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - }); - }); - - it('ignores setting userId field', () => { - return api.post('/tasks', { - text: 'test habit', - type: 'habit', - userId: 123, - }).then((task) => { - expect(task.userId).to.equal(user._id); + message: 'habit validation failed', }); }); diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 815d8f1368..7ea2440beb 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -3,6 +3,7 @@ import { requester, translate as t, } from '../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; describe('PUT /tasks/:id', () => { let user, api; @@ -14,22 +15,56 @@ describe('PUT /tasks/:id', () => { }); }); - context('validates params', () => { + xcontext('validates params', () => { let task; beforeEach(() => { - // create sample task - // task = createdTask + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + }).then((createdTask) => { + task = createdTask; + }); }); it(`ignores setting _id, type, userId, history, createdAt, updatedAt, challenge, completed, streak, - dateCompleted fields`); + dateCompleted fields`, () => { + api.put('/tasks/' + task._id, { + _id: 123, + type: 'daily', + userId: 123, + history: [123], + createdAt: 'yesterday', + updatedAt: 'tomorrow', + challenge: 'no', + completed: true, + streak: 25, + dateCompleted: 'never', + }).then((savedTask) => { + expect(savedTask._id).to.equal(task._id); + expect(savedTask.type).to.equal(task.type); + expect(savedTask.userId).to.equal(user._id); + expect(savedTask.history).to.eql([]); + expect(savedTask.createdAt).not.to.equal('yesterday'); + expect(savedTask.updatedAt).not.to.equal('tomorrow'); + expect(savedTask.challenge).not.to.equal('no'); + expect(savedTask.completed).to.equal(false); + expect(savedTask.streak).to.equal(0); + expect(savedTask.streak).not.to.equal('never'); + }); + }); - it('ignores invalid fields'); + it('ignores invalid fields', () => { + api.put('/tasks/' + task._id, { + notValid: true, + }).then((savedTask) => { + expect(savedTask.notValid).to.be.a('undefined'); + }); + }); }); - context('habits', () => { + xcontext('habits', () => { let habit; beforeEach(() => { @@ -57,7 +92,7 @@ describe('PUT /tasks/:id', () => { }); }); - context('todos', () => { + xcontext('todos', () => { let todo; beforeEach(() => { @@ -80,8 +115,38 @@ describe('PUT /tasks/:id', () => { }); }); - it('can update checklists'); // Can it? - it('can update tags'); // Can it? + it('can update checklists (replace it)', () => { + return api.put(`/tasks/${todo._id}`, { + checklist: [ + {text: 123, completed: false}, + {text: 456, completed: true}, + ] + }).then((savedTodo) => { + return api.put(`/tasks/${todo._id}`, { + checklist: [ + {text: 789, completed: false}, + ] + }); + }).then((savedTodo2) => { + expect(savedTodo2.checklist.length).to.equal(1); + expect(savedTodo2.checklist[0].text).to.equal("789"); + expect(savedTodo2.checklist[0].completed).to.equal(false); + }); + }); + + it('can update tags (replace them)', () => { + let finalUUID = generateUUID(); + return api.put(`/tasks/${todo._id}`, { + tags: [generateUUID(), generateUUID()], + }).then((savedTodo) => { + return api.put(`/tasks/${todo._id}`, { + tags: [finalUUID] + }); + }).then((savedTodo2) => { + expect(savedTodo2.tags.length).to.equal(1); + expect(savedTodo2.tags[0]).to.equal(finalUUID); + }); + }); }); context('dailys', () => { @@ -113,17 +178,84 @@ describe('PUT /tasks/:id', () => { }); }); - it('can update checklists'); // Can it? - it('can update tags'); // Can it? + it('can update checklists (replace it)', () => { + return api.put(`/tasks/${daily._id}`, { + checklist: [ + {text: 123, completed: false}, + {text: 456, completed: true}, + ] + }).then((savedDaily) => { + return api.put(`/tasks/${daily._id}`, { + checklist: [ + {text: 789, completed: false}, + ] + }); + }).then((savedDaily2) => { + expect(savedDaily2.checklist.length).to.equal(1); + expect(savedDaily2.checklist[0].text).to.equal("789"); + expect(savedDaily2.checklist[0].completed).to.equal(false); + }); + }); - it('updates repeat, even if frequency is set to daily'); + it('can update tags (replace them)', () => { + let finalUUID = generateUUID(); + return api.put(`/tasks/${daily._id}`, { + tags: [generateUUID(), generateUUID()], + }).then((savedDaily) => { + return api.put(`/tasks/${daily._id}`, { + tags: [finalUUID] + }); + }).then((savedDaily2) => { + expect(savedDaily2.tags.length).to.equal(1); + expect(savedDaily2.tags[0]).to.equal(finalUUID); + }); + }); - it('updates everyX, even if frequency is set to weekly'); + it('updates repeat, even if frequency is set to daily', () => { + return api.put(`/tasks/${daily._id}`, { + frequency: 'daily', + }).then((savedDaily) => { + return api.put(`/tasks/${daily._id}`, { + repeat: { + m: false, + su: false + } + }); + }).then((savedDaily2) => { + expect(savedDaily2.repeat).to.eql({ + m: false, + t: true, + w: true, + th: true, + f: true, + s: true, + su: false, + }); + }); + }); - it('defaults startDate to today if none date object is passed in'); + it('updates everyX, even if frequency is set to weekly', () => { + return api.put(`/tasks/${daily._id}`, { + frequency: 'weekly', + }).then((savedDaily) => { + return api.put(`/tasks/${daily._id}`, { + everyX: 5, + }); + }).then((savedDaily2) => { + expect(savedDaily2.everyX).to.eql(5); + }); + }); + + it('defaults startDate to today if none date object is passed in', () => { + return api.put(`/tasks/${daily._id}`, { + frequency: 'weekly', + }).then((savedDaily2) => { + expect((new Date(savedDaily2.startDate)).getDay()).to.eql((new Date()).getDay()); + }); + }); }); - context('rewards', () => { + xcontext('rewards', () => { let reward; beforeEach(() => { @@ -149,6 +281,12 @@ describe('PUT /tasks/:id', () => { }); }); - it('requires value to be coerced into a number'); + it('requires value to be coerced into a number', () => { + return api.put(`/tasks/${reward._id}`, { + value: "100", + }).then((task) => { + expect(task.value).to.eql(100); + }); + }); }); }); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index b9d9cc4519..f8ccd207d6 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -173,19 +173,22 @@ api.updateTask = { // If checklist is updated -> replace the original one if (req.body.checklist) { - delete req.body.checklist; task.checklist = req.body.checklist; + delete req.body.checklist; } // If tags are updated -> replace the original ones if (req.body.tags) { - delete req.body.tags; task.tags = req.body.tags; + delete req.body.tags; } - // TODO merge goes deep into objects, it's ok? - // TODO also check that array and mixed fields are updated correctly without marking modified - _.merge(task, Tasks.Task.sanitizeUpdate(req.body)); + // TODO we have to convert task to an object because otherwise thigns doesn't get merged correctly, very bad for performances + // TODO regarding comment above make sure other models with nested fields are using this trick too + _.assign(task, _.merge(task.toObject(), Tasks.Task.sanitizeUpdate(req.body))); + // TODO console.log(task.modifiedPaths(), task.toObject().repeat === tep) + // repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject() + // see https://github.com/Automattic/mongoose/issues/2749 return task.save(); }) .then((savedTask) => res.respond(200, savedTask)) From 237be08a619aa655b7f247dbf24e0c643a81e7da Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 7 Dec 2015 21:15:02 +0100 Subject: [PATCH 05/29] GET-tasks_id tests --- .../v3/integration/tasks/GET-tasks_id.test.js | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index 6c6c722703..4e69788c6c 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -3,6 +3,7 @@ import { requester, translate as t, } from '../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; describe('GET /tasks/:id', () => { let user, api; @@ -18,18 +19,51 @@ describe('GET /tasks/:id', () => { let task; beforeEach(() => { - // generate task - // task = generatedTask; + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + }).then((createdTask) => { + task = createdTask; + }); }); - it('gets specified task'); + it('gets specified task', () => { + return api.get('/tasks/' + task._id) + .then((getTask) => { + expect(getTask).to.eql(task); + }); + }); + // TODO after challenges are implemented it('can get active challenge task that user does not own'); // Yes? }); context('task cannot accessed', () => { - it('cannot get a non-existant task'); + it('cannot get a non-existant task', () => { + return expect(api.get('/tasks/' + generateUUID())).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); - it('cannot get a task owned by someone else'); + it('cannot get a task owned by someone else', () => { + let api2; + + return generateUser() + .then((user2) => { + api2 = requester(user2); + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + }) + }).then((task) => { + return expect(api2.get('/tasks/' + task._id)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + }); }); }); From 72b2791bc4d16cf74601de9f687f569613bde9c7 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 8 Dec 2015 14:41:42 +0100 Subject: [PATCH 06/29] refactor scoreTask --- common/script/api-v3/scoreTask.js | 259 ++++++++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 13 +- 2 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 common/script/api-v3/scoreTask.js diff --git a/common/script/api-v3/scoreTask.js b/common/script/api-v3/scoreTask.js new file mode 100644 index 0000000000..2a84f00568 --- /dev/null +++ b/common/script/api-v3/scoreTask.js @@ -0,0 +1,259 @@ +import _ from 'lodash'; +import moment from 'moment'; +import { + NotAuthorized, +} from '../../../website/src/libs/api-v3/errors'; +import i18n from '../i18n'; + +const MAX_TASK_VALUE = 21.27; +const MIN_TASK_VALUE = -47.27; +const CLOSE_ENOUGH = 0.00001; + +function _getTaskValue (taskValue) { + if (taskValue < MIN_TASK_VALUE) { + return MIN_TASK_VALUE; + } else if (taskValue < MAX_TASK_VALUE) { + return MAX_TASK_VALUE; + } else { + return taskValue; + } +} + +// Calculates the next task.value based on direction +// Uses a capped inverse log y=.95^x, y>= -5 +function _calculateDelta (task, direction, cron) { + // Min/max on task redness + let currVal = _getTaskValue(task.value); + let nextDelta = Math.pow(0.9747, currVal) * (direction === 'down' ? -1 : 1); + + // Checklists + if (task.checklist && task.checklist.length > 0) { + // If the Daily, only dock them them a portion based on their checklist completion + if (direction === 'down' && task.type === 'daily' && cron) { + nextDelta *= 1 - _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; + } + + // If To-Do, point-match the TD per checklist item completed + if (task.type === 'todo') { + nextDelta *= 1 + _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0); + } + } + + return nextDelta; +} + +// Approximates the reverse delta for the task value +// This is meant to return the task value to its original value when unchecking a task. +// First, calculate the the value using the normal way for our first guess although +// it will be a bit off +function _calculateReverseDelta (task, direction) { + let currVal = _getTaskValue(task.value); + let testVal = currVal + Math.pow(0.9747, currVal) * (direction === 'down' ? -1 : 1); + + // Now keep moving closer to the original value until we get "close enough" + // Check how close we are to the original value by computing the delta off our guess + // and looking at the difference between that and our current value. + while (true) { // eslint-disable-line no-constant-condition + let calc = testVal + Math.pow(0.9747, testVal); + let diff = currVal - calc; + + if (Math.abs(diff) < CLOSE_ENOUGH) break; + + if (diff > 0) { + testVal -= diff; + } else { + testVal += diff; + } + } + + // When we get close enough, return the difference between our approximated value + // and the current value. This will be the delta calculated from the original value + // before the task was checked. + let nextDelta = testVal - currVal; + + // Checklists - If To-Do, point-match the TD per checklist item completed + if (task.checklist && task.checklist.length > 0 && task.type === 'todo') { + nextDelta *= 1 + _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0); + } + + return nextDelta; +} + +function _gainMP (user, val) { + val *= user._tmp.crit || 1; + user.stats.mp += val; + + if (user.stats.mp >= user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; + if (user.stats.mp < 0) { + user.stats.mp = 0; + return user.stats.mp; + } +} + +// HP modifier +// ===== CONSTITUTION ===== +// TODO Decreases HP loss from bad habits / missed dailies by 0.5% per point. +function _subtractPoints (user, task, stats, delta) { + let conBonus = 1 - user._statsComputed.con / 250; + if (conBonus < 0.1) conBonus = 0.1; + + let hpMod = delta * conBonus * task.priority * 2; // constant 2 multiplier for better results + stats.hp += Math.round(hpMod * 10) / 10; // round to 1dp + return stats.hp; +} + +function _addPoints (user, task, stats, direction, delta) { + // ===== CRITICAL HITS ===== + // allow critical hit only when checking off a task, not when unchecking it: + let _crit = delta > 0 ? user.fns.crit() : 1; + // if there was a crit, alert the user via notification + if (_crit > 1) user._tmp.crit = _crit; + + // Exp Modifier + // ===== Intelligence ===== + // TODO Increases Experience gain by .2% per point. + let intBonus = 1 + user._statsComputed.int * 0.025; + stats.exp += Math.round(delta * intBonus * task.priority * _crit * 6); + + // GP modifier + // ===== PERCEPTION ===== + // TODO Increases Gold gained from tasks by .3% per point. + let perBonus = 1 + user._statsComputed.per * 0.02; + let gpMod = delta * task.priority * _crit * perBonus; + + if (task.streak) { + let currStreak = direction === 'down' ? task.streak - 1 : task.streak; + let streakBonus = currStreak / 100 + 1; // eg, 1-day streak is 1.01, 2-day is 1.02, etc + let afterStreak = gpMod * streakBonus; + if (currStreak > 0 && gpMod > 0) { + user._tmp.streakBonus = afterStreak - gpMod; // keep this on-hand for later, so we can notify streak-bonus + } + + stats.gp += afterStreak; + } else { + stats.gp += gpMod; + } +} + +function _changeTaskValue (user, task, direction, times, cron) { + let addToDelta = 0; + + // If multiple days have passed, multiply times days missed + _.times(times, () => { + // Each iteration calculate the nextDelta, which is then accumulated in the total delta. + let nextDelta = !cron && direction === 'down' ? _calculateReverseDelta(task, direction) : _calculateDelta(task, direction, cron); + + if (task.type !== 'reward') { + if (user.preferences.automaticAllocation === true && user.preferences.allocationMode === 'taskbased' && !(task.type === 'todo' && direction === 'down')) { + user.stats.training[task.attribute] += nextDelta; + } + + if (direction === 'up') { // Make progress on quest based on STR + user.party.quest.progress.up = user.party.quest.progress.up || 0; + + if (task.type === 'todo' || task.type === 'daily') { + user.party.quest.progress.up += nextDelta * (1 + user._statsComputed.str / 200); + } else if (task.type === 'habit') { + user.party.quest.progress.up += nextDelta * (0.5 + user._statsComputed.str / 400); + } + } + + task.value += nextDelta; + } + + addToDelta += nextDelta; + }); + + return addToDelta; +} + +export default function scoreTask (options = {}, req) { + let {user, task, direction, times = 1, cron = false} = options; + let delta = 0; + let stats = { + gp: user.stats.gp, + hp: user.stats.hp, + exp: user.stats.exp, + }; + + // TODO return or pass to cb, don't add to user object + // This is for setting one-time temporary flags, such as streakBonus or itemDropped. Useful for notifying + // the API consumer, then cleared afterwards + user._tmp = {}; + + // If they're trying to purhcase a too-expensive reward, don't allow them to do that. + if (task.value > user.stats.gp && task.type === 'reward') throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); + + // ===== starting to actually do stuff, most of above was definitions ===== + if (task.type === 'habit') { + delta += _changeTaskValue(user, task, direction, times, cron); + // Add habit value to habit-history (if different) + if (delta > 0) { + _addPoints(user, task, stats, direction, delta); + } else { + _subtractPoints(user, task, stats, delta); + } + _gainMP(user, _.max([0.25, 0.0025 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); + + // history + let th = task.history; + let thl = task.history.length; + + if (th[thl - 1] && moment(th[thl - 1].date).isSame(new Date(), 'day')) { + th[thl - 1].value = task.value; + } else { + th.push({ + date: Number(new Date()), // TODO are we going to cast history entries? + value: task.value, + }); + } + } else if (task.type === 'daily') { + if (cron) { + delta += _changeTaskValue(user, task, direction, times, cron); + _subtractPoints(user, task, stats, delta); + if (!user.stats.buffs.streaks) task.streak = 0; + } else { + delta += _changeTaskValue(user, task, direction, times, cron); + if (direction === 'down') delta = _calculateDelta(task, direction, delta); // recalculate delta for unchecking so the gp and exp come out correctly + _addPoints(user, task, stats, direction, delta); // obviously for delta>0, but also a trick to undo accidental checkboxes + _gainMP(user, _.max([1, 0.01 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); + + if (direction === 'up') { + task.streak += 1; + // Give a streak achievement when the streak is a multiple of 21 + if (task.streak % 21 === 0) user.achievements.streak = user.achievements.streak ? user.achievements.streak + 1 : 1; + } else { + // Remove a streak achievement if streak was a multiple of 21 and the daily was undone + if (task.streak % 21 === 0) user.achievements.streak = user.achievements.streak ? user.achievements.streak - 1 : 0; + task.streak -= 1; + } + } + } else if (task.type === 'todo') { + if (cron) { // don't touch stats on cron + delta += _changeTaskValue(user, task, direction, times, cron); + } else { + if (direction === 'up') task.dateCompleted = new Date(); + + delta += _changeTaskValue(user, task, direction, times, cron); + if (direction === 'down') delta = _calculateDelta(task, direction, delta); // recalculate delta for unchecking so the gp and exp come out correctly + _addPoints(user, task, stats, direction, delta); + + // MP++ per checklist item in ToDo, bonus per CLI + let multiplier = _.max([_.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 1), 1]); + _gainMP(user, _.max([multiplier, 0.01 * user._statsComputed.maxMP * multiplier]) * (direction === 'down' ? -1 : 1)); + } + } else if (task.type === 'reward') { + // Don't adjust values for rewards + delta += _changeTaskValue(user, task, direction, times, cron); + // purchase item + stats.gp -= Math.abs(task.value); + // hp - gp difference + if (stats.gp < 0) { + stats.hp += stats.gp; + stats.gp = 0; + } + } + + user.fns.updateStats(stats, req); + return delta; +} diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index f8ccd207d6..e61bd79965 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -9,6 +9,7 @@ import { import shared from '../../../../common'; import Q from 'q'; import _ from 'lodash'; +import scoreTask from '../../../../common/script/api-v3/scoreTask'; let api = {}; @@ -257,7 +258,15 @@ api.scoreTask = { task.completed = direction === 'up'; } - let delta = user.ops.score({params: {id: task._id, direction}, language: req.language}); + let delta; + try { + delta = scoreTask({task, user, direction}, req); + } catch (e) { + throw e; + } + + // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) + if (direction === 'up') user.fns.randomDrop({task, delta}, req); return Q.all([ user.save(), @@ -265,7 +274,7 @@ api.scoreTask = { ]).then((results) => { let savedUser = results[0]; - let userStats = savedUser.toJSON().stats; + let userStats = savedUser.stats.toJSON(); let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats); res.respond(200, resJsonData); From 0f3b307f409ad11dbc681652112ca518564a3942 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 9 Dec 2015 10:21:15 +0100 Subject: [PATCH 07/29] port tests for score and randomDrop --- common/script/api-v3/scoreTask.js | 6 +- test/common/algos.mocha.js | 104 ++++++------------------ website/src/controllers/api-v3/tasks.js | 8 +- 3 files changed, 30 insertions(+), 88 deletions(-) diff --git a/common/script/api-v3/scoreTask.js b/common/script/api-v3/scoreTask.js index 2a84f00568..91e872ee4b 100644 --- a/common/script/api-v3/scoreTask.js +++ b/common/script/api-v3/scoreTask.js @@ -12,7 +12,7 @@ const CLOSE_ENOUGH = 0.00001; function _getTaskValue (taskValue) { if (taskValue < MIN_TASK_VALUE) { return MIN_TASK_VALUE; - } else if (taskValue < MAX_TASK_VALUE) { + } else if (taskValue > MAX_TASK_VALUE) { return MAX_TASK_VALUE; } else { return taskValue; @@ -167,7 +167,7 @@ function _changeTaskValue (user, task, direction, times, cron) { return addToDelta; } -export default function scoreTask (options = {}, req) { +export default function scoreTask (options = {}, req = {}) { let {user, task, direction, times = 1, cron = false} = options; let delta = 0; let stats = { @@ -200,7 +200,7 @@ export default function scoreTask (options = {}, req) { let thl = task.history.length; if (th[thl - 1] && moment(th[thl - 1].date).isSame(new Date(), 'day')) { - th[thl - 1].value = task.value; + th[thl - 1].value = task.value; // TODO mark modified? } else { th.push({ date: Number(new Date()), // TODO are we going to cast history entries? diff --git a/test/common/algos.mocha.js b/test/common/algos.mocha.js index 07d97be9ea..a8c23bb9ba 100644 --- a/test/common/algos.mocha.js +++ b/test/common/algos.mocha.js @@ -5,6 +5,7 @@ import { startOfDay, daysSince, } from '../../common/script/cron'; +import scoreTask from '../../common/script/api-v3/scoreTask'; let expect = require('expect.js'); let sinon = require('sinon'); @@ -720,12 +721,9 @@ describe('User', () => { for (let random = MIN_RANGE_FOR_POTION; random <= MAX_RANGE_FOR_POTION; random += 0.1) { sinon.stub(user.fns, 'predictableRandom').returns(random); - user.ops.score({ - params: { - id: this.task_id, - direction: 'up', - }, - }); + + let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); + user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); expect(user.items.eggs).to.be.empty; expect(user.items.hatchingPotions).to.not.be.empty; expect(user.items.food).to.be.empty; @@ -738,12 +736,8 @@ describe('User', () => { for (let random = MIN_RANGE_FOR_EGG; random <= MAX_RANGE_FOR_EGG; random += 0.1) { sinon.stub(user.fns, 'predictableRandom').returns(random); - user.ops.score({ - params: { - id: this.task_id, - direction: 'up', - }, - }); + let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); + user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); expect(user.items.eggs).to.not.be.empty; expect(user.items.hatchingPotions).to.be.empty; expect(user.items.food).to.be.empty; @@ -757,12 +751,8 @@ describe('User', () => { for (let random = MIN_RANGE_FOR_FOOD; random <= MAX_RANGE_FOR_FOOD; random += 0.1) { sinon.stub(user.fns, 'predictableRandom').returns(random); - user.ops.score({ - params: { - id: this.task_id, - direction: 'up', - }, - }); + let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); + user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); expect(user.items.eggs).to.be.empty; expect(user.items.hatchingPotions).to.be.empty; expect(user.items.food).to.not.be.empty; @@ -773,12 +763,8 @@ describe('User', () => { it('does not get a drop', function () { sinon.stub(user.fns, 'predictableRandom').returns(0.5); - user.ops.score({ - params: { - id: this.task_id, - direction: 'up', - }, - }); + let delta = scoreTask({task: user.dailys[user.dailys.length - 1], user, direction: 'up'}); + user.fns.randomDrop({task: user.dailys[user.dailys.length - 1], delta}, {}); expect(user.items.eggs).to.eql({}); expect(user.items.hatchingPotions).to.eql({}); expect(user.items.food).to.eql({}); @@ -930,76 +916,38 @@ describe('Simple Scoring', () => { }); it('Habits : Up', function () { - this.after.ops.score({ - params: { - id: this.after.habits[0].id, - direction: 'down', - }, - query: { - times: 5, - }, - }); + let delta = scoreTask({task: this.after.habits[0], user: this.after, direction: 'down', times: 5}); + this.after.fns.randomDrop({task: this.after.habits[0], delta}, {}); expectLostPoints(this.before, this.after, 'habit'); }); it('Habits : Down', function () { - this.after.ops.score({ - params: { - id: this.after.habits[0].id, - direction: 'up', - }, - query: { - times: 5, - }, - }); + let delta = scoreTask({task: this.after.habits[0], user: this.after, direction: 'up', times: 5}); + this.after.fns.randomDrop({task: this.after.habits[0], delta}, {}); expectGainedPoints(this.before, this.after, 'habit'); }); it('Dailys : Up', function () { - this.after.ops.score({ - params: { - id: this.after.dailys[0].id, - direction: 'up', - }, - }); + let delta = scoreTask({task: this.after.dailys[0], user: this.after, direction: 'up'}); + this.after.fns.randomDrop({task: this.after.dailys[0], delta}, {}); expectGainedPoints(this.before, this.after, 'daily'); }); it('Dailys : Up, Down', function () { - this.after.ops.score({ - params: { - id: this.after.dailys[0].id, - direction: 'up', - }, - }); - this.after.ops.score({ - params: { - id: this.after.dailys[0].id, - direction: 'down', - }, - }); + let delta = scoreTask({task: this.after.dailys[0], user: this.after, direction: 'up'}); + this.after.fns.randomDrop({task: this.after.dailys[0], delta}, {}); + let delta2 = scoreTask({task: this.after.dailys[0], user: this.after, direction: 'down'}); + this.after.fns.randomDrop({task: this.after.dailys[0], delta2}, {}); expectClosePoints(this.before, this.after, 'daily'); }); it('Todos : Up', function () { - this.after.ops.score({ - params: { - id: this.after.todos[0].id, - direction: 'up', - }, - }); + let delta = scoreTask({task: this.after.todos[0], user: this.after, direction: 'up'}); + this.after.fns.randomDrop({task: this.after.todos[0], delta}, {}); expectGainedPoints(this.before, this.after, 'todo'); }); it('Todos : Up, Down', function () { - this.after.ops.score({ - params: { - id: this.after.todos[0].id, - direction: 'up', - }, - }); - this.after.ops.score({ - params: { - id: this.after.todos[0].id, - direction: 'down', - }, - }); + let delta = scoreTask({task: this.after.todos[0], user: this.after, direction: 'up'}); + this.after.fns.randomDrop({task: this.after.todos[0], delta}, {}); + let delta2 = scoreTask({task: this.after.todos[0], user: this.after, direction: 'down'}); + this.after.fns.randomDrop({task: this.after.todos[0], delta2}, {}); expectClosePoints(this.before, this.after, 'todo'); }); }); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index e61bd79965..2f36b8881a 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -258,13 +258,7 @@ api.scoreTask = { task.completed = direction === 'up'; } - let delta; - try { - delta = scoreTask({task, user, direction}, req); - } catch (e) { - throw e; - } - + let delta = scoreTask({task, user, direction}, req); // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) if (direction === 'up') user.fns.randomDrop({task, delta}, req); From 6a0f9564e03a70d2ac290c9bf6c2ac9d9f77e984 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 9 Dec 2015 10:40:40 +0100 Subject: [PATCH 08/29] begins testing score task route --- .../tasks/POST-tasks_score_id_direction.test.js | 17 +++++++++++++++-- website/src/controllers/api-v3/tasks.js | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js index 46264aee7c..502a75a1ac 100644 --- a/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js @@ -3,6 +3,7 @@ import { requester, translate as t, } from '../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/score/:id/:direction', () => { let user, api; @@ -15,9 +16,21 @@ describe('POST /tasks/score/:id/:direction', () => { }); context('all', () => { - it('requires a task id'); + it('requires a task id', () => { + return expect(api.post('/tasks/score/123/up')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); - it('requires a task direction'); + it('requires a task direction', () => { + return expect(api.post(`/tasks/score/${generateUUID()}/tt`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); }); context('todos', () => { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 2f36b8881a..d5fd1a587f 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -235,7 +235,7 @@ function _generateWebhookTaskData (task, direction, delta, stats, user) { */ api.scoreTask = { method: 'POST', - url: 'tasks/score/:taskId/:direction', + url: '/tasks/score/:taskId/:direction', middlewares: [authWithHeaders()], handler (req, res, next) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); From 9ffa0d58938d51fd4abe787a856704c9d5eca718 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 10 Dec 2015 19:07:09 +0100 Subject: [PATCH 09/29] port cron and preening --- common/script/api-v3/preenHistory.js | 64 +++++ common/script/api-v3/scoreTask.js | 18 +- website/src/middlewares/api-v3/cron.js | 309 +++++++++++++++++++++++++ website/src/models/user.js | 8 +- 4 files changed, 385 insertions(+), 14 deletions(-) create mode 100644 common/script/api-v3/preenHistory.js create mode 100644 website/src/middlewares/api-v3/cron.js diff --git a/common/script/api-v3/preenHistory.js b/common/script/api-v3/preenHistory.js new file mode 100644 index 0000000000..44ed51862e --- /dev/null +++ b/common/script/api-v3/preenHistory.js @@ -0,0 +1,64 @@ +import moment from 'moment'; +import _ from 'lodash'; + +function _preen (newHistory, history, amount, groupBy) { + let groups = _.chain(history) + .groupBy(h => moment(h.date).format(groupBy)) + .sortBy((h, k) => k) + .value(); + + groups = groups.slice(-amount); + groups.pop(); + + _.each(groups, (group) => { + newHistory.push({ + date: moment(group[0].date).toDate(), + value: _.reduce(group, (m, obj) => m + obj.value, 0) / group.length, + }); + }); +} + +// Free users: +// Preen history for users with > 7 history entries +// This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array +// of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week +// of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite] +// +// Subscribers: +// TODO implement +export function preenHistory (history) { + // TODO remember to add this to migration + /* history = _.filter(history, function(h) { + return !!h; + }); */ + let newHistory = []; + + _preen(newHistory, history, 50, 'YYYY'); + _preen(newHistory, history, moment().format('MM'), 'YYYYMM'); + + let thisMonth = moment().format('YYYYMM'); + newHistory = newHistory.concat(history.filter(h => { + return moment(h.date).format('YYYYMM') === thisMonth; + })); + + return newHistory; +} + +export function preenUserHistory (user, tasksByType, minHistLen = 7) { + tasksByType.habits.concat(user.dailys).forEach((task) => { + if (task.history.length > minHistLen) { + task.history = preenHistory(user, task.history); + task.markModified('history'); + } + }); + + if (user.history.exp.length > minHistLen) { + user.history.exp = preenHistory(user, user.history.exp); + user.markModified('history.exp'); + } + + if (user.history.todos.length > minHistLen) { + user.history.todos = preenHistory(user, user.history.todos); + user.markModified('history.todos'); + } +} diff --git a/common/script/api-v3/scoreTask.js b/common/script/api-v3/scoreTask.js index 91e872ee4b..25c59393b6 100644 --- a/common/script/api-v3/scoreTask.js +++ b/common/script/api-v3/scoreTask.js @@ -1,5 +1,4 @@ import _ from 'lodash'; -import moment from 'moment'; import { NotAuthorized, } from '../../../website/src/libs/api-v3/errors'; @@ -195,18 +194,11 @@ export default function scoreTask (options = {}, req = {}) { } _gainMP(user, _.max([0.25, 0.0025 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); - // history - let th = task.history; - let thl = task.history.length; - - if (th[thl - 1] && moment(th[thl - 1].date).isSame(new Date(), 'day')) { - th[thl - 1].value = task.value; // TODO mark modified? - } else { - th.push({ - date: Number(new Date()), // TODO are we going to cast history entries? - value: task.value, - }); - } + // Add history entry, even more than 1 per day + task.history.push({ + date: Number(new Date()), // TODO are we going to cast history entries? + value: task.value, + }); } else if (task.type === 'daily') { if (cron) { delta += _changeTaskValue(user, task, direction, times, cron); diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js new file mode 100644 index 0000000000..3dc5dbbf5e --- /dev/null +++ b/website/src/middlewares/api-v3/cron.js @@ -0,0 +1,309 @@ +import _ from 'lodash'; +import { + daysSince, + shouldDo, +} from '../../../../common/script/cron'; +import common from '../../../../common'; +import scoreTask from '../../../../common/script/api-v3/scoreTask'; +import moment from 'moment'; +import Task from '../../models/task'; +// import Group from '../../models/group'; + +function _runCron (options = {}) { + let {user, tasks, tasksByType, analytics, now, daysMissed} = options; + + user.auth.timestamps.loggedin = now; + user.lastCron = now; + // Reset the lastDrop count to zero + if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; + + // "Perfect Day" achievement for perfect-days + let perfect = true; + + let clearBuffs = { + str: 0, + int: 0, + per: 0, + con: 0, + stealth: 0, + streaks: false, + }; + + // end-of-month perks for subscribers + let plan = user.purchased.plan; + if (user.isSubscribed()) { + if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { + plan.gemsBought = 0; // reset gem-cap + plan.dateUpdated = now; + // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks + // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 + // TODO use month diff instead of ++ / --? + _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 + plan.consecutive.count++; + if (plan.consecutive.offset > 0) { + plan.consecutive.offset--; + } else if (plan.consecutive.count % 3 === 0) { // every 3 months + plan.consecutive.trinkets++; + plan.consecutive.gemCapExtra += 5; + if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) + } + } + + // If user cancelled subscription, we give them until 30day's end until it terminates + if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { + _.merge(plan, { + planId: null, + customerId: null, + paymentMethod: null, + }); + + _.merge(plan.consecutive, { + count: 0, + offset: 0, + gemCapExtra: 0, + }); + + user.markModified('purchased.plan'); // TODO necessary? + } + } + + // User is resting at the inn. + // On cron, buffs are cleared and all dailies are reset without performing damage + if (user.preferences.sleep === true) { + user.stats.buffs = _.cloneDeep(clearBuffs); + + tasksByType.dailys.forEach((daily) => { + let completed = daily.completed; + let thatDay = moment(now).subtract({days: 1}); + + if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { + daily.checklist.forEach(box => box.completed = false); + } + daily.completed = false; + }); + + return; + } + + let multiDaysCountAsOneDay = true; + // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day. + // When site-wide difficulty settings are introduced, this can be a user preference option. + + // Tally each task + let todoTally = 0; + + tasksByType.todos.forEach((task) => { // make uncompleted todos redder + let completed = task.completed; + scoreTask({ + task, + user, + direction: 'down', + cron: true, + times: multiDaysCountAsOneDay ? 1 : daysMissed, + // TODO pass req for analytics? + }); + + let absVal = completed ? Math.abs(task.value) : task.value; + todoTally += absVal; + }); + + let dailyChecked = 0; // how many dailies were checked? + let dailyDueUnchecked = 0; // how many dailies were cun-hecked? + if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; + + tasksByType.dailys.forEach((task) => { + let completed = task.completed; + // Deduct points for missed Daily tasks + let EvadeTask = 0; + let scheduleMisses = daysMissed; + + if (completed) { + dailyChecked += 1; + } else { + // dailys repeat, so need to calculate how many they've missed according to their own schedule + scheduleMisses = 0; + + for (let i = 0; i < daysMissed; i++) { + let thatDay = moment(now).subtract({days: i + 1}); + + if (shouldDo(thatDay.toDate(), task, user.preferences)) { + scheduleMisses++; + if (user.stats.buffs.stealth) { + user.stats.buffs.stealth--; + EvadeTask++; + } + if (multiDaysCountAsOneDay) break; + } + } + + if (scheduleMisses > EvadeTask) { + perfect = false; + + if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points + let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; + dailyDueUnchecked += 1 - fractionChecked; + dailyChecked += fractionChecked; + } else { + dailyDueUnchecked += 1; + } + + let delta = scoreTask({ + user, + task, + direction: 'down', + times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, + cron: true, + }); + + // Apply damage from a boss, less damage for Trivial priority (difficulty) + user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); + // NB: Medium and Hard priorities do not increase damage from boss. This was by accident + // initially, and when we realised, we could not fix it because users are used to + // their Medium and Hard Dailies doing an Easy amount of damage from boss. + // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future + // setting between Trivial and Easy. + } + } + + task.history.push({ + date: Number(new Date()), + value: task.value, + }); + task.completed = false; + + if (completed || scheduleMisses > 0) { + task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed + } + }); + + tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 + if (task.up === false || task.down === false) { + task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; + } + }); + + // Finished tallying + user.history.todos({date: now, value: todoTally}); + // tally experience + let expTally = user.stats.exp; + let lvl = 0; // iterator + while (lvl < user.stats.lvl - 1) { + lvl++; + expTally += common.tnl(lvl); + } + user.history.exp.push({date: now, value: expTally}); + + // preen user history so that it doesn't become a performance problem + // also for subscribed users but differentyly + // premium subscribers can keep their full history. + user.fns.preenUserHistory(tasks); + + if (perfect) { + user.achievements.perfect++; + let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); + user.stats.buffs = { + str: lvlDiv2, + int: lvlDiv2, + per: lvlDiv2, + con: lvlDiv2, + stealth: 0, + streaks: false, + }; + } else { + user.stats.buffs = _.cloneDeep(clearBuffs); + } + + // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit + // Adjust for fraction of dailies completed + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; + + if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) { + user.stats.mp = user._statsComputed.maxMP; + } + + // After all is said and done, progress up user's effect on quest, return those values & reset the user's + let progress = user.party.quest.progress; + let _progress = _.cloneDeep(progress); + _.merge(progress, {down: 0, up: 0}); + progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); + + + // Analytics + user.flags.cronCount++; + analytics.track('Cron', { + category: 'behavior', + gaLabel: 'Cron Count', + gaValue: user.flags.cronCount, + uuid: user._id, + user, // TODO is it really necessary passing the whole user object? + resting: user.preferences.sleep, + cronCount: user.flags.cronCount, + progressUp: _.min([_progress.up, 900]), + progressDown: _progress.down, + }); + + return _progress; +} + +// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) +// For incomplete Dailys, deduct experience +// Make sure to run this function once in a while as server will not take care of overnight calculations. +// And you have to run it every time client connects. +export default function cron (req, res, next) { + let user = res.locals.user; + let analytics = res.analytics; + + let now = new Date(); + let daysMissed = daysSince(user.lastCron, _.defaults({now}, user.preferences)); + + if (daysMissed <= 0) return next(null, user); // TODO why are we passing user down here? + + // Fetch active tasks (no completed todos) + Task.find({ + userId: user._id, + $or: [ // Exclude completed todos + {type: 'todo', completed: false}, + {type: {$in: ['habit', 'daily', 'reward']}}, + ], + }).exec() + .then((tasks) => { + let tasksByType = {habits: [], dailys: [], todos: [], rewards: []}; + tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); + + // Run cron + _runCron({user, tasks, tasksByType, now, daysMissed, analytics}); + + let ranCron = user.isModified(); + let quest = common.content.quests[user.party.quest.key]; + + // if (ranCron) res.locals.wasModified = true; // TODO remove? + if (!ranCron) return next(null, user); // TODO why are we passing user to next? + // TODO Group.tavernBoss(user, progress); + if (!quest || true /* TODO remove */) return user.save(next); + + // If user is on a quest, roll for boss & player, or handle collections + // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? + // TODO do + /* async.waterfall([ + function(cb){ + user.save(cb); // make sure to save the cron effects + }, + function(saved, count, cb){ + var type = quest.boss ? 'boss' : 'collect'; + Group[type+'Quest'](user,progress,cb); + }, + function(){ + var cb = arguments[arguments.length-1]; + // User has been updated in boss-grapple, reload + User.findById(user._id, cb); + } + ], function(err, saved) { + res.locals.user = saved; + next(err,saved); + user = progress = quest = null; + });*/ + }); +} diff --git a/website/src/models/user.js b/website/src/models/user.js index 0810bbfa62..a2e414a296 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -45,6 +45,7 @@ export let schema = new Schema({ // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which // have been updated (http://goo.gl/gQLz41), but we want *every* update _v: { type: Number, default: 0 }, + // TODO give all this a default of 0? achievements: { originalUser: Boolean, habitSurveys: Number, @@ -65,7 +66,7 @@ export let schema = new Schema({ quests: Schema.Types.Mixed, // TODO remove, use dictionary? rebirths: Number, rebirthLevel: Number, - perfect: Number, + perfect: {type: Number, default: 0}, habitBirthdays: Number, valentine: Number, costumeContest: Boolean, // Superseded by costumeContests @@ -627,6 +628,11 @@ schema.pre('save', true, function preSaveUser (next, done) { } }); +// TODO unit test this? +schema.methods.isSubscribed = function isSubscribed () { + return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion +}; + schema.methods.unlink = function unlink (options, cb) { let cid = options.cid; let keep = options.keep; From 501a80889319a1ff6e947850ea588844aaae3bca Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 11 Dec 2015 11:21:36 +0100 Subject: [PATCH 10/29] move cron logic back to common --- common/script/api-v3/cron.js | 250 ++++++++++++++++++++++++ website/src/middlewares/api-v3/cron.js | 251 +------------------------ 2 files changed, 253 insertions(+), 248 deletions(-) create mode 100644 common/script/api-v3/cron.js diff --git a/common/script/api-v3/cron.js b/common/script/api-v3/cron.js new file mode 100644 index 0000000000..1bc8f29927 --- /dev/null +++ b/common/script/api-v3/cron.js @@ -0,0 +1,250 @@ +import moment from 'moment'; +import _ from 'lodash'; +import scoreTask from './scoreTask'; +import common from '../../'; +import { + shouldDo, +} from '../cron'; + +let clearBuffs = { + str: 0, + int: 0, + per: 0, + con: 0, + stealth: 0, + streaks: false, +}; + +// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) +// For incomplete Dailys, deduct experience +// Make sure to run this function once in a while as server will not take care of overnight calculations. +// And you have to run it every time client connects. +export default function cron (options = {}) { + let {user, tasks, tasksByType, analytics, now, daysMissed} = options; + + user.auth.timestamps.loggedin = now; + user.lastCron = now; + // Reset the lastDrop count to zero + if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; + + // "Perfect Day" achievement for perfect-days + let perfect = true; + + // end-of-month perks for subscribers + let plan = user.purchased.plan; + if (user.isSubscribed()) { + if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { + plan.gemsBought = 0; // reset gem-cap + plan.dateUpdated = now; + // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks + // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 + // TODO use month diff instead of ++ / --? + _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 + plan.consecutive.count++; + if (plan.consecutive.offset > 0) { + plan.consecutive.offset--; + } else if (plan.consecutive.count % 3 === 0) { // every 3 months + plan.consecutive.trinkets++; + plan.consecutive.gemCapExtra += 5; + if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) + } + } + + // If user cancelled subscription, we give them until 30day's end until it terminates + if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { + _.merge(plan, { + planId: null, + customerId: null, + paymentMethod: null, + }); + + _.merge(plan.consecutive, { + count: 0, + offset: 0, + gemCapExtra: 0, + }); + + user.markModified('purchased.plan'); // TODO necessary? + } + } + + // User is resting at the inn. + // On cron, buffs are cleared and all dailies are reset without performing damage + if (user.preferences.sleep === true) { + user.stats.buffs = _.cloneDeep(clearBuffs); + + tasksByType.dailys.forEach((daily) => { + let completed = daily.completed; + let thatDay = moment(now).subtract({days: 1}); + + if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { + daily.checklist.forEach(box => box.completed = false); + } + daily.completed = false; + }); + + return; + } + + let multiDaysCountAsOneDay = true; + // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day. + // When site-wide difficulty settings are introduced, this can be a user preference option. + + // Tally each task + let todoTally = 0; + + tasksByType.todos.forEach((task) => { // make uncompleted todos redder + let completed = task.completed; + scoreTask({ + task, + user, + direction: 'down', + cron: true, + times: multiDaysCountAsOneDay ? 1 : daysMissed, + // TODO pass req for analytics? + }); + + let absVal = completed ? Math.abs(task.value) : task.value; + todoTally += absVal; + }); + + let dailyChecked = 0; // how many dailies were checked? + let dailyDueUnchecked = 0; // how many dailies were cun-hecked? + if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; + + tasksByType.dailys.forEach((task) => { + let completed = task.completed; + // Deduct points for missed Daily tasks + let EvadeTask = 0; + let scheduleMisses = daysMissed; + + if (completed) { + dailyChecked += 1; + } else { + // dailys repeat, so need to calculate how many they've missed according to their own schedule + scheduleMisses = 0; + + for (let i = 0; i < daysMissed; i++) { + let thatDay = moment(now).subtract({days: i + 1}); + + if (shouldDo(thatDay.toDate(), task, user.preferences)) { + scheduleMisses++; + if (user.stats.buffs.stealth) { + user.stats.buffs.stealth--; + EvadeTask++; + } + if (multiDaysCountAsOneDay) break; + } + } + + if (scheduleMisses > EvadeTask) { + perfect = false; + + if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points + let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; + dailyDueUnchecked += 1 - fractionChecked; + dailyChecked += fractionChecked; + } else { + dailyDueUnchecked += 1; + } + + let delta = scoreTask({ + user, + task, + direction: 'down', + times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, + cron: true, + }); + + // Apply damage from a boss, less damage for Trivial priority (difficulty) + user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); + // NB: Medium and Hard priorities do not increase damage from boss. This was by accident + // initially, and when we realised, we could not fix it because users are used to + // their Medium and Hard Dailies doing an Easy amount of damage from boss. + // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future + // setting between Trivial and Easy. + } + } + + task.history.push({ + date: Number(new Date()), + value: task.value, + }); + task.completed = false; + + if (completed || scheduleMisses > 0) { + task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed + } + }); + + tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 + if (task.up === false || task.down === false) { + task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; + } + }); + + // Finished tallying + user.history.todos({date: now, value: todoTally}); + // tally experience + let expTally = user.stats.exp; + let lvl = 0; // iterator + while (lvl < user.stats.lvl - 1) { + lvl++; + expTally += common.tnl(lvl); + } + user.history.exp.push({date: now, value: expTally}); + + // preen user history so that it doesn't become a performance problem + // also for subscribed users but differentyly + // premium subscribers can keep their full history. + user.fns.preenUserHistory(tasks); + + if (perfect) { + user.achievements.perfect++; + let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); + user.stats.buffs = { + str: lvlDiv2, + int: lvlDiv2, + per: lvlDiv2, + con: lvlDiv2, + stealth: 0, + streaks: false, + }; + } else { + user.stats.buffs = _.cloneDeep(clearBuffs); + } + + // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit + // Adjust for fraction of dailies completed + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; + + if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) { + user.stats.mp = user._statsComputed.maxMP; + } + + // After all is said and done, progress up user's effect on quest, return those values & reset the user's + let progress = user.party.quest.progress; + let _progress = _.cloneDeep(progress); + _.merge(progress, {down: 0, up: 0}); + progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); + + + // Analytics + user.flags.cronCount++; + analytics.track('Cron', { + category: 'behavior', + gaLabel: 'Cron Count', + gaValue: user.flags.cronCount, + uuid: user._id, + user, // TODO is it really necessary passing the whole user object? + resting: user.preferences.sleep, + cronCount: user.flags.cronCount, + progressUp: _.min([_progress.up, 900]), + progressDown: _progress.down, + }); + + return _progress; +} diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 3dc5dbbf5e..d71c123346 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -1,258 +1,13 @@ import _ from 'lodash'; import { daysSince, - shouldDo, } from '../../../../common/script/cron'; +import cron from '../../../../common/script/api-v3/cron'; import common from '../../../../common'; -import scoreTask from '../../../../common/script/api-v3/scoreTask'; -import moment from 'moment'; import Task from '../../models/task'; // import Group from '../../models/group'; -function _runCron (options = {}) { - let {user, tasks, tasksByType, analytics, now, daysMissed} = options; - - user.auth.timestamps.loggedin = now; - user.lastCron = now; - // Reset the lastDrop count to zero - if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; - - // "Perfect Day" achievement for perfect-days - let perfect = true; - - let clearBuffs = { - str: 0, - int: 0, - per: 0, - con: 0, - stealth: 0, - streaks: false, - }; - - // end-of-month perks for subscribers - let plan = user.purchased.plan; - if (user.isSubscribed()) { - if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { - plan.gemsBought = 0; // reset gem-cap - plan.dateUpdated = now; - // For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks - // If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0 - // TODO use month diff instead of ++ / --? - _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 - plan.consecutive.count++; - if (plan.consecutive.offset > 0) { - plan.consecutive.offset--; - } else if (plan.consecutive.count % 3 === 0) { // every 3 months - plan.consecutive.trinkets++; - plan.consecutive.gemCapExtra += 5; - if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) - } - } - - // If user cancelled subscription, we give them until 30day's end until it terminates - if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { - _.merge(plan, { - planId: null, - customerId: null, - paymentMethod: null, - }); - - _.merge(plan.consecutive, { - count: 0, - offset: 0, - gemCapExtra: 0, - }); - - user.markModified('purchased.plan'); // TODO necessary? - } - } - - // User is resting at the inn. - // On cron, buffs are cleared and all dailies are reset without performing damage - if (user.preferences.sleep === true) { - user.stats.buffs = _.cloneDeep(clearBuffs); - - tasksByType.dailys.forEach((daily) => { - let completed = daily.completed; - let thatDay = moment(now).subtract({days: 1}); - - if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { - daily.checklist.forEach(box => box.completed = false); - } - daily.completed = false; - }); - - return; - } - - let multiDaysCountAsOneDay = true; - // If the user does not log in for two or more days, cron (mostly) acts as if it were only one day. - // When site-wide difficulty settings are introduced, this can be a user preference option. - - // Tally each task - let todoTally = 0; - - tasksByType.todos.forEach((task) => { // make uncompleted todos redder - let completed = task.completed; - scoreTask({ - task, - user, - direction: 'down', - cron: true, - times: multiDaysCountAsOneDay ? 1 : daysMissed, - // TODO pass req for analytics? - }); - - let absVal = completed ? Math.abs(task.value) : task.value; - todoTally += absVal; - }); - - let dailyChecked = 0; // how many dailies were checked? - let dailyDueUnchecked = 0; // how many dailies were cun-hecked? - if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; - - tasksByType.dailys.forEach((task) => { - let completed = task.completed; - // Deduct points for missed Daily tasks - let EvadeTask = 0; - let scheduleMisses = daysMissed; - - if (completed) { - dailyChecked += 1; - } else { - // dailys repeat, so need to calculate how many they've missed according to their own schedule - scheduleMisses = 0; - - for (let i = 0; i < daysMissed; i++) { - let thatDay = moment(now).subtract({days: i + 1}); - - if (shouldDo(thatDay.toDate(), task, user.preferences)) { - scheduleMisses++; - if (user.stats.buffs.stealth) { - user.stats.buffs.stealth--; - EvadeTask++; - } - if (multiDaysCountAsOneDay) break; - } - } - - if (scheduleMisses > EvadeTask) { - perfect = false; - - if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points - let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; - dailyDueUnchecked += 1 - fractionChecked; - dailyChecked += fractionChecked; - } else { - dailyDueUnchecked += 1; - } - - let delta = scoreTask({ - user, - task, - direction: 'down', - times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, - cron: true, - }); - - // Apply damage from a boss, less damage for Trivial priority (difficulty) - user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); - // NB: Medium and Hard priorities do not increase damage from boss. This was by accident - // initially, and when we realised, we could not fix it because users are used to - // their Medium and Hard Dailies doing an Easy amount of damage from boss. - // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future - // setting between Trivial and Easy. - } - } - - task.history.push({ - date: Number(new Date()), - value: task.value, - }); - task.completed = false; - - if (completed || scheduleMisses > 0) { - task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed - } - }); - - tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 - if (task.up === false || task.down === false) { - task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; - } - }); - - // Finished tallying - user.history.todos({date: now, value: todoTally}); - // tally experience - let expTally = user.stats.exp; - let lvl = 0; // iterator - while (lvl < user.stats.lvl - 1) { - lvl++; - expTally += common.tnl(lvl); - } - user.history.exp.push({date: now, value: expTally}); - - // preen user history so that it doesn't become a performance problem - // also for subscribed users but differentyly - // premium subscribers can keep their full history. - user.fns.preenUserHistory(tasks); - - if (perfect) { - user.achievements.perfect++; - let lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); - user.stats.buffs = { - str: lvlDiv2, - int: lvlDiv2, - per: lvlDiv2, - con: lvlDiv2, - stealth: 0, - streaks: false, - }; - } else { - user.stats.buffs = _.cloneDeep(clearBuffs); - } - - // Add 10 MP, or 10% of max MP if that'd be more. Perform this after Perfect Day for maximum benefit - // Adjust for fraction of dailies completed - user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; - - if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; - user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); - if (user.stats.mp > user._statsComputed.maxMP) { - user.stats.mp = user._statsComputed.maxMP; - } - - // After all is said and done, progress up user's effect on quest, return those values & reset the user's - let progress = user.party.quest.progress; - let _progress = _.cloneDeep(progress); - _.merge(progress, {down: 0, up: 0}); - progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); - - - // Analytics - user.flags.cronCount++; - analytics.track('Cron', { - category: 'behavior', - gaLabel: 'Cron Count', - gaValue: user.flags.cronCount, - uuid: user._id, - user, // TODO is it really necessary passing the whole user object? - resting: user.preferences.sleep, - cronCount: user.flags.cronCount, - progressUp: _.min([_progress.up, 900]), - progressDown: _progress.down, - }); - - return _progress; -} - -// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) -// For incomplete Dailys, deduct experience -// Make sure to run this function once in a while as server will not take care of overnight calculations. -// And you have to run it every time client connects. -export default function cron (req, res, next) { +export default function cronMiddleware (req, res, next) { let user = res.locals.user; let analytics = res.analytics; @@ -274,7 +29,7 @@ export default function cron (req, res, next) { tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); // Run cron - _runCron({user, tasks, tasksByType, now, daysMissed, analytics}); + cron({user, tasks, tasksByType, now, daysMissed, analytics}); let ranCron = user.isModified(); let quest = common.content.quests[user.party.quest.key]; From e6d9c978f7d9370e63ed7bc1f9b12414e73f25d8 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Fri, 11 Dec 2015 11:41:46 +0100 Subject: [PATCH 11/29] change score task route --- ... => POST-tasks_id_score_direction.test.js} | 59 +++++++++++++++---- website/src/controllers/api-v3/tasks.js | 8 +-- 2 files changed, 53 insertions(+), 14 deletions(-) rename test/api/v3/integration/tasks/{POST-tasks_score_id_direction.test.js => POST-tasks_id_score_direction.test.js} (68%) diff --git a/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js similarity index 68% rename from test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js rename to test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 502a75a1ac..71367fbfeb 100644 --- a/test/api/v3/integration/tasks/POST-tasks_score_id_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -4,8 +4,9 @@ import { translate as t, } from '../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; +import Q from 'q'; -describe('POST /tasks/score/:id/:direction', () => { +describe('POST /tasks/:id/score/:direction', () => { let user, api; before(() => { @@ -17,7 +18,7 @@ describe('POST /tasks/score/:id/:direction', () => { context('all', () => { it('requires a task id', () => { - return expect(api.post('/tasks/score/123/up')).to.eventually.be.rejected.and.eql({ + return expect(api.post('/tasks/123/score/up')).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -25,7 +26,7 @@ describe('POST /tasks/score/:id/:direction', () => { }); it('requires a task direction', () => { - return expect(api.post(`/tasks/score/${generateUUID()}/tt`)).to.eventually.be.rejected.and.eql({ + return expect(api.post(`/tasks/${generateUUID()}/score/tt`)).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', message: t('invalidReqParams'), @@ -37,7 +38,12 @@ describe('POST /tasks/score/:id/:direction', () => { let todo; beforeEach(() => { - // todo = createdTodo + return api.post('/tasks', { + text: 'test todo', + type: 'todo', + }).then((task) => { + todo = task; + }); }); it('completes todo when direction is up'); @@ -65,7 +71,12 @@ describe('POST /tasks/score/:id/:direction', () => { let daily; beforeEach(() => { - // daily = createdDaily + return api.post('/tasks', { + text: 'test daily', + type: 'daily', + }).then((task) => { + daily = task; + }); }); it('completes daily when direction is up'); @@ -93,10 +104,33 @@ describe('POST /tasks/score/:id/:direction', () => { let habit, minusHabit, plusHabit, neitherHabit; beforeEach(() => { - // habit = createdHabit - // plusHabit = createdPlusHabit - // minusHabit = createdMinusHabit - // neitherHabit = createdNeitherHabit + return Q.all([ + api.post('/tasks', { + text: 'test habit', + type: 'habit', + }), + api.post('/tasks', { + text: 'test min habit', + type: 'habit', + up: false, + }), + api.post('/tasks', { + text: 'test plus habit', + type: 'habit', + down: false, + }), + api.post('/tasks', { + text: 'test neither habit', + type: 'habit', + up: false, + down: false, + }), + ]).then(tasks => { + habit = tasks[0]; + minusHabit = tasks[1]; + plusHabit = tasks[2]; + neitherHabit = tasks[3]; + }); }); it('prevents plus only habit from scoring down'); // Yes? @@ -120,7 +154,12 @@ describe('POST /tasks/score/:id/:direction', () => { let reward; beforeEach(() => { - // reward = createdReward + return api.post('/tasks', { + text: 'test reward', + type: 'reward', + }).then((task) => { + reward = task; + }); }); it('purchases reward'); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index d5fd1a587f..c64090eb13 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -223,7 +223,7 @@ function _generateWebhookTaskData (task, direction, delta, stats, user) { } /** - * @api {put} /tasks/score/:taskId/:direction Score a task + * @api {put} /tasks/:taskId/score/:direction Score a task * @apiVersion 3.0.0 * @apiName ScoreTask * @apiGroup Task @@ -235,11 +235,11 @@ function _generateWebhookTaskData (task, direction, delta, stats, user) { */ api.scoreTask = { method: 'POST', - url: '/tasks/score/:taskId/:direction', + url: '/tasks/:taskId/score/:direction', middlewares: [authWithHeaders()], handler (req, res, next) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); - req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); + req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); // TODO what about rewards? maybe separate route? let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); @@ -255,7 +255,7 @@ api.scoreTask = { if (!task) throw new NotFound(res.t('taskNotFound')); if (task.type === 'daily' || task.type === 'todo') { - task.completed = direction === 'up'; + task.completed = direction === 'up'; // TODO move into scoreTask } let delta = scoreTask({task, user, direction}, req); From a34f41f0f7bf18782ee40d5965fa3c7530bcbfa3 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 13 Dec 2015 20:08:14 +0100 Subject: [PATCH 12/29] misc fixes, GET user (with tests), more comments for preenHistory --- common/script/api-v3/preenHistory.js | 49 +++++++++++++------ test/api/v3/integration/user/GET-user.test.js | 31 ++++++++++++ website/src/controllers/api-v3/auth.js | 2 +- website/src/controllers/api-v3/user.js | 33 +++++++++++++ website/src/models/user.js | 2 +- 5 files changed, 99 insertions(+), 18 deletions(-) create mode 100644 test/api/v3/integration/user/GET-user.test.js create mode 100644 website/src/controllers/api-v3/user.js diff --git a/common/script/api-v3/preenHistory.js b/common/script/api-v3/preenHistory.js index 44ed51862e..50ffb1518f 100644 --- a/common/script/api-v3/preenHistory.js +++ b/common/script/api-v3/preenHistory.js @@ -2,30 +2,47 @@ import moment from 'moment'; import _ from 'lodash'; function _preen (newHistory, history, amount, groupBy) { - let groups = _.chain(history) + _.chain(history) .groupBy(h => moment(h.date).format(groupBy)) .sortBy((h, k) => k) + .slice(-amount) + .pop() + .each((group) => { + newHistory.push({ + date: moment(group[0].date).toDate(), + value: _.reduce(group, (m, obj) => m + obj.value, 0) / group.length, + }); + }) .value(); - - groups = groups.slice(-amount); - groups.pop(); - - _.each(groups, (group) => { - newHistory.push({ - date: moment(group[0].date).toDate(), - value: _.reduce(group, (m, obj) => m + obj.value, 0) / group.length, - }); - }); } // Free users: -// Preen history for users with > 7 history entries -// This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array -// of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week -// of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite] +// Preen history for users with > 7 history entries +// This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array +// of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week +// of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite] // // Subscribers: -// TODO implement +// TODO implement + +// TODO Probably the description ^ is not too correct, this method actually takes 1 value each for the last 50 years, +// then the X last months, where X is the month we're in (september = 8 starting from 0) +// and all the days in this month +// Allowing for multiple values in a single day for habits we probably want something different: +// For free users: +// - At max 30 values for today (max 30) +// - 1 value each for the previous 61 days (2 months) +// - 1 value each for the previous 10 months (max 10) +// - 1 value each for the previous 50 years +// - Total: 30+61+10+ a few years ~= 105 +// +// For subscribed users +// - At max 30 values for today (max 30) +// - 1 value each for the previous 364 days (max 364) +// - 1 value each for the previous 12 months (max 12) +// - 1 value each for the previous 50 years +// - Total: 30+364+12+ a few years ~= 410 +// export function preenHistory (history) { // TODO remember to add this to migration /* history = _.filter(history, function(h) { diff --git a/test/api/v3/integration/user/GET-user.test.js b/test/api/v3/integration/user/GET-user.test.js new file mode 100644 index 0000000000..47c8eb4cc9 --- /dev/null +++ b/test/api/v3/integration/user/GET-user.test.js @@ -0,0 +1,31 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('GET /user', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('returns the authenticated user', () => { + return api.get('/user') + .then(returnedUser => { + expect(returnedUser._id).to.equal(user._id); + }); + }); + + it('does not return private paths (and apiToken)', () => { + return api.get('/user') + .then(returnedUser => { + expect(returnedUser.auth.local.hashed_password).to.be.a('undefined'); + expect(returnedUser.auth.local.salt).to.be.a('undefined'); + expect(returnedUser.apiToken).to.be.a('undefined'); + }); + }); +}); diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 7a818d9877..6693dba861 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -30,7 +30,7 @@ api.registerLocal = { url: '/user/auth/local/register', handler (req, res, next) { let fbUser = res.locals.user; // If adding local auth to social user - + // TODO check user doesn't have local auth req.checkBody({ email: { notEmpty: {errorMessage: res.t('missingEmail')}, diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js new file mode 100644 index 0000000000..690b18aeb2 --- /dev/null +++ b/website/src/controllers/api-v3/user.js @@ -0,0 +1,33 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import common from '../../../../common'; + +let api = {}; + +/** + * @api {get} /user Get the authenticated user's profile + * @apiVersion 3.0.0 + * @apiName UserGet + * @apiGroup User + * + * @apiSuccess {Object} user The user object + */ +api.getUser = { + method: 'GET', + middlewares: [authWithHeaders()], + url: '/user', + handler (req, res) { + let user = res.locals.user.toJSON(); + + // Remove apiToken from resonse TODO make it priavte at the user level? returned in signup/login + delete user.apiToken; + + // TODO move to model (maybe virtuals, maybe in toJSON) + user.stats.toNextLevel = common.tnl(user.stats.lvl); + user.stats.maxHealth = common.maxHealth; + user.stats.maxMP = res.locals.user._statsComputed.maxMP; + + return res.json(200, user); + }, +}; + +export default api; diff --git a/website/src/models/user.js b/website/src/models/user.js index a2e414a296..42bb83f7a0 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -469,7 +469,7 @@ export let schema = new Schema({ }); schema.plugin(baseModel, { - noSet: ['_id', 'apikey', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags'], + noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function toJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? From 9394fb0d94d5e5b373678ada6a72c54886681914 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 13 Dec 2015 20:12:04 +0100 Subject: [PATCH 13/29] use res.respond --- website/src/controllers/api-v3/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 690b18aeb2..498841474f 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -26,7 +26,7 @@ api.getUser = { user.stats.maxHealth = common.maxHealth; user.stats.maxMP = res.locals.user._statsComputed.maxMP; - return res.json(200, user); + return res.respond(200, user); }, }; From 409102ae19b12aa95d0e9ea66741e2f6067a48c9 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Sun, 13 Dec 2015 23:51:06 +0100 Subject: [PATCH 14/29] wip score tests --- common/script/index.js | 14 +- .../POST-tasks_id_score_direction.test.js | 256 +++++++++++++++--- website/src/models/user.js | 3 +- 3 files changed, 224 insertions(+), 49 deletions(-) diff --git a/common/script/index.js b/common/script/index.js index b39ff00e89..3ccbc5726b 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -2063,7 +2063,7 @@ api.wrap = function(user, main) { return content.gear.flat[type + "_base_0"]; } return item; - }, + }, handleTwoHanded: function(item, type, req) { var message, currentWeapon, currentShield; if (type == null) { @@ -2071,17 +2071,17 @@ api.wrap = function(user, main) { } currentShield = content.gear.flat[user.items.gear[type].shield]; currentWeapon = content.gear.flat[user.items.gear[type].weapon]; - + if (item.type === "shield" && (currentWeapon ? currentWeapon.twoHanded : false)) { user.items.gear[type].weapon = 'weapon_base_0'; message = i18n.t('messageTwoHandedUnequip', { twoHandedText: currentWeapon.text(req.language), offHandedText: item.text(req.language), }, req.language); - } else if (item.twoHanded && (currentShield && user.items.gear[type].shield != "shield_base_0")) { - user.items.gear[type].shield = "shield_base_0"; + } else if (item.twoHanded && (currentShield && user.items.gear[type].shield != "shield_base_0")) { + user.items.gear[type].shield = "shield_base_0"; message = i18n.t('messageTwoHandedEquip', { twoHandedText: item.text(req.language), offHandedText: currentShield.text(req.language), - }, req.language); + }, req.language); } return message; }, @@ -2691,11 +2691,11 @@ api.wrap = function(user, main) { return computed; } }); - return Object.defineProperty(user, 'tasks', { + /*return Object.defineProperty(user, 'tasks', { get: function() { var tasks; tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards); return _.object(_.pluck(tasks, "id"), tasks); } - }); + });*/ }; diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 71367fbfeb..b3197a88ff 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -46,25 +46,77 @@ describe('POST /tasks/:id/score/:direction', () => { }); }); - it('completes todo when direction is up'); + it('completes todo when direction is up', () => { + return api.post(`/tasks/${todo._id}/score/up`) + .then((res) => api.get(`/tasks/${todo._id}`)) + .then((task) => expect(task.completed).to.equal(true)); + }); - it('uncompletes todo when direction is down'); + it('uncompletes todo when direction is down', () => { + return api.post(`/tasks/${todo._id}/score/down`) + .then((res) => api.get(`/tasks/${todo._id}`)) + .then((updatedTask) => { + expect(updatedTask.completed).to.equal(false); + }); + }); it('scores up todo even if it is already completed'); // Yes? it('scores down todo even if it is already uncompleted'); // Yes? - it('increases user\'s mp when direction is up'); + it('increases user\'s mp when direction is up', () => { + return api.post(`/tasks/${todo._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s mp when direction is down'); + it('decreases user\'s mp when direction is down', () => { + return api.post(`/tasks/${todo._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); + user = updatedUser; + }); + }); - it('increases user\'s exp when direction is up'); + it('increases user\'s exp when direction is up', () => { + return api.post(`/tasks/${todo._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s exp when direction is down'); + it('decreases user\'s exp when direction is down', () => { + return api.post(`/tasks/${todo._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); + user = updatedUser; + }); + }); - it('increases user\'s gold when direction is up'); + it('increases user\'s gold when direction is up', () => { + return api.post(`/tasks/${todo._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s gold when direction is down'); + it('decreases user\'s gold when direction is down', () => { + return api.post(`/tasks/${todo._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); + user = updatedUser; + }); + }); }); context('dailys', () => { @@ -79,57 +131,108 @@ describe('POST /tasks/:id/score/:direction', () => { }); }); - it('completes daily when direction is up'); + it('completes daily when direction is up', () => { + return api.post(`/tasks/${daily._id}/score/up`) + .then((res) => api.get(`/tasks/${daily._id}`)) + .then((task) => expect(task.completed).to.equal(true)); + }); - it('uncompletes daily when direction is down'); + it('uncompletes daily when direction is down', () => { + return api.post(`/tasks/${daily._id}/score/down`) + .then((res) => api.get(`/tasks/${daily._id}`)) + .then((task) => expect(task.completed).to.equal(false)); + }); it('scores up daily even if it is already completed'); // Yes? it('scores down daily even if it is already uncompleted'); // Yes? - it('increases user\'s mp when direction is up'); + it('increases user\'s mp when direction is up', () => { + return api.post(`/tasks/${daily._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s mp when direction is down'); + it('decreases user\'s mp when direction is down', () => { + return api.post(`/tasks/${daily._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); + user = updatedUser; + }); + }); - it('increases user\'s exp when direction is up'); + it('increases user\'s exp when direction is up', () => { + return api.post(`/tasks/${daily._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s exp when direction is down'); + it('decreases user\'s exp when direction is down', () => { + return api.post(`/tasks/${daily._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); + user = updatedUser; + }); + }); - it('increases user\'s gold when direction is up'); + it('increases user\'s gold when direction is up', () => { + return api.post(`/tasks/${daily._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s gold when direction is down'); + it('decreases user\'s gold when direction is down', () => { + return api.post(`/tasks/${daily._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); + user = updatedUser; + }); + }); }); context('habits', () => { let habit, minusHabit, plusHabit, neitherHabit; beforeEach(() => { - return Q.all([ - api.post('/tasks', { - text: 'test habit', - type: 'habit', - }), - api.post('/tasks', { + return api.post('/tasks', { + text: 'test habit', + type: 'habit', + }).then((task) => { + habit = task; + return api.post('/tasks', { text: 'test min habit', type: 'habit', up: false, - }), - api.post('/tasks', { + }); + }).then((task) => { + minusHabit = task; + return api.post('/tasks', { text: 'test plus habit', type: 'habit', down: false, - }), + }) + }).then((task) => { + plusHabit = task; api.post('/tasks', { text: 'test neither habit', type: 'habit', up: false, down: false, - }), - ]).then(tasks => { - habit = tasks[0]; - minusHabit = tasks[1]; - plusHabit = tasks[2]; - neitherHabit = tasks[3]; + }) + }).then((task) => { + neitherHabit = task; }); }); @@ -137,17 +240,59 @@ describe('POST /tasks/:id/score/:direction', () => { it('prevents minus only habit from scoring up'); // Yes? - it('increases user\'s mp when direction is up'); + it('increases user\'s mp when direction is up', () => { + return api.post(`/tasks/${habit._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s mp when direction is down'); + it('decreases user\'s mp when direction is down', () => { + return api.post(`/tasks/${habit._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); + user = updatedUser; + }); + }); - it('increases user\'s exp when direction is up'); + it('increases user\'s exp when direction is up', () => { + return api.post(`/tasks/${habit._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s exp when direction is down'); + it('decreases user\'s exp when direction is down', () => { + return api.post(`/tasks/${habit._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); + user = updatedUser; + }); + }); - it('increases user\'s gold when direction is up'); + it('increases user\'s gold when direction is up', () => { + return api.post(`/tasks/${habit._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); + user = updatedUser; + }); + }); - it('decreases user\'s gold when direction is down'); + it('decreases user\'s gold when direction is down', () => { + return api.post(`/tasks/${habit._id}/score/down`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); + user = updatedUser; + }); + }); }); context('reward', () => { @@ -157,17 +302,46 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post('/tasks', { text: 'test reward', type: 'reward', + value: 5, }).then((task) => { reward = task; }); }); - it('purchases reward'); + it('purchases reward', () => { + return api.post(`/tasks/${reward._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.hp).to.equal(updatedUser.stats.mp + 5); + user = updatedUser; + }); + }); - it('does not change user\'s mp'); + it('does not change user\'s mp', () => { + return api.post(`/tasks/${reward._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp).to.equal(updatedUser.stats.mp); + user = updatedUser; + }); + }); - it('does not change user\'s exp'); + it('does not change user\'s exp', () => { + return api.post(`/tasks/${reward._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.exp).to.equal(updatedUser.stats.exp); + user = updatedUser; + }); + }); - it('does not allow a down direction'); + it('does not allow a down direction', () => { + return api.post(`/tasks/${reward._id}/score/up`) + .then((res) => api.get(`/user`)) + .then((updatedUser) => { + expect(user.stats.mp).to.equal(updatedUser.stats.mp); + user = updatedUser; + }); + }); }); }); diff --git a/website/src/models/user.js b/website/src/models/user.js index 42bb83f7a0..4a4748ccc5 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -469,7 +469,8 @@ export let schema = new Schema({ }); schema.plugin(baseModel, { - noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags'], + // TODO revisit a lot of things are missing + noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats'], private: ['auth.local.hashed_password', 'auth.local.salt'], toJSONTransform: function toJSON (doc) { // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? From f4af7309cbc6b15f8863e38019df74f221e7e287 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 14 Dec 2015 08:59:41 -0600 Subject: [PATCH 15/29] tests(helpers): Fix local doc update --- test/helpers/api-integration.helper.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index 48ee56739d..027d719edd 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -1,7 +1,7 @@ /* eslint-disable no-use-before-define */ import { - assign, + set, each, isEmpty, times, @@ -282,11 +282,17 @@ function _updateDocument (collectionName, doc, update, cb) { let collection = db.collection(collectionName); - collection.update({ _id: doc._id }, { $set: update }, (updateErr) => { + collection.updateOne({ _id: doc._id }, { $set: update }, (updateErr) => { if (updateErr) throw new Error(`Error updating ${collectionName}: ${updateErr}`); - assign(doc, update); + _updateLocalDocument(doc, update); db.close(); cb(); }); }); } + +function _updateLocalDocument (doc, update) { + each(update, (value, param) => { + set(doc, param, value); + }); +} From c20617e18598d368e3a75632d01c9895de8fd678 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 14 Dec 2015 09:00:22 -0600 Subject: [PATCH 16/29] tests(apiv3): Remove unused dependency --- .../v3/integration/tasks/POST-tasks_id_score_direction.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index b3197a88ff..2b5826c269 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -4,7 +4,6 @@ import { translate as t, } from '../../../../helpers/api-integration.helper'; import { v4 as generateUUID } from 'uuid'; -import Q from 'q'; describe('POST /tasks/:id/score/:direction', () => { let user, api; From ac61809fc0288a2def12912465e222f8e9d2a4f5 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 14 Dec 2015 12:00:23 -0600 Subject: [PATCH 17/29] tests(apiv3): Remove user dependency --- .../POST-tasks_id_score_direction.test.js | 30 ++++--------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 2b5826c269..c256e5317f 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -8,8 +8,10 @@ import { v4 as generateUUID } from 'uuid'; describe('POST /tasks/:id/score/:direction', () => { let user, api; - before(() => { - return generateUser().then((generatedUser) => { + beforeEach(() => { + return generateUser({ + 'stats.gp': 100, + }).then((generatedUser) => { user = generatedUser; api = requester(user); }); @@ -68,7 +70,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); - user = updatedUser; }); }); @@ -77,7 +78,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); - user = updatedUser; }); }); @@ -86,7 +86,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); - user = updatedUser; }); }); @@ -95,7 +94,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); - user = updatedUser; }); }); @@ -104,7 +102,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); - user = updatedUser; }); }); @@ -113,7 +110,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); - user = updatedUser; }); }); }); @@ -151,7 +147,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); - user = updatedUser; }); }); @@ -160,7 +155,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); - user = updatedUser; }); }); @@ -169,7 +163,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); - user = updatedUser; }); }); @@ -178,7 +171,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); - user = updatedUser; }); }); @@ -187,7 +179,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); - user = updatedUser; }); }); @@ -196,7 +187,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); - user = updatedUser; }); }); }); @@ -244,7 +234,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); - user = updatedUser; }); }); @@ -253,7 +242,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); - user = updatedUser; }); }); @@ -262,7 +250,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); - user = updatedUser; }); }); @@ -271,7 +258,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); - user = updatedUser; }); }); @@ -280,7 +266,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); - user = updatedUser; }); }); @@ -289,7 +274,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); - user = updatedUser; }); }); }); @@ -311,8 +295,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${reward._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.hp).to.equal(updatedUser.stats.mp + 5); - user = updatedUser; + expect(user.stats.gp).to.equal(updatedUser.stats.gp + 5); }); }); @@ -321,7 +304,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp).to.equal(updatedUser.stats.mp); - user = updatedUser; }); }); @@ -330,7 +312,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.exp).to.equal(updatedUser.stats.exp); - user = updatedUser; }); }); @@ -339,7 +320,6 @@ describe('POST /tasks/:id/score/:direction', () => { .then((res) => api.get(`/user`)) .then((updatedUser) => { expect(user.stats.mp).to.equal(updatedUser.stats.mp); - user = updatedUser; }); }); }); From 9205f01e01b74275f9c990d28b3c1ad714fc4bab Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 14 Dec 2015 12:13:02 -0600 Subject: [PATCH 18/29] tests(apiv3): Use lessThan and greaterThan expectation syntax --- .../POST-tasks_id_score_direction.test.js | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index c256e5317f..89ae7090de 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -69,7 +69,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${todo._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); + expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); }); @@ -77,7 +77,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${todo._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); }); @@ -85,7 +85,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${todo._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); + expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); }); @@ -93,7 +93,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${todo._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); + expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); }); }); @@ -101,7 +101,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${todo._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); + expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); @@ -109,7 +109,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${todo._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); + expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); }); }); @@ -146,7 +146,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${daily._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); + expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); }); @@ -154,7 +154,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${daily._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); }); @@ -162,7 +162,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${daily._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); + expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); }); @@ -170,7 +170,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${daily._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); + expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); }); }); @@ -178,7 +178,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${daily._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); + expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); @@ -186,7 +186,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${daily._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); + expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); }); }); @@ -233,7 +233,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${habit._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.mp < updatedUser.stats.mp).to.equal(true); + expect(updatedUser.stats.mp).to.be.greaterThan(user.stats.mp); }); }); @@ -241,7 +241,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${habit._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.mp > updatedUser.stats.mp).to.equal(true); + expect(updatedUser.stats.mp).to.be.lessThan(user.stats.mp); }); }); @@ -249,7 +249,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${habit._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.exp < updatedUser.stats.exp).to.equal(true); + expect(updatedUser.stats.exp).to.be.greaterThan(user.stats.exp); }); }); @@ -257,7 +257,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${habit._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.exp > updatedUser.stats.exp).to.equal(true); + expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); }); }); @@ -265,7 +265,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${habit._id}/score/up`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.gp < updatedUser.stats.gp).to.equal(true); + expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); @@ -273,7 +273,7 @@ describe('POST /tasks/:id/score/:direction', () => { return api.post(`/tasks/${habit._id}/score/down`) .then((res) => api.get(`/user`)) .then((updatedUser) => { - expect(user.stats.gp > updatedUser.stats.gp).to.equal(true); + expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); }); }); }); From 3857316a1e399d31080dc666e8cd3c462b368ee8 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 14 Dec 2015 12:23:13 -0600 Subject: [PATCH 19/29] fix(api): Correct sendTaskWebhook call --- website/src/controllers/api-v3/tasks.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index c64090eb13..eec1e28ddb 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -1,5 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; -import webhook from '../../libs/api-v3/webhook'; +import { sendTaskWebhook } from '../../libs/api-v3/webhook'; import * as Tasks from '../../models/task'; import { NotFound, @@ -272,7 +272,7 @@ api.scoreTask = { let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats); res.respond(200, resJsonData); - webhook.sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); + sendTaskWebhook(user.preferences.webhooks, _generateWebhookTaskData(task, direction, delta, userStats, user)); // TODO sync challenge }); From 6709c83804efcfce893705e1756178143111a3f8 Mon Sep 17 00:00:00 2001 From: Blade Barringer Date: Mon, 14 Dec 2015 12:37:59 -0600 Subject: [PATCH 20/29] tests(apiv3): Remove incorrect tests --- .../tasks/POST-tasks_id_score_direction.test.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 89ae7090de..98d34bcbd5 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -253,14 +253,6 @@ describe('POST /tasks/:id/score/:direction', () => { }); }); - it('decreases user\'s exp when direction is down', () => { - return api.post(`/tasks/${habit._id}/score/down`) - .then((res) => api.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.exp).to.be.lessThan(user.stats.exp); - }); - }); - it('increases user\'s gold when direction is up', () => { return api.post(`/tasks/${habit._id}/score/up`) .then((res) => api.get(`/user`)) @@ -268,14 +260,6 @@ describe('POST /tasks/:id/score/:direction', () => { expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp); }); }); - - it('decreases user\'s gold when direction is down', () => { - return api.post(`/tasks/${habit._id}/score/down`) - .then((res) => api.get(`/user`)) - .then((updatedUser) => { - expect(updatedUser.stats.gp).to.be.lessThan(user.stats.gp); - }); - }); }); context('reward', () => { From 155719996d7f0047f3edac1dacf3efc8c1ba0e20 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 14 Dec 2015 20:10:18 +0100 Subject: [PATCH 21/29] remove user.tasks getter on the server --- common/script/index.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/common/script/index.js b/common/script/index.js index 3ccbc5726b..7f12be1bf4 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -2691,11 +2691,14 @@ api.wrap = function(user, main) { return computed; } }); - /*return Object.defineProperty(user, 'tasks', { - get: function() { - var tasks; - tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards); - return _.object(_.pluck(tasks, "id"), tasks); - } - });*/ + + if (typeof window !== 'undefined') { + Object.defineProperty(user, 'tasks', { + get: function() { + var tasks; + tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards); + return _.object(_.pluck(tasks, "id"), tasks); + } + }); + } }; From 9864b8a1cb7f58b76ad2f4e6cf6c66fc59a2bfd4 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 14 Dec 2015 21:56:09 +0100 Subject: [PATCH 22/29] tests for tags --- test/helpers/api-integration.helper.js | 1 + website/src/controllers/api-v3/tags.js | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test/helpers/api-integration.helper.js b/test/helpers/api-integration.helper.js index 027d719edd..973414a3a8 100644 --- a/test/helpers/api-integration.helper.js +++ b/test/helpers/api-integration.helper.js @@ -277,6 +277,7 @@ function _updateDocument (collectionName, doc, update, cb) { return cb(); } + // TODO use config for db url? mongo.connect('mongodb://localhost/habitrpg_test', (connectErr, db) => { if (connectErr) throw new Error(`Error connecting to database when updating ${collectionName} collection: ${connectErr}`); diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js index 4917858449..db601e968e 100644 --- a/website/src/controllers/api-v3/tags.js +++ b/website/src/controllers/api-v3/tags.js @@ -69,7 +69,7 @@ api.getTag = { handler (req, res, next) { let user = res.locals.user; - req.checkParams('taskId', res.t('tagIdRequired')).notEmpty().isUUID(); + req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); @@ -100,7 +100,7 @@ api.updateTag = { req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); // TODO check that req.body isn't empty - let tagId = req.params.id; + let tagId = req.params.tagId; let validationErrors = req.validationErrors(); if (validationErrors) return next(validationErrors); @@ -127,7 +127,7 @@ api.updateTag = { * @apiSuccess {object} empty An empty object */ api.deleteTag = { - method: 'GET', + method: 'DELETE', url: '/tags/:tagId', middlewares: [authWithHeaders()], handler (req, res, next) { From da154d3ea38467b294f6c432125bbc55dc12ab77 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 14 Dec 2015 21:56:29 +0100 Subject: [PATCH 23/29] tests for tags (missed some files before) --- .../integration/tags/DELETE-tags_id.test.js | 35 ++++++++++++++++++ test/api/v3/integration/tags/GET-tags.test.js | 26 ++++++++++++++ .../v3/integration/tags/GET-tags_id.test.js | 28 +++++++++++++++ .../api/v3/integration/tags/POST-tags.test.js | 32 +++++++++++++++++ .../v3/integration/tags/PUT-tags_id.test.js | 36 +++++++++++++++++++ 5 files changed, 157 insertions(+) create mode 100644 test/api/v3/integration/tags/DELETE-tags_id.test.js create mode 100644 test/api/v3/integration/tags/GET-tags.test.js create mode 100644 test/api/v3/integration/tags/GET-tags_id.test.js create mode 100644 test/api/v3/integration/tags/POST-tags.test.js create mode 100644 test/api/v3/integration/tags/PUT-tags_id.test.js diff --git a/test/api/v3/integration/tags/DELETE-tags_id.test.js b/test/api/v3/integration/tags/DELETE-tags_id.test.js new file mode 100644 index 0000000000..0cf4764679 --- /dev/null +++ b/test/api/v3/integration/tags/DELETE-tags_id.test.js @@ -0,0 +1,35 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('DELETE /tags/:tagId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('deletes a tag given it\'s id', () => { + let length; + let tag; + + return api.post('/tags', {name: 'Tag 1'}) + .then((createdTag) => { + tag = createdTag; + return api.get(`/tags`); + }) + .then((tags) => { + length = tags.length; + return api.del(`/tags/${tag._id}`); + }) + .then(() => api.get(`/tags`)) + .then((tags) => { + expect(tags.length).to.equal(length - 1); + expect(tags[tags.length - 1].name).to.not.equal('Tag 1'); + }); + }); +}); diff --git a/test/api/v3/integration/tags/GET-tags.test.js b/test/api/v3/integration/tags/GET-tags.test.js new file mode 100644 index 0000000000..bc0439ffb6 --- /dev/null +++ b/test/api/v3/integration/tags/GET-tags.test.js @@ -0,0 +1,26 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('GET /tags', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('returns all user\'s tags', () => { + return api.post('/tags', {name: 'Tag 1'}) + .then(() => api.post('/tags', {name: 'Tag 2'})) + .then(() => api.get('/tags')) + .then((tags) => { + expect(tags.length).to.equal(2 + 3); // + 3 because 1 is a default task + expect(tags[tags.length - 2].name).to.equal('Tag 1'); + expect(tags[tags.length - 1].name).to.equal('Tag 2'); + }); + }); +}); diff --git a/test/api/v3/integration/tags/GET-tags_id.test.js b/test/api/v3/integration/tags/GET-tags_id.test.js new file mode 100644 index 0000000000..86c63094f3 --- /dev/null +++ b/test/api/v3/integration/tags/GET-tags_id.test.js @@ -0,0 +1,28 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('GET /tags/:tagId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('returns a tag given it\'s id', () => { + let createdTag; + + return api.post('/tags', {name: 'Tag 1'}) + .then((tag) => { + createdTag = tag; + return api.get(`/tags/${createdTag._id}`) + }) + .then((tag) => { + expect(tag).to.deep.equal(createdTag); + }); + }); +}); diff --git a/test/api/v3/integration/tags/POST-tags.test.js b/test/api/v3/integration/tags/POST-tags.test.js new file mode 100644 index 0000000000..0bed7e71f5 --- /dev/null +++ b/test/api/v3/integration/tags/POST-tags.test.js @@ -0,0 +1,32 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('POST /tags', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('creates a tag correctly', () => { + let createdTag; + + return api.post('/tags', { + name: 'Tag 1', + ignored: false, + }).then((tag) => { + createdTag = tag; + expect(tag.name).to.equal('Tag 1'); + expect(tag.ignored).to.be.a('undefined'); + return api.get(`/tags/${createdTag._id}`) + }) + .then((tag) => { + expect(tag).to.deep.equal(createdTag); + }); + }); +}); diff --git a/test/api/v3/integration/tags/PUT-tags_id.test.js b/test/api/v3/integration/tags/PUT-tags_id.test.js new file mode 100644 index 0000000000..ace042afa8 --- /dev/null +++ b/test/api/v3/integration/tags/PUT-tags_id.test.js @@ -0,0 +1,36 @@ +import { + generateUser, + requester, +} from '../../../../helpers/api-integration.helper'; + +describe('PUT /tags/:tagId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('updates a tag given it\'s id', () => { + let length; + + return api.post('/tags', {name: 'Tag 1'}) + .then((createdTag) => { + return api.put(`/tags/${createdTag._id}`, { + name: 'Tag updated', + ignored: true + }); + }) + .then((updatedTag) => { + expect(updatedTag.name).to.equal('Tag updated'); + expect(updatedTag.ignored).to.be.a('undefined'); + return api.get(`/tags/${updatedTag._id}`); + }) + .then((tag) => { + expect(tag.name).to.equal('Tag updated'); + expect(tag.ignored).to.be.a('undefined'); + }); + }); +}); From e547eb2ddedb8ff304e1670d97bca363f6b75eac Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 14 Dec 2015 22:44:50 +0100 Subject: [PATCH 24/29] add tests for tags ops on tasks --- common/locales/en/api-v3.json | 2 +- .../DELETE-tasks_taskId_tags_tagId.test.js | 53 ++++++++++++++ .../tags/POST-tasks_taskId_tags_tagId.test.js | 70 +++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 6 +- 4 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js create mode 100644 test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 0b810c76d1..935fdf2a1d 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -27,5 +27,5 @@ "positionRequired": "\"position\" is required and must be a number.", "cantMoveCompletedTodo": "Can't move a completed todo.", "directionUpDown": "\"direction\" is required and must be 'up' or 'down'", - "alreadyTagged": "The task is already tagged with give tag." + "alreadyTagged": "The task is already tagged with given tag." } diff --git a/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js new file mode 100644 index 0000000000..6fa2c94d68 --- /dev/null +++ b/test/api/v3/integration/tasks/tags/DELETE-tasks_taskId_tags_tagId.test.js @@ -0,0 +1,53 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /tasks/:taskId/tags/:tagId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('removes a tag from a task', () => { + let tag; + let task; + + return api.post('/tasks', { + type: 'habit', + text: 'Task with tag', + }).then(createdTask => { + task = createdTask; + return api.post('/tags', {name: 'Tag 1'}); + }).then(createdTag => { + tag = createdTag; + return api.post(`/tasks/${task._id}/tags/${tag._id}`); + }).then(savedTask => { + return api.del(`/tasks/${task._id}/tags/${tag._id}`); + }).then(() => api.get(`/tasks/${task._id}`)) + .then(updatedTask => { + expect(updatedTask.tags.length).to.equal(0); + }); + }); + + it('only deletes existing tags', () => { + let task; + + return expect(api.post('/tasks', { + type: 'habit', + text: 'Task with tag', + }).then(createdTask => { + return api.del(`/tasks/${createdTask._id}/tags/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('tagNotFound'), + }); + }); +}); diff --git a/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js new file mode 100644 index 0000000000..b5e8f4e485 --- /dev/null +++ b/test/api/v3/integration/tasks/tags/POST-tasks_taskId_tags_tagId.test.js @@ -0,0 +1,70 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/:taskId/tags/:tagId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('adds a tag to a task', () => { + let tag; + let task; + + return api.post('/tasks', { + type: 'habit', + text: 'Task with tag', + }).then(createdTask => { + task = createdTask; + return api.post('/tags', {name: 'Tag 1'}); + }).then(createdTag => { + tag = createdTag; + return api.post(`/tasks/${task._id}/tags/${tag._id}`); + }).then(savedTask => { + expect(savedTask.tags[0]).to.equal(tag._id); + }); + }); + + it('does not add a tag to a task twice', () => { + let tag; + let task; + + return expect(api.post('/tasks', { + type: 'habit', + text: 'Task with tag', + }).then(createdTask => { + task = createdTask; + return api.post('/tags', {name: 'Tag 1'}); + }).then(createdTag => { + tag = createdTag; + return api.post(`/tasks/${task._id}/tags/${tag._id}`); + }).then(() => { + return api.post(`/tasks/${task._id}/tags/${tag._id}`); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('alreadyTagged'), + }); + }); + + it('does not add a non existing tag to a task', () => { + return expect(api.post('/tasks', { + type: 'habit', + text: 'Task with tag', + }).then((task) => { + return api.post(`/tasks/${task._id}/tags/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index eec1e28ddb..818362efc9 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -518,7 +518,7 @@ api.removeChecklistItem = { */ api.addTagToTask = { method: 'POST', - url: '/tasks/:taskId/tags', + url: '/tasks/:taskId/tags/:tagId', middlewares: [authWithHeaders()], handler (req, res, next) { let user = res.locals.user; @@ -538,7 +538,7 @@ api.addTagToTask = { if (!task) throw new NotFound(res.t('taskNotFound')); let tagId = req.params.tagId; - let alreadyTagged = task.tags.indexOf(tagId) === -1; + let alreadyTagged = task.tags.indexOf(tagId) !== -1; if (alreadyTagged) throw new BadRequest(res.t('alreadyTagged')); task.tags.push(tagId); @@ -580,7 +580,7 @@ api.removeTagFromTask = { .then((task) => { if (!task) throw new NotFound(res.t('taskNotFound')); - let tagI = _.findIndex(task.tags, {_id: req.params.tagId}); + let tagI = task.tags.indexOf(req.params.tagId); if (tagI === -1) throw new NotFound(res.t('tagNotFound')); task.tags.splice(tagI, 1); From 13cbf03759fa8c95c2dadb0fde6370744d1f1f14 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Tue, 15 Dec 2015 12:11:32 +0100 Subject: [PATCH 25/29] use cron middleware --- ...LETE-tasks_taskId_checklist_itemId.test.js | 0 .../POST-tasks_taskId_checklist.test.js | 0 ...asks_taskId_checklist_itemId_score.test.js | 0 .../PUT-tasks_taskId_checklist_itemId.test.js | 0 website/src/controllers/api-v3/auth.js | 5 +++- website/src/controllers/api-v3/tags.js | 11 ++++---- website/src/controllers/api-v3/tasks.js | 27 ++++++++++--------- website/src/controllers/api-v3/user.js | 3 ++- website/src/middlewares/api-v3/cron.js | 1 + 9 files changed, 27 insertions(+), 20 deletions(-) create mode 100644 test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js create mode 100644 test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js create mode 100644 test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js create mode 100644 test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/website/src/controllers/api-v3/auth.js b/website/src/controllers/api-v3/auth.js index 6693dba861..2c7e00cadc 100644 --- a/website/src/controllers/api-v3/auth.js +++ b/website/src/controllers/api-v3/auth.js @@ -1,6 +1,7 @@ import validator from 'validator'; import passport from 'passport'; import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; import { NotAuthorized, } from '../../libs/api-v3/errors'; @@ -138,6 +139,7 @@ function _loginRes (user, req, res, next) { api.loginLocal = { method: 'POST', url: '/user/auth/local/login', + middlewares: [cron], handler (req, res, next) { req.checkBody({ username: { @@ -182,6 +184,7 @@ api.loginLocal = { api.loginSocial = { method: 'POST', url: '/user/auth/social', // this isn't the most appropriate url but must be the same as v2 + middlewares: [cron], handler (req, res, next) { let accessToken = req.body.authResponse.access_token; let network = req.body.network; @@ -247,7 +250,7 @@ api.loginSocial = { api.deleteSocial = { method: 'DELETE', url: '/user/auth/social/:network', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; let network = req.params.network; diff --git a/website/src/controllers/api-v3/tags.js b/website/src/controllers/api-v3/tags.js index db601e968e..20d3f2f5ed 100644 --- a/website/src/controllers/api-v3/tags.js +++ b/website/src/controllers/api-v3/tags.js @@ -1,4 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; import { model as Tag } from '../../models/tag'; import { NotFound, @@ -18,7 +19,7 @@ let api = {}; api.createTag = { method: 'POST', url: '/tags', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -45,7 +46,7 @@ api.createTag = { api.getTags = { method: 'GET', url: '/tags', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res) { let user = res.locals.user; res.respond(200, user.tags); @@ -65,7 +66,7 @@ api.getTags = { api.getTag = { method: 'GET', url: '/tags/:tagId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -93,7 +94,7 @@ api.getTag = { api.updateTag = { method: 'PUT', url: '/tags/:tagId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -129,7 +130,7 @@ api.updateTag = { api.deleteTag = { method: 'DELETE', url: '/tags/:tagId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 818362efc9..019b7c058c 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -1,4 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; import { sendTaskWebhook } from '../../libs/api-v3/webhook'; import * as Tasks from '../../models/task'; import { @@ -26,7 +27,7 @@ let api = {}; api.createTask = { method: 'POST', url: '/tasks', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { req.checkBody('type', res.t('invalidTaskType')).notEmpty().isIn(Tasks.tasksTypes); @@ -64,7 +65,7 @@ api.createTask = { api.getTasks = { method: 'GET', url: '/tasks', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(Tasks.tasksTypes); @@ -120,7 +121,7 @@ api.getTasks = { api.getTask = { method: 'GET', url: '/tasks/:taskId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -154,7 +155,7 @@ api.getTask = { api.updateTask = { method: 'PUT', url: '/tasks/:taskId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -236,7 +237,7 @@ function _generateWebhookTaskData (task, direction, delta, stats, user) { api.scoreTask = { method: 'POST', url: '/tasks/:taskId/score/:direction', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); // TODO what about rewards? maybe separate route? @@ -297,7 +298,7 @@ api.scoreTask = { api.moveTask = { method: 'POST', url: '/tasks/move/:taskId/to/:position', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric(); @@ -349,7 +350,7 @@ api.moveTask = { api.addChecklistItem = { method: 'POST', url: '/tasks/:taskId/checklist', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -389,7 +390,7 @@ api.addChecklistItem = { api.scoreCheckListItem = { method: 'POST', url: '/tasks/:taskId/checklist/:itemId/score', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -432,7 +433,7 @@ api.scoreCheckListItem = { api.updateChecklistItem = { method: 'PUT', url: '/tasks/:taskId/checklist/:itemId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -476,7 +477,7 @@ api.updateChecklistItem = { api.removeChecklistItem = { method: 'DELETE', url: '/tasks/:taskId/checklist/:itemId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -519,7 +520,7 @@ api.removeChecklistItem = { api.addTagToTask = { method: 'POST', url: '/tasks/:taskId/tags/:tagId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -563,7 +564,7 @@ api.addTagToTask = { api.removeTagFromTask = { method: 'DELETE', url: '/tasks/:taskId/tags/:tagId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; @@ -620,7 +621,7 @@ function _removeTaskTasksOrder (user, taskId) { api.deleteTask = { method: 'DELETE', url: '/tasks/:taskId', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], handler (req, res, next) { let user = res.locals.user; diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index 498841474f..ef6749209c 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -1,4 +1,5 @@ import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; import common from '../../../../common'; let api = {}; @@ -13,7 +14,7 @@ let api = {}; */ api.getUser = { method: 'GET', - middlewares: [authWithHeaders()], + middlewares: [authWithHeaders(), cron], url: '/user', handler (req, res) { let user = res.locals.user.toJSON(); diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index d71c123346..d0c24d700d 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -7,6 +7,7 @@ import common from '../../../../common'; import Task from '../../models/task'; // import Group from '../../models/group'; +// TODO check that it's usef everywhere export default function cronMiddleware (req, res, next) { let user = res.locals.user; let analytics = res.analytics; From a46fa873a99202b8fafa23588384f296261435eb Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 16 Dec 2015 11:16:30 +0100 Subject: [PATCH 26/29] disable common tests for api v3 --- tasks/gulp-tests.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index 3698fd1f64..b3c1eadf84 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -380,7 +380,7 @@ gulp.task('test:all', (done) => { runSequence( 'lint', // 'test:e2e:safe', - 'test:common:safe', + //'test:common:safe', // 'test:content:safe', 'test:server_side:safe', // 'test:karma:safe', From cc7bd1b5ac62e14f5628bb2804809371fafc23ea Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 16 Dec 2015 11:17:56 +0100 Subject: [PATCH 27/29] disable server_side (api v2) tests for api v3 --- tasks/gulp-tests.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index b3c1eadf84..0c3cf30eb4 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -382,7 +382,7 @@ gulp.task('test:all', (done) => { // 'test:e2e:safe', //'test:common:safe', // 'test:content:safe', - 'test:server_side:safe', + //'test:server_side:safe', // 'test:karma:safe', // 'test:api-legacy:safe', // 'test:api-v2:safe', From d9e786ebaaf9f5fb50e6046fa22780d3aeb29914 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 16 Dec 2015 12:29:03 +0100 Subject: [PATCH 28/29] checklists tests --- ...LETE-tasks_taskId_checklist_itemId.test.js | 86 ++++++++++++++++++ .../POST-tasks_taskId_checklist.test.js | 76 ++++++++++++++++ ...asks_taskId_checklist_itemId_score.test.js | 85 ++++++++++++++++++ .../PUT-tasks_taskId_checklist_itemId.test.js | 87 +++++++++++++++++++ website/src/controllers/api-v3/tasks.js | 5 +- website/src/models/task.js | 6 ++ 6 files changed, 342 insertions(+), 3 deletions(-) diff --git a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js index e69de29bb2..013ba69725 100644 --- a/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/DELETE-tasks_taskId_checklist_itemId.test.js @@ -0,0 +1,86 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('DELETE /tasks/:taskId/checklist/:itemId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('deletes a checklist item', () => { + let task; + + return api.post('/tasks', { + type: 'daily', + text: 'Daily with checklist', + }).then(createdTask => { + task = createdTask; + return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + }).then((savedTask) => { + return api.del(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`); + }).then(() => { + return api.get(`/tasks/${task._id}`); + }).then((savedTask) => { + expect(savedTask.checklist.length).to.equal(0); + }); + }); + + it('does not work with habits', () => { + let habit; + return expect(api.post('/tasks', { + type: 'habit', + text: 'habit with checklist', + }).then(createdTask => { + habit = createdTask; + return api.del(`/tasks/${habit._id}/checklist/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('does not work with rewards', () => { + let reward; + return expect(api.post('/tasks', { + type: 'reward', + text: 'reward with checklist', + }).then(createdTask => { + reward = createdTask; + return api.del(`/tasks/${reward._id}/checklist/${generateUUID()}`); + }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on task not found', () => { + return expect(api.del(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('fails on checklist item not found', () => { + return expect(api.post('/tasks', { + type: 'daily', + text: 'daily with checklist', + }).then(createdTask => { + return api.del(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('checklistItemNotFound'), + }); + }); +}); diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js index e69de29bb2..e3ec919308 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist.test.js @@ -0,0 +1,76 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/:taskId/checklist/', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('adds a checklist item to a task', () => { + let task; + + return api.post('/tasks', { + type: 'daily', + text: 'Daily with checklist', + }).then(createdTask => { + task = createdTask; + return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', ignored: false, _id: 123}); + }).then((savedTask) => { + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('Checklist Item 1'); + expect(savedTask.checklist[0].completed).to.equal(false); + expect(savedTask.checklist[0]._id).to.be.a('string'); + expect(savedTask.checklist[0]._id).to.not.equal('123'); + expect(savedTask.checklist[0].ignored).to.be.an('undefined'); + }); + }); + + it('does not add a checklist to habits', () => { + let habit; + return expect(api.post('/tasks', { + type: 'habit', + text: 'habit with checklist', + }).then(createdTask => { + habit = createdTask; + return api.post(`/tasks/${habit._id}/checklist`, {text: 'Checklist Item 1'}); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('does not add a checklist to rewards', () => { + let reward; + return expect(api.post('/tasks', { + type: 'reward', + text: 'reward with checklist', + }).then(createdTask => { + reward = createdTask; + return api.post(`/tasks/${reward._id}/checklist`, {text: 'Checklist Item 1'}); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on task not found', () => { + return expect(api.post(`/tasks/${generateUUID()}/checklist`, { + text: 'Checklist Item 1' + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); +}); diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index e69de29bb2..32c09ba1c8 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -0,0 +1,85 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('POST /tasks/:taskId/checklist/:itemId/score', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('scores a checklist item', () => { + let task; + + return api.post('/tasks', { + type: 'daily', + text: 'Daily with checklist', + }).then(createdTask => { + task = createdTask; + return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + }).then((savedTask) => { + return api.post(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}/score`); + }).then((savedTask) => { + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].completed).to.equal(true); + }); + }); + + it('fails on habits', () => { + let habit; + return expect(api.post('/tasks', { + type: 'habit', + text: 'habit with checklist', + }).then(createdTask => { + habit = createdTask; + return api.post(`/tasks/${habit._id}/checklist/${generateUUID()}/score`, {text: 'Checklist Item 1'}); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on rewards', () => { + let reward; + return expect(api.post('/tasks', { + type: 'reward', + text: 'reward with checklist', + }).then(createdTask => { + reward = createdTask; + return api.post(`/tasks/${reward._id}/checklist/${generateUUID()}/score`); + }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on task not found', () => { + return expect(api.post(`/tasks/${generateUUID()}/checklist/${generateUUID()}/score`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('fails on checklist item not found', () => { + return expect(api.post('/tasks', { + type: 'daily', + text: 'daily with checklist', + }).then(createdTask => { + return api.post(`/tasks/${createdTask._id}/checklist/${generateUUID()}/score`); + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('checklistItemNotFound'), + }); + }); +}); diff --git a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js index e69de29bb2..e272d05da8 100644 --- a/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js +++ b/test/api/v3/integration/tasks/checklists/PUT-tasks_taskId_checklist_itemId.test.js @@ -0,0 +1,87 @@ +import { + generateUser, + requester, + translate as t, +} from '../../../../../helpers/api-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('PUT /tasks/:taskId/checklist/:itemId', () => { + let user, api; + + before(() => { + return generateUser().then((generatedUser) => { + user = generatedUser; + api = requester(user); + }); + }); + + it('updates a checklist item', () => { + let task; + + return api.post('/tasks', { + type: 'daily', + text: 'Daily with checklist', + }).then(createdTask => { + task = createdTask; + return api.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false}); + }).then((savedTask) => { + return api.put(`/tasks/${task._id}/checklist/${savedTask.checklist[0]._id}`, {text: 'updated', completed: true, _id: 123}); + }).then((savedTask) => { + expect(savedTask.checklist.length).to.equal(1); + expect(savedTask.checklist[0].text).to.equal('updated'); + expect(savedTask.checklist[0].completed).to.equal(true); + expect(savedTask.checklist[0]._id).to.not.equal('123'); + }); + }); + + it('fails on habits', () => { + let habit; + return expect(api.post('/tasks', { + type: 'habit', + text: 'habit with checklist', + }).then(createdTask => { + habit = createdTask; + return api.put(`/tasks/${habit._id}/checklist/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on rewards', () => { + let reward; + return expect(api.post('/tasks', { + type: 'reward', + text: 'reward with checklist', + }).then(createdTask => { + reward = createdTask; + return api.put(`/tasks/${reward._id}/checklist/${generateUUID()}`); + }).then(checklistItem => {})).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('checklistOnlyDailyTodo'), + }); + }); + + it('fails on task not found', () => { + return expect(api.put(`/tasks/${generateUUID()}/checklist/${generateUUID()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('taskNotFound'), + }); + }); + + it('fails on checklist item not found', () => { + return expect(api.post('/tasks', { + type: 'daily', + text: 'daily with checklist', + }).then(createdTask => { + return api.put(`/tasks/${createdTask._id}/checklist/${generateUUID()}`); + })).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('checklistItemNotFound'), + }); + }); +}); diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 019b7c058c..9221787a63 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -368,7 +368,7 @@ api.addChecklistItem = { if (!task) throw new NotFound(res.t('taskNotFound')); if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); - task.checklist.push(req.body); + task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); return task.save(); }) .then((savedTask) => res.respond(200, savedTask)) // TODO what to return @@ -454,8 +454,7 @@ api.updateChecklistItem = { let item = _.find(task.checklist, {_id: req.params.itemId}); if (!item) throw new NotFound(res.t('checklistItemNotFound')); - delete req.body.id; // Simple sanitization to prevent the ID to be changed - _.merge(item, req.body); + _.merge(item, Tasks.Task.sanitizeChecklist(req.body)); return task.save(); }) .then((savedTask) => res.respond(200, savedTask)) // TODO what to return diff --git a/website/src/models/task.js b/website/src/models/task.js index 3ba4f21b22..a4e376446d 100644 --- a/website/src/models/task.js +++ b/website/src/models/task.js @@ -57,6 +57,12 @@ TaskSchema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { return Task.sanitize(updateObj, noUpdate); // eslint-disable-line no-use-before-define }; +// Sanitize checklist objects (disallowing _id) +TaskSchema.statics.sanitizeChecklist = function sanitizeChecklist (checklistObj) { + delete checklistObj._id; + return checklistObj; +}; + export let Task = mongoose.model('Task', TaskSchema); // habits and dailies shared fields From 35316ebeb6432f3038f59b3a784de4fe28f42849 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Wed, 16 Dec 2015 12:57:19 +0100 Subject: [PATCH 29/29] move completed todos outside of tasksOrder (and back) with tests --- .../POST-tasks_id_score_direction.test.js | 33 +++++++++++++++++++ .../v3/integration/tasks/PUT-tasks_id.test.js | 8 ++--- website/src/controllers/api-v3/tasks.js | 17 ++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 98d34bcbd5..622d63dc4f 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -53,6 +53,39 @@ describe('POST /tasks/:id/score/:direction', () => { .then((task) => expect(task.completed).to.equal(true)); }); + it('moves completed todos out of user.tasksOrder.todos', () => { + return api.get('/user') + .then(user => { + expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1) + }).then(() => api.post(`/tasks/${todo._id}/score/up`)) + .then(() => api.get(`/tasks/${todo._id}`)) + .then((updatedTask) => { + expect(updatedTask.completed).to.equal(true); + return api.get('/user'); + }) + .then((user) => { + expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(-1) + }); + }); + + it('moves un-completed todos back into user.tasksOrder.todos', () => { + return api.get('/user') + .then(user => { + expect(user.tasksOrder.todos.indexOf(todo._id)).to.not.equal(-1) + }).then(() => api.post(`/tasks/${todo._id}/score/up`)) + .then(() => api.post(`/tasks/${todo._id}/score/down`)) + .then(() => api.get(`/tasks/${todo._id}`)) + .then((updatedTask) => { + expect(updatedTask.completed).to.equal(false); + return api.get('/user'); + }) + .then((user) => { + let l = user.tasksOrder.todos.length; + expect(user.tasksOrder.todos.indexOf(todo._id)).not.to.equal(-1); + expect(user.tasksOrder.todos.indexOf(todo._id)).to.equal(l - 1); // Check that it was pushed at the bottom + }); + }); + it('uncompletes todo when direction is down', () => { return api.post(`/tasks/${todo._id}/score/down`) .then((res) => api.get(`/tasks/${todo._id}`)) diff --git a/test/api/v3/integration/tasks/PUT-tasks_id.test.js b/test/api/v3/integration/tasks/PUT-tasks_id.test.js index 7ea2440beb..f78f7ca35e 100644 --- a/test/api/v3/integration/tasks/PUT-tasks_id.test.js +++ b/test/api/v3/integration/tasks/PUT-tasks_id.test.js @@ -15,7 +15,7 @@ describe('PUT /tasks/:id', () => { }); }); - xcontext('validates params', () => { + context('validates params', () => { let task; beforeEach(() => { @@ -64,7 +64,7 @@ describe('PUT /tasks/:id', () => { }); }); - xcontext('habits', () => { + context('habits', () => { let habit; beforeEach(() => { @@ -92,7 +92,7 @@ describe('PUT /tasks/:id', () => { }); }); - xcontext('todos', () => { + context('todos', () => { let todo; beforeEach(() => { @@ -255,7 +255,7 @@ describe('PUT /tasks/:id', () => { }); }); - xcontext('rewards', () => { + context('rewards', () => { let reward; beforeEach(() => { diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index 9221787a63..ef7180088e 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -255,6 +255,7 @@ api.scoreTask = { .then((task) => { if (!task) throw new NotFound(res.t('taskNotFound')); + let wasCompleted = task.completed; if (task.type === 'daily' || task.type === 'todo') { task.completed = direction === 'up'; // TODO move into scoreTask } @@ -263,6 +264,22 @@ api.scoreTask = { // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) if (direction === 'up') user.fns.randomDrop({task, delta}, req); + // If a todo was completed or uncompleted move it in or out of the user.tasksOrder.todos list + if (task.type === 'todo') { + if (!wasCompleted && task.completed) { + let i = user.tasksOrder.todos.indexOf(task._id); + if (i !== -1) user.tasksOrder.todos.splice(i, 1); + } else if (wasCompleted && !task.completed) { + let i = user.tasksOrder.todos.indexOf(task._id); + if (i === -1) { + user.tasksOrder.todos.push(task._id); // TODO push at the top? + } else { // If for some reason it hadn't been removed TODO ok? + user.tasksOrder.todos.splice(i, 1); + user.tasksOrder.push(task._id); + } + } + } + return Q.all([ user.save(), task.save(),