Ghost/test/regression/models/model_posts.test.js
Sam Lord 2887e416da
Switch to @tryghost/errors from ignition errors package (#13807)
refs: TryGhost/Toolbox#147

* Replaces all references to isIgnitionError with isGhostError
* Switches use of GhostError to InternalServerError - as GhostError is no longer public
There are places where InternalServerError is not the valid error, and new errors should be added to the @tryghost/errors package to ensure that we can use semantically correct errors in those cases.
2021-12-01 10:22:01 +00:00

1909 lines
88 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const errors = require('@tryghost/errors');
const should = require('should');
const sinon = require('sinon');
const testUtils = require('../../utils');
const moment = require('moment');
const _ = require('lodash');
const Promise = require('bluebird');
const {sequence} = require('@tryghost/promise');
const urlService = require('../../../core/server/services/url');
const ghostBookshelf = require('../../../core/server/models/base');
const models = require('../../../core/server/models');
const db = require('../../../core/server/data/db');
const settingsCache = require('../../../core/shared/settings-cache');
const events = require('../../../core/server/lib/common/events');
const configUtils = require('../../utils/configUtils');
const context = testUtils.context.owner;
const markdownToMobiledoc = testUtils.DataGenerator.markdownToMobiledoc;
/**
* IMPORTANT:
* - do not spy the events unit, because when we only spy, all listeners get the event
* - this can cause unexpected behaviour as the listeners execute code
* - using rewire is not possible, because each model self registers it's model registry in bookshelf
* - rewire would add 1 registry, a file who requires the models, tries to register the model another time
*/
describe('Post Model', function () {
let eventsTriggered = {};
before(testUtils.teardownDb);
before(testUtils.stopGhost);
after(testUtils.teardownDb);
before(testUtils.setup('users:roles'));
afterEach(function () {
sinon.restore();
});
beforeEach(function () {
sinon.stub(urlService, 'getUrlByResourceId').withArgs(testUtils.DataGenerator.Content.posts[0].id).returns('/html-ipsum/');
});
describe('Single author posts', function () {
afterEach(function () {
configUtils.restore();
});
describe('fetchOne/fetchAll/fetchPage', function () {
before(testUtils.fixtures.insertPostsAndTags);
after(function () {
return testUtils.truncate('posts_tags')
.then(function () {
return testUtils.truncate('tags');
})
.then(function () {
return testUtils.truncate('posts');
})
.then(function () {
return testUtils.truncate('posts_meta');
});
});
describe('findOne', function () {
it('transforms legacy email_recipient_filter values on read', function (done) {
const postId = testUtils.DataGenerator.Content.posts[0].id;
db.knex('posts').where({id: postId}).update({
email_recipient_filter: 'paid'
}).then(() => {
return db.knex('posts').where({id: postId});
}).then((knexResult) => {
const [knexPost] = knexResult;
knexPost.email_recipient_filter.should.equal('paid');
return models.Post.findOne({id: postId});
}).then((result) => {
should.exist(result);
const post = result.toJSON();
post.email_recipient_filter.should.equal('status:-free');
done();
}).catch(done);
});
});
describe('findPage', function () {
describe('with more posts/tags', function () {
beforeEach(function () {
return testUtils.truncate('posts_tags')
.then(function () {
return testUtils.truncate('tags');
})
.then(function () {
return testUtils.truncate('posts_meta');
})
.then(function () {
return testUtils.truncate('posts');
});
});
beforeEach(function () {
return testUtils.fixtures.insertPostsAndTags()
.then(function () {
return testUtils.fixtures.insertExtraPosts();
})
.then(function () {
return testUtils.fixtures.insertExtraPostsTags();
});
});
it('can findPage, with various options', function (done) {
models.Post.findPage({page: 2})
.then(function (paginationResult) {
paginationResult.meta.pagination.page.should.equal(2);
paginationResult.meta.pagination.limit.should.equal(15);
paginationResult.meta.pagination.pages.should.equal(4);
paginationResult.data.length.should.equal(15);
return models.Post.findPage({page: 5});
}).then(function (paginationResult) {
paginationResult.meta.pagination.page.should.equal(5);
paginationResult.meta.pagination.limit.should.equal(15);
paginationResult.meta.pagination.pages.should.equal(4);
paginationResult.data.length.should.equal(0);
return models.Post.findPage({limit: 30});
}).then(function (paginationResult) {
paginationResult.meta.pagination.page.should.equal(1);
paginationResult.meta.pagination.limit.should.equal(30);
paginationResult.meta.pagination.pages.should.equal(2);
paginationResult.data.length.should.equal(30);
// Test featured pages
return models.Post.findPage({limit: 10, filter: 'featured:true'});
}).then(function (paginationResult) {
paginationResult.meta.pagination.page.should.equal(1);
paginationResult.meta.pagination.limit.should.equal(10);
paginationResult.meta.pagination.pages.should.equal(1);
paginationResult.data.length.should.equal(2);
// Test both boolean formats for featured pages
return models.Post.findPage({limit: 10, filter: 'featured:1'});
}).then(function (paginationResult) {
paginationResult.meta.pagination.page.should.equal(1);
paginationResult.meta.pagination.limit.should.equal(10);
paginationResult.meta.pagination.pages.should.equal(1);
paginationResult.data.length.should.equal(2);
return models.Post.findPage({limit: 10, page: 2, status: 'all'});
}).then(function (paginationResult) {
paginationResult.meta.pagination.pages.should.equal(11);
return models.Post.findPage({limit: 'all', status: 'all'});
}).then(function (paginationResult) {
paginationResult.meta.pagination.page.should.equal(1);
paginationResult.meta.pagination.limit.should.equal('all');
paginationResult.meta.pagination.pages.should.equal(1);
paginationResult.data.length.should.equal(108);
done();
}).catch(done);
});
it('can findPage for tag, with various options', function (done) {
// Test tag filter
models.Post.findPage({page: 1, filter: 'tags:bacon'})
.then(function (paginationResult) {
paginationResult.meta.pagination.page.should.equal(1);
paginationResult.meta.pagination.limit.should.equal(15);
paginationResult.meta.pagination.pages.should.equal(1);
paginationResult.data.length.should.equal(2);
return models.Post.findPage({page: 1, filter: 'tags:kitchen-sink'});
}).then(function (paginationResult) {
paginationResult.meta.pagination.page.should.equal(1);
paginationResult.meta.pagination.limit.should.equal(15);
paginationResult.meta.pagination.pages.should.equal(1);
paginationResult.data.length.should.equal(2);
return models.Post.findPage({page: 1, filter: 'tags:injection'});
}).then(function (paginationResult) {
paginationResult.meta.pagination.page.should.equal(1);
paginationResult.meta.pagination.limit.should.equal(15);
paginationResult.meta.pagination.pages.should.equal(2);
paginationResult.data.length.should.equal(15);
return models.Post.findPage({page: 2, filter: 'tags:injection'});
}).then(function (paginationResult) {
paginationResult.meta.pagination.page.should.equal(2);
paginationResult.meta.pagination.limit.should.equal(15);
paginationResult.meta.pagination.pages.should.equal(2);
paginationResult.data.length.should.equal(10);
done();
}).catch(done);
});
});
});
});
describe('edit', function () {
beforeEach(testUtils.fixtures.insertPostsAndTags);
afterEach(function () {
return testUtils.truncate('posts_tags')
.then(function () {
return testUtils.truncate('tags');
})
.then(function () {
return testUtils.truncate('posts');
})
.then(function () {
return testUtils.truncate('posts_meta');
});
});
beforeEach(function () {
eventsTriggered = {};
sinon.stub(events, 'emit').callsFake(function (eventName, eventObj) {
if (!eventsTriggered[eventName]) {
eventsTriggered[eventName] = [];
}
eventsTriggered[eventName].push(eventObj);
});
});
it('[failure] multiple edits in one transaction', function () {
const options = _.cloneDeep(context);
const data = {
status: 'published'
};
return models.Base.transaction(function (txn) {
options.transacting = txn;
return models.Post.edit(data, _.merge({id: testUtils.DataGenerator.Content.posts[3].id}, options))
.then(function () {
return models.Post.edit(data, _.merge({id: testUtils.DataGenerator.Content.posts[5].id}, options));
})
.then(function () {
// force rollback
throw new Error();
});
}).catch(function () {
// txn was rolled back
Object.keys(eventsTriggered).length.should.eql(0);
});
});
it('multiple edits in one transaction', function () {
const options = _.cloneDeep(context);
const data = {
status: 'published'
};
return models.Base.transaction(function (txn) {
options.transacting = txn;
return models.Post.edit(data, _.merge({id: testUtils.DataGenerator.Content.posts[3].id}, options))
.then(function () {
return models.Post.edit(data, _.merge({id: testUtils.DataGenerator.Content.posts[5].id}, options));
});
}).then(function () {
// txn was successful
Object.keys(eventsTriggered).length.should.eql(4);
});
});
it('can change title', function (done) {
const postId = testUtils.DataGenerator.Content.posts[0].id;
models.Post.findOne({id: postId}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.id.should.equal(postId);
post.title.should.not.equal('new title');
return models.Post.edit({title: 'new title'}, _.extend({}, context, {id: postId}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.title.should.equal('new title');
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.published.edited']);
should.exist(eventsTriggered['post.edited']);
done();
}).catch(done);
});
it('[failure] custom excerpt soft limit reached', function (done) {
const postId = testUtils.DataGenerator.Content.posts[0].id;
models.Post.findOne({id: postId}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.id.should.equal(postId);
return models.Post.edit({
custom_excerpt: new Array(302).join('a')
}, _.extend({}, context, {id: postId}));
}).then(function () {
done(new Error('expected validation error'));
}).catch(function (err) {
err[0].name.should.eql('ValidationError');
done();
});
});
it('can publish draft post', function (done) {
const postId = testUtils.DataGenerator.Content.posts[3].id;
models.Post.findOne({id: postId, status: 'draft'}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.id.should.equal(postId);
post.status.should.equal('draft');
return models.Post.edit({status: 'published'}, _.extend({}, context, {id: postId}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('published');
Object.keys(eventsTriggered).length.should.eql(4);
should.exist(eventsTriggered['post.published']);
should.exist(eventsTriggered['post.edited']);
should.exist(eventsTriggered['tag.attached']);
should.exist(eventsTriggered['user.attached']);
done();
}).catch(done);
});
it('can unpublish published post', function (done) {
const postId = testUtils.DataGenerator.Content.posts[0].id;
models.Post.findOne({id: postId}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.id.should.equal(postId);
post.status.should.equal('published');
return models.Post.edit({status: 'draft'}, _.extend({}, context, {id: postId}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('draft');
Object.keys(eventsTriggered).length.should.eql(4);
should.exist(eventsTriggered['post.unpublished']);
should.exist(eventsTriggered['post.edited']);
done();
}).catch(done);
});
it('draft -> scheduled without published_at update', function (done) {
let post;
models.Post.findOne({status: 'draft'}).then(function (results) {
should.exist(results);
post = results.toJSON();
post.status.should.equal('draft');
results.set('published_at', null);
return results.save();
}).then(function () {
return models.Post.edit({
status: 'scheduled'
}, _.extend({}, context, {id: post.id}));
}).then(function () {
done(new Error('expected error'));
}).catch(function (err) {
should.exist(err);
(err instanceof errors.ValidationError).should.eql(true);
done();
});
});
it('draft -> scheduled: expect update of published_at', function (done) {
const newPublishedAt = moment().add(1, 'day').toDate();
models.Post.findOne({status: 'draft'}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.status.should.equal('draft');
return models.Post.edit({
status: 'scheduled',
published_at: newPublishedAt
}, _.extend({}, context, {id: post.id}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('scheduled');
// mysql does not store ms
moment(edited.attributes.published_at).startOf('seconds').diff(moment(newPublishedAt).startOf('seconds')).should.eql(0);
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.scheduled']);
should.exist(eventsTriggered['post.edited']);
done();
}).catch(done);
});
it('scheduled -> draft: expect unschedule', function (done) {
models.Post.findOne({status: 'scheduled'}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.status.should.equal('scheduled');
return models.Post.edit({
status: 'draft'
}, _.extend({}, context, {id: post.id}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('draft');
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.unscheduled']);
should.exist(eventsTriggered['post.edited']);
done();
}).catch(done);
});
it('scheduled -> scheduled with updated published_at', function (done) {
models.Post.findOne({status: 'scheduled'}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.status.should.equal('scheduled');
return models.Post.edit({
status: 'scheduled',
published_at: moment().add(20, 'days').toDate()
}, _.extend({}, context, {id: post.id}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('scheduled');
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.rescheduled']);
should.exist(eventsTriggered['post.edited']);
done();
}).catch(done);
});
it('scheduled -> scheduled with unchanged published_at', function (done) {
models.Post.findOne({status: 'scheduled'}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.status.should.equal('scheduled');
return models.Post.edit({
status: 'scheduled'
}, _.extend({}, context, {id: post.id}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('scheduled');
// nothing has changed
Object.keys(eventsTriggered).length.should.eql(0);
done();
}).catch(done);
});
it('scheduled -> scheduled with unchanged published_at (within the 2 minutes window)', function (done) {
let post;
models.Post.findOne({status: 'scheduled'}).then(function (results) {
should.exist(results);
post = results.toJSON();
post.status.should.equal('scheduled');
results.set('published_at', moment().add(2, 'minutes').add(2, 'seconds').toDate());
return results.save();
}).then(function () {
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.edited']);
should.exist(eventsTriggered['post.rescheduled']);
eventsTriggered = {};
return Promise.delay(1000 * 3);
}).then(function () {
return models.Post.edit({
status: 'scheduled'
}, _.extend({}, context, {id: post.id}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('scheduled');
Object.keys(eventsTriggered).length.should.eql(1);
should.exist(eventsTriggered['post.edited']);
done();
}).catch(done);
});
it('published -> scheduled and expect update of published_at', function (done) {
const postId = testUtils.DataGenerator.Content.posts[0].id;
models.Post.findOne({id: postId}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.id.should.equal(postId);
post.status.should.equal('published');
return models.Post.edit({
status: 'scheduled',
published_at: moment().add(1, 'day').toDate()
}, _.extend({}, context, {id: postId}));
}).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();
});
});
it('can convert draft post to page and back', function (done) {
const postId = testUtils.DataGenerator.Content.posts[3].id;
models.Post.findOne({id: postId, status: 'draft'}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.id.should.equal(postId);
post.status.should.equal('draft');
return models.Post.edit({type: 'page'}, _.extend({}, context, {id: postId}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('draft');
edited.attributes.type.should.equal('page');
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.deleted']);
should.exist(eventsTriggered['page.added']);
return models.Post.edit({type: 'post'}, _.extend({}, context, {id: postId}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('draft');
edited.attributes.type.should.equal('post');
Object.keys(eventsTriggered).length.should.eql(4);
should.exist(eventsTriggered['post.deleted']);
should.exist(eventsTriggered['page.added']);
should.exist(eventsTriggered['post.deleted']);
should.exist(eventsTriggered['post.added']);
done();
}).catch(done);
});
it('can convert draft to schedule AND post to page and back', function (done) {
models.Post.findOne({status: 'draft'}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.status.should.equal('draft');
return models.Post.edit({
type: 'page',
status: 'scheduled',
published_at: moment().add(10, 'days')
}, _.extend({}, context, {id: post.id}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('scheduled');
edited.attributes.type.should.equal('page');
Object.keys(eventsTriggered).length.should.eql(3);
should.exist(eventsTriggered['post.deleted']);
should.exist(eventsTriggered['page.added']);
should.exist(eventsTriggered['page.scheduled']);
return models.Post.edit({type: 'post'}, _.extend({}, context, {id: edited.id}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('scheduled');
edited.attributes.type.should.equal('post');
Object.keys(eventsTriggered).length.should.eql(7);
should.exist(eventsTriggered['page.unscheduled']);
should.exist(eventsTriggered['page.deleted']);
should.exist(eventsTriggered['post.added']);
should.exist(eventsTriggered['post.scheduled']);
done();
}).catch(done);
});
it('can convert published post to page and back', function (done) {
const postId = testUtils.DataGenerator.Content.posts[0].id;
models.Post.findOne({id: postId}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.id.should.equal(postId);
post.status.should.equal('published');
return models.Post.edit({type: 'page'}, _.extend({}, context, {id: postId}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('published');
edited.attributes.type.should.equal('page');
Object.keys(eventsTriggered).length.should.eql(4);
should.exist(eventsTriggered['post.unpublished']);
should.exist(eventsTriggered['post.deleted']);
should.exist(eventsTriggered['page.added']);
should.exist(eventsTriggered['page.published']);
return models.Post.edit({type: 'post'}, _.extend({}, context, {id: postId}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('published');
edited.attributes.type.should.equal('post');
Object.keys(eventsTriggered).length.should.eql(8);
should.exist(eventsTriggered['page.unpublished']);
should.exist(eventsTriggered['page.deleted']);
should.exist(eventsTriggered['post.added']);
should.exist(eventsTriggered['post.published']);
done();
}).catch(done);
});
it('can change type and status at the same time', function (done) {
const postId = testUtils.DataGenerator.Content.posts[3].id;
models.Post.findOne({id: postId, status: 'draft'}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.id.should.equal(postId);
post.status.should.equal('draft');
return models.Post.edit({type: 'page', status: 'published'}, _.extend({}, context, {id: postId}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('published');
edited.attributes.type.should.equal('page');
Object.keys(eventsTriggered).length.should.eql(5);
should.exist(eventsTriggered['post.deleted']);
should.exist(eventsTriggered['page.added']);
should.exist(eventsTriggered['page.published']);
should.exist(eventsTriggered['tag.attached']);
should.exist(eventsTriggered['user.attached']);
return models.Post.edit({type: 'post', status: 'draft'}, _.extend({}, context, {id: postId}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('draft');
edited.attributes.type.should.equal('post');
Object.keys(eventsTriggered).length.should.eql(8);
should.exist(eventsTriggered['page.unpublished']);
should.exist(eventsTriggered['page.deleted']);
should.exist(eventsTriggered['post.added']);
done();
}).catch(done);
});
it('cannot override the published_by setting', function (done) {
const postId = testUtils.DataGenerator.Content.posts[3].id;
models.Post.findOne({id: postId, status: 'draft'}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.id.should.equal(postId);
post.status.should.equal('draft');
// Test changing status and published_by at the same time
return models.Post.edit({
status: 'published',
published_by: 4
}, _.extend({}, context, {id: postId}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('published');
edited.attributes.published_by.should.equal(context.context.user);
// Test changing status and published_by on its own
return models.Post.edit({published_by: 4}, _.extend({}, context, {id: postId}));
}).then(function (edited) {
should.exist(edited);
edited.attributes.status.should.equal('published');
edited.attributes.published_by.should.equal(context.context.user);
done();
}).catch(done);
});
it('transforms legacy email_recipient_filter values on save', function (done) {
const postId = testUtils.DataGenerator.Content.posts[3].id;
models.Post.findOne({id: postId}).then(() => {
return models.Post.edit({
email_recipient_filter: 'free'
}, _.extend({}, context, {id: postId}));
}).then((edited) => {
edited.attributes.email_recipient_filter.should.equal('status:free');
return db.knex('posts').where({id: edited.id});
}).then((knexResult) => {
const [knexPost] = knexResult;
knexPost.email_recipient_filter.should.equal('status:free');
done();
}).catch(done);
});
it('transforms special-case visibility values on save', function (done) {
// status:-free === paid
// status:-free,status:free (+variations) === members
const postId = testUtils.DataGenerator.Content.posts[3].id;
models.Post.findOne({id: postId}).then(() => {
return models.Post.edit({
visibility: 'status:-free'
}, _.extend({}, context, {id: postId}));
}).then((edited) => {
edited.attributes.visibility.should.equal('paid');
return db.knex('posts').where({id: edited.id});
}).then((knexResult) => {
const [knexPost] = knexResult;
knexPost.visibility.should.equal('paid');
}).then(() => {
return models.Post.edit({
visibility: 'status:-free,status:free'
}, _.extend({}, context, {id: postId}));
}).then((edited) => {
edited.attributes.visibility.should.equal('members');
return models.Post.edit({
visibility: 'status:free,status:-free'
}, _.extend({}, context, {id: postId}));
}).then((edited) => {
edited.attributes.visibility.should.equal('members');
return models.Post.edit({
visibility: 'status:free,status:-free,label:vip'
}, _.extend({}, context, {id: postId}));
}).then((edited) => {
edited.attributes.visibility.should.equal('members');
done();
}).catch(done);
});
});
describe('add', function () {
before(testUtils.fixtures.insertPostsAndTags);
after(function () {
return testUtils.truncate('posts_tags')
.then(function () {
return testUtils.truncate('tags');
})
.then(function () {
return testUtils.truncate('posts');
})
.then(function () {
return testUtils.truncate('posts_meta');
});
});
beforeEach(function () {
eventsTriggered = {};
sinon.stub(events, 'emit').callsFake(function (eventName, eventObj) {
if (!eventsTriggered[eventName]) {
eventsTriggered[eventName] = [];
}
eventsTriggered[eventName].push(eventObj);
});
});
it('can add, defaults are all correct', function (done) {
let createdPostUpdatedDate;
const newPost = testUtils.DataGenerator.forModel.posts[2];
const newPostDB = testUtils.DataGenerator.Content.posts[2];
models.Post.add(newPost, _.merge({withRelated: ['author']}, context)).then(function (createdPost) {
return models.Post.findOne({id: createdPost.id, status: 'all'});
}).then(function (createdPost) {
should.exist(createdPost);
createdPost.has('uuid').should.equal(true);
createdPost.get('status').should.equal('draft');
createdPost.get('title').should.equal(newPost.title, 'title is correct');
createdPost.get('mobiledoc').should.equal(newPost.mobiledoc, 'mobiledoc is correct');
createdPost.has('html').should.equal(true);
createdPost.get('html').should.equal(newPostDB.html);
createdPost.has('plaintext').should.equal(true);
createdPost.get('plaintext').should.match(/^testing/);
createdPost.get('slug').should.equal(newPostDB.slug + '-2');
(!!createdPost.get('featured')).should.equal(false);
(!!createdPost.get('page')).should.equal(false);
should.equal(createdPost.get('locale'), null);
should.equal(createdPost.get('visibility'), 'public');
// testing for nulls
(createdPost.get('feature_image') === null).should.equal(true);
createdPost.get('created_at').should.be.above(new Date(0).getTime());
createdPost.get('created_by').should.equal(testUtils.DataGenerator.Content.users[0].id);
createdPost.get('author_id').should.equal(testUtils.DataGenerator.Content.users[0].id);
createdPost.has('author').should.equal(false);
createdPost.get('created_by').should.equal(createdPost.get('author_id'));
createdPost.get('updated_at').should.be.above(new Date(0).getTime());
createdPost.get('updated_by').should.equal(testUtils.DataGenerator.Content.users[0].id);
should.equal(createdPost.get('published_at'), null);
should.equal(createdPost.get('published_by'), null);
createdPostUpdatedDate = createdPost.get('updated_at');
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.added']);
should.exist(eventsTriggered['user.attached']);
// Set the status to published to check that `published_at` is set.
return createdPost.save({status: 'published'}, context);
}).then(function (publishedPost) {
publishedPost.get('published_at').should.be.instanceOf(Date);
publishedPost.get('published_by').should.equal(testUtils.DataGenerator.Content.users[0].id);
publishedPost.get('updated_at').should.be.instanceOf(Date);
publishedPost.get('updated_by').should.equal(testUtils.DataGenerator.Content.users[0].id);
publishedPost.get('updated_at').should.not.equal(createdPostUpdatedDate);
Object.keys(eventsTriggered).length.should.eql(4);
should.exist(eventsTriggered['post.published']);
should.exist(eventsTriggered['post.edited']);
done();
}).catch(done);
});
it('can add, default visibility is taken from settings cache', function (done) {
const originalSettingsCacheGetFn = settingsCache.get;
sinon.stub(settingsCache, 'get')
.callsFake(function (key, options) {
if (key === 'labs') {
return {
members: true
};
} else if (key === 'default_content_visibility') {
return 'paid';
}
return originalSettingsCacheGetFn(key, options);
});
let createdPostUpdatedDate;
const newPost = testUtils.DataGenerator.forModel.posts[2];
const newPostDB = testUtils.DataGenerator.Content.posts[2];
models.Post.add(newPost, _.merge({withRelated: ['author']}, context)).then(function (createdPost) {
return models.Post.findOne({id: createdPost.id, status: 'all'});
}).then(function (createdPost) {
should.exist(createdPost);
createdPost.has('uuid').should.equal(true);
createdPost.get('status').should.equal('draft');
createdPost.get('title').should.equal(newPost.title, 'title is correct');
createdPost.get('mobiledoc').should.equal(newPost.mobiledoc, 'mobiledoc is correct');
createdPost.has('html').should.equal(true);
createdPost.get('html').should.equal(newPostDB.html);
createdPost.has('plaintext').should.equal(true);
createdPost.get('plaintext').should.match(/^testing/);
// createdPost.get('slug').should.equal(newPostDB.slug + '-3');
(!!createdPost.get('featured')).should.equal(false);
(!!createdPost.get('page')).should.equal(false);
should.equal(createdPost.get('locale'), null);
should.equal(createdPost.get('visibility'), 'paid');
// testing for nulls
(createdPost.get('feature_image') === null).should.equal(true);
createdPost.get('created_at').should.be.above(new Date(0).getTime());
createdPost.get('created_by').should.equal(testUtils.DataGenerator.Content.users[0].id);
createdPost.get('author_id').should.equal(testUtils.DataGenerator.Content.users[0].id);
createdPost.has('author').should.equal(false);
createdPost.get('created_by').should.equal(createdPost.get('author_id'));
createdPost.get('updated_at').should.be.above(new Date(0).getTime());
createdPost.get('updated_by').should.equal(testUtils.DataGenerator.Content.users[0].id);
should.equal(createdPost.get('published_at'), null);
should.equal(createdPost.get('published_by'), null);
createdPostUpdatedDate = createdPost.get('updated_at');
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.added']);
should.exist(eventsTriggered['user.attached']);
// Set the status to published to check that `published_at` is set.
return createdPost.save({status: 'published'}, context);
}).then(function (publishedPost) {
publishedPost.get('published_at').should.be.instanceOf(Date);
publishedPost.get('published_by').should.equal(testUtils.DataGenerator.Content.users[0].id);
publishedPost.get('updated_at').should.be.instanceOf(Date);
publishedPost.get('updated_by').should.equal(testUtils.DataGenerator.Content.users[0].id);
publishedPost.get('updated_at').should.not.equal(createdPostUpdatedDate);
Object.keys(eventsTriggered).length.should.eql(4);
should.exist(eventsTriggered['post.published']);
should.exist(eventsTriggered['post.edited']);
done();
}).catch(done);
});
it('can add, with previous published_at date', function (done) {
const previousPublishedAtDate = new Date(2013, 8, 21, 12);
models.Post.add({
status: 'published',
published_at: previousPublishedAtDate,
title: 'published_at test',
mobiledoc: markdownToMobiledoc('This is some content')
}, context).then(function (newPost) {
should.exist(newPost);
new Date(newPost.get('published_at')).getTime().should.equal(previousPublishedAtDate.getTime());
Object.keys(eventsTriggered).length.should.eql(3);
should.exist(eventsTriggered['post.added']);
should.exist(eventsTriggered['post.published']);
should.exist(eventsTriggered['user.attached']);
done();
}).catch(done);
});
it('add draft post without published_at -> we expect no auto insert of published_at', function (done) {
models.Post.add({
status: 'draft',
title: 'draft 1',
mobiledoc: markdownToMobiledoc('This is some content')
}, context).then(function (newPost) {
should.exist(newPost);
should.not.exist(newPost.get('published_at'));
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.added']);
should.exist(eventsTriggered['user.attached']);
done();
}).catch(done);
});
it('add multiple authors', function (done) {
models.Post.add({
status: 'draft',
title: 'draft 1',
mobiledoc: markdownToMobiledoc('This is some content'),
authors: [{
id: testUtils.DataGenerator.forKnex.users[0].id,
name: testUtils.DataGenerator.forKnex.users[0].name
}]
}, _.merge({withRelated: ['authors']}, context)).then(function (newPost) {
should.exist(newPost);
newPost.toJSON().author.should.eql(testUtils.DataGenerator.forKnex.users[0].id);
newPost.toJSON().authors.length.should.eql(1);
newPost.toJSON().authors[0].id.should.eql(testUtils.DataGenerator.forKnex.users[0].id);
done();
}).catch(done);
});
it('add draft post with published_at -> we expect published_at to exist', function (done) {
models.Post.add({
status: 'draft',
published_at: moment().toDate(),
title: 'draft 1',
mobiledoc: markdownToMobiledoc('This is some content')
}, context).then(function (newPost) {
should.exist(newPost);
should.exist(newPost.get('published_at'));
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.added']);
should.exist(eventsTriggered['user.attached']);
done();
}).catch(done);
});
it('add scheduled post without published_at -> we expect an error', function (done) {
models.Post.add({
status: 'scheduled',
title: 'scheduled 1',
mobiledoc: markdownToMobiledoc('This is some content')
}, context).catch(function (err) {
should.exist(err);
(err instanceof errors.ValidationError).should.eql(true);
Object.keys(eventsTriggered).length.should.eql(0);
done();
});
});
it('add scheduled post with published_at not in future-> we expect an error', function (done) {
models.Post.add({
status: 'scheduled',
published_at: moment().subtract(1, 'minute'),
title: 'scheduled 1',
mobiledoc: markdownToMobiledoc('This is some content')
}, context).catch(function (err) {
should.exist(err);
(err instanceof errors.ValidationError).should.eql(true);
Object.keys(eventsTriggered).length.should.eql(0);
done();
});
});
it('add scheduled post with published_at 1 minutes in future -> we expect an error', function (done) {
models.Post.add({
status: 'scheduled',
published_at: moment().add(1, 'minute'),
title: 'scheduled 1',
mobiledoc: markdownToMobiledoc('This is some content')
}, context).catch(function (err) {
(err instanceof errors.ValidationError).should.eql(true);
Object.keys(eventsTriggered).length.should.eql(0);
done();
});
});
it('add scheduled post with published_at 10 minutes in future -> we expect success', function (done) {
models.Post.add({
status: 'scheduled',
published_at: moment().add(10, 'minute'),
title: 'scheduled 1',
mobiledoc: markdownToMobiledoc('This is some content')
}, context).then(function (post) {
should.exist(post);
Object.keys(eventsTriggered).length.should.eql(3);
should.exist(eventsTriggered['post.added']);
should.exist(eventsTriggered['post.scheduled']);
should.exist(eventsTriggered['user.attached']);
done();
}).catch(done);
});
it('can generate a non conflicting slug', function (done) {
// Create 12 posts with the same title
sequence(_.times(12, function (i) {
return function () {
return models.Post.add({
title: 'Test Title',
mobiledoc: markdownToMobiledoc('Test Content ' + (i + 1))
}, context);
};
})).then(function (createdPosts) {
// Should have created 12 posts
createdPosts.length.should.equal(12);
// Should have unique slugs and contents
_(createdPosts).each(function (post, i) {
const num = i + 1;
// First one has normal title
if (num === 1) {
post.get('slug').should.equal('test-title');
return;
}
post.get('slug').should.equal('test-title-' + num);
JSON.parse(post.get('mobiledoc')).cards[0][1].markdown.should.equal('Test Content ' + num);
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.added']);
should.exist(eventsTriggered['user.attached']);
eventsTriggered['post.added'].length.should.eql(12);
});
done();
}).catch(done);
});
it('can generate slugs without duplicate hyphens', function (done) {
const newPost = {
title: 'apprehensive titles have too many spaces—and m-dashes — and also n-dashes ',
mobiledoc: markdownToMobiledoc('Test Content 1')
};
models.Post.add(newPost, context).then(function (createdPost) {
createdPost.get('slug').should.equal('apprehensive-titles-have-too-many-spaces-and-m-dashes-and-also-n-dashes');
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.added']);
should.exist(eventsTriggered['user.attached']);
done();
}).catch(done);
});
it('can generate a safe slug when a protected keyword is used', function (done) {
const newPost = {
title: 'rss',
mobiledoc: markdownToMobiledoc('Test Content 1')
};
models.Post.add(newPost, context).then(function (createdPost) {
createdPost.get('slug').should.not.equal('rss');
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.added']);
should.exist(eventsTriggered['user.attached']);
done();
});
});
it('can generate slugs without non-ascii characters', function (done) {
const newPost = {
title: 'भुते धडकी भरवणारा आहेत',
mobiledoc: markdownToMobiledoc('Test Content 1')
};
models.Post.add(newPost, context).then(function (createdPost) {
createdPost.get('slug').should.equal('bhute-dhddkii-bhrvnnaaraa-aahet');
done();
}).catch(done);
});
it('detects duplicate slugs before saving', function (done) {
const firstPost = {
title: 'First post',
mobiledoc: markdownToMobiledoc('First content 1')
};
const secondPost = {
title: 'Second post',
mobiledoc: markdownToMobiledoc('Second content 1')
};
// Create the first post
models.Post.add(firstPost, context)
.then(function (createdFirstPost) {
// Store the slug for later
firstPost.slug = createdFirstPost.get('slug');
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.added']);
should.exist(eventsTriggered['user.attached']);
// Create the second post
return models.Post.add(secondPost, context);
}).then(function (createdSecondPost) {
// Store the slug for comparison later
secondPost.slug = createdSecondPost.get('slug');
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.added']);
should.exist(eventsTriggered['user.attached']);
// Update with a conflicting slug from the first post
return createdSecondPost.save({
slug: firstPost.slug
}, context);
}).then(function (updatedSecondPost) {
// Should have updated from original
updatedSecondPost.get('slug').should.not.equal(secondPost.slug);
// Should not have a conflicted slug from the first
updatedSecondPost.get('slug').should.not.equal(firstPost.slug);
Object.keys(eventsTriggered).length.should.eql(3);
should.exist(eventsTriggered['post.edited']);
return models.Post.findOne({
id: updatedSecondPost.id,
status: 'all'
});
}).then(function (foundPost) {
// Should have updated from original
foundPost.get('slug').should.not.equal(secondPost.slug);
// Should not have a conflicted slug from the first
foundPost.get('slug').should.not.equal(firstPost.slug);
done();
}).catch(done);
});
it('it stores urls as transform-ready and reads as absolute', function (done) {
const post = {
title: 'Absolute->Transform-ready URL Transform Test',
mobiledoc: '{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"http://127.0.0.1:2369/content/images/card.jpg"}]],"markups":[["a",["href","http://127.0.0.1:2369/test"]]],"sections":[[1,"p",[[0,[0],1,"Testing"]]],[10,0]]}',
custom_excerpt: 'Testing <a href="http://127.0.0.1:2369/internal">links</a> in custom excerpts',
codeinjection_head: '<script src="http://127.0.0.1:2369/assets/head.js"></script>',
codeinjection_foot: '<script src="http://127.0.0.1:2369/assets/foot.js"></script>',
feature_image: 'http://127.0.0.1:2369/content/images/feature.png',
canonical_url: 'http://127.0.0.1:2369/canonical',
posts_meta: {
og_image: 'http://127.0.0.1:2369/content/images/og.png',
twitter_image: 'http://127.0.0.1:2369/content/images/twitter.png'
}
};
models.Post.add(post, context).then((createdPost) => {
createdPost.get('mobiledoc').should.equal('{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"http://127.0.0.1:2369/content/images/card.jpg"}]],"markups":[["a",["href","http://127.0.0.1:2369/test"]]],"sections":[[1,"p",[[0,[0],1,"Testing"]]],[10,0]]}');
createdPost.get('html').should.equal('<p><a href="http://127.0.0.1:2369/test">Testing</a></p><figure class="kg-card kg-image-card"><img src="http://127.0.0.1:2369/content/images/card.jpg" class="kg-image" alt loading="lazy"></figure>');
createdPost.get('plaintext').should.containEql('Testing [http://127.0.0.1:2369/test]');
createdPost.get('custom_excerpt').should.equal('Testing <a href="http://127.0.0.1:2369/internal">links</a> in custom excerpts');
createdPost.get('codeinjection_head').should.equal('<script src="http://127.0.0.1:2369/assets/head.js"></script>');
createdPost.get('codeinjection_foot').should.equal('<script src="http://127.0.0.1:2369/assets/foot.js"></script>');
createdPost.get('feature_image').should.equal('http://127.0.0.1:2369/content/images/feature.png');
createdPost.get('canonical_url').should.equal('http://127.0.0.1:2369/canonical');
const postMeta = createdPost.relations.posts_meta;
postMeta.get('og_image').should.equal('http://127.0.0.1:2369/content/images/og.png');
postMeta.get('twitter_image').should.equal('http://127.0.0.1:2369/content/images/twitter.png');
// ensure canonical_url is not transformed when protocol does not match
return createdPost.save({
canonical_url: 'https://127.0.0.1:2369/https-internal',
// sanity check for general absolute->relative transform during edits
feature_image: 'http://127.0.0.1:2369/content/images/updated_feature.png'
});
}).then((updatedPost) => {
updatedPost.get('canonical_url').should.equal('https://127.0.0.1:2369/https-internal');
updatedPost.get('feature_image').should.equal('http://127.0.0.1:2369/content/images/updated_feature.png');
return updatedPost;
}).then((updatedPost) => {
return db.knex('posts').where({id: updatedPost.id});
}).then((knexResult) => {
const [knexPost] = knexResult;
knexPost.mobiledoc.should.equal('{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"__GHOST_URL__/content/images/card.jpg"}]],"markups":[["a",["href","__GHOST_URL__/test"]]],"sections":[[1,"p",[[0,[0],1,"Testing"]]],[10,0]]}');
knexPost.html.should.equal('<p><a href="__GHOST_URL__/test">Testing</a></p><figure class="kg-card kg-image-card"><img src="__GHOST_URL__/content/images/card.jpg" class="kg-image" alt loading="lazy"></figure>');
knexPost.plaintext.should.containEql('Testing [__GHOST_URL__/test]');
knexPost.custom_excerpt.should.equal('Testing <a href="__GHOST_URL__/internal">links</a> in custom excerpts');
knexPost.codeinjection_head.should.equal('<script src="__GHOST_URL__/assets/head.js"></script>');
knexPost.codeinjection_foot.should.equal('<script src="__GHOST_URL__/assets/foot.js"></script>');
knexPost.feature_image.should.equal('__GHOST_URL__/content/images/updated_feature.png');
knexPost.canonical_url.should.equal('https://127.0.0.1:2369/https-internal');
done();
}).catch(done);
});
});
describe('destroy', function () {
beforeEach(testUtils.fixtures.insertPostsAndTags);
afterEach(function () {
return testUtils.truncate('posts_tags')
.then(function () {
return testUtils.truncate('tags');
})
.then(function () {
return testUtils.truncate('posts');
})
.then(function () {
return testUtils.truncate('posts_meta');
});
});
beforeEach(function () {
eventsTriggered = {};
sinon.stub(events, 'emit').callsFake(function (eventName, eventObj) {
if (!eventsTriggered[eventName]) {
eventsTriggered[eventName] = [];
}
eventsTriggered[eventName].push(eventObj);
});
});
it('published post', function (done) {
// We're going to try deleting post id 1 which has tag id 1
const firstItemData = {id: testUtils.DataGenerator.Content.posts[0].id};
// Test that we have the post we expect, with exactly one tag
models.Post.findOne(firstItemData, {withRelated: ['tags']}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.id.should.equal(firstItemData.id);
post.status.should.equal('published');
post.tags.should.have.length(2);
post.tags[0].id.should.equal(testUtils.DataGenerator.Content.tags[0].id);
// Destroy the post
return results.destroy();
}).then(function (response) {
const deleted = response.toJSON();
should.equal(deleted.author, undefined);
Object.keys(eventsTriggered).length.should.eql(5);
should.exist(eventsTriggered['post.unpublished']);
should.exist(eventsTriggered['post.deleted']);
should.exist(eventsTriggered['user.detached']);
should.exist(eventsTriggered['tag.detached']);
should.exist(eventsTriggered['post.tag.detached']);
// Double check we can't find the post again
return models.Post.findOne(firstItemData);
}).then(function (newResults) {
should.equal(newResults, null);
// Double check we can't find any related tags
return ghostBookshelf.knex.select().table('posts_tags').where('post_id', firstItemData.id);
}).then(function (postsTags) {
postsTags.should.be.empty();
done();
}).catch(done);
});
it('draft post', function (done) {
// We're going to try deleting post 4 which also has tag 4
const firstItemData = {id: testUtils.DataGenerator.Content.posts[3].id, status: 'draft'};
// Test that we have the post we expect, with exactly one tag
models.Post.findOne(firstItemData, {withRelated: ['tags']}).then(function (results) {
let post;
should.exist(results);
post = results.toJSON();
post.id.should.equal(firstItemData.id);
post.tags.should.have.length(1);
post.tags[0].id.should.equal(testUtils.DataGenerator.Content.tags[3].id);
// Destroy the post
return results.destroy(firstItemData);
}).then(function (response) {
const deleted = response.toJSON();
should.equal(deleted.author, undefined);
Object.keys(eventsTriggered).length.should.eql(4);
should.exist(eventsTriggered['post.deleted']);
should.exist(eventsTriggered['tag.detached']);
should.exist(eventsTriggered['post.tag.detached']);
should.exist(eventsTriggered['user.detached']);
// Double check we can't find the post again
return models.Post.findOne(firstItemData);
}).then(function (newResults) {
should.equal(newResults, null);
// Double check we can't find any related tags
return ghostBookshelf.knex.select().table('posts_tags').where('post_id', firstItemData.id);
}).then(function (postsTags) {
postsTags.should.be.empty();
done();
}).catch(done);
});
it('published page', function (done) {
// We're going to try deleting page 6 which has tag 1
const firstItemData = {id: testUtils.DataGenerator.Content.posts[5].id};
// Test that we have the post we expect, with exactly one tag
models.Post.findOne(firstItemData, {withRelated: ['tags']}).then(function (results) {
let page;
should.exist(results);
page = results.toJSON();
page.id.should.equal(firstItemData.id);
page.status.should.equal('published');
page.type.should.equal('page');
// Destroy the page
return results.destroy(firstItemData);
}).then(function (response) {
const deleted = response.toJSON();
should.equal(deleted.author, undefined);
Object.keys(eventsTriggered).length.should.eql(3);
should.exist(eventsTriggered['page.unpublished']);
should.exist(eventsTriggered['page.deleted']);
should.exist(eventsTriggered['user.detached']);
// Double check we can't find the post again
return models.Post.findOne(firstItemData);
}).then(function (newResults) {
should.equal(newResults, null);
// Double check we can't find any related tags
return ghostBookshelf.knex.select().table('posts_tags').where('post_id', firstItemData.id);
}).then(function (postsTags) {
postsTags.should.be.empty();
done();
}).catch(done);
});
it('draft page', function (done) {
// We're going to try deleting post 7 which has tag 4
const firstItemData = {id: testUtils.DataGenerator.Content.posts[6].id, status: 'draft'};
// Test that we have the post we expect, with exactly one tag
models.Post.findOne(firstItemData, {withRelated: ['tags']}).then(function (results) {
let page;
should.exist(results);
page = results.toJSON();
page.id.should.equal(firstItemData.id);
// Destroy the page
return results.destroy(firstItemData);
}).then(function (response) {
const deleted = response.toJSON();
should.equal(deleted.author, undefined);
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['page.deleted']);
should.exist(eventsTriggered['user.detached']);
// Double check we can't find the post again
return models.Post.findOne(firstItemData);
}).then(function (newResults) {
should.equal(newResults, null);
// Double check we can't find any related tags
return ghostBookshelf.knex.select().table('posts_tags').where('post_id', firstItemData.id);
}).then(function (postsTags) {
postsTags.should.be.empty();
done();
}).catch(done);
});
});
describe('Collision Protection', function () {
before(testUtils.fixtures.insertPostsAndTags);
after(function () {
return testUtils.truncate('posts_tags')
.then(function () {
return testUtils.truncate('tags');
})
.then(function () {
return testUtils.truncate('posts');
})
.then(function () {
return testUtils.truncate('posts_meta');
});
});
it('update post title, but updated_at is out of sync', function () {
const postToUpdate = {id: testUtils.DataGenerator.Content.posts[1].id};
return models.Post.edit({
title: 'New Post Title',
updated_at: moment().subtract(1, 'day').format()
}, _.extend({}, context, {id: postToUpdate.id}))
.then(function () {
throw new Error('expected no success');
})
.catch(function (err) {
err.code.should.eql('UPDATE_COLLISION');
});
});
it('update post tags and updated_at is out of sync', function () {
const postToUpdate = {id: testUtils.DataGenerator.Content.posts[1].id};
return models.Post.edit({
tags: [{name: 'new-tag-1'}],
updated_at: moment().subtract(1, 'day').format()
}, _.extend({}, context, {id: postToUpdate.id}))
.then(function () {
throw new Error('expected no success');
})
.catch(function (err) {
err.code.should.eql('UPDATE_COLLISION');
});
});
it('update post authors and updated_at is out of sync', function () {
const postToUpdate = {id: testUtils.DataGenerator.Content.posts[1].id};
return models.Post.edit({
authors: [testUtils.DataGenerator.Content.users[3]],
updated_at: moment().subtract(1, 'day').format()
}, _.extend({}, context, {id: postToUpdate.id}))
.then(function () {
throw new Error('expected no success');
})
.catch(function (err) {
err.code.should.eql('UPDATE_COLLISION');
});
});
it('update post tags and updated_at is NOT out of sync', function () {
const postToUpdate = {id: testUtils.DataGenerator.Content.posts[1].id};
return models.Post.edit({
tags: [{name: 'new-tag-1'}]
}, _.extend({}, context, {id: postToUpdate.id}));
});
it('update post with no changes, but updated_at is out of sync', function () {
const postToUpdate = {id: testUtils.DataGenerator.Content.posts[1].id};
return models.Post.edit({
updated_at: moment().subtract(1, 'day').format()
}, _.extend({}, context, {id: postToUpdate.id}));
});
it('update post with old post title, but updated_at is out of sync', function () {
const postToUpdate = {
id: testUtils.DataGenerator.Content.posts[1].id,
title: testUtils.DataGenerator.forModel.posts[1].title
};
return models.Post.edit({
title: postToUpdate.title,
updated_at: moment().subtract(1, 'day').format()
}, _.extend({}, context, {id: postToUpdate.id}));
});
});
});
describe('mobiledoc versioning', function () {
it('can create revisions', function () {
const newPost = {
mobiledoc: markdownToMobiledoc('a')
};
return models.Post.add(newPost, context)
.then((createdPost) => {
return models.Post.findOne({id: createdPost.id, status: 'all'});
})
.then((createdPost) => {
should.exist(createdPost);
return createdPost.save({mobiledoc: markdownToMobiledoc('b')}, context);
})
.then((updatedPost) => {
updatedPost.get('mobiledoc').should.equal(markdownToMobiledoc('b'));
return models.MobiledocRevision
.findAll({
filter: `post_id:${updatedPost.id}`
});
})
.then((mobiledocRevisions) => {
should.equal(mobiledocRevisions.length, 2);
mobiledocRevisions.toJSON()[0].mobiledoc.should.equal(markdownToMobiledoc('b'));
mobiledocRevisions.toJSON()[1].mobiledoc.should.equal(markdownToMobiledoc('a'));
});
});
it('keeps only 10 last revisions in FIFO style', function () {
let revisionedPost;
const newPost = {
mobiledoc: markdownToMobiledoc('revision: 0')
};
return models.Post.add(newPost, context)
.then((createdPost) => {
return models.Post.findOne({id: createdPost.id, status: 'all'});
})
.then((createdPost) => {
should.exist(createdPost);
revisionedPost = createdPost;
return sequence(_.times(11, (i) => {
return () => {
return models.Post.edit({
mobiledoc: markdownToMobiledoc('revision: ' + (i + 1))
}, _.extend({}, context, {id: createdPost.id}));
};
}));
})
.then(() => models.MobiledocRevision
.findAll({
filter: `post_id:${revisionedPost.id}`
})
)
.then((mobiledocRevisions) => {
should.equal(mobiledocRevisions.length, 10);
mobiledocRevisions.toJSON()[0].mobiledoc.should.equal(markdownToMobiledoc('revision: 11'));
mobiledocRevisions.toJSON()[9].mobiledoc.should.equal(markdownToMobiledoc('revision: 2'));
});
});
it('creates 2 revisions after first edit for previously unversioned post', function () {
let unversionedPost;
const newPost = {
title: 'post title',
mobiledoc: markdownToMobiledoc('a')
};
// passing 'migrating' flag to simulate unversioned post
const options = Object.assign(_.clone(context), {migrating: true});
return models.Post.add(newPost, options)
.then((createdPost) => {
should.exist(createdPost);
unversionedPost = createdPost;
createdPost.get('mobiledoc').should.equal(markdownToMobiledoc('a'));
return models.MobiledocRevision
.findAll({
filter: `post_id:${createdPost.id}`
});
})
.then((mobiledocRevisions) => {
should.equal(mobiledocRevisions.length, 0);
return models.Post.edit({
mobiledoc: markdownToMobiledoc('b')
}, _.extend({}, context, {id: unversionedPost.id}));
})
.then((editedPost) => {
should.exist(editedPost);
editedPost.get('mobiledoc').should.equal(markdownToMobiledoc('b'));
return models.MobiledocRevision
.findAll({
filter: `post_id:${editedPost.id}`
});
})
.then((mobiledocRevisions) => {
should.equal(mobiledocRevisions.length, 2);
mobiledocRevisions.toJSON()[0].mobiledoc.should.equal(markdownToMobiledoc('b'));
mobiledocRevisions.toJSON()[1].mobiledoc.should.equal(markdownToMobiledoc('a'));
});
});
});
describe('Multiauthor Posts', function () {
before(testUtils.teardownDb);
after(function () {
return testUtils.teardownDb()
.then(function () {
return testUtils.setup('users:roles')();
});
});
before(testUtils.setup('posts:mu'));
it('can destroy multiple posts by author', function (done) {
// We're going to delete all posts by user 1
const authorData = {id: testUtils.DataGenerator.Content.users[0].id};
models.Post.findAll({context: {internal: true}}).then(function (found) {
// There are 10 posts created by posts:mu fixture
found.length.should.equal(10);
return models.Post.destroyByAuthor(authorData);
}).then(function (results) {
// User 1 has 2 posts in the database (each user has proportionate amount)
// 2 = 10 / 5 (posts / users)
results.length.should.equal(2);
return models.Post.findAll({context: {internal: true}});
}).then(function (found) {
// Only 8 should remain
// 8 = 10 - 2
found.length.should.equal(8);
done();
}).catch(done);
});
});
describe('Post tag handling edge cases', function () {
let postJSON;
let tagJSON;
let editOptions;
const createTag = testUtils.DataGenerator.forKnex.createTag;
beforeEach(function () {
return testUtils.truncate('posts_tags')
.then(function () {
return testUtils.truncate('tags');
})
.then(function () {
return testUtils.truncate('posts');
})
.then(function () {
return testUtils.truncate('posts_meta');
});
});
beforeEach(function () {
tagJSON = [];
const post = _.cloneDeep(testUtils.DataGenerator.forModel.posts[0]);
const postTags = [
createTag({name: 'tag1', slug: 'tag1'}),
createTag({name: 'tag2', slug: 'tag2'}),
createTag({name: 'tag3', slug: 'tag3'})
];
const extraTags = [
createTag({name: 'existing tag a', slug: 'existing-tag-a'}),
createTag({name: 'existing-tag-b', slug: 'existing-tag-b'}),
createTag({name: 'existing_tag_c', slug: 'existing_tag_c'})
];
post.tags = postTags;
post.status = 'published';
return Promise.props({
post: models.Post.add(post, _.extend({}, context, {withRelated: ['tags']})),
tag1: models.Tag.add(extraTags[0], context),
tag2: models.Tag.add(extraTags[1], context),
tag3: models.Tag.add(extraTags[2], context)
}).then(function (result) {
postJSON = result.post.toJSON({withRelated: ['tags']});
tagJSON.push(result.tag1.toJSON());
tagJSON.push(result.tag2.toJSON());
tagJSON.push(result.tag3.toJSON());
editOptions = _.extend({}, context, {id: postJSON.id, withRelated: ['tags']});
// reset the eventSpy here
sinon.restore();
});
});
it('should create the test data correctly', function (done) {
// creates a test tag
should.exist(tagJSON);
tagJSON.should.be.an.Array().with.lengthOf(3);
tagJSON[0].name.should.eql('existing tag a');
tagJSON[1].name.should.eql('existing-tag-b');
tagJSON[2].name.should.eql('existing_tag_c');
// creates a test post with an array of tags in the correct order
should.exist(postJSON);
postJSON.title.should.eql('HTML Ipsum');
should.exist(postJSON.tags);
postJSON.tags.should.be.an.Array().and.have.lengthOf(3);
postJSON.tags[0].name.should.eql('tag1');
postJSON.tags[1].name.should.eql('tag2');
postJSON.tags[2].name.should.eql('tag3');
done();
});
it('can edit slug of existing tag', function () {
const newJSON = _.cloneDeep(postJSON);
// Add an existing tag to the beginning of the array
newJSON.tags = [{id: postJSON.tags[0].id, slug: 'eins'}];
// Edit the post
return models.Post.edit(newJSON, editOptions).then(function (updatedPost) {
updatedPost = updatedPost.toJSON({withRelated: ['tags']});
updatedPost.tags.should.have.lengthOf(1);
updatedPost.tags[0].name.should.eql(postJSON.tags[0].name);
updatedPost.tags[0].slug.should.eql('eins');
updatedPost.tags[0].id.should.eql(postJSON.tags[0].id);
});
});
it('can\'t edit dates and authors of existing tag', function () {
const newJSON = _.cloneDeep(postJSON);
let updatedAtFormat;
let createdAtFormat;
// Add an existing tag to the beginning of the array
newJSON.tags = [_.cloneDeep(postJSON.tags[0])];
newJSON.tags[0].created_at = moment().add(2, 'days').format('YYYY-MM-DD HH:mm:ss');
newJSON.tags[0].updated_at = moment().add(2, 'days').format('YYYY-MM-DD HH:mm:ss');
// NOTE: this is currently only removed in the API layer
newJSON.tags[0].parent_id = newJSON.tags[0].parent;
delete newJSON.tags[0].parent;
// Edit the post
return Promise.delay(1000)
.then(function () {
return models.Post.edit(newJSON, editOptions);
})
.then(function (updatedPost) {
updatedPost = updatedPost.toJSON({withRelated: ['tags']});
updatedPost.tags.should.have.lengthOf(1);
updatedPost.tags[0].should.have.properties({
name: postJSON.tags[0].name,
slug: postJSON.tags[0].slug,
id: postJSON.tags[0].id,
created_by: postJSON.tags[0].created_by,
updated_by: postJSON.tags[0].updated_by
});
updatedAtFormat = moment(updatedPost.tags[0].updated_at).format('YYYY-MM-DD HH:mm:ss');
updatedAtFormat.should.eql(moment(postJSON.tags[0].updated_at).format('YYYY-MM-DD HH:mm:ss'));
updatedAtFormat.should.not.eql(moment(newJSON.tags[0].updated_at).format('YYYY-MM-DD HH:mm:ss'));
createdAtFormat = moment(updatedPost.tags[0].created_at).format('YYYY-MM-DD HH:mm:ss');
createdAtFormat.should.eql(moment(postJSON.tags[0].created_at).format('YYYY-MM-DD HH:mm:ss'));
createdAtFormat.should.not.eql(moment(newJSON.tags[0].created_at).format('YYYY-MM-DD HH:mm:ss'));
});
});
it('can reorder existing, added and deleted tags', function () {
const newJSON = _.cloneDeep(postJSON);
const lastTag = [postJSON.tags[2]];
// remove tag in the middle (tag1, tag2, tag3 -> tag1, tag3)
newJSON.tags.splice(1, 1);
// add a new one as first tag and reorder existing (tag4, tag3, tag1)
newJSON.tags = [{name: 'tag4'}].concat([newJSON.tags[1]]).concat([newJSON.tags[0]]);
// Edit the post
return models.Post.edit(newJSON, editOptions).then(function (updatedPost) {
updatedPost = updatedPost.toJSON({withRelated: ['tags']});
updatedPost.tags.should.have.lengthOf(3);
updatedPost.tags[0].should.have.properties({
name: 'tag4'
});
updatedPost.tags[1].should.have.properties({
name: 'tag3',
id: postJSON.tags[2].id
});
updatedPost.tags[2].should.have.properties({
name: 'tag1',
id: postJSON.tags[0].id
});
});
});
it('can add multiple tags with conflicting slugs', function () {
const newJSON = _.cloneDeep(postJSON);
// Add conflicting tags to the end of the array
newJSON.tags = [];
newJSON.tags.push({name: 'C'});
newJSON.tags.push({name: 'C++'});
newJSON.tags.push({name: 'C#'});
// Edit the post
return models.Post.edit(newJSON, editOptions).then(function (updatedPost) {
updatedPost = updatedPost.toJSON({withRelated: ['tags']});
updatedPost.tags.should.have.lengthOf(3);
updatedPost.tags[0].should.have.properties({name: 'C', slug: 'c'});
updatedPost.tags[1].should.have.properties({name: 'C++', slug: 'c-2'});
updatedPost.tags[2].should.have.properties({name: 'C#', slug: 'c-3'});
});
});
it('can handle lowercase/uppercase tags', function () {
const newJSON = _.cloneDeep(postJSON);
// Add conflicting tags to the end of the array
newJSON.tags = [];
newJSON.tags.push({name: 'test'});
newJSON.tags.push({name: 'tEst'});
// Edit the post
return models.Post.edit(newJSON, editOptions).then(function (updatedPost) {
updatedPost = updatedPost.toJSON({withRelated: ['tags']});
updatedPost.tags.should.have.lengthOf(1);
});
});
});
// disabling sanitization until we can implement a better version
// it('should sanitize the title', function (done) {
// new models.Post().fetch().then(function (model) {
// return model.set({'title': "</title></head><body><script>alert('blogtitle');</script>"}).save();
// }).then(function (saved) {
// saved.get('title').should.eql("&lt;/title&gt;&lt;/head>&lt;body&gt;[removed]alert&#40;'blogtitle'&#41;;[removed]");
// done();
// }).catch(done);
// });
});