fix: load calendar events and tasks in the app on Stalwart 1.0

Stalwart 1.0 refuses a CalendarEvent/get that asks for
recurrenceOverrides together with utcStart or utcEnd, and every event
read of the app did. The calendar showed a raw error over stale events,
and tasks, reminders, "all events" edits, contact activity, the
invitation banner's UID look-up and iCal subscriptions stopped working.
Subscriptions and imports also lost their UID check, so every sync tried
to create the whole feed again and ended in refusals.

- read a stored event's overrides with a second get in the same
  request; server-expanded occurrences get them from their base event
  as before
- read only ids, UIDs and calendars for the import and subscription
  UID look-ups
- fail a read when either get answers with an error, so the calendar
  keeps the events it has and shows the error
- keep the JMAP error type on calendar method errors (JMAPMethodError,
  plus a shared jmapErrorType helper in jmap-result)

0.16 accepts the split reads and returns the same events.
This commit is contained in:
Linus Rath
2026-09-25 19:45:19 +02:00
parent 4052523ba9
commit 28ade775e3
11 changed files with 723 additions and 79 deletions
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxObjectsInGet: () => 500,
getMaxCallsInRequest: () => 16,
},
}));
import { jmapClient } from '../jmap-client';
import { JMAPMethodError } from '../jmap-result';
import {
findEventsByUid,
getEventIdentities,
getEvents,
hydrateExpandedOccurrences,
resetSyntheticIdSupport,
} from '../calendar';
import {
OVERRIDES_WITH_UTC_ERROR,
asksForOverridesWithUtc,
createStalwartCalendarMock,
type StalwartCalendarMock,
type StalwartVersion,
} from './stalwart-calendar-mock';
// Stalwart 1.0 fails a CalendarEvent/get that asks for recurrenceOverrides
// together with utcStart or utcEnd - the whole call, before any id is read.
// Every get the app sends must keep them apart: a stored event gets its
// overrides from a second get in the same request, a server-expanded
// occurrence from its base event. 0.16 accepts either, and must get the same
// events back as before.
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
const SERIES = {
id: 'b', '@type': 'Event', uid: 'series@example.com', title: 'Daily standup', baseEventId: 'b',
start: '2026-09-07T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin',
utcStart: '2026-09-07T08:00:00Z', utcEnd: '2026-09-07T09:00:00Z', showWithoutTime: false,
recurrenceRule: { frequency: 'daily', count: 5 },
recurrenceOverrides: { '2026-09-10T10:00:00': { excluded: true } },
participants: { org: { '@type': 'Participant', calendarAddress: 'mailto:org@example.com', roles: { owner: true } } },
calendarIds: { cal: true },
};
const SINGLE = {
id: 'd', '@type': 'Event', uid: 'single@example.com', title: 'One off', baseEventId: 'd',
start: '2026-09-08T09:00:00', duration: 'PT30M', timeZone: 'Europe/Berlin',
utcStart: '2026-09-08T07:00:00Z', utcEnd: '2026-09-08T07:30:00Z', calendarIds: { other: true },
};
const OCCURRENCE = {
...SERIES, id: 'maaaaab', baseEventId: 'b', recurrenceRule: null, recurrenceOverrides: null,
start: '2026-09-08T10:00:00', utcStart: '2026-09-08T08:00:00Z', utcEnd: '2026-09-08T09:00:00Z',
recurrenceId: '2026-09-08T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin',
};
describe.each<StalwartVersion>(['0.16', '1.0'])('calendar reads on Stalwart %s', (version) => {
let server: StalwartCalendarMock;
beforeEach(() => {
vi.clearAllMocks();
resetSyntheticIdSupport();
server = createStalwartCalendarMock({
version,
objects: { b: SERIES, d: SINGLE },
occurrences: { maaaaab: OCCURRENCE },
});
mockRequest.mockImplementation(server.request);
});
afterEach(() => {
// The invariant: no get ever asks for both.
expect(server.gets().filter((call) => asksForOverridesWithUtc(call.args))).toEqual([]);
});
it(`reads a stored event with its overrides from a second get in the same request on Stalwart ${version}`, async () => {
const [series] = await getEvents(['b']);
expect(series.utcStart).toBe('2026-09-07T08:00:00Z');
expect(series.recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]);
expect(series.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } });
const [main, overrides] = server.gets();
expect(main.args.properties).toContain('utcStart');
expect(main.args.properties).not.toContain('recurrenceOverrides');
expect(overrides.args).toMatchObject({ ids: ['b'], properties: ['id', 'recurrenceOverrides'] });
expect(overrides.request).toBe(main.request);
expect(mockRequest).toHaveBeenCalledTimes(1);
});
it(`never asks for overrides of server-expanded occurrences on Stalwart ${version}`, async () => {
const [occurrence] = await getEvents(['maaaaab'], undefined, { expanded: true });
expect(occurrence.utcStart).toBe('2026-09-08T08:00:00Z');
expect(server.gets()).toHaveLength(1);
expect(server.gets()[0].args.properties).toEqual(expect.arrayContaining(['baseEventId', 'recurrenceIdTimeZone']));
expect(server.gets()[0].args.properties).not.toContain('recurrenceOverrides');
});
it(`gives an expanded occurrence its series' overrides from a get without utc* on Stalwart ${version}`, async () => {
const occurrences = await getEvents(['maaaaab'], undefined, { expanded: true });
const [hydrated] = await hydrateExpandedOccurrences(occurrences);
expect(hydrated.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } });
expect(hydrated.recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]);
const hydration = server.gets()[1];
expect(hydration.args.ids).toEqual(['b']);
expect(hydration.args.properties).not.toContain('utcStart');
});
it(`finds an invitation outside the loaded window by UID on Stalwart ${version}`, async () => {
const [found] = await findEventsByUid('series@example.com');
expect(found.id).toBe('b');
expect(found.participants?.org.calendarAddress).toBe('mailto:org@example.com');
expect(found.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } });
});
it(`reads only ids, UIDs and calendars for a UID look-up, in one get, on Stalwart ${version}`, async () => {
const identities = await getEventIdentities(['b', 'd']);
expect(identities).toEqual([
{ id: 'b', uid: 'series@example.com', calendarIds: { cal: true } },
{ id: 'd', uid: 'single@example.com', calendarIds: { other: true } },
]);
expect(server.gets()).toHaveLength(1);
expect(server.gets()[0].args.properties).toEqual(['id', 'uid', 'calendarIds']);
expect(await getEventIdentities([])).toEqual([]);
expect(mockRequest).toHaveBeenCalledTimes(1);
});
it(`reads every batch of maxObjectsInGet with its own overrides on Stalwart ${version}`, async () => {
const many = Object.fromEntries(Array.from({ length: 501 }, (_, i) => [`e${i}`, {
...SINGLE, id: `e${i}`, uid: `e${i}@example.com`,
recurrenceOverrides: i === 500 ? { '2026-09-09T09:00:00': { title: 'Last' } } : null,
}]));
server = createStalwartCalendarMock({ version, objects: many });
mockRequest.mockImplementation(server.request);
const events = await getEvents(Object.keys(many));
expect(events).toHaveLength(501);
expect(mockRequest).toHaveBeenCalledTimes(2);
expect(events[500].recurrenceOverrides).toEqual({ '2026-09-09T09:00:00': { title: 'Last' } });
});
});
describe('a refused calendar read', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('fails the read instead of passing an error answer for no events on Stalwart 1.0', async () => {
// What every read got on 1.0 before the split.
mockRequest.mockResolvedValue({ methodResponses: [['error', { ...OVERRIDES_WITH_UTC_ERROR }, '0']] });
const failure = getEvents(['b']);
await expect(failure).rejects.toBeInstanceOf(JMAPMethodError);
await expect(failure).rejects.toMatchObject({ type: 'invalidArguments', message: OVERRIDES_WITH_UTC_ERROR.description });
});
it('fails the read when only the overrides get is refused on Stalwart 1.0', async () => {
const server = createStalwartCalendarMock({
version: '1.0',
objects: { b: SERIES },
answer: (call) => (call.callId === '1' ? ['error', { type: 'serverFail' }] : undefined),
});
mockRequest.mockImplementation(server.request);
await expect(getEvents(['b'])).rejects.toMatchObject({ type: 'serverFail' });
});
it('fails the read when the server leaves the overrides get unanswered', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/get', { list: [SERIES] }, '0']] });
await expect(getEvents(['b'])).rejects.toMatchObject({ type: 'missingResponse' });
});
});
+18 -19
View File
@@ -40,6 +40,17 @@ describe('toLocalDateTime', () => {
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>; const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
// A stored event is read with a second get for its overrides in the same
// request (Stalwart 1.0 refuses them next to utcStart/utcEnd).
function getAnswer(list: unknown[]) {
return {
methodResponses: [
['CalendarEvent/get', { list }, '0'],
['CalendarEvent/get', { list: [] }, '1'],
],
};
}
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
resetSyntheticIdSupport(); resetSyntheticIdSupport();
@@ -182,7 +193,7 @@ describe('calendar operations', () => {
describe('getEvents for expanded occurrences', () => { describe('getEvents for expanded occurrences', () => {
it('asks for the base event id only then', async () => { it('asks for the base event id only then', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/get', { list: [] }, '0']] }); mockRequest.mockResolvedValue(getAnswer([]));
await getEvents(['a'], undefined, { expanded: true }); await getEvents(['a'], undefined, { expanded: true });
await getEvents(['a']); await getEvents(['a']);
@@ -294,9 +305,7 @@ describe('calendar operations', () => {
// single object) — requesting the RFC 8984 plural form returns no // single object) — requesting the RFC 8984 plural form returns no
// recurrence data and repeating events vanish from the grid (#13). // recurrence data and repeating events vanish from the grid (#13).
const events = [{ id: 'ev1', title: 'Meeting', start: '2026-03-29T10:00:00' }]; const events = [{ id: 'ev1', title: 'Meeting', start: '2026-03-29T10:00:00' }];
mockRequest.mockResolvedValue({ mockRequest.mockResolvedValue(getAnswer(events));
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
});
const result = await getEvents(['ev1']); const result = await getEvents(['ev1']);
expect(result).toEqual(events); expect(result).toEqual(events);
@@ -318,9 +327,7 @@ describe('calendar operations', () => {
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 }, recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 },
excludedRecurrenceRule: { frequency: 'weekly', interval: 10 }, excludedRecurrenceRule: { frequency: 'weekly', interval: 10 },
}]; }];
mockRequest.mockResolvedValue({ mockRequest.mockResolvedValue(getAnswer(events));
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
});
const [event] = await getEvents(['ev1']); const [event] = await getEvents(['ev1']);
expect(event.recurrenceRules).toEqual([ expect(event.recurrenceRules).toEqual([
@@ -340,9 +347,7 @@ describe('calendar operations', () => {
start: '2026-03-02T10:00:00', start: '2026-03-02T10:00:00',
recurrenceRule: [{ frequency: 'weekly', interval: 2 }], recurrenceRule: [{ frequency: 'weekly', interval: 2 }],
}]; }];
mockRequest.mockResolvedValue({ mockRequest.mockResolvedValue(getAnswer(events));
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
});
const [event] = await getEvents(['ev1']); const [event] = await getEvents(['ev1']);
expect(event.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 2 }]); expect(event.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 2 }]);
@@ -357,9 +362,7 @@ describe('calendar operations', () => {
{ id: 't5', progress: 'CANCELLED' }, { id: 't5', progress: 'CANCELLED' },
{ id: 't6', progress: 'needs-action' }, { id: 't6', progress: 'needs-action' },
]; ];
mockRequest.mockResolvedValue({ mockRequest.mockResolvedValue(getAnswer(list));
methodResponses: [['CalendarEvent/get', { list }, '0']],
});
const result = await getEvents(list.map((t) => t.id)); const result = await getEvents(list.map((t) => t.id));
expect(result.map((t) => t.progress)).toEqual([ expect(result.map((t) => t.progress)).toEqual([
@@ -381,9 +384,7 @@ describe('calendar operations', () => {
{ id: 't5', progress: null }, { id: 't5', progress: null },
{ id: 'ev1', title: 'Event' }, { id: 'ev1', title: 'Event' },
]; ];
mockRequest.mockResolvedValue({ mockRequest.mockResolvedValue(getAnswer(list));
methodResponses: [['CalendarEvent/get', { list }, '0']],
});
const result = await getEvents(list.map((t) => t.id)); const result = await getEvents(list.map((t) => t.id));
expect(result.map((t) => t.progress as unknown)).toEqual([ expect(result.map((t) => t.progress as unknown)).toEqual([
@@ -481,9 +482,7 @@ describe('calendar operations', () => {
.mockResolvedValueOnce({ .mockResolvedValueOnce({
methodResponses: [['CalendarEvent/query', { ids: ['ev9'] }, '0']], methodResponses: [['CalendarEvent/query', { ids: ['ev9'] }, '0']],
}) })
.mockResolvedValueOnce({ .mockResolvedValueOnce(getAnswer([{ id: 'ev9', uid: 'inv@example.com' }]));
methodResponses: [['CalendarEvent/get', { list: [{ id: 'ev9', uid: 'inv@example.com' }] }, '0']],
});
const found = await findEventsByUid('inv@example.com'); const found = await findEventsByUid('inv@example.com');
+206
View File
@@ -0,0 +1,206 @@
// A mock Stalwart calendar server for tests that run the real calendar api
// (src/api/calendar.ts) against `jmapClient.request`. What differs between
// the versions, as far as these tests go:
// - 1.0 refuses a CalendarEvent/get that asks for `recurrenceOverrides`
// together with `utcStart`/`utcEnd` (jmap/src/calendar_event/get.rs,
// ProjectionRequest::new), before reading any id; 0.16 answers any list.
// - 1.0 returns `recurrenceOverrides: null` for a synthetic occurrence id.
// Every method call is recorded with the request it came in.
import type { JMAPMethodCall } from '../types';
import { SYNTHETIC_ID_PROBE } from '../../lib/recurrence-instances';
export type StalwartVersion = '0.16' | '1.0';
/** Stalwart 1.0's answer to a CalendarEvent/get that asks for both. */
export const OVERRIDES_WITH_UTC_ERROR = {
type: 'invalidArguments',
description: 'recurrenceOverrides cannot be requested together with utcStart or utcEnd',
} as const;
/** True for a CalendarEvent/get Stalwart 1.0 refuses outright. */
export function asksForOverridesWithUtc(args: Record<string, unknown>): boolean {
const properties = args.properties;
if (!Array.isArray(properties)) return false;
return properties.includes('recurrenceOverrides')
&& (properties.includes('utcStart') || properties.includes('utcEnd'));
}
export interface RecordedCall {
method: string;
args: Record<string, unknown>;
callId: string;
/** Index of the request the call was part of. */
request: number;
}
export type MethodAnswer = [string, Record<string, unknown>];
export interface StalwartCalendarMockOptions {
version: StalwartVersion;
/** Stored events and tasks, by id. */
objects?: Record<string, Record<string, unknown>>;
/** What CalendarEvent/query with expandRecurrences hands out, by synthetic id. */
occurrences?: Record<string, Record<string, unknown>>;
/** Calendars Calendar/get lists. */
calendars?: Record<string, unknown>[];
/** Whether CalendarEvent/set takes synthetic ids (the probe answers notFound). Default true. */
syntheticIds?: boolean;
/** Answer a call yourself; return undefined for the default answer. */
answer?: (call: RecordedCall) => MethodAnswer | undefined;
}
export interface StalwartCalendarMock {
/** Stands in for `jmapClient.request`. */
request: (calls: JMAPMethodCall[], using?: string[]) => Promise<{ methodResponses: [string, Record<string, unknown>, string][] }>;
calls: RecordedCall[];
/** Every CalendarEvent/get sent so far. */
gets(): RecordedCall[];
queries(): RecordedCall[];
sets(): RecordedCall[];
objects: Record<string, Record<string, unknown>>;
}
function project(object: Record<string, unknown>, properties: unknown): Record<string, unknown> {
if (!Array.isArray(properties)) return { ...object };
const out: Record<string, unknown> = { id: object.id };
for (const key of properties as string[]) {
if (key in object) out[key] = object[key];
}
return out;
}
/** The calendar ids an `inCalendar` filter (one, or an OR of them) names; null without one. */
function calendarsOf(filter: Record<string, unknown> | undefined): Set<string> | null {
if (!filter) return null;
if (typeof filter.inCalendar === 'string') return new Set([filter.inCalendar]);
if (Array.isArray(filter.conditions)) {
const ids = new Set<string>();
let any = false;
for (const condition of filter.conditions as Record<string, unknown>[]) {
const inner = calendarsOf(condition);
if (!inner) continue;
any = true;
for (const id of inner) ids.add(id);
}
return any ? ids : null;
}
return null;
}
/** Point `jmapClient.request` at a mock Stalwart calendar server. */
export function createStalwartCalendarMock(options: StalwartCalendarMockOptions): StalwartCalendarMock {
const calls: RecordedCall[] = [];
const objects: Record<string, Record<string, unknown>> = { ...options.objects };
const occurrences = { ...options.occurrences };
let requests = 0;
let created = 0;
const defaultAnswer = (call: RecordedCall): MethodAnswer => {
const { method, args } = call;
if (method === 'Calendar/get') {
return ['Calendar/get', { accountId: args.accountId, list: options.calendars ?? [{ id: 'cal', name: 'Personal', isDefault: true }], notFound: [] }];
}
if (method === 'CalendarEvent/query') {
const filter = args.filter as Record<string, unknown> | undefined;
let ids = args.expandRecurrences ? Object.keys(occurrences) : Object.keys(objects);
if (typeof filter?.uid === 'string') ids = ids.filter((id) => objects[id]?.uid === filter.uid);
const wanted = calendarsOf(filter);
if (wanted) {
const source = args.expandRecurrences ? occurrences : objects;
ids = ids.filter((id) => Object.keys((source[id]?.calendarIds ?? {}) as object).some((c) => wanted.has(c)));
}
const position = (args.position as number | undefined) ?? 0;
const limit = (args.limit as number | undefined) ?? ids.length;
return ['CalendarEvent/query', { accountId: args.accountId, ids: ids.slice(position, position + limit), position }];
}
if (method === 'CalendarEvent/get') {
if (options.version === '1.0' && asksForOverridesWithUtc(args)) return ['error', { ...OVERRIDES_WITH_UTC_ERROR }];
const list: Record<string, unknown>[] = [];
const notFound: string[] = [];
for (const id of (args.ids as string[] | null) ?? Object.keys(objects)) {
const occurrence = occurrences[id];
const object = occurrence ?? objects[id];
if (!object) {
notFound.push(id);
continue;
}
const projected = project(object, args.properties);
if (options.version === '1.0' && occurrence && Array.isArray(args.properties)
&& (args.properties as string[]).includes('recurrenceOverrides')) {
projected.recurrenceOverrides = null;
}
list.push(projected);
}
return ['CalendarEvent/get', { accountId: args.accountId, list, notFound }];
}
if (method === 'CalendarEvent/set') {
const update = args.update as Record<string, Record<string, unknown>> | undefined;
if (update && SYNTHETIC_ID_PROBE in update) {
const failure = options.syntheticIds === false
? { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] }
: { type: 'notFound' };
return ['CalendarEvent/set', { notUpdated: { [SYNTHETIC_ID_PROBE]: failure } }];
}
const result: Record<string, unknown> = {};
const create = args.create as Record<string, Record<string, unknown>> | undefined;
if (create) {
const createdMap: Record<string, unknown> = {};
for (const [key, value] of Object.entries(create)) {
const id = `created-${++created}`;
objects[id] = { ...value, id };
createdMap[key] = { id };
}
result.created = createdMap;
}
if (update) {
for (const [id, patch] of Object.entries(update)) objects[id] = { ...objects[id], ...patch };
result.updated = Object.fromEntries(Object.keys(update).map((id) => [id, null]));
}
const destroy = args.destroy as string[] | undefined;
if (destroy) {
for (const id of destroy) delete objects[id];
result.destroyed = destroy;
}
return ['CalendarEvent/set', result];
}
return ['error', { type: 'unknownMethod' }];
};
const request = async (methodCalls: JMAPMethodCall[]) => {
const index = requests++;
const methodResponses: [string, Record<string, unknown>, string][] = [];
for (const [method, args, callId] of methodCalls) {
const call: RecordedCall = { method, args: args as Record<string, unknown>, callId, request: index };
calls.push(call);
const [name, body] = options.answer?.(call) ?? defaultAnswer(call);
methodResponses.push([name, body, callId]);
}
return { methodResponses };
};
return {
request,
calls,
gets: () => calls.filter((c) => c.method === 'CalendarEvent/get'),
queries: () => calls.filter((c) => c.method === 'CalendarEvent/query'),
sets: () => calls.filter((c) => c.method === 'CalendarEvent/set'),
objects,
};
}
/** The calendars account capability both versions advertise (0.16 hardcodes 365 days and never enforces it). */
export function calendarAccountCapability(
version: StalwartVersion,
maxExpandedQueryDuration: string | null = 'P52W1D',
): Record<string, unknown> {
const capability: Record<string, unknown> = {
maxCalendarsPerEvent: version === '1.0' ? 10 : null,
minDateTime: '0001-01-01T00:00:00Z',
maxDateTime: '9999-12-31T23:59:59Z',
maxParticipantsPerEvent: 20,
mayCreateCalendar: true,
};
if (maxExpandedQueryDuration !== null) capability.maxExpandedQueryDuration = maxExpandedQueryDuration;
return capability;
}
+91 -52
View File
@@ -1,7 +1,7 @@
import { jmapClient } from './jmap-client'; import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types'; import { CAPABILITIES } from './types';
import type { Calendar, CalendarEvent, CalendarRights } from './types'; import type { Calendar, CalendarEvent, CalendarRights } from './types';
import { assertSetResult } from './jmap-result'; import { assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result';
import { getEffectiveTimeZone } from '../lib/calendar-timezone'; import { getEffectiveTimeZone } from '../lib/calendar-timezone';
import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection'; import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection';
import { import {
@@ -70,7 +70,7 @@ export function resetCalendarAccessDenied(): void {
} }
function isAccessDeniedError(err: unknown): boolean { function isAccessDeniedError(err: unknown): boolean {
const type = (err as { jmapErrorType?: string } | null)?.jmapErrorType; const type = jmapErrorType(err);
return type === 'forbidden' || type === 'accountNotFound'; return type === 'forbidden' || type === 'accountNotFound';
} }
@@ -103,22 +103,34 @@ function buildInCalendarFilter(calendarIds: string[]): Record<string, unknown> {
// object, not RFC 8984's plural array forms. Requesting the plural names // object, not RFC 8984's plural array forms. Requesting the plural names
// returns no recurrence data at all, so recurring events silently render as // returns no recurrence data at all, so recurring events silently render as
// one-off events (#13). Request the singular names and normalize below. // one-off events (#13). Request the singular names and normalize below.
//
// No `recurrenceOverrides`: Stalwart 1.0 fails a CalendarEvent/get that asks
// for them together with `utcStart` or `utcEnd` (invalidArguments, before any
// id is read). getEvents reads them with a get of their own.
const CALENDAR_EVENT_PROPERTIES = [ const CALENDAR_EVENT_PROPERTIES = [
'id', '@type', 'uid', 'calendarIds', 'title', 'description', 'id', '@type', 'uid', 'calendarIds', 'title', 'description',
'start', 'duration', 'timeZone', 'showWithoutTime', 'start', 'duration', 'timeZone', 'showWithoutTime',
'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus',
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRule', 'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRule',
'recurrenceOverrides', 'excludedRecurrenceRule', 'recurrenceId', 'excludedRecurrenceRule', 'recurrenceId',
'replyTo', 'organizerCalendarAddress', 'sequence', 'replyTo', 'organizerCalendarAddress', 'sequence',
'locations', 'virtualLocations', 'locations', 'virtualLocations',
'progress', 'due', 'priority', 'percentComplete', 'progress', 'due', 'priority', 'percentComplete',
'links', 'created', 'updated', 'links', 'created', 'updated',
]; ];
/** The second get of getEvents: the overrides, by id. */
const OVERRIDE_PROPERTIES = ['id', 'recurrenceOverrides'];
// Asked for only on server-expanded ranges: servers that expand recurrences // Asked for only on server-expanded ranges: servers that expand recurrences
// for the app (see supportsSyntheticCalendarIds) know these. // for the app (see supportsSyntheticCalendarIds) know these. An occurrence
// gets its series' overrides from hydrateExpandedOccurrences instead.
const EXPANDED_EVENT_PROPERTIES = [...CALENDAR_EVENT_PROPERTIES, 'baseEventId', 'recurrenceIdTimeZone']; const EXPANDED_EVENT_PROPERTIES = [...CALENDAR_EVENT_PROPERTIES, 'baseEventId', 'recurrenceIdTimeZone'];
// What a UID look-up compares (import de-duplication, iCal subscription sync):
// reading whole events of a whole account for it would be wasted.
const EVENT_IDENTITY_PROPERTIES = ['id', 'uid', 'calendarIds'];
// Client-side fields that must never reach CalendarEvent/set. // Client-side fields that must never reach CalendarEvent/set.
const CLIENT_ONLY_EVENT_KEYS = [ const CLIENT_ONLY_EVENT_KEYS = [
'originalId', 'baseEventId', 'originalCalendarIds', 'accountId', 'isShared', 'localAccountId', 'originalId', 'baseEventId', 'originalCalendarIds', 'accountId', 'isShared', 'localAccountId',
@@ -225,20 +237,14 @@ function cleanRecurrenceRules(event: Record<string, unknown>): void {
} }
} }
function methodResult<T = any>(res: any, index = 0): T { /**
const entry = res?.methodResponses?.[index]; * The answer to call `callId` (the first call when omitted). A method error
if (!entry) throw new Error('JMAP: empty method response'); * throws a JMAPMethodError with the JMAP type, so callers can tell an
if (entry[0] === 'error') { * expected access rejection (forbidden / accountNotFound on a shared
const err = entry[1] || {}; * account) apart from a genuine failure.
// Keep the JMAP error type so callers can tell an expected access */
// rejection (forbidden / accountNotFound on a shared account) apart from function methodResult<T = any>(res: any, callId?: string): T {
// a genuine failure. return requireMethodResult<T>(res, callId);
throw Object.assign(
new Error(err.description || err.type || 'JMAP method error'),
{ jmapErrorType: err.type as string | undefined },
);
}
return entry[1] as T;
} }
// Other session accounts that expose the calendars capability hold calendars // Other session accounts that expose the calendars capability hold calendars
@@ -485,26 +491,56 @@ export async function hydrateExpandedOccurrences(
)); ));
if (baseIds.length === 0) return events; if (baseIds.length === 0) return events;
const account = accountId || jmapClient.accountId; const account = accountId || jmapClient.accountId;
const timeZone = getUserTimeZone();
const batchSize = jmapClient.getMaxObjectsInGet();
const bases = new Map<string, Partial<CalendarEvent>>(); const bases = new Map<string, Partial<CalendarEvent>>();
for (let i = 0; i < baseIds.length; i += batchSize) { const list = await fetchEventsById(account, baseIds, RECURRENCE_BASE_PROPERTIES, {
const res = await jmapClient.request( timeZone: getUserTimeZone(),
[['CalendarEvent/get', { });
accountId: account, for (const base of list as Partial<CalendarEvent>[]) {
ids: baseIds.slice(i, i + batchSize), if (base.id) bases.set(base.id, normalizeRecurrenceProperties(base));
properties: [...RECURRENCE_BASE_PROPERTIES],
...(timeZone ? { timeZone } : {}),
}, '0']],
USING,
);
for (const base of methodResult<{ list: Partial<CalendarEvent>[] }>(res).list ?? []) {
if (base.id) bases.set(base.id, normalizeRecurrenceProperties(base));
}
} }
return hydrateRecurrenceInstances(events, bases); return hydrateRecurrenceInstances(events, bases);
} }
/**
* CalendarEvent/get of `ids`, batched by maxObjectsInGet. With
* `withOverrides`, every request carries a second get of OVERRIDE_PROPERTIES
* and its overrides are merged in by id: Stalwart 1.0 refuses
* `recurrenceOverrides` in the same get as `utcStart`/`utcEnd`. An error
* answer to either get fails the whole read - taken as an empty list, it
* would pass for "no events".
*/
async function fetchEventsById(
accountId: string,
ids: string[],
properties: readonly string[],
options: { withOverrides?: boolean; timeZone?: string } = {},
): Promise<Record<string, unknown>[]> {
const { withOverrides = false, timeZone } = options;
const objects: Record<string, unknown>[] = [];
for (const batch of batched(ids, jmapClient.getMaxObjectsInGet())) {
const args = { accountId, ids: batch, ...(timeZone ? { timeZone } : {}) };
const calls: [string, Record<string, unknown>, string][] = [
['CalendarEvent/get', { ...args, properties: [...properties] }, '0'],
];
if (withOverrides) {
calls.push(['CalendarEvent/get', { ...args, properties: [...OVERRIDE_PROPERTIES] }, '1']);
}
const res = await jmapClient.request(calls, USING);
const list = methodResult<{ list?: Record<string, unknown>[] }>(res, '0').list ?? [];
if (withOverrides) {
const overrides = new Map<unknown, unknown>();
for (const entry of methodResult<{ list?: Record<string, unknown>[] }>(res, '1').list ?? []) {
if ('recurrenceOverrides' in entry) overrides.set(entry.id, entry.recurrenceOverrides);
}
for (const object of list) {
if (overrides.has(object.id)) object.recurrenceOverrides = overrides.get(object.id);
}
}
objects.push(...list);
}
return objects;
}
/** /**
* Every calendar object of an account with just the properties needed to tell * Every calendar object of an account with just the properties needed to tell
* tasks from events (see lib/calendar-component-detection). Used to find * tasks from events (see lib/calendar-component-detection). Used to find
@@ -541,32 +577,35 @@ export async function scanCalendarObjects(accountId?: string): Promise<ScannedCa
return all; return all;
} }
/**
* Events (or tasks) by id. A stored event comes with its recurrence
* overrides, read by a second get in the same request (fetchEventsById).
*/
export async function getEvents( export async function getEvents(
ids: string[], ids: string[],
accountId?: string, accountId?: string,
// `expanded`: the ids are server-expanded occurrences (queryExpandedEvents). // `expanded`: the ids are server-expanded occurrences (queryExpandedEvents),
// which get their series' overrides from hydrateExpandedOccurrences.
options?: { expanded?: boolean }, options?: { expanded?: boolean },
): Promise<CalendarEvent[]> { ): Promise<CalendarEvent[]> {
if (ids.length === 0) return []; if (ids.length === 0) return [];
const account = accountId || jmapClient.accountId; const account = accountId || jmapClient.accountId;
const timeZone = getUserTimeZone(); const list = await fetchEventsById(
const batchSize = jmapClient.getMaxObjectsInGet(); account,
const all: CalendarEvent[] = []; ids,
for (let i = 0; i < ids.length; i += batchSize) { options?.expanded ? EXPANDED_EVENT_PROPERTIES : CALENDAR_EVENT_PROPERTIES,
const batch = ids.slice(i, i + batchSize); { withOverrides: !options?.expanded, timeZone: getUserTimeZone() },
const res = await jmapClient.request( );
[['CalendarEvent/get', { return (list as unknown as CalendarEvent[]).map((e) => normalizeTaskProgress(normalizeRecurrenceProperties(e)));
accountId: account, }
ids: batch,
properties: options?.expanded ? EXPANDED_EVENT_PROPERTIES : CALENDAR_EVENT_PROPERTIES, export type EventIdentityFields = Pick<CalendarEvent, 'id' | 'uid' | 'calendarIds'>;
...(timeZone ? { timeZone } : {}),
}, '0']], /** Just the id, uid and calendars of `ids`: what a UID look-up compares. */
USING, export async function getEventIdentities(ids: string[], accountId?: string): Promise<EventIdentityFields[]> {
); if (ids.length === 0) return [];
const list = methodResult<{ list: CalendarEvent[] }>(res).list ?? []; const account = accountId || jmapClient.accountId;
all.push(...list.map((e) => normalizeTaskProgress(normalizeRecurrenceProperties(e)))); return (await fetchEventsById(account, ids, EVENT_IDENTITY_PROPERTIES)) as unknown as EventIdentityFields[];
}
return all;
} }
/** /**
+9
View File
@@ -20,6 +20,15 @@ export class JMAPMethodError extends Error {
} }
} }
/**
* The JMAP error type a thrown value carries: a method error, or a SetError
* thrown as a `JMAPMethodError`. Callers branch on it instead of the
* description, which differs between servers and Stalwart releases.
*/
export function jmapErrorType(err: unknown): string | undefined {
return err instanceof JMAPMethodError ? err.type : undefined;
}
/** /**
* Pull the response body for `callId` (or the first response when omitted) * Pull the response body for `callId` (or the first response when omitted)
* and throw a `JMAPMethodError` when the server answered with the error * and throw a `JMAPMethodError` when the server answered with the error
@@ -4,6 +4,7 @@ vi.mock('../../api/calendar', () => ({
getCalendars: vi.fn(), getCalendars: vi.fn(),
queryEvents: vi.fn(), queryEvents: vi.fn(),
getEvents: vi.fn(), getEvents: vi.fn(),
getEventIdentities: vi.fn(async () => []),
scanCalendarObjects: vi.fn(), scanCalendarObjects: vi.fn(),
isCalendarAccessDenied: vi.fn(() => false), isCalendarAccessDenied: vi.fn(() => false),
noteCalendarAccessError: vi.fn(() => false), noteCalendarAccessError: vi.fn(() => false),
@@ -0,0 +1,207 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// The real calendar api and store, against a mock Stalwart server
// (src/api/__tests__/stalwart-calendar-mock.ts). Stalwart 1.0 refuses a
// CalendarEvent/get that asks for recurrenceOverrides next to utcStart or
// utcEnd, and every read of the app did: the grid, tasks, reminders, the
// master of a series, the invitation look-up and the iCal subscription sync.
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
isConnected: true,
request: vi.fn(),
getMaxObjectsInGet: () => 500,
getMaxCallsInRequest: () => 16,
},
}));
vi.mock('../../api/blob', () => ({ uploadBytes: vi.fn(async () => ({ blobId: 'feed-blob' })) }));
import { jmapClient } from '../../api/jmap-client';
import type { CalendarEvent } from '../../api/types';
import { loadEventsInRange, useCalendarStore } from '../calendar-store';
import { useCalendarSubscriptionsStore } from '../calendar-subscriptions-store';
import {
asksForOverridesWithUtc,
createStalwartCalendarMock,
type StalwartCalendarMock,
type StalwartCalendarMockOptions,
type StalwartVersion,
} from '../../api/__tests__/stalwart-calendar-mock';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
const RANGE = { after: '2026-09-01T00:00:00.000Z', before: '2026-10-01T00:00:00.000Z' };
const CALENDARS = [{ id: 'cal', name: 'Personal', isDefault: true }, { id: 'work', name: 'Work' }];
const SERIES = {
id: 'b', '@type': 'Event', uid: 'series@example.com', title: 'Daily standup', baseEventId: 'b',
start: '2026-09-07T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin',
utcStart: '2026-09-07T08:00:00Z', utcEnd: '2026-09-07T09:00:00Z', showWithoutTime: false,
recurrenceRule: { frequency: 'daily', count: 5 },
recurrenceOverrides: { '2026-09-10T10:00:00': { excluded: true } },
calendarIds: { cal: true },
};
const SINGLE = {
id: 'd', '@type': 'Event', uid: 'single@example.com', title: 'One off', baseEventId: 'd',
start: '2026-09-08T09:00:00', duration: 'PT30M', timeZone: 'Europe/Berlin',
utcStart: '2026-09-08T07:00:00Z', utcEnd: '2026-09-08T07:30:00Z', calendarIds: { work: true },
};
const TASK = {
id: 't', '@type': 'Task', uid: 'task@example.com', title: 'Pay rent', due: '2026-09-10T00:00:00',
progress: 'needs-action', calendarIds: { cal: true },
};
const occurrenceOf = (id: string, day: string) => ({
...SERIES, id, baseEventId: 'b', recurrenceRule: null, recurrenceOverrides: null,
start: `2026-09-${day}T10:00:00`, utcStart: `2026-09-${day}T08:00:00Z`, utcEnd: `2026-09-${day}T09:00:00Z`,
recurrenceId: `2026-09-${day}T10:00:00`, recurrenceIdTimeZone: 'Europe/Berlin',
});
const OCCURRENCES = {
maaaaab: occurrenceOf('maaaaab', '07'),
maaaaac: occurrenceOf('maaaaac', '08'),
eaaaaad: { ...SINGLE, id: 'eaaaaad', baseEventId: 'd' },
};
describe.each<StalwartVersion>(['0.16', '1.0'])('calendar reads through the store on Stalwart %s', (version) => {
let server: StalwartCalendarMock;
function serve(options: Partial<StalwartCalendarMockOptions> = {}): void {
server = createStalwartCalendarMock({
version,
calendars: CALENDARS,
objects: { b: SERIES, d: SINGLE, t: TASK },
occurrences: OCCURRENCES,
...options,
});
mockRequest.mockImplementation(server.request);
}
beforeEach(() => {
vi.clearAllMocks();
useCalendarStore.getState().reset();
useCalendarStore.setState({ calendars: CALENDARS as never, hiddenCalendarIds: [] });
serve();
});
afterEach(() => {
// The invariant: no get ever asks for both.
expect(server.gets().filter((call) => asksForOverridesWithUtc(call.args))).toEqual([]);
});
it(`loads a range the server expands, with the series context of its occurrences, on Stalwart ${version}`, async () => {
await useCalendarStore.getState().fetchEvents(['cal', 'work'], RANGE.after, RANGE.before);
const { events, error } = useCalendarStore.getState();
expect(error).toBeNull();
expect(events.map((e) => e.id)).toEqual(['maaaaab', 'maaaaac', 'eaaaaad']);
expect(events[0].recurrenceRules).toEqual([{ frequency: 'daily', count: 5 }]);
expect(events[0].recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } });
});
it(`expands a series on the device from a read that carries its overrides on Stalwart ${version}`, async () => {
serve({ syntheticIds: false });
await useCalendarStore.getState().fetchEvents(['cal', 'work'], RANGE.after, RANGE.before);
const { events, error } = useCalendarStore.getState();
expect(error).toBeNull();
const standups = events.filter((e) => e.originalId === 'b').map((e) => e.recurrenceId);
// The override excludes the fourth day.
expect(standups).toEqual([
'2026-09-07T10:00:00', '2026-09-08T10:00:00', '2026-09-09T10:00:00', '2026-09-11T10:00:00',
]);
expect(events.some((e) => e.id === 'd')).toBe(true);
});
it(`loads the reminders' window on Stalwart ${version}`, async () => {
const upcoming = await loadEventsInRange(CALENDARS as never, ['cal', 'work'], RANGE.after, RANGE.before);
expect(upcoming.map((e) => e.id)).toEqual(['maaaaab', 'maaaaac', 'eaaaaad']);
});
it(`loads tasks on Stalwart ${version}`, async () => {
await useCalendarStore.getState().fetchTasks();
const { tasks, error } = useCalendarStore.getState();
expect(error).toBeNull();
expect(tasks.map((task) => [task.id, task.due])).toEqual([['t', '2026-09-10T00:00:00']]);
});
it(`fetches the master of an occurrence for an edit of all events on Stalwart ${version}`, async () => {
const occurrence = { ...OCCURRENCES.maaaaac, recurrenceRules: [{ frequency: 'daily' }] } as unknown as CalendarEvent;
const master = await useCalendarStore.getState().getMasterEvent(occurrence);
expect(master?.id).toBe('b');
expect(master?.start).toBe('2026-09-07T10:00:00');
expect(master?.recurrenceOverrides).toEqual({ '2026-09-10T10:00:00': { excluded: true } });
});
it(`imports events without creating the ones already there on Stalwart ${version}`, async () => {
const result = await useCalendarStore.getState().importEvents([
// Already in this calendar: skipped.
{ uid: 'series@example.com', title: 'Daily standup', start: '2026-09-07T10:00:00' },
// In another calendar: linked into this one.
{ uid: 'single@example.com', title: 'One off', start: '2026-09-08T09:00:00' },
{ uid: 'new@example.com', title: 'New', start: '2026-09-12T09:00:00' },
], 'cal');
expect(result).toEqual({ imported: 2, refused: [] });
const writes = server.sets().filter((call) => !(call.args.update as Record<string, unknown> | undefined)?.h333333);
const created = writes.flatMap((call) => Object.values((call.args.create ?? {}) as Record<string, { uid: string }>));
expect(created.map((event) => event.uid)).toEqual(['new@example.com']);
expect(server.objects.d.calendarIds).toEqual({ work: true, cal: true });
// The UID look-up reads ids, UIDs and calendars only.
expect(server.gets()[0].args.properties).toEqual(['id', 'uid', 'calendarIds']);
});
it(`removes what a subscribed feed dropped and adds only what is new on Stalwart ${version}`, async () => {
serve({
objects: {
old: { id: 'old', uid: 'old@feed', title: 'Gone upstream', start: '2026-09-02T09:00:00', calendarIds: { feed: true } },
kept: { id: 'kept', uid: 'kept@feed', title: 'Still there', start: '2026-09-03T09:00:00', calendarIds: { feed: true } },
},
answer: (call) => (call.method === 'CalendarEvent/parse'
? ['CalendarEvent/parse', { parsed: { 'feed-blob': [
{ uid: 'kept@feed', title: 'Still there', start: '2026-09-03T09:00:00' },
{ uid: 'new@feed', title: 'New upstream', start: '2026-09-04T09:00:00' },
] } }]
: undefined),
});
useCalendarStore.setState({ calendars: [...CALENDARS, { id: 'feed', name: 'Feed' }] as never });
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => null },
text: async () => 'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n',
} as unknown as Response);
useCalendarSubscriptionsStore.setState({
subscriptions: [{ id: 'sub', name: 'Feed', url: 'https://example.com/feed.ics', calendarId: 'feed', lastSyncAt: null, lastError: null }],
});
try {
await useCalendarSubscriptionsStore.getState().syncSubscription('sub');
} finally {
fetchSpy.mockRestore();
}
expect(useCalendarSubscriptionsStore.getState().subscriptions[0].lastError).toBeNull();
expect(Object.keys(server.objects).sort()).toEqual(['created-1', 'kept']);
expect(server.objects['created-1'].uid).toBe('new@feed');
});
it(`keeps the loaded events and reports the error when the server refuses a range on Stalwart ${version}`, async () => {
await useCalendarStore.getState().fetchEvents(['cal', 'work'], RANGE.after, RANGE.before);
const loaded = useCalendarStore.getState().events;
serve({
answer: (call) => (call.method === 'CalendarEvent/get'
? ['error', { type: 'serverFail', description: 'Internal server error' }]
: undefined),
});
await useCalendarStore.getState().refresh();
const { events, error } = useCalendarStore.getState();
expect(events).toEqual(loaded);
expect(error).toBe('Internal server error');
});
});
+6 -3
View File
@@ -4,6 +4,7 @@ vi.mock('../../api/calendar', () => ({
getCalendars: vi.fn(), getCalendars: vi.fn(),
queryEvents: vi.fn(), queryEvents: vi.fn(),
getEvents: vi.fn(), getEvents: vi.fn(),
getEventIdentities: vi.fn(),
scanCalendarObjects: vi.fn(), scanCalendarObjects: vi.fn(),
isCalendarAccessDenied: vi.fn(() => false), isCalendarAccessDenied: vi.fn(() => false),
noteCalendarAccessError: vi.fn(() => false), noteCalendarAccessError: vi.fn(() => false),
@@ -46,6 +47,7 @@ import {
const mockGetCalendars = calendarApi.getCalendars as ReturnType<typeof vi.fn>; const mockGetCalendars = calendarApi.getCalendars as ReturnType<typeof vi.fn>;
const mockQueryEvents = calendarApi.queryEvents as ReturnType<typeof vi.fn>; const mockQueryEvents = calendarApi.queryEvents as ReturnType<typeof vi.fn>;
const mockGetEvents = calendarApi.getEvents as ReturnType<typeof vi.fn>; const mockGetEvents = calendarApi.getEvents as ReturnType<typeof vi.fn>;
const mockGetIdentities = calendarApi.getEventIdentities as ReturnType<typeof vi.fn>;
const mockCreateEvent = calendarApi.createEvent as ReturnType<typeof vi.fn>; const mockCreateEvent = calendarApi.createEvent as ReturnType<typeof vi.fn>;
const mockUpdateEvent = calendarApi.updateEvent as ReturnType<typeof vi.fn>; const mockUpdateEvent = calendarApi.updateEvent as ReturnType<typeof vi.fn>;
const mockDeleteEvents = calendarApi.deleteEvents as ReturnType<typeof vi.fn>; const mockDeleteEvents = calendarApi.deleteEvents as ReturnType<typeof vi.fn>;
@@ -59,6 +61,7 @@ const mockHydrate = calendarApi.hydrateExpandedOccurrences as ReturnType<typeof
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockScan.mockResolvedValue([]); mockScan.mockResolvedValue([]);
mockGetIdentities.mockResolvedValue([]);
useCalendarStore.setState({ useCalendarStore.setState({
calendars: [], calendars: [],
events: [], events: [],
@@ -595,7 +598,7 @@ describe('calendar-store', () => {
loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-03-31T00:00:00Z' }, loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-03-31T00:00:00Z' },
}); });
mockQueryEvents.mockResolvedValue(['e1', 'e2']); mockQueryEvents.mockResolvedValue(['e1', 'e2']);
mockGetEvents.mockResolvedValue([ mockGetIdentities.mockResolvedValue([
{ id: 'e1', uid: 'uid-elsewhere', calendarIds: { 'cal-1': true } }, { id: 'e1', uid: 'uid-elsewhere', calendarIds: { 'cal-1': true } },
{ id: 'e2', uid: 'uid-here', calendarIds: { 'cal-2': true } }, { id: 'e2', uid: 'uid-here', calendarIds: { 'cal-2': true } },
]); ]);
@@ -677,7 +680,7 @@ describe('calendar-store', () => {
it('reports a link into the target calendar that the server refused', async () => { it('reports a link into the target calendar that the server refused', async () => {
mockQueryEvents.mockResolvedValue(['e1']); mockQueryEvents.mockResolvedValue(['e1']);
mockGetEvents.mockResolvedValue([{ id: 'e1', uid: 'shared', calendarIds: { 'cal-1': true } }]); mockGetIdentities.mockResolvedValue([{ id: 'e1', uid: 'shared', calendarIds: { 'cal-1': true } }]);
mockUpdateEvent.mockRejectedValue(new Error('forbidden')); mockUpdateEvent.mockRejectedValue(new Error('forbidden'));
await expect( await expect(
@@ -712,7 +715,7 @@ describe('calendar-store', () => {
it('resolves with nothing imported and nothing refused when every event is already there', async () => { it('resolves with nothing imported and nothing refused when every event is already there', async () => {
mockQueryEvents.mockResolvedValue(['e1']); mockQueryEvents.mockResolvedValue(['e1']);
mockGetEvents.mockResolvedValue([{ id: 'e1', uid: 'dup', calendarIds: { 'cal-1': true } }]); mockGetIdentities.mockResolvedValue([{ id: 'e1', uid: 'dup', calendarIds: { 'cal-1': true } }]);
await expect( await expect(
useCalendarStore.getState().importEvents([{ uid: 'dup', title: 'Dup' }], 'cal-1'), useCalendarStore.getState().importEvents([{ uid: 'dup', title: 'Dup' }], 'cal-1'),
@@ -5,7 +5,7 @@ vi.mock('../../api/calendar', () => ({
deleteCalendar: vi.fn(), deleteCalendar: vi.fn(),
parseCalendarBlob: vi.fn(), parseCalendarBlob: vi.fn(),
queryEvents: vi.fn(), queryEvents: vi.fn(),
getEvents: vi.fn(), getEventIdentities: vi.fn(),
deleteEvents: vi.fn(), deleteEvents: vi.fn(),
updateEvent: vi.fn(), updateEvent: vi.fn(),
updateCalendar: vi.fn(), updateCalendar: vi.fn(),
+4 -2
View File
@@ -7,6 +7,7 @@ import type { Calendar, CalendarEvent, CalendarRights, Participant, StateChange
import { normalizeAllDayDuration } from '../lib/calendar-utils'; import { normalizeAllDayDuration } from '../lib/calendar-utils';
import { import {
type CalendarUpdates, type CalendarUpdates,
type EventIdentityFields,
updateCalendar as apiUpdateCalendar, updateCalendar as apiUpdateCalendar,
deleteCalendar as apiDeleteCalendar, deleteCalendar as apiDeleteCalendar,
clearCalendarEvents as apiClearCalendarEvents, clearCalendarEvents as apiClearCalendarEvents,
@@ -14,6 +15,7 @@ import {
getCalendars as fetchCalendars, getCalendars as fetchCalendars,
queryEvents, queryEvents,
getEvents as fetchEvents, getEvents as fetchEvents,
getEventIdentities,
scanCalendarObjects, scanCalendarObjects,
isCalendarAccessDenied, isCalendarAccessDenied,
noteCalendarAccessError, noteCalendarAccessError,
@@ -948,8 +950,8 @@ export const useCalendarStore = create<CalendarState>()(
let linked = 0; let linked = 0;
try { try {
const existingIds = await queryEvents([], '', '', accountId); const existingIds = await queryEvents([], '', '', accountId);
const existing = existingIds.length > 0 ? await fetchEvents(existingIds, accountId) : []; const existing = await getEventIdentities(existingIds, accountId);
const byUid = new Map<string, CalendarEvent>(); const byUid = new Map<string, EventIdentityFields>();
for (const e of existing) if (e.uid) byUid.set(e.uid, e); for (const e of existing) if (e.uid) byUid.set(e.uid, e);
const fresh: Partial<CalendarEvent>[] = []; const fresh: Partial<CalendarEvent>[] = [];
for (const e of events) { for (const e of events) {
+2 -2
View File
@@ -7,7 +7,7 @@ import {
deleteCalendar, deleteCalendar,
parseCalendarBlob, parseCalendarBlob,
queryEvents, queryEvents,
getEvents, getEventIdentities,
deleteEvents, deleteEvents,
updateEvent, updateEvent,
updateCalendar, updateCalendar,
@@ -128,7 +128,7 @@ async function syncFeedIntoCalendar(calendarId: string, url: string): Promise<vo
try { try {
const ids = await queryEvents([calendarId], '', ''); const ids = await queryEvents([calendarId], '', '');
const existing = ids.length > 0 ? await getEvents(ids) : []; const existing = await getEventIdentities(ids);
const stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid)); const stale = existing.filter((e) => e.uid && !parsedUids.has(e.uid));
const toDelete: string[] = []; const toDelete: string[] = [];
for (const e of stale) { for (const e of stale) {