Added posts controller to v2

refs #9866
This commit is contained in:
kirrg001 2018-10-10 16:34:16 +02:00 committed by Katharina Irrgang
parent 27714075b5
commit a153400164
13 changed files with 1164 additions and 8 deletions

View File

@ -25,5 +25,9 @@ module.exports = {
get webhooks() {
return shared.pipeline(require('./webhooks'), localUtils);
},
get posts() {
return shared.pipeline(require('./posts'), localUtils);
}
};

184
core/server/api/v2/posts.js Normal file
View File

@ -0,0 +1,184 @@
const models = require('../../models');
const common = require('../../lib/common');
const urlService = require('../../services/url');
const allowedIncludes = ['created_by', 'updated_by', 'published_by', 'author', 'tags', 'authors', 'authors.roles'];
const unsafeAttrs = ['author_id', 'status', 'authors'];
module.exports = {
docName: 'posts',
browse: {
options: [
'include',
'filter',
'fields',
'formats',
'status',
'limit',
'order',
'debug'
],
validation: {
options: {
include: {
values: allowedIncludes
},
formats: {
values: models.Post.allowedFormats
}
}
},
permissions: {
unsafeAttrs: unsafeAttrs
},
query(frame) {
return models.Post.findPage(frame.options);
}
},
read: {
options: [
'include',
'filter',
'fields',
'status',
'formats',
'debug'
],
data: [
'id',
'slug',
'status',
'uuid'
],
validation: {
options: {
include: {
values: allowedIncludes
},
formats: {
values: models.Post.allowedFormats
}
}
},
permissions: {
unsafeAttrs: unsafeAttrs
},
query(frame) {
return models.Post.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
throw new common.errors.NotFoundError({
message: common.i18n.t('errors.api.posts.postNotFound')
});
}
return model;
});
}
},
add: {
statusCode: 201,
headers: {},
options: [
'include'
],
validation: {
options: {
include: {
values: allowedIncludes
}
}
},
permissions: {
unsafeAttrs: unsafeAttrs
},
query(frame) {
return models.Post.add(frame.data.posts[0], frame.options)
.then((model) => {
if (model.get('status') !== 'published') {
this.headers.cacheInvalidate = false;
} else {
this.headers.cacheInvalidate = true;
}
return model;
});
}
},
edit: {
headers: {},
options: [
'include',
'id'
],
validation: {
options: {
include: {
values: allowedIncludes
},
id: {
required: true
}
}
},
permissions: {
unsafeAttrs: unsafeAttrs
},
query(frame) {
return models.Post.edit(frame.data.posts[0], frame.options)
.then((model) => {
if (model.get('status') === 'published' ||
model.get('status') === 'draft' && model.updated('status') === 'published') {
this.headers.cacheInvalidate = true;
} else if (model.get('status') === 'draft' && model.updated('status') !== 'published') {
this.headers.cacheInvalidate = {
value: urlService.utils.urlFor({
relativeUrl: urlService.utils.urlJoin('/p', model.get('uuid'), '/')
})
};
} else {
this.headers.cacheInvalidate = false;
}
return model;
});
}
},
destroy: {
statusCode: 204,
headers: {
cacheInvalidate: true
},
options: [
'include',
'id'
],
validation: {
options: {
include: {
values: allowedIncludes
},
id: {
required: true
}
}
},
permissions: {
unsafeAttrs: unsafeAttrs
},
query(frame) {
frame.options.require = true;
return models.Post.destroy(frame.options)
.return(null)
.catch(models.Post.NotFoundError, () => {
throw new common.errors.NotFoundError({
message: common.i18n.t('errors.api.posts.postNotFound')
});
});
}
}
};

View File

@ -1,5 +1,9 @@
module.exports = {
get pages() {
return require('./pages');
},
get posts() {
return require('./posts');
}
};

View File

