Merge branch 's10/cal' into feat/stalwart-1.0

This commit is contained in:
Linus Rath
2026-10-29 02:27:44 +01:00
6 changed files with 1019 additions and 17 deletions
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const h = vi.hoisted(() => ({ session: null as unknown }));
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxObjectsInGet: () => 500,
getMaxCallsInRequest: () => 16,
get currentSession() {
return h.session;
},
},
}));
import { jmapClient } from '../jmap-client';
import { queryEvents, queryExpandedEvents, resetTimeZoneChecks } from '../calendar';
import { useSettingsStore } from '../../stores/settings-store';
// Stalwart 0.16 cannot place a range bound whose wall clock names no instant
// in the request's zone (the hour the clocks skip) or two (the hour they
// repeat): an expanded query fails with "Both 'after' and 'before' filters
// are required when expanding recurrences", a plain one drops the bound
// (live on 0.16.27). Such a bound goes out moved out of the range.
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
function connect(maxExpandedQueryDuration = 'P52W1D'): void {
h.session = {
apiUrl: 'https://mail.example.org/jmap/',
accounts: { 'acc-1': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': { maxExpandedQueryDuration } } } },
};
mockRequest.mockImplementation(async (calls: [string, unknown, string][]) => ({
methodResponses: calls.map(([name, , id]) => [name, { ids: [] }, id]),
}));
}
const bounds = () => mockRequest.mock.calls.flatMap(([calls]) =>
(calls as [string, { filter?: Record<string, unknown> }, string][])
.filter(([name]) => name === 'CalendarEvent/query')
.map(([, args]) => {
const filter = args.filter as { after?: string; before?: string; conditions?: { after?: string; before?: string }[] };
const range = filter.conditions?.find((c) => 'after' in c) ?? filter;
return [range.after, range.before];
}));
beforeEach(() => {
vi.clearAllMocks();
resetTimeZoneChecks();
});
afterEach(() => {
useSettingsStore.setState({ calendarTimeZone: 'auto' });
h.session = null;
});
describe('range bounds next to a change of the clocks', () => {
// Havana turns the clocks back from 01:00 to 00:00 on 7 November 2027:
// the week from that Sunday starts at a midnight that happens twice.
const HAVANA_WEEK = { after: '2027-11-07T04:00:00.000Z', before: '2027-11-14T05:00:00.000Z' };
it('moves an expanded query\'s start out of the hour that repeats', async () => {
useSettingsStore.setState({ calendarTimeZone: 'America/Havana' });
connect();
await queryExpandedEvents(HAVANA_WEEK.after, HAVANA_WEEK.before);
expect(bounds()).toEqual([['2027-11-06T23:45:00', '2027-11-14T00:00:00']]);
});
it('moves a plain query\'s start out of the hour that repeats', async () => {
useSettingsStore.setState({ calendarTimeZone: 'America/Havana' });
connect();
await queryEvents(['cal-1'], HAVANA_WEEK.after, HAVANA_WEEK.before);
expect(bounds()).toEqual([['2027-11-06T23:45:00', '2027-11-14T00:00:00']]);
});
it('keeps chunk borders out of the hour the clocks skip, still overlapping', async () => {
// Santiago skips from 00:00 to 01:00 on 5 September 2027. With a limit
// of two days the chunks are a day long and their borders fall at 00:30.
useSettingsStore.setState({ calendarTimeZone: 'America/Santiago' });
connect('P2D');
await queryExpandedEvents('2027-09-04T04:30:00.000Z', '2027-09-06T04:30:00.000Z');
expect(bounds()).toEqual([
['2027-09-04T00:30:00', '2027-09-05T01:00:00'],
['2027-09-04T23:59:59', '2027-09-06T00:29:59'],
['2027-09-06T00:29:58', '2027-09-06T01:30:00'],
]);
});
it('leaves bounds that name one instant as they are', async () => {
useSettingsStore.setState({ calendarTimeZone: 'Europe/Berlin' });
connect();
// Berlin turns the clocks back at 03:00; its midnights are unique.
await queryExpandedEvents('2027-10-30T22:00:00.000Z', '2027-11-06T23:00:00.000Z');
expect(bounds()).toEqual([['2027-10-31T00:00:00', '2027-11-07T00:00:00']]);
});
});
+36
View File
@@ -28,6 +28,8 @@ import {
hydrateExpandedOccurrences,
maxParticipantsPerEvent,
rsvpEvent,
isCalendarAccessDenied,
resetCalendarAccessDenied,
} from '../calendar';
import { JMAPSetError, jmapErrorType } from '../jmap-result';
import { useSettingsStore } from '../../stores/settings-store';
@@ -68,6 +70,40 @@ beforeEach(() => {
describe('calendar operations', () => {
describe('getCalendars', () => {
// Stalwart 1.0 lists an account that shares only a mail folder with the
// calendars capability and answers its Calendar/get with
// accountNotSupportedByMethod (0.16: forbidden).
it.each(['forbidden', 'accountNotSupportedByMethod'])(
'asks an account that answers %s for its calendars once per session',
async (type) => {
const client = jmapClient as unknown as { currentSession?: unknown };
client.currentSession = {
accounts: {
'acc-1': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } },
'acc-mail': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } },
},
};
try {
resetCalendarAccessDenied();
mockRequest.mockImplementation(async (calls: [string, { accountId: string }, string][]) => ({
methodResponses: calls.map(([name, args, id]) => (args.accountId === 'acc-mail'
? ['error', { type }, id]
: [name, name === 'Calendar/get' ? { list: [] } : {}, id])),
}));
await getCalendars();
await getCalendars();
const asked = mockRequest.mock.calls.filter(([calls]) =>
(calls as [string, { accountId: string }][]).some(([, args]) => args.accountId === 'acc-mail'));
expect(asked).toHaveLength(1);
expect(isCalendarAccessDenied('acc-mail')).toBe(true);
} finally {
client.currentSession = undefined;
mockRequest.mockReset();
resetCalendarAccessDenied();
}
},
);
it('should fetch calendars', async () => {
const calendars = [{ id: 'cal-1', name: 'Personal', color: '#3b82f6' }];
mockRequest.mockResolvedValue({
@@ -0,0 +1,726 @@
/**
* Live calendar checks against a real Stalwart server.
*
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and
* `BULWARK_LIVE_PASS` are set. `BULWARK_LIVE_USER2` (same password) is the
* attendee of the invitations and the account that shares a calendar and a
* mail folder with the first one; without it those checks are skipped.
*
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
* BULWARK_LIVE_USER=e2e-tablet-light@example.org \
* BULWARK_LIVE_USER2=e2e-tablet-land@example.org \
* BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart-calendar.test.ts
*
* Everything it creates carries the run's marker in its title or name and is
* removed afterwards, also after a failed check. The events lie in 2027, away
* from anything a person keeps in the account.
*
* Verified against stalwartlabs/stalwart:v0.16.27 and v1.0.0.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
vi.hoisted(() => {
process.env.TZ = 'Europe/Berlin';
});
import { JMAPClient } from '../jmap-client';
import { CAPABILITIES } from '../types';
import type { Calendar, CalendarEvent, Participant } from '../types';
import {
createEvent,
deleteEvents,
getCalendars,
getEvents,
queryEvents,
isCalendarAccessDenied,
} from '../calendar';
import { useCalendarStore } from '../../stores/calendar-store';
import { useSettingsStore } from '../../stores/settings-store';
import { saveOccurrenceEdit, deleteOccurrence, type SeriesEdit } from '../../lib/recurrence-scope-edit';
import { editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-editor-times';
import { participantFieldsToSave, participantLimitOf, seedAttendees, type Attendee } from '../../lib/calendar-participants';
import { expandRecurringEvents } from '../../lib/recurrence-expansion';
import { getEventStartDate } from '../../lib/calendar-utils';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
const USER2 = process.env.BULWARK_LIVE_USER2;
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
const live = SERVER && PASS ? describe : describe.skip;
const liveTwo = SERVER && PASS && USER2 ? describe : describe.skip;
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
const MAIL_USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
const RUN = `live-cal-${Date.now().toString(36)}`;
let client: JMAPClient;
let other: JMAPClient | null = null;
async function connectSingleton(): Promise<void> {
const mod = await import('../jmap-client');
client = new JMAPClient();
await client.connect(SERVER!, USER, PASS);
// The singleton is a const binding; copy the connected state onto it.
(mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot());
useCalendarStore.getState().reset();
}
/** The singleton the api modules use. */
async function singleton(): Promise<JMAPClient> {
return (await import('../jmap-client')).jmapClient as unknown as JMAPClient;
}
type Response = [string, Record<string, any>, string];
async function raw(c: JMAPClient, calls: [string, Record<string, unknown>, string][], using: string[] = USING): Promise<Response[]> {
return (await c.request(calls, using)).methodResponses as Response[];
}
/** Stalwart 1.0 advertises JMAP Metadata; 0.16 does not. */
function isStalwart10(): boolean {
return Object.values(client.currentSession?.accounts ?? {}).some(
(account) => 'urn:ietf:params:jmap:metadata' in (account.accountCapabilities ?? {}),
);
}
async function ownCalendarId(): Promise<string> {
const calendars = await getCalendars();
const own = calendars.filter((c) => !c.accountId);
const cal = own.find((c) => c.isDefault) ?? own[0];
expect(cal, 'the account has a calendar').toBeTruthy();
return cal.id;
}
const SERIES_RULE = (count: number) => [{ '@type': 'RecurrenceRule', frequency: 'weekly', count }] as CalendarEvent['recurrenceRules'];
function seriesData(title: string, start: string, count: number, extra: Partial<CalendarEvent> = {}): Partial<CalendarEvent> {
return {
'@type': 'Event',
title: `${RUN} ${title}`,
start,
duration: 'PT1H',
timeZone: 'Europe/Berlin',
recurrenceRules: SERIES_RULE(count),
...extra,
} as Partial<CalendarEvent>;
}
/** Load [after, before] into the store and return the occurrences of `uid`, by start. */
async function loadOccurrences(uid: string, after: string, before: string): Promise<CalendarEvent[]> {
const store = useCalendarStore.getState();
await store.fetchCalendars();
const ids = useCalendarStore.getState().calendars.map((c) => c.id);
await useCalendarStore.getState().fetchEvents(ids, after, before);
expect(useCalendarStore.getState().error).toBeNull();
return useCalendarStore.getState().events
.filter((e) => e.uid === uid)
.sort((a, b) => getEventStartDate(a).getTime() - getEventStartDate(b).getTime());
}
const startsOf = (events: CalendarEvent[]) => events.map((e) => getEventStartDate(e).toISOString());
/**
* What the event editor (EventModal.handleSave) writes for `event` when the
* user changes its title, moves it by `moveMinutes` or edits the attendees.
*/
function editorSave(
event: CalendarEvent,
change: { title?: string; moveMinutes?: number; attendees?: Attendee[] },
userEmails: string[],
): { updates: Partial<CalendarEvent>; edit: SeriesEdit; sendScheduling: boolean } {
const times = editorTimesFromEvent(event);
if (change.moveMinutes) {
const shift = change.moveMinutes * 60_000;
times.start = new Date(times.start.getTime() + shift);
times.end = new Date(times.end.getTime() + shift);
}
const attendees = change.attendees ?? seedAttendees(event, userEmails);
const updates: Partial<CalendarEvent> = {
title: change.title ?? event.title,
...eventTimeFieldsToSave(times, event),
recurrenceRules: event.recurrenceRules,
};
Object.assign(updates, participantFieldsToSave(event, attendees, {
userEmail: userEmails[0],
userEmails,
rescheduled: editorTimesChanged(times, event),
}));
for (const key of Object.keys(updates) as (keyof CalendarEvent)[]) {
if (updates[key] === undefined) delete updates[key];
}
return { updates, edit: { attendees, when: times, ruleChanged: false }, sendScheduling: attendees.length > 0 };
}
function storeActions() {
const s = useCalendarStore.getState();
return { updateEvent: s.updateEvent, createEvent: s.createEvent, getMasterEvent: s.getMasterEvent, deleteEvent: s.deleteEvent };
}
/** The stored series (with its overrides) of `uid` in the account `c` serves. */
async function seriesByUid(c: JMAPClient, uid: string): Promise<Record<string, any>[]> {
const res = await raw(c, [
['CalendarEvent/query', { accountId: c.accountId, filter: { uid } }, 'q'],
['CalendarEvent/get', {
accountId: c.accountId,
'#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
properties: ['id', 'uid', 'title', 'start', 'status', 'recurrenceRule', 'recurrenceOverrides', 'participants', 'useDefaultAlerts', 'alerts'],
}, 'g'],
]);
return (res.find((r) => r[2] === 'g')?.[1].list ?? []) as Record<string, any>[];
}
async function waitFor<T>(read: () => Promise<T>, done: (value: T) => boolean, ms = 15_000): Promise<T> {
const until = Date.now() + ms;
let value = await read();
while (!done(value) && Date.now() < until) {
await new Promise((resolve) => setTimeout(resolve, 500));
value = await read();
}
return value;
}
function statusOf(participants: Record<string, Participant> | undefined | null, email: string): string | undefined {
const p = Object.values(participants ?? {}).find((x) =>
(x.calendarAddress ?? '').toLowerCase() === `mailto:${email.toLowerCase()}`
|| (x.email ?? '').toLowerCase() === email.toLowerCase());
return p?.participationStatus;
}
/** Destroy every event of `c`'s account whose title carries the run's marker. */
async function removeRunEvents(c: JMAPClient): Promise<void> {
const res = await raw(c, [
['CalendarEvent/query', { accountId: c.accountId }, 'q'],
['CalendarEvent/get', {
accountId: c.accountId,
'#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
properties: ['id', 'title'],
}, 'g'],
]);
const ids = ((res.find((r) => r[2] === 'g')?.[1].list ?? []) as { id: string; title?: string }[])
.filter((e) => e.title?.includes(RUN))
.map((e) => e.id);
if (ids.length > 0) {
await raw(c, [['CalendarEvent/set', { accountId: c.accountId, destroy: ids, sendSchedulingMessages: false }, 'd']]);
}
}
live('live Stalwart calendar', () => {
let calendarId = '';
beforeAll(async () => {
await connectSingleton();
if (USER2) {
other = new JMAPClient();
await other.connect(SERVER!, USER2, PASS);
}
calendarId = await ownCalendarId();
}, 60_000);
afterAll(async () => {
useSettingsStore.setState({ calendarTimeZone: 'auto' });
await removeRunEvents(await singleton()).catch(() => undefined);
if (other) await removeRunEvents(other).catch(() => undefined);
}, 60_000);
describe('a series with overrides in its own time zone (native #89)', () => {
// Six Mondays at 10:00 in Berlin across the start of summer time on
// 28 March 2027; the second moved to 14:00 with a title of its own, the
// fourth excluded.
let uid = '';
let id = '';
beforeAll(async () => {
const created = await createEvent(seriesData('series', '2027-03-15T10:00:00', 6, {
recurrenceOverrides: {
'2027-03-22T10:00:00': { start: '2027-03-22T14:00:00', title: `${RUN} moved` },
'2027-04-05T10:00:00': { excluded: true },
} as CalendarEvent['recurrenceOverrides'],
}), calendarId);
id = created.id;
uid = (await getEvents([id]))[0].uid!;
}, 30_000);
it('cannot ask 1.0 for overrides and UTC times in one get, which is why the app reads them apart', async () => {
const [[name, result]] = await raw(client, [['CalendarEvent/get', {
accountId: client.accountId, ids: [id], properties: ['id', 'utcStart', 'recurrenceOverrides'],
}, '0']]);
if (isStalwart10()) {
expect(name).toBe('error');
expect(result.type).toBe('invalidArguments');
} else {
expect(name).toBe('CalendarEvent/get');
}
});
it('reads the series with its overrides and its UTC times', async () => {
const [event] = await getEvents([id]);
expect(event.utcStart).toBe('2027-03-15T09:00:00Z');
expect(Object.keys(event.recurrenceOverrides ?? {}).sort()).toEqual(['2027-03-22T10:00:00', '2027-04-05T10:00:00']);
expect(event.recurrenceRules?.[0]?.count).toBe(6);
});
it('shows the occurrences on the calendar at their Berlin clock time, moved and excluded ones included', async () => {
const occurrences = await loadOccurrences(uid, '2027-03-14T00:00:00.000Z', '2027-04-30T00:00:00.000Z');
expect(startsOf(occurrences)).toEqual([
'2027-03-15T09:00:00.000Z',
'2027-03-22T13:00:00.000Z',
'2027-03-29T08:00:00.000Z',
'2027-04-12T08:00:00.000Z',
'2027-04-19T08:00:00.000Z',
]);
expect(occurrences[1].title).toBe(`${RUN} moved`);
expect(occurrences[2].title).toBe(`${RUN} series`);
});
it('expands the same occurrences on the device from the series it reads', async () => {
const [event] = await getEvents([id]);
const expanded = expandRecurringEvents([event], '2027-03-14T00:00:00.000Z', '2027-04-30T00:00:00.000Z')
.sort((a, b) => getEventStartDate(a).getTime() - getEventStartDate(b).getTime());
expect(startsOf(expanded)).toEqual([
'2027-03-15T09:00:00.000Z',
'2027-03-22T13:00:00.000Z',
'2027-03-29T08:00:00.000Z',
'2027-04-12T08:00:00.000Z',
'2027-04-19T08:00:00.000Z',
]);
expect(expanded[1].title).toBe(`${RUN} moved`);
});
it('loads the calendar in a time zone the server does not know', async () => {
// Intl takes "europe/berlin"; Stalwart 1.0 refuses it (0.16 reads it as UTC).
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
useSettingsStore.setState({ calendarTimeZone: 'europe/berlin' });
try {
useCalendarStore.getState().reset();
const occurrences = await loadOccurrences(uid, '2027-03-14T00:00:00.000Z', '2027-04-30T00:00:00.000Z');
expect(occurrences).toHaveLength(5);
// Events with a zone of their own keep their times whatever zone the query is in.
expect(startsOf(occurrences)[0]).toBe('2027-03-15T09:00:00.000Z');
const refused = warn.mock.calls.some((args) => String(args[0]).includes('does not know the time zone'));
expect(refused).toBe(isStalwart10());
// Remembered: the next reads go out in UTC without a refused request first.
expect((await queryEvents([], '2027-03-14T00:00:00.000Z', '2027-04-30T00:00:00.000Z')).length).toBeGreaterThan(0);
} finally {
warn.mockRestore();
useSettingsStore.setState({ calendarTimeZone: 'auto' });
useCalendarStore.getState().reset();
}
}, 30_000);
it('loads a week that starts at a midnight the clocks repeat', async () => {
// Havana turns its clocks back from 01:00 to 00:00 on 7 November 2027;
// 0.16 refused an expanded query from that midnight.
useSettingsStore.setState({ calendarTimeZone: 'America/Havana' });
try {
useCalendarStore.getState().reset();
await useCalendarStore.getState().fetchCalendars();
await useCalendarStore.getState().fetchEvents(
useCalendarStore.getState().calendars.map((c) => c.id),
'2027-11-07T04:00:00.000Z',
'2027-11-14T05:00:00.000Z',
);
expect(useCalendarStore.getState().error).toBeNull();
expect(useCalendarStore.getState().loadedRange).toEqual({
after: '2027-11-07T04:00:00.000Z', before: '2027-11-14T05:00:00.000Z',
});
} finally {
useSettingsStore.setState({ calendarTimeZone: 'auto' });
useCalendarStore.getState().reset();
}
}, 30_000);
});
describe('series edits from an occurrence', () => {
// Six Mondays from 3 May 2027 at 10:00 in Berlin, no attendees.
const after = '2027-05-01T00:00:00.000Z';
const before = '2027-06-30T00:00:00.000Z';
let uid = '';
let masterId = '';
const me = [USER];
beforeAll(async () => {
const created = await createEvent(seriesData('edits', '2027-05-03T10:00:00', 6), calendarId);
masterId = created.id;
uid = (await getEvents([masterId]))[0].uid!;
}, 30_000);
const at = (events: CalendarEvent[], day: string) => events.find((e) => (e.recurrenceId ?? '').startsWith(day));
it('changes the title of this occurrence only', async () => {
const occurrences = await loadOccurrences(uid, after, before);
expect(occurrences).toHaveLength(6);
const { updates, edit } = editorSave(at(occurrences, '2027-05-10')!, { title: `${RUN} this` }, me);
expect(await saveOccurrenceEdit('this', { event: at(occurrences, '2027-05-10')!, updates, editor: edit, userEmails: me }, storeActions())).toBe(true);
const [master] = await seriesByUid(client, uid);
expect(master.title).toBe(`${RUN} edits`);
expect(master.recurrenceOverrides?.['2027-05-10T10:00:00']?.title).toBe(`${RUN} this`);
const after1 = await loadOccurrences(uid, after, before);
expect(after1.map((e) => e.title)).toEqual([
`${RUN} edits`, `${RUN} this`, `${RUN} edits`, `${RUN} edits`, `${RUN} edits`, `${RUN} edits`,
]);
}, 30_000);
it('moves this occurrence only', async () => {
const occurrences = await loadOccurrences(uid, after, before);
const { updates, edit } = editorSave(at(occurrences, '2027-05-17')!, { moveMinutes: 120 }, me);
expect(await saveOccurrenceEdit('this', { event: at(occurrences, '2027-05-17')!, updates, editor: edit, userEmails: me }, storeActions())).toBe(true);
const moved = await loadOccurrences(uid, after, before);
expect(startsOf(moved)).toEqual([
'2027-05-03T08:00:00.000Z', '2027-05-10T08:00:00.000Z', '2027-05-17T10:00:00.000Z',
'2027-05-24T08:00:00.000Z', '2027-05-31T08:00:00.000Z', '2027-06-07T08:00:00.000Z',
]);
}, 30_000);
it('deletes this occurrence only', async () => {
const occurrences = await loadOccurrences(uid, after, before);
expect(await deleteOccurrence('this', { event: at(occurrences, '2027-05-24')!, userEmails: me }, storeActions())).toBe(true);
const left = await loadOccurrences(uid, after, before);
expect(left.map((e) => e.recurrenceId?.slice(0, 10))).toEqual(['2027-05-03', '2027-05-10', '2027-05-17', '2027-05-31', '2027-06-07']);
const [master] = await seriesByUid(client, uid);
expect(master.recurrenceOverrides?.['2027-05-24T10:00:00']?.excluded).toBe(true);
}, 30_000);
it('splits the series at this and the following occurrences', async () => {
const occurrences = await loadOccurrences(uid, after, before);
const { updates, edit } = editorSave(at(occurrences, '2027-05-31')!, { title: `${RUN} following` }, me);
expect(await saveOccurrenceEdit('this_and_future', {
event: at(occurrences, '2027-05-31')!, updates, editor: edit, calendarId, userEmails: me,
}, storeActions())).toBe(true);
const [old] = await seriesByUid(client, uid);
expect(old.recurrenceRule?.until).toBeTruthy();
expect(old.recurrenceRule?.count ?? null).toBeNull();
const store = useCalendarStore.getState();
await store.fetchEvents(store.calendars.map((c) => c.id), after, before);
const rest = useCalendarStore.getState().events
.filter((e) => e.title === `${RUN} following`)
.sort((a, b) => getEventStartDate(a).getTime() - getEventStartDate(b).getTime());
// What was left of the count: two of six.
expect(startsOf(rest)).toEqual(['2027-05-31T08:00:00.000Z', '2027-06-07T08:00:00.000Z']);
expect(rest[0].uid).not.toBe(uid);
const kept = await loadOccurrences(uid, after, before);
expect(kept.map((e) => e.recurrenceId?.slice(0, 10))).toEqual(['2027-05-03', '2027-05-10', '2027-05-17']);
// Deleting the rest from its second occurrence on ends it after its first.
const second = rest[1];
expect(await deleteOccurrence('this_and_future', { event: second, userEmails: me }, storeActions())).toBe(true);
const [newSeries] = await seriesByUid(client, rest[0].uid!);
expect(newSeries.recurrenceRule?.until).toBeTruthy();
await store.fetchEvents(store.calendars.map((c) => c.id), after, before);
expect(useCalendarStore.getState().events.filter((e) => e.uid === rest[0].uid)).toHaveLength(1);
}, 60_000);
it('changes and moves all occurrences, keeping each one\'s own changes', async () => {
let occurrences = await loadOccurrences(uid, after, before);
const rename = editorSave(occurrences[0], { title: `${RUN} all` }, me);
expect(await saveOccurrenceEdit('all', { event: occurrences[0], updates: rename.updates, editor: rename.edit, userEmails: me }, storeActions())).toBe(true);
occurrences = await loadOccurrences(uid, after, before);
expect(occurrences.map((e) => e.title)).toEqual([`${RUN} all`, `${RUN} this`, `${RUN} all`]);
// An hour later, all of them: the occurrence moved on its own keeps
// the time the user gave it, the one deleted stays deleted, none is
// listed twice.
const move = editorSave(occurrences[0], { moveMinutes: 60 }, me);
expect(await saveOccurrenceEdit('all', { event: occurrences[0], updates: move.updates, editor: move.edit, userEmails: me }, storeActions())).toBe(true);
occurrences = await loadOccurrences(uid, after, before);
expect(startsOf(occurrences)).toEqual(['2027-05-03T09:00:00.000Z', '2027-05-10T09:00:00.000Z', '2027-05-17T10:00:00.000Z']);
expect(occurrences.map((e) => e.title)).toEqual([`${RUN} all`, `${RUN} this`, `${RUN} all`]);
const [master] = await seriesByUid(client, uid);
expect(master.start).toBe('2027-05-03T11:00:00');
expect(Object.keys(master.recurrenceOverrides ?? {}).sort()).toEqual([
'2027-05-10T11:00:00', '2027-05-17T11:00:00', '2027-05-24T11:00:00',
]);
expect(master.recurrenceOverrides['2027-05-24T11:00:00'].excluded).toBe(true);
}, 60_000);
it('deletes the whole series from an occurrence', async () => {
const occurrences = await loadOccurrences(uid, after, before);
expect(await deleteOccurrence('all', { event: occurrences[1], userEmails: me }, storeActions())).toBe(true);
expect(await seriesByUid(client, uid)).toHaveLength(0);
expect(await loadOccurrences(uid, after, before)).toHaveLength(0);
}, 30_000);
});
describe('participant limit', () => {
it('names the server\'s participant limit when 1.0 refuses an event with too many', async () => {
const participants: Record<string, Participant> = {
org: { '@type': 'Participant', calendarAddress: `mailto:${USER}`, roles: { owner: true }, participationStatus: 'accepted' } as Participant,
};
for (let i = 0; i < 20; i++) {
participants[`a${i}`] = { '@type': 'Participant', calendarAddress: `mailto:guest${i}@example.net`, roles: { attendee: true } } as Participant;
}
const event = {
'@type': 'Event', title: `${RUN} crowd`, start: '2027-07-05T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin',
participants, organizerCalendarAddress: `mailto:${USER}`,
} as Partial<CalendarEvent>;
let error: unknown = null;
try {
const created = await createEvent(event, calendarId, false);
await deleteEvents([created.id], false);
} catch (err) {
error = err;
}
if (isStalwart10()) {
expect(participantLimitOf(error)).toBe(20);
} else {
expect(error).toBeNull();
}
}, 30_000);
});
describe('default alerts', () => {
let calendar: Calendar | undefined;
let before: Record<string, unknown> | null = null;
beforeAll(async () => {
calendar = (await getCalendars()).find((c) => c.id === calendarId);
before = (calendar?.defaultAlertsWithTime as Record<string, unknown> | null | undefined) ?? null;
await raw(client, [['Calendar/set', {
accountId: client.accountId,
update: { [calendarId]: { defaultAlertsWithTime: { d1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } } },
}, '0']]);
}, 30_000);
afterAll(async () => {
await raw(client, [['Calendar/set', {
accountId: client.accountId, update: { [calendarId]: { defaultAlertsWithTime: before } },
}, '0']]).catch(() => undefined);
}, 30_000);
it('keeps the reminder changed on one occurrence of a series on the calendar\'s default reminders', async () => {
const created = await createEvent(seriesData('alerts', '2027-08-02T10:00:00', 3, { useDefaultAlerts: true }), calendarId);
const uid = (await getEvents([created.id]))[0].uid!;
const occurrences = await loadOccurrences(uid, '2027-08-01T00:00:00.000Z', '2027-08-31T00:00:00.000Z');
expect(occurrences).toHaveLength(3);
const own = { a1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } } };
await useCalendarStore.getState().updateEvent(occurrences[1].id, { useDefaultAlerts: false, alerts: own } as Partial<CalendarEvent>);
const [master] = await seriesByUid(client, uid);
expect(master.useDefaultAlerts).toBe(false);
const reloaded = await loadOccurrences(uid, '2027-08-01T00:00:00.000Z', '2027-08-31T00:00:00.000Z');
const offsets = (e: CalendarEvent) => Object.values(e.alerts ?? {}).map((a) => (a.trigger as { offset?: string }).offset).sort();
expect(offsets(reloaded[1])).toEqual(['-PT1H']);
// The others keep the reminder the calendar gave them.
expect(offsets(reloaded[0])).toEqual(['-PT15M']);
}, 60_000);
});
liveTwo('invitations', () => {
const after = '2027-09-01T00:00:00.000Z';
const before = '2027-09-30T00:00:00.000Z';
let uid = '';
const me = [USER];
it('keeps the attendee\'s answer on a change and asks again when the organizer moves it', async () => {
// Four Mondays from 6 September 2027, the second user invited.
const invitation = editorSave(
{ title: `${RUN} meeting`, start: '2027-09-06T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' } as CalendarEvent,
{ attendees: [{ name: 'Land', email: USER2! }] },
me,
);
const created = await useCalendarStore.getState().createEvent({
...seriesData('meeting', '2027-09-06T10:00:00', 4),
participants: invitation.updates.participants,
organizerCalendarAddress: invitation.updates.organizerCalendarAddress,
}, calendarId, { sendSchedulingMessages: true });
uid = created.uid!;
// The attendee's copy arrives through the server's own scheduling.
const copies = await waitFor(() => seriesByUid(other!, uid), (list) => list.length > 0);
expect(copies, 'the invitation reached the second user').toHaveLength(1);
const copy = copies[0];
const myId = Object.entries(copy.participants ?? {}).find(([, p]) =>
String((p as Participant).calendarAddress).toLowerCase() === `mailto:${USER2!.toLowerCase()}`)?.[0];
expect(myId).toBeTruthy();
await raw(other!, [['CalendarEvent/set', {
accountId: other!.accountId,
update: { [copy.id]: { [`participants/${myId}/participationStatus`]: 'accepted' } },
sendSchedulingMessages: true,
}, '0']]);
let [mine] = await waitFor(() => seriesByUid(client, uid), (list) => statusOf(list[0]?.participants, USER2!) === 'accepted');
expect(statusOf(mine.participants, USER2!)).toBe('accepted');
// The attendee declines the third occurrence alone, in the app.
(await singleton()).restoreSnapshot(other!.snapshot());
try {
useCalendarStore.getState().reset();
const theirs = await loadOccurrences(uid, after, before);
const third = theirs.find((e) => (e.recurrenceId ?? '').startsWith('2027-09-20'))!;
const participantId = Object.entries(third.participants ?? {}).find(([, p]) =>
String(p.calendarAddress).toLowerCase() === `mailto:${USER2!.toLowerCase()}`)![0];
await useCalendarStore.getState().rsvpEvent(third.id, participantId, 'declined', null, undefined, 'occurrence');
} finally {
await connectSingleton();
}
[mine] = await waitFor(() => seriesByUid(client, uid), (list) => {
const entry = list[0]?.recurrenceOverrides?.['2027-09-20T10:00:00'] as Record<string, any> | undefined;
return !!entry && JSON.stringify(entry).includes('declined');
});
expect(JSON.stringify(mine.recurrenceOverrides?.['2027-09-20T10:00:00'] ?? null)).toContain('declined');
expect(statusOf(mine.participants, USER2!)).toBe('accepted');
// A new title for all: the answer stays.
let occurrences = await loadOccurrences(uid, after, before);
const rename = editorSave(occurrences[0], { title: `${RUN} meeting renamed` }, me);
expect(await saveOccurrenceEdit('all', {
event: occurrences[0], updates: rename.updates, editor: rename.edit, sendScheduling: rename.sendScheduling, userEmails: me,
}, storeActions())).toBe(true);
[mine] = await seriesByUid(client, uid);
expect(mine.title).toBe(`${RUN} meeting renamed`);
expect(statusOf(mine.participants, USER2!)).toBe('accepted');
expect(JSON.stringify(mine.recurrenceOverrides?.['2027-09-20T10:00:00'] ?? null)).toContain('declined');
const renamed = await waitFor(() => seriesByUid(other!, uid), (list) => list[0]?.title === `${RUN} meeting renamed`);
expect(statusOf(renamed[0].participants, USER2!)).toBe('accepted');
// One occurrence an hour later: asked again for that one alone.
occurrences = await loadOccurrences(uid, after, before);
const moveOne = editorSave(occurrences[1], { moveMinutes: 60 }, me);
expect(await saveOccurrenceEdit('this', {
event: occurrences[1], updates: moveOne.updates, editor: moveOne.edit, sendScheduling: moveOne.sendScheduling, userEmails: me,
}, storeActions())).toBe(true);
[mine] = await seriesByUid(client, uid);
const override = mine.recurrenceOverrides?.['2027-09-13T10:00:00'] as Record<string, any> | undefined;
expect(override?.start).toBe('2027-09-13T11:00:00');
const overrideStatus = override?.participants
? statusOf(override.participants, USER2!)
: Object.entries(override ?? {}).find(([k]) => k.endsWith('/participationStatus'))?.[1];
expect(overrideStatus).toBe('needs-action');
expect(statusOf(mine.participants, USER2!)).toBe('accepted');
// All of them an hour later: asked again for the series.
occurrences = await loadOccurrences(uid, after, before);
const moveAll = editorSave(occurrences[0], { moveMinutes: 60 }, me);
expect(await saveOccurrenceEdit('all', {
event: occurrences[0], updates: moveAll.updates, editor: moveAll.edit, sendScheduling: moveAll.sendScheduling, userEmails: me,
}, storeActions())).toBe(true);
[mine] = await seriesByUid(client, uid);
expect(mine.start).toBe('2027-09-06T11:00:00');
expect(statusOf(mine.participants, USER2!)).toBe('needs-action');
// Cancelled for the attendee too.
occurrences = await loadOccurrences(uid, after, before);
expect(await deleteOccurrence('all', { event: occurrences[0], userEmails: me }, storeActions())).toBe(true);
const gone = await waitFor(() => seriesByUid(other!, uid), (list) => list.length === 0 || list[0]?.status === 'cancelled');
expect(gone.length === 0 || gone[0].status === 'cancelled').toBe(true);
}, 120_000);
});
liveTwo('shares', () => {
let mailboxId = '';
let sharedCalendarId = '';
let principal = '';
afterAll(async () => {
if (!other) return;
if (sharedCalendarId) {
await raw(other, [['Calendar/set', { accountId: other.accountId, destroy: [sharedCalendarId], onDestroyRemoveEvents: true }, '0']]).catch(() => undefined);
}
if (mailboxId) {
await raw(other, [['Mailbox/set', { accountId: other.accountId, destroy: [mailboxId], onDestroyRemoveEmails: true }, '0']], MAIL_USING).catch(() => undefined);
}
await connectSingleton().catch(() => undefined);
}, 60_000);
it('asks an account that shares only mail for its calendars once, without an error', async () => {
principal = client.accountId;
const [[, created]] = await raw(other!, [['Mailbox/set', {
accountId: other!.accountId,
create: { m: { name: `${RUN} shared`, shareWith: { [principal]: { mayReadItems: true } } } },
}, '0']], [...MAIL_USING, 'urn:ietf:params:jmap:principals']);
mailboxId = created.created?.m?.id ?? '';
expect(mailboxId, JSON.stringify(created.notCreated)).toBeTruthy();
await connectSingleton();
const sharer = other!.accountId;
expect(client.currentSession?.accounts?.[sharer], 'the sharer\'s account is in the session').toBeTruthy();
// What the server answers there: 0.16 forbidden, 1.0 accountNotSupportedByMethod.
const [[name, answer]] = await raw(client, [['Calendar/get', { accountId: sharer, ids: [] }, '0']]);
expect(name).toBe('error');
expect(['forbidden', 'accountNotSupportedByMethod', 'accountNotFound']).toContain(answer.type);
const jmap = await singleton();
const spy = vi.spyOn(jmap, 'request');
try {
const store = useCalendarStore.getState();
for (let i = 0; i < 3; i++) {
await store.fetchCalendars();
await useCalendarStore.getState().fetchEvents(useCalendarStore.getState().calendars.map((c) => c.id),
'2027-09-01T00:00:00.000Z', '2027-09-30T00:00:00.000Z');
await useCalendarStore.getState().fetchTasks();
}
expect(useCalendarStore.getState().error).toBeNull();
const toSharer = spy.mock.calls.filter(([calls]) =>
(calls as unknown as [string, Record<string, unknown>][]).some(([, args]) => args.accountId === sharer));
// At most the one probe: no retry on every load.
expect(toSharer.length).toBeLessThanOrEqual(1);
if (toSharer.length === 1) expect(isCalendarAccessDenied(sharer)).toBe(true);
} finally {
spy.mockRestore();
}
}, 60_000);
it('shows a calendar another account shares and writes its events there', async () => {
const [[, created]] = await raw(other!, [['Calendar/set', {
accountId: other!.accountId,
create: { c: { name: `${RUN} team`, shareWith: { [principal]: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayReadFreeBusy: true } } } },
}, '0']], [...USING, 'urn:ietf:params:jmap:principals']);
sharedCalendarId = created.created?.c?.id ?? '';
expect(sharedCalendarId, JSON.stringify(created.notCreated)).toBeTruthy();
const [[, made]] = await raw(other!, [['CalendarEvent/set', {
accountId: other!.accountId,
create: { e: { ...seriesData('owner series', '2027-10-04T10:00:00', 2), recurrenceRule: SERIES_RULE(2)![0], recurrenceRules: undefined, calendarIds: { [sharedCalendarId]: true } } },
}, '0']]);
expect(made.created?.e?.id, JSON.stringify(made.notCreated)).toBeTruthy();
await connectSingleton();
const store = useCalendarStore.getState();
await store.fetchCalendars();
const shared = useCalendarStore.getState().calendars.find((c) => c.name === `${RUN} team`);
expect(shared?.accountId).toBe(other!.accountId);
// The month as the app asks for it: from local midnight to local midnight.
await useCalendarStore.getState().fetchEvents(useCalendarStore.getState().calendars.map((c) => c.id),
'2027-09-30T22:00:00.000Z', '2027-10-30T22:00:00.000Z');
const theirs = useCalendarStore.getState().events
.filter((e) => e.title === `${RUN} owner series`)
.sort((a, b) => getEventStartDate(a).getTime() - getEventStartDate(b).getTime());
expect(theirs).toHaveLength(2);
expect(theirs.every((e) => e.accountId === other!.accountId && !!e.calendarIds?.[shared!.id])).toBe(true);
// An edit of one of its occurrences goes to the owner's account.
const { updates, edit } = editorSave(theirs[1], { title: `${RUN} owner second` }, [USER]);
expect(await saveOccurrenceEdit('this', { event: theirs[1], updates, editor: edit, userEmails: [USER] }, storeActions())).toBe(true);
const list = (await raw(other!, [['CalendarEvent/query', { accountId: other!.accountId, filter: { inCalendar: sharedCalendarId } }, 'q'],
['CalendarEvent/get', { accountId: other!.accountId, '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, properties: ['id', 'recurrenceOverrides'] }, 'g']]))
.find((r) => r[2] === 'g')?.[1].list as Record<string, any>[];
expect(list).toHaveLength(1);
expect(list[0].recurrenceOverrides?.['2027-10-11T10:00:00']?.title).toBe(`${RUN} owner second`);
}, 60_000);
});
describe('tasks', () => {
it('lists the account\'s own tasks and none of another account\'s', async () => {
const [[, created]] = await raw(client, [['CalendarEvent/set', {
accountId: client.accountId,
create: { t: { '@type': 'Task', title: `${RUN} task`, calendarIds: { [calendarId]: true }, due: '2027-11-01T10:00:00', timeZone: 'Europe/Berlin' } },
}, '0']]);
expect(created.created?.t?.id, JSON.stringify(created.notCreated)).toBeTruthy();
useCalendarStore.getState().reset();
await useCalendarStore.getState().fetchCalendars();
await useCalendarStore.getState().fetchTasks();
expect(useCalendarStore.getState().tasks.map((t) => t.title)).toContain(`${RUN} task`);
if (!USER2) return;
const second = new JMAPClient();
await second.connect(SERVER!, USER2, PASS);
const jmap = await singleton();
jmap.restoreSnapshot(second.snapshot());
try {
useCalendarStore.getState().reset();
await useCalendarStore.getState().fetchCalendars();
await useCalendarStore.getState().fetchTasks();
expect(useCalendarStore.getState().tasks.map((t) => t.title)).not.toContain(`${RUN} task`);
} finally {
await connectSingleton();
}
}, 60_000);
});
});
+61 -9
View File
@@ -2,7 +2,7 @@ import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { Calendar, CalendarEvent, CalendarRights } from './types';
import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result';
import { getEffectiveTimeZone } from '../lib/calendar-timezone';
import { formatWallClock, getEffectiveTimeZone, getTimeZoneOffsetMs } from '../lib/calendar-timezone';
import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection';
import {
RECURRENCE_BASE_PROPERTIES,
@@ -160,9 +160,11 @@ export function resetCalendarAccessDenied(): void {
calendarAccessDenied.clear();
}
// Stalwart 1.0 answers `accountNotSupportedByMethod` for an account that
// shares only a mail folder with the user, where 0.16 says `forbidden`.
function isAccessDeniedError(err: unknown): boolean {
const type = jmapErrorType(err);
return type === 'forbidden' || type === 'accountNotFound';
return type === 'forbidden' || type === 'accountNotFound' || type === 'accountNotSupportedByMethod';
}
/** Remember an access rejection for a shared account; returns true when it was one. */
@@ -418,10 +420,11 @@ export function resetSyntheticIdSupport(): void {
}
// The Calendar properties the app reads, which the default list on 0.16 and
// 1.0 covers. Stalwart 1.0 advertises JMAP Metadata but its handlers are
// still `todo!()`: a get that selects `metadata` or `privateMetadata` panics
// on the server. An explicit list keeps them out even if a later release
// adds them to the default. 0.16.6 knows every one of these.
// 1.0 covers. The pre-releases of Stalwart 1.0 advertised JMAP Metadata with
// handlers that were still `todo!()`: a get that selected `metadata` or
// `privateMetadata` panicked on the server (1.0.0 answers it). An explicit
// list keeps them, and whatever else a release adds to the default, out of
// every calendar load. 0.16.6 knows every one of these.
const CALENDAR_PROPERTIES = [
'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault',
'myRights', 'shareWith', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime',
@@ -507,8 +510,8 @@ export async function queryEvents(
const conditions: Record<string, unknown>[] = [];
if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds));
const range: Record<string, string> = {};
if (after) range.after = toLocalDateTime(after, timeZone);
if (before) range.before = toLocalDateTime(before, timeZone);
if (after) range.after = placeableLocalBound(toLocalDateTime(after, timeZone), timeZone, -1);
if (before) range.before = placeableLocalBound(toLocalDateTime(before, timeZone), timeZone, 1);
if (Object.keys(range).length > 0) conditions.push(range);
const filter = conditions.length === 1
? conditions[0]
@@ -587,6 +590,52 @@ function localDateTimeOf(seconds: number): string {
+ `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`;
}
// Stalwart 0.16 cannot place a range bound whose wall clock names no instant
// in the request's time zone (in the hour the clocks skip) or two (in the
// hour they repeat): an expanded query fails with "Both 'after' and 'before'
// filters are required when expanding recurrences", and a plain one leaves
// the bound out (live on 0.16.27; 1.0 places both). Where the clocks change
// at midnight - Havana, the Azores, Santiago - the start of a day is such a
// time, and a chunk border can be one anywhere. Such a bound moves out of
// the range until it names one instant: `after` earlier, `before` later.
const BOUND_STEP = 15 * 60;
const MAX_BOUND_SHIFT = 3 * 3600;
/** How many instants the wall clock `seconds` (wallClockSeconds) names in `timeZone`. */
function instantsAt(seconds: number, timeZone: string): number {
const wall = localDateTimeOf(seconds);
// The offsets a day either side: a change of the clocks in between is
// the only one near.
const offsets = new Set([seconds - DAY_SECONDS, seconds + DAY_SECONDS].map(
(at) => getTimeZoneOffsetMs(new Date(at * 1000), timeZone) / 1000,
));
let count = 0;
for (const offset of offsets) {
if (formatWallClock(new Date((seconds - offset) * 1000), timeZone) === wall) count++;
}
return count;
}
/** The wall clock `seconds`, or the nearest one in `direction` that names one instant in `timeZone`. */
function placeableBound(seconds: number, timeZone: string | undefined, direction: -1 | 1): number {
if (!timeZone || timeZone === 'UTC') return seconds;
try {
for (let shift = 0; shift <= MAX_BOUND_SHIFT; shift += BOUND_STEP) {
const candidate = seconds + direction * shift;
if (instantsAt(candidate, timeZone) === 1) return candidate;
}
} catch {
// A zone Intl does not know either: the server has the last word.
}
return seconds;
}
/** placeableBound of a LocalDateTime; anything else as it is. */
function placeableLocalBound(value: string, timeZone: string | undefined, direction: -1 | 1): string {
const seconds = wallClockSeconds(value);
return seconds === null ? value : localDateTimeOf(placeableBound(seconds, timeZone, direction));
}
/**
* [start, end] cut into chunks of at most `span` seconds, or null when that
* takes more than MAX_EXPANDED_CHUNKS. Neighbours share a second: the server
@@ -733,7 +782,10 @@ async function queryExpandedEventsIn(
'CalendarEvent/query',
{
...baseArgs,
filter: { after: localDateTimeOf(chunk.start), before: localDateTimeOf(chunk.end) },
filter: {
after: localDateTimeOf(placeableBound(chunk.start, timeZone, -1)),
before: localDateTimeOf(placeableBound(chunk.end, timeZone, 1)),
},
position: chunk.position,
limit: QUERY_PAGE_SIZE,
},
@@ -168,6 +168,53 @@ describe('calendar-store', () => {
});
});
describe('loads a reset overtook (an account switch)', () => {
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => { resolve = r; });
return { promise, resolve };
}
it('keeps the previous account\'s tasks out of the next account\'s list', async () => {
const scan = deferred<unknown[]>();
mockScan.mockReturnValueOnce(scan.promise);
mockGetEvents.mockImplementation(async (ids: string[]) => ids.map((id) => ({ id, title: 'Old account task' })));
const outgoing = useCalendarStore.getState().fetchTasks();
useCalendarStore.getState().reset();
// The next account's own load is not handed the one still under way.
mockScan.mockResolvedValueOnce([]);
await useCalendarStore.getState().fetchTasks();
expect(mockScan).toHaveBeenCalledTimes(2);
scan.resolve([{ id: 't-old', '@type': 'Task', calendarIds: { 'cal-1': true } }]);
await outgoing;
expect(useCalendarStore.getState().tasks).toEqual([]);
});
it('keeps the previous account\'s events and calendars out', async () => {
const calendars = deferred<unknown[]>();
mockGetCalendars.mockReturnValueOnce(calendars.promise);
const ids = deferred<string[]>();
mockQueryEvents.mockReturnValueOnce(ids.promise);
mockGetEvents.mockResolvedValue([{ id: 'e-old', title: 'Old account event', start: '2026-03-20T15:00:00', calendarIds: { 'cal-1': true } }]);
useCalendarStore.setState({ calendars: [{ id: 'cal-1' }] as never });
const calendarLoad = useCalendarStore.getState().fetchCalendars();
const eventLoad = useCalendarStore.getState().fetchEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-04-01T00:00:00Z');
useCalendarStore.getState().reset();
calendars.resolve([{ id: 'cal-1', name: 'Old account calendar' }]);
ids.resolve(['e-old']);
await Promise.all([calendarLoad, eventLoad]);
const state = useCalendarStore.getState();
expect(state.calendars).toEqual([]);
expect(state.events).toEqual([]);
expect(state.loadedRange).toBeNull();
expect(state.error).toBeNull();
});
});
describe('fetchTasks', () => {
it('classifies calendars whose contents are all tasks as task lists (#28)', async () => {
// VTODO-only CalDAV collections (Todoist imports, Thunderbird task
+44 -8
View File
@@ -303,6 +303,21 @@ export interface CalendarState {
let calendarsInFlight: Promise<void> | null = null;
let tasksInFlight: Promise<void> | null = null;
// One up with every reset (an account switch, a sign-out). A load still under
// way from before keeps its answer to itself: landing after the reset, it put
// the previous account's calendars, events or tasks under the next account
// until the next refresh, and that account's own task load was handed the old
// one still in flight.
let storeGeneration = 0;
/** A range load the store's reset overtook: what it read belongs to the account before. */
export class CalendarAccountChangedError extends Error {
constructor() {
super('The calendar was reset while its events were loading');
this.name = 'CalendarAccountChangedError';
}
}
function persistHidden(ids: string[]): void {
void AsyncStorage.setItem(HIDDEN_CALENDARS_STORAGE_KEY, JSON.stringify(ids)).catch(
(err) => console.warn('[calendar-store] persist hidden failed', err),
@@ -328,7 +343,8 @@ function unionRange(loaded: LoadedRange, after: string, before: string): LoadedR
* (Stalwart >= 0.16.20, see lib/recurrence-instances), else on the device.
* Doesn't touch the store: fetchEvents keeps the result as the visible
* window, the reminders load their own upcoming window with it. Throws when
* the primary account fails; a failing shared account is skipped.
* the primary account fails, or when the store was reset meanwhile
* (CalendarAccountChangedError); a failing shared account is skipped.
*/
export async function loadEventsInRange(
calendars: Calendar[],
@@ -354,10 +370,13 @@ export async function loadEventsInRange(
}
if (groups.size === 0) groups.set(undefined, []);
const generation = storeGeneration;
const expand = !!after && !!before
&& await supportsSyntheticCalendarIds().catch(() => false);
const raw: CalendarEvent[] = [];
for (const [accountId, ids] of groups) {
// The client serves another account now: the rest would be read there.
if (generation !== storeGeneration) throw new CalendarAccountChangedError();
if (accountId && isCalendarAccessDenied(accountId)) continue;
try {
let fetched: CalendarEvent[] | null = null;
@@ -391,9 +410,11 @@ export async function loadEventsInRange(
// A failing shared account must not hide the user's own events;
// remember an access rejection so it isn't re-probed every fetch.
if (!accountId) throw err;
noteCalendarAccessError(accountId, err);
// Not for the account the client serves since a reset.
if (generation === storeGeneration) noteCalendarAccessError(accountId, err);
}
}
if (generation !== storeGeneration) throw new CalendarAccountChangedError();
// Stalwart returns both Events and Tasks from CalendarEvent/query. Tasks
// (also CalDAV ones without an `@type`) are surfaced by fetchTasks so
// they don't pollute the grid.
@@ -721,14 +742,16 @@ export const useCalendarStore = create<CalendarState>()(
// concurrent first Calendar/* requests can each lazily create a default
// calendar. Share one in-flight request instead.
if (calendarsInFlight) return calendarsInFlight;
const generation = storeGeneration;
calendarsInFlight = (async () => {
try {
const calendars = sortCalendars((await fetchCalendars()) ?? []);
set({ calendars });
if (generation === storeGeneration) set({ calendars });
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to load calendars' });
if (generation === storeGeneration) set({ error: err instanceof Error ? err.message : 'Failed to load calendars' });
} finally {
calendarsInFlight = null;
// After a reset the slot belongs to the next account's load.
if (generation === storeGeneration) calendarsInFlight = null;
}
})();
return calendarsInFlight;
@@ -736,11 +759,14 @@ export const useCalendarStore = create<CalendarState>()(
fetchEvents: async (calendarIds, after, before) => {
if (!jmapClient.isConnected) return;
const generation = storeGeneration;
set({ loading: true, error: null });
try {
const events = await loadEventsInRange(get().calendars, calendarIds, after, before);
if (generation !== storeGeneration) return;
set({ events, loadedRange: { after, before }, loading: false });
} catch (err) {
if (generation !== storeGeneration) return;
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' });
}
},
@@ -748,6 +774,7 @@ export const useCalendarStore = create<CalendarState>()(
fetchTasks: async () => {
if (!jmapClient.isConnected) return;
if (tasksInFlight) return tasksInFlight;
const generation = storeGeneration;
tasksInFlight = (async () => {
try {
const calendars = get().calendars;
@@ -756,6 +783,8 @@ export const useCalendarStore = create<CalendarState>()(
const tasks: CalendarEvent[] = [];
const taskOnly: string[] = [];
for (const accountId of accountIds) {
// The client serves another account now: the rest would be read there.
if (generation !== storeGeneration) return;
if (accountId && isCalendarAccessDenied(accountId)) continue;
try {
const scanned = await scanCalendarObjects(accountId);
@@ -780,14 +809,15 @@ export const useCalendarStore = create<CalendarState>()(
tasks.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId)));
} catch (err) {
if (!accountId) throw err;
noteCalendarAccessError(accountId, err);
if (generation === storeGeneration) noteCalendarAccessError(accountId, err);
}
}
if (generation !== storeGeneration) return;
set({ tasks, taskOnlyCalendarIds: taskOnly });
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to load tasks' });
if (generation === storeGeneration) set({ error: err instanceof Error ? err.message : 'Failed to load tasks' });
} finally {
tasksInFlight = null;
if (generation === storeGeneration) tasksInFlight = null;
}
})();
return tasksInFlight;
@@ -836,12 +866,14 @@ export const useCalendarStore = create<CalendarState>()(
set({ loadedRange: plan.union });
return;
}
const generation = storeGeneration;
set({ loading: true, error: null });
try {
const incoming: CalendarEvent[] = [];
for (const piece of plan.pieces) {
incoming.push(...(await loadEventsInRange(calendars, calendarIds, piece.after, piece.before)));
}
if (generation !== storeGeneration) return;
// A jump elsewhere replaced the window meanwhile: these pieces no
// longer border it. (A refresh of the same window is fine.)
if (!sameRange(get().loadedRange, loaded)) {
@@ -854,6 +886,7 @@ export const useCalendarStore = create<CalendarState>()(
loading: false,
});
} catch (err) {
if (generation !== storeGeneration) return;
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' });
}
},
@@ -1375,6 +1408,9 @@ export const useCalendarStore = create<CalendarState>()(
reset: () => {
// A new session may grant access the old one lacked, or reach an
// upgraded server.
storeGeneration += 1;
calendarsInFlight = null;
tasksInFlight = null;
resetCalendarAccessDenied();
resetSyntheticIdSupport();
resetTimeZoneChecks();