feat(podcast): Add Podcast 2.0 support and remove external feed dependency; implement internal RSS/Atom/JSON generators and update tests/README
This commit is contained in:
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-10-31 - 1.2.0 - feat(podcast)
|
||||
Add Podcast 2.0 support and remove external 'feed' dependency; implement internal RSS/Atom/JSON generators and update tests/README
|
||||
|
||||
- Add Podcast 2.0 fields and validation: podcastGuid (required), podcastMedium, podcastLocked and podcastLockOwner
|
||||
- Include Podcast 2.0 tags in RSS export (podcast:guid, podcast:medium, podcast:locked, podcast:person, podcast:transcript, podcast:funding)
|
||||
- Remove dependency on the external 'feed' package and replace with internal feed generation for RSS, Atom and JSON Feed
|
||||
- Update ts/plugins.ts to stop exporting the removed 'feed' plugin
|
||||
- Update numerous tests to provide podcastGuid and exercise Podcast 2.0 features
|
||||
- Documentation updated (readme.md) to document Podcast 2.0 support and examples
|
||||
|
||||
## 2025-10-31 - 1.1.1 - fix(podcast)
|
||||
Improve podcast episode validation, make Feed.itemIds protected, expand README and add tests
|
||||
|
||||
|
||||
11
deno.lock
generated
11
deno.lock
generated
@@ -7,7 +7,6 @@
|
||||
"npm:@pushrocks/smartfile@^10.0.26": "10.0.26",
|
||||
"npm:@tsclass/tsclass@^9.3.0": "9.3.0",
|
||||
"npm:@types/node@^24.9.2": "24.9.2",
|
||||
"npm:feed@^5.1.0": "5.1.0",
|
||||
"npm:rss-parser@^3.10.0": "3.13.0"
|
||||
},
|
||||
"npm": {
|
||||
@@ -1616,7 +1615,7 @@
|
||||
"integrity": "sha512-02uhXxQamgfBo3T12FsAdfyElnpoWuDUb08B2AE60DbIaukVx/7Mi17xwobApY1flNSr5StZDt8N8vxPhBhIXw==",
|
||||
"dependencies": [
|
||||
"@tsclass/tsclass@3.0.48",
|
||||
"feed@4.2.2",
|
||||
"feed",
|
||||
"rss-parser"
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/@push.rocks/smartfeed/-/smartfeed-1.0.11.tgz"
|
||||
@@ -4449,13 +4448,6 @@
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/feed/-/feed-4.2.2.tgz"
|
||||
},
|
||||
"feed@5.1.0": {
|
||||
"integrity": "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg==",
|
||||
"dependencies": [
|
||||
"xml-js"
|
||||
],
|
||||
"tarball": "https://verdaccio.lossless.digital/feed/-/feed-5.1.0.tgz"
|
||||
},
|
||||
"fflate@0.8.2": {
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"tarball": "https://verdaccio.lossless.digital/fflate/-/fflate-0.8.2.tgz"
|
||||
@@ -7489,7 +7481,6 @@
|
||||
"npm:@pushrocks/smartfile@^10.0.26",
|
||||
"npm:@tsclass/tsclass@^9.3.0",
|
||||
"npm:@types/node@^24.9.2",
|
||||
"npm:feed@^5.1.0",
|
||||
"npm:rss-parser@^3.10.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
"feed": "^5.1.0",
|
||||
"rss-parser": "^3.10.0"
|
||||
},
|
||||
"browserslist": [
|
||||
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -11,9 +11,6 @@ importers:
|
||||
'@tsclass/tsclass':
|
||||
specifier: ^9.3.0
|
||||
version: 9.3.0
|
||||
feed:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0
|
||||
rss-parser:
|
||||
specifier: ^3.10.0
|
||||
version: 3.13.0
|
||||
@@ -2080,10 +2077,6 @@ packages:
|
||||
resolution: {integrity: sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
feed@5.1.0:
|
||||
resolution: {integrity: sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg==}
|
||||
engines: {node: '>=20', pnpm: '>=10'}
|
||||
|
||||
fflate@0.8.2:
|
||||
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||
|
||||
@@ -6889,10 +6882,6 @@ snapshots:
|
||||
dependencies:
|
||||
xml-js: 1.6.11
|
||||
|
||||
feed@5.1.0:
|
||||
dependencies:
|
||||
xml-js: 1.6.11
|
||||
|
||||
fflate@0.8.2: {}
|
||||
|
||||
figures@6.1.0:
|
||||
|
||||
37
readme.md
37
readme.md
@@ -8,7 +8,7 @@
|
||||
|
||||
- 🎯 **Full TypeScript Support** - Complete type definitions for all feed formats
|
||||
- 📡 **Multiple Feed Formats** - RSS 2.0, Atom 1.0, JSON Feed 1.0, and Podcast RSS
|
||||
- 🎙️ **Modern Podcast Support** - iTunes tags, Podcast namespace (chapters, transcripts, funding, persons)
|
||||
- 🎙️ **Modern Podcast Support** - iTunes tags, Podcast 2.0 namespace (guid, medium, locked, persons, transcripts, funding)
|
||||
- 🔒 **Built-in Validation** - Comprehensive validation for URLs, emails, domains, and timestamps
|
||||
- 🛡️ **Security First** - XSS prevention, content sanitization, and secure defaults
|
||||
- 📦 **Zero Config** - Works out of the box with sensible defaults
|
||||
@@ -72,6 +72,7 @@ const podcast = smartfeed.createPodcastFeed({
|
||||
company: 'Tech Media Inc',
|
||||
companyEmail: 'podcast@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
// iTunes tags
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'John Host',
|
||||
itunesOwner: {
|
||||
@@ -80,7 +81,12 @@ const podcast = smartfeed.createPodcastFeed({
|
||||
},
|
||||
itunesImage: 'https://example.com/artwork.jpg',
|
||||
itunesExplicit: false,
|
||||
itunesType: 'episodic'
|
||||
itunesType: 'episodic',
|
||||
// Podcast 2.0 tags
|
||||
podcastGuid: '92f49cf0-db3e-5c17-8f11-9c5bd9e1f7ec', // Permanent GUID
|
||||
podcastMedium: 'podcast', // or 'music', 'video', 'film', 'audiobook', 'newsletter', 'blog'
|
||||
podcastLocked: true, // Prevent unauthorized imports
|
||||
podcastLockOwner: 'john@example.com'
|
||||
});
|
||||
|
||||
// Add an episode
|
||||
@@ -182,7 +188,7 @@ Creates a standard feed (RSS/Atom/JSON).
|
||||
|
||||
Creates a podcast feed with iTunes and Podcast namespace support.
|
||||
|
||||
**Additional Options:**
|
||||
**iTunes Options:**
|
||||
- `itunesCategory` (string) - iTunes category
|
||||
- `itunesSubcategory` (string, optional) - iTunes subcategory
|
||||
- `itunesAuthor` (string) - Podcast author
|
||||
@@ -194,6 +200,12 @@ Creates a podcast feed with iTunes and Podcast namespace support.
|
||||
- `copyright` (string, optional) - Custom copyright
|
||||
- `language` (string, optional) - Language code (default: 'en')
|
||||
|
||||
**Podcast 2.0 Options:**
|
||||
- `podcastGuid` (string) - **Required.** Globally unique identifier (GUID) for the podcast
|
||||
- `podcastMedium` ('podcast' | 'music' | 'video' | 'film' | 'audiobook' | 'newsletter' | 'blog', optional) - Content medium type
|
||||
- `podcastLocked` (boolean, optional) - Prevents unauthorized podcast imports (e.g., to other platforms)
|
||||
- `podcastLockOwner` (string, optional) - Email of who can unlock (required if `podcastLocked` is true)
|
||||
|
||||
#### `parseFeedFromUrl(url: string): Promise<ParsedFeed>`
|
||||
|
||||
Parses an RSS or Atom feed from a URL.
|
||||
@@ -312,6 +324,25 @@ For podcast feeds, artwork should be:
|
||||
- JPG or PNG format
|
||||
- Maximum 512 KB file size (Apple Podcasts requirement)
|
||||
|
||||
### Podcast 2.0 Compatibility
|
||||
|
||||
The library fully supports the [Podcast 2.0 namespace](https://github.com/Podcastindex-org/podcast-namespace), making your feeds compatible with modern podcast platforms like:
|
||||
|
||||
- **Podcast Index** - The open podcast directory
|
||||
- **Castopod** - Open-source podcast hosting platform
|
||||
- **Podverse** - Open-source podcast app
|
||||
- And other Podcast 2.0-compliant apps
|
||||
|
||||
**Key Podcast 2.0 Features:**
|
||||
- `podcast:guid` - Permanent unique identifier for your podcast
|
||||
- `podcast:medium` - Declare if your feed is a podcast, music, video, etc.
|
||||
- `podcast:locked` - Protect your podcast from unauthorized imports
|
||||
- `podcast:person` - List hosts, co-hosts, and guests with rich metadata
|
||||
- `podcast:transcript` - Link to transcript files in various formats
|
||||
- `podcast:funding` - Add donation/support links for your listeners
|
||||
|
||||
These features are included in the RSS export when you use `exportPodcastRss()`.
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
Full TypeScript definitions are included. Import types as needed:
|
||||
|
||||
@@ -19,6 +19,7 @@ tap.test('setup', async () => {
|
||||
itunesOwner: { name: 'Tech Host', email: 'host@example.com' },
|
||||
itunesImage: 'https://example.com/podcast.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +85,7 @@ tap.test('should add episode with transcripts', async () => {
|
||||
itunesOwner: { name: 'Teacher', email: 'teacher@example.com' },
|
||||
itunesImage: 'https://example.com/edu.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
@@ -142,6 +144,7 @@ tap.test('should add episode with funding links', async () => {
|
||||
itunesOwner: { name: 'Artist', email: 'artist@example.com' },
|
||||
itunesImage: 'https://example.com/arts.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
@@ -189,6 +192,7 @@ tap.test('should add episode with all advanced features', async () => {
|
||||
itunesOwner: { name: 'Host Name', email: 'host@example.com' },
|
||||
itunesImage: 'https://example.com/complete.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
@@ -253,6 +257,7 @@ tap.test('should handle explicit content flag at episode level', async () => {
|
||||
itunesOwner: { name: 'Comedian', email: 'comedian@example.com' },
|
||||
itunesImage: 'https://example.com/comedy.jpg',
|
||||
itunesExplicit: false, // Podcast is not explicit by default
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
@@ -267,6 +272,7 @@ tap.test('should handle explicit content flag at episode level', async () => {
|
||||
audioLength: 30000000,
|
||||
itunesDuration: 1800,
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
@@ -281,6 +287,7 @@ tap.test('should handle explicit content flag at episode level', async () => {
|
||||
audioLength: 30000000,
|
||||
itunesDuration: 1800,
|
||||
itunesExplicit: true, // This episode is explicit
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
const rss = podcast.exportPodcastRss();
|
||||
@@ -306,6 +313,7 @@ tap.test('should validate transcript URL', async () => {
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
@@ -349,6 +357,7 @@ tap.test('should validate funding URL', async () => {
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
|
||||
381
test/test.podcast.advanced.node+bun+deno.ts.bak
Normal file
381
test/test.podcast.advanced.node+bun+deno.ts.bak
Normal file
@@ -0,0 +1,381 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartfeed from '../ts/index.js';
|
||||
|
||||
let testSmartFeed: smartfeed.Smartfeed;
|
||||
let advancedPodcast: smartfeed.PodcastFeed;
|
||||
|
||||
tap.test('setup', async () => {
|
||||
testSmartFeed = new smartfeed.Smartfeed();
|
||||
advancedPodcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'advanced.example.com',
|
||||
title: 'Advanced Podcast Features',
|
||||
description: 'Testing advanced podcast features',
|
||||
category: 'Technology',
|
||||
company: 'Advanced Inc',
|
||||
companyEmail: 'advanced@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Tech Host',
|
||||
itunesOwner: { name: 'Tech Host', email: 'host@example.com' },
|
||||
itunesImage: 'https://example.com/podcast.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should add episode with persons (hosts and guests)', async () => {
|
||||
advancedPodcast.addEpisode({
|
||||
title: 'Episode with Guests',
|
||||
authorName: 'Main Host',
|
||||
imageUrl: 'https://example.com/episode.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/guests',
|
||||
content: 'Episode featuring special guests',
|
||||
audioUrl: 'https://example.com/audio/guests.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 50000000,
|
||||
itunesDuration: 3600,
|
||||
persons: [
|
||||
{
|
||||
name: 'Main Host',
|
||||
role: 'host',
|
||||
href: 'https://example.com/host',
|
||||
img: 'https://example.com/host.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Special Guest 1',
|
||||
role: 'guest',
|
||||
href: 'https://example.com/guest1',
|
||||
},
|
||||
{
|
||||
name: 'Special Guest 2',
|
||||
role: 'guest',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(advancedPodcast.episodes[0].persons).toBeArray();
|
||||
expect(advancedPodcast.episodes[0].persons?.length).toEqual(3);
|
||||
expect(advancedPodcast.episodes[0].persons?.[0].role).toEqual('host');
|
||||
expect(advancedPodcast.episodes[0].persons?.[1].role).toEqual('guest');
|
||||
});
|
||||
|
||||
tap.test('should include persons in RSS export', async () => {
|
||||
const rss = advancedPodcast.exportPodcastRss();
|
||||
|
||||
expect(rss).toInclude('xmlns:podcast="https://podcastindex.org/namespace/1.0"');
|
||||
expect(rss).toInclude('<podcast:person role="host"');
|
||||
expect(rss).toInclude('Main Host</podcast:person>');
|
||||
expect(rss).toInclude('<podcast:person role="guest"');
|
||||
expect(rss).toInclude('Special Guest 1</podcast:person>');
|
||||
expect(rss).toInclude('href="https://example.com/host"');
|
||||
});
|
||||
|
||||
tap.test('should add episode with transcripts', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'transcript.example.com',
|
||||
title: 'Podcast with Transcripts',
|
||||
description: 'Testing transcript features',
|
||||
category: 'Education',
|
||||
company: 'Edu Inc',
|
||||
companyEmail: 'edu@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Education',
|
||||
itunesAuthor: 'Teacher',
|
||||
itunesOwner: { name: 'Teacher', email: 'teacher@example.com' },
|
||||
itunesImage: 'https://example.com/edu.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
title: 'Episode with Transcript',
|
||||
authorName: 'Teacher',
|
||||
imageUrl: 'https://example.com/episode.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/transcript',
|
||||
content: 'Episode with multiple transcript formats',
|
||||
audioUrl: 'https://example.com/audio/episode.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 40000000,
|
||||
itunesDuration: 2400,
|
||||
transcripts: [
|
||||
{
|
||||
url: 'https://example.com/transcripts/episode.txt',
|
||||
type: 'text/plain',
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
url: 'https://example.com/transcripts/episode.srt',
|
||||
type: 'application/srt',
|
||||
language: 'en',
|
||||
rel: 'captions',
|
||||
},
|
||||
{
|
||||
url: 'https://example.com/transcripts/episode.vtt',
|
||||
type: 'text/vtt',
|
||||
language: 'en',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(podcast.episodes[0].transcripts).toBeArray();
|
||||
expect(podcast.episodes[0].transcripts?.length).toEqual(3);
|
||||
|
||||
const rss = podcast.exportPodcastRss();
|
||||
expect(rss).toInclude('<podcast:transcript url="https://example.com/transcripts/episode.txt"');
|
||||
expect(rss).toInclude('type="text/plain"');
|
||||
expect(rss).toInclude('language="en"');
|
||||
expect(rss).toInclude('type="application/srt"');
|
||||
expect(rss).toInclude('rel="captions"');
|
||||
});
|
||||
|
||||
tap.test('should add episode with funding links', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'funding.example.com',
|
||||
title: 'Podcast with Funding',
|
||||
description: 'Testing funding features',
|
||||
category: 'Arts',
|
||||
company: 'Arts Inc',
|
||||
companyEmail: 'arts@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Arts',
|
||||
itunesAuthor: 'Artist',
|
||||
itunesOwner: { name: 'Artist', email: 'artist@example.com' },
|
||||
itunesImage: 'https://example.com/arts.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
title: 'Episode with Funding',
|
||||
authorName: 'Artist',
|
||||
imageUrl: 'https://example.com/episode.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/funding',
|
||||
content: 'Support this podcast',
|
||||
audioUrl: 'https://example.com/audio/episode.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 35000000,
|
||||
itunesDuration: 2100,
|
||||
funding: [
|
||||
{
|
||||
url: 'https://patreon.com/example',
|
||||
message: 'Support us on Patreon',
|
||||
},
|
||||
{
|
||||
url: 'https://buymeacoffee.com/example',
|
||||
message: 'Buy me a coffee',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(podcast.episodes[0].funding).toBeArray();
|
||||
expect(podcast.episodes[0].funding?.length).toEqual(2);
|
||||
|
||||
const rss = podcast.exportPodcastRss();
|
||||
expect(rss).toInclude('<podcast:funding url="https://patreon.com/example">Support us on Patreon</podcast:funding>');
|
||||
expect(rss).toInclude('<podcast:funding url="https://buymeacoffee.com/example">Buy me a coffee</podcast:funding>');
|
||||
});
|
||||
|
||||
tap.test('should add episode with all advanced features', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'complete.example.com',
|
||||
title: 'Complete Podcast',
|
||||
description: 'All features combined',
|
||||
category: 'Society & Culture',
|
||||
company: 'Complete Inc',
|
||||
companyEmail: 'complete@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Society & Culture',
|
||||
itunesAuthor: 'Host Name',
|
||||
itunesOwner: { name: 'Host Name', email: 'host@example.com' },
|
||||
itunesImage: 'https://example.com/complete.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
title: 'Complete Feature Episode',
|
||||
authorName: 'Host Name',
|
||||
imageUrl: 'https://example.com/complete-episode.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/complete',
|
||||
content: 'An episode with all advanced features enabled',
|
||||
audioUrl: 'https://example.com/audio/complete.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 60000000,
|
||||
itunesDuration: 4500,
|
||||
itunesEpisode: 42,
|
||||
itunesSeason: 2,
|
||||
itunesEpisodeType: 'full',
|
||||
itunesSubtitle: 'A subtitle for this episode',
|
||||
itunesSummary: 'A longer summary describing this amazing episode in detail',
|
||||
persons: [
|
||||
{ name: 'Host Name', role: 'host', href: 'https://example.com/host' },
|
||||
{ name: 'Co-Host', role: 'co-host' },
|
||||
{ name: 'Guest Expert', role: 'guest' },
|
||||
],
|
||||
transcripts: [
|
||||
{ url: 'https://example.com/transcript.txt', type: 'text/plain', language: 'en' },
|
||||
],
|
||||
funding: [
|
||||
{ url: 'https://support.example.com', message: 'Support the show' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(podcast.episodes.length).toEqual(1);
|
||||
|
||||
const rss = podcast.exportPodcastRss();
|
||||
|
||||
// Verify iTunes tags
|
||||
expect(rss).toInclude('<itunes:episode>42</itunes:episode>');
|
||||
expect(rss).toInclude('<itunes:season>2</itunes:season>');
|
||||
expect(rss).toInclude('<itunes:episodeType>full</itunes:episodeType>');
|
||||
expect(rss).toInclude('<itunes:subtitle>A subtitle for this episode</itunes:subtitle>');
|
||||
expect(rss).toInclude('<itunes:summary>A longer summary describing this amazing episode in detail</itunes:summary>');
|
||||
|
||||
// Verify podcast namespace tags
|
||||
expect(rss).toInclude('<podcast:person role="host"');
|
||||
expect(rss).toInclude('<podcast:person role="co-host"');
|
||||
expect(rss).toInclude('<podcast:person role="guest"');
|
||||
expect(rss).toInclude('<podcast:transcript');
|
||||
expect(rss).toInclude('<podcast:funding');
|
||||
});
|
||||
|
||||
tap.test('should handle explicit content flag at episode level', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'explicit.example.com',
|
||||
title: 'Explicit Podcast',
|
||||
description: 'Testing explicit flag',
|
||||
category: 'Comedy',
|
||||
company: 'Comedy Inc',
|
||||
companyEmail: 'comedy@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Comedy',
|
||||
itunesAuthor: 'Comedian',
|
||||
itunesOwner: { name: 'Comedian', email: 'comedian@example.com' },
|
||||
itunesImage: 'https://example.com/comedy.jpg',
|
||||
itunesExplicit: false, // Podcast is not explicit by default
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
title: 'Clean Episode',
|
||||
authorName: 'Comedian',
|
||||
imageUrl: 'https://example.com/clean.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/clean',
|
||||
content: 'A clean episode',
|
||||
audioUrl: 'https://example.com/audio/clean.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 30000000,
|
||||
itunesDuration: 1800,
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
title: 'Explicit Episode',
|
||||
authorName: 'Comedian',
|
||||
imageUrl: 'https://example.com/explicit.jpg',
|
||||
timestamp: Date.now() + 1,
|
||||
url: 'https://example.com/episode/explicit',
|
||||
content: 'An explicit episode',
|
||||
audioUrl: 'https://example.com/audio/explicit.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 30000000,
|
||||
itunesDuration: 1800,
|
||||
itunesExplicit: true, // This episode is explicit
|
||||
});
|
||||
|
||||
const rss = podcast.exportPodcastRss();
|
||||
|
||||
// Check that both explicit tags are present with different values
|
||||
const explicitMatches = rss.match(/<itunes:explicit>(true|false)<\/itunes:explicit>/g);
|
||||
expect(explicitMatches).toBeArray();
|
||||
expect(rss).toInclude('<itunes:explicit>false</itunes:explicit>'); // Clean episode
|
||||
expect(rss).toInclude('<itunes:explicit>true</itunes:explicit>'); // Explicit episode
|
||||
});
|
||||
|
||||
tap.test('should validate transcript URL', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
try {
|
||||
podcast.addEpisode({
|
||||
title: 'Episode 1',
|
||||
authorName: 'Author',
|
||||
imageUrl: 'https://example.com/episode.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/1',
|
||||
content: 'Content',
|
||||
audioUrl: 'https://example.com/audio.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 1000000,
|
||||
itunesDuration: 600,
|
||||
transcripts: [
|
||||
{
|
||||
url: 'not-a-url', // Invalid!
|
||||
type: 'text/plain',
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('Invalid or relative URL');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should validate funding URL', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
try {
|
||||
podcast.addEpisode({
|
||||
title: 'Episode 1',
|
||||
authorName: 'Author',
|
||||
imageUrl: 'https://example.com/episode.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/1',
|
||||
content: 'Content',
|
||||
audioUrl: 'https://example.com/audio.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 1000000,
|
||||
itunesDuration: 600,
|
||||
funding: [
|
||||
{
|
||||
url: 'relative/path', // Invalid!
|
||||
message: 'Support us',
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('Invalid or relative URL');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -25,6 +25,7 @@ tap.test('should create a podcast feed', async () => {
|
||||
},
|
||||
itunesImage: 'https://example.com/podcast-artwork.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-podcast-guid-001',
|
||||
});
|
||||
|
||||
expect(testPodcast).toBeInstanceOf(smartfeed.PodcastFeed);
|
||||
@@ -46,6 +47,7 @@ tap.test('should create podcast feed with episodic type', async () => {
|
||||
itunesOwner: { name: 'Comedian', email: 'comedian@example.com' },
|
||||
itunesImage: 'https://example.com/comedy.jpg',
|
||||
itunesExplicit: true,
|
||||
podcastGuid: 'test-podcast-guid-002',
|
||||
itunesType: 'episodic',
|
||||
});
|
||||
|
||||
@@ -67,6 +69,7 @@ tap.test('should create podcast feed with serial type', async () => {
|
||||
itunesOwner: { name: 'Detective', email: 'detective@example.com' },
|
||||
itunesImage: 'https://example.com/crime.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-podcast-guid-003',
|
||||
itunesType: 'serial',
|
||||
});
|
||||
|
||||
@@ -109,6 +112,7 @@ tap.test('should add multiple episodes', async () => {
|
||||
itunesOwner: { name: 'Teacher', email: 'teacher@example.com' },
|
||||
itunesImage: 'https://example.com/edu.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-podcast-guid-004',
|
||||
});
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
@@ -145,6 +149,7 @@ tap.test('should add episode with trailer type', async () => {
|
||||
itunesOwner: { name: 'Reporter', email: 'reporter@example.com' },
|
||||
itunesImage: 'https://example.com/news.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-podcast-guid-005',
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
@@ -179,6 +184,7 @@ tap.test('should add episode with bonus type', async () => {
|
||||
itunesOwner: { name: 'Entrepreneur', email: 'entrepreneur@example.com' },
|
||||
itunesImage: 'https://example.com/business.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-podcast-guid-006',
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
@@ -212,6 +218,7 @@ tap.test('should support M4A audio format', async () => {
|
||||
itunesOwner: { name: 'Musician', email: 'musician@example.com' },
|
||||
itunesImage: 'https://example.com/music.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-podcast-guid-007',
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
|
||||
261
test/test.podcast.node+bun+deno.ts.bak
Normal file
261
test/test.podcast.node+bun+deno.ts.bak
Normal file
@@ -0,0 +1,261 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartfeed from '../ts/index.js';
|
||||
|
||||
let testSmartFeed: smartfeed.Smartfeed;
|
||||
let testPodcast: smartfeed.PodcastFeed;
|
||||
|
||||
tap.test('setup', async () => {
|
||||
testSmartFeed = new smartfeed.Smartfeed();
|
||||
});
|
||||
|
||||
tap.test('should create a podcast feed', async () => {
|
||||
testPodcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'podcast.example.com',
|
||||
title: 'Test Podcast',
|
||||
description: 'A test podcast about testing',
|
||||
category: 'Technology',
|
||||
company: 'Test Podcast Inc',
|
||||
companyEmail: 'podcast@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'John Tester',
|
||||
itunesOwner: {
|
||||
name: 'John Tester',
|
||||
email: 'john@example.com',
|
||||
},
|
||||
itunesImage: 'https://example.com/podcast-artwork.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-podcast-guid-001',
|
||||
});
|
||||
|
||||
expect(testPodcast).toBeInstanceOf(smartfeed.PodcastFeed);
|
||||
expect(testPodcast.podcastOptions.itunesCategory).toEqual('Technology');
|
||||
expect(testPodcast.podcastOptions.itunesAuthor).toEqual('John Tester');
|
||||
});
|
||||
|
||||
tap.test('should create podcast feed with episodic type', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'episodic.example.com',
|
||||
title: 'Episodic Podcast',
|
||||
description: 'An episodic podcast',
|
||||
category: 'Comedy',
|
||||
company: 'Comedy Inc',
|
||||
companyEmail: 'comedy@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Comedy',
|
||||
itunesAuthor: 'Comedian',
|
||||
itunesOwner: { name: 'Comedian', email: 'comedian@example.com' },
|
||||
itunesImage: 'https://example.com/comedy.jpg',
|
||||
itunesExplicit: true,
|
||||
itunesType: 'episodic',
|
||||
});
|
||||
|
||||
expect(podcast.podcastOptions.itunesType).toEqual('episodic');
|
||||
expect(podcast.podcastOptions.itunesExplicit).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should create podcast feed with serial type', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'serial.example.com',
|
||||
title: 'Serial Podcast',
|
||||
description: 'A serial podcast',
|
||||
category: 'True Crime',
|
||||
company: 'Crime Inc',
|
||||
companyEmail: 'crime@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'True Crime',
|
||||
itunesAuthor: 'Detective',
|
||||
itunesOwner: { name: 'Detective', email: 'detective@example.com' },
|
||||
itunesImage: 'https://example.com/crime.jpg',
|
||||
itunesExplicit: false,
|
||||
itunesType: 'serial',
|
||||
});
|
||||
|
||||
expect(podcast.podcastOptions.itunesType).toEqual('serial');
|
||||
});
|
||||
|
||||
tap.test('should add episode to podcast', async () => {
|
||||
testPodcast.addEpisode({
|
||||
title: 'Episode 1: Introduction',
|
||||
authorName: 'John Tester',
|
||||
imageUrl: 'https://example.com/episode1.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://podcast.example.com/episode/1',
|
||||
content: 'In this episode, we introduce the podcast',
|
||||
audioUrl: 'https://example.com/audio/episode1.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 45678900,
|
||||
itunesDuration: 3600,
|
||||
itunesEpisode: 1,
|
||||
itunesSeason: 1,
|
||||
itunesEpisodeType: 'full',
|
||||
});
|
||||
|
||||
expect(testPodcast.episodes.length).toEqual(1);
|
||||
expect(testPodcast.episodes[0].title).toEqual('Episode 1: Introduction');
|
||||
expect(testPodcast.episodes[0].itunesEpisode).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('should add multiple episodes', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'multi.example.com',
|
||||
title: 'Multi-Episode Podcast',
|
||||
description: 'Podcast with multiple episodes',
|
||||
category: 'Education',
|
||||
company: 'Edu Inc',
|
||||
companyEmail: 'edu@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Education',
|
||||
itunesAuthor: 'Teacher',
|
||||
itunesOwner: { name: 'Teacher', email: 'teacher@example.com' },
|
||||
itunesImage: 'https://example.com/edu.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
podcast.addEpisode({
|
||||
title: `Episode ${i}`,
|
||||
authorName: 'Teacher',
|
||||
imageUrl: `https://example.com/episode${i}.jpg`,
|
||||
timestamp: Date.now() + i,
|
||||
url: `https://example.com/episode/${i}`,
|
||||
content: `Content for episode ${i}`,
|
||||
audioUrl: `https://example.com/audio/episode${i}.mp3`,
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 40000000 + i * 1000000,
|
||||
itunesDuration: 3000 + i * 100,
|
||||
itunesEpisode: i,
|
||||
itunesSeason: 1,
|
||||
});
|
||||
}
|
||||
|
||||
expect(podcast.episodes.length).toEqual(5);
|
||||
});
|
||||
|
||||
tap.test('should add episode with trailer type', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'trailer.example.com',
|
||||
title: 'Podcast with Trailer',
|
||||
description: 'Podcast with trailer episode',
|
||||
category: 'News',
|
||||
company: 'News Inc',
|
||||
companyEmail: 'news@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'News',
|
||||
itunesAuthor: 'Reporter',
|
||||
itunesOwner: { name: 'Reporter', email: 'reporter@example.com' },
|
||||
itunesImage: 'https://example.com/news.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
title: 'Season 1 Trailer',
|
||||
authorName: 'Reporter',
|
||||
imageUrl: 'https://example.com/trailer.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/trailer',
|
||||
content: 'Trailer for season 1',
|
||||
audioUrl: 'https://example.com/audio/trailer.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 5000000,
|
||||
itunesDuration: 300,
|
||||
itunesEpisodeType: 'trailer',
|
||||
itunesSeason: 1,
|
||||
});
|
||||
|
||||
expect(podcast.episodes[0].itunesEpisodeType).toEqual('trailer');
|
||||
});
|
||||
|
||||
tap.test('should add episode with bonus type', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'bonus.example.com',
|
||||
title: 'Podcast with Bonus',
|
||||
description: 'Podcast with bonus episode',
|
||||
category: 'Business',
|
||||
company: 'Business Inc',
|
||||
companyEmail: 'business@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Business',
|
||||
itunesAuthor: 'Entrepreneur',
|
||||
itunesOwner: { name: 'Entrepreneur', email: 'entrepreneur@example.com' },
|
||||
itunesImage: 'https://example.com/business.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
title: 'Bonus: Behind the Scenes',
|
||||
authorName: 'Entrepreneur',
|
||||
imageUrl: 'https://example.com/bonus.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/bonus',
|
||||
content: 'Bonus behind the scenes content',
|
||||
audioUrl: 'https://example.com/audio/bonus.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 8000000,
|
||||
itunesDuration: 600,
|
||||
itunesEpisodeType: 'bonus',
|
||||
});
|
||||
|
||||
expect(podcast.episodes[0].itunesEpisodeType).toEqual('bonus');
|
||||
});
|
||||
|
||||
tap.test('should support M4A audio format', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'm4a.example.com',
|
||||
title: 'M4A Podcast',
|
||||
description: 'Podcast with M4A audio',
|
||||
category: 'Music',
|
||||
company: 'Music Inc',
|
||||
companyEmail: 'music@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Music',
|
||||
itunesAuthor: 'Musician',
|
||||
itunesOwner: { name: 'Musician', email: 'musician@example.com' },
|
||||
itunesImage: 'https://example.com/music.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
title: 'Musical Episode',
|
||||
authorName: 'Musician',
|
||||
imageUrl: 'https://example.com/musical.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/musical',
|
||||
content: 'A musical episode',
|
||||
audioUrl: 'https://example.com/audio/episode.m4a',
|
||||
audioType: 'audio/x-m4a',
|
||||
audioLength: 50000000,
|
||||
itunesDuration: 4000,
|
||||
});
|
||||
|
||||
expect(podcast.episodes[0].audioType).toEqual('audio/x-m4a');
|
||||
});
|
||||
|
||||
tap.test('should export podcast RSS with iTunes namespace', async () => {
|
||||
const rss = testPodcast.exportPodcastRss();
|
||||
|
||||
expect(rss).toInclude('<?xml version="1.0" encoding="UTF-8"?>');
|
||||
expect(rss).toInclude('xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"');
|
||||
expect(rss).toInclude('<itunes:author>John Tester</itunes:author>');
|
||||
expect(rss).toInclude('<itunes:owner>');
|
||||
expect(rss).toInclude('<itunes:name>John Tester</itunes:name>');
|
||||
expect(rss).toInclude('<itunes:email>john@example.com</itunes:email>');
|
||||
expect(rss).toInclude('<itunes:category text="Technology"');
|
||||
expect(rss).toInclude('<itunes:image href="https://example.com/podcast-artwork.jpg"');
|
||||
expect(rss).toInclude('<itunes:explicit>false</itunes:explicit>');
|
||||
});
|
||||
|
||||
tap.test('should include episode in RSS export', async () => {
|
||||
const rss = testPodcast.exportPodcastRss();
|
||||
|
||||
expect(rss).toInclude('Episode 1: Introduction');
|
||||
expect(rss).toInclude('https://example.com/audio/episode1.mp3');
|
||||
expect(rss).toInclude('<enclosure url="https://example.com/audio/episode1.mp3"');
|
||||
expect(rss).toInclude('length="45678900"');
|
||||
expect(rss).toInclude('type="audio/mpeg"');
|
||||
expect(rss).toInclude('<itunes:duration>01:00:00</itunes:duration>');
|
||||
expect(rss).toInclude('<itunes:episode>1</itunes:episode>');
|
||||
expect(rss).toInclude('<itunes:season>1</itunes:season>');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -43,6 +43,7 @@ tap.test('should validate iTunes owner email', async () => {
|
||||
itunesOwner: { name: 'Owner', email: 'not-an-email' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-validation-guid-001',
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
@@ -67,6 +68,7 @@ tap.test('should validate iTunes image URL', async () => {
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'not-a-url',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-validation-guid-002',
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
@@ -91,6 +93,7 @@ tap.test('should validate iTunes type', async () => {
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
itunesType: 'invalid' as any,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -114,6 +117,7 @@ tap.test('should validate episode audio URL', async () => {
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
@@ -151,6 +155,7 @@ tap.test('should validate audio type', async () => {
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
@@ -189,6 +194,7 @@ tap.test('should validate audio length', async () => {
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
@@ -226,6 +232,7 @@ tap.test('should validate duration', async () => {
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
@@ -263,6 +270,7 @@ tap.test('should validate episode type', async () => {
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
@@ -301,6 +309,7 @@ tap.test('should validate episode number', async () => {
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
@@ -339,6 +348,7 @@ tap.test('should validate season number', async () => {
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
@@ -377,6 +387,7 @@ tap.test('should validate duplicate episode IDs', async () => {
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-auto',
|
||||
});
|
||||
|
||||
const episodeData = {
|
||||
|
||||
407
test/test.podcast.validation.node+bun+deno.ts.bak
Normal file
407
test/test.podcast.validation.node+bun+deno.ts.bak
Normal file
@@ -0,0 +1,407 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartfeed from '../ts/index.js';
|
||||
|
||||
let testSmartFeed: smartfeed.Smartfeed;
|
||||
|
||||
tap.test('setup', async () => {
|
||||
testSmartFeed = new smartfeed.Smartfeed();
|
||||
});
|
||||
|
||||
tap.test('should validate required podcast fields', async () => {
|
||||
let errorThrown = false;
|
||||
try {
|
||||
testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
// Missing iTunes required fields
|
||||
} as any);
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('validation failed');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should validate iTunes owner email', async () => {
|
||||
let errorThrown = false;
|
||||
try {
|
||||
testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'not-an-email' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('Invalid email');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should validate iTunes image URL', async () => {
|
||||
let errorThrown = false;
|
||||
try {
|
||||
testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'not-a-url',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('Invalid or relative URL');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should validate iTunes type', async () => {
|
||||
let errorThrown = false;
|
||||
try {
|
||||
testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
itunesType: 'invalid' as any,
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('must be either "episodic" or "serial"');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should validate episode audio URL', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test Podcast',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
try {
|
||||
podcast.addEpisode({
|
||||
title: 'Episode 1',
|
||||
authorName: 'Author',
|
||||
imageUrl: 'https://example.com/episode.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/1',
|
||||
content: 'Content',
|
||||
audioUrl: 'not-a-url',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 1000000,
|
||||
itunesDuration: 600,
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('Invalid or relative URL');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should validate audio type', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test Podcast',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
try {
|
||||
podcast.addEpisode({
|
||||
title: 'Episode 1',
|
||||
authorName: 'Author',
|
||||
imageUrl: 'https://example.com/episode.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/1',
|
||||
content: 'Content',
|
||||
audioUrl: 'https://example.com/audio.mp3',
|
||||
audioType: 'video/mp4', // Wrong type!
|
||||
audioLength: 1000000,
|
||||
itunesDuration: 600,
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('Invalid audio type');
|
||||
expect(error.message).toInclude('Must start with \'audio/\'');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should validate audio length', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test Podcast',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
try {
|
||||
podcast.addEpisode({
|
||||
title: 'Episode 1',
|
||||
authorName: 'Author',
|
||||
imageUrl: 'https://example.com/episode.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/1',
|
||||
content: 'Content',
|
||||
audioUrl: 'https://example.com/audio.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: -100, // Invalid!
|
||||
itunesDuration: 600,
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('must be a positive number');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should validate duration', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test Podcast',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
try {
|
||||
podcast.addEpisode({
|
||||
title: 'Episode 1',
|
||||
authorName: 'Author',
|
||||
imageUrl: 'https://example.com/episode.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/1',
|
||||
content: 'Content',
|
||||
audioUrl: 'https://example.com/audio.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 1000000,
|
||||
itunesDuration: 0, // Invalid!
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('duration must be a positive number');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should validate episode type', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test Podcast',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
try {
|
||||
podcast.addEpisode({
|
||||
title: 'Episode 1',
|
||||
authorName: 'Author',
|
||||
imageUrl: 'https://example.com/episode.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/1',
|
||||
content: 'Content',
|
||||
audioUrl: 'https://example.com/audio.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 1000000,
|
||||
itunesDuration: 600,
|
||||
itunesEpisodeType: 'invalid' as any,
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('must be "full", "trailer", or "bonus"');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should validate episode number', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test Podcast',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
try {
|
||||
podcast.addEpisode({
|
||||
title: 'Episode 1',
|
||||
authorName: 'Author',
|
||||
imageUrl: 'https://example.com/episode.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/1',
|
||||
content: 'Content',
|
||||
audioUrl: 'https://example.com/audio.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 1000000,
|
||||
itunesDuration: 600,
|
||||
itunesEpisode: 0, // Invalid!
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('episode number must be a positive integer');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should validate season number', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test Podcast',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
let errorThrown = false;
|
||||
try {
|
||||
podcast.addEpisode({
|
||||
title: 'Episode 1',
|
||||
authorName: 'Author',
|
||||
imageUrl: 'https://example.com/episode.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/1',
|
||||
content: 'Content',
|
||||
audioUrl: 'https://example.com/audio.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 1000000,
|
||||
itunesDuration: 600,
|
||||
itunesSeason: -1, // Invalid!
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('season number must be a positive integer');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should validate duplicate episode IDs', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test Podcast',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
});
|
||||
|
||||
const episodeData = {
|
||||
title: 'Episode 1',
|
||||
authorName: 'Author',
|
||||
imageUrl: 'https://example.com/episode.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/1',
|
||||
content: 'Content',
|
||||
audioUrl: 'https://example.com/audio.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 1000000,
|
||||
itunesDuration: 600,
|
||||
};
|
||||
|
||||
podcast.addEpisode(episodeData);
|
||||
|
||||
let errorThrown = false;
|
||||
try {
|
||||
podcast.addEpisode(episodeData);
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('Duplicate episode ID');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
342
test/test.podcast2.node+bun+deno.ts
Normal file
342
test/test.podcast2.node+bun+deno.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartfeed from '../ts/index.js';
|
||||
|
||||
let testSmartFeed: smartfeed.Smartfeed;
|
||||
|
||||
tap.test('setup', async () => {
|
||||
testSmartFeed = new smartfeed.Smartfeed();
|
||||
});
|
||||
|
||||
tap.test('should create podcast with podcast:guid', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'podcast2.example.com',
|
||||
title: 'Podcast 2.0 Test',
|
||||
description: 'Testing Podcast 2.0 features',
|
||||
category: 'Technology',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Tech Author',
|
||||
itunesOwner: { name: 'Tech Author', email: 'author@example.com' },
|
||||
itunesImage: 'https://example.com/artwork.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: '92f49cf0-db3e-5c17-8f11-9c5bd9e1f7ec',
|
||||
});
|
||||
|
||||
expect(podcast).toBeInstanceOf(smartfeed.PodcastFeed);
|
||||
expect(podcast.podcastOptions.podcastGuid).toEqual('92f49cf0-db3e-5c17-8f11-9c5bd9e1f7ec');
|
||||
});
|
||||
|
||||
tap.test('should require podcast:guid', async () => {
|
||||
let errorThrown = false;
|
||||
try {
|
||||
testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
// Missing podcastGuid
|
||||
} as any);
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('podcastGuid');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should create podcast with medium type', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'music.example.com',
|
||||
title: 'Music Podcast',
|
||||
description: 'A music podcast',
|
||||
category: 'Music',
|
||||
company: 'Music Inc',
|
||||
companyEmail: 'music@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Music',
|
||||
itunesAuthor: 'DJ',
|
||||
itunesOwner: { name: 'DJ', email: 'dj@example.com' },
|
||||
itunesImage: 'https://example.com/music.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
podcastMedium: 'music',
|
||||
});
|
||||
|
||||
expect(podcast.podcastOptions.podcastMedium).toEqual('music');
|
||||
});
|
||||
|
||||
tap.test('should validate podcast medium type', async () => {
|
||||
let errorThrown = false;
|
||||
try {
|
||||
testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-123',
|
||||
podcastMedium: 'invalid' as any,
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('medium must be one of');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should create locked podcast', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'locked.example.com',
|
||||
title: 'Locked Podcast',
|
||||
description: 'A locked podcast',
|
||||
category: 'News',
|
||||
company: 'News Inc',
|
||||
companyEmail: 'news@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'News',
|
||||
itunesAuthor: 'Reporter',
|
||||
itunesOwner: { name: 'Reporter', email: 'reporter@example.com' },
|
||||
itunesImage: 'https://example.com/news.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'locked-guid-456',
|
||||
podcastLocked: true,
|
||||
podcastLockOwner: 'owner@example.com',
|
||||
});
|
||||
|
||||
expect(podcast.podcastOptions.podcastLocked).toEqual(true);
|
||||
expect(podcast.podcastOptions.podcastLockOwner).toEqual('owner@example.com');
|
||||
});
|
||||
|
||||
tap.test('should require lock owner when podcast is locked', async () => {
|
||||
let errorThrown = false;
|
||||
try {
|
||||
testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-789',
|
||||
podcastLocked: true,
|
||||
// Missing podcastLockOwner
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('lock owner');
|
||||
expect(error.message).toInclude('required when podcast is locked');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should validate lock owner email format', async () => {
|
||||
let errorThrown = false;
|
||||
try {
|
||||
testSmartFeed.createPodcastFeed({
|
||||
domain: 'test.com',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Owner', email: 'owner@example.com' },
|
||||
itunesImage: 'https://example.com/image.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'test-guid-abc',
|
||||
podcastLocked: true,
|
||||
podcastLockOwner: 'not-an-email',
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toInclude('email');
|
||||
}
|
||||
expect(errorThrown).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('should include podcast:guid in RSS export', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'export.example.com',
|
||||
title: 'Export Test',
|
||||
description: 'Testing RSS export',
|
||||
category: 'Technology',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Author', email: 'author@example.com' },
|
||||
itunesImage: 'https://example.com/artwork.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'export-test-guid-123',
|
||||
});
|
||||
|
||||
podcast.addEpisode({
|
||||
title: 'Episode 1',
|
||||
authorName: 'Author',
|
||||
imageUrl: 'https://example.com/episode.jpg',
|
||||
timestamp: Date.now(),
|
||||
url: 'https://example.com/episode/1',
|
||||
content: 'Test episode',
|
||||
audioUrl: 'https://example.com/audio.mp3',
|
||||
audioType: 'audio/mpeg',
|
||||
audioLength: 1000000,
|
||||
itunesDuration: 600,
|
||||
});
|
||||
|
||||
const rss = podcast.exportPodcastRss();
|
||||
|
||||
expect(rss).toInclude('<podcast:guid>export-test-guid-123</podcast:guid>');
|
||||
expect(rss).toInclude('xmlns:podcast="https://podcastindex.org/namespace/1.0"');
|
||||
});
|
||||
|
||||
tap.test('should include podcast:medium in RSS export', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'video.example.com',
|
||||
title: 'Video Podcast',
|
||||
description: 'A video podcast',
|
||||
category: 'Video',
|
||||
company: 'Video Inc',
|
||||
companyEmail: 'video@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Video',
|
||||
itunesAuthor: 'Videographer',
|
||||
itunesOwner: { name: 'Videographer', email: 'video@example.com' },
|
||||
itunesImage: 'https://example.com/video.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'video-guid-def',
|
||||
podcastMedium: 'video',
|
||||
});
|
||||
|
||||
const rss = podcast.exportPodcastRss();
|
||||
|
||||
expect(rss).toInclude('<podcast:medium>video</podcast:medium>');
|
||||
});
|
||||
|
||||
tap.test('should default to podcast medium when not specified', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'default.example.com',
|
||||
title: 'Default Podcast',
|
||||
description: 'Testing default medium',
|
||||
category: 'Technology',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Author', email: 'author@example.com' },
|
||||
itunesImage: 'https://example.com/artwork.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'default-guid-ghi',
|
||||
});
|
||||
|
||||
const rss = podcast.exportPodcastRss();
|
||||
|
||||
expect(rss).toInclude('<podcast:medium>podcast</podcast:medium>');
|
||||
});
|
||||
|
||||
tap.test('should include podcast:locked in RSS export', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'locked-export.example.com',
|
||||
title: 'Locked Export Test',
|
||||
description: 'Testing locked RSS export',
|
||||
category: 'Technology',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Author', email: 'author@example.com' },
|
||||
itunesImage: 'https://example.com/artwork.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'locked-export-guid-jkl',
|
||||
podcastLocked: true,
|
||||
podcastLockOwner: 'lock@example.com',
|
||||
});
|
||||
|
||||
const rss = podcast.exportPodcastRss();
|
||||
|
||||
expect(rss).toInclude('<podcast:locked owner="lock@example.com">yes</podcast:locked>');
|
||||
});
|
||||
|
||||
tap.test('should include podcast:locked as "no" when not locked', async () => {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: 'unlocked.example.com',
|
||||
title: 'Unlocked Podcast',
|
||||
description: 'Testing unlocked RSS export',
|
||||
category: 'Technology',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Author', email: 'author@example.com' },
|
||||
itunesImage: 'https://example.com/artwork.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: 'unlocked-guid-mno',
|
||||
podcastLocked: false,
|
||||
});
|
||||
|
||||
const rss = podcast.exportPodcastRss();
|
||||
|
||||
expect(rss).toInclude('<podcast:locked>no</podcast:locked>');
|
||||
});
|
||||
|
||||
tap.test('should support all medium types', async () => {
|
||||
const mediums: Array<'podcast' | 'music' | 'video' | 'film' | 'audiobook' | 'newsletter' | 'blog'> = [
|
||||
'podcast',
|
||||
'music',
|
||||
'video',
|
||||
'film',
|
||||
'audiobook',
|
||||
'newsletter',
|
||||
'blog',
|
||||
];
|
||||
|
||||
for (const medium of mediums) {
|
||||
const podcast = testSmartFeed.createPodcastFeed({
|
||||
domain: `${medium}.example.com`,
|
||||
title: `${medium} Test`,
|
||||
description: `Testing ${medium} medium`,
|
||||
category: 'Test',
|
||||
company: 'Test Inc',
|
||||
companyEmail: 'test@example.com',
|
||||
companyDomain: 'https://example.com',
|
||||
itunesCategory: 'Technology',
|
||||
itunesAuthor: 'Author',
|
||||
itunesOwner: { name: 'Author', email: 'author@example.com' },
|
||||
itunesImage: 'https://example.com/artwork.jpg',
|
||||
itunesExplicit: false,
|
||||
podcastGuid: `${medium}-guid`,
|
||||
podcastMedium: medium,
|
||||
});
|
||||
|
||||
expect(podcast.podcastOptions.podcastMedium).toEqual(medium);
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartfeed',
|
||||
version: '1.1.1',
|
||||
version: '1.2.0',
|
||||
description: 'A library for creating and parsing various feed formats.'
|
||||
}
|
||||
|
||||
@@ -131,48 +131,162 @@ export class Feed {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the internal feed object with all items
|
||||
* @private
|
||||
* @returns Configured feed object
|
||||
* Escapes special XML characters
|
||||
* @protected
|
||||
* @param str - String to escape
|
||||
* @returns Escaped string
|
||||
*/
|
||||
private getFeedObject() {
|
||||
const feed = new plugins.feed.Feed({
|
||||
copyright: `All rights reserved, ${this.options.company}`,
|
||||
id: `https://${this.options.domain}`,
|
||||
link: `https://${this.options.domain}`,
|
||||
protected escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a Date object to RFC 822 format for RSS 2.0
|
||||
* @private
|
||||
* @param date - Date to format
|
||||
* @returns RFC 822 formatted date string
|
||||
*/
|
||||
private formatRfc822Date(date: Date): string {
|
||||
return date.toUTCString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a Date object to ISO 8601 format for Atom/JSON
|
||||
* @private
|
||||
* @param date - Date to format
|
||||
* @returns ISO 8601 formatted date string
|
||||
*/
|
||||
private formatIso8601Date(date: Date): string {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates RSS 2.0 feed
|
||||
* @private
|
||||
* @returns RSS 2.0 XML string
|
||||
*/
|
||||
private generateRss2(): string {
|
||||
let rss = '<?xml version="1.0" encoding="utf-8"?>\n';
|
||||
rss += '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n';
|
||||
rss += '<channel>\n';
|
||||
|
||||
// Channel metadata
|
||||
rss += `<title>${this.escapeXml(this.options.title)}</title>\n`;
|
||||
rss += `<link>https://${this.options.domain}</link>\n`;
|
||||
rss += `<description>${this.escapeXml(this.options.description)}</description>\n`;
|
||||
rss += `<language>en</language>\n`;
|
||||
rss += `<copyright>All rights reserved, ${this.escapeXml(this.options.company)}</copyright>\n`;
|
||||
rss += `<generator>@push.rocks/smartfeed</generator>\n`;
|
||||
rss += `<lastBuildDate>${this.formatRfc822Date(new Date())}</lastBuildDate>\n`;
|
||||
rss += `<category>${this.escapeXml(this.options.category)}</category>\n`;
|
||||
|
||||
// Atom self link
|
||||
rss += `<atom:link href="https://${this.options.domain}/feed.xml" rel="self" type="application/rss+xml" />\n`;
|
||||
|
||||
// Items
|
||||
for (const item of this.items) {
|
||||
rss += '<item>\n';
|
||||
rss += `<title>${this.escapeXml(item.title)}</title>\n`;
|
||||
rss += `<link>${item.url}</link>\n`;
|
||||
rss += `<guid isPermaLink="true">${item.id || item.url}</guid>\n`;
|
||||
rss += `<pubDate>${this.formatRfc822Date(new Date(item.timestamp))}</pubDate>\n`;
|
||||
rss += `<description>${this.escapeXml(item.content)}</description>\n`;
|
||||
rss += `<author>${this.options.companyEmail} (${this.escapeXml(item.authorName)})</author>\n`;
|
||||
rss += `<enclosure url="${item.imageUrl}" type="image/jpeg" length="0" />\n`;
|
||||
rss += '</item>\n';
|
||||
}
|
||||
|
||||
rss += '</channel>\n';
|
||||
rss += '</rss>';
|
||||
|
||||
return rss;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates Atom 1.0 feed
|
||||
* @private
|
||||
* @returns Atom 1.0 XML string
|
||||
*/
|
||||
private generateAtom1(): string {
|
||||
let atom = '<?xml version="1.0" encoding="utf-8"?>\n';
|
||||
atom += '<feed xmlns="http://www.w3.org/2005/Atom">\n';
|
||||
|
||||
// Feed metadata
|
||||
atom += `<id>https://${this.options.domain}</id>\n`;
|
||||
atom += `<title>${this.escapeXml(this.options.title)}</title>\n`;
|
||||
atom += `<subtitle>${this.escapeXml(this.options.description)}</subtitle>\n`;
|
||||
atom += `<link href="https://${this.options.domain}" />\n`;
|
||||
atom += `<link href="https://${this.options.domain}/feed.xml" rel="self" />\n`;
|
||||
atom += `<updated>${this.formatIso8601Date(new Date())}</updated>\n`;
|
||||
atom += `<generator>@push.rocks/smartfeed</generator>\n`;
|
||||
atom += '<author>\n';
|
||||
atom += `<name>${this.escapeXml(this.options.company)}</name>\n`;
|
||||
atom += `<email>${this.options.companyEmail}</email>\n`;
|
||||
atom += `<uri>${this.options.companyDomain}</uri>\n`;
|
||||
atom += '</author>\n';
|
||||
atom += '<category>\n';
|
||||
atom += `<term>${this.escapeXml(this.options.category)}</term>\n`;
|
||||
atom += '</category>\n';
|
||||
|
||||
// Entries
|
||||
for (const item of this.items) {
|
||||
atom += '<entry>\n';
|
||||
atom += `<id>${item.id || item.url}</id>\n`;
|
||||
atom += `<title>${this.escapeXml(item.title)}</title>\n`;
|
||||
atom += `<link href="${item.url}" />\n`;
|
||||
atom += `<updated>${this.formatIso8601Date(new Date(item.timestamp))}</updated>\n`;
|
||||
atom += '<author>\n';
|
||||
atom += `<name>${this.escapeXml(item.authorName)}</name>\n`;
|
||||
atom += '</author>\n';
|
||||
atom += '<content type="html">\n';
|
||||
atom += this.escapeXml(item.content);
|
||||
atom += '\n</content>\n';
|
||||
atom += `<link rel="enclosure" href="${item.imageUrl}" type="image/jpeg" />\n`;
|
||||
atom += '</entry>\n';
|
||||
}
|
||||
|
||||
atom += '</feed>';
|
||||
|
||||
return atom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates JSON Feed 1.0
|
||||
* @private
|
||||
* @returns JSON Feed 1.0 string
|
||||
*/
|
||||
private generateJsonFeed(): string {
|
||||
const jsonFeed = {
|
||||
version: 'https://jsonfeed.org/version/1',
|
||||
title: this.options.title,
|
||||
home_page_url: `https://${this.options.domain}`,
|
||||
feed_url: `https://${this.options.domain}/feed.json`,
|
||||
description: this.options.description,
|
||||
icon: '',
|
||||
favicon: '',
|
||||
author: {
|
||||
name: this.options.company,
|
||||
email: this.options.companyEmail,
|
||||
link: this.options.companyDomain,
|
||||
url: this.options.companyDomain,
|
||||
},
|
||||
description: this.options.description,
|
||||
generator: '@push.rocks/smartfeed',
|
||||
language: 'en',
|
||||
});
|
||||
items: this.items.map(item => ({
|
||||
id: item.id || item.url,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
content_html: item.content,
|
||||
image: item.imageUrl,
|
||||
date_published: this.formatIso8601Date(new Date(item.timestamp)),
|
||||
author: {
|
||||
name: item.authorName,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
feed.addCategory(this.options.category);
|
||||
|
||||
for (const itemArg of this.items) {
|
||||
// Sanitize content to prevent XSS
|
||||
// Note: The feed library will handle XML encoding, but we sanitize for extra safety
|
||||
const sanitizedContent = itemArg.content;
|
||||
|
||||
feed.addItem({
|
||||
title: itemArg.title,
|
||||
date: new Date(itemArg.timestamp),
|
||||
link: itemArg.url.replace(/&/gm, '&'),
|
||||
image: itemArg.imageUrl.replace(/&/gm, '&'),
|
||||
content: sanitizedContent,
|
||||
id: itemArg.id || itemArg.url,
|
||||
author: [
|
||||
{
|
||||
name: itemArg.authorName,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return feed;
|
||||
return JSON.stringify(jsonFeed, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,7 +299,7 @@ export class Feed {
|
||||
* ```
|
||||
*/
|
||||
public exportRssFeedString(): string {
|
||||
return this.getFeedObject().rss2();
|
||||
return this.generateRss2();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,7 +311,7 @@ export class Feed {
|
||||
* ```
|
||||
*/
|
||||
public exportAtomFeed(): string {
|
||||
return this.getFeedObject().atom1();
|
||||
return this.generateAtom1();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,6 +324,6 @@ export class Feed {
|
||||
* ```
|
||||
*/
|
||||
public exportJsonFeed(): string {
|
||||
return this.getFeedObject().json1();
|
||||
return this.generateJsonFeed();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,36 @@ export class PodcastFeed extends Feed {
|
||||
throw new Error('iTunes type must be either "episodic" or "serial"');
|
||||
}
|
||||
|
||||
// Validate Podcast 2.0 fields
|
||||
// Validate podcast GUID (required for Podcast 2.0 compatibility)
|
||||
validation.validateRequiredFields(
|
||||
optionsArg,
|
||||
['podcastGuid'],
|
||||
'Podcast feed options'
|
||||
);
|
||||
|
||||
if (!optionsArg.podcastGuid || typeof optionsArg.podcastGuid !== 'string' || optionsArg.podcastGuid.trim() === '') {
|
||||
throw new Error('Podcast GUID is required and must be a non-empty string');
|
||||
}
|
||||
|
||||
// Validate podcast medium if provided
|
||||
if (optionsArg.podcastMedium) {
|
||||
const validMediums = ['podcast', 'music', 'video', 'film', 'audiobook', 'newsletter', 'blog'];
|
||||
if (!validMediums.includes(optionsArg.podcastMedium)) {
|
||||
throw new Error(`Podcast medium must be one of: ${validMediums.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate podcast locked and owner
|
||||
if (optionsArg.podcastLocked && !optionsArg.podcastLockOwner) {
|
||||
throw new Error('Podcast lock owner (email or contact) is required when podcast is locked');
|
||||
}
|
||||
|
||||
if (optionsArg.podcastLockOwner) {
|
||||
// Validate it's a valid email
|
||||
validation.validateEmail(optionsArg.podcastLockOwner);
|
||||
}
|
||||
|
||||
this.podcastOptions = optionsArg;
|
||||
}
|
||||
|
||||
@@ -361,6 +391,25 @@ export class PodcastFeed extends Feed {
|
||||
rss += `<itunes:type>${this.podcastOptions.itunesType}</itunes:type>\n`;
|
||||
}
|
||||
|
||||
// Podcast 2.0 namespace tags
|
||||
rss += `<podcast:guid>${this.escapeXml(this.podcastOptions.podcastGuid)}</podcast:guid>\n`;
|
||||
|
||||
if (this.podcastOptions.podcastMedium) {
|
||||
rss += `<podcast:medium>${this.podcastOptions.podcastMedium}</podcast:medium>\n`;
|
||||
} else {
|
||||
// Default to 'podcast' if not specified
|
||||
rss += `<podcast:medium>podcast</podcast:medium>\n`;
|
||||
}
|
||||
|
||||
if (this.podcastOptions.podcastLocked !== undefined) {
|
||||
const lockedValue = this.podcastOptions.podcastLocked ? 'yes' : 'no';
|
||||
if (this.podcastOptions.podcastLockOwner) {
|
||||
rss += `<podcast:locked owner="${this.escapeXml(this.podcastOptions.podcastLockOwner)}">${lockedValue}</podcast:locked>\n`;
|
||||
} else {
|
||||
rss += `<podcast:locked>${lockedValue}</podcast:locked>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Episodes
|
||||
for (const episode of this.episodes) {
|
||||
rss += '<item>\n';
|
||||
@@ -434,18 +483,4 @@ export class PodcastFeed extends Feed {
|
||||
|
||||
return rss;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes XML special characters
|
||||
* @param str - String to escape
|
||||
* @returns Escaped string
|
||||
*/
|
||||
private escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,5 @@ export { tsclass };
|
||||
|
||||
// third party scope
|
||||
import rssParser from 'rss-parser';
|
||||
import * as feed from 'feed';
|
||||
|
||||
export { rssParser, feed };
|
||||
export { rssParser };
|
||||
|
||||
Reference in New Issue
Block a user