import { describe, expect, test } from 'bun:test'; import { patchContact, patchEvent, setProperty, setPropertyList } from './patch'; import { parseContacts, parseEvents } from './ical'; // What these protect: an edit must change what was asked and NOTHING else. Every failure mode here is // silent — the write succeeds, the list still looks right, and something the UI cannot show is gone. const EVENT = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT', 'UID:abc', 'DTSTAMP:20260827T100000Z', 'DTSTART;TZID=Europe/Lisbon:20260828T130000', 'DTEND;TZID=Europe/Lisbon:20260828T140000', 'SUMMARY:Before', 'RRULE:FREQ=WEEKLY', 'ATTENDEE;CN=Someone:mailto:someone@example.com', 'X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC', 'BEGIN:VALARM', 'ACTION:DISPLAY', 'TRIGGER:-PT15M', 'END:VALARM', 'END:VEVENT', 'END:VCALENDAR', ].join('\r\n'); const ALL_DAY = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT', 'UID:def', 'DTSTAMP:20260827T100000Z', 'DTSTART;VALUE=DATE:20260829', 'DTEND;VALUE=DATE:20260830', 'SUMMARY:Holiday', 'END:VEVENT', 'END:VCALENDAR', ].join('\r\n'); const CARD = [ 'BEGIN:VCARD', 'VERSION:3.0', 'UID:ghi', 'FN:Peter Griffin', 'N:Griffin;Peter;;;', 'EMAIL;TYPE=INTERNET:peter@quahog.example', 'EMAIL;TYPE=WORK:peter@brewery.example', 'TEL;TYPE=CELL:+351912345678', 'X-SOMETHING-ELSE:kept', 'END:VCARD', ].join('\r\n'); describe('setProperty', () => { test('changes only the line it owns, and keeps what it does not understand', () => { const out = setProperty(EVENT, 'SUMMARY', 'After'); expect(out).toContain('SUMMARY:After'); for (const kept of ['RRULE:FREQ=WEEKLY', 'ATTENDEE;CN=Someone', 'X-APPLE-TRAVEL', 'BEGIN:VALARM']) expect(out).toContain(kept); }); test('preserves parameters by default, and replaces them when told', () => { expect(setProperty(EVENT, 'DTSTART', '20260901T090000')).toContain('DTSTART;TZID=Europe/Lisbon:20260901T090000'); expect(setProperty(EVENT, 'DTSTART', '20260901T090000Z', '')).toContain('DTSTART:20260901T090000Z'); }); test('appends after UID when absent, so it lands in the right component', () => { const lines = setProperty(EVENT, 'LOCATION', 'Ramiro').split('\r\n'); expect(lines[lines.indexOf('UID:abc') + 1]).toBe('LOCATION:Ramiro'); }); test('null removes it', () => { expect(setProperty(EVENT, 'RRULE', null)).not.toContain('RRULE'); }); // The regression that shipped once: replacing only the first line of a FOLDED value left the rest // behind, and each edit inherited the tail of the last. test('replaces the whole of a folded value', () => { const folded = EVENT.replace('SUMMARY:Before', 'SUMMARY:Before and a great deal more text\r\n folded onto line two'); const out = setProperty(folded, 'SUMMARY', 'After'); expect(parseEvents(out)[0]!.summary).toBe('After'); expect(out).not.toContain('folded onto line two'); }); test('a long value is folded to 75 octets and reads back whole', () => { const long = 'y'.repeat(300); const out = setProperty(EVENT, 'SUMMARY', long); expect(out.split('\r\n').every((l) => Buffer.byteLength(l, 'utf8') <= 75)).toBe(true); expect(parseEvents(out)[0]!.summary).toBe(long); }); test('repeated edits do not accumulate', () => { let out = EVENT; for (const v of ['one'.repeat(40), 'two'.repeat(40), 'three']) out = setProperty(out, 'SUMMARY', v); expect(parseEvents(out)[0]!.summary).toBe('three'); }); }); describe('setPropertyList', () => { test('replaces the whole list and carries parameters over by position', () => { const out = setPropertyList(CARD, 'EMAIL', ['new@example.com'], ';TYPE=INTERNET'); expect(out).toContain('EMAIL;TYPE=INTERNET:new@example.com'); expect(out).not.toContain('brewery'); expect(parseContacts(out)[0]!.emails).toEqual(['new@example.com']); }); test('extra values get the default parameters', () => { const out = setPropertyList(CARD, 'TEL', ['+351911111111', '+351922222222'], ';TYPE=CELL'); expect(out).toContain('TEL;TYPE=CELL:+351911111111'); expect(out).toContain('TEL;TYPE=CELL:+351922222222'); }); test('an empty list removes them all, and leaves the rest of the card', () => { const out = setPropertyList(CARD, 'EMAIL', [], ';TYPE=INTERNET'); expect(out).not.toContain('EMAIL'); expect(out).toContain('X-SOMETHING-ELSE:kept'); expect(out).toContain('TEL;TYPE=CELL:+351912345678'); }); }); describe('patchEvent', () => { test('a text-only patch does not touch the times', () => { const out = patchEvent(EVENT, { summary: 'After' }); expect(out).toContain('DTSTART;TZID=Europe/Lisbon:20260828T130000'); expect(out).not.toContain('SEQUENCE'); }); test('moving a timed event writes UTC and DROPS the stale TZID', () => { const out = patchEvent(EVENT, { start: '2026-09-01T08:00:00Z', end: '2026-09-01T09:00:00Z' }); expect(out).toContain('DTSTART:20260901T080000Z'); expect(out).toContain('DTEND:20260901T090000Z'); expect(out).not.toContain('TZID'); }); test('moving bumps SEQUENCE, so a phone reads an update rather than a duplicate', () => { const once = patchEvent(EVENT, { start: '2026-09-01T08:00:00Z' }); expect(once).toContain('SEQUENCE:1'); expect(patchEvent(once, { start: '2026-09-02T08:00:00Z' })).toContain('SEQUENCE:2'); }); test('changing only the end leaves the start exactly as it was', () => { const out = patchEvent(ALL_DAY, { end: '2026-08-31' }); expect(out).toContain('DTSTART;VALUE=DATE:20260829'); expect(out).toContain('DTEND;VALUE=DATE:20260901'); // inclusive in, exclusive out }); // The one that would silently move a holiday: the file's DTEND is exclusive, so editing anything else // about the event must not re-add a day to it. test('editing an all-day event does not drift its end', () => { const out = patchEvent(patchEvent(ALL_DAY, { summary: 'A' }), { start: '2026-08-29' }); expect(out).toContain('DTEND;VALUE=DATE:20260830'); }); test('timed becomes all-day, both ends rewritten', () => { const out = patchEvent(EVENT, { allDay: true, start: '2026-08-28' }); expect(out).toContain('DTSTART;VALUE=DATE:20260828'); expect(out).toContain('DTEND;VALUE=DATE:20260829'); expect(parseEvents(out)[0]!.allDay).toBe(true); }); test('null removes a field, an empty string does not', () => { const withPlace = patchEvent(EVENT, { location: 'Ramiro' }); expect(patchEvent(withPlace, { location: null })).not.toContain('LOCATION'); expect(patchEvent(withPlace, { location: '' })).toContain('LOCATION:'); }); test('separators are escaped and survive the round trip', () => { const out = patchEvent(EVENT, { summary: 'a, b; c' }); expect(parseEvents(out)[0]!.summary).toBe('a, b; c'); }); }); describe('patchContact', () => { test('renaming updates N as well as FN', () => { const out = patchContact(CARD, { fullName: 'Lois Griffin' }); expect(out).toContain('FN:Lois Griffin'); expect(out).toContain('N:Griffin;Lois;;;'); }); test('a patch that names no list leaves both lists alone', () => { const out = patchContact(CARD, { org: 'Pawtucket' }); expect(parseContacts(out)[0]!.emails).toEqual(['peter@quahog.example', 'peter@brewery.example']); expect(out).toContain('ORG:Pawtucket'); }); test('everything unknown survives', () => { expect(patchContact(CARD, { fullName: 'Peter L. Griffin' })).toContain('X-SOMETHING-ELSE:kept'); }); test('REV moves, because some clients break a tie with it', () => { expect(patchContact(CARD, { title: 'Safety Inspector' })).toContain('REV:'); }); }); // The bug that live testing caught, and the reason `within` exists. Radicale stores a VTIMEZONE beside // any event carrying a TZID, and that block has DTSTART lines of its own — the summer-time rule. Taking // the first DTSTART in the file rewrote the timezone and left the event where it was. const WITH_VTIMEZONE = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VTIMEZONE', 'TZID:Europe/Lisbon', 'BEGIN:DAYLIGHT', 'DTSTART:20000326T010000', 'TZOFFSETFROM:+0000', 'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3', 'END:DAYLIGHT', 'END:VTIMEZONE', 'BEGIN:VEVENT', 'UID:jkl', 'DTSTAMP:20260827T100000Z', 'DTSTART;TZID=Europe/Lisbon:20260828T130000', 'DTEND;TZID=Europe/Lisbon:20260828T140000', 'SUMMARY:Probe', 'END:VEVENT', 'END:VCALENDAR', ].join('\r\n'); describe('a file with a VTIMEZONE', () => { test('moving the event does not touch the timezone rule', () => { const out = patchEvent(WITH_VTIMEZONE, { start: '2026-09-01T08:00:00Z', end: '2026-09-01T09:00:00Z' }); expect(out).toContain('DTSTART:20000326T010000'); // the summer-time rule, untouched expect(out).toContain('DTSTART:20260901T080000Z'); // the event, moved expect(out).not.toContain('DTSTART;TZID=Europe/Lisbon:20260828T130000'); expect(parseEvents(out)[0]!.start).toBe('2026-09-01T08:00:00Z'); }); test('the VTIMEZONE block survives intact', () => { const out = patchEvent(WITH_VTIMEZONE, { summary: 'Renamed' }); for (const kept of ['BEGIN:VTIMEZONE', 'TZID:Europe/Lisbon', 'BEGIN:DAYLIGHT', 'TZOFFSETFROM:+0000']) expect(out).toContain(kept); expect(parseEvents(out)[0]!.summary).toBe('Renamed'); }); test('a summary is not appended into the wrong component', () => { const out = patchEvent(WITH_VTIMEZONE, { location: 'Ramiro' }); const lines = out.split('\r\n'); expect(lines.indexOf('LOCATION:Ramiro')).toBeGreaterThan(lines.indexOf('BEGIN:VEVENT')); }); });