@ -0,0 +1,62 @@
const _ = require('lodash');
module.exports = {
add(apiConfig, frame) {
/**
* Convert author property to author_id to match the name in the database.
*
* @deprecated: `author`, might be removed in Ghost 3.0
*/
if (frame.data.posts[0].hasOwnProperty('author')) {
frame.data.posts[0].author_id = frame.data.posts[0].author;
delete frame.data.posts[0].author;
}
/**
* CASE: we don't support updating nested-nested relations e.g. `post.authors[*].roles` yet.
*
* Bookshelf-relations supports this feature, BUT bookshelf's `hasChanged` fn will currently
* clash with this, because `hasChanged` won't be able to tell if relations have changed or not.
* It would always return `changed.roles = [....]`. It would always throw a model event that relations
* were updated, which is not true.
*
* Bookshelf-relations can tell us if a relation has changed, it knows that.
* But the connection between our model layer, Bookshelf's `hasChanged` fn and Bookshelf-relations
* is not present. As long as we don't support this case, we have to ignore this.
*/
if (frame.data.posts[0].authors && frame.data.posts[0].authors.length) {
_.each(frame.data.posts[0].authors, (author, index) => {
if (author.hasOwnProperty('roles')) {
delete frame.data.posts[0].authors[index].roles;
}
if (author.hasOwnProperty('permissions')) {
delete frame.data.posts[0].authors[index].permissions;
}
});
}
/**
* Model notation is: `tag.parent_id`.
* The API notation is `tag.parent`.
*/
if (frame.data.posts[0].hasOwnProperty('tags')) {
if (_.isArray(frame.data.posts[0].tags) && frame.data.posts[0].tags.length) {
_.each(frame.data.posts[0].tags, (tag, index) => {
if (tag.hasOwnProperty('parent')) {
frame.data.posts[0].tags[index].parent_id = tag.parent;
delete frame.data.posts[0].tags[index].parent;
}
if (tag.hasOwnProperty('posts')) {
delete frame.data.posts[0].tags[index].posts;
}
});
}
}
},
edit(apiConfig, frame) {
this.add(apiConfig, frame);
}
};

View File

@ -13,5 +13,9 @@ module.exports = {
get webhooks() {
return require('./webhooks');
},
get posts() {
return require('./posts');
}
};

View File

@ -0,0 +1,100 @@
const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:posts');
const urlService = require('../../../../../services/url');
// @TODO: refactor if we add users+tags controllers
const urlsForUser = (user) => {
user.url = urlService.getUrlByResourceId(user.id, {absolute: true});
if (user.profile_image) {
user.profile_image = urlService.utils.urlFor('image', {image: user.profile_image}, true);
}
if (user.cover_image) {
user.cover_image = urlService.utils.urlFor('image', {image: user.cover_image}, true);
}
return user;
};
const urlsForTag = (tag) => {
tag.url = urlService.getUrlByResourceId(tag.id, {absolute: true});
if (tag.feature_image) {
tag.feature_image = urlService.utils.urlFor('image', {image: tag.feature_image}, true);
}
return tag;
};
// @TODO: Update the url decoration in https://github.com/TryGhost/Ghost/pull/9969.
const absoluteUrls = (attrs, options) => {
attrs.url = urlService.getUrlByResourceId(attrs.id, {absolute: true});
if (attrs.feature_image) {
attrs.feature_image = urlService.utils.urlFor('image', {image: attrs.feature_image}, true);
}
if (attrs.og_image) {
attrs.og_image = urlService.utils.urlFor('image', {image: attrs.og_image}, true);
}
if (attrs.twitter_image) {
attrs.twitter_image = urlService.utils.urlFor('image', {image: attrs.twitter_image}, true);
}
if (attrs.html) {
attrs.html = urlService.utils.makeAbsoluteUrls(attrs.html, urlService.utils.urlFor('home', true), attrs.url).html();
}
if (options.columns && !options.columns.includes('url')) {
delete attrs.url;
}
if (options && options.withRelated) {
options.withRelated.forEach((relation) => {
// @NOTE: this block also decorates primary_tag/primary_author objects as they
// are being passed by reference in tags/authors. Might be refactored into more explicit call
// in the future, but is good enough for current use-case
if (relation === 'tags' && attrs.tags) {
attrs.tags = attrs.tags.map(tag => urlsForTag(tag));
}
if (relation === 'author' && attrs.author) {
attrs.author = urlsForUser(attrs.author);
}
if (relation === 'authors' && attrs.authors) {
attrs.authors = attrs.authors.map(author => urlsForUser(author));
}
});
}
return attrs;
};
module.exports = {
all(models, apiConfig, frame) {
debug('all');
// CASE: e.g. destroy returns null
if (!models) {
return;
}
if (models.meta) {
frame.response = {
posts: models.data.map(model => absoluteUrls(model.toJSON(frame.options), frame.options)),
meta: models.meta
};
debug(frame.response);
return;
}
frame.response = {
posts: [absoluteUrls(models.toJSON(frame.options), frame.options)]
};
debug(frame.response);
}
};

