diff --git a/core/server/controllers/channel.js b/core/server/controllers/channel.js index d3d8caa124..1b1cc74c03 100644 --- a/core/server/controllers/channel.js +++ b/core/server/controllers/channel.js @@ -10,18 +10,16 @@ var _ = require('lodash'), module.exports = function channelController(req, res, next) { // Parse the parameters we need from the URL - var channelOpts = res.locals.channel, - pageParam = req.params.page !== undefined ? req.params.page : 1, + var pageParam = req.params.page !== undefined ? req.params.page : 1, slugParam = req.params.slug ? safeString(req.params.slug) : undefined; - // Ensure we at least have an empty object for postOptions - channelOpts.postOptions = channelOpts.postOptions || {}; + // @TODO: fix this, we shouldn't change the channel object! // Set page on postOptions for the query made later - channelOpts.postOptions.page = pageParam; - channelOpts.slugParam = slugParam; + res.locals.channel.postOptions.page = pageParam; + res.locals.channel.slugParam = slugParam; // Call fetchData to get everything we need from the API - return fetchData(channelOpts).then(function handleResult(result) { + return fetchData(res.locals.channel).then(function handleResult(result) { // If page is greater than number of pages we have, go straight to 404 if (pageParam > result.meta.pagination.pages) { return next(new errors.NotFoundError({message: i18n.t('errors.errors.pageNotFound')})); diff --git a/core/server/data/xml/rss/cache.js b/core/server/data/xml/rss/cache.js new file mode 100644 index 0000000000..fe215fae7a --- /dev/null +++ b/core/server/data/xml/rss/cache.js @@ -0,0 +1,16 @@ +var crypto = require('crypto'), + generateFeed = require('./generate-feed'), + feedCache = {}; + +module.exports.getXML = function getFeedXml(path, data) { + var dataHash = crypto.createHash('md5').update(JSON.stringify(data)).digest('hex'); + if (!feedCache[path] || feedCache[path].hash !== dataHash) { + // We need to regenerate + feedCache[path] = { + hash: dataHash, + xml: generateFeed(data) + }; + } + + return feedCache[path].xml; +}; diff --git a/core/server/data/xml/rss/controller.js b/core/server/data/xml/rss/controller.js new file mode 100644 index 0000000000..5259a4a887 --- /dev/null +++ b/core/server/data/xml/rss/controller.js @@ -0,0 +1,83 @@ +var _ = require('lodash'), + url = require('url'), + utils = require('../../../utils'), + errors = require('../../../errors'), + i18n = require('../../../i18n'), + safeString = require('../../../utils/index').safeString, + settingsCache = require('../../../settings/cache'), + + // Really ugly temporary hack for location of things + fetchData = require('../../../controllers/frontend/fetch-data'), + handleError = require('../../../controllers/frontend/error'), + + feedCache = require('./cache'), + generate; + +// @TODO: is this the right logic? Where should this live?! +function getBaseUrlForRSSReq(originalUrl, pageParam) { + return url.parse(originalUrl).pathname.replace(new RegExp('/' + pageParam + '/$'), '/'); +} + +// @TODO: is this really correct? Should we be using meta data title? +function getTitle(relatedData) { + relatedData = relatedData || {}; + var titleStart = _.get(relatedData, 'author[0].name') || _.get(relatedData, 'tag[0].name') || ''; + + titleStart += titleStart ? ' - ' : ''; + return titleStart + settingsCache.get('title'); +} + +// @TODO: merge this with the rest of the data processing for RSS +// @TODO: swap the fetchData call + duplicate code from channels with something DRY +function getData(channelOpts) { + channelOpts.data = channelOpts.data || {}; + + return fetchData(channelOpts).then(function formatResult(result) { + var response = {}; + + response.title = getTitle(result.data); + response.description = settingsCache.get('description'); + response.results = { + posts: result.posts, + meta: result.meta + }; + + return response; + }); +} + +// @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; + + // @TODO: fix this, we shouldn't change the channel object! + // Set page on postOptions for the query made later + res.locals.channel.postOptions.page = pageParam; + 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) { + return next(new errors.NotFoundError({message: i18n.t('errors.errors.pageNotFound')})); + } + + 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; + + // @TODO this is effectively a renderer + return feedCache.getXML(baseUrl, data).then(function then(feedXml) { + res.set('Content-Type', 'text/xml; charset=UTF-8'); + res.send(feedXml); + }); + }).catch(handleError(next)); +}; + +module.exports = generate; diff --git a/core/server/data/xml/rss/generate-feed.js b/core/server/data/xml/rss/generate-feed.js new file mode 100644 index 0000000000..e7a68ff4ab --- /dev/null +++ b/core/server/data/xml/rss/generate-feed.js @@ -0,0 +1,87 @@ +var downsize = require('downsize'), + RSS = require('rss'), + utils = require('../../../utils'), + filters = require('../../../filters'), + processUrls = require('../../../utils/make-absolute-urls'), + + generateFeed, + generateTags; + +generateTags = function generateTags(data) { + if (data.tags) { + return data.tags.reduce(function (tags, tag) { + if (tag.visibility !== 'internal') { + tags.push(tag.name); + } + return tags; + }, []); + } + + 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/' + } + }); + + 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: data.secure}, true); + + // 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); + } + + item.custom_elements.push({ + 'content:encoded': { + _cdata: htmlContent.html() + } + }); + + filters.doFilter('rss.item', item, post).then(function then(item) { + feed.item(item); + }); + }); + + return filters.doFilter('rss.feed', feed).then(function then(feed) { + return feed.xml(); + }); +}; + +module.exports = generateFeed; diff --git a/core/server/data/xml/rss/index.js b/core/server/data/xml/rss/index.js index bc23ec9f0a..2be3d172a7 100644 --- a/core/server/data/xml/rss/index.js +++ b/core/server/data/xml/rss/index.js @@ -1,173 +1 @@ -var crypto = require('crypto'), - downsize = require('downsize'), - RSS = require('rss'), - url = require('url'), - utils = require('../../../utils'), - errors = require('../../../errors'), - i18n = require('../../../i18n'), - filters = require('../../../filters'), - processUrls = require('../../../utils/make-absolute-urls'), - settingsCache = require('../../../settings/cache'), - - // Really ugly temporary hack for location of things - fetchData = require('../../../controllers/frontend/fetch-data'), - - generate, - generateFeed, - generateTags, - getFeedXml, - feedCache = {}; - -function handleError(next) { - return function handleError(err) { - return next(err); - }; -} - -function getData(channelOpts, slugParam) { - channelOpts.data = channelOpts.data || {}; - - return fetchData(channelOpts, slugParam).then(function (result) { - var response = {}, - titleStart = ''; - - if (result.data && result.data.tag) { titleStart = result.data.tag[0].name + ' - ' || ''; } - if (result.data && result.data.author) { titleStart = result.data.author[0].name + ' - ' || ''; } - - response.title = titleStart + settingsCache.get('title'); - response.description = settingsCache.get('description'); - response.results = { - posts: result.posts, - meta: result.meta - }; - - return response; - }); -} - -getFeedXml = function getFeedXml(path, data) { - var dataHash = crypto.createHash('md5').update(JSON.stringify(data)).digest('hex'); - if (!feedCache[path] || feedCache[path].hash !== dataHash) { - // We need to regenerate - feedCache[path] = { - hash: dataHash, - xml: generateFeed(data) - }; - } - - return feedCache[path].xml; -}; - -generateTags = function generateTags(data) { - if (data.tags) { - return data.tags.reduce(function (tags, tag) { - if (tag.visibility !== 'internal') { - tags.push(tag.name); - } - return tags; - }, []); - } - - 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/' - } - }); - - 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: data.secure}, true); - - // 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); - } - - item.custom_elements.push({ - 'content:encoded': { - _cdata: htmlContent.html() - } - }); - - filters.doFilter('rss.item', item, post).then(function then(item) { - feed.item(item); - }); - }); - - return filters.doFilter('rss.feed', feed).then(function then(feed) { - return feed.xml(); - }); -}; - -generate = function generate(req, res, next) { - // Initialize RSS - var pageParam = req.params.page !== undefined ? req.params.page : 1, - slugParam = req.params.slug, - // Base URL needs to be the URL for the feed without pagination: - baseUrl = url.parse(req.originalUrl).pathname.replace(new RegExp('/' + pageParam + '/$'), '/'), - channelConfig = res.locals.channel; - - // Ensure we at least have an empty object for postOptions - channelConfig.postOptions = channelConfig.postOptions || {}; - // Set page on postOptions for the query made later - channelConfig.postOptions.page = pageParam; - - channelConfig.slugParam = slugParam; - - return getData(channelConfig).then(function then(data) { - var maxPage = data.results.meta.pagination.pages; - - // If page is greater than number of pages we have, redirect to last page - if (pageParam > maxPage) { - return next(new errors.NotFoundError({message: i18n.t('errors.errors.pageNotFound')})); - } - - 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; - - return getFeedXml(baseUrl, data).then(function then(feedXml) { - res.set('Content-Type', 'text/xml; charset=UTF-8'); - res.send(feedXml); - }); - }).catch(handleError(next)); -}; - -module.exports = generate; +module.exports = require('./controller'); diff --git a/core/test/unit/rss/cache_spec.js b/core/test/unit/rss/cache_spec.js new file mode 100644 index 0000000000..790e52bc13 --- /dev/null +++ b/core/test/unit/rss/cache_spec.js @@ -0,0 +1,57 @@ +var should = require('should'), + sinon = require('sinon'), + rewire = require('rewire'), + configUtils = require('../../utils/configUtils'), + rssCache = rewire('../../../server/data/xml/rss/cache'), + + sandbox = sinon.sandbox.create(); + +describe('RSS: Cache', function () { + var generateSpy, generateFeedReset; + + afterEach(function () { + configUtils.restore(); + sandbox.restore(); + generateFeedReset(); + }); + + beforeEach(function () { + configUtils.set({url: 'http://my-ghost-blog.com'}); + + generateSpy = sandbox.spy(rssCache.__get__('generateFeed')); + generateFeedReset = rssCache.__set__('generateFeed', generateSpy); + }); + + it('should not rebuild xml for same data and url', function (done) { + var xmlData1, + data = { + title: 'Test Title', + description: 'Testing Desc', + permalinks: '/:slug/', + results: {posts: [], meta: {pagination: {pages: 1}}} + }; + + rssCache.getXML('/rss/', data) + .then(function (_xmlData) { + xmlData1 = _xmlData; + + // We should have called generateFeed + generateSpy.callCount.should.eql(1); + + // Call RSS again to check that we didn't rebuild + return rssCache.getXML('/rss/', data); + }) + .then(function (xmlData2) { + // Assertions + + // We should not have called generateFeed again + generateSpy.callCount.should.eql(1); + + // The data should be identical, no changing lastBuildDate + xmlData1.should.equal(xmlData2); + + done(); + }) + .catch(done); + }); +}); diff --git a/core/test/unit/rss/controller_spec.js b/core/test/unit/rss/controller_spec.js new file mode 100644 index 0000000000..a69f0d4db5 --- /dev/null +++ b/core/test/unit/rss/controller_spec.js @@ -0,0 +1,361 @@ +var should = require('should'), + sinon = require('sinon'), + 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/data/xml/rss/controller'), + rssCache = require('../../../server/data/xml/rss/cache'), + configUtils = require('../../utils/configUtils'), + + sandbox = sinon.sandbox.create(); + +// Helper function to prevent unit tests +// from failing via timeout when they +// should just immediately fail +function failTest(done) { + return function (err) { + done(err); + }; +} + +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'}; + }); + }); + + beforeEach(function () { + // Minimum setup of req and res + req = { + params: {}, + originalUrl: '/rss/' + }; + + res = { + locals: { + safeVersion: '0.6', + channel: channelUtils.getTestChannel('index') + }, + set: sinon.stub(), + send: sinon.spy() + }; + + // @TODO Get rid of this! - shouldn't be set on the channel + res.locals.channel.isRSS = true; + + // Overwrite getData + getDataStub = sandbox.stub(); + + resetGetData = rssController.__set__('getData', getDataStub); + + rssCacheStub = sandbox.stub(rssCache, 'getXML').returns(new Promise.resolve('dummyxml')); + }); + + 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)); + }); + + 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, failTest(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, failTest(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, failTest(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(); + }); + }); + }); + + // 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; + + 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() + }; + + sandbox.stub(settingsCache, 'get', function (key) { + var obj = { + title: 'Test', + description: 'Some Text', + permalinks: '/:slug/' + }; + + return obj[key]; + }); + + configUtils.set({ + url: 'http://my-ghost-blog.com' + }); + }); + + afterEach(function () { + sandbox.restore(); + configUtils.restore(); + }); + + 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)); + }); + + 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)); + }); + + 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(); + }; + + rssController(req, res, failTest(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)); + }); + + 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(); + }; + + rssController(req, res, failTest(done)); + }); + }); +}); diff --git a/core/test/unit/rss/generate-feed_spec.js b/core/test/unit/rss/generate-feed_spec.js new file mode 100644 index 0000000000..b472a6762a --- /dev/null +++ b/core/test/unit/rss/generate-feed_spec.js @@ -0,0 +1,202 @@ +var should = require('should'), + _ = require('lodash'), + testUtils = require('../../utils'), + configUtils = require('../../utils/configUtils'), + + generateFeed = require('../../../server/data/xml/rss/generate-feed'); + +describe('RSS: Generate Feed', function () { + var data = {}, + // Static set of posts + posts; + + 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'}; + }); + }); + + afterEach(function () { + configUtils.restore(); + }); + + 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/'; + data.title = 'Test Title'; + data.description = 'Testing Desc'; + data.permalinks = '/:slug/'; + }); + + it('should get the RSS tags correct', function (done) { + data.results = {posts: [], meta: {pagination: {pages: 1}}}; + + generateFeed(data).then(function (xmlData) { + should.exist(xmlData); + + // xml & rss tags + xmlData.should.match(/^<\?xml version="1.0" encoding="UTF-8"\?>/); + xmlData.should.match(/<rss/); + xmlData.should.match(/xmlns:dc="http:\/\/purl.org\/dc\/elements\/1.1\/"/); + xmlData.should.match(/xmlns:content="http:\/\/purl.org\/rss\/1.0\/modules\/content\/"/); + xmlData.should.match(/xmlns:atom="http:\/\/www.w3.org\/2005\/Atom"/); + xmlData.should.match(/version="2.0"/); + xmlData.should.match(/xmlns:media="http:\/\/search.yahoo.com\/mrss\/"/); + + // channel tags + xmlData.should.match(/<channel><title><!\[CDATA\[Test Title\]\]><\/title>/); + xmlData.should.match(/<description><!\[CDATA\[Testing Desc\]\]><\/description>/); + xmlData.should.match(/<link>http:\/\/my-ghost-blog.com\/<\/link>/); + xmlData.should.match(/<image><url>http:\/\/my-ghost-blog.com\/favicon.png<\/url><title>Test Title<\/title><link>http:\/\/my-ghost-blog.com\/<\/link><\/image>/); + xmlData.should.match(/<generator>Ghost 0.6<\/generator>/); + xmlData.should.match(/<lastBuildDate>.*?<\/lastBuildDate>/); + xmlData.should.match(/<atom:link href="http:\/\/my-ghost-blog.com\/rss\/" rel="self"/); + xmlData.should.match(/type="application\/rss\+xml"\/><ttl>60<\/ttl>/); + xmlData.should.match(/<\/channel><\/rss>$/); + + done(); + }).catch(done); + }); + + it('should get the item tags correct', function (done) { + data.results = {posts: posts, meta: {pagination: {pages: 1}}}; + + generateFeed(data).then(function (xmlData) { + should.exist(xmlData); + + // item tags + xmlData.should.match(/<item><title><!\[CDATA\[HTML Ipsum\]\]><\/title>/); + xmlData.should.match(/<description><!\[CDATA\[<h1>HTML Ipsum Presents<\/h1>/); + xmlData.should.match(/<link>http:\/\/my-ghost-blog.com\/html-ipsum\/<\/link>/); + xmlData.should.match(/<image><url>http:\/\/my-ghost-blog.com\/favicon.png<\/url><title>Test Title<\/title><link>http:\/\/my-ghost-blog.com\/<\/link><\/image>/); + xmlData.should.match(/<guid isPermaLink="false">/); + xmlData.should.match(/<\/guid><dc:creator><!\[CDATA\[Joe Bloggs\]\]><\/dc:creator>/); + xmlData.should.match(/<pubDate>Thu, 01 Jan 2015/); + xmlData.should.match(/<content:encoded><!\[CDATA\[<h1>HTML Ipsum Presents<\/h1><p><strong>Pellentes/); + xmlData.should.match(/<\/code><\/pre>\]\]><\/content:encoded><\/item>/); + xmlData.should.not.match(/<author>/); + + // basic structure check + var postEnd = '<\/code><\/pre>\]\]><\/content:encoded>', + firstIndex = xmlData.indexOf(postEnd); + + // The first title should be before the first content + xmlData.indexOf('HTML Ipsum').should.be.below(firstIndex); + // The second title should be after the first content + xmlData.indexOf('Ghostly Kitchen Sink').should.be.above(firstIndex); + + done(); + }).catch(done); + }); + + it('should only return visible tags', function (done) { + var postWithTags = posts[2]; + postWithTags.tags = [ + {name: 'public', visibility: 'public'}, + {name: 'internal', visibility: 'internal'}, + {name: 'visibility'} + ]; + + data.results = {posts: [postWithTags], meta: {pagination: {pages: 1}}}; + + generateFeed(data).then(function (xmlData) { + should.exist(xmlData); + // item 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.match(/<category><!\[CDATA\[public\]\]/); + xmlData.should.match(/<category><!\[CDATA\[visibility\]\]/); + xmlData.should.not.match(/<category><!\[CDATA\[internal\]\]/); + done(); + }).catch(done); + }); + + it('should use meta_description and image where available', function (done) { + data.results = {posts: [posts[2]], meta: {pagination: {pages: 1}}}; + + generateFeed(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"\/>/); + + done(); + }).catch(done); + }); + + it('should use excerpt when no meta_description is set', function (done) { + data.results = {posts: [posts[0]], meta: {pagination: {pages: 1}}}; + + generateFeed(data).then(function (xmlData) { + should.exist(xmlData); + + // special/optional tags + xmlData.should.match(/<title><!\[CDATA\[HTML Ipsum\]\]>/); + xmlData.should.match(/<description><!\[CDATA\[This is my custom excerpt!/); + + done(); + }).catch(done); + }); + + it('should process urls correctly', function (done) { + data.results = {posts: [posts[3]], meta: {pagination: {pages: 1}}}; + + generateFeed(data).then(function (xmlData) { + should.exist(xmlData); + + // anchor URL - <a href="#nowhere" title="Anchor URL"> + xmlData.should.match(/<a href="#nowhere" title="Anchor URL">/); + + // relative URL - <a href="/about#nowhere" title="Relative URL"> + xmlData.should.match(/<a href="http:\/\/my-ghost-blog.com\/about#nowhere" title="Relative URL">/); + + // protocol relative URL - <a href="//somewhere.com/link#nowhere" title="Protocol Relative URL"> + xmlData.should.match(/<a href="\/\/somewhere.com\/link#nowhere" title="Protocol Relative URL">/); + + // absolute URL - <a href="http://somewhere.com/link#nowhere" title="Absolute URL"> + xmlData.should.match(/<a href="http:\/\/somewhere.com\/link#nowhere" title="Absolute URL">/); + + done(); + }).catch(done); + }); + + 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/'; + data.results = {posts: [posts[3]], meta: {pagination: {pages: 1}}}; + + generateFeed(data).then(function (xmlData) { + should.exist(xmlData); + + // anchor URL - <a href="#nowhere" title="Anchor URL"> + xmlData.should.match(/<a href="#nowhere" title="Anchor URL">/); + + // relative URL - <a href="/about#nowhere" title="Relative URL"> + xmlData.should.match(/<a href="http:\/\/my-ghost-blog.com\/blog\/about#nowhere" title="Relative URL">/); + + // absolute URL - <a href="http://somewhere.com/link#nowhere" title="Absolute URL"> + xmlData.should.match(/<a href="http:\/\/somewhere.com\/link#nowhere" title="Absolute URL">/); + + done(); + }).catch(done); + }); +}); diff --git a/core/test/unit/rss_spec.js b/core/test/unit/rss_spec.js deleted file mode 100644 index afa1b620db..0000000000 --- a/core/test/unit/rss_spec.js +++ /dev/null @@ -1,559 +0,0 @@ -var should = require('should'), - sinon = require('sinon'), - rewire = require('rewire'), - _ = require('lodash'), - Promise = require('bluebird'), - testUtils = require('../utils'), - channelUtils = require('../utils/channelUtils'), - api = require('../../server/api'), - settingsCache = require('../../server/settings/cache'), - rss = rewire('../../server/data/xml/rss'), - configUtils = require('../utils/configUtils'), - - sandbox = sinon.sandbox.create(); - -// Helper function to prevent unit tests -// from failing via timeout when they -// should just immediately fail -function failTest(done) { - return function (err) { - done(err); - }; -} - -describe('RSS', function () { - var req, res, posts; - - 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'}; - }); - }); - - afterEach(function () { - sandbox.restore(); - rss = rewire('../../server/data/xml/rss'); - configUtils.restore(); - }); - - describe('Check XML', function () { - beforeEach(function () { - req = { - params: {}, - originalUrl: '/rss/' - }; - - res = { - locals: { - safeVersion: '0.6' - }, - set: sinon.stub() - }; - - configUtils.set({url: 'http://my-ghost-blog.com'}); - }); - - it('should get the RSS tags correct', function (done) { - rss.__set__('getData', function () { - return Promise.resolve({ - title: 'Test Title', - description: 'Testing Desc', - permalinks: '/:slug/', - results: {posts: [], meta: {pagination: {pages: 1}}} - }); - }); - - res.send = function send(xmlData) { - should.exist(xmlData); - res.set.calledWith('Content-Type', 'text/xml; charset=UTF-8').should.be.true(); - - // xml & rss tags - xmlData.should.match(/^<\?xml version="1.0" encoding="UTF-8"\?>/); - xmlData.should.match(/<rss/); - xmlData.should.match(/xmlns:dc="http:\/\/purl.org\/dc\/elements\/1.1\/"/); - xmlData.should.match(/xmlns:content="http:\/\/purl.org\/rss\/1.0\/modules\/content\/"/); - xmlData.should.match(/xmlns:atom="http:\/\/www.w3.org\/2005\/Atom"/); - xmlData.should.match(/version="2.0"/); - xmlData.should.match(/xmlns:media="http:\/\/search.yahoo.com\/mrss\/"/); - - // channel tags - xmlData.should.match(/<channel><title><!\[CDATA\[Test Title\]\]><\/title>/); - xmlData.should.match(/<description><!\[CDATA\[Testing Desc\]\]><\/description>/); - xmlData.should.match(/<link>http:\/\/my-ghost-blog.com\/<\/link>/); - xmlData.should.match(/<image><url>http:\/\/my-ghost-blog.com\/favicon.png<\/url><title>Test Title<\/title><link>http:\/\/my-ghost-blog.com\/<\/link><\/image>/); - xmlData.should.match(/<generator>Ghost 0.6<\/generator>/); - xmlData.should.match(/<lastBuildDate>.*?<\/lastBuildDate>/); - xmlData.should.match(/<atom:link href="http:\/\/my-ghost-blog.com\/rss\/" rel="self"/); - xmlData.should.match(/type="application\/rss\+xml"\/><ttl>60<\/ttl>/); - xmlData.should.match(/<\/channel><\/rss>$/); - - done(); - }; - - res.locals.channel = channelUtils.getTestChannel('index'); - res.locals.channel.isRSS = true; - rss(req, res, failTest(done)); - }); - - it('should get the item tags correct', function (done) { - rss.__set__('getData', function () { - return Promise.resolve({ - title: 'Test Title', - description: 'Testing Desc', - permalinks: '/:slug/', - results: {posts: posts, meta: {pagination: {pages: 1}}} - }); - }); - - res.send = function send(xmlData) { - should.exist(xmlData); - - // item tags - xmlData.should.match(/<item><title><!\[CDATA\[HTML Ipsum\]\]><\/title>/); - xmlData.should.match(/<description><!\[CDATA\[<h1>HTML Ipsum Presents<\/h1>/); - xmlData.should.match(/<link>http:\/\/my-ghost-blog.com\/html-ipsum\/<\/link>/); - xmlData.should.match(/<image><url>http:\/\/my-ghost-blog.com\/favicon.png<\/url><title>Test Title<\/title><link>http:\/\/my-ghost-blog.com\/<\/link><\/image>/); - xmlData.should.match(/<guid isPermaLink="false">/); - xmlData.should.match(/<\/guid><dc:creator><!\[CDATA\[Joe Bloggs\]\]><\/dc:creator>/); - xmlData.should.match(/<pubDate>Thu, 01 Jan 2015/); - xmlData.should.match(/<content:encoded><!\[CDATA\[<h1>HTML Ipsum Presents<\/h1><p><strong>Pellentes/); - xmlData.should.match(/<\/code><\/pre>\]\]><\/content:encoded><\/item>/); - xmlData.should.not.match(/<author>/); - - // basic structure check - var postEnd = '<\/code><\/pre>\]\]><\/content:encoded>', - firstIndex = xmlData.indexOf(postEnd); - - // The first title should be before the first content - xmlData.indexOf('HTML Ipsum').should.be.below(firstIndex); - // The second title should be after the first content - xmlData.indexOf('Ghostly Kitchen Sink').should.be.above(firstIndex); - - // done - done(); - }; - - res.locals.channel = channelUtils.getTestChannel('index'); - res.locals.channel.isRSS = true; - rss(req, res, failTest(done)); - }); - - it('should only return visible tags', function (done) { - var postWithTags = posts[2]; - postWithTags.tags = [ - {name: 'public', visibility: 'public'}, - {name: 'internal', visibility: 'internal'}, - {name: 'visibility'} - ]; - - rss.__set__('getData', function () { - return Promise.resolve({ - title: 'Test Title', - description: 'Testing Desc', - permalinks: '/:slug/', - results: {posts: [postWithTags], meta: {pagination: {pages: 1}}} - }); - }); - - res.send = function send(xmlData) { - should.exist(xmlData); - // item 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.match(/<category><!\[CDATA\[public\]\]/); - xmlData.should.match(/<category><!\[CDATA\[visibility\]\]/); - xmlData.should.not.match(/<category><!\[CDATA\[internal\]\]/); - done(); - }; - - res.locals.channel = channelUtils.getTestChannel('index'); - res.locals.channel.isRSS = true; - rss(req, res, failTest(done)); - }); - - it('should use meta_description and image where available', function (done) { - rss.__set__('getData', function () { - return Promise.resolve({ - title: 'Test Title', - description: 'Testing Desc', - permalinks: '/:slug/', - results: {posts: [posts[2]], meta: {pagination: {pages: 1}}} - }); - }); - - res.send = function send(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"\/>/); - - done(); - }; - - res.locals.channel = channelUtils.getTestChannel('index'); - res.locals.channel.isRSS = true; - rss(req, res, failTest(done)); - }); - - it('should use excerpt when no meta_description is set', function (done) { - rss.__set__('getData', function () { - return Promise.resolve({ - title: 'Test Title', - description: 'Testing Desc', - permalinks: '/:slug/', - results: {posts: [posts[0]], meta: {pagination: {pages: 1}}} - }); - }); - - res.send = function send(xmlData) { - should.exist(xmlData); - - // special/optional tags - xmlData.should.match(/<title><!\[CDATA\[HTML Ipsum\]\]>/); - xmlData.should.match(/<description><!\[CDATA\[This is my custom excerpt!/); - - done(); - }; - - res.locals.channel = channelUtils.getTestChannel('index'); - res.locals.channel.isRSS = true; - rss(req, res, failTest(done)); - }); - - it('should process urls correctly', function (done) { - rss.__set__('getData', function () { - return Promise.resolve({ - title: 'Test Title', - description: 'Testing Desc', - permalinks: '/:slug/', - results: {posts: [posts[3]], meta: {pagination: {pages: 1}}} - }); - }); - - res.send = function send(xmlData) { - should.exist(xmlData); - - // anchor URL - <a href="#nowhere" title="Anchor URL"> - xmlData.should.match(/<a href="#nowhere" title="Anchor URL">/); - - // relative URL - <a href="/about#nowhere" title="Relative URL"> - xmlData.should.match(/<a href="http:\/\/my-ghost-blog.com\/about#nowhere" title="Relative URL">/); - - // protocol relative URL - <a href="//somewhere.com/link#nowhere" title="Protocol Relative URL"> - xmlData.should.match(/<a href="\/\/somewhere.com\/link#nowhere" title="Protocol Relative URL">/); - - // absolute URL - <a href="http://somewhere.com/link#nowhere" title="Absolute URL"> - xmlData.should.match(/<a href="http:\/\/somewhere.com\/link#nowhere" title="Absolute URL">/); - - // done - done(); - }; - - res.locals.channel = channelUtils.getTestChannel('index'); - res.locals.channel.isRSS = true; - rss(req, res, failTest(done)); - }); - - it('should process urls correctly with subdirectory', function (done) { - configUtils.set({url: 'http://my-ghost-blog.com/blog/'}); - rss.__set__('getData', function () { - return Promise.resolve({ - title: 'Test Title', - description: 'Testing Desc', - permalinks: '/:slug/', - results: {posts: [posts[3]], meta: {pagination: {pages: 1}}} - }); - }); - - res.send = function send(xmlData) { - should.exist(xmlData); - - // anchor URL - <a href="#nowhere" title="Anchor URL"> - xmlData.should.match(/<a href="#nowhere" title="Anchor URL">/); - - // relative URL - <a href="/about#nowhere" title="Relative URL"> - xmlData.should.match(/<a href="http:\/\/my-ghost-blog.com\/blog\/about#nowhere" title="Relative URL">/); - - // absolute URL - <a href="http://somewhere.com/link#nowhere" title="Absolute URL"> - xmlData.should.match(/<a href="http:\/\/somewhere.com\/link#nowhere" title="Absolute URL">/); - - // done - done(); - }; - - res.locals.channel = channelUtils.getTestChannel('index'); - res.locals.channel.isRSS = true; - rss(req, res, failTest(done)); - }); - }); - - describe('dataBuilder', function () { - var apiBrowseStub, apiTagStub, apiUserStub; - - 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() - }; - - sandbox.stub(settingsCache, 'get', function (key) { - var obj = { - title: 'Test', - description: 'Some Text', - permalinks: '/:slug/' - }; - - return obj[key]; - }); - - configUtils.set({ - url: 'http://my-ghost-blog.com' - }); - }); - - it('should process the data correctly for the index feed', function (done) { - res.send = function send(xmlData) { - apiBrowseStub.calledOnce.should.be.true(); - apiBrowseStub.calledWith({page: 1, include: 'author,tags'}).should.be.true(); - xmlData.should.match(/<channel><title><!\[CDATA\[Test\]\]><\/title>/); - done(); - }; - - res.locals.channel = channelUtils.getTestChannel('index'); - res.locals.channel.isRSS = true; - rss(req, res, failTest(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(); - }; - - rss(req, res, failTest(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(); - }; - - rss(req, res, failTest(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(); - }; - - rss(req, res, failTest(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(); - }; - - rss(req, res, failTest(done)); - }); - }); - - describe('caching', function () { - beforeEach(function () { - req = { - params: {}, - originalUrl: '/rss/' - }; - - res = { - locals: { - safeVersion: '0.6' - }, - set: sinon.stub() - }; - - configUtils.set({url: 'http://my-ghost-blog.com'}); - }); - - it('should not rebuild xml for same data and url', function (done) { - var xmlData; - rss.__set__('getData', function () { - return Promise.resolve({ - title: 'Test Title', - description: 'Testing Desc', - permalinks: '/:slug/', - results: {posts: [], meta: {pagination: {pages: 1}}} - }); - }); - res.locals.channel = channelUtils.getTestChannel('index'); - res.locals.channel.isRSS = true; - - function secondCall() { - res.send = function sendFirst(data) { - // The data should be identical, no changing lastBuildDate - data.should.equal(xmlData); - - // Now call done! - done(); - }; - - rss(req, res, failTest(done)); - } - - function firstCall() { - res.send = function sendFirst(data) { - xmlData = data; - - // Call RSS again to check that we didn't rebuild - secondCall(); - }; - - rss(req, res, failTest(done)); - } - - firstCall(); - }); - }); - - describe('pagination', function () { - beforeEach(function () { - res = { - locals: {version: ''}, - redirect: sandbox.spy(), - render: sandbox.spy() - }; - - rss.__set__('getData', function () { - return Promise.resolve({ - title: 'Test', - description: 'Testing', - permalinks: '/:slug/', - results: {posts: [], meta: {pagination: {pages: 3}}} - }); - }); - }); - - it('Should 404 if page number too big', function (done) { - configUtils.set({url: 'http://localhost:82832/'}); - - req = {params: {page: 4}, route: {path: '/rss/:page/'}}; - req.originalUrl = req.route.path.replace(':page', req.params.page); - res.locals.channel = channelUtils.getTestChannel('index'); - res.locals.channel.isRSS = true; - - rss(req, res, function (err) { - should.exist(err); - err.statusCode.should.eql(404); - res.redirect.called.should.be.false(); - res.render.called.should.be.false(); - done(); - }).catch(done); - }); - - it('Redirects to last page if page number too big with subdirectory', function (done) { - configUtils.set({url: 'http://localhost:82832/blog'}); - - req = {params: {page: 4}, route: {path: '/rss/:page/'}}; - req.originalUrl = req.route.path.replace(':page', req.params.page); - res.locals.channel = channelUtils.getTestChannel('index'); - res.locals.channel.isRSS = true; - - rss(req, res, function (err) { - should.exist(err); - err.statusCode.should.eql(404); - res.redirect.called.should.be.false(); - res.render.called.should.be.false(); - done(); - }).catch(done); - }); - }); -});