Add iCal download composable with RFC 5545 VEVENT generation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 21:39:54 +01:00
parent d4a1f0dc23
commit 75e6548403
2 changed files with 191 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest'
import { generateIcs } from '../useIcalDownload'
describe('generateIcs', () => {
const baseEvent = {
eventToken: '550e8400-e29b-41d4-a716-446655440000',
title: 'Sommerfest am See',
dateTime: '2026-07-15T18:00:00+02:00',
location: 'Stadtpark Berlin',
description: 'Bring your own drinks',
}
it('generates valid VCALENDAR wrapper', () => {
const ics = generateIcs(baseEvent)
expect(ics).toMatch(/^BEGIN:VCALENDAR\r\n/)
expect(ics).toMatch(/\r\nEND:VCALENDAR\r\n$/)
})
it('includes VERSION and PRODID', () => {
const ics = generateIcs(baseEvent)
expect(ics).toContain('VERSION:2.0\r\n')
expect(ics).toContain('PRODID:-//fete//EN\r\n')
})
it('generates valid VEVENT block', () => {
const ics = generateIcs(baseEvent)
expect(ics).toContain('BEGIN:VEVENT\r\n')
expect(ics).toContain('END:VEVENT\r\n')
})
it('sets UID from eventToken', () => {
const ics = generateIcs(baseEvent)
expect(ics).toContain('UID:550e8400-e29b-41d4-a716-446655440000@fete\r\n')
})
it('sets DTSTART in UTC format', () => {
const ics = generateIcs(baseEvent)
expect(ics).toContain('DTSTART:20260715T160000Z\r\n')
})
it('does NOT include DTEND or DURATION', () => {
const ics = generateIcs(baseEvent)
expect(ics).not.toContain('DTEND')
expect(ics).not.toContain('DURATION')
})
it('sets SUMMARY from title', () => {
const ics = generateIcs(baseEvent)
expect(ics).toContain('SUMMARY:Sommerfest am See\r\n')
})
it('sets LOCATION when present', () => {
const ics = generateIcs(baseEvent)
expect(ics).toContain('LOCATION:Stadtpark Berlin\r\n')
})
it('sets DESCRIPTION when present', () => {
const ics = generateIcs(baseEvent)
expect(ics).toContain('DESCRIPTION:Bring your own drinks\r\n')
})
it('omits LOCATION when not provided', () => {
const { location: _location, ...noLocation } = baseEvent
const ics = generateIcs(noLocation)
expect(ics).not.toContain('LOCATION')
})
it('omits DESCRIPTION when not provided', () => {
const { description: _description, ...noDesc } = baseEvent
const ics = generateIcs(noDesc)
expect(ics).not.toContain('DESCRIPTION')
})
it('includes SEQUENCE:0', () => {
const ics = generateIcs(baseEvent)
expect(ics).toContain('SEQUENCE:0\r\n')
})
it('includes DTSTAMP in UTC format', () => {
const ics = generateIcs(baseEvent)
expect(ics).toMatch(/DTSTAMP:\d{8}T\d{6}Z\r\n/)
})
it('escapes commas in text fields', () => {
const ics = generateIcs({ ...baseEvent, title: 'Hello, World' })
expect(ics).toContain('SUMMARY:Hello\\, World\r\n')
})
it('escapes semicolons in text fields', () => {
const ics = generateIcs({ ...baseEvent, description: 'foo; bar' })
expect(ics).toContain('DESCRIPTION:foo\\; bar\r\n')
})
it('escapes backslashes in text fields', () => {
const ics = generateIcs({ ...baseEvent, title: 'path\\to' })
expect(ics).toContain('SUMMARY:path\\\\to\r\n')
})
it('escapes newlines in text fields', () => {
const ics = generateIcs({ ...baseEvent, description: 'line1\nline2' })
expect(ics).toContain('DESCRIPTION:line1\\nline2\r\n')
})
it('produces deterministic output for the same input', () => {
const ics1 = generateIcs(baseEvent)
const ics2 = generateIcs(baseEvent)
// DTSTAMP changes with time, so strip it for comparison
const strip = (s: string) => s.replace(/DTSTAMP:\d{8}T\d{6}Z\r\n/, '')
expect(strip(ics1)).toBe(strip(ics2))
})
it('uses CRLF line endings throughout', () => {
const ics = generateIcs(baseEvent)
const lines = ics.split('\r\n')
// Every "line" split by CRLF should not contain a bare LF
for (const line of lines) {
expect(line).not.toContain('\n')
}
})
})