From b107803177d5b83baa9764b9d332354d18851268 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Thu, 21 May 2026 07:35:32 +0200 Subject: [PATCH] feat: add Atom 1.0 feed route at /atom/:feedId --- src/routes/atom.test.ts | 132 ++++++++++++++++++++++++++++++++++++++++ src/routes/atom.ts | 65 ++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 src/routes/atom.test.ts create mode 100644 src/routes/atom.ts diff --git a/src/routes/atom.test.ts b/src/routes/atom.test.ts new file mode 100644 index 0000000..d587a11 --- /dev/null +++ b/src/routes/atom.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { handle } from "./atom"; +import { createMockEnv } from "../test/setup"; +import { Env } from "../types"; + +describe("Atom Feed Route", () => { + let testApp: Hono; + let mockEnv: Env; + + beforeEach(() => { + mockEnv = createMockEnv() as unknown as Env; + testApp = new Hono(); + testApp.get("/:feedId", handle); + }); + + describe("unknown feed", () => { + it("returns 404 when no metadata exists in KV", async () => { + const res = await testApp.request("/nonexistent-feed", {}, mockEnv); + expect(res.status).toBe(404); + expect(await res.text()).toBe("Feed not found"); + }); + }); + + describe("valid feed with no emails", () => { + beforeEach(async () => { + await mockEnv.EMAIL_STORAGE.put( + "feed:empty-feed:metadata", + JSON.stringify({ emails: [] }), + ); + }); + + it("returns 200 with application/atom+xml content type", async () => { + const res = await testApp.request("/empty-feed", {}, mockEnv); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("application/atom+xml"); + }); + + it("returns valid Atom XML with no elements", async () => { + const res = await testApp.request("/empty-feed", {}, mockEnv); + const body = await res.text(); + expect(body).toContain('xmlns="http://www.w3.org/2005/Atom"'); + expect(body).not.toContain(""); + }); + + it("includes Cache-Control header", async () => { + const res = await testApp.request("/empty-feed", {}, mockEnv); + expect(res.headers.get("Cache-Control")).toBe("max-age=1800"); + }); + }); + + describe("valid feed with emails", () => { + const FEED_ID = "test-feed-atom"; + + beforeEach(async () => { + const emailKey = `feed:${FEED_ID}:1700000001000`; + await mockEnv.EMAIL_STORAGE.put( + emailKey, + JSON.stringify({ + subject: "Atom Entry Subject", + from: "Sender ", + content: "

Email body

", + receivedAt: 1700000001000, + headers: {}, + }), + ); + await mockEnv.EMAIL_STORAGE.put( + `feed:${FEED_ID}:metadata`, + JSON.stringify({ + emails: [ + { key: emailKey, subject: "Atom Entry Subject", receivedAt: 1700000001000 }, + ], + }), + ); + await mockEnv.EMAIL_STORAGE.put( + `feed:${FEED_ID}:config`, + JSON.stringify({ + title: "Atom Test Feed", + description: "Integration test", + site_url: "https://test.getmynews.app/rss/test-feed-atom", + feed_url: "https://test.getmynews.app/rss/test-feed-atom", + language: "en", + created_at: 1700000000000, + }), + ); + }); + + it("returns 200 with application/atom+xml", async () => { + const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("application/atom+xml"); + }); + + it("contains Atom namespace declaration", async () => { + const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv); + const body = await res.text(); + expect(body).toContain('xmlns="http://www.w3.org/2005/Atom"'); + }); + + it("contains for the email", async () => { + const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv); + const body = await res.text(); + expect(body).toContain(""); + expect(body).toContain("Atom Entry Subject"); + }); + + it("contains feed title", async () => { + const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv); + const body = await res.text(); + expect(body).toContain("Atom Test Feed"); + }); + + it("self-link points to atom URL", async () => { + const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv); + const body = await res.text(); + expect(body).toContain(`/atom/${FEED_ID}`); + }); + }); + + describe("fallback config when no config in KV", () => { + it("uses atom path in fallback and returns 200", async () => { + await mockEnv.EMAIL_STORAGE.put( + "feed:no-config-feed:metadata", + JSON.stringify({ emails: [] }), + ); + const res = await testApp.request("/no-config-feed", {}, mockEnv); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toContain('xmlns="http://www.w3.org/2005/Atom"'); + }); + }); +}); diff --git a/src/routes/atom.ts b/src/routes/atom.ts new file mode 100644 index 0000000..c05bea5 --- /dev/null +++ b/src/routes/atom.ts @@ -0,0 +1,65 @@ +import { Context } from "hono"; +import { Env, FeedConfig, FeedMetadata, EmailData } from "../types"; +import { generateAtomFeed } from "../utils/feed-generator"; + +export async function handle(c: Context): Promise { + try { + const env = c.env as unknown as Env; + + const feedId = c.req.param("feedId"); + + if (!feedId) { + return new Response("Feed ID is required", { status: 400 }); + } + + const emailStorage = env.EMAIL_STORAGE; + + const feedMetadata = (await emailStorage.get( + `feed:${feedId}:metadata`, + "json", + )) as FeedMetadata | null; + + if (!feedMetadata) { + return new Response("Feed not found", { status: 404 }); + } + + const feedConfig = ((await emailStorage.get( + `feed:${feedId}:config`, + "json", + )) as FeedConfig | null) || { + title: `Newsletter Feed ${feedId}`, + description: "Converted email newsletter", + site_url: `https://${env.DOMAIN}/atom/${feedId}`, + feed_url: `https://${env.DOMAIN}/atom/${feedId}`, + language: "en", + created_at: Date.now(), + }; + + const emails = feedMetadata.emails.slice(0, 20); + const emailsData: EmailData[] = []; + + for (const email of emails) { + const emailData = (await emailStorage.get( + email.key, + "json", + )) as EmailData | null; + if (emailData) { + emailsData.push(emailData); + } + } + + const baseUrl = `https://${env.DOMAIN}`; + const atomXml = generateAtomFeed(feedConfig, emailsData, baseUrl, feedId); + + return new Response(atomXml, { + status: 200, + headers: { + "Content-Type": "application/atom+xml", + "Cache-Control": "max-age=1800", + }, + }); + } catch (error) { + console.error("Error generating Atom feed:", error); + return new Response("Internal Server Error", { status: 500 }); + } +}