diff --git a/common/models/user.json b/common/models/user.json index 16545ab4..a3f3973f 100644 --- a/common/models/user.json +++ b/common/models/user.json @@ -75,6 +75,12 @@ "permission": "ALLOW", "property": "updateAttributes" }, + { + "principalType": "ROLE", + "principalId": "$owner", + "permission": "ALLOW", + "property": "replaceById" + }, { "principalType": "ROLE", "principalId": "$everyone", diff --git a/lib/persisted-model.js b/lib/persisted-model.js index 537448b5..534a41e0 100644 --- a/lib/persisted-model.js +++ b/lib/persisted-model.js @@ -125,10 +125,27 @@ module.exports = function(registry) { * @param {Object} model Updated model instance. */ - PersistedModel.upsert = PersistedModel.updateOrCreate = function upsert(data, callback) { + PersistedModel.upsert = PersistedModel.updateOrCreate = PersistedModel.patchOrCreate = + function upsert(data, callback) { throwNotAttached(this.modelName, 'upsert'); }; + /** + * Replace or insert a model instance; replace existing record if one is found, + * such that parameter `data.id` matches `id` of model instance; otherwise, + * insert a new record. + * @param {Object} data The model instance data. + * @options {Object} [options] Options for replaceOrCreate + * @property {Boolean} validate Perform validation before saving. Default is true. + * @callback {Function} callback Callback function called with `cb(err, obj)` signature. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} model Replaced model instance. + */ + + PersistedModel.replaceOrCreate = function replaceOrCreate(data, callback) { + throwNotAttached(this.modelName, 'replaceOrCreate'); + }; + /** * Finds one record matching the optional filter object. If not found, creates * the object using the data provided as second argument. In this sense it is @@ -492,10 +509,45 @@ module.exports = function(registry) { * @param {Object} instance Updated instance. */ - PersistedModel.prototype.updateAttributes = function updateAttributes(data, cb) { + PersistedModel.prototype.updateAttributes = PersistedModel.prototype.patchAttributes = + function updateAttributes(data, cb) { throwNotAttached(this.modelName, 'updateAttributes'); }; + /** + * Replace attributes for a model instance and persist it into the datasource. + * Performs validation before replacing. + * + * @param {Object} data Data to replace. + * @options {Object} [options] Options for replace + * @property {Boolean} validate Perform validation before saving. Default is true. + * @callback {Function} callback Callback function called with `(err, instance)` arguments. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} instance Replaced instance. + */ + + PersistedModel.prototype.replaceAttributes = function replaceAttributes(data, cb) { + throwNotAttached(this.modelName, 'replaceAttributes'); + }; + + /** + * Replace attributes for a model instance whose id is the first input + * argument and persist it into the datasource. + * Performs validation before replacing. + * + * @param {*} id The ID value of model instance to replace. + * @param {Object} data Data to replace. + * @options {Object} [options] Options for replace + * @property {Boolean} validate Perform validation before saving. Default is true. + * @callback {Function} callback Callback function called with `(err, instance)` arguments. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} instance Replaced instance. + */ + + PersistedModel.replaceById = function replaceById(id, data, cb) { + throwNotAttached(this.modelName, 'replaceById'); + }; + /** * Reload object from persistence. Requires `id` member of `object` to be able to call `find`. * @callback {Function} callback Callback function called with `(err, instance)` arguments. Required. @@ -564,6 +616,9 @@ module.exports = function(registry) { var typeName = PersistedModel.modelName; var options = PersistedModel.settings; + // This is just for LB 2.x + options.replaceOnPUT = options.replaceOnPUT === true; + function setRemoting(scope, name, options) { var fn = scope[name]; fn._delegate = true; @@ -579,15 +634,35 @@ module.exports = function(registry) { http: {verb: 'post', path: '/'} }); - setRemoting(PersistedModel, 'upsert', { - aliases: ['updateOrCreate'], - description: g.s('Update an existing model instance or insert a new one ' + - 'into the data source.'), + var upsertOptions = { + aliases: ['patchOrCreate', 'updateOrCreate'], + description: g.s('Patch an existing model instance or insert a new one into the data source.'), accessType: 'WRITE', - accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, - returns: {arg: 'data', type: typeName, root: true}, - http: {verb: 'put', path: '/'} - }); + accepts: { arg: 'data', type: 'object', http: { source: 'body' }, description: + 'Model instance data' }, + returns: { arg: 'data', type: typeName, root: true }, + http: [{ verb: 'patch', path: '/' }], + }; + + if (!options.replaceOnPUT) { + upsertOptions.http.push({ verb: 'put', path: '/' }); + } + setRemoting(PersistedModel, 'upsert', upsertOptions); + + var replaceOrCreateOptions = { + description: 'Replace an existing model instance or insert a new one into the data source.', + accessType: 'WRITE', + accepts: { arg: 'data', type: 'object', http: { source: 'body' }, description: + 'Model instance data' }, + returns: { arg: 'data', type: typeName, root: true }, + http: [{ verb: 'post', path: '/replaceOrCreate' }], + }; + + if (options.replaceOnPUT) { + replaceOrCreateOptions.http.push({ verb: 'put', path: '/' }); + } + + setRemoting(PersistedModel, 'replaceOrCreate', replaceOrCreateOptions); setRemoting(PersistedModel, 'exists', { description: g.s('Check whether a model instance exists in the data source.'), @@ -634,6 +709,25 @@ module.exports = function(registry) { rest: {after: convertNullToNotFoundError} }); + var replaceByIdOptions = { + description: 'Replace attributes for a model instance and persist it into the data source.', + accessType: 'WRITE', + accepts: [ + { arg: 'id', type: 'any', description: 'Model id', required: true, + http: { source: 'path' }}, + { arg: 'data', type: 'object', http: { source: 'body' }, description: + 'Model instance data' }, + ], + returns: { arg: 'data', type: typeName, root: true }, + http: [{ verb: 'post', path: '/:id/replace' }], + }; + + if (options.replaceOnPUT) { + replaceByIdOptions.http.push({ verb: 'put', path: '/:id' }); + } + + setRemoting(PersistedModel, 'replaceById', replaceByIdOptions); + setRemoting(PersistedModel, 'find', { description: g.s('Find all instances of the model matched by filter from the data source.'), accessType: 'READ', @@ -702,14 +796,21 @@ module.exports = function(registry) { http: {verb: 'get', path: '/count'} }); - setRemoting(PersistedModel.prototype, 'updateAttributes', { - description: g.s('Update attributes for a model instance and persist it into ' + - 'the data source.'), + var updateAttributesOptions = { + aliases: ['patchAttributes'], + description: g.s('Patch attributes for a model instance and persist it into the data source.'), accessType: 'WRITE', - accepts: {arg: 'data', type: 'object', http: {source: 'body'}, description: 'An object of model property name/value pairs'}, - returns: {arg: 'data', type: typeName, root: true}, - http: {verb: 'put', path: '/'} - }); + + accepts: { arg: 'data', type: 'object', http: { source: 'body' }, description: 'An object of model property name/value pairs' }, + returns: { arg: 'data', type: typeName, root: true }, + http: [{ verb: 'patch', path: '/' }], + }; + + setRemoting(PersistedModel.prototype, 'updateAttributes', updateAttributesOptions); + + if (!options.replaceOnPUT) { + updateAttributesOptions.http.push({ verb: 'put', path: '/' }); + } if (options.trackChanges || options.enableRemoteReplication) { setRemoting(PersistedModel, 'diff', { diff --git a/test/access-control.integration.js b/test/access-control.integration.js index b3215257..3fe2e9c5 100644 --- a/test/access-control.integration.js +++ b/test/access-control.integration.js @@ -121,9 +121,15 @@ describe('access control - integration', function() { assert.equal(user.password, undefined); }); }); + + // user has replaceOnPUT = false; so then both PUT and PATCH should be allowed for update lt.describe.whenCalledRemotely('PUT', '/api/users/:id', function() { lt.it.shouldBeAllowed(); }); + + lt.describe.whenCalledRemotely('PATCH', '/api/users/:id', function() { + lt.it.shouldBeAllowed(); + }); }); lt.it.shouldBeDeniedWhenCalledAnonymously('DELETE', urlForUser); @@ -173,7 +179,7 @@ describe('access control - integration', function() { } }); - describe('/accounts', function() { + describe('/accounts with replaceOnPUT true', function() { var count = 0; before(function() { var roleModel = loopback.getModelByType(loopback.Role); @@ -187,48 +193,68 @@ describe('access control - integration', function() { }); }); - lt.beforeEach.givenModel('account'); + lt.beforeEach.givenModel('accountWithReplaceOnPUTtrue'); - lt.it.shouldBeDeniedWhenCalledAnonymously('GET', '/api/accounts'); - lt.it.shouldBeDeniedWhenCalledUnauthenticated('GET', '/api/accounts'); - lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'GET', '/api/accounts'); + lt.it.shouldBeDeniedWhenCalledAnonymously('GET', '/api/accounts-replacing'); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('GET', '/api/accounts-replacing'); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'GET', '/api/accounts-replacing'); lt.it.shouldBeDeniedWhenCalledAnonymously('GET', urlForAccount); lt.it.shouldBeDeniedWhenCalledUnauthenticated('GET', urlForAccount); lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'GET', urlForAccount); - lt.it.shouldBeDeniedWhenCalledAnonymously('POST', '/api/accounts'); - lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', '/api/accounts'); - lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', '/api/accounts'); + lt.it.shouldBeDeniedWhenCalledAnonymously('POST', '/api/accounts-replacing'); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', '/api/accounts-replacing'); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', '/api/accounts-replacing'); + + lt.it.shouldBeDeniedWhenCalledAnonymously('POST', urlForReplaceAccountPOST); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', urlForReplaceAccountPOST); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', urlForReplaceAccountPOST); lt.it.shouldBeDeniedWhenCalledAnonymously('PUT', urlForAccount); lt.it.shouldBeDeniedWhenCalledUnauthenticated('PUT', urlForAccount); lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'PUT', urlForAccount); + lt.it.shouldBeDeniedWhenCalledAnonymously('PATCH', urlForAccount); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('PATCH', urlForAccount); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'PATCH', urlForAccount); + lt.describe.whenLoggedInAsUser(CURRENT_USER, function() { + var actId; beforeEach(function(done) { var self = this; - // Create an account under the given user - app.models.account.create({ + app.models.accountWithReplaceOnPUTtrue.create({ userId: self.user.id, balance: 100 }, function(err, act) { - self.url = '/api/accounts/' + act.id; - + actId = act.id; + self.url = '/api/accounts-replacing/' + actId; done(); }); + }); - }); - lt.describe.whenCalledRemotely('PUT', '/api/accounts/:id', function() { + lt.describe.whenCalledRemotely('PATCH', '/api/accounts-replacing/:id', function() { lt.it.shouldBeAllowed(); }); - lt.describe.whenCalledRemotely('GET', '/api/accounts/:id', function() { + lt.describe.whenCalledRemotely('PUT', '/api/accounts-replacing/:id', function() { lt.it.shouldBeAllowed(); }); - lt.describe.whenCalledRemotely('DELETE', '/api/accounts/:id', function() { + lt.describe.whenCalledRemotely('GET', '/api/accounts-replacing/:id', function() { + lt.it.shouldBeAllowed(); + }); + lt.describe.whenCalledRemotely('DELETE', '/api/accounts-replacing/:id', function() { lt.it.shouldBeDenied(); }); + describe('replace on POST verb', function() { + beforeEach(function(done) { + this.url = '/api/accounts-replacing/' + actId + '/replace'; + done(); + }); + lt.describe.whenCalledRemotely('POST', '/api/accounts-replacing/:id/replace', function() { + lt.it.shouldBeAllowed(); + }); + }); }); lt.it.shouldBeDeniedWhenCalledAnonymously('DELETE', urlForAccount); @@ -236,7 +262,77 @@ describe('access control - integration', function() { lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'DELETE', urlForAccount); function urlForAccount() { - return '/api/accounts/' + this.account.id; + return '/api/accounts-replacing/' + this.accountWithReplaceOnPUTtrue.id; + } + function urlForReplaceAccountPOST() { + return '/api/accounts-replacing/' + this.accountWithReplaceOnPUTtrue.id + '/replace'; + } + }); + + describe('/accounts with replaceOnPUT false', function() { + lt.beforeEach.givenModel('accountWithReplaceOnPUTfalse'); + lt.it.shouldBeDeniedWhenCalledAnonymously('POST', urlForReplaceAccountPOST); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('POST', urlForReplaceAccountPOST); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'POST', urlForReplaceAccountPOST); + + lt.it.shouldBeDeniedWhenCalledAnonymously('PUT', urlForAccount); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('PUT', urlForAccount); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'PUT', urlForAccount); + + lt.it.shouldBeDeniedWhenCalledAnonymously('PATCH', urlForAccount); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('PATCH', urlForAccount); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'PATCH', urlForAccount); + + lt.describe.whenLoggedInAsUser(CURRENT_USER, function() { + var actId; + beforeEach(function(done) { + var self = this; + // Create an account under the given user + app.models.accountWithReplaceOnPUTfalse.create({ + userId: self.user.id, + balance: 100, + }, function(err, act) { + actId = act.id; + self.url = '/api/accounts-updating/' + actId; + done(); + }); + }); + + lt.describe.whenCalledRemotely('PATCH', '/api/accounts-updating/:id', function() { + lt.it.shouldBeAllowed(); + }); + + lt.describe.whenCalledRemotely('PUT', '/api/accounts-updating/:id', function() { + + lt.it.shouldBeAllowed(); + }); + lt.describe.whenCalledRemotely('GET', '/api/accounts-updating/:id', function() { + lt.it.shouldBeAllowed(); + }); + lt.describe.whenCalledRemotely('DELETE', '/api/accounts-updating/:id', function() { + lt.it.shouldBeDenied(); + }); + + describe('replace on POST verb', function() { + beforeEach(function(done) { + this.url = '/api/accounts-updating/' + actId + '/replace'; + done(); + }); + lt.describe.whenCalledRemotely('POST', '/api/accounts-updating/:id/replace', function() { + lt.it.shouldBeAllowed(); + }); + }); + }); + + lt.it.shouldBeDeniedWhenCalledAnonymously('DELETE', urlForAccount); + lt.it.shouldBeDeniedWhenCalledUnauthenticated('DELETE', urlForAccount); + lt.it.shouldBeDeniedWhenCalledByUser(CURRENT_USER, 'DELETE', urlForAccount); + + function urlForAccount() { + return '/api/accounts-updating/' + this.accountWithReplaceOnPUTfalse.id; + } + function urlForReplaceAccountPOST() { + return '/api/accounts-updating/' + this.accountWithReplaceOnPUTfalse.id + '/replace'; } }); diff --git a/test/fixtures/access-control/common/models/account.json b/test/fixtures/access-control/common/models/account.json index 4607adfc..e0c08bfd 100644 --- a/test/fixtures/access-control/common/models/account.json +++ b/test/fixtures/access-control/common/models/account.json @@ -1,5 +1,6 @@ { - "name": "account", + "name": "accountWithReplaceOnPUTtrue", + "plural": "accounts-replacing", "relations": { "transactions": { "model": "transaction", @@ -38,5 +39,6 @@ "principalId": "$dummy" } ], - "properties": {} + "properties": {}, + "replaceOnPUT": true } \ No newline at end of file diff --git a/test/fixtures/access-control/common/models/accountWithReplaceOnPUTfalse.json b/test/fixtures/access-control/common/models/accountWithReplaceOnPUTfalse.json new file mode 100644 index 00000000..a54f9cc2 --- /dev/null +++ b/test/fixtures/access-control/common/models/accountWithReplaceOnPUTfalse.json @@ -0,0 +1,44 @@ +{ + "name": "accountWithReplaceOnPUTfalse", + "plural": "accounts-updating", + "relations": { + "transactions": { + "model": "transaction", + "type": "hasMany" + }, + "user": { + "model": "user", + "type": "belongsTo", + "foreignKey": "userId" + } + }, + "acls": [ + { + "accessType": "*", + "permission": "DENY", + "principalType": "ROLE", + "principalId": "$everyone" + }, + { + "accessType": "*", + "permission": "ALLOW", + "principalType": "ROLE", + "principalId": "$owner" + }, + { + "permission": "DENY", + "principalType": "ROLE", + "principalId": "$owner", + "property": "deleteById" + }, + { + "accessType": "*", + "permission": "DENY", + "property": "find", + "principalType": "ROLE", + "principalId": "$dummy" + } + ], + "properties": {}, + "replaceOnPUT": false +} \ No newline at end of file diff --git a/test/fixtures/access-control/common/models/user.json b/test/fixtures/access-control/common/models/user.json index 7ecfb373..ef769cbf 100644 --- a/test/fixtures/access-control/common/models/user.json +++ b/test/fixtures/access-control/common/models/user.json @@ -19,5 +19,6 @@ "principalType": "ROLE", "principalId": "$everyone" } - ] + ], + "replaceOnPUT": false } \ No newline at end of file diff --git a/test/fixtures/access-control/server/model-config.json b/test/fixtures/access-control/server/model-config.json index ddb886fc..62816359 100644 --- a/test/fixtures/access-control/server/model-config.json +++ b/test/fixtures/access-control/server/model-config.json @@ -34,7 +34,11 @@ "public": true, "dataSource": "db" }, - "account": { + "accountWithReplaceOnPUTtrue": { + "public": true, + "dataSource": "db" + }, + "accountWithReplaceOnPUTfalse": { "public": true, "dataSource": "db" }, diff --git a/test/fixtures/simple-integration-app/common/models/store-replacing.json b/test/fixtures/simple-integration-app/common/models/store-replacing.json new file mode 100644 index 00000000..17166526 --- /dev/null +++ b/test/fixtures/simple-integration-app/common/models/store-replacing.json @@ -0,0 +1,19 @@ +{ + "name": "storeWithReplaceOnPUTtrue", + "plural": "stores-replacing", + "properties": {}, + "scopes": { + "superStores": { + "where": { + "size": "super" + } + } + }, + "relations": { + "widgets": { + "model": "widget", + "type": "hasMany" + } + }, + "replaceOnPUT": true +} \ No newline at end of file diff --git a/test/fixtures/simple-integration-app/common/models/store-updating.json b/test/fixtures/simple-integration-app/common/models/store-updating.json new file mode 100644 index 00000000..c876336c --- /dev/null +++ b/test/fixtures/simple-integration-app/common/models/store-updating.json @@ -0,0 +1,19 @@ +{ + "name": "storeWithReplaceOnPUTfalse", + "plural": "stores-updating", + "properties": {}, + "scopes": { + "superStores": { + "where": { + "size": "super" + } + } + }, + "relations": { + "widgets": { + "model": "widget", + "type": "hasMany" + } + }, + "replaceOnPUT": false +} \ No newline at end of file diff --git a/test/fixtures/simple-integration-app/server/model-config.json b/test/fixtures/simple-integration-app/server/model-config.json index ed73e7ca..1eb76159 100644 --- a/test/fixtures/simple-integration-app/server/model-config.json +++ b/test/fixtures/simple-integration-app/server/model-config.json @@ -38,6 +38,14 @@ "public": true, "dataSource": "db" }, + "storeWithReplaceOnPUTfalse": { + "public": true, + "dataSource": "db" + }, + "storeWithReplaceOnPUTtrue": { + "public": true, + "dataSource": "db" + }, "physician": { "dataSource": "db", "public": true diff --git a/test/model.test.js b/test/model.test.js index 27e89ca5..ef7dd9c2 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -627,9 +627,14 @@ describe.onServer('Remote Methods', function() { var methodNames = []; metadata.methods.forEach(function(method) { methodNames.push(method.name); - methodNames = methodNames.concat(method.sharedMethod.aliases || []); + var aliases = method.sharedMethod.aliases; + if (method.name.indexOf('prototype.') === 0) { + aliases = aliases.map(function(alias) { + return 'prototype.' + alias; + }); + } + methodNames = methodNames.concat(aliases || []); }); - expect(methodNames).to.have.members([ // NOTE(bajtos) These three methods are disabled by default // Because all tests share the same global registry model @@ -637,9 +642,11 @@ describe.onServer('Remote Methods', function() { // this test was seeing this method (with all aliases) as public // 'destroyAll', 'deleteAll', 'remove', 'create', - 'upsert', 'updateOrCreate', + 'upsert', 'updateOrCreate', 'patchOrCreate', 'exists', 'findById', + 'replaceById', + 'replaceOrCreate', 'find', 'findOne', 'updateAll', 'update', @@ -647,8 +654,8 @@ describe.onServer('Remote Methods', function() { 'destroyById', 'removeById', 'count', - 'prototype.updateAttributes', - 'createChangeStream' + 'prototype.patchAttributes', 'prototype.updateAttributes', + 'createChangeStream', ]); }); diff --git a/test/remoting.integration.js b/test/remoting.integration.js index f047f7b3..59b3ca07 100644 --- a/test/remoting.integration.js +++ b/test/remoting.integration.js @@ -80,61 +80,28 @@ describe('remoting - integration', function() { }); describe('Model shared classes', function() { - function formatReturns(m) { - var returns = m.returns; - if (!returns || returns.length === 0) { - return ''; - } - var type = returns[0].type; - return type ? ':' + type : ''; - } - - function formatMethod(m) { - return [ - m.name, - '(', - m.accepts.map(function(a) { - return a.arg + ':' + a.type; - }).join(','), - ')', - formatReturns(m), - ' ', - m.getEndpoints()[0].verb, - ' ', - m.getEndpoints()[0].fullPath - ].join(''); - } - - function findClass(name) { - return app.handler('rest').adapter - .getClasses() - .filter(function(c) { - return c.name === name; - })[0]; - } - - it('has expected remote methods', function() { + it('has expected remote methods with default model.settings.replaceOnPUT' + + 'set to false (2.x)', + function() { var storeClass = findClass('store'); - var methods = storeClass.methods - .filter(function(m) { - return m.name.indexOf('__') === -1; - }) - .map(function(m) { - return formatMethod(m); - }); + var methods = getFormattedMethodsExcludingRelations(storeClass.methods); var expectedMethods = [ 'create(data:object):store POST /stores', + 'upsert(data:object):store PATCH /stores', 'upsert(data:object):store PUT /stores', + 'replaceOrCreate(data:object):store POST /stores/replaceOrCreate', 'exists(id:any):boolean GET /stores/:id/exists', 'findById(id:any,filter:object):store GET /stores/:id', + 'prototype.updateAttributes(data:object):store PUT /stores/:id', + 'replaceById(id:any,data:object):store POST /stores/:id/replace', 'find(filter:object):store GET /stores', 'findOne(filter:object):store GET /stores/findOne', 'updateAll(where:object,data:object):object POST /stores/update', 'deleteById(id:any):object DELETE /stores/:id', 'count(where:object):number GET /stores/count', - 'prototype.updateAttributes(data:object):store PUT /stores/:id', - 'createChangeStream(options:object):ReadableStream POST /stores/change-stream' + 'prototype.updateAttributes(data:object):store PATCH /stores/:id', + 'createChangeStream(options:object):ReadableStream POST /stores/change-stream', ]; // The list of methods is from docs: @@ -144,13 +111,7 @@ describe('remoting - integration', function() { it('has expected remote methods for scopes', function() { var storeClass = findClass('store'); - var methods = storeClass.methods - .filter(function(m) { - return m.name.indexOf('__') === 0; - }) - .map(function(m) { - return formatMethod(m); - }); + var methods = getFormattedScopeMethods(storeClass.methods); var expectedMethods = [ '__get__superStores(filter:object):store GET /stores/superStores', @@ -166,13 +127,7 @@ describe('remoting - integration', function() { function() { var widgetClass = findClass('widget'); - var methods = widgetClass.methods - .filter(function(m) { - return m.name.indexOf('prototype.__') === 0; - }) - .map(function(m) { - return formatMethod(m); - }); + var methods = getFormattedPrototypeMethods(widgetClass.methods); var expectedMethods = [ 'prototype.__get__store(refresh:boolean):store ' + @@ -185,13 +140,7 @@ describe('remoting - integration', function() { function() { var physicianClass = findClass('store'); - var methods = physicianClass.methods - .filter(function(m) { - return m.name.indexOf('prototype.__') === 0; - }) - .map(function(m) { - return formatMethod(m); - }); + var methods = getFormattedPrototypeMethods(physicianClass.methods); var expectedMethods = [ 'prototype.__findById__widgets(fk:any):widget ' + @@ -214,15 +163,8 @@ describe('remoting - integration', function() { it('should have correct signatures for hasMany-through methods', function() { // jscs:disable validateIndentation - - var physicianClass = findClass('physician'); - var methods = physicianClass.methods - .filter(function(m) { - return m.name.indexOf('prototype.__') === 0; - }) - .map(function(m) { - return formatMethod(m); - }); + var physicianClass = findClass('physician'); + var methods = getFormattedPrototypeMethods(physicianClass.methods); var expectedMethods = [ 'prototype.__findById__patients(fk:any):patient ' + @@ -251,3 +193,127 @@ describe('remoting - integration', function() { }); }); + +describe('With model.settings.replaceOnPUT false', function() { + lt.beforeEach.withApp(app); + lt.beforeEach.givenModel('storeWithReplaceOnPUTfalse'); + afterEach(function(done) { + this.app.models.storeWithReplaceOnPUTfalse.destroyAll(done); + }); + + it('should have expected remote methods', + function() { + var storeClass = findClass('storeWithReplaceOnPUTfalse'); + var methods = getFormattedMethodsExcludingRelations(storeClass.methods); + + var expectedMethods = [ + 'upsert(data:object):storeWithReplaceOnPUTfalse PATCH /stores-updating', + 'upsert(data:object):storeWithReplaceOnPUTfalse PUT /stores-updating', + 'replaceOrCreate(data:object):storeWithReplaceOnPUTfalse POST /stores-updating/replaceOrCreate', + 'replaceById(id:any,data:object):storeWithReplaceOnPUTfalse POST /stores-updating/:id/replace', + 'prototype.updateAttributes(data:object):storeWithReplaceOnPUTfalse PATCH /stores-updating/:id', + 'prototype.updateAttributes(data:object):storeWithReplaceOnPUTfalse PUT /stores-updating/:id', + ]; + + expect(methods).to.include.members(expectedMethods); + }); +}); + +describe('With model.settings.replaceOnPUT true', function() { + lt.beforeEach.withApp(app); + lt.beforeEach.givenModel('storeWithReplaceOnPUTtrue'); + afterEach(function(done) { + this.app.models.storeWithReplaceOnPUTtrue.destroyAll(done); + }); + + it('should have expected remote methods', + function() { + var storeClass = findClass('storeWithReplaceOnPUTtrue'); + var methods = getFormattedMethodsExcludingRelations(storeClass.methods); + + var expectedMethods = [ + 'upsert(data:object):storeWithReplaceOnPUTtrue PATCH /stores-replacing', + 'replaceOrCreate(data:object):storeWithReplaceOnPUTtrue POST /stores-replacing/replaceOrCreate', + 'replaceOrCreate(data:object):storeWithReplaceOnPUTtrue PUT /stores-replacing', + 'replaceById(id:any,data:object):storeWithReplaceOnPUTtrue POST /stores-replacing/:id/replace', + 'replaceById(id:any,data:object):storeWithReplaceOnPUTtrue PUT /stores-replacing/:id', + 'prototype.updateAttributes(data:object):storeWithReplaceOnPUTtrue PATCH /stores-replacing/:id', + ]; + + expect(methods).to.include.members(expectedMethods); + }); +}); + +function formatReturns(m) { + var returns = m.returns; + if (!returns || returns.length === 0) { + return ''; + } + var type = returns[0].type; + return type ? ':' + type : ''; +} + +function formatMethod(m) { + var arr = []; + var endpoints = m.getEndpoints(); + for (var i = 0; i < endpoints.length; i++) { + arr.push([ + m.name, + '(', + m.accepts.map(function(a) { + return a.arg + ':' + a.type; + }).join(','), + ')', + formatReturns(m), + ' ', + endpoints[i].verb, + ' ', + endpoints[i].fullPath, + ].join('')); + } + return arr; +} + +function findClass(name) { + return app.handler('rest').adapter + .getClasses() + .filter(function(c) { + return c.name === name; + })[0]; +} + +function getFormattedMethodsExcludingRelations(methods) { + return methods.filter(function(m) { + return m.name.indexOf('__') === -1; + }) + .map(function(m) { + return formatMethod(m); + }) + .reduce(function(p, c) { + return p.concat(c); + }); +} + +function getFormattedScopeMethods(methods) { + return methods.filter(function(m) { + return m.name.indexOf('__') === 0; + }) + .map(function(m) { + return formatMethod(m); + }) + .reduce(function(p, c) { + return p.concat(c); + }); +} + +function getFormattedPrototypeMethods(methods) { + return methods.filter(function(m) { + return m.name.indexOf('prototype.__') === 0; + }) + .map(function(m) { + return formatMethod(m); + }) + .reduce(function(p, c) { + return p.concat(c); + }); +}