From d875c512bfa507dd616f3dd1fe591b3965feae43 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 15 May 2014 17:27:02 -0700 Subject: [PATCH] Rework replication test --- .gitignore | 1 + lib/models/change.js | 24 +-- lib/models/data-model.js | 75 +++++--- test/change.test.js | 16 +- test/fixtures/e2e/app.js | 4 +- test/model.test.js | 79 +------- test/remote-connector.test.js | 1 - test/replication.test.js | 343 ++++++++++++++++++++++++++++++++++ 8 files changed, 412 insertions(+), 131 deletions(-) create mode 100644 test/replication.test.js diff --git a/.gitignore b/.gitignore index a84a7659..fdcae0c8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ *.swp *.swo node_modules +dist diff --git a/lib/models/change.js b/lib/models/change.js index c55db2d9..22d0b2c6 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -195,7 +195,8 @@ Change.prototype.rectify = function(cb) { Change.prototype.currentRevision = function(cb) { var model = this.getModelCtor(); - model.findById(this.modelId, function(err, inst) { + var id = this.getModelId(); + model.findById(id, function(err, inst) { if(err) return Change.handleError(err, cb); if(inst) { cb(null, Change.revisionForInst(inst)); @@ -254,16 +255,6 @@ Change.prototype.type = function() { return Change.UNKNOWN; } -/** - * Get the `Model` class for `change.modelName`. - * @return {Model} - */ - -Change.prototype.getModelCtor = function() { - // todo - not sure if this works with multiple data sources - return loopback.getModel(this.modelName); -} - /** * Compare two changes. * @param {Change} change @@ -403,9 +394,18 @@ Change.handleError = function(err) { } } +/** + * Get the `Model` class for `change.modelName`. + * @return {Model} + */ + +Change.prototype.getModelCtor = function() { + return this.constructor.settings.trackModel; +} + Change.prototype.getModelId = function() { // TODO(ritch) get rid of the need to create an instance - var Model = this.constructor.settings.trackModel; + var Model = this.getModelCtor(); var id = this.modelId; var m = new Model(); m.setId(id); diff --git a/lib/models/data-model.js b/lib/models/data-model.js index 8295d6dd..5fbe1d4e 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -62,16 +62,31 @@ DataModel.setup = function setupDataModel() { * @private */ -function setRemoting(fn, options) { - options = options || {}; - for (var opt in options) { - if (options.hasOwnProperty(opt)) { - fn[opt] = options[opt]; - } +function setRemoting(target, name, options) { + var fn = target[name]; + setupFunction(fn, options); + target[name] = createProxy(fn, options); +} + +function createProxy(fn, options) { + var p = function proxy() { + return fn.apply(this, arguments); } - fn.shared = true; - // allow connectors to override the function by marking as delegate - fn._delegate = true; + + return setupFunction(fn, options); +} + +function setupFunction(fn, options) { + options = options || {}; + for (var opt in options) { + if (options.hasOwnProperty(opt)) { + fn[opt] = options[opt]; + } + } + fn.shared = true; + // allow connectors to override the function by marking as delegate + fn._delegate = true; + return fn; } /*! @@ -425,28 +440,28 @@ DataModel.setupRemoting = function() { // TODO(ritch) setRemoting should create its own function... - setRemoting(DataModel.create, { + setRemoting(DataModel, 'create', { description: 'Create a new instance of the model and persist it into the data source', accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'post', path: '/'} }); - setRemoting(DataModel.upsert, { + setRemoting(DataModel, 'upsert', { description: 'Update an existing model instance or insert a new one into the data source', accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'put', path: '/'} }); - setRemoting(DataModel.exists, { + setRemoting(DataModel, 'exists', { description: 'Check whether a model instance exists in the data source', accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, returns: {arg: 'exists', type: 'boolean'}, http: {verb: 'get', path: '/:id/exists'} }); - setRemoting(DataModel.findById, { + setRemoting(DataModel, 'findById', { description: 'Find a model instance by id from the data source', accepts: { arg: 'id', type: 'any', description: 'Model id', required: true, @@ -457,42 +472,42 @@ DataModel.setupRemoting = function() { rest: {after: convertNullToNotFoundError} }); - setRemoting(DataModel.find, { + setRemoting(DataModel, 'find', { description: 'Find all instances of the model matched by filter from the data source', accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, returns: {arg: 'data', type: [typeName], root: true}, http: {verb: 'get', path: '/'} }); - setRemoting(DataModel.findOne, { + setRemoting(DataModel, 'findOne', { description: 'Find first instance of the model matched by filter from the data source', accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'get', path: '/findOne'} }); - setRemoting(DataModel.destroyAll, { + setRemoting(DataModel, 'destroyAll', { description: 'Delete all matching records', accepts: {arg: 'where', type: 'object', description: 'filter.where object'}, - http: {verb: 'del', path: '/'} + http: {verb: 'del', path: '/'}, + shared: false }); - DataModel.destroyAll.shared = false; - setRemoting(DataModel.deleteById, { + setRemoting(DataModel, 'removeById', { description: 'Delete a model instance by id from the data source', accepts: {arg: 'id', type: 'any', description: 'Model id', required: true, http: {source: 'path'}}, http: {verb: 'del', path: '/:id'} }); - setRemoting(DataModel.count, { + setRemoting(DataModel, 'count', { description: 'Count instances of the model matched by where from the data source', accepts: {arg: 'where', type: 'object', description: 'Criteria to match model instances'}, returns: {arg: 'count', type: 'number'}, http: {verb: 'get', path: '/count'} }); - setRemoting(DataModel.prototype.updateAttributes, { + setRemoting(DataModel.prototype, 'updateAttributes', { description: 'Update attributes for a model instance and persist it into the data source', accepts: {arg: 'data', type: 'object', http: {source: 'body'}, description: 'An object of model property name/value pairs'}, returns: {arg: 'data', type: typeName, root: true}, @@ -500,7 +515,7 @@ DataModel.setupRemoting = function() { }); if(options.trackChanges) { - setRemoting(DataModel.diff, { + setRemoting(DataModel, 'diff', { description: 'Get a set of deltas and conflicts since the given checkpoint', accepts: [ {arg: 'since', type: 'number', description: 'Find deltas since this checkpoint'}, @@ -511,7 +526,7 @@ DataModel.setupRemoting = function() { http: {verb: 'post', path: '/diff'} }); - setRemoting(DataModel.changes, { + setRemoting(DataModel, 'changes', { description: 'Get the changes to a model since a given checkpoint.' + 'Provide a filter object to reduce the number of results returned.', accepts: [ @@ -522,37 +537,37 @@ DataModel.setupRemoting = function() { http: {verb: 'get', path: '/changes'} }); - setRemoting(DataModel.checkpoint, { + setRemoting(DataModel, 'checkpoint', { description: 'Create a checkpoint.', returns: {arg: 'checkpoint', type: 'object', root: true}, http: {verb: 'post', path: '/checkpoint'} }); - setRemoting(DataModel.currentCheckpoint, { + setRemoting(DataModel, 'currentCheckpoint', { description: 'Get the current checkpoint.', returns: {arg: 'checkpoint', type: 'object', root: true}, http: {verb: 'get', path: '/checkpoint'} }); - setRemoting(DataModel.createUpdates, { + setRemoting(DataModel, 'createUpdates', { description: 'Create an update list from a delta list', accepts: {arg: 'deltas', type: 'array', http: {source: 'body'}}, returns: {arg: 'updates', type: 'array', root: true}, http: {verb: 'post', path: '/create-updates'} }); - setRemoting(DataModel.bulkUpdate, { + setRemoting(DataModel, 'bulkUpdate', { description: 'Run multiple updates at once. Note: this is not atomic.', accepts: {arg: 'updates', type: 'array'}, http: {verb: 'post', path: '/bulk-update'} }); - setRemoting(DataModel.rectifyAllChanges, { + setRemoting(DataModel, 'rectifyAllChanges', { description: 'Rectify all Model changes.', http: {verb: 'post', path: '/rectify-all'} }); - setRemoting(DataModel.rectifyChange, { + setRemoting(DataModel, 'rectifyChange', { description: 'Tell loopback that a change to the model with the given id has occurred.', accepts: {arg: 'id', type: 'any', http: {source: 'path'}}, http: {verb: 'post', path: '/:id/rectify-change'} @@ -889,8 +904,6 @@ DataModel.getSourceId = function(cb) { */ DataModel.enableChangeTracking = function() { - // console.log('THIS SHOULD NOT RUN ON A MODEL CONNECTED TO A REMOTE DATASOURCE'); - var Model = this; var Change = this.Change || this._defineChangeModel(); var cleanupInterval = Model.settings.changeCleanupInterval || 30000; diff --git a/test/change.test.js b/test/change.test.js index d570f509..7b2452c5 100644 --- a/test/change.test.js +++ b/test/change.test.js @@ -6,12 +6,12 @@ describe('Change', function(){ var memory = loopback.createDataSource({ connector: loopback.Memory }); - Change = loopback.Change.extend('change'); - Change.attachTo(memory); - - TestModel = loopback.DataModel.extend('chtest'); + TestModel = loopback.DataModel.extend('chtest', {}, { + trackChanges: true + }); this.modelName = TestModel.modelName; TestModel.attachTo(memory); + Change = TestModel.getChangeModel(); }); beforeEach(function(done) { @@ -46,16 +46,16 @@ describe('Change', function(){ describe('using an existing untracked model', function () { beforeEach(function(done) { var test = this; - Change.rectifyModelChanges(this.modelName, [this.modelId], function(err, trakedChagnes) { + Change.rectifyModelChanges(this.modelName, [this.modelId], function(err, trackedChanges) { if(err) return done(err); - test.trakedChagnes = trakedChagnes; + test.trackedChanges = trackedChanges; done(); }); }); it('should create an entry', function () { - assert(Array.isArray(this.trakedChagnes)); - assert.equal(this.trakedChagnes[0].modelId, this.modelId); + assert(Array.isArray(this.trackedChanges)); + assert.equal(this.trackedChanges[0].modelId, this.modelId); }); it('should only create one change', function (done) { diff --git a/test/fixtures/e2e/app.js b/test/fixtures/e2e/app.js index 462d0426..608b3d7e 100644 --- a/test/fixtures/e2e/app.js +++ b/test/fixtures/e2e/app.js @@ -3,7 +3,7 @@ var path = require('path'); var app = module.exports = loopback(); var models = require('./models'); var TestModel = models.TestModel; -var explorer = require('loopback-explorer'); +// var explorer = require('loopback-explorer'); app.use(loopback.cookieParser({secret: app.get('cookieSecret')})); var apiPath = '/api'; @@ -13,7 +13,7 @@ TestModel.attachTo(loopback.memory()); app.model(TestModel); app.model(TestModel.getChangeModel()); -app.use('/explorer', explorer(app, {basePath: apiPath})); +// app.use('/explorer', explorer(app, {basePath: apiPath})); app.use(loopback.static(path.join(__dirname, 'public'))); app.use(loopback.urlNotFound()); diff --git a/test/model.test.js b/test/model.test.js index 28753e29..5499f0d5 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -479,7 +479,7 @@ describe.onServer('Remote Methods', function(){ var result; var current; - async.parallel(tasks, function(err) { + async.series(tasks, function(err) { if(err) return done(err); assert.equal(result, current + 1); @@ -495,88 +495,13 @@ describe.onServer('Remote Methods', function(){ function checkpoint(cb) { User.checkpoint(function(err, cp) { - result = cp.id; + result = cp.seq; cb(err); }); } }); }); - describe('Replication / Change APIs', function() { - beforeEach(function(done) { - var test = this; - this.dataSource = dataSource; - var SourceModel = this.SourceModel = DataModel.extend('SourceModel', {}, { - trackChanges: true - }); - SourceModel.attachTo(dataSource); - - var TargetModel = this.TargetModel = DataModel.extend('TargetModel', {}, { - trackChanges: true - }); - TargetModel.attachTo(dataSource); - - var createOne = SourceModel.create.bind(SourceModel, { - name: 'baz' - }); - - async.parallel([ - createOne, - function(cb) { - SourceModel.currentCheckpoint(function(err, id) { - if(err) return cb(err); - test.startingCheckpoint = id; - cb(); - }); - } - ], process.nextTick.bind(process, done)); - }); - - describe('Model.changes(since, filter, callback)', function() { - it('Get changes since the given checkpoint', function (done) { - this.SourceModel.changes(this.startingCheckpoint, {}, function(err, changes) { - assert.equal(changes.length, 1); - done(); - }); - }); - }); - - describe.skip('Model.replicate(since, targetModel, options, callback)', function() { - it('Replicate data using the target model', function (done) { - var test = this; - var options = {}; - var sourceData; - var targetData; - - this.SourceModel.replicate(this.startingCheckpoint, this.TargetModel, - options, function(err, conflicts) { - assert(conflicts.length === 0); - async.parallel([ - function(cb) { - test.SourceModel.find(function(err, result) { - if(err) return cb(err); - sourceData = result; - cb(); - }); - }, - function(cb) { - test.TargetModel.find(function(err, result) { - if(err) return cb(err); - targetData = result; - cb(); - }); - } - ], function(err) { - if(err) return done(err); - - assert.deepEqual(sourceData, targetData); - done(); - }); - }); - }); - }); - }); - describe('Model._getACLModel()', function() { it('should return the subclass of ACL', function() { var Model = require('../').Model; diff --git a/test/remote-connector.test.js b/test/remote-connector.test.js index 5871299b..129525b8 100644 --- a/test/remote-connector.test.js +++ b/test/remote-connector.test.js @@ -62,7 +62,6 @@ describe('RemoteConnector', function() { ServerModel.setupRemoting(); var m = new RemoteModel({foo: 'bar'}); - console.log(m.save.toString()); m.save(function(err, inst) { assert(inst instanceof RemoteModel); assert(calledServerCreate); diff --git a/test/replication.test.js b/test/replication.test.js new file mode 100644 index 00000000..bda692b3 --- /dev/null +++ b/test/replication.test.js @@ -0,0 +1,343 @@ +var async = require('async'); +var loopback = require('../'); +var ACL = loopback.ACL; +var Change = loopback.Change; +var defineModelTestsWithDataSource = require('./util/model-tests'); +var DataModel = loopback.DataModel; + +describe('Replication / Change APIs', function() { + beforeEach(function() { + var test = this; + var dataSource = this.dataSource = loopback.createDataSource({ + connector: loopback.Memory + }); + var SourceModel = this.SourceModel = DataModel.extend('SourceModel', {}, { + trackChanges: true + }); + SourceModel.attachTo(dataSource); + + var TargetModel = this.TargetModel = DataModel.extend('TargetModel', {}, { + trackChanges: true + }); + TargetModel.attachTo(dataSource); + + this.createInitalData = function(cb) { + SourceModel.create({name: 'foo'}, function(err, inst) { + if(err) return cb(err); + test.model = inst; + + // give loopback a chance to register the change + // TODO(ritch) get rid of this... + setTimeout(function() { + SourceModel.replicate(TargetModel, cb); + }, 100); + }); + }; + }); + + describe('Model.changes(since, filter, callback)', function() { + it('Get changes since the given checkpoint', function (done) { + var test = this; + this.SourceModel.create({name: 'foo'}, function(err) { + if(err) return done(err); + setTimeout(function() { + test.SourceModel.changes(test.startingCheckpoint, {}, function(err, changes) { + assert.equal(changes.length, 1); + done(); + }); + }, 1); + }); + }); + }); + + describe('Model.replicate(since, targetModel, options, callback)', function() { + it('Replicate data using the target model', function (done) { + var test = this; + var options = {}; + var sourceData; + var targetData; + + this.SourceModel.create({name: 'foo'}, function(err) { + setTimeout(replicate, 100); + }); + + function replicate() { + test.SourceModel.replicate(test.startingCheckpoint, test.TargetModel, + options, function(err, conflicts) { + assert(conflicts.length === 0); + async.parallel([ + function(cb) { + test.SourceModel.find(function(err, result) { + if(err) return cb(err); + sourceData = result; + cb(); + }); + }, + function(cb) { + test.TargetModel.find(function(err, result) { + if(err) return cb(err); + targetData = result; + cb(); + }); + } + ], function(err) { + if(err) return done(err); + + assert.deepEqual(sourceData, targetData); + done(); + }); + }); + } + }); + }); + + describe('conflict detection - both updated', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict(err, conflicts) { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.name = 'source update'; + inst.save(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.name = 'target update'; + inst.save(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should detect a single conflict', function() { + assert.equal(this.conflicts.length, 1); + assert(this.conflict); + }); + it('type should be UPDATE', function(done) { + this.conflict.type(function(err, type) { + assert.equal(type, Change.UPDATE); + done(); + }); + }); + it('conflict.changes()', function(done) { + var test = this; + this.conflict.changes(function(err, sourceChange, targetChange) { + assert.equal(typeof sourceChange.id, 'string'); + assert.equal(typeof targetChange.id, 'string'); + assert.equal(test.model.getId(), sourceChange.getModelId()); + assert.equal(sourceChange.type(), Change.UPDATE); + assert.equal(targetChange.type(), Change.UPDATE); + done(); + }); + }); + it('conflict.models()', function(done) { + var test = this; + this.conflict.models(function(err, source, target) { + assert.deepEqual(source.toJSON(), { + id: 1, + name: 'source update' + }); + assert.deepEqual(target.toJSON(), { + id: 1, + name: 'target update' + }); + done(); + }); + }); + }); + + describe('conflict detection - source deleted', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict() { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + test.model = inst; + inst.remove(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.name = 'target update'; + inst.save(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should detect a single conflict', function() { + assert.equal(this.conflicts.length, 1); + assert(this.conflict); + }); + it('type should be DELETE', function(done) { + this.conflict.type(function(err, type) { + assert.equal(type, Change.DELETE); + done(); + }); + }); + it('conflict.changes()', function(done) { + var test = this; + this.conflict.changes(function(err, sourceChange, targetChange) { + assert.equal(typeof sourceChange.id, 'string'); + assert.equal(typeof targetChange.id, 'string'); + assert.equal(test.model.getId(), sourceChange.getModelId()); + assert.equal(sourceChange.type(), Change.DELETE); + assert.equal(targetChange.type(), Change.UPDATE); + done(); + }); + }); + it('conflict.models()', function(done) { + var test = this; + this.conflict.models(function(err, source, target) { + assert.equal(source, null); + assert.deepEqual(target.toJSON(), { + id: 1, + name: 'target update' + }); + done(); + }); + }); + }); + + describe('conflict detection - target deleted', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict() { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + test.model = inst; + inst.name = 'source update'; + inst.save(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.remove(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should detect a single conflict', function() { + assert.equal(this.conflicts.length, 1); + assert(this.conflict); + }); + it('type should be DELETE', function(done) { + this.conflict.type(function(err, type) { + assert.equal(type, Change.DELETE); + done(); + }); + }); + it('conflict.changes()', function(done) { + var test = this; + this.conflict.changes(function(err, sourceChange, targetChange) { + assert.equal(typeof sourceChange.id, 'string'); + assert.equal(typeof targetChange.id, 'string'); + assert.equal(test.model.getId(), sourceChange.getModelId()); + assert.equal(sourceChange.type(), Change.UPDATE); + assert.equal(targetChange.type(), Change.DELETE); + done(); + }); + }); + it('conflict.models()', function(done) { + var test = this; + this.conflict.models(function(err, source, target) { + assert.equal(target, null); + assert.deepEqual(source.toJSON(), { + id: 1, + name: 'source update' + }); + done(); + }); + }); + }); + + describe('conflict detection - both deleted', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict() { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + test.model = inst; + inst.remove(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.remove(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should not detect a conflict', function() { + assert.equal(this.conflicts.length, 0); + assert(!this.conflict); + }); + }); +}); \ No newline at end of file