diff --git a/src/components/Editor/InvitationResponseButtons.vue b/src/components/Editor/InvitationResponseButtons.vue index ea91e53835..30c3a5ddd2 100644 --- a/src/components/Editor/InvitationResponseButtons.vue +++ b/src/components/Editor/InvitationResponseButtons.vue @@ -13,7 +13,7 @@ class="invitation-response-buttons__button" :disabled="loading" @click="accept"> - {{ t('calendar', 'Accept') }} + {{ acceptLabel }} - {{ t('calendar', 'Decline') }} + {{ declineLabel }} - {{ t('calendar', 'Tentative') }} + {{ tentativeLabel }} @@ -72,11 +72,6 @@ export default { required: true, }, - calendarId: { - type: String, - required: true, - }, - narrow: { type: Boolean, default: false, @@ -109,6 +104,45 @@ export default { isTentative() { return this.attendee.participationStatus === 'TENTATIVE' }, + + responseScope() { + const eventComponent = this.calendarObjectInstanceStore.calendarObjectInstance?.eventComponent + if (!eventComponent?.isPartOfRecurrenceSet()) { + return null + } + + return eventComponent.isRecurrenceException() ? 'occurrence' : 'series' + }, + + acceptLabel() { + if (this.responseScope === 'occurrence') { + return this.t('calendar', 'Accept this occurrence') + } + if (this.responseScope === 'series') { + return this.t('calendar', 'Accept entire series') + } + return this.t('calendar', 'Accept') + }, + + declineLabel() { + if (this.responseScope === 'occurrence') { + return this.t('calendar', 'Decline this occurrence') + } + if (this.responseScope === 'series') { + return this.t('calendar', 'Decline entire series') + } + return this.t('calendar', 'Decline') + }, + + tentativeLabel() { + if (this.responseScope === 'occurrence') { + return this.t('calendar', 'Tentative for this occurrence') + } + if (this.responseScope === 'series') { + return this.t('calendar', 'Tentative for entire series') + } + return this.t('calendar', 'Tentative') + }, }, methods: { @@ -154,16 +188,10 @@ export default { async setParticipationStatus(participationStatus) { this.loading = true try { - this.calendarObjectInstanceStore.changeAttendeesParticipationStatus({ + await this.calendarObjectInstanceStore.saveAttendeeParticipationResponse({ attendee: this.attendee, participationStatus, }) - // TODO: What about recurring events? Add new buttons like "Accept this and all future"? - // Currently, this will only accept a single occurrence. - await this.calendarObjectInstanceStore.saveCalendarObjectInstance({ - thisAndAllFuture: false, - calendarId: this.calendarId, - }) } catch (error) { logger.error('Failed to set participation status', { error, participationStatus }) throw error diff --git a/src/components/Editor/Repeat/Repeat.vue b/src/components/Editor/Repeat/Repeat.vue index f5f655ef40..47cc3686a0 100644 --- a/src/components/Editor/Repeat/Repeat.vue +++ b/src/components/Editor/Repeat/Repeat.vue @@ -33,18 +33,18 @@

{{ $t('calendar', 'Repeat event') }}

- - + +
@@ -163,7 +170,6 @@ @@ -298,7 +304,7 @@ + @click="acceptAttachmentsModal()"> {{ t('calendar', 'Invite') }}
@@ -345,7 +351,6 @@ import { NcActionButton, NcActionLink, NcActions, - NcActionSeparator, NcButton, NcCheckboxRadioSwitch, NcDialog, @@ -422,7 +427,6 @@ export default { IconVideo, HelpCircleIcon, NcActions, - NcActionSeparator, Close, }, @@ -432,7 +436,7 @@ export default { data() { return { - thisAndAllFuture: false, + saveScope: 'occurrence', doNotShare: false, showModal: false, showModalNewAttachments: [], @@ -719,7 +723,7 @@ export default { this.showModal = false this.showModalNewAttachments = [] this.showModalUsers = [] - this.saveEvent(this.thisAndAllFuture) + this.saveEvent(this.saveScope) }, 500) // trigger save event after make each attachment access // 1) if !isPrivate get attachments NOT SHARED and SharedType is empry -> API ADD SHARE @@ -743,8 +747,8 @@ export default { return name.split('/').pop() }, - prepareAccessForAttachments(thisAndAllFuture = false) { - this.thisAndAllFuture = thisAndAllFuture + prepareAccessForAttachments(scope) { + this.saveScope = scope const newAttachments = this.calendarObjectInstance.attachments.filter((attachment) => { // get only new attachments // TODO get NOT only new attachments =) Maybe we should filter all attachments without share-type, 'cause event can be private and AFTER save owner could add new participant @@ -764,14 +768,14 @@ export default { return false }) } else { - this.saveEvent(thisAndAllFuture) + this.saveEvent(this.saveScope) } }, - saveEvent(thisAndAllFuture = false) { + saveEvent(scope) { // if there is new attachments and !private, then make modal with users and files/ // maybe check shared access before add file - this.saveAndLeave(thisAndAllFuture) + this.saveAndLeave(scope) this.calendarObjectInstance.attachments = this.calendarObjectInstance.attachments.map((attachment) => { if (attachment.isNew) { delete attachment.isNew diff --git a/src/views/EditSimple.vue b/src/views/EditSimple.vue index f4a2334b25..73d4e42071 100644 --- a/src/views/EditSimple.vue +++ b/src/views/EditSimple.vue @@ -88,24 +88,29 @@ {{ $t('calendar', 'Duplicate') }} - + {{ $t('calendar', 'Delete') }} - + {{ $t('calendar', 'Delete this occurrence') }} - - + - {{ $t('calendar', 'Delete this and all future') }} + {{ $t('calendar', 'Delete this and future occurrences') }} + + + + {{ $t('calendar', 'Delete entire series') }} @@ -214,7 +219,6 @@ v-if="isViewedByAttendee && isViewing" class="event-popover__response-buttons" :attendee="userAsAttendee" - :calendarId="calendarId" @close="closeEditorAndSkipAction" />
@@ -229,15 +233,17 @@ } */ - async saveAndView(thisAndAllFuture) { + async saveAndView(scope) { // Transitioning from new to edit routes is not implemented for now if (this.isNew) { - await this.saveAndLeave(thisAndAllFuture) + await this.saveAndLeave(scope) return } this.isViewing = true try { - await this.save(thisAndAllFuture) + await this.save(scope) this.requiresActionOnRouteLeave = false } catch (error) { logger.error('Failed to save event, reverting to edit mode', { error }) diff --git a/tests/javascript/unit/components/InvitationResponseButtons.test.js b/tests/javascript/unit/components/InvitationResponseButtons.test.js new file mode 100644 index 0000000000..abad352e68 --- /dev/null +++ b/tests/javascript/unit/components/InvitationResponseButtons.test.js @@ -0,0 +1,41 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import InvitationResponseButtons from '@/components/Editor/InvitationResponseButtons.vue' + +describe('components/Editor/InvitationResponseButtons', () => { + it.each([ + [false, false, null], + [true, false, 'series'], + [true, true, 'occurrence'], + ])('determines the response scope', (isRecurring, isException, expected) => { + const vm = { + calendarObjectInstanceStore: { + calendarObjectInstance: { + eventComponent: { + isPartOfRecurrenceSet: vi.fn().mockReturnValue(isRecurring), + isRecurrenceException: vi.fn().mockReturnValue(isException), + }, + }, + }, + } + + expect(InvitationResponseButtons.computed.responseScope.call(vm)).toBe(expected) + }) + + it.each([ + [null, 'Accept', 'Decline', 'Tentative'], + ['occurrence', 'Accept this occurrence', 'Decline this occurrence', 'Tentative for this occurrence'], + ['series', 'Accept entire series', 'Decline entire series', 'Tentative for entire series'], + ])('labels responses for the selected scope', (responseScope, accept, decline, tentative) => { + const vm = { + responseScope, + t: (app, text) => text, + } + + expect(InvitationResponseButtons.computed.acceptLabel.call(vm)).toBe(accept) + expect(InvitationResponseButtons.computed.declineLabel.call(vm)).toBe(decline) + expect(InvitationResponseButtons.computed.tentativeLabel.call(vm)).toBe(tentative) + }) +}) diff --git a/tests/javascript/unit/components/Repeat.test.js b/tests/javascript/unit/components/Repeat.test.js new file mode 100644 index 0000000000..edfa87a31d --- /dev/null +++ b/tests/javascript/unit/components/Repeat.test.js @@ -0,0 +1,31 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import Repeat from '@/components/Editor/Repeat/Repeat.vue' + +describe('components/Editor/Repeat/Repeat', () => { + it.each([ + [false, [['requireFutureUpdate']]], + [true, []], + ])('requires a future update only outside the master item', (isEditingBaseInstance , expectedCalls) => { + const $emit = vi.fn() + const vm = { + $emit, + isEditingBaseInstance , + recurrenceRule: { isUnsupported: false }, + calendarObjectInstanceStore: { + calendarObjectInstance: { + canModifyAllDay: false, + eventComponent: { + canModifyAllDay: vi.fn().mockReturnValue(true), + }, + }, + }, + } + + Repeat.methods.modified.call(vm) + + expect($emit.mock.calls).toEqual(expectedCalls) + }) +}) diff --git a/tests/javascript/unit/components/SaveButtons.test.js b/tests/javascript/unit/components/SaveButtons.test.js new file mode 100644 index 0000000000..0084a16514 --- /dev/null +++ b/tests/javascript/unit/components/SaveButtons.test.js @@ -0,0 +1,57 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import SaveButtons from '@/components/Editor/SaveButtons.vue' + +describe('components/Editor/SaveButtons', () => { + /** + * Compute the visible controls for the given props. + * + * @param {object} overrides Props to override + * @return {object} Visibility by control + */ + function getVisibility(overrides = {}) { + const vm = { + isReadOnly: false, + isNew: false, + canUpdateOccurrence: false, + canUpdateFuture: false, + canUpdateSeries: false, + ...overrides, + } + vm.allowedUpdateScopeCount = SaveButtons.computed.allowedUpdateScopeCount.call(vm) + + return { + save: SaveButtons.computed.showSaveButton.call(vm), + update: SaveButtons.computed.showUpdateButton.call(vm), + future: SaveButtons.computed.showUpdateFutureButton.call(vm), + series: SaveButtons.computed.showUpdateSeriesButton.call(vm), + menu: SaveButtons.computed.showUpdateMenu.call(vm), + } + } + + it.each([ + [{ isNew: true, canUpdateOccurrence: true }, { save: true, update: false, future: false, series: false, menu: false }], + [{ canUpdateOccurrence: true }, { save: false, update: true, future: false, series: false, menu: false }], + [{ canUpdateFuture: true }, { save: false, update: false, future: true, series: false, menu: false }], + [{ canUpdateSeries: true }, { save: false, update: false, future: false, series: true, menu: false }], + [{ canUpdateOccurrence: true, canUpdateFuture: true, canUpdateSeries: true }, { save: false, update: false, future: false, series: false, menu: true }], + [{ isReadOnly: true, canUpdateOccurrence: true }, { save: false, update: false, future: false, series: false, menu: false }], + ])('shows the controls for the allowed update scopes', (props, expected) => { + expect(getVisibility(props)).toEqual(expected) + }) + + it.each([ + ['saveOccurrence', 'saveOccurrence'], + ['saveFuture', 'saveFuture'], + ['saveSeries', 'saveSeries'], + ['showMore', 'showMore'], + ])('%s emits %s', (method, event) => { + const $emit = vi.fn() + + SaveButtons.methods[method].call({ $emit }) + + expect($emit).toHaveBeenCalledWith(event) + }) +}) diff --git a/tests/javascript/unit/mixins/EditorMixin.test.js b/tests/javascript/unit/mixins/EditorMixin.test.js index 5886dd07be..18ac5e5ed4 100644 --- a/tests/javascript/unit/mixins/EditorMixin.test.js +++ b/tests/javascript/unit/mixins/EditorMixin.test.js @@ -2,8 +2,8 @@ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import EditorMixin from '../../../../src/mixins/EditorMixin.js' -import { ViewMode } from '../../../../src/utils/router.js' +import EditorMixin from '@/mixins/EditorMixin.js' +import { ViewMode } from '@/utils/router.js' describe('mixins/EditorMixin test suite', () => { describe('viewMode', () => { @@ -28,6 +28,236 @@ describe('mixins/EditorMixin test suite', () => { }) }) + describe('isRecurringInstance', () => { + it.each([ + [false, false, false], + [true, false, true], + [false, true, true], + ])('returns the recurrence state for generated and exception instances', (canCreateRecurrenceException, isEditingExceptionInstance, expected) => { + expect(EditorMixin.computed.isRecurringInstance.call({ + canCreateRecurrenceException, + isEditingExceptionInstance, + })).toBe(expected) + }) + }) + + describe('canDelete', () => { + it.each([ + [{ calendarObject: null }, 'occurrence', false], + [{ calendarObject: { existsOnServer: false } }, 'occurrence', false], + [{ isReadOnly: true }, 'occurrence', false], + [{ isLoading: true }, 'occurrence', false], + [{ isRecurringInstance: false }, 'occurrence', true], + [{ isRecurringInstance: false }, 'series', false], + [{ isRecurringInstance: true }, 'occurrence', true], + [{ isRecurringInstance: true }, 'future', true], + [{ isRecurringInstance: true }, 'series', true], + [{ isRecurringInstance: true, isViewedByAttendee: true }, 'occurrence', false], + [{ isRecurringInstance: true, isViewedByAttendee: true }, 'future', false], + [{ isRecurringInstance: true, isViewedByAttendee: true }, 'series', true], + [{ isRecurringInstance: true, isEditingExceptionInstance: true, isViewedByAttendee: true }, 'occurrence', true], + [{ isRecurringInstance: true, isEditingExceptionInstance: true, isViewedByAttendee: true }, 'future', false], + // An existing exception has nothing of its own to delete "the rest of the series" + // from - only "this occurrence" (the exception itself) applies here, regardless + // of viewer role. + [{ isRecurringInstance: true, isEditingExceptionInstance: true }, 'occurrence', true], + [{ isRecurringInstance: true, isEditingExceptionInstance: true }, 'future', false], + [{ isRecurringInstance: true, isEditingExceptionInstance: true }, 'series', false], + // The primary occurrence IS the whole series - deleting "just this occurrence" + // or "this and future" doesn't offer anything meaningfully different from + // deleting "the whole series" here (for the organizer; an attendee's own + // RSVP scope is unrelated and stays governed by isViewedByAttendee above). + [{ isRecurringInstance: true, isEditingBaseInstance: true }, 'occurrence', false], + [{ isRecurringInstance: true, isEditingBaseInstance: true }, 'future', false], + [{ isRecurringInstance: true, isEditingBaseInstance: true }, 'series', true], + // isEditingBaseInstance is purely position-based, so it can also be true for an + // exception that happens to sit at the primary occurrence's own position - the + // exception check must stay the sole authority there, not this one, so + // "occurrence" (the only scope an exception allows) must not get blocked too. + [{ isRecurringInstance: true, isEditingExceptionInstance: true, isEditingBaseInstance: true }, 'occurrence', true], + [{ isRecurringInstance: true, isEditingExceptionInstance: true, isEditingBaseInstance: true }, 'series', false], + ])('restricts deletion by availability, recurrence, and attendee state', (overrides, scope, expected) => { + const vm = { + calendarObject: { existsOnServer: true }, + isReadOnly: false, + isLoading: false, + isRecurringInstance: false, + isEditingExceptionInstance: false, + isEditingBaseInstance: false, + isViewedByAttendee: false, + ...overrides, + } + expect(EditorMixin.methods.canDelete.call(vm, scope)).toBe(expected) + }) + }) + + describe('delete', () => { + it('does not execute a disallowed deletion mode', async () => { + const deleteCalendarObjectInstance = vi.fn() + const vm = { + calendarObject: {}, + canDelete: vi.fn().mockReturnValue(false), + calendarObjectInstanceStore: { deleteCalendarObjectInstance }, + isLoading: false, + } + + await EditorMixin.methods.delete.call(vm, 'occurrence') + + expect(deleteCalendarObjectInstance).not.toHaveBeenCalled() + expect(vm.isLoading).toBe(false) + }) + + it('executes an allowed deletion mode', async () => { + const deleteCalendarObjectInstance = vi.fn().mockResolvedValue() + const vm = { + calendarObject: {}, + canDelete: vi.fn().mockReturnValue(true), + calendarObjectInstanceStore: { deleteCalendarObjectInstance }, + isLoading: false, + } + + await EditorMixin.methods.delete.call(vm, 'series') + + expect(deleteCalendarObjectInstance).toHaveBeenCalledWith({ scope: 'series' }) + expect(vm.isLoading).toBe(false) + }) + }) + + describe('canUpdate', () => { + it.each([ + [{ calendarObject: null }, 'occurrence', false], + [{ calendarObject: { existsOnServer: false } }, 'occurrence', false], + [{ isReadOnly: true }, 'occurrence', false], + [{ isLoading: true }, 'occurrence', false], + [{ isNew: true }, 'occurrence', true], + [{ isNew: true }, 'series', false], + [{ requiresFutureUpdate: true }, 'occurrence', false], + [{ requiresFutureUpdate: true }, 'future', true], + [{ isRecurringInstance: false }, 'occurrence', true], + [{ isRecurringInstance: false }, 'series', false], + [{ isRecurringInstance: true }, 'occurrence', true], + [{ isRecurringInstance: true }, 'future', true], + [{ isRecurringInstance: true }, 'series', true], + [{ isRecurringInstance: true, isViewedByAttendee: true }, 'occurrence', false], + [{ isRecurringInstance: true, isViewedByAttendee: true }, 'future', false], + [{ isRecurringInstance: true, isViewedByAttendee: true }, 'series', true], + [{ isRecurringInstance: true, isEditingExceptionInstance: true, isViewedByAttendee: true }, 'occurrence', true], + [{ isRecurringInstance: true, isEditingExceptionInstance: true, isViewedByAttendee: true }, 'future', false], + // An existing exception never carries its own RRULE/RDATE/EXDATE, so neither + // "series" nor "future" (which also needs a recurrence rule to split off of) + // is offered while editing one - regardless of viewer role. + [{ isRecurringInstance: true, isEditingExceptionInstance: true }, 'occurrence', true], + [{ isRecurringInstance: true, isEditingExceptionInstance: true }, 'future', false], + [{ isRecurringInstance: true, isEditingExceptionInstance: true }, 'series', false], + [{ isRecurringInstance: true, isEditingExceptionInstance: true, isViewedByAttendee: true }, 'series', false], + // The primary occurrence IS the whole series - "this occurrence" and "this and + // future" aren't offered there, only "series" (for the organizer; an attendee's + // own RSVP scope is unrelated and stays governed by isViewedByAttendee above). + [{ isRecurringInstance: true, isEditingBaseInstance: true }, 'occurrence', false], + [{ isRecurringInstance: true, isEditingBaseInstance: true }, 'future', false], + [{ isRecurringInstance: true, isEditingBaseInstance: true }, 'series', true], + // isEditingBaseInstance is purely position-based, so it can also be true for an + // exception that happens to sit at the primary occurrence's own position - the + // exception check must stay the sole authority there, not this one, so + // "occurrence" (the only scope an exception allows) must not get blocked too. + [{ isRecurringInstance: true, isEditingExceptionInstance: true, isEditingBaseInstance: true }, 'occurrence', true], + [{ isRecurringInstance: true, isEditingExceptionInstance: true, isEditingBaseInstance: true }, 'series', false], + ])('restricts updates by availability, recurrence, and attendee state', (overrides, scope, expected) => { + const vm = { + calendarObject: { existsOnServer: true }, + isReadOnly: false, + isLoading: false, + isNew: false, + requiresFutureUpdate: false, + isRecurringInstance: false, + isEditingExceptionInstance: false, + isEditingBaseInstance: false, + isViewedByAttendee: false, + ...overrides, + } + expect(EditorMixin.methods.canUpdate.call(vm, scope)).toBe(expected) + }) + }) + + describe('requireFutureUpdate', () => { + it('marks future updates as required', () => { + const vm = { requiresFutureUpdate: false } + + EditorMixin.methods.requireFutureUpdate.call(vm) + + expect(vm.requiresFutureUpdate).toBe(true) + }) + }) + + describe('created', () => { + it('marks a new event as its own master item', async () => { + const vm = { + isWidget: false, + isLoading: true, + isEditingBaseInstance : false, + calendarId: null, + $route: { name: 'NewFullView', params: { allDay: '0', dtstart: '1000', dtend: '2000' } }, + settingsStore: { getResolvedTimezone: 'UTC' }, + calendarObjectInstanceStore: { + getCalendarObjectInstanceForNewEvent: vi.fn().mockResolvedValue(), + }, + loadingCalendars: vi.fn().mockResolvedValue(), + addDelegatorAsAttendeeIfNeeded: vi.fn(), + calendarObject: { calendarId: 'calendar-1' }, + selectedCalendar: {}, + } + + await EditorMixin.created.call(vm) + + // Without this, a recurrence-rule change on a brand new event is + // wrongly treated as requiring a future-only update, which a new + // event can never satisfy, silently blocking the save. + expect(vm.isEditingBaseInstance ).toBe(true) + }) + }) + + describe('save', () => { + it('does not execute a disallowed update scope', async () => { + const saveCalendarObjectInstance = vi.fn() + const vm = { + calendarObject: {}, + requiresFutureUpdate: false, + canUpdate: vi.fn().mockReturnValue(false), + calendarObjectInstanceStore: { saveCalendarObjectInstance }, + isLoading: false, + isSaving: false, + } + + await EditorMixin.methods.save.call(vm, 'occurrence') + + expect(saveCalendarObjectInstance).not.toHaveBeenCalled() + expect(vm.isLoading).toBe(false) + expect(vm.isSaving).toBe(false) + }) + + it('executes an allowed update scope', async () => { + const saveCalendarObjectInstance = vi.fn().mockResolvedValue() + const vm = { + calendarObject: {}, + calendarId: 'calendar-1', + requiresFutureUpdate: false, + canUpdate: vi.fn().mockReturnValue(true), + calendarObjectInstanceStore: { saveCalendarObjectInstance }, + isLoading: false, + isSaving: false, + } + + await EditorMixin.methods.save.call(vm, 'series') + + expect(saveCalendarObjectInstance).toHaveBeenCalledWith({ + scope: 'series', + calendarId: 'calendar-1', + }) + expect(vm.isLoading).toBe(false) + expect(vm.isSaving).toBe(false) + }) + }) + describe('duplicateEvent', () => { it('does nothing when duplication is not allowed in the current view (e.g. public/embedded/widget)', async () => { const duplicateCalendarObjectInstance = vi.fn() diff --git a/tests/javascript/unit/models/event.test.js b/tests/javascript/unit/models/event.test.js index f2f830c3ee..4fa5e65506 100644 --- a/tests/javascript/unit/models/event.test.js +++ b/tests/javascript/unit/models/event.test.js @@ -55,7 +55,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: false, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -98,7 +97,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: false, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -149,7 +147,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -209,7 +206,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [ 'ATTENDEE1', @@ -283,7 +279,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -341,7 +336,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -398,7 +392,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -458,7 +451,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -515,7 +507,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -569,7 +560,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -623,7 +613,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: true, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -681,7 +670,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: false, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: true, attendees: [], organizer: null, @@ -738,7 +726,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: false, isRecurrenceException: true, - forceThisAndAllFuture: false, canCreateRecurrenceException: false, attendees: [], organizer: null, @@ -793,7 +780,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: true, isMasterItem: false, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: true, attendees: [], organizer: null, @@ -851,7 +837,6 @@ describe('Test suite: Event model (models/event.js)', () => { hasMultipleRRules: false, isMasterItem: false, isRecurrenceException: false, - forceThisAndAllFuture: false, canCreateRecurrenceException: true, attendees: [], organizer: null, diff --git a/tests/javascript/unit/store/calendarObjectInstance.test.ts b/tests/javascript/unit/store/calendarObjectInstance.test.ts index 33b22e8d1f..69c6ddfb89 100644 --- a/tests/javascript/unit/store/calendarObjectInstance.test.ts +++ b/tests/javascript/unit/store/calendarObjectInstance.test.ts @@ -2,22 +2,97 @@ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { createEvent, DateTimeValue, getParserManager } from '@nextcloud/calendar-js' +import { showWarning } from '@nextcloud/dialogs' +import { translate } from '@nextcloud/l10n' import { createPinia, setActivePinia } from 'pinia' import { describe, expect, it, vi } from 'vitest' +import { markRaw } from 'vue' import { mapAlarmComponentToAlarmObject } from '@/models/alarm.js' import { copyCalendarObjectInstanceIntoEventComponent, mapEventComponentToEventObject } from '@/models/event.js' +import { updateRoomParticipantsFromEvent } from '@/services/talkService' +import getTimezoneManager from '@/services/timezoneDataProviderService.js' import useCalendarObjectInstanceStore from '@/store/calendarObjectInstance.js' import useCalendarObjectsStore from '@/store/calendarObjects.js' -import { getObjectAtRecurrenceId } from '@/utils/calendarObject.js' +import { getObjectAtRecurrenceId, isBaseOccurrence } from '@/utils/calendarObject.js' +import logger from '@/utils/logger.js' vi.mock('@/models/alarm.js') vi.mock('@/models/event.js') +vi.mock('@/services/talkService') vi.mock('@/utils/calendarObject.js') +vi.mock('@nextcloud/dialogs') +vi.mock('@nextcloud/l10n') const mockedMapAlarmComponentToAlarmObject = vi.mocked(mapAlarmComponentToAlarmObject) const mockedCopyCalendarObjectInstanceIntoEventComponent = vi.mocked(copyCalendarObjectInstanceIntoEventComponent) const mockedMapEventComponentToEventObject = vi.mocked(mapEventComponentToEventObject) const mockedGetObjectAtRecurrenceId = vi.mocked(getObjectAtRecurrenceId) +const mockedisBaseOccurrence = vi.mocked(isBaseOccurrence) + +/** + * Builds a minimal fake DateTimeValue-like object, just enough for the + * comparison/arithmetic the store performs when deciding whether an + * occurrence's date/time was changed, plus the date-component getters + * getDateFromDateTimeValue() reads when reverting a discarded change. + * + * @param time Point in time, treated as unix seconds (UTC) + */ +function fakeDateTime(time: number) { + return { + time, + timezoneId: undefined as string | undefined, + get year() { + return new Date(this.time * 1000).getFullYear() + }, + get month() { + return new Date(this.time * 1000).getMonth() + 1 + }, + get day() { + return new Date(this.time * 1000).getDate() + }, + get hour() { + return new Date(this.time * 1000).getHours() + }, + get minute() { + return new Date(this.time * 1000).getMinutes() + }, + compare(other: { time: number }) { + if (this.time === other.time) { + return 0 + } + return this.time < other.time ? -1 : 1 + }, + clone() { + return fakeDateTime(this.time) + }, + addDuration(duration: { seconds: number }) { + this.time += duration.seconds + }, + subtractDateWithTimezone(other: { time: number }) { + return fakeDuration(this.time - other.time) + }, + } +} + +/** + * Builds a minimal fake DurationValue-like object, just enough for the + * length comparison the store performs when deciding whether an + * occurrence's duration was changed. + * + * @param seconds Length of the duration, in arbitrary units + */ +function fakeDuration(seconds: number) { + return { + seconds, + compare(other: { seconds: number }) { + if (this.seconds === other.seconds) { + return 0 + } + return this.seconds < other.seconds ? -1 : 1 + }, + } +} describe('store/calendarObjectInstance test suite', () => { beforeEach(() => { @@ -27,6 +102,10 @@ describe('store/calendarObjectInstance test suite', () => { mockedCopyCalendarObjectInstanceIntoEventComponent.mockReset() mockedMapEventComponentToEventObject.mockReset().mockReturnValue({ eventComponent: {} }) mockedGetObjectAtRecurrenceId.mockReset().mockReturnValue({}) + mockedisBaseOccurrence.mockReset() + vi.mocked(showWarning).mockClear() + vi.mocked(translate).mockClear().mockReturnValue('translated warning') + vi.mocked(updateRoomParticipantsFromEvent).mockClear() }) describe('duplicateCalendarObjectInstance', () => { @@ -58,9 +137,7 @@ describe('store/calendarObjectInstance test suite', () => { await store.duplicateCalendarObjectInstance({ calendarId: 'writable-calendar' }) - expect(calendarObjectsStore.createNewEvent).toHaveBeenCalledWith( - expect.objectContaining({ calendarId: 'writable-calendar' }), - ) + expect(calendarObjectsStore.createNewEvent).toHaveBeenCalledWith(expect.objectContaining({ calendarId: 'writable-calendar' })) }) it('marks the duplicated event as a new, unsaved calendar-object', async () => { @@ -138,4 +215,970 @@ describe('store/calendarObjectInstance test suite', () => { expect(calendarObjectInstance.alarms).not.toContain(alarm) }) }) + + describe('saveAttendeeParticipationResponse', () => { + it('updates the recurring master when responding to a generated occurrence', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const masterAttendee = { + email: 'attendee@example.com', + participationStatus: 'NEEDS-ACTION', + } + const masterComponent = { + name: 'VEVENT', + hasProperty: vi.fn().mockReturnValue(false), + getAttendeeIterator: vi.fn().mockReturnValue([masterAttendee]), + } + const occurrenceAttendee = { + email: 'ATTENDEE@example.com', + participationStatus: 'NEEDS-ACTION', + } + const eventComponent = { + name: 'VEVENT', + isRecurrenceException: vi.fn().mockReturnValue(false), + } + const attendee = { + attendeeProperty: occurrenceAttendee, + participationStatus: 'NEEDS-ACTION', + } + const calendarObject = { + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([masterComponent]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + + await store.saveAttendeeParticipationResponse({ + attendee, + participationStatus: 'ACCEPTED', + }) + + expect(masterAttendee.participationStatus).toBe('ACCEPTED') + expect(occurrenceAttendee.participationStatus).toBe('NEEDS-ACTION') + expect(attendee.participationStatus).toBe('ACCEPTED') + expect(calendarObjectsStore.updateCalendarObject).toHaveBeenCalledWith({ calendarObject }) + }) + + it('updates an existing recurrence exception without changing the master', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const masterAttendee = { + email: 'attendee@example.com', + participationStatus: 'ACCEPTED', + } + const exceptionAttendee = { + email: 'attendee@example.com', + participationStatus: 'NEEDS-ACTION', + } + const eventComponent = { + name: 'VEVENT', + isRecurrenceException: vi.fn().mockReturnValue(true), + } + const attendee = { + attendeeProperty: exceptionAttendee, + participationStatus: 'NEEDS-ACTION', + } + const calendarObject = { + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([{ + name: 'VEVENT', + getAttendeeIterator: vi.fn().mockReturnValue([masterAttendee]), + }]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + + await store.saveAttendeeParticipationResponse({ + attendee, + participationStatus: 'DECLINED', + }) + + expect(exceptionAttendee.participationStatus).toBe('DECLINED') + expect(masterAttendee.participationStatus).toBe('ACCEPTED') + expect(attendee.participationStatus).toBe('DECLINED') + expect(calendarObject.calendarComponent.getComponentIterator).not.toHaveBeenCalled() + expect(calendarObjectsStore.updateCalendarObject).toHaveBeenCalledWith({ calendarObject }) + }) + }) + + describe('saveCalendarObjectInstance', () => { + /** + * Whether eventComponent is the primary occurrence is decided by the mocked + * isBaseOccurrence() (configured per test below) - it has its + * own dedicated, real-calendar-js-backed tests in utils/calendarObject.test.js, + * including the DTSTART-misalignment edge case. + * + * @param baseStart Base component's own DTSTART + * @param baseEnd Base component's own DTEND + */ + function setUpBaseComponent(baseStart: number, baseEnd: number) { + const baseProperty = { + name: 'SUMMARY', + } + return { + name: 'VEVENT', + hasProperty: vi.fn().mockReturnValue(false), + getPropertyIterator: vi.fn().mockReturnValue([baseProperty]), + deleteAllProperties: vi.fn(), + addProperty: vi.fn(), + deleteAllComponents: vi.fn(), + addComponent: vi.fn(), + startDate: fakeDateTime(baseStart), + endDate: fakeDateTime(baseEnd), + recurrenceManager: { + // Overridden per-test via mockReturnValue() where the non-primary-occurrence + // branch needs to look up the actual, unedited occurrence. + getOccurrenceAtExactly: vi.fn(), + }, + } + } + + /** + * @param originalRecurrenceId The occurrence's original (pre-edit) recurrence-id, or null when not forked + * @param start The occurrence's current (possibly edited) start time + * @param end The occurrence's current (possibly edited) end time + */ + function setUpEventComponent(originalRecurrenceId: number | null, start: number, end: number) { + const exceptionPropertyClone = {} + const exceptionProperty = { + name: 'SUMMARY', + clone: vi.fn().mockReturnValue(exceptionPropertyClone), + } + return { + name: 'VEVENT', + primaryItem: {}, + isDirty: vi.fn().mockReturnValue(true), + isPartOfRecurrenceSet: vi.fn().mockReturnValue(true), + isRecurrenceException: vi.fn().mockReturnValue(false), + isAllDay: vi.fn().mockReturnValue(false), + // Deliberately left undefined by default (only given a real mock where a + // test needs it) - some tests rely on these being unmocked so they throw + // loudly if an early-return guard is ever bypassed unexpectedly. + canCreateRecurrenceExceptions: undefined as unknown as (() => boolean) | undefined, + createRecurrenceException: undefined as unknown as ((thisAndAllFuture: boolean) => [unknown, unknown]) | undefined, + getPropertyIterator: vi.fn().mockReturnValue([exceptionProperty]), + getAlarmIterator: vi.fn().mockReturnValue([]), + resetDirty: vi.fn(), + originalRecurrenceId: originalRecurrenceId === null ? null : fakeDateTime(originalRecurrenceId), + startDate: fakeDateTime(start), + endDate: fakeDateTime(end), + } + } + + it('updates the recurring base component when saving the series from an unmoved exception', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const baseComponent = setUpBaseComponent(1000, 2000) + // Same recurrence-id it was forked at, and the date/time weren't touched + const exceptionComponent = setUpEventComponent(5000, 5000, 6000) + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([baseComponent, exceptionComponent]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent: exceptionComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + mockedisBaseOccurrence.mockReturnValue(false) + // The real, unedited occurrence - same position/length as the (unchanged) exception + baseComponent.recurrenceManager.getOccurrenceAtExactly.mockReturnValue({ + startDate: fakeDateTime(5000), + endDate: fakeDateTime(6000), + isAllDay: vi.fn().mockReturnValue(false), + }) + + await store.saveCalendarObjectInstance({ + scope: 'series', + calendarId: 'calendar-1', + }) + + expect(baseComponent.deleteAllProperties).toHaveBeenCalledWith('SUMMARY') + expect(baseComponent.addProperty).toHaveBeenCalledWith(expect.anything()) + expect(baseComponent.startDate.time).toBe(1000) + expect(baseComponent.endDate.time).toBe(2000) + expect(showWarning).not.toHaveBeenCalled() + expect(calendarObjectsStore.updateCalendarObject).toHaveBeenCalledWith({ calendarObject }) + expect(exceptionComponent.resetDirty).toHaveBeenCalled() + expect(updateRoomParticipantsFromEvent).toHaveBeenCalledWith(exceptionComponent) + }) + + it('copies a property onto the base component even when the base component never had it before', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + // The base component has no LOCATION property at all yet + const baseComponent = setUpBaseComponent(1000, 2000) + const locationPropertyClone = { marker: 'location-clone' } + const locationProperty = { + name: 'LOCATION', + clone: vi.fn().mockReturnValue(locationPropertyClone), + } + const primaryOccurrence = setUpEventComponent(1000, 1000, 2000) + // The user just added a location for the first time + primaryOccurrence.getPropertyIterator = vi.fn().mockReturnValue([...primaryOccurrence.getPropertyIterator(), locationProperty]) + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([baseComponent, primaryOccurrence]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent: primaryOccurrence } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + mockedisBaseOccurrence.mockReturnValue(true) + + await store.saveCalendarObjectInstance({ + scope: 'series', + calendarId: 'calendar-1', + }) + + expect(baseComponent.addProperty).toHaveBeenCalledWith(locationPropertyClone) + }) + + it('resets the dirty state of the throwaway fork after a successful series save, so closing the editor does not prompt to discard changes', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const baseComponent = setUpBaseComponent(1000, 2000) + const primaryOccurrence = setUpEventComponent(1000, 1500, 2500) + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([baseComponent, primaryOccurrence]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent: primaryOccurrence } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + mockedisBaseOccurrence.mockReturnValue(true) + + await store.saveCalendarObjectInstance({ + scope: 'series', + calendarId: 'calendar-1', + }) + + // eventComponent is a throwaway fork never added to the calendar-object's + // component tree, so calendarComponent.toICS() never undirtifies it on its own + expect(primaryOccurrence.resetDirty).toHaveBeenCalled() + }) + + it.each(['series', 'future'] as const)('refuses to save %s-wide changes while editing an existing recurrence exception', async (scope) => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const baseComponent = setUpBaseComponent(1000, 2000) + const exceptionOccurrence = setUpEventComponent(1000, 1000, 2000) + exceptionOccurrence.isRecurrenceException = vi.fn().mockReturnValue(true) + // "future" would otherwise reach createRecurrenceException(), which isn't + // stubbed here - if the early return is ever bypassed, this throws loudly + // instead of silently succeeding against an undefined method. + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([baseComponent, exceptionOccurrence]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent: exceptionOccurrence } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + vi.spyOn(calendarObjectsStore, 'moveCalendarObject').mockResolvedValue() + + // calendarId also differs from calendarObject.calendarId here, to pin down that + // the early return bails out of the whole action - including the calendar move + // below, which is otherwise unrelated to scope - not just the series-wide copy. + await store.saveCalendarObjectInstance({ + scope, + calendarId: 'calendar-2', + }) + + // The base component's RRULE/RDATE/EXDATE (and everything else) must be left + // completely untouched - an exception has none of these to (wrongly) copy over + expect(baseComponent.deleteAllProperties).not.toHaveBeenCalled() + expect(baseComponent.addProperty).not.toHaveBeenCalled() + expect(calendarObjectsStore.updateCalendarObject).not.toHaveBeenCalled() + expect(calendarObjectsStore.moveCalendarObject).not.toHaveBeenCalled() + }) + + it('allows resaving an already-existing recurrence exception with occurrence scope', async () => { + // A fork of an already-existing exception can't itself create a further + // exception: calendar-js's canCreateRecurrenceExceptions() is false here + // because neither the fork nor its primaryItem (the stored exception + // itself) carries an RRULE/RDATE - only the master does. So this is the + // one path that's supposed to keep working for an exception: the guard + // above must not trip (scope is 'occurrence'), and createRecurrenceException + // must not be called since it isn't needed - the fork already IS backed by + // the real, already-existing exception component. + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const exceptionComponent = setUpEventComponent(1000, 1500, 2500) + exceptionComponent.isRecurrenceException = vi.fn().mockReturnValue(true) + exceptionComponent.canCreateRecurrenceExceptions = vi.fn().mockReturnValue(false) + exceptionComponent.createRecurrenceException = vi.fn() + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent: exceptionComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + + await store.saveCalendarObjectInstance({ + scope: 'occurrence', + calendarId: 'calendar-1', + }) + + expect(exceptionComponent.createRecurrenceException).not.toHaveBeenCalled() + expect(calendarObjectsStore.updateCalendarObject).toHaveBeenCalledWith({ calendarObject }) + }) + + it.each(['occurrence', 'future'] as const)('refuses to save %s-wide changes while editing the primary occurrence of a series', async (scope) => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const baseComponent = setUpBaseComponent(1000, 2000) + const primaryOccurrence = setUpEventComponent(1000, 1000, 2000) + // "occurrence"/"future" would otherwise reach createRecurrenceException(), which + // isn't stubbed here - if the early return is ever bypassed, this throws loudly + // instead of silently succeeding against an undefined method. + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([baseComponent, primaryOccurrence]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent: primaryOccurrence } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + mockedisBaseOccurrence.mockReturnValue(true) + + await store.saveCalendarObjectInstance({ + scope, + calendarId: 'calendar-1', + }) + + expect(baseComponent.deleteAllProperties).not.toHaveBeenCalled() + expect(baseComponent.addProperty).not.toHaveBeenCalled() + expect(calendarObjectsStore.updateCalendarObject).not.toHaveBeenCalled() + }) + + it('does not consult isBaseOccurrence() for a brand new, never-forked event', async () => { + // Regression test: isBaseOccurrence() calls isPartOfRecurrenceSet(), which + // needs a recurrence-manager/master item a brand new event doesn't have yet + // - calling it unconditionally here crashed real, fresh event creation. + // The primary-occurrence guard must only run for forked items (primaryItem + // !== null), exactly like the canCreateRecurrenceExceptions() check right + // below it. mockedisBaseOccurrence would normally refuse this save if + // consulted - it isn't, because isForkedItem is false here. + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const newEventComponent = setUpEventComponent(null, 1000, 2000) + newEventComponent.primaryItem = null + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([]), + }, + existsOnServer: false, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent: newEventComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + mockedisBaseOccurrence.mockReturnValue(true) + + await store.saveCalendarObjectInstance({ + scope: 'occurrence', + calendarId: 'calendar-1', + }) + + expect(mockedisBaseOccurrence).not.toHaveBeenCalled() + expect(calendarObjectsStore.updateCalendarObject).toHaveBeenCalledWith({ calendarObject }) + }) + + it('applies the date/time change when editing the primary occurrence of the series', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const baseComponent = setUpBaseComponent(1000, 2000) + // Forked at the base component's own date, and then moved + const primaryOccurrence = setUpEventComponent(1000, 1500, 2500) + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([baseComponent, primaryOccurrence]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent: primaryOccurrence } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + mockedisBaseOccurrence.mockReturnValue(true) + + await store.saveCalendarObjectInstance({ + scope: 'series', + calendarId: 'calendar-1', + }) + + expect(baseComponent.startDate.time).toBe(1500) + expect(baseComponent.endDate.time).toBe(2500) + expect(showWarning).not.toHaveBeenCalled() + }) + + it('discards the date/time change and warns when a non-primary occurrence was moved', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + // Values are whole minutes (seconds always 0) since getDateFromDateTimeValue() + // always truncates seconds - keeps the reverted-Date assertions below exact. + const baseComponent = setUpBaseComponent(60_000, 120_000) + // Forked at 300_000, but then moved to 359_940/420_000 + const movedOccurrence = setUpEventComponent(300_000, 359_940, 420_000) + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([baseComponent, movedOccurrence]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent: movedOccurrence } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + mockedisBaseOccurrence.mockReturnValue(false) + // The real, unedited occurrence - what the editor should revert back to + baseComponent.recurrenceManager.getOccurrenceAtExactly.mockReturnValue({ startDate: fakeDateTime(300_000), endDate: fakeDateTime(360_000) }) + + await store.saveCalendarObjectInstance({ + scope: 'series', + calendarId: 'calendar-1', + }) + + expect(baseComponent.startDate.time).toBe(60_000) + expect(baseComponent.endDate.time).toBe(120_000) + expect(showWarning).toHaveBeenCalledTimes(1) + expect(showWarning).toHaveBeenCalledWith('translated warning') + + // The editor itself must also stop showing the discarded change - + // otherwise it displays a time that was never actually saved, while + // the calendar grid (reading the real, unchanged data) shows the truth. + expect(movedOccurrence.startDate.time).toBe(300_000) + expect(movedOccurrence.endDate.time).toBe(360_000) + expect(store.calendarObjectInstance.startDate).toStrictEqual(new Date(300_000 * 1000)) + expect(store.calendarObjectInstance.endDate).toStrictEqual(new Date(360_000 * 1000)) + }) + + it('looks up an all-day occurrence by its real DateTimeValue, not a lossy JS-Date round-trip', async () => { + // Regression test for a real bug: the store used to look up the unedited + // occurrence via getObjectAtRecurrenceId(calendarObject, originalRecurrenceId.jsDate), + // which converts the DateTimeValue to a JS Date and back. That round-trip + // silently drops the isDate (all-day) flag (DateTimeValue.fromJSDate() defaults + // isDate to false), so the reconstructed lookup no longer matches the real + // all-day occurrence's own recurrence-id and returns null - crashing on + // originalOccurrence.startDate. Only a real DateTimeValue (mocks can't fake + // isDate semantics) can catch this, hence the real, unmocked calendar-js here. + // + // The round trip only loses information when the local UTC offset isn't + // exactly zero (an isDate value's .jsDate is local midnight; reading it + // back via UTC getters only recovers the same instant when offset === 0). + // Pin a fixed, known non-UTC zone here so this stays deterministic + // regardless of the host machine's own timezone (this test previously + // passed or failed purely based on whatever TZ happened to be ambient). + const originalTz = process.env.TZ + process.env.TZ = 'America/New_York' + try { + getTimezoneManager() + + const ics = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Nextcloud//calendar-js tests//EN', + 'BEGIN:VEVENT', + 'UID:all-day-series-test', + 'DTSTART;VALUE=DATE:20260907', + 'DTEND;VALUE=DATE:20260908', + 'DTSTAMP:20260901T000000Z', + 'SUMMARY:All-day recurring test', + 'RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n') + + const parser = getParserManager().getParserForFileType('text/calendar') + parser.parse(ics) + const calendarComponent = parser.getItemIterator().next().value + + let baseComponent = null + for (const component of calendarComponent.getComponentIterator()) { + if (component.name === 'VEVENT' && !component.hasProperty('RECURRENCE-ID')) { + baseComponent = component + } + } + const rangeEnd = baseComponent.startDate.clone() + rangeEnd.year += 1 + const secondOccurrence = baseComponent.recurrenceManager.getAllOccurrencesBetween(baseComponent.startDate, rangeEnd)[1] + const secondOccurrenceRecurrenceId = secondOccurrence.getReferenceRecurrenceId() + expect(secondOccurrenceRecurrenceId.isDate).toBe(true) + + // What the store now does: pass the DateTimeValue straight through, no JS-Date round-trip + const viaDirectLookup = baseComponent.recurrenceManager.getOccurrenceAtExactly(secondOccurrenceRecurrenceId) + expect(viaDirectLookup).not.toBeNull() + expect(viaDirectLookup.startDate.compare(secondOccurrenceRecurrenceId)).toBe(0) + + // What the store used to do: DateTimeValue -> JS Date -> DateTimeValue, losing isDate + const jsDateRoundTripped = DateTimeValue.fromJSDate(secondOccurrenceRecurrenceId.jsDate, true) + expect(jsDateRoundTripped.isDate).toBe(false) + expect(baseComponent.recurrenceManager.getOccurrenceAtExactly(jsDateRoundTripped)).toBeNull() + } finally { + process.env.TZ = originalTz + } + }) + + it('does not crash saving a brand new, non-recurring event (real calendar-js)', async () => { + // Regression test: the primary-occurrence/exception guards must live inside + // their own scope-specific if-blocks, not run unconditionally for every save - + // isBaseOccurrence() calls eventComponent.isPartOfRecurrenceSet(), which is only + // meaningful once a recurrence-manager and master item exist. Calling it up front + // for a brand new event's first save previously broke saving new events entirely. + const start = DateTimeValue.fromJSDate(new Date('2026-09-07T10:00:00Z'), true) + const end = DateTimeValue.fromJSDate(new Date('2026-09-07T11:00:00Z'), true) + const calendarComponent = createEvent(start, end) + const eventComponent = calendarComponent.getVObjectIterator().next().value + eventComponent.updatePropertyWithValue('SUMMARY', 'New event') + eventComponent.markDirty() + + const calendarObject = { calendarComponent, calendarId: 'personal', existsOnServer: false } + + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + + await expect(store.saveCalendarObjectInstance({ + scope: 'occurrence', + calendarId: 'personal', + })).resolves.not.toThrow() + + expect(calendarObjectsStore.updateCalendarObject).toHaveBeenCalledWith({ calendarObject }) + }) + + it('moves the calendar-object without saving when the event is not dirty', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const eventComponent = setUpEventComponent(1000, 1000, 2000) + eventComponent.isDirty = vi.fn().mockReturnValue(false) + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + vi.spyOn(calendarObjectsStore, 'moveCalendarObject').mockResolvedValue() + + await store.saveCalendarObjectInstance({ + scope: 'occurrence', + calendarId: 'calendar-2', + }) + + expect(calendarObjectsStore.moveCalendarObject).toHaveBeenCalledWith({ + calendarObject, + newCalendarId: 'calendar-2', + }) + expect(calendarObjectsStore.updateCalendarObject).not.toHaveBeenCalled() + }) + + it('does nothing when series scope is requested for a non-recurring event', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const eventComponent = setUpEventComponent(null, 1000, 2000) + eventComponent.isPartOfRecurrenceSet = vi.fn().mockReturnValue(false) + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + vi.spyOn(calendarObjectsStore, 'moveCalendarObject').mockResolvedValue() + + await store.saveCalendarObjectInstance({ + scope: 'series', + calendarId: 'calendar-1', + }) + + expect(calendarObjectsStore.updateCalendarObject).not.toHaveBeenCalled() + expect(calendarObjectsStore.moveCalendarObject).not.toHaveBeenCalled() + expect(eventComponent.resetDirty).not.toHaveBeenCalled() + }) + + it('logs an error and does nothing further when the base/master component cannot be found', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const eventComponent = setUpEventComponent(1000, 1000, 2000) + // No component without RECURRENCE-ID exists in the tree at all + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + vi.spyOn(calendarObjectsStore, 'moveCalendarObject').mockResolvedValue() + vi.spyOn(logger, 'error').mockImplementation(() => {}) + + await expect(store.saveCalendarObjectInstance({ + scope: 'series', + calendarId: 'calendar-1', + })).resolves.not.toThrow() + + expect(logger.error).toHaveBeenCalledWith('Could not find master component to save series-wide changes to') + expect(calendarObjectsStore.updateCalendarObject).not.toHaveBeenCalled() + expect(calendarObjectsStore.moveCalendarObject).not.toHaveBeenCalled() + expect(eventComponent.resetDirty).not.toHaveBeenCalled() + }) + + it('discards the change and warns when only the timezone differs on a non-primary occurrence', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const baseComponent = setUpBaseComponent(1000, 2000) + const movedOccurrence = setUpEventComponent(5000, 5000, 6000) + // Same instant as the original occurrence, but a different timezoneId + movedOccurrence.startDate.timezoneId = 'Europe/Vienna' + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([baseComponent, movedOccurrence]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent: movedOccurrence } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + mockedisBaseOccurrence.mockReturnValue(false) + const originalStart = fakeDateTime(5000) + originalStart.timezoneId = 'UTC' + baseComponent.recurrenceManager.getOccurrenceAtExactly.mockReturnValue({ + startDate: originalStart, + endDate: fakeDateTime(6000), + isAllDay: vi.fn().mockReturnValue(false), + }) + + await store.saveCalendarObjectInstance({ + scope: 'series', + calendarId: 'calendar-1', + }) + + expect(showWarning).toHaveBeenCalledTimes(1) + expect(baseComponent.startDate.time).toBe(1000) + expect(baseComponent.endDate.time).toBe(2000) + }) + + it('discards the change and warns when only the all-day flag differs on a non-primary occurrence', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const baseComponent = setUpBaseComponent(1000, 2000) + const movedOccurrence = setUpEventComponent(5000, 5000, 6000) + // Same start/end instant and timezone as the original occurrence, but toggled to all-day + movedOccurrence.isAllDay = vi.fn().mockReturnValue(true) + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([baseComponent, movedOccurrence]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent: movedOccurrence } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + mockedisBaseOccurrence.mockReturnValue(false) + baseComponent.recurrenceManager.getOccurrenceAtExactly.mockReturnValue({ + startDate: fakeDateTime(5000), + endDate: fakeDateTime(6000), + isAllDay: vi.fn().mockReturnValue(false), + }) + + await store.saveCalendarObjectInstance({ + scope: 'series', + calendarId: 'calendar-1', + }) + + expect(showWarning).toHaveBeenCalledTimes(1) + expect(baseComponent.startDate.time).toBe(1000) + expect(baseComponent.endDate.time).toBe(2000) + }) + + it.each([ + { scope: 'occurrence' as const, thisAndAllFuture: false }, + { scope: 'future' as const, thisAndAllFuture: true }, + ])('calls createRecurrenceException with thisAndAllFuture=$thisAndAllFuture for scope "$scope"', async ({ scope, thisAndAllFuture }) => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const eventComponent = setUpEventComponent(1000, 1000, 2000) + // Same root on both sides - this test only cares about the createRecurrenceException call itself + eventComponent.canCreateRecurrenceExceptions = vi.fn().mockReturnValue(true) + eventComponent.createRecurrenceException = vi.fn().mockReturnValue([{ root: 'same-root' }, { root: 'same-root' }]) + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + + await store.saveCalendarObjectInstance({ + scope, + calendarId: 'calendar-1', + }) + + expect(eventComponent.createRecurrenceException).toHaveBeenCalledWith(thisAndAllFuture) + }) + + it('creates a new calendar-object from the fork when its root differs from the original (future truncate-and-fork)', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const eventComponent = setUpEventComponent(1000, 1000, 2000) + const originalItem = { root: 'master-root' } + const forkItem = { root: 'fork-root' } + eventComponent.canCreateRecurrenceExceptions = vi.fn().mockReturnValue(true) + eventComponent.createRecurrenceException = vi.fn().mockReturnValue([originalItem, forkItem]) + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + vi.spyOn(calendarObjectsStore, 'createCalendarObjectFromFork').mockResolvedValue() + + await store.saveCalendarObjectInstance({ + scope: 'future', + calendarId: 'calendar-1', + }) + + expect(calendarObjectsStore.createCalendarObjectFromFork).toHaveBeenCalledWith({ + eventComponent: forkItem, + calendarId: 'calendar-1', + }) + }) + + it('does not create a new calendar-object when the fork shares the original\'s root (in-place occurrence exception)', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const eventComponent = setUpEventComponent(1000, 1000, 2000) + const sharedRootItem = { root: 'shared-root' } + const otherSharedRootItem = { root: 'shared-root' } + eventComponent.canCreateRecurrenceExceptions = vi.fn().mockReturnValue(true) + eventComponent.createRecurrenceException = vi.fn().mockReturnValue([sharedRootItem, otherSharedRootItem]) + const calendarObject = { + calendarId: 'calendar-1', + calendarComponent: { + getComponentIterator: vi.fn().mockReturnValue([]), + }, + } + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + vi.spyOn(calendarObjectsStore, 'createCalendarObjectFromFork').mockResolvedValue() + + await store.saveCalendarObjectInstance({ + scope: 'occurrence', + calendarId: 'calendar-1', + }) + + expect(calendarObjectsStore.createCalendarObjectFromFork).not.toHaveBeenCalled() + }) + + it('creates a real recurrence-exception in the same document when saving an edited nth occurrence with occurrence scope (real calendar-js)', async () => { + const ics = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Nextcloud//calendar-js tests//EN', + 'BEGIN:VEVENT', + 'UID:nth-occurrence-create-test', + 'DTSTART:20260907T100000Z', + 'DTEND:20260907T110000Z', + 'DTSTAMP:20260901T000000Z', + 'SUMMARY:Original title', + 'RRULE:FREQ=WEEKLY;COUNT=5', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n') + + const parser = getParserManager().getParserForFileType('text/calendar') + parser.parse(ics) + const calendarComponent = parser.getItemIterator().next().value + + let masterComponent = null + for (const component of calendarComponent.getComponentIterator()) { + if (component.name === 'VEVENT' && !component.hasProperty('RECURRENCE-ID')) { + masterComponent = component + } + } + const rangeEnd = masterComponent.startDate.clone() + rangeEnd.year += 1 + // The 2nd occurrence - not the primary + const secondOccurrence = masterComponent.recurrenceManager.getAllOccurrencesBetween(masterComponent.startDate, rangeEnd)[1] + const secondOccurrenceRecurrenceId = secondOccurrence.getReferenceRecurrenceId() + + secondOccurrence.updatePropertyWithValue('SUMMARY', 'Edited title') + secondOccurrence.markDirty() + + const calendarObject = { calendarId: 'personal', calendarComponent } + + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent: markRaw(secondOccurrence) } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + vi.spyOn(calendarObjectsStore, 'createCalendarObjectFromFork').mockResolvedValue() + + await store.saveCalendarObjectInstance({ + scope: 'occurrence', + calendarId: 'personal', + }) + + // The master is untouched, and a brand new exception now lives in the same document + let exceptionComponent = null + let componentCount = 0 + for (const component of calendarComponent.getComponentIterator()) { + componentCount++ + if (component.hasProperty('RECURRENCE-ID')) { + exceptionComponent = component + } + } + expect(componentCount).toBe(2) + expect(exceptionComponent).not.toBeNull() + expect(exceptionComponent.getFirstPropertyFirstValue('SUMMARY')).toBe('Edited title') + expect(exceptionComponent.getFirstPropertyFirstValue('RECURRENCE-ID').compare(secondOccurrenceRecurrenceId)).toBe(0) + expect(calendarObjectsStore.createCalendarObjectFromFork).not.toHaveBeenCalled() + expect(calendarObjectsStore.updateCalendarObject).toHaveBeenCalledWith({ calendarObject }) + }) + }) + + describe('deleteCalendarObjectInstance', () => { + /** + * @param isPartOfRecurrenceSet Whether eventComponent belongs to a recurring series + */ + function setUpEventComponent(isPartOfRecurrenceSet: boolean) { + return { + isPartOfRecurrenceSet: vi.fn().mockReturnValue(isPartOfRecurrenceSet), + removeThisOccurrence: vi.fn().mockReturnValue(false), + } + } + + it('deletes the whole calendar-object for a non-recurring event', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const eventComponent = setUpEventComponent(false) + const calendarObject = {} + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'deleteCalendarObject').mockResolvedValue() + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + + await store.deleteCalendarObjectInstance({ scope: 'occurrence' }) + + expect(calendarObjectsStore.deleteCalendarObject).toHaveBeenCalledWith({ calendarObject }) + expect(eventComponent.removeThisOccurrence).not.toHaveBeenCalled() + expect(calendarObjectsStore.updateCalendarObject).not.toHaveBeenCalled() + }) + + it('deletes the whole calendar-object when scope is "series", even for a recurring event', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const eventComponent = setUpEventComponent(true) + const calendarObject = {} + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'deleteCalendarObject').mockResolvedValue() + + await store.deleteCalendarObjectInstance({ scope: 'series' }) + + expect(calendarObjectsStore.deleteCalendarObject).toHaveBeenCalledWith({ calendarObject }) + expect(eventComponent.removeThisOccurrence).not.toHaveBeenCalled() + }) + + it('removes only the single occurrence when deleting an existing recurrence exception', async () => { + // Regression test for a real bug: isRecurring() only checks whether the + // component itself carries RRULE/RDATE. A recurrence-exception's own VEVENT + // never does (only the master does), so isRecurring() was always false for + // an exception - which made the "singleton event" guard fire for + // scope: 'occurrence' too, deleting the entire calendar-object instead of + // just that occurrence. isPartOfRecurrenceSet() resolves through to the + // master and is unaffected by which component (master, generated occurrence, + // or exception) eventComponent actually is. + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const eventComponent = setUpEventComponent(true) + const calendarObject = {} + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'deleteCalendarObject').mockResolvedValue() + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + + await store.deleteCalendarObjectInstance({ scope: 'occurrence' }) + + expect(eventComponent.removeThisOccurrence).toHaveBeenCalledWith(false) + expect(calendarObjectsStore.updateCalendarObject).toHaveBeenCalledWith({ calendarObject }) + expect(calendarObjectsStore.deleteCalendarObject).not.toHaveBeenCalled() + }) + + it('passes thisAndAllFuture=true to removeThisOccurrence when scope is "future"', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const eventComponent = setUpEventComponent(true) + store.calendarObject = {} + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + + await store.deleteCalendarObjectInstance({ scope: 'future' }) + + expect(eventComponent.removeThisOccurrence).toHaveBeenCalledWith(true) + }) + + it('deletes the whole calendar-object when removing the last remaining occurrence empties the recurrence set', async () => { + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const eventComponent = setUpEventComponent(true) + eventComponent.removeThisOccurrence.mockReturnValue(true) + const calendarObject = {} + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'deleteCalendarObject').mockResolvedValue() + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + + await store.deleteCalendarObjectInstance({ scope: 'occurrence' }) + + expect(calendarObjectsStore.deleteCalendarObject).toHaveBeenCalledWith({ calendarObject }) + expect(calendarObjectsStore.updateCalendarObject).not.toHaveBeenCalled() + }) + + it.each(['occurrence', 'future'] as const)('refuses to delete %s scope from the primary occurrence of a series', async (scope) => { + // canDelete() in EditorMixin already restricts the primary occurrence to + // "series" only in the UI, mirroring canUpdate()'s rule for + // saveCalendarObjectInstance - this is the backend-side enforcement of + // that same rule, so a caller that bypasses the UI can't delete just one + // occurrence (or truncate the series) starting from the primary occurrence. + const store = useCalendarObjectInstanceStore() + const calendarObjectsStore = useCalendarObjectsStore() + const eventComponent = setUpEventComponent(true) + const calendarObject = {} + store.calendarObject = calendarObject + store.calendarObjectInstance = { eventComponent } + vi.spyOn(calendarObjectsStore, 'deleteCalendarObject').mockResolvedValue() + vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue() + mockedisBaseOccurrence.mockReturnValue(true) + + await store.deleteCalendarObjectInstance({ scope }) + + expect(eventComponent.removeThisOccurrence).not.toHaveBeenCalled() + expect(calendarObjectsStore.deleteCalendarObject).not.toHaveBeenCalled() + }) + }) }) diff --git a/tests/javascript/unit/utils/calendarObject.test.js b/tests/javascript/unit/utils/calendarObject.test.js new file mode 100644 index 0000000000..6b832c3294 --- /dev/null +++ b/tests/javascript/unit/utils/calendarObject.test.js @@ -0,0 +1,123 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getParserManager } from '@nextcloud/calendar-js' +import { describe, expect, it } from 'vitest' +import { isBaseOccurrence } from '@/utils/calendarObject.js' + +/** + * @param {string} ics Raw ICS text + * @return {object} calendarObject Fake calendar-object model wrapping the parsed calendarComponent + */ +function parseCalendarObject(ics) { + const parser = getParserManager().getParserForFileType('text/calendar') + parser.parse(ics) + const calendarComponent = parser.getItemIterator().next().value + return { calendarComponent } +} + +/** + * @param {object} calendarObject Calendar-object model + * @return {object} The master VEVENT component (without RECURRENCE-ID) + */ +function getBaseComponent(calendarObject) { + for (const component of calendarObject.calendarComponent.getComponentIterator()) { + if (component.name === 'VEVENT' && !component.hasProperty('RECURRENCE-ID')) { + return component + } + } + return null +} + +describe('utils/calendarObject isBaseOccurrence', () => { + it('returns true for the first occurrence of a simple weekly series', () => { + const calendarObject = parseCalendarObject([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Nextcloud Tests//calendar-js//EN', + 'BEGIN:VEVENT', + 'UID:primary-occurrence-test', + 'DTSTART:20260907T100000Z', + 'DTEND:20260907T110000Z', + 'DTSTAMP:20260901T000000Z', + 'SUMMARY:Test', + 'RRULE:FREQ=WEEKLY;COUNT=5', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n')) + const baseComponent = getBaseComponent(calendarObject) + + const firstOccurrence = baseComponent.recurrenceManager.getOccurrenceAtExactly(baseComponent.startDate) + + expect(isBaseOccurrence(calendarObject, firstOccurrence)).toBe(true) + }) + + it('returns false for a later occurrence of a simple weekly series', () => { + const calendarObject = parseCalendarObject([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Nextcloud Tests//calendar-js//EN', + 'BEGIN:VEVENT', + 'UID:primary-occurrence-test', + 'DTSTART:20260907T100000Z', + 'DTEND:20260907T110000Z', + 'DTSTAMP:20260901T000000Z', + 'SUMMARY:Test', + 'RRULE:FREQ=WEEKLY;COUNT=5', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n')) + const baseComponent = getBaseComponent(calendarObject) + const rangeEnd = baseComponent.startDate.clone() + rangeEnd.year += 1 + const secondOccurrence = baseComponent.recurrenceManager.getAllOccurrencesBetween(baseComponent.startDate, rangeEnd)[1] + + expect(isBaseOccurrence(calendarObject, secondOccurrence)).toBe(false) + }) + + it('returns true for the real first occurrence even when DTSTART does not match the RRULE (e.g. BYDAY excludes it)', () => { + // DTSTART is a Thursday, but the rule only ever generates Mondays - DTSTART + // itself is never a valid occurrence, so the real first occurrence is later. + const calendarObject = parseCalendarObject([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Nextcloud Tests//calendar-js//EN', + 'BEGIN:VEVENT', + 'UID:misaligned-dtstart-test', + 'DTSTART:20260903T100000Z', + 'DTEND:20260903T110000Z', + 'DTSTAMP:20260901T000000Z', + 'SUMMARY:Test', + 'RRULE:FREQ=WEEKLY;COUNT=5;BYDAY=MO', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n')) + const baseComponent = getBaseComponent(calendarObject) + + expect(baseComponent.recurrenceManager.getOccurrenceAtExactly(baseComponent.startDate)).toBeNull() + + const realFirstOccurrence = baseComponent.recurrenceManager.getClosestOccurrence(baseComponent.startDate) + + expect(isBaseOccurrence(calendarObject, realFirstOccurrence)).toBe(true) + }) + + it('returns false for a non-recurring event', () => { + const calendarObject = parseCalendarObject([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Nextcloud Tests//calendar-js//EN', + 'BEGIN:VEVENT', + 'UID:non-recurring-test', + 'DTSTART:20260907T100000Z', + 'DTEND:20260907T110000Z', + 'DTSTAMP:20260901T000000Z', + 'SUMMARY:Test', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n')) + const baseComponent = getBaseComponent(calendarObject) + + expect(isBaseOccurrence(calendarObject, baseComponent)).toBe(false) + }) +})