From dfd4afea195c6ce577bff5a76ccfc0b6f06afaf6 Mon Sep 17 00:00:00 2001 From: Katharina Irrgang Date: Tue, 21 Nov 2017 14:28:05 +0100 Subject: [PATCH] Add bookshelf-relations (#9252) no issue - added https://github.com/TryGhost/bookshelf-relations as dependency - remove existing tag handling --- * Important: Ensure we trigger parent initialize function - otherwise the plugin is unable to listen on model events - important: event order for listeners is Ghost -> Plugin - Ghost should be able to listen on the events as first instance - e.g. be able to modify/validate relationships * Fix tag validation - we detect lower/update case slugs for tags manually - this can't be taken over from the plugin obviously - ensure we update the target model e.g. this.set('tags', ...) * override base fn: `permittedAttributes` - ensure we call the base - put relations on top - each relation is allowed to be passed - the plugin will auto-unset any relations to it does not reach the database * Ensure we run add/edit/delete within a transaction - updating nested relationships requires sql queries - all sql statements have to run in a single transaction to ensure we rollback everything if an error occurs - use es6 --- core/server/models/base/index.js | 24 + core/server/models/base/utils.js | 67 +- core/server/models/post.js | 333 +++--- core/test/integration/api/api_posts_spec.js | 167 --- .../integration/model/model_posts_spec.js | 947 +++--------------- package.json | 1 + yarn.lock | 10 +- 7 files changed, 312 insertions(+), 1237 deletions(-) diff --git a/core/server/models/base/index.js b/core/server/models/base/index.js index 94cd39d116c7..33bfb05dfb3e 100644 --- a/core/server/models/base/index.js +++ b/core/server/models/base/index.js @@ -45,6 +45,27 @@ ghostBookshelf.plugin(plugins.pagination); // Update collision plugin ghostBookshelf.plugin(plugins.collision); +// Manages nested updates (relationships) +ghostBookshelf.plugin('bookshelf-relations', { + allowedOptions: ['context'], + unsetRelations: true, + hooks: { + belongsToMany: { + after: function (existing, targets, options) { + // reorder tags + return Promise.each(targets.models, function (target, index) { + return existing.updatePivot({ + sort_order: index + }, _.extend({}, options, {query: {where: {tag_id: target.id}}})); + }); + }, + beforeRelationCreation: function onCreatingRelation(model, data) { + data.id = ObjectId.generate(); + } + } + } +}); + // Cache an instance of the base model prototype proto = ghostBookshelf.Model.prototype; @@ -119,6 +140,9 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({ return Promise.resolve(self.onValidate.apply(self, args)); }); }); + + // NOTE: Please keep here. If we don't initialize the parent, bookshelf-relations won't work. + proto.initialize.call(this); }, onValidate: function onValidate() { diff --git a/core/server/models/base/utils.js b/core/server/models/base/utils.js index c91dfb78be2a..f0a94633c85d 100644 --- a/core/server/models/base/utils.js +++ b/core/server/models/base/utils.js @@ -6,7 +6,7 @@ var _ = require('lodash'), Promise = require('bluebird'), ObjectId = require('bson-objectid'), errors = require('../../errors'), - tagUpdate, attach; + attach; /** * Attach wrapper (please never call attach manual!) @@ -63,69 +63,4 @@ attach = function attach(Model, effectedModelId, relation, modelsToAttach, optio }); }; -tagUpdate = { - fetchCurrentPost: function fetchCurrentPost(PostModel, id, options) { - return PostModel.forge({id: id}).fetch(_.extend({}, options, {withRelated: ['tags']})); - }, - - fetchMatchingTags: function fetchMatchingTags(TagModel, tagsToMatch, options) { - if (_.isEmpty(tagsToMatch)) { - return false; - } - return TagModel.forge() - .query('whereIn', 'name', _.map(tagsToMatch, 'name')).fetchAll(options); - }, - - detachTagFromPost: function detachTagFromPost(post, tag, options) { - return function () { - // See tgriesser/bookshelf#294 for an explanation of _.omit(options, 'query') - return post.tags().detach(tag.id, _.omit(options, 'query')); - }; - }, - - attachTagToPost: function attachTagToPost(Post, postId, tag, index, options) { - return function () { - // See tgriesser/bookshelf#294 for an explanation of _.omit(options, 'query') - return attach(Post, postId, 'tags', [{tag_id: tag.id, sort_order: index}], _.omit(options, 'query')); - }; - }, - - createTagThenAttachTagToPost: function createTagThenAttachTagToPost(PostModel, TagModel, post, tag, index, options) { - var fields = ['name', 'slug', 'description', 'feature_image', 'visibility', 'parent_id', 'meta_title', 'meta_description']; - return function () { - return TagModel.add(_.pick(tag, fields), options).then(function then(createdTag) { - return tagUpdate.attachTagToPost(PostModel, post.id, createdTag, index, options)(); - }); - }; - }, - - updateTagOrderForPost: function updateTagOrderForPost(post, tag, index, options) { - return function () { - return post.tags().updatePivot( - {sort_order: index}, _.extend({}, options, {query: {where: {tag_id: tag.id}}}) - ); - }; - }, - - // Test if two tags are the same, checking ID first, and falling back to name - tagsAreEqual: function tagsAreEqual(tag1, tag2) { - if (tag1.hasOwnProperty('id') && tag2.hasOwnProperty('id')) { - return tag1.id === tag2.id; - } - return tag1.name.toString() === tag2.name.toString(); - }, - - tagSetsAreEqual: function tagSetsAreEqual(tags1, tags2) { - // If the lengths are different, they cannot be the same - if (tags1.length !== tags2.length) { - return false; - } - // Return if no item is not the same (double negative is horrible) - return !_.some(tags1, function (tag1, index) { - return !tagUpdate.tagsAreEqual(tag1, tags2[index]); - }); - } -}; - module.exports.attach = attach; -module.exports.tagUpdate = tagUpdate; diff --git a/core/server/models/post.js b/core/server/models/post.js index b21af37030c5..659788a9095b 100644 --- a/core/server/models/post.js +++ b/core/server/models/post.js @@ -1,18 +1,19 @@ +'use strict'; + // # Post Model -var _ = require('lodash'), - uuid = require('uuid'), - moment = require('moment'), - Promise = require('bluebird'), - ObjectId = require('bson-objectid'), - sequence = require('../utils/sequence'), - errors = require('../errors'), - htmlToText = require('html-to-text'), - ghostBookshelf = require('./base'), - events = require('../events'), - config = require('../config'), - utils = require('../utils'), - baseUtils = require('./base/utils'), - i18n = require('../i18n'), +var _ = require('lodash'), + uuid = require('uuid'), + moment = require('moment'), + Promise = require('bluebird'), + ObjectId = require('bson-objectid'), + sequence = require('../utils/sequence'), + errors = require('../errors'), + htmlToText = require('html-to-text'), + ghostBookshelf = require('./base'), + events = require('../events'), + config = require('../config'), + utils = require('../utils'), + i18n = require('../i18n'), Post, Posts; @@ -20,6 +21,23 @@ Post = ghostBookshelf.Model.extend({ tableName: 'posts', + relationships: ['tags'], + + /** + * The base model keeps only the columns, which are defined in the schema. + * We have to add the relations on top, otherwise bookshelf-relations + * has no access to the nested relations, which should be updated. + */ + permittedAttributes: function permittedAttributes() { + let filteredKeys = ghostBookshelf.Model.prototype.permittedAttributes.apply(this, arguments); + + this.relationships.forEach((key) => { + filteredKeys.push(key); + }); + + return filteredKeys; + }, + emitChange: function emitChange(event, options) { options = options || {}; @@ -52,8 +70,6 @@ Post = ghostBookshelf.Model.extend({ if (['published', 'scheduled'].indexOf(status) !== -1) { model.emitChange(status, {importing: options.importing}); } - - return this.updateTags(model, response, options); }, onUpdated: function onUpdated(model) { @@ -122,24 +138,12 @@ Post = ghostBookshelf.Model.extend({ } }, - onDestroying: function onDestroying(model, options) { - return model.load('tags', options) - .then(function (response) { - if (!response.related || !response.related('tags') || !response.related('tags').length) { - return; - } - - return Promise.mapSeries(response.related('tags').models, function (tag) { - return baseUtils.tagUpdate.detachTagFromPost(model, tag, options)(); - }); - }) - .then(function () { - if (model.previous('status') === 'published') { - model.emitChange('unpublished'); - } + onDestroying: function onDestroying(model) { + if (model.previous('status') === 'published') { + model.emitChange('unpublished'); + } - model.emitChange('deleted'); - }); + model.emitChange('deleted'); }, onSaving: function onSaving(model, attr, options) { @@ -149,16 +153,16 @@ Post = ghostBookshelf.Model.extend({ title, i, // Variables to make the slug checking more readable - newTitle = this.get('title'), - newStatus = this.get('status'), + newTitle = this.get('title'), + newStatus = this.get('status'), olderStatus = this.previous('status'), - prevTitle = this._previousAttributes.title, - prevSlug = this._previousAttributes.slug, - tagsToCheck = this.get('tags'), + prevTitle = this._previousAttributes.title, + prevSlug = this._previousAttributes.slug, publishedAt = this.get('published_at'), publishedAtHasChanged = this.hasDateChanged('published_at', {beforeWrite: true}), - mobiledoc = this.get('mobiledoc'), - tags = [], ops = []; + mobiledoc = this.get('mobiledoc'), + tagsToSave, + ops = []; // CASE: disallow published -> scheduled // @TODO: remove when we have versioning based on updated_at @@ -192,22 +196,22 @@ Post = ghostBookshelf.Model.extend({ } } - // If we have a tags property passed in - if (!_.isUndefined(tagsToCheck) && !_.isNull(tagsToCheck)) { + // CASE: detect lowercase/uppercase tag slugs + if (!_.isUndefined(this.get('tags')) && !_.isNull(this.get('tags'))) { + tagsToSave = []; + // and deduplicate upper/lowercase tags - _.each(tagsToCheck, function each(item) { - for (i = 0; i < tags.length; i = i + 1) { - if (tags[i].name.toLocaleLowerCase() === item.name.toLocaleLowerCase()) { + _.each(this.get('tags'), function each(item) { + for (i = 0; i < tagsToSave.length; i = i + 1) { + if (tagsToSave[i].name.toLocaleLowerCase() === item.name.toLocaleLowerCase()) { return; } } - tags.push(item); + tagsToSave.push(item); }); - // keep tags for 'saved' event - // get('tags') will be removed after saving, because it's not a direct attribute of posts (it's a relation) - this.tagsToSave = tags; + this.set('tags', tagsToSave); } ghostBookshelf.Model.prototype.onSaving.call(this, model, attr, options); @@ -252,19 +256,6 @@ Post = ghostBookshelf.Model.extend({ } } - /** - * - `updateTags` happens before the post is saved to the database - * - when editing a post, it's running in a transaction, see `Post.edit` - * - we are using a update collision detection, we have to know if tags were updated in the client - * - * NOTE: For adding a post, updateTags happens after the post insert, see `onCreated` event - */ - if (options.method === 'update' || options.method === 'patch') { - ops.push(function updateTags() { - return self.updateTags(model, attr, options); - }); - } - // If a title is set, not the same as the old title, a draft post, and has never been published if (prevTitle !== undefined && newTitle !== prevTitle && newStatus === 'draft' && !publishedAt) { ops.push(function updateSlug() { @@ -314,116 +305,6 @@ Post = ghostBookshelf.Model.extend({ ghostBookshelf.Model.prototype.onCreating.call(this, model, attr, options); }, - /** - * ### updateTags - * Update tags that are attached to a post. Create any tags that don't already exist. - * @param {Object} savedModel - * @param {Object} response - * @param {Object} options - * @return {Promise(ghostBookshelf.Models.Post)} Updated Post model - */ - updateTags: function updateTags(savedModel, response, options) { - if (_.isUndefined(this.tagsToSave)) { - // The tag property was not set, so we shouldn't be doing any playing with tags on this request - return Promise.resolve(); - } - - var newTags = this.tagsToSave, - TagModel = ghostBookshelf.model('Tag'); - - options = options || {}; - - function doTagUpdates(options) { - return Promise.props({ - currentPost: baseUtils.tagUpdate.fetchCurrentPost(Post, savedModel.id, options), - existingTags: baseUtils.tagUpdate.fetchMatchingTags(TagModel, newTags, options) - }).then(function fetchedData(results) { - var currentTags = results.currentPost.related('tags').toJSON(options), - existingTags = results.existingTags ? results.existingTags.toJSON(options) : [], - tagOps = [], - tagsToRemove, - tagsToCreate; - - // CASE: if nothing has changed, unset `tags`. - if (baseUtils.tagUpdate.tagSetsAreEqual(newTags, currentTags)) { - savedModel.unset('tags'); - return; - } - - // Tags from the current tag array which don't exist in the new tag array should be removed - tagsToRemove = _.reject(currentTags, function (currentTag) { - if (newTags.length === 0) { - return false; - } - return _.some(newTags, function (newTag) { - return baseUtils.tagUpdate.tagsAreEqual(currentTag, newTag); - }); - }); - - // Tags from the new tag array which don't exist in the DB should be created - tagsToCreate = _.map(_.reject(newTags, function (newTag) { - return _.some(existingTags, function (existingTag) { - return baseUtils.tagUpdate.tagsAreEqual(existingTag, newTag); - }); - }), 'name'); - - // Remove any tags which don't exist anymore - _.each(tagsToRemove, function (tag) { - tagOps.push(baseUtils.tagUpdate.detachTagFromPost(savedModel, tag, options)); - }); - - // Loop through the new tags and either add them, attach them, or update them - _.each(newTags, function (newTag, index) { - var tag; - - if (tagsToCreate.indexOf(newTag.name) > -1) { - tagOps.push(baseUtils.tagUpdate.createTagThenAttachTagToPost(Post, TagModel, savedModel, newTag, index, options)); - } else { - // try to find a tag on the current post which matches - tag = _.find(currentTags, function (currentTag) { - return baseUtils.tagUpdate.tagsAreEqual(currentTag, newTag); - }); - - if (tag) { - tagOps.push(baseUtils.tagUpdate.updateTagOrderForPost(savedModel, tag, index, options)); - return; - } - - // else finally, find the existing tag which matches - tag = _.find(existingTags, function (existingTag) { - return baseUtils.tagUpdate.tagsAreEqual(existingTag, newTag); - }); - - if (tag) { - tagOps.push(baseUtils.tagUpdate.attachTagToPost(Post, savedModel.id, tag, index, options)); - } - } - }); - - return sequence(tagOps); - }); - } - - // Handle updating tags in a transaction, unless we're already in one - if (options.transacting) { - return doTagUpdates(options); - } else { - return ghostBookshelf.transaction(function (t) { - options.transacting = t; - - return doTagUpdates(options); - }).then(function () { - // Don't do anything, the transaction processed ok - }).catch(function failure(err) { - return Promise.reject(new errors.GhostError({ - err: err, - context: i18n.t('errors.models.post.tagUpdates.error'), - help: i18n.t('errors.models.post.tagUpdates.help') - })); - }); - } - }, - // Relations author: function author() { return this.belongsTo('User', 'author_id'); @@ -626,7 +507,7 @@ Post = ghostBookshelf.Model.extend({ */ filterData: function filterData(data) { var filteredData = ghostBookshelf.Model.filterData.apply(this, arguments), - extraData = _.pick(data, ['tags']); + extraData = _.pick(data, this.prototype.relationships); _.merge(filteredData, extraData); return filteredData; @@ -663,13 +544,15 @@ Post = ghostBookshelf.Model.extend({ * **See:** [ghostBookshelf.Model.edit](base.js.html#edit) */ edit: function edit(data, options) { - var self = this, - editPost = function editPost(data, options) { - options.forUpdate = true; + let opts = _.cloneDeep(options || {}); - return ghostBookshelf.Model.edit.call(self, data, options).then(function then(post) { - return self.findOne({status: 'all', id: options.id}, options) - .then(function then(found) { + const editPost = () => { + opts.forUpdate = true; + + return ghostBookshelf.Model.edit.call(this, data, opts) + .then((post) => { + return this.findOne({status: 'all', id: opts.id}, opts) + .then((found) => { if (found) { // Pass along the updated attributes for checking status changes found._updatedAttributes = post._updatedAttributes; @@ -677,18 +560,16 @@ Post = ghostBookshelf.Model.extend({ } }); }); - }; - - options = options || {}; + }; - if (options.transacting) { - return editPost(data, options); + if (!opts.transacting) { + return ghostBookshelf.transaction((transacting) => { + opts.transacting = transacting; + return editPost(); + }); } - return ghostBookshelf.transaction(function (transacting) { - options.transacting = transacting; - return editPost(data, options); - }); + return editPost(); }, /** @@ -697,36 +578,80 @@ Post = ghostBookshelf.Model.extend({ * **See:** [ghostBookshelf.Model.add](base.js.html#add) */ add: function add(data, options) { - var self = this; - options = options || {}; + let opts = _.cloneDeep(options || {}); - return ghostBookshelf.Model.add.call(this, data, options).then(function then(post) { - return self.findOne({status: 'all', id: post.id}, options); + const addPost = (() => { + return ghostBookshelf.Model.add.call(this, data, opts) + .then((post) => { + return this.findOne({status: 'all', id: post.id}, opts); + }); }); + + if (!opts.transacting) { + return ghostBookshelf.transaction((transacting) => { + opts.transacting = transacting; + + return addPost(); + }); + } + + return addPost(); + }, + + destroy: function destroy(options) { + let opts = _.cloneDeep(options || {}); + + const destroyPost = () => { + return ghostBookshelf.Model.destroy.call(this, opts); + }; + + if (!opts.transacting) { + return ghostBookshelf.transaction((transacting) => { + opts.transacting = transacting; + return destroyPost(); + }); + } + + return destroyPost(); }, /** * ### destroyByAuthor * @param {[type]} options has context and id. Context is the user doing the destroy, id is the user to destroy */ - destroyByAuthor: Promise.method(function destroyByAuthor(options) { - var postCollection = Posts.forge(), - authorId = options.id; + destroyByAuthor: function destroyByAuthor(options) { + let opts = _.cloneDeep(options || {}); - options = this.filterOptions(options, 'destroyByAuthor'); + let postCollection = Posts.forge(), + authorId = opts.id; + + opts = this.filterOptions(opts, 'destroyByAuthor'); if (!authorId) { - throw new errors.NotFoundError({message: i18n.t('errors.models.post.noUserFound')}); + throw new errors.NotFoundError({ + message: i18n.t('errors.models.post.noUserFound') + }); } - return postCollection - .query('where', 'author_id', '=', authorId) - .fetch(options) - .call('invokeThen', 'destroy', options) - .catch(function (err) { - return Promise.reject(new errors.GhostError({err: err})); + const destroyPost = (() => { + return postCollection + .query('where', 'author_id', '=', authorId) + .fetch(opts) + .call('invokeThen', 'destroy', opts) + .catch((err) => { + throw new errors.GhostError({err: err}); + }); + }); + + if (!opts.transacting) { + return ghostBookshelf.transaction((transacting) => { + opts.transacting = transacting; + return destroyPost(); }); - }), + } + + return destroyPost(); + }, permissible: function permissible(postModelOrId, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasAppPermission) { var self = this, diff --git a/core/test/integration/api/api_posts_spec.js b/core/test/integration/api/api_posts_spec.js index 89bfd326d169..e110398a6928 100644 --- a/core/test/integration/api/api_posts_spec.js +++ b/core/test/integration/api/api_posts_spec.js @@ -701,172 +701,5 @@ describe('Post API', function () { done(); }); }); - - // These tests are for #6920 - it('should update post & not delete tags with `tags` not included', function (done) { - var options = { - context: {user: testUtils.DataGenerator.Content.users[1].id}, - id: testUtils.DataGenerator.Content.posts[0].id - }, - includeOptions = {include: 'tags'}, - startTags; - - // Step 1, fetch a post from the API with tags - PostAPI.read(_.extend({}, options, includeOptions)).then(function (results) { - var postWithoutTags = results.posts[0]; - should.exist(results.posts[0]); - should.exist(results.posts[0].tags); - results.posts[0].tags.should.have.lengthOf(2); - - // Save the tags for testing against later - startTags = _.clone(results.posts[0].tags); - - // Remove the tags from the object we're sending - we'll send no `tags` property at all - delete postWithoutTags.tags; - - // Update a single property so we can see the post does get updated - postWithoutTags.title = 'HTML Ipsum Updated'; - - // Step 2, call edit but don't include tags in the response - return PostAPI.edit({posts: [postWithoutTags]}, options); - }).then(function (results) { - should.exist(results.posts[0]); - should.not.exist(results.posts[0].tags); - results.posts[0].title.should.eql('HTML Ipsum Updated'); - - // Step 3, request the post with its tags again, to check they are still present - return PostAPI.read(_.extend({}, options, includeOptions)); - }).then(function (results) { - should.exist(results.posts[0]); - should.exist(results.posts[0].tags); - results.posts[0].tags.should.have.lengthOf(2); - results.posts[0].tags.should.eql(startTags); - - done(); - }).catch(done); - }); - - it('should update post & not delete tags with `tags` set to undefined', function (done) { - var options = { - context: {user: testUtils.DataGenerator.Content.users[1].id}, - id: testUtils.DataGenerator.Content.posts[0].id - }, - includeOptions = {include: 'tags'}, - startTags; - - // Step 1, fetch a post from the API with tags - PostAPI.read(_.extend({}, options, includeOptions)).then(function (results) { - var postWithoutTags = results.posts[0]; - should.exist(results.posts[0]); - should.exist(results.posts[0].tags); - results.posts[0].tags.should.have.lengthOf(2); - - // Save the tags for testing against later - startTags = _.clone(results.posts[0].tags); - - // Remove the tags from the object we're sending - we'll send no `tags` property at all - postWithoutTags.tags = undefined; - - // Update a single property so we can see the post does get updated - postWithoutTags.title = 'HTML Ipsum Updated'; - - // Step 2, call edit but don't include tags in the response - return PostAPI.edit({posts: [postWithoutTags]}, options); - }).then(function (results) { - should.exist(results.posts[0]); - should.not.exist(results.posts[0].tags); - results.posts[0].title.should.eql('HTML Ipsum Updated'); - - // Step 3, request the post with its tags again, to check they are still present - return PostAPI.read(_.extend({}, options, includeOptions)); - }).then(function (results) { - should.exist(results.posts[0]); - should.exist(results.posts[0].tags); - results.posts[0].tags.should.have.lengthOf(2); - results.posts[0].tags.should.eql(startTags); - - done(); - }).catch(done); - }); - - it('should update post & not delete tags with `tags` set to null', function (done) { - var options = { - context: {user: testUtils.DataGenerator.Content.users[1].id}, - id: testUtils.DataGenerator.Content.posts[0].id - }, - includeOptions = {include: 'tags'}, - startTags; - - // Step 1, fetch a post from the API with tags - PostAPI.read(_.extend({}, options, includeOptions)).then(function (results) { - var postWithoutTags = results.posts[0]; - should.exist(results.posts[0]); - should.exist(results.posts[0].tags); - results.posts[0].tags.should.have.lengthOf(2); - - // Save the tags for testing against later - startTags = _.clone(results.posts[0].tags); - - // Remove the tags from the object we're sending - we'll send no `tags` property at all - postWithoutTags.tags = null; - - // Update a single property so we can see the post does get updated - postWithoutTags.title = 'HTML Ipsum Updated'; - - // Step 2, call edit but don't include tags in the response - return PostAPI.edit({posts: [postWithoutTags]}, options); - }).then(function (results) { - should.exist(results.posts[0]); - should.not.exist(results.posts[0].tags); - results.posts[0].title.should.eql('HTML Ipsum Updated'); - - // Step 3, request the post with its tags again, to check they are still present - return PostAPI.read(_.extend({}, options, includeOptions)); - }).then(function (results) { - should.exist(results.posts[0]); - should.exist(results.posts[0].tags); - results.posts[0].tags.should.have.lengthOf(2); - results.posts[0].tags.should.eql(startTags); - - done(); - }).catch(done); - }); - - it('should update post & should delete tags with `tags` set to []', function (done) { - var options = { - context: {user: testUtils.DataGenerator.Content.users[1].id}, - id: testUtils.DataGenerator.Content.posts[0].id - }, - includeOptions = {include: 'tags'}; - - // Step 1, fetch a post from the API with tags - PostAPI.read(_.extend({}, options, includeOptions)).then(function (results) { - var postWithoutTags = results.posts[0]; - should.exist(results.posts[0]); - should.exist(results.posts[0].tags); - results.posts[0].tags.should.have.lengthOf(2); - - // Remove the tags from the object we're sending - we'll send no `tags` property at all - postWithoutTags.tags = []; - - // Update a single property so we can see the post does get updated - postWithoutTags.title = 'HTML Ipsum Updated'; - - // Step 2, call edit but don't include tags in the response - return PostAPI.edit({posts: [postWithoutTags]}, options); - }).then(function (results) { - should.exist(results.posts[0]); - should.not.exist(results.posts[0].tags); - results.posts[0].title.should.eql('HTML Ipsum Updated'); - - // Step 3, request the post with its tags again, to check they are still present - return PostAPI.read(_.extend({}, options, includeOptions)); - }).then(function (results) { - should.exist(results.posts[0]); - results.posts[0].tags.should.eql([]); - - done(); - }).catch(done); - }); }); }); diff --git a/core/test/integration/model/model_posts_spec.js b/core/test/integration/model/model_posts_spec.js index 9d6a70386b73..437db9a670cd 100644 --- a/core/test/integration/model/model_posts_spec.js +++ b/core/test/integration/model/model_posts_spec.js @@ -1691,838 +1691,187 @@ describe('Post Model', function () { }); }); - describe('Post tag handling', function () { + describe('Post tag handling edge cases', function () { beforeEach(testUtils.setup()); - describe('Post with tags', function () { - var postJSON, - tagJSON, - editOptions, - createTag = testUtils.DataGenerator.forKnex.createTag; - - beforeEach(function () { - tagJSON = []; - - var post = _.cloneDeep(testUtils.DataGenerator.forModel.posts[0]), - postTags = [ - createTag({name: 'tag1'}), - createTag({name: 'tag2'}), - createTag({name: 'tag3'}) - ], - extraTags = [ - createTag({name: 'existing tag a'}), - createTag({name: 'existing-tag-b'}), - createTag({name: 'existing_tag_c'}) - ]; - - post.tags = postTags; - post.status = 'published'; - - return Promise.props({ - post: PostModel.add(post, _.extend({}, context, {withRelated: ['tags']})), - tag1: TagModel.add(extraTags[0], context), - tag2: TagModel.add(extraTags[1], context), - tag3: TagModel.add(extraTags[2], context) - }).then(function (result) { - postJSON = result.post.toJSON({include: ['tags']}); - tagJSON.push(result.tag1.toJSON()); - tagJSON.push(result.tag2.toJSON()); - tagJSON.push(result.tag3.toJSON()); - editOptions = _.extend({}, context, {id: postJSON.id, withRelated: ['tags']}); - - // reset the eventSpy here - sandbox.restore(); - }); - }); - - it('should create the test data correctly', function (done) { - // creates a test tag - should.exist(tagJSON); - tagJSON.should.be.an.Array().with.lengthOf(3); - tagJSON.should.have.enumerable(0).with.property('name', 'existing tag a'); - tagJSON.should.have.enumerable(1).with.property('name', 'existing-tag-b'); - tagJSON.should.have.enumerable(2).with.property('name', 'existing_tag_c'); - - // creates a test post with an array of tags in the correct order - should.exist(postJSON); - postJSON.title.should.eql('HTML Ipsum'); - should.exist(postJSON.tags); - postJSON.tags.should.be.an.Array().and.have.lengthOf(3); - postJSON.tags.should.have.enumerable(0).with.property('name', 'tag1'); - postJSON.tags.should.have.enumerable(1).with.property('name', 'tag2'); - postJSON.tags.should.have.enumerable(2).with.property('name', 'tag3'); - - done(); - }); - - describe('Adding brand new tags', function () { - it('can add a single tag to the end of the tags array', function () { - var newJSON = _.cloneDeep(postJSON); - - // Add a single tag to the end of the array - newJSON.tags.push(createTag({name: 'tag4'})); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(4); - updatedPost.tags.should.have.enumerable(0).with.properties({ - name: 'tag1', - id: postJSON.tags[0].id - }); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'tag2', - id: postJSON.tags[1].id - }); - updatedPost.tags.should.have.enumerable(2).with.properties({ - name: 'tag3', - id: postJSON.tags[2].id - }); - updatedPost.tags.should.have.enumerable(3).with.property('name', 'tag4'); - }); - }); - - it('can add a single tag to the beginning of the tags array', function () { - var newJSON = _.cloneDeep(postJSON); - - // Add a single tag to the beginning of the array - newJSON.tags = [createTag({name: 'tag4'})].concat(postJSON.tags); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(4); - updatedPost.tags.should.have.enumerable(0).with.property('name', 'tag4'); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'tag1', - id: postJSON.tags[0].id - }); - updatedPost.tags.should.have.enumerable(2).with.properties({ - name: 'tag2', - id: postJSON.tags[1].id - }); - updatedPost.tags.should.have.enumerable(3).with.properties({ - name: 'tag3', - id: postJSON.tags[2].id - }); - }); - }); + var postJSON, + tagJSON, + editOptions, + createTag = testUtils.DataGenerator.forKnex.createTag; + + beforeEach(function () { + tagJSON = []; + + var post = _.cloneDeep(testUtils.DataGenerator.forModel.posts[0]), + postTags = [ + createTag({name: 'tag1'}), + createTag({name: 'tag2'}), + createTag({name: 'tag3'}) + ], + extraTags = [ + createTag({name: 'existing tag a'}), + createTag({name: 'existing-tag-b'}), + createTag({name: 'existing_tag_c'}) + ]; + + post.tags = postTags; + post.status = 'published'; + + return Promise.props({ + post: PostModel.add(post, _.extend({}, context, {withRelated: ['tags']})), + tag1: TagModel.add(extraTags[0], context), + tag2: TagModel.add(extraTags[1], context), + tag3: TagModel.add(extraTags[2], context) + }).then(function (result) { + postJSON = result.post.toJSON({include: ['tags']}); + tagJSON.push(result.tag1.toJSON()); + tagJSON.push(result.tag2.toJSON()); + tagJSON.push(result.tag3.toJSON()); + editOptions = _.extend({}, context, {id: postJSON.id, withRelated: ['tags']}); + + // reset the eventSpy here + sandbox.restore(); }); + }); - describe('Adding pre-existing tags', function () { - it('can add a single tag to the end of the tags array', function () { - var newJSON = _.cloneDeep(postJSON); - - // Add a single pre-existing tag to the end of the array - newJSON.tags.push(tagJSON[0]); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(4); - updatedPost.tags.should.have.enumerable(0).with.properties({ - name: 'tag1', - id: postJSON.tags[0].id - }); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'tag2', - id: postJSON.tags[1].id - }); - updatedPost.tags.should.have.enumerable(2).with.properties({ - name: 'tag3', - id: postJSON.tags[2].id - }); - updatedPost.tags.should.have.enumerable(3).with.properties({ - name: 'existing tag a', - id: tagJSON[0].id - }); - }); - }); - - it('can add a single tag to the beginning of the tags array', function () { - var newJSON = _.cloneDeep(postJSON); - - // Add an existing tag to the beginning of the array - newJSON.tags = [tagJSON[0]].concat(postJSON.tags); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(4); - updatedPost.tags.should.have.enumerable(0).with.properties({ - name: 'existing tag a', - id: tagJSON[0].id - }); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'tag1', - id: postJSON.tags[0].id - }); - updatedPost.tags.should.have.enumerable(2).with.properties({ - name: 'tag2', - id: postJSON.tags[1].id - }); - updatedPost.tags.should.have.enumerable(3).with.properties({ - name: 'tag3', - id: postJSON.tags[2].id - }); - }); - }); - - it('can add a single tag to the middle of the tags array', function () { - var newJSON = _.cloneDeep(postJSON); - - // Add a single pre-existing tag to the middle of the array - newJSON.tags = postJSON.tags.slice(0, 1).concat([tagJSON[0]]).concat(postJSON.tags.slice(1)); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(4); - updatedPost.tags.should.have.enumerable(0).with.properties({ - name: 'tag1', - id: postJSON.tags[0].id - }); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'existing tag a', - id: tagJSON[0].id - }); - updatedPost.tags.should.have.enumerable(2).with.properties({ - name: 'tag2', - id: postJSON.tags[1].id - }); - updatedPost.tags.should.have.enumerable(3).with.properties({ - name: 'tag3', - id: postJSON.tags[2].id - }); - }); - }); - }); - - describe('Removing tags', function () { - it('can remove a single tag from the end of the tags array', function () { - var newJSON = _.cloneDeep(postJSON); - - // Remove a single tag from the end of the array - newJSON.tags = postJSON.tags.slice(0, -1); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(2); - updatedPost.tags.should.have.enumerable(0).with.properties({ - name: 'tag1', - id: postJSON.tags[0].id - }); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'tag2', - id: postJSON.tags[1].id - }); - }); - }); - - it('can remove a single tag from the beginning of the tags array', function () { - var newJSON = _.cloneDeep(postJSON); - - // Remove a single tag from the beginning of the array - newJSON.tags = postJSON.tags.slice(1); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(2); - updatedPost.tags.should.have.enumerable(0).with.properties({ - name: 'tag2', - id: postJSON.tags[1].id - }); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'tag3', - id: postJSON.tags[2].id - }); - }); - }); + it('should create the test data correctly', function (done) { + // creates a test tag + should.exist(tagJSON); + tagJSON.should.be.an.Array().with.lengthOf(3); + tagJSON.should.have.enumerable(0).with.property('name', 'existing tag a'); + tagJSON.should.have.enumerable(1).with.property('name', 'existing-tag-b'); + tagJSON.should.have.enumerable(2).with.property('name', 'existing_tag_c'); + + // creates a test post with an array of tags in the correct order + should.exist(postJSON); + postJSON.title.should.eql('HTML Ipsum'); + should.exist(postJSON.tags); + postJSON.tags.should.be.an.Array().and.have.lengthOf(3); + postJSON.tags.should.have.enumerable(0).with.property('name', 'tag1'); + postJSON.tags.should.have.enumerable(1).with.property('name', 'tag2'); + postJSON.tags.should.have.enumerable(2).with.property('name', 'tag3'); + + done(); + }); - it('can remove all tags', function () { - var newJSON = _.cloneDeep(postJSON); + it('can edit slug of existing tag', function () { + var newJSON = _.cloneDeep(postJSON); - // Remove all the tags - newJSON.tags = []; + // Add an existing tag to the beginning of the array + newJSON.tags = [{id: postJSON.tags[0].id, slug: 'eins'}]; - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); + // Edit the post + return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { + updatedPost = updatedPost.toJSON({include: ['tags']}); - updatedPost.tags.should.have.lengthOf(0); - }); + updatedPost.tags.should.have.lengthOf(1); + updatedPost.tags.should.have.enumerable(0).with.properties({ + name: postJSON.tags[0].name, + slug: 'eins', + id: postJSON.tags[0].id }); }); + }); - describe('Reordering tags', function () { - it('can reorder the first tag to be the last', function () { - var newJSON = _.cloneDeep(postJSON), - firstTag = [postJSON.tags[0]]; - - // Reorder the tags, so that the first tag is moved to the end - newJSON.tags = postJSON.tags.slice(1).concat(firstTag); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(3); - updatedPost.tags.should.have.enumerable(0).with.properties({ - name: 'tag2', - id: postJSON.tags[1].id - }); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'tag3', - id: postJSON.tags[2].id - }); - updatedPost.tags.should.have.enumerable(2).with.properties({ - name: 'tag1', - id: postJSON.tags[0].id - }); - }); - }); - - it('can reorder the last tag to be the first', function () { - var newJSON = _.cloneDeep(postJSON), - lastTag = [postJSON.tags[2]]; - - // Reorder the tags, so that the last tag is moved to the beginning - newJSON.tags = lastTag.concat(postJSON.tags.slice(0, -1)); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(3); - updatedPost.tags.should.have.enumerable(0).with.properties({ - name: 'tag3', - id: postJSON.tags[2].id - }); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'tag1', - id: postJSON.tags[0].id - }); - updatedPost.tags.should.have.enumerable(2).with.properties({ - name: 'tag2', - id: postJSON.tags[1].id - }); + it('can\'t edit dates and authors of existing tag', function () { + var newJSON = _.cloneDeep(postJSON), updatedAtFormat; + + // Add an existing tag to the beginning of the array + newJSON.tags = [{ + id: postJSON.tags[0].id, + slug: 'eins', + created_at: moment().add(2, 'days').format('YYYY-MM-DD HH:mm:ss'), + updated_at: moment().add(2, 'days').format('YYYY-MM-DD HH:mm:ss'), + created_by: 2, + updated_by: 2 + }]; + + // Edit the post + return Promise.delay(1000) + .then(function () { + return PostModel.edit(newJSON, editOptions); + }) + .then(function (updatedPost) { + updatedPost = updatedPost.toJSON({include: ['tags']}); + + updatedPost.tags.should.have.lengthOf(1); + updatedPost.tags.should.have.enumerable(0).with.properties({ + name: postJSON.tags[0].name, + slug: 'eins', + id: postJSON.tags[0].id, + created_at: postJSON.tags[0].created_at, + created_by: postJSON.created_by, + updated_by: postJSON.updated_by }); - }); - }); - describe('Edit post', function () { - // These tests are for #6920 - it('can edit a post SAFELY when tags are not included', function () { - var postId = postJSON.id, - toJSONOpts = {include: ['tags']}, - startTags; - - // Step 1, fetch a post with its tags, just to see what tags we have - return PostModel.findOne({id: postId}, {withRelated: ['tags']}).then(function (results) { - var post = results.toJSON(toJSONOpts); - should.exist(results); - post.title.should.not.equal('new title'); - post.tags.should.have.lengthOf(3); - - // Save a copy of these tags to test later - startTags = _.cloneDeep(post.tags); - - // Step 2, edit a single property of the post... we aren't doing anything with tags here... - return PostModel.edit({title: 'new title'}, _.extend({}, context, {id: postId})); - }).then(function (edited) { - should.exist(edited); - var post = edited.toJSON(toJSONOpts); - - post.title.should.equal('new title'); - // edit didn't include tags, so they should be blank - should.not.exist(post.tags); - - // Step 3, request the same post again, including tags... they should still be present - return PostModel.findOne({id: postId}, {withRelated: ['tags']}).then(function (results) { - var post = results.toJSON(toJSONOpts); - post.tags.should.have.lengthOf(3); - post.tags.should.eql(startTags); - }); - }); - }); - - it('can edit a post SAFELY when tags is undefined', function () { - var postId = postJSON.id, - toJSONOpts = {include: ['tags']}, - startTags; - - // Step 1, fetch a post with its tags, just to see what tags we have - return PostModel.findOne({id: postId}, {withRelated: ['tags']}).then(function (results) { - var post = results.toJSON(toJSONOpts); - should.exist(results); - post.title.should.not.equal('new title'); - post.tags.should.have.lengthOf(3); - - // Save a copy of these tags to test later - startTags = _.cloneDeep(post.tags); - - // Step 2, edit a single property of the post... we aren't doing anything with tags here... - return PostModel.edit({ - title: 'new title', - tags: undefined - }, _.extend({}, context, {id: postId})); - }).then(function (edited) { - should.exist(edited); - var post = edited.toJSON(toJSONOpts); - - post.title.should.equal('new title'); - // edit didn't include tags, so they should be blank - should.not.exist(post.tags); - - // Step 3, request the same post again, including tags... they should still be present - return PostModel.findOne({id: postId}, {withRelated: ['tags']}).then(function (results) { - var post = results.toJSON(toJSONOpts); - post.tags.should.have.lengthOf(3); - post.tags.should.eql(startTags); - }); - }); + updatedAtFormat = moment(updatedPost.tags[0].updated_at).format('YYYY-MM-DD HH:mm:ss'); + updatedAtFormat.should.not.eql(moment(postJSON.updated_at).format('YYYY-MM-DD HH:mm:ss')); + updatedAtFormat.should.not.eql(moment(newJSON.tags[0].updated_at).format('YYYY-MM-DD HH:mm:ss')); }); + }); - it('can edit a post SAFELY when tags is null', function () { - var postId = postJSON.id, - toJSONOpts = {include: ['tags']}, - startTags; + it('can reorder existing, added and deleted tags', function () { + var newJSON = _.cloneDeep(postJSON), + lastTag = [postJSON.tags[2]]; - // Step 1, fetch a post with its tags, just to see what tags we have - return PostModel.findOne({id: postId}, {withRelated: ['tags']}).then(function (results) { - var post = results.toJSON(toJSONOpts); - should.exist(results); - post.title.should.not.equal('new title'); - post.tags.should.have.lengthOf(3); - - // Save a copy of these tags to test later - startTags = _.cloneDeep(post.tags); - - // Step 2, edit a single property of the post... we aren't doing anything with tags here... - return PostModel.edit({title: 'new title', tags: null}, _.extend({}, context, {id: postId})); - }).then(function (edited) { - should.exist(edited); - var post = edited.toJSON(toJSONOpts); - - post.title.should.equal('new title'); - // edit didn't include tags, so they should be blank - should.not.exist(post.tags); - - // Step 3, request the same post again, including tags... they should still be present - return PostModel.findOne({id: postId}, {withRelated: ['tags']}).then(function (results) { - var post = results.toJSON(toJSONOpts); - post.tags.should.have.lengthOf(3); - post.tags.should.eql(startTags); - }); - }); - }); + // remove tag in the middle (tag1, tag2, tag3 -> tag1, tag3) + newJSON.tags.splice(1, 1); - it('can remove all tags when sent an empty array', function () { - var postId = postJSON.id, - toJSONOpts = {include: ['tags']}; + // add a new one as first tag and reorder existing (tag4, tag3, tag1) + newJSON.tags = [{name: 'tag4'}].concat([newJSON.tags[1]]).concat([newJSON.tags[0]]); - // Step 1, fetch a post with its tags, just to see what tags we have - return PostModel.findOne({id: postId}, {withRelated: ['tags']}).then(function (results) { - var post = results.toJSON(toJSONOpts); - should.exist(results); - post.title.should.not.equal('new title'); - post.tags.should.have.lengthOf(3); - - // Step 2, edit a single property of the post... we aren't doing anything with tags here... - return PostModel.edit({title: 'new title', tags: []}, _.extend({}, context, {id: postId})); - }).then(function (edited) { - should.exist(edited); - var post = edited.toJSON(toJSONOpts); - - post.title.should.equal('new title'); - // edit didn't include tags, so they should be blank - should.not.exist(post.tags); - - // Step 3, request the same post again, including tags... they should be gone - return PostModel.findOne({id: postId}, {withRelated: ['tags']}).then(function (results) { - var post = results.toJSON(toJSONOpts); - // Tags should be gone - post.tags.should.eql([]); - }); - }); - }); - }); + // Edit the post + return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { + updatedPost = updatedPost.toJSON({include: ['tags']}); - describe('Combination updates', function () { - it('can add a combination of new and pre-existing tags', function () { - var newJSON = _.cloneDeep(postJSON); - - // Push a bunch of new and existing tags to the end of the array - newJSON.tags.push({name: 'tag4'}); - newJSON.tags.push({name: 'existing tag a'}); - newJSON.tags.push({name: 'tag5'}); - newJSON.tags.push({name: 'existing-tag-b'}); - newJSON.tags.push({name: 'bob'}); - newJSON.tags.push({name: 'existing_tag_c'}); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(9); - updatedPost.tags.should.have.enumerable(0).with.properties({ - name: 'tag1', - id: postJSON.tags[0].id - }); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'tag2', - id: postJSON.tags[1].id - }); - updatedPost.tags.should.have.enumerable(2).with.properties({ - name: 'tag3', - id: postJSON.tags[2].id - }); - updatedPost.tags.should.have.enumerable(3).with.property('name', 'tag4'); - updatedPost.tags.should.have.enumerable(4).with.properties({ - name: 'existing tag a', - id: tagJSON[0].id - }); - updatedPost.tags.should.have.enumerable(5).with.property('name', 'tag5'); - updatedPost.tags.should.have.enumerable(6).with.properties({ - name: 'existing-tag-b', - id: tagJSON[1].id - }); - updatedPost.tags.should.have.enumerable(7).with.property('name', 'bob'); - updatedPost.tags.should.have.enumerable(8).with.properties({ - name: 'existing_tag_c', - id: tagJSON[2].id - }); - }); + updatedPost.tags.should.have.lengthOf(3); + updatedPost.tags.should.have.enumerable(0).with.properties({ + name: 'tag4' }); - - it('can reorder the first tag to be the last and add a tag to the beginning', function () { - var newJSON = _.cloneDeep(postJSON), - firstTag = [postJSON.tags[0]]; - - // Add a new tag to the beginning, and move the original first tag to the end - newJSON.tags = [tagJSON[0]].concat(postJSON.tags.slice(1)).concat(firstTag); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(4); - updatedPost.tags.should.have.enumerable(0).with.properties({ - name: 'existing tag a', - id: tagJSON[0].id - }); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'tag2', - id: postJSON.tags[1].id - }); - updatedPost.tags.should.have.enumerable(2).with.properties({ - name: 'tag3', - id: postJSON.tags[2].id - }); - updatedPost.tags.should.have.enumerable(3).with.properties({ - name: 'tag1', - id: postJSON.tags[0].id - }); - }); + updatedPost.tags.should.have.enumerable(1).with.properties({ + name: 'tag3', + id: postJSON.tags[2].id }); - - it('can reorder the first tag to be the last, remove the original last tag & add a tag to the beginning', function () { - var newJSON = _.cloneDeep(postJSON), - firstTag = [newJSON.tags[0]]; - - // And an existing tag to the beginning of the array, move the original first tag to the end and remove the original last tag - newJSON.tags = [tagJSON[0]].concat(newJSON.tags.slice(1, -1)).concat(firstTag); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(3); - updatedPost.tags.should.have.enumerable(0).with.properties({ - name: 'existing tag a', - id: tagJSON[0].id - }); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'tag2', - id: postJSON.tags[1].id - }); - updatedPost.tags.should.have.enumerable(2).with.properties({ - name: 'tag1', - id: postJSON.tags[0].id - }); - }); - }); - - it('can reorder original tags, remove one, and add new and existing tags', function () { - var newJSON = _.cloneDeep(postJSON), - firstTag = [newJSON.tags[0]]; - - // Reorder original 3 so that first is at the end - newJSON.tags = newJSON.tags.slice(1).concat(firstTag); - - // add an existing tag in the middle - newJSON.tags = newJSON.tags.slice(0, 1).concat({name: 'existing-tag-b'}).concat(newJSON.tags.slice(1)); - - // add a brand new tag in the middle - newJSON.tags = newJSON.tags.slice(0, 3).concat({name: 'betty'}).concat(newJSON.tags.slice(3)); - - // Add some more tags to the end - newJSON.tags.push({name: 'bob'}); - newJSON.tags.push({name: 'existing tag a'}); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(7); - - updatedPost.tags.should.have.enumerable(0).with.properties({ - name: 'tag2', - id: postJSON.tags[1].id - }); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'existing-tag-b', - id: tagJSON[1].id - }); - updatedPost.tags.should.have.enumerable(2).with.properties({ - name: 'tag3', - id: postJSON.tags[2].id - }); - updatedPost.tags.should.have.enumerable(3).with.property('name', 'betty'); - updatedPost.tags.should.have.enumerable(4).with.properties({ - name: 'tag1', - id: postJSON.tags[0].id - }); - updatedPost.tags.should.have.enumerable(5).with.property('name', 'bob'); - updatedPost.tags.should.have.enumerable(6).with.properties({ - name: 'existing tag a', - id: tagJSON[0].id - }); - }); + updatedPost.tags.should.have.enumerable(2).with.properties({ + name: 'tag1', + id: postJSON.tags[0].id }); }); }); - describe('Posts with NO tags', function () { - var postJSON, - tagJSON, - editOptions, - createTag = testUtils.DataGenerator.forKnex.createTag; - - beforeEach(function () { - tagJSON = []; - - var post = _.cloneDeep(testUtils.DataGenerator.forModel.posts[0]), - extraTag1 = createTag({name: 'existing tag a'}), - extraTag2 = createTag({name: 'existing-tag-b'}), - extraTag3 = createTag({name: 'existing_tag_c'}); - - return Promise.props({ - post: PostModel.add(post, _.extend({}, context, {withRelated: ['tags']})), - tag1: TagModel.add(extraTag1, context), - tag2: TagModel.add(extraTag2, context), - tag3: TagModel.add(extraTag3, context) - }).then(function (result) { - postJSON = result.post.toJSON({include: ['tags']}); - tagJSON.push(result.tag1.toJSON()); - tagJSON.push(result.tag2.toJSON()); - tagJSON.push(result.tag3.toJSON()); - editOptions = _.extend({}, context, {id: postJSON.id, withRelated: ['tags']}); - }); - }); - - it('should create the test data correctly', function () { - // creates two test tags - should.exist(tagJSON); - tagJSON.should.be.an.Array().with.lengthOf(3); - tagJSON.should.have.enumerable(0).with.property('name', 'existing tag a'); - tagJSON.should.have.enumerable(1).with.property('name', 'existing-tag-b'); - tagJSON.should.have.enumerable(2).with.property('name', 'existing_tag_c'); - - // creates a test post with no tags - should.exist(postJSON); - postJSON.title.should.eql('HTML Ipsum'); - should.exist(postJSON.tags); - }); - - describe('Adding brand new tags', function () { - it('can add a single tag', function () { - var newJSON = _.cloneDeep(postJSON); - - // Add a single tag to the end of the array - newJSON.tags.push(createTag({name: 'tag1'})); + it('can add multiple tags with conflicting slugs', function () { + var newJSON = _.cloneDeep(postJSON); - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); + // Add conflicting tags to the end of the array + newJSON.tags = []; + newJSON.tags.push({name: 'C'}); + newJSON.tags.push({name: 'C++'}); + newJSON.tags.push({name: 'C#'}); - updatedPost.tags.should.have.lengthOf(1); - updatedPost.tags.should.have.enumerable(0).with.property('name', 'tag1'); - }); - }); - - it('can add multiple tags', function () { - var newJSON = _.cloneDeep(postJSON); - - // Add a bunch of tags to the end of the array - newJSON.tags.push(createTag({name: 'tag1'})); - newJSON.tags.push(createTag({name: 'tag2'})); - newJSON.tags.push(createTag({name: 'tag3'})); - newJSON.tags.push(createTag({name: 'tag4'})); - newJSON.tags.push(createTag({name: 'tag5'})); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(5); - updatedPost.tags.should.have.enumerable(0).with.property('name', 'tag1'); - updatedPost.tags.should.have.enumerable(1).with.property('name', 'tag2'); - updatedPost.tags.should.have.enumerable(2).with.property('name', 'tag3'); - updatedPost.tags.should.have.enumerable(3).with.property('name', 'tag4'); - updatedPost.tags.should.have.enumerable(4).with.property('name', 'tag5'); - }); - }); + // Edit the post + return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { + updatedPost = updatedPost.toJSON({include: ['tags']}); - it('can add multiple tags with conflicting slugs', function () { - var newJSON = _.cloneDeep(postJSON); - - // Add conflicting tags to the end of the array - newJSON.tags.push({name: 'C'}); - newJSON.tags.push({name: 'C++'}); - newJSON.tags.push({name: 'C#'}); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(3); - updatedPost.tags.should.have.enumerable(0).with.properties({name: 'C', slug: 'c'}); - updatedPost.tags.should.have.enumerable(1).with.properties({name: 'C++', slug: 'c-2'}); - updatedPost.tags.should.have.enumerable(2).with.properties({name: 'C#', slug: 'c-3'}); - }); - }); + updatedPost.tags.should.have.lengthOf(3); + updatedPost.tags.should.have.enumerable(0).with.properties({name: 'C', slug: 'c'}); + updatedPost.tags.should.have.enumerable(1).with.properties({name: 'C++', slug: 'c-2'}); + updatedPost.tags.should.have.enumerable(2).with.properties({name: 'C#', slug: 'c-3'}); }); + }); - describe('Adding pre-existing tags', function () { - it('can add a single tag', function () { - var newJSON = _.cloneDeep(postJSON); - - // Add a single pre-existing tag - newJSON.tags.push(tagJSON[0]); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(1); - updatedPost.tags.should.have.enumerable(0).with.properties({ - name: 'existing tag a', - id: tagJSON[0].id - }); - }); - }); + it('can handle lowercase/uppercase tags', function () { + var newJSON = _.cloneDeep(postJSON); - it('can add multiple tags', function () { - var newJSON = _.cloneDeep(postJSON); - - // Add many preexisting tags - newJSON.tags.push(tagJSON[0]); - newJSON.tags.push(tagJSON[1]); - newJSON.tags.push(tagJSON[2]); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(3); - updatedPost.tags.should.have.enumerable(0).with.properties({ - name: 'existing tag a', - id: tagJSON[0].id - }); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'existing-tag-b', - id: tagJSON[1].id - }); - updatedPost.tags.should.have.enumerable(2).with.properties({ - name: 'existing_tag_c', - id: tagJSON[2].id - }); - }); - }); + // Add conflicting tags to the end of the array + newJSON.tags = []; + newJSON.tags.push({name: 'test'}); + newJSON.tags.push({name: 'tEst'}); - it('can add multiple tags in wrong order', function () { - var newJSON = _.cloneDeep(postJSON); - - // Add tags to the array - newJSON.tags.push(tagJSON[2]); - newJSON.tags.push(tagJSON[0]); - newJSON.tags.push(tagJSON[1]); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(3); - updatedPost.tags.should.have.enumerable(0).with.properties({ - name: 'existing_tag_c', - id: tagJSON[2].id - }); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'existing tag a', - id: tagJSON[0].id - }); - updatedPost.tags.should.have.enumerable(2).with.properties({ - name: 'existing-tag-b', - id: tagJSON[1].id - }); - }); - }); - }); + // Edit the post + return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { + updatedPost = updatedPost.toJSON({include: ['tags']}); - describe('Adding combinations', function () { - it('can add a combination of new and pre-existing tags', function () { - var newJSON = _.cloneDeep(postJSON); - - // Add a bunch of new and existing tags to the array - newJSON.tags.push({name: 'tag1'}); - newJSON.tags.push({name: 'existing tag a'}); - newJSON.tags.push({name: 'tag3'}); - newJSON.tags.push({name: 'existing-tag-b'}); - newJSON.tags.push({name: 'tag5'}); - newJSON.tags.push({name: 'existing_tag_c'}); - - // Edit the post - return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { - updatedPost = updatedPost.toJSON({include: ['tags']}); - - updatedPost.tags.should.have.lengthOf(6); - updatedPost.tags.should.have.enumerable(0).with.property('name', 'tag1'); - updatedPost.tags.should.have.enumerable(1).with.properties({ - name: 'existing tag a', - id: tagJSON[0].id - }); - updatedPost.tags.should.have.enumerable(2).with.property('name', 'tag3'); - updatedPost.tags.should.have.enumerable(3).with.properties({ - name: 'existing-tag-b', - id: tagJSON[1].id - }); - updatedPost.tags.should.have.enumerable(4).with.property('name', 'tag5'); - updatedPost.tags.should.have.enumerable(5).with.properties({ - name: 'existing_tag_c', - id: tagJSON[2].id - }); - }); - }); + updatedPost.tags.should.have.lengthOf(1); }); }); }); diff --git a/package.json b/package.json index e2ea31187901..06207e311bd2 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "bluebird": "3.5.1", "body-parser": "1.18.2", "bookshelf": "0.10.3", + "bookshelf-relations": "0.1.3", "brute-knex": "https://github.com/cobbspur/brute-knex/tarball/37439f56965b17d29bb4ff9b3f3222b2f4bd6ce3", "bson-objectid": "1.2.1", "chalk": "1.1.3", diff --git a/yarn.lock b/yarn.lock index f371886a1d9b..ec4c89419e34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -381,7 +381,7 @@ bluebird@3.4.6: version "3.4.6" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.6.tgz#01da8d821d87813d158967e743d5fe6c62cf8c0f" -bluebird@3.5.1, bluebird@^3.0.5, bluebird@^3.4.3, bluebird@^3.4.6: +bluebird@3.5.1, bluebird@^3.0.5, bluebird@^3.4.1, bluebird@^3.4.3, bluebird@^3.4.6: version "3.5.1" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9" @@ -415,6 +415,14 @@ body-parser@~1.14.0: raw-body "~2.1.5" type-is "~1.6.10" +bookshelf-relations@0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/bookshelf-relations/-/bookshelf-relations-0.1.3.tgz#aa403b0fdb1bb76c67119bdae1106f6e6342b128" + dependencies: + bluebird "^3.4.1" + ghost-ignition "^2.8.16" + lodash "^4.17.4" + bookshelf@0.10.3: version "0.10.3" resolved "https://registry.yarnpkg.com/bookshelf/-/bookshelf-0.10.3.tgz#72558204e83815f8e5bba6fd808702563e72b3e4"