Merge pull request #522 from switz/api

@lefnire => Initial API support including specs
This commit is contained in:
Tyler Renelle 2013-03-01 12:32:49 -08:00
commit 7e78e8cd12
11 changed files with 490 additions and 109 deletions

2
.gitignore vendored
View file

@ -4,4 +4,4 @@ node_modules
#lib/ #lib/
*.swp *.swp
.idea* .idea*
config.json config.json

View file

@ -23,8 +23,10 @@
"mongoskin": "*", "mongoskin": "*",
"nconf": "*", "nconf": "*",
"icalendar": "git://github.com/lefnire/node-icalendar#master", "icalendar": "git://github.com/lefnire/node-icalendar#master",
"superagent": "~0.12.4",
"resolve": "~0.2.3", "resolve": "~0.2.3",
"browserify": "1.17.3", "browserify": "1.17.3",
"expect.js": "~0.2.0",
"webkit-devtools-agent": "*" "webkit-devtools-agent": "*"
}, },
"private": true, "private": true,
@ -38,6 +40,7 @@
"npm": "1.1.x" "npm": "1.1.x"
}, },
"scripts": { "scripts": {
"start": "server.js" "start": "server.js",
"test": "mocha test/api.mocha.coffee"
} }
} }

View file

@ -3,56 +3,116 @@ router = new express.Router()
scoring = require '../app/scoring' scoring = require '../app/scoring'
_ = require 'underscore' _ = require 'underscore'
icalendar = require('icalendar') validator = require 'derby-auth/node_modules/validator'
check = validator.check
sanitize = validator.sanitize
icalendar = require 'icalendar'
# ---------- /v1 API ------------ NO_TOKEN_OR_UID = err: "You must include a token and uid (user id) in your request"
# Every url added beneath router is prefaced by /v1 NO_USER_FOUND = err: "No user found."
# ---------- /api/v1 API ------------
# Every url added beneath router is prefaced by /api/v1
### ###
v1 API. Requires user-id and apiToken, task-id, direction. Test with: v1 API. Requires api-v1-user (user id) and api-v1-key (api key) headers, Test with:
curl -X POST -H "Content-Type:application/json" -d '{"apiToken":"{TOKEN}"}' localhost:3000/v1/users/{UID}/tasks/productivity/up $ cd node_modules/racer && npm install && cd ../..
$ mocha test/api.mocha.coffee
### ###
router.post '/users/:uid/tasks/:taskId/:direction', (req, res) -> router.get '/status', (req, res) ->
{uid, taskId, direction} = req.params res.json status: 'up'
{apiToken, title, service, icon} = req.body
console.log {params:req.params, body:req.body} if process.env.NODE_ENV == 'development'
# Send error responses for improper API call auth = (req, res, next) ->
return res.send(500, 'request body "apiToken" required') unless apiToken uid = req.headers['x-api-user']
return res.send(500, ':uid required') unless uid token = req.headers['x-api-key']
return res.send(500, ':taskId required') unless taskId return res.json 401, NO_TOKEN_OR_UID unless uid || token
return res.send(500, ":direction must be 'up' or 'down'") unless direction in ['up','down']
req._isServer = true
model = req.getModel() model = req.getModel()
model.fetch model.query('users').withIdAndToken(uid, apiToken), (err, result) -> query = model.query('users').withIdAndToken(uid, token)
return res.send(500, err) if err
user = result
userObj = user.get()
if _.isEmpty(userObj)
return res.send(500, "User with uid=#{uid}, token=#{apiToken} not found. Make sure you're not using your username, but your User Id")
model.ref('_user', user) query.fetch (err, user) ->
return res.json err: err if err
req.user = user
req.userObj = user.get()
return res.json 401, NO_USER_FOUND if !req.userObj || _.isEmpty(req.userObj)
req._isServer = true
next()
# Create task if doesn't exist router.get '/user', auth, (req, res) ->
# TODO add service & icon to task user = req.userObj
unless model.get("_user.tasks.#{taskId}")
model.refList "_habitList", "_user.tasks", "_user.habitIds"
model.at('_habitList').push
id: taskId
type: 'habit'
text: (title || taskId)
value: 0
up: true
down: true
notes: "This task was created by a third-party service. Feel free to edit, it won't harm the connection to that service. Additionally, multiple services may piggy-back off this task."
score = scoring.Scoring(model) delete user.apiToken
delta = score.score(taskId, direction)
result = model.get ('_user.stats') res.json user
result.delta = delta
res.send(result) router.get '/user/task/:id', auth, (req, res) ->
task = req.userObj.tasks[req.params.id]
return res.json 400, err: "No task found." if !task || _.isEmpty(task)
res.json 200, task
validateTask = (req, res, next) ->
task = {}
newTask = { type, text, notes, value, up, down, completed } = req.body
# If we're updating, get the task from the user
if req.method is 'PUT'
task = req.userObj?.tasks[req.params.id]
return res.json 400, err: "No task found." if !task || _.isEmpty(task)
# Strip for now
type = undefined
delete newTask.type
else if req.method is 'POST'
unless /^(habit|todo|daily|reward)$/.test type
return res.json 400, err: 'type must be habit, todo, daily, or reward'
text = sanitize(text).xss()
notes = sanitize(notes).xss()
value = sanitize(value).toInt()
switch type
when 'habit'
newTask.up = true unless typeof up is 'boolean'
newTask.down = true unless typeof down is 'boolean'
when 'daily', 'todo'
newTask.completed = false unless typeof completed is 'boolean'
_.extend task, newTask
req.task = task
next()
router.put '/user/task/:id', auth, validateTask, (req, res) ->
req.user.set "tasks.#{req.task.id}", req.task
res.json 200, req.task
router.post '/user/task', auth, validateTask, (req, res) ->
task = req.task
type = task.type
model = req.getModel()
model.ref '_user', req.user
model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids"
model.at("_#{type}List").push task
res.json 201, task
router.get '/user/tasks', auth, (req, res) ->
user = req.userObj
return res.json 400, NO_USER_FOUND if !user || _.isEmpty(user)
model = req.getModel()
model.ref '_user', req.user
tasks = []
types = ['habit','todo','daily','reward']
if /^(habit|todo|daily|reward)$/.test req.query.type
types = [req.query.type]
for type in types
model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids"
tasks = tasks.concat model.get("_#{type}List")
res.json 200, tasks
router.get '/users/:uid/calendar.ics', (req, res) -> router.get '/users/:uid/calendar.ics', (req, res) ->
#return next() #disable for now #return next() #disable for now
@ -62,11 +122,11 @@ router.get '/users/:uid/calendar.ics', (req, res) ->
model = req.getModel() model = req.getModel()
query = model.query('users').withIdAndToken(uid, apiToken) query = model.query('users').withIdAndToken(uid, apiToken)
query.fetch (err, result) -> query.fetch (err, result) ->
return res.send(500, err) if err return res.send(400, err) if err
tasks = result.get('tasks') tasks = result.get('tasks')
# tasks = result[0].tasks # tasks = result[0].tasks
tasksWithDates = _.filter tasks, (task) -> !!task.date tasksWithDates = _.filter tasks, (task) -> !!task.date
return res.send(500, "No events found") if _.isEmpty(tasksWithDates) return res.send(400, "No events found") if _.isEmpty(tasksWithDates)
ical = new icalendar.iCalendar() ical = new icalendar.iCalendar()
ical.addProperty('NAME', 'HabitRPG') ical.addProperty('NAME', 'HabitRPG')

