loopback-datasource-juggler
Version:
LoopBack DataSource Juggler
1,480 lines (1,278 loc) • 140 kB
JavaScript
// Copyright IBM Corp. 2015,2019. All Rights Reserved.
// Node module: loopback-datasource-juggler
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT
'use strict';
const ValidationError = require('../').ValidationError;
const async = require('async');
const contextTestHelpers = require('./helpers/context-test-helpers');
const ContextRecorder = contextTestHelpers.ContextRecorder;
const deepCloneToObject = contextTestHelpers.deepCloneToObject;
const aCtxForModel = contextTestHelpers.aCtxForModel;
const GeoPoint = require('../lib/geo.js').GeoPoint;
const uid = require('./helpers/uid-generator');
const getLastGeneratedUid = uid.last;
const HookMonitor = require('./helpers/hook-monitor');
let isNewInstanceFlag;
module.exports = function(dataSource, should, connectorCapabilities) {
isNewInstanceFlag = connectorCapabilities.replaceOrCreateReportsNewInstance;
if (!connectorCapabilities) connectorCapabilities = {};
if (isNewInstanceFlag === undefined) {
const warn = 'The connector does not support a recently added feature:' +
' replaceOrCreateReportsNewInstance';
console.warn(warn);
}
describe('Persistence hooks', function() {
let ctxRecorder, hookMonitor, expectedError;
let TestModel, existingInstance, GeoModel;
let migrated = false;
let undefinedValue = undefined;
beforeEach(function setupDatabase(done) {
ctxRecorder = new ContextRecorder('hook not called');
hookMonitor = new HookMonitor({includeModelName: false});
expectedError = new Error('test error');
TestModel = dataSource.createModel('TestModel', {
// Set id.generated to false to honor client side values
id: {type: String, id: true, generated: false, default: uid.next},
name: {type: String, required: true},
extra: {type: String, required: false},
});
GeoModel = dataSource.createModel('GeoModel', {
id: {type: String, id: true, default: uid.next},
name: {type: String, required: false},
location: {type: GeoPoint, required: false},
});
uid.reset();
if (migrated) {
async.series([
function(cb) {
TestModel.deleteAll(cb);
},
function(cb) {
GeoModel.deleteAll(cb);
},
], done);
} else {
dataSource.automigrate([TestModel.modelName, 'GeoModel'], 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);
const location1 = new GeoPoint({lat: 10.2, lng: 6.7});
const location2 = new GeoPoint({lat: 10.3, lng: 6.8});
GeoModel.create([
{name: 'Rome', location: location1},
{name: 'Tokyo', location: location2},
], function(err) {
done(err);
});
});
});
});
});
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);
hookMonitor.names.should.eql([
'access',
'loaded',
]);
done();
},
);
});
it('triggers the loaded hook multiple times when multiple instances exist', function(done) {
monitorHookExecution();
TestModel.find(function(err, list) {
if (err) return done(err);
hookMonitor.names.should.eql([
'access',
'loaded',
'loaded',
]);
done();
});
});
it('should not trigger hooks, if notify is false', function(done) {
monitorHookExecution();
TestModel.find(
{where: {id: '1'}},
{notify: false},
function(err, list) {
if (err) return done(err);
hookMonitor.names.should.be.empty();
done();
},
);
});
it('triggers the loaded hook multiple times when multiple instances exist when near filter is used',
function(done) {
const hookMonitorGeoModel = new HookMonitor({includeModelName: false});
function monitorHookExecutionGeoModel(hookNames) {
hookMonitorGeoModel.install(GeoModel, hookNames);
}
monitorHookExecutionGeoModel();
const query = {
where: {location: {near: '10,5'}},
};
GeoModel.find(query, function(err, list) {
if (err) return done(err);
hookMonitorGeoModel.names.should.eql(['access', 'loaded', 'loaded']);
done();
});
});
it('applies updates from `loaded` hook when near filter is used', function(done) {
GeoModel.observe('loaded', function(ctx, next) {
// It's crucial to change `ctx.data` reference, not only data props
ctx.data = Object.assign({}, ctx.data, {name: 'Berlin'});
next();
});
const query = {
where: {location: {near: '10,5'}},
};
GeoModel.find(query, function(err, list) {
if (err) return done(err);
list.map(get('name')).should.eql(['Berlin', 'Berlin']);
done();
});
});
it('applies updates to one specific instance from `loaded` hook when near filter is used',
function(done) {
GeoModel.observe('loaded', function(ctx, next) {
if (ctx.data.name === 'Rome') {
// It's crucial to change `ctx.data` reference, not only data props
ctx.data = Object.assign({}, ctx.data, {name: 'Berlin'});
}
next();
});
const query = {
where: {location: {near: '10,5'}},
};
GeoModel.find(query, function(err, list) {
if (err) return done(err);
list.map(get('name')).should.containEql('Berlin', 'Tokyo');
done();
});
});
it('applies updates from `loaded` hook when near filter is not used', function(done) {
TestModel.observe('loaded', function(ctx, next) {
// It's crucial to change `ctx.data` reference, not only data props
ctx.data = Object.assign({}, ctx.data, {name: 'Paris'});
next();
});
TestModel.find(function(err, list) {
if (err) return done(err);
list.map(get('name')).should.eql(['Paris', 'Paris']);
done();
});
});
it('applies updates to one specific instance from `loaded` hook when near filter is not used',
function(done) {
TestModel.observe('loaded', function(ctx, next) {
if (ctx.data.name === 'first') {
// It's crucial to change `ctx.data` reference, not only data props
ctx.data = Object.assign({}, ctx.data, {name: 'Paris'});
}
next();
});
TestModel.find(function(err, list) {
if (err) return done(err);
list.map(get('name')).should.eql(['Paris', 'second']);
done();
});
});
it('should not trigger hooks for geo queries, if notify is false',
function(done) {
monitorHookExecution();
TestModel.find(
{where: {geo: {near: '10,20'}}},
{notify: false},
function(err, list) {
if (err) return done(err);
hookMonitor.names.should.be.empty();
done();
},
);
});
it('should apply updates from `access` hook', function(done) {
TestModel.observe('access', function(ctx, next) {
ctx.query = {where: {name: 'second'}};
next();
});
TestModel.find({name: 'first'}, function(err, list) {
if (err) return done(err);
list.map(get('name')).should.eql(['second']);
done();
});
});
it('triggers `access` hook', function(done) {
TestModel.observe('access', ctxRecorder.recordAndNext());
TestModel.find({where: {id: '1'}}, function(err, list) {
if (err) return done(err);
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
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', ctxRecorder.recordAndNext());
TestModel.find({where: {geo: {near: '10,20'}}}, function(err, list) {
if (err) return done(err);
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
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', ctxRecorder.recordAndNext(function(ctx) {
// It's crucial to change `ctx.data` reference, not only data props
ctx.data = Object.assign({}, ctx.data, {extra: 'hook data'});
}));
TestModel.find(
{where: {id: 1}},
function(err, list) {
if (err) return done(err);
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
data: {
id: '1',
name: 'first',
extra: 'hook data',
},
isNewInstance: false,
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);
hookMonitor.names.should.eql([
'before save',
'persist',
'loaded',
'after save',
]);
done();
},
);
});
it('aborts when `after save` fires when option to notify is false', function(done) {
monitorHookExecution();
TestModel.create({name: 'created'}, {notify: false}, function(err, record, created) {
if (err) return done(err);
hookMonitor.names.should.not.containEql('after save');
done();
});
});
it('triggers `before save` hook', function(done) {
TestModel.observe('before save', ctxRecorder.recordAndNext());
TestModel.create({name: 'created'}, function(err, instance) {
if (err) return done(err);
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
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.next(), 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', ctxRecorder.recordAndNext());
TestModel.create(
[{name: '1'}, {name: '2'}],
function(err, list) {
if (err) return done(err);
// Creation of multiple instances is executed in parallel
ctxRecorder.records.sort(function(c1, c2) {
return c1.instance.name - c2.instance.name;
});
ctxRecorder.records.should.eql([
aCtxForModel(TestModel, {
instance: {id: list[0].id, name: '1', extra: undefined},
isNewInstance: true,
}),
aCtxForModel(TestModel, {
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', ctxRecorder.recordAndNext());
TestModel.create(
{id: 'new-id', name: 'a name'},
function(err, instance) {
if (err) return done(err);
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
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', ctxRecorder.recordAndNext(function(ctx) {
// It's crucial to change `ctx.data` reference, not only data props
ctx.data = Object.assign({}, 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', ctxRecorder.recordAndNext());
// 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);
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
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', ctxRecorder.recordAndNext(function(ctx) {
// It's crucial to change `ctx.data` reference, not only data props
ctx.data = Object.assign({}, 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', ctxRecorder.recordAndNext());
TestModel.create({name: 'created'}, function(err, instance) {
if (err) return done(err);
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
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', ctxRecorder.recordAndNext());
TestModel.create(
[{name: '1'}, {name: '2'}],
function(err, list) {
if (err) return done(err);
// Creation of multiple instances is executed in parallel
ctxRecorder.records.sort(function(c1, c2) {
return c1.instance.name - c2.instance.name;
});
ctxRecorder.records.should.eql([
aCtxForModel(TestModel, {
instance: {id: list[0].id, name: '1', extra: undefined},
isNewInstance: true,
}),
aCtxForModel(TestModel, {
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', ctxRecorder.recordAndNext());
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']);
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
instance: {id: list[0].id, name: 'ok', extra: undefined},
isNewInstance: true,
}));
done();
},
);
});
});
describe('PersistedModel.createAll', function() {
it('triggers hooks in the correct order', function(done) {
monitorHookExecution();
TestModel.createAll(
[{name: '1'}, {name: '2'}],
function(err) {
if (err) return done(err);
hookMonitor.names.should.eql([
'before save',
'before save',
'persist',
'loaded',
'after save',
'after save',
]);
done();
},
);
});
it('aborts when `after save` fires when option to notify is false', function(done) {
monitorHookExecution();
TestModel.create(
[{name: '1'}, {name: '2'}],
{notify: false},
function(err) {
if (err) return done(err);
hookMonitor.names.should.not.containEql('after save');
done();
},
);
});
it('triggers `before save` hook for each item in the array', function(done) {
TestModel.observe('before save', ctxRecorder.recordAndNext());
TestModel.createAll([{name: '1'}, {name: '2'}], function(err, list) {
if (err) return done(err);
// Creation of multiple instances is executed in parallel
ctxRecorder.records.sort(function(c1, c2) {
return c1.instance.name - c2.instance.name;
});
ctxRecorder.records.should.eql([
aCtxForModel(TestModel, {
instance: {id: list[0].id, name: '1', extra: undefined},
isNewInstance: true,
}),
aCtxForModel(TestModel, {
instance: {id: list[1].id, name: '2', extra: undefined},
isNewInstance: true,
}),
]);
done();
});
});
it('aborts when `before save` hook fails', function(done) {
TestModel.observe('before save', nextWithError(expectedError));
TestModel.createAll([{name: '1'}, {name: '2'}], function(err) {
err.should.eql(expectedError);
done();
});
});
it('applies updates from `before save` hook to each item in the array', function(done) {
TestModel.observe('before save', function(ctx, next) {
ctx.instance.should.be.instanceOf(TestModel);
ctx.instance.extra = 'hook data';
next();
});
TestModel.createAll(
[{id: uid.next(), name: 'a-name'}, {id: uid.next(), name: 'b-name'}],
function(err, instances) {
if (err) return done(err);
instances.forEach(instance => {
instance.should.have.property('extra', 'hook data');
});
done();
},
);
});
it('validates model after `before save` hook', function(done) {
TestModel.observe('before save', invalidateTestModel());
TestModel.createAll([{name: 'created1'}, {name: 'created2'}], function(err) {
(err || {}).should.be.instanceOf(ValidationError);
(err.details.codes || {}).should.eql({name: ['presence']});
done();
});
});
it('triggers `persist` hook', function(done) {
TestModel.observe('persist', ctxRecorder.recordAndNext());
TestModel.createAll(
[{id: 'new-id-1', name: 'a name'}, {id: 'new-id-2', name: 'b name'}],
function(err, instances) {
if (err) return done(err);
ctxRecorder.records.should.eql([
aCtxForModel(TestModel, {
data: {id: 'new-id-1', name: 'a name'},
isNewInstance: true,
currentInstance: {extra: null, id: 'new-id-1', name: 'a name'},
}),
aCtxForModel(TestModel, {
data: {id: 'new-id-2', name: 'b name'},
isNewInstance: true,
currentInstance: {extra: null, id: 'new-id-2', name: 'b name'},
}),
]);
done();
},
);
});
it('applies updates from `persist` hook', function(done) {
TestModel.observe(
'persist',
ctxRecorder.recordAndNext(function(ctxArr) {
// It's crucial to change `ctx.data` reference, not only data props
ctxArr.forEach(ctx => {
ctx.data = Object.assign({}, 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.createAll(
[{id: 'new-id', name: 'a name'}],
function(err, instances) {
if (err) return done(err);
instances.forEach(instance => {
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', ctxRecorder.recordAndNext());
// 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.createAll(
[
{id: 'new-id-1', name: 'a name'},
{id: 'new-id-2', name: 'b name'},
],
function(err) {
if (err) return done(err);
ctxRecorder.records.sort(function(c1, c2) {
return c1.data.name - c2.data.name;
});
ctxRecorder.records.should.eql([
aCtxForModel(TestModel, {
data: {id: 'new-id-1', name: 'a name'},
isNewInstance: true,
}),
aCtxForModel(TestModel, {
data: {id: 'new-id-2', name: 'b name'},
isNewInstance: true,
}),
]);
done();
},
);
});
it('emits error when `loaded` hook fails', function(done) {
TestModel.observe('loaded', nextWithError(expectedError));
TestModel.createAll(
[{id: 'new-id', name: 'a name'}],
function(err) {
err.should.eql(expectedError);
done();
},
);
});
it('applies updates from `loaded` hook', function(done) {
TestModel.observe(
'loaded',
ctxRecorder.recordAndNext(function(ctx) {
// It's crucial to change `ctx.data` reference, not only data props
ctx.data = Object.assign({}, 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, instances) {
if (err) return done(err);
instances.forEach((instance) => {
instance.should.have.property('extra', 'hook data');
});
done();
},
);
});
it('triggers `after save` hook', function(done) {
TestModel.observe('after save', ctxRecorder.recordAndNext());
TestModel.createAll([{name: '1'}, {name: '2'}], function(err, list) {
if (err) return done(err);
ctxRecorder.records.sort(function(c1, c2) {
return c1.instance.name - c2.instance.name;
});
ctxRecorder.records.should.eql([
aCtxForModel(TestModel, {
instance: {id: list[0].id, name: '1', extra: undefined},
isNewInstance: true,
}),
aCtxForModel(TestModel, {
instance: {id: list[1].id, name: '2', extra: undefined},
isNewInstance: true,
}),
]);
done();
});
});
it('aborts when `after save` hook fails', function(done) {
TestModel.observe('after save', nextWithError(expectedError));
TestModel.createAll([{name: 'created'}], 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();
});
TestModel.createAll([
{name: 'a-name'},
{name: 'b-name'},
], function(err, instances) {
if (err) return done(err);
instances.forEach((instance) => {
instance.should.have.property('extra', 'hook data');
});
done();
});
});
it('do not emit `after save` when before save fails for even one', function(done) {
TestModel.observe('before save', function(ctx, next) {
if (ctx.instance.name === 'fail') next(expectedError);
else next();
});
TestModel.observe('after save', ctxRecorder.recordAndNext());
TestModel.createAll([{name: 'ok'}, {name: 'fail'}], function(err, list) {
err.should.eql(expectedError);
done();
});
});
});
describe('PersistedModel.findOrCreate', function() {
it('triggers `access` hook', function(done) {
TestModel.observe('access', ctxRecorder.recordAndNext());
TestModel.findOrCreate(
{where: {name: 'new-record'}},
{name: 'new-record'},
function(err, record, created) {
if (err) return done(err);
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {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', ctxRecorder.recordAndNext());
TestModel.findOrCreate(
{where: {name: existingInstance.name}},
{name: existingInstance.name},
function(err, record, created) {
if (err) return done(err);
record.id.should.eql(existingInstance.id);
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
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', ctxRecorder.recordAndNext());
TestModel.findOrCreate(
{where: {name: 'new-record'}},
{name: 'new-record'},
function(err, record, created) {
if (err) return done(err);
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
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);
hookMonitor.names.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) {
hookMonitor.names.should.eql([
'access',
'before save',
'persist',
'loaded',
]);
} else {
hookMonitor.names.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', ctxRecorder.recordAndNext());
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.
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
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', ctxRecorder.recordAndNext());
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) {
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
data: {
id: record.id,
name: 'new-record',
},
isNewInstance: true,
currentInstance: {
id: record.id,
name: record.name,
extra: null,
},
where: {name: 'new-record'},
}));
} else {
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
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', ctxRecorder.recordAndNext(function(ctx) {
// It's crucial to change `ctx.data` reference, not only data props
ctx.data = Object.assign({}, 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', ctxRecorder.recordAndNext(function(ctx) {
// It's crucial to change `ctx.data` reference, not only data props
ctx.data = Object.assign({}, 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', ctxRecorder.recordAndNext());
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
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
data: {
id: existingInstance.id,
name: existingInstance.name,
},
isNewInstance: false,
}));
done();
},
);
});
}
it('triggers `loaded` hook when not found', function(done) {
TestModel.observe('loaded', ctxRecorder.recordAndNext());
TestModel.findOrCreate(
{where: {name: 'new-record'}},
{name: 'new-record'},
function(err, record, created) {
if (err) return done(err);
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
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', ctxRecorder.recordAndNext(function(ctx) {
// It's crucial to change `ctx.data` reference, not only data props
ctx.data = Object.assign({}, 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', ctxRecorder.recordAndNext(function(ctx) {
// It's crucial to change `ctx.data` reference, not only data props
ctx.data = Object.assign({}, 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', ctxRecorder.recordAndNext());
TestModel.findOrCreate(
{where: {name: 'new name'}},
{name: 'new name'},
function(err, instance) {
if (err) return done(err);
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {
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', ctxRecorder.recordAndNext());
TestModel.findOrCreate(
{where: {id: existingInstance.id}},
{name: existingInstance.name},
function(err, instance) {
if (err) return done(err);
ctxRecorder.records.should.eql('hook not called');
done();
},
);
});
});
describe('PersistedModel.count', function(done) {
it('triggers `access` hook', function(done) {
TestModel.observe('access', ctxRecorder.recordAndNext());
TestModel.count({id: existingInstance.id}, function(err, count) {
if (err) return done(err);
ctxRecorder.records.should.eql(aCtxForModel(TestModel, {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);
hookMonitor.names.should.eql([
'before save',
'persist',
'loaded',
'after save',
]);
done();
},
);
});
it('triggers `before save` hook', function(done) {
TestModel.observe('before save', ctxRecorder.recordAndNext());
existingInstance.name = 'changed';
existingInstance.save(function(err, instance) {
if (err) return done(err);
ctxRecorder.records.should.eql(aCtxForMo