var ValidationError = require('../').ValidationError; var traverse = require('traverse'); module.exports = function(dataSource, should, connectorCapabilities) { if (!connectorCapabilities) connectorCapabilities = {}; if (connectorCapabilities.replaceOrCreateReportsNewInstance === undefined) { console.warn('The connector does not support a recently added feature: replaceOrCreateReportsNewInstance'); } describe('Persistence hooks', function() { var observedContexts, expectedError, observersCalled; var TestModel, existingInstance; var migrated = false, lastId; var triggered; var undefinedValue = undefined; beforeEach(function setupDatabase(done) { observedContexts = "hook not called"; expectedError = new Error('test error'); observersCalled = []; TestModel = dataSource.createModel('TestModel', { // Set id.generated to false to honor client side values id: { type: String, id: true, generated: false, default: uid }, name: { type: String, required: true }, extra: { type: String, required: false } }); lastId = 0; if (migrated) { TestModel.deleteAll(done); } else { dataSource.automigrate(TestModel.modelName, function(err) { migrated = true; done(err); }); } }); beforeEach(function createTestData(done) { TestModel.create({ name: 'first' }, function(err, instance) { if (err) return done(err); // Look it up from DB so that default values are retrieved TestModel.findById(instance.id, function(err, instance) { existingInstance = instance; undefinedValue = existingInstance.extra; TestModel.create({ name: 'second' }, function(err) { if (err) return done(err); done(); }); }); }); }); describe('PersistedModel.find', function() { it('triggers hooks in the correct order', function(done) { monitorHookExecution(); TestModel.find( { where: { id: '1' } }, function(err, list) { if (err) return done(err); triggered.should.eql([ 'access', 'loaded' ]); done(); }); }); it('triggers `access` hook', function(done) { TestModel.observe('access', pushContextAndNext()); TestModel.find({ where: { id: '1' } }, function(err, list) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ query: { where: { id: '1' } } })); done(); }); }); it('aborts when `access` hook fails', function(done) { TestModel.observe('access', nextWithError(expectedError)); TestModel.find(function(err, list) { [err].should.eql([expectedError]); done(); }); }); it('applies updates from `access` hook', function(done) { TestModel.observe('access', function(ctx, next) { ctx.query = { where: { id: existingInstance.id } }; next(); }); TestModel.find(function(err, list) { if (err) return done(err); list.map(get('name')).should.eql([existingInstance.name]); done(); }); }); it('triggers `access` hook for geo queries', function(done) { TestModel.observe('access', pushContextAndNext()); TestModel.find({ where: { geo: { near: '10,20' }}}, function(err, list) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ query: { where: { geo: { near: '10,20' } } } })); done(); }); }); it('applies updates from `access` hook for geo queries', function(done) { TestModel.observe('access', function(ctx, next) { ctx.query = { where: { id: existingInstance.id } }; next(); }); TestModel.find({ where: { geo: { near: '10,20' } } }, function(err, list) { if (err) return done(err); list.map(get('name')).should.eql([existingInstance.name]); done(); }); }); it('applies updates from `loaded` hook', function(done) { TestModel.observe('loaded', pushContextAndNext(function(ctx) { ctx.data.extra = 'hook data'; })); TestModel.find( {where: {id: 1}}, function (err, list) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ data: { id: "1", name: "first", extra: "hook data" }, isNewInstance: false, hookState: { test: true }, options: {} })); list[0].should.have.property('extra', 'hook data'); done(); }); }) it('emits error when `loaded` hook fails', function(done) { TestModel.observe('loaded', nextWithError(expectedError)); TestModel.find( {where: {id: 1}}, function(err, list) { [err].should.eql([expectedError]); done(); }); }); }); describe('PersistedModel.create', function() { it('triggers hooks in the correct order', function(done) { monitorHookExecution(); TestModel.create( { name: 'created' }, function(err, record, created) { if (err) return done(err); triggered.should.eql([ 'before save', 'persist', 'loaded', 'after save' ]); done(); }); }); it('triggers `before save` hook', function(done) { TestModel.observe('before save', pushContextAndNext()); TestModel.create({ name: 'created' }, function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ instance: { id: instance.id, name: 'created', extra: undefined }, isNewInstance: true })); done(); }); }); it('aborts when `before save` hook fails', function(done) { TestModel.observe('before save', nextWithError(expectedError)); TestModel.create({ name: 'created' }, function(err, instance) { [err].should.eql([expectedError]); done(); }); }); it('applies updates from `before save` hook', function(done) { TestModel.observe('before save', function(ctx, next) { ctx.instance.should.be.instanceOf(TestModel); ctx.instance.extra = 'hook data'; next(); }); TestModel.create({ id: uid(), name: 'a-name' }, function(err, instance) { if (err) return done(err); instance.should.have.property('extra', 'hook data'); done(); }); }); it('sends `before save` for each model in an array', function(done) { TestModel.observe('before save', pushContextAndNext()); TestModel.create( [{ name: '1' }, { name: '2' }], function(err, list) { if (err) return done(err); // Creation of multiple instances is executed in parallel observedContexts.sort(function(c1, c2) { return c1.instance.name - c2.instance.name; }); observedContexts.should.eql([ aTestModelCtx({ instance: { id: list[0].id, name: '1', extra: undefined }, isNewInstance: true }), aTestModelCtx({ instance: { id: list[1].id, name: '2', extra: undefined }, isNewInstance: true }), ]); done(); }); }); it('validates model after `before save` hook', function(done) { TestModel.observe('before save', invalidateTestModel()); TestModel.create({ name: 'created' }, function(err) { (err || {}).should.be.instanceOf(ValidationError); (err.details.codes || {}).should.eql({ name: ['presence'] }); done(); }); }); it('triggers `persist` hook', function(done) { TestModel.observe('persist', pushContextAndNext()); TestModel.create( { id: 'new-id', name: 'a name' }, function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ data: { id: 'new-id', name: 'a name' }, isNewInstance: true, currentInstance: { extra: null, id: 'new-id', name: 'a name' } })); done(); }); }); it('applies updates from `persist` hook', function(done) { TestModel.observe('persist', pushContextAndNext(function(ctx){ ctx.data.extra = 'hook data'; })); // By default, the instance passed to create callback is NOT updated // with the changes made through persist/loaded hooks. To preserve // backwards compatibility, we introduced a new setting updateOnLoad, // which if set, will apply these changes to the model instance too. TestModel.settings.updateOnLoad = true; TestModel.create( { id: 'new-id', name: 'a name' }, function(err, instance) { if (err) return done(err); instance.should.have.property('extra', 'hook data'); // Also query the database here to verify that, on `create` // updates from `persist` hook are reflected into database TestModel.findById('new-id', function(err, dbInstance) { if (err) return done(err); should.exists(dbInstance); dbInstance.toObject(true).should.eql({ id: 'new-id', name: 'a name', extra: 'hook data' }); done(); }); }); }); it('triggers `loaded` hook', function(done) { TestModel.observe('loaded', pushContextAndNext()); // By default, the instance passed to create callback is NOT updated // with the changes made through persist/loaded hooks. To preserve // backwards compatibility, we introduced a new setting updateOnLoad, // which if set, will apply these changes to the model instance too. TestModel.settings.updateOnLoad = true; TestModel.create( { id: 'new-id', name: 'a name' }, function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ data: { id: 'new-id', name: 'a name' }, isNewInstance: true })); done(); }); }); it('emits error when `loaded` hook fails', function(done) { TestModel.observe('loaded', nextWithError(expectedError)); TestModel.create( { id: 'new-id', name: 'a name' }, function(err, instance) { [err].should.eql([expectedError]); done(); }); }); it('applies updates from `loaded` hook', function(done) { TestModel.observe('loaded', pushContextAndNext(function(ctx){ ctx.data.extra = 'hook data'; })); // By default, the instance passed to create callback is NOT updated // with the changes made through persist/loaded hooks. To preserve // backwards compatibility, we introduced a new setting updateOnLoad, // which if set, will apply these changes to the model instance too. TestModel.settings.updateOnLoad = true; TestModel.create( { id: 'new-id', name: 'a name' }, function(err, instance) { if (err) return done(err); instance.should.have.property('extra', 'hook data'); done(); }); }); it('triggers `after save` hook', function(done) { TestModel.observe('after save', pushContextAndNext()); TestModel.create({ name: 'created' }, function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ instance: { id: instance.id, name: 'created', extra: undefined }, isNewInstance: true })); done(); }); }); it('aborts when `after save` hook fails', function(done) { TestModel.observe('after save', nextWithError(expectedError)); TestModel.create({ name: 'created' }, function(err, instance) { [err].should.eql([expectedError]); done(); }); }); it('applies updates from `after save` hook', function(done) { TestModel.observe('after save', function(ctx, next) { ctx.instance.should.be.instanceOf(TestModel); ctx.instance.extra = 'hook data'; next(); }); TestModel.create({ name: 'a-name' }, function(err, instance) { if (err) return done(err); instance.should.have.property('extra', 'hook data'); done(); }); }); it('sends `after save` for each model in an array', function(done) { TestModel.observe('after save', pushContextAndNext()); TestModel.create( [{ name: '1' }, { name: '2' }], function(err, list) { if (err) return done(err); // Creation of multiple instances is executed in parallel observedContexts.sort(function(c1, c2) { return c1.instance.name - c2.instance.name; }); observedContexts.should.eql([ aTestModelCtx({ instance: { id: list[0].id, name: '1', extra: undefined }, isNewInstance: true }), aTestModelCtx({ instance: { id: list[1].id, name: '2', extra: undefined }, isNewInstance: true }), ]); done(); }); }); it('emits `after save` when some models were not saved', function(done) { TestModel.observe('before save', function(ctx, next) { if (ctx.instance.name === 'fail') next(expectedError); else next(); }); TestModel.observe('after save', pushContextAndNext()); TestModel.create( [{ name: 'ok' }, { name: 'fail' }], function(err, list) { (err || []).should.have.length(2); err[1].should.eql(expectedError); // NOTE(bajtos) The current implementation of `Model.create(array)` // passes all models in the second callback argument, including // the models that were not created due to an error. list.map(get('name')).should.eql(['ok', 'fail']); observedContexts.should.eql(aTestModelCtx({ instance: { id: list[0].id, name: 'ok', extra: undefined }, isNewInstance: true })); done(); }); }); }); describe('PersistedModel.findOrCreate', function() { it('triggers `access` hook', function(done) { TestModel.observe('access', pushContextAndNext()); TestModel.findOrCreate( { where: { name: 'new-record' } }, { name: 'new-record' }, function(err, record, created) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ query: { where: { name: 'new-record' }, limit: 1, offset: 0, skip: 0 }})); done(); }); }); if (dataSource.connector.findOrCreate) { it('triggers `before save` hook when found', function(done) { TestModel.observe('before save', pushContextAndNext()); TestModel.findOrCreate( { where: { name: existingInstance.name } }, { name: existingInstance.name }, function(err, record, created) { if (err) return done(err); record.id.should.eql(existingInstance.id); observedContexts.should.eql(aTestModelCtx({ instance: { id: getLastGeneratedUid(), name: existingInstance.name, extra: undefined }, isNewInstance: true })); done(); }); }); } it('triggers `before save` hook when not found', function(done) { TestModel.observe('before save', pushContextAndNext()); TestModel.findOrCreate( { where: { name: 'new-record' } }, { name: 'new-record' }, function(err, record, created) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ instance: { id: record.id, name: 'new-record', extra: undefined }, isNewInstance: true })); done(); }); }); it('validates model after `before save` hook', function(done) { TestModel.observe('before save', invalidateTestModel()); TestModel.findOrCreate( { where: { name: 'new-record' } }, { name: 'new-record' }, function(err) { (err || {}).should.be.instanceOf(ValidationError); (err.details.codes || {}).should.eql({ name: ['presence'] }); done(); }); }); it('triggers hooks in the correct order when not found', function(done) { monitorHookExecution(); TestModel.findOrCreate( { where: { name: 'new-record' } }, { name: 'new-record' }, function(err, record, created) { if (err) return done(err); triggered.should.eql([ 'access', 'before save', 'persist', 'loaded', 'after save' ]); done(); }); }); it('triggers hooks in the correct order when found', function(done) { monitorHookExecution(); TestModel.findOrCreate( { where: { name: existingInstance.name } }, { name: existingInstance.name }, function(err, record, created) { if (err) return done(err); if (dataSource.connector.findOrCreate) { triggered.should.eql([ 'access', 'before save', 'persist', 'loaded' ]); } else { triggered.should.eql([ 'access', 'loaded' ]); } done(); }); }); it('aborts when `access` hook fails', function(done) { TestModel.observe('access', nextWithError(expectedError)); TestModel.findOrCreate( { where: { id: 'does-not-exist' } }, { name: 'does-not-exist' }, function(err, instance) { [err].should.eql([expectedError]); done(); }); }); it('aborts when `before save` hook fails', function(done) { TestModel.observe('before save', nextWithError(expectedError)); TestModel.findOrCreate( { where: { id: 'does-not-exist' } }, { name: 'does-not-exist' }, function(err, instance) { [err].should.eql([expectedError]); done(); }); }); if (dataSource.connector.findOrCreate) { it('triggers `persist` hook when found', function(done) { TestModel.observe('persist', pushContextAndNext()); TestModel.findOrCreate( { where: { name: existingInstance.name } }, { name: existingInstance.name }, function(err, record, created) { if (err) return done(err); record.id.should.eql(existingInstance.id); // `findOrCreate` creates a new instance of the object everytime. // So, `data.id` as well as `currentInstance.id` always matches // the newly generated UID. // Hence, the test below asserts both `data.id` and // `currentInstance.id` to match getLastGeneratedUid(). // On same lines, it also asserts `isNewInstance` to be true. observedContexts.should.eql(aTestModelCtx({ data: { id: getLastGeneratedUid(), name: existingInstance.name }, isNewInstance: true, currentInstance: { id: getLastGeneratedUid(), name: record.name, extra: null }, where: { name: existingInstance.name } })); done(); }); }); } it('triggers `persist` hook when not found', function(done) { TestModel.observe('persist', pushContextAndNext()); TestModel.findOrCreate( { where: { name: 'new-record' } }, { name: 'new-record' }, function(err, record, created) { if (err) return done(err); // `context.where` is present in Optimized connector context, // but, unoptimized connector does NOT have it. if (dataSource.connector.findOrCreate) { observedContexts.should.eql(aTestModelCtx({ data: { id: record.id, name: 'new-record' }, isNewInstance: true, currentInstance: { id: record.id, name: record.name, extra: null }, where: { name: 'new-record' } })); } else { observedContexts.should.eql(aTestModelCtx({ data: { id: record.id, name: 'new-record' }, isNewInstance: true, currentInstance: { id: record.id, name: record.name, extra: null } })); } done(); }); }); if (dataSource.connector.findOrCreate) { it('applies updates from `persist` hook when found', function(done) { TestModel.observe('persist', pushContextAndNext(function(ctx){ ctx.data.extra = 'hook data'; })); TestModel.findOrCreate( { where: { name: existingInstance.name } }, { name: existingInstance.name }, function(err, instance) { if (err) return done(err); // instance returned by `findOrCreate` context does not // have the values updated from `persist` hook instance.should.not.have.property('extra', 'hook data'); // Query the database. Here, since record already exists // `findOrCreate`, does not update database for // updates from `persist` hook TestModel.findById(existingInstance.id, function(err, dbInstance) { if (err) return done(err); should.exists(dbInstance); dbInstance.toObject(true).should.eql({ id: existingInstance.id, name: existingInstance.name, extra: undefined }); }); done(); }); }); } it('applies updates from `persist` hook when not found', function(done) { TestModel.observe('persist', pushContextAndNext(function(ctx){ ctx.data.extra = 'hook data'; })); TestModel.findOrCreate( { where: { name: 'new-record' } }, { name: 'new-record' }, function(err, instance) { if (err) return done(err); if (dataSource.connector.findOrCreate) { instance.should.have.property('extra', 'hook data'); } else { // Unoptimized connector gives a call to `create. And during // create the updates applied through persist hook are // reflected into the database, but the same updates are // NOT reflected in the instance object obtained in callback // of create. // So, this test asserts unoptimized connector to // NOT have `extra` property. And then verifes that the // property `extra` is actually updated in DB instance.should.not.have.property('extra', 'hook data'); TestModel.findById(instance.id, function(err, dbInstance) { if (err) return done(err); should.exists(dbInstance); dbInstance.toObject(true).should.eql({ id: instance.id, name: instance.name, extra: 'hook data' }); }); } done(); }); }); if (dataSource.connector.findOrCreate) { it('triggers `loaded` hook when found', function(done) { TestModel.observe('loaded', pushContextAndNext()); TestModel.findOrCreate( { where: { name: existingInstance.name } }, { name: existingInstance.name }, function(err, record, created) { if (err) return done(err); record.id.should.eql(existingInstance.id); // After the call to `connector.findOrCreate`, since the record // already exists, `data.id` matches `existingInstance.id` // as against the behaviour noted for `persist` hook observedContexts.should.eql(aTestModelCtx({ data: { id: existingInstance.id, name: existingInstance.name }, isNewInstance: false })); done(); }); }); } it('triggers `loaded` hook when not found', function(done) { TestModel.observe('loaded', pushContextAndNext()); TestModel.findOrCreate( { where: { name: 'new-record' } }, { name: 'new-record' }, function(err, record, created) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ data: { id: record.id, name: 'new-record' }, isNewInstance: true })); done(); }); }); it('emits error when `loaded` hook fails', function(done) { TestModel.observe('loaded', nextWithError(expectedError)); TestModel.findOrCreate( { where: { name: 'new-record' } }, { name: 'new-record' }, function(err, instance) { [err].should.eql([expectedError]); done(); }); }); if (dataSource.connector.findOrCreate) { it('applies updates from `loaded` hook when found', function(done) { TestModel.observe('loaded', pushContextAndNext(function(ctx){ ctx.data.extra = 'hook data'; })); TestModel.findOrCreate( { where: { name: existingInstance.name } }, { name: existingInstance.name }, function(err, instance) { if (err) return done(err); instance.should.have.property('extra', 'hook data'); done(); }); }); } it('applies updates from `loaded` hook when not found', function(done) { TestModel.observe('loaded', pushContextAndNext(function(ctx){ ctx.data.extra = 'hook data'; })); // Unoptimized connector gives a call to `create. But, // by default, the instance passed to create callback is NOT updated // with the changes made through persist/loaded hooks. To preserve // backwards compatibility, we introduced a new setting updateOnLoad, // which if set, will apply these changes to the model instance too. // Note - in case of findOrCreate, this setting is needed ONLY for // unoptimized connector. TestModel.settings.updateOnLoad = true; TestModel.findOrCreate( { where: { name: 'new-record' } }, { name: 'new-record' }, function(err, instance) { if (err) return done(err); instance.should.have.property('extra', 'hook data'); done(); }); }); it('triggers `after save` hook when not found', function(done) { TestModel.observe('after save', pushContextAndNext()); TestModel.findOrCreate( { where: { name: 'new name' } }, { name: 'new name' }, function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ instance: { id: instance.id, name: 'new name', extra: undefined }, isNewInstance: true })); done(); }); }); it('does not trigger `after save` hook when found', function(done) { TestModel.observe('after save', pushContextAndNext()); TestModel.findOrCreate( { where: { id: existingInstance.id } }, { name: existingInstance.name }, function(err, instance) { if (err) return done(err); observedContexts.should.eql("hook not called"); done(); }); }); }); describe('PersistedModel.count', function(done) { it('triggers `access` hook', function(done) { TestModel.observe('access', pushContextAndNext()); TestModel.count({ id: existingInstance.id }, function(err, count) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ query: { where: { id: existingInstance.id } }})); done(); }); }); it('applies updates from `access` hook', function(done) { TestModel.observe('access', function(ctx, next) { ctx.query.where = { id: existingInstance.id }; next(); }); TestModel.count(function(err, count) { if (err) return done(err); count.should.equal(1); done(); }); }); }); describe('PersistedModel.prototype.save', function() { it('triggers hooks in the correct order', function(done) { monitorHookExecution(); existingInstance.save( function(err, record, created) { if (err) return done(err); triggered.should.eql([ 'before save', 'persist', 'loaded', 'after save' ]); done(); }); }); it('triggers `before save` hook', function(done) { TestModel.observe('before save', pushContextAndNext()); existingInstance.name = 'changed'; existingInstance.save(function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ instance: { id: existingInstance.id, name: 'changed', extra: undefined, }, options: { throws: false, validate: true } })); done(); }); }); it('aborts when `before save` hook fails', function(done) { TestModel.observe('before save', nextWithError(expectedError)); existingInstance.save(function(err, instance) { [err].should.eql([expectedError]); done(); }); }); it('applies updates from `before save` hook', function(done) { TestModel.observe('before save', function(ctx, next) { ctx.instance.should.be.instanceOf(TestModel); ctx.instance.extra = 'hook data'; next(); }); existingInstance.save(function(err, instance) { if (err) return done(err); instance.should.have.property('extra', 'hook data'); done(); }); }); it('validates model after `before save` hook', function(done) { TestModel.observe('before save', invalidateTestModel()); existingInstance.save(function(err) { (err || {}).should.be.instanceOf(ValidationError); (err.details.codes || {}).should.eql({ name: ['presence'] }); done(); }); }); it('triggers `persist` hook', function(done) { TestModel.observe('persist', pushContextAndNext()); existingInstance.name = 'changed'; existingInstance.save(function(err, instance) { if (err) return done(err); // HACK: extra is undefined for NoSQL and null for SQL delete observedContexts.data.extra; delete observedContexts.currentInstance.extra; observedContexts.should.eql(aTestModelCtx({ data: { id: existingInstance.id, name: 'changed' }, currentInstance: { id: existingInstance.id, name: 'changed' }, where: { id: existingInstance.id }, options: { throws: false, validate: true } })); done(); }); }); it('applies updates from `persist` hook', function(done) { TestModel.observe('persist', pushContextAndNext(function(ctx){ ctx.data.extra = 'hook data'; })); existingInstance.save(function(err, instance) { if (err) return done(err); instance.should.have.property('extra', 'hook data'); done(); }); }); it('triggers `loaded` hook', function(done) { TestModel.observe('loaded', pushContextAndNext()); existingInstance.extra = 'changed'; existingInstance.save(function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ data: { id: existingInstance.id, name: existingInstance.name, extra: 'changed', }, isNewInstance: false, options: { throws: false, validate: true } })); done(); }); }); it('emits error when `loaded` hook fails', function(done) { TestModel.observe('loaded', nextWithError(expectedError)); existingInstance.save( function(err, instance) { [err].should.eql([expectedError]); done(); }); }); it('applies updates from `loaded` hook', function(done) { TestModel.observe('loaded', pushContextAndNext(function(ctx){ ctx.data.extra = 'hook data'; })); existingInstance.save(function(err, instance) { if (err) return done(err); instance.should.have.property('extra', 'hook data'); done(); }); }); it('triggers `after save` hook on update', function(done) { TestModel.observe('after save', pushContextAndNext()); existingInstance.name = 'changed'; existingInstance.save(function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ instance: { id: existingInstance.id, name: 'changed', extra: undefined }, isNewInstance: false, options: { throws: false, validate: true } })); done(); }); }); it('triggers `after save` hook on create', function(done) { TestModel.observe('after save', pushContextAndNext()); var instance = new TestModel( { id: 'new-id', name: 'created' }, { persisted: true }); instance.save(function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ instance: { id: instance.id, name: 'created', extra: undefined }, isNewInstance: true, options: { throws: false, validate: true } })); done(); }); }); it('aborts when `after save` hook fails', function(done) { TestModel.observe('after save', nextWithError(expectedError)); existingInstance.save(function(err, instance) { [err].should.eql([expectedError]); done(); }); }); it('applies updates from `after save` hook', function(done) { TestModel.observe('after save', function(ctx, next) { ctx.instance.should.be.instanceOf(TestModel); ctx.instance.extra = 'hook data'; next(); }); existingInstance.save(function(err, instance) { if (err) return done(err); instance.should.have.property('extra', 'hook data'); done(); }); }); }); describe('PersistedModel.prototype.updateAttributes', function() { it('triggers hooks in the correct order', function(done) { monitorHookExecution(); existingInstance.updateAttributes( { name: 'changed' }, function(err, record, created) { if (err) return done(err); triggered.should.eql([ 'before save', 'persist', 'loaded', 'after save' ]); done(); }); }); it('triggers `before save` hook', function(done) { TestModel.observe('before save', pushContextAndNext()); var currentInstance = deepCloneToObject(existingInstance); existingInstance.updateAttributes({ name: 'changed' }, function(err) { if (err) return done(err); existingInstance.name.should.equal('changed'); observedContexts.should.eql(aTestModelCtx({ where: { id: existingInstance.id }, data: { name: 'changed' }, currentInstance: currentInstance })); done(); }); }); it('aborts when `before save` hook fails', function(done) { TestModel.observe('before save', nextWithError(expectedError)); existingInstance.updateAttributes({ name: 'updated' }, function(err) { [err].should.eql([expectedError]); done(); }); }); it('applies updates from `before save` hook', function(done) { TestModel.observe('before save', function(ctx, next) { ctx.data.extra = 'extra data'; ctx.data.name = 'hooked name'; next(); }); existingInstance.updateAttributes({ name: 'updated' }, function(err) { if (err) return done(err); // We must query the database here because `updateAttributes` // returns effectively `this`, not the data from the datasource TestModel.findById(existingInstance.id, function(err, instance) { if (err) return done(err); should.exists(instance); instance.toObject(true).should.eql({ id: existingInstance.id, name: 'hooked name', extra: 'extra data' }); done(); }); }); }); it('validates model after `before save` hook', function(done) { TestModel.observe('before save', invalidateTestModel()); existingInstance.updateAttributes({ name: 'updated' }, function(err) { (err || {}).should.be.instanceOf(ValidationError); (err.details.codes || {}).should.eql({ name: ['presence'] }); done(); }); }); it('triggers `persist` hook', function(done) { TestModel.observe('persist', pushContextAndNext()); existingInstance.updateAttributes({ name: 'changed' }, function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ where: { id: existingInstance.id }, data: { name: 'changed' }, currentInstance: { id: existingInstance.id, name: 'changed', extra: null }, isNewInstance: false })); done(); }); }); it('applies updates from `persist` hook', function(done) { TestModel.observe('persist', pushContextAndNext(function(ctx){ ctx.data.extra = 'hook data'; })); // By default, the instance passed to updateAttributes callback is NOT updated // with the changes made through persist/loaded hooks. To preserve // backwards compatibility, we introduced a new setting updateOnLoad, // which if set, will apply these changes to the model instance too. TestModel.settings.updateOnLoad = true; existingInstance.updateAttributes({ name: 'changed' }, function(err, instance) { if (err) return done(err); instance.should.have.property('extra', 'hook data'); done(); }); }); it('applies updates from `persist` hook - for nested model instance', function(done) { var Address = dataSource.createModel('NestedAddress', { id: { type: String, id: true, default: 1 }, city: { type: String, required: true }, country: { type: String, required: true } }); var User = dataSource.createModel('UserWithAddress', { id: { type: String, id: true, default: uid() }, name: { type: String, required: true }, address: {type: Address, required: false}, extra: {type: String} }); dataSource.automigrate(['UserWithAddress', 'NestedAddress'], function(err) { if (err) return done(err); User.create({name: 'Joe'}, function(err, instance) { if (err) return done(err); var existingUser = instance; User.observe('persist', pushContextAndNext(function(ctx) { should.exist(ctx.data.address) ctx.data.address.should.be.type('object'); ctx.data.address.should.not.be.instanceOf(Address); ctx.data.extra = 'hook data'; })); // By default, the instance passed to updateAttributes callback is NOT updated // with the changes made through persist/loaded hooks. To preserve // backwards compatibility, we introduced a new setting updateOnLoad, // which if set, will apply these changes to the model instance too. User.settings.updateOnLoad = true; existingUser.updateAttributes( {address: new Address({city: 'Springfield', country: 'USA'})}, function(err, inst) { if (err) return done(err); inst.should.have.property('extra', 'hook data'); User.findById(existingUser.id, function(err, dbInstance) { if (err) return done(err); dbInstance.toObject(true).should.eql({ id: existingUser.id, name: existingUser.name, address: {id: '1', city: 'Springfield', country: 'USA'}, extra: 'hook data' }); done(); }); }); }); }); }); it('triggers `loaded` hook', function(done) { TestModel.observe('loaded', pushContextAndNext()); existingInstance.updateAttributes({ name: 'changed' }, function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ data: { name: 'changed' } })); done(); }); }); it('emits error when `loaded` hook fails', function(done) { TestModel.observe('loaded', nextWithError(expectedError)); existingInstance.updateAttributes( { name: 'changed' }, function(err, instance) { [err].should.eql([expectedError]); done(); }); }); it('applies updates from `loaded` hook updateAttributes', function(done) { TestModel.observe('loaded', pushContextAndNext(function(ctx){ ctx.data.extra = 'hook data'; })); // By default, the instance passed to updateAttributes callback is NOT updated // with the changes made through persist/loaded hooks. To preserve // backwards compatibility, we introduced a new setting updateOnLoad, // which if set, will apply these changes to the model instance too. TestModel.settings.updateOnLoad = true; existingInstance.updateAttributes({ name: 'changed' }, function(err, instance) { if (err) return done(err); instance.should.have.property('extra', 'hook data'); done(); }); }); it('triggers `after save` hook', function(done) { TestModel.observe('after save', pushContextAndNext()); existingInstance.name = 'changed'; existingInstance.updateAttributes({ name: 'changed' }, function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ instance: { id: existingInstance.id, name: 'changed', extra: undefined }, isNewInstance: false })); done(); }); }); it('aborts when `after save` hook fails', function(done) { TestModel.observe('after save', nextWithError(expectedError)); existingInstance.updateAttributes({ name: 'updated' }, function(err) { [err].should.eql([expectedError]); done(); }); }); it('applies updates from `after save` hook', function(done) { TestModel.observe('after save', function(ctx, next) { ctx.instance.should.be.instanceOf(TestModel); ctx.instance.extra = 'hook data'; next(); }); existingInstance.updateAttributes({ name: 'updated' }, function(err, instance) { if (err) return done(err); instance.should.have.property('extra', 'hook data'); done(); }); }); }); if (!getSchema().connector.replaceById) { describe.skip('replaceById - not implemented', function(){}); } else { describe('PersistedModel.prototype.replaceAttributes', function() { it('triggers hooks in the correct order', function(done) { monitorHookExecution(); existingInstance.replaceAttributes( { name: 'replaced' }, function(err, record, created) { if (err) return done(err); triggered.should.eql([ 'before save', 'persist', 'loaded', 'after save' ]); done(); }); }); it('triggers `before save` hook', function(done) { TestModel.observe('before save', pushContextAndNext()); existingInstance.replaceAttributes({ name: 'changed' }, function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ instance: { id: existingInstance.id, name: 'changed', extra: undefined, }, isNewInstance: false })); done(); }); }); it('aborts when `before save` hook fails', function(done) { TestModel.observe('before save', nextWithError(expectedError)); existingInstance.replaceAttributes({ name: 'replaced' }, function(err) { [err].should.eql([expectedError]); done(); }); }); it('applies updates from `before save` hook', function(done) { TestModel.observe('before save', function(ctx, next) { ctx.instance.extra = 'extra data'; ctx.instance.name = 'hooked name'; next(); }); existingInstance.replaceAttributes({ name: 'updated' }, function(err) { if (err) return done(err); TestModel.findById(existingInstance.id, function(err, instance) { if (err) return done(err); should.exists(instance); instance.toObject(true).should.eql({ id: existingInstance.id, name: 'hooked name', extra: 'extra data' }); done(); }); }); }); it('validates model after `before save` hook', function(done) { TestModel.observe('before save', invalidateTestModel()); existingInstance.replaceAttributes({ name: 'updated' }, function(err) { (err || {}).should.be.instanceOf(ValidationError); (err.details.codes || {}).should.eql({ name: ['presence'] }); done(); }); }); it('triggers `persist` hook', function(done) { TestModel.observe('persist', pushContextAndNext()); existingInstance.replaceAttributes({ name: 'replacedName' }, function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ where: { id: existingInstance.id }, data: { name: 'replacedName', id: existingInstance.id }, currentInstance: { id: existingInstance.id, name: 'replacedName', extra: null }, isNewInstance: false })); done(); }); }); it('applies delete from `persist` hook', function(done) { TestModel.observe('persist', pushContextAndNext(function(ctx){ delete ctx.data.extra; })); existingInstance.replaceAttributes({ name: 'changed' }, function(err, instance) { if (err) return done(err); instance.should.not.have.property('extra', 'hook data'); done(); }); }); it('applies updates from `persist` hook - for nested model instance', function(done) { var Address = dataSource.createModel('NestedAddress', { id: { type: String, id: true, default: 1 }, city: { type: String, required: true }, country: { type: String, required: true } }); var User = dataSource.createModel('UserWithAddress', { id: { type: String, id: true, default: uid() }, name: { type: String, required: true }, address: {type: Address, required: false}, extra: {type: String} }); dataSource.automigrate(['UserWithAddress', 'NestedAddress'], function(err) { if (err) return done(err); User.create({name: 'Joe'}, function(err, instance) { if (err) return done(err); var existingUser = instance; User.observe('persist', pushContextAndNext(function(ctx) { should.exist(ctx.data.address) ctx.data.address.should.be.type('object'); ctx.data.address.should.not.be.instanceOf(Address); ctx.data.extra = 'hook data'; })); existingUser.replaceAttributes( {name: 'John', address: new Address({city: 'Springfield', country: 'USA'})}, function(err, inst) { if (err) return done(err); inst.should.have.property('extra', 'hook data'); User.findById(existingUser.id, function(err, dbInstance) { if (err) return done(err); dbInstance.toObject(true).should.eql({ id: existingUser.id, name: 'John', address: {id: '1', city: 'Springfield', country: 'USA'}, extra: 'hook data' }); done(); }); }); }); }); }); it('triggers `loaded` hook', function(done) { TestModel.observe('loaded', pushContextAndNext()); existingInstance.replaceAttributes({ name: 'changed' }, function(err, data) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ data: { name: 'changed', id: data.id }, isNewInstance : false })); done(); }); }); it('emits error when `loaded` hook fails', function(done) { TestModel.observe('loaded', nextWithError(expectedError)); existingInstance.replaceAttributes( { name: 'replaced' }, function(err, instance) { [err].should.eql([expectedError]); done(); }); }); it('applies updates from `loaded` hook replaceAttributes', function(done) { TestModel.observe('loaded', pushContextAndNext(function(ctx){ ctx.data.name = 'changed in hook'; })); existingInstance.replaceAttributes({ name: 'changed' }, function(err, instance) { if (err) return done(err); instance.should.have.property('name', 'changed in hook'); done(); }); }); it('triggers `after save` hook', function(done) { TestModel.observe('after save', pushContextAndNext()); existingInstance.name = 'replaced'; existingInstance.replaceAttributes({ name: 'replaced' }, function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ instance: { id: existingInstance.id, name: 'replaced', extra: undefined }, isNewInstance: false })); done(); }); }); it('aborts when `after save` hook fails', function(done) { TestModel.observe('after save', nextWithError(expectedError)); existingInstance.replaceAttributes({ name: 'replaced' }, function(err) { [err].should.eql([expectedError]); done(); }); }); it('applies updates from `after save` hook', function(done) { TestModel.observe('after save', function(ctx, next) { ctx.instance.should.be.instanceOf(TestModel); ctx.instance.extra = 'hook data'; next(); }); existingInstance.replaceAttributes({ name: 'updated' }, function(err, instance) { if (err) return done(err); instance.should.have.property('extra', 'hook data'); done(); }); }); }); } describe('PersistedModel.updateOrCreate', function() { it('triggers hooks in the correct order on create', function(done) { monitorHookExecution(); TestModel.updateOrCreate( { id: 'not-found', name: 'not found' }, function(err, record, created) { if (err) return done(err); triggered.should.eql([ 'access', 'before save', 'persist', 'loaded', 'after save' ]); done(); }); }); it('triggers hooks in the correct order on update', function(done) { monitorHookExecution(); TestModel.updateOrCreate( { id: existingInstance.id, name: 'new name' }, function(err, record, created) { if (err) return done(err); if (dataSource.connector.updateOrCreate) { triggered.should.eql([ 'access', 'before save', 'persist', 'loaded', 'after save' ]); } else { triggered.should.eql([ 'access', 'loaded', 'before save', 'persist', 'loaded', 'after save' ]); } done(); }); }); it('triggers `access` hook on create', function(done) { TestModel.observe('access', pushContextAndNext()); TestModel.updateOrCreate( { id: 'not-found', name: 'not found' }, function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ query: { where: { id: 'not-found' } }})); done(); }); }); it('triggers `access` hook on update', function(done) { TestModel.observe('access', pushContextAndNext()); TestModel.updateOrCreate( { id: existingInstance.id, name: 'new name' }, function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ query: { where: { id: existingInstance.id } }})); done(); }); }); it('does not trigger `access` on missing id', function(done) { TestModel.observe('access', pushContextAndNext()); TestModel.updateOrCreate( { name: 'new name' }, function(err, instance) { if (err) return done(err); observedContexts.should.equal('hook not called'); done(); }); }); it('applies updates from `access` hook when found', function(done) { TestModel.observe('access', function(ctx, next) { ctx.query = { where: { id: { neq: existingInstance.id } } }; next(); }); TestModel.updateOrCreate( { id: existingInstance.id, name: 'new name' }, function(err, instance) { if (err) return done(err); findTestModels({ fields: ['id', 'name' ] }, function(err, list) { if (err) return done(err); (list||[]).map(toObject).should.eql([ { id: existingInstance.id, name: existingInstance.name, extra: undefined }, { id: instance.id, name: 'new name', extra: undefined } ]); done(); }); }); }); it('applies updates from `access` hook when not found', function(done) { TestModel.observe('access', function(ctx, next) { ctx.query = { where: { id: 'not-found' } }; next(); }); TestModel.updateOrCreate( { id: existingInstance.id, name: 'new name' }, function(err, instance) { if (err) return done(err); findTestModels({ fields: ['id', 'name' ] }, function(err, list) { if (err) return done(err); (list||[]).map(toObject).should.eql([ { id: existingInstance.id, name: existingInstance.name, extra: undefined }, { id: list[1].id, name: 'second', extra: undefined }, { id: instance.id, name: 'new name', extra: undefined } ]); done(); }); }); }); it('triggers hooks only once', function(done) { TestModel.observe('access', pushNameAndNext('access')); TestModel.observe('before save', pushNameAndNext('before save')); TestModel.observe('access', function(ctx, next) { ctx.query = { where: { id: { neq: existingInstance.id } } }; next(); }); TestModel.updateOrCreate( { id: 'ignored', name: 'new name' }, function(err, instance) { if (err) return done(err); observersCalled.should.eql(['access', 'before save']); done(); }); }); it('triggers `before save` hook on update', function(done) { TestModel.observe('before save', pushContextAndNext()); TestModel.updateOrCreate( { id: existingInstance.id, name: 'updated name' }, function(err, instance) { if (err) return done(err); if (dataSource.connector.updateOrCreate) { // Atomic implementations of `updateOrCreate` cannot // provide full instance as that depends on whether // UPDATE or CREATE will be triggered observedContexts.should.eql(aTestModelCtx({ where: { id: existingInstance.id }, data: { id: existingInstance.id, name: 'updated name' } })); } else { // currentInstance is set, because a non-atomic `updateOrCreate` // will use `prototype.updateAttributes` internally, which // exposes this to the context observedContexts.should.eql(aTestModelCtx({ where: { id: existingInstance.id }, data: { id: existingInstance.id, name: 'updated name' }, currentInstance: existingInstance })); } done(); }); }); it('triggers `before save` hook on create', function(done) { TestModel.observe('before save', pushContextAndNext()); TestModel.updateOrCreate( { id: 'new-id', name: 'a name' }, function(err, instance) { if (err) return done(err); if (dataSource.connector.updateOrCreate) { // Atomic implementations of `updateOrCreate` cannot // provide full instance as that depends on whether // UPDATE or CREATE will be triggered observedContexts.should.eql(aTestModelCtx({ where: { id: 'new-id' }, data: { id: 'new-id', name: 'a name' } })); } else { // The default unoptimized implementation runs // `instance.save` and thus a full instance is availalbe observedContexts.should.eql(aTestModelCtx({ instance: { id: 'new-id', name: 'a name', extra: undefined }, isNewInstance: true })); } done(); }); }); it('applies updates from `before save` hook on update', function(done) { TestModel.observe('before save', function(ctx, next) { ctx.data.name = 'hooked'; next(); }); TestModel.updateOrCreate( { id: existingInstance.id, name: 'updated name' }, function(err, instance) { if (err) return done(err); instance.name.should.equal('hooked'); done(); }); }); it('applies updates from `before save` hook on create', function(done) { TestModel.observe('before save', function(ctx, next) { if (ctx.instance) { ctx.instance.name = 'hooked'; } else { ctx.data.name = 'hooked'; } next(); }); TestModel.updateOrCreate( { id: 'new-id', name: 'new name' }, function(err, instance) { if (err) return done(err); instance.name.should.equal('hooked'); done(); }); }); // FIXME(bajtos) this fails with connector-specific updateOrCreate // implementations, see the comment inside lib/dao.js (updateOrCreate) it.skip('validates model after `before save` hook on update', function(done) { TestModel.observe('before save', invalidateTestModel()); TestModel.updateOrCreate( { id: existingInstance.id, name: 'updated name' }, function(err, instance) { (err || {}).should.be.instanceOf(ValidationError); (err.details.codes || {}).should.eql({ name: ['presence'] }); done(); }); }); // FIXME(bajtos) this fails with connector-specific updateOrCreate // implementations, see the comment inside lib/dao.js (updateOrCreate) it.skip('validates model after `before save` hook on create', function(done) { TestModel.observe('before save', invalidateTestModel()); TestModel.updateOrCreate( { id: 'new-id', name: 'new name' }, function(err, instance) { (err || {}).should.be.instanceOf(ValidationError); (err.details.codes || {}).should.eql({ name: ['presence'] }); done(); }); }); it('triggers `persist` hook on create', function(done) { TestModel.observe('persist', pushContextAndNext()); TestModel.updateOrCreate( { id: 'new-id', name: 'a name' }, function(err, instance) { if (err) return done(err); if (dataSource.connector.updateOrCreate) { observedContexts.should.eql(aTestModelCtx({ where: { id: 'new-id' }, data: { id: 'new-id', name: 'a name' }, currentInstance: { id: 'new-id', name: 'a name', extra: undefined } })); } else { observedContexts.should.eql(aTestModelCtx({ data: { id: 'new-id', name: 'a name' }, isNewInstance: true, currentInstance: { id: 'new-id', name: 'a name', extra: undefined } })); } done(); }); }); it('triggers `persist` hook on update', function(done) { TestModel.observe('persist', pushContextAndNext()); TestModel.updateOrCreate( { id: existingInstance.id, name: 'updated name' }, function(err, instance) { if (err) return done(err); var expectedContext = aTestModelCtx({ where: { id: existingInstance.id }, data: { id: existingInstance.id, name: 'updated name' }, currentInstance: { id: existingInstance.id, name: 'updated name', extra: undefined } }); if (!dataSource.connector.updateOrCreate) { // When the connector does not provide updateOrCreate, // DAO falls back to updateAttributes which sets this flag expectedContext.isNewInstance = false; } observedContexts.should.eql(expectedContext); done(); }); }); it('triggers `loaded` hook on create', function(done) { TestModel.observe('loaded', pushContextAndNext()); TestModel.updateOrCreate( { id: 'new-id', name: 'a name' }, function(err, instance) { if (err) return done(err); if (dataSource.connector.updateOrCreate) { observedContexts.should.eql(aTestModelCtx({ data: { id: 'new-id', name: 'a name' }, isNewInstance: true, })); } else { observedContexts.should.eql(aTestModelCtx({ data: { id: 'new-id', name: 'a name' }, isNewInstance: true })); } done(); }); }); it('triggers `loaded` hook on update', function(done) { TestModel.observe('loaded', pushContextAndNext()); TestModel.updateOrCreate( { id: existingInstance.id, name: 'updated name' }, function(err, instance) { if (err) return done(err); if (dataSource.connector.updateOrCreate) { observedContexts.should.eql(aTestModelCtx({ data: { id: existingInstance.id, name: 'updated name' }, isNewInstance: false })); } else { // For Unoptimized connector, the callback function `pushContextAndNext` // is called twice. As a result, observedContexts // returns an array and NOT a single instance. observedContexts.should.eql([ aTestModelCtx({ data: { id: existingInstance.id, name: 'first', }, isNewInstance: false, options: { notify: false } }), aTestModelCtx({ data: { id: existingInstance.id, name: 'updated name' } }) ]); } done(); }); }); it('emits error when `loaded` hook fails', function(done) { TestModel.observe('loaded', nextWithError(expectedError)); TestModel.updateOrCreate( { id: 'new-id', name: 'a name' }, function(err, instance) { [err].should.eql([expectedError]); done(); }); }); it('triggers `after save` hook on update', function(done) { TestModel.observe('after save', pushContextAndNext()); TestModel.updateOrCreate( { id: existingInstance.id, name: 'updated name' }, function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ instance: { id: existingInstance.id, name: 'updated name', extra: undefined }, isNewInstance: false })); done(); }); }); it('triggers `after save` hook on create', function(done) { TestModel.observe('after save', pushContextAndNext()); TestModel.updateOrCreate( { id: 'new-id', name: 'a name' }, function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ instance: { id: instance.id, name: 'a name', extra: undefined }, isNewInstance: true })); done(); }); }); }); if (!getSchema().connector.replaceById) { describe.skip('replaceById - not implemented', function(){}); } else { describe('PersistedModel.replaceOrCreate', function() { it('triggers hooks in the correct order on create', function(done) { monitorHookExecution(); TestModel.replaceOrCreate( { id: 'not-found', name: 'not found' }, function(err, record, created) { if (err) return done(err); triggered.should.eql([ 'access', 'before save', 'persist', 'loaded', 'after save' ]); done(); }); }); it('triggers hooks in the correct order on replace', function(done) { monitorHookExecution(); TestModel.replaceOrCreate( { id: existingInstance.id, name: 'new name' }, function(err, record, created) { if (err) return done(err); if (dataSource.connector.replaceOrCreate) { triggered.should.eql([ 'access', 'before save', 'persist', 'loaded', 'after save' ]); } else { // TODO: Please see loopback-datasource-juggler/issues#836 // // loaded hook is triggered twice in non-atomic version: // 1) It gets triggered once by "find()" in this chain: // "replaceORCreate()->findOne()->find()", // which is a bug; Please see this ticket: // loopback-datasource-juggler/issues#836. // 2) It, also, gets triggered in "replaceAttributes()" // in this chain replaceORCreate()->replaceAttributes() triggered.should.eql([ 'access', 'loaded', 'before save', 'persist', 'loaded', 'after save' ]); }; done(); }); }); it('triggers `access` hook on create', function(done) { TestModel.observe('access', pushContextAndNext()); TestModel.replaceOrCreate( { id: 'not-found', name: 'not found' }, function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ query: { where: { id: 'not-found' } }})); done(); }); }); it('triggers `access` hook on replace', function(done) { TestModel.observe('access', pushContextAndNext()); TestModel.replaceOrCreate( { id: existingInstance.id, name: 'new name' }, function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ query: { where: { id: existingInstance.id } }})); done(); }); }); it('does not trigger `access` on missing id', function(done) { TestModel.observe('access', pushContextAndNext()); TestModel.replaceOrCreate( { name: 'new name' }, function(err, instance) { if (err) return done(err); observedContexts.should.equal('hook not called'); done(); }); }); it('applies updates from `access` hook when found', function(done) { TestModel.observe('access', function(ctx, next) { ctx.query = { where: { id: { neq: existingInstance.id } } }; next(); }); TestModel.replaceOrCreate( { id: existingInstance.id, name: 'new name' }, function(err, instance) { if (err) return done(err); findTestModels({ fields: ['id', 'name' ] }, function(err, list) { if (err) return done(err); (list||[]).map(toObject).should.eql([ { id: existingInstance.id, name: existingInstance.name, extra: undefined }, { id: instance.id, name: 'new name', extra: undefined } ]); done(); }); }); }); it('applies updates from `access` hook when not found', function(done) { TestModel.observe('access', function(ctx, next) { ctx.query = { where: { id: 'not-found' } }; next(); }); TestModel.replaceOrCreate( { id: existingInstance.id, name: 'new name' }, function(err, instance) { if (err) return done(err); findTestModels({ fields: ['id', 'name' ] }, function(err, list) { if (err) return done(err); (list||[]).map(toObject).should.eql([ { id: existingInstance.id, name: existingInstance.name, extra: undefined }, { id: list[1].id, name: 'second', extra: undefined }, { id: instance.id, name: 'new name', extra: undefined } ]); done(); }); }); }); it('triggers hooks only once', function(done) { TestModel.observe('access', pushNameAndNext('access')); TestModel.observe('before save', pushNameAndNext('before save')); TestModel.observe('access', function(ctx, next) { ctx.query = { where: { id: { neq: existingInstance.id } } }; next(); }); TestModel.replaceOrCreate( { id: 'ignored', name: 'new name' }, function(err, instance) { if (err) return done(err); observersCalled.should.eql(['access', 'before save']); done(); }); }); it('triggers `before save` hookon create', function(done) { TestModel.observe('before save', pushContextAndNext()); TestModel.replaceOrCreate({id: existingInstance.id, name: 'new name'}, function(err, instance) { if (err) return done(err); var expectedContext = aTestModelCtx({ instance: instance }); if (!dataSource.connector.replaceOrCreate) { expectedContext.isNewInstance = false; } done(); }); }); it('triggers `before save` hook on replace', function(done) { TestModel.observe('before save', pushContextAndNext()); TestModel.replaceOrCreate( { id: existingInstance.id, name: 'replaced name' }, function(err, instance) { if (err) return done(err); var expectedContext = aTestModelCtx({ instance: { id: existingInstance.id, name: 'replaced name', extra: undefined } }); if (!dataSource.connector.replaceOrCreate) { expectedContext.isNewInstance = false; } observedContexts.should.eql(expectedContext); done(); }); }); it('triggers `before save` hook on create', function(done) { TestModel.observe('before save', pushContextAndNext()); TestModel.replaceOrCreate( { id: 'new-id', name: 'a name' }, function(err, instance) { if (err) return done(err); var expectedContext = aTestModelCtx({ instance: { id: 'new-id', name: 'a name', extra: undefined } }); if (!dataSource.connector.replaceOrCreate) { expectedContext.isNewInstance = true; } observedContexts.should.eql(expectedContext); done(); }); }); it('applies updates from `before save` hook on create', function(done) { TestModel.observe('before save', function(ctx, next) { ctx.instance.name = 'hooked'; next(); }); TestModel.replaceOrCreate( { id: 'new-id', name: 'new name' }, function(err, instance) { if (err) return done(err); instance.name.should.equal('hooked'); done(); }); }); it('validates model after `before save` hook on create', function(done) { TestModel.observe('before save', invalidateTestModel()); TestModel.replaceOrCreate( { id: 'new-id', name: 'new name' }, function(err, instance) { (err || {}).should.be.instanceOf(ValidationError); (err.details.codes || {}).should.eql({ name: ['presence'] }); done(); }); }); it('triggers `persist` hook on create', function(done) { TestModel.observe('persist', pushContextAndNext()); TestModel.replaceOrCreate( { id: 'new-id', name: 'a name' }, function(err, instance) { if (err) return done(err); var expectedContext = aTestModelCtx({ currentInstance: { id: 'new-id', name: 'a name', extra: undefined }, data: { id: 'new-id', name: 'a name' } }); if (dataSource.connector.replaceOrCreate) { expectedContext.where = { id: 'new-id' }; } else { // non-atomic implementation does not provide ctx.where // because a new instance is being created, so there // are not records to match where filter. expectedContext.isNewInstance = true; } observedContexts.should.eql(expectedContext); done(); }); }); it('triggers `persist` hook on replace', function(done) { TestModel.observe('persist', pushContextAndNext()); TestModel.replaceOrCreate( { id: existingInstance.id, name: 'replaced name' }, function(err, instance) { if (err) return done(err); var expected = { where: { id: existingInstance.id }, data: { id: existingInstance.id, name: 'replaced name' }, currentInstance: { id: existingInstance.id, name: 'replaced name', extra: undefined } }; var expectedContext = aTestModelCtx(expected); var expectedContext; if (!dataSource.connector.replaceOrCreate) { expectedContext.isNewInstance = false; } observedContexts.should.eql(expectedContext); done(); }); }); it('triggers `loaded` hook on create', function(done) { TestModel.observe('loaded', pushContextAndNext()); TestModel.replaceOrCreate( { id: 'new-id', name: 'a name' }, function(err, instance) { if (err) return done(err); var expected = { data: { id: 'new-id', name: 'a name' } }; expected.isNewInstance = connectorCapabilities.replaceOrCreateReportsNewInstance ? true : undefined; observedContexts.should.eql(aTestModelCtx(expected)); done(); }); }); it('triggers `loaded` hook on replace', function(done) { TestModel.observe('loaded', pushContextAndNext()); TestModel.replaceOrCreate( { id: existingInstance.id, name: 'replaced name' }, function(err, instance) { if (err) return done(err); var expected = { data: { id: existingInstance.id, name: 'replaced name' } }; expected.isNewInstance = connectorCapabilities.replaceOrCreateReportsNewInstance ? false : undefined; if (dataSource.connector.replaceOrCreate) { observedContexts.should.eql(aTestModelCtx(expected)); } else { // TODO: Please see loopback-datasource-juggler/issues#836 // // loaded hook is triggered twice in non-atomic version: // 1) It gets triggered once by "find()" in this chain: // "replaceORCreate()->findOne()->find()", // which is a bug; Please see this ticket: // loopback-datasource-juggler/issues#836. // 2) It, also, gets triggered in "replaceAttributes()" // in this chain replaceORCreate()->replaceAttributes() observedContexts.should.eql([ aTestModelCtx({ data: { id: existingInstance.id, name: 'first' }, isNewInstance: false, options: { notify: false } }), aTestModelCtx({ data: { id: existingInstance.id, name: 'replaced name' }, isNewInstance: false }) ]); } done(); }); }); it('emits error when `loaded` hook fails', function(done) { TestModel.observe('loaded', nextWithError(expectedError)); TestModel.replaceOrCreate( { id: 'new-id', name: 'a name' }, function(err, instance) { [err].should.eql([expectedError]); done(); }); }); it('triggers `after save` hook on replace', function(done) { TestModel.observe('after save', pushContextAndNext()); TestModel.replaceOrCreate( { id: existingInstance.id, name: 'replaced name' }, function(err, instance) { if (err) return done(err); var expected = { instance: { id: existingInstance.id, name: 'replaced name', extra: undefined } }; expected.isNewInstance = connectorCapabilities.replaceOrCreateReportsNewInstance ? false : undefined; observedContexts.should.eql(aTestModelCtx(expected)); done(); }); }); it('triggers `after save` hook on create', function(done) { TestModel.observe('after save', pushContextAndNext()); TestModel.replaceOrCreate( { id: 'new-id', name: 'a name' }, function(err, instance) { if (err) return done(err); var expected = { instance: { id: instance.id, name: 'a name', extra: undefined } }; expected.isNewInstance = connectorCapabilities.replaceOrCreateReportsNewInstance ? true : undefined; observedContexts.should.eql(aTestModelCtx(expected)); done(); }); }); }); } describe('PersistedModel.deleteAll', function() { it('triggers `access` hook with query', function(done) { TestModel.observe('access', pushContextAndNext()); TestModel.deleteAll({ name: existingInstance.name }, function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ query: { where: { name: existingInstance.name } } })); done(); }); }); it('triggers `access` hook without query', function(done) { TestModel.observe('access', pushContextAndNext()); TestModel.deleteAll(function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ query: { where: {} } })); done(); }); }); it('applies updates from `access` hook', function(done) { TestModel.observe('access', function(ctx, next) { ctx.query = { where: { id: { neq: existingInstance.id } } }; next(); }); TestModel.deleteAll(function(err) { if (err) return done(err); findTestModels(function(err, list) { if (err) return done(err); (list || []).map(get('id')).should.eql([existingInstance.id]); done(); }); }); }); it('triggers `before delete` hook with query', function(done) { TestModel.observe('before delete', pushContextAndNext()); TestModel.deleteAll({ name: existingInstance.name }, function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ where: { name: existingInstance.name } })); done(); }); }); it('triggers `before delete` hook without query', function(done) { TestModel.observe('before delete', pushContextAndNext()); TestModel.deleteAll(function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ where: {} })); done(); }); }); it('applies updates from `before delete` hook', function(done) { TestModel.observe('before delete', function(ctx, next) { ctx.where = { id: { neq: existingInstance.id } }; next(); }); TestModel.deleteAll(function(err) { if (err) return done(err); findTestModels(function(err, list) { if (err) return done(err); (list || []).map(get('id')).should.eql([existingInstance.id]); done(); }); }); }); it('aborts when `before delete` hook fails', function(done) { TestModel.observe('before delete', nextWithError(expectedError)); TestModel.deleteAll(function(err, list) { [err].should.eql([expectedError]); TestModel.findById(existingInstance.id, function(err, inst) { if (err) return done(err); (inst ? inst.toObject() : 'null').should. eql(existingInstance.toObject()); done(); }); }); }); it('triggers `after delete` hook without query', function(done) { TestModel.observe('after delete', pushContextAndNext()); TestModel.deleteAll(function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ where: {} })); done(); }); }); it('triggers `after delete` hook without query', function(done) { TestModel.observe('after delete', pushContextAndNext()); TestModel.deleteAll({ name: existingInstance.name }, function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ where: { name: existingInstance.name } })); done(); }); }); it('aborts when `after delete` hook fails', function(done) { TestModel.observe('after delete', nextWithError(expectedError)); TestModel.deleteAll(function(err) { [err].should.eql([expectedError]); done(); }); }); }); describe('PersistedModel.prototype.delete', function() { it('triggers `access` hook', function(done) { TestModel.observe('access', pushContextAndNext()); existingInstance.delete(function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ query: { where: { id: existingInstance.id } } })); done(); }); }); it('applies updated from `access` hook', function(done) { TestModel.observe('access', function(ctx, next) { ctx.query = { where: { id: { neq: existingInstance.id } } }; next(); }); existingInstance.delete(function(err) { if (err) return done(err); findTestModels(function(err, list) { if (err) return done(err); (list || []).map(get('id')).should.eql([existingInstance.id]); done(); }); }); }); it('triggers `before delete` hook', function(done) { TestModel.observe('before delete', pushContextAndNext()); existingInstance.delete(function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ where: { id: existingInstance.id }, instance: existingInstance })); done(); }); }); it('applies updated from `before delete` hook', function(done) { TestModel.observe('before delete', function(ctx, next) { ctx.where = { id: { neq: existingInstance.id } }; next(); }); existingInstance.delete(function(err) { if (err) return done(err); findTestModels(function(err, list) { if (err) return done(err); (list || []).map(get('id')).should.eql([existingInstance.id]); done(); }); }); }); it('aborts when `before delete` hook fails', function(done) { TestModel.observe('before delete', nextWithError(expectedError)); existingInstance.delete(function(err, list) { [err].should.eql([expectedError]); TestModel.findById(existingInstance.id, function(err, inst) { if (err) return done(err); (inst ? inst.toObject() : 'null').should.eql( existingInstance.toObject()); done(); }); }); }); it('triggers `after delete` hook', function(done) { TestModel.observe('after delete', pushContextAndNext()); existingInstance.delete(function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ where: { id: existingInstance.id }, instance: existingInstance })); done(); }); }); it('triggers `after delete` hook without query', function(done) { TestModel.observe('after delete', pushContextAndNext()); TestModel.deleteAll({ name: existingInstance.name }, function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ where: { name: existingInstance.name } })); done(); }); }); it('aborts when `after delete` hook fails', function(done) { TestModel.observe('after delete', nextWithError(expectedError)); TestModel.deleteAll(function(err) { [err].should.eql([expectedError]); done(); }); }); it('propagates hookState from `before delete` to `after delete`', function(done) { TestModel.observe('before delete', pushContextAndNext(function(ctx) { ctx.hookState.foo = 'bar'; })); TestModel.observe('after delete', pushContextAndNext(function(ctx) { ctx.hookState.foo = ctx.hookState.foo.toUpperCase(); })); existingInstance.delete(function(err) { if (err) return done(err); observedContexts.should.eql([ aTestModelCtx({ hookState: { foo: 'bar', test: true }, where: { id: '1' }, instance: existingInstance }), aTestModelCtx({ hookState: { foo: 'BAR', test: true }, where: { id: '1' }, instance: existingInstance }) ]); done(); }); }); it('triggers hooks only once', function(done) { TestModel.observe('access', pushNameAndNext('access')); TestModel.observe('after delete', pushNameAndNext('after delete')); TestModel.observe('access', function(ctx, next) { ctx.query = { where: { id: { neq: existingInstance.id } } }; next(); }); existingInstance.delete(function(err) { if (err) return done(err); observersCalled.should.eql(['access', 'after delete']); done(); }); }); }); describe('PersistedModel.updateAll', function() { it('triggers `access` hook', function(done) { TestModel.observe('access', pushContextAndNext()); TestModel.updateAll( { name: 'searched' }, { name: 'updated' }, function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ query: { where: { name: 'searched' } }})); done(); }); }); it('applies updates from `access` hook', function(done) { TestModel.observe('access', function(ctx, next) { ctx.query = { where: { id: { neq: existingInstance.id } } }; next(); }); TestModel.updateAll( { id: existingInstance.id }, { name: 'new name' }, function(err) { if (err) return done(err); findTestModels({ fields: ['id', 'name' ] }, function(err, list) { if (err) return done(err); (list||[]).map(toObject).should.eql([ { id: existingInstance.id, name: existingInstance.name, extra: undefined }, { id: '2', name: 'new name', extra: undefined } ]); done(); }); }); }); it('triggers `before save` hook', function(done) { TestModel.observe('before save', pushContextAndNext()); TestModel.updateAll( { name: 'searched' }, { name: 'updated' }, function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ where: { name: 'searched' }, data: { name: 'updated' }, })); done(); }); }); it('applies updates from `before save` hook', function(done) { TestModel.observe('before save', function(ctx, next) { ctx.data = { name: 'hooked', extra: 'added' }; next(); }); TestModel.updateAll( { id: existingInstance.id }, { name: 'updated name' }, function(err) { if (err) return done(err); loadTestModel(existingInstance.id, function(err, instance) { if (err) return done(err); instance.should.have.property('name', 'hooked'); instance.should.have.property('extra', 'added'); done(); }); }); }); it('triggers `persist` hook', function(done) { TestModel.observe('persist', pushContextAndNext()); TestModel.updateAll( { name: existingInstance.name }, { name: 'changed' }, function(err, instance) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ data: { name: 'changed' }, where: { name: existingInstance.name } })); done(); }); }); it('applies updates from `persist` hook', function(done) { TestModel.observe('persist', pushContextAndNext(function(ctx){ ctx.data.extra = 'hook data'; })); TestModel.updateAll( { id: existingInstance.id }, { name: 'changed' }, function(err) { if (err) return done(err); loadTestModel(existingInstance.id, function(err, instance) { instance.should.have.property('extra', 'hook data'); done(); }); }); }); it('does not trigger `loaded`', function(done) { TestModel.observe('loaded', pushContextAndNext()); TestModel.updateAll( { id: existingInstance.id }, { name: 'changed' }, function(err, instance) { if (err) return done(err); observedContexts.should.eql("hook not called"); done(); }); }); it('triggers `after save` hook', function(done) { TestModel.observe('after save', pushContextAndNext()); TestModel.updateAll( { id: existingInstance.id }, { name: 'updated name' }, function(err) { if (err) return done(err); observedContexts.should.eql(aTestModelCtx({ where: { id: existingInstance.id }, data: { name: 'updated name' } })); done(); }); }); it('accepts hookState from options', function(done) { TestModel.observe('after save', pushContextAndNext()); TestModel.updateAll( { id: existingInstance.id }, { name: 'updated name' }, { foo: 'bar' }, function(err) { if (err) return done(err); observedContexts.options.should.eql({ foo: 'bar' }); done(); }); }); }); function pushContextAndNext(fn) { return function(context, next) { if (typeof fn === 'function') { fn(context); } context = deepCloneToObject(context); context.hookState.test = true; if (typeof observedContexts === 'string') { observedContexts = context; return next(); } if (!Array.isArray(observedContexts)) { observedContexts = [observedContexts]; } observedContexts.push(context); next(); }; } function pushNameAndNext(name) { return function(context, next) { observersCalled.push(name); next(); }; } function nextWithError(err) { return function(context, next) { next(err); }; } function invalidateTestModel() { return function(context, next) { if (context.instance) { context.instance.name = ''; } else { context.data.name = ''; } next(); }; } function aTestModelCtx(ctx) { ctx.Model = TestModel; if (!ctx.hookState) { ctx.hookState = { test: true }; } if (!ctx.options) { ctx.options = {}; } return deepCloneToObject(ctx); } function findTestModels(query, cb) { if (cb === undefined && typeof query === 'function') { cb = query; query = null; } TestModel.find(query, { notify: false }, cb); } function loadTestModel(id, cb) { TestModel.findOne({ where: { id: id } }, { notify: false }, cb); } function uid() { lastId += 1; return '' + lastId; } function getLastGeneratedUid() { return '' + lastId; } function monitorHookExecution() { triggered = []; TestModel._notify = TestModel.notifyObserversOf; TestModel.notifyObserversOf = function(operation, context, callback) { triggered.push(operation); this._notify.apply(this, arguments); }; } }); function deepCloneToObject(obj) { return traverse(obj).map(function(x) { if (x === undefined) { // RDBMSs return null return null; } if (x && x.toObject) return x.toObject(true); if (x && typeof x === 'function' && x.modelName) return '[ModelCtor ' + x.modelName + ']'; }); } function get(propertyName) { return function(obj) { return obj[propertyName]; }; } function toObject(obj) { return obj.toObject ? obj.toObject() : obj; } };