├── .gitignore ├── .travis.yml ├── Gruntfile.js ├── README.md ├── lib ├── fitbit.js └── resources │ ├── activities.js │ ├── body.js │ ├── devices.js │ ├── foods.js │ ├── resource.js │ └── sleep.js ├── package.json └── spec ├── fitbit-spec.js ├── fixtures.js ├── mocks.js ├── module-loader.js └── resource-spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | pids 11 | logs 12 | results 13 | 14 | npm-debug.log 15 | node_modules 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | before_script: 3 | - "npm install -g grunt-cli" 4 | node_js: 5 | - "5" 6 | - "4.3" 7 | - "0.10" 8 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | grunt.initConfig({ 3 | jshint: { 4 | options: { 5 | laxcomma: true, 6 | supernew: true, 7 | expr: true 8 | }, 9 | all: ['Gruntfile.js', 'spec/**/*.js', 'src/**/*.js'] 10 | }, 11 | jasmine_node: { 12 | specNameMatcher: "spec", 13 | extensions: 'js', 14 | projectRoot: ".", 15 | requirejs: false, 16 | forceExit: true, 17 | jUnit: { 18 | report: false, 19 | savePath : "./build/reports/jasmine/", 20 | useDotNotation: true, 21 | consolidate: true 22 | } 23 | } 24 | }); 25 | 26 | grunt.loadNpmTasks('grunt-jasmine-node'); 27 | grunt.loadNpmTasks('grunt-contrib-jshint'); 28 | 29 | grunt.registerTask('default', 'jasmine_node'); 30 | grunt.registerTask('test', ['jshint', 'jasmine_node']); 31 | }; 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Fitbit API Client for Node.js [![Build Status](https://travis-ci.org/p-m-p/node-fitbit.png?branch=master)](https://travis-ci.org/p-m-p/node-fitbit) 2 | === 3 | 4 | Currently a read only implementation for reading data from the Fitbit API 5 | as an authenticated user. 6 | 7 | ### TODO 8 | 9 | I've split this into two sections, top section is what I need to have for the 10 | project I created this module for and the latter is what the API supports. 11 | 12 | Needed by me: 13 | 14 | * Add user model 15 | * Add time series data for models 16 | * Allow data models to be updated("logged") and deleted via the API 17 | 18 | Supported by API: 19 | 20 | * Add Blood pressure, heart rate, glucose resource models 21 | * Add goals for all models that support them 22 | * Collection metadata models 23 | * Anything else... 24 | 25 | ### Installation 26 | 27 | `npm install fitbit` 28 | 29 | ### Usage 30 | 31 | Below is an example usage for authenticating and making a resource request: 32 | 33 | ```javascript 34 | var express = require('express') 35 | , config = require('./config/app') 36 | , app = express() 37 | , Fitbit = require('fitbit'); 38 | 39 | app.use(express.cookieParser()); 40 | app.use(express.session({secret: 'hekdhthigib'})); 41 | app.listen(3000); 42 | 43 | // OAuth flow 44 | app.get('/', function (req, res) { 45 | // Create an API client and start authentication via OAuth 46 | var client = new Fitbit(config.CONSUMER_KEY, config.CONSUMER_SECRET); 47 | 48 | client.getRequestToken(function (err, token, tokenSecret) { 49 | if (err) { 50 | // Take action 51 | return; 52 | } 53 | 54 | req.session.oauth = { 55 | requestToken: token 56 | , requestTokenSecret: tokenSecret 57 | }; 58 | res.redirect(client.authorizeUrl(token)); 59 | }); 60 | }); 61 | 62 | // On return from the authorization 63 | app.get('/oauth_callback', function (req, res) { 64 | var verifier = req.query.oauth_verifier 65 | , oauthSettings = req.session.oauth 66 | , client = new Fitbit(config.CONSUMER_KEY, config.CONSUMER_SECRET); 67 | 68 | // Request an access token 69 | client.getAccessToken( 70 | oauthSettings.requestToken 71 | , oauthSettings.requestTokenSecret 72 | , verifier 73 | , function (err, token, secret) { 74 | if (err) { 75 | // Take action 76 | return; 77 | } 78 | 79 | oauthSettings.accessToken = token; 80 | oauthSettings.accessTokenSecret = secret; 81 | 82 | res.redirect('/stats'); 83 | } 84 | ); 85 | }); 86 | 87 | // Display some stats 88 | app.get('/stats', function (req, res) { 89 | client = new Fitbit( 90 | config.CONSUMER_KEY 91 | , config.CONSUMER_SECRET 92 | , { // Now set with access tokens 93 | accessToken: req.session.oauth.accessToken 94 | , accessTokenSecret: req.session.oauth.accessTokenSecret 95 | , unitMeasure: 'en_GB' 96 | } 97 | ); 98 | 99 | // Fetch todays activities 100 | client.getActivities(function (err, activities) { 101 | if (err) { 102 | // Take action 103 | return; 104 | } 105 | 106 | // `activities` is a Resource model 107 | res.send('Total steps today: ' + activities.steps()); 108 | }); 109 | }); 110 | ``` 111 | -------------------------------------------------------------------------------- /lib/fitbit.js: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | var oauth = require('oauth') 3 | , _ = require('lodash'); 4 | 5 | // Resource Models 6 | var resources = { 7 | Activities: require('./resources/activities') 8 | , Devices: require('./resources/devices') 9 | , Sleep: require('./resources/sleep') 10 | , Foods: require('./resources/foods') 11 | }; 12 | // Includes all body resource Models 13 | _.extend(resources, require('./resources/body')); 14 | 15 | // API Endpoints 16 | var endpoints = { 17 | // TODO allow user id for unauthenticated calls 18 | resources: 'https://api.fitbit.com/1/user/-/' 19 | , requestToken: 'https://api.fitbit.com/oauth/request_token' 20 | , accessToken: 'https://api.fitbit.com/oauth/access_token' 21 | , authorize: 'https://www.fitbit.com/oauth/authorize' 22 | }; 23 | 24 | // API Client 25 | // --- 26 | // 27 | // Constructor for a new API Client. `options.accessToken` and 28 | // `options.accessTokenSecret` can be supplied for pre-authenticated users 29 | var FitbitApiClient = function (consumerKey, consumerSecret, options) { 30 | options || (options = {}); 31 | 32 | this._oauth = new oauth.OAuth( 33 | endpoints.requestToken 34 | , endpoints.accessToken 35 | , consumerKey 36 | , consumerSecret 37 | , '1.0' 38 | , null 39 | , 'HMAC-SHA1' 40 | ); 41 | 42 | this.setUnitMeasure(options.unitMeasure); 43 | 44 | // Set authenticated user access token if set 45 | if (options.accessToken) { 46 | this.accessToken = options.accessToken; 47 | this.accessTokenSecret = options.accessTokenSecret; 48 | } 49 | }; 50 | 51 | var cp = FitbitApiClient.prototype; 52 | module.exports = FitbitApiClient; 53 | 54 | // OAuth flow 55 | // --- 56 | 57 | // Fetches a request token and runs callback 58 | cp.getRequestToken = function (extraParams, callback) { 59 | if (typeof extraParams === 'function') { 60 | callback = extraParams; 61 | extraParams = {}; 62 | } 63 | 64 | this._oauth.getOAuthRequestToken(extraParams, callback); 65 | }; 66 | 67 | // Returns the authorization url required for obtaining an access token 68 | cp.authorizeUrl = function (requestToken) { 69 | return endpoints.authorize + '?oauth_token=' + requestToken; 70 | }; 71 | 72 | // Fetches an access token and runs callback 73 | cp.getAccessToken = function (token, tokenSecret, verifier, callback) { 74 | this._oauth.getOAuthAccessToken(token, tokenSecret, verifier, callback); 75 | }; 76 | 77 | // Makes an authenticated call to the Fitbit API 78 | cp.apiCall = function (url, callback) { 79 | var that = this; 80 | 81 | if (!this.accessToken || !this.accessTokenSecret) { 82 | throw new Error('Authenticate before making API calls'); 83 | } 84 | 85 | this._oauth.get(url, this.accessToken, this.accessTokenSecret, 86 | function (err, data, response) { 87 | callback.call(that, err, data); 88 | }); 89 | }; 90 | 91 | // Resources 92 | // --- 93 | 94 | // Sets the Unit Measure type for all subsequent API calls 95 | cp.setUnitMeasure = function (unitType) { 96 | if (!_.contains([null, void 0, 'en_US', 'en_GB'], unitType)) { 97 | throw new Error('Unit Measure type must be en_US, en_GB or null') 98 | } 99 | 100 | // Remove any previously set unit measure to reset to Metric 101 | if (unitType == null) { 102 | this._oauth.removeCustomHeader('Accept-Language'); 103 | } 104 | // Set unit measure for all further request 105 | else { 106 | this._oauth.setCustomHeader('Accept-Language', unitType); 107 | } 108 | 109 | return this; 110 | }; 111 | 112 | // Set up a `get[ResourceName]` method for each type of resource in `resources` 113 | // 114 | // Fetches the data for and returns the corresponding resource model object for 115 | // each resource type on a given date set in `options`. If `options.date` is 116 | // not supplied then activities for the current day will be supplied to 117 | // `callback` 118 | // 119 | // `callback` parameters are `error`, `Resource` and `data` where `Resource` 120 | // is a model resource object and data is the raw response data 121 | _.each(resources, function (modelClass, modelName) { 122 | cp['get' + modelName] = function (options, callback) { 123 | this._getResource(modelName.toLowerCase(), modelClass, options, callback); 124 | }; 125 | }); 126 | 127 | // Generic method for fetching a resource 128 | cp._getResource = function (type, Resource, options, callback) { 129 | if (_.isFunction(options)) { 130 | callback = options; 131 | options = {}; 132 | } 133 | 134 | this.apiCall( 135 | helpers.resourceUrl(type, options.date) 136 | , function (err, data) { 137 | // Scope callbacks to undefined 138 | var ctx = void 0; 139 | 140 | if (err) { 141 | return callback.call(ctx, err); 142 | } 143 | 144 | callback.call(ctx, err, new Resource(JSON.parse(data)), data); 145 | } 146 | ); 147 | }; 148 | 149 | // Helpers 150 | // --- 151 | var helpers = { 152 | // Returns the API endpoint for a `resource` such as 'activities' or 'food'. 153 | // If `date` is not supplied todays date will be used. 154 | resourceUrl: function (resource, date) { 155 | var resourceSection = resource; 156 | date || (date = new Date); 157 | 158 | // Normalize the log resource types 159 | if (_.contains(['bodyweight', 'bodyfat', 'foods'], resource)) { 160 | resourceSection = resource 161 | .replace(/^(body|foods)(.*)$/, '$1/log/$2') 162 | .replace(/\/$/, ''); 163 | } 164 | else if (resource === 'bodymeasurements') { 165 | resourceSection = 'body'; 166 | } 167 | 168 | return ( 169 | endpoints.resources + 170 | resourceSection + 171 | '/date/' + 172 | this.formatDate(date) + '.json' 173 | ); 174 | }, 175 | 176 | // Returns date formatted for making API calls. 177 | formatDate: function (date) { 178 | var day, month; 179 | 180 | if (!(date instanceof Date)) { 181 | date = new Date(date); 182 | } 183 | 184 | month = date.getMonth() + 1; 185 | month < 10 && (month = '0' + month); 186 | day = date.getDate(); 187 | day < 10 && (day = '0' + day); 188 | 189 | return date.getFullYear() + '-' + month + '-' + day; 190 | } 191 | }; 192 | -------------------------------------------------------------------------------- /lib/resources/activities.js: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | var Resource = require('./resource') 3 | , _ = require('lodash'); 4 | 5 | // Activites 6 | // --- 7 | // 8 | // Model for activities resource 9 | var Activities = Resource.extend({ 10 | // Returns the step total from summary 11 | steps: function () { 12 | return this.getSummaryItem('steps'); 13 | }, 14 | 15 | // Returns the floors climbed from summary 16 | floors: function () { 17 | return this.getSummaryItem('floors'); 18 | }, 19 | 20 | // Returns the active score from summary 21 | activeScore: function () { 22 | return this.getSummaryItem('activeScore'); 23 | }, 24 | 25 | // Returns the total distance travelled 26 | totalDistance: function () { 27 | var total = _.find(this.getSummaryItem('distances'), { 28 | activity: 'total' 29 | }); 30 | 31 | return total ? total.distance : 0; 32 | } 33 | }); 34 | 35 | module.exports = Activities; -------------------------------------------------------------------------------- /lib/resources/body.js: -------------------------------------------------------------------------------- 1 | var Resource = require('./resource'); 2 | 3 | module.exports = { 4 | BodyWeight: Resource.extend({}) 5 | , BodyMeasurements: Resource.extend({}) 6 | , BodyFat: Resource.extend({}) 7 | }; 8 | -------------------------------------------------------------------------------- /lib/resources/devices.js: -------------------------------------------------------------------------------- 1 | var Resource = require('./resource') 2 | , _ = require('lodash'); 3 | 4 | // Devices 5 | // --- 6 | // 7 | // Model for devices resource 8 | var Devices = Resource.extend({ 9 | // Returns the step total from summary 10 | device: function (version) { 11 | return _.find(this._attributes, { deviceVersion: version }); 12 | } 13 | }); 14 | 15 | module.exports = Devices; 16 | -------------------------------------------------------------------------------- /lib/resources/foods.js: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | var Resource = require('./resource'); 3 | 4 | // Food 5 | // --- 6 | // 7 | // Model for food resource 8 | module.exports = Resource.extend({}); 9 | -------------------------------------------------------------------------------- /lib/resources/resource.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | 3 | var Resource = function (data) { 4 | this._attributes = data; 5 | }; 6 | 7 | Resource.prototype = { 8 | // Returns a specific attribute 9 | get: function (attribute) { 10 | return this._attributes[attribute]; 11 | }, 12 | 13 | // Returns `item` from the summary 14 | getSummaryItem: function (item) { 15 | var summary = this.get('summary'); 16 | 17 | if (summary) { 18 | return summary[item]; 19 | } 20 | 21 | throw new Error('Resource does not contain summary data'); 22 | } 23 | }; 24 | 25 | // Creates a new resource type by extending the base resource 26 | Resource.extend = function (props) { 27 | var parent = this 28 | , child = function () { return parent.apply(this, arguments); } 29 | , tmp = function () { this.constructor = child; }; 30 | 31 | tmp.prototype = parent.prototype; 32 | child.prototype = new tmp; 33 | _.extend(child.prototype, props); 34 | 35 | return child; 36 | }; 37 | 38 | module.exports = Resource; 39 | -------------------------------------------------------------------------------- /lib/resources/sleep.js: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | var Resource = require('./resource'); 3 | 4 | // Activites 5 | // --- 6 | // 7 | // Model for activities resource 8 | var Sleep = Resource.extend({ 9 | // Returns the total amount of time in bed 10 | timeInBed: function () { 11 | return this.getSummaryItem('totalTimeInBed'); 12 | }, 13 | 14 | // Returns the total amount of time asleep in minutes 15 | minutesAsleep: function () { 16 | return this.getSummaryItem('totalMinutesAsleep'); 17 | }, 18 | 19 | // Returns the total amount of time asleep in hours 20 | hoursAndMinutesAsleep: function () { 21 | var mins = this.minutesAsleep(); 22 | 23 | return { 24 | hours: Math.floor(mins / 60) 25 | , mins: mins % 60 26 | }; 27 | } 28 | }); 29 | 30 | module.exports = Sleep; 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fitbit", 3 | "version": "0.0.7", 4 | "description": "Fitbit API client", 5 | "main": "lib/fitbit.js", 6 | "scripts": { 7 | "test": "grunt test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/p-m-p/node-fitbit.git" 12 | }, 13 | "keywords": [ 14 | "fitbit" 15 | ], 16 | "author": "Phil Parsons", 17 | "license": "MIT", 18 | "readmeFilename": "README.md", 19 | "gitHead": "75eaae79c2d84ef0df1b024f1723c67014fc4bdb", 20 | "dependencies": { 21 | "oauth": "p-m-p/node-oauth", 22 | "lodash": "~1.3.1" 23 | }, 24 | "devDependencies": { 25 | "grunt": "~0.4.1", 26 | "grunt-jasmine-node": "~0.1.0", 27 | "grunt-contrib-jshint": "~0.6.0", 28 | "sinon": "~1.7.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /spec/fitbit-spec.js: -------------------------------------------------------------------------------- 1 | describe('Fitbit API Client', function () { 2 | var loader = require('./module-loader') 3 | , sinon = require('sinon') 4 | , mocks = require('./mocks') 5 | , fixtures = require('./fixtures') 6 | , FitbitModule = loader.loadModule(__dirname + '/../lib/fitbit.js', { 7 | oauth: mocks.oauth 8 | }) 9 | , authProto = mocks.oauth.OAuth.prototype 10 | , helpers = FitbitModule.helpers 11 | , client; 12 | 13 | describe('authentication flow', function () { 14 | beforeEach(function () { 15 | client = new FitbitModule.FitbitApiClient( 16 | 'consumerKey' 17 | , 'consumerSecret' 18 | ); 19 | }); 20 | 21 | it('should get a request token', function () { 22 | var callback = sinon.spy(); 23 | sinon.stub(authProto, 'getOAuthRequestToken', function (extra, cb) { 24 | cb.call(void 0, null, 'token', 'tokenSecret'); 25 | }); 26 | client.getRequestToken(callback); 27 | 28 | expect(callback.calledWith(null, 'token', 'tokenSecret')).toBe(true); 29 | authProto.getOAuthRequestToken.restore(); 30 | }); 31 | 32 | it('should return the authorization url', function () { 33 | expect(client.authorizeUrl('token')) 34 | .toBe('https://www.fitbit.com/oauth/authorize?oauth_token=token'); 35 | }); 36 | 37 | it('should get an access token', function () { 38 | var callback = sinon.spy(); 39 | sinon.stub(authProto, 'getOAuthAccessToken', function (r, rs, v, cb) { 40 | expect(r).toBe('requestToken'); 41 | expect(rs).toBe('requestTokenSecret'); 42 | expect(v).toBe('verifier'); 43 | 44 | cb.call(void 0, null, 'token', 'tokenSecret'); 45 | }); 46 | client.getAccessToken( 47 | 'requestToken' 48 | , 'requestTokenSecret' 49 | , 'verifier' 50 | , callback 51 | ); 52 | 53 | expect(callback.calledWith(null, 'token', 'tokenSecret')).toBe(true); 54 | authProto.getOAuthAccessToken.restore(); 55 | }); 56 | 57 | it('should error trying to make an unauthorized API call', function () { 58 | try { 59 | client.apiCall('https://api.endpoint', function () {}); 60 | } 61 | catch (ex) { 62 | expect(ex.message).toBe('Authenticate before making API calls'); 63 | } 64 | }); 65 | }); 66 | 67 | describe('when authenticated', function () { 68 | beforeEach(function () { 69 | client = new FitbitModule.FitbitApiClient( 70 | 'consumerKey' 71 | , 'consumerSecret' 72 | , { 73 | accessToken: 'accessToken' 74 | , accessTokenSecret: 'accessTokenSecret' 75 | } 76 | ); 77 | }); 78 | 79 | it('should set the unit measure', function () { 80 | var oa = client._oauth; 81 | sinon.stub(authProto, 'setCustomHeader'); 82 | sinon.stub(authProto, 'removeCustomHeader'); 83 | 84 | expect(client.setUnitMeasure('en_GB')).toBe(client); 85 | expect(oa.setCustomHeader.calledWithExactly('Accept-Language', 'en_GB')) 86 | .toBe(true); 87 | 88 | oa.setCustomHeader.reset(); 89 | expect(client.setUnitMeasure('en_US')).toBe(client); 90 | expect(oa.setCustomHeader.calledWithExactly('Accept-Language', 'en_US')) 91 | .toBe(true); 92 | 93 | oa.setCustomHeader.reset(); 94 | expect(client.setUnitMeasure(null)).toBe(client); 95 | expect(oa.setCustomHeader.called).toBe(false); 96 | expect(oa.removeCustomHeader.calledWithExactly('Accept-Language')) 97 | .toBe(true); 98 | 99 | oa.removeCustomHeader.reset(); 100 | expect(client.setUnitMeasure()).toBe(client); 101 | expect(oa.setCustomHeader.called).toBe(false); 102 | expect(oa.removeCustomHeader.calledWithExactly('Accept-Language')) 103 | .toBe(true); 104 | 105 | try { 106 | client.setUnitMeasure('parp'); 107 | } 108 | catch (ex) { 109 | expect(ex.message).toBe('Unit Measure type must be en_US, en_GB or null'); 110 | } 111 | }); 112 | 113 | it('should make an API call', function () { 114 | var callback = sinon.spy() 115 | , data = '{ "data": ["one", "two", "three"] }'; 116 | sinon.stub(authProto, 'get', function (u, t, ts, cb) { 117 | expect(u).toBe('https://api.endpoint'); 118 | expect(t).toBe('accessToken'); 119 | expect(ts).toBe('accessTokenSecret'); 120 | 121 | cb.call(void 0, null, data); 122 | }); 123 | client.apiCall('https://api.endpoint', callback); 124 | 125 | expect(callback.calledWith(null, data)).toBe(true); 126 | expect(callback.calledOn(client)).toBe(true); 127 | authProto.get.restore(); 128 | }); 129 | 130 | it('should fetch activities for a day', function () { 131 | var callback = sinon.spy() 132 | , data = fixtures.raw('activities'); 133 | sinon.stub(authProto, 'get', function (u, t, ts, cb) { 134 | expect(u).toBe('https://api.fitbit.com/1/user/-/activities/date/2013-06-23.json'); 135 | 136 | cb.call(void 0, null, data); 137 | }); 138 | client.getActivities({date: '2013-06-23'}, callback); 139 | 140 | expect(callback.calledOn(void 0)).toBe(true); 141 | expect(callback.args[0][1] instanceof FitbitModule.resources.Activities).toBe(true); 142 | expect(callback.args[0][2]).toBe(data); 143 | authProto.get.restore(); 144 | }); 145 | 146 | it('should fetch activities without options', function () { 147 | var callback = sinon.spy() 148 | , data = fixtures.raw('activities') 149 | , today = helpers.formatDate(new Date); 150 | sinon.stub(authProto, 'get', function (u, t, ts, cb) { 151 | expect(u).toBe('https://api.fitbit.com/1/user/-/activities/date/' + today + '.json'); 152 | 153 | cb.call(void 0, null, data); 154 | }); 155 | client.getActivities(callback); 156 | 157 | expect(callback.calledOn(void 0)).toBe(true); 158 | expect(callback.args[0][1] instanceof FitbitModule.resources.Activities).toBe(true); 159 | expect(callback.args[0][2]).toBe(data); 160 | authProto.get.restore(); 161 | }); 162 | 163 | it('should fail to fetch activities', function () { 164 | var callback = sinon.spy() 165 | , error = new Error('Failed to load activities'); 166 | sinon.stub(authProto, 'get', function (u, t, ts, cb) { 167 | cb.call(void 0, error); 168 | }); 169 | client.getActivities(callback); 170 | 171 | expect(callback.calledOn(void 0)).toBe(true); 172 | expect(callback.calledWithExactly(error)).toBe(true); 173 | authProto.get.restore(); 174 | }); 175 | 176 | it('should fetch sleep for a day', function () { 177 | var callback = sinon.spy() 178 | , data = fixtures.raw('sleep'); 179 | sinon.stub(authProto, 'get', function (u, t, ts, cb) { 180 | expect(u).toBe('https://api.fitbit.com/1/user/-/sleep/date/2013-06-23.json'); 181 | 182 | cb.call(void 0, null, data); 183 | }); 184 | client.getSleep({date: '2013-06-23'}, callback); 185 | 186 | expect(callback.calledOn(void 0)).toBe(true); 187 | expect(callback.args[0][1] instanceof FitbitModule.resources.Sleep).toBe(true); 188 | expect(callback.args[0][2]).toBe(data); 189 | authProto.get.restore(); 190 | }); 191 | 192 | it('should fetch sleep without options', function () { 193 | var callback = sinon.spy() 194 | , data = fixtures.raw('sleep') 195 | , today = helpers.formatDate(new Date); 196 | sinon.stub(authProto, 'get', function (u, t, ts, cb) { 197 | expect(u).toBe('https://api.fitbit.com/1/user/-/sleep/date/' + today + '.json'); 198 | 199 | cb.call(void 0, null, data); 200 | }); 201 | client.getSleep(callback); 202 | 203 | expect(callback.calledOn(void 0)).toBe(true); 204 | expect(callback.args[0][1] instanceof FitbitModule.resources.Sleep).toBe(true); 205 | expect(callback.args[0][2]).toBe(data); 206 | authProto.get.restore(); 207 | }); 208 | 209 | it('should fail to fetch sleep', function () { 210 | var callback = sinon.spy() 211 | , error = new Error('Failed to load sleep'); 212 | sinon.stub(authProto, 'get', function (u, t, ts, cb) { 213 | cb.call(void 0, error); 214 | }); 215 | client.getSleep(callback); 216 | 217 | expect(callback.calledOn(void 0)).toBe(true); 218 | expect(callback.calledWithExactly(error)).toBe(true); 219 | authProto.get.restore(); 220 | }); 221 | }); 222 | 223 | describe('helper methods', function () { 224 | it('should format a date', function () { 225 | expect(helpers.formatDate('Sun Jun 23 2013')).toBe('2013-06-23'); 226 | expect(helpers.formatDate('Sun Jun 8 2013')).toBe('2013-06-08'); 227 | expect(helpers.formatDate(new Date('Sun Jun 8 2013'))).toBe('2013-06-08'); 228 | }); 229 | 230 | it('should return a resource url', function () { 231 | var today = helpers.formatDate(new Date); 232 | 233 | expect(helpers.resourceUrl('sleep', 'Sun Jun 23 2013')) 234 | .toBe('https://api.fitbit.com/1/user/-/sleep/date/2013-06-23.json'); 235 | expect(helpers.resourceUrl('activities')) 236 | .toBe('https://api.fitbit.com/1/user/-/activities/date/' + today + '.json'); 237 | expect(helpers.resourceUrl('bodyweight', 'Sun Oct 1 2013')) 238 | .toBe('https://api.fitbit.com/1/user/-/body/log/weight/date/2013-10-01.json'); 239 | expect(helpers.resourceUrl('bodymeasurements', 'Sun Oct 13 2013')) 240 | .toBe('https://api.fitbit.com/1/user/-/body/date/2013-10-13.json'); 241 | expect(helpers.resourceUrl('bodyfat', 'Sun Aug 21 2009')) 242 | .toBe('https://api.fitbit.com/1/user/-/body/log/fat/date/2009-08-21.json'); 243 | expect(helpers.resourceUrl('foods')) 244 | .toBe('https://api.fitbit.com/1/user/-/foods/log/date/' + today + '.json'); 245 | }); 246 | }); 247 | }); 248 | -------------------------------------------------------------------------------- /spec/fixtures.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | activities: { 3 | "activities":[ 4 | { 5 | "activityId":2050, 6 | "activityParentId":2050, 7 | "activityParentName":"Weight lifting (free weight, nautilus or universal-type), power lifting or body building, vigorous effort", 8 | "calories":364, 9 | "description":"", 10 | "duration":3000000, 11 | "hasStartTime":true, 12 | "isFavorite":false, 13 | "logId":27516995, 14 | "name":"Weight lifting (free weight, nautilus or universal-type), power lifting or body building, vigorous effort", 15 | "startTime":"19:10" 16 | } 17 | ], 18 | "goals":{ 19 | "activeScore":1000, 20 | "caloriesOut":2520, 21 | "distance":8.05, 22 | "floors":40, 23 | "steps":10000 24 | }, 25 | "summary":{ 26 | "activeScore":649, 27 | "activityCalories":1021, 28 | "caloriesBMR":1756, 29 | "caloriesOut":2548, 30 | "distances":[ 31 | {"activity":"total","distance":9.81}, 32 | {"activity":"tracker","distance":9.81}, 33 | {"activity":"loggedActivities","distance":0}, 34 | {"activity":"veryActive","distance":2.72}, 35 | {"activity":"moderatelyActive","distance":6.73}, 36 | {"activity":"lightlyActive","distance":0.35}, 37 | {"activity":"sedentaryActive","distance":0} 38 | ], 39 | "elevation":0, 40 | "fairlyActiveMinutes":103, 41 | "floors":0, 42 | "lightlyActiveMinutes":55, 43 | "marginalCalories":730, 44 | "sedentaryMinutes":1249, 45 | "steps":12448, 46 | "veryActiveMinutes":33 47 | } 48 | }, 49 | 50 | sleep: { 51 | "sleep": [{ 52 | "awakeningsCount":13, 53 | "duration":26760000, 54 | "efficiency":94, 55 | "isMainSleep":true, 56 | "logId":41050914, 57 | "minuteData":[ 58 | {"dateTime":"23:10:00","value":"2"}, 59 | {"dateTime":"23:11:00","value":"2"}, 60 | {"dateTime":"23:12:00","value":"2"} 61 | ], 62 | "minutesAfterWakeup":0, 63 | "minutesAsleep":404, 64 | "minutesAwake":25, 65 | "minutesToFallAsleep":17, 66 | "startTime":"2013-06-18T23:10:00.000", 67 | "timeInBed":446 68 | }], 69 | "summary":{ 70 | "totalMinutesAsleep":404, 71 | "totalSleepRecords":1, 72 | "totalTimeInBed":446 73 | } 74 | }, 75 | 76 | devices: [ 77 | { 78 | "battery":"High", 79 | "id":"123456", 80 | "lastSyncTime":"2011-08-26T11:19:03.000", 81 | "type":"TRACKER", 82 | "deviceVersion":"Ultra" 83 | }, 84 | { 85 | "battery":"Full", 86 | "id":"123457", 87 | "lastSyncTime":"2011-08-26T11:19:03.000", 88 | "type":"TRACKER", 89 | "deviceVersion":"Flex" 90 | } 91 | ], 92 | 93 | // Return the data as a raw string 94 | raw: function (fixture) { 95 | return JSON.stringify(this[fixture]); 96 | } 97 | }; 98 | -------------------------------------------------------------------------------- /spec/mocks.js: -------------------------------------------------------------------------------- 1 | var noop = function () {} 2 | , OAuth = function () {}; 3 | 4 | OAuth.prototype.getOAuthRequestToken = 5 | OAuth.prototype.getOAuthAccessToken = 6 | OAuth.prototype.get = 7 | OAuth.prototype.setCustomHeader = 8 | OAuth.prototype.removeCustomHeader = 9 | noop; 10 | 11 | module.exports = { 12 | oauth: { OAuth: OAuth } 13 | }; 14 | -------------------------------------------------------------------------------- /spec/module-loader.js: -------------------------------------------------------------------------------- 1 | var vm = require('vm'); 2 | var fs = require('fs'); 3 | var path = require('path'); 4 | 5 | /** 6 | * Helper for unit testing: 7 | * - load module with mocked dependencies 8 | * - allow accessing private state of the module 9 | * 10 | * @param {string} filePath Absolute path to module (file to load) 11 | * @param {Object=} mocks Hash of mocked dependencies 12 | */ 13 | exports.loadModule = function(filePath, mocks) { 14 | mocks = mocks || {}; 15 | 16 | // this is necessary to allow relative path modules within loaded file 17 | // i.e. requiring ./some inside file /a/b.js needs to be resolved to /a/some 18 | var resolveModule = function(module) { 19 | if (module.charAt(0) !== '.') return module; 20 | return path.resolve(path.dirname(filePath), module); 21 | }; 22 | 23 | var exports = {}; 24 | var context = { 25 | require: function(name) { 26 | return mocks[name] || require(resolveModule(name)); 27 | }, 28 | console: console, 29 | exports: exports, 30 | module: { 31 | exports: exports 32 | } 33 | }; 34 | 35 | vm.runInNewContext(fs.readFileSync(filePath), context); 36 | return context; 37 | }; 38 | 39 | -------------------------------------------------------------------------------- /spec/resource-spec.js: -------------------------------------------------------------------------------- 1 | describe('Resources', function () { 2 | var libPath = __dirname + '/../lib/resources/' 3 | , Resource = require(libPath + 'resource') 4 | , Activities = require(libPath + 'activities') 5 | , Sleep = require(libPath + 'sleep') 6 | , Devices = require(libPath + 'devices') 7 | , fixtures = require('./fixtures'); 8 | 9 | describe('base resource', function () { 10 | var base; 11 | 12 | beforeEach(function () { 13 | base = new Resource({"rootItem": "Hello world!", "summary": {"one": 1}}); 14 | }); 15 | 16 | it('should get an attribute', function () { 17 | expect(base.get('rootItem')).toBe('Hello world!'); 18 | }); 19 | 20 | it('should get a summary item', function () { 21 | expect(base.getSummaryItem('one')).toBe(1); 22 | }); 23 | }); 24 | 25 | describe('activities', function () { 26 | var activities; 27 | 28 | beforeEach(function () { 29 | activities = new Activities(fixtures.activities); 30 | }); 31 | 32 | it('should return steps', function () { 33 | expect(activities.steps()).toBe(12448); 34 | }); 35 | 36 | it('should return floors', function () { 37 | expect(activities.floors()).toBe(0); 38 | }); 39 | 40 | it('should return active score', function () { 41 | expect(activities.activeScore()).toBe(649); 42 | }); 43 | 44 | it('should return total distance', function () { 45 | expect(activities.totalDistance()).toBe(9.81); 46 | }); 47 | }); 48 | 49 | describe('sleep', function () { 50 | var sleep; 51 | 52 | beforeEach(function () { 53 | sleep = new Sleep(fixtures.sleep); 54 | }); 55 | 56 | it('should return time in bed', function () { 57 | expect(sleep.timeInBed()).toBe(446); 58 | }); 59 | 60 | it('should return number of minutes asleep', function () { 61 | expect(sleep.minutesAsleep()).toBe(404); 62 | }); 63 | 64 | it('should return hours and minutes asleep', function () { 65 | expect(sleep.hoursAndMinutesAsleep()).toEqual({ 66 | hours: 6 67 | , mins: 44 68 | }); 69 | }); 70 | }); 71 | 72 | describe('devices', function () { 73 | var devices; 74 | 75 | beforeEach(function () { 76 | devices = new Devices(fixtures.devices); 77 | }); 78 | 79 | it('should return the correct device version', function () { 80 | expect(devices.device('Flex').id).toBe('123457'); 81 | }); 82 | }); 83 | }); 84 | --------------------------------------------------------------------------------