import { updateSeries } from '@/features/Events/api/updateSeries' import { deleteEvent, importEventFromFile, moveEvent, putEvent, searchEvent } from '@/features/Events/EventApi' import { CalendarEvent } from '@/features/Events/EventsTypes' import { calendarEventToJCal } from '@/features/Events/utils' import { clientConfig } from '@/features/User/oidcAuth' import { api } from '@/utils/apiUtils' clientConfig.url = 'https://example.com' jest.mock('@/utils/apiUtils') const day = new Date() const mockEvent = { uid: 'event1', title: 'Test Event', timezone: 'UTC', calId: '667037022b752d0026472254/cal1', URL: '/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics', start: day.toISOString(), end: day.toISOString(), status: 'PUBLIC', organizer: { cn: 'test', cal_address: 'test@test.com' }, attendee: [ { cn: 'test', cal_address: 'test@test.com', partstat: 'NEEDS-ACTION', rsvp: 'TRUE', role: 'REQ-PARTICIPANT', cutype: 'INDIVIDUAL' }, { cn: 'John', cal_address: 'john@test.com', partstat: 'NEEDS-ACTION', rsvp: 'TRUE', role: 'REQ-PARTICIPANT', cutype: 'INDIVIDUAL' } ] } as CalendarEvent describe('eventApi', () => { beforeEach(() => { jest.clearAllMocks() }) it('putEvent sends PUT request with JCal body', async () => { const mockResponse = { status: 201, url: '/dav/cals/test.ics' } ;(api as unknown as jest.Mock).mockReturnValue(mockResponse) const result = await putEvent(mockEvent) const expectedResult = calendarEventToJCal(mockEvent) expect(api).toHaveBeenCalledWith( 'dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics', expect.objectContaining({ method: 'PUT', headers: { 'content-type': 'text/calendar; charset=utf-8' }, body: JSON.stringify(expectedResult) }) ) expect(result).toBe(mockResponse) }) it('putEvent logs when status is 201', async () => { const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation() const mockResponse = { status: 201, url: '/dav/cals/test.ics' } ;(api as unknown as jest.Mock).mockReturnValue(mockResponse) await putEvent(mockEvent) expect(consoleInfoSpy).toHaveBeenCalledWith( 'Event created successfully:', '/dav/cals/test.ics' ) consoleInfoSpy.mockRestore() }) test('moveEvent sends MOVE request with destination header', async () => { const mockResponse = { status: 204 } ;(api as unknown as jest.Mock).mockReturnValue({ json: jest.fn().mockResolvedValue(mockResponse) }) const result = await moveEvent(mockEvent, 'newurl.ics') expect(api).toHaveBeenCalledWith( 'dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics', expect.objectContaining({ method: 'MOVE', headers: { destination: 'newurl.ics' } }) ) }) it('deleteEvent sends DELETE request and returns json response', async () => { const mockResponse = { ok: true } ;(api as unknown as jest.Mock).mockReturnValue({ json: jest.fn().mockResolvedValue(mockResponse) }) const result = await deleteEvent('/calendars/test.ics') expect(api).toHaveBeenCalledWith('dav/calendars/test.ics', { method: 'DELETE' }) }) it('import event file', async () => { const mockResponse = { status: 202 } ;(api.post as unknown as jest.Mock).mockReturnValue({ json: jest.fn().mockResolvedValue(mockResponse) }) await importEventFromFile('123456789', '/calendar/calLink.json') expect(api.post).toHaveBeenCalledWith('api/import', { body: JSON.stringify({ fileId: '123456789', target: '/calendar/calLink.json' }) }) }) test('putEvent handles byday field correctly', async () => { const mockResponse = { status: 201, url: '/dav/cals/test.ics' } ;(api as unknown as jest.Mock).mockReturnValue(mockResponse) const eventWithByday = { ...mockEvent, repetition: { freq: 'weekly', interval: 1, byday: ['MO', 'WE', 'FR'] } } await putEvent(eventWithByday) const expectedResult = calendarEventToJCal(eventWithByday) expect(api).toHaveBeenCalledWith( 'dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics', expect.objectContaining({ method: 'PUT', headers: { 'content-type': 'text/calendar; charset=utf-8' }, body: JSON.stringify(expectedResult) }) ) }) test('putEvent handles null byday field correctly', async () => { const mockResponse = { status: 201, url: '/dav/cals/test.ics' } ;(api as unknown as jest.Mock).mockReturnValue(mockResponse) const eventWithNullByday = { ...mockEvent, repetition: { freq: 'daily', interval: 1, byday: null } } await putEvent(eventWithNullByday) const expectedResult = calendarEventToJCal(eventWithNullByday) expect(api).toHaveBeenCalledWith( 'dav/calendars/667037022b752d0026472254/667037022b752d0026472254/cal1.ics', expect.objectContaining({ method: 'PUT', headers: { 'content-type': 'text/calendar; charset=utf-8' }, body: JSON.stringify(expectedResult) }) ) }) describe('searchEvent', () => { const mockFilters = { searchIn: ['user1/calendar1', 'user2/calendar2'], keywords: 'meeting', organizers: ['org@example.com'], attendees: ['part@example.com'] } it('should call API with correct parameters', async () => { const mockResponse = { _total_hits: 5, _embedded: { events: [] } } ;(api.post as jest.Mock).mockReturnValue({ json: jest.fn().mockResolvedValue(mockResponse) }) await searchEvent('test', mockFilters) expect(api.post).toHaveBeenCalledWith( 'calendar/api/events/search?limit=30&offset=0', { body: JSON.stringify({ query: 'meeting', calendars: [ { calendarId: 'calendar1', userId: 'user1' }, { calendarId: 'calendar2', userId: 'user2' } ], organizers: ['org@example.com'], attendees: ['part@example.com'] }) } ) }) it('should use query param when keywords is empty', async () => { const mockResponse = { _total_hits: 0, _embedded: { events: [] } } ;(api.post as jest.Mock).mockReturnValue({ json: jest.fn().mockResolvedValue(mockResponse) }) await searchEvent('fallback query', { ...mockFilters, keywords: '' }) expect(api.post).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ body: expect.stringContaining('"query":"fallback query"') }) ) }) it('should omit organizers when empty', async () => { const mockResponse = { _total_hits: 0, _embedded: { events: [] } } ;(api.post as jest.Mock).mockReturnValue({ json: jest.fn().mockResolvedValue(mockResponse) }) await searchEvent('test', { ...mockFilters, organizers: [] }) const callArgs = (api.post as jest.Mock).mock.calls[0][1] const body = JSON.parse(callArgs.body) expect(body.organizers).toBeUndefined() }) it('should omit participants when empty', async () => { const mockResponse = { _total_hits: 0, _embedded: { events: [] } } ;(api.post as jest.Mock).mockReturnValue({ json: jest.fn().mockResolvedValue(mockResponse) }) await searchEvent('test', { ...mockFilters, attendees: [] }) const callArgs = (api.post as jest.Mock).mock.calls[0][1] const body = JSON.parse(callArgs.body) expect(body.participants).toBeUndefined() }) }) describe('updateSeries', () => { const recurringEventICS = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Test//EN BEGIN:VEVENT UID:event1 DTSTART:20240201T100000Z DTEND:20240201T110000Z RRULE:FREQ=DAILY SUMMARY:Old title SEQUENCE:1 END:VEVENT BEGIN:VEVENT UID:event1 RECURRENCE-ID:20240202T100000Z DTSTART:20240202T100000Z DTEND:20240202T110000Z SUMMARY:Old title SEQUENCE:1 END:VEVENT END:VCALENDAR ` beforeEach(() => { jest.clearAllMocks() ;(api.get as jest.Mock).mockResolvedValue({ text: jest.fn().mockResolvedValue(recurringEventICS) }) ;(api as jest.Mock).mockResolvedValue({ ok: true }) }) it('propagates summary changes to overrides', async () => { await updateSeries( { ...mockEvent, title: 'New title' } as any, undefined, false ) const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => props.some(([k]: any) => k === 'recurrence-id') ) const summary = override[1].find(([k]: any) => k === 'summary') expect(summary[3]).toBe('New title') }) it('does not remove recurrence-id from overrides', async () => { await updateSeries(mockEvent as any, undefined, false) const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => props.some(([k]: any) => k === 'recurrence-id') ) expect(override).toBeDefined() }) it('increments SEQUENCE on overrides when metadata changes', async () => { await updateSeries( { ...mockEvent, title: 'Changed title' } as any, undefined, false ) const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => props.some(([k]: any) => k === 'recurrence-id') ) const sequence = override[1].find(([k]: any) => k === 'sequence') expect(sequence[3]).toBe(2) }) it('propagates description, location, class, and transp changes', async () => { const icsWithMultipleFields = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:event1 DTSTART:20240201T100000Z DTEND:20240201T110000Z RRULE:FREQ=DAILY SUMMARY:Title DESCRIPTION:Old description LOCATION:Old location CLASS:PUBLIC TRANSP:OPAQUE SEQUENCE:1 END:VEVENT BEGIN:VEVENT UID:event1 RECURRENCE-ID:20240202T100000Z DTSTART:20240202T100000Z DTEND:20240202T110000Z SUMMARY:Title DESCRIPTION:Old description LOCATION:Old location CLASS:PUBLIC TRANSP:OPAQUE SEQUENCE:1 END:VEVENT END:VCALENDAR ` ;(api.get as jest.Mock).mockResolvedValueOnce({ text: jest.fn().mockResolvedValue(icsWithMultipleFields) }) await updateSeries( { ...mockEvent, description: 'New description', location: 'New location', class: 'PRIVATE', transp: 'TRANSPARENT' } as any, undefined, false ) const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => props.some(([k]: any) => k === 'recurrence-id') ) const description = override[1].find(([k]: any) => k === 'description') const location = override[1].find(([k]: any) => k === 'location') const classField = override[1].find(([k]: any) => k === 'class') const transp = override[1].find(([k]: any) => k === 'transp') expect(description[3]).toBe('New description') expect(location[3]).toBe('New location') expect(classField[3]).toBe('PRIVATE') expect(transp[3]).toBe('TRANSPARENT') }) it('propagates organizer changes', async () => { const icsWithOrganizer = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:event1 DTSTART:20240201T100000Z RRULE:FREQ=DAILY SUMMARY:Title ORGANIZER;CN=Alice:mailto:alice@example.com SEQUENCE:1 END:VEVENT BEGIN:VEVENT UID:event1 RECURRENCE-ID:20240202T100000Z DTSTART:20240202T100000Z SUMMARY:Title ORGANIZER;CN=Alice:mailto:alice@example.com SEQUENCE:1 END:VEVENT END:VCALENDAR ` ;(api.get as jest.Mock).mockResolvedValueOnce({ text: jest.fn().mockResolvedValue(icsWithOrganizer) }) await updateSeries( { ...mockEvent, organizer: { cn: 'Bob', cal_address: 'bob@example.com' } } as any, undefined, false ) const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => props.some(([k]: any) => k === 'recurrence-id') ) const organizer = override[1].find(([k]: any) => k === 'organizer') expect(organizer[3]).toBe('mailto:bob@example.com') expect(organizer[1].cn).toBe('Bob') }) it('propagates attendee changes', async () => { const icsWithAttendees = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:event1 DTSTART:20240201T100000Z RRULE:FREQ=DAILY SUMMARY:Title ATTENDEE;CN=Alice:mailto:alice@example.com SEQUENCE:1 END:VEVENT BEGIN:VEVENT UID:event1 RECURRENCE-ID:20240202T100000Z DTSTART:20240202T100000Z SUMMARY:Title ATTENDEE;CN=Alice:mailto:alice@example.com SEQUENCE:1 END:VEVENT END:VCALENDAR ` ;(api.get as jest.Mock).mockResolvedValueOnce({ text: jest.fn().mockResolvedValue(icsWithAttendees) }) await updateSeries( { ...mockEvent, attendee: [ { cn: 'Bob', cal_address: 'bob@example.com', partstat: 'NEEDS-ACTION', cutype: 'INDIVIDUAL', role: 'REQ-PARTICIPANT', rsvp: 'TRUE' }, { cn: 'Charlie', cal_address: 'charlie@example.com', partstat: 'NEEDS-ACTION', cutype: 'INDIVIDUAL', role: 'REQ-PARTICIPANT', rsvp: 'TRUE' } ] } as any, undefined, false ) const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => props.some(([k]: any) => k === 'recurrence-id') ) const attendees = override[1].filter(([k]: any) => k === 'attendee') expect(attendees).toHaveLength(2) expect(attendees[0][3]).toBe('mailto:bob@example.com') expect(attendees[1][3]).toBe('mailto:charlie@example.com') }) it('propagates x-openpaas-videoconference changes', async () => { const icsWithVideo = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:event1 DTSTART:20240201T100000Z RRULE:FREQ=DAILY SUMMARY:Title X-OPENPAAS-VIDEOCONFERENCE:https://meet.old.com SEQUENCE:1 END:VEVENT BEGIN:VEVENT UID:event1 RECURRENCE-ID:20240202T100000Z DTSTART:20240202T100000Z SUMMARY:Title X-OPENPAAS-VIDEOCONFERENCE:https://meet.old.com SEQUENCE:1 END:VEVENT END:VCALENDAR ` ;(api.get as jest.Mock).mockResolvedValueOnce({ text: jest.fn().mockResolvedValue(icsWithVideo) }) await updateSeries( { ...mockEvent, x_openpass_videoconference: 'https://meet.new.com' } as any, undefined, false ) const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => props.some(([k]: any) => k === 'recurrence-id') ) const videoconf = override[1].find( ([k]: any) => k === 'x-openpaas-videoconference' ) expect(videoconf[3]).toBe('https://meet.new.com') }) it('works with multiple override instances', async () => { const icsMultipleOverrides = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:event1 DTSTART:20240201T100000Z DTEND:20240201T110000Z RRULE:FREQ=DAILY SUMMARY:Old SEQUENCE:1 END:VEVENT BEGIN:VEVENT UID:event1 RECURRENCE-ID:20240202T100000Z DTSTART:20240202T100000Z DTEND:20240202T110000Z SUMMARY:Old SEQUENCE:1 END:VEVENT BEGIN:VEVENT UID:event1 RECURRENCE-ID:20240203T100000Z DTSTART:20240203T100000Z DTEND:20240203T110000Z SUMMARY:Old SEQUENCE:1 END:VEVENT END:VCALENDAR ` ;(api.get as jest.Mock).mockResolvedValueOnce({ text: jest.fn().mockResolvedValue(icsMultipleOverrides) }) await updateSeries( { ...mockEvent, title: 'New title' } as any, undefined, false ) const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) const vevents = body[2].filter(([n]: any) => n === 'vevent') const overrides = vevents.filter(([, props]: any) => props.some(([k]: any) => k === 'recurrence-id') ) expect(overrides).toHaveLength(2) overrides.forEach(override => { const summary = override[1].find(([k]: any) => k === 'summary') const sequence = override[1].find(([k]: any) => k === 'sequence') expect(summary[3]).toBe('New title') expect(sequence[3]).toBe(2) }) }) it('adds sequence field when missing and metadata changes', async () => { const icsNoSequence = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:event1 DTSTART:20240201T100000Z DTEND:20240201T110000Z RRULE:FREQ=DAILY SUMMARY:Old END:VEVENT BEGIN:VEVENT UID:event1 RECURRENCE-ID:20240202T100000Z DTSTART:20240202T100000Z DTEND:20240202T110000Z SUMMARY:Old END:VEVENT END:VCALENDAR ` ;(api.get as jest.Mock).mockResolvedValueOnce({ text: jest.fn().mockResolvedValue(icsNoSequence) }) await updateSeries( { ...mockEvent, title: 'New title' } as any, undefined, false ) const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => props.some(([k]: any) => k === 'recurrence-id') ) const sequence = override[1].find(([k]: any) => k === 'sequence') expect(sequence[3]).toBe(1) }) it('preserves override-specific fields when propagating metadata', async () => { const icsCustomFields = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:event1 DTSTART:20240201T100000Z DTEND:20240201T110000Z RRULE:FREQ=DAILY SUMMARY:Old SEQUENCE:1 END:VEVENT BEGIN:VEVENT UID:event1 RECURRENCE-ID:20240202T100000Z DTSTART:20240202T140000Z DTEND:20240202T150000Z SUMMARY:Old DESCRIPTION:custom-value SEQUENCE:1 END:VEVENT END:VCALENDAR ` ;(api.get as jest.Mock).mockResolvedValueOnce({ text: jest.fn().mockResolvedValue(icsCustomFields) }) await updateSeries( { ...mockEvent, title: 'New title' } as any, undefined, false ) const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => props.some(([k]: any) => k === 'recurrence-id') ) const summary = override[1].find(([k]: any) => k === 'summary') const dtstart = override[1].find(([k]: any) => k === 'dtstart') const dtend = override[1].find(([k]: any) => k === 'dtend') const description = override[1].find(([k]: any) => k === 'description') const recurrenceId = override[1].find(([k]: any) => k === 'recurrence-id') // Summary should be updated expect(summary[3]).toBe('New title') // Override-specific time should be preserved expect(dtstart[3]).toBe('2024-02-02T14:00:00Z') expect(dtend[3]).toBe('2024-02-02T15:00:00Z') // Custom field should be preserved expect(description[3]).toBe('custom-value') // Recurrence-id should be preserved expect(recurrenceId[3]).toBe('2024-02-02T10:00:00Z') }) const recurringWithAlarmICS = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:event1 DTSTART:20240201T100000Z RRULE:FREQ=DAILY SUMMARY:Old BEGIN:VALARM ACTION:DISPLAY TRIGGER:-PT15M END:VALARM END:VEVENT BEGIN:VEVENT UID:event1 RECURRENCE-ID:20240202T100000Z DTSTART:20240202T100000Z SUMMARY:Old BEGIN:VALARM ACTION:DISPLAY TRIGGER:-PT15M END:VALARM END:VEVENT END:VCALENDAR ` it('replaces VALARM on overrides when alarm changes', async () => { ;(api.get as jest.Mock).mockResolvedValueOnce({ text: jest.fn().mockResolvedValue(recurringWithAlarmICS) }) await updateSeries( { ...mockEvent, alarm: { action: 'EMAIL', trigger: '-PT30M' } } as any, undefined, false ) const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) const vevents = body[2].filter(([n]: any) => n === 'vevent') const override = vevents.find(([, props]: any) => props.some(([k]: any) => k === 'recurrence-id') ) const alarms = override[2].filter(([n]: any) => n === 'valarm') expect(alarms).toHaveLength(1) const action = alarms[0][1].find(([k]: any) => k === 'action') const trigger = alarms[0][1].find(([k]: any) => k === 'trigger') expect(action[3]).toBe('EMAIL') expect(trigger[3]).toBe('-PT30M') }) }) })