mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,17 @@ describe('toLocalDateTime', () => {
|
||||
|
||||
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(() => {
|
||||
vi.clearAllMocks();
|
||||
resetSyntheticIdSupport();
|
||||
@@ -182,7 +193,7 @@ describe('calendar operations', () => {
|
||||
|
||||
describe('getEvents for expanded occurrences', () => {
|
||||
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']);
|
||||
@@ -294,9 +305,7 @@ describe('calendar operations', () => {
|
||||
// single object) — requesting the RFC 8984 plural form returns no
|
||||
// recurrence data and repeating events vanish from the grid (#13).
|
||||
const events = [{ id: 'ev1', title: 'Meeting', start: '2026-03-29T10:00:00' }];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
|
||||
});
|
||||
mockRequest.mockResolvedValue(getAnswer(events));
|
||||
|
||||
const result = await getEvents(['ev1']);
|
||||
expect(result).toEqual(events);
|
||||
@@ -318,9 +327,7 @@ describe('calendar operations', () => {
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 },
|
||||
excludedRecurrenceRule: { frequency: 'weekly', interval: 10 },
|
||||
}];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
|
||||
});
|
||||
mockRequest.mockResolvedValue(getAnswer(events));
|
||||
|
||||
const [event] = await getEvents(['ev1']);
|
||||
expect(event.recurrenceRules).toEqual([
|
||||
@@ -340,9 +347,7 @@ describe('calendar operations', () => {
|
||||
start: '2026-03-02T10:00:00',
|
||||
recurrenceRule: [{ frequency: 'weekly', interval: 2 }],
|
||||
}];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
|
||||
});
|
||||
mockRequest.mockResolvedValue(getAnswer(events));
|
||||
|
||||
const [event] = await getEvents(['ev1']);
|
||||
expect(event.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 2 }]);
|
||||
@@ -357,9 +362,7 @@ describe('calendar operations', () => {
|
||||
{ id: 't5', progress: 'CANCELLED' },
|
||||
{ id: 't6', progress: 'needs-action' },
|
||||
];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list }, '0']],
|
||||
});
|
||||
mockRequest.mockResolvedValue(getAnswer(list));
|
||||
|
||||
const result = await getEvents(list.map((t) => t.id));
|
||||
expect(result.map((t) => t.progress)).toEqual([
|
||||
@@ -381,9 +384,7 @@ describe('calendar operations', () => {
|
||||
{ id: 't5', progress: null },
|
||||
{ id: 'ev1', title: 'Event' },
|
||||
];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list }, '0']],
|
||||
});
|
||||
mockRequest.mockResolvedValue(getAnswer(list));
|
||||
|
||||
const result = await getEvents(list.map((t) => t.id));
|
||||
expect(result.map((t) => t.progress as unknown)).toEqual([
|
||||
@@ -481,9 +482,7 @@ describe('calendar operations', () => {
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [['CalendarEvent/query', { ids: ['ev9'] }, '0']],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [['CalendarEvent/get', { list: [{ id: 'ev9', uid: 'inv@example.com' }] }, '0']],
|
||||
});
|
||||
.mockResolvedValueOnce(getAnswer([{ id: 'ev9', uid: 'inv@example.com' }]));
|
||||
|
||||
const found = await findEventsByUid('inv@example.com');
|
||||
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } 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 { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection';
|
||||
import {
|
||||
@@ -70,7 +70,7 @@ export function resetCalendarAccessDenied(): void {
|
||||
}
|
||||
|
||||
function isAccessDeniedError(err: unknown): boolean {
|
||||
const type = (err as { jmapErrorType?: string } | null)?.jmapErrorType;
|
||||
const type = jmapErrorType(err);
|
||||
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
|
||||
// returns no recurrence data at all, so recurring events silently render as
|
||||
// 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 = [
|
||||
'id', '@type', 'uid', 'calendarIds', 'title', 'description',
|
||||
'start', 'duration', 'timeZone', 'showWithoutTime',
|
||||
'utcStart', 'utcEnd', 'status', 'freeBusyStatus',
|
||||
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRule',
|
||||
'recurrenceOverrides', 'excludedRecurrenceRule', 'recurrenceId',
|
||||
'excludedRecurrenceRule', 'recurrenceId',
|
||||
'replyTo', 'organizerCalendarAddress', 'sequence',
|
||||
'locations', 'virtualLocations',
|
||||
'progress', 'due', 'priority', 'percentComplete',
|
||||
'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
|
||||
// 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'];
|
||||
|
||||
// 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.
|
||||
const CLIENT_ONLY_EVENT_KEYS = [
|
||||
'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];
|
||||
if (!entry) throw new Error('JMAP: empty method response');
|
||||
if (entry[0] === 'error') {
|
||||
const err = entry[1] || {};
|
||||
// Keep the JMAP error type so callers can tell an expected access
|
||||
// rejection (forbidden / accountNotFound on a shared account) apart from
|
||||
// a genuine failure.
|
||||
throw Object.assign(
|
||||
new Error(err.description || err.type || 'JMAP method error'),
|
||||
{ jmapErrorType: err.type as string | undefined },
|
||||
);
|
||||
}
|
||||
return entry[1] as T;
|
||||
/**
|
||||
* The answer to call `callId` (the first call when omitted). A method error
|
||||
* throws a JMAPMethodError with the JMAP type, so callers can tell an
|
||||
* expected access rejection (forbidden / accountNotFound on a shared
|
||||
* account) apart from a genuine failure.
|
||||
*/
|
||||
function methodResult<T = any>(res: any, callId?: string): T {
|
||||
return requireMethodResult<T>(res, callId);
|
||||
}
|
||||
|
||||
// Other session accounts that expose the calendars capability hold calendars
|
||||
@@ -485,26 +491,56 @@ export async function hydrateExpandedOccurrences(
|
||||
));
|
||||
if (baseIds.length === 0) return events;
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const timeZone = getUserTimeZone();
|
||||
const batchSize = jmapClient.getMaxObjectsInGet();
|
||||
const bases = new Map<string, Partial<CalendarEvent>>();
|
||||
for (let i = 0; i < baseIds.length; i += batchSize) {
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/get', {
|
||||
accountId: account,
|
||||
ids: baseIds.slice(i, i + batchSize),
|
||||
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));
|
||||
}
|
||||
const list = await fetchEventsById(account, baseIds, RECURRENCE_BASE_PROPERTIES, {
|
||||
timeZone: getUserTimeZone(),
|
||||
});
|
||||
for (const base of list as Partial<CalendarEvent>[]) {
|
||||
if (base.id) bases.set(base.id, normalizeRecurrenceProperties(base));
|
||||
}
|
||||
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
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
ids: 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 },
|
||||
): Promise<CalendarEvent[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const timeZone = getUserTimeZone();
|
||||
const batchSize = jmapClient.getMaxObjectsInGet();
|
||||
const all: CalendarEvent[] = [];
|
||||
for (let i = 0; i < ids.length; i += batchSize) {
|
||||
const batch = ids.slice(i, i + batchSize);
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/get', {
|
||||
accountId: account,
|
||||
ids: batch,
|
||||
properties: options?.expanded ? EXPANDED_EVENT_PROPERTIES : CALENDAR_EVENT_PROPERTIES,
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
const list = methodResult<{ list: CalendarEvent[] }>(res).list ?? [];
|
||||
all.push(...list.map((e) => normalizeTaskProgress(normalizeRecurrenceProperties(e))));
|
||||
}
|
||||
return all;
|
||||
const list = await fetchEventsById(
|
||||
account,
|
||||
ids,
|
||||
options?.expanded ? EXPANDED_EVENT_PROPERTIES : CALENDAR_EVENT_PROPERTIES,
|
||||
{ withOverrides: !options?.expanded, timeZone: getUserTimeZone() },
|
||||
);
|
||||
return (list as unknown as CalendarEvent[]).map((e) => normalizeTaskProgress(normalizeRecurrenceProperties(e)));
|
||||
}
|
||||
|
||||
export type EventIdentityFields = Pick<CalendarEvent, 'id' | 'uid' | 'calendarIds'>;
|
||||
|
||||
/** Just the id, uid and calendars of `ids`: what a UID look-up compares. */
|
||||
export async function getEventIdentities(ids: string[], accountId?: string): Promise<EventIdentityFields[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const account = accountId || jmapClient.accountId;
|
||||
return (await fetchEventsById(account, ids, EVENT_IDENTITY_PROPERTIES)) as unknown as EventIdentityFields[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
* and throw a `JMAPMethodError` when the server answered with the error
|
||||
|
||||
@@ -4,6 +4,7 @@ vi.mock('../../api/calendar', () => ({
|
||||
getCalendars: vi.fn(),
|
||||
queryEvents: vi.fn(),
|
||||
getEvents: vi.fn(),
|
||||
getEventIdentities: vi.fn(async () => []),
|
||||
scanCalendarObjects: vi.fn(),
|
||||
isCalendarAccessDenied: 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');
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ vi.mock('../../api/calendar', () => ({
|
||||
getCalendars: vi.fn(),
|
||||
queryEvents: vi.fn(),
|
||||
getEvents: vi.fn(),
|
||||
getEventIdentities: vi.fn(),
|
||||
scanCalendarObjects: vi.fn(),
|
||||
isCalendarAccessDenied: vi.fn(() => false),
|
||||
noteCalendarAccessError: vi.fn(() => false),
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
const mockGetCalendars = calendarApi.getCalendars as ReturnType<typeof vi.fn>;
|
||||
const mockQueryEvents = calendarApi.queryEvents 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 mockUpdateEvent = calendarApi.updateEvent 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(() => {
|
||||
vi.clearAllMocks();
|
||||
mockScan.mockResolvedValue([]);
|
||||
mockGetIdentities.mockResolvedValue([]);
|
||||
useCalendarStore.setState({
|
||||
calendars: [],
|
||||
events: [],
|
||||
@@ -595,7 +598,7 @@ describe('calendar-store', () => {
|
||||
loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-03-31T00:00:00Z' },
|
||||
});
|
||||
mockQueryEvents.mockResolvedValue(['e1', 'e2']);
|
||||
mockGetEvents.mockResolvedValue([
|
||||
mockGetIdentities.mockResolvedValue([
|
||||
{ id: 'e1', uid: 'uid-elsewhere', calendarIds: { 'cal-1': 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 () => {
|
||||
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'));
|
||||
|
||||
await expect(
|
||||
@@ -712,7 +715,7 @@ describe('calendar-store', () => {
|
||||
|
||||
it('resolves with nothing imported and nothing refused when every event is already there', async () => {
|
||||
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(
|
||||
useCalendarStore.getState().importEvents([{ uid: 'dup', title: 'Dup' }], 'cal-1'),
|
||||
|
||||
@@ -5,7 +5,7 @@ vi.mock('../../api/calendar', () => ({
|
||||
deleteCalendar: vi.fn(),
|
||||
parseCalendarBlob: vi.fn(),
|
||||
queryEvents: vi.fn(),
|
||||
getEvents: vi.fn(),
|
||||
getEventIdentities: vi.fn(),
|
||||
deleteEvents: vi.fn(),
|
||||
updateEvent: vi.fn(),
|
||||
updateCalendar: vi.fn(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Calendar, CalendarEvent, CalendarRights, Participant, StateChange
|
||||
import { normalizeAllDayDuration } from '../lib/calendar-utils';
|
||||
import {
|
||||
type CalendarUpdates,
|
||||
type EventIdentityFields,
|
||||
updateCalendar as apiUpdateCalendar,
|
||||
deleteCalendar as apiDeleteCalendar,
|
||||
clearCalendarEvents as apiClearCalendarEvents,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
getCalendars as fetchCalendars,
|
||||
queryEvents,
|
||||
getEvents as fetchEvents,
|
||||
getEventIdentities,
|
||||
scanCalendarObjects,
|
||||
isCalendarAccessDenied,
|
||||
noteCalendarAccessError,
|
||||
@@ -948,8 +950,8 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
let linked = 0;
|
||||
try {
|
||||
const existingIds = await queryEvents([], '', '', accountId);
|
||||
const existing = existingIds.length > 0 ? await fetchEvents(existingIds, accountId) : [];
|
||||
const byUid = new Map<string, CalendarEvent>();
|
||||
const existing = await getEventIdentities(existingIds, accountId);
|
||||
const byUid = new Map<string, EventIdentityFields>();
|
||||
for (const e of existing) if (e.uid) byUid.set(e.uid, e);
|
||||
const fresh: Partial<CalendarEvent>[] = [];
|
||||
for (const e of events) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
deleteCalendar,
|
||||
parseCalendarBlob,
|
||||
queryEvents,
|
||||
getEvents,
|
||||
getEventIdentities,
|
||||
deleteEvents,
|
||||
updateEvent,
|
||||
updateCalendar,
|
||||
@@ -128,7 +128,7 @@ async function syncFeedIntoCalendar(calendarId: string, url: string): Promise<vo
|
||||
|
||||
try {
|
||||
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 toDelete: string[] = [];
|
||||
for (const e of stale) {
|
||||
|
||||
Reference in New Issue
Block a user