View File

@ -1 +1,5 @@
module.exports = {};
module.exports = {
get posts() {
return require('./posts');
}
};

View File

@ -0,0 +1,49 @@
const _ = require('lodash');
const Promise = require('bluebird');
const common = require('../../../../../lib/common');
module.exports = {
add(apiConfig, frame) {
/**
* Ensure correct incoming `post.authors` structure.
*
* NOTE:
* The `post.authors[*].id` attribute is required till we release Ghost 3.0.
* Ghost 1.x keeps the deprecated support for `post.author_id`, which is the primary author id and needs to be
* updated if the order of the `post.authors` array changes.
* If we allow adding authors via the post endpoint e.g. `authors=[{name: 'newuser']` (no id property), it's hard
* to update the primary author id (`post.author_id`), because the new author `id` is generated when attaching
* the author to the post. And the attach operation happens in bookshelf-relations, which happens after
* the event handling in the post model.
*
* It's solvable, but not worth right now solving, because the admin UI does not support this feature.
*
* TLDR; You can only attach existing authors to a post.
*
* @TODO: remove `id` restriction in Ghost 3.0
*/
if (frame.data.posts[0].hasOwnProperty('authors')) {
if (!_.isArray(frame.data.posts[0].authors) ||
(frame.data.posts[0].authors.length && _.filter(frame.data.posts[0].authors, 'id').length !== frame.data.posts[0].authors.length)) {
return Promise.reject(new common.errors.BadRequestError({
message: common.i18n.t('errors.api.utils.invalidStructure', {key: 'posts[*].authors'})
}));
}
}
},
edit(apiConfig, frame) {
const result = this.add(apiConfig, frame);
if (result instanceof Promise) {
return result;
}
if (frame.options.id && frame.data[apiConfig.docName][0].id
&& frame.options.id !== frame.data[apiConfig.docName][0].id) {
return Promise.reject(new common.errors.BadRequestError({
message: common.i18n.t('errors.api.utils.invalidIdProvided')
}));
}
}
};

View File

