import { Calendar } from '@/features/Calendars/CalendarTypes' import { VObjectProperty } from '@/features/Calendars/types/CalendarData' import { putEvent, putEventWithOverrides } from '@/features/Events/EventApi' import { CalendarEvent } from '@/features/Events/EventsTypes' import { calendarEventToJCal, makeVevent, parseCalendarEvent } from '@/features/Events/utils' import { api } from '@/utils/apiUtils' jest.mock('@/utils/apiUtils') const MOCK_CALENDAR: Calendar = { id: 'cal-1', link: '/dav/calendars/user/cal-1.json' } as Calendar const EVENT_URL = '/dav/calendars/user/cal-1/event-abc.ics' const mockEvent = { uid: 'event-123', title: 'Test Event', start: '2025-07-23T08:00:00.000Z', end: '2025-07-23T09:00:00.000Z', timezone: 'Europe/Paris', allday: false, attendee: [], passthroughProps: [ ['x-attachment', {}, 'text', 'data:application/pdf;base64,abc123=='], ['x-custom-prop', {}, 'text', 'custom-value'] ] as VObjectProperty[] } as unknown as CalendarEvent /** Minimal valid vevent props (dtstart is a timed event in Europe/Paris) */ function baseVeventProps(overrides: VObjectProperty[] = []): VObjectProperty[] { return [ ['uid', {}, 'text', 'event-abc-uid'], ['summary', {}, 'text', 'Team standup'], ['dtstart', { tzid: 'Europe/Paris' }, 'date-time', '2024-03-15T10:00:00'], ['dtend', { tzid: 'Europe/Paris' }, 'date-time', '2024-03-15T10:30:00'], ['dtstamp', {}, 'date-time', '2024-03-01T00:00:00Z'], ['sequence', {}, 'integer', 0], ...overrides ] } /** X- / custom props that must survive round-trips */ const CUSTOM_PROPS: VObjectProperty[] = [ ['x-attachment', {}, 'text', 'base64encodeddata=='], ['x-apple-travel-title', {}, 'text', 'Drive to office'], ['x-custom-field', {}, 'unknown', 'some-value'] ] /** Build a CalendarEvent directly (skips parseCalendarEvent) for makeVevent tests */ function baseCalendarEvent( overrides: Partial = {} ): CalendarEvent { return { uid: 'event-abc-uid', title: 'Team standup', start: '2024-03-15T09:00:00.000Z', end: '2024-03-15T09:30:00.000Z', timezone: 'Europe/Paris', allday: false, attendee: [], calId: 'cal-1', URL: EVENT_URL, transp: 'OPAQUE', class: 'PUBLIC', sequence: 1, exdates: [], ...overrides } as CalendarEvent } describe('parseCalendarEvent — passthroughProps', () => { it('captures unknown X- properties into passthroughProps', () => { const props = baseVeventProps(CUSTOM_PROPS) const event = parseCalendarEvent(props, {}, MOCK_CALENDAR, EVENT_URL) expect(event.passthroughProps).toBeDefined() const keys = event.passthroughProps!.map(([k]) => k.toLowerCase()) expect(keys).toContain('x-attachment') expect(keys).toContain('x-apple-travel-title') expect(keys).toContain('x-custom-field') }) it('preserves the full tuple structure of passthrough props', () => { const props = baseVeventProps(CUSTOM_PROPS) const event = parseCalendarEvent(props, {}, MOCK_CALENDAR, EVENT_URL) const attachment = event.passthroughProps!.find( ([k]) => k === 'x-attachment' ) expect(attachment).toEqual([ 'x-attachment', {}, 'text', 'base64encodeddata==' ]) }) it('does NOT include known props in passthroughProps', () => { const props = baseVeventProps(CUSTOM_PROPS) const event = parseCalendarEvent(props, {}, MOCK_CALENDAR, EVENT_URL) const known = ['uid', 'summary', 'dtstart', 'dtend', 'dtstamp', 'sequence'] const passthroughKeys = event.passthroughProps!.map(([k]) => k.toLowerCase() ) for (const k of known) { expect(passthroughKeys).not.toContain(k) } }) it('sets passthroughProps to empty array when no unknown props exist', () => { const event = parseCalendarEvent( baseVeventProps(), {}, MOCK_CALENDAR, EVENT_URL ) expect(event.passthroughProps).toEqual([]) }) it('parses known fields correctly alongside custom props', () => { const props = baseVeventProps(CUSTOM_PROPS) const event = parseCalendarEvent(props, {}, MOCK_CALENDAR, EVENT_URL) expect(event.title).toBe('Team standup') expect(event.uid).toBe('event-abc-uid') expect(event.allday).toBe(false) }) }) describe('makeVevent — passthroughProps round-trip', () => { it('injects passthroughProps into the vevent output', () => { const event = baseCalendarEvent({ passthroughProps: CUSTOM_PROPS }) const [, props] = makeVevent(event, 'Europe/Paris', undefined) const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) expect(keys).toContain('x-attachment') expect(keys).toContain('x-apple-travel-title') expect(keys).toContain('x-custom-field') }) it('preserves the exact value of passthrough props', () => { const event = baseCalendarEvent({ passthroughProps: CUSTOM_PROPS }) const [, props] = makeVevent(event, 'Europe/Paris', undefined) const attachment = (props as VObjectProperty[]).find( ([k]) => k === 'x-attachment' ) expect(attachment?.[3]).toBe('base64encodeddata==') }) it('does NOT duplicate a prop that is already written by makeVevent', () => { // If somehow a passthrough prop overlaps a known key, it must not be doubled const duplicateSummary: VObjectProperty = [ 'summary', {}, 'text', 'duplicate' ] const event = baseCalendarEvent({ passthroughProps: [duplicateSummary] }) const [, props] = makeVevent(event, 'Europe/Paris', undefined) const summaries = (props as VObjectProperty[]).filter( ([k]) => k === 'summary' ) expect(summaries.length).toBe(1) }) it('produces a vevent with no passthroughProps when field is undefined', () => { const event = baseCalendarEvent({ passthroughProps: undefined }) const [name, props] = makeVevent(event, 'Europe/Paris', undefined) expect(name).toBe('vevent') expect(Array.isArray(props)).toBe(true) }) it('produces a vevent with no passthroughProps when field is empty array', () => { const event = baseCalendarEvent({ passthroughProps: [] }) const [, props] = makeVevent(event, 'Europe/Paris', undefined) // Just check it doesn't throw and still has standard props const keys = (props as VObjectProperty[]).map(([k]) => k) expect(keys).toContain('uid') expect(keys).toContain('summary') }) }) describe('calendarEventToJCal', () => { it('preserves passthroughProps inside the vcalendar output', () => { const event = baseCalendarEvent({ passthroughProps: CUSTOM_PROPS }) const [, , children] = calendarEventToJCal(event) const vevent = (children as unknown[][]).find(c => c[0] === 'vevent') const veventProps = vevent![1] as VObjectProperty[] const keys = veventProps.map(([k]) => k.toLowerCase()) expect(keys).toContain('x-attachment') expect(keys).toContain('x-custom-field') }) }) describe('Standard event modification', () => { describe('title update', () => { it('updates title while preserving custom props', () => { const event = baseCalendarEvent({ title: 'Old title', passthroughProps: CUSTOM_PROPS }) const updated = { ...event, title: 'New title' } const [, props] = makeVevent(updated, updated.timezone, undefined) const summary = (props as VObjectProperty[]).find( ([k]) => k === 'summary' ) expect(summary?.[3]).toBe('New title') const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) expect(keys).toContain('x-attachment') }) }) describe('time update', () => { it('updates dtstart/dtend while preserving custom props', () => { const event = baseCalendarEvent({ start: '2024-03-16T08:00:00.000Z', end: '2024-03-16T09:00:00.000Z', passthroughProps: CUSTOM_PROPS }) const [, props] = makeVevent(event, event.timezone, undefined) const dtstart = (props as VObjectProperty[]).find( ([k]) => k === 'dtstart' ) expect(dtstart).toBeDefined() const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) expect(keys).toContain('x-attachment') }) }) describe('description update', () => { it('adds description while preserving custom props', () => { const event = baseCalendarEvent({ description: 'Updated description', passthroughProps: CUSTOM_PROPS }) const [, props] = makeVevent(event, event.timezone, undefined) const desc = (props as VObjectProperty[]).find( ([k]) => k === 'description' ) expect(desc?.[3]).toBe('Updated description') const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) expect(keys).toContain('x-attachment') }) }) describe('location update', () => { it('adds location while preserving custom props', () => { const event = baseCalendarEvent({ location: 'Conference Room B', passthroughProps: CUSTOM_PROPS }) const [, props] = makeVevent(event, event.timezone, undefined) const loc = (props as VObjectProperty[]).find(([k]) => k === 'location') expect(loc?.[3]).toBe('Conference Room B') const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) expect(keys).toContain('x-attachment') }) }) describe('attendee update', () => { it('adds attendee while preserving custom props', () => { const event = baseCalendarEvent({ attendee: [ { cal_address: 'alice@example.com', cn: 'Alice', partstat: 'ACCEPTED', rsvp: 'TRUE', role: 'REQ-PARTICIPANT', cutype: 'INDIVIDUAL' } ], passthroughProps: CUSTOM_PROPS }) const [, props] = makeVevent( event, event.timezone, 'organizer@example.com' ) const attendees = (props as VObjectProperty[]).filter( ([k]) => k === 'attendee' ) expect(attendees.length).toBe(1) expect(String(attendees[0][3])).toContain('alice@example.com') const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) expect(keys).toContain('x-attachment') }) }) describe('all-day event', () => { it('formats dates without time component and preserves custom props', () => { const event = baseCalendarEvent({ allday: true, start: '2024-03-15T00:00:00.000Z', end: '2024-03-16T00:00:00.000Z', passthroughProps: CUSTOM_PROPS }) const [, props] = makeVevent(event, event.timezone, undefined) const dtstart = (props as VObjectProperty[]).find( ([k]) => k === 'dtstart' ) // All-day values should be date strings (no T separator) expect(String(dtstart?.[3])).not.toContain('T') const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) expect(keys).toContain('x-attachment') }) }) describe('timezone change', () => { it('switches tzid param while preserving custom props', () => { const event = baseCalendarEvent({ timezone: 'America/New_York', passthroughProps: CUSTOM_PROPS }) const [, props] = makeVevent(event, 'America/New_York', undefined) const dtstart = (props as VObjectProperty[]).find( ([k]) => k === 'dtstart' ) expect((dtstart?.[1] as Record)?.tzid).toBe( 'America/New_York' ) const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) expect(keys).toContain('x-attachment') }) }) }) describe('Recurring event — makeVevent as master', () => { const recurringBase = () => baseCalendarEvent({ repetition: { freq: 'weekly', byday: ['MO', 'WE', 'FR'], interval: 1 }, passthroughProps: CUSTOM_PROPS }) it('includes rrule in the output', () => { const [, props] = makeVevent( recurringBase(), 'Europe/Paris', undefined, true ) const rrule = (props as VObjectProperty[]).find(([k]) => k === 'rrule') expect(rrule).toBeDefined() expect((rrule?.[3] as Record)?.freq).toBe('weekly') }) it('preserves custom props on master vevent', () => { const [, props] = makeVevent( recurringBase(), 'Europe/Paris', undefined, true ) const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) expect(keys).toContain('x-attachment') expect(keys).toContain('x-custom-field') }) it('does NOT include recurrence-id on master event', () => { const [, props] = makeVevent( recurringBase(), 'Europe/Paris', undefined, true ) const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) expect(keys).not.toContain('recurrence-id') }) }) describe('Recurring event — makeVevent as override (single instance)', () => { const overrideBase = () => baseCalendarEvent({ recurrenceId: '2024-03-20T09:00:00', uid: 'event-abc-uid/2024-03-20T09:00:00', passthroughProps: CUSTOM_PROPS }) it('preserves custom props on override vevent', () => { const [, props] = makeVevent( overrideBase(), 'Europe/Paris', undefined, false ) const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) expect(keys).toContain('x-attachment') }) }) describe('Recurring event — exdate (delete instance)', () => { it('adds exdate entries and preserves custom props', () => { const event = baseCalendarEvent({ exdates: ['2024-03-20T09:00:00.000Z', '2024-03-27T09:00:00.000Z'], passthroughProps: CUSTOM_PROPS }) const [, props] = makeVevent(event, 'Europe/Paris', undefined, true) const exdates = (props as VObjectProperty[]).filter(([k]) => k === 'exdate') expect(exdates.length).toBe(2) const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) expect(keys).toContain('x-attachment') }) }) describe('Edge cases', () => { it('handles multiple custom props of the same name', () => { const multiAttach: VObjectProperty[] = [ ['x-attachment', {}, 'text', 'file1=='], ['x-attachment', {}, 'text', 'file2=='] ] const event = parseCalendarEvent( baseVeventProps(multiAttach), {}, MOCK_CALENDAR, EVENT_URL ) expect( event.passthroughProps?.filter(([k]) => k === 'x-attachment').length ).toBe(2) const updated = baseCalendarEvent({ passthroughProps: event.passthroughProps }) const [, props] = makeVevent(updated, updated.timezone, undefined) const attachments = (props as VObjectProperty[]).filter( ([k]) => k === 'x-attachment' ) expect(attachments.length).toBe(2) }) it('handles passthrough prop with complex params object', () => { const complexProp: VObjectProperty = [ 'x-complex', { encoding: 'BASE64', fmttype: 'application/pdf' }, 'binary', 'binarydata==' ] const event = baseCalendarEvent({ passthroughProps: [complexProp] }) const [, props] = makeVevent(event, event.timezone, undefined) const found = (props as VObjectProperty[]).find(([k]) => k === 'x-complex') expect(found?.[1]).toEqual({ encoding: 'BASE64', fmttype: 'application/pdf' }) expect(found?.[3]).toBe('binarydata==') }) it('round-trips without mutation: passthroughProps array is not modified', () => { const originalProps = [...CUSTOM_PROPS] const event = baseCalendarEvent({ passthroughProps: CUSTOM_PROPS }) makeVevent(event, event.timezone, undefined) // Original array must be untouched expect(CUSTOM_PROPS).toEqual(originalProps) }) it('handles event with alarm alongside passthroughProps', () => { const event = baseCalendarEvent({ alarm: { trigger: '-PT15M', action: 'EMAIL' }, passthroughProps: CUSTOM_PROPS }) const [, props, subComponents] = makeVevent( event, event.timezone, 'owner@example.com' ) expect(Array.isArray(subComponents)).toBe(true) const keys = (props as VObjectProperty[]).map(([k]) => k.toLowerCase()) expect(keys).toContain('x-attachment') }) }) describe('putEvent - passthroughProps preservation', () => { beforeEach(() => { jest.clearAllMocks() }) it('includes passthroughProps in the PUT body', async () => { const mockResponse = { status: 201, url: '/dav/cals/test.ics' } ;(api as unknown as jest.Mock).mockReturnValue(mockResponse) const eventWithPassthrough = { ...mockEvent, passthroughProps: [ ['x-attachment', {}, 'text', 'data:application/pdf;base64,abc123=='] ] } as CalendarEvent await putEvent(eventWithPassthrough) const callArgs = (api as unknown as jest.Mock).mock.calls[0][1] const body = JSON.parse(callArgs.body) const veventProps = body[2][0][1] expect(veventProps).toContainEqual([ 'x-attachment', {}, 'text', 'data:application/pdf;base64,abc123==' ]) }) it('sends clean body without passthroughProps when none exist', async () => { const mockResponse = { status: 201, url: '/dav/cals/test.ics' } ;(api as unknown as jest.Mock).mockReturnValue(mockResponse) await putEvent({ ...mockEvent, passthroughProps: [] } as CalendarEvent) const callArgs = (api as unknown as jest.Mock).mock.calls[0][1] const body = JSON.parse(callArgs.body) const veventProps = body[2][0][1] as [string, ...unknown[]][] const xProps = veventProps.filter(([k]) => k.startsWith('x-attachment')) expect(xProps).toHaveLength(0) }) }) describe('putEventWithOverrides - passthroughProps isolation', () => { const icsWithPassthrough = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Test//EN BEGIN:VEVENT UID:event1 DTSTART:20240201T100000Z DTEND:20240201T110000Z RRULE:FREQ=DAILY SUMMARY:Master SEQUENCE:1 END:VEVENT BEGIN:VEVENT UID:event1 RECURRENCE-ID:20240202T100000Z DTSTART:20240202T100000Z DTEND:20240202T110000Z SUMMARY:Override X-ATTACHMENT:data:application/pdf;base64,abc123== SEQUENCE:1 END:VEVENT END:VCALENDAR ` beforeEach(() => { jest.clearAllMocks() ;(api.get as jest.Mock).mockResolvedValue({ text: jest.fn().mockResolvedValue(icsWithPassthrough) }) ;(api as jest.Mock).mockResolvedValue({ ok: true }) }) it('preserves X-ATTACHMENT on the updated instance in the PUT body', async () => { const updatedInstance = { ...mockEvent, title: 'Override updated', recurrenceId: '20240202T100000Z', start: '2024-02-02T10:00:00Z', end: '2024-02-02T11:00:00Z', passthroughProps: [ ['x-attachment', {}, 'text', 'data:application/pdf;base64,abc123=='] ] as VObjectProperty[] } as CalendarEvent await putEventWithOverrides(updatedInstance) 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 attachment = override[1].find(([k]: any) => k === 'x-attachment') expect(attachment).toBeDefined() expect(attachment[3]).toBe('data:application/pdf;base64,abc123==') }) it('does not inject instance passthroughProps into the master vevent', async () => { const updatedInstance = { ...mockEvent, title: 'Override updated', recurrenceId: '20240202T100000Z', start: '2024-02-02T10:00:00Z', end: '2024-02-02T11:00:00Z', passthroughProps: [ ['x-attachment', {}, 'text', 'data:application/pdf;base64,abc123=='] ] as VObjectProperty[] } as CalendarEvent await putEventWithOverrides(updatedInstance) const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) const vevents = body[2].filter(([n]: any) => n === 'vevent') const master = vevents.find(([, props]: any) => props.every(([k]: any) => k !== 'recurrence-id') ) const attachment = master[1].find(([k]: any) => k === 'x-attachment') expect(attachment).toBeUndefined() }) it('preserves raw X-ATTACHMENT on untouched instances from the ICS', async () => { const icsWithTwoOverrides = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:event1 DTSTART:20240201T100000Z DTEND:20240201T110000Z RRULE:FREQ=DAILY SUMMARY:Master SEQUENCE:1 END:VEVENT BEGIN:VEVENT UID:event1 RECURRENCE-ID:20240202T100000Z DTSTART:20240202T100000Z DTEND:20240202T110000Z SUMMARY:Override A X-ATTACHMENT:data:application/pdf;base64,aaa== SEQUENCE:1 END:VEVENT BEGIN:VEVENT UID:event1 RECURRENCE-ID:20240203T100000Z DTSTART:20240203T100000Z DTEND:20240203T110000Z SUMMARY:Override B X-ATTACHMENT:data:application/pdf;base64,bbb== SEQUENCE:1 END:VEVENT END:VCALENDAR ` ;(api.get as jest.Mock).mockResolvedValueOnce({ text: jest.fn().mockResolvedValue(icsWithTwoOverrides) }) // Only updating Override A — Override B should be untouched const updatedInstance = { ...mockEvent, title: 'Override A updated', recurrenceId: '20240202T100000Z', start: '2024-02-02T10:00:00Z', end: '2024-02-02T11:00:00Z', passthroughProps: [ ['x-attachment', {}, 'text', 'data:application/pdf;base64,aaa=='] ] as VObjectProperty[] } as CalendarEvent await putEventWithOverrides(updatedInstance) const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) const vevents = body[2].filter(([n]: any) => n === 'vevent') const overrideB = vevents.find(([, props]: any) => props.some( ([k, , , v]: any) => k === 'recurrence-id' && v === '2024-02-03T10:00:00Z' ) ) // Override B was never touched — its raw jCal props come straight from the ICS const attachmentB = overrideB[1].find(([k]: any) => k === 'x-attachment') expect(attachmentB).toBeDefined() expect(attachmentB[3]).toBe('data:application/pdf;base64,bbb==') }) it('does not bleed X-ATTACHMENT from one instance to another in the same PUT', async () => { const icsWithTwoOverrides = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:event1 DTSTART:20240201T100000Z DTEND:20240201T110000Z RRULE:FREQ=DAILY SUMMARY:Master SEQUENCE:1 END:VEVENT BEGIN:VEVENT UID:event1 RECURRENCE-ID:20240202T100000Z DTSTART:20240202T100000Z DTEND:20240202T110000Z SUMMARY:Override A X-ATTACHMENT:data:application/pdf;base64,aaa== SEQUENCE:1 END:VEVENT BEGIN:VEVENT UID:event1 RECURRENCE-ID:20240203T100000Z DTSTART:20240203T100000Z DTEND:20240203T110000Z SUMMARY:Override B - no attachment SEQUENCE:1 END:VEVENT END:VCALENDAR ` ;(api.get as jest.Mock).mockResolvedValueOnce({ text: jest.fn().mockResolvedValue(icsWithTwoOverrides) }) const updatedInstance = { ...mockEvent, title: 'Override A updated', recurrenceId: '20240202T100000Z', start: '2024-02-02T10:00:00Z', end: '2024-02-02T11:00:00Z', passthroughProps: [ ['x-attachment', {}, 'text', 'data:application/pdf;base64,aaa=='] ] as VObjectProperty[] } as CalendarEvent await putEventWithOverrides(updatedInstance) const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body) const vevents = body[2].filter(([n]: any) => n === 'vevent') const overrideB = vevents.find(([, props]: any) => props.some( ([k, , , v]: any) => k === 'recurrence-id' && v === '2024-02-03T10:00:00Z' ) ) const attachmentB = overrideB[1].find(([k]: any) => k === 'x-attachment') expect(attachmentB).toBeUndefined() }) })