CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
atom-feed.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 26484eb | 1 | /** |
| 2 | * Block J19 — Atom feed renderer. Pure XML + route smokes. | |
| 3 | */ | |
| 4 | ||
| 5 | import { describe, it, expect } from "bun:test"; | |
| 6 | import app from "../app"; | |
| 7 | import { | |
| 8 | escapeXml, | |
| 9 | toIsoUtc, | |
| 10 | renderAtomFeed, | |
| 11 | ATOM_CONTENT_TYPE, | |
| 12 | __internal, | |
| 13 | type AtomEntry, | |
| 14 | } from "../lib/atom-feed"; | |
| 15 | ||
| 16 | describe("atom-feed — escapeXml", () => { | |
| 17 | it("escapes the five XML metacharacters", () => { | |
| 18 | expect(escapeXml("a < b & c > d")).toBe("a < b & c > d"); | |
| 19 | expect(escapeXml(`"single'`)).toBe(""single'"); | |
| 20 | }); | |
| 21 | ||
| 22 | it("returns '' for empty / falsy input", () => { | |
| 23 | expect(escapeXml("")).toBe(""); | |
| 24 | }); | |
| 25 | ||
| 26 | it("idempotent escape of already-escaped text", () => { | |
| 27 | // Double-escaping is expected — this is a serialiser, not a re-encoder. | |
| 28 | // We just need it not to throw or drop content. | |
| 29 | const once = escapeXml("&"); | |
| 30 | const twice = escapeXml(once); | |
| 31 | expect(twice).toBe("&amp;"); | |
| 32 | }); | |
| 33 | }); | |
| 34 | ||
| 35 | describe("atom-feed — toIsoUtc", () => { | |
| 36 | it("converts valid ISO strings to ISO-UTC", () => { | |
| 37 | const out = toIsoUtc("2026-04-15T12:00:00Z"); | |
| 38 | expect(out).toBe("2026-04-15T12:00:00.000Z"); | |
| 39 | }); | |
| 40 | ||
| 41 | it("accepts Date instances", () => { | |
| 42 | const out = toIsoUtc(new Date("2026-04-15T12:00:00Z")); | |
| 43 | expect(out).toBe("2026-04-15T12:00:00.000Z"); | |
| 44 | }); | |
| 45 | ||
| 46 | it("falls back to epoch on garbage", () => { | |
| 47 | expect(toIsoUtc("not-a-date")).toBe("1970-01-01T00:00:00.000Z"); | |
| 48 | expect(toIsoUtc(null)).toBe("1970-01-01T00:00:00.000Z"); | |
| 49 | expect(toIsoUtc(undefined)).toBe("1970-01-01T00:00:00.000Z"); | |
| 50 | expect(toIsoUtc("")).toBe("1970-01-01T00:00:00.000Z"); | |
| 51 | }); | |
| 52 | }); | |
| 53 | ||
| 54 | describe("atom-feed — renderAtomFeed", () => { | |
| 55 | const entry: AtomEntry = { | |
| 56 | id: "tag:gluecron,2026:alice/repo/commit/abc", | |
| 57 | title: "Fix bug", | |
| 58 | href: "https://gluecron.com/alice/repo/commit/abc", | |
| 59 | updatedAt: "2026-04-15T12:00:00Z", | |
| 60 | summary: "Fix a bug", | |
| 61 | author: { name: "Alice", email: "a@x" }, | |
| 62 | }; | |
| 63 | ||
| 64 | it("prefixes with the XML declaration + feed root", () => { | |
| 65 | const xml = renderAtomFeed({ | |
| 66 | id: "tag:feed", | |
| 67 | title: "Test", | |
| 68 | selfHref: "https://gluecron.com/feed", | |
| 69 | entries: [], | |
| 70 | }); | |
| 71 | expect(xml.startsWith('<?xml version="1.0" encoding="utf-8"?>\n')).toBe( | |
| 72 | true | |
| 73 | ); | |
| 74 | expect(xml).toContain('<feed xmlns="http://www.w3.org/2005/Atom">'); | |
| 75 | expect(xml.trim().endsWith("</feed>")).toBe(true); | |
| 76 | }); | |
| 77 | ||
| 78 | it("emits required feed-level elements", () => { | |
| 79 | const xml = renderAtomFeed({ | |
| 80 | id: "tag:feed", | |
| 81 | title: "Test Feed", | |
| 82 | subtitle: "Sub", | |
| 83 | selfHref: "https://g.c/feed", | |
| 84 | alternateHref: "https://g.c/", | |
| 85 | entries: [], | |
| 86 | }); | |
| 87 | expect(xml).toContain("<id>tag:feed</id>"); | |
| 88 | expect(xml).toContain("<title>Test Feed</title>"); | |
| 89 | expect(xml).toContain("<subtitle>Sub</subtitle>"); | |
| 90 | expect(xml).toContain('<link rel="self" href="https://g.c/feed"/>'); | |
| 91 | expect(xml).toContain('<link rel="alternate" href="https://g.c/"/>'); | |
| 92 | expect(xml).toMatch(/<updated>.+<\/updated>/); | |
| 93 | }); | |
| 94 | ||
| 95 | it("renders entry title, id, link, updated, published, author, summary", () => { | |
| 96 | const xml = renderAtomFeed({ | |
| 97 | id: "tag:feed", | |
| 98 | title: "Test", | |
| 99 | selfHref: "https://g.c/feed", | |
| 100 | entries: [entry], | |
| 101 | }); | |
| 102 | expect(xml).toContain("<entry>"); | |
| 103 | expect(xml).toContain("</entry>"); | |
| 104 | expect(xml).toContain(`<id>${entry.id}</id>`); | |
| 105 | expect(xml).toContain(`<title>${entry.title}</title>`); | |
| 106 | expect(xml).toContain(`<link rel="alternate" href="${entry.href}"/>`); | |
| 107 | expect(xml).toContain("<updated>2026-04-15T12:00:00.000Z</updated>"); | |
| 108 | expect(xml).toContain("<published>2026-04-15T12:00:00.000Z</published>"); | |
| 109 | expect(xml).toContain("<name>Alice</name>"); | |
| 110 | expect(xml).toContain("<email>a@x</email>"); | |
| 111 | expect(xml).toContain('<summary type="text">Fix a bug</summary>'); | |
| 112 | }); | |
| 113 | ||
| 114 | it("escapes entry text fields", () => { | |
| 115 | const xml = renderAtomFeed({ | |
| 116 | id: "tag:feed", | |
| 117 | title: "Test & Ampersand", | |
| 118 | selfHref: "https://g.c/feed?x=1&y=2", | |
| 119 | entries: [ | |
| 120 | { | |
| 121 | id: "tag:e", | |
| 122 | title: "<script>bad</script>", | |
| 123 | href: "https://g.c/x?a=1&b=2", | |
| 124 | updatedAt: "2026-04-15T12:00:00Z", | |
| 125 | summary: 'She said "hi"', | |
| 126 | }, | |
| 127 | ], | |
| 128 | }); | |
| 129 | expect(xml).toContain("Test & Ampersand"); | |
| 130 | expect(xml).toContain("x=1&y=2"); | |
| 131 | expect(xml).toContain("<script>bad</script>"); | |
| 132 | expect(xml).toContain(""hi""); | |
| 133 | // Must not contain the raw unescaped forms. | |
| 134 | expect(xml).not.toContain("<script>bad</script>"); | |
| 135 | }); | |
| 136 | ||
| 137 | it("picks feed updated from the newest entry when not set explicitly", () => { | |
| 138 | const xml = renderAtomFeed({ | |
| 139 | id: "tag:feed", | |
| 140 | title: "Test", | |
| 141 | selfHref: "https://g.c/feed", | |
| 142 | entries: [ | |
| 143 | { id: "a", title: "A", href: "h", updatedAt: "2026-04-10T00:00:00Z" }, | |
| 144 | { id: "b", title: "B", href: "h", updatedAt: "2026-04-14T00:00:00Z" }, | |
| 145 | { id: "c", title: "C", href: "h", updatedAt: "2026-04-12T00:00:00Z" }, | |
| 146 | ], | |
| 147 | }); | |
| 148 | // The feed `<updated>` should pick the newest entry (Apr 14) | |
| 149 | expect(xml).toContain("<updated>2026-04-14T00:00:00.000Z</updated>"); | |
| 150 | }); | |
| 151 | ||
| 152 | it("respects explicit feed updatedAt", () => { | |
| 153 | const xml = renderAtomFeed({ | |
| 154 | id: "tag:feed", | |
| 155 | title: "Test", | |
| 156 | selfHref: "https://g.c/feed", | |
| 157 | updatedAt: "2026-01-01T00:00:00Z", | |
| 158 | entries: [ | |
| 159 | { id: "a", title: "A", href: "h", updatedAt: "2026-04-14T00:00:00Z" }, | |
| 160 | ], | |
| 161 | }); | |
| 162 | expect(xml).toContain("<updated>2026-01-01T00:00:00.000Z</updated>"); | |
| 163 | }); | |
| 164 | ||
| 165 | it("produces a well-formed document even with zero entries", () => { | |
| 166 | const xml = renderAtomFeed({ | |
| 167 | id: "tag:empty", | |
| 168 | title: "Empty", | |
| 169 | selfHref: "https://g.c/empty.atom", | |
| 170 | entries: [], | |
| 171 | }); | |
| 172 | expect(xml).toContain("<id>tag:empty</id>"); | |
| 173 | expect(xml).not.toContain("<entry>"); | |
| 174 | }); | |
| 175 | ||
| 176 | it("falls back to (untitled) when a title is empty", () => { | |
| 177 | const xml = renderAtomFeed({ | |
| 178 | id: "tag:feed", | |
| 179 | title: "Test", | |
| 180 | selfHref: "https://g.c/feed", | |
| 181 | entries: [ | |
| 182 | { id: "a", title: "", href: "h", updatedAt: "2026-04-14T00:00:00Z" }, | |
| 183 | ], | |
| 184 | }); | |
| 185 | expect(xml).toContain("<title>(untitled)</title>"); | |
| 186 | }); | |
| 187 | }); | |
| 188 | ||
| 189 | describe("atom-feed — ATOM_CONTENT_TYPE", () => { | |
| 190 | it("is the canonical Atom mime with charset", () => { | |
| 191 | expect(ATOM_CONTENT_TYPE).toBe("application/atom+xml; charset=utf-8"); | |
| 192 | }); | |
| 193 | }); | |
| 194 | ||
| 195 | describe("atom-feed — __internal", () => { | |
| 196 | it("re-exports the helpers for parity", () => { | |
| 197 | expect(__internal.escapeXml).toBe(escapeXml); | |
| 198 | expect(__internal.toIsoUtc).toBe(toIsoUtc); | |
| 199 | expect(__internal.renderAtomFeed).toBe(renderAtomFeed); | |
| 200 | expect(__internal.ATOM_CONTENT_TYPE).toBe(ATOM_CONTENT_TYPE); | |
| 201 | }); | |
| 202 | }); | |
| 203 | ||
| 204 | describe("atom-feed — routes", () => { | |
| 205 | it("GET /:o/:r/commits.atom returns 200 with Atom content-type", async () => { | |
| 206 | const res = await app.request("/alice/nope/commits.atom"); | |
| 207 | expect(res.status).toBe(200); | |
| 208 | expect(res.headers.get("content-type")).toBe(ATOM_CONTENT_TYPE); | |
| 209 | const body = await res.text(); | |
| 210 | expect(body).toContain('<feed xmlns="http://www.w3.org/2005/Atom">'); | |
| 211 | }); | |
| 212 | ||
| 213 | it("GET /:o/:r/releases.atom returns 200 Atom", async () => { | |
| 214 | const res = await app.request("/alice/nope/releases.atom"); | |
| 215 | expect(res.status).toBe(200); | |
| 216 | expect(res.headers.get("content-type")).toBe(ATOM_CONTENT_TYPE); | |
| 217 | }); | |
| 218 | ||
| 219 | it("GET /:o/:r/issues.atom returns 200 Atom", async () => { | |
| 220 | const res = await app.request("/alice/nope/issues.atom"); | |
| 221 | expect(res.status).toBe(200); | |
| 222 | expect(res.headers.get("content-type")).toBe(ATOM_CONTENT_TYPE); | |
| 223 | }); | |
| 224 | ||
| 225 | it("cache headers set for feed reader friendliness", async () => { | |
| 226 | const res = await app.request("/alice/nope/commits.atom"); | |
| 227 | const cc = res.headers.get("cache-control") || ""; | |
| 228 | expect(cc).toContain("max-age"); | |
| 229 | expect(cc).toContain("stale-while-revalidate"); | |
| 230 | }); | |
| 231 | }); |