mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-21 14:23:48 +00:00
feat(favicon): per-feed icon from the last sender's domain
Resolve each feed's most recent sender domain and serve its favicon at GET /favicon/:feedId, falling back to the project icon. Icons are fetched in the background on ingestion (direct /favicon.ico then a DuckDuckGo fallback), cached base64 in KV keyed by domain with a 1-week TTL so the fetch only fires when absent. Exposed via RSS <image> / Atom <icon>/<logo> and rendered in the admin feed list, plus a landing-page feature card. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import worker from "../index";
|
||||
import { createMockEnv } from "../test/setup";
|
||||
import { iconKey } from "../utils/storage";
|
||||
import type { Env } from "../types";
|
||||
|
||||
function req(path: string): Request {
|
||||
return new Request(`https://test.getmynews.app${path}`);
|
||||
}
|
||||
|
||||
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 9, 8, 7]);
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
describe("project favicon", () => {
|
||||
it("serves an SVG favicon at /favicon.svg", async () => {
|
||||
const env = createMockEnv() as unknown as Env;
|
||||
@@ -27,3 +34,58 @@ describe("project favicon", () => {
|
||||
expect(body).toContain("<svg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-feed favicon", () => {
|
||||
it("serves the cached domain icon when available", async () => {
|
||||
const env = createMockEnv() as unknown as Env;
|
||||
await env.EMAIL_STORAGE.put(
|
||||
"feed:abc:metadata",
|
||||
JSON.stringify({ emails: [], iconDomain: "github.com" }),
|
||||
);
|
||||
await env.EMAIL_STORAGE.put(
|
||||
iconKey("github.com"),
|
||||
JSON.stringify({ data: toBase64(PNG), contentType: "image/png" }),
|
||||
);
|
||||
|
||||
const res = await worker.fetch(req("/favicon/abc"), env);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("image/png");
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(PNG);
|
||||
});
|
||||
|
||||
it("falls back to the project SVG when the feed has no icon domain", async () => {
|
||||
const env = createMockEnv() as unknown as Env;
|
||||
await env.EMAIL_STORAGE.put(
|
||||
"feed:abc:metadata",
|
||||
JSON.stringify({ emails: [] }),
|
||||
);
|
||||
|
||||
const res = await worker.fetch(req("/favicon/abc"), env);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toMatch(/^image\/svg\+xml/);
|
||||
expect(await res.text()).toContain("<svg");
|
||||
});
|
||||
|
||||
it("falls back to the project SVG for a negative cache entry", async () => {
|
||||
const env = createMockEnv() as unknown as Env;
|
||||
await env.EMAIL_STORAGE.put(
|
||||
"feed:abc:metadata",
|
||||
JSON.stringify({ emails: [], iconDomain: "nope.test" }),
|
||||
);
|
||||
await env.EMAIL_STORAGE.put(
|
||||
iconKey("nope.test"),
|
||||
JSON.stringify({ data: null, contentType: "" }),
|
||||
);
|
||||
|
||||
const res = await worker.fetch(req("/favicon/abc"), env);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toMatch(/^image\/svg\+xml/);
|
||||
});
|
||||
|
||||
it("falls back to the project SVG for an unknown feed", async () => {
|
||||
const env = createMockEnv() as unknown as Env;
|
||||
const res = await worker.fetch(req("/favicon/missing"), env);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toMatch(/^image\/svg\+xml/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user