Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions src/mixins/EditorMixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -741,9 +741,6 @@ export default {
if (this.isViewedByAttendee) {
return scope === 'series' || (this.isEditingExceptionInstance && scope === 'occurrence')
}
if (!this.isEditingExceptionInstance && this.isEditingBaseInstance && scope !== 'series') {
return false
}

return ['occurrence', 'future', 'series'].includes(scope)
},
Expand Down Expand Up @@ -819,9 +816,6 @@ export default {
if (this.isViewedByAttendee) {
return scope === 'series' || (this.isEditingExceptionInstance && scope === 'occurrence')
}
if (!this.isEditingExceptionInstance && this.isEditingBaseInstance && scope !== 'series') {
return false
}

return ['occurrence', 'future', 'series'].includes(scope)
},
Expand Down
13 changes: 0 additions & 13 deletions src/store/calendarObjectInstance.js
Original file line number Diff line number Diff line change
Expand Up @@ -1555,12 +1555,6 @@ export default defineStore('calendarObjectInstance', {
logger.error('Only "this occurrence" can be updated while editing an existing recurrence exception')
return
}
// Do not permit "this occurrence"/"this and future" edits on the primary
if (isForkedItem && (scope === 'occurrence' || scope === 'future') && isBaseOccurrence(calendarObject, eventComponent)) {
logger.error('Only "series" can be updated while editing the primary occurrence of a series')
return
}

let original = null
let fork = null

Expand Down Expand Up @@ -1639,13 +1633,6 @@ export default defineStore('calendarObjectInstance', {
return
}

// Do not permit "this occurrence"/"this and future" deletes on the primary
// occurrence of a series - only "series" makes sense there
if ((scope === 'occurrence' || scope === 'future') && isBaseOccurrence(this.calendarObject, eventComponent)) {
logger.error('Only "series" can be deleted while editing the primary occurrence of a series')
return
}

// Recurring event - remove this occurrence or this and all future
const isRecurrenceSetEmpty = eventComponent.removeThisOccurrence(scope === 'future')
if (isRecurrenceSetEmpty) {
Expand Down
18 changes: 7 additions & 11 deletions tests/javascript/unit/mixins/EditorMixin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,10 @@ describe('mixins/EditorMixin test suite', () => {
[{ 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],
// Deleting the primary occurrence removes it from the recurrence set without
// necessarily deleting later occurrences.
[{ isRecurringInstance: true, isEditingBaseInstance: true }, 'occurrence', true],
[{ isRecurringInstance: true, isEditingBaseInstance: true }, 'future', true],
[{ 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
Expand Down Expand Up @@ -150,11 +148,9 @@ describe('mixins/EditorMixin test suite', () => {
[{ 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],
// The primary occurrence can also be edited independently by creating an exception.
[{ isRecurringInstance: true, isEditingBaseInstance: true }, 'occurrence', true],
[{ isRecurringInstance: true, isEditingBaseInstance: true }, 'future', true],
[{ 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
Expand Down
144 changes: 107 additions & 37 deletions tests/javascript/unit/store/calendarObjectInstance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* 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 { createEvent, DateTimeValue, DurationValue, getParserManager } from '@nextcloud/calendar-js'
import { showWarning } from '@nextcloud/dialogs'
import { translate } from '@nextcloud/l10n'
import { createPinia, setActivePinia } from 'pinia'
Expand Down Expand Up @@ -535,35 +535,6 @@ describe('store/calendarObjectInstance test suite', () => {
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
Expand Down Expand Up @@ -1060,6 +1031,55 @@ describe('store/calendarObjectInstance test suite', () => {
expect(calendarObjectsStore.updateCalendarObject).toHaveBeenCalledWith({ calendarObject })
})

it('creates a real recurrence-exception when saving an edited first occurrence with occurrence scope (real calendar-js)', async () => {
const ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Nextcloud//calendar-js tests//EN',
'BEGIN:VEVENT',
'UID:first-occurrence-create-test',
'DTSTART:20260907T100000Z',
'DTEND:20260907T110000Z',
'DTSTAMP:20260901T000000Z',
'SUMMARY:Original title',
'RRULE:FREQ=WEEKLY;COUNT=3',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n')

const parser = getParserManager().getParserForFileType('text/calendar')
parser.parse(ics)
const calendarComponent = parser.getItemIterator().next().value
const masterComponent = [...calendarComponent.getComponentIterator()][0]
const firstOccurrence = masterComponent.recurrenceManager.getOccurrenceAtExactly(masterComponent.startDate)
const firstOccurrenceRecurrenceId = firstOccurrence.getReferenceRecurrenceId()
firstOccurrence.updatePropertyWithValue('SUMMARY', 'Edited title')
firstOccurrence.markDirty()

const calendarObject = { calendarId: 'personal', calendarComponent }
const store = useCalendarObjectInstanceStore()
const calendarObjectsStore = useCalendarObjectsStore()
store.calendarObject = calendarObject
store.calendarObjectInstance = { eventComponent: markRaw(firstOccurrence) }
vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue()
vi.spyOn(calendarObjectsStore, 'createCalendarObjectFromFork').mockResolvedValue()
mockedisBaseOccurrence.mockReturnValue(true)

await store.saveCalendarObjectInstance({
scope: 'occurrence',
calendarId: 'personal',
})

const components = [...calendarComponent.getComponentIterator()]
const exceptionComponent = components.find((component) => component.hasProperty('RECURRENCE-ID'))
expect(masterComponent.title).toBe('Original title')
expect(exceptionComponent).toBeDefined()
expect(exceptionComponent.title).toBe('Edited title')
expect(exceptionComponent.getFirstPropertyFirstValue('RECURRENCE-ID').compare(firstOccurrenceRecurrenceId)).toBe(0)
expect(calendarObjectsStore.createCalendarObjectFromFork).not.toHaveBeenCalled()
expect(calendarObjectsStore.updateCalendarObject).toHaveBeenCalledWith({ calendarObject })
})

it('preserves the original RRULE when saving series scope from a non-primary occurrence (real calendar-js)', async () => {
// Regression test for a real bug: forkItem() adjusts a forked occurrence's
// own RRULE COUNT down to "occurrences remaining from this point" (needed
Expand Down Expand Up @@ -1219,12 +1239,7 @@ describe('store/calendarObjectInstance test suite', () => {
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.
it.each(['occurrence', 'future'] as const)('deletes %s scope from the primary occurrence of a series', async (scope) => {
const store = useCalendarObjectInstanceStore()
const calendarObjectsStore = useCalendarObjectsStore()
const eventComponent = setUpEventComponent(true)
Expand All @@ -1237,9 +1252,64 @@ describe('store/calendarObjectInstance test suite', () => {

await store.deleteCalendarObjectInstance({ scope })

expect(eventComponent.removeThisOccurrence).not.toHaveBeenCalled()
expect(eventComponent.removeThisOccurrence).toHaveBeenCalledWith(scope === 'future')
expect(calendarObjectsStore.updateCalendarObject).toHaveBeenCalledWith({ calendarObject })
expect(calendarObjectsStore.deleteCalendarObject).not.toHaveBeenCalled()
})

it.each([
{ scope: 'occurrence' as const, expectedEmpty: false },
{ scope: 'future' as const, expectedEmpty: true },
])('removes the first occurrence with real calendar-js for scope "$scope"', async ({ scope, expectedEmpty }) => {
const ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Nextcloud//calendar-js tests//EN',
'BEGIN:VEVENT',
'UID:first-occurrence-delete-test',
'DTSTART:20260907T100000Z',
'DTEND:20260907T110000Z',
'DTSTAMP:20260901T000000Z',
'SUMMARY:Recurring event',
'RRULE:FREQ=WEEKLY;COUNT=3',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n')

const parser = getParserManager().getParserForFileType('text/calendar')
parser.parse(ics)
const calendarComponent = parser.getItemIterator().next().value
const masterComponent = [...calendarComponent.getComponentIterator()][0]
const firstOccurrence = masterComponent.recurrenceManager.getOccurrenceAtExactly(masterComponent.startDate)
const rangeEnd = masterComponent.startDate.clone()
rangeEnd.addDuration(DurationValue.fromSeconds(14 * 24 * 60 * 60))
const firstOccurrenceRecurrenceId = firstOccurrence.getReferenceRecurrenceId()
const nextOccurrenceRecurrenceId = masterComponent.recurrenceManager.getAllOccurrencesBetween(masterComponent.startDate, rangeEnd)[1].getReferenceRecurrenceId()
const calendarObject = { calendarId: 'personal', calendarComponent }
const store = useCalendarObjectInstanceStore()
const calendarObjectsStore = useCalendarObjectsStore()
store.calendarObject = calendarObject
store.calendarObjectInstance = { eventComponent: markRaw(firstOccurrence) }
vi.spyOn(calendarObjectsStore, 'deleteCalendarObject').mockResolvedValue()
vi.spyOn(calendarObjectsStore, 'updateCalendarObject').mockResolvedValue()

await store.deleteCalendarObjectInstance({ scope })

const remainingOccurrences = masterComponent.recurrenceManager.getAllOccurrencesBetween(masterComponent.startDate, rangeEnd)
expect({
deleted: calendarObjectsStore.deleteCalendarObject.mock.calls.length > 0,
updated: calendarObjectsStore.updateCalendarObject.mock.calls.length > 0,
remainingCount: remainingOccurrences.length,
hasFirstOccurrence: remainingOccurrences.some((occurrence) => occurrence.getReferenceRecurrenceId().compare(firstOccurrenceRecurrenceId) === 0),
hasNextOccurrence: remainingOccurrences.some((occurrence) => occurrence.getReferenceRecurrenceId().compare(nextOccurrenceRecurrenceId) === 0),
}).toEqual({
deleted: expectedEmpty,
updated: !expectedEmpty,
remainingCount: expectedEmpty ? 0 : 2,
hasFirstOccurrence: false,
hasNextOccurrence: !expectedEmpty,
})
})
})

describe('removeAttendee', () => {
Expand Down
Loading