Files
native/src/lib/__tests__/override-patch.test.ts
T
Linus Rath 2af3892fec fix: apply recurrence overrides as patches when the app expands a series
Stalwart 1.0 returns recurrence overrides as RFC 8984 PatchObjects,
with keys like participants/p1/participationStatus and nulls that
remove. Expanding a series on the device spread them onto the
occurrence, so a declined occurrence stayed accepted, a changed room
stayed the same and the pointer keys became junk properties. Moved or
resized occurrences also kept the time and length the rule gave them,
and were placed in the range by their original date.

- apply overrides as patches: pointer paths decoded per RFC 6901, null
  deletes, shorter paths first, a whole participants map completed
  from the series
- time an occurrence from its patched start, time zone and duration,
  and range-check a moved occurrence by where it is now
- fall back to the JSCalendar duration when the master's utcStart or
  utcEnd don't parse (0.16.23 returned the year 32548 for a counted
  series), and never keep the master's instants on another occurrence
- merge a change into an existing override without keys that are
  prefixes of one another, and match an occurrence listed twice
  against the members its override patches

The device expands series on 0.16.6 to 0.16.19, after a created series
and when a range can't be expanded on the server. 0.16 returns whole
override values, which apply the same way.
2026-09-25 20:38:43 +02:00

81 lines
4.0 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { applyOverridePatch } from '../recurrence-instances';
import type { CalendarEvent } from '../../api/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 as unknown as { keywords: unknown }).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' } });
});
});