Files
kill-the-news/src/routes/atom.test.ts
T
Julien Herr 1332362005 fix(feeds): self link uses configured domain, not request host
The RSS/Atom/JSON self link was derived from the request origin, leaking
the workers.dev host when reached directly instead of via the custom
domain. Use the configured-domain URL builders so self matches alternate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:38:38 +02:00

263 lines
8.3 KiB
TypeScript

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 <entry> 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("<entry>");
});
it("includes Cache-Control header", async () => {
const res = await testApp.request("/empty-feed", {}, mockEnv);
expect(res.headers.get("Cache-Control")).toBe("max-age=1800");
});
it("sets X-Robots-Tag: noindex", async () => {
const res = await testApp.request("/empty-feed", {}, mockEnv);
expect(res.headers.get("X-Robots-Tag")).toBe("noindex");
});
});
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 <sender@example.com>",
content: "<p>Email body</p>",
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",
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 <entry> for the email", async () => {
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
const body = await res.text();
expect(body).toContain("<entry>");
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 uses the configured domain, not the request host", async () => {
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
const body = await res.text();
expect(body).toContain(
`rel="self" href="https://${mockEnv.DOMAIN}/atom/${FEED_ID}"`,
);
});
it("Link header advertises hub and self for WebSub discovery", async () => {
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
const link = res.headers.get("Link") ?? "";
expect(link).toContain(`rel="hub"`);
expect(link).toContain(`/atom/${FEED_ID}`);
expect(link).toContain(`rel="self"`);
});
});
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"');
});
});
describe("conditional GET (ETag + Last-Modified)", () => {
const FEED_ID = "test-feed-atom-cget";
const EMAIL_RECEIVED_AT = 1700000001000;
beforeEach(async () => {
const emailKey = `feed:${FEED_ID}:${EMAIL_RECEIVED_AT}`;
await mockEnv.EMAIL_STORAGE.put(
emailKey,
JSON.stringify({
subject: "Atom Subject",
from: "Sender <sender@example.com>",
content: "<p>Body</p>",
receivedAt: EMAIL_RECEIVED_AT,
headers: {},
}),
);
await mockEnv.EMAIL_STORAGE.put(
`feed:${FEED_ID}:metadata`,
JSON.stringify({
emails: [
{
key: emailKey,
subject: "Atom Subject",
receivedAt: EMAIL_RECEIVED_AT,
},
],
}),
);
await mockEnv.EMAIL_STORAGE.put(
`feed:${FEED_ID}:config`,
JSON.stringify({
title: "Atom Cget Feed",
language: "en",
created_at: 1700000000000,
}),
);
});
it("first GET returns 200 with ETag and Last-Modified headers", async () => {
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
expect(res.status).toBe(200);
expect(res.headers.get("ETag")).toBeTruthy();
expect(res.headers.get("Last-Modified")).toBeTruthy();
});
it("GET with matching If-None-Match returns 304 with empty body", async () => {
const first = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
const etag = first.headers.get("ETag")!;
const res = await testApp.request(
`/${FEED_ID}`,
{ headers: { "If-None-Match": etag } },
mockEnv,
);
expect(res.status).toBe(304);
expect(await res.text()).toBe("");
});
it("GET with If-Modified-Since in the future returns 304", async () => {
const future = new Date(EMAIL_RECEIVED_AT + 1000).toUTCString();
const res = await testApp.request(
`/${FEED_ID}`,
{ headers: { "If-Modified-Since": future } },
mockEnv,
);
expect(res.status).toBe(304);
});
it("stale If-None-Match after new email results in 200", async () => {
const first = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
const oldEtag = first.headers.get("ETag")!;
const newReceivedAt = EMAIL_RECEIVED_AT + 5000;
const newEmailKey = `feed:${FEED_ID}:${newReceivedAt}`;
await mockEnv.EMAIL_STORAGE.put(
newEmailKey,
JSON.stringify({
subject: "Newer Atom Email",
from: "Sender <sender@example.com>",
content: "<p>New body</p>",
receivedAt: newReceivedAt,
headers: {},
}),
);
await mockEnv.EMAIL_STORAGE.put(
`feed:${FEED_ID}:metadata`,
JSON.stringify({
emails: [
{
key: newEmailKey,
subject: "Newer Atom Email",
receivedAt: newReceivedAt,
},
{
key: `feed:${FEED_ID}:${EMAIL_RECEIVED_AT}`,
subject: "Atom Subject",
receivedAt: EMAIL_RECEIVED_AT,
},
],
}),
);
const res = await testApp.request(
`/${FEED_ID}`,
{ headers: { "If-None-Match": oldEtag } },
mockEnv,
);
expect(res.status).toBe(200);
const newEtag = res.headers.get("ETag");
expect(newEtag).not.toBe(oldEtag);
});
});
});