mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
Merge branch 's10/cal' into feat/stalwart-1.0
This commit is contained in:
@@ -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']]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user