diff --git a/core/server/controllers/channel.js b/core/server/controllers/channel.js index 56a5d3caca3b..f324a458a892 100644 --- a/core/server/controllers/channel.js +++ b/core/server/controllers/channel.js @@ -9,7 +9,7 @@ var _ = require('lodash'), renderChannel = require('./frontend/render-channel'); // This here is a controller. -// The "route" is handled in controllers/channels/router.js +// The "route" is handled in services/channels/router.js // There's both a top-level channelS router, and an individual channel one module.exports = function channelController(req, res, next) { // Parse the parameters we need from the URL diff --git a/core/server/controllers/frontend/context.js b/core/server/controllers/frontend/context.js index 35964a53f9cc..6b212754e610 100644 --- a/core/server/controllers/frontend/context.js +++ b/core/server/controllers/frontend/context.js @@ -18,7 +18,6 @@ var config = require('../../config'), privatePattern = new RegExp('^\\/' + config.get('routeKeywords').private + '\\/'), subscribePattern = new RegExp('^\\/' + config.get('routeKeywords').subscribe + '\\/'), ampPattern = new RegExp('\\/' + config.get('routeKeywords').amp + '\\/$'), - rssPattern = new RegExp('^\\/rss\\/'), homePattern = new RegExp('^\\/$'); function setResponseContext(req, res, data) { @@ -42,11 +41,6 @@ function setResponseContext(req, res, data) { res.locals.context.push('home'); } - // This is not currently used, as setRequestContext is not called for RSS feeds - if (rssPattern.test(res.locals.relativeUrl)) { - res.locals.context.push('rss'); - } - // Add context 'amp' to either post or page, if we have an `*/amp` route if (ampPattern.test(res.locals.relativeUrl) && data.post) { res.locals.context.push('amp'); diff --git a/core/server/controllers/rss.js b/core/server/controllers/rss.js index 01cd4129e39b..6d99b46b064b 100644 --- a/core/server/controllers/rss.js +++ b/core/server/controllers/rss.js @@ -1,6 +1,5 @@ var _ = require('lodash'), url = require('url'), - utils = require('../utils'), errors = require('../errors'), i18n = require('../i18n'), safeString = require('../utils/index').safeString, @@ -10,7 +9,7 @@ var _ = require('lodash'), fetchData = require('./frontend/fetch-data'), handleError = require('./frontend/error'), - rssCache = require('../services/rss'), + rssService = require('../services/rss'), generate; // @TODO: is this the right logic? Where should this live?! @@ -33,27 +32,25 @@ function getData(channelOpts) { channelOpts.data = channelOpts.data || {}; return fetchData(channelOpts).then(function formatResult(result) { - var response = {}; + var response = _.pick(result, ['posts', 'meta']); response.title = getTitle(result.data); response.description = settingsCache.get('description'); - response.results = { - posts: result.posts, - meta: result.meta - }; return response; }); } // This here is a controller. -// The "route" is handled in controllers/channels/router.js +// The "route" is handled in services/channels/router.js // We can only generate RSS for channels, so that sorta makes sense, but the location is rubbish // @TODO finish refactoring this - it's now a controller generate = function generate(req, res, next) { // Parse the parameters we need from the URL var pageParam = req.params.page !== undefined ? req.params.page : 1, - slugParam = req.params.slug ? safeString(req.params.slug) : undefined; + slugParam = req.params.slug ? safeString(req.params.slug) : undefined, + // Base URL needs to be the URL for the feed without pagination: + baseUrl = getBaseUrlForRSSReq(req.originalUrl, pageParam); // @TODO: fix this, we shouldn't change the channel object! // Set page on postOptions for the query made later @@ -61,31 +58,13 @@ generate = function generate(req, res, next) { res.locals.channel.slugParam = slugParam; return getData(res.locals.channel).then(function handleResult(data) { - // Base URL needs to be the URL for the feed without pagination: - var baseUrl = getBaseUrlForRSSReq(req.originalUrl, pageParam), - maxPage = data.results.meta.pagination.pages; - - // If page is greater than number of pages we have, redirect to last page - if (pageParam > maxPage) { + // If page is greater than number of pages we have, go straight to 404 + if (pageParam > data.meta.pagination.pages) { return next(new errors.NotFoundError({message: i18n.t('errors.errors.pageNotFound')})); } - // Renderer begin - // Format data - data.version = res.locals.safeVersion; - data.siteUrl = utils.url.urlFor('home', {secure: req.secure}, true); - data.feedUrl = utils.url.urlFor({relativeUrl: baseUrl, secure: req.secure}, true); - data.secure = req.secure; - - // No context, no template - // @TODO: should we have context? The context file expects it! - - // Render call - to a different renderer - // @TODO this is effectively a renderer - return rssCache.getXML(baseUrl, data).then(function then(feedXml) { - res.set('Content-Type', 'text/xml; charset=UTF-8'); - res.send(feedXml); - }); + // Render call - to a special RSS renderer + return rssService.render(res, baseUrl, data); }).catch(handleError(next)); }; diff --git a/core/server/services/rss/cache.js b/core/server/services/rss/cache.js index fe215fae7a82..5e1a7bf772be 100644 --- a/core/server/services/rss/cache.js +++ b/core/server/services/rss/cache.js @@ -2,15 +2,15 @@ var crypto = require('crypto'), generateFeed = require('./generate-feed'), feedCache = {}; -module.exports.getXML = function getFeedXml(path, data) { +module.exports.getXML = function getFeedXml(baseUrl, data) { var dataHash = crypto.createHash('md5').update(JSON.stringify(data)).digest('hex'); - if (!feedCache[path] || feedCache[path].hash !== dataHash) { + if (!feedCache[baseUrl] || feedCache[baseUrl].hash !== dataHash) { // We need to regenerate - feedCache[path] = { + feedCache[baseUrl] = { hash: dataHash, - xml: generateFeed(data) + xml: generateFeed(baseUrl, data) }; } - return feedCache[path].xml; + return feedCache[baseUrl].xml; }; diff --git a/core/server/services/rss/generate-feed.js b/core/server/services/rss/generate-feed.js index bda3ba71a840..89f89042ce68 100644 --- a/core/server/services/rss/generate-feed.js +++ b/core/server/services/rss/generate-feed.js @@ -5,6 +5,7 @@ var downsize = require('downsize'), processUrls = require('../../utils/make-absolute-urls'), generateFeed, + generateItem, generateTags; generateTags = function generateTags(data) { @@ -20,60 +21,77 @@ generateTags = function generateTags(data) { return []; }; -generateFeed = function generateFeed(data) { - var feed = new RSS({ - title: data.title, - description: data.description, - generator: 'Ghost ' + data.version, - feed_url: data.feedUrl, - site_url: data.siteUrl, - image_url: utils.url.urlFor({relativeUrl: 'favicon.png'}, true), - ttl: '60', - custom_namespaces: { - content: 'http://purl.org/rss/1.0/modules/content/', - media: 'http://search.yahoo.com/mrss/' - } - }); +generateItem = function generateItem(post, siteUrl, secure) { + var itemUrl = utils.url.urlFor('post', {post: post, secure: secure}, true), + htmlContent = processUrls(post.html, siteUrl, itemUrl), + item = { + title: post.title, + // @TODO: DRY this up with data/meta/index & other excerpt code + description: post.custom_excerpt || post.meta_description || downsize(htmlContent.html(), {words: 50}), + guid: post.id, + url: itemUrl, + date: post.published_at, + categories: generateTags(post), + author: post.author ? post.author.name : null, + custom_elements: [] + }, + imageUrl; - data.results.posts.forEach(function forEach(post) { - var itemUrl = utils.url.urlFor('post', {post: post, secure: data.secure}, true), - htmlContent = processUrls(post.html, data.siteUrl, itemUrl), - item = { - title: post.title, - description: post.custom_excerpt || post.meta_description || downsize(htmlContent.html(), {words: 50}), - guid: post.id, - url: itemUrl, - date: post.published_at, - categories: generateTags(post), - author: post.author ? post.author.name : null, - custom_elements: [] - }, - imageUrl; + if (post.feature_image) { + imageUrl = utils.url.urlFor('image', {image: post.feature_image, secure: secure}, true); - if (post.feature_image) { - imageUrl = utils.url.urlFor('image', {image: post.feature_image, secure: data.secure}, true); - - // Add a media content tag - item.custom_elements.push({ - 'media:content': { - _attr: { - url: imageUrl, - medium: 'image' - } + // Add a media content tag + item.custom_elements.push({ + 'media:content': { + _attr: { + url: imageUrl, + medium: 'image' } - }); + } + }); - // Also add the image to the content, because not all readers support media:content - htmlContent('p').first().before(''); - htmlContent('img').attr('alt', post.title); + // Also add the image to the content, because not all readers support media:content + htmlContent('p').first().before(''); + htmlContent('img').attr('alt', post.title); + } + + item.custom_elements.push({ + 'content:encoded': { + _cdata: htmlContent.html() } + }); - item.custom_elements.push({ - 'content:encoded': { - _cdata: htmlContent.html() + return item; +}; + +/** + * Generate Feed + * + * Data is an object which contains the res.locals + results from fetching a channel, but without related data. + * + * @param {string} baseUrl + * @param {{title, description, safeVersion, secure, posts}} data + */ +generateFeed = function generateFeed(baseUrl, data) { + var siteUrl = utils.url.urlFor('home', {secure: data.secure}, true), + feedUrl = utils.url.urlFor({relativeUrl: baseUrl, secure: data.secure}, true), + feed = new RSS({ + title: data.title, + description: data.description, + generator: 'Ghost ' + data.safeVersion, + feed_url: feedUrl, + site_url: siteUrl, + image_url: utils.url.urlFor({relativeUrl: 'favicon.png'}, true), + ttl: '60', + custom_namespaces: { + content: 'http://purl.org/rss/1.0/modules/content/', + media: 'http://search.yahoo.com/mrss/' } }); + data.posts.forEach(function forEach(post) { + var item = generateItem(post, siteUrl, data.secure); + filters.doFilter('rss.item', item, post).then(function then(item) { feed.item(item); }); diff --git a/core/server/services/rss/index.js b/core/server/services/rss/index.js index b7efec6b26c9..4fd2a734a6c7 100644 --- a/core/server/services/rss/index.js +++ b/core/server/services/rss/index.js @@ -1 +1 @@ -module.exports = require('./cache'); +module.exports = require('./renderer'); diff --git a/core/server/services/rss/renderer.js b/core/server/services/rss/renderer.js new file mode 100644 index 000000000000..4be8bd4efd37 --- /dev/null +++ b/core/server/services/rss/renderer.js @@ -0,0 +1,15 @@ +var _ = require('lodash'), + rssCache = require('./cache'); + +module.exports.render = function render(res, baseUrl, data) { + // Format data - this is the same as what Express does + var rssData = _.merge({}, res.locals, data); + + // Fetch RSS from the cache + return rssCache + .getXML(baseUrl, rssData) + .then(function then(feedXml) { + res.set('Content-Type', 'text/xml; charset=UTF-8'); + res.send(feedXml); + }); +}; diff --git a/core/test/unit/controllers/frontend/context_spec.js b/core/test/unit/controllers/frontend/context_spec.js index b7852037926c..585880e7f0a1 100644 --- a/core/test/unit/controllers/frontend/context_spec.js +++ b/core/test/unit/controllers/frontend/context_spec.js @@ -410,48 +410,6 @@ describe('Contexts', function () { }); }); - describe('RSS', function () { - // NOTE: this works, but is never used in reality, as setResponseContext isn't called - // for RSS feeds at the moment. - it('should correctly identify /rss/ as rss', function () { - // Setup test - setupContext('/rss/'); - - // Execute test - setResponseContext(req, res, data); - - // Check context - should.exist(res.locals.context); - res.locals.context.should.be.an.Array().with.lengthOf(1); - res.locals.context[0].should.eql('rss'); - }); - - it('will not identify /rss/2/ as rss & paged without page param', function () { - // Setup test by setting relativeUrl - setupContext('/rss/2/'); - - // Execute test - setResponseContext(req, res, data); - - // Check context - should.exist(res.locals.context); - res.locals.context.should.be.an.Array().with.lengthOf(1); - res.locals.context[0].should.eql('rss'); - }); - - it('should correctly identify /rss/2/ as rss & paged with page param', function () { - // Setup test by setting relativeUrl - setupContext('/rss/2/', 2); - - // Execute test - setResponseContext(req, res, data); - - should.exist(res.locals.context); - res.locals.context.should.be.an.Array().with.lengthOf(2); - res.locals.context[0].should.eql('paged'); - res.locals.context[1].should.eql('rss'); - }); - }); describe('AMP', function () { it('should correctly identify an AMP post', function () { // Setup test diff --git a/core/test/unit/controllers/rss_spec.js b/core/test/unit/controllers/rss_spec.js index 2dd229e79376..af469b8583ca 100644 --- a/core/test/unit/controllers/rss_spec.js +++ b/core/test/unit/controllers/rss_spec.js @@ -3,13 +3,10 @@ var should = require('should'), rewire = require('rewire'), _ = require('lodash'), Promise = require('bluebird'), - testUtils = require('../../utils'), channelUtils = require('../../utils/channelUtils'), - api = require('../../../server/api'), settingsCache = require('../../../server/settings/cache'), rssController = rewire('../../../server/controllers/rss'), - rssCache = require('../../../server/services/rss'), - configUtils = require('../../utils/configUtils'), + rssService = require('../../../server/services/rss'), sandbox = sinon.sandbox.create(); @@ -24,20 +21,7 @@ function failTest(done) { describe('RSS', function () { describe('RSS: Controller only', function () { - var req, res, posts, getDataStub, resetGetData, rssCacheStub; - - before(function () { - posts = _.cloneDeep(testUtils.DataGenerator.forKnex.posts); - posts = _.filter(posts, function filter(post) { - return post.status === 'published' && post.page === false; - }); - - _.each(posts, function (post, i) { - post.id = i; - post.url = '/' + post.slug + '/'; - post.author = {name: 'Joe Bloggs'}; - }); - }); + var req, res, next, getDataStub, fakeData, resetGetData, rssServiceStub; beforeEach(function () { // Minimum setup of req and res @@ -49,313 +33,259 @@ describe('RSS', function () { res = { locals: { safeVersion: '0.6', - channel: channelUtils.getTestChannel('index') - }, - set: sinon.stub(), - send: sinon.spy() + channel: {postOptions: {}} + } }; - // @TODO Get rid of this! - shouldn't be set on the channel - res.locals.channel.isRSS = true; + next = sandbox.stub(); // Overwrite getData - getDataStub = sandbox.stub(); + fakeData = {meta: {pagination: {pages: 3}}}; + getDataStub = sandbox.stub().returns(new Promise.resolve(fakeData)); resetGetData = rssController.__set__('getData', getDataStub); - rssCacheStub = sandbox.stub(rssCache, 'getXML').returns(new Promise.resolve('dummyxml')); + rssServiceStub = sandbox.stub(rssService, 'render').returns(new Promise.resolve()); }); afterEach(function () { sandbox.restore(); - configUtils.restore(); resetGetData(); }); it('should fetch data and attempt to send XML', function (done) { - getDataStub.returns(new Promise.resolve({ - results: {meta: {pagination: {pages: 3}}} - })); - - res.send = function (result) { - result.should.eql('dummyxml'); - res.set.calledOnce.should.be.true(); - res.set.calledWith('Content-Type', 'text/xml; charset=UTF-8').should.be.true(); - getDataStub.calledOnce.should.be.true(); - rssCacheStub.calledOnce.should.be.true(); - rssCacheStub.calledWith('/rss/').should.be.true(); - done(); - }; - - rssController(req, res, failTest(done)); + rssController(req, res, next) + .then(function () { + next.called.should.be.false(); + + getDataStub.calledOnce.should.be.true(); + getDataStub.calledWith(res.locals.channel).should.be.true(); + + rssServiceStub.calledOnce.should.be.true(); + rssServiceStub.firstCall.args[0].should.eql(res); + rssServiceStub.firstCall.args[1].should.eql('/rss/'); + rssServiceStub.firstCall.args[2].should.match(fakeData); + done(); + }) + .catch(done); }); it('can handle paginated urls', function (done) { - getDataStub.returns(new Promise.resolve({ - results: {meta: {pagination: {pages: 3}}} - })); - req.originalUrl = '/rss/2/'; req.params.page = 2; - res.send = function (result) { - result.should.eql('dummyxml'); - res.set.calledOnce.should.be.true(); - res.set.calledWith('Content-Type', 'text/xml; charset=UTF-8').should.be.true(); - getDataStub.calledOnce.should.be.true(); - rssCacheStub.calledOnce.should.be.true(); - rssCacheStub.calledWith('/rss/').should.be.true(); - done(); - }; + rssController(req, res, next) + .then(function () { + next.called.should.be.false(); - rssController(req, res, failTest(done)); + getDataStub.calledOnce.should.be.true(); + getDataStub.calledWith(res.locals.channel).should.be.true(); + + rssServiceStub.calledOnce.should.be.true(); + rssServiceStub.firstCall.args[0].should.eql(res); + rssServiceStub.firstCall.args[1].should.eql('/rss/'); + rssServiceStub.firstCall.args[2].should.match(fakeData); + done(); + }) + .catch(done); }); it('can handle paginated urls with subdirectories', function (done) { - getDataStub.returns(new Promise.resolve({ - results: {meta: {pagination: {pages: 3}}} - })); - req.originalUrl = '/blog/rss/2/'; req.params.page = 2; - res.send = function (result) { - result.should.eql('dummyxml'); - res.set.calledOnce.should.be.true(); - res.set.calledWith('Content-Type', 'text/xml; charset=UTF-8').should.be.true(); - getDataStub.calledOnce.should.be.true(); - rssCacheStub.calledOnce.should.be.true(); - rssCacheStub.calledWith('/blog/rss/').should.be.true(); - done(); - }; + rssController(req, res, next) + .then(function () { + next.called.should.be.false(); + + getDataStub.calledOnce.should.be.true(); + getDataStub.calledWith(res.locals.channel).should.be.true(); - rssController(req, res, failTest(done)); + rssServiceStub.calledOnce.should.be.true(); + rssServiceStub.firstCall.args[0].should.eql(res); + rssServiceStub.firstCall.args[1].should.eql('/blog/rss/'); + rssServiceStub.firstCall.args[2].should.match(fakeData); + done(); + }) + .catch(done); }); it('can handle paginated urls for channels', function (done) { - getDataStub.returns(new Promise.resolve({ - results: {meta: {pagination: {pages: 3}}} - })); - req.originalUrl = '/tags/test/rss/2/'; req.params.page = 2; req.params.slug = 'test'; - res.send = function (result) { - result.should.eql('dummyxml'); - res.set.calledOnce.should.be.true(); - res.set.calledWith('Content-Type', 'text/xml; charset=UTF-8').should.be.true(); - getDataStub.calledOnce.should.be.true(); - rssCacheStub.calledOnce.should.be.true(); - rssCacheStub.calledWith('/tags/test/rss/').should.be.true(); - done(); - }; + rssController(req, res, next) + .then(function () { + next.called.should.be.false(); + + getDataStub.calledOnce.should.be.true(); + getDataStub.calledWith(res.locals.channel).should.be.true(); - rssController(req, res, failTest(done)); + rssServiceStub.calledOnce.should.be.true(); + rssServiceStub.firstCall.args[0].should.eql(res); + rssServiceStub.firstCall.args[1].should.eql('/tags/test/rss/'); + rssServiceStub.firstCall.args[2].should.match(fakeData); + done(); + }) + .catch(done); }); it('should call next with 404 if page number too big', function (done) { - getDataStub.returns(new Promise.resolve({ - results: {meta: {pagination: {pages: 3}}} - })); - req.originalUrl = '/rss/4/'; req.params.page = 4; - rssController(req, res, function (err) { - should.exist(err); - err.statusCode.should.eql(404); - res.send.called.should.be.false(); - done(); - }); + rssController(req, res, next) + .then(function () { + next.called.should.be.true(); + next.firstCall.args[0].statusCode.should.eql(404); + + getDataStub.calledOnce.should.be.true(); + getDataStub.calledWith(res.locals.channel).should.be.true(); + + rssServiceStub.called.should.be.false(); + done(); + }) + .catch(done); }); }); // These tests check the RSS feed from controller to result // @TODO: test only the data generation, once we've refactored to make that easier - describe('RSS: data generation', function () { - var apiBrowseStub, apiTagStub, apiUserStub, req, res; + describe('RSS: getData / getTitle', function () { + var fetchDataStub, resetFetchData, getData; beforeEach(function () { - apiBrowseStub = sandbox.stub(api.posts, 'browse', function () { - return Promise.resolve({posts: [], meta: {pagination: {pages: 3}}}); - }); - - apiTagStub = sandbox.stub(api.tags, 'read', function () { - return Promise.resolve({tags: [{name: 'Magic'}]}); - }); - - apiUserStub = sandbox.stub(api.users, 'read', function () { - return Promise.resolve({users: [{name: 'Joe Blogs'}]}); - }); - - req = { - params: {}, - originalUrl: '/rss/' - }; - - res = { - locals: { - safeVersion: '0.6' - }, - set: sinon.stub() - }; + fetchDataStub = sandbox.stub(); + resetFetchData = rssController.__set__('fetchData', fetchDataStub); + getData = rssController.__get__('getData'); sandbox.stub(settingsCache, 'get', function (key) { var obj = { title: 'Test', - description: 'Some Text', - permalinks: '/:slug/' + description: 'Some Text' }; return obj[key]; }); - - configUtils.set({ - url: 'http://my-ghost-blog.com' - }); }); afterEach(function () { sandbox.restore(); - configUtils.restore(); + resetFetchData(); }); it('should process the data correctly for the index feed', function (done) { - // setup - req.originalUrl = '/rss/'; - res.locals.channel = channelUtils.getTestChannel('index'); - res.locals.channel.isRSS = true; - - // test - res.send = function send(xmlData) { - apiBrowseStub.calledOnce.should.be.true(); - apiBrowseStub.calledWith({ - page: 1, - include: 'author,tags' - }).should.be.true(); - apiTagStub.called.should.be.false(); - apiUserStub.called.should.be.false(); - xmlData.should.match(/<!\[CDATA\[Test\]\]><\/title>/); - xmlData.should.match(/<atom:link href="http:\/\/my-ghost-blog.com\/rss\/" rel="self" type="application\/rss\+xml"\/>/); - done(); - }; - - rssController(req, res, failTest(done)); - }); - - it('should process the data correctly for the paginated index feed', function (done) { - // setup - req.originalUrl = '/rss/2/'; - req.params.page = '2'; - res.locals.channel = channelUtils.getTestChannel('index'); - res.locals.channel.isRSS = true; - - // test - res.send = function send(xmlData) { - apiBrowseStub.calledOnce.should.be.true(); - apiBrowseStub.calledWith({ - page: '2', - include: 'author,tags' - }).should.be.true(); - - apiTagStub.called.should.be.false(); - apiUserStub.called.should.be.false(); - xmlData.should.match(/<channel><title><!\[CDATA\[Test\]\]><\/title>/); - xmlData.should.match(/<atom:link href="http:\/\/my-ghost-blog.com\/rss\/" rel="self" type="application\/rss\+xml"\/>/); - done(); - }; - - rssController(req, res, failTest(done)); + fetchDataStub.returns(new Promise.resolve({posts: [{test: 'hey'}], meta: {foo: 'you'}})); + + var channel = channelUtils.getTestChannel('index'); + + getData(channel) + .then(function (result) { + fetchDataStub.calledOnce.should.be.true(); + fetchDataStub.calledWith(channel).should.be.true(); + + result.should.eql({ + title: 'Test', + description: 'Some Text', + posts: [{test: 'hey'}], + meta: {foo: 'you'} + }); + done(); + }) + .catch(done); }); it('should process the data correctly for a tag feed', function (done) { - // setup - req.originalUrl = '/tag/magic/rss/'; - req.params.slug = 'magic'; - res.locals.channel = channelUtils.getTestChannel('tag'); - res.locals.channel.isRSS = true; - - // test - res.send = function send(xmlData) { - apiBrowseStub.calledOnce.should.be.true(); - apiBrowseStub.calledWith({ - page: 1, - filter: 'tags:\'magic\'+tags.visibility:public', - include: 'author,tags' - }).should.be.true(); - apiTagStub.calledOnce.should.be.true(); - xmlData.should.match(/<channel><title><!\[CDATA\[Magic - Test\]\]><\/title>/); - xmlData.should.match(/<atom:link href="http:\/\/my-ghost-blog.com\/tag\/magic\/rss\/" rel="self" type="application\/rss\+xml"\/>/); - done(); - }; - - rssController(req, res, failTest(done)); + fetchDataStub.returns(new Promise.resolve({posts: [{test: 'hey'}], meta: {foo: 'you'}})); + + var channel = channelUtils.getTestChannel('tag'); + + getData(channel) + .then(function (result) { + fetchDataStub.calledOnce.should.be.true(); + fetchDataStub.calledWith(channel).should.be.true(); + + result.should.eql({ + title: 'Test', + description: 'Some Text', + posts: [{test: 'hey'}], + meta: {foo: 'you'} + }); + done(); + }) + .catch(done); }); - it('should process the data correctly for a paginated tag feed', function (done) { - // setup - req.originalUrl = '/tag/magic/rss/2/'; - req.params.slug = 'magic'; - req.params.page = '2'; - res.locals.channel = channelUtils.getTestChannel('tag'); - res.locals.channel.isRSS = true; - - // test - res.send = function send(xmlData) { - apiBrowseStub.calledOnce.should.be.true(); - apiBrowseStub.calledWith({ - page: '2', - filter: 'tags:\'magic\'+tags.visibility:public', - include: 'author,tags' - }).should.be.true(); - - apiTagStub.calledOnce.should.be.true(); - xmlData.should.match(/<channel><title><!\[CDATA\[Magic - Test\]\]><\/title>/); - xmlData.should.match(/<atom:link href="http:\/\/my-ghost-blog.com\/tag\/magic\/rss\/" rel="self" type="application\/rss\+xml"\/>/); - done(); - }; + it('should process the data correctly for a tag feed WITH related data', function (done) { + fetchDataStub.returns(new Promise.resolve({ + posts: [{test: 'hey'}], + meta: {foo: 'you'}, + data: {tag: [{name: 'there'}]} + })); - rssController(req, res, failTest(done)); + var channel = channelUtils.getTestChannel('tag'); + + getData(channel) + .then(function (result) { + fetchDataStub.calledOnce.should.be.true(); + fetchDataStub.calledWith(channel).should.be.true(); + + result.should.eql({ + title: 'there - Test', + description: 'Some Text', + posts: [{test: 'hey'}], + meta: {foo: 'you'} + }); + done(); + }) + .catch(done); }); it('should process the data correctly for an author feed', function (done) { - req.originalUrl = '/author/joe/rss/'; - req.params.slug = 'joe'; - res.locals.channel = channelUtils.getTestChannel('author'); - res.locals.channel.isRSS = true; - - // test - res.send = function send(xmlData) { - apiBrowseStub.calledOnce.should.be.true(); - apiBrowseStub.calledWith({page: 1, filter: 'author:\'joe\'', include: 'author,tags'}).should.be.true(); - apiUserStub.calledOnce.should.be.true(); - xmlData.should.match(/<channel><title><!\[CDATA\[Joe Blogs - Test\]\]><\/title>/); - xmlData.should.match(/<atom:link href="http:\/\/my-ghost-blog.com\/author\/joe\/rss\/" rel="self" type="application\/rss\+xml"\/>/); - done(); - }; - - rssController(req, res, failTest(done)); + fetchDataStub.returns(new Promise.resolve({posts: [{test: 'hey'}], meta: {foo: 'you'}})); + + var channel = channelUtils.getTestChannel('author'); + + getData(channel) + .then(function (result) { + fetchDataStub.calledOnce.should.be.true(); + fetchDataStub.calledWith(channel).should.be.true(); + + result.should.eql({ + title: 'Test', + description: 'Some Text', + posts: [{test: 'hey'}], + meta: {foo: 'you'} + }); + done(); + }) + .catch(done); }); - it('should process the data correctly for a paginated author feed', function (done) { - req.originalUrl = '/author/joe/rss/2/'; - req.params.slug = 'joe'; - req.params.page = '2'; - res.locals.channel = channelUtils.getTestChannel('author'); - res.locals.channel.isRSS = true; - - // test - res.send = function send(xmlData) { - apiBrowseStub.calledOnce.should.be.true(); - apiBrowseStub.calledWith({page: '2', filter: 'author:\'joe\'', include: 'author,tags'}).should.be.true(); - apiUserStub.calledOnce.should.be.true(); - xmlData.should.match(/<channel><title><!\[CDATA\[Joe Blogs - Test\]\]><\/title>/); - xmlData.should.match(/<atom:link href="http:\/\/my-ghost-blog.com\/author\/joe\/rss\/" rel="self" type="application\/rss\+xml"\/>/); - done(); - }; + it('should process the data correctly for an author feed WITH related data', function (done) { + fetchDataStub.returns(new Promise.resolve({ + posts: [{test: 'hey'}], + meta: {foo: 'you'}, + data: {author: [{name: 'there'}]} + })); - rssController(req, res, failTest(done)); + var channel = channelUtils.getTestChannel('author'); + + getData(channel) + .then(function (result) { + fetchDataStub.calledOnce.should.be.true(); + fetchDataStub.calledWith(channel).should.be.true(); + + result.should.eql({ + title: 'there - Test', + description: 'Some Text', + posts: [{test: 'hey'}], + meta: {foo: 'you'} + }); + done(); + }) + .catch(done); }); }); }); diff --git a/core/test/unit/services/rss/cache_spec.js b/core/test/unit/services/rss/cache_spec.js index f310eeef597c..edf15effc270 100644 --- a/core/test/unit/services/rss/cache_spec.js +++ b/core/test/unit/services/rss/cache_spec.js @@ -27,8 +27,8 @@ describe('RSS: Cache', function () { data = { title: 'Test Title', description: 'Testing Desc', - permalinks: '/:slug/', - results: {posts: [], meta: {pagination: {pages: 1}}} + posts: [], + meta: {pagination: {pages: 1}} }; rssCache.getXML('/rss/', data) diff --git a/core/test/unit/services/rss/generate-feed_spec.js b/core/test/unit/services/rss/generate-feed_spec.js index b754c91b7a14..efba5a4a054b 100644 --- a/core/test/unit/services/rss/generate-feed_spec.js +++ b/core/test/unit/services/rss/generate-feed_spec.js @@ -7,6 +7,7 @@ var should = require('should'), describe('RSS: Generate Feed', function () { var data = {}, + baseUrl, // Static set of posts posts; @@ -30,18 +31,18 @@ describe('RSS: Generate Feed', function () { beforeEach(function () { configUtils.set({url: 'http://my-ghost-blog.com'}); - data.version = '0.6'; - data.siteUrl = 'http://my-ghost-blog.com/'; - data.feedUrl = 'http://my-ghost-blog.com/rss/'; + baseUrl = '/rss/'; + + data.safeVersion = '0.6'; data.title = 'Test Title'; data.description = 'Testing Desc'; - data.permalinks = '/:slug/'; + data.meta = {pagination: {pages: 1}}; }); it('should get the RSS tags correct', function (done) { - data.results = {posts: [], meta: {pagination: {pages: 1}}}; + data.posts = []; - generateFeed(data).then(function (xmlData) { + generateFeed(baseUrl, data).then(function (xmlData) { should.exist(xmlData); // xml & rss tags @@ -69,9 +70,9 @@ describe('RSS: Generate Feed', function () { }); it('should get the item tags correct', function (done) { - data.results = {posts: posts, meta: {pagination: {pages: 1}}}; + data.posts = posts; - generateFeed(data).then(function (xmlData) { + generateFeed(baseUrl, data).then(function (xmlData) { should.exist(xmlData); // item tags @@ -107,9 +108,9 @@ describe('RSS: Generate Feed', function () { {name: 'visibility'} ]; - data.results = {posts: [postWithTags], meta: {pagination: {pages: 1}}}; + data.posts = [postWithTags]; - generateFeed(data).then(function (xmlData) { + generateFeed(baseUrl, data).then(function (xmlData) { should.exist(xmlData); // item tags xmlData.should.match(/<title><!\[CDATA\[Short and Sweet\]\]>/); @@ -124,10 +125,28 @@ describe('RSS: Generate Feed', function () { }).catch(done); }); + it('should no error if author is somehow not present', function (done) { + data.posts = [_.omit(posts[2], 'author')]; + + generateFeed(baseUrl, data).then(function (xmlData) { + should.exist(xmlData); + + // special/optional tags + xmlData.should.match(/<title><!\[CDATA\[Short and Sweet\]\]>/); + xmlData.should.match(/<description><!\[CDATA\[test stuff/); + xmlData.should.match(/<content:encoded><!\[CDATA\[<div class="kg-card-markdown"><h2 id="testing">testing<\/h2>\n/); + xmlData.should.match(/<img src="http:\/\/placekitten.com\/500\/200"/); + xmlData.should.match(/<media:content url="http:\/\/placekitten.com\/500\/200" medium="image"\/>/); + xmlData.should.not.match(/<dc:creator>/); + + done(); + }).catch(done); + }); + it('should use meta_description and image where available', function (done) { - data.results = {posts: [posts[2]], meta: {pagination: {pages: 1}}}; + data.posts = [posts[2]]; - generateFeed(data).then(function (xmlData) { + generateFeed(baseUrl, data).then(function (xmlData) { should.exist(xmlData); // special/optional tags @@ -142,9 +161,9 @@ describe('RSS: Generate Feed', function () { }); it('should use excerpt when no meta_description is set', function (done) { - data.results = {posts: [posts[0]], meta: {pagination: {pages: 1}}}; + data.posts = [posts[0]]; - generateFeed(data).then(function (xmlData) { + generateFeed(baseUrl, data).then(function (xmlData) { should.exist(xmlData); // special/optional tags @@ -156,9 +175,9 @@ describe('RSS: Generate Feed', function () { }); it('should process urls correctly', function (done) { - data.results = {posts: [posts[3]], meta: {pagination: {pages: 1}}}; + data.posts = [posts[3]]; - generateFeed(data).then(function (xmlData) { + generateFeed(baseUrl, data).then(function (xmlData) { should.exist(xmlData); // anchor URL - <a href="#nowhere" title="Anchor URL"> @@ -180,11 +199,10 @@ describe('RSS: Generate Feed', function () { it('should process urls correctly with subdirectory', function (done) { configUtils.set({url: 'http://my-ghost-blog.com/blog/'}); - data.siteUrl = 'http://my-ghost-blog.com/blog/'; - data.feedUrl = 'http://my-ghost-blog.com/blog/rss/'; + baseUrl = '/blog/rss/'; data.results = {posts: [posts[3]], meta: {pagination: {pages: 1}}}; - generateFeed(data).then(function (xmlData) { + generateFeed(baseUrl, data).then(function (xmlData) { should.exist(xmlData); // anchor URL - <a href="#nowhere" title="Anchor URL"> diff --git a/core/test/unit/services/rss/renderer_spec.js b/core/test/unit/services/rss/renderer_spec.js new file mode 100644 index 000000000000..7b4b86cd0868 --- /dev/null +++ b/core/test/unit/services/rss/renderer_spec.js @@ -0,0 +1,102 @@ +var should = require('should'), + sinon = require('sinon'), + Promise = require('bluebird'), + + rssCache = require('../../../../server/services/rss/cache'), + renderer = require('../../../../server/services/rss/renderer'), + + sandbox = sinon.sandbox.create(); + +describe('RSS: Renderer', function () { + var rssCacheStub, res, baseUrl; + + beforeEach(function () { + rssCacheStub = sandbox.stub(rssCache, 'getXML'); + + res = { + locals: {}, + set: sinon.stub(), + send: sinon.spy() + }; + + baseUrl = '/rss/'; + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('calls the cache and attempts to render, even without data', function (done) { + rssCacheStub.returns(new Promise.resolve('dummyxml')); + + renderer.render(res, baseUrl).then(function () { + rssCacheStub.calledOnce.should.be.true(); + rssCacheStub.firstCall.args.should.eql(['/rss/', {}]); + + res.set.calledOnce.should.be.true(); + res.set.calledWith('Content-Type', 'text/xml; charset=UTF-8').should.be.true(); + + res.send.calledOnce.should.be.true(); + res.send.calledWith('dummyxml').should.be.true(); + + done(); + }).catch(done); + }); + + it('correctly merges locals into empty data before rendering', function (done) { + rssCacheStub.returns(new Promise.resolve('dummyxml')); + + res.locals = {foo: 'bar'}; + + renderer.render(res, baseUrl).then(function () { + rssCacheStub.calledOnce.should.be.true(); + rssCacheStub.firstCall.args.should.eql(['/rss/', {foo: 'bar'}]); + + res.set.calledOnce.should.be.true(); + res.set.calledWith('Content-Type', 'text/xml; charset=UTF-8').should.be.true(); + + res.send.calledOnce.should.be.true(); + res.send.calledWith('dummyxml').should.be.true(); + + done(); + }).catch(done); + }); + + it('correctly merges locals into non-empty data before rendering', function (done) { + rssCacheStub.returns(new Promise.resolve('dummyxml')); + + res.locals = {foo: 'bar'}; + var data = {foo: 'baz', fizz: 'buzz'}; + + renderer.render(res, baseUrl, data).then(function () { + rssCacheStub.calledOnce.should.be.true(); + rssCacheStub.firstCall.args.should.eql(['/rss/', {foo: 'baz', fizz: 'buzz'}]); + + res.set.calledOnce.should.be.true(); + res.set.calledWith('Content-Type', 'text/xml; charset=UTF-8').should.be.true(); + + res.send.calledOnce.should.be.true(); + res.send.calledWith('dummyxml').should.be.true(); + + done(); + }).catch(done); + }); + + it('does nothing if it gets an error', function (done) { + rssCacheStub.returns(new Promise.reject(new Error('Fake Error'))); + + renderer.render(res, baseUrl).then(function () { + done('This should have errored'); + }).catch(function (err) { + err.message.should.eql('Fake Error'); + + rssCacheStub.calledOnce.should.be.true(); + rssCacheStub.firstCall.args.should.eql(['/rss/', {}]); + + res.set.called.should.be.false(); + res.send.called.should.be.false(); + + done(); + }); + }); +});