@ -26,13 +26,12 @@ module.exports = function apiRoutes() {
router.get('/configuration/:key', mw.authAdminAPI, api.http(api.configuration.read));
// ## Posts
router.get('/posts', mw.authAdminAPI, api.http(api.posts.browse));
router.post('/posts', mw.authAdminAPI, api.http(api.posts.add));
router.get('/posts/:id', mw.authAdminAPI, api.http(api.posts.read));
router.get('/posts/slug/:slug', mw.authAdminAPI, api.http(api.posts.read));
router.put('/posts/:id', mw.authAdminAPI, api.http(api.posts.edit));
router.del('/posts/:id', mw.authAdminAPI, api.http(api.posts.destroy));
router.get('/posts', mw.authAdminAPI, apiv2.http(apiv2.posts.browse));
router.post('/posts', mw.authAdminAPI, apiv2.http(apiv2.posts.add));
router.get('/posts/:id', mw.authAdminAPI, apiv2.http(apiv2.posts.read));
router.get('/posts/slug/:slug', mw.authAdminAPI, apiv2.http(apiv2.posts.read));
router.put('/posts/:id', mw.authAdminAPI, apiv2.http(apiv2.posts.edit));
router.del('/posts/:id', mw.authAdminAPI, apiv2.http(apiv2.posts.destroy));
// ## Schedules
router.put('/schedules/posts/:id', [

View File

@ -0,0 +1,541 @@
const should = require('should');
const supertest = require('supertest');
const _ = require('lodash');
const ObjectId = require('bson-objectid');
const moment = require('moment-timezone');
const testUtils = require('../../../../utils');
const localUtils = require('./utils');
const config = require('../../../../../../core/server/config');
const ghost = testUtils.startGhost;
let request;
describe('Posts API V2', function () {
let ghostServer;
describe('As Owner', function () {
let ownerCookie;
before(function () {
return ghost()
.then(function (_ghostServer) {
ghostServer = _ghostServer;
request = supertest.agent(config.get('url'));
})
.then(function () {
return localUtils.doAuth(request, 'users:extra', 'posts');
})
.then(function (cookie) {
ownerCookie = cookie;
});
});
describe('Browse', function () {
it('retrieves all published posts only by default', function (done) {
request.get(localUtils.API.getApiQuery('posts/'))
.set('Origin', config.get('url'))
.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']);
const jsonResponse = res.body;
should.exist(jsonResponse.posts);
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(11);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
testUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
_.isBoolean(jsonResponse.posts[0].featured).should.eql(true);
_.isBoolean(jsonResponse.posts[0].page).should.eql(true);
// Ensure default order
jsonResponse.posts[0].slug.should.eql('welcome');
jsonResponse.posts[10].slug.should.eql('html-ipsum');
// Absolute urls by default
jsonResponse.posts[0].url.should.eql(`${config.get('url')}/welcome/`);
jsonResponse.posts[9].feature_image.should.eql(`${config.get('url')}/content/images/2018/hey.jpg`);
done();
});
});
it('can retrieve multiple post formats', function (done) {
request.get(localUtils.API.getApiQuery('posts/?formats=plaintext,mobiledoc&limit=3&order=title%20ASC'))
.set('Origin', config.get('url'))
.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']);
const jsonResponse = res.body;
should.exist(jsonResponse.posts);
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(3);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post', ['mobiledoc', 'plaintext'], ['html']);
testUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
_.isBoolean(jsonResponse.posts[0].featured).should.eql(true);
_.isBoolean(jsonResponse.posts[0].page).should.eql(true);
// ensure order works
jsonResponse.posts[0].slug.should.eql('apps-integrations');
done();
});
});
it('fields & formats combined', function (done) {
request.get(localUtils.API.getApiQuery('posts/?formats=mobiledoc,html&fields=id,title'))
.set('Origin', config.get('url'))
.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']);
const jsonResponse = res.body;
should.exist(jsonResponse.posts);
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(11);
testUtils.API.checkResponse(
jsonResponse.posts[0],
'post',
null,
null,
['mobiledoc', 'id', 'title', 'html']
);
testUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
done();
});
});
it('with includes', function (done) {
request.get(localUtils.API.getApiQuery('posts/?include=tags,authors'))
.set('Origin', config.get('url'))
.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']);
const jsonResponse = res.body;
should.exist(jsonResponse.posts);
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(11);
testUtils.API.checkResponse(
jsonResponse.posts[0],
'post',
['tags', 'authors']
);
testUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
jsonResponse.posts[0].tags.length.should.eql(1);
jsonResponse.posts[0].authors.length.should.eql(1);
jsonResponse.posts[0].tags[0].url.should.eql(`${config.get('url')}/tag/getting-started/`);
jsonResponse.posts[0].authors[0].url.should.eql(`${config.get('url')}/author/ghost/`);
done();
});
});
it('fields combined with formats and include', function (done) {
request.get(localUtils.API.getApiQuery('posts/?formats=mobiledoc,html&fields=id,title&include=authors'))
.set('Origin', config.get('url'))
.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']);
const jsonResponse = res.body;
should.exist(jsonResponse.posts);
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(11);
testUtils.API.checkResponse(
jsonResponse.posts[0],
'post',
null,
null,
['mobiledoc', 'id', 'title', 'html', 'authors']
);
testUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
done();
});
});
it('can use a filter', function (done) {
request.get(localUtils.API.getApiQuery('posts/?filter=page:[false,true]&status=all'))
.set('Origin', config.get('url'))
.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']);
const jsonResponse = res.body;
should.exist(jsonResponse.posts);
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(15);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
testUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
done();
});
});
});
describe('read', function () {
it('by id', function (done) {
request.get(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/'))
.set('Origin', config.get('url'))
.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);
should.exist(jsonResponse.posts);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
jsonResponse.posts[0].id.should.equal(testUtils.DataGenerator.Content.posts[0].id);
jsonResponse.posts[0].page.should.not.be.ok();
_.isBoolean(jsonResponse.posts[0].featured).should.eql(true);
_.isBoolean(jsonResponse.posts[0].page).should.eql(true);
jsonResponse.posts[0].author.should.be.a.String();
testUtils.API.isISO8601(jsonResponse.posts[0].created_at).should.be.true();
jsonResponse.posts[0].created_by.should.be.a.String();
// Tags aren't included by default
should.not.exist(jsonResponse.posts[0].tags);
done();
});
});
it('by id, with formats', function (done) {
request
.get(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/?formats=plaintext,mobiledoc'))
.set('Origin', config.get('url'))
.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);
jsonResponse.posts.should.have.length(1);
jsonResponse.posts[0].id.should.equal(testUtils.DataGenerator.Content.posts[0].id);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post', ['mobiledoc', 'plaintext'], ['html']);
done();
});
});
it('can retrieve a post by slug', function (done) {
request.get(localUtils.API.getApiQuery('posts/slug/welcome/'))
.set('Origin', config.get('url'))
.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);
should.exist(jsonResponse.posts);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
jsonResponse.posts[0].slug.should.equal('welcome');
jsonResponse.posts[0].page.should.not.be.ok();
_.isBoolean(jsonResponse.posts[0].featured).should.eql(true);
_.isBoolean(jsonResponse.posts[0].page).should.eql(true);
jsonResponse.posts[0].author.should.be.a.String();
jsonResponse.posts[0].created_by.should.be.a.String();
// Tags aren't included by default
should.not.exist(jsonResponse.posts[0].tags);
done();
});
});
it('with includes', function (done) {
request
.get(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/?include=authors,tags,created_by'))
.set('Origin', config.get('url'))
.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);
should.exist(jsonResponse.posts);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post', ['tags', 'authors']);
jsonResponse.posts[0].author.should.be.a.String();
jsonResponse.posts[0].page.should.not.be.ok();
jsonResponse.posts[0].authors[0].should.be.an.Object();
testUtils.API.checkResponse(jsonResponse.posts[0].authors[0], 'user', ['url']);
jsonResponse.posts[0].tags[0].should.be.an.Object();
testUtils.API.checkResponse(jsonResponse.posts[0].tags[0], 'tag', ['url']);
done();
});
});
it('can\'t retrieve non existent post', function (done) {
request.get(localUtils.API.getApiQuery(`posts/${ObjectId.generate()}/`))
.set('Origin', config.get('url'))
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(404)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.errors);
testUtils.API.checkResponseValue(jsonResponse.errors[0], ['message', 'errorType']);
done();
});
});
});
describe('add', function () {
it('default', function () {
const post = {
title: 'My post',
status: 'draft',
published_at: '2016-05-30T07:00:00.000Z',
mobiledoc: testUtils.DataGenerator.markdownToMobiledoc('my post'),
created_at: moment().subtract(2, 'days').toDate(),
updated_at: moment().subtract(2, 'days').toDate(),
created_by: ObjectId.generate(),
updated_by: ObjectId.generate()
};
return request.post(localUtils.API.getApiQuery('posts'))
.set('Origin', config.get('url'))
.send({posts: [post]})
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
res.body.posts.length.should.eql(1);
testUtils.API.checkResponse(res.body.posts[0], 'post');
should.not.exist(res.headers['x-cache-invalidate']);
res.body.posts[0].title.should.eql(post.title);
res.body.posts[0].status.should.eql(post.status);
res.body.posts[0].published_at.should.eql('2016-05-30T07:00:00.000Z');
res.body.posts[0].published_at = '2016-05-30T09:00:00.000Z';
res.body.posts[0].created_at.should.not.eql(post.created_at.toISOString());
res.body.posts[0].updated_at.should.not.eql(post.updated_at.toISOString());
res.body.posts[0].updated_by.should.not.eql(post.updated_by);
res.body.posts[0].created_by.should.not.eql(post.created_by);
});
});
it('published post', function () {
const post = {
posts: [{
status: 'published'
}]
};
return request.post(localUtils.API.getApiQuery('posts'))
.set('Origin', config.get('url'))
.send(post)
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
res.body.posts.length.should.eql(1);
testUtils.API.checkResponse(res.body.posts[0], 'post');
res.body.posts[0].status.should.eql('published');
res.headers['x-cache-invalidate'].should.eql('/*');
});
});
});
describe('edit', function () {
it('default', function () {
const post = {
title: 'My new Title',
author: testUtils.DataGenerator.Content.extraUsers[0].id,
custom_template: 'custom-about'
};
return request
.put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/'))
.set('Origin', config.get('url'))
.send({posts: [post]})
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
res.headers['x-cache-invalidate'].should.eql('/*');
testUtils.API.checkResponse(res.body.posts[0], 'post');
res.body.posts[0].title.should.eql(post.title);
res.body.posts[0].author.should.eql(post.author);
res.body.posts[0].status.should.eql('published');
res.body.posts[0].custom_template.should.eql('custom-about');
});
});
it('update dates', function () {
const post = {
created_by: ObjectId.generate(),
updated_by: ObjectId.generate(),
created_at: moment().add(2, 'days').format(),
updated_at: moment().add(2, 'days').format()
};
return request
.put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/'))
.set('Origin', config.get('url'))
.send({posts: [post]})
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
res.headers['x-cache-invalidate'].should.eql('/*');
testUtils.API.checkResponse(res.body.posts[0], 'post');
// We expect that the changed properties aren't changed, they are still the same than before.
res.body.posts[0].created_by.should.not.eql(post.created_by);
res.body.posts[0].updated_by.should.not.eql(post.updated_by);
res.body.posts[0].created_at.should.not.eql(post.created_at);
// `updated_at` is automatically set, but it's not the date we send to override.
res.body.posts[0].updated_at.should.not.eql(post.updated_at);
});
});
it('update draft', function () {
const post = {
title: 'update draft'
};
return request.put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[3].id))
.set('Origin', config.get('url'))
.send({posts: [post]})
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
res.headers['x-cache-invalidate'].should.eql('/p/' + res.body.posts[0].uuid + '/');
});
});
it('unpublish', function () {
const post = {
status: 'draft'
};
return request
.put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[1].id + '/'))
.set('Origin', config.get('url'))
.send({posts: [post]})
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
res.headers['x-cache-invalidate'].should.eql('/*');
res.body.posts[0].status.should.eql('draft');
});
});
it('published_at = null', function () {
return request
.put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/'))
.set('Origin', config.get('url'))
.send({
posts: [{published_at: null}]
})
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
res.headers['x-cache-invalidate'].should.eql('/*');
should.exist(res.body.posts);
should.exist(res.body.posts[0].published_at);
testUtils.API.checkResponse(res.body.posts[0], 'post');
});
});
});
describe('destroy', function () {
it('default', function () {
return request
.del(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/'))
.set('Origin', config.get('url'))
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(204)
.then((res) => {
res.body.should.be.empty();
res.headers['x-cache-invalidate'].should.eql('/*');
});
});
it('non existent post', function () {
return request
.del(localUtils.API.getApiQuery('posts/' + ObjectId.generate() + '/'))
.set('Origin', config.get('url'))
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(404)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
should.exist(res.body);
should.exist(res.body.errors);
testUtils.API.checkResponseValue(res.body.errors[0], ['message', 'errorType']);
});
});
});
});
});

