diff --git a/lib/error.ts b/lib/error.ts deleted file mode 100644 index 398792e..0000000 --- a/lib/error.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class NotFoundError extends Error { - constructor(url: URL, context: string) { - super(`Not found (${context}): ${url}`); - this.name = "NotFoundError"; - } -} diff --git a/lib/feed-handler.test.ts b/lib/feed-handler.test.ts index d9ae648..b63d263 100644 --- a/lib/feed-handler.test.ts +++ b/lib/feed-handler.test.ts @@ -1,8 +1,11 @@ import { strict as assert } from "node:assert"; import test from "node:test"; +import { json } from "itty-router"; import { feedHandler } from "./feed-handler.js"; +import { FetchWithErr, NotOk, OkResponse } from "./fetch.js"; import * as logger from "./logger.js"; -import { fetchMock, raiBaseUrl } from "./test/fetch-mock.js"; +import genresJson from "./test/generi.json"; +import feedJson from "./test/lastoriaingiallo.json"; import expectedJson from "./test/lastoriaingiallo.parsed.json" with { type: "json", }; @@ -16,14 +19,64 @@ test("feed-handler", async (t) => { }); const baseUrl = new URL("https://test.dev/"); +const raiBaseUrl = new URL("https://rai.dev/"); +const mediaBaseUrl = new URL("https://media.dev/"); + +const fetchMock: FetchWithErr = async (input, init) => { + const requestUrlStr = input.toString(); + const url = new URL(requestUrlStr); + const { protocol, hostname, pathname, search } = url; + + if (!(protocol === raiBaseUrl.protocol && hostname === raiBaseUrl.hostname)) { + throw new Error(`unexpected request to ${requestUrlStr}`); + } + + if (pathname === "/generi.json") { + return json(genresJson) as OkResponse; + } + + if (pathname === "/programmi/lastoriaingiallo.json") { + return json(feedJson) as OkResponse; + } + if (pathname === "/programmi/500.json") { + throw new NotOk(url, 500, "server error"); + } + if (pathname === "/programmi/corrupt.json") { + return json({ foo: "bar" }) as OkResponse; + } + + const relinkerRel = "/relinker/relinkerServlet.htm"; + const relinkerSearchStart = "?cont="; + if ( + init?.method === "HEAD" && + pathname === relinkerRel && + search.startsWith(relinkerSearchStart) + ) { + const urlStart = requestUrlStr.replace( + new URL(`${relinkerRel}${relinkerSearchStart}`, raiBaseUrl).toString(), + mediaBaseUrl.toString(), + ); + const url = `${urlStart}.mp3`; + return { + url: url, + headers: new Headers({ + "Content-Type": "audio/mpeg", + "Content-Length": "123456789", + }), + } as OkResponse; + } + + throw new NotOk(url, 404, "not found"); +}; const conf = { baseUrl, raiBaseUrl, poolSize: 1, - fetch: fetchMock, + fetchWithErr: fetchMock, logger: logger.disabled, }; + async function rssFeedSuccess() { const req = new Request("https://test.dev/programmi/lastoriaingiallo.xml"); const resp = await feedHandler(conf, req); @@ -42,7 +95,7 @@ async function rssFeedFail404() { const text = await resp.text(); assert.strictEqual( text, - "404Not Found", + "404not found", ); } diff --git a/lib/feed-handler.ts b/lib/feed-handler.ts index e67c40b..b1050e9 100644 --- a/lib/feed-handler.ts +++ b/lib/feed-handler.ts @@ -1,13 +1,13 @@ import { createResponse } from "itty-router"; -import { NotFoundError } from "./error.js"; import { convertFeed } from "./feed.js"; +import { FetchWithErr, NotOk } from "./fetch.js"; import { Logger } from "./logger.js"; type Config = { baseUrl: URL; raiBaseUrl: URL; poolSize: number; - fetch: typeof fetch; + fetchWithErr: FetchWithErr; logger: Logger; }; export async function feedHandler( @@ -22,14 +22,14 @@ export async function feedHandler( try { feedXml = await convertFeed(conf, jsonPath); } catch (e) { - const headers = new Headers({ "Content-Type": "application/xml" }); + conf.logger.error("error converting feed", jsonPath, e); + const contentType = "application/xml"; + const headers = new Headers({ "Content-Type": contentType }); let status = 500; let body = "500server error"; - if (e instanceof NotFoundError) { - status = 404; - body = "404Not Found"; - } else { - conf.logger.error("error converting feed", jsonPath, e); + if (e instanceof NotOk && e.status === 404) { + status = e.status; + body = "404not found"; } return new Response(body, { status, headers }); } diff --git a/lib/feed.test.ts b/lib/feed.test.ts index 7639ab8..5d79d5e 100644 --- a/lib/feed.test.ts +++ b/lib/feed.test.ts @@ -1,8 +1,8 @@ import { strict as assert } from "node:assert"; import test from "node:test"; -import { error, json } from "itty-router"; -import { NotFoundError } from "./error.js"; +import { json } from "itty-router"; import { ConvertConf, convertFeed } from "./feed.js"; +import { FetchWithErr, NotOk, OkResponse } from "./fetch.js"; import feedJson from "./test/lastoriaingiallo.json" with { type: "json" }; import expectedJson from "./test/lastoriaingiallo.parsed.json" with { type: "json", @@ -18,12 +18,11 @@ test("feed", async (t) => { const baseUrl = new URL("https://test.dev/"); const raiBaseUrl = new URL("https://rai.dev/"); const poolSize = 5; // arbitrary -const feedFetchFn: typeof fetch = async (input) => { - assert.strictEqual(input.toString(), "https://rai.dev/programmi/foo.json"); - return json(feedJson); -}; -const mediaFetchFn: typeof fetch = async (input) => - ({ + +const feedFetchFn: FetchWithErr = () => + Promise.resolve(json(feedJson) as OkResponse); +const mediaFetchFn: FetchWithErr = (input) => + Promise.resolve({ url: input .toString() .replace(/.+cont=(.*)/, (_, cont) => `https://media.dev/${cont}.mp3`), @@ -32,33 +31,32 @@ const mediaFetchFn: typeof fetch = async (input) => "content-type": "audio/mpeg", "content-length": "123456789", }), - }) as Response; + } as OkResponse); async function convertFeedSuccess() { - const fetchFn: typeof fetch = async (input) => { + const fetchWithErr: FetchWithErr = async (input) => { return input.toString().endsWith("foo.json") ? feedFetchFn(input) : mediaFetchFn(input); }; - const conf: ConvertConf = { raiBaseUrl, baseUrl, poolSize, fetch: fetchFn }; + const conf: ConvertConf = { raiBaseUrl, baseUrl, poolSize, fetchWithErr }; const feed = await convertFeed(conf, "programmi/foo.json"); const parsed = parseFeed(feed); assert.deepStrictEqual(parsed, expectedJson); } async function convertFeed404() { - const fetchFn = async () => error(404, "Not found"); - const conf: ConvertConf = { raiBaseUrl, baseUrl, poolSize, fetch: fetchFn }; - const expectedErr = new NotFoundError( - new URL("https://rai.dev/programmi/foo.json"), - "fetching feed", - ); - await assert.rejects(convertFeed(conf, "programmi/foo.json"), expectedErr); + const url = new URL("https://rai.dev/programmi/foo.json"); + const notFound = new NotOk(url, 404, "Not Found"); + const fetchWithErr = () => Promise.reject(notFound); + const conf: ConvertConf = { raiBaseUrl, baseUrl, poolSize, fetchWithErr }; + await assert.rejects(convertFeed(conf, "programmi/foo.json"), notFound); } async function convertFeedNonCompliantJson() { - const fetchFn = async () => json({ foo: "bar" }); - const conf: ConvertConf = { raiBaseUrl, baseUrl, poolSize, fetch: fetchFn }; + const fetchWithErr = () => + Promise.resolve(json({ foo: "bar" }) as OkResponse); + const conf: ConvertConf = { raiBaseUrl, baseUrl, poolSize, fetchWithErr }; const expectedErr = /^Error: failed to parse feed JSON/; await assert.rejects(convertFeed(conf, "programmi/foo.json"), expectedErr); } diff --git a/lib/feed.ts b/lib/feed.ts index 6659558..36896da 100644 --- a/lib/feed.ts +++ b/lib/feed.ts @@ -1,8 +1,8 @@ import { PromisePool } from "@supercharge/promise-pool"; import { z } from "zod"; import { Podcast } from "../build/podcast/index.js"; -import { NotFoundError } from "./error.js"; -import { fetchInfo } from "./media.js"; +import { FetchWithErr } from "./fetch.js"; +import * as media from "./media.js"; const cardSchema = z.object({ episode_title: z.string(), @@ -32,58 +32,39 @@ export type ConvertConf = { raiBaseUrl: URL; baseUrl: URL; poolSize: number; - fetch: typeof fetch; + fetchWithErr: FetchWithErr; }; export async function convertFeed( c: ConvertConf, relUrl: string, ): Promise { + const fetchInfo = media.mkFetchInfo(c.fetchWithErr); const convertor = new Convertor({ raiBaseUrl: c.raiBaseUrl, poolSize: c.poolSize, - fetch: c.fetch, + fetchInfo, }); - const feedJson = await fetchFeed(c, relUrl); - return convertor.convert(feedJson); -} - -type FetcherConf = { - raiBaseUrl: URL; - fetch: typeof fetch; -}; - -async function fetchFeed( - { raiBaseUrl, fetch }: FetcherConf, - relUrl: string, -): Promise { - const url = new URL(relUrl, raiBaseUrl); - const res = await fetch(url); - if (!res.ok) { - if (res.status === 404) { - throw new NotFoundError(url, "fetching feed"); - } - throw new Error( - `Failed to fetch ${url}: ${res.status} ${res.statusText}`.trim(), - ); - } - return res.json(); + const url = new URL(relUrl, c.raiBaseUrl); + const resp = await c.fetchWithErr(url); + const json = await resp.json(); + return convertor.convert(json); } type ConvertorConf = { raiBaseUrl: URL; poolSize: number; - fetch: typeof fetch; + fetchInfo: media.FetchInfo; }; class Convertor { readonly #raiBaseUrl: URL; readonly #poolSize: number; - readonly #fetch: typeof fetch; + readonly #fetchInfo: media.FetchInfo; - constructor({ raiBaseUrl, poolSize, fetch }: ConvertorConf) { + constructor({ raiBaseUrl, poolSize, fetchInfo }: ConvertorConf) { this.#raiBaseUrl = raiBaseUrl; this.#poolSize = poolSize; - this.#fetch = fetch; + this.#fetchInfo = fetchInfo; } // TODO: feedUrl, siteUrl @@ -125,7 +106,7 @@ class Convertor { async convertCard(card: Card) { const imageUrl = new URL(card.image, this.#raiBaseUrl).toString(); const date = new Date(card.track_info.date); - const mediaInfo = await fetchInfo(this.#fetch, card.downloadable_audio.url); + const mediaInfo = await this.#fetchInfo(card.downloadable_audio.url); const url = mediaInfo.url.toString(); return { title: card.episode_title, diff --git a/lib/fetch.ts b/lib/fetch.ts new file mode 100644 index 0000000..a61cb99 --- /dev/null +++ b/lib/fetch.ts @@ -0,0 +1,48 @@ +export { FetchWithErr, NotOk, OkResponse, mkFetchWithErr }; + +type FetchWithErr = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +type OkResponse = Response & { ok: true }; + +/** + * Returns a fetch function that errors on a non-ok response + * @param fetchFn - The underlying fetch function to use + * @returns A fetch function that throws an error for non-ok responses + */ +const mkFetchWithErr = + (fetchFn: typeof fetch): FetchWithErr => + async (input, init) => { + const res = await fetchFn(input, init); + if (!res.ok) { + // maybe we should use the url from the response? + const url = new URL(input.toString()); + throw new NotOk(url, res.status, res.statusText); + } + // this assertion really shouldn't be necessary... + return res as OkResponse; + }; + +/** + * Error class for non-ok responses + */ +class NotOk extends Error { + readonly url: URL; + readonly status: number; + readonly statusText: string; + + /** + * @param url - The URL that resulted in a non-ok response + * @param status - The status code of the non-ok response + * @param statusText - The status text of the non-ok response + */ + constructor(url: URL, status: number, statusText: string) { + super(`Not ok: ${status} ${statusText} (${url})`); + this.name = "NotOkError"; + this.url = url; + this.status = status; + this.statusText = statusText; + } +} diff --git a/lib/genres.test.ts b/lib/genres.test.ts index b1864fd..a069dae 100644 --- a/lib/genres.test.ts +++ b/lib/genres.test.ts @@ -1,7 +1,7 @@ import { strict as assert } from "node:assert"; import test from "node:test"; import { json } from "itty-router"; -import { NotFoundError } from "./error.js"; +import { FetchWithErr, NotOk, OkResponse } from "./fetch.js"; import { genresHtml } from "./genres.js"; import genresJson from "./test/generi.json" with { type: "json" }; @@ -12,47 +12,36 @@ test("genres", async (t) => { }); async function genresHtmlSuccess() { - const fetchFn: typeof fetch = async (input) => { - assert.strictEqual(input.toString(), "https://rai.dev/generi.json"); - return json(genresJson); - }; - const conf = confWithFetch(fetchFn); + const fetchWithErr: FetchWithErr = () => + Promise.resolve(json(genresJson) as OkResponse); + const conf = confWithFetch(fetchWithErr); await genresHtml(conf); } async function genresHtmlNotFound() { - const fetchFn: typeof fetch = async (input) => { - assert.strictEqual(input.toString(), "https://rai.dev/generi.json"); - return new Response("Not found", { status: 404 }); - }; - const conf = confWithFetch(fetchFn); - - const expectedErr = new NotFoundError( - new URL("https://rai.dev/generi.json"), - "fetching genres", - ); + const url = new URL("https://rai.dev/generi.json"); + const notFound = new NotOk(url, 404, "Not Found"); + const fetchWithErr: FetchWithErr = () => Promise.reject(notFound); + const conf = confWithFetch(fetchWithErr); + const p = genresHtml(conf); - await assert.rejects(p, expectedErr); + await assert.rejects(p, notFound); } async function genresHtml500() { - const fetchFn: typeof fetch = async (input) => { - assert.strictEqual(input.toString(), "https://rai.dev/generi.json"); - return new Response("Internal Server Error", { status: 500 }); - }; - const conf = confWithFetch(fetchFn); - - const expectedErr = new Error( - "Failed to fetch https://rai.dev/generi.json 500", - ); + const url = new URL("https://rai.dev/generi.json"); + const internalServerErr = new NotOk(url, 500, "Internal Server Error"); + const fetchWithErr: FetchWithErr = () => Promise.reject(internalServerErr); + const conf = confWithFetch(fetchWithErr); + const p = genresHtml(conf); - await assert.rejects(p, expectedErr); + await assert.rejects(p, internalServerErr); } -const confWithFetch = (fetchFn: typeof fetch) => ({ +const confWithFetch = (fetchWithErr: FetchWithErr) => ({ baseUrl: new URL("https://test.dev/"), raiBaseUrl: new URL("https://rai.dev/"), genresRel: "generi.json", - fetch: fetchFn, + fetchWithErr, }); diff --git a/lib/genres.ts b/lib/genres.ts index 3537963..ca03045 100644 --- a/lib/genres.ts +++ b/lib/genres.ts @@ -1,5 +1,7 @@ import { z } from "zod"; -import { NotFoundError } from "./error.js"; +import { FetchWithErr } from "./fetch.js"; + +export { Conf, genresHtml }; const cardSchema = z.object({ title: z.string(), @@ -13,30 +15,20 @@ const schema = z.object({ }), }); -export type Conf = { +type Conf = { raiBaseUrl: URL; baseUrl: URL; - fetch: typeof fetch; + fetchWithErr: FetchWithErr; }; -export async function genresHtml(c: Conf): Promise { +async function genresHtml(c: Conf): Promise { const json = await fetchGenres(c); return renderGenres(c.baseUrl, json); } const fetchGenres = async (c: Conf) => { const url = new URL("generi.json", c.raiBaseUrl); - const res = await c.fetch(url.toString()); - - if (!res.ok) { - if (res.status === 404) { - throw new NotFoundError(url, "fetching genres"); - } - throw new Error( - `Failed to fetch ${url} ${res.status} ${res.statusText}`.trim(), - ); - } - + const res = await c.fetchWithErr(url.toString()); return res.json(); }; diff --git a/lib/handler.test.ts b/lib/handler.test.ts index d42df3d..f14042c 100644 --- a/lib/handler.test.ts +++ b/lib/handler.test.ts @@ -1,16 +1,18 @@ import { strict as assert } from "node:assert"; import test from "node:test"; +import { error, json } from "itty-router"; import { mkFetchHandler } from "./handler.js"; import * as logger from "./logger.js"; -import { fetchMock } from "./test/fetch-mock.js"; +import genresJson from "./test/generi.json"; +import feedJson from "./test/lastoriaingiallo.json"; import expectedJson from "./test/lastoriaingiallo.parsed.json" with { type: "json", }; import { parseFeed } from "./test/parse-feed.js"; test("handler", async (t) => { - await t.test(indexSucccess); - await t.test(rssFeedSuccess); + await t.test(indexSuccess); + await test(rssFeedSuccess); await t.test(rssFeedFail404); await t.test(rssFeedFail500); await t.test(rssFeedFailCorrupt); @@ -19,16 +21,9 @@ test("handler", async (t) => { const baseUrl = new URL("https://test.dev/"); const raiBaseUrl = new URL("https://rai.dev/"); +const mediaBaseUrl = new URL("https://media.dev/"); -const fetchHandler = mkFetchHandler({ - baseUrl, - raiBaseUrl, - poolSize: 1, - fetch: fetchMock, - logger: logger.disabled, -}); - -async function indexSucccess() { +async function indexSuccess() { const req = new Request("arbitrary://arbitrary/"); const resp = await fetchHandler(req); @@ -58,7 +53,7 @@ async function rssFeedFail404() { const text = await resp.text(); assert.strictEqual( text, - "404Not Found", + "404not found", ); } @@ -95,3 +90,58 @@ async function notFound() { const text = await resp.text(); assert.strictEqual(text, "Not found."); } + +const fetchMock: typeof fetch = async (input, init) => { + const requestUrlStr = input.toString(); + const { protocol, hostname, pathname, search } = new URL(requestUrlStr); + + if (!(protocol === raiBaseUrl.protocol && hostname === raiBaseUrl.hostname)) { + throw new Error(`unexpected request to ${requestUrlStr}`); + } + + if (pathname === "/generi.json") { + return json(genresJson); + } + + if (pathname === "/programmi/lastoriaingiallo.json") { + return json(feedJson); + } + if (pathname === "/programmi/500.json") { + return error(500, "internal server error"); + } + if (pathname === "/programmi/corrupt.json") { + return json({ foo: "bar" }); + } + + const relinkerRel = "/relinker/relinkerServlet.htm"; + const relinkerSearchStart = "?cont="; + if ( + init?.method === "HEAD" && + pathname === relinkerRel && + search.startsWith(relinkerSearchStart) + ) { + const urlStart = requestUrlStr.replace( + new URL(`${relinkerRel}${relinkerSearchStart}`, raiBaseUrl).toString(), + mediaBaseUrl.toString(), + ); + const url = `${urlStart}.mp3`; + return { + url: url, + headers: new Headers({ + "Content-Type": "audio/mpeg", + "Content-Length": "123456789", + }), + ok: true, + } as Response; + } + + return error(404, "not found"); +}; + +const fetchHandler = mkFetchHandler({ + baseUrl, + raiBaseUrl, + poolSize: 1, + fetch: fetchMock, + logger: logger.disabled, +}); diff --git a/lib/handler.ts b/lib/handler.ts index 8e356c1..f5df3c2 100644 --- a/lib/handler.ts +++ b/lib/handler.ts @@ -1,5 +1,6 @@ import { Router, createResponse, error, html, text } from "itty-router"; import { feedHandler } from "./feed-handler.js"; +import { mkFetchWithErr } from "./fetch.js"; import { genresHtml } from "./genres.js"; import { Logger } from "./logger.js"; @@ -13,11 +14,27 @@ export type FetchHandlerConfig = { type FetchHandler = (req: Request) => Promise; export function mkFetchHandler(conf: FetchHandlerConfig): FetchHandler { + const fetchWithErr = mkFetchWithErr(conf.fetch); + + const fetchGenresConf = { + baseUrl: conf.baseUrl, + raiBaseUrl: conf.raiBaseUrl, + fetchWithErr, + logger: conf.logger, + }; const fetchGenres = async () => { - const gh = await genresHtml(conf); + const gh = await genresHtml(fetchGenresConf); return html(gh, { headers: { "Content-Language": "it" } }); }; - const fetchFeed = (request: Request) => feedHandler(conf, request); + + const fetchFeedConf = { + baseUrl: conf.baseUrl, + raiBaseUrl: conf.raiBaseUrl, + poolSize: conf.poolSize, + fetchWithErr, + logger: conf.logger, + }; + const fetchFeed = (request: Request) => feedHandler(fetchFeedConf, request); const router = Router() .get("/", fetchGenres) diff --git a/lib/media.test.ts b/lib/media.test.ts index dc4ff5b..5fa2bee 100644 --- a/lib/media.test.ts +++ b/lib/media.test.ts @@ -1,6 +1,7 @@ import { strict as assert } from "node:assert"; import test from "node:test"; -import { fetchInfo } from "./media.js"; +import { FetchWithErr, OkResponse } from "./fetch.js"; +import { mkFetchInfo } from "./media.js"; test("media", (t) => { return t.test(fetchInfoSuccess); @@ -10,7 +11,7 @@ async function fetchInfoSuccess() { const url = "https://mediapolisvod.rai.it/relinker/relinkerServlet.htm?cont=PE3wc6etKfssSlashNKfaoXssSlashpWcgeeqqEEqualeeqqEEqual"; const mediaUrl = new URL("https://test.dev/foo.mp3"); - const fetch: typeof globalThis.fetch = async () => + const fetch: FetchWithErr = async () => ({ url: mediaUrl.toString(), status: 200, @@ -18,8 +19,10 @@ async function fetchInfoSuccess() { "content-type": "audio/mpeg", "content-length": "123456789", }), - }) as Response; - const info = await fetchInfo(fetch, url); + }) as OkResponse; + const fetchInfo = mkFetchInfo(fetch); + + const info = await fetchInfo(url); assert.deepStrictEqual(info, { url: mediaUrl, size: 123456789, diff --git a/lib/media.ts b/lib/media.ts index 33d396e..a6eea41 100644 --- a/lib/media.ts +++ b/lib/media.ts @@ -1,7 +1,8 @@ -export { MediaInfo, MediaUrl, fetchInfo }; +import { FetchWithErr } from "./fetch.js"; -const chromeAgent = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/[latest_version] Safari/537.36"; +export { FetchInfo, MediaInfo, MediaUrl, mkFetchInfo }; + +type FetchInfo = (url: string) => Promise; type MediaUrl = URL; @@ -11,40 +12,42 @@ type MediaInfo = { }; const relinkerRe = /^\?cont=[a-zA-Z0-9]+$/; -async function fetchInfo( - fetch: typeof globalThis.fetch, - url: string, -): Promise { - const mediaUrl = mkMediaUrl(url); - if (typeof mediaUrl === "string") { - const err = `Invalid URL (${url}): ${mediaUrl}`; - throw new Error(err); - } - const chromeHeadInit: RequestInit = { - method: "HEAD", - headers: { - "User-Agent": chromeAgent, - }, - }; - const resp = await fetch(url, chromeHeadInit); +const mkFetchInfo = + (fetchWithErr: FetchWithErr): FetchInfo => + async (url) => { + const mediaUrl = mkMediaUrl(url); + if (typeof mediaUrl === "string") { + const err = `Invalid URL (${url}): ${mediaUrl}`; + throw new Error(err); + } - const expectedContentType = "audio/mpeg"; - const contentType = resp.headers.get("content-type"); - if (contentType !== expectedContentType) { - throw new Error( - `Invalid content type: ${contentType}, wanted ${expectedContentType}`, - ); - } + const chromeAgent = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/[latest_version] Safari/537.36"; + const chromeHeadInit: RequestInit = { + method: "HEAD", + headers: { + "User-Agent": chromeAgent, + }, + }; + const resp = await fetchWithErr(url, chromeHeadInit); - const contentLength = resp.headers.get("content-length"); - const length = Number(contentLength); - if (Number.isNaN(length)) { - throw new Error(`Invalid content length: ${contentLength}`); - } + const expectedContentType = "audio/mpeg"; + const contentType = resp.headers.get("content-type"); + if (contentType !== expectedContentType) { + throw new Error( + `Invalid content type: ${contentType}, wanted ${expectedContentType}`, + ); + } - return { url: new URL(resp.url), size: length }; -} + const contentLength = resp.headers.get("content-length"); + const length = Number(contentLength); + if (Number.isNaN(length)) { + throw new Error(`Invalid content length: ${contentLength}`); + } + + return { url: new URL(resp.url), size: length }; + }; function mkMediaUrl(urlStr: string): MediaUrl | string { let url: URL; diff --git a/lib/test/fetch-mock.ts b/lib/test/fetch-mock.ts deleted file mode 100644 index a74c33a..0000000 --- a/lib/test/fetch-mock.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { error, json } from "itty-router"; -import genresJson from "./generi.json"; -import feedJson from "./lastoriaingiallo.json"; - -export const raiBaseUrl = new URL("https://rai.dev/"); -const mediaBaseUrl = new URL("https://media.dev/"); -export const fetchMock: typeof fetch = async (input, init) => { - const requestUrlStr = input.toString(); - const { protocol, hostname, pathname, search } = new URL(requestUrlStr); - - if (!(protocol === raiBaseUrl.protocol && hostname === raiBaseUrl.hostname)) { - throw new Error(`unexpected request to ${requestUrlStr}`); - } - - if (pathname === "/generi.json") { - return json(genresJson); - } - - if (pathname === "/programmi/lastoriaingiallo.json") { - return json(feedJson); - } - if (pathname === "/programmi/500.json") { - return error(500, "internal server error"); - } - if (pathname === "/programmi/corrupt.json") { - return json({ foo: "bar" }); - } - - const relinkerRel = "/relinker/relinkerServlet.htm"; - const relinkerSearchStart = "?cont="; - if ( - init?.method === "HEAD" && - pathname === relinkerRel && - search.startsWith(relinkerSearchStart) - ) { - const urlStart = requestUrlStr.replace( - new URL(`${relinkerRel}${relinkerSearchStart}`, raiBaseUrl).toString(), - mediaBaseUrl.toString(), - ); - const url = `${urlStart}.mp3`; - return { - url: url, - headers: new Headers({ - "Content-Type": "audio/mpeg", - "Content-Length": "123456789", - }), - } as Response; - } - - return error(404, "not found"); -};