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

post-scheduling

refs #6413
- PUT endpoint to publish a post/page for the scheduler
- fn endpoint to get all scheduled posts (with from/to query params) for the scheduler
- hardcoded permission handling for scheduler client
- fix event bug: unscheduled
- basic structure for scheduling
- post scheduling basics
- offer easy option to change adapter
- integrate the default scheduler adapter
- update scheduled posts when blog TZ changes
- safety check before scheduler can publish a post (not allowed to publish in the future or past)
- add force flag to allow publishing in the past
- invalidate cache header for /schedules/posts/:id
This commit is contained in:
“kirrg001” 2016-05-19 13:49:22 +02:00 committed by kirrg001
parent 1b98d80a73
commit 1421c92ba5
33 changed files with 1737 additions and 78 deletions

View file

@ -7,12 +7,12 @@
var _ = require('lodash'),
Promise = require('bluebird'),
config = require('../config'),
// Include Endpoints
configuration = require('./configuration'),
db = require('./db'),
mail = require('./mail'),
notifications = require('./notifications'),
posts = require('./posts'),
schedules = require('./schedules'),
roles = require('./roles'),
settings = require('./settings'),
tags = require('./tags'),
@ -60,6 +60,7 @@ cacheInvalidationHeader = function cacheInvalidationHeader(req, result) {
var parsedUrl = req._parsedUrl.pathname.replace(/^\/|\/$/g, '').split('/'),
method = req.method,
endpoint = parsedUrl[0],
subdir = parsedUrl[1],
jsonResult = result.toJSON ? result.toJSON() : result,
INVALIDATE_ALL = '/*',
post,
@ -67,6 +68,9 @@ cacheInvalidationHeader = function cacheInvalidationHeader(req, result) {
wasPublishedUpdated;
if (['POST', 'PUT', 'DELETE'].indexOf(method) > -1) {
if (endpoint === 'schedules' && subdir === 'posts') {
return INVALIDATE_ALL;
}
if (['settings', 'users', 'db', 'tags'].indexOf(endpoint) > -1) {
return INVALIDATE_ALL;
} else if (endpoint === 'posts') {
@ -213,7 +217,8 @@ http = function http(apiMethod) {
var object = req.body,
options = _.extend({}, req.file, req.query, req.params, {
context: {
user: ((req.user && req.user.id) || (req.user && req.user.id === 0)) ? req.user.id : null
user: ((req.user && req.user.id) || (req.user && req.user.id === 0)) ? req.user.id : null,
client: (req.client && req.client.slug) ? req.client.slug : null
}
});
@ -257,6 +262,7 @@ module.exports = {
mail: mail,
notifications: notifications,
posts: posts,
schedules: schedules,
roles: roles,
settings: settings,
tags: tags,

View file

@ -0,0 +1,85 @@
var _ = require('lodash'),
Promise = require('bluebird'),
moment = require('moment'),
config = require('../config'),
pipeline = require(config.paths.corePath + '/server/utils/pipeline'),
dataProvider = require(config.paths.corePath + '/server/models'),
i18n = require(config.paths.corePath + '/server/i18n'),
errors = require(config.paths.corePath + '/server/errors'),
apiPosts = require(config.paths.corePath + '/server/api/posts'),
utils = require('./utils');
/**
* publish a scheduled post
*
* object.force: you can force publishing a post in the past (for example if your service was down)
*/
exports.publishPost = function publishPost(object, options) {
if (_.isEmpty(options)) {
options = object || {};
object = {};
}
var post, publishedAtMoment,
publishAPostBySchedulerToleranceInMinutes = config.times.publishAPostBySchedulerToleranceInMinutes;
// CASE: only the scheduler client is allowed to publish (hardcoded because of missing client permission system)
if (!options.context || !options.context.client || options.context.client !== 'ghost-scheduler') {
return Promise.reject(new errors.NoPermissionError(i18n.t('errors.permissions.noPermissionToAction')));
}
options.context = {internal: true};
return pipeline([
utils.validate('posts', {opts: utils.idDefaultOptions}),
function (cleanOptions) {
cleanOptions.status = 'scheduled';
return apiPosts.read(cleanOptions)
.then(function (result) {
post = result.posts[0];
publishedAtMoment = moment(post.published_at);
if (publishedAtMoment.diff(moment(), 'minutes') > publishAPostBySchedulerToleranceInMinutes) {
return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.job.notFound')));
}
if (publishedAtMoment.diff(moment(), 'minutes') < publishAPostBySchedulerToleranceInMinutes * -1 && object.force !== true) {
return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.job.publishInThePast')));
}
return apiPosts.edit({posts: [{status: 'published'}]}, _.pick(cleanOptions, ['context', 'id']));
});
}
], options);
};
/**
* get all scheduled posts/pages
* permission check not needed, because route is not exposed
*/
exports.getScheduledPosts = function readPosts(options) {
options = options || {};
options.context = {internal: true};
return pipeline([
utils.validate('posts', {opts: ['from', 'to']}),
function (cleanOptions) {
cleanOptions.filter = 'status:scheduled';
cleanOptions.columns = ['id', 'published_at', 'created_at'];
if (cleanOptions.from) {
cleanOptions.filter += '+created_at:>=\'' + moment(cleanOptions.from).format('YYYY-MM-DD HH:mm:ss') + '\'';
}
if (cleanOptions.to) {
cleanOptions.filter += '+created_at:<=\'' + moment(cleanOptions.to).format('YYYY-MM-DD HH:mm:ss') + '\'';
}
return dataProvider.Post.findAll(cleanOptions)
.then(function (result) {
return Promise.resolve({posts: result.models});
});
}
], options);
};

View file

@ -43,6 +43,7 @@ utils = {
*/
return function doValidate() {
var object, options, permittedOptions;
if (arguments.length === 2) {
object = arguments[0];
options = _.clone(arguments[1]) || {};
@ -114,6 +115,8 @@ utils = {
slug: {isSlug: true},
page: {matches: /^\d+$/},
limit: {matches: /^\d+|all$/},
from: {isDate: true},
to: {isDate: true},
fields: {matches: /^[\w, ]+$/},
order: {matches: /^[a-z0-9_,\. ]+$/i},
name: {}

View file

@ -91,10 +91,13 @@ ConfigManager.prototype.init = function (rawConfig) {
*/
ConfigManager.prototype.set = function (config) {
var localPath = '',
defaultStorage = 'local-file-store',
defaultStorageAdapter = 'local-file-store',
defaultSchedulingAdapter = 'SchedulingDefault',
activeStorageAdapter,
activePostSchedulingAdapter,
contentPath,
activeStorage,
storagePath,
postSchedulingPath,
subdir,
assetHash;
@ -142,18 +145,28 @@ ConfigManager.prototype.set = function (config) {
assetHash = this._config.assetHash ||
(crypto.createHash('md5').update(packageInfo.version + Date.now()).digest('hex')).substring(0, 10);
// Protect against accessing a non-existent object.
// This ensures there's always at least a storage object
// because it's referenced in multiple places.
// read storage adapter from config file or attach default adapter
this._config.storage = this._config.storage || {};
activeStorage = this._config.storage.active || defaultStorage;
activeStorageAdapter = this._config.storage.active || defaultStorageAdapter;
if (activeStorage === defaultStorage) {
// read scheduling adapter(s) from config file or attach default adapter
this._config.scheduling = this._config.scheduling || {};
this._config.scheduling.postScheduling = this._config.scheduling.postScheduling || {};
activePostSchedulingAdapter = this._config.scheduling.postScheduling.active || defaultSchedulingAdapter;
// we support custom adapters located in content folder
if (activeStorageAdapter === defaultStorageAdapter) {
storagePath = path.join(corePath, '/server/storage/');
} else {
storagePath = path.join(contentPath, 'storage');
}
if (activePostSchedulingAdapter === defaultSchedulingAdapter) {
postSchedulingPath = path.join(corePath, '/server/scheduling/');
} else {
postSchedulingPath = path.join(contentPath, '/scheduling/');
}
_.merge(this._config, {
ghostVersion: packageInfo.version,
paths: {
@ -163,7 +176,7 @@ ConfigManager.prototype.set = function (config) {
configExample: path.join(appRoot, 'config.example.js'),
corePath: corePath,
storage: path.join(storagePath, activeStorage),
storage: path.join(storagePath, activeStorageAdapter),
contentPath: contentPath,
themePath: path.resolve(contentPath, 'themes'),
@ -179,8 +192,14 @@ ConfigManager.prototype.set = function (config) {
availableApps: this._config.paths.availableApps || {},
clientAssets: path.join(corePath, '/built/assets/')
},
scheduling: {
postScheduling: {
active: activePostSchedulingAdapter,
path: postSchedulingPath
}
},
storage: {
active: activeStorage
active: activeStorageAdapter
},
theme: {
// normalise the URL by removing any trailing slash
@ -216,7 +235,11 @@ ConfigManager.prototype.set = function (config) {
deprecatedItems: ['updateCheck', 'mail.fromaddress'],
// create a hash for cache busting assets
assetHash: assetHash,
preloadHeaders: this._config.preloadHeaders || false
preloadHeaders: this._config.preloadHeaders || false,
times: {
cannotScheduleAPostBeforeInMinutes: 2,
publishAPostBySchedulerToleranceInMinutes: 2
}
});
// Also pass config object to

View file

@ -0,0 +1,11 @@
function IncorrectUsage(message, context) {
this.name = 'IncorrectUsage';
this.stack = new Error().stack;
this.statusCode = 400;
this.errorType = this.name;
this.message = message;
this.context = context;
}
IncorrectUsage.prototype = Object.create(Error.prototype);
module.exports = IncorrectUsage;

View file

@ -19,6 +19,7 @@ var _ = require('lodash'),
TooManyRequestsError = require('./too-many-requests-error'),
TokenRevocationError = require('./token-revocation-error'),
VersionMismatchError = require('./version-mismatch-error'),
IncorrectUsage = require('./incorrect-usage'),
i18n = require('../i18n'),
config,
errors,
@ -447,3 +448,4 @@ module.exports.MethodNotAllowedError = MethodNotAllowedError;
module.exports.TooManyRequestsError = TooManyRequestsError;
module.exports.TokenRevocationError = TokenRevocationError;
module.exports.VersionMismatchError = VersionMismatchError;
module.exports.IncorrectUsage = IncorrectUsage;

View file

@ -6,8 +6,17 @@ var events = require('events'),
EventRegistry = function () {
events.EventEmitter.call(this);
};
util.inherits(EventRegistry, events.EventEmitter);
EventRegistry.prototype.onMany = function (arr, onEvent) {
var self = this;
arr.forEach(function (eventName) {
self.on(eventName, onEvent);
});
};
EventRegistryInstance = new EventRegistry();
EventRegistryInstance.setMaxListeners(100);

View file

@ -3,6 +3,7 @@
// Module dependencies
var express = require('express'),
_ = require('lodash'),
uuid = require('node-uuid'),
Promise = require('bluebird'),
i18n = require('./i18n'),
@ -18,6 +19,7 @@ var express = require('express'),
xmlrpc = require('./data/xml/xmlrpc'),
slack = require('./data/slack'),
GhostServer = require('./ghost-server'),
scheduling = require('./scheduling'),
validateThemes = require('./utils/validate-themes'),
dbHash;
@ -47,6 +49,8 @@ function initDbHashAndFirstRun() {
// Sets up the express server instances, runs init on a bunch of stuff, configures views, helpers, routes and more
// Finally it returns an instance of GhostServer
function init(options) {
var ghostServer = null;
// ### Initialisation
// The server and its dependencies require a populated config
// It returns a promise that is resolved when the application
@ -108,6 +112,14 @@ function init(options) {
});
return new GhostServer(parentApp);
}).then(function (_ghostServer) {
ghostServer = _ghostServer;
// scheduling can trigger api requests, that's why we initialize the module after the ghost server creation
// scheduling module can create x schedulers with different adapters
return scheduling.init(_.extend(config.scheduling, {apiUrl: config.url + config.urlFor('api')}));
}).then(function () {
return ghostServer;
});
}

View file

@ -7,19 +7,20 @@
// allowed to access data via the API.
var _ = require('lodash'),
bookshelf = require('bookshelf'),
moment = require('moment'),
Promise = require('bluebird'),
uuid = require('node-uuid'),
config = require('../../config'),
db = require('../../data/db'),
errors = require('../../errors'),
filters = require('../../filters'),
moment = require('moment'),
Promise = require('bluebird'),
schema = require('../../data/schema'),
utils = require('../../utils'),
labs = require('../../utils/labs'),
uuid = require('node-uuid'),
validation = require('../../data/validation'),
plugins = require('../plugins'),
i18n = require('../../i18n'),
ghostBookshelf,
proto;
@ -217,6 +218,10 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
// Get a specific updated attribute value
updated: function updated(attr) {
return this.updatedAttributes()[attr];
},
hasDateChanged: function (attr) {
return moment(this.get(attr)).diff(moment(this.updated(attr))) !== 0;
}
}, {
// ## Data Utility Functions

View file

@ -2,7 +2,7 @@ var config = require('../../config'),
events = require(config.paths.corePath + '/server/events'),
models = require(config.paths.corePath + '/server/models'),
errors = require(config.paths.corePath + '/server/errors'),
Promise = require('bluebird'),
sequence = require(config.paths.corePath + '/server/utils/sequence'),
moment = require('moment-timezone');
/**
@ -36,27 +36,29 @@ events.on('settings.activeTimezone.edited', function (settingModel) {
return;
}
return Promise.mapSeries(results.map(function (post) {
var newPublishedAtMoment = moment(post.get('published_at')).add(timezoneOffset, 'minutes');
return sequence(results.map(function (post) {
return function reschedulePostIfPossible() {
var newPublishedAtMoment = moment(post.get('published_at')).add(timezoneOffset, 'minutes');
/**
* CASE:
* - your configured TZ is GMT+01:00
* - now is 10AM +01:00 (9AM UTC)
* - your post should be published 8PM +01:00 (7PM UTC)
* - you reconfigure your blog TZ to GMT+08:00
* - now is 5PM +08:00 (9AM UTC)
* - if we don't change the published_at, 7PM + 8 hours === next day 5AM
* - so we update published_at to 7PM - 480minutes === 11AM UTC
* - 11AM UTC === 7PM +08:00
*/
if (newPublishedAtMoment.isBefore(moment().add(5, 'minutes'))) {
post.set('status', 'draft');
} else {
post.set('published_at', newPublishedAtMoment.toDate());
}
/**
* CASE:
* - your configured TZ is GMT+01:00
* - now is 10AM +01:00 (9AM UTC)
* - your post should be published 8PM +01:00 (7PM UTC)
* - you reconfigure your blog TZ to GMT+08:00
* - now is 5PM +08:00 (9AM UTC)
* - if we don't change the published_at, 7PM + 8 hours === next day 5AM
* - so we update published_at to 7PM - 480minutes === 11AM UTC
* - 11AM UTC === 7PM +08:00
*/
if (newPublishedAtMoment.isBefore(moment().add(5, 'minutes'))) {
post.set('status', 'draft');
} else {
post.set('published_at', newPublishedAtMoment.toDate());
}
return models.Post.edit(post.toJSON(), {id: post.id, context: {internal: true}}).reflect();
return models.Post.edit(post.toJSON(), {id: post.id, context: {internal: true}}).reflect();
};
})).each(function (result) {
if (!result.isFulfilled()) {
errors.logError(result.reason());

View file

@ -36,7 +36,7 @@ Client = ghostBookshelf.Model.extend({
// whitelists for the `options` hash argument on methods, by method name.
// these are the only options that can be passed to Bookshelf / Knex.
validOptions = {
findOne: ['withRelated']
findOne: ['columns', 'withRelated']
};
if (validOptions[methodName]) {

View file

@ -60,7 +60,8 @@ Post = ghostBookshelf.Model.extend({
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');
model.publishedAtHasChanged = model.hasDateChanged('published_at');
model.needsReschedule = model.publishedAtHasChanged && model.isScheduled;
// Handle added and deleted for post -> page or page -> post
if (model.resourceTypeChanging) {
@ -100,7 +101,7 @@ Post = ghostBookshelf.Model.extend({
}
// CASE: from scheduled to something
if (model.wasScheduled && !model.isScheduled) {
if (model.wasScheduled && !model.isScheduled && !model.isPublished) {
model.emitChange('unscheduled');
}
} else {
@ -137,13 +138,23 @@ Post = ghostBookshelf.Model.extend({
// Variables to make the slug checking more readable
newTitle = this.get('title'),
newStatus = this.get('status'),
olderStatus = this.previous('status'),
prevTitle = this._previousAttributes.title,
prevSlug = this._previousAttributes.slug,
tagsToCheck = this.get('tags'),
publishedAt = this.get('published_at'),
publishedAtHasChanged = this.hasDateChanged('published_at'),
tags = [];
// both page and post can get scheduled
// CASE: disallow published -> scheduled
// @TODO: remove when we have versioning based on updated_at
if (newStatus !== olderStatus && newStatus === 'scheduled' && olderStatus === 'published') {
return Promise.reject(new errors.ValidationError(
i18n.t('errors.models.post.isAlreadyPublished', {key: 'status'})
));
}
// CASE: both page and post can get scheduled
if (newStatus === 'scheduled') {
if (!publishedAt) {
return Promise.reject(new errors.ValidationError(
@ -153,13 +164,12 @@ Post = ghostBookshelf.Model.extend({
return Promise.reject(new errors.ValidationError(
i18n.t('errors.models.post.valueCannotBeBlank', {key: 'published_at'})
));
} else if (moment(publishedAt).isBefore(moment())) {
// CASE: to schedule/reschedule a post, a minimum diff of x minutes is needed (default configured is 2minutes)
} else if (publishedAtHasChanged && moment(publishedAt).isBefore(moment().add(config.times.cannotScheduleAPostBeforeInMinutes, 'minutes'))) {
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')
i18n.t('errors.models.post.expectedPublishedAtInFuture', {
cannotScheduleAPostBeforeInMinutes: config.times.cannotScheduleAPostBeforeInMinutes
})
));
}
}
@ -447,11 +457,11 @@ Post = ghostBookshelf.Model.extend({
// the status provided.
if (options.status && options.status !== 'all') {
// make sure that status is valid
options.status = _.includes(['published', 'draft'], options.status) ? options.status : 'published';
options.status = _.includes(['published', 'draft', 'scheduled'], options.status) ? options.status : 'published';
options.where.statements.push({prop: 'status', op: '=', value: options.status});
delete options.status;
} else if (options.status === 'all') {
options.where.statements.push({prop: 'status', op: 'IN', value: ['published', 'draft']});
options.where.statements.push({prop: 'status', op: 'IN', value: ['published', 'draft', 'scheduled']});
delete options.status;
}

View file

@ -44,6 +44,9 @@ apiRoutes = function apiRoutes(middleware) {
router.put('/posts/:id', authenticatePrivate, api.http(api.posts.edit));
router.del('/posts/:id', authenticatePrivate, api.http(api.posts.destroy));
// ## Schedules
router.put('/schedules/posts/:id', [middleware.api.authenticateClient, middleware.api.authenticateUser], api.http(api.schedules.publishPost));
// ## Settings
router.get('/settings', authenticatePrivate, api.http(api.settings.browse));
router.get('/settings/:key', authenticatePrivate, api.http(api.settings.read));

View file

@ -0,0 +1,8 @@
function SchedulingBase() {
Object.defineProperty(this, 'requiredFns', {
value: ['schedule', 'unschedule', 'reschedule', 'run'],
writable: false
});
}
module.exports = SchedulingBase;

View file

@ -0,0 +1,192 @@
var util = require('util'),
moment = require('moment'),
request = require('superagent'),
SchedulingBase = require(__dirname + '/SchedulingBase'),
errors = require(__dirname + '/../errors');
/**
* allJobs is a sorted list by time attribute
*/
function SchedulingDefault(options) {
SchedulingBase.call(this, options);
this.runTimeoutInMs = 1000 * 60 * 5;
this.offsetInMinutes = 10;
this.beforePingInMs = -50;
this.allJobs = {};
this.deletedJobs = {};
}
util.inherits(SchedulingDefault, SchedulingBase);
/**
* add to list
*/
SchedulingDefault.prototype.schedule = function (object) {
this._addJob(object);
};
/**
* remove from list
* add to list
*/
SchedulingDefault.prototype.reschedule = function (object) {
this._deleteJob({time: object.extra.oldTime, url: object.url});
this._addJob(object);
};
/**
* remove from list
* deletion happens right before execution
*/
SchedulingDefault.prototype.unschedule = function (object) {
this._deleteJob(object);
};
/**
* check if there are new jobs which needs to be published in the next x minutes
* because allJobs is a sorted list, we don't have to iterate over all jobs, just until the offset is too big
*/
SchedulingDefault.prototype.run = function () {
var self = this,
timeout = null;
timeout = setTimeout(function () {
var times = Object.keys(self.allJobs),
nextJobs = {};
times.every(function (time) {
if (moment(Number(time)).diff(moment(), 'minutes') <= self.offsetInMinutes) {
nextJobs[time] = self.allJobs[time];
delete self.allJobs[time];
return true;
}
// break!
return false;
});
clearTimeout(timeout);
self._execute(nextJobs);
// recursive!
self.run();
}, self.runTimeoutInMs);
};
/**
* each timestamp key entry can have multiple jobs
*/
SchedulingDefault.prototype._addJob = function (object) {
var timestamp = moment(object.time).valueOf(),
keys = [],
sortedJobs = {},
instantJob = {},
i = 0;
// CASE: should have been already pinged or should be pinged soon
if (moment(timestamp).diff(moment(), 'minutes') < this.offsetInMinutes) {
instantJob[timestamp] = [object];
this._execute(instantJob);
return;
}
// CASE: are there jobs already scheduled for the same time?
if (!this.allJobs[timestamp]) {
this.allJobs[timestamp] = [];
}
this.allJobs[timestamp].push(object);
keys = Object.keys(this.allJobs);
keys.sort();
for (i = 0; i < keys.length; i = i + 1) {
sortedJobs[keys[i]] = this.allJobs[keys[i]];
}
this.allJobs = sortedJobs;
};
SchedulingDefault.prototype._deleteJob = function (object) {
this.deletedJobs[object.url + '_' + moment(object.time).valueOf()] = true;
};
/**
* ping jobs
* setTimeout is not accurate, but we can live with that fact and use setImmediate feature to qualify
* we don't want to use process.nextTick, this would block any I/O operation
*/
SchedulingDefault.prototype._execute = function (jobs) {
var keys = Object.keys(jobs),
self = this;
keys.forEach(function (timestamp) {
var timeout = null,
diff = moment(Number(timestamp)).diff(moment());
// awake a little before
timeout = setTimeout(function () {
clearTimeout(timeout);
(function retry() {
var immediate = setImmediate(function () {
clearImmediate(immediate);
if (moment().diff(moment(Number(timestamp))) <= self.beforePingInMs) {
return retry();
}
var toExecute = jobs[timestamp];
delete jobs[timestamp];
toExecute.forEach(function (job) {
var deleteKey = job.url + '_' + moment(job.time).valueOf();
if (self.deletedJobs[deleteKey]) {
delete self.deletedJobs[deleteKey];
return;
}
self._pingUrl(job);
});
});
})();
}, diff - 200);
});
};
/**
* if we detect to publish a post in the past (case blog is down)
* we add a force flag
*/
SchedulingDefault.prototype._pingUrl = function (object) {
var url = object.url,
time = object.time,
httpMethod = object.extra.httpMethod,
req = request[httpMethod.toLowerCase()](url);
if (moment(time).isBefore(moment())) {
if (httpMethod === 'GET') {
req.query('force=true');
} else {
req.send({
force: true
});
}
}
req.end(function (err, response) {
if (err) {
// CASE: post/page was deleted already
if (response && response.status === 404) {
return;
}
errors.logError(err);
}
});
};
module.exports = SchedulingDefault;

View file

@ -0,0 +1,12 @@
var _ = require('lodash'),
postScheduling = require(__dirname + '/post-scheduling');
/**
* scheduling modules:
* - post scheduling: publish posts/pages when scheduled
*/
exports.init = function init(options) {
options = options || {};
return postScheduling.init(_.pick(options, 'postScheduling', 'apiUrl'));
};

View file

@ -0,0 +1,99 @@
var Promise = require('bluebird'),
moment = require('moment'),
utils = require(__dirname + '/../utils'),
events = require(__dirname + '/../../events'),
errors = require(__dirname + '/../../errors'),
models = require(__dirname + '/../../models'),
schedules = require(__dirname + '/../../api/schedules'),
_private = {};
_private.normalize = function normalize(options) {
var object = options.object,
apiUrl = options.apiUrl,
client = options.client;
return {
time: object.get('published_at'),
url: apiUrl + '/schedules/posts/' + object.get('id') + '?client_id=' + client.get('slug') + '&client_secret=' + client.get('secret'),
extra: {
httpMethod: 'PUT',
oldTime: object.updated('published_at') || null
}
};
};
_private.loadClient = function loadClient() {
return models.Client.findOne({slug: 'ghost-scheduler'}, {columns: ['slug', 'secret']});
};
_private.loadScheduledPosts = function () {
return schedules.getScheduledPosts({
from: moment().subtract(7, 'days').startOf('day').toDate(),
to: moment().endOf('day').toDate()
}).then(function (result) {
return result.posts || [];
});
};
exports.init = function init(options) {
options = options || {};
var config = options.postScheduling,
apiUrl = options.apiUrl,
adapter = null,
client = null;
if (!config) {
return Promise.reject(new errors.IncorrectUsage('post-scheduling: no config was provided'));
}
if (!apiUrl) {
return Promise.reject(new errors.IncorrectUsage('post-scheduling: no apiUrl was provided'));
}
return _private.loadClient()
.then(function (_client) {
client = _client;
return utils.createAdapter(config);
})
.then(function (_adapter) {
adapter = _adapter;
return _private.loadScheduledPosts();
})
.then(function (scheduledPosts) {
if (!scheduledPosts.length) {
return;
}
scheduledPosts.forEach(function (object) {
adapter.reschedule(_private.normalize({object: object, apiUrl: apiUrl, client: client}));
});
})
.then(function () {
adapter.run();
})
.then(function () {
events.onMany([
'post.scheduled',
'page.scheduled'
], function (object) {
adapter.schedule(_private.normalize({object: object, apiUrl: apiUrl, client: client}));
});
events.onMany([
'post.rescheduled',
'page.rescheduled'
], function (object) {
adapter.reschedule(_private.normalize({object: object, apiUrl: apiUrl, client: client}));
});
events.onMany([
'post.unscheduled',
'page.unscheduled'
], function (object) {
adapter.unschedule(_private.normalize({object: object, apiUrl: apiUrl, client: client}));
});
});
};

View file

@ -0,0 +1,54 @@
var _ = require('lodash'),
Promise = require('bluebird'),
SchedulingBase = require(__dirname + '/SchedulingBase'),
errors = require(__dirname + '/../errors');
exports.createAdapter = function (options) {
options = options || {};
var adapter = null,
activeAdapter = options.active,
path = options.path;
if (!activeAdapter) {
return Promise.reject(new errors.IncorrectUsage('Please provide an active adapter.'));
}
/**
* CASE: active adapter is a npm module
*/
try {
adapter = new (require(activeAdapter))(options);
} catch (err) {
if (err.code !== 'MODULE_NOT_FOUND') {
return Promise.reject(new errors.IncorrectUsage(err.message));
}
}
/**
* CASE: active adapter is located in specific ghost path
*/
try {
adapter = adapter || new (require(path + activeAdapter))(options);
} catch (err) {
if (err.code === 'MODULE_NOT_FOUND') {
return Promise.reject(new errors.IncorrectUsage('MODULE_NOT_FOUND', activeAdapter));
}
return Promise.reject(new errors.IncorrectUsage(err.message));
}
if (!(adapter instanceof SchedulingBase)) {
return Promise.reject(new errors.IncorrectUsage('Your adapter does not inherit from the SchedulingBase.'));
}
if (!adapter.requiredFns) {
return Promise.reject(new errors.IncorrectUsage('Your adapter does not provide the minimum required functions.'));
}
if (_.xor(adapter.requiredFns, Object.keys(_.pick(Object.getPrototypeOf(adapter), adapter.requiredFns))).length) {
return Promise.reject(new errors.IncorrectUsage('Your adapter does not provide the minimum required functions.'));
}
return Promise.resolve(adapter);
};

View file

@ -206,7 +206,8 @@
"post": {
"untitled": "(Untitled)",
"valueCannotBeBlank": "Value in {key} cannot be blank.",
"expectedPublishedAtInFuture": "Expected published_at to be in the future.",
"isAlreadyPublished": "Your post is already published, please reload your page.",
"expectedPublishedAtInFuture": "Date must be at least {cannotScheduleAPostBeforeInMinutes} minutes in the future.",
"noUserFound": "No user found",
"notEnoughPermission": "You do not have permission to perform this action",
"tagUpdates": {
@ -266,7 +267,7 @@
},
"permissions": {
"noActionsMapFound": {
"error": "No actions map found, please call permissions.init() before use."
"error": "No actions map found, ensure you have loaded permissions into database and then call permissions.init() before use."
},
"applyStatusRules": {
"error": "You do not have permission to retrieve {docName} with that status"
@ -323,6 +324,10 @@
"posts": {
"postNotFound": "Post not found."
},
"job": {
"notFound": "Job not found.",
"publishInThePast": "Use the force flag to publish a post in the past."
},
"settings": {
"problemFindingSetting": "Problem finding setting: {key}",
"accessCoreSettingFromExtReq": "Attempted to access core setting from external request",

View file

@ -1,11 +1,14 @@
var Promise = require('bluebird');
/**
* expects an array of functions returning a promise
*/
function sequence(tasks /* Any Arguments */) {
var args = Array.prototype.slice.call(arguments, 1);
return Promise.reduce(tasks, function (results, task) {
return task.apply(this, args).then(function (result) {
results.push(result);
return results;
});
}, []);

View file

@ -94,7 +94,7 @@ describe('Post API', function () {
var jsonResponse = res.body;
should.exist(jsonResponse.posts);
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(8);
jsonResponse.posts.should.have.length(9);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
testUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
done();
@ -166,6 +166,28 @@ describe('Post API', function () {
done();
});
});
it('can retrieve just scheduled posts', function (done) {
request.get(testUtils.API.getApiQuery('posts/?status=scheduled'))
.set('Authorization', 'Bearer ' + accesstoken)
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
should.exist(jsonResponse.posts);
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(1);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
testUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
done();
});
});
});
// ## Read

View file

@ -2,8 +2,7 @@
var testUtils = require('../../utils'),
should = require('should'),
_ = require('lodash'),
// Stuff we are testing
errors = require('../../../server/errors'),
PostAPI = require('../../../server/api/posts');
describe('Post API', function () {
@ -183,10 +182,10 @@ describe('Post API', function () {
should.exist(results);
testUtils.API.checkResponse(results, 'posts');
should.exist(results.posts);
results.posts.length.should.eql(5);
results.posts[0].status.should.eql('draft');
testUtils.API.checkResponse(results.posts[0], 'post');
// DataGenerator creates 6 posts by default + 2 static pages
results.posts.length.should.eql(6);
testUtils.API.checkResponse(results.posts[0], 'post');
done();
}).catch(done);
});
@ -245,7 +244,7 @@ describe('Post API', function () {
it('can fetch all posts for an author', function (done) {
PostAPI.browse({context: {user: 1}, status: 'all', filter: 'author:joe-bloggs', include: 'author'}).then(function (results) {
should.exist(results.posts);
results.posts.length.should.eql(5);
results.posts.length.should.eql(6);
_.each(results.posts, function (post) {
post.author.slug.should.eql('joe-bloggs');
@ -342,7 +341,7 @@ describe('Post API', function () {
it('can order posts using asc', function (done) {
var posts, expectedTitles;
posts = _(testUtils.DataGenerator.Content.posts).reject('page').reject({status: 'scheduled'}).value();
posts = _(testUtils.DataGenerator.Content.posts).reject('page').value();
expectedTitles = _(posts).map('title').sortBy().value();
PostAPI.browse({context: {user: 1}, status: 'all', order: 'title asc', fields: 'title'}).then(function (results) {
@ -358,7 +357,7 @@ describe('Post API', function () {
it('can order posts using desc', function (done) {
var posts, expectedTitles;
posts = _(testUtils.DataGenerator.Content.posts).reject('page').reject({status: 'scheduled'}).value();
posts = _(testUtils.DataGenerator.Content.posts).reject('page').value();
expectedTitles = _(posts).map('title').sortBy().reverse().value();
PostAPI.browse({context: {user: 1}, status: 'all', order: 'title DESC', fields: 'title'}).then(function (results) {
@ -374,7 +373,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').reject({status: 'scheduled'}).value();
posts = _(testUtils.DataGenerator.Content.posts).reject('page').value();
expectedTitles = _(posts).map('title').sortBy().value();
PostAPI.browse({context: {user: 1}, status: 'all', order: 'bunny DESC, title ASC', fields: 'title'}).then(function (results) {
@ -586,6 +585,24 @@ describe('Post API', function () {
});
describe('Edit', function () {
it('can edit own post', function (done) {
PostAPI.edit({posts:[{status: 'test'}]}, {context: {user: 1}, id: 1}).then(function (results) {
should.exist(results.posts);
done();
}).catch(done);
});
it('cannot edit others post', function (done) {
testUtils.fixtures.insertOne('users', 'createUser', 4)
.then(function (result) {
PostAPI.edit({posts: [{status: 'test'}]}, {context: {user: result[0]}, id: 1}).catch(function (err) {
should.exist(err);
(err instanceof errors.NoPermissionError).should.eql(true);
done();
});
});
});
// These tests are for #6920
it('should update post & not delete tags with `tags` not included', function (done) {
var options = {context: {user: 1}, id: 1},

View file

@ -0,0 +1,451 @@
/*globals describe, it, after, before, beforeEach, afterEach */
var should = require('should'),
moment = require('moment'),
Promise = require('bluebird'),
testUtils = require('../../utils'),
config = require(__dirname + '/../../../server/config'),
sequence = require(config.paths.corePath + '/server/utils/sequence'),
errors = require(config.paths.corePath + '/server/errors'),
api = require(config.paths.corePath + '/server/api'),
models = require(config.paths.corePath + '/server/models');
describe('Schedules API', function () {
var scope = {posts: []};
after(function (done) {
testUtils.teardown(done);
});
describe('fn: getScheduledPosts', function () {
before(function (done) {
sequence([
testUtils.teardown,
testUtils.setup('clients', 'users:roles', 'perms:post', 'perms:init')
]).then(function () {
done();
}).catch(done);
});
describe('success', function () {
before(function (done) {
scope.posts.push(testUtils.DataGenerator.forKnex.createPost({
created_by: testUtils.users.ids.editor,
author_id: testUtils.users.ids.editor,
published_by: testUtils.users.ids.editor,
created_at: moment().add(2, 'days').set('hours', 8).toDate(),
published_at: moment().add(5, 'days').toDate(),
status: 'scheduled',
slug: '2'
}));
scope.posts.push(testUtils.DataGenerator.forKnex.createPost({
created_by: testUtils.users.ids.owner,
author_id: testUtils.users.ids.owner,
published_by: testUtils.users.ids.owner,
created_at: moment().add(2, 'days').set('hours', 12).toDate(),
published_at: moment().add(5, 'days').toDate(),
status: 'scheduled',
page: 1,
slug: '5'
}));
scope.posts.push(testUtils.DataGenerator.forKnex.createPost({
created_by: testUtils.users.ids.author,
author_id: testUtils.users.ids.author,
published_by: testUtils.users.ids.author,
created_at: moment().add(5, 'days').set('hours', 6).toDate(),
published_at: moment().add(10, 'days').toDate(),
status: 'scheduled',
slug: '1'
}));
scope.posts.push(testUtils.DataGenerator.forKnex.createPost({
created_by: testUtils.users.ids.owner,
author_id: testUtils.users.ids.owner,
published_by: testUtils.users.ids.owner,
created_at: moment().add(6, 'days').set('hours', 10).set('minutes', 0).toDate(),
published_at: moment().add(7, 'days').toDate(),
status: 'scheduled',
slug: '3'
}));
scope.posts.push(testUtils.DataGenerator.forKnex.createPost({
created_by: testUtils.users.ids.owner,
author_id: testUtils.users.ids.owner,
published_by: testUtils.users.ids.owner,
created_at: moment().add(6, 'days').set('hours', 11).toDate(),
published_at: moment().add(8, 'days').toDate(),
status: 'scheduled',
slug: '4'
}));
scope.posts.push(testUtils.DataGenerator.forKnex.createPost({
created_by: testUtils.users.ids.owner,
author_id: testUtils.users.ids.owner,
published_by: testUtils.users.ids.owner,
status: 'draft',
slug: '6'
}));
Promise.all(scope.posts.map(function (post) {
return models.Post.add(post, {context: {internal: true}, importing: true});
})).then(function () {
return done();
}).catch(done);
});
after(function () {
scope.posts = [];
});
it('all', function (done) {
api.schedules.getScheduledPosts()
.then(function (result) {
result.posts.length.should.eql(5);
Object.keys(result.posts[0].toJSON()).should.eql(['id', 'published_at', 'created_at', 'author', 'url']);
done();
})
.catch(done);
});
it('for specific datetime', function (done) {
api.schedules.getScheduledPosts({
from: moment().add(2, 'days').startOf('day').toDate(),
to: moment().add(2, 'days').endOf('day').toDate()
}).then(function (result) {
result.posts.length.should.eql(2);
done();
}).catch(done);
});
it('for specific datetime', function (done) {
api.schedules.getScheduledPosts({
from: moment().add(2, 'days').startOf('day').toDate(),
to: moment().add(2, 'days').set('hours', 8).toDate()
}).then(function (result) {
result.posts.length.should.eql(1);
done();
}).catch(done);
});
it('for specific date', function (done) {
api.schedules.getScheduledPosts({
from: moment().add(5, 'days').startOf('day').toDate(),
to: moment().add(6, 'days').endOf('day').toDate()
}).then(function (result) {
result.posts.length.should.eql(3);
done();
}).catch(done);
});
it('for specific date', function (done) {
api.schedules.getScheduledPosts({
from: moment().add(6, 'days').set('hours', 10).set('minutes', 30).toDate(),
to: moment().add(6, 'days').endOf('day').toDate()
}).then(function (result) {
result.posts.length.should.eql(1);
done();
}).catch(done);
});
it('for specific date', function (done) {
api.schedules.getScheduledPosts({
from: moment().add(1, 'days').toDate()
}).then(function (result) {
result.posts.length.should.eql(5);
done();
}).catch(done);
});
});
describe('error', function () {
it('from is invalid', function (done) {
api.schedules.getScheduledPosts({
from: 'bee'
}).catch(function (err) {
should.exist(err);
(err instanceof errors.ValidationError).should.eql(true);
done();
});
});
});
});
describe('fn: publishPost', function () {
var originalCannotScheduleAPostBeforeInMinutes;
beforeEach(function (done) {
originalCannotScheduleAPostBeforeInMinutes = config.times.cannotScheduleAPostBeforeInMinutes;
// we can insert published_at less then 5minutes
config.times.cannotScheduleAPostBeforeInMinutes = -15;
sequence([
testUtils.teardown,
testUtils.setup('clients', 'users:roles', 'perms:post', 'perms:init')
]).then(function () {
done();
}).catch(done);
});
after(function () {
config.times.cannotScheduleAPostBeforeInMinutes = originalCannotScheduleAPostBeforeInMinutes;
});
describe('success', function () {
beforeEach(function (done) {
scope.posts.push(testUtils.DataGenerator.forKnex.createPost({
created_by: testUtils.users.ids.author,
author_id: testUtils.users.ids.author,
published_by: testUtils.users.ids.author,
published_at: moment().toDate(),
status: 'scheduled',
slug: 'first'
}));
scope.posts.push(testUtils.DataGenerator.forKnex.createPost({
created_by: testUtils.users.ids.author,
author_id: testUtils.users.ids.author,
published_by: testUtils.users.ids.author,
published_at: moment().add(30, 'seconds').toDate(),
status: 'scheduled',
slug: 'second'
}));
scope.posts.push(testUtils.DataGenerator.forKnex.createPost({
created_by: testUtils.users.ids.author,
author_id: testUtils.users.ids.author,
published_by: testUtils.users.ids.author,
published_at: moment().subtract(30, 'seconds').toDate(),
status: 'scheduled',
slug: 'third'
}));
scope.posts.push(testUtils.DataGenerator.forKnex.createPost({
created_by: testUtils.users.ids.author,
author_id: testUtils.users.ids.author,
published_by: testUtils.users.ids.author,
published_at: moment().subtract(10, 'minute').toDate(),
status: 'scheduled',
slug: 'fourth'
}));
Promise.all(scope.posts.map(function (post) {
return models.Post.add(post, {context: {internal: true}});
})).then(function (result) {
// returns id 1 and 2, but hard to check, because PG returns a different order
result.length.should.eql(4);
return done();
}).catch(done);
});
afterEach(function () {
scope.posts = [];
});
it('client with specific perms has access to publish post', function (done) {
api.schedules.publishPost({id: 1, context: {client: 'ghost-scheduler'}})
.then(function (result) {
result.posts[0].id.should.eql(1);
result.posts[0].status.should.eql('published');
done();
})
.catch(done);
});
it('can publish with tolerance (30 seconds in the future)', function (done) {
api.schedules.publishPost({id: 2, context: {client: 'ghost-scheduler'}})
.then(function (result) {
result.posts[0].id.should.eql(2);
result.posts[0].status.should.eql('published');
done();
})
.catch(done);
});
it('can publish with tolerance (30seconds in the past)', function (done) {
api.schedules.publishPost({id: 3, context: {client: 'ghost-scheduler'}})
.then(function (result) {
result.posts[0].id.should.eql(3);
result.posts[0].status.should.eql('published');
done();
})
.catch(done);
});
it('can publish a post in the past with force flag', function (done) {
api.schedules.publishPost({force: true}, {id: 4, context: {client: 'ghost-scheduler'}})
.then(function (result) {
result.posts[0].id.should.eql(4);
result.posts[0].status.should.eql('published');
done();
})
.catch(done);
});
});
describe('error', function () {
beforeEach(function (done) {
scope.posts.push(testUtils.DataGenerator.forKnex.createPost({
created_by: testUtils.users.ids.author,
author_id: testUtils.users.ids.author,
published_by: testUtils.users.ids.author,
published_at: moment().add(2, 'days').toDate(),
status: 'scheduled',
slug: 'first'
}));
scope.posts.push(testUtils.DataGenerator.forKnex.createPost({
created_by: testUtils.users.ids.author,
author_id: testUtils.users.ids.author,
published_by: testUtils.users.ids.author,
published_at: moment().add(2, 'days').toDate(),
status: 'draft',
slug: 'second'
}));
scope.posts.push(testUtils.DataGenerator.forKnex.createPost({
created_by: testUtils.users.ids.author,
author_id: testUtils.users.ids.author,
published_by: testUtils.users.ids.author,
published_at: moment().add(4, 'minutes').toDate(),
status: 'scheduled',
slug: 'third'
}));
scope.posts.push(testUtils.DataGenerator.forKnex.createPost({
created_by: testUtils.users.ids.author,
author_id: testUtils.users.ids.author,
published_by: testUtils.users.ids.author,
published_at: moment().subtract(4, 'minutes').toDate(),
status: 'scheduled',
slug: 'fourth'
}));
Promise.all(scope.posts.map(function (post) {
return models.Post.add(post, {context: {internal: true}});
})).then(function (result) {
result.length.should.eql(4);
return done();
}).catch(done);
});
afterEach(function () {
scope.posts = [];
});
it('ghost admin has no access', function (done) {
api.schedules.publishPost({id: 1, context: {client: 'ghost-admin'}})
.then(function () {
done(new Error('expected NoPermissionError'));
})
.catch(function (err) {
should.exist(err);
(err instanceof errors.NoPermissionError).should.eql(true);
done();
});
});
it('owner has no access (this is how it is right now!)', function (done) {
api.schedules.publishPost({id: 2, context: {user: testUtils.users.ids.author}})
.then(function () {
done(new Error('expected NoPermissionError'));
})
.catch(function (err) {
should.exist(err);
(err instanceof errors.NoPermissionError).should.eql(true);
done();
});
});
it('other user has no access', function (done) {
testUtils.fixtures.insertOne('users', 'createUser', 4)
.then(function (result) {
api.schedules.publishPost({id: 1, context: {user: result[0]}})
.then(function () {
done(new Error('expected NoPermissionError'));
})
.catch(function (err) {
should.exist(err);
(err instanceof errors.NoPermissionError).should.eql(true);
done();
});
})
.catch(done);
});
it('invalid params', function (done) {
api.schedules.publishPost({id: 'bla', context: {client: 'ghost-scheduler'}})
.then(function () {
done(new Error('expected ValidationError'));
})
.catch(function (err) {
should.exist(err);
(err instanceof errors.ValidationError).should.eql(true);
done();
});
});
it('post does not exist', function (done) {
api.schedules.publishPost({id: 10, context: {client: 'ghost-scheduler'}})
.then(function () {
done(new Error('expected ValidationError'));
})
.catch(function (err) {
should.exist(err);
(err instanceof errors.NotFoundError).should.eql(true);
done();
});
});
it('publish at a wrong time', function (done) {
api.schedules.publishPost({id: 1, context: {client: 'ghost-scheduler'}})
.then(function () {
done(new Error('expected ValidationError'));
})
.catch(function (err) {
should.exist(err);
(err instanceof errors.NotFoundError).should.eql(true);
done();
});
});
it('publish at a wrong time', function (done) {
api.schedules.publishPost({id: 3, context: {client: 'ghost-scheduler'}})
.then(function () {
done(new Error('expected ValidationError'));
})
.catch(function (err) {
should.exist(err);
(err instanceof errors.NotFoundError).should.eql(true);
done();
});
});
it('publish at a wrong time', function (done) {
api.schedules.publishPost({id: 4, context: {client: 'ghost-scheduler'}})
.then(function () {
done(new Error('expected ValidationError'));
})
.catch(function (err) {
should.exist(err);
(err instanceof errors.NotFoundError).should.eql(true);
done();
});
});
it('publish, but status is draft', function (done) {
api.schedules.publishPost({id: 2, context: {client: 'ghost-scheduler'}})
.then(function () {
done(new Error('expected ValidationError'));
})
.catch(function (err) {
should.exist(err);
(err instanceof errors.NotFoundError).should.eql(true);
done();
});
});
});
});
});

View file

@ -238,7 +238,7 @@ describe('Post Model', function () {
paginationResult.meta.pagination.page.should.equal(1);
paginationResult.meta.pagination.limit.should.equal('all');
paginationResult.meta.pagination.pages.should.equal(1);
paginationResult.posts.length.should.equal(107);
paginationResult.posts.length.should.equal(108);
done();
}).catch(done);
@ -573,6 +573,27 @@ describe('Post Model', function () {
}).catch(done);
});
it('scheduled -> scheduled with unchanged 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'
}, _.extend({}, context, {id: post.id}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('scheduled');
eventSpy.callCount.should.eql(1);
eventSpy.firstCall.calledWith('post.edited').should.be.true();
done();
}).catch(done);
});
it('published -> scheduled and expect update of published_at', function (done) {
var postId = 1;
@ -587,16 +608,13 @@ describe('Post Model', function () {
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();
}).then(function () {
done(new Error('change status from published to scheduled is not allowed right now!'));
}).catch(function (err) {
should.exist(err);
(err instanceof errors.ValidationError).should.eql(true);
done();
}).catch(done);
});
});
it('can convert draft post to page and back', function (done) {

View file

@ -0,0 +1,33 @@
/*globals describe, it */
var should = require('should'),
rewire = require('rewire'),
config = rewire('../../../server/config'),
api = rewire(config.paths.corePath + '/server/api');
describe('API: index', function () {
describe('fn: cacheInvalidationHeader', function () {
it('/schedules/posts should invalidate cache', function () {
var cacheInvalidationHeader = api.__get__('cacheInvalidationHeader'),
result = cacheInvalidationHeader({
_parsedUrl: {
pathname: '/schedules/posts/1'
},
method: 'PUT'
}, {});
result.should.eql('/*');
});
it('/schedules/something should NOT invalidate cache', function () {
var cacheInvalidationHeader = api.__get__('cacheInvalidationHeader'),
result = cacheInvalidationHeader({
_parsedUrl: {
pathname: '/schedules/something'
},
method: 'PUT'
}, {});
should.not.exist(result);
});
});
});

View file

@ -156,9 +156,20 @@ describe('API Utils', function () {
}).catch(done);
});
it('should reject if invalid options are passed', function (done) {
it('should reject if limit is invalid', function (done) {
apiUtils.validate('test', {opts: apiUtils.browseDefaultOptions})(
{context: 'internal', include: 'stuff', page: 1, limit: 'none'}
{limit: 'none'}
).then(function () {
done(new Error('Should have thrown a validation error'));
}).catch(function (err) {
err.should.have.property('errorType', 'ValidationError');
done();
});
});
it('should reject if from is invalid', function (done) {
apiUtils.validate('test', {opts: ['from']})(
{from: true}
).then(function () {
done(new Error('Should have thrown a validation error'));
}).catch(function (err) {

View file

@ -0,0 +1,286 @@
/*globals describe, it, before, afterEach*/
var config = require(__dirname + '/../../../server/config'),
moment = require('moment'),
_ = require('lodash'),
should = require('should'),
express = require('express'),
bodyParser = require('body-parser'),
http = require('http'),
sinon = require('sinon');
describe('Scheduling Default Adapter', function () {
var scope = {};
before(function () {
scope.SchedulingDefault = require(config.paths.corePath + '/server/scheduling/SchedulingDefault');
scope.adapter = new scope.SchedulingDefault();
});
afterEach(function () {
scope.adapter.allJobs = {};
});
describe('success', function () {
it('addJob (schedule)', function () {
sinon.stub(scope.adapter, 'run');
sinon.stub(scope.adapter, '_execute');
var dates = [
moment().add(1, 'day').subtract(30, 'seconds').toDate(),
moment().add(7, 'minutes').toDate(),
// over 10minutes offset
moment().add(12, 'minutes').toDate(),
moment().add(20, 'minutes').toDate(),
moment().add(15, 'minutes').toDate(),
moment().add(15, 'minutes').add(10, 'seconds').toDate(),
moment().add(15, 'minutes').subtract(30, 'seconds').toDate(),
moment().add(50, 'seconds').toDate()
];
dates.forEach(function (time) {
scope.adapter._addJob({
time: time,
url: 'something'
});
});
// 2 jobs get immediately executed
should.not.exist(scope.adapter.allJobs[moment(dates[1]).valueOf()]);
should.not.exist(scope.adapter.allJobs[moment(dates[7]).valueOf()]);
scope.adapter._execute.calledTwice.should.eql(true);
Object.keys(scope.adapter.allJobs).length.should.eql(dates.length - 2);
Object.keys(scope.adapter.allJobs).should.eql([
moment(dates[2]).valueOf().toString(),
moment(dates[6]).valueOf().toString(),
moment(dates[4]).valueOf().toString(),
moment(dates[5]).valueOf().toString(),
moment(dates[3]).valueOf().toString(),
moment(dates[0]).valueOf().toString()
]);
scope.adapter.run.restore();
scope.adapter._execute.restore();
});
it('run', function (done) {
var timestamps = _.map(_.range(1000), function (i) {
return moment().add(i, 'seconds').valueOf();
}),
allJobs = {};
sinon.stub(scope.adapter, '_execute', function (nextJobs) {
Object.keys(nextJobs).length.should.eql(182);
Object.keys(scope.adapter.allJobs).length.should.eql(1000 - 182);
scope.adapter._execute.restore();
done();
});
timestamps.forEach(function (timestamp) {
allJobs[timestamp] = [{url: 'xxx'}];
});
scope.adapter.allJobs = allJobs;
scope.adapter.runTimeoutInMs = 1000;
scope.adapter.offsetInMinutes = 2;
scope.adapter.run();
});
it('execute', function (done) {
var pinged = 0,
jobs = 3,
timestamps = _.map(_.range(jobs), function (i) {
return moment().add(1, 'seconds').add(i * 100, 'milliseconds').valueOf();
}),
nextJobs = {};
sinon.stub(scope.adapter, 'run');
sinon.stub(scope.adapter, '_pingUrl', function () {
pinged = pinged + 1;
});
timestamps.forEach(function (timestamp) {
nextJobs[timestamp] = [{url: 'xxx'}];
});
scope.adapter._execute(nextJobs);
(function retry() {
if (pinged !== jobs) {
return setTimeout(retry, 100);
}
scope.adapter.run.restore();
scope.adapter._pingUrl.restore();
done();
})();
});
it('delete job (unschedule)', function (done) {
sinon.stub(scope.adapter, 'run');
sinon.stub(scope.adapter, '_pingUrl');
// add 3 jobs to delete
var jobs = {};
jobs[moment().add(500, 'milliseconds').valueOf()] = [{url: '/first', time: 1234}];
jobs[moment().add(550, 'milliseconds').valueOf()] = [{url: '/first', time: 1235}];
jobs[moment().add(600, 'milliseconds').valueOf()] = [{url: '/second', time: 1236}];
_.map(jobs, function (value) {
scope.adapter._deleteJob(value[0]);
});
// add another, which will be pinged
jobs[moment().add(650, 'milliseconds').valueOf()] = [{url: '/third', time: 1237}];
// simulate execute is called
scope.adapter._execute(jobs);
(function retry() {
if (!scope.adapter._pingUrl.called) {
return setTimeout(retry, 10);
}
Object.keys(scope.adapter.deletedJobs).length.should.eql(0);
scope.adapter._pingUrl.calledOnce.should.eql(true);
scope.adapter.run.restore();
scope.adapter._pingUrl.restore();
done();
})();
});
it('pingUrl (PUT)', function (done) {
var app = express(),
server = http.createServer(app),
wasPinged = false,
reqBody;
app.use(bodyParser.json());
app.put('/ping', function (req, res) {
wasPinged = true;
reqBody = req.body;
res.sendStatus(200);
});
server.listen(1111);
scope.adapter._pingUrl({
url: 'http://localhost:1111/ping',
time: moment().add(1, 'second').valueOf(),
extra: {
httpMethod: 'PUT'
}
});
(function retry() {
if (wasPinged) {
should.not.exist(reqBody.force);
return server.close(done);
}
setTimeout(retry, 100);
})();
});
it('pingUrl (GET)', function (done) {
var app = express(),
server = http.createServer(app),
wasPinged = false,
reqQuery;
app.get('/ping', function (req, res) {
wasPinged = true;
reqQuery = req.query;
res.sendStatus(200);
});
server.listen(1111);
scope.adapter._pingUrl({
url: 'http://localhost:1111/ping',
time: moment().add(1, 'second').valueOf(),
extra: {
httpMethod: 'GET'
}
});
(function retry() {
if (wasPinged) {
should.not.exist(reqQuery.force);
return server.close(done);
}
setTimeout(retry, 100);
})();
});
it('pingUrl (PUT, and detect publish in the past)', function (done) {
var app = express(),
server = http.createServer(app),
wasPinged = false,
reqBody;
app.use(bodyParser.json());
app.put('/ping', function (req, res) {
wasPinged = true;
reqBody = req.body;
res.sendStatus(200);
});
server.listen(1111);
scope.adapter._pingUrl({
url: 'http://localhost:1111/ping',
time: moment().subtract(10, 'minutes').valueOf(),
extra: {
httpMethod: 'PUT'
}
});
(function retry() {
if (wasPinged) {
should.exist(reqBody.force);
return server.close(done);
}
setTimeout(retry, 100);
})();
});
it('pingUrl (GET, and detect publish in the past)', function (done) {
var app = express(),
server = http.createServer(app),
wasPinged = false,
reqQuery;
app.get('/ping', function (req, res) {
wasPinged = true;
reqQuery = req.query;
res.sendStatus(200);
});
server.listen(1111);
scope.adapter._pingUrl({
url: 'http://localhost:1111/ping',
time: moment().subtract(10, 'minutes').valueOf(),
extra: {
httpMethod: 'GET'
}
});
(function retry() {
if (wasPinged) {
should.exist(reqQuery.force);
return server.close(done);
}
setTimeout(retry, 100);
})();
});
});
});

View file

@ -0,0 +1,33 @@
/*globals describe, it, before, after*/
var sinon = require('sinon'),
rewire = require('rewire'),
/*jshint unused:false*/
should = require('should'),
Promise = require('bluebird'),
config = require(__dirname + '/../../../server/config'),
postScheduling = require(__dirname + '/../../../server/scheduling/post-scheduling');
describe('Scheduling', function () {
var scope = {};
before(function () {
sinon.stub(postScheduling, 'init').returns(Promise.resolve());
scope.scheduling = rewire(config.paths.corePath + '/server/scheduling');
});
after(function () {
postScheduling.init.restore();
});
describe('success', function () {
it('ensure post scheduling init is called', function (done) {
scope.scheduling.init({
postScheduling: {}
}).then(function () {
postScheduling.init.calledOnce.should.eql(true);
done();
}).catch(done);
});
});
});

View file

@ -0,0 +1,113 @@
/*globals describe, it, beforeEach, afterEach*/
var should = require('should'),
sinon = require('sinon'),
Promise = require('bluebird'),
config = require(__dirname + '/../../../../server/config'),
testUtils = require(config.paths.corePath + '/test/utils'),
errors = require(config.paths.corePath + '/server/errors'),
events = require(config.paths.corePath + '/server/events'),
models = require(config.paths.corePath + '/server/models'),
api = require(config.paths.corePath + '/server/api'),
schedulingUtils = require(config.paths.corePath + '/server/scheduling/utils'),
SchedulingDefault = require(config.paths.corePath + '/server/scheduling/SchedulingDefault'),
postScheduling = require(config.paths.corePath + '/server/scheduling/post-scheduling');
describe('Scheduling: Post Scheduling', function () {
var scope = {
events: {},
scheduledPosts: [],
apiUrl: 'localhost:1111/',
client: null,
post: null
};
beforeEach(testUtils.setup());
beforeEach(function () {
scope.client = models.Client.forge(testUtils.DataGenerator.forKnex.createClient({slug: 'ghost-scheduler'}));
scope.post = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({id: 1337, markdown: 'something'}));
scope.adapter = new SchedulingDefault();
sinon.stub(api.schedules, 'getScheduledPosts', function () {
return Promise.resolve({posts: scope.scheduledPosts});
});
sinon.stub(events, 'onMany', function (events, stubDone) {
events.forEach(function (event) {
scope.events[event] = stubDone;
});
});
sinon.stub(schedulingUtils, 'createAdapter').returns(Promise.resolve(scope.adapter));
models.Client.findOne = function () {
return Promise.resolve(scope.client);
};
sinon.spy(scope.adapter, 'schedule');
sinon.spy(scope.adapter, 'reschedule');
});
afterEach(function (done) {
scope.adapter.schedule.reset();
schedulingUtils.createAdapter.restore();
scope.adapter.schedule.restore();
scope.adapter.reschedule.restore();
events.onMany.restore();
api.schedules.getScheduledPosts.restore();
testUtils.teardown(done);
});
describe('fn:init', function () {
describe('success', function () {
it('will be scheduled', function (done) {
postScheduling.init({
apiUrl: scope.apiUrl,
postScheduling: {}
}).then(function () {
scope.events['post.scheduled'](scope.post);
scope.adapter.schedule.called.should.eql(true);
scope.adapter.schedule.calledWith({
time: scope.post.get('published_at'),
url: scope.apiUrl + '/schedules/posts/' + scope.post.get('id') + '?client_id=' + scope.client.get('slug') + '&client_secret=' + scope.client.get('secret'),
extra: {
httpMethod: 'PUT',
oldTime: null
}
}).should.eql(true);
done();
}).catch(done);
});
it('will load scheduled posts from database', function (done) {
scope.scheduledPosts = [
models.Post.forge(testUtils.DataGenerator.forKnex.createPost({status: 'scheduled'})),
models.Post.forge(testUtils.DataGenerator.forKnex.createPost({status: 'scheduled'}))
];
postScheduling.init({
apiUrl: scope.apiUrl,
postScheduling: {}
}).then(function () {
scope.adapter.reschedule.calledTwice.should.eql(true);
done();
}).catch(done);
});
});
describe('error', function () {
it('no url passed', function (done) {
postScheduling.init()
.catch(function (err) {
should.exist(err);
(err instanceof errors.IncorrectUsage).should.eql(true);
done();
});
});
});
});
});

View file

@ -0,0 +1,85 @@
/*globals describe, it*/
var should = require('should'),
fs = require('fs'),
config = require(__dirname + '/../../../server/config'),
errors = require(config.paths.corePath + '/server/errors'),
schedulingUtils = require(config.paths.corePath + '/server/scheduling/utils');
describe('Scheduling: utils', function () {
describe('success', function () {
it('create good adapter', function (done) {
schedulingUtils.createAdapter({
active: __dirname + '/../../../server/scheduling/SchedulingDefault'
}).then(function (adapter) {
should.exist(adapter);
done();
}).catch(done);
});
it('create good adapter', function (done) {
var jsFile = '' +
'var util = require(\'util\');' +
'var SchedulingBase = require(__dirname + \'/../../../server/scheduling/SchedulingBase\');' +
'var AnotherAdapter = function (){ SchedulingBase.call(this); };' +
'util.inherits(AnotherAdapter, SchedulingBase);' +
'AnotherAdapter.prototype.run = function (){};' +
'AnotherAdapter.prototype.schedule = function (){};' +
'AnotherAdapter.prototype.reschedule = function (){};' +
'AnotherAdapter.prototype.unschedule = function (){};' +
'module.exports = AnotherAdapter';
fs.writeFileSync(__dirname + '/another-scheduler.js', jsFile);
schedulingUtils.createAdapter({
active: 'another-scheduler',
path: __dirname + '/'
}).then(function (adapter) {
should.exist(adapter);
done();
}).finally(function () {
fs.unlinkSync(__dirname + '/another-scheduler.js');
}).catch(done);
});
});
describe('error', function () {
it('create without adapter path', function (done) {
schedulingUtils.createAdapter()
.catch(function (err) {
should.exist(err);
done();
});
});
it('create with unknown adapter', function (done) {
schedulingUtils.createAdapter({
active: '/follow/the/heart'
}).catch(function (err) {
should.exist(err);
done();
});
});
it('create with adapter, but missing fn\'s', function (done) {
var jsFile = '' +
'var util = require(\'util\');' +
'var SchedulingBase = require(__dirname + \'/../../../server/scheduling/SchedulingBase\');' +
'var BadAdapter = function (){ SchedulingBase.call(this); };' +
'util.inherits(BadAdapter, SchedulingBase);' +
'BadAdapter.prototype.schedule = function (){};' +
'module.exports = BadAdapter';
fs.writeFileSync(__dirname + '/bad-adapter.js', jsFile);
schedulingUtils.createAdapter({
active: __dirname + '/bad-adapter'
}).catch(function (err) {
should.exist(err);
(err instanceof errors.IncorrectUsage).should.eql(true);
done();
}).finally(function () {
fs.unlinkSync(__dirname + '/bad-adapter.js');
});
});
});
});

View file

@ -1,7 +1,9 @@
var _ = require('lodash'),
uuid = require('node-uuid'),
moment = require('moment'),
globalUtils = require('../../../server/utils'),
DataGenerator = {};
/*jshint quotmark:false*/
// jscs:disable validateQuoteMarks, requireCamelCaseOrUpperCaseIdentifiers
DataGenerator.Content = {
@ -58,7 +60,8 @@ DataGenerator.Content = {
title: "This is a scheduled post!!",
slug: "scheduled-post",
markdown: "<h1>Welcome to my invisible post!</h1>",
status: "scheduled"
status: "scheduled",
published_at: moment().add(2, 'days').toDate()
}
],
@ -276,6 +279,7 @@ DataGenerator.forKnex = (function () {
return _.defaults(newObj, {
uuid: uuid.v4(),
title: 'title',
status: 'published',
html: overrides.markdown,
language: 'en_US',
@ -320,6 +324,19 @@ DataGenerator.forKnex = (function () {
});
}
function createClient(overrides) {
overrides = overrides || {};
var newObj = _.cloneDeep(overrides),
basics = createBasic(newObj);
return _.defaults(newObj, {
secret: 'not_available',
type: 'ua',
status: 'enabled'
}, basics);
}
function createGenericUser(uniqueInteger) {
return createUser({
name: 'Joe Bloggs',
@ -405,7 +422,8 @@ DataGenerator.forKnex = (function () {
];
clients = [
createBasic({name: 'Ghost Admin', slug: 'ghost-admin', secret: 'not_available', type: 'ua', status: 'enabled'})
createClient({name: 'Ghost Admin', slug: 'ghost-admin', type: 'ua'}),
createClient({name: 'Ghost Scheduler', slug: 'ghost-scheduler', type: 'web'})
];
roles_users = [
@ -440,6 +458,7 @@ DataGenerator.forKnex = (function () {
createGenericPost: createGenericPost,
createTag: createBasic,
createUser: createUser,
createClient: createClient,
createGenericUser: createGenericUser,
createBasic: createBasic,
createRole: createBasic,

View file

@ -2,6 +2,7 @@ var Promise = require('bluebird'),
_ = require('lodash'),
fs = require('fs-extra'),
path = require('path'),
Module = require('module'),
uuid = require('node-uuid'),
db = require('../../server/data/db'),
migration = require('../../server/data/migration/'),
@ -20,8 +21,11 @@ var Promise = require('bluebird'),
fixtures,
getFixtureOps,
toDoList,
originalRequireFn,
postsInserted = 0,
mockNotExistingModule,
unmockNotExistingModule,
teardown,
setup,
doAuth,
@ -38,7 +42,7 @@ fixtures = {
return Promise.resolve(db.knex('posts').insert(posts));
},
insertPostsAndTags: function insertPosts() {
insertPostsAndTags: function insertPostsAndTags() {
return Promise.resolve(db.knex('posts').insert(DataGenerator.forKnex.posts)).then(function () {
return db.knex('tags').insert(DataGenerator.forKnex.tags);
}).then(function () {
@ -403,8 +407,7 @@ toDoList = {
roles: function insertRoles() { return fixtures.insertRoles(); },
tag: function insertTag() { return fixtures.insertOne('tags', 'createTag'); },
subscriber: function insertSubscriber() { return fixtures.insertOne('subscribers', 'createSubscriber'); },
posts: function insertPosts() { return fixtures.insertPostsAndTags(); },
posts: function insertPostsAndTags() { return fixtures.insertPostsAndTags(); },
'posts:mu': function insertMultiAuthorPosts() { return fixtures.insertMultiAuthorPosts(); },
tags: function insertMoreTags() { return fixtures.insertMoreTags(); },
apps: function insertApps() { return fixtures.insertApps(); },
@ -447,6 +450,7 @@ getFixtureOps = function getFixtureOps(toDos) {
fixtureOps.push(function initDB() {
return migration.init(tablesOnly);
});
delete toDos.default;
delete toDos.init;
}
@ -495,7 +499,7 @@ setup = function setup() {
Models.init();
if (done) {
return initFixtures.apply(self, args).then(function () {
initFixtures.apply(self, args).then(function () {
done();
}).catch(done);
} else {
@ -590,6 +594,25 @@ teardown = function teardown(done) {
}
};
/**
* offer helper functions for mocking
* we start with a small function set to mock non existent modules
*/
originalRequireFn = Module.prototype.require;
mockNotExistingModule = function mockNotExistingModule(modulePath, module) {
Module.prototype.require = function (path) {
if (path.match(modulePath)) {
return module;
}
return originalRequireFn.apply(this, arguments);
};
};
unmockNotExistingModule = function unmockNotExistingModule() {
Module.prototype.require = originalRequireFn;
};
module.exports = {
teardown: teardown,
setup: setup,
@ -597,6 +620,9 @@ module.exports = {
login: login,
togglePermalinks: togglePermalinks,
mockNotExistingModule: mockNotExistingModule,
unmockNotExistingModule: unmockNotExistingModule,
initFixtures: initFixtures,
initData: initData,
clearData: clearData,

View file

@ -66,6 +66,7 @@
"semver": "5.1.0",
"showdown-ghost": "0.3.6",
"sqlite3": "3.1.4",
"superagent": "1.8.3",
"unidecode": "0.1.8",
"validator": "5.4.0",
"xml": "1.0.1"