View File

@ -168,6 +168,23 @@ describe('Unit: api/shared/validators/input/all', function () {
should.exist(err);
});
});
it('invalid fields', function () {
const frame = {
options: {
context: {},
id: 'invalid'
}
};
const apiConfig = {};
return shared.validators.input.all.all(apiConfig, frame)
.then(Promise.reject)
.catch((err) => {
should.exist(err);
});
});
});
describe('browse', function () {

View File

@ -0,0 +1,83 @@
const should = require('should');
const sinon = require('sinon');
const testUtils = require('../../../../../../utils');
const urlService = require('../../../../../../../server/services/url');
const serializers = require('../../../../../../../server/api/v2/utils/serializers');
const sandbox = sinon.sandbox.create();
describe('Unit: v2/utils/serializers/output/posts', function () {
let postModel;
beforeEach(function () {
postModel = (data) => {
return {
toJSON: sandbox.stub().returns(data)
};
};
sandbox.stub(urlService, 'getUrlByResourceId').returns('getUrlByResourceId');
sandbox.stub(urlService.utils, 'urlFor').returns('urlFor');
sandbox.stub(urlService.utils, 'makeAbsoluteUrls').returns({html: sandbox.stub()});
});
afterEach(function () {
sandbox.restore();
});
describe('Ensure absolute urls are returned by default', function () {
it('meta & models & relations', function () {
const apiConfig = {};
const frame = {
options: {
withRelated: ['tags', 'authors']
}
};
const ctrlResponse = {
data: [
postModel(testUtils.DataGenerator.forKnex.createPost({
id: 'id1',
feature_image: 'value',
tags: [{
id: 'id3',
feature_image: 'value'
}],
authors: [{
id: 'id4',
name: 'Ghosty'
}]
})),
postModel(testUtils.DataGenerator.forKnex.createPost({
id: 'id2',
html: '<img href=/content/test.jpf'
}))
],
meta: {}
};
serializers.output.posts.all(ctrlResponse, apiConfig, frame);
frame.response.posts[0].hasOwnProperty('url').should.be.true();
frame.response.posts[0].tags[0].hasOwnProperty('url').should.be.true();
frame.response.posts[0].authors[0].hasOwnProperty('url').should.be.true();
frame.response.posts[1].hasOwnProperty('url').should.be.true();
urlService.utils.urlFor.callCount.should.eql(4);
urlService.utils.urlFor.getCall(0).args.should.eql(['image', {image: 'value'}, true]);
urlService.utils.urlFor.getCall(1).args.should.eql(['home', true]);
urlService.utils.urlFor.getCall(2).args.should.eql(['image', {image: 'value'}, true]);
urlService.utils.urlFor.getCall(3).args.should.eql(['home', true]);
urlService.utils.makeAbsoluteUrls.callCount.should.eql(2);
urlService.utils.makeAbsoluteUrls.getCall(0).args.should.eql(['## markdown', 'urlFor', 'getUrlByResourceId']);
urlService.utils.makeAbsoluteUrls.getCall(1).args.should.eql(['<img href=/content/test.jpf', 'urlFor', 'getUrlByResourceId']);
urlService.getUrlByResourceId.callCount.should.eql(4);
urlService.getUrlByResourceId.getCall(0).args.should.eql(['id1', {absolute: true}]);
urlService.getUrlByResourceId.getCall(1).args.should.eql(['id3', {absolute: true}]);
urlService.getUrlByResourceId.getCall(2).args.should.eql(['id4', {absolute: true}]);
urlService.getUrlByResourceId.getCall(3).args.should.eql(['id2', {absolute: true}]);
});
});
});

View File

@ -0,0 +1,105 @@
const should = require('should');
const Promise = require('bluebird');
const common = require('../../../../../../../server/lib/common');
const validators = require('../../../../../../../server/api/v2/utils/validators');
describe('Unit: v2/utils/validators/input/posts', function () {
describe('add', function () {
it('authors structure', function () {
const apiConfig = {
docName: 'posts'
};
const frame = {
options: {},
data: {
posts: [
{
authors: {}
}
]
}
};
return validators.input.posts.add(apiConfig, frame)
.then(Promise.reject)
.catch((err) => {
(err instanceof common.errors.BadRequestError).should.be.true();
});
});
it('authors structure', function () {
const apiConfig = {
docName: 'posts'
};
const frame = {
options: {},
data: {
posts: [
{
authors: [{
name: 'hey'
}]
}
]
}
};
return validators.input.posts.add(apiConfig, frame)
.then(Promise.reject)
.catch((err) => {
(err instanceof common.errors.BadRequestError).should.be.true();
});
});
it('authors structure', function () {
const apiConfig = {
docName: 'posts'
};
const frame = {
options: {},
data: {
posts: [
{
authors: [{
id: 'correct',
name: 'ja'
}]
}
]
}
};
return validators.input.posts.add(apiConfig, frame);
});
});
describe('edit', function () {
it('id mismatch', function () {
const apiConfig = {
docName: 'posts'
};
const frame = {
options: {
id: 'zwei'
},
data: {
posts: [
{
id: 'eins'
}
]
}
};
return validators.input.posts.edit(apiConfig, frame)
.then(Promise.reject)
.catch((err) => {
(err instanceof common.errors.BadRequestError).should.be.true();
});
});
});
});