View file

@ -1,6 +1,10 @@
express = require 'express' express = require 'express'
router = new express.Router() router = new express.Router()
scoring = require '../app/scoring'
_ = require 'underscore'
icalendar = require('icalendar')
# ---------- Deprecated Paths ------------ # ---------- Deprecated Paths ------------
deprecatedMessage = 'This API is no longer supported, see https://github.com/lefnire/habitrpg/wiki/API for new protocol' deprecatedMessage = 'This API is no longer supported, see https://github.com/lefnire/habitrpg/wiki/API for new protocol'
@ -9,4 +13,72 @@ router.get '/:uid/up/:score?', (req, res) -> res.send(500, deprecatedMessage)
router.get '/:uid/down/:score?', (req, res) -> res.send(500, deprecatedMessage) router.get '/:uid/down/:score?', (req, res) -> res.send(500, deprecatedMessage)
router.post '/users/:uid/tasks/:taskId/:direction', (req, res) -> res.send(500, deprecatedMessage) router.post '/users/:uid/tasks/:taskId/:direction', (req, res) -> res.send(500, deprecatedMessage)
router.post '/v1/users/:uid/tasks/:taskId/:direction', (req, res) ->
{uid, taskId, direction} = req.params
{apiToken, title, service, icon} = req.body
console.log {params:req.params, body:req.body} if process.env.NODE_ENV == 'development'
# Send error responses for improper API call
return res.send(500, 'request body "apiToken" required') unless apiToken
return res.send(500, ':uid required') unless uid
return res.send(500, ':taskId required') unless taskId
return res.send(500, ":direction must be 'up' or 'down'") unless direction in ['up','down']
model = req.getModel()
model.fetch model.query('users').withIdAndToken(uid, apiToken), (err, result) ->
return res.send(500, err) if err
user = result
userObj = user.get()
if _.isEmpty(userObj)
return res.send(500, "User with uid=#{uid}, token=#{apiToken} not found. Make sure you're not using your username, but your User Id")
model.ref('_user', user)
req._isServer = true
# Create task if doesn't exist
# TODO add service & icon to task
unless model.get("_user.tasks.#{taskId}")
model.refList "_habitList", "_user.tasks", "_user.habitIds"
model.at('_habitList').push
id: taskId
type: 'habit'
text: (title || taskId)
value: 0
up: true
down: true
notes: "This task was created by a third-party service. Feel free to edit, it won't harm the connection to that service. Additionally, multiple services may piggy-back off this task."
score = new scoring.Scoring(model)
delta = score.score(taskId, direction)
result = model.get ('_user.stats')
result.delta = delta
res.send(result)
router.get '/v1/users/:uid/calendar.ics', (req, res) ->
#return next() #disable for now
{uid} = req.params
{apiToken} = req.query
model = req.getModel()
query = model.query('users').withIdAndToken(uid, apiToken)
query.fetch (err, result) ->
return res.send(500, err) if err
tasks = result.get('tasks')
# tasks = result[0].tasks
tasksWithDates = _.filter tasks, (task) -> !!task.date
return res.send(500, "No events found") if _.isEmpty(tasksWithDates)
ical = new icalendar.iCalendar()
ical.addProperty('NAME', 'HabitRPG')
_.each tasksWithDates, (task) ->
event = new icalendar.VEvent(task.id);
event.setSummary(task.text);
d = new Date(task.date)
d.date_only = true
event.setDate d
ical.addComponent event
res.type('text/calendar')
formattedIcal = ical.toString().replace(/DTSTART\:/g, 'DTSTART;VALUE=DATE:')
res.send(200, formattedIcal)
module.exports = router module.exports = router

