From 1421c92ba5356d9bac95a5d8edd4f037f25eadfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Ckirrg001=E2=80=9D?= Date: Thu, 19 May 2016 13:49:22 +0200 Subject: [PATCH] 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 --- core/server/api/index.js | 10 +- core/server/api/schedules.js | 85 ++++ core/server/api/utils.js | 3 + core/server/config/index.js | 43 +- core/server/errors/incorrect-usage.js | 11 + core/server/errors/index.js | 2 + core/server/events/index.js | 9 + core/server/index.js | 12 + core/server/models/base/index.js | 11 +- core/server/models/base/listeners.js | 42 +- core/server/models/client.js | 2 +- core/server/models/post.js | 32 +- core/server/routes/api.js | 3 + core/server/scheduling/SchedulingBase.js | 8 + core/server/scheduling/SchedulingDefault.js | 192 ++++++++ core/server/scheduling/index.js | 12 + .../scheduling/post-scheduling/index.js | 99 ++++ core/server/scheduling/utils.js | 54 +++ core/server/translations/en.json | 9 +- core/server/utils/sequence.js | 5 +- core/test/functional/routes/api/posts_spec.js | 24 +- core/test/integration/api/api_posts_spec.js | 35 +- .../integration/api/api_schedules_spec.js | 451 ++++++++++++++++++ .../integration/model/model_posts_spec.js | 38 +- core/test/unit/api/index_spec.js | 33 ++ core/test/unit/api_utils_spec.js | 15 +- .../unit/scheduling/SchedulingDefault_spec.js | 286 +++++++++++ core/test/unit/scheduling/index_spec.js | 33 ++ .../scheduling/post-scheduling/index_spec.js | 113 +++++ core/test/unit/scheduling/utils_spec.js | 85 ++++ core/test/utils/fixtures/data-generator.js | 23 +- core/test/utils/index.js | 34 +- package.json | 1 + 33 files changed, 1737 insertions(+), 78 deletions(-) create mode 100644 core/server/api/schedules.js create mode 100644 core/server/errors/incorrect-usage.js create mode 100644 core/server/scheduling/SchedulingBase.js create mode 100644 core/server/scheduling/SchedulingDefault.js create mode 100644 core/server/scheduling/index.js create mode 100644 core/server/scheduling/post-scheduling/index.js create mode 100644 core/server/scheduling/utils.js create mode 100644 core/test/integration/api/api_schedules_spec.js create mode 100644 core/test/unit/api/index_spec.js create mode 100644 core/test/unit/scheduling/SchedulingDefault_spec.js create mode 100644 core/test/unit/scheduling/index_spec.js create mode 100644 core/test/unit/scheduling/post-scheduling/index_spec.js create mode 100644 core/test/unit/scheduling/utils_spec.js diff --git a/core/server/api/index.js b/core/server/api/index.js index 1250b8b1d1..4c15e0e7f8 100644 --- a/core/server/api/index.js +++ b/core/server/api/index.js @@ -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, diff --git a/core/server/api/schedules.js b/core/server/api/schedules.js new file mode 100644 index 0000000000..fc294388a1 --- /dev/null +++ b/core/server/api/schedules.js @@ -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); +}; diff --git a/core/server/api/utils.js b/core/server/api/utils.js index 00e4832ba2..c11d64edd9 100644 --- a/core/server/api/utils.js +++ b/core/server/api/utils.js @@ -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: {} diff --git a/core/server/config/index.js b/core/server/config/index.js index b0a780b437..879e1b7a98 100644 --- a/core/server/config/index.js +++ b/core/server/config/index.js @@ -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 diff --git a/core/server/errors/incorrect-usage.js b/core/server/errors/incorrect-usage.js new file mode 100644 index 0000000000..24a851a3e9 --- /dev/null +++ b/core/server/errors/incorrect-usage.js @@ -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; diff --git a/core/server/errors/index.js b/core/server/errors/index.js index 6bc266db41..bf9f80ace2 100644 --- a/core/server/errors/index.js +++ b/core/server/errors/index.js @@ -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; diff --git a/core/server/events/index.js b/core/server/events/index.js index f4411b9358..61b40d7151 100644 --- a/core/server/events/index.js +++ b/core/server/events/index.js @@ -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); diff --git a/core/server/index.js b/core/server/index.js index a1813994ef..a06aa78c39 100644 --- a/core/server/index.js +++ b/core/server/index.js @@ -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; }); } diff --git a/core/server/models/base/index.js b/core/server/models/base/index.js index 6d12e0ecb5..90cef2b440 100644 --- a/core/server/models/base/index.js +++ b/core/server/models/base/index.js @@ -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 diff --git a/core/server/models/base/listeners.js b/core/server/models/base/listeners.js index 7c5aa79313..fe87ec8197 100644 --- a/core/server/models/base/listeners.js +++ b/core/server/models/base/listeners.js @@ -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()); diff --git a/core/server/models/client.js b/core/server/models/client.js index caab81c742..22c3388009 100644 --- a/core/server/models/client.js +++ b/core/server/models/client.js @@ -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]) { diff --git a/core/server/models/post.js b/core/server/models/post.js index 1a190ed80f..026e01af0b 100644 --- a/core/server/models/post.js +++ b/core/server/models/post.js @@ -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; } diff --git a/core/server/routes/api.js b/core/server/routes/api.js index 59869df646..e63f879543 100644 --- a/core/server/routes/api.js +++ b/core/server/routes/api.js @@ -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)); diff --git a/core/server/scheduling/SchedulingBase.js b/core/server/scheduling/SchedulingBase.js new file mode 100644 index 0000000000..8b9fa98056 --- /dev/null +++ b/core/server/scheduling/SchedulingBase.js @@ -0,0 +1,8 @@ +function SchedulingBase() { + Object.defineProperty(this, 'requiredFns', { + value: ['schedule', 'unschedule', 'reschedule', 'run'], + writable: false + }); +} + +module.exports = SchedulingBase; diff --git a/core/server/scheduling/SchedulingDefault.js b/core/server/scheduling/SchedulingDefault.js new file mode 100644 index 0000000000..60aae38794 --- /dev/null +++ b/core/server/scheduling/SchedulingDefault.js @@ -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; diff --git a/core/server/scheduling/index.js b/core/server/scheduling/index.js new file mode 100644 index 0000000000..d7b7e7a143 --- /dev/null +++ b/core/server/scheduling/index.js @@ -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')); +}; diff --git a/core/server/scheduling/post-scheduling/index.js b/core/server/scheduling/post-scheduling/index.js new file mode 100644 index 0000000000..bd19a7b112 --- /dev/null +++ b/core/server/scheduling/post-scheduling/index.js @@ -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})); + }); + }); +}; diff --git a/core/server/scheduling/utils.js b/core/server/scheduling/utils.js new file mode 100644 index 0000000000..91b08d3bbd --- /dev/null +++ b/core/server/scheduling/utils.js @@ -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); +}; diff --git a/core/server/translations/en.json b/core/server/translations/en.json index f633c80ca7..161a99cfa2 100644 --- a/core/server/translations/en.json +++ b/core/server/translations/en.json @@ -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", diff --git a/core/server/utils/sequence.js b/core/server/utils/sequence.js index d499a271d9..34d6d42456 100644 --- a/core/server/utils/sequence.js +++ b/core/server/utils/sequence.js @@ -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; }); }, []); diff --git a/core/test/functional/routes/api/posts_spec.js b/core/test/functional/routes/api/posts_spec.js index bb99a7c6d3..2ab55b18dd 100644 --- a/core/test/functional/routes/api/posts_spec.js +++ b/core/test/functional/routes/api/posts_spec.js @@ -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 diff --git a/core/test/integration/api/api_posts_spec.js b/core/test/integration/api/api_posts_spec.js index ceef5a9323..37f84e6905 100644 --- a/core/test/integration/api/api_posts_spec.js +++ b/core/test/integration/api/api_posts_spec.js @@ -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}, diff --git a/core/test/integration/api/api_schedules_spec.js b/core/test/integration/api/api_schedules_spec.js new file mode 100644 index 0000000000..55fd65bb18 --- /dev/null +++ b/core/test/integration/api/api_schedules_spec.js @@ -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(); + }); + }); + }); + }); +}); diff --git a/core/test/integration/model/model_posts_spec.js b/core/test/integration/model/model_posts_spec.js index 1758b61c32..8f3fe6a0e7 100644 --- a/core/test/integration/model/model_posts_spec.js +++ b/core/test/integration/model/model_posts_spec.js @@ -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) { diff --git a/core/test/unit/api/index_spec.js b/core/test/unit/api/index_spec.js new file mode 100644 index 0000000000..f63ea56867 --- /dev/null +++ b/core/test/unit/api/index_spec.js @@ -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); + }); + }); +}); diff --git a/core/test/unit/api_utils_spec.js b/core/test/unit/api_utils_spec.js index 421352c92f..bc5bf9e97f 100644 --- a/core/test/unit/api_utils_spec.js +++ b/core/test/unit/api_utils_spec.js @@ -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) { diff --git a/core/test/unit/scheduling/SchedulingDefault_spec.js b/core/test/unit/scheduling/SchedulingDefault_spec.js new file mode 100644 index 0000000000..9546e88011 --- /dev/null +++ b/core/test/unit/scheduling/SchedulingDefault_spec.js @@ -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); + })(); + }); + }); +}); diff --git a/core/test/unit/scheduling/index_spec.js b/core/test/unit/scheduling/index_spec.js new file mode 100644 index 0000000000..fb1d621b77 --- /dev/null +++ b/core/test/unit/scheduling/index_spec.js @@ -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); + }); + }); +}); diff --git a/core/test/unit/scheduling/post-scheduling/index_spec.js b/core/test/unit/scheduling/post-scheduling/index_spec.js new file mode 100644 index 0000000000..79ecf92f4f --- /dev/null +++ b/core/test/unit/scheduling/post-scheduling/index_spec.js @@ -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(); + }); + }); + }); + }); +}); diff --git a/core/test/unit/scheduling/utils_spec.js b/core/test/unit/scheduling/utils_spec.js new file mode 100644 index 0000000000..087294041e --- /dev/null +++ b/core/test/unit/scheduling/utils_spec.js @@ -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'); + }); + }); + }); +}); diff --git a/core/test/utils/fixtures/data-generator.js b/core/test/utils/fixtures/data-generator.js index ea0917c286..778f8cfe36 100644 --- a/core/test/utils/fixtures/data-generator.js +++ b/core/test/utils/fixtures/data-generator.js @@ -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: "

Welcome to my invisible post!

", - 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, diff --git a/core/test/utils/index.js b/core/test/utils/index.js index 2c074b7cb6..e2327546bc 100644 --- a/core/test/utils/index.js +++ b/core/test/utils/index.js @@ -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, diff --git a/package.json b/package.json index 6cec37ecec..bfd819b3ff 100644 --- a/package.json +++ b/package.json @@ -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"