Dynamic Routing Beta: Channels

refs #9601

- refactor architecture of routing so you can define a channel
- a channel is a different way of looking at your posts (a view)
- a channel does not change the url of a resource

Example channel

```
routes:
  /worldcup-2018-russia/:
    controller: channel
    filter: tag:football18
    data: tag.football18
```

- added ability to redirect resources to a channel/static route
- support templates for channels
- ensure we still support static routes (e.g. /about/: home)
- ensure pagination + rss works out of the box
This commit is contained in:
kirrg001 2018-06-24 00:32:08 +02:00 committed by Katharina Irrgang
parent 0229ac9e82
commit c2fa469c4d
26 changed files with 1641 additions and 328 deletions

View File

@ -1,6 +1,9 @@
const debug = require('ghost-ignition').debug('services:routing:static-pages-router');
const common = require('../../lib/common');
const helpers = require('./helpers');
const urlService = require('../../services/url');
const RSSRouter = require('./RSSRouter');
const controllers = require('./controllers');
const middlewares = require('./middlewares');
const ParentRouter = require('./ParentRouter');
class StaticRoutesRouter extends ParentRouter {
@ -8,39 +11,88 @@ class StaticRoutesRouter extends ParentRouter {
super('StaticRoutesRouter');
this.route = {value: mainRoute};
this.templates = object.templates || [];
this.templates = (object.templates || []).reverse();
this.data = object.data || {query: {}, router: {}};
debug(this.route.value, this.templates);
this._registerRoutes();
if (this.isChannel(object)) {
this.filter = object.filter;
this.limit = object.limit;
this.order = object.order;
this.routerName = mainRoute === '/' ? 'index' : mainRoute.replace(/\//g, '');
this.controller = object.controller;
debug(this.route.value, this.templates, this.filter, this.data);
this._registerChannelRoutes();
} else {
debug(this.route.value, this.templates);
this._registerStaticRoute();
}
}
_registerRoutes() {
this.router().use(this._prepareContext.bind(this));
_registerChannelRoutes() {
this.router().use(this._prepareChannelContext.bind(this));
this.mountRoute(this.route.value, this._renderStaticRoute.bind(this));
// REGISTER: enable rss by default
this.rssRouter = new RSSRouter();
this.mountRouter(this.route.value, this.rssRouter.router());
// REGISTER: channel route
this.mountRoute(this.route.value, controllers[this.controller]);
// REGISTER: pagination
this.router().param('page', middlewares.pageParam);
this.mountRoute(urlService.utils.urlJoin(this.route.value, 'page', ':page(\\d+)'), controllers[this.controller]);
common.events.emit('router.created', this);
}
_prepareContext(req, res, next) {
// @TODO: index.hbs as fallback for static routes O_O
res._route = {
type: 'custom',
templates: this.templates,
defaultTemplate: 'index'
_prepareChannelContext(req, res, next) {
res.locals.routerOptions = {
name: this.routerName,
context: [this.routerName],
filter: this.filter,
limit: this.limit,
order: this.order,
data: this.data.query,
templates: this.templates
};
res.locals.routerOptions = {
context: []
res._route = {
type: this.controller
};
next();
}
_renderStaticRoute(req, res) {
debug('StaticRoutesRouter');
helpers.renderer(req, res, {});
_registerStaticRoute() {
this.router().use(this._prepareStaticRouteContext.bind(this));
this.mountRoute(this.route.value, controllers.static);
common.events.emit('router.created', this);
}
_prepareStaticRouteContext(req, res, next) {
res.locals.routerOptions = {
data: this.data.query,
context: []
};
res._route = {
type: 'custom',
templates: this.templates
};
next();
}
isChannel(object) {
if (object && object.controller && object.controller === 'channel') {
return true;
}
return this.controller === 'channel';
}
}

View File

@ -1,4 +1,5 @@
const debug = require('ghost-ignition').debug('services:routing:taxonomy-router');
const _ = require('lodash');
const common = require('../../lib/common');
const ParentRouter = require('./ParentRouter');
const RSSRouter = require('./RSSRouter');
@ -30,15 +31,18 @@ class TaxonomyRouter extends ParentRouter {
// REGISTER: context middleware
this.router().use(this._prepareContext.bind(this));
this.router().param('slug', this._respectDominantRouter.bind(this));
// REGISTER: enable rss by default
this.mountRouter(this.permalinks.getValue(), new RSSRouter().router());
this.rssRouter = new RSSRouter();
this.mountRouter(this.permalinks.getValue(), this.rssRouter.router());
// REGISTER: e.g. /tag/:slug/
this.mountRoute(this.permalinks.getValue(), controllers.collection);
this.mountRoute(this.permalinks.getValue(), controllers.channel);
// REGISTER: enable pagination for each taxonomy by default
this.router().param('page', middlewares.pageParam);
this.mountRoute(urlService.utils.urlJoin(this.permalinks.value, 'page', ':page(\\d+)'), controllers.collection);
this.mountRoute(urlService.utils.urlJoin(this.permalinks.value, 'page', ':page(\\d+)'), controllers.channel);
this.mountRoute(urlService.utils.urlJoin(this.permalinks.value, 'edit'), this._redirectEditOption.bind(this));
@ -58,7 +62,7 @@ class TaxonomyRouter extends ParentRouter {
};
res._route = {
type: 'collection'
type: 'channel'
};
next();

View File

@ -0,0 +1,68 @@
const _ = require('lodash'),
debug = require('ghost-ignition').debug('services:routing:controllers:channel'),
common = require('../../../lib/common'),
security = require('../../../lib/security'),
themes = require('../../themes'),
filters = require('../../../filters'),
helpers = require('../helpers');
// @TODO: the collection+rss controller does almost the same
module.exports = function channelController(req, res, next) {
debug('channelController', req.params, res.locals.routerOptions);
const pathOptions = {
page: req.params.page !== undefined ? req.params.page : 1,
slug: req.params.slug ? security.string.safe(req.params.slug) : undefined
};
if (pathOptions.page) {
// CASE 1: routes.yaml `limit` is stronger than theme definition
// CASE 2: use `posts_per_page` config from theme as `limit` value
if (res.locals.routerOptions.limit) {
themes.getActive().updateTemplateOptions({
data: {
config: {
posts_per_page: res.locals.routerOptions.limit
}
}
});
pathOptions.limit = res.locals.routerOptions.limit;
} else {
const postsPerPage = parseInt(themes.getActive().config('posts_per_page'));
if (!isNaN(postsPerPage) && postsPerPage > 0) {
pathOptions.limit = postsPerPage;
}
}
}
return helpers.fetchData(pathOptions, res.locals.routerOptions)
.then(function handleResult(result) {
// CASE: requested page is greater than number of pages we have
if (pathOptions.page > result.meta.pagination.pages) {
return next(new common.errors.NotFoundError({
message: common.i18n.t('errors.errors.pageNotFound')
}));
}
// Format data 1
// @TODO: figure out if this can be removed, it's supposed to ensure that absolutely URLs get generated
// correctly for the various objects, but I believe it doesn't work and a different approach is needed.
helpers.secure(req, result.posts);
// @TODO: get rid of this O_O
_.each(result.data, function (data) {
helpers.secure(req, data);
});
// @TODO: properly design these filters
filters.doFilter('prePostsRender', result.posts, res.locals)
.then(function (posts) {
result.posts = posts;
return result;
})
.then(helpers.renderEntries(req, res));
})
.catch(helpers.handleError(next));
};

View File

@ -2,6 +2,7 @@ const _ = require('lodash'),
debug = require('ghost-ignition').debug('services:routing:controllers:collection'),
common = require('../../../lib/common'),
security = require('../../../lib/security'),
urlService = require('../../../services/url'),
themes = require('../../themes'),
filters = require('../../../filters'),
helpers = require('../helpers');
@ -45,6 +46,13 @@ module.exports = function collectionController(req, res, next) {
}));
}
// CASE: does this post belong to this collection?
result.posts = _.filter(result.posts, (post) => {
if (urlService.owns(res.locals.routerOptions.identifier, post.url)) {
return post;
}
});
// Format data 1
// @TODO: figure out if this can be removed, it's supposed to ensure that absolutely URLs get generated
// correctly for the various objects, but I believe it doesn't work and a different approach is needed.
@ -61,7 +69,7 @@ module.exports = function collectionController(req, res, next) {
result.posts = posts;
return result;
})
.then(helpers.renderCollection(req, res));
.then(helpers.renderEntries(req, res));
})
.catch(helpers.handleError(next));
};

View File

@ -13,5 +13,13 @@ module.exports = {
get preview() {
return require('./preview');
},
get channel() {
return require('./channel');
},
get static() {
return require('./static');
}
};

View File