View file

@ -31,7 +31,7 @@ server = http.createServer expressApp
module.exports = server module.exports = server
derby.use require('racer-db-mongo') derby.use require('racer-db-mongo')
store = derby.createStore module.exports.habitStore = store = derby.createStore
db: {type: 'Mongo', uri: process.env.NODE_DB_URI, safe:true} db: {type: 'Mongo', uri: process.env.NODE_DB_URI, safe:true}
listen: server listen: server
@ -74,6 +74,9 @@ mongo_store = new MongoStore {url: process.env.NODE_DB_URI}, ->
) )
# Adds req.getModel method # Adds req.getModel method
.use(store.modelMiddleware()) .use(store.modelMiddleware())
# API should be hit before all other routes
.use('/api/v1', require('./api').middleware)
.use(require('./deprecated').middleware)
# Show splash page for newcomers # Show splash page for newcomers
.use(middleware.splash) .use(middleware.splash)
.use(priv.middleware) .use(priv.middleware)
@ -81,9 +84,7 @@ mongo_store = new MongoStore {url: process.env.NODE_DB_URI}, ->
.use(auth.middleware(strategies, options)) .use(auth.middleware(strategies, options))
# Creates an express middleware from the app's routes # Creates an express middleware from the app's routes
.use(app.router()) .use(app.router())
.use('/v1', require('./api').middleware)
.use(require('./static').middleware) .use(require('./static').middleware)
.use(require('./deprecated').middleware)
.use(expressApp.router) .use(expressApp.router)
.use(serverError(root)) .use(serverError(root))

View file

