mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: stop a series edit or delete after an account switch instead of finishing it in the next account
This commit is contained in:
@@ -45,6 +45,7 @@ import {
|
|||||||
import { displayNow } from '../../lib/calendar-timezone';
|
import { displayNow } from '../../lib/calendar-timezone';
|
||||||
import { editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-editor-times';
|
import { editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-editor-times';
|
||||||
import { useSettingsStore } from '../../stores/settings-store';
|
import { useSettingsStore } from '../../stores/settings-store';
|
||||||
|
import { CalendarAccountChangedError } from '../../stores/calendar-store';
|
||||||
import { canCreateEventsIn } from '../../lib/calendar-editability';
|
import { canCreateEventsIn } from '../../lib/calendar-editability';
|
||||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||||
import { formatCalendarDate } from '../../lib/date-format';
|
import { formatCalendarDate } from '../../lib/date-format';
|
||||||
@@ -417,6 +418,12 @@ export function EventModal({
|
|||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// The account changed under the save: the event is the previous
|
||||||
|
// account's, and the calendar shows another one now.
|
||||||
|
if (err instanceof CalendarAccountChangedError) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// The editor stays open with everything typed, and says why.
|
// The editor stays open with everything typed, and says why.
|
||||||
NativeAlert.alert(
|
NativeAlert.alert(
|
||||||
t('calendar.notifications.event_error', 'Failed to save event'),
|
t('calendar.notifications.event_error', 'Failed to save event'),
|
||||||
|
|||||||
@@ -125,6 +125,25 @@ function actions(master: CalendarEvent | null) {
|
|||||||
} satisfies OccurrenceEditActions & { deleteEvent: (id: string) => Promise<void> };
|
} satisfies OccurrenceEditActions & { deleteEvent: (id: string) => Promise<void> };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `deps` with the account the operation starts in, as the store's
|
||||||
|
* calendarOperationScope gives it: after `switchAccount` its check throws.
|
||||||
|
*/
|
||||||
|
function scoped<D extends object>(deps: D) {
|
||||||
|
let changed = false;
|
||||||
|
return {
|
||||||
|
deps: {
|
||||||
|
...deps,
|
||||||
|
operationScope: () => ({
|
||||||
|
assert: () => {
|
||||||
|
if (changed) throw new Error('The account changed');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
switchAccount: () => { changed = true; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => useSettingsStore.setState({ calendarTimeZone: 'Europe/Berlin' }));
|
beforeEach(() => useSettingsStore.setState({ calendarTimeZone: 'Europe/Berlin' }));
|
||||||
afterEach(() => useSettingsStore.setState({ calendarTimeZone: 'auto' }));
|
afterEach(() => useSettingsStore.setState({ calendarTimeZone: 'auto' }));
|
||||||
|
|
||||||
@@ -864,6 +883,39 @@ describe.each(occurrences)('saving this and the following occurrences on Stalwar
|
|||||||
expect(statusOf(created.participants, ORGANIZER)).toBe('accepted');
|
expect(statusOf(created.participants, ORGANIZER)).toBe('accepted');
|
||||||
expect(statusOf(created.participants, ATTENDEE)).toBe('needs-action');
|
expect(statusOf(created.participants, ATTENDEE)).toBe('needs-action');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The store writes to the account the client serves when a write begins:
|
||||||
|
// the rest of the series went to the next account's calendar of the same
|
||||||
|
// id, and invited its attendees.
|
||||||
|
it('makes no rest of the series once the account changed after ending it', async () => {
|
||||||
|
const occurrence = make();
|
||||||
|
const { deps, switchAccount } = scoped(actions(series(version)));
|
||||||
|
deps.updateEvent.mockImplementationOnce(async () => { switchAccount(); return undefined; });
|
||||||
|
|
||||||
|
await expect(saveOccurrenceEdit('this_and_future', {
|
||||||
|
event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence),
|
||||||
|
calendarId: 'cal', sendScheduling: true, userEmails: [ORGANIZER],
|
||||||
|
}, deps)).rejects.toThrow('account changed');
|
||||||
|
|
||||||
|
expect(deps.updateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.createEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts nothing back in the next account when the account changed while making the rest', async () => {
|
||||||
|
const occurrence = make();
|
||||||
|
const { deps, switchAccount } = scoped(actions(series(version)));
|
||||||
|
deps.createEvent.mockImplementationOnce(async () => {
|
||||||
|
switchAccount();
|
||||||
|
throw new Error('The account changed while the calendar was saving.');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(saveOccurrenceEdit('this_and_future', {
|
||||||
|
event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), sendScheduling: true, userEmails: [ORGANIZER],
|
||||||
|
}, deps)).rejects.toThrow('account changed');
|
||||||
|
|
||||||
|
// The truncation alone, no rollback.
|
||||||
|
expect(deps.updateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('the new series from this and the following occurrences', () => {
|
describe('the new series from this and the following occurrences', () => {
|
||||||
@@ -1156,6 +1208,31 @@ describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('the series
|
|||||||
expect(statusOf(change['recurrenceOverrides/2026-10-05T10:00:00'].participants as CalendarEvent['participants'], ATTENDEE)).toBe('accepted');
|
expect(statusOf(change['recurrenceOverrides/2026-10-05T10:00:00'].participants as CalendarEvent['participants'], ATTENDEE)).toBe('accepted');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends no second step once the account changed after the first', async () => {
|
||||||
|
const master = base();
|
||||||
|
const { deps, switchAccount } = scoped(actions(master));
|
||||||
|
deps.updateEvent.mockImplementationOnce(async () => { switchAccount(); return undefined; });
|
||||||
|
|
||||||
|
await expect(saveOccurrenceEdit('this', {
|
||||||
|
event: master, updates: kickOff(master), sendScheduling: true, userEmails: [ORGANIZER],
|
||||||
|
}, deps)).rejects.toThrow('account changed');
|
||||||
|
|
||||||
|
expect(deps.updateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.updateEvent.mock.calls[0][2]).toEqual({ sendSchedulingMessages: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes nothing once the account changed while the series was read', async () => {
|
||||||
|
const master = base();
|
||||||
|
const { deps, switchAccount } = scoped(actions(master));
|
||||||
|
deps.getMasterEvent.mockImplementationOnce(async () => { switchAccount(); return master; });
|
||||||
|
|
||||||
|
await expect(deleteOccurrence('this', { event: master, userEmails: [ORGANIZER] }, deps)).rejects.toThrow('account changed');
|
||||||
|
await expect(deleteOccurrence('this_and_future', { event: master, userEmails: [ORGANIZER] }, deps)).rejects.toThrow('account changed');
|
||||||
|
|
||||||
|
expect(deps.updateEvent).not.toHaveBeenCalled();
|
||||||
|
expect(deps.deleteEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('changes it in one write when nobody has answered, or nothing is sent', async () => {
|
it('changes it in one write when nobody has answered, or nothing is sent', async () => {
|
||||||
const unanswered = base({
|
const unanswered = base({
|
||||||
participants: { org: participant(version, ORGANIZER, true, 'accepted'), b: participant(version, ATTENDEE, false, 'needs-action') },
|
participants: { org: participant(version, ORGANIZER, true, 'accepted'), b: participant(version, ATTENDEE, false, 'needs-action') },
|
||||||
|
|||||||
@@ -581,6 +581,39 @@ export interface OccurrenceEditActions {
|
|||||||
) => Promise<CalendarEvent | null | undefined>;
|
) => Promise<CalendarEvent | null | undefined>;
|
||||||
/** The base event of a series, from one of its occurrences. */
|
/** The base event of a series, from one of its occurrences. */
|
||||||
getMasterEvent: (occurrence: CalendarEvent) => Promise<CalendarEvent | null>;
|
getMasterEvent: (occurrence: CalendarEvent) => Promise<CalendarEvent | null>;
|
||||||
|
/**
|
||||||
|
* The account the edit is made in, taken once when it starts (the store's
|
||||||
|
* calendarOperationScope); `assert` throws once the client serves another.
|
||||||
|
* Each step is a write of its own: after an account switch the next one
|
||||||
|
* went to the next account with this one's ids - the rest of a split
|
||||||
|
* series made in its calendar of the same id, mailed to its attendees.
|
||||||
|
*/
|
||||||
|
operationScope?: () => { assert: () => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `actions` with the account checked before every step, against one scope
|
||||||
|
* for the whole operation: the store writes to the account the client serves
|
||||||
|
* when a write begins, and a rollback after the switch would undo nothing of
|
||||||
|
* this account. Steps handed on (a this-and-future edit from the first
|
||||||
|
* occurrence saves as 'all') keep that scope.
|
||||||
|
*/
|
||||||
|
function inOneScope<A extends Partial<OccurrenceEditActions & OccurrenceDeleteActions>>(actions: A): A {
|
||||||
|
const scope = actions.operationScope?.();
|
||||||
|
if (!scope) return actions;
|
||||||
|
const checked = <F extends (...args: never[]) => Promise<unknown>>(step: F | undefined) =>
|
||||||
|
step && (((...args: Parameters<F>) => {
|
||||||
|
scope.assert();
|
||||||
|
return step(...args);
|
||||||
|
}) as unknown as F);
|
||||||
|
return {
|
||||||
|
...actions,
|
||||||
|
operationScope: () => scope,
|
||||||
|
updateEvent: checked(actions.updateEvent),
|
||||||
|
createEvent: checked(actions.createEvent),
|
||||||
|
deleteEvent: checked(actions.deleteEvent),
|
||||||
|
getMasterEvent: checked(actions.getMasterEvent),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OccurrenceEdit {
|
export interface OccurrenceEdit {
|
||||||
@@ -923,8 +956,9 @@ export function followingSeriesData(
|
|||||||
export async function saveOccurrenceEdit(
|
export async function saveOccurrenceEdit(
|
||||||
scope: RecurrenceEditScope,
|
scope: RecurrenceEditScope,
|
||||||
edit: OccurrenceEdit,
|
edit: OccurrenceEdit,
|
||||||
actions: OccurrenceEditActions,
|
unscoped: OccurrenceEditActions,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
const actions = inOneScope(unscoped);
|
||||||
const { updateEvent, createEvent } = actions;
|
const { updateEvent, createEvent } = actions;
|
||||||
const { event, updates, sendScheduling } = edit;
|
const { event, updates, sendScheduling } = edit;
|
||||||
const scheduling = { sendSchedulingMessages: sendScheduling };
|
const scheduling = { sendSchedulingMessages: sendScheduling };
|
||||||
@@ -1009,7 +1043,7 @@ export async function saveOccurrenceEdit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** What deleting part of a series works with: the calendar store's actions. */
|
/** What deleting part of a series works with: the calendar store's actions. */
|
||||||
export interface OccurrenceDeleteActions extends Pick<OccurrenceEditActions, 'updateEvent' | 'getMasterEvent'> {
|
export interface OccurrenceDeleteActions extends Pick<OccurrenceEditActions, 'updateEvent' | 'getMasterEvent' | 'operationScope'> {
|
||||||
deleteEvent: (id: string) => Promise<void>;
|
deleteEvent: (id: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1022,8 +1056,9 @@ export interface OccurrenceDeleteActions extends Pick<OccurrenceEditActions, 'up
|
|||||||
export async function deleteOccurrence(
|
export async function deleteOccurrence(
|
||||||
scope: RecurrenceEditScope,
|
scope: RecurrenceEditScope,
|
||||||
deletion: { event: CalendarEvent; userEmails: string[] },
|
deletion: { event: CalendarEvent; userEmails: string[] },
|
||||||
actions: OccurrenceDeleteActions,
|
unscoped: OccurrenceDeleteActions,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
const actions = inOneScope(unscoped);
|
||||||
const { event } = deletion;
|
const { event } = deletion;
|
||||||
if (scope === 'this') {
|
if (scope === 'this') {
|
||||||
if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) {
|
if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import { useCalendarLocale } from '../lib/calendar-locale';
|
|||||||
import { displayNow } from '../lib/calendar-timezone';
|
import { displayNow } from '../lib/calendar-timezone';
|
||||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
import { useCalendarStore } from '../stores/calendar-store';
|
import { CalendarAccountChangedError, calendarOperationScope, useCalendarStore } from '../stores/calendar-store';
|
||||||
import { useSettingsStore } from '../stores/settings-store';
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
import { MonthView } from '../components/calendar/MonthView';
|
import { MonthView } from '../components/calendar/MonthView';
|
||||||
import { MonthScrollView } from '../components/calendar/MonthScrollView';
|
import { MonthScrollView } from '../components/calendar/MonthScrollView';
|
||||||
@@ -635,6 +635,9 @@ export default function CalendarScreen() {
|
|||||||
|
|
||||||
const reportError = React.useCallback(
|
const reportError = React.useCallback(
|
||||||
(err: unknown) => {
|
(err: unknown) => {
|
||||||
|
// The account changed under the write: nothing failed in the account
|
||||||
|
// shown now, and the previous one shows what got in when it is back.
|
||||||
|
if (err instanceof CalendarAccountChangedError) return;
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t('calendar.notifications.event_error', 'Something went wrong'),
|
t('calendar.notifications.event_error', 'Something went wrong'),
|
||||||
err instanceof Error ? err.message : undefined,
|
err instanceof Error ? err.message : undefined,
|
||||||
@@ -724,12 +727,14 @@ export default function CalendarScreen() {
|
|||||||
sendScheduling: action.sendScheduling,
|
sendScheduling: action.sendScheduling,
|
||||||
userEmails: currentUserEmails,
|
userEmails: currentUserEmails,
|
||||||
resolveName: resolveContactName,
|
resolveName: resolveContactName,
|
||||||
}, { updateEvent, createEvent, getMasterEvent })
|
}, { updateEvent, createEvent, getMasterEvent, operationScope: calendarOperationScope })
|
||||||
: await deleteOccurrence(scope, { event, userEmails: currentUserEmails }, {
|
: await deleteOccurrence(scope, { event, userEmails: currentUserEmails }, {
|
||||||
updateEvent, deleteEvent, getMasterEvent,
|
updateEvent, deleteEvent, getMasterEvent, operationScope: calendarOperationScope,
|
||||||
});
|
});
|
||||||
if (!done) throw new Error('Master event not found');
|
if (!done) throw new Error('Master event not found');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// The account changed under the edit: it stopped there (see reportError).
|
||||||
|
if (err instanceof CalendarAccountChangedError) return;
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t('calendar.notifications.event_error', 'Something went wrong'),
|
t('calendar.notifications.event_error', 'Something went wrong'),
|
||||||
participantLimitMessage(err, t) ?? (err instanceof Error ? err.message : undefined),
|
participantLimitMessage(err, t) ?? (err instanceof Error ? err.message : undefined),
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// A calendar write the account switch overtook throws
|
||||||
|
// CalendarAccountChangedError: the write stopped so that nothing reaches the
|
||||||
|
// next account. Nothing failed in the account shown now, and the alert
|
||||||
|
// "Something went wrong" came up over it. The places that show a calendar
|
||||||
|
// write's error pass it over in silence.
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const SRC = join(__dirname, '../..');
|
||||||
|
|
||||||
|
/** The text of `file` from `marker` to the first `Alert.alert(` after it. */
|
||||||
|
function beforeAlert(file: string, marker: string): string {
|
||||||
|
const src = readFileSync(join(SRC, file), 'utf8');
|
||||||
|
const start = src.indexOf(marker);
|
||||||
|
expect(start).toBeGreaterThanOrEqual(0);
|
||||||
|
const alert = src.indexOf('Alert.alert(', start);
|
||||||
|
expect(alert).toBeGreaterThan(start);
|
||||||
|
return src.slice(start, alert);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SILENT = /if \(err instanceof CalendarAccountChangedError\)/;
|
||||||
|
|
||||||
|
describe('a calendar write the account switch stopped shows no error', () => {
|
||||||
|
it('CalendarScreen reportError', () => {
|
||||||
|
expect(beforeAlert('screens/CalendarScreen.tsx', 'const reportError = React.useCallback(')).toMatch(SILENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CalendarScreen saving or deleting part of a series', () => {
|
||||||
|
expect(beforeAlert('screens/CalendarScreen.tsx', "if (!done) throw new Error('Master event not found');")).toMatch(SILENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('EventModal save', () => {
|
||||||
|
expect(beforeAlert('components/calendar/EventModal.tsx', 'await onSave(data, calendarId, {')).toMatch(SILENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CalendarScreen gives the scope edits one account for the whole operation', () => {
|
||||||
|
const src = readFileSync(join(SRC, 'screens/CalendarScreen.tsx'), 'utf8');
|
||||||
|
expect(src.match(/operationScope: calendarOperationScope/g)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -99,7 +99,10 @@ describe('a write the account switch overtook', () => {
|
|||||||
it('names the account it started in and leaves the next account\'s event alone (updateEvent)', async () => {
|
it('names the account it started in and leaves the next account\'s event alone (updateEvent)', async () => {
|
||||||
mockUpdate.mockImplementationOnce(async () => switchAccount());
|
mockUpdate.mockImplementationOnce(async () => switchAccount());
|
||||||
|
|
||||||
await useCalendarStore.getState().updateEvent('c', { title: 'Renamed' });
|
// Fails, so that a caller with more steps (lib/recurrence-scope-edit)
|
||||||
|
// sends no next one to the next account.
|
||||||
|
await expect(useCalendarStore.getState().updateEvent('c', { title: 'Renamed' }))
|
||||||
|
.rejects.toBeInstanceOf(CalendarAccountChangedError);
|
||||||
|
|
||||||
expect(mockUpdate).toHaveBeenCalledWith('c', { title: 'Renamed' }, undefined, 'acc-1');
|
expect(mockUpdate).toHaveBeenCalledWith('c', { title: 'Renamed' }, undefined, 'acc-1');
|
||||||
expect(useCalendarStore.getState().events).toEqual([THEIRS]);
|
expect(useCalendarStore.getState().events).toEqual([THEIRS]);
|
||||||
@@ -108,7 +111,7 @@ describe('a write the account switch overtook', () => {
|
|||||||
it('leaves the next account\'s event of the same id in the store (deleteEvent)', async () => {
|
it('leaves the next account\'s event of the same id in the store (deleteEvent)', async () => {
|
||||||
mockDelete.mockImplementationOnce(async () => switchAccount());
|
mockDelete.mockImplementationOnce(async () => switchAccount());
|
||||||
|
|
||||||
await useCalendarStore.getState().deleteEvent('c');
|
await expect(useCalendarStore.getState().deleteEvent('c')).rejects.toBeInstanceOf(CalendarAccountChangedError);
|
||||||
|
|
||||||
expect(mockDelete).toHaveBeenCalledWith(['c'], undefined, 'acc-1');
|
expect(mockDelete).toHaveBeenCalledWith(['c'], undefined, 'acc-1');
|
||||||
expect(useCalendarStore.getState().events).toEqual([THEIRS]);
|
expect(useCalendarStore.getState().events).toEqual([THEIRS]);
|
||||||
@@ -120,7 +123,7 @@ describe('a write the account switch overtook', () => {
|
|||||||
return event('n', 'New');
|
return event('n', 'New');
|
||||||
});
|
});
|
||||||
|
|
||||||
await useCalendarStore.getState().createEvent({ title: 'New' }, 'b');
|
await expect(useCalendarStore.getState().createEvent({ title: 'New' }, 'b')).rejects.toBeInstanceOf(CalendarAccountChangedError);
|
||||||
|
|
||||||
expect(mockCreate).toHaveBeenCalledWith({ title: 'New' }, 'b', undefined, 'acc-1');
|
expect(mockCreate).toHaveBeenCalledWith({ title: 'New' }, 'b', undefined, 'acc-1');
|
||||||
expect(mockGetEvents).not.toHaveBeenCalled();
|
expect(mockGetEvents).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ vi.mock('../../api/jmap-client', () => ({
|
|||||||
|
|
||||||
import * as calendarApi from '../../api/calendar';
|
import * as calendarApi from '../../api/calendar';
|
||||||
import type { CalendarEvent } from '../../api/types';
|
import type { CalendarEvent } from '../../api/types';
|
||||||
import { useCalendarStore } from '../calendar-store';
|
import { jmapClient } from '../../api/jmap-client';
|
||||||
|
import { CalendarAccountChangedError, calendarOperationScope, useCalendarStore } from '../calendar-store';
|
||||||
import { saveOccurrenceEdit } from '../../lib/recurrence-scope-edit';
|
import { saveOccurrenceEdit } from '../../lib/recurrence-scope-edit';
|
||||||
import { editorTimesFromEvent } from '../../lib/event-editor-times';
|
import { editorTimesFromEvent } from '../../lib/event-editor-times';
|
||||||
|
|
||||||
@@ -98,6 +99,39 @@ describe('this and the following occurrences on the calendar store (Stalwart 1.0
|
|||||||
expect([calendarId, schedule]).toEqual(['cal-1', true]);
|
expect([calendarId, schedule]).toEqual(['cal-1', true]);
|
||||||
expect(created).toMatchObject({ start: '2026-10-19T10:00:00', recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 3 }] });
|
expect(created).toMatchObject({ start: '2026-10-19T10:00:00', recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 3 }] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The account changes while the store reloads after ending the series:
|
||||||
|
// that write is done, and the next one took the account the client served
|
||||||
|
// by then - the rest of the series went to acc-2's calendar of the same id.
|
||||||
|
it('makes no rest of the series in the account the client serves after a switch', async () => {
|
||||||
|
const store = useCalendarStore.getState();
|
||||||
|
const refresh = store.refresh;
|
||||||
|
useCalendarStore.setState({
|
||||||
|
refresh: async () => {
|
||||||
|
useCalendarStore.getState().reset();
|
||||||
|
(jmapClient as { accountId: string }).accountId = 'acc-2';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await expect(saveOccurrenceEdit('this_and_future', {
|
||||||
|
event: occurrence,
|
||||||
|
updates: { title: 'Planning, room 2', start: occurrence.start, duration: 'PT1H', timeZone: 'Europe/Berlin', participants },
|
||||||
|
editor: { attendees: [{ name: '', email: 'userb@example.org' }], when: editorTimesFromEvent(occurrence) },
|
||||||
|
calendarId: 'cal-1', sendScheduling: true, userEmails: [ORGANIZER],
|
||||||
|
}, {
|
||||||
|
updateEvent: store.updateEvent, createEvent: store.createEvent, getMasterEvent: store.getMasterEvent,
|
||||||
|
operationScope: calendarOperationScope,
|
||||||
|
})).rejects.toBeInstanceOf(CalendarAccountChangedError);
|
||||||
|
} finally {
|
||||||
|
(jmapClient as { accountId: string }).accountId = 'acc-1';
|
||||||
|
useCalendarStore.setState({ refresh });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The truncation in acc-1, and nothing after it: no new series, no rollback.
|
||||||
|
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockUpdate.mock.calls[0][3]).toBe('acc-1');
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The series itself, opened from a reminder or a link: not in the grid, and
|
// The series itself, opened from a reminder or a link: not in the grid, and
|
||||||
|
|||||||
@@ -361,6 +361,19 @@ function assertInScope(scope: WriteScope): void {
|
|||||||
if (!inScope(scope)) throw new CalendarAccountChangedError();
|
if (!inScope(scope)) throw new CalendarAccountChangedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account an operation of several store writes runs in (the scope edits
|
||||||
|
* in lib/recurrence-scope-edit), taken once when it starts. Each write takes
|
||||||
|
* the account the client serves when it begins, so a step after an account
|
||||||
|
* switch went to the next account with the ids of this one. `assert` throws
|
||||||
|
* CalendarAccountChangedError once the store was reset or the client serves
|
||||||
|
* another account.
|
||||||
|
*/
|
||||||
|
export function calendarOperationScope(): { assert: () => void } {
|
||||||
|
const scope = writeScope();
|
||||||
|
return { assert: () => assertInScope(scope) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The JMAP account a write goes to, named on every request: a shared
|
* The JMAP account a write goes to, named on every request: a shared
|
||||||
* calendar's owner, else the primary account the write started in.
|
* calendar's owner, else the primary account the write started in.
|
||||||
@@ -1072,8 +1085,9 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
schedule,
|
schedule,
|
||||||
writeAccount(scope, accountId),
|
writeAccount(scope, accountId),
|
||||||
);
|
);
|
||||||
// Made; the store shows another account by now.
|
// Made; the store shows another account by now. Throws so that a caller
|
||||||
if (!inScope(scope)) return created;
|
// with more to do stops here, and opens nothing of the previous account.
|
||||||
|
assertInScope(scope);
|
||||||
// The /set echo lacks server-computed properties (utcStart/utcEnd, the
|
// The /set echo lacks server-computed properties (utcStart/utcEnd, the
|
||||||
// normalised recurrence rule); re-read the event so it renders at the
|
// normalised recurrence rule); re-read the event so it renders at the
|
||||||
// right instant, then expand a recurring series across the loaded range
|
// right instant, then expand a recurring series across the loaded range
|
||||||
@@ -1085,7 +1099,7 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
} catch {
|
} catch {
|
||||||
// Keep the echo; the next refresh reconciles.
|
// Keep the echo; the next refresh reconciles.
|
||||||
}
|
}
|
||||||
if (!inScope(scope)) return full;
|
assertInScope(scope);
|
||||||
// Map the same way fetchEvents does so the optimistic insert doesn't
|
// Map the same way fetchEvents does so the optimistic insert doesn't
|
||||||
// collide with the user's own events and stays visible under the right
|
// collide with the user's own events and stays visible under the right
|
||||||
// calendar filter.
|
// calendar filter.
|
||||||
@@ -1179,8 +1193,9 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
} else {
|
} else {
|
||||||
await updateBaseEvent(realId, patch, schedule, account, scope);
|
await updateBaseEvent(realId, patch, schedule, account, scope);
|
||||||
}
|
}
|
||||||
// Saved; the store shows another account by now, whose events can carry the same ids.
|
// Saved; the store shows another account by now, whose events can carry
|
||||||
if (!inScope(scope)) return;
|
// the same ids. Throws so that a caller with more steps sends no next one.
|
||||||
|
assertInScope(scope);
|
||||||
set({
|
set({
|
||||||
events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial<CalendarEvent>) } : e)),
|
events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial<CalendarEvent>) } : e)),
|
||||||
});
|
});
|
||||||
@@ -1206,16 +1221,17 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
const schedule = hasSchedulingParticipants(storeEvent) ? true : undefined;
|
const schedule = hasSchedulingParticipants(storeEvent) ? true : undefined;
|
||||||
if (target.isOccurrence && storeEvent) {
|
if (target.isOccurrence && storeEvent) {
|
||||||
await destroyOccurrence(storeEvent, realId, schedule, writeAccount(scope, accountId), scope);
|
await destroyOccurrence(storeEvent, realId, schedule, writeAccount(scope, accountId), scope);
|
||||||
if (!inScope(scope)) return;
|
assertInScope(scope);
|
||||||
set({ events: get().events.filter((e) => e.id !== id) });
|
set({ events: get().events.filter((e) => e.id !== id) });
|
||||||
} else if (target.isBrowserOccurrence) {
|
} else if (target.isBrowserOccurrence) {
|
||||||
await destroyBrowserOccurrence(target, schedule, scope);
|
await destroyBrowserOccurrence(target, schedule, scope);
|
||||||
if (!inScope(scope)) return;
|
assertInScope(scope);
|
||||||
set({ events: get().events.filter((e) => e.id !== id) });
|
set({ events: get().events.filter((e) => e.id !== id) });
|
||||||
} else {
|
} else {
|
||||||
await apiDeleteEvents([realId], schedule, writeAccount(scope, accountId));
|
await apiDeleteEvents([realId], schedule, writeAccount(scope, accountId));
|
||||||
// Deleted; the store shows another account by now, whose events can carry the same ids.
|
// Deleted; the store shows another account by now, whose events can
|
||||||
if (!inScope(scope)) return;
|
// carry the same ids. Throws as updateEvent does.
|
||||||
|
assertInScope(scope);
|
||||||
knownMasters.delete(id);
|
knownMasters.delete(id);
|
||||||
// Destroying a master removes every expanded occurrence of it, not
|
// Destroying a master removes every expanded occurrence of it, not
|
||||||
// just the tapped one.
|
// just the tapped one.
|
||||||
|
|||||||
Reference in New Issue
Block a user