@ -0,0 +1,47 @@
const _ = require('lodash'),
Promise = require('bluebird'),
debug = require('ghost-ignition').debug('services:routing:controllers:static'),
api = require('../../../api'),
helpers = require('../helpers');
function processQuery(query) {
query = _.cloneDeep(query);
// Return a promise for the api query
return api[query.resource][query.type](query.options);
}
module.exports = function staticController(req, res, next) {
debug('staticController', res.locals.routerOptions);
let props = {};
_.each(res.locals.routerOptions.data, function (query, name) {
props[name] = processQuery(query);
});
return Promise.props(props)
.then(function handleResult(result) {
let response = {};
if (res.locals.routerOptions.data) {
response.data = {};
_.each(res.locals.routerOptions.data, function (config, name) {
if (config.type === 'browse') {
response.data[name] = result[name];
} else {
response.data[name] = result[name][config.resource];
}
});
}
// @TODO: get rid of this O_O
_.each(response.data, function (data) {
helpers.secure(req, data);
});
helpers.renderer(req, res, helpers.formatResponse.entries(response));
})
.catch(helpers.handleError(next));
};

View File

@ -4,7 +4,6 @@
*/
const _ = require('lodash'),
Promise = require('bluebird'),
urlService = require('../../url'),
api = require('../../../api'),
defaultPostQuery = {};
@ -80,7 +79,7 @@ function fetchData(pathOptions, routerOptions) {
postQuery.options.limit = pathOptions.limit;
}
// CASE: always fetch post collection
// CASE: always fetch post entries
// The filter can in theory contain a "%s" e.g. filter="primary_tag:%s"
props.posts = processQuery(postQuery, pathOptions.slug);
@ -92,27 +91,15 @@ function fetchData(pathOptions, routerOptions) {
return Promise.props(props)
.then(function formatResponse(results) {
const response = _.cloneDeep(results.posts);
delete results.posts;
// CASE: does this post belong to this collection?
// EXCEPTION: Taxonomies always list the posts which belong to a tag/author.
if (!routerOptions.data && routerOptions.identifier) {
response.posts = _.filter(response.posts, (post) => {
if (urlService.owns(routerOptions.identifier, post.url)) {
return post;
}
});
}
// process any remaining data
if (!_.isEmpty(results)) {
if (routerOptions.data) {
response.data = {};
_.each(results, function (result, name) {
if (routerOptions.data[name].type === 'browse') {
response.data[name] = result;
_.each(routerOptions.data, function (config, name) {
if (config.type === 'browse') {
response.data[name] = results[name];
} else {
response.data[name] = result[routerOptions.data[name].resource];
response.data[name] = results[name][config.resource];
}
});
}

View File

@ -6,10 +6,15 @@ const _ = require('lodash');
* @return {Object} containing page variables
*/
function formatPageResponse(result) {
var response = {
posts: result.posts,
pagination: result.meta.pagination
};
var response = {};
if (result.posts) {
response.posts = result.posts;
}
if (result.meta && result.meta.pagination) {
response.pagination = result.meta.pagination;
}
_.each(result.data, function (data, name) {
if (data.meta) {
@ -37,6 +42,6 @@ function formatResponse(post) {
}
module.exports = {
collection: formatPageResponse,
entries: formatPageResponse,
entry: formatResponse
};

View File

@ -7,8 +7,8 @@ module.exports = {
return require('./fetch-data');
},
get renderCollection() {
return require('./render-collection');
get renderEntries() {
return require('./render-entries');
},
get formatResponse() {

View File

@ -1,12 +0,0 @@
const debug = require('ghost-ignition').debug('services:routing:helpers:render-collection'),
formatResponse = require('./format-response'),
renderer = require('./renderer');
module.exports = function renderCollection(req, res) {
debug('renderCollection called');
return function renderCollection(result) {
// Format data 2
// Render
return renderer(req, res, formatResponse.collection(result));
};
};

View File

@ -0,0 +1,12 @@
const debug = require('ghost-ignition').debug('services:routing:helpers:render-entries'),
formatResponse = require('./format-response'),
renderer = require('./renderer');
module.exports = function renderEntries(req, res) {
debug('renderEntries called');
return function renderEntries(result) {
// Format data 2
// Render
return renderer(req, res, formatResponse.entries(result));
};
};

View File

@ -34,7 +34,7 @@ _private.getErrorTemplateHierarchy = function getErrorTemplateHierarchy(statusCo
};
/**
* ## Get Collection Template Hierarchy
* ## Get Template Hierarchy
*
* Fetch the ordered list of templates that can be used to render this request.
* 'index' is the default / fallback
@ -45,7 +45,7 @@ _private.getErrorTemplateHierarchy = function getErrorTemplateHierarchy(statusCo
* @param {Object} routerOptions
* @returns {String[]}
*/
_private.getCollectionTemplateHierarchy = function getCollectionTemplateHierarchy(routerOptions, requestOptions) {
_private.getEntriesTemplateHierarchy = function getEntriesTemplateHierarchy(routerOptions, requestOptions) {
const templateList = ['index'];
// CASE: author, tag, custom collection name
@ -57,7 +57,7 @@ _private.getCollectionTemplateHierarchy = function getCollectionTemplateHierarch
}
}
// CASE: collections can define a template list
// CASE: collections/channels can define a template list
if (routerOptions.templates && routerOptions.templates.length) {
routerOptions.templates.forEach((template) => {
templateList.unshift(template);
@ -145,8 +145,8 @@ _private.getTemplateForEntry = function getTemplateForEntry(postObject) {
return _private.pickTemplate(templateList, fallback);
};
_private.getTemplateForCollection = function getTemplateForCollection(routerOptions, requestOptions) {
const templateList = _private.getCollectionTemplateHierarchy(routerOptions, requestOptions),
_private.getTemplateForEntries = function getTemplateForEntries(routerOptions, requestOptions) {
const templateList = _private.getEntriesTemplateHierarchy(routerOptions, requestOptions),
fallback = templateList[templateList.length - 1];
return _private.pickTemplate(templateList, fallback);
};
@ -169,21 +169,17 @@ module.exports.setTemplate = function setTemplate(req, res, data) {
return;
}
switch (routeConfig.type) {
case 'custom':
res._template = _private.pickTemplate(routeConfig.templates, routeConfig.defaultTemplate);
break;
case 'collection':
res._template = _private.getTemplateForCollection(res.locals.routerOptions, {
path: url.parse(req.url).pathname,
page: req.params.page,
slugParam: req.params.slug
});
break;
case 'entry':
res._template = _private.getTemplateForEntry(data.post);
break;
default:
res._template = 'index';
if (['channel', 'collection'].indexOf(routeConfig.type) !== -1) {
res._template = _private.getTemplateForEntries(res.locals.routerOptions, {
path: url.parse(req.url).pathname,
page: req.params.page,
slugParam: req.params.slug
});
} else if (routeConfig.type === 'custom') {
res._template = _private.pickTemplate(routeConfig.templates, routeConfig.defaultTemplate);
} else if (routeConfig.type === 'entry') {
res._template = _private.getTemplateForEntry(data.post);
} else {
res._template = 'index';
}
};

View File

@ -1,10 +1,12 @@
const should = require('should'),
sinon = require('sinon'),
_ = require('lodash'),
cheerio = require('cheerio'),
testUtils = require('../../utils'),
configUtils = require('../../utils/configUtils'),
api = require('../../../server/api'),
settingsService = require('../../../server/services/settings'),
RESOURCE_CONFIG = require('../../../server/services/routing/assets/resource-config'),
themeConfig = require('../../../server/services/themes/config'),
siteApp = require('../../../server/web/parent-app'),
sandbox = sinon.sandbox.create();
@ -12,6 +14,7 @@ const should = require('should'),
describe('Integration - Web - Site', function () {
let app;
before(testUtils.teardown);
before(testUtils.setup('users:roles', 'posts'));
describe('default routes.yaml', function () {
@ -424,8 +427,8 @@ describe('Integration - Web - Site', function () {
});
});
describe('extended routes.yaml (1): 2 collections', function () {
describe('behaviour: default cases', function () {
describe('extended routes.yaml: collections', function () {
describe('2 collections', function () {
before(function () {
sandbox.stub(settingsService, 'get').returns({
routes: {
@ -605,51 +608,49 @@ describe('Integration - Web - Site', function () {
});
});
});
});
describe('extended routes.yaml (2): static permalink route', function () {
before(function () {
sandbox.stub(settingsService, 'get').returns({
routes: {},
describe('static permalink route', function () {
before(function () {
sandbox.stub(settingsService, 'get').returns({
routes: {},
collections: {
'/podcast/': {
permalink: '/featured/',
filter: 'featured:true'
collections: {
'/podcast/': {
permalink: '/featured/',
filter: 'featured:true'
},
'/': {
permalink: '/:slug/'
}
},
'/': {
permalink: '/:slug/'
}
},
taxonomies: {}
});
taxonomies: {}
testUtils.integrationTesting.urlService.resetGenerators();
testUtils.integrationTesting.defaultMocks(sandbox);
return testUtils.integrationTesting.initGhost()
.then(function () {
app = siteApp();
return testUtils.integrationTesting.urlService.waitTillFinished();
});
});
testUtils.integrationTesting.urlService.resetGenerators();
testUtils.integrationTesting.defaultMocks(sandbox);
beforeEach(function () {
testUtils.integrationTesting.overrideGhostConfig(configUtils);
});
return testUtils.integrationTesting.initGhost()
.then(function () {
app = siteApp();
afterEach(function () {
configUtils.restore();
});
return testUtils.integrationTesting.urlService.waitTillFinished();
});
});
after(function () {
sandbox.restore();
});
beforeEach(function () {
testUtils.integrationTesting.overrideGhostConfig(configUtils);
});
afterEach(function () {
configUtils.restore();
});
after(function () {
sandbox.restore();
});
describe('behaviour: default cases', function () {
it('serve post', function () {
const req = {
secure: true,
@ -711,10 +712,323 @@ describe('Integration - Web - Site', function () {
});
});
});
describe('primary author permalink', function () {
before(function () {
sandbox.stub(settingsService, 'get').returns({
routes: {},
collections: {
'/something/': {
permalink: '/:primary_author/:slug/'
}
},
taxonomies: {}
});
testUtils.integrationTesting.urlService.resetGenerators();
testUtils.integrationTesting.defaultMocks(sandbox);
return testUtils.integrationTesting.initGhost()
.then(function () {
app = siteApp();
return testUtils.integrationTesting.urlService.waitTillFinished();
});
});
beforeEach(function () {
testUtils.integrationTesting.overrideGhostConfig(configUtils);
});
afterEach(function () {
configUtils.restore();
});
after(function () {
sandbox.restore();
});
it('serve post', function () {
const req = {
secure: true,
method: 'GET',
url: '/joe-bloggs/html-ipsum/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
response.template.should.eql('post');
});
});
it('post without author', function () {
const req = {
secure: true,
method: 'GET',
url: '/html-ipsum/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(404);
response.template.should.eql('error-404');
});
});
it('page', function () {
const req = {
secure: true,
method: 'GET',
url: '/static-page-test/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
response.template.should.eql('page');
});
});
});
describe('primary tag permalink', function () {
before(function () {
sandbox.stub(settingsService, 'get').returns({
routes: {},
collections: {
'/something/': {
permalink: '/something/:primary_tag/:slug/'
}
},
taxonomies: {}
});
testUtils.integrationTesting.urlService.resetGenerators();
testUtils.integrationTesting.defaultMocks(sandbox);
return testUtils.integrationTesting.initGhost()
.then(function () {
app = siteApp();
return testUtils.integrationTesting.urlService.waitTillFinished();
});
});
beforeEach(function () {
testUtils.integrationTesting.overrideGhostConfig(configUtils);
});
afterEach(function () {
configUtils.restore();
});
after(function () {
sandbox.restore();
});
it('serve post', function () {
const req = {
secure: true,
method: 'GET',
url: '/something/kitchen-sink/html-ipsum/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
response.template.should.eql('post');
});
});
it('post without tag', function () {
const req = {
secure: true,
method: 'GET',
url: '/something/html-ipsum/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(404);
response.template.should.eql('error-404');
});
});
it('post without tag', function () {
const req = {
secure: true,
method: 'GET',
url: '/html-ipsum/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(404);
response.template.should.eql('error-404');
});
});
it('page', function () {
const req = {
secure: true,
method: 'GET',
url: '/static-page-test/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
response.template.should.eql('page');
});
});
});
describe('collection with data key', function () {
before(function () {
sandbox.stub(settingsService, 'get').returns({
routes: {},
collections: {
'/food/': {
permalink: '/food/:slug/',
filter: 'tag:bacon',
data: {
query: {
tag: {
resource: 'tags',
type: 'read',
options: {
slug: 'bacon'
}
}
},
router: {
tags: [{redirect: true, slug: 'bacon'}]
}
}
},
'/sport/': {
permalink: '/sport/:slug/',
filter: 'tag:pollo',
data: {
query: {
apollo: {
resource: 'tags',
type: 'read',
options: {
slug: 'pollo'
}
}
},
router: {
tags: [{redirect: false, slug: 'bacon'}]
}
}
}
},
taxonomies: {
tag: '/categories/:slug/',
author: '/authors/:slug/'
}
});
testUtils.integrationTesting.urlService.resetGenerators();
testUtils.integrationTesting.defaultMocks(sandbox);
return testUtils.integrationTesting.initGhost()
.then(function () {
app = siteApp();
return testUtils.integrationTesting.urlService.waitTillFinished();
});
});
beforeEach(function () {
testUtils.integrationTesting.overrideGhostConfig(configUtils);
});
afterEach(function () {
configUtils.restore();
});
after(function () {
sandbox.restore();
});
it('serve /food/', function () {
const req = {
secure: true,
method: 'GET',
url: '/food/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
response.template.should.eql('index');
});
});
it('serve bacon tag', function () {
const req = {
secure: true,
method: 'GET',
url: '/categories/bacon/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(301);
});
});
it('serve /sport/', function () {
const req = {
secure: true,
method: 'GET',
url: '/sport/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
response.template.should.eql('index');
});
});
it('serve pollo tag', function () {
const req = {
secure: true,
method: 'GET',
url: '/categories/pollo/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
});
});
});
});
describe('extended routes.yaml (3): templates', function () {
describe('(3) (1)', function () {
describe('extended routes.yaml: templates', function () {
describe('default template, no template', function () {
before(function () {
sandbox.stub(settingsService, 'get').returns({
routes: {},
@ -784,7 +1098,7 @@ describe('Integration - Web - Site', function () {
});
});
describe('(3) (2)', function () {
describe('two templates', function () {
before(function () {
sandbox.stub(settingsService, 'get').returns({
routes: {},
@ -836,7 +1150,7 @@ describe('Integration - Web - Site', function () {
});
});
describe('(3) (3)', function () {
describe('home.hbs priority', function () {
before(function () {
sandbox.stub(settingsService, 'get').returns({
routes: {},
@ -908,186 +1222,278 @@ describe('Integration - Web - Site', function () {
});
});
describe('extended routes.yaml (4): primary author permalink', function () {
before(function () {
sandbox.stub(settingsService, 'get').returns({
routes: {},
describe('extended routes.yaml: routes', function () {
describe('channels', function () {
before(testUtils.teardown);
before(testUtils.setup('users:roles', 'posts'));
collections: {
'/something/': {
permalink: '/:primary_author/:slug/'
before(function () {
testUtils.integrationTesting.defaultMocks(sandbox, {theme: 'test-theme-channels'});
sandbox.stub(settingsService, 'get').returns({
routes: {
'/channel1/': {
controller: 'channel',
filter: 'tag:kitchen-sink',
data: {
query: {
tag: {
resource: 'tags',
type: 'read',
options: {
slug: 'kitchen-sink'
}
}
},
router: {
tags: [{redirect: true, slug: 'kitchen-sink'}]
}
}
},
'/channel2/': {
controller: 'channel',
filter: 'tag:bacon',
data: {
query: {
tag: {
resource: 'tags',
type: 'read',
options: {
slug: 'bacon'
}
}
},
router: {
tags: [{redirect: true, slug: 'bacon'}]
}
},
templates: ['default']
},
'/channel3/': {
controller: 'channel',
filter: 'author:joe-bloggs',
data: {
query: {
tag: {
resource: 'users',
type: 'read',
options: {
slug: 'joe-bloggs'
}
}
},
router: {
users: [{redirect: true, slug: 'joe-bloggs'}]
}
}
},
'/channel4/': {
controller: 'channel',
filter: 'author:joe-bloggs'
},
'/channel5/': {
controller: 'channel',
data: {
query: {
tag: {
resource: 'users',
type: 'read',
options: {
slug: 'joe-bloggs'
}
}
},
router: {
users: [{redirect: true, slug: 'joe-bloggs'}]
}
}
}
},
collections: {},
taxonomies: {
tag: '/tag/:slug/',
author: '/author/:slug/'
}
},
taxonomies: {}
});
testUtils.integrationTesting.urlService.resetGenerators();
testUtils.integrationTesting.defaultMocks(sandbox);
return testUtils.integrationTesting.initGhost()
.then(function () {
app = siteApp();
return testUtils.integrationTesting.urlService.waitTillFinished();
});
});
beforeEach(function () {
testUtils.integrationTesting.overrideGhostConfig(configUtils);
});
testUtils.integrationTesting.urlService.resetGenerators();
afterEach(function () {
configUtils.restore();
});
return testUtils.integrationTesting.initGhost()
.then(function () {
app = siteApp();
after(function () {
sandbox.restore();
});
return testUtils.integrationTesting.urlService.waitTillFinished();
});
});
describe('behaviour: default cases', function () {
it('serve post', function () {
beforeEach(function () {
testUtils.integrationTesting.overrideGhostConfig(configUtils);
});
afterEach(function () {
configUtils.restore();
});
after(function () {
sandbox.restore();
});
it('serve channel 1', function () {
const req = {
secure: true,
method: 'GET',
url: '/joe-bloggs/html-ipsum/',
url: '/channel1/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
const $ = cheerio.load(response.body);
response.statusCode.should.eql(200);
response.template.should.eql('index');
$('.post-card').length.should.equal(2);
});
});
it('serve channel 1: rss', function () {
const req = {
secure: true,
method: 'GET',
url: '/channel1/rss/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
response.template.should.eql('post');
response.headers['content-type'].should.eql('text/xml; charset=UTF-8');
});
});
it('post without author', function () {
it('serve channel 2', function () {
const req = {
secure: true,
method: 'GET',
url: '/html-ipsum/',
url: '/channel2/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(404);
response.template.should.eql('error-404');
const $ = cheerio.load(response.body);
response.statusCode.should.eql(200);
response.template.should.eql('default');
// default tempalte does not list posts
$('.post-card').length.should.equal(0);
});
});
it('page', function () {
it('serve channel 3', function () {
const req = {
secure: true,
method: 'GET',
url: '/static-page-test/',
url: '/channel3/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
const $ = cheerio.load(response.body);
response.statusCode.should.eql(200);
response.template.should.eql('channel3');
});
});
it('serve channel 4', function () {
const req = {
secure: true,
method: 'GET',
url: '/channel4/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
const $ = cheerio.load(response.body);
response.statusCode.should.eql(200);
response.template.should.eql('index');
$('.post-card').length.should.equal(4);
});
});
it('serve channel 5', function () {
const req = {
secure: true,
method: 'GET',
url: '/channel5/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
const $ = cheerio.load(response.body);
response.statusCode.should.eql(200);
response.template.should.eql('index');
$('.post-card').length.should.equal(4);
});
});
it('serve kitching-sink', function () {
const req = {
secure: true,
method: 'GET',
url: '/tag/kitchen-sink/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(301);
response.headers.location.should.eql('/channel1/');
});
});
it('serve chorizo: no redirect', function () {
const req = {
secure: true,
method: 'GET',
url: '/tag/chorizo/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
response.template.should.eql('page');
});
});
});
});
describe('extended routes.yaml (4): primary tag permalink', function () {
before(function () {
sandbox.stub(settingsService, 'get').returns({
routes: {},
collections: {
'/something/': {
permalink: '/something/:primary_tag/:slug/'
}
},
taxonomies: {}
});
testUtils.integrationTesting.urlService.resetGenerators();
testUtils.integrationTesting.defaultMocks(sandbox);
return testUtils.integrationTesting.initGhost()
.then(function () {
app = siteApp();
return testUtils.integrationTesting.urlService.waitTillFinished();
});
});
beforeEach(function () {
testUtils.integrationTesting.overrideGhostConfig(configUtils);
});
afterEach(function () {
configUtils.restore();
});
after(function () {
sandbox.restore();
});
describe('behaviour: default cases', function () {
it('serve post', function () {
it('serve joe-bloggs', function () {
const req = {
secure: true,
method: 'GET',
url: '/something/kitchen-sink/html-ipsum/',
url: '/author/joe-bloggs/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
response.template.should.eql('post');
});
});
it('post without tag', function () {
const req = {
secure: true,
method: 'GET',
url: '/something/html-ipsum/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(404);
response.template.should.eql('error-404');
});
});
it('post without tag', function () {
const req = {
secure: true,
method: 'GET',
url: '/html-ipsum/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(404);
response.template.should.eql('error-404');
});
});
it('page', function () {
const req = {
secure: true,
method: 'GET',
url: '/static-page-test/',
host: 'example.com'
};
return testUtils.mocks.express.invoke(app, req)
.then(function (response) {
response.statusCode.should.eql(200);
response.template.should.eql('page');
response.statusCode.should.eql(301);
response.headers.location.should.eql('/channel3/');
});
});
});

View File

@ -2,6 +2,7 @@ const should = require('should'),
sinon = require('sinon'),
settingsCache = require('../../../../server/services/settings/cache'),
common = require('../../../../server/lib/common'),
controllers = require('../../../../server/services/routing/controllers'),
StaticRoutesRouter = require('../../../../server/services/routing/StaticRoutesRouter'),
configUtils = require('../../../utils/configUtils'),
sandbox = sinon.sandbox.create();
@ -50,21 +51,220 @@ describe('UNIT - services/routing/StaticRoutesRouter', function () {
// parent route
staticRoutesRouter.mountRoute.args[0][0].should.eql('/about/');
staticRoutesRouter.mountRoute.args[0][1].should.eql(staticRoutesRouter._renderStaticRoute.bind(staticRoutesRouter));
staticRoutesRouter.mountRoute.args[0][1].should.eql(controllers.static);
});
it('fn: _prepareContext', function () {
it('initialise with data+filter', function () {
const staticRoutesRouter = new StaticRoutesRouter('/about/', {
data: {query: {}, router: {}},
filter: 'tag:test'
});
should.exist(staticRoutesRouter.router);
should.not.exist(staticRoutesRouter.getPermalinks());
should.not.exist(staticRoutesRouter.getFilter());
staticRoutesRouter.templates.should.eql([]);
common.events.emit.calledOnce.should.be.true();
common.events.emit.calledWith('router.created', staticRoutesRouter).should.be.true();
staticRoutesRouter.mountRoute.callCount.should.eql(1);
// parent route
staticRoutesRouter.mountRoute.args[0][0].should.eql('/about/');
staticRoutesRouter.mountRoute.args[0][1].should.eql(controllers.static);
});
it('fn: _prepareStaticRouteContext', function () {
const staticRoutesRouter = new StaticRoutesRouter('/about/', {templates: []});
staticRoutesRouter._prepareContext(req, res, next);
staticRoutesRouter._prepareStaticRouteContext(req, res, next);
next.called.should.be.true();
res._route.should.eql({
type: 'custom',
templates: [],
defaultTemplate: 'index'
templates: []
});
res.locals.routerOptions.should.eql({context: []});
res.locals.routerOptions.should.eql({context: [], data: {}});
should.not.exist(res.locals.slug);
});
});
describe('channels', function () {
describe('initialise', function () {
it('initialise with controller+data+filter', function () {
const staticRoutesRouter = new StaticRoutesRouter('/channel/', {
controller: 'channel',
data: {query: {}, router: {}},
filter: 'tag:test'
});
should.exist(staticRoutesRouter.router);
should.not.exist(staticRoutesRouter.getPermalinks());
staticRoutesRouter.getFilter().should.eql('tag:test');
staticRoutesRouter.templates.should.eql([]);
should.exist(staticRoutesRouter.data);
common.events.emit.calledOnce.should.be.true();
common.events.emit.calledWith('router.created', staticRoutesRouter).should.be.true();
staticRoutesRouter.mountRoute.callCount.should.eql(2);
// parent route
staticRoutesRouter.mountRoute.args[0][0].should.eql('/channel/');
staticRoutesRouter.mountRoute.args[0][1].should.eql(controllers.channel);
// pagination feature
staticRoutesRouter.mountRoute.args[1][0].should.eql('/channel/page/:page(\\d+)');
staticRoutesRouter.mountRoute.args[1][1].should.eql(controllers.channel);
});
it('initialise with controller+filter', function () {
const staticRoutesRouter = new StaticRoutesRouter('/channel/', {
controller: 'channel',
filter: 'tag:test'
});
should.exist(staticRoutesRouter.router);
should.not.exist(staticRoutesRouter.getPermalinks());
staticRoutesRouter.getFilter().should.eql('tag:test');
staticRoutesRouter.templates.should.eql([]);
common.events.emit.calledOnce.should.be.true();
common.events.emit.calledWith('router.created', staticRoutesRouter).should.be.true();
staticRoutesRouter.mountRoute.callCount.should.eql(2);
// parent route
staticRoutesRouter.mountRoute.args[0][0].should.eql('/channel/');
staticRoutesRouter.mountRoute.args[0][1].should.eql(controllers.channel);
// pagination feature
staticRoutesRouter.mountRoute.args[1][0].should.eql('/channel/page/:page(\\d+)');
staticRoutesRouter.mountRoute.args[1][1].should.eql(controllers.channel);
});
it('initialise with controller+data', function () {
const staticRoutesRouter = new StaticRoutesRouter('/channel/', {
controller: 'channel',
data: {query: {}, router: {}},
});
should.not.exist(staticRoutesRouter.getFilter());
});
it('initialise on subdirectory with controller+data+filter', function () {
configUtils.set('url', 'http://localhost:2366/blog/');
const staticRoutesRouter = new StaticRoutesRouter('/channel/', {
controller: 'channel',
data: {query: {}, router: {}},
filter: 'author:michi'
});
staticRoutesRouter.mountRoute.callCount.should.eql(2);
// parent route
staticRoutesRouter.mountRoute.args[0][0].should.eql('/channel/');
staticRoutesRouter.mountRoute.args[0][1].should.eql(controllers.channel);
// pagination feature
staticRoutesRouter.mountRoute.args[1][0].should.eql('/channel/page/:page(\\d+)');
staticRoutesRouter.mountRoute.args[1][1].should.eql(controllers.channel);
});
});
describe('fn: _prepareChannelContext', function () {
it('with data+filter', function () {
const staticRoutesRouter = new StaticRoutesRouter('/channel/', {
controller: 'channel',
data: {query: {}, router: {}},
filter: 'tag:test'
});
staticRoutesRouter._prepareChannelContext(req, res, next);
next.calledOnce.should.eql(true);
res.locals.routerOptions.should.eql({
context: ['channel'],
filter: 'tag:test',
name: 'channel',
data: {},
limit: undefined,
order: undefined,
templates: []
});
res._route.type.should.eql('channel');
});
it('with data', function () {
const staticRoutesRouter = new StaticRoutesRouter('/nothingcomparestoyou/', {
controller: 'channel',
data: {query: {type: 'read'}, router: {}}
});
staticRoutesRouter._prepareChannelContext(req, res, next);
next.calledOnce.should.eql(true);
res.locals.routerOptions.should.eql({
context: ['nothingcomparestoyou'],
name: 'nothingcomparestoyou',
filter: undefined,
data: {type: 'read'},
limit: undefined,
order: undefined,
templates: []
});
res._route.type.should.eql('channel');
});
it('with filter', function () {
const staticRoutesRouter = new StaticRoutesRouter('/channel/', {
controller: 'channel',
filter: 'tag:test'
});
staticRoutesRouter._prepareChannelContext(req, res, next);
next.calledOnce.should.eql(true);
res.locals.routerOptions.should.eql({
context: ['channel'],
filter: 'tag:test',
name: 'channel',
limit: undefined,
order: undefined,
data: {},
templates: []
});
res._route.type.should.eql('channel');
});
it('with order+limit', function () {
const staticRoutesRouter = new StaticRoutesRouter('/channel/', {
controller: 'channel',
filter: 'tag:test',
limit: 2,
order: 'published_at asc'
});
staticRoutesRouter._prepareChannelContext(req, res, next);
next.calledOnce.should.eql(true);
res.locals.routerOptions.should.eql({
context: ['channel'],
filter: 'tag:test',
name: 'channel',
limit: 2,
order: 'published_at asc',
data: {},
templates: []
});
res._route.type.should.eql('channel');
});
});
});
});

View File

@ -0,0 +1,83 @@
const should = require('should'),
sinon = require('sinon'),
_ = require('lodash'),
settingsCache = require('../../../../server/services/settings/cache'),
common = require('../../../../server/lib/common'),
controllers = require('../../../../server/services/routing/controllers'),
TaxonomyRouter = require('../../../../server/services/routing/TaxonomyRouter'),
RESOURCE_CONFIG = require('../../../../server/services/routing/assets/resource-config'),
sandbox = sinon.sandbox.create();
describe('UNIT - services/routing/TaxonomyRouter', function () {
let req, res, next;
beforeEach(function () {
sandbox.stub(settingsCache, 'get').withArgs('permalinks').returns('/:slug/');
sandbox.stub(common.events, 'emit');
sandbox.stub(common.events, 'on');
sandbox.spy(TaxonomyRouter.prototype, 'mountRoute');
sandbox.spy(TaxonomyRouter.prototype, 'mountRouter');
req = sandbox.stub();
res = sandbox.stub();
next = sandbox.stub();
res.locals = {};
});
afterEach(function () {
sandbox.restore();
});
it('instantiate', function () {
const taxonomyRouter = new TaxonomyRouter('tag', '/tag/:slug/');
should.exist(taxonomyRouter.router);
should.exist(taxonomyRouter.rssRouter);
taxonomyRouter.taxonomyKey.should.eql('tag');
taxonomyRouter.getPermalinks().getValue().should.eql('/tag/:slug/');
common.events.emit.calledOnce.should.be.true();
common.events.emit.calledWith('router.created', taxonomyRouter).should.be.true();
taxonomyRouter.mountRouter.callCount.should.eql(1);
taxonomyRouter.mountRouter.args[0][0].should.eql('/tag/:slug/');
taxonomyRouter.mountRouter.args[0][1].should.eql(taxonomyRouter.rssRouter.router());
taxonomyRouter.mountRoute.callCount.should.eql(3);
// permalink route
taxonomyRouter.mountRoute.args[0][0].should.eql('/tag/:slug/');
taxonomyRouter.mountRoute.args[0][1].should.eql(controllers.channel);
// pagination feature
taxonomyRouter.mountRoute.args[1][0].should.eql('/tag/:slug/page/:page(\\d+)');
taxonomyRouter.mountRoute.args[1][1].should.eql(controllers.channel);
// edit feature
taxonomyRouter.mountRoute.args[2][0].should.eql('/tag/:slug/edit');
taxonomyRouter.mountRoute.args[2][1].should.eql(taxonomyRouter._redirectEditOption.bind(taxonomyRouter));
});
it('fn: _prepareContext', function () {
const taxonomyRouter = new TaxonomyRouter('tag', '/tag/:slug/');
taxonomyRouter._prepareContext(req, res, next);
next.calledOnce.should.eql(true);
res.locals.routerOptions.should.eql({
name: 'tag',
permalinks: '/tag/:slug/',
type: RESOURCE_CONFIG.QUERY.tag.resource,
data: {tag: _.omit(RESOURCE_CONFIG.QUERY.tag, 'alias')},
filter: RESOURCE_CONFIG.TAXONOMIES.tag.filter,
context: ['tag'],
slugTemplate: true,
identifier: taxonomyRouter.identifier
});
res._route.type.should.eql('channel');
});
});

View File

@ -0,0 +1,264 @@
const should = require('should'),
sinon = require('sinon'),
testUtils = require('../../../../utils'),
common = require('../../../../../server/lib/common'),
security = require('../../../../../server/lib/security'),
filters = require('../../../../../server/filters'),
themeService = require('../../../../../server/services/themes'),
controllers = require('../../../../../server/services/routing/controllers'),
helpers = require('../../../../../server/services/routing/helpers'),
sandbox = sinon.sandbox.create();
function failTest(done) {
return function (err) {
should.exist(err);
done(err);
};
}
describe('Unit - services/routing/controllers/channel', function () {
let req, res, fetchDataStub, secureStub, renderStub, posts, postsPerPage;
beforeEach(function () {
postsPerPage = 5;
posts = [
testUtils.DataGenerator.forKnex.createPost()
];
secureStub = sandbox.stub();
fetchDataStub = sandbox.stub();
renderStub = sandbox.stub();
sandbox.stub(helpers, 'fetchData').get(function () {
return fetchDataStub;
});
sandbox.stub(security.string, 'safe').returns('safe');
sandbox.stub(helpers, 'secure').get(function () {
return secureStub;
});
sandbox.stub(themeService, 'getActive').returns({
updateTemplateOptions: sandbox.stub(),
config: function (key) {
key.should.eql('posts_per_page');
return postsPerPage;
}
});
sandbox.stub(helpers, 'renderEntries').get(function () {
return renderStub;
});
sandbox.stub(filters, 'doFilter');
req = {
path: '/',
params: {},
route: {}
};
res = {
locals: {
routerOptions: {}
},
render: sinon.spy(),
redirect: sinon.spy()
};
});
afterEach(function () {
sandbox.restore();
});
it('no params', function (done) {
fetchDataStub.withArgs({page: 1, slug: undefined, limit: postsPerPage}, res.locals.routerOptions)
.resolves({
posts: posts,
meta: {
pagination: {
pages: 5
}
}
});
filters.doFilter.withArgs('prePostsRender', posts, res.locals).resolves();
renderStub.callsFake(function () {
themeService.getActive.calledOnce.should.be.true();
security.string.safe.calledOnce.should.be.false();
fetchDataStub.calledOnce.should.be.true();
filters.doFilter.calledOnce.should.be.true();
secureStub.calledOnce.should.be.true();
done();
});
controllers.channel(req, res, failTest(done));
});
it('pass page param', function (done) {
req.params.page = 2;
fetchDataStub.withArgs({page: 2, slug: undefined, limit: postsPerPage}, res.locals.routerOptions)
.resolves({
posts: posts,
meta: {
pagination: {
pages: 5
}
}
});
filters.doFilter.withArgs('prePostsRender', posts, res.locals).resolves();
renderStub.callsFake(function () {
themeService.getActive.calledOnce.should.be.true();
security.string.safe.calledOnce.should.be.false();
fetchDataStub.calledOnce.should.be.true();
filters.doFilter.calledOnce.should.be.true();
secureStub.calledOnce.should.be.true();
done();
});
controllers.channel(req, res, failTest(done));
});
it('update hbs engine: router defines limit', function (done) {
res.locals.routerOptions.limit = 3;
req.params.page = 2;
fetchDataStub.withArgs({page: 2, slug: undefined, limit: 3}, res.locals.routerOptions)
.resolves({
posts: posts,
meta: {
pagination: {
pages: 3
}
}
});
filters.doFilter.withArgs('prePostsRender', posts, res.locals).resolves();
renderStub.callsFake(function () {
themeService.getActive.calledOnce.should.be.true();
themeService.getActive().updateTemplateOptions.withArgs({data: {config: {posts_per_page: 3}}}).calledOnce.should.be.true();
security.string.safe.calledOnce.should.be.false();
fetchDataStub.calledOnce.should.be.true();
filters.doFilter.calledOnce.should.be.true();
secureStub.calledOnce.should.be.true();
done();
});
controllers.channel(req, res, failTest(done));
});
it('page param too big', function (done) {
req.params.page = 6;
fetchDataStub.withArgs({page: 6, slug: undefined, limit: postsPerPage}, res.locals.routerOptions)
.resolves({
posts: posts,
meta: {
pagination: {
pages: 5
}
}
});
controllers.channel(req, res, function (err) {
(err instanceof common.errors.NotFoundError).should.be.true();
themeService.getActive.calledOnce.should.be.true();
security.string.safe.calledOnce.should.be.false();
fetchDataStub.calledOnce.should.be.true();
filters.doFilter.calledOnce.should.be.false();
renderStub.calledOnce.should.be.false();
secureStub.calledOnce.should.be.false();
done();
});
});
it('slug param', function (done) {
req.params.slug = 'unsafe';
fetchDataStub.withArgs({page: 1, slug: 'safe', limit: postsPerPage}, res.locals.routerOptions)
.resolves({
posts: posts,
meta: {
pagination: {
pages: 5
}
}
});
filters.doFilter.withArgs('prePostsRender', posts, res.locals).resolves();
renderStub.callsFake(function () {
themeService.getActive.calledOnce.should.be.true();
security.string.safe.calledOnce.should.be.true();
fetchDataStub.calledOnce.should.be.true();
filters.doFilter.calledOnce.should.be.true();
secureStub.calledOnce.should.be.true();
done();
});
controllers.channel(req, res, failTest(done));
});
it('invalid posts per page', function (done) {
postsPerPage = -1;
fetchDataStub.withArgs({page: 1, slug: undefined}, res.locals.routerOptions)
.resolves({
posts: posts,
meta: {
pagination: {
pages: 5
}
}
});
filters.doFilter.withArgs('prePostsRender', posts, res.locals).resolves();
renderStub.callsFake(function () {
themeService.getActive.calledOnce.should.be.true();
security.string.safe.calledOnce.should.be.false();
fetchDataStub.calledOnce.should.be.true();
filters.doFilter.calledOnce.should.be.true();
secureStub.calledOnce.should.be.true();
done();
});
controllers.channel(req, res, failTest(done));
});
it('ensure secure helper get\'s called for data object', function (done) {
fetchDataStub.withArgs({page: 1, slug: undefined, limit: postsPerPage}, res.locals.routerOptions)
.resolves({
posts: posts,
data: {
tag: [testUtils.DataGenerator.forKnex.createTag()]
},
meta: {
pagination: {
pages: 5
}
}
});
filters.doFilter.withArgs('prePostsRender', posts, res.locals).resolves();
renderStub.callsFake(function () {
themeService.getActive.calledOnce.should.be.true();
security.string.safe.calledOnce.should.be.false();
fetchDataStub.calledOnce.should.be.true();
filters.doFilter.calledOnce.should.be.true();
secureStub.calledTwice.should.be.true();
done();
});
controllers.channel(req, res, failTest(done));
});
});

View File

@ -49,12 +49,15 @@ describe('Unit - services/routing/controllers/collection', function () {
}
});
sandbox.stub(helpers, 'renderCollection').get(function () {
sandbox.stub(helpers, 'renderEntries').get(function () {
return renderStub;
});
sandbox.stub(filters, 'doFilter');
sandbox.stub(urlService, 'owns');
urlService.owns.withArgs('identifier', posts[0].url).returns(true);
req = {
path: '/',
params: {},
@ -63,7 +66,9 @@ describe('Unit - services/routing/controllers/collection', function () {
res = {
locals: {
routerOptions: {}
routerOptions: {
identifier: 'identifier'
}
},
render: sinon.spy(),
redirect: sinon.spy()
@ -93,6 +98,7 @@ describe('Unit - services/routing/controllers/collection', function () {
fetchDataStub.calledOnce.should.be.true();
filters.doFilter.calledOnce.should.be.true();
secureStub.calledOnce.should.be.true();
urlService.owns.calledOnce.should.be.true();
done();
});
@ -120,6 +126,7 @@ describe('Unit - services/routing/controllers/collection', function () {
fetchDataStub.calledOnce.should.be.true();
filters.doFilter.calledOnce.should.be.true();
secureStub.calledOnce.should.be.true();
urlService.owns.calledOnce.should.be.true();
done();
});
@ -178,7 +185,7 @@ describe('Unit - services/routing/controllers/collection', function () {
filters.doFilter.calledOnce.should.be.false();
renderStub.calledOnce.should.be.false();
secureStub.calledOnce.should.be.false();
urlService.owns.calledOnce.should.be.false();
done();
});
});
@ -204,6 +211,7 @@ describe('Unit - services/routing/controllers/collection', function () {
fetchDataStub.calledOnce.should.be.true();
filters.doFilter.calledOnce.should.be.true();
secureStub.calledOnce.should.be.true();
urlService.owns.calledOnce.should.be.true();
done();
});
@ -231,6 +239,7 @@ describe('Unit - services/routing/controllers/collection', function () {
fetchDataStub.calledOnce.should.be.true();
filters.doFilter.calledOnce.should.be.true();
secureStub.calledOnce.should.be.true();
urlService.owns.calledOnce.should.be.true();
done();
});
@ -259,6 +268,51 @@ describe('Unit - services/routing/controllers/collection', function () {
fetchDataStub.calledOnce.should.be.true();
filters.doFilter.calledOnce.should.be.true();
secureStub.calledTwice.should.be.true();
urlService.owns.calledOnce.should.be.true();
done();
});
controllers.collection(req, res, failTest(done));
});
it('should verify if post belongs to collection', function (done) {
posts = [
testUtils.DataGenerator.forKnex.createPost({url: '/a/'}),
testUtils.DataGenerator.forKnex.createPost({url: '/b/'}),
testUtils.DataGenerator.forKnex.createPost({url: '/c/'}),
testUtils.DataGenerator.forKnex.createPost({url: '/d/'})
];
res.locals.routerOptions.filter = 'featured:true';
urlService.owns.reset();
urlService.owns.withArgs('identifier', posts[0].url).returns(false);
urlService.owns.withArgs('identifier', posts[1].url).returns(true);
urlService.owns.withArgs('identifier', posts[2].url).returns(false);
urlService.owns.withArgs('identifier', posts[3].url).returns(false);
fetchDataStub.withArgs({page: 1, slug: undefined, limit: postsPerPage}, res.locals.routerOptions)
.resolves({
posts: posts,
data: {
tag: [testUtils.DataGenerator.forKnex.createTag()]
},
meta: {
pagination: {
pages: 5
}
}
});
filters.doFilter.withArgs('prePostsRender', [posts[1]], res.locals).resolves();
renderStub.callsFake(function () {
themeService.getActive.calledOnce.should.be.true();
security.string.safe.calledOnce.should.be.false();
fetchDataStub.calledOnce.should.be.true();
filters.doFilter.calledOnce.should.be.true();
secureStub.calledTwice.should.be.true();
urlService.owns.callCount.should.eql(4);
done();
});

View File

@ -0,0 +1,110 @@
const should = require('should'),
sinon = require('sinon'),
testUtils = require('../../../../utils'),
api = require('../../../../../server/api'),
themeService = require('../../../../../server/services/themes'),
helpers = require('../../../../../server/services/routing/helpers'),
controllers = require('../../../../../server/services/routing/controllers'),
sandbox = sinon.sandbox.create();
function failTest(done) {
return function (err) {
should.exist(err);
done(err);
};
}
describe('Unit - services/routing/controllers/static', function () {
let req, res, secureStub, renderStub, handleErrorStub, formatResponseStub, posts, postsPerPage;
beforeEach(function () {
postsPerPage = 5;
posts = [
testUtils.DataGenerator.forKnex.createPost()
];
secureStub = sandbox.stub();
renderStub = sandbox.stub();
handleErrorStub = sandbox.stub();
formatResponseStub = sandbox.stub();
formatResponseStub.entries = sandbox.stub();
sandbox.stub(api.tags, 'read');
sandbox.stub(helpers, 'secure').get(function () {
return secureStub;
});
sandbox.stub(helpers, 'handleError').get(function () {
return handleErrorStub;
});
sandbox.stub(themeService, 'getActive').returns({
config: function (key) {
key.should.eql('posts_per_page');
return postsPerPage;
}
});
sandbox.stub(helpers, 'renderer').get(function () {
return renderStub;
});
sandbox.stub(helpers, 'formatResponse').get(function () {
return formatResponseStub;
});
req = {
path: '/',
params: {},
route: {}
};
res = {
locals: {
routerOptions: {}
},
render: sinon.spy(),
redirect: sinon.spy()
};
});
afterEach(function () {
sandbox.restore();
});
it('no extra data to fetch', function (done) {
helpers.renderer.callsFake(function () {
helpers.formatResponse.entries.withArgs({}).calledOnce.should.be.true();
api.tags.read.called.should.be.false();
helpers.secure.called.should.be.false();
done();
});
controllers.static(req, res, failTest(done));
});
it('extra data to fetch', function (done) {
res.locals.routerOptions.data = {
tag: {
resource: 'tags',
type: 'read',
options: {
slug: 'bacon'
}
}
};
api.tags.read.withArgs({slug: 'bacon'}).resolves({tags: [{slug: 'bacon'}]});
helpers.renderer.callsFake(function () {
api.tags.read.withArgs({slug: 'bacon'}).called.should.be.true();
helpers.formatResponse.entries.withArgs({data: {tag: [{slug: 'bacon'}]}}).calledOnce.should.be.true();
helpers.secure.calledOnce.should.be.true();
done();
});
controllers.static(req, res, failTest(done));
});
});

View File

@ -1,7 +1,6 @@
const should = require('should'),
sinon = require('sinon'),
api = require('../../../../../server/api'),
urlService = require('../../../../../server/services/url'),
helpers = require('../../../../../server/services/routing/helpers'),
testUtils = require('../../../../utils'),
sandbox = sinon.sandbox.create();
@ -35,8 +34,6 @@ describe('Unit - services/routing/helpers/fetch-data', function () {
});
sandbox.stub(api.tags, 'read').resolves({tags: tags});
sandbox.stub(urlService, 'owns');
});
afterEach(function () {
@ -65,7 +62,6 @@ describe('Unit - services/routing/helpers/fetch-data', function () {
result.should.not.have.property('data');
result.posts.length.should.eql(posts.length);
urlService.owns.called.should.be.false();
api.posts.browse.calledOnce.should.be.true();
api.posts.browse.firstCall.args[0].should.be.an.Object();
@ -102,7 +98,6 @@ describe('Unit - services/routing/helpers/fetch-data', function () {
result.posts.length.should.eql(posts.length);
result.data.featured.posts.length.should.eql(posts.length);
urlService.owns.called.should.be.false();
api.posts.browse.calledTwice.should.be.true();
api.posts.browse.firstCall.args[0].should.have.property('include', 'author,authors,tags');
@ -138,7 +133,6 @@ describe('Unit - services/routing/helpers/fetch-data', function () {
result.posts.length.should.eql(posts.length);
result.data.featured.posts.length.should.eql(posts.length);
urlService.owns.called.should.be.false();
api.posts.browse.calledTwice.should.be.true();
api.posts.browse.firstCall.args[0].should.have.property('include', 'author,authors,tags');
@ -172,7 +166,6 @@ describe('Unit - services/routing/helpers/fetch-data', function () {
result.posts.length.should.eql(posts.length);
result.data.tag.length.should.eql(tags.length);
urlService.owns.called.should.be.false();
api.posts.browse.calledOnce.should.be.true();
api.posts.browse.firstCall.args[0].should.have.property('include');
@ -182,31 +175,4 @@ describe('Unit - services/routing/helpers/fetch-data', function () {
done();
}).catch(done);
});
it('should verify if post belongs to collection', function (done) {
const pathOptions = {};
const routerOptions = {
identifier: 'identifier',
filter: 'featured:true'
};
urlService.owns.withArgs('identifier', posts[0].url).returns(false);
urlService.owns.withArgs('identifier', posts[1].url).returns(true);
urlService.owns.withArgs('identifier', posts[2].url).returns(false);
urlService.owns.withArgs('identifier', posts[3].url).returns(false);
helpers.fetchData(pathOptions, routerOptions).then(function (result) {
should.exist(result);
result.should.be.an.Object().with.properties('posts', 'meta');
result.posts.length.should.eql(1);
urlService.owns.callCount.should.eql(4);
api.posts.browse.calledOnce.should.be.true();
api.posts.browse.firstCall.args[0].should.have.property('include');
api.posts.browse.firstCall.args[0].should.have.property('filter', 'featured:true');
done();
}).catch(done);
});
});

View File

@ -27,7 +27,7 @@ describe('Unit - services/routing/helpers/format-response', function () {
});
});
describe('collection', function () {
describe('entries', function () {
it('should return posts and posts pagination as top level keys', function () {
let formatted,
data = {
@ -35,7 +35,7 @@ describe('Unit - services/routing/helpers/format-response', function () {
meta: {pagination: {}}
};
formatted = helpers.formatResponse.collection(data);
formatted = helpers.formatResponse.entries(data);
formatted.should.be.an.Object().with.properties('posts', 'pagination');
formatted.posts.should.eql(data.posts);
@ -50,7 +50,7 @@ describe('Unit - services/routing/helpers/format-response', function () {
data: {tag: tags}
};
formatted = helpers.formatResponse.collection(data);
formatted = helpers.formatResponse.entries(data);
formatted.should.be.an.Object().with.properties('posts', 'pagination', 'tag');
formatted.tag.should.eql(data.data.tag[0]);
@ -69,7 +69,7 @@ describe('Unit - services/routing/helpers/format-response', function () {
}
};
formatted = helpers.formatResponse.collection(data);
formatted = helpers.formatResponse.entries(data);
formatted.should.be.an.Object().with.properties('posts', 'pagination', 'featured');
formatted.featured.should.be.an.Object().with.properties('posts', 'pagination');

View File

@ -13,44 +13,44 @@ describe('templates', function () {
sandbox.restore();
});
describe('[private fn] getCollectionTemplateHierarchy', function () {
describe('[private fn] getEntriesTemplateHierarchy', function () {
it('should return just index for empty options', function () {
_private.getCollectionTemplateHierarchy({}).should.eql(['index']);
_private.getEntriesTemplateHierarchy({}).should.eql(['index']);
});
it('should return just index if collection name is index', function () {
_private.getCollectionTemplateHierarchy({name: 'index'}).should.eql(['index']);
_private.getEntriesTemplateHierarchy({name: 'index'}).should.eql(['index']);
});
it('should return custom templates even if the collection is index', function () {
_private.getCollectionTemplateHierarchy({name: 'index', templates: ['something']}).should.eql(['something', 'index']);
_private.getEntriesTemplateHierarchy({name: 'index', templates: ['something']}).should.eql(['something', 'index']);
});
it('should return collection name', function () {
_private.getCollectionTemplateHierarchy({name: 'podcast'}).should.eql(['podcast', 'index']);
_private.getEntriesTemplateHierarchy({name: 'podcast'}).should.eql(['podcast', 'index']);
});
it('should return custom templates', function () {
_private.getCollectionTemplateHierarchy({name: 'podcast', templates: ['mozart']}).should.eql(['mozart', 'podcast', 'index']);
_private.getEntriesTemplateHierarchy({name: 'podcast', templates: ['mozart']}).should.eql(['mozart', 'podcast', 'index']);
});
it('should return just index if collection name is index even if slug is set', function () {
_private.getCollectionTemplateHierarchy({name: 'index', slugTemplate: true}, {slugParam: 'test'}).should.eql(['index']);
_private.getEntriesTemplateHierarchy({name: 'index', slugTemplate: true}, {slugParam: 'test'}).should.eql(['index']);
});
it('should return collection, index if collection has name', function () {
_private.getCollectionTemplateHierarchy({name: 'tag'}).should.eql(['tag', 'index']);
_private.getEntriesTemplateHierarchy({name: 'tag'}).should.eql(['tag', 'index']);
});
it('should return collection-slug, collection, index if collection has name & slug + slugTemplate set', function () {
_private.getCollectionTemplateHierarchy({
_private.getEntriesTemplateHierarchy({
name: 'tag',
slugTemplate: true
}, {slugParam: 'test'}).should.eql(['tag-test', 'tag', 'index']);
});
it('should return front, collection-slug, collection, index if name, slugParam+slugTemplate & frontPageTemplate+pageParam=1 is set', function () {
_private.getCollectionTemplateHierarchy({
_private.getEntriesTemplateHierarchy({
name: 'tag',
slugTemplate: true,
frontPageTemplate: 'front-tag'
@ -58,14 +58,14 @@ describe('templates', function () {
});
it('should return home, index for index collection if front is set and pageParam = 1', function () {
_private.getCollectionTemplateHierarchy({
_private.getEntriesTemplateHierarchy({
name: 'index',
frontPageTemplate: 'home'
}, {path: '/'}).should.eql(['home', 'index']);
});
it('should not use frontPageTemplate if not / collection', function () {
_private.getCollectionTemplateHierarchy({
_private.getEntriesTemplateHierarchy({
name: 'index',
frontPageTemplate: 'home'
}, {path: '/magic/'}).should.eql(['index']);
@ -254,7 +254,7 @@ describe('templates', function () {
});
});
describe('[private fn] getTemplateForCollection', function () {
describe('[private fn] getTemplateForEntries', function () {
beforeEach(function () {
hasTemplateStub = sandbox.stub().returns(false);
@ -270,7 +270,7 @@ describe('templates', function () {
});
it('will return correct view for a tag', function () {
var view = _private.getTemplateForCollection({name: 'tag', slugTemplate: true}, {slugParam: 'development'});
var view = _private.getTemplateForEntries({name: 'tag', slugTemplate: true}, {slugParam: 'development'});
should.exist(view);
view.should.eql('index');
});
@ -285,20 +285,20 @@ describe('templates', function () {
});
it('will return correct view for a tag', function () {
var view = _private.getTemplateForCollection({name: 'tag', slugTemplate: true}, {slugParam: 'design'});
var view = _private.getTemplateForEntries({name: 'tag', slugTemplate: true}, {slugParam: 'design'});
should.exist(view);
view.should.eql('tag-design');
});
it('will return correct view for a tag', function () {
var view = _private.getTemplateForCollection({name: 'tag', slugTemplate: true}, {slugParam: 'development'});
var view = _private.getTemplateForEntries({name: 'tag', slugTemplate: true}, {slugParam: 'development'});
should.exist(view);
view.should.eql('tag');
});
});
it('will fall back to index even if no index.hbs', function () {
var view = _private.getTemplateForCollection({name: 'tag', slugTemplate: true}, {slugParam: 'development'});
var view = _private.getTemplateForEntries({name: 'tag', slugTemplate: true}, {slugParam: 'development'});
should.exist(view);
view.should.eql('index');
});
@ -395,7 +395,7 @@ describe('templates', function () {
stubs.pickTemplate = sandbox.stub(_private, 'pickTemplate').returns('testFromPickTemplate');
stubs.getTemplateForEntry = sandbox.stub(_private, 'getTemplateForEntry').returns('testFromEntry');
stubs.getTemplateForCollection = sandbox.stub(_private, 'getTemplateForCollection').returns('testFromCollection');
stubs.getTemplateForEntries = sandbox.stub(_private, 'getTemplateForEntries').returns('testFromEntries');
stubs.getTemplateForError = sandbox.stub(_private, 'getTemplateForError').returns('testFromError');
});
@ -412,7 +412,7 @@ describe('templates', function () {
// And nothing got called
stubs.pickTemplate.called.should.be.false();
stubs.getTemplateForEntry.called.should.be.false();
stubs.getTemplateForCollection.called.should.be.false();
stubs.getTemplateForEntries.called.should.be.false();
stubs.getTemplateForError.called.should.be.false();
});
@ -428,7 +428,7 @@ describe('templates', function () {
// And nothing got called
stubs.pickTemplate.called.should.be.false();
stubs.getTemplateForEntry.called.should.be.false();
stubs.getTemplateForCollection.called.should.be.false();
stubs.getTemplateForEntries.called.should.be.false();
stubs.getTemplateForError.called.should.be.false();
});
@ -448,7 +448,7 @@ describe('templates', function () {
// Only pickTemplate got called
stubs.pickTemplate.called.should.be.true();
stubs.getTemplateForEntry.called.should.be.false();
stubs.getTemplateForCollection.called.should.be.false();
stubs.getTemplateForEntries.called.should.be.false();
stubs.getTemplateForError.called.should.be.false();
stubs.pickTemplate.calledWith('test', 'path/to/local/test.hbs').should.be.true();
@ -470,7 +470,7 @@ describe('templates', function () {
// Only pickTemplate got called
stubs.pickTemplate.called.should.be.true();
stubs.getTemplateForEntry.called.should.be.false();
stubs.getTemplateForCollection.called.should.be.false();
stubs.getTemplateForEntries.called.should.be.false();
stubs.getTemplateForError.called.should.be.false();
stubs.pickTemplate.calledWith('test', 'path/to/local/test.hbs').should.be.true();
@ -493,13 +493,13 @@ describe('templates', function () {
// Only pickTemplate got called
stubs.pickTemplate.called.should.be.false();
stubs.getTemplateForEntry.called.should.be.true();
stubs.getTemplateForCollection.called.should.be.false();
stubs.getTemplateForEntries.called.should.be.false();
stubs.getTemplateForError.called.should.be.false();
stubs.getTemplateForEntry.calledWith({slug: 'test'}).should.be.true();
});
it('calls getTemplateForCollection for type collection', function () {
it('calls getTemplateForEntries for type collection', function () {
req.url = '/';
req.params = {};
@ -512,15 +512,39 @@ describe('templates', function () {
// Call setTemplate
templates.setTemplate(req, res, data);
res._template.should.eql('testFromCollection');
res._template.should.eql('testFromEntries');
// Only pickTemplate got called
stubs.pickTemplate.called.should.be.false();
stubs.getTemplateForEntry.called.should.be.false();
stubs.getTemplateForCollection.called.should.be.true();
stubs.getTemplateForEntries.called.should.be.true();
stubs.getTemplateForError.called.should.be.false();
stubs.getTemplateForCollection.calledWith({testCollection: 'test'}).should.be.true();
stubs.getTemplateForEntries.calledWith({testCollection: 'test'}).should.be.true();
});
it('calls getTemplateForEntries for type channel', function () {
req.url = '/';
req.params = {};
res._route = {
type: 'channel'
};
res.locals.routerOptions = {testChannel: 'test'};
// Call setTemplate
templates.setTemplate(req, res, data);
res._template.should.eql('testFromEntries');
// Only pickTemplate got called
stubs.pickTemplate.called.should.be.false();
stubs.getTemplateForEntry.called.should.be.false();
stubs.getTemplateForEntries.called.should.be.true();
stubs.getTemplateForError.called.should.be.false();
stubs.getTemplateForEntries.calledWith({testChannel: 'test'}).should.be.true();
});
it('calls getTemplateForError if there is an error', function () {
@ -544,7 +568,7 @@ describe('templates', function () {
// Only pickTemplate got called
stubs.pickTemplate.called.should.be.false();
stubs.getTemplateForEntry.called.should.be.false();
stubs.getTemplateForCollection.called.should.be.false();
stubs.getTemplateForEntries.called.should.be.false();
stubs.getTemplateForError.called.should.be.true();
stubs.getTemplateForError.calledWith(404).should.be.true();

View File

@ -0,0 +1 @@
channel2

View File

@ -0,0 +1 @@
channel3

View File

@ -0,0 +1,28 @@
{{#foreach posts}}
<article class="post-card {{post_class}}{{#unless feature_image}} no-image{{/unless}}">
{{#if feature_image}}
<a class="post-card-image-link" href="{{url}}">
<div class="post-card-image" style="background-image: url({{feature_image}})"></div>
</a>
{{/if}}
<div class="post-card-content">
<a class="post-card-content-link" href="{{url}}">
<header class="post-card-header">
{{#if primary_tag}}
<span class="post-card-tags">{{primary_tag.name}}</span>
{{/if}}
<h2 class="post-card-title">{{title}}</h2>
</header>
<section class="post-card-excerpt">
<p>{{excerpt words="33"}}</p>
</section>
</a>
<footer class="post-card-meta">
{{#if author.profile_image}}
<img class="author-profile-image" src="{{author.profile_image}}" alt="{{author.name}}" />
{{/if}}
<span class="post-card-author">{{author}}</span>
</footer>
</div>
</article>
{{/foreach}}

View File

@ -0,0 +1 @@
<div class="slug">{{tag.slug}}</div>