@ -1,17 +1,14 @@
module.exports.splash = (req, res, next) -> module.exports.splash = (req, res, next) ->
# This was an API call, not a page load
return next() if req.is('json')
unless req.query?.play? or req.getModel().get('_userId') unless req.query?.play? or req.getModel().get('_userId')
res.redirect('/splash.html') res.redirect('/splash.html')
else else
next() next()
module.exports.view = (req, res, next) -> module.exports.view = (req, res, next) ->
model = req.getModel() model = req.getModel()
_view = model.get('_view') || {} _view = model.get('_view') || {}
## Set _mobileDevice to true or false so view can exclude portions from mobile device ## Set _mobileDevice to true or false so view can exclude portions from mobile device
_view.mobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header 'User-Agent') _view.mobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header 'User-Agent')
_view.nodeEnv = process.env.NODE_ENV _view.nodeEnv = process.env.NODE_ENV
model.set '_view', _view model.set '_view', _view
next() next()

View file

@ -28,7 +28,10 @@ userAccess = (store) ->
store.writeAccess "*", "users.*", -> # captures, value, accept, err -> store.writeAccess "*", "users.*", -> # captures, value, accept, err ->
accept = arguments[arguments.length-2] accept = arguments[arguments.length-2]
err = arguments[arguments.length - 1] err = arguments[arguments.length - 1]
# return err(derbyAuth.SESSION_INVALIDATED_ERROR) if derbyAuth.bustedSession(@) # return err(derbyAuth.SESSION_INVALIDATED_ERROR) if derbyAuth.bustedSession(@)
return accept(true) if derbyAuth.isServer(@)
return accept(false) if derbyAuth.bustedSession(@) return accept(false) if derbyAuth.bustedSession(@)
captures = arguments[0].split('.') captures = arguments[0].split('.')
@ -40,8 +43,7 @@ userAccess = (store) ->
return accept(true) return accept(true)
# Same session (user.id = this.session.userId) # Same session (user.id = this.session.userId)
if (uid is @session.userId) or derbyAuth.isServer(@) return accept(true) if uid is @session.userId
return accept(true)
accept(false) accept(false)

245
test/api.mocha.coffee Normal file
View file

