import { describe, expect, test } from 'bun:test'; import { buildEvent, fold, parseEvents } from './ical'; // The two rules here are the ones that are invisible when broken: DTEND is exclusive for an all-day // event, and a content line is folded at 75 OCTETS. Both produce a file that looks fine and renders // wrong somewhere else. describe('buildEvent', () => { test('a timed event is written in UTC, with both ends', () => { const ics = buildEvent('u1', { summary: 'Lunch', start: '2026-08-28T13:00:00.000Z', end: '2026-08-28T14:00:00.000Z', allDay: false, }); expect(ics).toContain('DTSTART:20260828T130000Z'); expect(ics).toContain('DTEND:20260828T140000Z'); expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true); }); test('no end means an hour', () => { const ics = buildEvent('u1', { summary: 'Lunch', start: '2026-08-28T13:00:00.000Z', allDay: false }); expect(ics).toContain('DTEND:20260828T140000Z'); }); test('an all-day event ends the NEXT day, because DTEND is exclusive', () => { const ics = buildEvent('u1', { summary: 'Holiday', start: '2026-08-29', allDay: true }); expect(ics).toContain('DTSTART;VALUE=DATE:20260829'); expect(ics).toContain('DTEND;VALUE=DATE:20260830'); }); test('a multi-day all-day event takes the last day inclusive', () => { const ics = buildEvent('u1', { summary: 'Trip', start: '2026-08-29', end: '2026-08-31', allDay: true }); expect(ics).toContain('DTEND;VALUE=DATE:20260901'); }); test('a month boundary rolls the month, not just the day', () => { const ics = buildEvent('u1', { summary: 'Last of the month', start: '2026-08-31', allDay: true }); expect(ics).toContain('DTEND;VALUE=DATE:20260901'); }); test('TEXT separators are escaped, and survive the parser', () => { const summary = 'Comma, semicolon; and a backslash \\ walk in'; const ics = buildEvent('u1', { summary, start: '2026-08-28T13:00:00.000Z', allDay: false }); expect(ics).toContain(String.raw`SUMMARY:Comma\, semicolon\; and a backslash \\ walk in`); expect(parseEvents(ics)[0]!.summary).toBe(summary); }); test('a long summary folds, and the parser reads it back whole', () => { const summary = 'x'.repeat(200); const ics = buildEvent('u1', { summary, start: '2026-08-28T13:00:00.000Z', allDay: false }); expect(ics.split('\r\n').every((l) => Buffer.byteLength(l, 'utf8') <= 75)).toBe(true); expect(parseEvents(ics)[0]!.summary).toBe(summary); }); }); describe('fold', () => { test('a short line is left alone', () => { expect(fold('SUMMARY:short')).toBe('SUMMARY:short'); }); test('every produced line fits in 75 octets, continuations included', () => { const folded = fold(`SUMMARY:${'a'.repeat(400)}`); expect(folded.split('\r\n').every((l) => Buffer.byteLength(l, 'utf8') <= 75)).toBe(true); }); test('continuation lines start with exactly one space', () => { const [, ...rest] = fold(`SUMMARY:${'a'.repeat(200)}`).split('\r\n'); expect(rest.every((l) => l.startsWith(' ') && !l.startsWith(' a'))).toBe(true); }); test('a multi-byte character is never cut in half', () => { // 'é' is two octets, so a boundary lands mid-character unless the fold counts encoded length. const folded = fold(`SUMMARY:${'é'.repeat(100)}`); expect(folded).not.toContain('�'); expect(folded.split('\r\n').every((l) => Buffer.byteLength(l, 'utf8') <= 75)).toBe(true); expect(folded.split('\r\n').map((l, i) => (i ? l.slice(1) : l.slice(8))).join('')).toBe('é'.repeat(100)); }); });