diff --git a/lib/application.js b/lib/application.js index c532f664..8da8d59d 100644 --- a/lib/application.js +++ b/lib/application.js @@ -8,7 +8,8 @@ var _ = require('lodash'), Core = require('./core'), qs = require('querystring'), Payroll = require('./payroll'), - xml2js = require('xml2js') + xml2js = require('xml2js'), + events = new require('events'); function Batch(application) { logger.debug('Batch::constructor'); @@ -45,6 +46,7 @@ function Application(options) { var core = new Core(this); var payroll = new Payroll(this); + Object.defineProperties(this, { core: { get: function() { @@ -81,6 +83,8 @@ Object.assign(Application.prototype, { if (this.options["runscopeBucketId"] && this.options["runscopeBucketId"] !== "") { this.options.baseUrl = "https://api-xero-com-" + this.options["runscopeBucketId"] + ".runscope.net"; } + + this.eventEmitter = new events.EventEmitter(); }, post: function(path, body, options, callback) { return this.putOrPost('post', path, body, options, callback); @@ -98,55 +102,73 @@ Object.assign(Application.prototype, { options = options || {}; return new Promise(function(resolve, reject) { var params = {}; - if (options.summarizeErrors === false) - params.summarizeErrors = false; - //Added to support more than 2dp being added. - if (options.unitdp) - params.unitdp = options.unitdp; + self.checkExpiry() + .then(function() { + if (options.summarizeErrors === false) + params.summarizeErrors = false; - var endPointUrl = options.api === 'payroll' ? self.options.payrollAPIEndPointUrl : self.options.coreAPIEndPointUrl; - var url = self.options.baseUrl + endPointUrl + path; - if (!_.isEmpty(params)) - url += '?' + querystring.stringify(params); + //Added to support more than 2dp being added. + if (options.unitdp) + params.unitdp = options.unitdp; - self.oa[method](url, self.options.accessToken, self.options.accessSecret, { xml: body }, function(err, data, res) { + var endPointUrl = options.api === 'payroll' ? self.options.payrollAPIEndPointUrl : self.options.coreAPIEndPointUrl; + var url = self.options.baseUrl + endPointUrl + path; + if (!_.isEmpty(params)) + url += '?' + querystring.stringify(params); - if (err && data && data.indexOf('oauth_problem') >= 0) { - var errObj = new Error(method.toUpperCase() + ' call failed with: ' + err.statusCode); - errObj.data = qs.parse(data); - reject(errObj); - callback && callback(errObj); - return; - } + self.oa[method](url, self.options.accessToken, self.options.accessSecret, { xml: body }, function(err, data, res) { - self.xml2js(data) - .then(function(obj) { - if (err) { - var exception = ""; - if (obj.ApiException) - exception = obj.ApiException; - else if (obj.Response.ErrorNumber) - exception = obj.Response; - var errObj = new Error(method.toUpperCase() + ' call failed with: ' + err.statusCode + ' and exception: ' + JSON.stringify(exception, null, 2)); + if (err && data && data.indexOf('oauth_problem') >= 0) { + var errObj = new Error(method.toUpperCase() + ' call failed with: ' + err.statusCode); + errObj.data = qs.parse(data); reject(errObj); callback && callback(errObj); - } else { - var ret = { response: obj.Response, res: res }; - if (options.entityConstructor) { - ret.entities = self.convertEntities(obj.Response, options); - } - resolve(ret); - callback && callback(null, obj, res, ret.entities); + return; } - }) - .catch(function(err) { - logger.error(err); - throw err; - }) + self.xml2js(data) + .then(function(obj) { + if (err) { + var exception = ""; + if (obj.ApiException) + exception = obj.ApiException; + else if (obj.Response.ErrorNumber) + exception = obj.Response; + var errObj = new Error(method.toUpperCase() + ' call failed with: ' + err.statusCode + ' and exception: ' + JSON.stringify(exception, null, 2)); + reject(errObj); + callback && callback(errObj); + } else { + var ret = { response: obj.Response, res: res }; + if (options.entityConstructor) { + ret.entities = self.convertEntities(obj.Response, options); + } + resolve(ret); + callback && callback(null, obj, res, ret.entities); + } + + }) + .catch(function(err) { + logger.error(err); + throw err; + }) + + }); + }) + .catch(function(err) { + logger.debug(err); + if (err && err.data) { + var dataParts = qs.parse(err.data); + + var errObj = new Error(method.toUpperCase() + ' call failed with: ' + err.statusCode); + errObj.data = dataParts; + reject(errObj); + callback && callback(errObj); + return; + } + }); + - }); }); }, delete: function(path, options, callback) { @@ -157,42 +179,57 @@ Object.assign(Application.prototype, { var endPointUrl = options.api === 'payroll' ? self.options.payrollAPIEndPointUrl : self.options.coreAPIEndPointUrl; var url = self.options.baseUrl + endPointUrl + path; - self.oa.delete(url, self.options.accessToken, self.options.accessSecret, function(err, data, res) { - if (options.stream && !err) { - // Already done - return resolve(); - } - if (err && data && data.indexOf('oauth_problem') >= 0) { - var errObj = new Error('DELETE call failed with: ' + err.statusCode); - errObj.data = qs.parse(data); - reject(errObj); - callback && callback(errObj); - return; - } + self.checkExpiry() + .then(function() { + self.oa.delete(url, self.options.accessToken, self.options.accessSecret, function(err, data, res) { + if (options.stream && !err) { + // Already done + return resolve(); + } + if (err && data && data.indexOf('oauth_problem') >= 0) { + var errObj = new Error('DELETE call failed with: ' + err.statusCode); + errObj.data = qs.parse(data); + reject(errObj); + callback && callback(errObj); + return; + } - if (err) { - var errObj = new Error('DELETE call failed with: ' + err.statusCode + ' and message: ' + err.data); - reject(errObj); - callback && callback(errObj); - return; - } + if (err) { + var errObj = new Error('DELETE call failed with: ' + err.statusCode + ' and message: ' + err.data); + reject(errObj); + callback && callback(errObj); + return; + } - //Some delete operations don't return any content (e.g. HTTP204) so simply resolve the promise - if (!data || data === "") { - return resolve(); - } + //Some delete operations don't return any content (e.g. HTTP204) so simply resolve the promise + if (!data || data === "") { + return resolve(); + } - self.xml2js(data) - .then(function(obj) { - var ret = { response: obj.Response, res: res }; - resolve(ret); - callback && callback(null, obj, res); - }) - .catch(function(err) { - logger.error(err); - throw err; - }) - }, { stream: options.stream }); + self.xml2js(data) + .then(function(obj) { + var ret = { response: obj.Response, res: res }; + resolve(ret); + callback && callback(null, obj, res); + }) + .catch(function(err) { + logger.error(err); + throw err; + }) + }, { stream: options.stream }); + }) + .catch(function(err) { + logger.debug(err); + if (err && err.data) { + var dataParts = qs.parse(err.data); + + var errObj = new Error('DELETE call failed with: ' + err.statusCode); + errObj.data = dataParts; + reject(errObj); + callback && callback(errObj); + return; + } + }); }); }, get: function(path, options, callback) { @@ -207,10 +244,25 @@ Object.assign(Application.prototype, { if (options.format) self.oa._headers['Accept'] = 'application/' + options.format; - if (options.pager) - getResource(options.pager.start || 1) - else - getResource(); + self.checkExpiry() + .then(function() { + if (options.pager) + getResource(options.pager.start || 1) + else + getResource(); + }) + .catch(function(err) { + logger.debug(err); + if (err && err.data) { + var dataParts = qs.parse(err.data); + + var errObj = new Error('GET call failed with: ' + err.statusCode); + errObj.data = dataParts; + reject(errObj); + callback && callback(errObj); + return; + } + }); function getResource(offset) { var endPointUrl = options.api === 'payroll' ? self.options.payrollAPIEndPointUrl : self.options.coreAPIEndPointUrl; @@ -243,11 +295,7 @@ Object.assign(Application.prototype, { return resolve(); } if (err && data) { - var dataParts; - if (_.isObject(data)) - dataParts = qs.parse(data); - else - dataParts = data; + var dataParts = qs.parse(data); var errObj = new Error('GET call failed with: ' + err.statusCode); errObj.data = dataParts; @@ -417,6 +465,31 @@ Object.assign(Application.prototype, { var builder = new xml2js.Builder({ rootName: rootName, headless: true }); var obj = builder.buildObject(obj); return obj; + }, + checkExpiry: function() { + + /** + * CheckExpiry is a helper function that will compare the current token expiry to the current time. + * + * As there is potential for a time difference, instead of waiting all the way until the current time + * has passed the expiry time, we instead add 3 minutes to the current time, and use that as a comparison. + * + * This ensures that if the token is 'nearing' the expiry, it'll attempt to be refreshed. + */ + + var expiry = new Date(this.options.tokenExpiry), + checkTime = addMinutes(new Date(), 3); + + if (checkTime >= expiry) { + logger.debug("Refreshing Access Token"); + return this.refreshAccessToken(); + } else { + return Promise.resolve(); + } + + function addMinutes(date, minutes) { + return new Date(date.getTime() + minutes * 60000); + } } }) @@ -471,15 +544,47 @@ var RequireAuthorizationApplication = Application.extend({ }); }); }, - getAccessToken: function(token, secret, verifier, callback, options) { + setAccessToken: function(token, secret, verifier, callback, options) { var self = this; return new Promise(function(resolve, reject) { - self.oa.getOAuthAccessToken(token, secret, verifier, function(err, token, secret, results) { + self.oa.getOAuthAccessToken(token, secret, verifier, function(err, results) { if (err) reject(err); - else - resolve({ token: token, secret: secret, results: results }); + else { + var exp = new Date(); + exp.setTime(exp.getTime() + (results.oauth_expires_in * 1000)); + self.setOptions({ + accessToken: results.oauth_token, + accessSecret: results.oauth_token_secret, + sessionHandle: results.oauth_session_handle, + tokenExpiry: exp.toString() + }, false); + resolve({ results: results }); + } + callback && callback.apply(callback, arguments); + }) + }); + }, + refreshAccessToken: function(callback, options) { + var self = this; + + return new Promise(function(resolve, reject) { + self.oa.getOAuthAccessToken(self.options.accessToken, self.options.accessSecret, { oauth_session_handle: self.options.sessionHandle }, function(err, results) { + if (err) + reject(err); + else { + var exp = new Date(); + exp.setTime(exp.getTime() + (results.oauth_expires_in * 1000)); + self.setOptions({ + accessToken: results.oauth_token, + accessSecret: results.oauth_token_secret, + sessionHandle: results.oauth_session_handle, + tokenExpiry: exp.toString() + }, true); + resolve({ results: results }); + } + callback && callback.apply(callback, arguments); }) }); @@ -488,9 +593,21 @@ var RequireAuthorizationApplication = Application.extend({ var q = Object.assign({}, { oauth_token: requestToken }, other); return this.options.baseUrl + this.options.authorizeUrl + '?' + querystring.stringify(q); }, - setOptions: function(options) { - this.options.accessToken = options.accessToken; - this.options.accessSecret = options.accessSecret; + setOptions: function(options, emitThisEvent) { + logger.debug("Setting options"); + options.accessToken ? this.options.accessToken = options.accessToken : false; + options.accessSecret ? this.options.accessSecret = options.accessSecret : false; + options.sessionHandle ? this.options.sessionHandle = options.sessionHandle : false; + options.tokenExpiry ? this.options.tokenExpiry = options.tokenExpiry : false; + + if (emitThisEvent && this.eventEmitter) { + logger.debug("Emitting event"); + try { + this.eventEmitter.emit('xeroTokenUpdate', options); + } catch (e) { + logger.error(e); + } + } } }); @@ -560,4 +677,4 @@ var PartnerApplication = RequireAuthorizationApplication.extend({ module.exports.PrivateApplication = PrivateApplication; module.exports.PublicApplication = PublicApplication; module.exports.PartnerApplication = PartnerApplication; -module.exports.Application = Application; +module.exports.Application = Application; \ No newline at end of file diff --git a/lib/oauth/oauth.js b/lib/oauth/oauth.js index 7d7417d8..0753a064 100644 --- a/lib/oauth/oauth.js +++ b/lib/oauth/oauth.js @@ -461,6 +461,8 @@ exports.OAuth.prototype.getOAuthAccessToken = function(oauth_token, oauth_token_ var extraParams = {}; if (typeof oauth_verifier == "function") { callback = oauth_verifier; + } else if (typeof oauth_verifier == "object") { + extraParams.oauth_session_handle = oauth_verifier.oauth_session_handle; } else { extraParams.oauth_verifier = oauth_verifier; } @@ -469,11 +471,7 @@ exports.OAuth.prototype.getOAuthAccessToken = function(oauth_token, oauth_token_ if (error) callback(error); else { var results = querystring.parse(data); - var oauth_access_token = results["oauth_token"]; - delete results["oauth_token"]; - var oauth_access_token_secret = results["oauth_token_secret"]; - delete results["oauth_token_secret"]; - callback(null, oauth_access_token, oauth_access_token_secret, results); + callback(null, results); } }) } @@ -584,4 +582,4 @@ exports.OAuth.prototype.authHeader = function(url, oauth_token, oauth_token_secr var orderedParameters = this._prepareParameters(oauth_token, oauth_token_secret, method, url, {}); return this._buildAuthorizationHeaders(orderedParameters); -}; +}; \ No newline at end of file diff --git a/package.json b/package.json index e3856ea6..2a44be9b 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "license": "MIT", "dependencies": { "dateformat": "~1.0.7-1.2.3", + "events": "^1.1.1", "lodash": "~2.4.1", "log4js": "~0.6.9", "lru-cache": "^4.0.2", @@ -39,6 +40,7 @@ "mustache": "^2.3.0", "nodemailer": "", "nyc": "^10.1.2", + "sinon": "^1.17.7", "zombie": "^5.0.5", "zombie-phantom": "0.0.6" } diff --git a/sample_app/index.js b/sample_app/index.js index be1ed3b0..65f9e448 100644 --- a/sample_app/index.js +++ b/sample_app/index.js @@ -6,18 +6,42 @@ var express = require('express'), nodemailer = require('nodemailer'), metaConfig = require('./config/config.json'); -function getXeroApp(session) { - var APPTYPE = metaConfig.APPTYPE; - var config = metaConfig[APPTYPE.toLowerCase()]; - - if (session) { - if (session.oauthAccessToken && session.oauthAccessSecret) { - config.accessToken = session.oauthAccessToken; - config.accessSecret = session.oauthAccessSecret; +var xeroClient; +var eventReceiver; + +function getXeroClient(session) { + + if (!xeroClient) { + var APPTYPE = metaConfig.APPTYPE; + var config = metaConfig[APPTYPE.toLowerCase()]; + + if (session) { + if (session.oauthAccessToken && session.oauthAccessSecret) { + config.accessToken = session.oauthAccessToken; + config.accessSecret = session.oauthAccessSecret; + } + } + + if (config.privateKeyPath && !config.privateKey) config.privateKey = fs.readFileSync(config.privateKeyPath); + + switch (APPTYPE) { + case "PUBLIC": + xeroClient = new xero.PublicApplication(config); + break; + case "PARTNER": + xeroClient = new xero.PartnerApplication(config); + eventReceiver = xeroClient.eventEmitter; + eventReceiver.on('xeroTokenUpdate', function(data) { + //Store the data that was received from the xeroTokenRefresh event + console.log("Received xero token refresh: ", data); + }); + break; + default: + throw "No App Type Set!!" } } - return new xero.PublicApplication(config); + return xeroClient; } var app = express(); @@ -72,8 +96,8 @@ app.use(express.static(__dirname + '/assets')); // app.use(express.cookieSession({ secret: 'sfsdfsdfsdfsdf234234234fd', cookie: { maxAge: 123467654456 } })); function authorizeRedirect(req, res, returnTo) { - var xeroApp = getXeroApp(null, returnTo); - xeroApp.getRequestToken(function(err, token, secret) { + var xeroClient = getXeroClient(null, returnTo); + xeroClient.getRequestToken(function(err, token, secret) { if (!err) { req.session.oauthRequestToken = token; req.session.oauthRequestSecret = secret; @@ -83,7 +107,7 @@ function authorizeRedirect(req, res, returnTo) { var PayrollScope = 'payroll.employees,payroll.payitems,payroll.timesheets'; var AccountingScope = ''; - var authoriseUrl = xeroApp.buildAuthorizeUrl(token, { + var authoriseUrl = xeroClient.buildAuthorizeUrl(token, { scope: PayrollScope }); res.redirect(authoriseUrl); @@ -97,13 +121,27 @@ function authorizeRedirect(req, res, returnTo) { var cache = LRU(); function authorizedOperation(req, res, returnTo, callback) { - if (req.session.oauthAccessToken) { - var xeroApp = getXeroApp(req.session); - callback(xeroApp); - } else + if (xeroClient) { + callback(xeroClient); + } else { authorizeRedirect(req, res, returnTo); + } +} + +function handleErr(err, req, res, returnTo) { + console.log(err); + if (err.data && err.data.oauth_problem && err.data.oauth_problem == "token_rejected") { + authorizeRedirect(req, res, returnTo); + } else { + res.redirect('error'); + } } +app.get('/error', function(req, res) { + console.log(req.query.error); + res.render('index', { error: req.query.error }); +}) + // Home Page app.get('/', function(req, res) { res.render('index', { @@ -115,23 +153,23 @@ app.get('/', function(req, res) { // Redirected from xero with oauth results app.get('/access', function(req, res) { - var xeroApp = getXeroApp(); + var xeroClient = getXeroClient(); if (req.query.oauth_verifier && req.query.oauth_token == req.session.oauthRequestToken) { - xeroApp.getAccessToken(req.session.oauthRequestToken, req.session.oauthRequestSecret, req.query.oauth_verifier, - function(err, accessToken, accessSecret, results) { - req.session.oauthAccessToken = accessToken; - req.session.oauthAccessSecret = accessSecret; + xeroClient.setAccessToken(req.session.oauthRequestToken, req.session.oauthRequestSecret, req.query.oauth_verifier) + .then(function() { var returnTo = req.session.returnto; res.redirect(returnTo || '/'); - } - ) + }) + .catch(function(err) { + handleErr(err, req, res, 'error'); + }) } }); app.get('/organisations', function(req, res) { - authorizedOperation(req, res, '/organisations', function(xeroApp) { - xeroApp.core.organisations.getOrganisations() + authorizedOperation(req, res, '/organisations', function(xeroClient) { + xeroClient.core.organisations.getOrganisations() .then(function(organisations) { res.render('organisations', { organisations: organisations, @@ -141,14 +179,14 @@ app.get('/organisations', function(req, res) { }); }) .catch(function(err) { - console.log(err); + handleErr(err, req, res, 'organisations'); }) }) }); app.get('/taxrates', function(req, res) { - authorizedOperation(req, res, '/taxrates', function(xeroApp) { - xeroApp.core.taxrates.getTaxRates() + authorizedOperation(req, res, '/taxrates', function(xeroClient) { + xeroClient.core.taxrates.getTaxRates() .then(function(taxrates) { res.render('taxrates', { taxrates: taxrates, @@ -157,12 +195,15 @@ app.get('/taxrates', function(req, res) { } }); }) + .catch(function(err) { + handleErr(err, req, res, 'taxrates'); + }) }) }); app.get('/users', function(req, res) { - authorizedOperation(req, res, '/users', function(xeroApp) { - xeroApp.core.users.getUsers() + authorizedOperation(req, res, '/users', function(xeroClient) { + xeroClient.core.users.getUsers() .then(function(users) { res.render('users', { users: users, @@ -171,12 +212,15 @@ app.get('/users', function(req, res) { } }); }) + .catch(function(err) { + handleErr(err, req, res, 'users'); + }) }) }); app.get('/employees', function(req, res) { - authorizedOperation(req, res, '/employees', function(xeroApp) { - xeroApp.payroll.employees.getEmployees() + authorizedOperation(req, res, '/employees', function(xeroClient) { + xeroClient.payroll.employees.getEmployees() .then(function(employees) { res.render('employees', { employees: employees, @@ -186,26 +230,15 @@ app.get('/employees', function(req, res) { }); }) .catch(function(err) { - console.log(err) - res.render('employees', { - error: err, - active: { - employees: true - } - }) + handleErr(err, req, res, 'employees'); }) }) }); -app.get('/error', function(req, res) { - console.log(req.query.error); - res.render('index', { error: req.query.error }); -}) - app.get('/contacts', function(req, res) { - authorizedOperation(req, res, '/contacts', function(xeroApp) { + authorizedOperation(req, res, '/contacts', function(xeroClient) { var contacts = []; - xeroApp.core.contacts.getContacts({ pager: { callback: pagerCallback } }) + xeroClient.core.contacts.getContacts({ pager: { callback: pagerCallback } }) .then(function() { res.render('contacts', { contacts: contacts, @@ -214,6 +247,9 @@ app.get('/contacts', function(req, res) { } }); }) + .catch(function(err) { + handleErr(err, req, res, 'contacts'); + }) function pagerCallback(err, response, cb) { contacts.push.apply(contacts, response.data); @@ -223,9 +259,9 @@ app.get('/contacts', function(req, res) { }); app.get('/banktransactions', function(req, res) { - authorizedOperation(req, res, '/banktransactions', function(xeroApp) { + authorizedOperation(req, res, '/banktransactions', function(xeroClient) { var bankTransactions = []; - xeroApp.core.bankTransactions.getBankTransactions({ pager: { callback: pagerCallback } }) + xeroClient.core.bankTransactions.getBankTransactions({ pager: { callback: pagerCallback } }) .then(function() { res.render('banktransactions', { bankTransactions: bankTransactions, @@ -234,6 +270,9 @@ app.get('/banktransactions', function(req, res) { } }); }) + .catch(function(err) { + handleErr(err, req, res, 'banktransactions'); + }) function pagerCallback(err, response, cb) { bankTransactions.push.apply(bankTransactions, response.data); @@ -243,9 +282,9 @@ app.get('/banktransactions', function(req, res) { }); app.get('/journals', function(req, res) { - authorizedOperation(req, res, '/journals', function(xeroApp) { + authorizedOperation(req, res, '/journals', function(xeroClient) { var journals = []; - xeroApp.core.journals.getJournals({ pager: { callback: pagerCallback } }) + xeroClient.core.journals.getJournals({ pager: { callback: pagerCallback } }) .then(function() { res.render('journals', { journals: journals, @@ -254,6 +293,9 @@ app.get('/journals', function(req, res) { } }); }) + .catch(function(err) { + handleErr(err, req, res, 'journals'); + }) function pagerCallback(err, response, cb) { journals.push.apply(journals, response.data); @@ -263,9 +305,9 @@ app.get('/journals', function(req, res) { }); app.get('/banktransfers', function(req, res) { - authorizedOperation(req, res, '/banktransfers', function(xeroApp) { + authorizedOperation(req, res, '/banktransfers', function(xeroClient) { var bankTransfers = []; - xeroApp.core.bankTransfers.getBankTransfers({ pager: { callback: pagerCallback } }) + xeroClient.core.bankTransfers.getBankTransfers({ pager: { callback: pagerCallback } }) .then(function() { res.render('banktransfers', { bankTransfers: bankTransfers, @@ -274,6 +316,9 @@ app.get('/banktransfers', function(req, res) { } }); }) + .catch(function(err) { + handleErr(err, req, res, 'banktransfers'); + }) function pagerCallback(err, response, cb) { bankTransfers.push.apply(bankTransfers, response.data); @@ -283,8 +328,8 @@ app.get('/banktransfers', function(req, res) { }); app.get('/payments', function(req, res) { - authorizedOperation(req, res, '/payments', function(xeroApp) { - xeroApp.core.payments.getPayments() + authorizedOperation(req, res, '/payments', function(xeroClient) { + xeroClient.core.payments.getPayments() .then(function(payments) { res.render('payments', { payments: payments, @@ -293,12 +338,15 @@ app.get('/payments', function(req, res) { } }); }) + .catch(function(err) { + handleErr(err, req, res, 'payments'); + }) }) }); app.get('/trackingcategories', function(req, res) { - authorizedOperation(req, res, '/trackingcategories', function(xeroApp) { - xeroApp.core.trackingCategories.getTrackingCategories() + authorizedOperation(req, res, '/trackingcategories', function(xeroClient) { + xeroClient.core.trackingCategories.getTrackingCategories() .then(function(trackingcategories) { res.render('trackingcategories', { trackingcategories: trackingcategories, @@ -307,12 +355,15 @@ app.get('/trackingcategories', function(req, res) { } }); }) + .catch(function(err) { + handleErr(err, req, res, 'trackingcategories'); + }) }) }); app.get('/accounts', function(req, res) { - authorizedOperation(req, res, '/accounts', function(xeroApp) { - xeroApp.core.accounts.getAccounts() + authorizedOperation(req, res, '/accounts', function(xeroClient) { + xeroClient.core.accounts.getAccounts() .then(function(accounts) { res.render('accounts', { accounts: accounts, @@ -321,13 +372,16 @@ app.get('/accounts', function(req, res) { } }); }) + .catch(function(err) { + handleErr(err, req, res, 'accounts'); + }) }) }); app.get('/timesheets', function(req, res) { - authorizedOperation(req, res, '/timesheets', function(xeroApp) { - xeroApp.payroll.timesheets.getTimesheets() + authorizedOperation(req, res, '/timesheets', function(xeroClient) { + xeroClient.payroll.timesheets.getTimesheets() .then(function(timesheets) { res.render('timesheets', { timesheets: timesheets, @@ -337,48 +391,17 @@ app.get('/timesheets', function(req, res) { }); }) .catch(function(err) { - console.log(err) - res.render('timesheets', { - error: err, - active: { - timesheets: true - } - }) + handleErr(err, req, res, 'timesheets'); }) }) }); -app.use('/createtimesheet', function(req, res) { - if (req.method == 'GET') { - return res.render('createtimesheet'); - } else if (req.method == 'POST') { - authorizedOperation(req, res, '/createtimesheet', function(xeroApp) { - var timesheet = xeroApp.payroll.timesheets.newTimesheet({ - EmployeeID: '065a115c-ba9c-4c03-b8e3-44c551ed8f21', - StartDate: new Date(2014, 8, 23), - EndDate: new Date(2014, 8, 29), - Status: 'Draft', - TimesheetLines: [{ - EarningsTypeID: 'a9ab82bf-c421-4840-b245-1df307c2127a', - NumberOfUnits: [5, 0, 0, 0, 0, 0, 0] - }] - }); - timesheet.save() - .then(function(ret) { - res.render('createtimesheet', { timesheets: ret.entities }) - }) - .catch(function(err) { - res.render('createtimesheet', { err: err }) - }) - }) - } -}); app.get('/invoices', function(req, res) { - authorizedOperation(req, res, '/invoices', function(xeroApp) { - xeroApp.core.invoices.getInvoices() + authorizedOperation(req, res, '/invoices', function(xeroClient) { + xeroClient.core.invoices.getInvoices() .then(function(invoices) { console.log(invoices[0].Payments[0]); res.render('invoices', { @@ -388,13 +411,16 @@ app.get('/invoices', function(req, res) { } }); }) + .catch(function(err) { + handleErr(err, req, res, 'invoices'); + }) }) }); app.get('/items', function(req, res) { - authorizedOperation(req, res, '/items', function(xeroApp) { - xeroApp.core.items.getItems() + authorizedOperation(req, res, '/items', function(xeroClient) { + xeroClient.core.items.getItems() .then(function(items) { res.render('items', { items: items, @@ -403,6 +429,9 @@ app.get('/items', function(req, res) { } }); }) + .catch(function(err) { + handleErr(err, req, res, 'items'); + }) }) }); @@ -411,8 +440,8 @@ app.use('/createinvoice', function(req, res) { if (req.method == 'GET') { return res.render('createinvoice'); } else if (req.method == 'POST') { - authorizedOperation(req, res, '/createinvoice', function(xeroApp) { - var invoice = xeroApp.core.invoices.newInvoice({ + authorizedOperation(req, res, '/createinvoice', function(xeroClient) { + var invoice = xeroClient.core.invoices.newInvoice({ Type: req.body.Type, Contact: { Name: req.body.Contact @@ -439,13 +468,40 @@ app.use('/createinvoice', function(req, res) { } }); +app.use('/createtimesheet', function(req, res) { + if (req.method == 'GET') { + return res.render('createtimesheet'); + } else if (req.method == 'POST') { + authorizedOperation(req, res, '/createtimesheet', function(xeroClient) { + var timesheet = xeroClient.payroll.timesheets.newTimesheet({ + EmployeeID: '065a115c-ba9c-4c03-b8e3-44c551ed8f21', + StartDate: new Date(2014, 8, 23), + EndDate: new Date(2014, 8, 29), + Status: 'Draft', + TimesheetLines: [{ + EarningsTypeID: 'a9ab82bf-c421-4840-b245-1df307c2127a', + NumberOfUnits: [5, 0, 0, 0, 0, 0, 0] + }] + }); + timesheet.save() + .then(function(ret) { + res.render('createtimesheet', { timesheets: ret.entities }) + }) + .catch(function(err) { + res.render('createtimesheet', { err: err }) + }) + + }) + } +}); + app.use('/emailinvoice', function(req, res) { if (req.method == 'GET' && !req.query.a) { res.render('emailinvoice', { id: req.query.id }); } else { - authorizedOperation(req, res, '/emailinvoice?id=' + req.query.id + '&a=1&email=' + encodeURIComponent(req.body.Email), function(xeroApp) { + authorizedOperation(req, res, '/emailinvoice?id=' + req.query.id + '&a=1&email=' + encodeURIComponent(req.body.Email), function(xeroClient) { var file = fs.createWriteStream(__dirname + '/invoice.pdf', { encoding: 'binary' }); - xeroApp.core.invoices.streamInvoice(req.query.id, 'pdf', file); + xeroClient.core.invoices.streamInvoice(req.query.id, 'pdf', file); file.on('finish', function() { var transporter = nodemailer.createTransport(); // Direct var mailOptions = { diff --git a/test/accountingtests.js b/test/accountingtests.js index 7961eb8a..7205c7b3 100644 --- a/test/accountingtests.js +++ b/test/accountingtests.js @@ -1,6 +1,7 @@ var chai = require('chai'), should = chai.should(), expect = chai.expect, + sinon = require('sinon'), _ = require('lodash'), xero = require('..'), util = require('util'), @@ -14,6 +15,7 @@ process.on('uncaughtException', function(err) { }) var currentApp; +var eventReceiver; var organisationCountry = ''; var APPTYPE = metaConfig.APPTYPE; @@ -36,6 +38,8 @@ before('init instance and set options', function(done) { throw "No App Type Set!!" } + eventReceiver = currentApp.eventEmitter; + done(); }) @@ -47,7 +51,6 @@ describe('get access for public or partner application', function() { }); describe('Get tokens', function() { - var authoriseUrl = ""; var requestToken = ""; var requestSecret = ""; @@ -56,6 +59,26 @@ describe('get access for public or partner application', function() { var accessToken = ""; var accessSecret = ""; + //This function is used by the event emitter to receive the event when the token + //is automatically refreshed. We use the 'spy' function so that we can include + //some checks within the tests. + var spy = sinon.spy(function() { + console.log("Event Received. Creating new Partner App"); + + //Create a new application object when we receive new tokens + currentApp = new xero.PartnerApplication(config); + currentApp.setOptions(arguments[0]); + //Reset the event receiver so the listener stack is shared correctly. + eventReceiver = currentApp.eventEmitter; + eventReceiver.on('xeroTokenUpdate', function(data) { console.log("Event Received: ", data); }); + + console.log("Partner app recreated"); + }); + + it('adds the event listener', function(done) { + eventReceiver.on('xeroTokenUpdate', spy); + done(); + }); it('user gets a token and builds the url', function() { return currentApp.getRequestToken() @@ -77,7 +100,7 @@ describe('get access for public or partner application', function() { runScripts: false }); - browser.debug(); + //browser.debug(); before(function(done) { if (APPTYPE === "PRIVATE") { @@ -147,10 +170,44 @@ describe('get access for public or partner application', function() { }); describe('swaps the request token for an access token', function() { - it('calls the access token method and sets the options', function() { - return currentApp.getAccessToken(requestToken, requestSecret, verifier) - .then(function({ token, secret }) { - currentApp.setOptions({ accessToken: token, accessSecret: secret }); + it('calls the access token method and sets the options', function(done) { + currentApp.setAccessToken(requestToken, requestSecret, verifier) + .then(function() { + expect(currentApp.options.accessToken).to.not.equal(undefined); + expect(currentApp.options.accessToken).to.not.equal(""); + expect(currentApp.options.accessSecret).to.not.equal(undefined); + expect(currentApp.options.accessSecret).to.not.equal(""); + + if (APPTYPE === "PARTNER") { + expect(currentApp.options.sessionHandle).to.not.equal(undefined); + expect(currentApp.options.sessionHandle).to.not.equal(""); + } + + done(); + }).catch(function(err) { + done(wrapError(err)); + }); + }); + + it('refreshes the token', function(done) { + if (APPTYPE !== "PARTNER") { + this.skip(); + } + + //Only supported for Partner integrations + currentApp.refreshAccessToken() + .then(function() { + expect(currentApp.options.accessToken).to.not.equal(undefined); + expect(currentApp.options.accessToken).to.not.equal(""); + expect(currentApp.options.accessSecret).to.not.equal(undefined); + expect(currentApp.options.accessSecret).to.not.equal(""); + expect(currentApp.options.sessionHandle).to.not.equal(undefined); + expect(currentApp.options.sessionHandle).to.not.equal(""); + + expect(spy.called).to.equal(true); + done(); + }).catch(function(err) { + done(wrapError(err)); }); }); }); @@ -158,7 +215,6 @@ describe('get access for public or partner application', function() { }); describe('regression tests', function() { - var InvoiceID = ""; var PaymentID = ""; @@ -633,7 +689,37 @@ describe('regression tests', function() { .catch(function(err) { done(wrapError(err)); }) - }) + }); + + it('saves multiple invoices', function(done) { + var invoices = []; + + for (var i = 0; i < 10; i++) { + invoices.push(currentApp.core.invoices.newInvoice({ + Type: 'ACCREC', + Contact: { + Name: 'Department of Testing' + }, + DueDate: new Date().toISOString().split("T")[0], + LineItems: [{ + Description: 'Services', + Quantity: 2, + UnitAmount: 230, + AccountCode: '400' + }] + })); + } + + currentApp.core.invoices.saveInvoices(invoices) + .then(function(response) { + expect(response.entities).to.have.length.greaterThan(9); + done(); + }) + .catch(function(err) { + done(wrapError(err)); + }) + + }); }); describe('payments', function() { @@ -1484,6 +1570,11 @@ describe('regression tests', function() { function wrapError(err) { if (err instanceof Error) return err; - else if (err.statusCode) - return new Error(err.statusCode + ': ' + err.exception.Message); + else if (err.statusCode) { + var msg = err.data; + if (err.exception && err.exception.Message) { + msg = err.exception.Message; + } + return new Error(err.statusCode + ': ' + msg); + } } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index d97ca20d..ce4ed3d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -662,6 +662,10 @@ esutils@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" +events@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" + eventsource@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-0.1.6.tgz#0acede849ed7dd1ccc32c811bb11b944d4f29232" @@ -787,6 +791,12 @@ form-data@~2.1.1: combined-stream "^1.0.5" mime-types "^2.1.12" +formatio@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.1.1.tgz#5ed3ccd636551097383465d996199100e86161e9" + dependencies: + samsam "~1.1" + fresh@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.2.0.tgz#bfd9402cf3df12c4a4c310c79f99a3dde13d34a7" @@ -969,6 +979,10 @@ inherits@2, inherits@~2.0.1: version "2.0.3" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" +inherits@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" + invariant@^2.2.0: version "2.2.2" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360" @@ -1292,6 +1306,10 @@ log4js@~0.6.9: readable-stream "~1.0.2" semver "~4.3.3" +lolex@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.3.2.tgz#7c3da62ffcb30f0f5a80a2566ca24e45d8a01f31" + longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" @@ -1871,6 +1889,10 @@ rimraf@^2.3.3, rimraf@^2.4.3, rimraf@^2.4.4, rimraf@^2.5.4: dependencies: glob "^7.0.5" +samsam@1.1.2, samsam@~1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.2.tgz#bec11fdc83a9fda063401210e40176c3024d1567" + sax@>=0.6.0, sax@^1.1.4: version "1.2.2" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.2.tgz#fd8631a23bc7826bef5d871bdb87378c95647828" @@ -1904,6 +1926,15 @@ signal-exit@^3.0.0, signal-exit@^3.0.1: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" +sinon@^1.17.7: + version "1.17.7" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-1.17.7.tgz#4542a4f49ba0c45c05eb2e9dd9d203e2b8efe0bf" + dependencies: + formatio "1.1.1" + lolex "1.3.2" + samsam "1.1.2" + util ">=0.10.3 <1" + slide@^1.1.5: version "1.1.6" resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707" @@ -2163,6 +2194,12 @@ url-parse@1.0.x: querystringify "0.0.x" requires-port "1.0.x" +"util@>=0.10.3 <1": + version "0.10.3" + resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" + dependencies: + inherits "2.0.1" + uuid@^3.0.0, uuid@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.0.1.tgz#6544bba2dfda8c1cf17e629a3a305e2bb1fee6c1"