|
|
|
@@ -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> = {}
|
|
|
|
|
): 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<string, string>)?.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<string, unknown>)?.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();
|
|
|
|
|
});
|
|
|
|
|
});
|