Files
bibdle/tests/rss.test.ts
George Powell 97bff35323 fix(rss): halt future-verse leaks, correct timezone dates, fix discovery
- Cap /feed.xml at the current America/New_York calendar day so
  pre-scheduled future verses (and stray 2124 rows) can no longer
  surface at the top of the feed
- Emit DST-aware RFC 822 pubDates (correct -04:00/-05:00 offset) instead
  of a hardcoded EST offset, and parse calendar days as UTC so titles
  no longer shift by one day on UTC hosts
- Add content-addressed ETag + Last-Modified with If-None-Match /
  If-Modified-Since conditional handling
- Add atom:link rel=self, content:encoded, channel pubDate/generator/image
- Move RSS auto-discovery <link> into app.html: the root route sets
  ssr=false, so the layout <svelte:head> link never reached the raw HTML
  and feed readers could not discover /feed.xml
- Extract feed helpers into $lib/server/rss and add unit tests
2026-09-22 11:31:36 -04:00

94 lines
3.1 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import {
cdata,
escapeXml,
feedFingerprint,
formatRFC822,
formatReadableDate,
formatVerseText,
latestModification,
todayInTimeZone
} from '$lib/server/rss';
describe('RSS date formatting', () => {
test('formatReadableDate uses the calendar day, independent of server TZ', () => {
expect(formatReadableDate('2026-09-18')).toBe('September 18, 2026');
expect(formatReadableDate('2026-01-01')).toBe('January 1, 2026');
});
test('formatRFC822 emits a valid GMT timestamp', () => {
expect(formatRFC822('2026-01-15')).toBe('Thu, 15 Jan 2026 05:00:00 GMT');
expect(formatRFC822('2026-09-18')).toBe('Fri, 18 Sep 2026 04:00:00 GMT');
});
test('formatRFC822 handles daylight saving (EST -05:00 vs EDT -04:00)', () => {
// Winter: midnight New York is 05:00 UTC.
expect(formatRFC822('2026-01-15')).toContain('05:00:00 GMT');
// Summer: midnight New York is 04:00 UTC.
expect(formatRFC822('2026-07-15')).toContain('04:00:00 GMT');
// Never a bare, ambiguous abbreviation like EST/EDT.
expect(formatRFC822('2026-07-15')).not.toMatch(/\bE[SD]T\b/);
});
test('todayInTimeZone respects the reference timezone', () => {
// 02:00 UTC on the 23rd is still the evening of the 22nd in New York.
expect(
todayInTimeZone('America/New_York', new Date('2026-09-23T02:00:00Z'))
).toBe('2026-09-22');
// 04:00 UTC on the 23rd is midnight in New York.
expect(
todayInTimeZone('America/New_York', new Date('2026-09-23T04:00:00Z'))
).toBe('2026-09-23');
});
});
describe('RSS content fingerprint', () => {
const row = { date: '2026-09-18', reference: 'Leviticus 19:9-11', verseText: 'a' };
test('is stable for identical content', () => {
expect(feedFingerprint([row])).toBe(feedFingerprint([{ ...row }]));
});
test('changes when verse text changes', () => {
expect(feedFingerprint([row])).not.toBe(feedFingerprint([{ ...row, verseText: 'b' }]));
});
test('changes when the set of days changes', () => {
expect(feedFingerprint([row])).not.toBe(
feedFingerprint([row, { ...row, date: '2026-09-17' }])
);
});
});
describe('latestModification', () => {
const fallback = new Date('2026-09-18T00:00:00Z');
test('returns the newest createdAt', () => {
const older = new Date('2026-09-16T10:00:00Z');
const newer = new Date('2026-09-17T10:00:00Z');
expect(
latestModification([{ createdAt: older }, { createdAt: newer }], fallback)
).toEqual(newer);
});
test('falls back when rows have no timestamp', () => {
expect(latestModification([{ createdAt: null }], fallback)).toEqual(fallback);
expect(latestModification([], fallback)).toEqual(fallback);
});
});
describe('XML serialization helpers', () => {
test('escapeXml escapes all predefined entities', () => {
expect(escapeXml(`<a href="x">&'</a>`)).toBe(
'&lt;a href=&quot;x&quot;&gt;&amp;&apos;&lt;/a&gt;'
);
});
test('cdata splits embedded CDATA terminators', () => {
expect(cdata('a]]>b')).toBe('<![CDATA[a]]]]><![CDATA[>b]]>');
});
test('formatVerseText capitalizes and replaces trailing punctuation', () => {
expect(formatVerseText('for God so loved the world,')).toBe('For God so loved the world...');
});
});