import {describe, expect, it} from 'vitest'; import {convertToIcal, escapeText, foldLine} from '../../src/models/calendar/events/icalgenerator.service.js'; import {Event} from '../../src/models/calendar/events/event.interface.js'; /** * The iCal export is read by every subscribed calendar app, so a malformed * file is an outage nobody on our side sees. These pin RFC 5545's text * escaping and line folding, which the generator did not do before: a line * break in a description ended the DESCRIPTION property early, and the new * calendar frontend's multi-line description field would have produced * exactly that. */ const event = (overrides: Partial = {}): Event => ({ eventId: 1, calendarId: 1, uuid: 'uuid-1', name: 'Konzert', description: '', startDateTime: new Date('2026-10-03T17:00:00Z'), endDateTime: new Date('2026-10-03T19:00:00Z'), createdDate: new Date('2026-01-01T12:00:00Z'), location: '', createdBy: 'Anna', url: '', wholeDay: false, repeatFrequency: '', ...overrides }); /** RFC 5545 unfolding: a CRLF followed by one space or tab joins the lines. */ const unfold = (ical: string): string[] => ical.replace(/\r\n[ \t]/g, '').split('\r\n').filter(Boolean); describe('escapeText', () => { it('escapes backslash, semicolon, comma and line breaks', () => { expect(escapeText('a\\b;c,d')).toBe('a\\\\b\\;c\\,d'); expect(escapeText('Zeile 1\nZeile 2\r\nZeile 3\rZeile 4')).toBe('Zeile 1\\nZeile 2\\nZeile 3\\nZeile 4'); }); it('escapes the backslash first, so the others are not doubled', () => { expect(escapeText(';')).toBe('\\;'); }); }); describe('foldLine', () => { it('leaves a short line alone', () => { expect(foldLine('SUMMARY:Konzert')).toBe('SUMMARY:Konzert'); }); it('keeps every physical line within 75 octets and unfolds back to the original', () => { const line = 'DESCRIPTION:' + 'Probe im Gemeindehaus Süd – bitte Noten mitbringen. '.repeat(6); const folded = foldLine(line); for (const physical of folded.split('\r\n')) { expect(Buffer.byteLength(physical, 'utf8')).toBeLessThanOrEqual(75); } expect(folded.replace(/\r\n /g, '')).toBe(line); }); it('never splits a multi-byte character', () => { const line = 'SUMMARY:' + 'ü'.repeat(80); for (const physical of foldLine(line).split('\r\n')) { expect(physical.replace(/^ /, '')).toMatch(/^(SUMMARY:)?ü*$/); expect(Buffer.byteLength(physical, 'utf8')).toBeLessThanOrEqual(75); } }); }); describe('convertToIcal', () => { it('terminates every line with CRLF', async () => { const ical = await convertToIcal([event()]); expect(ical.endsWith('END:VCALENDAR\r\n')).toBe(true); expect(ical.replace(/\r\n/g, '')).not.toContain('\n'); }); it('keeps a multi-line description inside one DESCRIPTION property', async () => { const ical = await convertToIcal([event({description: 'Einlass 18:30\nBeginn 19:00; Eintritt frei, Spenden willkommen'})]); const lines = unfold(ical); expect(lines).toContain('DESCRIPTION:Einlass 18:30\\nBeginn 19:00\\; Eintritt frei\\, Spenden willkommen'); // Nothing leaked out as a line of its own. expect(lines.some((l) => l.startsWith('Beginn'))).toBe(false); }); it('escapes the title and the location too', async () => { const lines = unfold(await convertToIcal([event({name: 'Konzert, Teil 2', location: 'Kirche; Karlsruhe'})])); expect(lines).toContain('SUMMARY:Konzert\\, Teil 2'); expect(lines).toContain('LOCATION:Kirche\\; Karlsruhe'); }); it('does not escape the URL, but keeps it on one line', async () => { const lines = unfold(await convertToIcal([event({url: 'https://nachklang.art/konzert?a=1,2'})])); expect(lines).toContain('URL:https://nachklang.art/konzert?a=1,2'); }); it('still writes the yearly rule for birthdays', async () => { const lines = unfold(await convertToIcal([event({repeatFrequency: 'YEARLY', wholeDay: true})])); expect(lines).toContain('RRULE:FREQ=YEARLY'); }); it('leaves the event\'s own dates untouched', async () => { // formatDate used to add the whole-day end's extra day to the caller's Date in place. const whole = event({ wholeDay: true, startDateTime: new Date('2026-10-03T00:00:00'), endDateTime: new Date('2026-10-03T23:59:00') }); const endBefore = whole.endDateTime.getTime(); await convertToIcal([whole]); expect(whole.endDateTime.getTime()).toBe(endBefore); }); });