fix: move a task into or within a shared calendar (bulwarkmail/webmail#1204)

This commit is contained in:
Linus Rath
2026-10-31 04:29:28 +01:00
parent 9b5692a8ab
commit b6995bb638
3 changed files with 115 additions and 1 deletions
@@ -783,6 +783,38 @@ live('live Stalwart calendar', () => {
expect(list).toHaveLength(1);
expect(list[0].recurrenceOverrides?.['2027-10-11T10:00:00']?.title).toBe(`${RUN} owner second`);
}, 60_000);
it('moves a task into the shared calendar and back out of it (#1204)', async () => {
expect(sharedCalendarId, 'the calendar shared above').toBeTruthy();
await connectSingleton();
const [[, made]] = await raw(client, [['CalendarEvent/set', {
accountId: client.accountId,
create: { t: { '@type': 'Task', title: `${RUN} moving task`, calendarIds: { [calendarId]: true }, due: '2027-11-03T10:00:00', timeZone: 'Europe/Berlin' } },
}, '0']]);
expect(made.created?.t?.id, JSON.stringify(made.notCreated)).toBeTruthy();
const store = useCalendarStore.getState();
await store.fetchCalendars();
await useCalendarStore.getState().fetchTasks();
const taskOf = () => useCalendarStore.getState().tasks.find((t) => t.title === `${RUN} moving task`);
const shared = useCalendarStore.getState().calendars.find((c) => c.name === `${RUN} team`)!;
const task = taskOf()!;
expect(task.accountId).toBeFalsy();
await useCalendarStore.getState().updateTask(task.id, { calendarIds: { [shared.id]: true } });
const inShared = (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', 'uid', 'title', '@type'] }, 'g']]))
.find((r) => r[2] === 'g')?.[1].list as Record<string, any>[];
const moved = inShared.find((e) => e.title === `${RUN} moving task`);
expect(moved?.uid).toBe(task.uid);
expect((await raw(client, [['CalendarEvent/get', { accountId: client.accountId, ids: [task.id], properties: ['id'] }, '0']]))[0][1].notFound)
.toEqual([task.id]);
expect(taskOf()?.accountId).toBe(other!.accountId);
// And back into the user's own calendar.
await useCalendarStore.getState().updateTask(taskOf()!.id, { calendarIds: { [calendarId]: true } });
expect(taskOf()?.accountId).toBeFalsy();
await removeRunEvents(client);
}, 60_000);
});
describe('a new calendar', () => {
@@ -656,6 +656,49 @@ describe('calendar-store', () => {
});
});
describe('updateTask in shared calendars (#1204)', () => {
const calendars = [
{ id: 'own', name: 'Own' },
{ id: 'acc-2:shared', originalId: 'shared', accountId: 'acc-2', isShared: true, name: 'Team' },
{ id: 'acc-2:other', originalId: 'other', accountId: 'acc-2', isShared: true, name: 'Team 2' },
] as any[];
it('moves a task within a shared account under the raw calendar id', async () => {
useCalendarStore.setState({
calendars,
tasks: [{ id: 'acc-2:t1', originalId: 't1', accountId: 'acc-2', calendarIds: { 'acc-2:shared': true } } as any],
});
mockUpdateEvent.mockResolvedValue(undefined);
await useCalendarStore.getState().updateTask('acc-2:t1', { calendarIds: { 'acc-2:other': true } });
expect(mockUpdateEvent).toHaveBeenCalledWith('t1', { calendarIds: { other: true } }, undefined, 'acc-2');
expect(mockCreateEvent).not.toHaveBeenCalled();
});
it('moves an own task into a shared calendar by making it there and removing the original', async () => {
useCalendarStore.setState({
calendars,
tasks: [{ id: 't1', calendarIds: { own: true }, title: 'Pay' } as any],
});
mockGetEvents.mockResolvedValue([{
id: 't1', '@type': 'Task', uid: 'u-1', title: 'Pay', calendarIds: { own: true }, sequence: 2, created: 'x',
}]);
mockCreateEvent.mockResolvedValue({ id: 'n1' });
mockDeleteEvents.mockResolvedValue(undefined);
await useCalendarStore.getState().updateTask('t1', { calendarIds: { 'acc-2:shared': true }, title: 'Pay rent' });
expect(mockUpdateEvent).not.toHaveBeenCalled();
const [created, calendarId, , account] = mockCreateEvent.mock.calls[0];
expect(calendarId).toBe('shared');
expect(account).toBe('acc-2');
expect(created).toMatchObject({ '@type': 'Task', uid: 'u-1', title: 'Pay rent' });
expect(created).not.toHaveProperty('id');
expect(created).not.toHaveProperty('sequence');
expect(mockDeleteEvents).toHaveBeenCalledWith(['t1'], undefined, 'acc-1');
// Created first: a failed create leaves the task where it was.
expect(mockCreateEvent.mock.invocationCallOrder[0]).toBeLessThan(mockDeleteEvents.mock.invocationCallOrder[0]);
});
});
describe('deleteEvent', () => {
it('should remove event from list', async () => {
useCalendarStore.setState({
+40 -1
View File
@@ -1540,7 +1540,46 @@ export const useCalendarStore = create<CalendarState>()(
const scope = writeScope();
const task = get().tasks.find((t) => t.id === id);
const realId = task?.originalId || id;
await apiUpdateEvent(realId, changes, undefined, writeAccount(scope, task?.accountId));
const sourceAccount = task?.accountId ?? undefined;
// Store calendar ids (namespaced for a shared account) back to the raw
// ids of the account that owns the calendar, which may be another one
// than the task's (webmail #1204).
const patch: Partial<CalendarEvent> = { ...changes };
let destAccount = sourceAccount;
if (changes.calendarIds) {
const remapped: Record<string, boolean> = {};
for (const [calId, on] of Object.entries(changes.calendarIds)) {
const cal = get().calendars.find((c) => c.id === calId);
remapped[cal?.originalId || calId] = on;
if (on && cal) destAccount = cal.accountId ?? undefined;
}
patch.calendarIds = remapped;
}
if (destAccount !== sourceAccount) {
// JMAP has no move across accounts: create the task in the calendar's
// account, then destroy the original. Creating first means a failed
// create leaves the task where it was.
const [original] = await fetchEvents([realId], writeAccount(scope, sourceAccount));
if (!original) throw new Error('Task not found');
assertInScope(scope);
const {
id: _id, created: _created, updated: _updated, sequence: _sequence, isOrigin: _isOrigin,
utcStart: _utcStart, utcEnd: _utcEnd, calendarIds: _calendarIds, ...rest
} = original as CalendarEvent & Record<string, unknown>;
const destCalendarId = Object.keys(patch.calendarIds ?? {})[0];
const { calendarIds: _patchCalendars, ...fields } = patch;
await apiCreateEvent(
{ ...rest, ...fields, '@type': 'Task' } as Partial<CalendarEvent>,
destCalendarId,
undefined,
writeAccount(scope, destAccount),
);
await apiDeleteEvents([realId], undefined, writeAccount(scope, sourceAccount));
// Under a new id in another account: reload rather than patch.
if (inScope(scope)) await get().fetchTasks();
return;
}
await apiUpdateEvent(realId, patch, undefined, writeAccount(scope, sourceAccount));
// Saved; the next account's tasks can carry the same ids.
if (!inScope(scope)) return;
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });