mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-21 06:13:48 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import app from "./admin";
|
||||
import { createMockEnv } from "../test/setup";
|
||||
import { Env } from "../types";
|
||||
|
||||
describe("OPML export — GET /admin/opml", () => {
|
||||
let testApp: Hono;
|
||||
let mockEnv: Env;
|
||||
let request: (path: string, init?: RequestInit) => Promise<Response>;
|
||||
let loginAndGetCookie: () => Promise<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEnv = createMockEnv() as unknown as Env;
|
||||
testApp = new Hono();
|
||||
testApp.route("/admin", app);
|
||||
request = (path, init = {}) =>
|
||||
Promise.resolve(testApp.request(path, init, mockEnv));
|
||||
loginAndGetCookie = async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("password", "test-password");
|
||||
const response = await request("/admin/login", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
expect(response.status).toBe(302);
|
||||
const setCookie = response.headers.get("Set-Cookie");
|
||||
expect(setCookie).toBeTruthy();
|
||||
return (setCookie as string).split(";")[0];
|
||||
};
|
||||
});
|
||||
|
||||
it("should return 302 redirect to login when not authenticated", async () => {
|
||||
const res = await request("/admin/opml");
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("Location")).toBe("/admin/login");
|
||||
});
|
||||
|
||||
it("should return 200 with OPML content when authenticated", async () => {
|
||||
// Seed two feeds in the registry
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
"feeds:list",
|
||||
JSON.stringify({
|
||||
feeds: [
|
||||
{ id: "feed-abc", title: "My Newsletter", description: "Daily news" },
|
||||
{ id: "feed-xyz", title: "Tech Digest" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const authCookie = await loginAndGetCookie();
|
||||
const res = await request("/admin/opml", {
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
Origin: "https://test.getmynews.app",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const contentType = res.headers.get("Content-Type") ?? "";
|
||||
expect(contentType).toContain("text/x-opml");
|
||||
expect(res.headers.get("Content-Disposition")).toBe(
|
||||
'attachment; filename="feeds.opml"',
|
||||
);
|
||||
expect(res.headers.get("X-Robots-Tag")).toBe("noindex");
|
||||
|
||||
const body = await res.text();
|
||||
|
||||
// Valid OPML 2.0 structure
|
||||
expect(body).toContain('<?xml version="1.0" encoding="UTF-8"?>');
|
||||
expect(body).toContain('<opml version="2.0">');
|
||||
expect(body).toContain("<head>");
|
||||
expect(body).toContain("<title>kill-the-news feeds</title>");
|
||||
expect(body).toContain("<body>");
|
||||
|
||||
// One outline per feed with correct xmlUrl
|
||||
expect(body).toContain('type="rss"');
|
||||
expect(body).toContain('text="My Newsletter"');
|
||||
expect(body).toContain('title="My Newsletter"');
|
||||
expect(body).toContain('xmlUrl="https://test.getmynews.app/rss/feed-abc"');
|
||||
expect(body).toContain('description="Daily news"');
|
||||
|
||||
expect(body).toContain('text="Tech Digest"');
|
||||
expect(body).toContain('xmlUrl="https://test.getmynews.app/rss/feed-xyz"');
|
||||
|
||||
// feed-xyz has no description — attribute must not appear
|
||||
const feedXyzLine =
|
||||
body.split("\n").find((l) => l.includes("feed-xyz")) ?? "";
|
||||
expect(feedXyzLine).not.toContain("description=");
|
||||
});
|
||||
|
||||
it("should XML-escape special characters in title and description", async () => {
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
"feeds:list",
|
||||
JSON.stringify({
|
||||
feeds: [
|
||||
{
|
||||
id: "feed-special",
|
||||
title: "News & <Updates>",
|
||||
description: 'Say "hello" & goodbye',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const authCookie = await loginAndGetCookie();
|
||||
const res = await request("/admin/opml", {
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
Origin: "https://test.getmynews.app",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
|
||||
// Raw special chars must not appear unescaped in attribute values
|
||||
const outlineLine =
|
||||
body.split("\n").find((l) => l.includes("feed-special")) ?? "";
|
||||
expect(outlineLine).toContain("News & <Updates>");
|
||||
expect(outlineLine).toContain("Say "hello" & goodbye");
|
||||
expect(outlineLine).not.toContain('title="News & <');
|
||||
});
|
||||
|
||||
it("should return empty body element when there are no feeds", async () => {
|
||||
const authCookie = await loginAndGetCookie();
|
||||
const res = await request("/admin/opml", {
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
Origin: "https://test.getmynews.app",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("<body>");
|
||||
expect(body).not.toContain("<outline");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user