diff --git a/.travis.yml b/.travis.yml index 168369451..d431fa429 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,5 +3,6 @@ language: node_js node_js: - "0.10" - "0.12" - - "iojs" + - "4" + - "6" diff --git a/CHANGES.md b/CHANGES.md index ea87f5de4..aa42725cf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,47 @@ +2016-05-02, Version 2.28.0 +========================== + + * Add new feature to emit a `remoteMethodDisabled` event when disabling a remote method. (Supasate Choochaisri) + + * Fix typo in Model.nestRemoting (Tim Needham) + + * Allow built-in token middleware to run repeatedly (Benjamin Kröger) + + * Improve error message on connector init error (Miroslav Bajtoš) + + * application: correct spelling of "cannont" (Sam Roberts) + + +2016-02-19, Version 2.27.0 +========================== + + * Remove sl-blip from dependency (Candy) + + * Fix race condition in replication tests (Miroslav Bajtoš) + + * test: remove errant console.log from test (Ryan Graham) + + * Promisify Model Change (Jue Hou) + + * Fix race condition in error handler test (Miroslav Bajtoš) + + * Travis: drop iojs, add v4.x and v5.x (Miroslav Bajtoš) + + * Correct JSDoc findOrCreate() callback in PersistedModel (Miroslav Bajtoš) + + * Hide verificationToken (Miroslav Bajtoš) + + * test: use ephemeral port for e2e server (Ryan Graham) + + * test: fail on error instead of crash (Ryan Graham) + + * ensure app is booted before integration tests (Ryan Graham) + + * Checkpoint speedup (Amir Jafarian) + + * Pull in API doc fix from PR into master #1910 (crandmck) + + 2015-12-22, Version 2.26.2 ========================== @@ -989,8 +1033,6 @@ 2014-07-15, Version 2.0.0-beta6 =============================== - * 2.0.0-beta6 (Miroslav Bajtoš) - * lib/application: publish Change models to REST API (Miroslav Bajtoš) * models/change: fix typo (Miroslav Bajtoš) @@ -1001,8 +1043,6 @@ 2014-07-03, Version 2.0.0-beta5 =============================== - * 2.0.0-beta5 (Miroslav Bajtoš) - * app: update `url` on `listening` event (Miroslav Bajtoš) * Fix "ReferenceError: loopback is not defined" in registry.memory(). (Guilherme Cirne) @@ -1021,8 +1061,6 @@ 2014-06-26, Version 2.0.0-beta4 =============================== - * 2.0.0-beta4 (Miroslav Bajtoš) - * package: upgrade juggler to 2.0.0-beta2 (Miroslav Bajtoš) * Fix loopback in PhantomJS, fix karma tests (Miroslav Bajtoš) @@ -1149,8 +1187,6 @@ 2014-05-28, Version 2.0.0-beta3 =============================== - * 2.0.0-beta3 (Miroslav Bajtoš) - * package.json: fix malformed json (Miroslav Bajtoš) * 2.0.0-beta2 (Ritchie Martori) diff --git a/Gruntfile.js b/Gruntfile.js index a7a755c81..3df919962 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -217,7 +217,10 @@ module.exports = function(grunt) { grunt.registerTask('e2e-server', function() { var done = this.async(); var app = require('./test/fixtures/e2e/app'); - app.listen(3000, done); + app.listen(0, function() { + process.env.PORT = this.address().port; + done(); + }); }); grunt.registerTask('e2e', ['e2e-server', 'karma:e2e']); diff --git a/common/models/change.js b/common/models/change.js index be353a039..3b347ec5c 100644 --- a/common/models/change.js +++ b/common/models/change.js @@ -4,6 +4,7 @@ var PersistedModel = require('../../lib/loopback').PersistedModel; var loopback = require('../../lib/loopback'); +var utils = require('../../lib/utils'); var crypto = require('crypto'); var CJSON = {stringify: require('canonical-json')}; var async = require('async'); @@ -77,6 +78,8 @@ module.exports = function(Change) { var Change = this; var errors = []; + callback = callback || utils.createPromiseCallback(); + var tasks = modelIds.map(function(id) { return function(cb) { Change.findOrCreateChange(modelName, id, function(err, change) { @@ -111,6 +114,7 @@ module.exports = function(Change) { } callback(); }); + return callback.promise; }; /** @@ -138,6 +142,7 @@ module.exports = function(Change) { Change.findOrCreateChange = function(modelName, modelId, callback) { assert(loopback.findModel(modelName), modelName + ' does not exist'); + callback = callback || utils.createPromiseCallback(); var id = this.idForModel(modelName, modelId); var Change = this; @@ -155,6 +160,7 @@ module.exports = function(Change) { Change.updateOrCreate(ch, callback); } }); + return callback.promise; }; /** @@ -171,9 +177,7 @@ module.exports = function(Change) { change.debug('rectify change'); - cb = cb || function(err) { - if (err) throw new Error(err); - }; + cb = cb || utils.createPromiseCallback(); change.currentRevision(function(err, rev) { if (err) return cb(err); @@ -194,6 +198,7 @@ module.exports = function(Change) { } ); }); + return cb.promise; function doRectify(checkpoint, rev) { if (rev) { @@ -248,6 +253,7 @@ module.exports = function(Change) { */ Change.prototype.currentRevision = function(cb) { + cb = cb || utils.createPromiseCallback(); var model = this.getModelCtor(); var id = this.getModelId(); model.findById(id, function(err, inst) { @@ -258,6 +264,7 @@ module.exports = function(Change) { cb(null, null); } }); + return cb.promise; }; /** @@ -390,8 +397,11 @@ module.exports = function(Change) { */ Change.diff = function(modelName, since, remoteChanges, callback) { + callback = callback || utils.createPromiseCallback(); + if (!Array.isArray(remoteChanges) || remoteChanges.length === 0) { - return callback(null, {deltas: [], conflicts: []}); + callback(null, {deltas: [], conflicts: []}); + return callback.promise; } var remoteChangeIndex = {}; var modelIds = []; @@ -455,6 +465,7 @@ module.exports = function(Change) { conflicts: conflicts }); }); + return callback.promise; }; /** diff --git a/common/models/checkpoint.js b/common/models/checkpoint.js index 304f83148..59cc33de0 100644 --- a/common/models/checkpoint.js +++ b/common/models/checkpoint.js @@ -27,43 +27,45 @@ module.exports = function(Checkpoint) { * Get the current checkpoint id * @callback {Function} callback * @param {Error} err - * @param {Number} checkpointId The current checkpoint id + * @param {Number} checkpoint The current checkpoint seq */ - Checkpoint.current = function(cb) { var Checkpoint = this; - this.find({ - limit: 1, - order: 'seq DESC' - }, function(err, checkpoints) { - if (err) return cb(err); - var checkpoint = checkpoints[0]; - if (checkpoint) { - cb(null, checkpoint.seq); - } else { - Checkpoint.create({ seq: 1 }, function(err, checkpoint) { - if (err) return cb(err); - cb(null, checkpoint.seq); - }); - } + Checkpoint._getSingleton(function(err, cp) { + cb(err, cp.seq); }); }; - Checkpoint.observe('before save', function(ctx, next) { - if (!ctx.instance) { - // Example: Checkpoint.updateAll() and Checkpoint.updateOrCreate() - return next(new Error('Checkpoint does not support partial updates.')); - } + Checkpoint._getSingleton = function(cb) { + var query = {limit: 1}; // match all instances, return only one + var initialData = {seq: 1}; + this.findOrCreate(query, initialData, cb); + }; - var model = ctx.instance; - if (!model.getId() && model.seq === undefined) { - model.constructor.current(function(err, seq) { - if (err) return next(err); - model.seq = seq + 1; - next(); + /** + * Increase the current checkpoint if it already exists otherwise initialize it + * @callback {Function} callback + * @param {Error} err + * @param {Object} checkpoint The current checkpoint + */ + Checkpoint.bumpLastSeq = function(cb) { + var Checkpoint = this; + Checkpoint._getSingleton(function(err, cp) { + if (err) return cb(err); + var originalSeq = cp.seq; + cp.seq++; + // Update the checkpoint but only if it was not changed under our hands + Checkpoint.updateAll({id: cp.id, seq: originalSeq}, {seq: cp.seq}, function(err, info) { + if (err) return cb(err); + // possible outcomes + // 1) seq was updated to seq+1 - exactly what we wanted! + // 2) somebody else already updated seq to seq+1 and our call was a no-op. + // That should be ok, checkpoints are time based, so we reuse the one created just now + // 3) seq was bumped more than once, so we will be using a value that is behind the latest seq. + // @bajtos is not entirely sure if this is ok, but since it wasn't handled by the current implementation either, + // he thinks we can keep it this way. + cb(null, cp); }); - } else { - next(); - } - }); + }); + }; }; diff --git a/common/models/role.js b/common/models/role.js index a176fec17..523e68982 100644 --- a/common/models/role.js +++ b/common/models/role.js @@ -147,12 +147,10 @@ module.exports = function(Role) { }); function isUserClass(modelClass) { - if (modelClass) { - return modelClass === loopback.User || - modelClass.prototype instanceof loopback.User; - } else { - return false; - } + if (!modelClass) return false; + var User = modelClass.modelBuilder.models.User; + if (!User) return false; + return modelClass == User || modelClass.prototype instanceof User; } /*! diff --git a/common/models/user.json b/common/models/user.json index d70a89d3f..16545ab4b 100644 --- a/common/models/user.json +++ b/common/models/user.json @@ -32,7 +32,7 @@ "options": { "caseSensitiveEmail": true }, - "hidden": ["password"], + "hidden": ["password", "verificationToken"], "acls": [ { "principalType": "ROLE", diff --git a/lib/application.js b/lib/application.js index bd3375539..dad8f24f6 100644 --- a/lib/application.js +++ b/lib/application.js @@ -153,6 +153,11 @@ app.model = function(Model, config) { this.emit('modelRemoted', Model.sharedClass); } + var self = this; + Model.on('remoteMethodDisabled', function(model, methodName) { + self.emit('remoteMethodDisabled', model, methodName); + }); + Model.shared = isPublic; Model.app = this; Model.emit('attached', this); @@ -219,11 +224,19 @@ app.models = function() { * @param {Object} config The data source config */ app.dataSource = function(name, config) { - var ds = dataSourcesFromConfig(name, config, this.connectors, this.registry); - this.dataSources[name] = - this.dataSources[classify(name)] = - this.dataSources[camelize(name)] = ds; - return ds; + try { + var ds = dataSourcesFromConfig(name, config, this.connectors, this.registry); + this.dataSources[name] = + this.dataSources[classify(name)] = + this.dataSources[camelize(name)] = ds; + return ds; + } catch (err) { + if (err.message) { + err.message = 'Cannot create data source ' + JSON.stringify(name) + + ': ' + err.message; + } + throw err; + } }; /** @@ -397,7 +410,7 @@ function dataSourcesFromConfig(name, config, connectorRegistry, registry) { var connectorPath; assert(typeof config === 'object', - 'cannont create data source without config object'); + 'can not create data source without config object'); if (typeof config.connector === 'string') { name = config.connector; @@ -410,6 +423,8 @@ function dataSourcesFromConfig(name, config, connectorRegistry, registry) { config.connector = require(connectorPath); } } + if (!config.connector.name) + config.connector.name = name; } return registry.createDataSource(config); @@ -551,7 +566,11 @@ app.listen = function(cb) { (arguments.length == 1 && typeof arguments[0] == 'function'); if (useAppConfig) { - server.listen(this.get('port'), this.get('host'), cb); + var port = this.get('port'); + // NOTE(bajtos) port:undefined no longer works on node@6, + // we must pass port:0 explicitly + if (port === undefined) port = 0; + server.listen(port, this.get('host'), cb); } else { server.listen.apply(server, arguments); } diff --git a/lib/model.js b/lib/model.js index f3ec47b25..685e84045 100644 --- a/lib/model.js +++ b/lib/model.js @@ -431,6 +431,7 @@ module.exports = function(registry) { Model.disableRemoteMethod = function(name, isStatic) { this.sharedClass.disableMethod(name, isStatic || false); + this.emit('remoteMethodDisabled', this.sharedClass, name); }; Model.belongsToRemoting = function(relationName, relation, define) { @@ -806,8 +807,8 @@ module.exports = function(registry) { listenerTree.before = listenerTree.before || {}; listenerTree.after = listenerTree.after || {}; - var beforeListeners = remotes.listenerTree.before[toModelName] || {}; - var afterListeners = remotes.listenerTree.after[toModelName] || {}; + var beforeListeners = listenerTree.before[toModelName] || {}; + var afterListeners = listenerTree.after[toModelName] || {}; sharedClass.methods().forEach(function(method) { var delegateTo = method.rest && method.rest.delegateTo; diff --git a/lib/persisted-model.js b/lib/persisted-model.js index b3bb62281..5dceeec04 100644 --- a/lib/persisted-model.js +++ b/lib/persisted-model.js @@ -111,17 +111,34 @@ module.exports = function(registry) { }; /** - * Find one record matching the optional `where` filter. The same as `find`, but limited to one object. - * Returns an object, not collection. - * If not found, create the object using data provided as second argument. + * Finds one record matching the optional filter object. If not found, creates + * the object using the data provided as second argument. In this sense it is + * the same as `find`, but limited to one object. Returns an object, not + * collection. If you don't provide the filter object argument, it tries to + * locate an existing object that matches the `data` argument. * - * @param {Object} where Where clause, such as `{test: 'me'}` - *
see - * [Where filter](https://docs.strongloop.com/display/LB/Where+filter#Wherefilter-Whereclauseforothermethods). + * @options {Object} [filter] Optional Filter object; see below. + * @property {String|Object|Array} fields Identify fields to include in return result. + *
See [Fields filter](http://docs.strongloop.com/display/LB/Fields+filter). + * @property {String|Object|Array} include See PersistedModel.include documentation. + *
See [Include filter](http://docs.strongloop.com/display/LB/Include+filter). + * @property {Number} limit Maximum number of instances to return. + *
See [Limit filter](http://docs.strongloop.com/display/LB/Limit+filter). + * @property {String} order Sort order: either "ASC" for ascending or "DESC" for descending. + *
See [Order filter](http://docs.strongloop.com/display/LB/Order+filter). + * @property {Number} skip Number of results to skip. + *
See [Skip filter](http://docs.strongloop.com/display/LB/Skip+filter). + * @property {Object} where Where clause, like + * ``` + * {where: {key: val, key2: {gt: val2}, ...}} + * ``` + *
See + * [Where filter](https://docs.strongloop.com/display/LB/Where+filter#Wherefilter-Whereclauseforqueries). * @param {Object} data Data to insert if object matching the `where` filter is not found. - * @callback {Function} callback Callback function called with `cb(err, instance)` arguments. Required. + * @callback {Function} callback Callback function called with `cb(err, instance, created)` arguments. Required. * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). * @param {Object} instance Model instance matching the `where` filter, if found. + * @param {Boolean} created True if the instance matching the `where` filter was created. */ PersistedModel.findOrCreate = function findOrCreate(query, data, callback) { @@ -882,12 +899,7 @@ module.exports = function(registry) { PersistedModel.checkpoint = function(cb) { var Checkpoint = this.getChangeModel().getCheckpointModel(); - this.getSourceId(function(err, sourceId) { - if (err) return cb(err); - Checkpoint.create({ - sourceId: sourceId - }, cb); - }); + Checkpoint.bumpLastSeq(cb); }; /** diff --git a/package.json b/package.json index bccf39157..2eb05c8b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "loopback", - "version": "2.26.2", + "version": "2.28.0", "description": "LoopBack: Open Source Framework for Node.js", "homepage": "http://loopback.io", "keywords": [ @@ -78,13 +78,15 @@ "karma-html2js-preprocessor": "^0.1.0", "karma-junit-reporter": "^0.2.2", "karma-mocha": "^0.1.10", - "karma-phantomjs-launcher": "^0.1.4", + "karma-phantomjs-launcher": "^1.0.0", "karma-script-launcher": "^0.1.0", "loopback-boot": "^2.7.0", "loopback-datasource-juggler": "^2.19.1", "loopback-testing": "~1.1.0", "mocha": "^2.1.0", + "phantomjs-prebuilt": "^2.1.7", "sinon": "^1.13.0", + "sinon-chai": "^2.8.0", "strong-task-emitter": "^0.0.6", "supertest": "^0.15.0" }, @@ -102,8 +104,10 @@ "depd": "loopback-datasource-juggler/lib/browser.depd.js", "bcrypt": false }, - "license": "MIT", - "optionalDependencies": { - "sl-blip": "http://blip.strongloop.com/loopback@2.26.2" - } + "config": { + "ci": { + "debug": "*,-mocha:*,-eslint:*" + } + }, + "license": "MIT" } diff --git a/server/middleware/token.js b/server/middleware/token.js index e80eb560b..146c75d17 100644 --- a/server/middleware/token.js +++ b/server/middleware/token.js @@ -62,6 +62,8 @@ function escapeRegExp(str) { * @property {Array} [headers] Array of header names. * @property {Array} [params] Array of param names. * @property {Boolean} [searchDefaultTokenKeys] Use the default search locations for Token in request + * @property {Boolean} [enableDoublecheck] Execute middleware although an instance mounted earlier in the chain didn't find a token + * @property {Boolean} [overwriteExistingToken] only has effect in combination with `enableDoublecheck`. If truthy, will allow to overwrite an existing accessToken. * @property {Function|String} [model] AccessToken model name or class to use. * @property {String} [currentUserLiteral] String literal for the current user. * @header loopback.token([options]) @@ -80,6 +82,9 @@ function token(options) { currentUserLiteral = escapeRegExp(currentUserLiteral); } + var enableDoublecheck = !!options.enableDoublecheck; + var overwriteExistingToken = !!options.overwriteExistingToken; + return function(req, res, next) { var app = req.app; var registry = app.registry; @@ -97,8 +102,19 @@ function token(options) { 'loopback.token() middleware requires a AccessToken model'); if (req.accessToken !== undefined) { - rewriteUserLiteral(req, currentUserLiteral); - return next(); + if (!enableDoublecheck) { + // req.accessToken is defined already (might also be "null" or "false") and enableDoublecheck + // has not been set --> skip searching for credentials + rewriteUserLiteral(req, currentUserLiteral); + return next(); + } + if (req.accessToken && req.accessToken.id && !overwriteExistingToken) { + // req.accessToken.id is defined, which means that some other middleware has identified a valid user. + // when overwriteExistingToken is not set to a truthy value, skip searching for credentials. + rewriteUserLiteral(req, currentUserLiteral); + return next(); + } + // continue normal operation (as if req.accessToken was undefined) } TokenModel.findForRequest(req, options, function(err, token) { req.accessToken = token || null; diff --git a/test/access-control.integration.js b/test/access-control.integration.js index 3125a6535..7e417bfe3 100644 --- a/test/access-control.integration.js +++ b/test/access-control.integration.js @@ -11,6 +11,12 @@ var CURRENT_USER = {email: 'current@test.test', password: 'test'}; var debug = require('debug')('loopback:test:access-control.integration'); describe('access control - integration', function() { + before(function(done) { + if (app.booting) { + return app.once('booted', done); + } + done(); + }); lt.beforeEach.withApp(app); diff --git a/test/access-token.test.js b/test/access-token.test.js index 9e57cba2a..a2ddbdb09 100644 --- a/test/access-token.test.js +++ b/test/access-token.test.js @@ -65,7 +65,7 @@ describe('loopback.token(options)', function() { .end(done); }); - describe('populating req.toen from HTTP Basic Auth formatted authorization header', function() { + describe('populating req.token from HTTP Basic Auth formatted authorization header', function() { it('parses "standalone-token"', function(done) { var token = this.token.id; token = 'Basic ' + new Buffer(token).toString('base64'); @@ -199,6 +199,90 @@ describe('loopback.token(options)', function() { done(); }); }); + + describe('loading multiple instances of token middleware', function() { + it('should skip when req.token is already present and no further options are set', + function(done) { + var tokenStub = { id: 'stub id' }; + app.use(function(req, res, next) { + req.accessToken = tokenStub; + next(); + }); + app.use(loopback.token({ model: Token })); + app.get('/', function(req, res, next) { + res.send(req.accessToken); + }); + + request(app).get('/') + .set('Authorization', this.token.id) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(res.body).to.eql(tokenStub); + done(); + }); + }); + + it('should not overwrite valid existing token (has "id" property) ' + + ' when overwriteExistingToken is falsy', + function(done) { + var tokenStub = { id: 'stub id' }; + app.use(function(req, res, next) { + req.accessToken = tokenStub; + next(); + }); + app.use(loopback.token({ + model: Token, + enableDoublecheck: true, + })); + app.get('/', function(req, res, next) { + res.send(req.accessToken); + }); + + request(app).get('/') + .set('Authorization', this.token.id) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(res.body).to.eql(tokenStub); + done(); + }); + }); + + it('should overwrite existing token when enableDoublecheck ' + + 'and overwriteExistingToken options are truthy', + function(done) { + var token = this.token; + var tokenStub = { id: 'stub id' }; + + app.use(function(req, res, next) { + req.accessToken = tokenStub; + next(); + }); + app.use(loopback.token({ + model: Token, + enableDoublecheck: true, + overwriteExistingToken: true, + })); + app.get('/', function(req, res, next) { + res.send(req.accessToken); + }); + + request(app).get('/') + .set('Authorization', token.id) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(res.body).to.eql({ + id: token.id, + ttl: token.ttl, + userId: token.userId, + created: token.created.toJSON(), + }); + done(); + }); + }); + }); }); describe('AccessToken', function() { diff --git a/test/app.test.js b/test/app.test.js index 2701ef9d2..347d738b7 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -606,6 +606,22 @@ describe('app', function() { expect(remotedClass).to.eql(Color.sharedClass); }); + it('emits a `remoteMethodDisabled` event', function() { + var Color = PersistedModel.extend('color', { name: String }); + Color.shared = true; + var remoteMethodDisabledClass, disabledRemoteMethod; + app.on('remoteMethodDisabled', function(sharedClass, methodName) { + remoteMethodDisabledClass = sharedClass; + disabledRemoteMethod = methodName; + }); + app.model(Color); + app.models.Color.disableRemoteMethod('findOne'); + expect(remoteMethodDisabledClass).to.exist; + expect(remoteMethodDisabledClass).to.eql(Color.sharedClass); + expect(disabledRemoteMethod).to.exist; + expect(disabledRemoteMethod).to.eql('findOne'); + }); + it.onServer('updates REST API when a new model is added', function(done) { app.use(loopback.rest()); request(app).get('/colors').expect(404, function(err, res) { @@ -732,6 +748,16 @@ describe('app', function() { app.dataSource('custom', { connector: 'custom' }); expect(app.dataSources.custom.name).to.equal(loopback.Memory.name); }); + + it('adds data source name to error messages', function() { + app.connector('throwing', { + initialize: function() { throw new Error('expected test error'); }, + }); + + expect(function() { + app.dataSource('bad-ds', { connector: 'throwing' }); + }).to.throw(/bad-ds.*throwing/); + }); }); describe.onServer('listen()', function() { diff --git a/test/change.test.js b/test/change.test.js index 55d551c72..2f43c037f 100644 --- a/test/change.test.js +++ b/test/change.test.js @@ -82,6 +82,38 @@ describe('Change', function() { }); }); + describe('Change.rectifyModelChanges - promise variant', function() { + describe('using an existing untracked model', function() { + beforeEach(function(done) { + var test = this; + Change.rectifyModelChanges(this.modelName, [this.modelId]) + .then(function(trackedChanges) { + done(); + }) + .catch(done); + }); + + it('should create an entry', function(done) { + var test = this; + Change.find() + .then(function(trackedChanges) { + assert.equal(trackedChanges[0].modelId, test.modelId.toString()); + done(); + }) + .catch(done); + }); + + it('should only create one change', function(done) { + Change.count() + .then(function(count) { + assert.equal(count, 1); + done(); + }) + .catch(done); + }); + }); + }); + describe('Change.findOrCreateChange(modelName, modelId, callback)', function() { describe('when a change doesnt exist', function() { @@ -104,6 +136,27 @@ describe('Change', function() { }); }); + describe('when a change doesnt exist - promise variant', function() { + beforeEach(function(done) { + var test = this; + Change.findOrCreateChange(this.modelName, this.modelId) + .then(function(result) { + test.result = result; + done(); + }) + .catch(done); + }); + + it('should create an entry', function(done) { + var test = this; + Change.findById(this.result.id, function(err, change) { + if (err) return done(err); + assert.equal(change.id, test.result.id); + done(); + }); + }); + }); + describe('when a change does exist', function() { beforeEach(function(done) { var test = this; @@ -219,6 +272,28 @@ describe('Change', function() { }); }); + describe('change.rectify - promise variant', function() { + var change; + beforeEach(function(done) { + Change.findOrCreateChange(this.modelName, this.modelId) + .then(function(ch) { + change = ch; + done(); + }) + .catch(done); + }); + + it('should create a new change with the correct revision', function(done) { + var test = this; + change.rectify() + .then(function(ch) { + assert.equal(ch.rev, test.revisionForModel); + done(); + }) + .catch(done); + }); + }); + describe('change.currentRevision(callback)', function() { it('should get the correct revision', function(done) { var test = this; @@ -234,6 +309,23 @@ describe('Change', function() { }); }); + describe('change.currentRevision - promise variant', function() { + it('should get the correct revision', function(done) { + var test = this; + var change = new Change({ + modelName: this.modelName, + modelId: this.modelId + }); + + change.currentRevision() + .then(function(rev) { + assert.equal(rev, test.revisionForModel); + done(); + }) + .catch(done); + }); + }); + describe('Change.hash(str)', function() { // todo(ritch) test other hashing algorithms it('should hash the given string', function() { @@ -374,6 +466,25 @@ describe('Change', function() { }); }); + it('should return delta and conflict lists - promise variant', function(done) { + var remoteChanges = [ + // an update => should result in a delta + {rev: 'foo2', prev: 'foo', modelName: this.modelName, modelId: 9, checkpoint: 1}, + // no change => should not result in a delta / conflict + {rev: 'bar', prev: 'bar', modelName: this.modelName, modelId: 10, checkpoint: 1}, + // a conflict => should result in a conflict + {rev: 'bat2', prev: 'bat0', modelName: this.modelName, modelId: 11, checkpoint: 1}, + ]; + + Change.diff(this.modelName, 0, remoteChanges) + .then(function(diff) { + assert.equal(diff.deltas.length, 1); + assert.equal(diff.conflicts.length, 1); + done(); + }) + .catch(done); + }); + it('should set "prev" to local revision in non-conflicting delta', function(done) { var updateRecord = { rev: 'foo-new', diff --git a/test/checkpoint.test.js b/test/checkpoint.test.js index 98e248efb..c824d02ea 100644 --- a/test/checkpoint.test.js +++ b/test/checkpoint.test.js @@ -1,20 +1,22 @@ var async = require('async'); var loopback = require('../'); +var expect = require('chai').expect; -// create a unique Checkpoint model var Checkpoint = loopback.Checkpoint.extend('TestCheckpoint'); -var memory = loopback.createDataSource({ - connector: loopback.Memory -}); -Checkpoint.attachTo(memory); - describe('Checkpoint', function() { - describe('current()', function() { + describe('bumpLastSeq() and current()', function() { + beforeEach(function() { + var memory = loopback.createDataSource({ + connector: loopback.Memory + }); + Checkpoint.attachTo(memory); + }); + it('returns the highest `seq` value', function(done) { async.series([ - Checkpoint.create.bind(Checkpoint), - Checkpoint.create.bind(Checkpoint), + Checkpoint.bumpLastSeq.bind(Checkpoint), + Checkpoint.bumpLastSeq.bind(Checkpoint), function(next) { Checkpoint.current(function(err, seq) { if (err) next(err); @@ -24,5 +26,59 @@ describe('Checkpoint', function() { } ], done); }); + + it('Should be no race condition for current() when calling in parallel', function(done) { + async.parallel([ + function(next) { Checkpoint.current(next); }, + function(next) { Checkpoint.current(next); } + ], function(err, list) { + if (err) return done(err); + Checkpoint.find(function(err, data) { + if (err) return done(err); + expect(data).to.have.length(1); + done(); + }); + }); + }); + + it('Should be no race condition for bumpLastSeq() when calling in parallel', function(done) { + async.parallel([ + function(next) { Checkpoint.bumpLastSeq(next); }, + function(next) { Checkpoint.bumpLastSeq(next); } + ], function(err, list) { + if (err) return done(err); + Checkpoint.find(function(err, data) { + if (err) return done(err); + // The invariant "we have at most 1 checkpoint instance" is preserved + // even when multiple calls are made in parallel + expect(data).to.have.length(1); + // There is a race condition here, we could end up with both 2 or 3 as the "seq". + // The current implementation of the memory connector always yields 2 though. + expect(data[0].seq).to.equal(2); + // In this particular case, since the new last seq is always 2, both results + // should be 2. + expect(list.map(function(it) {return it.seq;})) + .to.eql([2, 2]); + done(); + }); + }); + }); + + it('Checkpoint.current() for non existing checkpoint should initialize checkpoint', function(done) { + Checkpoint.current(function(err, seq) { + expect(seq).to.equal(1); + done(err); + }); + }); + + it('bumpLastSeq() works when singleton instance does not exists yet', function(done) { + Checkpoint.bumpLastSeq(function(err, cp) { + // We expect `seq` to be 2 since `checkpoint` does not exist and + // `bumpLastSeq` for the first time not only initializes it to one, + // but also increments the initialized value by one. + expect(cp.seq).to.equal(2); + done(err); + }); + }); }); }); diff --git a/test/error-handler.test.js b/test/error-handler.test.js index d19abf47e..0494f2ef3 100644 --- a/test/error-handler.test.js +++ b/test/error-handler.test.js @@ -41,15 +41,23 @@ describe('loopback.errorHandler(options)', function() { //arrange var app = loopback(); app.use(loopback.urlNotFound()); - app.use(loopback.errorHandler({ includeStack: false, log: customLogger })); - //act - request(app).get('/url-does-not-exist').end(); + var errorLogged; + app.use(loopback.errorHandler({ + includeStack: false, + log: function customLogger(err, str, req) { + errorLogged = err; + } + })); - //assert - function customLogger(err, str, req) { - assert.ok(err.message === 'Cannot GET /url-does-not-exist'); + //act + request(app).get('/url-does-not-exist').end(function(err) { + if (err) return done(err); + //assert + expect(errorLogged) + .to.have.property('message', 'Cannot GET /url-does-not-exist'); done(); - } + }); + }); }); diff --git a/test/model.test.js b/test/model.test.js index b9a16f36d..37147f40a 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -1,9 +1,13 @@ var async = require('async'); +var chai = require('chai'); +var expect = chai.expect; var loopback = require('../'); var ACL = loopback.ACL; var Change = loopback.Change; var defineModelTestsWithDataSource = require('./util/model-tests'); var PersistedModel = loopback.PersistedModel; +var sinonChai = require('sinon-chai'); +chai.use(sinonChai); var describe = require('./util/describe'); @@ -618,6 +622,20 @@ describe.onServer('Remote Methods', function() { 'createChangeStream' ]); }); + + it('emits a `remoteMethodDisabled` event', function() { + var app = loopback(); + var model = PersistedModel.extend('TestModelForDisablingRemoteMethod'); + app.dataSource('db', { connector: 'memory' }); + app.model(model, { dataSource: 'db' }); + + var callbackSpy = require('sinon').spy(); + var TestModel = app.models.TestModelForDisablingRemoteMethod; + TestModel.on('remoteMethodDisabled', callbackSpy); + TestModel.disableRemoteMethod('findOne'); + + expect(callbackSpy).to.have.been.calledWith(TestModel.sharedClass, 'findOne'); + }); }); describe('Model.getApp(cb)', function() { diff --git a/test/relations.integration.js b/test/relations.integration.js index 70f1ae122..dbb2e2556 100644 --- a/test/relations.integration.js +++ b/test/relations.integration.js @@ -11,6 +11,12 @@ var debug = require('debug')('loopback:test:relations.integration'); var async = require('async'); describe('relations - integration', function() { + before(function(done) { + if (app.booting) { + return app.once('booted', done); + } + done(); + }); lt.beforeEach.withApp(app); @@ -91,6 +97,7 @@ describe('relations - integration', function() { this.get(url) .query({'filter': {'include' : 'pictures'}}) .expect(200, function(err, res) { + if (err) return done(err); // console.log(res.body); expect(res.body.name).to.be.equal('Reader 1'); expect(res.body.pictures).to.be.eql([ @@ -106,6 +113,7 @@ describe('relations - integration', function() { this.get(url) .query({'filter': {'include' : 'imageable'}}) .expect(200, function(err, res) { + if (err) return done(err); // console.log(res.body); expect(res.body[0].name).to.be.equal('Picture 1'); expect(res.body[1].name).to.be.equal('Picture 2'); @@ -119,6 +127,7 @@ describe('relations - integration', function() { this.get(url) .query({'filter': {'include' : {'relation': 'imageable', 'scope': { 'include' : 'team'}}}}) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body[0].name).to.be.equal('Picture 1'); expect(res.body[1].name).to.be.equal('Picture 2'); expect(res.body[0].imageable.name).to.be.eql('Reader 1'); @@ -133,6 +142,7 @@ describe('relations - integration', function() { it('should invoke scoped methods remotely', function(done) { this.get('/api/stores/superStores') .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.array; done(); }); @@ -369,7 +379,7 @@ describe('relations - integration', function() { self.url = root.relUrl; self.patient = root.patient; self.physician = root.physician; - done(); + done(err); }); }); @@ -408,7 +418,7 @@ describe('relations - integration', function() { self.url = root.relUrl; self.patient = root.patient; self.physician = root.physician; - done(); + done(err); }); }); @@ -453,7 +463,7 @@ describe('relations - integration', function() { self.url = root.relUrl; self.patient = root.patient; self.physician = root.physician; - done(); + done(err); }); }); @@ -473,7 +483,7 @@ describe('relations - integration', function() { '/patients/rel/' + '999'; self.patient = root.patient; self.physician = root.physician; - done(); + done(err); }); }); @@ -492,7 +502,7 @@ describe('relations - integration', function() { self.url = root.relUrl; self.patient = root.patient; self.physician = root.physician; - done(); + done(err); }); }); @@ -547,7 +557,7 @@ describe('relations - integration', function() { '/patients/' + root.patient.id; self.patient = root.patient; self.physician = root.physician; - done(); + done(err); }); }); @@ -568,7 +578,7 @@ describe('relations - integration', function() { '/patients/' + root.patient.id; self.patient = root.patient; self.physician = root.physician; - done(); + done(err); }); }); @@ -684,6 +694,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.have.property('products'); expect(res.body.products).to.eql([ { @@ -703,6 +714,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.have.property('products'); expect(res.body.products).to.eql([ { @@ -764,6 +776,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body.name).to.be.equal('Group 1'); expect(res.body.poster).to.be.eql( { url: 'http://image.url' } @@ -777,6 +790,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql( { url: 'http://image.url' } ); @@ -800,6 +814,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql( { url: 'http://changed.url' } ); @@ -857,6 +872,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body.name).to.be.equal('List A'); expect(res.body.todoItems).to.be.eql([ { content: 'Todo 1', id: 1 }, @@ -871,6 +887,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql([ { content: 'Todo 1', id: 1 }, { content: 'Todo 2', id: 2 } @@ -885,6 +902,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql([ { content: 'Todo 2', id: 2 } ]); @@ -910,6 +928,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql([ { content: 'Todo 1', id: 1 }, { content: 'Todo 2', id: 2 }, @@ -924,6 +943,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql( { content: 'Todo 3', id: 3 } ); @@ -946,6 +966,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql([ { content: 'Todo 1', id: 1 }, { content: 'Todo 3', id: 3 } @@ -957,6 +978,7 @@ describe('relations - integration', function() { it('returns a 404 response when embedded model is not found', function(done) { var url = '/api/todo-lists/' + this.todoList.id + '/items/2'; this.get(url).expect(404, function(err, res) { + if (err) return done(err); expect(res.body.error.status).to.be.equal(404); expect(res.body.error.message).to.be.equal('Unknown "todoItem" id "2".'); expect(res.body.error.code).to.be.equal('MODEL_NOT_FOUND'); @@ -1044,6 +1066,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body.ingredientIds).to.eql([test.ingredient1]); expect(res.body).to.not.have.property('ingredients'); done(); @@ -1069,6 +1092,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql([ { name: 'Chocolate', id: test.ingredient1 }, { name: 'Sugar', id: test.ingredient2 }, @@ -1084,6 +1108,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql([ { name: 'Chocolate', id: test.ingredient1 }, { name: 'Butter', id: test.ingredient3 } @@ -1099,6 +1124,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql([ { name: 'Butter', id: test.ingredient3 } ]); @@ -1113,6 +1139,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body.ingredientIds).to.eql([ test.ingredient1, test.ingredient3 ]); @@ -1131,6 +1158,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql( { name: 'Butter', id: test.ingredient3 } ); @@ -1146,6 +1174,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body.ingredientIds).to.eql(expected); expect(res.body).to.not.have.property('ingredients'); done(); @@ -1169,6 +1198,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql([ { name: 'Chocolate', id: test.ingredient1 }, { name: 'Sugar', id: test.ingredient2 } @@ -1183,6 +1213,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql([ { name: 'Chocolate', id: test.ingredient1 } ]); @@ -1210,6 +1241,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql([ { name: 'Chocolate', id: test.ingredient1 }, { name: 'Sugar', id: test.ingredient2 } @@ -1235,6 +1267,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql([ { name: 'Sugar', id: test.ingredient2 } ]); @@ -1248,6 +1281,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.eql([ { name: 'Chocolate', id: test.ingredient1 }, { name: 'Sugar', id: test.ingredient2 } @@ -1261,6 +1295,7 @@ describe('relations - integration', function() { this.get(url) .expect(200, function(err, res) { + if (err) return done(err); expect(err).to.not.exist; expect(res.body.name).to.equal('Photo 1'); done(); @@ -1395,6 +1430,7 @@ describe('relations - integration', function() { var test = this; this.get('/api/books/' + test.book.id + '/pages') .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.an.array; expect(res.body).to.have.length(1); expect(res.body[0].name).to.equal('Page 1'); @@ -1406,6 +1442,7 @@ describe('relations - integration', function() { var test = this; this.get('/api/pages/' + test.page.id + '/notes/' + test.note.id) .expect(200, function(err, res) { + if (err) return done(err); expect(res.headers['x-before']).to.equal('before'); expect(res.headers['x-after']).to.equal('after'); expect(res.body).to.be.an.object; @@ -1418,6 +1455,7 @@ describe('relations - integration', function() { var test = this; this.get('/api/books/unknown/pages/' + test.page.id + '/notes') .expect(404, function(err, res) { + if (err) return done(err); expect(res.body.error).to.be.an.object; var expected = 'could not find a model with id unknown'; expect(res.body.error.message).to.equal(expected); @@ -1430,6 +1468,7 @@ describe('relations - integration', function() { var test = this; this.get('/api/images/' + test.image.id + '/book/pages') .end(function(err, res) { + if (err) return done(err); expect(res.body).to.be.an.array; expect(res.body).to.have.length(1); expect(res.body[0].name).to.equal('Page 1'); @@ -1441,6 +1480,7 @@ describe('relations - integration', function() { var test = this; this.get('/api/images/' + test.image.id + '/book/pages/' + test.page.id) .end(function(err, res) { + if (err) return done(err); expect(res.body).to.be.an.object; expect(res.body.name).to.equal('Page 1'); done(); @@ -1451,6 +1491,7 @@ describe('relations - integration', function() { var test = this; this.get('/api/books/' + test.book.id + '/pages/' + test.page.id + '/notes') .expect(200, function(err, res) { + if (err) return done(err); expect(res.body).to.be.an.array; expect(res.body).to.have.length(1); expect(res.body[0].text).to.equal('Page Note 1'); @@ -1462,6 +1503,7 @@ describe('relations - integration', function() { var test = this; this.get('/api/books/' + test.book.id + '/pages/' + test.page.id + '/notes/' + test.note.id) .expect(200, function(err, res) { + if (err) return done(err); expect(res.headers['x-before']).to.equal('before'); expect(res.headers['x-after']).to.equal('after'); expect(res.body).to.be.an.object; @@ -1474,6 +1516,7 @@ describe('relations - integration', function() { var test = this; this.get('/api/books/' + test.book.id + '/chapters/' + test.chapter.id + '/notes/' + test.cnote.id) .expect(200, function(err, res) { + if (err) return done(err); expect(res.headers['x-before']).to.empty; expect(res.headers['x-after']).to.empty; done(); @@ -1497,6 +1540,7 @@ describe('relations - integration', function() { var test = this; this.get('/api/books/' + test.book.id + '/pages/' + this.page.id + '/throws') .end(function(err, res) { + if (err) return done(err); expect(res.body).to.be.an('object'); expect(res.body.error).to.be.an('object'); expect(res.body.error.name).to.equal('Error'); diff --git a/test/remoting.integration.js b/test/remoting.integration.js index c3288d799..b2b92cd81 100644 --- a/test/remoting.integration.js +++ b/test/remoting.integration.js @@ -6,6 +6,12 @@ var app = require(path.join(SIMPLE_APP, 'server/server.js')); var assert = require('assert'); describe('remoting - integration', function() { + before(function(done) { + if (app.booting) { + return app.once('booted', done); + } + done(); + }); lt.beforeEach.withApp(app); lt.beforeEach.givenModel('store'); diff --git a/test/replication.test.js b/test/replication.test.js index 2606aaf53..ecdffbf8d 100644 --- a/test/replication.test.js +++ b/test/replication.test.js @@ -67,40 +67,26 @@ describe('Replication / Change APIs', function() { }); it('should call rectifyAllChanges if no id is passed for rectifyOnDelete', function(done) { - SourceModel.rectifyChange = function() { - return done(new Error('Should not call rectifyChange')); - }; - SourceModel.rectifyAllChanges = function() { - return done(); - }; + var calls = mockSourceModelRectify(); SourceModel.destroyAll({name: 'John'}, function(err, data) { - if (err) - return done(err); + if (err) return done(err); + expect(calls).to.eql(['rectifyAllChanges']); + done(); }); }); it('should call rectifyAllChanges if no id is passed for rectifyOnSave', function(done) { - SourceModel.rectifyChange = function() { - return done(new Error('Should not call rectifyChange')); - }; - SourceModel.rectifyAllChanges = function() { - return done(); - }; + var calls = mockSourceModelRectify(); var newData = {'name': 'Janie'}; SourceModel.update({name: 'Jane'}, newData, function(err, data) { - if (err) - return done(err); + if (err) return done(err); + expect(calls).to.eql(['rectifyAllChanges']); + done(); }); }); it('rectifyOnDelete for Delete should call rectifyChange instead of rectifyAllChanges', function(done) { - TargetModel.rectifyChange = function() { - return done(); - }; - TargetModel.rectifyAllChanges = function() { - return done(new Error('Should not call rectifyAllChanges')); - }; - + var calls = mockTargetModelRectify(); async.waterfall([ function(callback) { SourceModel.destroyAll({name: 'John'}, callback); @@ -110,19 +96,14 @@ describe('Replication / Change APIs', function() { // replicate should call `rectifyOnSave` and then `rectifyChange` not `rectifyAllChanges` through `after save` operation } ], function(err, results) { - if (err) - return done(err); + if (err) return done(err); + expect(calls).to.eql(['rectifyChange']); + done(); }); }); it('rectifyOnSave for Update should call rectifyChange instead of rectifyAllChanges', function(done) { - TargetModel.rectifyChange = function() { - return done(); - }; - TargetModel.rectifyAllChanges = function() { - return done(new Error('Should not call rectifyAllChanges')); - }; - + var calls = mockTargetModelRectify(); var newData = {'name': 'Janie'}; async.waterfall([ function(callback) { @@ -131,20 +112,16 @@ describe('Replication / Change APIs', function() { function(data, callback) { SourceModel.replicate(TargetModel, callback); // replicate should call `rectifyOnSave` and then `rectifyChange` not `rectifyAllChanges` through `after save` operation - }], function(err, result) { - if (err) - return done(err); + } + ], function(err, result) { + if (err) return done(err); + expect(calls).to.eql(['rectifyChange']); + done(); }); }); it('rectifyOnSave for Create should call rectifyChange instead of rectifyAllChanges', function(done) { - TargetModel.rectifyChange = function() { - return done(); - }; - TargetModel.rectifyAllChanges = function() { - return done(new Error('Should not call rectifyAllChanges')); - }; - + var calls = mockTargetModelRectify(); var newData = [{name: 'Janie', surname: 'Doe'}]; async.waterfall([ function(callback) { @@ -155,10 +132,43 @@ describe('Replication / Change APIs', function() { // replicate should call `rectifyOnSave` and then `rectifyChange` not `rectifyAllChanges` through `after save` operation } ], function(err, result) { - if (err) - return done(err); + if (err) return done(err); + expect(calls).to.eql(['rectifyChange']); + done(); }); }); + + function mockSourceModelRectify() { + var calls = []; + + SourceModel.rectifyChange = function(id, cb) { + calls.push('rectifyChange'); + process.nextTick(cb); + }; + + SourceModel.rectifyAllChanges = function(cb) { + calls.push('rectifyAllChanges'); + process.nextTick(cb); + }; + + return calls; + } + + function mockTargetModelRectify() { + var calls = []; + + TargetModel.rectifyChange = function(id, cb) { + calls.push('rectifyChange'); + process.nextTick(cb); + }; + + TargetModel.rectifyAllChanges = function(cb) { + calls.push('rectifyAllChanges'); + process.nextTick(cb); + }; + + return calls; + } }); describe('Model.changes(since, filter, callback)', function() { diff --git a/test/rest.middleware.test.js b/test/rest.middleware.test.js index 5757c44b6..61b935644 100644 --- a/test/rest.middleware.test.js +++ b/test/rest.middleware.test.js @@ -1,11 +1,15 @@ var path = require('path'); describe('loopback.rest', function() { - var MyModel; + var app, MyModel; + beforeEach(function() { - var ds = app.dataSource('db', { connector: loopback.Memory }); - MyModel = ds.createModel('MyModel', {name: String}); - loopback.autoAttach(); + // override the global app object provided by test/support.js + // and create a local one that does not share state with other tests + app = loopback({ localRegistry: true, loadBuiltinModels: true }); + var db = app.dataSource('db', { connector: 'memory' }); + MyModel = app.registry.createModel('MyModel'); + MyModel.attachTo(db); }); it('works out-of-the-box', function(done) { @@ -101,7 +105,7 @@ describe('loopback.rest', function() { }); it('should honour `remoting.rest.supportedTypes`', function(done) { - var app = loopback(); + var app = loopback({ localRegistry: true }); // NOTE it is crucial to set `remoting` before creating any models var supportedTypes = ['json', 'application/javascript', 'text/javascript']; @@ -117,26 +121,24 @@ describe('loopback.rest', function() { }); it('allows models to provide a custom HTTP path', function(done) { - var ds = app.dataSource('db', { connector: loopback.Memory }); - var CustomModel = ds.createModel('CustomModel', + var CustomModel = app.registry.createModel('CustomModel', { name: String }, { http: { 'path': 'domain1/CustomModelPath' } }); - app.model(CustomModel); + app.model(CustomModel, { dataSource: 'db' }); app.use(loopback.rest()); request(app).get('/domain1/CustomModelPath').expect(200).end(done); }); it('should report 200 for url-encoded HTTP path', function(done) { - var ds = app.dataSource('db', { connector: loopback.Memory }); - var CustomModel = ds.createModel('CustomModel', + var CustomModel = app.registry.createModel('CustomModel', { name: String }, { http: { path: 'domain%20one/CustomModelPath' } }); - app.model(CustomModel); + app.model(CustomModel, { dataSource: 'db' }); app.use(loopback.rest()); request(app).get('/domain%20one/CustomModelPath').expect(200).end(done); @@ -144,12 +146,12 @@ describe('loopback.rest', function() { it('includes loopback.token when necessary', function(done) { givenUserModelWithAuth(); - app.enableAuth(); + app.enableAuth({ dataSource: 'db' }); app.use(loopback.rest()); givenLoggedInUser(function(err, token) { if (err) return done(err); - expect(token).instanceOf(app.models.accessToken); + expect(token).instanceOf(app.models.AccessToken); request(app).get('/users/' + token.userId) .set('Authorization', token.id) .expect(200) @@ -268,25 +270,25 @@ describe('loopback.rest', function() { it('should enable context using loopback.context', function(done) { app.use(loopback.context({ enableHttpContext: true })); - app.enableAuth(); + app.enableAuth({ dataSource: 'db' }); app.use(loopback.rest()); invokeGetToken(done); }); it('should enable context with loopback.rest', function(done) { - app.enableAuth(); - app.set('remoting', { context: { enableHttpContext: true } }); + app.enableAuth({ dataSource: 'db' }); + app.set('remoting', { context: { enableHttpContext: true }}); app.use(loopback.rest()); invokeGetToken(done); }); it('should support explicit context', function(done) { - app.enableAuth(); + app.enableAuth({ dataSource: 'db' }); app.use(loopback.context()); app.use(loopback.token( - { model: loopback.getModelByType(loopback.AccessToken) })); + { model: app.registry.getModelByType('AccessToken') })); app.use(function(req, res, next) { loopback.getCurrentContext().set('accessToken', req.accessToken); next(); @@ -321,32 +323,26 @@ describe('loopback.rest', function() { }); function givenUserModelWithAuth() { - // NOTE(bajtos) It is important to create a custom AccessToken model here, - // in order to overwrite the entry created by previous tests in - // the global model registry - app.model('accessToken', { - options: { - base: 'AccessToken' - }, - dataSource: 'db' - }); - return app.model('user', { - options: { - base: 'User', - relations: { - accessTokens: { - model: 'accessToken', - type: 'hasMany', - foreignKey: 'userId' - } - } - }, - dataSource: 'db' - }); + var AccessToken = app.registry.getModel('AccessToken'); + app.model(AccessToken, { dataSource: 'db' }); + var User = app.registry.getModel('User'); + app.model(User, { dataSource: 'db' }); + + // NOTE(bajtos) This is puzzling to me. The built-in User & AccessToken + // models should come with both relations already set up, i.e. the + // following two lines should not be neccessary. + // And it does behave that way when only tests in this file are run. + // However, when I run the full test suite (all files), the relations + // get broken. + AccessToken.belongsTo(User, { as: 'user', foreignKey: 'userId' }); + User.hasMany(AccessToken, { as: 'accessTokens', foreignKey: 'userId' }); + + return User; } + function givenLoggedInUser(cb, done) { var credentials = { email: 'user@example.com', password: 'pwd' }; - var User = app.models.user; + var User = app.models.User; User.create(credentials, function(err, user) { if (err) return done(err); diff --git a/test/role.test.js b/test/role.test.js index 3053a0aff..8e5d1a129 100644 --- a/test/role.test.js +++ b/test/role.test.js @@ -407,4 +407,27 @@ describe('role model', function() { }); }); + describe('isOwner', function() { + it('supports app-local model registry', function(done) { + var app = loopback({ localRegistry: true, loadBuiltinModels: true }); + app.dataSource('db', { connector: 'memory' }); + // attach all auth-related models to 'db' datasource + app.enableAuth({ dataSource: 'db' }); + + var Role = app.models.Role; + var User = app.models.User; + + var u = app.registry.findModel('User'); + var credentials = { email: 'test@example.com', password: 'pass' }; + User.create(credentials, function(err, user) { + if (err) return done(err); + + Role.isOwner(User, user.id, user.id, function(err, result) { + if (err) return done(err); + expect(result, 'isOwner result').to.equal(true); + done(); + }); + }); + }); + }); }); diff --git a/test/user.integration.js b/test/user.integration.js index b2a92537b..fd4a198bc 100644 --- a/test/user.integration.js +++ b/test/user.integration.js @@ -7,6 +7,12 @@ var app = require(path.join(SIMPLE_APP, 'server/server.js')); var expect = require('chai').expect; describe('users - integration', function() { + before(function(done) { + if (app.booting) { + return app.once('booted', done); + } + done(); + }); lt.beforeEach.withApp(app); diff --git a/test/user.test.js b/test/user.test.js index 8e2b14948..23fca2ad5 100644 --- a/test/user.test.js +++ b/test/user.test.js @@ -1,12 +1,6 @@ require('./support'); var loopback = require('../'); -var User; -var AccessToken; -var MailConnector = require('../lib/connectors/mail'); - -var userMemory = loopback.createDataSource({ - connector: 'memory' -}); +var User, AccessToken; describe('User', function() { var validCredentialsEmail = 'foo@bar.com'; @@ -19,45 +13,59 @@ describe('User', function() { var invalidCredentials = {email: 'foo1@bar.com', password: 'invalid'}; var incompleteCredentials = {password: 'bar1'}; - var defaultApp; + // Create a local app variable to prevent clashes with the global + // variable shared by all tests. While this should not be necessary if + // the tests were written correctly, it turns out that's not the case :( + var app; + + beforeEach(function setupAppAndModels(done) { + // override the global app object provided by test/support.js + // and create a local one that does not share state with other tests + app = loopback({ localRegistry: true, loadBuiltinModels: true }); + app.dataSource('db', { connector: 'memory' }); + + // setup Email model, it's needed by User tests + app.dataSource('email', { + connector: loopback.Mail, + transports: [{ type: 'STUB' }], + }); + var Email = app.registry.getModel('Email'); + app.model(Email, { dataSource: 'email' }); + + // attach User and related models + User = app.registry.createModel('TestUser', {}, { + base: 'User', + http: { path: 'test-users' }, + }); + app.model(User, { dataSource: 'db' }); + + AccessToken = app.registry.getModel('AccessToken'); + app.model(AccessToken, { dataSource: 'db' }); - beforeEach(function() { - // FIXME: [rfeng] Remove loopback.User.app so that remote hooks don't go - // to the wrong app instance - defaultApp = loopback.User.app; - loopback.User.app = null; - User = loopback.User.extend('TestUser', {}, {http: {path: 'test-users'}}); - AccessToken = loopback.AccessToken.extend('TestAccessToken'); - User.email = loopback.Email.extend('email'); - loopback.autoAttach(); + User.email = Email; // Update the AccessToken relation to use the subclass of User AccessToken.belongsTo(User, {as: 'user', foreignKey: 'userId'}); User.hasMany(AccessToken, {as: 'accessTokens', foreignKey: 'userId'}); + // Speed up the password hashing algorithm + // for tests using the built-in User model + User.settings.saltWorkFactor = 4; + // allow many User.afterRemote's to be called User.setMaxListeners(0); - }); - - beforeEach(function(done) { - app.enableAuth(); - app.use(loopback.token({model: AccessToken})); + app.enableAuth({ dataSource: 'db' }); + app.use(loopback.token({ model: AccessToken })); app.use(loopback.rest()); app.model(User); User.create(validCredentials, function(err, user) { + if (err) return done(err); User.create(validCredentialsEmailVerified, done); }); }); - afterEach(function(done) { - loopback.User.app = defaultApp; - User.destroyAll(function(err) { - User.accessToken.destroyAll(done); - }); - }); - describe('User.create', function() { it('Create a new user', function(done) { User.create({email: 'f@b.com', password: 'bar'}, function(err, user) { @@ -694,12 +702,19 @@ describe('User', function() { var User; var AccessToken; - before(function() { - User = loopback.User.extend('RealmUser', {}, - {realmRequired: true, realmDelimiter: ':'}); - AccessToken = loopback.AccessToken.extend('RealmAccessToken'); + beforeEach(function() { + User = app.registry.createModel('RealmUser', {}, { + base: 'TestUser', + realmRequired: true, + realmDelimiter: ':', + }); + + AccessToken = app.registry.createModel('RealmAccessToken', {}, { + base: 'AccessToken', + }); - loopback.autoAttach(); + app.model(AccessToken, { dataSource: 'db' }); + app.model(User, { dataSource: 'db' }); // Update the AccessToken relation to use the subclass of User AccessToken.belongsTo(User, {as: 'user', foreignKey: 'userId'}); @@ -770,15 +785,6 @@ describe('User', function() { }); }); - afterEach(function(done) { - User.deleteAll({realm: 'realm1'}, function(err) { - if (err) { - return done(err); - } - User.deleteAll({realm: 'realm2'}, done); - }); - }); - it('rejects a user by without realm', function(done) { User.login(credentialWithoutRealm, function(err, accessToken) { assert(err); @@ -828,11 +834,11 @@ describe('User', function() { }); describe('User.login with realmRequired but no realmDelimiter', function() { - before(function() { + beforeEach(function() { User.settings.realmDelimiter = undefined; }); - after(function() { + afterEach(function() { User.settings.realmDelimiter = ':'; }); @@ -1042,7 +1048,6 @@ describe('User', function() { user.verify(options) .then(function(result) { - console.log('here in then function'); assert(result.email); assert(result.email.response); assert(result.token); @@ -1320,6 +1325,12 @@ describe('User', function() { }); }); + it('should hide verification tokens from user JSON', function(done) { + var user = new User({email: 'bar@bat.com', password: 'bar', verificationToken: 'a-token' }); + var data = user.toJSON(); + assert(!('verificationToken' in data)); + done(); + }); }); describe('User.confirm(options, fn)', function() { @@ -1529,7 +1540,7 @@ describe('User', function() { describe('ctor', function() { it('exports default Email model', function() { expect(User.email, 'User.email').to.be.a('function'); - expect(User.email.modelName, 'modelName').to.eql('email'); + expect(User.email.modelName, 'modelName').to.eql('Email'); }); it('exports default AccessToken model', function() {