Batch of four reader-facing improvements (TODO "Compat lecteurs + dedup"): - JSON Feed at /json/:feedId (feed lib .json1()); all formats cross-link - OPML export at /admin/opml (admin-protected; the registry lists every feed URL, so it must not be public) - Conditional GET on /rss + /atom: strong ETag + Last-Modified, 304 on If-None-Match/If-Modified-Since, validators shared via http-cache.ts - Duplicate-send dedup in ingestion: match by Message-ID, fall back to a SHA-256 of normalized subject+content; a duplicate is a no-op and bumps the new emails_deduplicated counter (status page + /api/v1/stats) 429 tests green, tsc clean, build dry-run OK. Docs (README/CLAUDE/TODO + landing cards) updated. Co-Authored-By: Claude Opus 4.7 <[email protected]>
144 lines
4.5 KiB
TypeScript
144 lines
4.5 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import { Hono } from "hono";
|
|
import { handle } from "./json";
|
|
import { createMockEnv } from "../test/setup";
|
|
import { Env } from "../types";
|
|
|
|
describe("JSON 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/feed+json content type", async () => {
|
|
const res = await testApp.request("/empty-feed", {}, mockEnv);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("Content-Type")).toContain(
|
|
"application/feed+json",
|
|
);
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
it("Link header advertises hub and self", async () => {
|
|
const res = await testApp.request("/empty-feed", {}, mockEnv);
|
|
const link = res.headers.get("Link") ?? "";
|
|
expect(link).toContain(`rel="hub"`);
|
|
expect(link).toContain(`rel="self"`);
|
|
});
|
|
|
|
it("body parses as JSON with jsonfeed version 1.1", async () => {
|
|
const res = await testApp.request("/empty-feed", {}, mockEnv);
|
|
const body = (await res.json()) as { version: string; items: unknown[] };
|
|
expect(body.version).toBe("https://jsonfeed.org/version/1");
|
|
expect(Array.isArray(body.items)).toBe(true);
|
|
expect(body.items).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("valid feed with emails", () => {
|
|
const FEED_ID = "test-feed-json";
|
|
const EMAIL_RECEIVED_AT = 1700000001000;
|
|
|
|
beforeEach(async () => {
|
|
const emailKey = `feed:${FEED_ID}:${EMAIL_RECEIVED_AT}`;
|
|
await mockEnv.EMAIL_STORAGE.put(
|
|
emailKey,
|
|
JSON.stringify({
|
|
subject: "JSON Feed Subject",
|
|
from: "Sender <[email protected]>",
|
|
content: "<p>Body content</p>",
|
|
receivedAt: EMAIL_RECEIVED_AT,
|
|
headers: {},
|
|
}),
|
|
);
|
|
await mockEnv.EMAIL_STORAGE.put(
|
|
`feed:${FEED_ID}:metadata`,
|
|
JSON.stringify({
|
|
emails: [
|
|
{
|
|
key: emailKey,
|
|
subject: "JSON Feed Subject",
|
|
receivedAt: EMAIL_RECEIVED_AT,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
await mockEnv.EMAIL_STORAGE.put(
|
|
`feed:${FEED_ID}:config`,
|
|
JSON.stringify({
|
|
title: "My JSON Feed",
|
|
language: "en",
|
|
created_at: 1700000000000,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns 200 with items containing the seeded email", async () => {
|
|
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
version: string;
|
|
items: Array<{ title: string }>;
|
|
};
|
|
expect(body.version).toBe("https://jsonfeed.org/version/1");
|
|
expect(Array.isArray(body.items)).toBe(true);
|
|
expect(body.items).toHaveLength(1);
|
|
expect(body.items[0].title).toBe("JSON Feed Subject");
|
|
});
|
|
});
|
|
|
|
describe("expired feed", () => {
|
|
beforeEach(async () => {
|
|
const pastTimestamp = Date.now() - 1000 * 60 * 60 * 24; // 1 day ago
|
|
await mockEnv.EMAIL_STORAGE.put(
|
|
"feed:expired-feed:metadata",
|
|
JSON.stringify({ emails: [] }),
|
|
);
|
|
await mockEnv.EMAIL_STORAGE.put(
|
|
"feed:expired-feed:config",
|
|
JSON.stringify({
|
|
title: "Expired Feed",
|
|
language: "en",
|
|
created_at: pastTimestamp,
|
|
expires_at: pastTimestamp,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns 410 for expired feed", async () => {
|
|
const res = await testApp.request("/expired-feed", {}, mockEnv);
|
|
expect(res.status).toBe(410);
|
|
expect(await res.text()).toBe("Feed has expired");
|
|
});
|
|
});
|
|
});
|