feat: reader-compat batch — JSON Feed, OPML export, conditional GET, dedup

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]>
This commit is contained in:
Julien HerrandClaude Opus 4.7 committed 2026-05-24 20:47:54 +02:00
1 parent 334713fbd9
commit 0abd5f306c
23 files changed
+1015 -11

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
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");
});
});
});