@ -0,0 +1,245 @@
_ = require 'underscore'
request = require 'superagent'
expect = require 'expect.js'
require 'coffee-script'
conf = require("nconf")
conf.argv().env().file({file: __dirname + '../config.json'}).defaults
# Override normal ENV values with nconf ENV values (ENV values are used the same way without nconf)
#FIXME can't get nconf file above to load...
process.env.BASE_URL = conf.get("BASE_URL")
process.env.FACEBOOK_KEY = conf.get("FACEBOOK_KEY")
process.env.FACEBOOK_SECRET = conf.get("FACEBOOK_SECRET")
process.env.NODE_DB_URI = 'mongodb://localhost/habirpg'
## monkey-patch expect.js for better diffs on mocha
## see: https://github.com/LearnBoost/expect.js/pull/34
origBe = expect.Assertion::be
expect.Assertion::be = expect.Assertion::equal = (obj) ->
@_expected = obj
origBe.call this, obj
# Custom modules
character = require '../src/app/character'
###### Helpers & Variables ######
model = null
uuid = null
taskPath = null
baseURL = 'http://localhost:1337/api/v1'
###### Specs ######
describe 'API', ->
server = null
store = null
model = null
user = null
uid = null
before (done) ->
server = require '../src/server'
server.listen '1337', '0.0.0.0'
server.on 'listening', (data) ->
store = server.habitStore
#store.flush()
model = store.createModel()
model.set '_userId', uid = model.id()
user = character.newUserObject()
user.apiToken = model.id()
model.session = {userId:uid}
model.set "users.#{uid}", user
delete model.session
# Crappy hack to let server start before tests run
setTimeout done, 2000
describe 'Without token or user id', ->
it '/api/v1/status', (done) ->
request.get("#{baseURL}/status")
.set('Accept', 'application/json')
.end (res) ->
expect(res.statusCode).to.be 200
expect(res.body.status).to.be 'up'
done()
it '/api/v1/user', (done) ->
request.get("#{baseURL}/user")
.set('Accept', 'application/json')
.end (res) ->
expect(res.statusCode).to.be 401
expect(res.body.err).to.be 'You must include a token and uid (user id) in your request'
done()
describe 'With token and user id', ->
params = null
currentUser = null
before ->
user = model.at("users.#{uid}")
currentUser = user.get()
params =
title: 'Title'
text: 'Text'
type: 'habit'
beforeEach ->
currentUser = user.get()
it 'GET /api/v1/user', (done) ->
request.get("#{baseURL}/user")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.end (res) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 200
expect(res.body.id).not.to.be.empty()
self = _.clone(currentUser)
delete self.apiToken
expect(res.body).to.eql self
done()
it 'GET /api/v1/user/task/:id', (done) ->
tid = _.pluck(currentUser.tasks, 'id')[0]
request.get("#{baseURL}/user/task/#{tid}")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.end (res) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 200
expect(res.body).to.eql currentUser.tasks[tid]
done()
it 'POST /api/v1/user/task', (done) ->
request.post("#{baseURL}/user/task")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.send(params)
.end (res) ->
query = model.query('users').withIdAndToken(currentUser.id, currentUser.apiToken)
query.fetch (err, user) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 201
expect(res.body.id).not.to.be.empty()
# Ensure that user owns the newly created object
expect(user.get().tasks[res.body.id]).to.be.an('object')
done()
it 'POST /api/v1/user/task (without type)', (done) ->
request.post("#{baseURL}/user/task")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.send({})
.end (res) ->
expect(res.body.err).to.be 'type must be habit, todo, daily, or reward'
expect(res.statusCode).to.be 400
done()
it 'POST /api/v1/user/task (only type)', (done) ->
request.post("#{baseURL}/user/task")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.send(type: 'habit')
.end (res) ->
query = model.query('users').withIdAndToken(currentUser.id, currentUser.apiToken)
query.fetch (err, user) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 201
expect(res.body.id).not.to.be.empty()
# Ensure that user owns the newly created object
expect(user.get().tasks[res.body.id]).to.be.an('object')
done()
it 'PUT /api/v1/user/task/:id', (done) ->
tid = _.pluck(currentUser.tasks, 'id')[0]
request.put("#{baseURL}/user/task/#{tid}")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.send(text: 'bye')
.end (res) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 200
currentUser.tasks[tid].text = 'bye'
expect(res.body).to.eql currentUser.tasks[tid]
done()
it 'PUT /api/v1/user/task/:id (shouldnt update type)', (done) ->
tid = _.pluck(currentUser.tasks, 'id')[1]
type = if currentUser.tasks[tid].type is 'habit' then 'daily' else 'habit'
request.put("#{baseURL}/user/task/#{tid}")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.send(type: type, text: 'fishman')
.end (res) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 200
currentUser.tasks[tid].text = 'fishman'
expect(res.body).to.eql currentUser.tasks[tid]
done()
it 'PUT /api/v1/user/task/:id (update notes)', (done) ->
tid = _.pluck(currentUser.tasks, 'id')[2]
request.put("#{baseURL}/user/task/#{tid}")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.send(text: 'hi',notes:'foobar matey')
.end (res) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 200
currentUser.tasks[tid].text = 'hi'
currentUser.tasks[tid].notes = 'foobar matey'
expect(res.body).to.eql currentUser.tasks[tid]
done()
it 'GET /api/v1/user/tasks', (done) ->
request.get("#{baseURL}/user/tasks")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.end (res) ->
query = model.query('users').withIdAndToken(currentUser.id, currentUser.apiToken)
query.fetch (err, user) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 200
model.ref '_user', user
tasks = []
for type in ['habit','todo','daily','reward']
model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids"
tasks = tasks.concat model.get("_#{type}List")
# Ensure that user owns the tasks
expect(res.body.length).to.equal tasks.length
# Ensure that the two sets are equal
expect(_.difference(_.pluck(res.body,'id'), _.pluck(tasks,'id')).length).to.equal 0
done()
it 'GET /api/v1/user/tasks (todos)', (done) ->
request.get("#{baseURL}/user/tasks")
.set('Accept', 'application/json')
.set('X-API-User', currentUser.id)
.set('X-API-Key', currentUser.apiToken)
.query(type:'todo')
.end (res) ->
query = model.query('users').withIdAndToken(currentUser.id, currentUser.apiToken)
query.fetch (err, user) ->
expect(res.body.err).to.be undefined
expect(res.statusCode).to.be 200
model.ref '_user', user
model.refList "_todoList", "_user.tasks", "_user.todoIds"
tasks = model.get("_todoList")
# Ensure that user owns the tasks
expect(res.body.length).to.equal tasks.length
# Ensure that the two sets are equal
expect(_.difference(_.pluck(res.body,'id'), _.pluck(tasks,'id')).length).to.equal 0
done()

