From af46b92dac6bcba95429583327e463321a1fd628 Mon Sep 17 00:00:00 2001 From: Dane Stuckel Date: Tue, 16 Jun 2015 16:01:51 -0400 Subject: [PATCH] cascading PUTs --- lib/services/components.js | 85 +++++++++++++++++++++---- lib/services/components.test.js | 106 +++++++++++++++++++++++--------- test/api/components/put.js | 43 +++++++++---- test/fixtures/api-accepts.js | 43 +++++++++++-- test/index.js | 6 ++ 5 files changed, 226 insertions(+), 57 deletions(-) diff --git a/lib/services/components.js b/lib/services/components.js index 8f63da07..de8ecb6b 100644 --- a/lib/services/components.js +++ b/lib/services/components.js @@ -15,7 +15,8 @@ var _ = require('lodash'), is = require('../assert-is'), path = require('path'), config = require('config'), - glob = require('glob'); + glob = require('glob'), + referenceProperty = '_ref'; /** * Takes a ref, and returns the component name within it. @@ -56,11 +57,11 @@ function get(ref, locals) { */ function putLatest(ref, data) { data = JSON.stringify(data); - return db.batch([ + return [ { type: 'put', key: ref, value: data }, { type: 'put', key: ref + '@latest', value: data }, { type: 'put', key: ref + '@' + uid(), value: data} - ]); + ]; } /** @@ -71,10 +72,10 @@ function putLatest(ref, data) { */ function putPublished(ref, data) { data = JSON.stringify(data); - return db.batch([ + return [ { type: 'put', key: ref + '@published', value: data }, { type: 'put', key: ref + '@' + uid(), value: data} - ]); + ]; } /** @@ -86,9 +87,9 @@ function putPublished(ref, data) { */ function putTag(ref, data, tag) { data = JSON.stringify(data); - return db.batch([ + return [ { type: 'put', key: ref + '@' + tag, value: data } - ]); + ]; } /** @@ -123,16 +124,76 @@ function putDefaultBehavior(ref, data) { * @param data */ function put(ref, data) { - var promise, + var result, componentModule = files.getComponentModule(getName(ref)); if (componentModule && _.isFunction(componentModule.put)) { - promise = componentModule.put(ref, data); + result = componentModule.put(ref, data); } else { - promise = putDefaultBehavior(ref, data); + result = putDefaultBehavior(ref, data); } - return is.promise(promise, ref).return(data); + return result; +} + +/** + * Clear all of an object properties (in place), not a new object + * + * No need to return anything since it's in-place + * + * @param {object} obj + */ +function clearOwnProperties(obj) { + _.forOwn(obj, function (value, key) { + delete obj[key]; + }); +} + +/** + * True if this is a reference object that also has real data in it. + * + * Used to determine if that data should be preserved or not. + * + * @param {object} obj + * @returns {boolean} + */ +function isReferencedAndReal(obj) { + return _.isString(obj[referenceProperty]) && _.size(obj) > 1; +} + +/** + * @param {string} ref + * @param {object} data + * @returns {Promise.object} + */ +function cascadingPut(ref, data) { + //search for _ref with _size greater than 1 + var ops = [], + deepObjects = _.listDeepObjects(data, isReferencedAndReal); + + //reverse; children should be before parents + _.each(deepObjects.reverse(), function (obj) { + var ref = obj[referenceProperty]; + + // since children are before parents, no one will see data below them + ops.push({key: ref, value: _.omit(obj, referenceProperty)}); + + // omit cloned 1 level deep and we clear what omit cloned from obj + // so the op gets the first level of data, but it's removed from the main obj + clearOwnProperties(obj); + obj[referenceProperty] = ref; + }); + + //add the cleaned root object at the end + ops.push({key: ref, value: data}); + + return bluebird.map(ops, function (op) { + //run each through the normal put, which may or may not hit custom component logic + return put(op.key, op.value); + }).then(function (ops) { + //flatten to list of final batch ops + return db.batch(_.filter(_.flattenDeep(ops), _.identity)); + }).return(data); //return root object if successful } /** @@ -202,7 +263,7 @@ function getTemplate(ref) { //outsiders can act on components too module.exports.get = get; -module.exports.put = put; +module.exports.put = cascadingPut; //special: could lead to multiple put operations module.exports.del = del; //maybe server.js people want to reach in and reuse these? diff --git a/lib/services/components.test.js b/lib/services/components.test.js index 7a94cbb9..700e4ef3 100644 --- a/lib/services/components.test.js +++ b/lib/services/components.test.js @@ -32,7 +32,7 @@ describe(_.startCase(filename), function () { }); afterEach(function () { - sandbox.verifyAndRestore(); + sandbox.restore(); }); describe('getName', function () { @@ -93,39 +93,24 @@ describe(_.startCase(filename), function () { describe('putTag', function () { var fn = lib[this.title]; - it('puts to tag', function (done) { - sandbox.mock(db).expects('batch').withArgs().once().returns(bluebird.resolve()); - fn('/components/whatever', {}, 'special').done(function () { - done(); - }, function (err) { - done(err); - }); + it('puts to tag', function () { + fn('/components/whatever', {}, 'special'); }); }); describe('putPublished', function () { var fn = lib[this.title]; - it('puts to published', function (done) { - sandbox.mock(db).expects('batch').withArgs().once().returns(bluebird.resolve()); - fn('/components/whatever', {}).done(function () { - done(); - }, function (err) { - done(err); - }); + it('puts to published', function () { + fn('/components/whatever', {}); }); }); describe('putLatest', function () { var fn = lib[this.title]; - it('puts to latest', function (done) { - sandbox.mock(db).expects('batch').withArgs().once().returns(bluebird.resolve()); - fn('/components/whatever', {}).done(function () { - done(); - }, function (err) { - done(err); - }); + it('puts to latest', function () { + fn('/components/whatever', {}); }); }); @@ -136,17 +121,13 @@ describe(_.startCase(filename), function () { }); - it('deletes', function (done) { + it('deletes', function () { var mockDb = sandbox.mock(db), mockFiles = sandbox.mock(files); mockDb.expects('get').withArgs().once().returns(bluebird.resolve('{}')); mockDb.expects('del').withArgs().once().returns(bluebird.resolve()); mockFiles.expects('getComponentModule').withArgs('whatever').twice().returns(null); - fn('/components/whatever').done(function () { - done(); - }, function (err) { - done(err); - }); + return fn('/components/whatever'); }); }); @@ -156,6 +137,75 @@ describe(_.startCase(filename), function () { it('throws error if component module does not return promise', function () { }); + + it('puts', function () { + var ref = 'a', + data = {}; + + sandbox.stub(files, 'getComponentModule'); + sandbox.stub(db, 'batch'); + + return fn(ref, data).then(function () { + expect(db.batch.getCall(0).args[0]).to.deep.contain.members([{ key: 'a', type: 'put', value: '{}' }]); + }); + }); + + it('returns original object if successful', function () { + var ref = 'a', + data = {}; + + sandbox.stub(files, 'getComponentModule'); + sandbox.stub(db, 'batch'); + + return fn(ref, data).then(function (result) { + expect(result).to.deep.equal({}); + }); + }); + + it('cascades', function () { + var ref = 'a', + data = {a: 'b', c: {_ref:'d', e: 'f'}}; + + sandbox.stub(files, 'getComponentModule'); + sandbox.stub(db, 'batch'); + + return fn(ref, data).then(function () { + var ops = db.batch.getCall(0).args[0]; + expect(ops).to.deep.contain.members([ + { key: 'd', type: 'put', value: JSON.stringify({e: 'f'}) }, + { key: 'a', type: 'put', value: JSON.stringify({a: 'b', c: { _ref: 'd'}}) } + ]); + }); + }); + + it('cascades with component modules', function () { + var ref = 'a', + data = {a: 'b', c: {_ref:'d', e: 'f'}}, + rootModuleData = {type: 'put', key: 'g', value: JSON.stringify({h: 'i'})}, + deepModuleData = {type: 'put', key: 'j', value: JSON.stringify({k: 'l'})}, + putSpy = sinon.stub(); + + sandbox.stub(files, 'getComponentModule').returns({put: putSpy}); + sandbox.stub(db, 'batch'); + putSpy.withArgs('a', sinon.match.object).returns([rootModuleData]); + putSpy.withArgs('d', sinon.match.object).returns([deepModuleData]); + + return fn(ref, data).then(function () { + expect(db.batch.getCall(0).args[0]).to.deep.contain.members([rootModuleData, deepModuleData]); + }); + }); + + it('returns basic root object if successful even if cascading', function () { + var ref = 'a', + data = {a: 'b', c: {_ref:'d', e: 'f'}}; + + sandbox.stub(files, 'getComponentModule'); + sandbox.stub(db, 'batch'); + + return fn(ref, data).then(function (result) { + expect(result).to.deep.equal({ a: 'b', c: { _ref: 'd' } }); + }); + }); }); describe('get', function () { diff --git a/test/api/components/put.js b/test/api/components/put.js index ecb70ae2..cd52abb9 100644 --- a/test/api/components/put.js +++ b/test/api/components/put.js @@ -15,7 +15,12 @@ describe(endpointName, function () { acceptsHtml = apiAccepts.acceptsHtml(_.camelCase(filename)), updatesOther = apiAccepts.updatesOther(_.camelCase(filename)), createsNewVersion = apiAccepts.createsNewVersion(_.camelCase(filename)), - data = { name: 'Manny', species: 'cat' }; + cascades = apiAccepts.cascades(_.camelCase(filename)), + data = { name: 'Manny', species: 'cat' }, + cascadingTarget = '/components/validDeep', + cascadingData = {a: 'b', c: {_ref: cascadingTarget, d: 'e'}}, + cascadingReturnData = {a: 'b', c: {_ref: cascadingTarget}}, + cascadingDeepData = {d: 'e'}; beforeEach(function () { sandbox = sinon.sandbox.create(); @@ -44,10 +49,13 @@ describe(endpointName, function () { acceptsJsonBody(path, {name: 'invalid'}, {}, 404, { code: 404, message: 'Not Found' }); acceptsJsonBody(path, {name: 'valid'}, data, 200, data); acceptsJsonBody(path, {name: 'missing'}, data, 200, data); + acceptsJsonBody(path, {name: 'valid'}, cascadingData, 200, cascadingReturnData); acceptsHtml(path, {name: 'invalid'}, 404); acceptsHtml(path, {name: 'valid'}, 406); acceptsHtml(path, {name: 'missing'}, 406); + + cascades(path, {name: 'valid'}, cascadingData, cascadingTarget, cascadingDeepData); }); describe('/components/:name/schema', function () { @@ -65,17 +73,20 @@ describe(endpointName, function () { describe('/components/:name@:version', function () { var path = this.title; - acceptsJson(path, {name: 'invalid', version: 'abc'}, 404, { code: 404, message: 'Not Found' }); - acceptsJson(path, {name: 'valid', version: 'abc'}, 200, {}); - acceptsJson(path, {name: 'missing', version: 'abc'}, 200, {}); + acceptsJson(path, {name: 'invalid', version: 'def'}, 404, { code: 404, message: 'Not Found' }); + acceptsJson(path, {name: 'valid', version: 'def'}, 200, {}); + acceptsJson(path, {name: 'missing', version: 'def'}, 200, {}); acceptsJsonBody(path, {name: 'invalid', version: 'def'}, {}, 404, { code: 404, message: 'Not Found' }); acceptsJsonBody(path, {name: 'valid', version: 'def'}, data, 200, data); acceptsJsonBody(path, {name: 'missing', version: 'def'}, data, 200, data); + acceptsJsonBody(path, {name: 'valid', version: 'def'}, cascadingData, 200, cascadingReturnData); + + acceptsHtml(path, {name: 'invalid', version: 'def'}, 404); + acceptsHtml(path, {name: 'valid', version: 'def'}, 406); + acceptsHtml(path, {name: 'missing', version: 'def'}, 406); - acceptsHtml(path, {name: 'invalid', version: 'ghi'}, 404); - acceptsHtml(path, {name: 'valid', version: 'ghi'}, 406); - acceptsHtml(path, {name: 'missing', version: 'ghi'}, 406); + cascades(path, {name: 'valid', version: 'def'}, cascadingData, cascadingTarget, cascadingDeepData); }); describe('/components/:name/instances', function () { @@ -104,11 +115,14 @@ describe(endpointName, function () { acceptsJsonBody(path, {name: 'invalid', id: 'valid'}, {}, 404, { code: 404, message: 'Not Found' }); acceptsJsonBody(path, {name: 'valid', id: 'valid'}, data, 200, data); acceptsJsonBody(path, {name: 'missing', id: 'missing'}, data, 200, data); + acceptsJsonBody(path, {name: 'valid'}, cascadingData, 200, cascadingReturnData); acceptsHtml(path, {name: 'invalid', id: 'valid'}, 404); acceptsHtml(path, {name: 'valid', id: 'valid'}, 406); acceptsHtml(path, {name: 'valid', id: 'missing'}, 406); + cascades(path, {name: 'valid', id: 'valid'}, cascadingData, cascadingTarget, cascadingDeepData); + updatesOther(path, path + '@latest', {name: 'valid', id: 'newId'}, data); updatesOther(path + '@latest', path, {name: 'valid', id: 'newId'}, data); @@ -120,17 +134,20 @@ describe(endpointName, function () { describe('/components/:name/instances/:id@:version', function () { var path = this.title; - acceptsJson(path, {name: 'invalid', version: 'abc', id: 'valid'}, 404, { code: 404, message: 'Not Found' }); - acceptsJson(path, {name: 'valid', version: 'abc', id: 'valid'}, 200, {}); - acceptsJson(path, {name: 'valid', version: 'abc', id: 'missing'}, 200, {}); + acceptsJson(path, {name: 'invalid', version: 'def', id: 'valid'}, 404, { code: 404, message: 'Not Found' }); + acceptsJson(path, {name: 'valid', version: 'def', id: 'valid'}, 200, {}); + acceptsJson(path, {name: 'valid', version: 'def', id: 'missing'}, 200, {}); acceptsJsonBody(path, {name: 'invalid', version: 'def', id: 'valid'}, {}, 404, { code: 404, message: 'Not Found' }); acceptsJsonBody(path, {name: 'valid', version: 'def', id: 'valid'}, data, 200, data); acceptsJsonBody(path, {name: 'missing', version: 'def', id: 'missing'}, data, 200, data); + acceptsJsonBody(path, {name: 'valid', version: 'def', id: 'valid'}, cascadingData, 200, cascadingReturnData); + + acceptsHtml(path, {name: 'invalid', version: 'def', id: 'valid'}, 404); + acceptsHtml(path, {name: 'valid', version: 'def', id: 'valid'}, 406); + acceptsHtml(path, {name: 'valid', version: 'def', id: 'missing'}, 406); - acceptsHtml(path, {name: 'invalid', version: 'ghi', id: 'valid'}, 404); - acceptsHtml(path, {name: 'valid', version: 'ghi', id: 'valid'}, 406); - acceptsHtml(path, {name: 'valid', version: 'ghi', id: 'missing'}, 406); + cascades(path, {name: 'valid', version: 'def', id: 'valid'}, cascadingData, cascadingTarget, cascadingDeepData); }); }); }); \ No newline at end of file diff --git a/test/fixtures/api-accepts.js b/test/fixtures/api-accepts.js index 320029b2..24dc09da 100644 --- a/test/fixtures/api-accepts.js +++ b/test/fixtures/api-accepts.js @@ -17,6 +17,14 @@ var _ = require('lodash'), app, host; +/** + * @param {object} replacements + * @param {string} path + */ +function getRealPath(replacements, path) { + return _.reduce(replacements, function (str, value, key) { return str.replace(':' + key, value); }, path); +} + /** * Create a generic API test. (shortcut) * @param {object} options @@ -29,7 +37,7 @@ var _ = require('lodash'), * @param options.data Expected data to be returned */ function createTest(options) { - var realPath = _.reduce(options.replacements, function (str, value, key) { return str.replace(':' + key, value); }, options.path); + var realPath = getRealPath(options.replacements, options.path); it(options.description, function () { var promise = request(app)[options.method](realPath); @@ -123,8 +131,8 @@ function acceptsJson(method) { function updatesOther(method) { return function (path, otherPath, replacements, data) { - var realPath = _.reduce(replacements, function (str, value, key) { return str.replace(':' + key, value); }, path), - realOtherPath = _.reduce(replacements, function (str, value, key) { return str.replace(':' + key, value); }, otherPath); + var realPath = getRealPath(replacements, path), + realOtherPath = getRealPath(replacements, otherPath); it(JSON.stringify(replacements) + ' updates other ' + otherPath, function () { return request(app)[method](realPath) @@ -167,7 +175,7 @@ function getVersions(ref) { function createsNewVersion(method) { return function (path, replacements, data) { - var realPath = _.reduce(replacements, function (str, value, key) { return str.replace(':' + key, value); }, path); + var realPath = getRealPath(replacements, path); it(realPath + ' creates new version', function () { return getVersions(realPath).then(function (oldVersions) { @@ -191,6 +199,32 @@ function createsNewVersion(method) { }; } +/** + * Expect deep data to exist after cascading operation + * @param method + * @returns {Function} + */ +function cascades(method) { + return function (path, replacements, data, cascadingTarget, cascadingDeepData) { + var realPath = getRealPath(replacements, path); + + it(realPath + ' cascades', function () { + return request(app)[method](realPath) + .send(data) + .type('application/json') + .set('Accept', 'application/json') + .set('Host', host) + .expect(200) + .then(function () { + //expect deep data to now exist + return db.get(cascadingTarget).then(JSON.parse).then(function (result) { + expect(result).to.deep.equal(cascadingDeepData); + }); + }); + }); + }; +} + function setApp(value) { app = value; } @@ -385,6 +419,7 @@ module.exports.acceptsJson = acceptsJson; module.exports.acceptsJsonBody = acceptsJsonBody; module.exports.updatesOther = updatesOther; module.exports.createsNewVersion = createsNewVersion; +module.exports.cascades = cascades; module.exports.stubComponentPath = stubSchema; module.exports.beforeTesting = beforeTesting; module.exports.beforeEachComponentTest = beforeEachComponentTest; diff --git a/test/index.js b/test/index.js index 4b48efee..8446a6c9 100644 --- a/test/index.js +++ b/test/index.js @@ -1,9 +1,15 @@ 'use strict'; var glob = require('glob'), _ = require('lodash'), + chai = require('chai'), tests = glob.sync(__dirname + '/../lib/**/*.test.js'), apiTests = glob.sync(__dirname + '/api/**/*.js'); +//defaults for chai +chai.config.showDiff = true; +chai.config.truncateThreshold = 0; + + _.map(tests, function (test) { require(test); });