mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
More edits of a recurring meeting went wrong at the edges of a series (0.16 and 1.0 alike, attendees included): - A series the app shows as itself - opened from a reminder or an event link, or one an edit just made recurring (0.16 before 0.16.20 showed it until the next reload) - took "This event only" as a change of the whole series: `CalendarEvent/set` on the series' id with the editor's title, start and rule. "Delete this event only" deleted the series. The series now stands for its first occurrence: a change becomes an override of it, in two writes when the user organizes the meeting, sends invitations and an attendee has answered (Stalwart sends a new override only to attendees who have not), and a delete excludes it. "This and following" from the first occurrence changes, or deletes, the whole series instead of ending it before its first occurrence. A series opened from a reminder on a calendar shared with the user is now known to the store, so the change goes to the owner's account under the series' own id. - An edit that makes an event recurring, or single again, now loads the range again, so the grid shows the occurrences instead of the series itself. - 0.16 (0.16.23 checked) reads a rule's UNTIL as the series' wall clock taken for UTC, so in a zone behind UTC a split or "delete this and following" kept the occurrence it was to end before. The UNTIL is now earlier by the zone's offset (the larger of the split's day and the day before), which ends the series there on 0.16 and 1.0 alike. - 0.16.6 to 0.16.23 send a change of one occurrence with its SEQUENCE raised but do not keep the raise, so a later change of every occurrence - a new title - went out with a lower number for that occurrence and the attendee's server refused it. A change of every occurrence that the user as organizer sends, of something the attendees see, on a series with changed occurrences, now raises the SEQUENCE of the series and of each changed occurrence by one. 1.0 raises and keeps the number itself; the result there is the same. The app's update sends `sequence` as it is, so nothing has to keep it. - An existing override written on the base event (0.16 before 0.16.20, and ranges the device expands) gets the series' organizer again when it has none: 0.16.19 refuses an answer to such an occurrence. 1.0 refuses the key inside an override once, and the store writes the override again without it.
514 lines
24 KiB
TypeScript
514 lines
24 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
baseEventStoreId,
|
||
buildFallbackExcludePatch,
|
||
buildFallbackOverridePatch,
|
||
buildOccurrencePatch,
|
||
buildOccurrenceRsvpPatch,
|
||
hydrateRecurrenceInstances,
|
||
isBrowserExpandedOccurrence,
|
||
isOverrideSeriesKeyRejection,
|
||
isServerRecurrenceInstance,
|
||
isSyntheticIdMutationUnsupported,
|
||
mergeOverrideParticipants,
|
||
resolveOverrideKey,
|
||
seriesIdOf,
|
||
stableOccurrenceKey,
|
||
withNewOverrideDetails,
|
||
withoutOverrideSeriesKeys,
|
||
} from '../recurrence-instances';
|
||
import { expandRecurringEvents } from '../recurrence-expansion';
|
||
import { JMAPMethodError } from '../../api/jmap-result';
|
||
import type { CalendarEvent } from '../../api/types';
|
||
|
||
function ev(partial: Partial<CalendarEvent>): CalendarEvent {
|
||
return { id: 'e', uid: 'u', title: 't', start: '2026-03-02T09:00:00', calendarIds: { c: true }, ...partial } as CalendarEvent;
|
||
}
|
||
|
||
describe('server recurrence instances', () => {
|
||
it('tells a server-expanded occurrence from a stored event', () => {
|
||
expect(isServerRecurrenceInstance(ev({ id: 's1', baseEventId: 'base' }))).toBe(true);
|
||
// Shared account: the store id is namespaced, originalId holds the raw id.
|
||
expect(isServerRecurrenceInstance(ev({ id: 'acc:s1', originalId: 's1', baseEventId: 'base' }))).toBe(true);
|
||
// A stored event fetched directly names itself.
|
||
expect(isServerRecurrenceInstance(ev({ id: 'base', baseEventId: 'base' }))).toBe(false);
|
||
expect(isServerRecurrenceInstance(ev({ id: 'acc:base', originalId: 'base', baseEventId: 'base' }))).toBe(false);
|
||
expect(isServerRecurrenceInstance(ev({ id: 'x:2026', originalId: 'x' }))).toBe(false);
|
||
});
|
||
|
||
it('names the base event under the store id it would have', () => {
|
||
expect(baseEventStoreId(ev({ id: 's1', baseEventId: 'base' }))).toBe('base');
|
||
expect(baseEventStoreId(ev({ id: 'acc:s1', originalId: 's1', baseEventId: 'base' }))).toBe('acc:base');
|
||
expect(baseEventStoreId(ev({ id: 'base' }))).toBeNull();
|
||
});
|
||
|
||
it('finds the stored event behind any store event', () => {
|
||
expect(seriesIdOf(ev({ id: 's1', baseEventId: 'base' }))).toBe('base');
|
||
expect(seriesIdOf(ev({ id: 'm:2026-03-02T09:00:00', originalId: 'm' }))).toBe('m');
|
||
expect(seriesIdOf(ev({ id: 'plain' }))).toBe('plain');
|
||
});
|
||
|
||
it('keys an occurrence by what survives a refetch', () => {
|
||
expect(stableOccurrenceKey(ev({ id: 's7', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00' })))
|
||
.toBe('base:2026-03-02T09:00:00');
|
||
expect(stableOccurrenceKey(ev({ id: 'acc:s7', originalId: 's7', accountId: 'acc', baseEventId: 'base' })))
|
||
.toBe('acc:base');
|
||
expect(stableOccurrenceKey(ev({ id: 'm:2026-03-02T09:00:00', originalId: 'm', recurrenceId: '2026-03-02T09:00:00' })))
|
||
.toBe('m:2026-03-02T09:00:00');
|
||
});
|
||
|
||
it('finds a moved occurrence\'s override by its new start on older servers', () => {
|
||
const overrides = { '2026-03-02T09:00:00': { start: '2026-03-02T11:00:00' } };
|
||
expect(resolveOverrideKey({ recurrenceId: '2026-03-02T09:00:00', start: '2026-03-02T11:00:00' }, overrides))
|
||
.toBe('2026-03-02T09:00:00');
|
||
expect(resolveOverrideKey({ recurrenceId: '2026-03-02T11:00:00', start: '2026-03-02T11:00:00' }, overrides))
|
||
.toBe('2026-03-02T09:00:00');
|
||
expect(resolveOverrideKey({ recurrenceId: '2026-03-04T09:00:00', start: '2026-03-04T09:00:00' }, overrides))
|
||
.toBe('2026-03-04T09:00:00');
|
||
expect(resolveOverrideKey({ start: '2026-03-04T09:00:00' }, overrides)).toBeNull();
|
||
});
|
||
|
||
it('completes an override\'s participants from the series', () => {
|
||
const series = {
|
||
org: { calendarAddress: 'mailto:org@x', roles: { owner: true }, participationStatus: 'accepted' },
|
||
a: { calendarAddress: 'mailto:a@x', participationStatus: 'needs-action' },
|
||
} as unknown as CalendarEvent['participants'];
|
||
const override = {
|
||
o2: { calendarAddress: 'MAILTO:org@x', participationStatus: null },
|
||
a: { participationStatus: 'declined' },
|
||
} as unknown as CalendarEvent['participants'];
|
||
|
||
expect(mergeOverrideParticipants(series, override)).toEqual({
|
||
org: { calendarAddress: 'MAILTO:org@x', roles: { owner: true }, participationStatus: 'accepted' },
|
||
a: { calendarAddress: 'mailto:a@x', participationStatus: 'declined' },
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('hydrateRecurrenceInstances', () => {
|
||
const base = {
|
||
id: 'base',
|
||
recurrenceRules: [{ frequency: 'weekly' }],
|
||
recurrenceOverrides: { '2026-03-09T09:00:00': { title: 'Moved', start: '2026-03-09T11:00:00' } },
|
||
showWithoutTime: false,
|
||
timeZone: 'Europe/Berlin',
|
||
duration: 'PT1H',
|
||
} as Partial<CalendarEvent>;
|
||
const bases = new Map([['base', base]]);
|
||
|
||
it('copies the series context and the base duration of an override without one', () => {
|
||
const [plain, overridden] = hydrateRecurrenceInstances([
|
||
ev({ id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', duration: 'PT1H' }),
|
||
ev({
|
||
id: 's2', baseEventId: 'base', recurrenceId: '2026-03-09T09:00:00', start: '2026-03-09T11:00:00',
|
||
title: 'Moved', duration: 'PT2H', utcStart: '2026-03-09T10:00:00Z', utcEnd: '2026-03-09T12:00:00Z',
|
||
}),
|
||
], bases);
|
||
|
||
expect(plain.recurrenceRules).toEqual([{ frequency: 'weekly' }]);
|
||
expect(plain.recurrenceOverrides).toBe(base.recurrenceOverrides);
|
||
expect(overridden.duration).toBe('PT1H');
|
||
expect(overridden.utcEnd).toBe('2026-03-09T11:00:00.000Z');
|
||
});
|
||
|
||
it('restores the all-day flag the expansion dropped', () => {
|
||
const allDay = new Map([['b2', { id: 'b2', showWithoutTime: true, timeZone: null, duration: 'P1D' }]]);
|
||
const [hydrated] = hydrateRecurrenceInstances(
|
||
[ev({ id: 's1', baseEventId: 'b2', recurrenceId: '2026-03-02T00:00:00', timeZone: 'Europe/Berlin' })],
|
||
allDay,
|
||
);
|
||
expect(hydrated.showWithoutTime).toBe(true);
|
||
expect(hydrated.timeZone).toBeNull();
|
||
});
|
||
|
||
it('collapses an occurrence listed twice, taking the override\'s own fields', () => {
|
||
const withReply = new Map([['base', {
|
||
...base,
|
||
recurrenceOverrides: { '2026-03-02T09:00:00': { participants: { a: { participationStatus: 'accepted' } } } },
|
||
}]]);
|
||
const series = ev({
|
||
id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', title: 'Standup',
|
||
participants: { a: { calendarAddress: 'mailto:a@x', participationStatus: 'needs-action' } } as any,
|
||
});
|
||
const fromOverride = ev({
|
||
id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00', title: '',
|
||
participants: { a: { participationStatus: 'accepted' } } as any,
|
||
});
|
||
|
||
const result = hydrateRecurrenceInstances([series, fromOverride], withReply);
|
||
|
||
expect(result).toHaveLength(1);
|
||
expect(result[0].title).toBe('Standup');
|
||
expect(result[0].participants).toEqual({
|
||
a: { calendarAddress: 'mailto:a@x', participationStatus: 'accepted' },
|
||
});
|
||
});
|
||
|
||
it('leaves events without a known base alone', () => {
|
||
const lone = ev({ id: 's9', baseEventId: 'unknown', recurrenceId: '2026-03-02T09:00:00' });
|
||
expect(hydrateRecurrenceInstances([lone], bases)[0]).toBe(lone);
|
||
});
|
||
});
|
||
|
||
describe('expandRecurringEvents with server occurrences', () => {
|
||
it('never expands an occurrence the server already expanded', () => {
|
||
const occurrence = ev({
|
||
id: 's1', baseEventId: 'base', recurrenceId: '2026-03-02T09:00:00',
|
||
recurrenceRules: [{ frequency: 'daily' } as any],
|
||
});
|
||
const result = expandRecurringEvents([occurrence], '2026-03-01T00:00:00', '2026-03-31T00:00:00');
|
||
expect(result).toEqual([occurrence]);
|
||
});
|
||
});
|
||
|
||
describe('changing one occurrence', () => {
|
||
it('recognises a server that refuses synthetic ids', () => {
|
||
expect(isSyntheticIdMutationUnsupported(new Error('Updating synthetic ids is not yet supported.'))).toBe(true);
|
||
expect(isSyntheticIdMutationUnsupported(
|
||
new Error('Failed to destroy event s1: invalidProperties – Deleting synthetic ids is not yet supported.'),
|
||
)).toBe(true);
|
||
expect(isSyntheticIdMutationUnsupported(new Error('forbidden'))).toBe(false);
|
||
});
|
||
|
||
it('tells an occurrence the device expanded from a server one', () => {
|
||
const rules = [{ frequency: 'daily' } as any];
|
||
expect(isBrowserExpandedOccurrence(ev({ id: 'm:x', originalId: 'm', recurrenceId: 'x', recurrenceRules: rules }))).toBe(true);
|
||
expect(isBrowserExpandedOccurrence(ev({ id: 's1', baseEventId: 'm', recurrenceId: 'x', recurrenceRules: rules }))).toBe(false);
|
||
expect(isBrowserExpandedOccurrence(ev({ id: 'lone', recurrenceId: 'x' }))).toBe(false);
|
||
});
|
||
|
||
it('keeps only what one occurrence may carry, also behind a pointer', () => {
|
||
expect(buildOccurrencePatch({
|
||
title: 'x',
|
||
'locations/l1/name': 'Room',
|
||
'calendarIds/c2': true,
|
||
recurrenceRules: [],
|
||
recurrenceId: 'r',
|
||
utcStart: 'z',
|
||
useDefaultAlerts: true,
|
||
originalId: 'o',
|
||
} as any)).toEqual({ title: 'x', 'locations/l1/name': 'Room' });
|
||
});
|
||
|
||
it('builds an override that keeps what the occurrence already overrides', () => {
|
||
const occurrence = ev({
|
||
id: 'm:2026-03-03T09:00:00', originalId: 'm', recurrenceId: '2026-03-03T09:00:00',
|
||
start: '2026-03-03T11:00:00', duration: 'PT1H', recurrenceRules: [{ frequency: 'daily' } as any],
|
||
recurrenceOverrides: {
|
||
'2026-03-03T09:00:00': { start: '2026-03-03T11:00:00', title: 'Late', updated: '2026-01-01T00:00:00Z' } as any,
|
||
},
|
||
});
|
||
|
||
expect(buildFallbackOverridePatch(occurrence, { description: 'Bring slides' })).toEqual({
|
||
'recurrenceOverrides/2026-03-03T09:00:00': {
|
||
start: '2026-03-03T11:00:00',
|
||
duration: 'PT1H',
|
||
title: 'Late',
|
||
description: 'Bring slides',
|
||
},
|
||
});
|
||
expect(buildFallbackExcludePatch(occurrence)).toEqual({
|
||
'recurrenceOverrides/2026-03-03T09:00:00': { excluded: true },
|
||
});
|
||
});
|
||
|
||
it('copies the occurrence details into a new override only', () => {
|
||
const fresh = ev({
|
||
id: 's1', baseEventId: 'm', recurrenceId: '2026-03-02T09:00:00', title: 'Standup',
|
||
alerts: { a: {} } as any, sequence: 3, recurrenceOverrides: null,
|
||
});
|
||
expect(withNewOverrideDetails(fresh, { start: '2026-03-02T10:00:00' })).toEqual({
|
||
title: 'Standup', alerts: { a: {} }, sequence: 3, start: '2026-03-02T10:00:00',
|
||
});
|
||
|
||
const overridden = { ...fresh, recurrenceOverrides: { '2026-03-02T09:00:00': { title: 'Standup' } } };
|
||
expect(withNewOverrideDetails(overridden, { start: '2026-03-02T10:00:00' }))
|
||
.toEqual({ start: '2026-03-02T10:00:00' });
|
||
|
||
// Not hydrated: the base event's overrides are unknown, so nothing is guessed.
|
||
const unknown = { ...fresh, recurrenceOverrides: undefined };
|
||
expect(withNewOverrideDetails(unknown, { start: '2026-03-02T10:00:00' }))
|
||
.toEqual({ start: '2026-03-02T10:00:00' });
|
||
expect(buildFallbackExcludePatch(unknown)).toEqual({
|
||
'recurrenceOverrides/2026-03-02T09:00:00': { excluded: true },
|
||
});
|
||
});
|
||
|
||
it('answers one occurrence with the whole participant map', () => {
|
||
const occurrence = ev({
|
||
id: 's1', baseEventId: 'm', recurrenceId: '2026-03-02T09:00:00', title: 'Standup',
|
||
organizerCalendarAddress: 'mailto:org@x', sequence: 1,
|
||
recurrenceOverrides: { '2026-03-02T09:00:00': {} },
|
||
participants: {
|
||
org: { calendarAddress: 'mailto:org@x', participationStatus: 'accepted' },
|
||
me: { calendarAddress: 'mailto:me@x', participationStatus: 'needs-action' },
|
||
} as any,
|
||
});
|
||
const participants = {
|
||
org: { calendarAddress: 'mailto:org@x', participationStatus: 'accepted' },
|
||
me: { calendarAddress: 'mailto:me@x', participationStatus: 'accepted' },
|
||
};
|
||
|
||
// Through a synthetic id: the organizer is the series', which Stalwart
|
||
// 1.0 checks against the base event.
|
||
expect(buildOccurrenceRsvpPatch(occurrence, 'me', 'accepted')).toEqual({ sequence: 1, participants });
|
||
// In an override on the base event: 0.16.19 needs the organizer there.
|
||
expect(buildOccurrenceRsvpPatch(occurrence, 'me', 'accepted', { seriesKeys: true })).toEqual({
|
||
sequence: 1,
|
||
organizerCalendarAddress: 'mailto:org@x',
|
||
participants,
|
||
});
|
||
expect(buildOccurrenceRsvpPatch(occurrence, 'nobody', 'accepted')).toBeNull();
|
||
});
|
||
});
|
||
|
||
// Stalwart 1.0 checks what one occurrence carries (jmap/src/calendar_event/
|
||
// validate.rs): through a synthetic id a series property must repeat the base
|
||
// event's value, inside an override on the base event it is refused whatever
|
||
// its value. 0.16.20-0.16.23 drop such keys silently; older 0.16 store them.
|
||
describe('series keys on one occurrence', () => {
|
||
it('leaves the series keys and pointers at an address out of an occurrence patch', () => {
|
||
const patch = buildOccurrencePatch({
|
||
privacy: 'public',
|
||
organizerCalendarAddress: 'mailto:me@example.com',
|
||
method: 'request',
|
||
prodId: '-//Bulwark//EN',
|
||
sentBy: 'mailto:assistant@example.com',
|
||
recurrenceRule: { frequency: 'daily' },
|
||
excludedRecurrenceRule: null,
|
||
'participants/p1/calendarAddress': 'mailto:other@example.com',
|
||
'participants/p1/participationStatus': 'declined',
|
||
participants: { p1: { '@type': 'Participant', calendarAddress: 'mailto:p1@example.com' } },
|
||
relatedTo: { r1: { '@type': 'Relation' } },
|
||
sequence: 3,
|
||
excluded: false,
|
||
status: 'tentative',
|
||
freeBusyStatus: 'free',
|
||
accountId: 'acc-2',
|
||
} as unknown as Partial<CalendarEvent>);
|
||
|
||
expect(patch).toEqual({
|
||
'participants/p1/participationStatus': 'declined',
|
||
participants: { p1: { '@type': 'Participant', calendarAddress: 'mailto:p1@example.com' } },
|
||
relatedTo: { r1: { '@type': 'Relation' } },
|
||
sequence: 3,
|
||
excluded: false,
|
||
status: 'tentative',
|
||
freeBusyStatus: 'free',
|
||
});
|
||
});
|
||
|
||
it('keeps the organizer in an override on the base event, as 0.16 has always been sent it', () => {
|
||
const occurrence = ev({
|
||
id: 'm:2026-03-03T09:00:00', originalId: 'm', recurrenceId: '2026-03-03T09:00:00',
|
||
start: '2026-03-03T09:00:00', duration: 'PT1H', recurrenceRules: [{ frequency: 'daily' } as any],
|
||
recurrenceOverrides: null,
|
||
});
|
||
|
||
expect(buildFallbackOverridePatch(occurrence, {
|
||
title: 'x', organizerCalendarAddress: 'mailto:org@example.com', uid: 'u', calendarIds: { c: true },
|
||
})).toEqual({
|
||
recurrenceOverrides: {
|
||
'2026-03-03T09:00:00': {
|
||
start: '2026-03-03T09:00:00', duration: 'PT1H', title: 'x', organizerCalendarAddress: 'mailto:org@example.com',
|
||
},
|
||
},
|
||
});
|
||
});
|
||
|
||
it('copies the organizer into a new override only when it goes on the base event', () => {
|
||
const occurrence = ev({
|
||
id: 's1', baseEventId: 'm', recurrenceId: '2026-03-02T09:00:00', title: 'Standup',
|
||
organizerCalendarAddress: 'mailto:org@example.com', recurrenceOverrides: null,
|
||
});
|
||
|
||
expect(withNewOverrideDetails(occurrence, { start: 'x' })).not.toHaveProperty('organizerCalendarAddress');
|
||
expect(withNewOverrideDetails(occurrence, { start: 'x' }, { seriesKeys: true })).toMatchObject({
|
||
organizerCalendarAddress: 'mailto:org@example.com',
|
||
});
|
||
});
|
||
|
||
it('takes the series and per-event keys out of every override an update writes', () => {
|
||
expect(withoutOverrideSeriesKeys({
|
||
title: 'Series title',
|
||
organizerCalendarAddress: 'mailto:org@example.com',
|
||
recurrenceOverrides: {
|
||
'2026-09-08T10:00:00': { title: 'Moved', privacy: 'public', organizerCalendarAddress: 'mailto:o@example.com' },
|
||
'2026-09-09T10:00:00': { excluded: true },
|
||
},
|
||
})).toEqual({
|
||
title: 'Series title',
|
||
organizerCalendarAddress: 'mailto:org@example.com',
|
||
recurrenceOverrides: {
|
||
'2026-09-08T10:00:00': { title: 'Moved' },
|
||
'2026-09-09T10:00:00': { excluded: true },
|
||
},
|
||
});
|
||
});
|
||
|
||
it('takes them out of an override entry and of pointers into one', () => {
|
||
expect(withoutOverrideSeriesKeys({
|
||
'recurrenceOverrides/2026-09-08T10:00:00': { start: '2026-09-08T12:00:00', privacy: 'public', uid: 'x' },
|
||
'recurrenceOverrides/2026-09-09T10:00:00/privacy': 'public',
|
||
'recurrenceOverrides/2026-09-09T10:00:00/participants/p1/calendarAddress': 'mailto:x@example.com',
|
||
'recurrenceOverrides/2026-09-09T10:00:00/participants/p1/participationStatus': 'accepted',
|
||
'recurrenceOverrides/2026-09-10T10:00:00': null,
|
||
})).toEqual({
|
||
'recurrenceOverrides/2026-09-08T10:00:00': { start: '2026-09-08T12:00:00' },
|
||
'recurrenceOverrides/2026-09-09T10:00:00/participants/p1/participationStatus': 'accepted',
|
||
'recurrenceOverrides/2026-09-10T10:00:00': null,
|
||
});
|
||
});
|
||
|
||
it('recognises Stalwart 1.0 refusing a series key inside an override', () => {
|
||
expect(isOverrideSeriesKeyRejection(
|
||
new JMAPMethodError('invalidProperties', 'organizerCalendarAddress cannot be set inside recurrenceOverrides.'),
|
||
)).toBe(true);
|
||
expect(isOverrideSeriesKeyRejection(
|
||
new JMAPMethodError('invalidProperties', 'This property cannot be modified on a single occurrence.'),
|
||
)).toBe(false);
|
||
// Without the SetError type it is not recognised.
|
||
expect(isOverrideSeriesKeyRejection(new Error('privacy cannot be set inside recurrenceOverrides.'))).toBe(false);
|
||
});
|
||
});
|
||
|
||
// Stalwart 1.0 returns recurrence overrides as RFC 8984 PatchObjects, with
|
||
// keys like participants/<id>/participationStatus. RFC 8984 does not allow
|
||
// keys that are prefixes of one another in one PatchObject.
|
||
describe('overrides returned as PatchObjects', () => {
|
||
it('merges a change into an override without keys that are prefixes of one another', () => {
|
||
const occurrence = ev({
|
||
id: 'm:2026-09-08T10:00:00', originalId: 'm', recurrenceId: '2026-09-08T10:00:00',
|
||
start: '2026-09-08T10:00:00', duration: 'PT1H', recurrenceRules: [{ frequency: 'daily' } as any],
|
||
recurrenceOverrides: {
|
||
'2026-09-08T10:00:00': {
|
||
start: '2026-09-08T10:00:00',
|
||
'participants/tom/participationStatus': 'declined',
|
||
locations: { l1: { '@type': 'Location', name: 'Room A' } },
|
||
} as any,
|
||
'2026-09-09T10:00:00': { excluded: true } as any,
|
||
},
|
||
});
|
||
const participants = { tom: { '@type': 'Participant', participationStatus: 'accepted' } } as any;
|
||
|
||
// A whole participants map replaces the patches beneath it; a pointer
|
||
// goes into the whole locations value the entry already sets.
|
||
expect(buildFallbackOverridePatch(occurrence, {
|
||
participants,
|
||
'locations/l1/name': 'Room B',
|
||
} as any)).toEqual({
|
||
'recurrenceOverrides/2026-09-08T10:00:00': {
|
||
start: '2026-09-08T10:00:00',
|
||
duration: 'PT1H',
|
||
participants,
|
||
locations: { l1: { '@type': 'Location', name: 'Room B' } },
|
||
},
|
||
});
|
||
});
|
||
|
||
it('tells the override from the series by the members its PatchObject sets on Stalwart 1.0', () => {
|
||
const org = { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'accepted' };
|
||
const series = ev({
|
||
id: 's1', baseEventId: 'b', recurrenceId: '2026-09-08T10:00:00', title: 'Team sync',
|
||
participants: {
|
||
org, att: { '@type': 'Participant', calendarAddress: 'mailto:b@example.org', participationStatus: 'needs-action' },
|
||
} as any,
|
||
});
|
||
const declined = ev({
|
||
...series,
|
||
participants: {
|
||
org, att: { '@type': 'Participant', calendarAddress: 'mailto:b@example.org', participationStatus: 'declined' },
|
||
} as any,
|
||
});
|
||
const bases = new Map<string, Partial<CalendarEvent>>([['b', {
|
||
id: 'b', duration: 'PT1H',
|
||
recurrenceOverrides: { '2026-09-08T10:00:00': { 'participants/att/participationStatus': 'declined' } as any },
|
||
}]]);
|
||
|
||
for (const order of [[series, declined], [declined, series]]) {
|
||
const [merged, ...rest] = hydrateRecurrenceInstances(order, bases);
|
||
expect(rest).toEqual([]);
|
||
expect(merged.participants?.att.participationStatus).toBe('declined');
|
||
expect(merged.participants?.org.participationStatus).toBe('accepted');
|
||
}
|
||
});
|
||
});
|
||
|
||
// A reply to one occurrence, as 0.16 stored it: an override with little more
|
||
// than the attendee - no title, place or description. 1.0 builds that
|
||
// occurrence from the override alone, and returns the override as a
|
||
// PatchObject that removes what the series has and the stub lacks.
|
||
describe('hydrateRecurrenceInstances with a reply stub', () => {
|
||
const series: Partial<CalendarEvent> = {
|
||
id: 'b', duration: 'PT1H', recurrenceRules: [{ frequency: 'daily' } as any],
|
||
title: 'Team sync', description: 'Agenda in the doc',
|
||
locations: { l1: { '@type': 'Location', name: 'Room 4' } } as any,
|
||
};
|
||
const stub = (): CalendarEvent => {
|
||
const occurrence: Partial<CalendarEvent> = ev({
|
||
id: 's1', baseEventId: 'b', recurrenceId: '2026-09-08T10:00:00', start: '2026-09-08T10:00:00', duration: 'PT1H',
|
||
});
|
||
delete occurrence.title;
|
||
return occurrence as CalendarEvent;
|
||
};
|
||
const withOverride = (override: Record<string, unknown>) => new Map<string, Partial<CalendarEvent>>([['b', {
|
||
...series, recurrenceOverrides: { '2026-09-08T10:00:00': override as Partial<CalendarEvent> },
|
||
}]]);
|
||
|
||
it('takes the title, place and description from the series on Stalwart 1.0', () => {
|
||
const [out] = hydrateRecurrenceInstances([stub()], withOverride({
|
||
title: null, description: null, locations: null, 'participants/att/participationStatus': 'declined',
|
||
}));
|
||
expect(out.title).toBe('Team sync');
|
||
expect(out.locations).toEqual(series.locations);
|
||
expect(out.description).toBe('Agenda in the doc');
|
||
});
|
||
|
||
it('takes only the title from the series on 0.16, whose override is the stub itself', () => {
|
||
const [out] = hydrateRecurrenceInstances([stub()], withOverride({
|
||
participants: { att: { '@type': 'Participant', participationStatus: 'declined' } },
|
||
}));
|
||
expect(out.title).toBe('Team sync');
|
||
expect(out.locations).toBeUndefined();
|
||
expect(out.description).toBeUndefined();
|
||
});
|
||
|
||
it('keeps a title the override sets, even an empty one', () => {
|
||
const renamed = { ...stub(), title: 'Team sync (moved)' };
|
||
expect(hydrateRecurrenceInstances([renamed], withOverride({ title: 'Team sync (moved)' }))[0].title)
|
||
.toBe('Team sync (moved)');
|
||
expect(hydrateRecurrenceInstances([{ ...stub(), title: '' }], withOverride({ title: '' }))[0].title).toBe('');
|
||
});
|
||
});
|
||
|
||
// Until a562ea5 every editor save wrote the organizer into the override it
|
||
// rewrote; 0.16.19 refuses an answer to an occurrence whose override names
|
||
// none. An override on the base event (0.16 before 0.16.20, and ranges the
|
||
// device expands) gets the series' organizer back when it has none; through
|
||
// a synthetic id it is never sent. 1.0 refuses it inside an override once,
|
||
// and the store writes the override again without it.
|
||
describe.each(['0.16.19', '1.0'])('the organizer of an existing override written on the base event (Stalwart %s)', () => {
|
||
const occurrence = (entry: Record<string, unknown>) => ({
|
||
id: 'b:2026-09-08T10:00:00', originalId: 'b', start: '2026-09-08T10:00:00', recurrenceId: '2026-09-08T10:00:00',
|
||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' }],
|
||
organizerCalendarAddress: 'mailto:org@example.com',
|
||
recurrenceOverrides: { '2026-09-08T10:00:00': entry },
|
||
}) as unknown as CalendarEvent;
|
||
|
||
it('adds it to an override that has none', () => {
|
||
expect(withNewOverrideDetails(occurrence({ title: 'Moved' }), { title: 'Renamed' }, { seriesKeys: true }))
|
||
.toEqual({ title: 'Renamed', organizerCalendarAddress: 'mailto:org@example.com' });
|
||
});
|
||
|
||
it('leaves an override that has one, and a patch that sets one, alone', () => {
|
||
const named = occurrence({ title: 'Moved', organizerCalendarAddress: 'mailto:org@example.com' });
|
||
expect(withNewOverrideDetails(named, { title: 'Renamed' }, { seriesKeys: true })).toEqual({ title: 'Renamed' });
|
||
expect(withNewOverrideDetails(occurrence({}), { organizerCalendarAddress: null } as never, { seriesKeys: true }))
|
||
.toEqual({ organizerCalendarAddress: null });
|
||
});
|
||
|
||
it('sends nothing of the series through a synthetic id', () => {
|
||
expect(withNewOverrideDetails(occurrence({ title: 'Moved' }), { title: 'Renamed' })).toEqual({ title: 'Renamed' });
|
||
});
|
||
});
|