Files
webmail/lib/__tests__/override-patch.test.ts
Linus Rath d0b0383660 fix: apply recurrence overrides as patches in browser expansion
Stalwart 1.0 returns recurrence overrides as RFC 8984 PatchObjects, with
keys like participants/p1/participationStatus and nulls that remove.
Browser expansion spread them onto the occurrence, so a declined
occurrence stayed accepted and the pointer keys became junk properties.
Moved or resized occurrences also kept the time and length the rule
gave them and were placed by their original date.

Overrides are now applied as patches, occurrence times come from the
patched start, time zone and duration, and new changes merge into an
existing override without keys that are prefixes of one another.
2026-09-25 17:42:59 +02:00

81 lines
3.9 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { applyOverridePatch } from '../recurrence-instances';
import type { CalendarEvent } from '@/lib/jmap/types';
// A recurrence override is an RFC 8984 PatchObject: its keys are JSON
// Pointers into the occurrence, and null removes what one points at. Stalwart
// 1.0 returns minimal patches (jmap/src/calendar_event/get.rs, conformance
// test "Figures 2-6"); 0.16 returned whole top-level values.
const series = {
id: 'b',
title: 'Team sync',
description: 'Agenda',
locations: { l1: { '@type': 'Location', name: 'Room A' } },
participants: {
org: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'accepted' },
tom: { '@type': 'Participant', calendarAddress: 'mailto:b@example.org', participationStatus: 'needs-action' },
'a/b': { '@type': 'Participant', calendarAddress: 'mailto:c@example.org', participationStatus: 'needs-action' },
'x~y': { '@type': 'Participant', calendarAddress: 'mailto:d@example.org', participationStatus: 'needs-action' },
},
} as unknown as CalendarEvent;
describe('applyOverridePatch', () => {
it('sets what a pointer names on Stalwart 1.0, leaving the rest and the series untouched', () => {
const before = JSON.stringify(series);
const occurrence = applyOverridePatch(series, { 'participants/tom/participationStatus': 'declined' });
expect(occurrence.participants?.tom.participationStatus).toBe('declined');
expect(occurrence.participants?.org).toBe(series.participants?.org);
expect(occurrence).not.toHaveProperty(['participants/tom/participationStatus']);
expect(JSON.stringify(series)).toBe(before);
});
it('reads escaped tokens: ~1 is a slash, ~0 a tilde', () => {
const occurrence = applyOverridePatch(series, {
'participants/a~1b/participationStatus': 'accepted',
'participants/x~0y/participationStatus': 'tentative',
});
expect(occurrence.participants?.['a/b'].participationStatus).toBe('accepted');
expect(occurrence.participants?.['x~y'].participationStatus).toBe('tentative');
});
it('removes what null points at, at the top and further down', () => {
const occurrence = applyOverridePatch(series, { description: null, 'participants/tom': null, 'links/none': null });
expect(occurrence).not.toHaveProperty('description');
expect(Object.keys(occurrence.participants ?? {})).toEqual(['org', 'a/b', 'x~y']);
// Nothing to remove under a member that is not there.
expect(occurrence).not.toHaveProperty('links');
});
it('creates the members a pointer needs', () => {
const occurrence = applyOverridePatch(series, { 'keywords/$flagged': true });
expect(occurrence.keywords).toEqual({ $flagged: true });
});
it('applies whole values the way 0.16 returns them, completing participants from the series', () => {
const occurrence = applyOverridePatch(series, {
title: 'Moved sync',
start: '2026-09-08T14:00:00',
participants: {
tom: { '@type': 'Participant', calendarAddress: 'mailto:b@example.org', participationStatus: 'declined' },
n1: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org' },
},
});
expect(occurrence.title).toBe('Moved sync');
expect(occurrence.start).toBe('2026-09-08T14:00:00');
expect(occurrence.participants).toEqual({
tom: { '@type': 'Participant', calendarAddress: 'mailto:b@example.org', participationStatus: 'declined' },
org: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'accepted' },
});
});
it('applies shorter paths first, so a pointer refines a value set whole in the same patch', () => {
const occurrence = applyOverridePatch(series, {
'locations/l1/name': 'Room B',
locations: { l1: { '@type': 'Location', name: 'Room C', description: 'Second floor' } },
});
expect(occurrence.locations).toEqual({ l1: { '@type': 'Location', name: 'Room B', description: 'Second floor' } });
});
});