From 311886b316664dfad6019b6e0a0d76d173fd864f Mon Sep 17 00:00:00 2001 From: Vishvamsinh Vaghela <90895835+vishvamsinh28@users.noreply.github.com> Date: Sat, 9 Nov 2024 12:29:51 +0530 Subject: [PATCH] feat: added test for build-rss.js (#3101) Co-authored-by: Akshat Nema <76521428+akshatnema@users.noreply.github.com>%0ACo-authored-by: Ansh Goyal %0ACo-authored-by: asyncapi-bot --- package-lock.json | 29 ++++++++ package.json | 3 +- scripts/build-rss.js | 128 ++++++++++++++++++++------------- tests/build-rss.test.js | 147 ++++++++++++++++++++++++++++++++++++++ tests/fixtures/rssData.js | 93 ++++++++++++++++++++++++ 5 files changed, 349 insertions(+), 51 deletions(-) create mode 100644 tests/build-rss.test.js create mode 100644 tests/fixtures/rssData.js diff --git a/package-lock.json b/package-lock.json index 837177c7acf7..afff3d9c026d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "clsx": "^2.1.0", "cssnano": "^6.0.3", "dotenv": "^16.4.4", + "fast-xml-parser": "^4.5.0", "fs-extra": "^11.2.0", "fuse.js": "^7.0.0", "googleapis": "^133.0.0", @@ -13859,6 +13860,28 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-xml-parser": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz", + "integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -27471,6 +27494,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", + "license": "MIT" + }, "node_modules/style-loader": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", diff --git a/package.json b/package.json index 3885874ae365..84f538697d4d 100644 --- a/package.json +++ b/package.json @@ -157,6 +157,7 @@ "remark-cli": "^12.0.1", "remark-lint": "^10.0.0", "remark-mdx": "^3.0.1", - "storybook": "^8.2.4" + "storybook": "^8.2.4", + "fast-xml-parser": "^4.5.0" } } diff --git a/scripts/build-rss.js b/scripts/build-rss.js index a5461f5e0baf..673da1398fe0 100644 --- a/scripts/build-rss.js +++ b/scripts/build-rss.js @@ -1,8 +1,8 @@ -const fs = require('fs') +const fs = require('fs').promises const json2xml = require('jgexml/json2xml') function getAllPosts() { - return require('../config/posts.json') + return require('../config/posts.json'); } function clean(s) { @@ -15,61 +15,89 @@ function clean(s) { return s } -module.exports = function rssFeed(type, title, desc, outputPath) { +module.exports = async function rssFeed(type, title, desc, outputPath) { + try { - const posts = getAllPosts()[`${type}`] - .sort((i1, i2) => { - const i1Date = new Date(i1.date) - const i2Date = new Date(i2.date) + let posts = getAllPosts()[`${type}`] + const missingDatePosts = posts.filter(post => !post.date); + posts = posts.filter(post => post.date); + posts.sort((i1, i2) => { + const i1Date = new Date(i1.date); + const i2Date = new Date(i2.date); + if (i1.featured && !i2.featured) return -1; + if (!i1.featured && i2.featured) return 1; + return i2Date - i1Date; + }); - if (i1.featured && !i2.featured) return -1 - if (!i1.featured && i2.featured) return 1 - return i2Date - i1Date - }) + if (missingDatePosts.length > 0) { + throw new Error(`Missing date in posts: ${missingDatePosts.map(p => p.title || p.slug).join(', ')}`); + } + + const base = 'https://www.asyncapi.com' + const tracking = '?utm_source=rss'; + + const feed = {} + const rss = {} + rss['@version'] = '2.0' + rss["@xmlns:atom"] = 'http://www.w3.org/2005/Atom' + rss.channel = {} + rss.channel.title = title + rss.channel.link = `${base}/${outputPath}` + rss.channel["atom:link"] = {} + rss.channel["atom:link"]["@rel"] = 'self' + rss.channel["atom:link"]["@href"] = rss.channel.link + rss.channel["atom:link"]["@type"] = 'application/rss+xml' + rss.channel.description = desc + rss.channel.language = 'en-gb'; + rss.channel.copyright = 'Made with :love: by the AsyncAPI Initiative.'; + rss.channel.webMaster = 'info@asyncapi.io (AsyncAPI Initiative)' + rss.channel.pubDate = new Date().toUTCString() + rss.channel.generator = 'next.js' + rss.channel.item = [] - const base = 'https://www.asyncapi.com' - const tracking = '?utm_source=rss'; + const invalidPosts = posts.filter(post => + !post.title || !post.slug || !post.excerpt || !post.date + ); - const feed = {} - const rss = {} - rss['@version'] = '2.0' - rss["@xmlns:atom"] = 'http://www.w3.org/2005/Atom' - rss.channel = {} - rss.channel.title = title - rss.channel.link = `${base}/${outputPath}` - rss.channel["atom:link"] = {} - rss.channel["atom:link"]["@rel"] = 'self' - rss.channel["atom:link"]["@href"] = rss.channel.link - rss.channel["atom:link"]["@type"] = 'application/rss+xml' - rss.channel.description = desc - rss.channel.language = 'en-gb'; - rss.channel.copyright = 'Made with :love: by the AsyncAPI Initiative.'; - rss.channel.webMaster = 'info@asyncapi.io (AsyncAPI Initiative)' - rss.channel.pubDate = new Date().toUTCString() - rss.channel.generator = 'next.js' - rss.channel.item = [] + if (invalidPosts.length > 0) { + throw new Error(`Missing required fields in posts: ${invalidPosts.map(p => p.title || p.slug).join(', ')}`); + } - for (let post of posts) { - const link = `${base}${post.slug}${tracking}`; - const item = { title: post.title, description: clean(post.excerpt), link, category: type, guid: { '@isPermaLink': true, '': link }, pubDate: new Date(post.date).toUTCString() } - if (post.cover) { - const enclosure = {}; - enclosure["@url"] = base+post.cover; - enclosure["@length"] = 15026; // dummy value, anything works - enclosure["@type"] = 'image/jpeg'; - if (typeof enclosure["@url"] === 'string') { - let tmp = enclosure["@url"].toLowerCase(); - if (tmp.indexOf('.png')>=0) enclosure["@type"] = 'image/png'; - if (tmp.indexOf('.svg')>=0) enclosure["@type"] = 'image/svg+xml'; - if (tmp.indexOf('.webp')>=0) enclosure["@type"] = 'image/webp'; + for (let post of posts) { + const link = `${base}${post.slug}${tracking}`; + const { title, excerpt, date } = post; + const pubDate = new Date(date).toUTCString(); + const description = clean(excerpt); + const guid = { '@isPermaLink': true, '': link }; + const item = { + title, + description, + link, + category: type, + guid, + pubDate + }; + if (post.cover) { + const enclosure = {}; + enclosure["@url"] = base + post.cover; + enclosure["@length"] = 15026; // dummy value, anything works + enclosure["@type"] = 'image/jpeg'; + if (typeof enclosure["@url"] === 'string') { + let tmp = enclosure["@url"].toLowerCase(); + if (tmp.indexOf('.png') >= 0) enclosure["@type"] = 'image/png'; + if (tmp.indexOf('.svg') >= 0) enclosure["@type"] = 'image/svg+xml'; + if (tmp.indexOf('.webp') >= 0) enclosure["@type"] = 'image/webp'; + } + item.enclosure = enclosure; } - item.enclosure = enclosure; + rss.channel.item.push(item) } - rss.channel.item.push(item) - } - feed.rss = rss + feed.rss = rss - const xml = json2xml.getXml(feed,'@','',2) - fs.writeFileSync(`./public/${outputPath}`, xml, 'utf8') + const xml = json2xml.getXml(feed, '@', '', 2); + await fs.writeFile(`./public/${outputPath}`, xml, 'utf8'); + } catch (err) { + throw new Error(`Failed to generate RSS feed: ${err.message}`); + } }; diff --git a/tests/build-rss.test.js b/tests/build-rss.test.js new file mode 100644 index 000000000000..7961740fe5c6 --- /dev/null +++ b/tests/build-rss.test.js @@ -0,0 +1,147 @@ +const fs = require('fs'); +const path = require('path'); +const rssFeed = require('../scripts/build-rss'); +const { XMLParser } = require('fast-xml-parser'); +const parser = new XMLParser({ ignoreAttributes: false }); +const { mockRssData, title, type, desc, missingDateMockData, incompletePostMockData } = require('./fixtures/rssData'); + +describe('rssFeed', () => { + const testOutputDir = path.join(__dirname, '..', 'public', 'test-output'); + const outputPath = 'test-output/rss.xml'; + + beforeAll(async () => { + try { + await fs.promises.mkdir(testOutputDir, { recursive: true }); + } catch (err) { + throw new Error(`Error while creating temp dir: ${err.message}`); + } + }); + + afterAll(async () => { + try { + const files = await fs.promises.readdir(testOutputDir); + await Promise.all(files.map(file => fs.promises.unlink(path.join(testOutputDir, file)))); + await fs.promises.rmdir(testOutputDir); + } catch (err) { + throw new Error(`Error while deleting temp dir: ${err.message}`); + } + }); + + afterEach(() => { + jest.resetModules(); + }); + + it('should generate RSS feed and write to file', async () => { + + jest.doMock('../config/posts.json', () => mockRssData, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).resolves.toBeUndefined() + + const filePath = path.join(__dirname, '..', 'public', outputPath); + expect(fs.existsSync(filePath)).toBe(true); + const fileContent = fs.readFileSync(filePath, 'utf8'); + expect(fileContent).toContain(' { + jest.doMock('../config/posts.json', () => mockRssData, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).resolves.toBeUndefined(); + + const filePath = path.join(__dirname, '..', 'public', outputPath); + const fileContent = fs.readFileSync(filePath, 'utf8'); + + const parsedContent = parser.parse(fileContent); + const itemTitles = parsedContent.rss.channel.item.map(item => item.title); + + expect(itemTitles[0]).toBe('Test Post 1'); + expect(itemTitles[1]).toBe('Another Featured Post'); + + expect(itemTitles[2]).toBe('Post with Special Characters: & < > "'); + expect(itemTitles[3]).toBe('Post with UTC Date Format'); + expect(itemTitles[4]).toBe('Non-Featured Post 1'); + expect(itemTitles[5]).toBe('Non-Featured Post 3'); + }); + + it('should sort posts by date in descending order', async () => { + jest.doMock('../config/posts.json', () => mockRssData, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).resolves.toBeUndefined(); + + const filePath = path.join(__dirname, '..', 'public', outputPath); + const fileContent = fs.readFileSync(filePath, 'utf8'); + + const parsedContent = parser.parse(fileContent); + const itemTitles = parsedContent.rss.channel.item.map(item => item.title); + + expect(itemTitles[0]).toBe('Test Post 1'); + expect(itemTitles[1]).toBe('Another Featured Post') + expect(itemTitles[2]).toBe('Post with Special Characters: & < > "'); + expect(itemTitles[3]).toBe('Post with UTC Date Format'); + expect(itemTitles[4]).toBe('Non-Featured Post 1'); + expect(itemTitles[5]).toBe('Non-Featured Post 3'); + }); + + it('should set correct enclosure type based on image extension', async () => { + jest.doMock('../config/posts.json', () => mockRssData, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).resolves.toBeUndefined() + + const filePath = path.join(__dirname, '..', 'public', outputPath); + const fileContent = fs.readFileSync(filePath, 'utf8'); + + expect(fileContent).toContain(' { + jest.doMock('../config/posts.json', () => mockRssData, { virtual: true }); + + const invalidOutputPath = "invalid/path"; + + await expect(rssFeed(type, title, desc, invalidOutputPath)).rejects.toThrow(/ENOENT|EACCES/); + + }); + + it('should throw an error when posts.json is malformed', async () => { + jest.doMock('../config/posts.json', () => { + return { invalidKey: [] }; + }, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).rejects.toThrow('Failed to generate RSS feed'); + + }); + + it('should handle empty posts array', async () => { + const emptyMockData = { blog: [] }; + jest.doMock('../config/posts.json', () => emptyMockData, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).resolves.toBeUndefined() + + const filePath = path.join(__dirname, '..', 'public', outputPath); + const fileContent = fs.readFileSync(filePath, 'utf8'); + expect(fileContent).toContain(''); + }); + + it('should throw an error when post is missing required fields', async () => { + + jest.doMock('../config/posts.json', () => incompletePostMockData, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).rejects.toThrow('Missing required fields'); + + }); + + it('should throw an error when a post is missing a date field during sorting', async () => { + + jest.doMock('../config/posts.json', () => missingDateMockData, { virtual: true }); + + await expect(rssFeed(type, title, desc, outputPath)).rejects.toThrow('Failed to generate RSS feed: Missing date in posts: Post without Date'); + + }); + +}); diff --git a/tests/fixtures/rssData.js b/tests/fixtures/rssData.js new file mode 100644 index 000000000000..89717784e51b --- /dev/null +++ b/tests/fixtures/rssData.js @@ -0,0 +1,93 @@ +const mockRssData = { + blog: [ + { + title: 'Non-Featured Post 1', + slug: '/blog/non-featured-post-1', + excerpt: 'This is a non-featured post', + date: '2023-07-05', + featured: false, + }, + { + title: 'Test Post 1', + slug: '/blog/test-post-1', + excerpt: 'This is a featured test post', + date: '2023-07-07', + featured: true, + cover: '/img/test-cover.jpg', + }, + { + title: 'Another Featured Post', + slug: '/blog/another-featured-post', + excerpt: 'This is another featured post', + date: '2023-07-06', + featured: true, + cover: '/img/test-cover.svg', + }, + { + title: 'Non-Featured Post 2', + slug: '/blog/non-featured-post-2', + excerpt: 'This is another non-featured post', + date: '2023-07-03', + featured: false, + cover: '/img/test-cover.webp', + }, + { + title: 'Non-Featured Post 3', + slug: '/blog/non-featured-post-3', + excerpt: 'This is yet another non-featured post', + date: '2023-07-04', + featured: false, + cover: '/img/test-cover.png', + }, + { + title: 'Post with Special Characters: & < > "', + slug: '/blog/special-chars', + excerpt: 'Testing HTML entities & encoding', + date: '2023-07-06T12:00:00Z', + featured: false, + }, + { + title: 'Post with UTC Date Format', + slug: '/blog/utc-date-format', + excerpt: 'This post uses a UTC date format', + date: 'Wed, 05 Jul 2023 12:00:00 GMT', + featured: false, + }, + ], +}; + +const missingDateMockData = { + blog: [ + { + title: 'Post without Date', + slug: '/blog/no-date-post', + excerpt: 'This post is missing a date', + featured: false, + }, + { + title: 'Valid Post', + slug: '/blog/valid-post', + excerpt: 'This post has a valid date', + date: '2024-07-05', + featured: true, + }, + ], +}; + +const incompletePostMockData = { + blog: [ + { + slug: '/blog/incomplete-post', + excerpt: 'This post is incomplete', + date: '2024-07-05', + featured: false, + }, + ], +}; + +const type = 'blog'; +const title = 'Test Blog RSS'; +const desc = 'Test blog RSS feed'; +const outputPath = 'test-output/blog.xml'; + +module.exports = { mockRssData, title, type, desc, outputPath, missingDateMockData, incompletePostMockData };