diff --git a/__test__/features/Events/ICSPropertiesPreservation.test.tsx b/__test__/features/Events/ICSPropertiesPreservation.test.tsx new file mode 100644 index 0000000..f338312 --- /dev/null +++ b/__test__/features/Events/ICSPropertiesPreservation.test.tsx @@ -0,0 +1,757 @@ +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(); + }); +}); diff --git a/src/features/Events/EventsTypes.ts b/src/features/Events/EventsTypes.ts index 9ca1e2e..5e4c35c 100644 --- a/src/features/Events/EventsTypes.ts +++ b/src/features/Events/EventsTypes.ts @@ -1,4 +1,5 @@ import { Calendar } from "../Calendars/CalendarTypes"; +import { VObjectProperty } from "../Calendars/types/CalendarData"; import { userAttendee } from "../User/models/attendee"; import { userOrganiser } from "../User/userDataTypes"; @@ -27,6 +28,7 @@ export interface CalendarEvent { repetition?: RepetitionObject; alarm?: AlarmObject; exdates?: string[]; + passthroughProps?: VObjectProperty[]; } export interface RepetitionObject { diff --git a/src/features/Events/utils/makeVevent.ts b/src/features/Events/utils/makeVevent.ts index 9398594..21ead92 100644 --- a/src/features/Events/utils/makeVevent.ts +++ b/src/features/Events/utils/makeVevent.ts @@ -121,5 +121,16 @@ export function makeVevent( }); } + if (event.passthroughProps?.length) { + const existingKeys = new Set( + (vevent[1] as unknown[]).map((p) => (p as [string])[0].toLowerCase()) + ); + for (const prop of event.passthroughProps) { + if (!existingKeys.has(prop[0].toLowerCase())) { + vevent[1].push(prop); + } + } + } + return vevent; } diff --git a/src/features/Events/utils/parseCalendarEvent.ts b/src/features/Events/utils/parseCalendarEvent.ts index ac8e4eb..c4ea9cb 100644 --- a/src/features/Events/utils/parseCalendarEvent.ts +++ b/src/features/Events/utils/parseCalendarEvent.ts @@ -9,9 +9,30 @@ import { import { userAttendee } from "../../User/models/attendee"; import { createAttendee } from "../../User/models/attendee.mapper"; import { AlarmObject, CalendarEvent } from "../EventsTypes"; -import { inferTimezoneFromValue } from "./inferTimezoneFromValue"; import { buildDelegatedEventURL } from "./buildDelegatedEventURL"; import { formatDateToICal } from "./formatDateToICal"; +import { inferTimezoneFromValue } from "./inferTimezoneFromValue"; + +const KNOWN_PROPS = new Set([ + "uid", + "transp", + "dtstart", + "dtend", + "class", + "x-openpaas-videoconference", + "summary", + "description", + "location", + "organizer", + "attendee", + "dtstamp", + "sequence", + "recurrence-id", + "exdate", + "status", + "duration", + "rrule", +]); export function parseCalendarEvent( data: VObjectProperty[], @@ -211,6 +232,9 @@ export function parseCalendarEvent( event.end = endISO; } } + event.passthroughProps = data.filter( + ([key]) => !KNOWN_PROPS.has(key.toLowerCase()) + ); return event as CalendarEvent; }