diff --git a/core/server/data/fixtures/index.js b/core/server/data/fixtures/index.js index 8b58f67c8f..0e792b84c2 100644 --- a/core/server/data/fixtures/index.js +++ b/core/server/data/fixtures/index.js @@ -245,7 +245,7 @@ to004 = function to004() { }); ops.push(upgradeOp); - // add ghost-frontend client if missing + // clean up broken tags upgradeOp = models.Tag.findAll(options).then(function (tags) { var tagOps = []; if (tags) { @@ -267,7 +267,27 @@ to004 = function to004() { } return Promise.resolve(); }); + ops.push(upgradeOp); + // Add post_tag order + upgradeOp = models.Post.findAll(_.extend({}, options, {withRelated: ['tags']})).then(function (posts) { + var tagOps = []; + if (posts) { + posts.each(function (post) { + var order = 0; + post.related('tags').each(function (tag) { + tagOps.push(post.tags().updatePivot( + {sort_order: order}, _.extend({}, options, {query: {where: {tag_id: tag.id}}}) + )); + order += 1; + }); + }); + } + if (tagOps.length > 0) { + logInfo('Updating order on ' + tagOps.length + ' tags'); + return Promise.all(tagOps); + } + }); ops.push(upgradeOp); return Promise.all(ops); diff --git a/core/server/data/schema.js b/core/server/data/schema.js index 9b3c8c5cdc..1ce4bbfa4c 100644 --- a/core/server/data/schema.js +++ b/core/server/data/schema.js @@ -117,7 +117,8 @@ var db = { posts_tags: { id: {type: 'increments', nullable: false, primary: true}, post_id: {type: 'integer', nullable: false, unsigned: true, references: 'posts.id'}, - tag_id: {type: 'integer', nullable: false, unsigned: true, references: 'tags.id'} + tag_id: {type: 'integer', nullable: false, unsigned: true, references: 'tags.id'}, + sort_order: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0} }, apps: { id: {type: 'increments', nullable: false, primary: true}, diff --git a/core/server/models/base/utils.js b/core/server/models/base/utils.js index c0d2b466e8..fae15e42e5 100644 --- a/core/server/models/base/utils.js +++ b/core/server/models/base/utils.js @@ -2,10 +2,11 @@ * # Utils * Parts of the model code which can be split out and unit tested */ -var _ = require('lodash'), +var _ = require('lodash'), collectionQuery, filtering, - addPostCount; + addPostCount, + tagUpdate; addPostCount = function addPostCount(options, itemCollection) { if (options.include && options.include.indexOf('post_count') > -1) { @@ -66,6 +67,69 @@ filtering = { } }; +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', _.pluck(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, tag, index, options) { + return function () { + // See tgriesser/bookshelf#294 for an explanation of _.omit(options, 'query') + return post.tags().attach({tag_id: tag.id, sort_order: index}, _.omit(options, 'query')); + }; + }, + + createTagThenAttachTagToPost: function createTagThenAttachTagToPost(TagModel, post, tag, index, options) { + return function () { + return TagModel.add({name: tag.name}, options).then(function then(createdTag) { + return tagUpdate.attachTagToPost(post, 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 parseInt(tag1.id, 10) === parseInt(tag2.id, 10); + } + 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 !_.any(tags1, function (tag1, index) { + return !tagUpdate.tagsAreEqual(tag1, tags2[index]); + }); + } +}; + module.exports.filtering = filtering; module.exports.collectionQuery = collectionQuery; module.exports.addPostCount = addPostCount; +module.exports.tagUpdate = tagUpdate; diff --git a/core/server/models/post.js b/core/server/models/post.js index 26c8d23fcc..7b5042ac51 100644 --- a/core/server/models/post.js +++ b/core/server/models/post.js @@ -9,6 +9,7 @@ var _ = require('lodash'), ghostBookshelf = require('./base'), events = require('../events'), config = require('../config'), + baseUtils = require('./base/utils'), permalinkSetting = '', getPermalinkSetting, Post, @@ -180,62 +181,101 @@ Post = ghostBookshelf.Model.extend({ * @return {Promise(ghostBookshelf.Models.Post)} Updated Post model */ updateTags: function updateTags(savedModel, response, options) { - var self = this; + var newTags = this.myTags, + TagModel = ghostBookshelf.model('Tag'); + options = options || {}; - if (!this.myTags) { - return; - } + 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; - return Post.forge({id: savedModel.id}).fetch({withRelated: ['tags'], transacting: options.transacting}).then(function then(post) { - var tagOps = []; + if (baseUtils.tagUpdate.tagSetsAreEqual(newTags, currentTags)) { + return; + } - // remove all existing tags from the post - // _.omit(options, 'query') is a fix for using bookshelf 0.6.8 - // (https://github.com/tgriesser/bookshelf/issues/294) - tagOps.push(post.tags().detach(null, _.omit(options, 'query'))); - - if (_.isEmpty(self.myTags)) { - return Promise.all(tagOps); - } - - return ghostBookshelf.collection('Tags').forge().query('whereIn', 'name', _.pluck(self.myTags, 'name')).fetch(options).then(function then(existingTags) { - var doNotExist = [], - sequenceTasks = []; - - existingTags = existingTags.toJSON(options); - - doNotExist = _.reject(self.myTags, function (tag) { - return _.any(existingTags, function (existingTag) { - return existingTag.name === tag.name; + // 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 _.any(newTags, function (newTag) { + return baseUtils.tagUpdate.tagsAreEqual(currentTag, newTag); }); }); - // Create tags that don't exist and attach to post - _.each(doNotExist, function (tag) { - var createAndAttachOperation = function createAndAttachOperation() { - return ghostBookshelf.model('Tag').add({name: tag.name}, options).then(function then(createdTag) { - // _.omit(options, 'query') is a fix for using bookshelf 0.6.8 - // (https://github.com/tgriesser/bookshelf/issues/294) - return post.tags().attach(createdTag.id, _.omit(options, 'query')); + // Tags from the new tag array which don't exist in the DB should be created + tagsToCreate = _.pluck(_.reject(newTags, function (newTag) { + return _.any(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(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); }); - }; - sequenceTasks.push(createAndAttachOperation); + 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(savedModel, tag, index, options)); + } + } }); - tagOps = tagOps.concat(sequence(sequenceTasks)); - - // attach the tags that already existed - _.each(existingTags, function (tag) { - // _.omit(options, 'query') is a fix for using bookshelf 0.6.8 - // (https://github.com/tgriesser/bookshelf/issues/294) - tagOps.push(post.tags().attach(tag.id, _.omit(options, 'query'))); - }); - - return Promise.all(tagOps); + 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(error) { + errors.logError( + error, + 'Unable to save tags.', + 'Your post was saved, but your tags were not updated.' + ); + return Promise.reject(new errors.InternalServerError( + 'Unable to save tags. Your post was saved, but your tags were not updated. ' + error + )); + }); + } }, // Relations @@ -256,7 +296,7 @@ Post = ghostBookshelf.Model.extend({ }, tags: function tags() { - return this.belongsToMany('Tag'); + return this.belongsToMany('Tag').withPivot('sort_order').query('orderBy', 'sort_order', 'ASC'); }, fields: function fields() { diff --git a/core/test/integration/model/model_tags_spec.js b/core/test/integration/model/model_tags_spec.js index cbbfdb37c5..8ae901cbbf 100644 --- a/core/test/integration/model/model_tags_spec.js +++ b/core/test/integration/model/model_tags_spec.js @@ -116,379 +116,566 @@ describe('Tag Model', function () { }); }); - describe('a Post', function () { - it('can add a tag', function (done) { - var newPost = testUtils.DataGenerator.forModel.posts[0], - newTag = testUtils.DataGenerator.forModel.tags[0], - createdPostID; + describe('Post tag handling, post with NO tags', function () { + var postJSON, + tagJSON, + editOptions, + createTag = testUtils.DataGenerator.forKnex.createTag; - Promise.all([ - PostModel.add(newPost, context), - TagModel.add(newTag, context) - ]).then(function (models) { - var createdPost = models[0], - createdTag = models[1]; + beforeEach(function (done) { + tagJSON = []; - eventSpy.calledTwice.should.be.true; - eventSpy.calledWith('post.added').should.be.true; - eventSpy.calledWith('tag.added').should.be.true; + var post = 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']}); - createdPostID = createdPost.id; - return createdPost.tags().attach(createdTag); - }).then(function () { - return PostModel.findOne({id: createdPostID, status: 'all'}, {withRelated: ['tags']}); - }).then(function (postWithTag) { - postWithTag.related('tags').length.should.equal(1); done(); }).catch(done); }); - it('can remove a tag', function (done) { - // The majority of this test is ripped from above, which is obviously a Bad Thing. - // Would be nice to find a way to seed data with relations for cases like this, - // because there are more DB hits than needed - var newPost = testUtils.DataGenerator.forModel.posts[0], - newTag = testUtils.DataGenerator.forModel.tags[0], - createdTagID, - createdPostID; + 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'); - Promise.all([ - PostModel.add(newPost, context), - TagModel.add(newTag, context) - ]).then(function (models) { - var createdPost = models[0], - createdTag = models[1]; - - eventSpy.calledTwice.should.be.true; - eventSpy.calledWith('post.added').should.be.true; - eventSpy.calledWith('tag.added').should.be.true; - - createdPostID = createdPost.id; - createdTagID = createdTag.id; - return createdPost.tags().attach(createdTag); - }).then(function () { - return PostModel.findOne({id: createdPostID, status: 'all'}, {withRelated: ['tags']}); - }).then(function (postWithTag) { - return postWithTag.tags().detach(createdTagID); - }).then(function () { - eventSpy.calledTwice.should.be.true; - return PostModel.findOne({id: createdPostID, status: 'all'}, {withRelated: ['tags']}); - }).then(function (postWithoutTag) { - postWithoutTag.related('tags').length.should.equal(0); - done(); - }).catch(done); + // creates a test post with no tags + should.exist(postJSON); + postJSON.title.should.eql('HTML Ipsum'); + should.exist(postJSON.tags); }); - describe('setting tags from an array on update', function () { - // When a Post is updated, iterate through the existing tags, and detach any that have since been removed. - // It can be assumed that any remaining tags in the update data are newly added. - // Create new tags if needed, and attach them to the Post + describe('Adding brand new tags', function () { + it('can add a single tag', function (done) { + var newJSON = _.cloneDeep(postJSON); - function seedTags(tagNames) { - var createOperations = [ - PostModel.add(testUtils.DataGenerator.forModel.posts[0], context) - ], - tagModels = tagNames.map(function (tagName) { return TagModel.add({name: tagName}, context); }); + // Add a single tag to the end of the array + newJSON.tags.push(createTag({name: 'tag1'})); - createOperations = createOperations.concat(tagModels); + // Edit the post + return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { + updatedPost = updatedPost.toJSON({include: ['tags']}); - return Promise.all(createOperations).then(function (models) { - var postModel = models[0], - attachOperations, - i; - - attachOperations = []; - for (i = 1; i < models.length; i += 1) { - attachOperations.push(postModel.tags().attach(models[i])); - } - - return Promise.all(attachOperations).then(function () { - return postModel; - }); - }).then(function (postModel) { - return PostModel.findOne({id: postModel.id, status: 'all'}, {withRelated: ['tags']}); - }); - } - - it('does nothing if tags haven\'t changed', function (done) { - var seededTagNames = ['tag1', 'tag2', 'tag3']; - - seedTags(seededTagNames).then(function (postModel) { - // the tag API expects tags to be provided like {id: 1, name: 'draft'} - var existingTagData = seededTagNames.map(function (tagName, i) { - return {id: i + 1, name: tagName}; - }); - - postModel = postModel.toJSON(); - postModel.tags = existingTagData; - - return PostModel.edit(postModel, _.extend({}, context, {id: postModel.id, withRelated: ['tags']})); - }).then(function (postModel) { - var tagNames = postModel.related('tags').models.map(function (t) { return t.attributes.name; }); - tagNames.sort().should.eql(seededTagNames); - - return TagModel.findAll(); - }).then(function (tagsFromDB) { - tagsFromDB.length.should.eql(seededTagNames.length); + updatedPost.tags.should.have.lengthOf(1); + updatedPost.tags.should.have.enumerable(0).with.property('name', 'tag1'); done(); }).catch(done); }); - it('detaches tags that have been removed', function (done) { - var seededTagNames = ['tag1', 'tag2', 'tag3']; + it('can add multiple tags', function (done) { + var newJSON = _.cloneDeep(postJSON); - seedTags(seededTagNames).then(function (postModel) { - // the tag API expects tags to be provided like {id: 1, name: 'draft'} - var tagData = seededTagNames.map(function (tagName, i) { return {id: i + 1, name: tagName}; }); + // 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'})); - // remove the second tag, and save - tagData.splice(1, 1); + // Edit the post + return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { + updatedPost = updatedPost.toJSON({include: ['tags']}); - postModel = postModel.toJSON(); - postModel.tags = tagData; - - return PostModel.edit(postModel, _.extend({}, context, {id: postModel.id, withRelated: ['tags']})); - }).then(function (postModel) { - return PostModel.findOne({id: postModel.id, status: 'all'}, {withRelated: ['tags']}); - }).then(function (reloadedPost) { - var tagNames = reloadedPost.related('tags').models.map(function (t) { return t.attributes.name; }); - tagNames.sort().should.eql(['tag1', 'tag3']); + 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'); done(); }).catch(done); }); - it('attaches tags that are new to the post, but already exist in the database', function (done) { - var seededTagNames = ['tag1', 'tag2'], - postModel; + it('can add multiple tags with conflicting slugs', function (done) { + var newJSON = _.cloneDeep(postJSON); - seedTags(seededTagNames).then(function (_postModel) { - postModel = _postModel; - return TagModel.add({name: 'tag3'}, context); - }).then(function () { - // the tag API expects tags to be provided like {id: 1, name: 'draft'} - var tagData = seededTagNames.map(function (tagName, i) { return {id: i + 1, name: tagName}; }); + // Add conflicting tags to the end of the array + newJSON.tags.push({name: 'C'}); + newJSON.tags.push({name: 'C++'}); + newJSON.tags.push({name: 'C#'}); - // add the additional tag, and save - tagData.push({id: 3, name: 'tag3'}); - postModel = postModel.toJSON(); - postModel.tags = tagData; + // Edit the post + return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { + updatedPost = updatedPost.toJSON({include: ['tags']}); - return PostModel.edit(postModel, _.extend({}, context, {id: postModel.id, withRelated: ['tags']})); - }).then(function () { - return PostModel.findOne({id: postModel.id, status: 'all'}, {withRelated: ['tags']}); - }).then(function (reloadedPost) { - var tagModels = reloadedPost.related('tags').models, - tagNames = tagModels.map(function (t) { return t.attributes.name; }), - tagIds = _.pluck(tagModels, 'id'); - tagNames.sort().should.eql(['tag1', 'tag2', 'tag3']); + 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'}); - // make sure it hasn't just added a new tag with the same name - // Don't expect a certain order in results - check for number of items! - Math.max.apply(Math, tagIds).should.eql(3); + done(); + }).catch(done); + }); + }); + + describe('Adding pre-existing tags', function () { + it('can add a single tag', function (done) { + 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}); done(); }).catch(done); }); - it('creates and attaches a tag that is new to the Tags table', function (done) { - var seededTagNames = ['tag1', 'tag2']; + it('can add multiple tags', function (done) { + var newJSON = _.cloneDeep(postJSON); - seedTags(seededTagNames).then(function (postModel) { - // the tag API expects tags to be provided like {id: 1, name: 'draft'} - var tagData = seededTagNames.map(function (tagName, i) { return {id: i + 1, name: tagName}; }); + // Add many preexisting tags + newJSON.tags.push(tagJSON[0]); + newJSON.tags.push(tagJSON[1]); + newJSON.tags.push(tagJSON[2]); - // add the additional tag, and save - tagData.push({id: null, name: 'tag3'}); - postModel = postModel.toJSON(); - postModel.tags = tagData; + // Edit the post + return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { + updatedPost = updatedPost.toJSON({include: ['tags']}); - return PostModel.edit(postModel, _.extend({}, context, {id: postModel.id, withRelated: ['tags']})); - }).then(function (postModel) { - return PostModel.findOne({id: postModel.id, status: 'all'}, {withRelated: ['tags']}); - }).then(function (reloadedPost) { - var tagNames = reloadedPost.related('tags').models.map(function (t) { return t.attributes.name; }); - tagNames.sort().should.eql(['tag1', 'tag2', 'tag3']); + 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}); done(); }).catch(done); }); - it('creates and attaches multiple tags that are new to the Tags table', function (done) { - var seededTagNames = ['tag1']; + it('can add multiple tags in wrong order', function (done) { + var newJSON = _.cloneDeep(postJSON); - seedTags(seededTagNames).then(function (postModel) { - // the tag API expects tags to be provided like {id: 1, name: 'draft'} - var tagData = seededTagNames.map(function (tagName, i) { return {id: i + 1, name: tagName}; }); + // Add tags to the array + newJSON.tags.push(tagJSON[2]); + newJSON.tags.push(tagJSON[0]); + newJSON.tags.push(tagJSON[1]); - // add the additional tags, and save - tagData.push({id: null, name: 'tag2'}); - tagData.push({id: null, name: 'tag3'}); + // Edit the post + return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { + updatedPost = updatedPost.toJSON({include: ['tags']}); - postModel = postModel.toJSON(); - postModel.tags = tagData; + 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}); - return PostModel.edit(postModel, _.extend({}, context, {id: postModel.id, withRelated: ['tags']})); - }).then(function (postModel) { - return PostModel.findOne({id: postModel.id, status: 'all'}, {withRelated: ['tags']}); - }).then(function (reloadedPost) { - var tagNames = reloadedPost.related('tags').models.map(function (t) { return t.attributes.name; }); - tagNames.sort().should.eql(['tag1', 'tag2', 'tag3']); + done(); + }).catch(done); + }); + }); + + describe('Adding combinations', function () { + it('can add a combination of new and pre-existing tags', function (done) { + 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}); + + done(); + }).catch(done); + }); + }); + }); + + describe('Post tag handling, post with tags', function () { + var postJSON, + tagJSON, + editOptions, + createTag = testUtils.DataGenerator.forKnex.createTag; + + beforeEach(function (done) { + tagJSON = []; + + var post = 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; + + 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']}); + + done(); + }); + }); + + it('should create the test data correctly', function () { + // 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'); + }); + + describe('Adding brand new tags', function () { + it('can add a single tag to the end of the tags array', function (done) { + 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'); done(); }).catch(done); }); - it('attaches one tag that exists in the Tags database and one tag that is new', function (done) { - var seededTagNames = ['tag1'], - postModel; + it('can add a single tag to the beginning of the tags array', function (done) { + var newJSON = _.cloneDeep(postJSON); - seedTags(seededTagNames).then(function (_postModel) { - postModel = _postModel; - return TagModel.add({name: 'tag2'}, context); - }).then(function () { - // the tag API expects tags to be provided like {id: 1, name: 'draft'} - var tagData = seededTagNames.map(function (tagName, i) { return {id: i + 1, name: tagName}; }); + // Add a single tag to the beginning of the array + newJSON.tags = [createTag({name: 'tag4'})].concat(postJSON.tags); - // Add the tag that exists in the database - tagData.push({id: 2, name: 'tag2'}); + // Edit the post + return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { + updatedPost = updatedPost.toJSON({include: ['tags']}); - // Add the tag that doesn't exist in the database - tagData.push({id: 3, name: 'tag3'}); + 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}); - postModel = postModel.toJSON(); - postModel.tags = tagData; + done(); + }).catch(done); + }); + }); - return PostModel.edit(postModel, _.extend({}, context, {id: postModel.id, withRelated: ['tags']})); - }).then(function () { - return PostModel.findOne({id: postModel.id, status: 'all'}, {withRelated: ['tags']}); - }).then(function (reloadedPost) { - var tagModels = reloadedPost.related('tags').models, - tagNames = tagModels.map(function (t) { return t.attributes.name; }), - tagIds = _.pluck(tagModels, 'id'); + describe('Adding pre-existing tags', function () { + it('can add a single tag to the end of the tags array', function (done) { + var newJSON = _.cloneDeep(postJSON); - tagNames.sort().should.eql(['tag1', 'tag2', 'tag3']); + // Add a single pre-existing tag to the end of the array + newJSON.tags.push(tagJSON[0]); - // make sure it hasn't just added a new tag with the same name - // Don't expect a certain order in results - check for number of items! - Math.max.apply(Math, tagIds).should.eql(3); + // 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}); done(); }).catch(done); }); - it('attaches one tag that exists in the Tags database and two tags that are new', function (done) { - var seededTagNames = ['tag1'], - postModel; + it('can add a single tag to the beginning of the tags array', function (done) { + var newJSON = _.cloneDeep(postJSON); - seedTags(seededTagNames).then(function (_postModel) { - postModel = _postModel; - return TagModel.add({name: 'tag2'}, context); - }).then(function () { - // the tag API expects tags to be provided like {id: 1, name: 'draft'} - var tagData = seededTagNames.map(function (tagName, i) { return {id: i + 1, name: tagName}; }); + // Add an existing tag to the beginning of the array + newJSON.tags = [tagJSON[0]].concat(postJSON.tags); - // Add the tag that exists in the database - tagData.push({id: 2, name: 'tag2'}); + // Edit the post + return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { + updatedPost = updatedPost.toJSON({include: ['tags']}); - // Add the tags that doesn't exist in the database - tagData.push({id: 3, name: 'tag3'}); - tagData.push({id: 4, name: 'tag4'}); - - postModel = postModel.toJSON(); - postModel.tags = tagData; - - return PostModel.edit(postModel, _.extend({}, context, {id: postModel.id, withRelated: ['tags']})); - }).then(function () { - return PostModel.findOne({id: postModel.id, status: 'all'}, {withRelated: ['tags']}); - }).then(function (reloadedPost) { - var tagModels = reloadedPost.related('tags').models, - tagNames = tagModels.map(function (t) { return t.get('name'); }), - tagIds = _.pluck(tagModels, 'id'); - - tagNames.sort().should.eql(['tag1', 'tag2', 'tag3', 'tag4']); - - // make sure it hasn't just added a new tag with the same name - // Don't expect a certain order in results - check for number of items! - Math.max.apply(Math, tagIds).should.eql(4); + 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}); done(); }).catch(done); }); - it('attaches two new tags and resolves conflicting slugs', function (done) { - var tagData = []; + it('can add a single tag to the middle of the tags array', function (done) { + var newJSON = _.cloneDeep(postJSON); - // Add the tags that don't exist in the database and have potentially - // conflicting slug names - tagData.push({id: 1, name: 'C'}); - tagData.push({id: 2, name: 'C++'}); + // 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)); - PostModel.add(testUtils.DataGenerator.forModel.posts[0], context).then(function (postModel) { - postModel = postModel.toJSON(); - postModel.tags = tagData; + // Edit the post + return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { + updatedPost = updatedPost.toJSON({include: ['tags']}); - return PostModel.edit(postModel, _.extend({}, context, {id: postModel.id})).then(function () { - return PostModel.findOne({id: postModel.id, status: 'all'}, {withRelated: ['tags']}); - }).then(function (reloadedPost) { - var tagModels = reloadedPost.related('tags').models, - tagNames = tagModels.map(function (t) { return t.get('name'); }), - tagIds = _.pluck(tagModels, 'id'); + 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}); - tagNames.sort().should.eql(['C', 'C++']); - - // make sure it hasn't just added a new tag with the same name - // Don't expect a certain order in results - check for number of items! - Math.max.apply(Math, tagIds).should.eql(2); - - done(); - }); + done(); }).catch(done); }); + }); - it('correctly creates non-conflicting slugs', function (done) { - var seededTagNames = ['tag1'], - postModel; + describe('Removing tags', function () { + it('can remove a single tag from the end of the tags array', function (done) { + var newJSON = _.cloneDeep(postJSON); - seedTags(seededTagNames).then(function (_postModel) { - postModel = _postModel; - return TagModel.add({name: 'tagc'}, context); - }).then(function () { - // the tag API expects tags to be provided like {id: 1, name: 'draft'} - var tagData = []; + // Remove a single tag from the end of the array + newJSON.tags = postJSON.tags.slice(0, -1); - tagData.push({id: 2, name: 'tagc'}); - tagData.push({id: 3, name: 'tagc++'}); + // Edit the post + return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { + updatedPost = updatedPost.toJSON({include: ['tags']}); - postModel = postModel.toJSON(); - postModel.tags = tagData; - - return PostModel.edit(postModel, _.extend({}, context, {id: postModel.id, withRelated: ['tags']})); - }).then(function () { - return PostModel.findOne({id: postModel.id, status: 'all'}, {withRelated: ['tags']}); - }).then(function (reloadedPost) { - var tagModels = reloadedPost.related('tags').models, - tagSlugs = tagModels.map(function (t) { return t.get('slug'); }), - tagIds = _.pluck(tagModels, 'id'); - - tagSlugs.sort().should.eql(['tagc', 'tagc-2']); - - // make sure it hasn't just added a new tag with the same name - // Don't expect a certain order in results - check for number of items! - Math.max.apply(Math, tagIds).should.eql(3); + 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}); done(); }).catch(done); }); - it('can add a tag to a post on creation', function (done) { - var newPost = _.extend({}, testUtils.DataGenerator.forModel.posts[0], {tags: [{name: 'test_tag_1'}]}); + it('can remove a single tag from the beginning of the tags array', function (done) { + 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}); + + done(); + }).catch(done); + }); + + it('can remove all tags', function (done) { + var newJSON = _.cloneDeep(postJSON); + + // Remove all the tags + newJSON.tags = []; + + // Edit the post + return PostModel.edit(newJSON, editOptions).then(function (updatedPost) { + updatedPost = updatedPost.toJSON({include: ['tags']}); + + updatedPost.tags.should.have.lengthOf(0); + + done(); + }).catch(done); + }); + }); + + describe('Reordering tags', function () { + it('can reorder the first tag to be the last', function (done) { + 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}); + + done(); + }).catch(done); + }); + + it('can reorder the last tag to be the first', function (done) { + 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}); + + done(); + }).catch(done); + }); + }); + + describe('Combination updates', function () { + it('can add a combination of new and pre-existing tags', function (done) { + 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}); + + done(); + }).catch(done); + }); + + it('can reorder the first tag to be the last and add a tag to the beginning', function (done) { + 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}); + + done(); + }).catch(done); + }); + + it('can reorder the first tag to be the last, remove the original last tag & add a tag to the beginning', function (done) { + 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}); + + done(); + }).catch(done); + }); + + it('can reorder original tags, remove one, and add new and existing tags', function (done) { + 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}); - PostModel.add(newPost, context).then(function (createdPost) { - return PostModel.findOne({id: createdPost.id, status: 'all'}, {withRelated: ['tags']}); - }).then(function (postWithTag) { - postWithTag.related('tags').length.should.equal(1); done(); }).catch(done); }); diff --git a/core/test/unit/migration_spec.js b/core/test/unit/migration_spec.js index c20f6c9345..7d07ddae31 100644 --- a/core/test/unit/migration_spec.js +++ b/core/test/unit/migration_spec.js @@ -20,7 +20,7 @@ describe('Migrations', function () { describe('DB version integrity', function () { // Only these variables should need updating var currentDbVersion = '004', - currentSchemaHash = '5e6c9d6b9df6a0b77aff2ec582f536d9', + currentSchemaHash = 'a195562bf4915e3f3f610f6d178aba01', currentPermissionsHash = '42e486732270cda623fc5efc04808c0c'; // If this test is failing, then it is likely a change has been made that requires a DB version bump,