Skip to content

Commit

Permalink
Add main tests for atom feeds
Browse files Browse the repository at this point in the history
  • Loading branch information
myl7 committed Apr 22, 2024
1 parent 448971c commit 8cd29aa
Show file tree
Hide file tree
Showing 4 changed files with 351 additions and 13 deletions.
21 changes: 10 additions & 11 deletions packages/astro-atom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,27 +185,26 @@ async function generateAtom(atomOptions: ValidatedAtomOptions): Promise<string>
...(isXSL && { '@_type': 'text/xsl' }),
};
}
root.feed = { '@_version': '2.0' };

// xmlns
const XMLNamespace = 'http://www.w3.org/2005/Atom';
root.feed['@_xmlns'] = XMLNamespace;
root.feed = { '@_xmlns': XMLNamespace };
if (atomOptions.xmlns) {
for (const [k, v] of Object.entries(atomOptions.xmlns)) {
root.rss[`@_xmlns:${k}`] = v;
root.feed[`@_xmlns:${k}`] = v;
}
}

// title, description, customData
(root.feed.title = atomOptions.title),
(root.feed.subtitle = atomOptions.subtitle),
(root.feed.link = {
'@_href': createCanonicalURL(site, atomOptions.trailingSlash, undefined).href,
});
root.feed.title = atomOptions.title;
root.feed.subtitle = atomOptions.subtitle;
root.feed.link = {
'@_href': createCanonicalURL(site, atomOptions.trailingSlash, undefined).href,
};
if (typeof atomOptions.customData === 'string')
Object.assign(root.rss.feed, parser.parse(`<feed>${atomOptions.customData}</feed>`).feed);
Object.assign(root.feed, parser.parse(`<feed>${atomOptions.customData}</feed>`).feed);
// entris
root.rss.channel.entry = entries.map((result) => {
root.feed.entry = entries.map((result) => {
const entry: Record<string, unknown> & { link: any[] } = { link: [] };

if (result.title) {
Expand Down Expand Up @@ -245,7 +244,7 @@ async function generateAtom(atomOptions: ValidatedAtomOptions): Promise<string>
}
if (result.source) {
// TODO: Source object
entry.source = { title: result.source.title, link: { href: result.source.url } };
entry.source = { title: result.source.title, link: { '@_href': result.source.url } };
}
if (result.enclosure) {
const enclosureURL = isValidURL(result.enclosure.url)
Expand Down
4 changes: 2 additions & 2 deletions packages/astro-atom/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export const atomSchema = z.object({
published: z
.union([z.string(), z.number(), z.date()])
.optional()
.transform((value) => (value === undefined ? value : new Date(value)))
.refine((value) => (value === undefined ? value : !isNaN(value.getTime()))),
.transform((value) => (value !== undefined ? new Date(value) : undefined))
.refine((value) => (value ? !isNaN(value.getTime()) : true)),
updated: z
.union([z.string(), z.number(), z.date()])
.optional()
Expand Down
270 changes: 270 additions & 0 deletions packages/astro-atom/test/atom.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

import { z } from 'astro/zod';
import atom, { getAtomString } from '../dist/index.js';
import { atomSchema } from '../dist/schema.js';
import {
subtitle,
parseXmlString,
phpFeedEntry,
phpFeedEntryWithContent,
phpFeedEntryWithCustomData,
site,
title,
web1FeedEntry,
web1FeedEntryWithAllData,
web1FeedEntryWithContent,
} from './test-utils.js';

// note: I spent 30 minutes looking for a nice node-based snapshot tool
// ...and I gave up. Enjoy big strings!

// biome-ignore format: keep in one line
const validXmlResult = `<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title><![CDATA[${title}]]></title><subtitle><![CDATA[${subtitle}]]></subtitle><link href="${site}/" /><entry><title><![CDATA[${phpFeedEntry.title}]]></title><link href="${site}${phpFeedEntry.link}/" /><id>${site}${phpFeedEntry.link}/</id><summary><![CDATA[${phpFeedEntry.summary}]]></summary><updated>${new Date(phpFeedEntry.updated).toUTCString()}</updated></entry><entry><title><![CDATA[${web1FeedEntry.title}]]></title><link href="${site}${web1FeedEntry.link}/" /><id>${site}${web1FeedEntry.link}/</id><summary><![CDATA[${web1FeedEntry.summary}]]></summary><updated>${new Date(web1FeedEntry.updated).toUTCString()}</updated></entry></feed>`;
// biome-ignore format: keep in one line
const validXmlWithContentResult = `<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title><![CDATA[${title}]]></title><subtitle><![CDATA[${subtitle}]]></subtitle><link href="${site}/" /><entry><title><![CDATA[${phpFeedEntryWithContent.title}]]></title><link href="${site}${phpFeedEntryWithContent.link}/" /><id>${site}${phpFeedEntryWithContent.link}/</id><summary><![CDATA[${phpFeedEntryWithContent.summary}]]></summary><updated>${new Date(phpFeedEntryWithContent.updated).toUTCString()}</updated><content type="html"><![CDATA[${phpFeedEntryWithContent.content}]]></content></entry><entry><title><![CDATA[${web1FeedEntryWithContent.title}]]></title><link href="${site}${web1FeedEntryWithContent.link}/" /><id>${site}${web1FeedEntryWithContent.link}/</id><summary><![CDATA[${web1FeedEntryWithContent.summary}]]></summary><updated>${new Date(web1FeedEntryWithContent.updated).toUTCString()}</updated><content type="html"><![CDATA[${web1FeedEntryWithContent.content}]]></content></entry></feed>`;
// biome-ignore format: keep in one line
const validXmlResultWithAllData = `<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title><![CDATA[${title}]]></title><subtitle><![CDATA[${subtitle}]]></subtitle><link href="${site}/" /><entry><title><![CDATA[${phpFeedEntry.title}]]></title><link href="${site}${phpFeedEntry.link}/" /><id>${site}${phpFeedEntry.link}/</id><summary><![CDATA[${phpFeedEntry.summary}]]></summary><updated>${new Date(phpFeedEntry.updated).toUTCString()}</updated></entry><entry><title><![CDATA[${web1FeedEntryWithAllData.title}]]></title><link href="${site}${web1FeedEntryWithAllData.link}/" /><id>${site}${web1FeedEntryWithAllData.link}/</id><summary><![CDATA[${web1FeedEntryWithAllData.summary}]]></summary><updated>${new Date(web1FeedEntryWithAllData.updated).toUTCString()}</updated><category term="${web1FeedEntryWithAllData.categories[0]}" /><category term="${web1FeedEntryWithAllData.categories[1]}" /><author><name>${web1FeedEntryWithAllData.author.name}</name><email>${web1FeedEntryWithAllData.author.email}</email></author><source><title>${web1FeedEntryWithAllData.source.title}</title><link href="${web1FeedEntryWithAllData.source.url}" /></source><link rel="enclosure" href="${site}${web1FeedEntryWithAllData.enclosure.url}" length="${web1FeedEntryWithAllData.enclosure.length}" type="${web1FeedEntryWithAllData.enclosure.type}"/></entry></feed>`;
// biome-ignore format: keep in one line
const validXmlWithCustomDataResult = `<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"><title><![CDATA[${title}]]></title><subtitle><![CDATA[${subtitle}]]></subtitle><link href="${site}/" /><entry><title><![CDATA[${phpFeedEntryWithCustomData.title}]]></title><link href="${site}${phpFeedEntryWithCustomData.link}/" /><id>${site}${phpFeedEntryWithCustomData.link}/</id><summary><![CDATA[${phpFeedEntryWithCustomData.summary}]]></summary><updated>${new Date(phpFeedEntryWithCustomData.updated).toUTCString()}</updated>${phpFeedEntryWithCustomData.customData}</entry><entry><title><![CDATA[${web1FeedEntryWithContent.title}]]></title><link href="${site}${web1FeedEntryWithContent.link}/" /><id>${site}${web1FeedEntryWithContent.link}/</id><summary><![CDATA[${web1FeedEntryWithContent.summary}]]></summary><updated>${new Date(web1FeedEntryWithContent.updated).toUTCString()}</updated><content type="html"><![CDATA[${web1FeedEntryWithContent.content}]]></content></entry></feed>`;
// biome-ignore format: keep in one line
const validXmlWithStylesheet = `<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/feedstylesheet.css"?><feed xmlns="http://www.w3.org/2005/Atom"><title><![CDATA[${title}]]></title><subtitle><![CDATA[${subtitle}]]></subtitle><link href="${site}/" /></feed>`;
// biome-ignore format: keep in one line
const validXmlWithXSLStylesheet = `<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/feedstylesheet.xsl" type="text/xsl"?><feed xmlns="http://www.w3.org/2005/Atom"><title><![CDATA[${title}]]></title><subtitle><![CDATA[${subtitle}]]></subtitle><link href="${site}/" /></feed>`;

function assertXmlDeepEqual(a, b) {
const parsedA = parseXmlString(a);
const parsedB = parseXmlString(b);

assert.equal(parsedA.err, null);
assert.equal(parsedB.err, null);
assert.deepEqual(parsedA.result, parsedB.result);
}

describe('atom', () => {
it('should return a response', async () => {
const response = await atom({
title,
subtitle,
entries: [phpFeedEntry, web1FeedEntry],
site,
});

const str = await response.text();

// NOTE: Chai used the below parser to perform the tests, but I have omitted it for now.
// parser = new xml2js.Parser({ trim: flag(this, 'deep') });

assertXmlDeepEqual(str, validXmlResult);

const contentType = response.headers.get('Content-Type');
assert.equal(contentType, 'application/xml');
});

it('should be the same string as getAtomString', async () => {
const options = {
title,
subtitle,
entries: [phpFeedEntry, web1FeedEntry],
site,
};

const response = await atom(options);
const str1 = await response.text();
const str2 = await getAtomString(options);

assert.equal(str1, str2);
});
});

describe('getAtomString', () => {
it('should generate on valid AtomFeedEntry array', async () => {
const str = await getAtomString({
title,
subtitle,
entries: [phpFeedEntry, web1FeedEntry],
site,
});

assertXmlDeepEqual(str, validXmlResult);
});

it('should generate on valid AtomFeedEntry array with HTML content included', async () => {
const str = await getAtomString({
title,
subtitle,
entries: [phpFeedEntryWithContent, web1FeedEntryWithContent],
site,
});

assertXmlDeepEqual(str, validXmlWithContentResult);
});

it('should generate on valid AtomFeedEntry array with all Atom content included', async () => {
const str = await getAtomString({
title,
subtitle,
entries: [phpFeedEntry, web1FeedEntryWithAllData],
site,
});

assertXmlDeepEqual(str, validXmlResultWithAllData);
});

it('should generate on valid AtomFeedEntry array with custom data included', async () => {
const str = await getAtomString({
xmlns: {
dc: 'http://purl.org/dc/elements/1.1/',
},
title,
subtitle,
entries: [phpFeedEntryWithCustomData, web1FeedEntryWithContent],
site,
});

assertXmlDeepEqual(str, validXmlWithCustomDataResult);
});

it('should include xml-stylesheet instruction when stylesheet is defined', async () => {
const str = await getAtomString({
title,
subtitle,
entries: [],
site,
stylesheet: '/feedstylesheet.css',
});

assertXmlDeepEqual(str, validXmlWithStylesheet);
});

it('should include xml-stylesheet instruction with xsl type when stylesheet is set to xsl file', async () => {
const str = await getAtomString({
title,
subtitle,
entries: [],
site,
stylesheet: '/feedstylesheet.xsl',
});

assertXmlDeepEqual(str, validXmlWithXSLStylesheet);
});

it('should preserve self-closing tags on `customData`', async () => {
const customData =
'<atom:link href="https://example.com/feed.xml" rel="self" type="application/rss+xml"/>';
const str = await getAtomString({
title,
subtitle,
entries: [],
site,
xmlns: {
atom: 'http://www.w3.org/2005/Atom',
},
customData,
});

assert.ok(str.includes(customData));
});

it('should not append trailing slash to URLs with the given option', async () => {
const str = await getAtomString({
title,
subtitle,
entries: [phpFeedEntry],
site,
trailingSlash: false,
});

assert.ok(str.includes('https://example.com/"'));
assert.ok(str.includes('https://example.com/php"'));
});

// it('Deprecated import.meta.glob mapping still works', async () => {
// const globResult = {
// './posts/php.md': () =>
// new Promise((resolve) =>
// resolve({
// url: phpFeedEntry.link,
// frontmatter: {
// title: phpFeedEntry.title,
// updated: phpFeedEntry.updated,
// summary: phpFeedEntry.summary,
// },
// })
// ),
// './posts/nested/web1.md': () =>
// new Promise((resolve) =>
// resolve({
// url: web1FeedEntry.link,
// frontmatter: {
// title: web1FeedEntry.title,
// updated: web1FeedEntry.updated,
// summary: web1FeedEntry.summary,
// },
// })
// ),
// };

// const str = await getAtomString({
// title,
// summary,
// entries: globResult,
// site,
// });

// assertXmlDeepEqual(str, validXmlResult);
// });

it('should fail when an invalid date string is provided', async () => {
const res = atomSchema.safeParse({
title: phpFeedEntry.title,
updated: 'invalid date',
summary: phpFeedEntry.summary,
link: phpFeedEntry.link,
});

assert.equal(res.success, false);
assert.equal(res.error.issues[0].path[0], 'updated');
});

it('should be extendable', () => {
let error = null;
try {
atomSchema.extend({
category: z.string().optional(),
});
} catch (e) {
error = e.message;
}
assert.equal(error, null);
});

it('should not fail when an enclosure has a length of 0', async () => {
let error = null;
try {
await getAtomString({
title,
summary: subtitle,
entries: [
{
title: 'Title',
updated: new Date().toISOString(),
summary: 'Description',
link: '/link',
enclosure: {
url: '/enclosure',
length: 0,
type: 'audio/mpeg',
},
},
],
site,
});
} catch (e) {
error = e.message;
}

assert.equal(error, null);
});
});
69 changes: 69 additions & 0 deletions packages/astro-atom/test/test-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import xml2js from 'xml2js';

export const title = 'My Atom feed';
export const subtitle = 'This sure is a nice Atom feed';
export const site = 'https://example.com';

export const phpFeedEntry = {
link: '/php',
title: 'Remember PHP?',
updated: '1994-05-03',
summary:
'PHP is a general-purpose scripting language geared toward web development. It was originally created by Danish-Canadian programmer Rasmus Lerdorf in 1994.',
};
export const phpFeedEntryWithContent = {
...phpFeedEntry,
content: `<h1>${phpFeedEntry.title}</h1><p>${phpFeedEntry.summary}</p>`,
};
export const phpFeedEntryWithCustomData = {
...phpFeedEntry,
customData: '<dc:creator><![CDATA[Buster Bluth]]></dc:creator>',
};

export const web1FeedEntry = {
// Should support empty string as a URL (possible for homepage route)
link: '',
title: 'Web 1.0',
updated: '1997-05-03',
summary:
'Web 1.0 is the term used for the earliest version of the Internet as it emerged from its origins with Defense Advanced Research Projects Agency (DARPA) and became, for the first time, a global network representing the future of digital communications.',
};
export const web1FeedEntryWithContent = {
...web1FeedEntry,
content: `<h1>${web1FeedEntry.title}</h1><p>${web1FeedEntry.summary}</p>`,
};
export const web1FeedEntryWithAllData = {
...web1FeedEntry,
categories: ['web1', 'history'],
author: {
name: 'test',
email: 'test@example.com'
},
// commentsUrl: 'http://example.com/comments',
source: {
url: 'http://example.com/source',
title: 'The Web 1.0 blog',
},
enclosure: {
url: '/podcast.mp3',
length: 256,
type: 'audio/mpeg',
},
};

const parser = new xml2js.Parser({ trim: true });

/**
*
* Utility function to parse an XML string into an object using `xml2js`.
*
* @param {string} xmlString - Stringified XML to parse.
* @return {{ err: Error, result: any }} Represents an option containing the parsed XML string or an Error.
*/
export function parseXmlString(xmlString) {
let res;
parser.parseString(xmlString, (err, result) => {
res = { err, result };
});
return res;
}

0 comments on commit 8cd29aa

Please sign in to comment.