2
1
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2023-12-13 21:00:40 +01:00

add events for post scheduling

refs #6413
- accept scheduled status
- add a lot of tests for all kinds of edge cases
- compare dates without ms because mysql does not store ms
This commit is contained in:
Katharina Irrgang 2016-04-14 13:22:38 +02:00 committed by kirrg001
parent 817a302885
commit d24466a284
6 changed files with 356 additions and 17 deletions

View file

@ -1,6 +1,7 @@
// # Post Model
var _ = require('lodash'),
uuid = require('node-uuid'),
moment = require('moment'),
Promise = require('bluebird'),
sequence = require('../utils/sequence'),
errors = require('../errors'),
@ -43,38 +44,73 @@ Post = ghostBookshelf.Model.extend({
});
this.on('created', function onCreated(model) {
var status = model.get('status');
model.emitChange('added');
if (model.get('status') === 'published') {
model.emitChange('published');
if (['published', 'scheduled'].indexOf(status) !== -1) {
model.emitChange(status);
}
});
this.on('updated', function onUpdated(model) {
model.statusChanging = model.get('status') !== model.updated('status');
model.isPublished = model.get('status') === 'published';
model.isScheduled = model.get('status') === 'scheduled';
model.wasPublished = model.updated('status') === 'published';
model.wasScheduled = model.updated('status') === 'scheduled';
model.resourceTypeChanging = model.get('page') !== model.updated('page');
model.needsReschedule = model.get('published_at') !== model.updated('published_at');
// Handle added and deleted for changing resource
// Handle added and deleted for post -> page or page -> post
if (model.resourceTypeChanging) {
if (model.wasPublished) {
model.emitChange('unpublished', true);
}
if (model.wasScheduled) {
model.emitChange('unscheduled', true);
}
model.emitChange('deleted', true);
model.emitChange('added');
if (model.isPublished) {
model.emitChange('published');
}
if (model.isScheduled) {
model.emitChange('scheduled');
}
} else {
if (model.statusChanging) {
model.emitChange(model.isPublished ? 'published' : 'unpublished');
// CASE: was published before and is now e.q. draft or scheduled
if (model.wasPublished) {
model.emitChange('unpublished');
}
// CASE: was draft or scheduled before and is now e.q. published
if (model.isPublished) {
model.emitChange('published');
}
// CASE: was draft or published before and is now e.q. scheduled
if (model.isScheduled) {
model.emitChange('scheduled');
}
// CASE: from scheduled to something
if (model.wasScheduled && !model.isScheduled) {
model.emitChange('unscheduled');
}
} else {
if (model.isPublished) {
model.emitChange('published.edited');
}
if (model.needsReschedule) {
model.emitChange('rescheduled');
}
}
// Fire edited if this wasn't a change between resourceType
@ -93,20 +129,41 @@ Post = ghostBookshelf.Model.extend({
},
saving: function saving(model, attr, options) {
options = options || {};
var self = this,
tagsToCheck,
title,
i,
// Variables to make the slug checking more readable
newTitle = this.get('title'),
newStatus = this.get('status'),
prevTitle = this._previousAttributes.title,
prevSlug = this._previousAttributes.slug,
postStatus = this.get('status'),
tagsToCheck = this.get('tags'),
publishedAt = this.get('published_at');
options = options || {};
// both page and post can get scheduled
if (newStatus === 'scheduled') {
if (!publishedAt) {
return Promise.reject(new errors.ValidationError(
i18n.t('errors.models.post.valueCannotBeBlank', {key: 'published_at'})
));
} else if (!moment(publishedAt).isValid()) {
return Promise.reject(new errors.ValidationError(
i18n.t('errors.models.post.valueCannotBeBlank', {key: 'published_at'})
));
} else if (moment(publishedAt).isBefore(moment())) {
return Promise.reject(new errors.ValidationError(
i18n.t('errors.models.post.expectedPublishedAtInFuture')
));
} else if (moment(publishedAt).isBefore(moment().add(5, 'minutes'))) {
return Promise.reject(new errors.ValidationError(
i18n.t('errors.models.post.expectedPublishedAtInFuture')
));
}
}
// keep tags for 'saved' event and deduplicate upper/lowercase tags
tagsToCheck = this.get('tags');
this.myTags = [];
_.each(tagsToCheck, function each(item) {
@ -129,12 +186,12 @@ Post = ghostBookshelf.Model.extend({
// ### Business logic for published_at and published_by
// If the current status is 'published' and published_at is not set, set it to now
if (this.get('status') === 'published' && !this.get('published_at')) {
if (newStatus === 'published' && !publishedAt) {
this.set('published_at', new Date());
}
// If the current status is 'published' and the status has just changed ensure published_by is set correctly
if (this.get('status') === 'published' && this.hasChanged('status')) {
if (newStatus === 'published' && this.hasChanged('status')) {
// unless published_by is set and we're importing, set published_by to contextUser
if (!(this.get('published_by') && options.importing)) {
this.set('published_by', this.contextUser(options));
@ -147,7 +204,7 @@ Post = ghostBookshelf.Model.extend({
}
// 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 && postStatus === 'draft' && !publishedAt) {
if (prevTitle !== undefined && newTitle !== prevTitle && newStatus === 'draft' && !publishedAt) {
// Pass the new slug through the generator to strip illegal characters, detect duplicates
return ghostBookshelf.Model.generateSlug(Post, this.get('title'),
{status: 'all', transacting: options.transacting, importing: options.importing})

View file

@ -199,6 +199,8 @@
"models": {
"post": {
"untitled": "(Untitled)",
"valueCannotBeBlank": "Value in {key} cannot be blank.",
"expectedPublishedAtInFuture": "Expected published_at to be in the future.",
"noUserFound": "No user found",
"notEnoughPermission": "You do not have permission to perform this action",
"tagUpdates": {

View file

@ -342,7 +342,7 @@ describe('Post API', function () {
it('can order posts using asc', function (done) {
var posts, expectedTitles;
posts = _(testUtils.DataGenerator.Content.posts).reject('page').value();
posts = _(testUtils.DataGenerator.Content.posts).reject('page').reject({status: 'scheduled'}).value();
expectedTitles = _(posts).pluck('title').sortBy().value();
PostAPI.browse({context: {user: 1}, status: 'all', order: 'title asc', fields: 'title'}).then(function (results) {
@ -358,7 +358,7 @@ describe('Post API', function () {
it('can order posts using desc', function (done) {
var posts, expectedTitles;
posts = _(testUtils.DataGenerator.Content.posts).reject('page').value();
posts = _(testUtils.DataGenerator.Content.posts).reject('page').reject({status: 'scheduled'}).value();
expectedTitles = _(posts).pluck('title').sortBy().reverse().value();
PostAPI.browse({context: {user: 1}, status: 'all', order: 'title DESC', fields: 'title'}).then(function (results) {
@ -374,7 +374,7 @@ describe('Post API', function () {
it('can order posts and filter disallowed attributes', function (done) {
var posts, expectedTitles;
posts = _(testUtils.DataGenerator.Content.posts).reject('page').value();
posts = _(testUtils.DataGenerator.Content.posts).reject('page').reject({status: 'scheduled'}).value();
expectedTitles = _(posts).pluck('title').sortBy().value();
PostAPI.browse({context: {user: 1}, status: 'all', order: 'bunny DESC, title ASC', fields: 'title'}).then(function (results) {

View file

@ -205,7 +205,7 @@ describe('Users API', function () {
response.users[0].count.posts.should.eql(0);
response.users[1].count.posts.should.eql(0);
response.users[2].count.posts.should.eql(0);
response.users[3].count.posts.should.eql(7);
response.users[3].count.posts.should.eql(8);
response.users[4].count.posts.should.eql(0);
response.users[5].count.posts.should.eql(0);
response.users[6].count.posts.should.eql(0);

View file

@ -1,14 +1,16 @@
/*globals describe, before, beforeEach, afterEach, it */
var testUtils = require('../../utils'),
should = require('should'),
sequence = require('../../../server/utils/sequence'),
moment = require('moment'),
_ = require('lodash'),
sinon = require('sinon'),
// Stuff we are testing
sequence = require('../../../server/utils/sequence'),
ghostBookshelf = require('../../../server/models/base'),
PostModel = require('../../../server/models/post').Post,
events = require('../../../server/events'),
errors = require('../../../server/errors'),
DataGenerator = testUtils.DataGenerator,
context = testUtils.context.owner,
sandbox = sinon.sandbox.create(),
@ -425,6 +427,142 @@ describe('Post Model', function () {
}).catch(done);
});
it('draft -> scheduled without published_at update', function (done) {
PostModel.findOne({status: 'draft'}).then(function (results) {
var post;
should.exist(results);
post = results.toJSON();
post.status.should.equal('draft');
return PostModel.edit({
status: 'scheduled'
}, _.extend({}, context, {id: post.id}));
}).catch(function (err) {
should.exist(err);
(err instanceof errors.ValidationError).should.eql(true);
done();
});
});
it('draft -> scheduled: invalid published_at update', function (done) {
PostModel.findOne({status: 'draft'}).then(function (results) {
var post;
should.exist(results);
post = results.toJSON();
post.status.should.equal('draft');
return PostModel.edit({
status: 'scheduled',
published_at: '328432423'
}, _.extend({}, context, {id: post.id}));
}).catch(function (err) {
should.exist(err);
(err instanceof errors.ValidationError).should.eql(true);
done();
});
});
it('draft -> scheduled: expect update of published_at', function (done) {
var newPublishedAt = moment().add(1, 'day').toDate();
PostModel.findOne({status: 'draft'}).then(function (results) {
var post;
should.exist(results);
post = results.toJSON();
post.status.should.equal('draft');
return PostModel.edit({
status: 'scheduled',
published_at: newPublishedAt
}, _.extend({}, context, {id: post.id}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('scheduled');
// mysql does not store ms
moment(edited.attributes.published_at).startOf('seconds').diff(moment(newPublishedAt).startOf('seconds')).should.eql(0);
eventSpy.calledTwice.should.be.true();
eventSpy.firstCall.calledWith('post.scheduled').should.be.true();
eventSpy.secondCall.calledWith('post.edited').should.be.true();
done();
}).catch(done);
});
it('scheduled -> draft: expect unschedule', function (done) {
PostModel.findOne({status: 'scheduled'}).then(function (results) {
var post;
should.exist(results);
post = results.toJSON();
post.status.should.equal('scheduled');
return PostModel.edit({
status: 'draft'
}, _.extend({}, context, {id: post.id}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('draft');
eventSpy.callCount.should.eql(2);
eventSpy.firstCall.calledWith('post.unscheduled').should.be.true();
eventSpy.secondCall.calledWith('post.edited').should.be.true();
done();
}).catch(done);
});
it('scheduled -> scheduled with updated published_at', function (done) {
PostModel.findOne({status: 'scheduled'}).then(function (results) {
var post;
should.exist(results);
post = results.toJSON();
post.status.should.equal('scheduled');
return PostModel.edit({
status: 'scheduled',
published_at: moment().add(20, 'days')
}, _.extend({}, context, {id: post.id}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('scheduled');
eventSpy.callCount.should.eql(2);
eventSpy.firstCall.calledWith('post.rescheduled').should.be.true();
eventSpy.secondCall.calledWith('post.edited').should.be.true();
done();
}).catch(done);
});
it('published -> scheduled and expect update of published_at', function (done) {
var postId = 1;
PostModel.findOne({id: postId}).then(function (results) {
var post;
should.exist(results);
post = results.toJSON();
post.id.should.equal(postId);
post.status.should.equal('published');
return PostModel.edit({
status: 'scheduled',
published_at: moment().add(1, 'day').toDate()
}, _.extend({}, context, {id: postId}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('scheduled');
eventSpy.callCount.should.eql(3);
eventSpy.firstCall.calledWith('post.unpublished').should.be.true();
eventSpy.secondCall.calledWith('post.scheduled').should.be.true();
eventSpy.thirdCall.calledWith('post.edited').should.be.true();
done();
}).catch(done);
});
it('can convert draft post to page and back', function (done) {
var postId = 4;
@ -456,6 +594,41 @@ describe('Post Model', function () {
}).catch(done);
});
it('can convert draft to schedule AND post to page and back', function (done) {
PostModel.findOne({status: 'draft'}).then(function (results) {
var post;
should.exist(results);
post = results.toJSON();
post.status.should.equal('draft');
return PostModel.edit({
page: 1,
status: 'scheduled',
published_at: moment().add(10, 'days')
}, _.extend({}, context, {id: post.id}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('scheduled');
edited.attributes.page.should.equal(true);
eventSpy.callCount.should.be.eql(3);
eventSpy.firstCall.calledWith('post.deleted').should.be.true();
eventSpy.secondCall.calledWith('page.added').should.be.true();
eventSpy.thirdCall.calledWith('page.scheduled').should.be.true();
return PostModel.edit({page: 0}, _.extend({}, context, {id: edited.id}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('scheduled');
edited.attributes.page.should.equal(false);
eventSpy.callCount.should.equal(7);
eventSpy.getCall(3).calledWith('page.unscheduled').should.be.true();
eventSpy.getCall(4).calledWith('page.deleted').should.be.true();
eventSpy.getCall(5).calledWith('post.added').should.be.true();
eventSpy.getCall(6).calledWith('post.scheduled').should.be.true();
done();
}).catch(done);
});
it('can convert published post to page and back', function (done) {
var postId = 1;
@ -666,6 +839,106 @@ describe('Post Model', function () {
}).catch(done);
});
it('add draft post without published_at -> we expect no auto insert of published_at', function (done) {
PostModel.add({
status: 'draft',
title: 'draft 1',
markdown: 'This is some content'
}, context).then(function (newPost) {
should.exist(newPost);
should.not.exist(newPost.get('published_at'));
eventSpy.calledOnce.should.be.true();
eventSpy.firstCall.calledWith('post.added').should.be.true();
done();
}).catch(done);
});
it('add draft post with published_at -> we expect published_at to exist', function (done) {
PostModel.add({
status: 'draft',
published_at: moment().toDate(),
title: 'draft 1',
markdown: 'This is some content'
}, context).then(function (newPost) {
should.exist(newPost);
should.exist(newPost.get('published_at'));
eventSpy.calledOnce.should.be.true();
eventSpy.firstCall.calledWith('post.added').should.be.true();
done();
}).catch(done);
});
it('add scheduled post without published_at -> we expect an error', function (done) {
PostModel.add({
status: 'scheduled',
title: 'scheduled 1',
markdown: 'This is some content'
}, context).catch(function (err) {
should.exist(err);
(err instanceof errors.ValidationError).should.eql(true);
eventSpy.called.should.be.false();
done();
});
});
it('add scheduled post with published_at not in future-> we expect an error', function (done) {
PostModel.add({
status: 'scheduled',
published_at: moment().subtract(1, 'minute'),
title: 'scheduled 1',
markdown: 'This is some content'
}, context).catch(function (err) {
should.exist(err);
(err instanceof errors.ValidationError).should.eql(true);
eventSpy.called.should.be.false();
done();
});
});
it('add scheduled post with published_at 1 minutes in future -> we expect an error', function (done) {
PostModel.add({
status: 'scheduled',
published_at: moment().add(1, 'minute'),
title: 'scheduled 1',
markdown: 'This is some content'
}, context).catch(function (err) {
(err instanceof errors.ValidationError).should.eql(true);
eventSpy.called.should.be.false();
done();
});
});
it('add scheduled post with published_at 10 minutes in future -> we expect success', function (done) {
PostModel.add({
status: 'scheduled',
published_at: moment().add(10, 'minute'),
title: 'scheduled 1',
markdown: 'This is some content'
}, context).then(function (post) {
should.exist(post);
eventSpy.calledTwice.should.be.true();
eventSpy.firstCall.calledWith('post.added').should.be.true();
eventSpy.secondCall.calledWith('post.scheduled').should.be.true();
done();
}).catch(done);
});
it('add scheduled page with published_at 10 minutes in future -> we expect success', function (done) {
PostModel.add({
status: 'scheduled',
page: 1,
published_at: moment().add(10, 'minute'),
title: 'scheduled 1',
markdown: 'This is some content'
}, context).then(function (post) {
should.exist(post);
eventSpy.calledTwice.should.be.true();
eventSpy.firstCall.calledWith('page.added').should.be.true();
eventSpy.secondCall.calledWith('page.scheduled').should.be.true();
done();
}).catch(done);
});
it('can add default title, if it\'s missing', function (done) {
PostModel.add({
markdown: 'Content'

View file

@ -53,6 +53,12 @@ DataGenerator.Content = {
markdown: "<h1>Static page test is what this is for.</h1><p>Hopefully you don't find it a bore.</p>",
page: 1,
status: "draft"
},
{
title: "This is a scheduled post!!",
slug: "scheduled-post",
markdown: "<h1>Welcome to my invisible post!</h1>",
status: "scheduled"
}
],
@ -363,7 +369,8 @@ DataGenerator.forKnex = (function () {
createPost(DataGenerator.Content.posts[3]),
createPost(DataGenerator.Content.posts[4]),
createPost(DataGenerator.Content.posts[5]),
createPost(DataGenerator.Content.posts[6])
createPost(DataGenerator.Content.posts[6]),
createPost(DataGenerator.Content.posts[7])
];
tags = [