diff --git a/src/parser/phase/__test__/phase-8.test.ts b/src/parser/phase/__test__/phase-8.test.ts new file mode 100644 index 0000000..c9063fc --- /dev/null +++ b/src/parser/phase/__test__/phase-8.test.ts @@ -0,0 +1,126 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import * as helpers from "../../__test__/helpers"; + +const phase = 8; + +describe("phase 8", () => { + let feed; + beforeAll(async () => { + feed = await helpers.loadSimple(); + }); + + describe("podcast:follow", () => { + const supportedName = "follow"; + + it("skips missing tag", () => { + const result = helpers.parseValidFeed(feed); + + expect(result).not.toHaveProperty("podcastFollow"); + expect(helpers.getPhaseSupport(result, phase)).not.toContain(supportedName); + }); + + it("ignores missing url", () => { + const xml = helpers.spliceFeed(feed, ``); + const result = helpers.parseValidFeed(xml); + + expect(result).not.toHaveProperty("podcastFollow"); + expect(helpers.getPhaseSupport(result, phase)).not.toContain(supportedName); + }); + + it("extracts a PRX follow url", () => { + const xml = helpers.spliceFeed( + feed, + `` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).toHaveProperty("podcastFollow"); + expect(result.podcastFollow).toHaveProperty( + "url", + "https://f.prxu.org/72/subscribelinks.json" + ); + expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName); + }); + + it("extracts a Podnews follow url", () => { + const xml = helpers.spliceFeed( + feed, + `` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).toHaveProperty("podcastFollow"); + expect(result.podcastFollow).toHaveProperty( + "url", + "https://podnews.net/podcast/i8xe9/follow.json" + ); + expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName); + }); + + it("extracts an insecure HTTP follow url", () => { + const xml = helpers.spliceFeed( + feed, + `` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).toHaveProperty("podcastFollow"); + expect(result.podcastFollow).toHaveProperty("url", "http://example.com/follow.json"); + expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName); + }); + + it("handles multiple follow tags by taking the first one", () => { + const xml = helpers.spliceFeed( + feed, + ` + ` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).toHaveProperty("podcastFollow"); + expect(result.podcastFollow).toHaveProperty("url", "https://example.com/first.json"); + expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName); + }); + + it("handles multiple follow tags by taking the first valid one", () => { + const xml = helpers.spliceFeed( + feed, + ` + + ` + ); + const result = helpers.parseValidFeed(xml); + + expect(result).toHaveProperty("podcastFollow"); + expect(result.podcastFollow).toHaveProperty("url", "https://example.com/valid.json"); + expect(helpers.getPhaseSupport(result, phase)).toContain(supportedName); + }); + + it("ignores empty or whitespace-only url values", () => { + const empty = helpers.spliceFeed(feed, ``); + const whitespace = helpers.spliceFeed(feed, ``); + + expect(helpers.parseValidFeed(empty)).not.toHaveProperty("podcastFollow"); + expect(helpers.parseValidFeed(whitespace)).not.toHaveProperty("podcastFollow"); + }); + + it("ignores malformed and unsupported url values", () => { + const malformed = helpers.spliceFeed(feed, ``); + const unsupported = helpers.spliceFeed( + feed, + `` + ); + + expect(helpers.parseValidFeed(malformed)).not.toHaveProperty("podcastFollow"); + expect(helpers.getPhaseSupport(helpers.parseValidFeed(malformed), phase)).not.toContain( + supportedName + ); + expect(helpers.parseValidFeed(unsupported)).not.toHaveProperty("podcastFollow"); + expect(helpers.getPhaseSupport(helpers.parseValidFeed(unsupported), phase)).not.toContain( + supportedName + ); + }); + }); +}); diff --git a/src/parser/phase/index.ts b/src/parser/phase/index.ts index 5432152..ae8e980 100644 --- a/src/parser/phase/index.ts +++ b/src/parser/phase/index.ts @@ -16,6 +16,7 @@ import * as phase4 from "./phase-4"; import * as phase5 from "./phase-5"; import * as phase6 from "./phase-6"; import * as phase7 from "./phase-7"; +import * as phase8 from "./phase-8"; import * as pending from "./phase-pending"; import { XmlNodeSource } from "./types"; @@ -99,6 +100,8 @@ const feeds: FeedUpdate[] = [ phase7.podcastChat, phase7.podcastPublisher, + phase8.podcastFollow, + pending.metaBoost, pending.id, pending.social, diff --git a/src/parser/phase/phase-8.ts b/src/parser/phase/phase-8.ts new file mode 100644 index 0000000..fd6aad6 --- /dev/null +++ b/src/parser/phase/phase-8.ts @@ -0,0 +1,33 @@ +import { ensureArray, getAttribute, normalizeHttpUrl } from "../shared"; +import type { XmlNode } from "../types"; + +export type Phase8Follow = { + url: string; +}; + +function getFollowUrl(node: XmlNode): string | null { + const url = getAttribute(node, "url"); + return url ? normalizeHttpUrl(url, true) : null; +} + +export const podcastFollow = { + phase: 8, + name: "follow", + tag: "podcast:follow", + nodeTransform: (node: XmlNode | XmlNode[]): XmlNode | undefined => + ensureArray(node).find((n) => getFollowUrl(n)), + supportCheck: (node: XmlNode): boolean => Boolean(getFollowUrl(node)), + fn(node: XmlNode): { podcastFollow: Phase8Follow } { + const url = getFollowUrl(node); + + if (!url) { + throw new Error("Unable to extract phase 8 podcastFollow; supportCheck needs to be updated"); + } + + return { + podcastFollow: { + url, + }, + }; + }, +}; diff --git a/src/parser/types.ts b/src/parser/types.ts index 2134a65..dcfe370 100644 --- a/src/parser/types.ts +++ b/src/parser/types.ts @@ -22,6 +22,7 @@ import type { import type { Phase5Blocked, Phase5BlockedPlatforms, Phase5SocialInteract } from "./phase/phase-5"; import type { Phase6RemoteItem, Phase6TxtEntry } from "./phase/phase-6"; import type { Phase7Chat, Phase7Publisher } from "./phase/phase-7"; +import type { Phase8Follow } from "./phase/phase-8"; import { PhasePendingPodcastId, PhasePendingSocial, @@ -193,6 +194,11 @@ export interface FeedObject extends BasicFeed { chat?: Phase7Chat; podcastPublisher?: Phase7Publisher; + // #region Phase 8 + /** PENDING Phase 8: URL to a JSON document containing podcast follow links. */ + podcastFollow?: Phase8Follow; + // #endregion + /** Channel-level podcast:socialInteract (same shape as item podcastSocialInteraction). */ channelPodcastSocialInteract?: Phase5SocialInteract[]; // #region Pending Phase