Added plugin based author and public tag models in API v2 (#10284)

refs #10124

- Author model returns only users that have published non-page posts
- Added a public controller for tags (should be extracted to separate Content API controller https://github.com/TryGhost/Ghost/issues/10106)
- Made resource configuration dynamic based on current theme engine
- This needs a follow-up PR with fixes to the problems described in the PR
This commit is contained in:
Naz Gargol 2019-01-03 20:30:35 +01:00 committed by GitHub
parent 75037bcb28
commit d3f3b3dc20
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 529 additions and 183 deletions

View File

@ -25,7 +25,7 @@ module.exports = {
},
permissions: true,
query(frame) {
return models.User.findPage(frame.options);
return models.Author.findPage(frame.options);
}
},
@ -51,7 +51,7 @@ module.exports = {
},
permissions: true,
query(frame) {
return models.User.findOne(frame.data, frame.options)
return models.Author.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new common.errors.NotFoundError({

View File

@ -67,6 +67,10 @@ module.exports = {
return shared.pipeline(require('./tags'), localUtils);
},
get tagsPublic() {
return shared.pipeline(require('./tags-public'), localUtils);
},
get users() {
return shared.pipeline(require('./users'), localUtils);
},

View File

@ -0,0 +1,66 @@
const Promise = require('bluebird');
const common = require('../../lib/common');
const models = require('../../models');
const ALLOWED_INCLUDES = ['count.posts'];
module.exports = {
docName: 'tags',
browse: {
options: [
'include',
'filter',
'fields',
'limit',
'order',
'page',
'debug'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.TagPublic.findPage(frame.options);
}
},
read: {
options: [
'include',
'filter',
'fields',
'debug'
],
data: [
'id',
'slug',
'visibility'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.TagPublic.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new common.errors.NotFoundError({
message: common.i18n.t('errors.api.tags.tagNotFound')
}));
}
return model;
});
}
}
};

View File

