fix: put back an event moved in place to another account's calendar #34

An app that moves an event by changing its calendar in place can move
it into a calendar of another account on the device. A patch cannot
move an event between accounts, so the move was dropped; with nothing
else changed, the row was then marked clean where it stood. The phone
showed the event in the other account's calendar while the server kept
it in the old one, until the next server change moved it back.

The row now goes back to its calendar at once, rewritten from the last
server version. Changes made together with the move still upload, and
their accepted write puts the row back as before.

Checked with a run through the engine and the fake server with a shared
account: the event is back in its calendar and clean, and the server
and the other account are untouched.
This commit is contained in:
Linus Rath
2026-09-27 07:10:47 +02:00
parent d3262ff387
commit 6785276377
2 changed files with 42 additions and 0 deletions
@@ -131,6 +131,41 @@ describe('calendar device edits through the engine', () => {
expect(h.events().every((e) => Number(e[Events.DIRTY] ?? 0) === 0)).toBe(true);
});
it('puts an event an app moved in place to a calendar of another account back into its calendar', async () => {
const h = real({ accounts: 'withShared' });
const team = String(h.server.all('Calendar', 'team')[0].id);
h.prefs.calendarSelection[`team/${team}`] = true;
const id = h.server.addEvent('a', {
uid: 'lunch-uid',
title: 'Lunch',
start: '2026-10-06T12:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
calendarIds: { [h.calendar]: true },
});
await h.run(CALENDAR_AUTHORITY);
const calendarRow = (key: string) => Number(h.device.rows('calendars').find((c) => c._sync_id === key)!._id);
const row = () => h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!;
expect(row()[Events.CALENDAR_ID]).toBe(calendarRow(`a/${h.calendar}`));
// An app moves it by changing CALENDAR_ID in place (a move across JMAP accounts can't be a patch).
h.device.user.updateEvent(Number(row()._id), { [Events.CALENDAR_ID]: calendarRow(`team/${team}`) });
const report = await h.run(CALENDAR_AUTHORITY);
expect(row()).toMatchObject({ [Events.CALENDAR_ID]: calendarRow(`a/${h.calendar}`), [Events.DIRTY]: 0 });
expect(h.server.get('CalendarEvent', 'a', id)!.calendarIds).toEqual({ [h.calendar]: true });
expect(h.server.all('CalendarEvent', 'team')).toEqual([]);
// Nothing was sent; the change counts as skipped (a report entry needs the engine to report reverts).
expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { created: 0, updated: 0, deleted: 0 }, skipped: 1 } });
// Together with another edit: that one uploads, and the row goes back all the same.
h.device.user.updateEvent(Number(row()._id), { [Events.CALENDAR_ID]: calendarRow(`team/${team}`), [Events.TITLE]: 'Team lunch' });
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', stats: { uploaded: { updated: 1 } } });
expect(h.server.get('CalendarEvent', 'a', id)).toMatchObject({ title: 'Team lunch', calendarIds: { [h.calendar]: true } });
expect(row()).toMatchObject({ [Events.CALENDAR_ID]: calendarRow(`a/${h.calendar}`), [Events.TITLE]: 'Team lunch', [Events.DIRTY]: 0 });
expect(h.server.all('CalendarEvent', 'team')).toEqual([]);
});
it('keeps a description and location cleared on one occurrence cleared', async () => {
const h = real();
const id = h.server.addEvent('a', {
+7
View File
@@ -316,6 +316,13 @@ function planUpload(local: LocalEvent, ctx: CalendarContext): Plan {
}
if (!keys.length) {
if (computation.dropped.some((u) => u === 'timing' || u === 'rule')) return { kind: 'skip', reason: SKIP.ruleNotRepresentable };
// A move into a calendar of another JMAP account is no patch (only new rows are created across accounts):
// the row goes back to its calendar instead of staying there unsynced. With other changes, their accepted
// write puts it back.
if (computation.dropped.includes('calendar')) {
const ops = revertGroup(local, ctx);
if (ops) return { kind: 'revert', ops };
}
return { kind: 'clean', ops: cleanGroup(local, computation) };
}
const action: UploadAction<CalendarEventWire> = { kind: 'update', id: ref.id, patch: computation.patch };