diff --git a/src/api/__tests__/live-stalwart-calendar.test.ts b/src/api/__tests__/live-stalwart-calendar.test.ts new file mode 100644 index 00000000..f112f0f5 --- /dev/null +++ b/src/api/__tests__/live-stalwart-calendar.test.ts @@ -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 { + 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 { + return (await import('../jmap-client')).jmapClient as unknown as JMAPClient; +} + +type Response = [string, Record, string]; + +async function raw(c: JMAPClient, calls: [string, Record, string][], using: string[] = USING): Promise { + 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 { + 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 = {}): Partial { + return { + '@type': 'Event', + title: `${RUN} ${title}`, + start, + duration: 'PT1H', + timeZone: 'Europe/Berlin', + recurrenceRules: SERIES_RULE(count), + ...extra, + } as Partial; +} + +/** Load [after, before] into the store and return the occurrences of `uid`, by start. */ +async function loadOccurrences(uid: string, after: string, before: string): Promise { + 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; 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 = { + 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[]> { + 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[]; +} + +async function waitFor(read: () => Promise, done: (value: T) => boolean, ms = 15_000): Promise { + 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 | 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 { + 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 = { + 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; + 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 | null = null; + + beforeAll(async () => { + calendar = (await getCalendars()).find((c) => c.id === calendarId); + before = (calendar?.defaultAlertsWithTime as Record | 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); + 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 | 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 | 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][]).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[]; + 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); + }); +});