@ -1,6 +1,7 @@
const Promise = require('bluebird');
const common = require('../../lib/common');
const models = require('../../models');
const ALLOWED_INCLUDES = ['count.posts'];
module.exports = {

View File

@ -158,6 +158,7 @@ class BaseSiteMapGenerator {
reset() {
this.nodeLookup = {};
this.nodeTimeLookup = {};
this.siteMapContent = null;
}
}

View File

@ -17,7 +17,7 @@ var proxy = require('./proxy'),
/**
* v0.1: users, posts, tags
* v2: authors, pages, posts, tags
* v2: authors, pages, posts, tagsPublic
*
* @NOTE: if you use "users" in v2, we should fallback to authors
*/
@ -27,7 +27,7 @@ const RESOURCES = {
resource: 'posts'
},
tags: {
alias: 'tags',
alias: 'tagsPublic',
resource: 'tags'
},
users: {

View File

@ -0,0 +1,18 @@
const ghostBookshelf = require('./base');
const user = require('./user');
const Author = user.User.extend({
shouldHavePosts: {
joinTo: 'author_id',
joinTable: 'posts_authors'
}
});
const Authors = ghostBookshelf.Collection.extend({
model: Author
});
module.exports = {
Author: ghostBookshelf.model('Author', Author),
Authors: ghostBookshelf.collection('Authors', Authors)
};

View File

@ -46,6 +46,9 @@ ghostBookshelf.plugin(plugins.pagination);
// Update collision plugin
ghostBookshelf.plugin(plugins.collision);
// Load hasPosts plugin for authors models
ghostBookshelf.plugin(plugins.hasPosts);
// Manages nested updates (relationships)
ghostBookshelf.plugin('bookshelf-relations', {
allowedOptions: ['context', 'importing', 'migrating'],
@ -1030,6 +1033,7 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
};
const exclude = options.exclude;
const filter = options.filter;
const shouldHavePosts = options.shouldHavePosts;
const withRelated = options.withRelated;
const withRelatedFields = options.withRelatedFields;
const relations = {
@ -1085,6 +1089,10 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
// @NOTE: We can't use the filter plugin, because we are not using bookshelf.
nql(filter).querySQL(query);
if (shouldHavePosts) {
require('../plugins/has-posts').addHasPostsWhere(tableNames[modelName], shouldHavePosts)(query);
}
return query.then((objects) => {
debug('fetched', modelName, filter);

View File

@ -29,7 +29,9 @@ models = [
'session',
'subscriber',
'tag',
'tag-public',
'user',
'author',
'invite',
'webhook',
'integration',

View File

@ -0,0 +1,60 @@
const _ = require('lodash');
const _debug = require('ghost-ignition').debug._base;
const debug = _debug('ghost-query');
const addHasPostsWhere = (tableName, config) => {
const comparisonField = `${tableName}.id`;
return function (qb) {
return qb.whereIn(comparisonField, function () {
const innerQb = this
.distinct(`${config.joinTable}.${config.joinTo}`)
.select()
.from(config.joinTable)
.whereRaw(`${config.joinTable}.${config.joinTo} = ${comparisonField}`)
.join('posts', 'posts.id', `${config.joinTable}.post_id`)
.andWhere('posts.status', '=', 'published');
debug(`QUERY has posts: ${innerQb.toSQL().sql}`);
return innerQb;
});
};
};
const hasPosts = function hasPosts(Bookshelf) {
const modelPrototype = Bookshelf.Model.prototype;
Bookshelf.Model = Bookshelf.Model.extend({
initialize: function () {
return modelPrototype.initialize.apply(this, arguments);
},
fetch: function () {
if (this.shouldHavePosts) {
this.query(addHasPostsWhere(_.result(this, 'tableName'), this.shouldHavePosts));
}
if (_debug.enabled('ghost-query')) {
debug('QUERY', this.query().toQuery());
}
return modelPrototype.fetch.apply(this, arguments);
},
fetchAll: function () {
if (this.shouldHavePosts) {
this.query(addHasPostsWhere(_.result(this, 'tableName'), this.shouldHavePosts));
}
if (_debug.enabled('ghost-query')) {
debug('QUERY', this.query().toQuery());
}
return modelPrototype.fetchAll.apply(this, arguments);
}
});
};
module.exports = hasPosts;
module.exports.addHasPostsWhere = addHasPostsWhere;

View File

@ -3,5 +3,6 @@ module.exports = {
includeCount: require('./include-count'),
pagination: require('./pagination'),
collision: require('./collision'),
transactionEvents: require('./transaction-events')
transactionEvents: require('./transaction-events'),
hasPosts: require('./has-posts')
};

View File

@ -0,0 +1,18 @@
const ghostBookshelf = require('./base');
const tag = require('./tag');
const TagPublic = tag.Tag.extend({
shouldHavePosts: {
joinTo: 'tag_id',
joinTable: 'posts_tags'
}
});
const TagsPublic = ghostBookshelf.Collection.extend({
model: TagPublic
});
module.exports = {
TagPublic: ghostBookshelf.model('TagPublic', TagPublic),
TagsPublic: ghostBookshelf.collection('TagsPublic', TagsPublic)
};

View File

@ -83,8 +83,22 @@ module.exports = {
// no need to check the score, activation should be used in combination with validate.check
// Use the two theme objects to set the current active theme
try {
let previousGhostAPI;
if (this.getActive()) {
previousGhostAPI = this.getActive().engine('ghost-api');
}
active.set(loadedTheme, checkedTheme, error);
const currentGhostAPI = this.getActive().engine('ghost-api');
common.events.emit('services.themes.activated');
if (previousGhostAPI !== undefined && (previousGhostAPI !== currentGhostAPI)) {
common.events.emit('services.themes.api.changed');
const siteApp = require('../../web/site/app');
siteApp.reload();
}
} catch (err) {
common.logging.error(new common.errors.InternalServerError({
message: common.i18n.t('errors.middleware.themehandler.activateFailed', {theme: loadedTheme.name}),

View File

@ -1,144 +1,21 @@
const debug = require('ghost-ignition').debug('services:url:resources'),
Promise = require('bluebird'),
_ = require('lodash'),
Resource = require('./Resource'),
config = require('../../config'),
models = require('../../models'),
common = require('../../lib/common');
const _ = require('lodash');
const Promise = require('bluebird');
const debug = require('ghost-ignition').debug('services:url:resources');
const Resource = require('./Resource');
const config = require('../../config');
const models = require('../../models');
const common = require('../../lib/common');
/**
* These are the default resources and filters.
* These are the minimum filters for public accessibility of resources.
*/
const resourcesConfig = [
{
type: 'posts',
modelOptions: {
modelName: 'Post',
filter: 'visibility:public+status:published+page:false',
exclude: [
'title',
'mobiledoc',
'html',
'plaintext',
'amp',
'codeinjection_head',
'codeinjection_foot',
'meta_title',
'meta_description',
'custom_excerpt',
'og_image',
'og_title',
'og_description',
'twitter_image',
'twitter_title',
'twitter_description',
'custom_template',
'feature_image',
'locale'
],
withRelated: ['tags', 'authors'],
withRelatedPrimary: {
primary_tag: 'tags',
primary_author: 'authors'
},
withRelatedFields: {
tags: ['tags.id', 'tags.slug'],
authors: ['users.id', 'users.slug']
}
},
events: {
add: 'post.published',
update: 'post.published.edited',
remove: 'post.unpublished'
}
},
{
type: 'pages',
modelOptions: {
modelName: 'Post',
exclude: [
'title',
'mobiledoc',
'html',
'plaintext',
'amp',
'codeinjection_head',
'codeinjection_foot',
'meta_title',
'meta_description',
'custom_excerpt',
'og_image',
'og_title',
'og_description',
'twitter_image',
'twitter_title',
'twitter_description',
'custom_template',
'feature_image',
'locale',
'tags',
'authors',
'primary_tag',
'primary_author'
],
filter: 'visibility:public+status:published+page:true'
},
events: {
add: 'page.published',
update: 'page.published.edited',
remove: 'page.unpublished'
}
},
{
type: 'tags',
keep: ['id', 'slug', 'updated_at', 'created_at'],
modelOptions: {
modelName: 'Tag',
exclude: [
'description',
'meta_title',
'meta_description'
],
filter: 'visibility:public'
},
events: {
add: 'tag.added',
update: 'tag.edited',
remove: 'tag.deleted'
}
},
{
type: 'authors',
modelOptions: {
modelName: 'User',
exclude: [
'bio',
'website',
'location',
'facebook',
'twitter',
'accessibility',
'meta_title',
'meta_description',
'tour'
],
filter: 'visibility:public'
},
events: {
add: 'user.activated',
update: 'user.activated.edited',
remove: 'user.deactivated'
}
}
];
/**
* NOTE: We are querying knex directly, because the Bookshelf ORM overhead is too slow.
* At the moment Resource service is directly responsible for data population
* for URLs in UrlService. But because it's actually a storage of all possible
* resources in the system, could also be used as a cache for Content API in
* the future.
*/
class Resources {
constructor(queue) {
this.queue = queue;
this.resourcesConfig = [];
this.data = {};
this.listeners = [];
@ -160,15 +37,27 @@ class Resources {
* Currently the url service needs to use the settings cache,
* because we need to `settings.permalink`.
*/
this._listenOn('db.ready', this._onDatabaseReady.bind(this));
this._listenOn('db.ready', this.fetchResources.bind(this));
}
_onDatabaseReady() {
_initResourceConfig() {
if (!_.isEmpty(this.resourcesConfig)) {
return this.resourceConfig;
}
this.resourcesAPIVersion = require('../themes').getActive().engine('ghost-api') || 'v0.1';
this.resourcesConfig = require(`./configs/${this.resourcesAPIVersion}`);
}
fetchResources() {
const ops = [];
debug('db ready. settings cache ready.');
this._initResourceConfig();
_.each(resourcesConfig, (resourceConfig) => {
_.each(this.resourcesConfig, (resourceConfig) => {
this.data[resourceConfig.type] = [];
// NOTE: We are querying knex directly, because the Bookshelf ORM overhead is too slow.
ops.push(this._fetch(resourceConfig));
this._listenOn(resourceConfig.events.add, (model) => {
@ -223,7 +112,7 @@ class Resources {
}
_onResourceAdded(type, model) {
const resourceConfig = _.find(resourcesConfig, {type: type});
const resourceConfig = _.find(this.resourcesConfig, {type: type});
const exclude = resourceConfig.modelOptions.exclude;
const withRelatedFields = resourceConfig.modelOptions.withRelatedFields;
const obj = _.omit(model.toJSON(), exclude);
@ -296,7 +185,7 @@ class Resources {
this.data[type].every((resource) => {
if (resource.data.id === model.id) {
const resourceConfig = _.find(resourcesConfig, {type: type});
const resourceConfig = _.find(this.resourcesConfig, {type: type});
const exclude = resourceConfig.modelOptions.exclude;
const withRelatedFields = resourceConfig.modelOptions.withRelatedFields;
const obj = _.omit(model.toJSON(), exclude);
@ -401,12 +290,13 @@ class Resources {
this.listeners = [];
this.data = {};
this.resourcesConfig = null;
}
softReset() {
this.data = {};
_.each(resourcesConfig, (resourceConfig) => {
_.each(this.resourcesConfig, (resourceConfig) => {
this.data[resourceConfig.type] = [];
});
}

View File

@ -29,6 +29,9 @@ class UrlService {
this._onRouterAddedListener = this._onRouterAddedType.bind(this);
common.events.on('router.created', this._onRouterAddedListener);
this._onThemeChangedListener = this._onThemeChangedListener.bind(this);
common.events.on('services.themes.api.changed', this._onThemeChangedListener);
/**
* The queue will notify us if url generation has started/finished.
*/
@ -65,6 +68,11 @@ class UrlService {
this.urlGenerators.push(urlGenerator);
}
_onThemeChangedListener() {
this.reset({keepListeners: true});
this.init();
}
/**
* You have a url and want to know which the url belongs to.
*
@ -215,7 +223,11 @@ class UrlService {
.getValue(options);
}
reset() {
init() {
this.resources.fetchResources();
}
reset(options = {}) {
debug('reset');
this.urlGenerators = [];
@ -223,9 +235,12 @@ class UrlService {
this.queue.reset();
this.resources.reset();
this._onQueueStartedListener && this.queue.removeListener('started', this._onQueueStartedListener);
this._onQueueEndedListener && this.queue.removeListener('ended', this._onQueueEndedListener);
this._onRouterAddedListener && common.events.removeListener('router.created', this._onRouterAddedListener);
if (!options.keepListeners) {
this._onQueueStartedListener && this.queue.removeListener('started', this._onQueueStartedListener);
this._onQueueEndedListener && this.queue.removeListener('ended', this._onQueueEndedListener);
this._onRouterAddedListener && common.events.removeListener('router.created', this._onRouterAddedListener);
this._onThemeChangedListener && common.events.removeListener('services.themes.api.changed', this._onThemeChangedListener);
}
}
resetGenerators(options = {}) {

View File

@ -0,0 +1,123 @@
/*
* These are the default resources and filters.
* They contain minimum filters for public accessibility of resources.
*/
module.exports = [
{
type: 'posts',
modelOptions: {
modelName: 'Post',
filter: 'visibility:public+status:published+page:false',
exclude: [
'title',
'mobiledoc',
'html',
'plaintext',
'amp',
'codeinjection_head',
'codeinjection_foot',
'meta_title',
'meta_description',
'custom_excerpt',
'og_image',
'og_title',
'og_description',
'twitter_image',
'twitter_title',
'twitter_description',
'custom_template',
'feature_image',
'locale'
],
withRelated: ['tags', 'authors'],
withRelatedPrimary: {
primary_tag: 'tags',
primary_author: 'authors'
},
withRelatedFields: {
tags: ['tags.id', 'tags.slug'],
authors: ['users.id', 'users.slug']
}
},
events: {
add: 'post.published',
update: 'post.published.edited',
remove: 'post.unpublished'
}
},
{
type: 'pages',
modelOptions: {
modelName: 'Post',
exclude: [
'title',
'mobiledoc',
'html',
'plaintext',
'amp',
'codeinjection_head',
'codeinjection_foot',
'meta_title',
'meta_description',
'custom_excerpt',
'og_image',
'og_title',
'og_description',
'twitter_image',
'twitter_title',
'twitter_description',
'custom_template',
'feature_image',
'locale',
'tags',
'authors',
'primary_tag',
'primary_author'
],
filter: 'visibility:public+status:published+page:true'
},
events: {
add: 'page.published',
update: 'page.published.edited',
remove: 'page.unpublished'
}
},
{
type: 'tags',
keep: ['id', 'slug', 'updated_at', 'created_at'],
modelOptions: {
modelName: 'Tag',
exclude: ['description', 'meta_title', 'meta_description'],
filter: 'visibility:public'
},
events: {
add: 'tag.added',
update: 'tag.edited',
remove: 'tag.deleted'
}
},
{
type: 'authors',
modelOptions: {
modelName: 'User',
exclude: [
'bio',
'website',
'location',
'facebook',
'twitter',
'accessibility',
'meta_title',
'meta_description',
'tour'
],
filter: 'visibility:public'
},
events: {
add: 'user.activated',
update: 'user.activated.edited',
remove: 'user.deactivated'
}
}
];

View File

@ -0,0 +1,131 @@
/*
* These are the default resources and filters.
* They contain minimum filters for public accessibility of resources.
*/
module.exports = [
{
type: 'posts',
modelOptions: {
modelName: 'Post',
filter: 'visibility:public+status:published+page:false',
exclude: [
'title',
'mobiledoc',
'html',
'plaintext',
'amp',
'codeinjection_head',
'codeinjection_foot',
'meta_title',
'meta_description',
'custom_excerpt',
'og_image',
'og_title',
'og_description',
'twitter_image',
'twitter_title',
'twitter_description',
'custom_template',
'feature_image',
'locale'
],
withRelated: ['tags', 'authors'],
withRelatedPrimary: {
primary_tag: 'tags',
primary_author: 'authors'
},
withRelatedFields: {
tags: ['tags.id', 'tags.slug'],
authors: ['users.id', 'users.slug']
}
},
events: {
add: 'post.published',
update: 'post.published.edited',
remove: 'post.unpublished'
}
},
{
type: 'pages',
modelOptions: {
modelName: 'Post',
exclude: [
'title',
'mobiledoc',
'html',
'plaintext',
'amp',
'codeinjection_head',
'codeinjection_foot',
'meta_title',
'meta_description',
'custom_excerpt',
'og_image',
'og_title',
'og_description',
'twitter_image',
'twitter_title',
'twitter_description',
'custom_template',
'feature_image',
'locale',
'tags',
'authors',
'primary_tag',
'primary_author'
],
filter: 'visibility:public+status:published+page:true'
},
events: {
add: 'page.published',
update: 'page.published.edited',
remove: 'page.unpublished'
}
},
{
type: 'tags',
keep: ['id', 'slug', 'updated_at', 'created_at'],
modelOptions: {
modelName: 'Tag',
exclude: ['description', 'meta_title', 'meta_description'],
filter: 'visibility:public',
shouldHavePosts: {
joinTo: 'tag_id',
joinTable: 'posts_tags'
}
},
events: {
add: 'tag.added',
update: 'tag.edited',
remove: 'tag.deleted'
}
},
{
type: 'authors',
modelOptions: {
modelName: 'User',
exclude: [
'bio',
'website',
'location',
'facebook',
'twitter',
'accessibility',
'meta_title',
'meta_description',
'tour'
],
filter: 'visibility:public',
shouldHavePosts: {
joinTo: 'author_id',
joinTable: 'posts_authors'
}
},
events: {
add: 'user.activated',
update: 'user.activated.edited',
remove: 'user.deactivated'
}
}
];

View File

@ -24,9 +24,9 @@ module.exports = function apiRoutes() {
router.get('/authors/slug/:slug', mw.authenticatePublic, apiv2.http(apiv2.authors.read));
// ## Tags
router.get('/tags', mw.authenticatePublic, apiv2.http(apiv2.tags.browse));
router.get('/tags/:id', mw.authenticatePublic, apiv2.http(apiv2.tags.read));
router.get('/tags/slug/:slug', mw.authenticatePublic, apiv2.http(apiv2.tags.read));
router.get('/tags', mw.authenticatePublic, apiv2.http(apiv2.tagsPublic.browse));
router.get('/tags/:id', mw.authenticatePublic, apiv2.http(apiv2.tagsPublic.read));
router.get('/tags/slug/:slug', mw.authenticatePublic, apiv2.http(apiv2.tagsPublic.read));
return router;
};

View File

@ -4,15 +4,13 @@ const testUtils = require('../../../../utils');
const localUtils = require('./utils');
const config = require('../../../../../../core/server/config');
const ghost = testUtils.startGhost;
let request;
describe('Tag API V2', function () {
let ghostServer;
let request;
before(function () {
return ghost()
.then(function (_ghostServer) {
ghostServer = _ghostServer;
request = supertest.agent(config.get('url'));
})
.then(function () {

View File

@ -45,7 +45,7 @@ describe('Authors Content API V2', function () {
var jsonResponse = res.body;
should.exist(jsonResponse.authors);
localUtils.API.checkResponse(jsonResponse, 'authors');
jsonResponse.authors.should.have.length(7);
jsonResponse.authors.should.have.length(3);
// We don't expose the email address, status and other attrs.
localUtils.API.checkResponse(jsonResponse.authors[0], 'author', ['url'], null, null);
@ -55,9 +55,9 @@ describe('Authors Content API V2', function () {
should.exist(url.parse(res.body.authors[0].url).host);
// Public api returns all authors, but no status! Locked/Inactive authors can still have written articles.
models.User.findPage(Object.assign({status: 'all'}, testUtils.context.internal))
models.Author.findPage(Object.assign({status: 'all'}, testUtils.context.internal))
.then((response) => {
_.map(response.data, (model) => model.toJSON()).length.should.eql(7);
_.map(response.data, (model) => model.toJSON()).length.should.eql(3);
done();
});
});
@ -139,29 +139,21 @@ describe('Authors Content API V2', function () {
var jsonResponse = res.body;
should.exist(jsonResponse.authors);
jsonResponse.authors.should.have.length(7);
jsonResponse.authors.should.have.length(3);
// We don't expose the email address.
localUtils.API.checkResponse(jsonResponse.authors[0], 'author', ['count', 'url'], null, null);
// Each user should have the correct count
// Each user should have the correct count and be more than 0
_.find(jsonResponse.authors, {slug:'joe-bloggs'}).count.posts.should.eql(4);
_.find(jsonResponse.authors, {slug:'contributor'}).count.posts.should.eql(0);
_.find(jsonResponse.authors, {slug:'slimer-mcectoplasm'}).count.posts.should.eql(1);
_.find(jsonResponse.authors, {slug:'jimothy-bogendath'}).count.posts.should.eql(0);
_.find(jsonResponse.authors, {slug: 'smith-wellingsworth'}).count.posts.should.eql(0);
_.find(jsonResponse.authors, {slug:'ghost'}).count.posts.should.eql(7);
_.find(jsonResponse.authors, {slug:'inactive'}).count.posts.should.eql(0);
const ids = jsonResponse.authors
.filter(author => (author.slug !== 'ghost'))
.filter(author => (author.slug !== 'inactive'))
.map(user=> user.id);
ids.should.eql([
testUtils.DataGenerator.Content.users[1].id,
testUtils.DataGenerator.Content.users[2].id,
testUtils.DataGenerator.Content.users[7].id,
testUtils.DataGenerator.Content.users[3].id,
testUtils.DataGenerator.Content.users[0].id
]);
@ -185,7 +177,7 @@ describe('Authors Content API V2', function () {
var jsonResponse = res.body;
should.exist(jsonResponse.authors);
localUtils.API.checkResponse(jsonResponse, 'authors');
jsonResponse.authors.should.have.length(7);
jsonResponse.authors.should.have.length(3);
// We don't expose the email address.
localUtils.API.checkResponse(jsonResponse.authors[0], 'author', ['count', 'url'], null, null);

View File

@ -4,19 +4,16 @@ const _ = require('lodash');
const url = require('url');
const configUtils = require('../../../../utils/configUtils');
const config = require('../../../../../../core/server/config');
const models = require('../../../../../../core/server/models');
const testUtils = require('../../../../utils');
const localUtils = require('./utils');
const ghost = testUtils.startGhost;
let request;
describe('Tags Content API V2', function () {
let ghostServer;
let request;
before(function () {
return ghost()
.then(function (_ghostServer) {
ghostServer = _ghostServer;
request = supertest.agent(config.get('url'));
})
.then(function () {
@ -45,7 +42,7 @@ describe('Tags Content API V2', function () {
var jsonResponse = res.body;
should.exist(jsonResponse.tags);
localUtils.API.checkResponse(jsonResponse, 'tags');
jsonResponse.tags.should.have.length(15);
jsonResponse.tags.should.have.length(4);
localUtils.API.checkResponse(jsonResponse.tags[0], 'tag', ['url']);
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
@ -71,7 +68,7 @@ describe('Tags Content API V2', function () {
var jsonResponse = res.body;
should.exist(jsonResponse.tags);
localUtils.API.checkResponse(jsonResponse, 'tags');
jsonResponse.tags.should.have.length(56);
jsonResponse.tags.should.have.length(4);
localUtils.API.checkResponse(jsonResponse.tags[0], 'tag', ['url']);
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
done();
@ -79,7 +76,7 @@ describe('Tags Content API V2', function () {
});
it('browse tags without limit=4 fetches 4 tags', function (done) {
request.get(localUtils.API.getApiQuery(`tags/?limit=4&key=${validKey}`))
request.get(localUtils.API.getApiQuery(`tags/?limit=3&key=${validKey}`))
.set('Origin', testUtils.API.getURL())
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
@ -93,7 +90,7 @@ describe('Tags Content API V2', function () {
var jsonResponse = res.body;
should.exist(jsonResponse.tags);
localUtils.API.checkResponse(jsonResponse, 'tags');
jsonResponse.tags.should.have.length(4);
jsonResponse.tags.should.have.length(3);
localUtils.API.checkResponse(jsonResponse.tags[0], 'tag', ['url']);
localUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
done();
@ -114,15 +111,13 @@ describe('Tags Content API V2', function () {
const jsonResponse = res.body;
should.exist(jsonResponse.tags);
jsonResponse.tags.should.be.an.Array().with.lengthOf(56);
jsonResponse.tags.should.be.an.Array().with.lengthOf(4);
// Each tag should have the correct count
_.find(jsonResponse.tags, {name: 'Getting Started'}).count.posts.should.eql(7);
_.find(jsonResponse.tags, {name: 'kitchen sink'}).count.posts.should.eql(2);
_.find(jsonResponse.tags, {name: 'bacon'}).count.posts.should.eql(2);
_.find(jsonResponse.tags, {name: 'chorizo'}).count.posts.should.eql(1);
_.find(jsonResponse.tags, {name: 'pollo'}).count.posts.should.eql(0);
_.find(jsonResponse.tags, {name: 'injection'}).count.posts.should.eql(0);
done();
});

View File

@ -6,6 +6,7 @@ const testUtils = require('../../../utils');
const configUtils = require('../../../utils/configUtils');
const models = require('../../../../server/models');
const common = require('../../../../server/lib/common');
const themes = require('../../../../server/services/themes');
const UrlService = rewire('../../../../server/services/url/UrlService');
const sandbox = sinon.sandbox.create();
@ -14,6 +15,10 @@ describe('Integration: services/url/UrlService', function () {
before(function () {
models.init();
sandbox.stub(themes, 'getActive').returns({
engine: () => 'v0.1'
});
});
before(testUtils.teardown);

View File

@ -4,6 +4,7 @@ const should = require('should'),
moment = require('moment'),
testUtils = require('../../utils'),
configUtils = require('../../utils/configUtils'),
themes = require('../../../server/services/themes'),
models = require('../../../server/models'),
imageLib = require('../../../server/lib/image'),
routing = require('../../../server/services/routing'),
@ -277,6 +278,9 @@ describe('{{ghost_head}} helper', function () {
// @TODO: this is a LOT of mocking :/
sandbox.stub(routing.registry, 'getRssUrl').returns('http://localhost:65530/rss/');
sandbox.stub(imageLib.imageSize, 'getImageSizeFromUrl').resolves();
sandbox.stub(themes, 'getActive').returns({
engine: () => 'v0.1'
});
sandbox.stub(settingsCache, 'get');
settingsCache.get.withArgs('title').returns('Ghost');

View File

@ -1,4 +1,3 @@
const should = require('should');
const _ = require('lodash');
const sinon = require('sinon');

View File

@ -54,8 +54,9 @@ describe('Unit: services/url/UrlService', function () {
urlService.queue.addListener.args[0][0].should.eql('started');
urlService.queue.addListener.args[1][0].should.eql('ended');
common.events.on.calledOnce.should.be.true();
common.events.on.calledTwice.should.be.true();
common.events.on.args[0][0].should.eql('router.created');
common.events.on.args[1][0].should.eql('services.themes.api.changed');
});
it('fn: _onQueueStarted', function () {