From 1c75da873343224f06b670d68efc199e687d9e8a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:41:39 +0200 Subject: [PATCH] fix: remove an event created in a read-only calendar and say so #34 An event created on the phone in a calendar the user may only read was meant to be removed, since the server takes no changes there, but the claim step ignored that answer: the event stayed on the phone, was never sent or reported, and turning sync off always warned that a change had not reached the server. The claim step now applies it: the row goes, the report lists it as read-only, and nothing is left waiting. --- .../__tests__/calendar/engine.test.ts | 38 +++++++++++++++++++ src/device-sync/engine/item-sync.ts | 5 +++ 2 files changed, 43 insertions(+) diff --git a/src/device-sync/__tests__/calendar/engine.test.ts b/src/device-sync/__tests__/calendar/engine.test.ts index 6aacfcf8..fd298087 100644 --- a/src/device-sync/__tests__/calendar/engine.test.ts +++ b/src/device-sync/__tests__/calendar/engine.test.ts @@ -310,6 +310,44 @@ describe('calendar device edits through the engine', () => { }); }); + it('removes an event created in a read-only calendar and reports it', async () => { + const h = real({ accounts: 'withShared' }); + const team = h.server.addCalendar('team', { + name: 'Team events', + myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false }, + }); + h.prefs.calendarSelection[`team/${team}`] = true; + await h.run(CALENDAR_AUTHORITY); + const teamRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `team/${team}`)!._id); + + const id = h.device.user.insertEvent(teamRow, { + [Events.TITLE]: 'Mine', + [Events.DTSTART]: Date.UTC(2026, 9, 14, 13), + [Events.DTEND]: Date.UTC(2026, 9, 14, 14), + [Events.EVENT_TIMEZONE]: 'Europe/Berlin', + }); + const report = await h.run(CALENDAR_AUTHORITY); + + expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { created: 0 }, skipped: 1 } }); + expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `row:${id}`, side: 'upload', type: 'readOnly' })]); + expect(h.events().find((e) => Number(e._id) === id)).toBeUndefined(); + expect(h.server.all('CalendarEvent', 'team')).toEqual([]); + // Nothing is left waiting: the next run has nothing to say about it. + expect((await h.run(CALENDAR_AUTHORITY)).itemErrors).toEqual([]); + }); + + it('leaves nothing waiting for turning sync off after an event was created in a read-only calendar', async () => { + const h = real({ accounts: 'withShared' }); + const team = h.server.addCalendar('team', { name: 'Team events', myRights: { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayRSVP: false } }); + h.prefs.calendarSelection[`team/${team}`] = true; + await h.run(CALENDAR_AUTHORITY); + const teamRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `team/${team}`)!._id); + h.device.user.insertEvent(teamRow, { [Events.TITLE]: 'Mine', [Events.DTSTART]: Date.UTC(2026, 9, 14, 13), [Events.DTEND]: Date.UTC(2026, 9, 14, 14), [Events.EVENT_TIMEZONE]: 'Europe/Berlin' }); + + expect(await h.teardown(CALENDAR_AUTHORITY)).toEqual({ pending: 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', { diff --git a/src/device-sync/engine/item-sync.ts b/src/device-sync/engine/item-sync.ts index 76ad8fa6..f4c8275f 100644 --- a/src/device-sync/engine/item-sync.ts +++ b/src/device-sync/engine/item-sync.ts @@ -912,6 +912,11 @@ export abstract class ItemSync { if (plan.kind === 'skip') { this.env.report.stats.skipped++; this.env.report.itemError({ ref, side: 'upload', type: plan.reason }); + } else if (plan.kind === 'revert') { + // It may not be created where it was made (a read-only calendar): the row goes, and the report says so. + this.env.report.stats.skipped++; + this.env.report.itemError({ ref, side: 'upload', type: 'readOnly' }); + works.push(this.planWork(acct, held, 'revert', plan.ops)); } else if (plan.kind === 'claim' || plan.kind === 'purge' || plan.kind === 'clean') { works.push(this.planWork(acct, held, plan.kind, plan.ops)); }