View file

@ -12,8 +12,8 @@ casper.start "#{url}/?play=1", ->
@fill 'form#derby-auth-register', @fill 'form#derby-auth-register',
username: user1.id username: user1.id
email: "{user1.id}@gmail.com" email: "{user1.id}@gmail.com"
'email-confirmation': "{user1.id}@gmail.com"
password: 'habitrpg123' password: 'habitrpg123'
'password-confirmation': "habitrpg123"
, true , true
casper.thenOpen "#{url}/logout" casper.thenOpen "#{url}/logout"
casper.thenOpen "#{url}/?play=1", -> casper.thenOpen "#{url}/?play=1", ->

View file

@ -1,6 +1,7 @@
--colors --colors
--reporter spec --reporter spec
--timeout 1200 --timeout 2800
--ignore-leaks
--growl --growl
--debug --debug
--compilers coffee:coffee-script --compilers coffee:coffee-script

View file

@ -8,7 +8,7 @@ moment = require 'moment'
# Custom modules # Custom modules
scoring = require '../src/app/scoring' scoring = require '../src/app/scoring'
schema = require '../src/app/schema' schema = require '../src/app/schema'
###### Helpers & Variables ###### ###### Helpers & Variables ######
model = null model = null
@ -19,11 +19,11 @@ taskPath = null
# Otherwise, using model.get(path) will give the same object before as after # Otherwise, using model.get(path) will give the same object before as after
pathSnapshots = (paths) -> pathSnapshots = (paths) ->
if _.isString(paths) if _.isString(paths)
return clone(model.get(paths)) return clone(model.get(paths))
_.map paths, (path) -> clone(model.get(path)) _.map paths, (path) -> clone(model.get(path))
statsTask = -> pathSnapshots(['_user.stats', taskPath]) # quick snapshot of user.stats & task statsTask = -> pathSnapshots(['_user.stats', taskPath]) # quick snapshot of user.stats & task
cleanUserObj = -> cleanUserObj = ->
userObj = schema.newUserObject() userObj = schema.newUserObject()
userObj.tasks = {} userObj.tasks = {}
userObj.habitIds = [] userObj.habitIds = []
@ -42,14 +42,14 @@ freshTask = (taskObj) ->
model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids" model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids"
[taskObj.id, taskObj.value] = [uuid, 0] [taskObj.id, taskObj.value] = [uuid, 0]
model.at("_#{type}List").push taskObj model.at("_#{type}List").push taskObj
### ###
Helper function to determine if stats updates are numerically correct based on scoring Helper function to determine if stats updates are numerically correct based on scoring
@direction: 'up' or 'down' @direction: 'up' or 'down'
@options: The user stats modifiers and times to run, defaults to {times:1, modifiers:{lvl:1, weapon:0, armor:0}} @options: The user stats modifiers and times to run, defaults to {times:1, modifiers:{lvl:1, weapon:0, armor:0}}
### ###
modificationsLookup = (direction, options = {}) -> modificationsLookup = (direction, options = {}) ->
merged = _.defaults options, {times:1, lvl:1, weapon:0, armor:0} merged = _.defaults options, {times:1, lvl:1, weapon:0, armor:0}
{times, lvl, armor, weapon} = merged {times, lvl, armor, weapon} = merged
userObj = cleanUserObj() userObj = cleanUserObj()
value = 0 value = 0
@ -64,12 +64,12 @@ modificationsLookup = (direction, options = {}) ->
loss = scoring.hpModifier(delta, options) loss = scoring.hpModifier(delta, options)
userObj.stats.hp += loss userObj.stats.hp += loss
return {user:userObj, value:value} return {user:userObj, value:value}
###### Specs ###### ###### Specs ######
describe 'User', -> describe 'User', ->
model = null model = null
before -> before ->
model = new Model model = new Model
model.set '_user', schema.newUserObject() model.set '_user', schema.newUserObject()
@ -85,23 +85,23 @@ describe 'User', ->
expect(_.size(user.dailyIds)).to.eql 3 expect(_.size(user.dailyIds)).to.eql 3
expect(_.size(user.todoIds)).to.eql 1 expect(_.size(user.todoIds)).to.eql 1
expect(_.size(user.rewardIds)).to.eql 2 expect(_.size(user.rewardIds)).to.eql 2
##### Habits ##### ##### Habits #####
describe 'Tasks', -> describe 'Tasks', ->
beforeEach -> beforeEach ->
resetUser() resetUser()
describe 'Habits', -> describe 'Habits', ->
beforeEach -> beforeEach ->
freshTask {type: 'habit', text: 'Habit', up: true, down: true} freshTask {type: 'habit', text: 'Habit', up: true, down: true}
it 'created the habit', -> it 'created the habit', ->
task = model.get(taskPath) task = model.get(taskPath)
expect(task.text).to.eql 'Habit' expect(task.text).to.eql 'Habit'
expect(task.value).to.eql 0 expect(task.value).to.eql 0
it 'test a few scoring numbers (this will change if constants / formulae change)', -> it 'test a few scoring numbers (this will change if constants / formulae change)', ->
{user} = modificationsLookup('down') {user} = modificationsLookup('down')
expect(user.stats.hp).to.eql 49 expect(user.stats.hp).to.eql 49
@ -113,10 +113,10 @@ describe 'User', ->
expect(user.stats.money).to.eql 1 expect(user.stats.money).to.eql 1
{user} = modificationsLookup('up', {times:5}) {user} = modificationsLookup('up', {times:5})
expect(user.stats.exp).to.be.within(4,5) expect(user.stats.exp).to.be.within(4,5)
it 'made proper modifications when down-scored', -> it 'made proper modifications when down-scored', ->
## Trial 1 ## Trial 1
shouldBe = modificationsLookup('down') shouldBe = modificationsLookup('down')
scoring.score(uuid,'down') scoring.score(uuid,'down')
[stats, task] = statsTask() [stats, task] = statsTask()
@ -130,14 +130,14 @@ describe 'User', ->
[stats, task] = statsTask() [stats, task] = statsTask()
expect(stats.hp).to.be.eql shouldBe.user.stats.hp expect(stats.hp).to.be.eql shouldBe.user.stats.hp
expect(task.value).to.eql shouldBe.value expect(task.value).to.eql shouldBe.value
it 'made proper modifications when up-scored', -> it 'made proper modifications when up-scored', ->
# Up-score the habit # Up-score the habit
[statsBefore, taskBefore] = statsTask() [statsBefore, taskBefore] = statsTask()
scoring.score(uuid, 'up') scoring.score(uuid, 'up')
[statsAfter, taskAfter] = statsTask() [statsAfter, taskAfter] = statsTask()
# User should have gained Exp, GP # User should have gained Exp, GP
expect(statsAfter.exp).to.be.greaterThan statsBefore.exp expect(statsAfter.exp).to.be.greaterThan statsBefore.exp
expect(statsAfter.money).to.be.greaterThan statsBefore.money expect(statsAfter.money).to.be.greaterThan statsBefore.money
# HP should not change # HP should not change
@ -145,9 +145,9 @@ describe 'User', ->
# Task should have lost value # Task should have lost value
expect(taskBefore.value).to.eql 0 expect(taskBefore.value).to.eql 0
expect(taskAfter.value).to.be.greaterThan taskBefore.value expect(taskAfter.value).to.be.greaterThan taskBefore.value
## Trial 2 ## Trial 2
taskBefore = pathSnapshots(taskPath) taskBefore = pathSnapshots(taskPath)
scoring.score(uuid, 'up') scoring.score(uuid, 'up')
taskAfter = pathSnapshots(taskPath) taskAfter = pathSnapshots(taskPath)
# Should have lost in value # Should have lost in value
@ -155,54 +155,54 @@ describe 'User', ->
# And lost more than trial 1 # And lost more than trial 1
diff = Math.abs(taskAfter.value) - Math.abs(taskBefore.value) diff = Math.abs(taskAfter.value) - Math.abs(taskBefore.value)
expect(diff).to.be.lessThan 1 expect(diff).to.be.lessThan 1
it 'makes history entry for habit' it 'makes history entry for habit'
it 'makes proper modifications each time when clicking + / - in rapid succession' it 'makes proper modifications each time when clicking + / - in rapid succession'
# saw an issue here once, so test that it wasn't a fluke # saw an issue here once, so test that it wasn't a fluke
it 'should not modify certain attributes given certain conditions' it 'should not modify certain attributes given certain conditions'
# non up+down habits # non up+down habits
# what else? # what else?
it 'should show "undo" notification if user unchecks completed daily' it 'should show "undo" notification if user unchecks completed daily'
describe 'Lvl & Items', -> describe 'Lvl & Items', ->
beforeEach -> beforeEach ->
freshTask {type: 'habit', text: 'Habit', up: true, down: true} freshTask {type: 'habit', text: 'Habit', up: true, down: true}
it 'modified damage based on lvl & armor' it 'modified damage based on lvl & armor'
it 'modified exp/gp based on lvl & weapon' it 'modified exp/gp based on lvl & weapon'
it 'always decreases hp with damage, regardless of stats/items' it 'always decreases hp with damage, regardless of stats/items'
it 'always increases exp/gp with gain, regardless of stats/items' it 'always increases exp/gp with gain, regardless of stats/items'
describe 'Dailies', -> describe 'Dailies', ->
beforeEach -> beforeEach ->
freshTask {type: 'daily', text: 'Daily', completed: false} freshTask {type: 'daily', text: 'Daily', completed: false}
it 'created the daily', -> it 'created the daily', ->
task = model.get(taskPath) task = model.get(taskPath)
expect(task.text).to.eql 'Daily' expect(task.text).to.eql 'Daily'
expect(task.value).to.eql 0 expect(task.value).to.eql 0
it 'does proper calculations when daily is complete' it 'does proper calculations when daily is complete'
it 'calculates dailys properly when they have repeat dates' it 'calculates dailys properly when they have repeat dates'
runCron = (times, pass=1) -> runCron = (times, pass=1) ->
# Set lastCron to days ago # Set lastCron to days ago
today = new moment() today = new moment()
ago = new moment().subtract('days',times) ago = new moment().subtract('days',times)
model.set '_user.lastCron', ago.toDate() model.set '_user.lastCron', ago.toDate()
# Run run # Run run
scoring.cron() scoring.cron()
[stats, task] = statsTask() [stats, task] = statsTask()
# Should have updated cron to today # Should have updated cron to today
lastCron = moment(model.get('_user.lastCron')) lastCron = moment(model.get('_user.lastCron'))
expect(today.diff(lastCron, 'days')).to.eql 0 expect(today.diff(lastCron, 'days')).to.eql 0
shouldBe = modificationsLookup('down', {times:times*pass}) shouldBe = modificationsLookup('down', {times:times*pass})
# Should have updated points properly # Should have updated points properly
expect(Math.round(stats.hp)).to.be.eql Math.round(shouldBe.user.stats.hp) expect(Math.round(stats.hp)).to.be.eql Math.round(shouldBe.user.stats.hp)
@ -215,10 +215,10 @@ describe 'User', ->
runCron(5) runCron(5)
runCron(5, 2) runCron(5, 2)
#TODO clicking repeat dates on newly-created item doesn't refresh until you refresh the page #TODO clicking repeat dates on newly-created item doesn't refresh until you refresh the page
#TODO dates on dailies is having issues, possibility: date cusps? my saturday exempts were set to exempt at 8pm friday #TODO dates on dailies is having issues, possibility: date cusps? my saturday exempts were set to exempt at 8pm friday
describe 'Todos', -> describe 'Todos', ->
describe 'Cron', -> describe 'Cron', ->
it 'calls cron asyncronously' it 'calls cron asyncronously'
@ -230,11 +230,11 @@ describe 'User', ->
# stop passing in tallyFor, let moment().sod().toDate() be handled in scoring.score() # stop passing in tallyFor, let moment().sod().toDate() be handled in scoring.score()
it 'should defer saving user modifications until, save as aggregate values' it 'should defer saving user modifications until, save as aggregate values'
# pass in commit parameter to scoring func, if true save right away, otherwise return aggregated array so can save in the end (so total hp loss, etc) # pass in commit parameter to scoring func, if true save right away, otherwise return aggregated array so can save in the end (so total hp loss, etc)
describe 'Rewards', -> describe 'Rewards', ->
#### Require.js stuff, might be necessary to place in casper.coffee #### Require.js stuff, might be necessary to place in casper.coffee
it "doesn't setup dependent functions until their modules are loaded, require.js callback" it "doesn't setup dependent functions until their modules are loaded, require.js callback"
# sortable, stripe, etc # sortable, stripe, etc
#TODO refactor as user->habits, user->dailys, user->todos, user->rewards #TODO refactor as user->habits, user->dailys, user->todos, user->rewards