[DELEGATION] Edit attendance and events, delete event for someone (#553)
* [#523] added way to manage attendance for delegated calendars * [DELEGATION] #523 & #524 changed eventURL calculation for delegated events to have the rigth one * [#524] added drag and drop authorisation to delegated event * [#524] added tests * [#524] support for the permission access * [#524] no edit when delegated event is not public
This commit is contained in:
@@ -44,7 +44,7 @@ describe("CalendarSelection", () => {
|
||||
name: "Calendar personal",
|
||||
id: "user1/cal1",
|
||||
color: { light: "#FF0000", dark: "#000" },
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {
|
||||
event1: {
|
||||
id: "event1",
|
||||
@@ -76,7 +76,7 @@ describe("CalendarSelection", () => {
|
||||
delegated: true,
|
||||
id: "user2/cal1",
|
||||
color: { light: "#FF0000", dark: "#000" },
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {
|
||||
event1: {
|
||||
id: "event1",
|
||||
@@ -107,7 +107,7 @@ describe("CalendarSelection", () => {
|
||||
name: "Calendar shared",
|
||||
id: "user3/cal1",
|
||||
color: { light: "#FF0000", dark: "#000" },
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {
|
||||
event1: {
|
||||
id: "event1",
|
||||
@@ -336,7 +336,7 @@ describe("calendar Availability search", () => {
|
||||
name: "Calendar personal",
|
||||
id: "user1/cal1",
|
||||
color: { light: "#FF0000", dark: "#000" },
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("CalendarSearch", () => {
|
||||
name: "My Calendar",
|
||||
id: "user1/cal1",
|
||||
color: "#0000FF",
|
||||
ownerEmails: ["test@test.com"],
|
||||
owner: { emails: ["test@test.com"] },
|
||||
events: {},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -24,20 +24,20 @@ describe("CalendarSelection", () => {
|
||||
name: "Calendar personal",
|
||||
id: "user1/cal1",
|
||||
color: "#FF0000",
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
},
|
||||
"user2/cal1": {
|
||||
name: "Calendar delegated",
|
||||
delegated: true,
|
||||
id: "user2/cal1",
|
||||
color: "#00FF00",
|
||||
ownerEmails: ["bob@example.com"],
|
||||
owner: { emails: ["bob@example.com"] },
|
||||
},
|
||||
"user3/cal1": {
|
||||
name: "Calendar shared",
|
||||
id: "user3/cal1",
|
||||
color: "#0000FF",
|
||||
ownerEmails: ["charlie@example.com"],
|
||||
owner: { emails: ["charlie@example.com"] },
|
||||
},
|
||||
};
|
||||
beforeAll(() => {
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("Event Error Handling", () => {
|
||||
name: "Calendar personal",
|
||||
id: "user1/cal1",
|
||||
color: { light: "#FF0000", dark: "#000" },
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {
|
||||
event1: {
|
||||
id: "event1",
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("CalendarApp integration", () => {
|
||||
name: "Calendar 1",
|
||||
id: "667037022b752d0026472254/cal1",
|
||||
color: { light: "#FFFFFF", dark: "#000000" },
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {
|
||||
event1: {
|
||||
id: "event1",
|
||||
@@ -136,7 +136,7 @@ describe("CalendarApp integration", () => {
|
||||
name: "Calendar 1",
|
||||
id: "667037022b752d0026472254/cal1",
|
||||
color: { light: "#FFFFFF", dark: "#000000" },
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {
|
||||
event1: {
|
||||
id: "event1",
|
||||
@@ -247,7 +247,7 @@ describe("CalendarApp integration", () => {
|
||||
name: "Calendar 1",
|
||||
id: "667037022b752d0026472254/cal1",
|
||||
color: { light: "#FF0000", dark: "#000" },
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {
|
||||
event1: {
|
||||
id: "event1",
|
||||
@@ -291,7 +291,7 @@ describe("CalendarApp integration", () => {
|
||||
name: "Calendar 1",
|
||||
id: "667037022b752d0026472254/cal1",
|
||||
color: { light: "#FFFFFF", dark: "#000000" },
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {
|
||||
event1: {
|
||||
id: "event1",
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("MiniCalendar", () => {
|
||||
"667037022b752d0026472254/cal1": {
|
||||
name: "Calendar 1",
|
||||
color: { light: "#FF0000", dark: "#000" },
|
||||
ownerEmails: ["test@test.com"],
|
||||
owner: { emails: ["test@test.com"] },
|
||||
events: {
|
||||
event1: {
|
||||
calId: "667037022b752d0026472254/cal1",
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("Calendar API", () => {
|
||||
const calendars = await getCalendars(mockUserId);
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith(
|
||||
`dav/calendars/${mockUserId}.json?personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&`,
|
||||
`dav/calendars/${mockUserId}.json?personal=true&sharedDelegationStatus=accepted&sharedPublicSubscription=true&withRights=true`,
|
||||
{
|
||||
headers: { Accept: "application/calendar+json" },
|
||||
}
|
||||
|
||||
@@ -109,8 +109,7 @@ describe("CalendarPopover (editing mode)", () => {
|
||||
name: "Work Calendar",
|
||||
description: "Team meetings",
|
||||
color: { light: "#33B679" },
|
||||
owner: "alice",
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { firstname: "alice", emails: ["alice@example.com"] },
|
||||
visibility: "public",
|
||||
events: {},
|
||||
};
|
||||
@@ -214,8 +213,7 @@ describe("CalendarPopover - Tabs Scenarios", () => {
|
||||
name: "Work Calendar",
|
||||
description: "Team meetings",
|
||||
color: { light: "#33B679" },
|
||||
owner: "alice",
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { firstname: "alice", emails: ["alice@example.com"] },
|
||||
visibility: "public",
|
||||
events: {},
|
||||
};
|
||||
|
||||
@@ -195,9 +195,9 @@ describe("CalendarSlice", () => {
|
||||
expect(getUserDetailsMock).toHaveBeenCalledWith("u3");
|
||||
|
||||
const state = store.getState().calendars;
|
||||
expect(state.list["u1/cal1"].owner).toContain("Alice");
|
||||
expect(state.list["u2/cal2"].owner).toContain("Bob");
|
||||
expect(state.list["u3/cal3"].owner).toContain("Charlie");
|
||||
expect(state.list["u1/cal1"].owner.firstname).toContain("Alice");
|
||||
expect(state.list["u2/cal2"].owner.firstname).toContain("Bob");
|
||||
expect(state.list["u3/cal3"].owner.firstname).toContain("Charlie");
|
||||
});
|
||||
|
||||
it("getCalendarsListAsync deduplicates getUserDetails calls for same ownerId", async () => {
|
||||
@@ -331,8 +331,8 @@ describe("CalendarSlice", () => {
|
||||
|
||||
expect(getUserDetailsMock).toHaveBeenCalledTimes(2);
|
||||
const state = store.getState().calendars;
|
||||
expect(state.list["u1/cal1"].owner).toContain("Alice");
|
||||
expect(state.list["u2/cal2"].owner).toContain("Unknown User");
|
||||
expect(state.list["u1/cal1"].owner.firstname).toContain("Alice");
|
||||
expect(state.list["u2/cal2"].owner.lastname).toContain("Unknown User");
|
||||
expect(result.payload.errors).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -375,8 +375,7 @@ describe("CalendarSlice", () => {
|
||||
color: { "apple:color": "#f00" },
|
||||
name: "Test",
|
||||
desc: "Desc",
|
||||
owner: "Owner",
|
||||
ownerEmails: ["o@example.com"],
|
||||
owner: { firstname: "Owner", emails: ["o@example.com"] },
|
||||
};
|
||||
const state = reducer(
|
||||
initialState,
|
||||
@@ -393,8 +392,7 @@ describe("CalendarSlice", () => {
|
||||
link: "/calendars/u1/c1.json",
|
||||
name: "Shared",
|
||||
desc: "Shared Desc",
|
||||
owner: "O",
|
||||
ownerEmails: ["o@example.com"],
|
||||
owner: { firstname: "O", emails: ["o@example.com"] },
|
||||
};
|
||||
const mockCal = {
|
||||
cal: {
|
||||
@@ -423,8 +421,7 @@ describe("CalendarSlice", () => {
|
||||
color: { "apple:color": "#aaa" },
|
||||
events: {},
|
||||
visibility: "public",
|
||||
owner: "O",
|
||||
ownerEmails: ["o@o.com"],
|
||||
owner: { firstname: "O", emails: ["o@o.com"] },
|
||||
link: "/calendars/t1.json",
|
||||
description: "desc",
|
||||
} as Calendar,
|
||||
|
||||
@@ -27,8 +27,7 @@ describe("Calendar - Timezone Integration", () => {
|
||||
name: "Test Calendar",
|
||||
description: "",
|
||||
color: "#33B679",
|
||||
owner: "user1",
|
||||
ownerEmails: ["test@test.com"],
|
||||
owner: { firstname: "user1", emails: ["test@test.com"] },
|
||||
visibility: "public",
|
||||
events: {},
|
||||
},
|
||||
@@ -174,8 +173,7 @@ describe("EventDisplayPreview - Timezone Display", () => {
|
||||
id: "user1/cal1",
|
||||
name: "Test Calendar",
|
||||
color: "#33B679",
|
||||
owner: "user1",
|
||||
ownerEmails: ["test@test.com"],
|
||||
owner: { firstname: "user1", emails: ["test@test.com"] },
|
||||
visibility: "public",
|
||||
events: {
|
||||
event1: baseEvent,
|
||||
|
||||
@@ -17,8 +17,7 @@ describe("refreshCalendarWithSyncToken", () => {
|
||||
id: "user1/cal1",
|
||||
name: "Test Calendar",
|
||||
link: "/calendars/user1/cal1.json",
|
||||
owner: "Test User",
|
||||
ownerEmails: ["test@example.com"],
|
||||
owner: { firstname: "Test User", emails: ["test@example.com"] },
|
||||
description: "Test calendar description",
|
||||
delegated: false,
|
||||
color: { light: "#006BD8", dark: "#FFF" },
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
||||
import { AttendanceValidation } from "@/features/Events/AttendanceValidation/AttendanceValidation";
|
||||
import { ContextualizedEvent } from "@/features/Events/EventsTypes";
|
||||
import { userAttendee } from "@/features/User/models/attendee";
|
||||
import { userData } from "@/features/User/userDataTypes";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
jest.mock("twake-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
jest.mock("@/app/hooks", () => ({
|
||||
useAppDispatch: () => jest.fn(),
|
||||
useAppSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
const makeUser = (email: string): userData => ({
|
||||
email,
|
||||
given_name: "Alice",
|
||||
family_name: "User",
|
||||
name: "Alice User",
|
||||
sid: "user1",
|
||||
sub: "user1",
|
||||
});
|
||||
|
||||
const makeContext = (
|
||||
overrides: Partial<ContextualizedEvent> = {}
|
||||
): ContextualizedEvent =>
|
||||
({
|
||||
event: {
|
||||
uid: "event-1",
|
||||
calId: "user1/cal1",
|
||||
title: "Test",
|
||||
start: "2024-01-15T10:00:00",
|
||||
end: "2024-01-15T11:00:00",
|
||||
organizer: { cal_address: "owner@example.com" },
|
||||
attendee: [
|
||||
{ cal_address: "owner@example.com", partstat: "NEEDS-ACTION" },
|
||||
],
|
||||
},
|
||||
calendar: {
|
||||
id: "user1/cal1",
|
||||
name: "Test",
|
||||
delegated: false,
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {},
|
||||
},
|
||||
currentUserAttendee: {
|
||||
cal_address: "alice@example.com",
|
||||
partstat: "NEEDS-ACTION",
|
||||
},
|
||||
isOwn: true,
|
||||
isOrganizer: false,
|
||||
isRecurring: false,
|
||||
...overrides,
|
||||
}) as unknown as ContextualizedEvent;
|
||||
|
||||
const noopSetFunc = jest.fn();
|
||||
|
||||
describe("AttendanceValidation - delegation", () => {
|
||||
describe("non-delegated calendar", () => {
|
||||
it("renders when isOwn and currentUserAttendee exists", () => {
|
||||
const { container } = render(
|
||||
<AttendanceValidation
|
||||
contextualizedEvent={makeContext({ isOwn: true })}
|
||||
user={makeUser("alice@example.com")}
|
||||
setAfterChoiceFunc={noopSetFunc}
|
||||
setOpenEditModePopup={noopSetFunc}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when not isOwn and not delegated", () => {
|
||||
const { container } = render(
|
||||
<AttendanceValidation
|
||||
contextualizedEvent={makeContext({ isOwn: false })}
|
||||
user={makeUser("other@example.com")}
|
||||
setAfterChoiceFunc={noopSetFunc}
|
||||
setOpenEditModePopup={noopSetFunc}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("delegated calendar", () => {
|
||||
const delegatedContext = makeContext({
|
||||
isOwn: false, // logged-in user is not the owner
|
||||
calendar: {
|
||||
id: "user2/cal1",
|
||||
name: "Delegated Cal",
|
||||
delegated: true,
|
||||
owner: { emails: ["owner@example.com"] },
|
||||
events: {},
|
||||
} as Calendar,
|
||||
currentUserAttendee: {
|
||||
cal_address: "owner@example.com",
|
||||
partstat: "NEEDS-ACTION",
|
||||
} as userAttendee,
|
||||
event: { class: "PUBLIC" },
|
||||
});
|
||||
|
||||
it("renders even when isOwn is false", () => {
|
||||
const { container } = render(
|
||||
<AttendanceValidation
|
||||
contextualizedEvent={delegatedContext}
|
||||
user={makeUser("alice@example.com")}
|
||||
setAfterChoiceFunc={noopSetFunc}
|
||||
setOpenEditModePopup={noopSetFunc}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders regardless of currentUserAttendee presence", () => {
|
||||
const { container } = render(
|
||||
<AttendanceValidation
|
||||
contextualizedEvent={{
|
||||
...delegatedContext,
|
||||
currentUserAttendee: undefined,
|
||||
}}
|
||||
user={makeUser("alice@example.com")}
|
||||
setAfterChoiceFunc={noopSetFunc}
|
||||
setOpenEditModePopup={noopSetFunc}
|
||||
/>
|
||||
);
|
||||
// delegated flag alone should keep it visible
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
stringAvatar,
|
||||
stringToColor,
|
||||
} from "@/components/Event/utils/eventUtils";
|
||||
import * as calendarSlice from "@/features/Calendars/CalendarSlice";
|
||||
import { DelegationAccess } from "@/features/Calendars/CalendarTypes";
|
||||
import * as eventThunks from "@/features/Calendars/services";
|
||||
import EventPreviewModal from "@/features/Events/EventDisplayPreview";
|
||||
import EventUpdateModal from "@/features/Events/EventUpdateModal";
|
||||
@@ -98,7 +98,7 @@ describe("Event Preview Display", () => {
|
||||
end: day.toISOString(),
|
||||
},
|
||||
},
|
||||
ownerEmails: ["test@test.com"],
|
||||
owner: { emails: ["test@test.com"] },
|
||||
},
|
||||
"otherCal/cal": {
|
||||
id: "otherCal/cal",
|
||||
@@ -659,10 +659,6 @@ describe("Event Preview Display", () => {
|
||||
|
||||
fireEvent.click(emailButton);
|
||||
|
||||
const event =
|
||||
preloadedState.calendars.list["667037022b752d0026472254/cal1"].events[
|
||||
"event1"
|
||||
];
|
||||
const expectedUrl = `test/mailto/?uri=mailto:john@test.com&subject=Test%20Event`;
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledWith(expectedUrl);
|
||||
@@ -774,7 +770,7 @@ describe("Event Preview Display", () => {
|
||||
name: "Calendar 1",
|
||||
id: "667037022b752d0026472254/cal1",
|
||||
color: "#FF0000",
|
||||
ownerEmails: ownerEmails,
|
||||
owner: { emails: ownerEmails },
|
||||
events: {
|
||||
event1: {
|
||||
uid: "event1",
|
||||
@@ -988,7 +984,7 @@ describe("Event Preview Display", () => {
|
||||
attendee: attendees,
|
||||
},
|
||||
},
|
||||
ownerEmails: ["test@test.com"],
|
||||
owner: { emails: ["test@test.com"] },
|
||||
},
|
||||
},
|
||||
pending: false,
|
||||
@@ -1555,6 +1551,161 @@ describe("Event Preview Display", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventDisplayPreview - delegation", () => {
|
||||
const delegatedBaseEvent = {
|
||||
uid: "event-1",
|
||||
calId: "user2/cal1",
|
||||
title: "Delegated Event",
|
||||
start: day.toISOString(),
|
||||
end: day.toISOString(),
|
||||
organizer: { cal_address: "owner@example.com" },
|
||||
attendee: [
|
||||
{ cal_address: "owner@example.com", partstat: "NEEDS-ACTION" },
|
||||
],
|
||||
URL: "/calendars/user2/cal1/event-1.ics",
|
||||
};
|
||||
|
||||
const makeDelegatedState = (
|
||||
eventOverrides = {},
|
||||
access: DelegationAccess = {
|
||||
write: true,
|
||||
freebusy: false,
|
||||
read: true,
|
||||
"write-properties": false,
|
||||
all: false,
|
||||
}
|
||||
) => ({
|
||||
...preloadedState,
|
||||
calendars: {
|
||||
list: {
|
||||
"user2/cal1": {
|
||||
id: "user2/cal1",
|
||||
name: "Delegated Calendar",
|
||||
delegated: true,
|
||||
access,
|
||||
owner: {
|
||||
id: "user2",
|
||||
firstname: "Bob",
|
||||
lastname: "Owner",
|
||||
emails: ["owner@example.com"],
|
||||
preferredEmail: "owner@example.com",
|
||||
},
|
||||
color: { light: "#FF0000", dark: "#000" },
|
||||
events: {
|
||||
"event-1": { ...delegatedBaseEvent, ...eventOverrides },
|
||||
},
|
||||
},
|
||||
},
|
||||
templist: {},
|
||||
pending: false,
|
||||
},
|
||||
user: {
|
||||
userData: {
|
||||
...preloadedState.user.userData,
|
||||
email: "alice@example.com",
|
||||
openpaasId: "user1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("edit button visibility", () => {
|
||||
it("shows edit button when calendar is write-delegated and owner is organizer", () => {
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
eventId="event-1"
|
||||
calId="user2/cal1"
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
makeDelegatedState()
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("EditIcon").closest("button")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show edit button when delegated but owner is not organizer", () => {
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
eventId="event-1"
|
||||
calId="user2/cal1"
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
makeDelegatedState({
|
||||
organizer: { cal_address: "someone-else@example.com" },
|
||||
})
|
||||
);
|
||||
expect(screen.queryByTestId("EditIcon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show edit button when delegated with read-only access", () => {
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
eventId="event-1"
|
||||
calId="user2/cal1"
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
makeDelegatedState(
|
||||
{},
|
||||
{
|
||||
write: false,
|
||||
freebusy: false,
|
||||
read: true,
|
||||
"write-properties": false,
|
||||
all: false,
|
||||
}
|
||||
)
|
||||
);
|
||||
expect(screen.queryByTestId("EditIcon")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete menu item visibility", () => {
|
||||
it("shows delete option when calendar is write-delegated", () => {
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
eventId="event-1"
|
||||
calId="user2/cal1"
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
makeDelegatedState()
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("MoreVertIcon"));
|
||||
expect(
|
||||
screen.getByText("eventPreview.deleteEvent")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show delete option when delegated with read-only access", () => {
|
||||
renderWithProviders(
|
||||
<EventPreviewModal
|
||||
eventId="event-1"
|
||||
calId="user2/cal1"
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
makeDelegatedState(
|
||||
{},
|
||||
{
|
||||
write: false,
|
||||
freebusy: false,
|
||||
read: true,
|
||||
"write-properties": false,
|
||||
all: false,
|
||||
}
|
||||
)
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("MoreVertIcon"));
|
||||
expect(
|
||||
screen.queryByText("eventPreview.deleteEvent")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("BUGFIX", () => {
|
||||
it("doesnt render anything next to date of all day preview", () => {
|
||||
const allDayState = {
|
||||
|
||||
@@ -95,7 +95,7 @@ const basePreloadedState = {
|
||||
],
|
||||
},
|
||||
},
|
||||
ownerEmails: ["test@test.com"],
|
||||
owner: { emails: ["test@test.com"] },
|
||||
},
|
||||
},
|
||||
pending: false,
|
||||
@@ -1151,7 +1151,7 @@ describe("Event URL handling for recurring events", () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
ownerEmails: ["test@test.com"],
|
||||
owner: { emails: ["test@test.com"] },
|
||||
},
|
||||
"667037022b752d0026472254/cal2": {
|
||||
id: "667037022b752d0026472254/cal2",
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
|
||||
name: "Test Calendar",
|
||||
color: "#FF0000",
|
||||
events: {},
|
||||
ownerEmails: ["test@test.com"],
|
||||
owner: { emails: ["test@test.com"] },
|
||||
},
|
||||
},
|
||||
pending: false,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
||||
import { VObjectProperty } from "@/features/Calendars/types/CalendarData";
|
||||
import { CalendarEvent, RepetitionObject } from "@/features/Events/EventsTypes";
|
||||
import {
|
||||
calendarEventToJCal,
|
||||
@@ -10,7 +12,7 @@ import {
|
||||
|
||||
describe("parseCalendarEvent", () => {
|
||||
const baseColor = { light: "#00FF00" };
|
||||
const calendarId = "calendar-123";
|
||||
const calendar = { id: "calendar-123" } as Calendar;
|
||||
|
||||
it("parses a full event with MAILTO in caps correctly", () => {
|
||||
const rawData = [
|
||||
@@ -33,12 +35,12 @@ describe("parseCalendarEvent", () => {
|
||||
["TRANSP", {}, "text", "OPAQUE"],
|
||||
["CLASS", {}, "text", "PUBLIC"],
|
||||
["DTSTAMP", {}, "date-time", "2025-07-18T08:00:00Z"],
|
||||
] as unknown as [string, Record<string, string>, string, any];
|
||||
] as VObjectProperty[];
|
||||
|
||||
const result = parseCalendarEvent(
|
||||
rawData,
|
||||
baseColor,
|
||||
calendarId,
|
||||
calendar,
|
||||
"/calendars/test.ics"
|
||||
);
|
||||
|
||||
@@ -95,12 +97,12 @@ describe("parseCalendarEvent", () => {
|
||||
["TRANSP", {}, "text", "OPAQUE"],
|
||||
["CLASS", {}, "text", "PUBLIC"],
|
||||
["DTSTAMP", {}, "date-time", "2025-07-18T08:00:00Z"],
|
||||
] as unknown as [string, Record<string, string>, string, any];
|
||||
] as VObjectProperty[];
|
||||
|
||||
const result = parseCalendarEvent(
|
||||
rawData,
|
||||
baseColor,
|
||||
calendarId,
|
||||
calendar,
|
||||
"/calendars/test.ics"
|
||||
);
|
||||
|
||||
@@ -137,16 +139,16 @@ describe("parseCalendarEvent", () => {
|
||||
});
|
||||
|
||||
it("marks allday true for DATE format", () => {
|
||||
const rawData = [
|
||||
const rawData: VObjectProperty[] = [
|
||||
["UID", {}, "text", "event-2"],
|
||||
["DTSTART", {}, "date", "2025-07-20"],
|
||||
["DTEND", {}, "date", "2025-07-21"],
|
||||
] as any;
|
||||
];
|
||||
|
||||
const result = parseCalendarEvent(
|
||||
rawData,
|
||||
baseColor,
|
||||
calendarId,
|
||||
calendar,
|
||||
"/calendars/test.ics"
|
||||
);
|
||||
|
||||
@@ -154,7 +156,7 @@ describe("parseCalendarEvent", () => {
|
||||
});
|
||||
|
||||
it("appends recurrence-id to UID if present", () => {
|
||||
const rawData: any = [
|
||||
const rawData: VObjectProperty[] = [
|
||||
["UID", {}, "text", "event-2"],
|
||||
["RECURRENCE-ID", {}, "date-time", "2025-07-18T09:00:00Z"],
|
||||
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
||||
@@ -163,7 +165,7 @@ describe("parseCalendarEvent", () => {
|
||||
const result = parseCalendarEvent(
|
||||
rawData,
|
||||
baseColor,
|
||||
calendarId,
|
||||
calendar,
|
||||
"/calendars/test.ics"
|
||||
);
|
||||
|
||||
@@ -171,24 +173,26 @@ describe("parseCalendarEvent", () => {
|
||||
});
|
||||
|
||||
it("returns error if UID or start is missing", () => {
|
||||
const rawDataMissingUid: any = [
|
||||
const rawDataMissingUid: VObjectProperty[] = [
|
||||
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
||||
];
|
||||
|
||||
const result = parseCalendarEvent(
|
||||
rawDataMissingUid,
|
||||
baseColor,
|
||||
calendarId,
|
||||
calendar,
|
||||
"/calendars/test.ics"
|
||||
);
|
||||
expect(result.error).toMatch(/missing crucial event param/);
|
||||
|
||||
const rawDataMissingStart: any = [["UID", {}, "text", "event-3"]];
|
||||
const rawDataMissingStart: VObjectProperty[] = [
|
||||
["UID", {}, "text", "event-3"],
|
||||
];
|
||||
|
||||
const result2 = parseCalendarEvent(
|
||||
rawDataMissingStart,
|
||||
baseColor,
|
||||
calendarId,
|
||||
calendar,
|
||||
"/calendars/test.ics"
|
||||
);
|
||||
expect(result2.error).toMatch(/missing crucial event param/);
|
||||
@@ -197,7 +201,7 @@ describe("parseCalendarEvent", () => {
|
||||
it("returns computed end when there is no end but a duration", () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2025-07-18T00:00:00Z"));
|
||||
|
||||
const rawDataMissing: any = [
|
||||
const rawDataMissing: VObjectProperty[] = [
|
||||
["UID", {}, "text", "event-1"],
|
||||
["DTSTART", {}, "date-time", "2025-07-18T09:00:00"],
|
||||
["duration", {}, "duration", "PT60M"],
|
||||
@@ -206,7 +210,7 @@ describe("parseCalendarEvent", () => {
|
||||
const result = parseCalendarEvent(
|
||||
rawDataMissing,
|
||||
baseColor,
|
||||
calendarId,
|
||||
calendar,
|
||||
"/calendars/test.ics"
|
||||
);
|
||||
expect(result.end).toBeDefined();
|
||||
@@ -216,7 +220,7 @@ describe("parseCalendarEvent", () => {
|
||||
});
|
||||
|
||||
it("returns 30 minutes event if end and duration are missing", () => {
|
||||
const rawDataMissing: any = [
|
||||
const rawDataMissing: VObjectProperty[] = [
|
||||
["UID", {}, "text", "event-1"],
|
||||
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
||||
];
|
||||
@@ -224,7 +228,7 @@ describe("parseCalendarEvent", () => {
|
||||
const result = parseCalendarEvent(
|
||||
rawDataMissing,
|
||||
baseColor,
|
||||
calendarId,
|
||||
calendar,
|
||||
"/calendars/test.ics"
|
||||
);
|
||||
expect(result.end).toBeDefined();
|
||||
@@ -234,23 +238,23 @@ describe("parseCalendarEvent", () => {
|
||||
});
|
||||
|
||||
it("parses alarm block correctly", () => {
|
||||
const rawData = [
|
||||
const rawData: VObjectProperty[] = [
|
||||
["UID", {}, "text", "event-5"],
|
||||
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
||||
] as any;
|
||||
];
|
||||
|
||||
const valarm: any = [
|
||||
const valarm = [
|
||||
"VALARM",
|
||||
[
|
||||
["ACTION", {}, "text", "DISPLAY"],
|
||||
["TRIGGER", {}, "duration", "-PT15M"],
|
||||
],
|
||||
];
|
||||
] as unknown as VObjectProperty[];
|
||||
|
||||
const result = parseCalendarEvent(
|
||||
rawData,
|
||||
baseColor,
|
||||
calendarId,
|
||||
calendar,
|
||||
"/calendars/test.ics",
|
||||
valarm
|
||||
);
|
||||
@@ -266,12 +270,12 @@ describe("parseCalendarEvent", () => {
|
||||
["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"],
|
||||
["ATTENDEE", {}, "cal-address", "john@example.com"],
|
||||
["ORGANIZER", {}, "cal-address", "jane@example.com"],
|
||||
] as unknown as [string, Record<string, string>, string, any];
|
||||
] as VObjectProperty[];
|
||||
|
||||
const result = parseCalendarEvent(
|
||||
rawData,
|
||||
baseColor,
|
||||
calendarId,
|
||||
calendar,
|
||||
"/calendars/test.ics"
|
||||
);
|
||||
|
||||
@@ -297,12 +301,12 @@ describe("parseCalendarEvent", () => {
|
||||
["UID", {}, "text", "event-tz"],
|
||||
["DTSTART", { tzid: "Asia/Bangkok" }, "date-time", "2025-07-18T09:00:00"],
|
||||
["DTEND", { tzid: "Asia/Bangkok" }, "date-time", "2025-07-18T10:00:00"],
|
||||
] as unknown as [string, Record<string, string>, string, any];
|
||||
] as VObjectProperty[];
|
||||
|
||||
const result = parseCalendarEvent(
|
||||
rawData,
|
||||
baseColor,
|
||||
calendarId,
|
||||
calendar,
|
||||
"/calendars/test.ics"
|
||||
);
|
||||
|
||||
@@ -319,12 +323,12 @@ describe("parseCalendarEvent", () => {
|
||||
["UID", {}, "text", "event-utc"],
|
||||
["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"],
|
||||
["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"],
|
||||
] as unknown as [string, Record<string, string>, string, any];
|
||||
] as VObjectProperty[];
|
||||
|
||||
const result = parseCalendarEvent(
|
||||
rawData,
|
||||
baseColor,
|
||||
calendarId,
|
||||
calendar,
|
||||
"/calendars/test.ics"
|
||||
);
|
||||
|
||||
@@ -337,12 +341,12 @@ describe("parseCalendarEvent", () => {
|
||||
["UID", {}, "text", "event-allday"],
|
||||
["DTSTART", {}, "date", "2025-07-18"],
|
||||
["DTEND", {}, "date", "2025-07-19"],
|
||||
] as unknown as [string, Record<string, string>, string, any];
|
||||
] as VObjectProperty[];
|
||||
|
||||
const result = parseCalendarEvent(
|
||||
rawData,
|
||||
baseColor,
|
||||
calendarId,
|
||||
calendar,
|
||||
"/calendars/test.ics"
|
||||
);
|
||||
|
||||
@@ -391,8 +395,8 @@ describe("calendarEventToJCal", () => {
|
||||
expect(vevent[0]).toBe("vevent");
|
||||
|
||||
const props = vevent[1];
|
||||
const dtstart = props.find((p: any[]) => p[0] === "dtstart");
|
||||
const dtend = props.find((p: any[]) => p[0] === "dtend");
|
||||
const dtstart = props.find((p: VObjectProperty) => p[0] === "dtstart");
|
||||
const dtend = props.find((p: VObjectProperty) => p[0] === "dtend");
|
||||
|
||||
expect(dtstart).toBeDefined();
|
||||
expect(dtstart[1]).toEqual({ tzid: "Europe/Paris" });
|
||||
@@ -474,7 +478,7 @@ describe("calendarEventToJCal", () => {
|
||||
});
|
||||
|
||||
it("converts with alarm included", () => {
|
||||
const mockEvent: any = {
|
||||
const mockEvent = {
|
||||
uid: "event-10",
|
||||
title: "Alarm Event",
|
||||
start: "2025-07-20T07:00:00.000Z",
|
||||
@@ -483,7 +487,7 @@ describe("calendarEventToJCal", () => {
|
||||
allday: false,
|
||||
alarm: { trigger: "-PT10M", action: "DISPLAY" },
|
||||
attendee: [],
|
||||
};
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
const result = calendarEventToJCal(mockEvent, "owner@example.com");
|
||||
const vevent = result[2][0];
|
||||
@@ -498,7 +502,7 @@ describe("calendarEventToJCal", () => {
|
||||
});
|
||||
|
||||
it("converts all-day events", () => {
|
||||
const mockEvent: any = {
|
||||
const mockEvent = {
|
||||
uid: "event-11",
|
||||
title: "All Day",
|
||||
start: "2025-07-21T00:00:00.000Z",
|
||||
@@ -506,7 +510,7 @@ describe("calendarEventToJCal", () => {
|
||||
timezone: "Europe/Paris",
|
||||
allday: true,
|
||||
attendee: [],
|
||||
};
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
const result = calendarEventToJCal(mockEvent);
|
||||
const veventProps = result[2][0][1];
|
||||
@@ -843,7 +847,9 @@ describe("calendarEventToJCal", () => {
|
||||
const [vevent] = result[2];
|
||||
const props = vevent[1];
|
||||
|
||||
const sequenceProp = props.find((p: any[]) => p[0] === "sequence");
|
||||
const sequenceProp = props.find(
|
||||
(p: VObjectProperty) => p[0] === "sequence"
|
||||
);
|
||||
expect(sequenceProp).toBeDefined();
|
||||
expect(sequenceProp).toEqual(["sequence", {}, "integer", 3]);
|
||||
});
|
||||
@@ -864,7 +870,9 @@ describe("calendarEventToJCal", () => {
|
||||
const [vevent] = result[2];
|
||||
const props = vevent[1];
|
||||
|
||||
const sequenceProp = props.find((p: any[]) => p[0] === "sequence");
|
||||
const sequenceProp = props.find(
|
||||
(p: VObjectProperty) => p[0] === "sequence"
|
||||
);
|
||||
expect(sequenceProp).toBeDefined();
|
||||
expect(sequenceProp).toEqual(["sequence", {}, "integer", 1]);
|
||||
});
|
||||
@@ -887,7 +895,9 @@ describe("calendarEventToJCal", () => {
|
||||
const [vevent] = result[2];
|
||||
const props = vevent[1];
|
||||
|
||||
const sequenceProp = props.find((p: any[]) => p[0] === "sequence");
|
||||
const sequenceProp = props.find(
|
||||
(p: VObjectProperty) => p[0] === "sequence"
|
||||
);
|
||||
expect(sequenceProp).toBeDefined();
|
||||
expect(sequenceProp).toEqual(["sequence", {}, "integer", 2]);
|
||||
});
|
||||
@@ -910,18 +920,22 @@ describe("calendarEventToJCal", () => {
|
||||
const [vevent] = result[2];
|
||||
const props = vevent[1];
|
||||
|
||||
const sequenceProp = props.find((p: any[]) => p[0] === "sequence");
|
||||
const sequenceProp = props.find(
|
||||
(p: VObjectProperty) => p[0] === "sequence"
|
||||
);
|
||||
expect(sequenceProp).toBeDefined();
|
||||
expect(sequenceProp).toEqual(["sequence", {}, "integer", 5]);
|
||||
|
||||
// Also verify recurrence-id is present
|
||||
const recurrenceIdProp = props.find((p: any[]) => p[0] === "recurrence-id");
|
||||
const recurrenceIdProp = props.find(
|
||||
(p: VObjectProperty) => p[0] === "recurrence-id"
|
||||
);
|
||||
expect(recurrenceIdProp).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("combineMasterDateWithFormTime", () => {
|
||||
const mockFormatDateTime = (iso: string, tz: string) => {
|
||||
const mockFormatDateTime = (iso: string) => {
|
||||
const date = new Date(iso);
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
@@ -1127,12 +1141,6 @@ describe("normalizeTimezone", () => {
|
||||
|
||||
describe("detectRecurringEventChanges", () => {
|
||||
const mockResolveTimezone = (tz: string) => tz;
|
||||
const mockFormatDateTime = (iso: string, tz: string) => {
|
||||
const date = new Date(iso);
|
||||
const hour = String(date.getUTCHours()).padStart(2, "0");
|
||||
const minute = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
return `2025-10-14T${hour}:${minute}`;
|
||||
};
|
||||
|
||||
it("should detect when only time changes", () => {
|
||||
const oldEvent = {
|
||||
@@ -1346,3 +1354,69 @@ describe("detectRecurringEventChanges", () => {
|
||||
expect(result.repetitionRulesChanged).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCalendarEvent - delegated calendar URL handling", () => {
|
||||
const baseColor = { light: "#00FF00" };
|
||||
const rawData: VObjectProperty[] = [
|
||||
["uid", {}, "text", "event-uid-123"],
|
||||
["dtstart", {}, "date-time", "20240115T100000Z"],
|
||||
["dtend", {}, "date-time", "20240115T110000Z"],
|
||||
["summary", {}, "text", "Test Event"],
|
||||
];
|
||||
|
||||
const eventURL = "/calendars/user2/cal1/event-uid-123.ics";
|
||||
|
||||
describe("non-delegated calendar", () => {
|
||||
const calendar = {
|
||||
id: "user1/cal1",
|
||||
delegated: false,
|
||||
link: "/calendars/user1/cal1.json",
|
||||
} as Calendar;
|
||||
|
||||
it("uses the raw eventURL as-is", () => {
|
||||
const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL);
|
||||
expect(result.URL).toBe(eventURL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delegated calendar", () => {
|
||||
const calendar = {
|
||||
id: "user2/cal1",
|
||||
delegated: true,
|
||||
link: "/calendars/user2/cal1.json",
|
||||
owner: { emails: ["owner@example.com"] },
|
||||
} as unknown as Calendar;
|
||||
|
||||
it("rewrites URL using calendar link base path", () => {
|
||||
const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL);
|
||||
expect(result.URL).toBe("/calendars/user2/cal1/event-uid-123.ics");
|
||||
});
|
||||
|
||||
it("preserves the event filename from the original URL", () => {
|
||||
const differentSourceURL = "/calendars/someother/path/event-uid-123.ics";
|
||||
const result = parseCalendarEvent(
|
||||
rawData,
|
||||
baseColor,
|
||||
calendar,
|
||||
differentSourceURL
|
||||
);
|
||||
// filename is extracted and rebased onto calendar link
|
||||
expect(result.URL).toMatch(
|
||||
/\/calendars\/user2\/cal1\/event-uid-123\.ics$/
|
||||
);
|
||||
});
|
||||
|
||||
it("still sets calId from calendar.id", () => {
|
||||
const result = parseCalendarEvent(rawData, baseColor, calendar, eventURL);
|
||||
expect(result.calId).toBe("user2/cal1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty calendar object (used in deleteEventInstance)", () => {
|
||||
it("does not throw and sets URL to empty string", () => {
|
||||
const result = parseCalendarEvent(rawData, baseColor, {} as Calendar, "");
|
||||
expect(result.URL).toBe("");
|
||||
expect(result.calId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("EventSearchBar", () => {
|
||||
name: "Calendar personal",
|
||||
id: "user1/cal1",
|
||||
color: { light: "#FF0000", dark: "#000" },
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {
|
||||
event1: {
|
||||
id: "event1",
|
||||
@@ -58,7 +58,7 @@ describe("EventSearchBar", () => {
|
||||
delegated: true,
|
||||
id: "user2/cal1",
|
||||
color: { light: "#FF0000", dark: "#000" },
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {
|
||||
event1: {
|
||||
id: "event1",
|
||||
@@ -89,7 +89,7 @@ describe("EventSearchBar", () => {
|
||||
name: "Calendar shared",
|
||||
id: "user3/cal1",
|
||||
color: { light: "#FF0000", dark: "#000" },
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {
|
||||
event1: {
|
||||
id: "event1",
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("SearchResultsPage", () => {
|
||||
name: "Calendar personal",
|
||||
id: "user1/cal1",
|
||||
color: { light: "#FF0000", dark: "#000" },
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {
|
||||
event1: {
|
||||
id: "event1",
|
||||
@@ -56,7 +56,7 @@ describe("SearchResultsPage", () => {
|
||||
delegated: true,
|
||||
id: "user2/cal1",
|
||||
color: { light: "#FF0000", dark: "#000" },
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {
|
||||
event1: {
|
||||
id: "event1",
|
||||
@@ -87,7 +87,7 @@ describe("SearchResultsPage", () => {
|
||||
name: "Calendar shared",
|
||||
id: "user3/cal1",
|
||||
color: { light: "#FF0000", dark: "#000" },
|
||||
ownerEmails: ["alice@example.com"],
|
||||
owner: { emails: ["alice@example.com"] },
|
||||
events: {
|
||||
event1: {
|
||||
id: "event1",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { buildDelegatedEventURL } from "@/features/Events/eventUtils";
|
||||
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
||||
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||
|
||||
const makeCalendar = (link: string): Calendar =>
|
||||
({
|
||||
id: "user2/cal1",
|
||||
delegated: true,
|
||||
link,
|
||||
owner: { emails: ["owner@example.com"] },
|
||||
}) as Calendar;
|
||||
|
||||
const makeEvent = (url: string): CalendarEvent =>
|
||||
({ URL: url, calId: "user2/cal1" }) as CalendarEvent;
|
||||
|
||||
describe("buildDelegatedEventURL", () => {
|
||||
it("rebases event filename onto calendar link base path", () => {
|
||||
const calendar = makeCalendar("/calendars/user2/cal1.json");
|
||||
const event = makeEvent("/calendars/someother/path/event-abc.ics");
|
||||
expect(buildDelegatedEventURL(calendar, event)).toBe(
|
||||
"/calendars/user2/cal1/event-abc.ics"
|
||||
);
|
||||
});
|
||||
|
||||
it("strips .json suffix from calendar link", () => {
|
||||
const calendar = makeCalendar("/calendars/user2/cal1.json");
|
||||
const event = makeEvent("/calendars/user2/cal1/event-abc.ics");
|
||||
const result = buildDelegatedEventURL(calendar, event);
|
||||
expect(result).not.toContain(".json");
|
||||
});
|
||||
|
||||
it("preserves the exact filename from the event URL", () => {
|
||||
const calendar = makeCalendar("/calendars/user2/cal1.json");
|
||||
const event = makeEvent("/calendars/user2/cal1/some-uid-with-dashes.ics");
|
||||
expect(buildDelegatedEventURL(calendar, event)).toBe(
|
||||
"/calendars/user2/cal1/some-uid-with-dashes.ics"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when event URL has no filename", () => {
|
||||
const calendar = makeCalendar("/calendars/user2/cal1.json");
|
||||
const event = makeEvent("");
|
||||
expect(() => buildDelegatedEventURL(calendar, event)).toThrow(
|
||||
/Cannot extract filename from event URL/
|
||||
);
|
||||
});
|
||||
|
||||
it("works with nested calendar link paths", () => {
|
||||
const calendar = makeCalendar("/dav/calendars/users/user2/cal1.json");
|
||||
const event = makeEvent("/other/path/event-xyz.ics");
|
||||
expect(buildDelegatedEventURL(calendar, event)).toBe(
|
||||
"/dav/calendars/users/user2/cal1/event-xyz.ics"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { getCalendarVisibility } from "@/components/Calendar/utils/calendarUtils";
|
||||
import {
|
||||
eventToFullCalendarFormat,
|
||||
getCalendarVisibility,
|
||||
} from "@/components/Calendar/utils/calendarUtils";
|
||||
import { Calendar, DelegationAccess } from "@/features/Calendars/CalendarTypes";
|
||||
import { AclEntry } from "@/features/Calendars/types/CalendarData";
|
||||
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||
import { userOrganiser } from "@/features/User/userDataTypes";
|
||||
|
||||
describe("getCalendarVisibility", () => {
|
||||
it("returns 'public' when {DAV:}authenticated has {DAV:}read", () => {
|
||||
@@ -67,3 +73,159 @@ describe("getCalendarVisibility", () => {
|
||||
expect(getCalendarVisibility(acl)).toBe("public");
|
||||
});
|
||||
});
|
||||
|
||||
jest.mock("twake-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const baseEvent: CalendarEvent = {
|
||||
uid: "event-1",
|
||||
calId: "user1/cal1",
|
||||
title: "Test Event",
|
||||
start: "2024-01-15T10:00:00",
|
||||
end: "2024-01-15T11:00:00",
|
||||
allday: false,
|
||||
organizer: { cal_address: "owner@example.com" } as userOrganiser,
|
||||
color: { light: "#FF0000" },
|
||||
class: "PUBLIC",
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
const makeCalendar = (overrides: Partial<Calendar> = {}): Calendar =>
|
||||
({
|
||||
id: "user1/cal1",
|
||||
name: "Test Calendar",
|
||||
delegated: false,
|
||||
access: {} as DelegationAccess,
|
||||
owner: { emails: ["alice@example.com"], firstname: "Alice" },
|
||||
color: { light: "#FF0000" },
|
||||
events: {},
|
||||
...overrides,
|
||||
}) as Calendar;
|
||||
|
||||
const callFormat = (
|
||||
event: CalendarEvent,
|
||||
calendars: Record<string, Calendar>,
|
||||
userAddress = "alice@example.com",
|
||||
userId = "user1"
|
||||
) =>
|
||||
eventToFullCalendarFormat([event], [], userId, userAddress, false, calendars);
|
||||
|
||||
describe("eventToFullCalendarFormat - editable flag", () => {
|
||||
describe("personal calendar (non-delegated)", () => {
|
||||
const calendar = makeCalendar({ delegated: false });
|
||||
|
||||
it("is editable when user is organizer", () => {
|
||||
const event = {
|
||||
...baseEvent,
|
||||
organizer: { cal_address: "alice@example.com" },
|
||||
} as CalendarEvent;
|
||||
const [result] = callFormat(event, { "user1/cal1": calendar });
|
||||
expect(result.editable).toBe(true);
|
||||
});
|
||||
|
||||
it("is not editable when user is not organizer", () => {
|
||||
const event = {
|
||||
...baseEvent,
|
||||
organizer: { cal_address: "other@example.com" },
|
||||
} as CalendarEvent;
|
||||
const [result] = callFormat(event, { "user1/cal1": calendar });
|
||||
expect(result.editable).toBe(false);
|
||||
});
|
||||
|
||||
it("is not editable when pending", () => {
|
||||
const event = {
|
||||
...baseEvent,
|
||||
organizer: { cal_address: "alice@example.com" },
|
||||
} as CalendarEvent;
|
||||
const [result] = eventToFullCalendarFormat(
|
||||
[event],
|
||||
[],
|
||||
"user1",
|
||||
"alice@example.com",
|
||||
true,
|
||||
{ "user1/cal1": calendar }
|
||||
);
|
||||
expect(result.editable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delegated calendar", () => {
|
||||
const writeDelegatedCalendar = makeCalendar({
|
||||
id: "user2/cal1",
|
||||
delegated: true,
|
||||
access: { write: true } as DelegationAccess,
|
||||
owner: { emails: ["owner@example.com"], firstname: "Owner" },
|
||||
});
|
||||
const readDelegatedCalendar = makeCalendar({
|
||||
id: "user2/cal1",
|
||||
delegated: true,
|
||||
access: { read: true } as DelegationAccess,
|
||||
owner: { emails: ["owner@example.com"], firstname: "Owner" },
|
||||
});
|
||||
const event = { ...baseEvent, calId: "user2/cal1" };
|
||||
|
||||
it("is editable when owner is organizer and delegated user has write rights", () => {
|
||||
const [result] = callFormat(
|
||||
{
|
||||
...event,
|
||||
organizer: { cal_address: "owner@example.com" },
|
||||
} as CalendarEvent,
|
||||
{ "user2/cal1": writeDelegatedCalendar }
|
||||
);
|
||||
expect(result.editable).toBe(true);
|
||||
});
|
||||
|
||||
it("is not editable when owner is organizer but delegated user only has read access", () => {
|
||||
const [result] = callFormat(
|
||||
{
|
||||
...event,
|
||||
organizer: { cal_address: "owner@example.com" },
|
||||
} as CalendarEvent,
|
||||
{ "user2/cal1": readDelegatedCalendar }
|
||||
);
|
||||
expect(result.editable).toBe(false);
|
||||
});
|
||||
|
||||
it("is not editable when owner is not organizer", () => {
|
||||
const [result] = callFormat(
|
||||
{
|
||||
...event,
|
||||
organizer: { cal_address: "someone-else@example.com" },
|
||||
} as CalendarEvent,
|
||||
{ "user2/cal1": writeDelegatedCalendar }
|
||||
);
|
||||
expect(result.editable).toBe(false);
|
||||
});
|
||||
|
||||
it("is not editable when pending even if owner is organizer", () => {
|
||||
const [result] = eventToFullCalendarFormat(
|
||||
[
|
||||
{
|
||||
...event,
|
||||
organizer: { cal_address: "owner@example.com" },
|
||||
} as CalendarEvent,
|
||||
],
|
||||
[],
|
||||
"user1",
|
||||
"alice@example.com",
|
||||
true,
|
||||
{ "user2/cal1": writeDelegatedCalendar }
|
||||
);
|
||||
expect(result.editable).toBe(false);
|
||||
});
|
||||
|
||||
it("uses owner email not logged-in user email for organizer check", () => {
|
||||
// logged-in user is alice, owner is owner@example.com
|
||||
// organizer matches owner → editable
|
||||
const [result] = callFormat(
|
||||
{
|
||||
...event,
|
||||
organizer: { cal_address: "owner@example.com" },
|
||||
} as CalendarEvent,
|
||||
{ "user2/cal1": writeDelegatedCalendar },
|
||||
"alice@example.com" // logged-in user, should NOT be used for delegated
|
||||
);
|
||||
expect(result.editable).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createEventContext } from "@/features/Events/createEventContext";
|
||||
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
||||
import { CalendarEvent } from "@/features/Events/EventsTypes";
|
||||
import { userData } from "@/features/User/userDataTypes";
|
||||
import { userAttendee } from "@/features/User/models/attendee";
|
||||
|
||||
const makeUser = (email: string): userData => ({
|
||||
email,
|
||||
given_name: "Alice",
|
||||
family_name: "User",
|
||||
name: "Alice User",
|
||||
sid: "user1",
|
||||
sub: "user1",
|
||||
});
|
||||
|
||||
const makeEvent = (overrides: Partial<CalendarEvent> = {}): CalendarEvent =>
|
||||
({
|
||||
uid: "event-1",
|
||||
calId: "user1/cal1",
|
||||
title: "Test Event",
|
||||
start: "2024-01-15T10:00:00",
|
||||
end: "2024-01-15T11:00:00",
|
||||
organizer: { cal_address: "alice@example.com" },
|
||||
attendee: [
|
||||
{ cal_address: "alice@example.com", partstat: "ACCEPTED" },
|
||||
{ cal_address: "owner@example.com", partstat: "ACCEPTED" },
|
||||
{ cal_address: "other@example.com", partstat: "NEEDS-ACTION" },
|
||||
],
|
||||
...overrides,
|
||||
}) as CalendarEvent;
|
||||
|
||||
const makeCalendar = (overrides: Partial<Calendar> = {}): Calendar =>
|
||||
({
|
||||
id: "user1/cal1",
|
||||
name: "Test Calendar",
|
||||
delegated: false,
|
||||
owner: { emails: ["alice@example.com"], firstname: "Alice" },
|
||||
events: {},
|
||||
...overrides,
|
||||
}) as Calendar;
|
||||
|
||||
describe("createEventContext", () => {
|
||||
describe("non-delegated calendar", () => {
|
||||
const calendar = makeCalendar({ delegated: false });
|
||||
const user = makeUser("alice@example.com");
|
||||
|
||||
it("finds currentUserAttendee by logged-in user email", () => {
|
||||
const event = makeEvent();
|
||||
const ctx = createEventContext(event, calendar, user);
|
||||
expect(ctx.currentUserAttendee?.cal_address).toBe("alice@example.com");
|
||||
});
|
||||
|
||||
it("returns null currentUserAttendee when user is not an attendee", () => {
|
||||
const event = makeEvent({ attendee: [] });
|
||||
const ctx = createEventContext(event, calendar, user);
|
||||
expect(ctx.currentUserAttendee).toBeUndefined();
|
||||
});
|
||||
|
||||
it("isOwn is true when user email is in owner emails", () => {
|
||||
const ctx = createEventContext(makeEvent(), calendar, user);
|
||||
expect(ctx.isOwn).toBe(true);
|
||||
});
|
||||
|
||||
it("isOwn is false when user email is not in owner emails", () => {
|
||||
const other = makeUser("other@example.com");
|
||||
const ctx = createEventContext(makeEvent(), calendar, other);
|
||||
expect(ctx.isOwn).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delegated calendar", () => {
|
||||
const calendar = makeCalendar({
|
||||
id: "user2/cal1",
|
||||
delegated: true,
|
||||
owner: { emails: ["owner@example.com"], firstname: "Owner" },
|
||||
});
|
||||
const user = makeUser("alice@example.com"); // logged-in user, not the owner
|
||||
|
||||
it("finds currentUserAttendee by owner email, not logged-in user email", () => {
|
||||
const event = makeEvent({ calId: "user2/cal1" });
|
||||
const ctx = createEventContext(event, calendar, user);
|
||||
expect(ctx.currentUserAttendee?.cal_address).toBe("owner@example.com");
|
||||
});
|
||||
|
||||
it("does not find logged-in user as attendee for delegated calendar", () => {
|
||||
const event = makeEvent({
|
||||
calId: "user2/cal1",
|
||||
attendee: [
|
||||
{
|
||||
cal_address: "owner@example.com",
|
||||
partstat: "ACCEPTED",
|
||||
} as userAttendee,
|
||||
],
|
||||
});
|
||||
const ctx = createEventContext(event, calendar, user);
|
||||
// currentUserAttendee should be owner's, not alice's
|
||||
expect(ctx.currentUserAttendee?.cal_address).toBe("owner@example.com");
|
||||
});
|
||||
|
||||
it("returns undefined currentUserAttendee when owner is not an attendee", () => {
|
||||
const event = makeEvent({
|
||||
calId: "user2/cal1",
|
||||
attendee: [
|
||||
{
|
||||
cal_address: "alice@example.com",
|
||||
partstat: "ACCEPTED",
|
||||
} as userAttendee,
|
||||
],
|
||||
});
|
||||
const ctx = createEventContext(event, calendar, user);
|
||||
expect(ctx.currentUserAttendee).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes event through unchanged", () => {
|
||||
const event = makeEvent({ calId: "user2/cal1" });
|
||||
const ctx = createEventContext(event, calendar, user);
|
||||
expect(ctx.event).toBe(event);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user