diff --git a/lib/expressapp.js b/lib/expressapp.js index ea7e860..3ad7b2d 100644 --- a/lib/expressapp.js +++ b/lib/expressapp.js @@ -13,7 +13,7 @@ var Stats = require('./stats'); log.disableColor(); log.debug = log.verbose; -log.level = 'debug'; +log.level = 'info'; var ExpressApp = function() { this.app = express(); @@ -85,7 +85,8 @@ ExpressApp.prototype.start = function(opts, cb) { message: err.message, }).end(); } else { - var code = 500, message; + var code = 500, + message; if (_.isObject(err)) { code = err.code || err.statusCode; message = err.message || err.body; @@ -430,10 +431,25 @@ ExpressApp.prototype.start = function(opts, cb) { router.get('/v1/version/', function(req, res) { var server = getServer(req, res); - res.json( { serviceVersion: server.serviceVersion } ); + res.json({ + serviceVersion: server.serviceVersion + }); res.end(); }); + router.get('/v1/notifications/', function(req, res) { + getServerWithAuth(req, res, function(server) { + var opts = { + minTs: +Date.now() - (60 * 1000), + notificationId: req.query.notificationId, + }; + server.getNotifications(opts, function(err, notifications) { + if (err) return returnError(err, res, req); + res.json(notifications); + }); + }); + }); + this.app.use(opts.basePath || '/bws/api', router); WalletService.initialize(opts, cb); diff --git a/lib/model/notification.js b/lib/model/notification.js index 13c29f2..2a7de39 100644 --- a/lib/model/notification.js +++ b/lib/model/notification.js @@ -32,6 +32,7 @@ Notification.create = function(opts) { x.version = '1.0.0'; var now = Date.now(); + x.createdOn = Math.floor(now / 1000); x.id = _.padLeft(now, 14, '0') + _.padLeft(opts.ticker || 0, 4, '0'); x.type = opts.type || 'general'; diff --git a/lib/server.js b/lib/server.js index 683069b..1062171 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1641,24 +1641,23 @@ WalletService.prototype.getTxs = function(opts, cb) { /** - * Retrieves notifications in the range (maxTs-minTs). - * Times are in UNIX EPOCH. Order is assured even for events with the same time + * Retrieves notifications after a specific id or from a given ts (whichever is more recent). * - * @param {Object} opts.minTs (defaults to 0) - * @param {Object} opts.maxTs (defaults to now) - * @param {Object} opts.limit - * @param {Object} opts.reverse (default false) + * @param {Object} opts + * @param {Object} opts.notificationId (optional) + * @param {Object} opts.minTs (optional) - default 0. * @returns {Notification[]} Notifications */ WalletService.prototype.getNotifications = function(opts, cb) { var self = this; - self.storage.fetchNotifications(self.walletId, opts, function(err, notifications) { + opts = opts || {}; + + self.storage.fetchNotifications(self.walletId, opts.notificationId, opts.minTs || 0, function(err, notifications) { if (err) return cb(err); return cb(null, notifications); }); }; - WalletService.prototype._normalizeTxHistory = function(txs) { var now = Math.floor(Date.now() / 1000); diff --git a/lib/storage.js b/lib/storage.js index 61dd719..73e4941 100644 --- a/lib/storage.js +++ b/lib/storage.js @@ -315,6 +315,44 @@ Storage.prototype.fetchNotifications = function(walletId, opts, cb) { }); }; +/** + * Retrieves notifications after a specific id or from a given ts (whichever is more recent). + * + * @param {String} notificationId + * @param {Number} minTs + * @returns {Notification[]} Notifications + */ +Storage.prototype.fetchNotifications = function(walletId, notificationId, minTs, cb) { + function makeId(timestamp) { + return _.padLeft(timestamp, 14, '0') + _.repeat('0', 4); + }; + + var self = this; + + var minId = makeId(minTs); + if (notificationId) { + minId = notificationId > minId ? notificationId : minId; + } + + this.db.collection(collections.NOTIFICATIONS) + .find({ + walletId: walletId, + id: { + $gt: minId, + }, + }) + .sort({ + id: 1 + }) + .toArray(function(err, result) { + if (err) return cb(err); + if (!result) return cb(); + var notifications = _.map(result, function(notification) { + return Model.Notification.fromObj(notification); + }); + return cb(null, notifications); + }); +}; // TODO: remove walletId from signature Storage.prototype.storeNotification = function(walletId, notification, cb) { diff --git a/package.json b/package.json index 0608da2..c3a76a2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "bitcore-wallet-service", "description": "A service for Mutisig HD Bitcoin Wallets", "author": "BitPay Inc", - "version": "0.3.0", + "version": "0.4.0", "keywords": [ "bitcoin", "copay", @@ -64,18 +64,14 @@ "coveralls": "./node_modules/.bin/istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage" }, "bitcoreNode": "./bitcorenode", - "contributors": [ - { - "name": "Braydon Fuller", - "email": "braydon@bitpay.com" - }, - { - "name": "Ivan Socolsky", - "email": "ivan@bitpay.com" - }, - { - "name": "Matias Alejo Garcia", - "email": "ematiu@gmail.com" - } - ] + "contributors": [{ + "name": "Braydon Fuller", + "email": "braydon@bitpay.com" + }, { + "name": "Ivan Socolsky", + "email": "ivan@bitpay.com" + }, { + "name": "Matias Alejo Garcia", + "email": "ematiu@gmail.com" + }] } diff --git a/test/expressapp.js b/test/expressapp.js index 3e2b84f..2a9e8b0 100644 --- a/test/expressapp.js +++ b/test/expressapp.js @@ -38,6 +38,22 @@ describe('ExpressApp', function() { describe('Routes', function() { var testPort = 3239; var testHost = 'http://127.0.0.1'; + var httpServer; + + function start(ExpressApp, done) { + var app = new ExpressApp(); + httpServer = http.Server(app.app); + + app.start(config, function(err) { + should.not.exist(err); + httpServer.listen(testPort); + done(); + }); + }; + + afterEach(function() { + httpServer.close(); + }); it('/v2/wallets', function(done) { var server = { @@ -49,20 +65,15 @@ describe('ExpressApp', function() { getInstanceWithAuth: sinon.stub().callsArgWith(1, null, server), } }); - var app = new TestExpressApp(); - var httpServer = http.Server(app.app); - - app.start(config, function(err) { - should.not.exist(err); - httpServer.listen(testPort); + start(TestExpressApp, function() { var requestOptions = { - url: testHost + ':' + testPort + config.basePath + '/v1/wallets', + url: testHost + ':' + testPort + config.basePath + '/v2/wallets', headers: { 'x-identity': 'identity', 'x-signature': 'signature' } }; - request(requestOptions, function(err, response, body){ + request(requestOptions, function(err, response, body) { should.not.exist(err); response.statusCode.should.equal(200); body.should.equal('{}'); @@ -70,6 +81,41 @@ describe('ExpressApp', function() { }); }); }); + + it('/v1/notifications', function(done) { + var clock = sinon.useFakeTimers(1234000, 'Date'); + + var server = { + getNotifications: sinon.stub().callsArgWith(1, null, {}) + }; + var TestExpressApp = proxyquire('../lib/expressapp', { + './server': { + initialize: sinon.stub().callsArg(1), + getInstanceWithAuth: sinon.stub().callsArgWith(1, null, server), + } + }); + start(TestExpressApp, function() { + var requestOptions = { + url: testHost + ':' + testPort + config.basePath + '/v1/notifications' + '?notificationId=123&minTs=0', + headers: { + 'x-identity': 'identity', + 'x-signature': 'signature' + } + }; + request(requestOptions, function(err, response, body) { + should.not.exist(err); + response.statusCode.should.equal(200); + body.should.equal('{}'); + server.getNotifications.calledWith({ + notificationId: '123', + minTs: 1234000 - 60000, // override minTs argument with a hardcoded 60 seconds span + }).should.be.true; + + clock.restore(); + done(); + }); + }); + }); }); }); }); diff --git a/test/integration/server.js b/test/integration/server.js index 41fc629..2bfa182 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -3875,75 +3875,103 @@ describe('Wallet service', function() { }); }); - describe('Notifications', function() { + describe('#getNotifications', function() { + var clock; var server, wallet; beforeEach(function(done) { + clock = sinon.useFakeTimers(10 * 1000, 'Date'); helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; helpers.stubUtxos(server, wallet, _.range(4), function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.01, TestData.copayers[0].privKey_1H_0); async.eachSeries(_.range(3), function(i, next) { + clock.tick(25 * 1000); server.createTx(txOpts, function(err, tx) { should.not.exist(err); next(); }); }, function(err) { + clock.tick(20 * 1000); return done(err); }); }); }); }); + afterEach(function() { + clock.restore(); + }); - it('should pull the last 4 notifications after 3 TXs', function(done) { - server.getNotifications({ - limit: 4, - reverse: true, - }, function(err, notifications) { + it('should pull all notifications', function(done) { + server.getNotifications({}, function(err, notifications) { should.not.exist(err); var types = _.pluck(notifications, 'type'); - types.should.deep.equal(['NewTxProposal', 'NewTxProposal', 'NewTxProposal', 'NewAddress']); + types.should.deep.equal(['NewCopayer', 'NewAddress', 'NewAddress', 'NewTxProposal', 'NewTxProposal', 'NewTxProposal']); var walletIds = _.uniq(_.pluck(notifications, 'walletId')); walletIds.length.should.equal(1); walletIds[0].should.equal(wallet.id); - var creators = _.uniq(_.pluck(notifications, 'creatorId')); + var creators = _.uniq(_.compact(_.pluck(notifications, 'creatorId'))); creators.length.should.equal(1); creators[0].should.equal(wallet.copayers[0].id); done(); }); }); - it('should pull the last 4 notifications, using now', function(done) { + it('should pull notifications in the last 60 seconds', function(done) { server.getNotifications({ - limit: 4, - reverse: true, - maxTs: Date.now() / 1000, - minTs: Date.now() / 1000 - 1000, + minTs: +Date.now() - (60 * 1000), }, function(err, notifications) { should.not.exist(err); var types = _.pluck(notifications, 'type'); - types.should.deep.equal(['NewTxProposal', 'NewTxProposal', 'NewTxProposal', 'NewAddress']); + types.should.deep.equal(['NewTxProposal', 'NewTxProposal']); done(); }); }); - it('should pull all notifications after wallet creation', function(done) { - server.getNotifications({ - minTs: 0, - }, function(err, notifications) { + it('should pull notifications after a given notification id', function(done) { + server.getNotifications({}, function(err, notifications) { should.not.exist(err); - var types = _.pluck(notifications, 'type'); - types[0].should.equal('NewCopayer'); - types[types.length - 1].should.equal('NewTxProposal'); + var from = _.first(_.takeRight(notifications, 2)).id; // second to last + server.getNotifications({ + notificationId: from, + minTs: +Date.now() - (60 * 1000), + }, function(err, res) { + should.not.exist(err); + res.length.should.equal(1); + res[0].id.should.equal(_.first(_.takeRight(notifications)).id); + done(); + }); + }); + }); + + it('should return empty if no notifications found after a given id', function(done) { + server.getNotifications({}, function(err, notifications) { + should.not.exist(err); + var from = _.first(_.takeRight(notifications)).id; // last one + server.getNotifications({ + notificationId: from, + }, function(err, res) { + should.not.exist(err); + res.length.should.equal(0); + done(); + }); + }); + }); + + it('should return empty if no notifications exist in the given timespan', function(done) { + clock.tick(100 * 1000); + server.getNotifications({ + minTs: +Date.now() - (60 * 1000), + }, function(err, res) { + should.not.exist(err); + res.length.should.equal(0); done(); }); }); it('should contain walletId & creatorId on NewCopayer', function(done) { - server.getNotifications({ - minTs: 0, - }, function(err, notifications) { + server.getNotifications({}, function(err, notifications) { should.not.exist(err); var newCopayer = notifications[0]; newCopayer.type.should.equal('NewCopayer'); @@ -3963,12 +3991,12 @@ describe('Wallet service', function() { signatures: signatures, }, function(err) { server.getNotifications({ - limit: 3, - reverse: true, + minTs: Date.now(), }, function(err, notifications) { should.not.exist(err); + notifications.length.should.equal(2); var types = _.pluck(notifications, 'type'); - types.should.deep.equal(['TxProposalFinallyAccepted', 'TxProposalAcceptedBy', 'NewTxProposal']); + types.should.deep.equal(['TxProposalAcceptedBy', 'TxProposalFinallyAccepted']); done(); }); }); @@ -3983,12 +4011,12 @@ describe('Wallet service', function() { }, function(err) { should.not.exist(err); server.getNotifications({ - limit: 2, - reverse: true, + minTs: Date.now(), }, function(err, notifications) { should.not.exist(err); + notifications.length.should.equal(2); var types = _.pluck(notifications, 'type'); - types.should.deep.equal(['TxProposalFinallyRejected', 'TxProposalRejectedBy']); + types.should.deep.equal(['TxProposalRejectedBy', 'TxProposalFinallyRejected']); done(); }); }); @@ -4011,12 +4039,12 @@ describe('Wallet service', function() { }, function(err, txp) { should.not.exist(err); server.getNotifications({ - limit: 3, - reverse: true, + minTs: Date.now(), }, function(err, notifications) { should.not.exist(err); + notifications.length.should.equal(3); var types = _.pluck(notifications, 'type'); - types.should.deep.equal(['NewOutgoingTx', 'TxProposalFinallyAccepted', 'TxProposalAcceptedBy']); + types.should.deep.equal(['TxProposalAcceptedBy', 'TxProposalFinallyAccepted', 'NewOutgoingTx']); done(); }); }); @@ -4043,12 +4071,12 @@ describe('Wallet service', function() { }, function(err, txp) { should.not.exist(err); server.getNotifications({ - limit: 3, - reverse: true, + minTs: Date.now(), }, function(err, notifications) { should.not.exist(err); + notifications.length.should.equal(3); var types = _.pluck(notifications, 'type'); - types.should.deep.equal(['NewOutgoingTxByThirdParty', 'TxProposalFinallyAccepted', 'TxProposalAcceptedBy']); + types.should.deep.equal(['TxProposalAcceptedBy', 'TxProposalFinallyAccepted', 'NewOutgoingTxByThirdParty']); done(); }); }); @@ -4101,7 +4129,7 @@ describe('Wallet service', function() { }); }, function(next) { - server.storage.fetchNotifications(wallet.id, {}, function(err, items) { + server.getNotifications({}, function(err, items) { items.length.should.equal(0); next(); });