[#527] calendar delegation CRUD (#581)

* [#527] added panel to grant right for calendar delegation

* [#527] added rights management to access modal

* [#527] conditionnal rendering of access granting based on rights

* [#527] saves on close except cancel

* [#527] added tests
This commit is contained in:
Camille Moussu
2026-03-02 09:34:45 +01:00
committed by GitHub
parent 1af571d98e
commit 72a9d0ee05
11 changed files with 1073 additions and 69 deletions
@@ -0,0 +1,426 @@
import { AccessTab } from "@/components/Calendar/AccessTab";
import {
CalendarAccessRights,
UserWithAccess,
} from "@/components/Calendar/CalendarAccessRights";
import CalendarPopover from "@/components/Calendar/CalendarModal";
import { updateDelegationCalendar } from "@/features/Calendars/api/updateDelegationCalendar";
import { AccessRight, Calendar } from "@/features/Calendars/CalendarTypes";
import * as eventThunks from "@/features/Calendars/services";
import * as delegationThunks from "@/features/Calendars/services/updateDelegationCalendarAsync";
import { getUserDetails } from "@/features/User/userAPI";
import { accessRightToDavProp } from "@/utils/accessRightToDavProp";
import { api } from "@/utils/apiUtils";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../utils/Renderwithproviders";
jest.mock("@/utils/apiUtils", () => ({
api: { post: jest.fn() },
}));
jest.mock("@/features/User/userAPI", () => ({
getUserDetails: jest.fn(),
}));
jest.mock("@/features/Calendars/CalendarApi", () => ({
getSecretLink: jest.fn().mockReturnValue(""),
exportCalendar: jest.fn(),
}));
const mockThunkWithUnwrap = (resolvedValue: unknown = {}) =>
jest.fn().mockImplementation(() => {
const result = Object.assign(Promise.resolve(resolvedValue), {
unwrap: () => Promise.resolve(resolvedValue),
});
return jest.fn().mockReturnValue(result);
});
describe("accessRightToDavProp", () => {
it("maps 5 (ADMIN) → dav:administration", () => {
expect(accessRightToDavProp(5)).toBe("dav:administration");
});
it("maps 3 (EDITOR) → dav:read-write", () => {
expect(accessRightToDavProp(3)).toBe("dav:read-write");
});
it("maps 2 (VIEW) → dav:read", () => {
expect(accessRightToDavProp(2)).toBe("dav:read");
});
it("defaults to dav:read for unknown values (covered by default case)", () => {
expect(accessRightToDavProp(999 as AccessRight)).toBe("dav:read");
});
});
describe("updateDelegationCalendar", () => {
beforeEach(() => jest.clearAllMocks());
it("posts to the correct DAV endpoint with the share body", async () => {
(api.post as jest.Mock).mockResolvedValue({ ok: true });
const share = {
set: [{ "dav:href": "mailto:alice@example.com", "dav:read": true }],
remove: [],
};
await updateDelegationCalendar("/calendars/user/cal1.json", share);
await waitFor(() =>
expect(api.post).toHaveBeenCalledWith(
"dav/calendars/user/cal1.json",
expect.objectContaining({
body: JSON.stringify({ share }),
})
)
);
});
});
const baseCalendar: Calendar = {
id: "user1/cal1",
name: "My Calendar",
description: "",
color: { color: "#0062FF", dark: "#FFF" },
link: "/calendars/user1/cal1.json",
visibility: "public",
events: {},
invite: [],
owner: { emails: ["user1@example.com"] },
};
describe("CalendarAccessRights", () => {
const mockOnChange = jest.fn();
const mockOnInvitesLoaded = jest.fn();
beforeEach(() => jest.clearAllMocks());
it("renders the grant access rights section", () => {
renderWithProviders(
<CalendarAccessRights
calendar={baseCalendar}
value={[]}
onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded}
/>
);
expect(
screen.getByText("calendarPopover.access.grantAccessRights")
).toBeInTheDocument();
});
it("shows a list of users already passed in via value prop", () => {
const users: UserWithAccess[] = [
{
openpaasId: "abc",
displayName: "Alice",
email: "alice@example.com",
accessRight: 2,
},
];
renderWithProviders(
<CalendarAccessRights
calendar={baseCalendar}
value={users}
onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded}
/>
);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
});
it("calls onChange with user removed when remove button is clicked", () => {
const users: UserWithAccess[] = [
{
openpaasId: "abc",
displayName: "Alice",
email: "alice@example.com",
accessRight: 2,
},
];
renderWithProviders(
<CalendarAccessRights
calendar={baseCalendar}
value={users}
onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded}
/>
);
fireEvent.click(screen.getByLabelText(/remove/i));
expect(mockOnChange).toHaveBeenCalledWith([]);
});
it("loads invited users from calendar.invite on mount", async () => {
const calendarWithInvite: Calendar = {
...baseCalendar,
invite: [
{
href: "mailto:bob@example.com",
principal: "/principals/users/bob123",
access: 3,
inviteStatus: 1,
},
],
};
(getUserDetails as jest.Mock).mockResolvedValue({
preferredEmail: "bob@example.com",
firstname: "Bob",
lastname: "Smith",
emails: ["bob@example.com"],
});
renderWithProviders(
<CalendarAccessRights
calendar={calendarWithInvite}
value={[]}
onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded}
/>
);
await waitFor(() => expect(getUserDetails).toHaveBeenCalledWith("bob123"));
await waitFor(() =>
expect(mockOnInvitesLoaded).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
email: "bob@example.com",
accessRight: 3,
}),
])
)
);
});
it("skips invite entries where getUserDetails throws", async () => {
const calendarWithInvite: Calendar = {
...baseCalendar,
invite: [
{
href: "mailto:ghost@example.com",
principal: "/principals/users/ghost",
access: 2,
inviteStatus: 1,
},
],
};
(getUserDetails as jest.Mock).mockRejectedValue(new Error("Not found"));
renderWithProviders(
<CalendarAccessRights
calendar={calendarWithInvite}
value={[]}
onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded}
/>
);
await waitFor(() => expect(mockOnInvitesLoaded).toHaveBeenCalledWith([]));
});
it("shows a loading spinner while invite users are being fetched", async () => {
const calendarWithInvite: Calendar = {
...baseCalendar,
invite: [
{
href: "mailto:carol@example.com",
principal: "/principals/users/carol",
access: 2,
inviteStatus: 1,
},
],
};
// Never resolves during the assertion window
(getUserDetails as jest.Mock).mockImplementation(
() => new Promise(() => {})
);
renderWithProviders(
<CalendarAccessRights
calendar={calendarWithInvite}
value={[]}
onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded}
/>
);
expect(document.querySelector('[role="progressbar"]')).toBeInTheDocument();
});
});
const userState = {
user: {
userData: { openpaasId: "user1", email: "user1@example.com" },
},
};
describe("AccessTab conditional rendering of CalendarAccessRights", () => {
const noop = jest.fn();
afterEach(() => jest.clearAllMocks());
it("shows CalendarAccessRights when the current user owns the calendar", () => {
renderWithProviders(
<AccessTab
calendar={baseCalendar}
usersWithAccess={[]}
onUsersWithAccessChange={noop}
onInvitesLoaded={noop}
/>,
{
...userState,
calendars: { list: { "user1/cal1": baseCalendar } },
}
);
expect(
screen.getByText("calendarPopover.access.grantAccessRights")
).toBeInTheDocument();
});
it("hides CalendarAccessRights for a non-owner without admin delegation", () => {
const foreignCalendar: Calendar = {
...baseCalendar,
id: "otherUser/cal1",
invite: [],
};
renderWithProviders(
<AccessTab
calendar={foreignCalendar}
usersWithAccess={[]}
onUsersWithAccessChange={noop}
onInvitesLoaded={noop}
/>,
{
...userState,
calendars: { list: { "otherUser/cal1": foreignCalendar } },
}
);
expect(
screen.queryByText("calendarPopover.access.grantAccessRights")
).not.toBeInTheDocument();
});
it("shows CalendarAccessRights when the user has access=5 (admin) delegation", () => {
const delegatedCalendar: Calendar = {
...baseCalendar,
id: "otherUser/cal1",
invite: [
{
href: "mailto:user1@example.com",
principal: "/principals/users/user1",
access: 5,
inviteStatus: 1,
},
],
};
renderWithProviders(
<AccessTab
calendar={delegatedCalendar}
usersWithAccess={[]}
onUsersWithAccessChange={noop}
onInvitesLoaded={noop}
/>,
{
...userState,
calendars: { list: { "otherUser/cal1": delegatedCalendar } },
}
);
expect(
screen.getByText("calendarPopover.access.grantAccessRights")
).toBeInTheDocument();
});
});
const existingCalendar: Calendar = {
id: "user1/cal1",
name: "Existing Cal",
description: "Desc",
color: { color: "#0062FF", dark: "#FFF" },
link: "/calendars/user/cal1",
visibility: "public",
events: {},
invite: [],
owner: { emails: ["user1@example.com"] },
};
describe("CalendarModal updateDelegationCalendarAsync integration", () => {
const mockOnClose = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
jest
.spyOn(eventThunks, "patchCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
jest
.spyOn(eventThunks, "patchACLCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
jest
.spyOn(delegationThunks, "updateDelegationCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
});
it("does NOT call updateDelegationCalendarAsync when no users are added or removed", async () => {
renderWithProviders(
<CalendarPopover
open={true}
onClose={mockOnClose}
calendar={existingCalendar}
/>,
{ ...userState, calendars: { list: { "user1/cal1": existingCalendar } } }
);
fireEvent.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => expect(mockOnClose).toHaveBeenCalled());
expect(
delegationThunks.updateDelegationCalendarAsync
).not.toHaveBeenCalled();
});
});
describe("CalendarModal cancel button", () => {
const mockOnClose = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
jest
.spyOn(delegationThunks, "updateDelegationCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
jest
.spyOn(eventThunks, "patchCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
});
it("calls onClose without saving when Cancel is clicked", async () => {
renderWithProviders(
<CalendarPopover
open={true}
onClose={mockOnClose}
calendar={existingCalendar}
/>,
{ ...userState }
);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
await waitFor(() => expect(mockOnClose).toHaveBeenCalled());
expect(
delegationThunks.updateDelegationCalendarAsync
).not.toHaveBeenCalled();
expect(eventThunks.patchCalendarAsync).not.toHaveBeenCalled();
});
});
@@ -2,6 +2,7 @@ import CalendarPopover from "@/components/Calendar/CalendarModal";
import { getSecretLink } from "@/features/Calendars/CalendarApi";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import * as eventThunks from "@/features/Calendars/services";
import * as delegationThunks from "@/features/Calendars/services/updateDelegationCalendarAsync";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../utils/Renderwithproviders";
@@ -9,11 +10,22 @@ jest.mock("@/features/Calendars/CalendarApi", () => ({
getSecretLink: jest.fn(),
}));
const mockThunkWithUnwrap = (resolvedValue: unknown = {}) =>
jest.fn().mockImplementation(() => {
const dispatchResult = Object.assign(Promise.resolve(resolvedValue), {
unwrap: () => Promise.resolve(resolvedValue),
});
return jest.fn().mockReturnValue(dispatchResult);
});
describe("CalendarPopover", () => {
const mockOnClose = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
jest
.spyOn(delegationThunks, "updateDelegationCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
});
const renderPopover = (open = true) => {
@@ -54,12 +66,11 @@ describe("CalendarPopover", () => {
expect(descInput).toHaveValue("Test description");
});
it("dispatches createCalendar and calls onClose when Save clicked", () => {
const spy = jest
it("dispatches createCalendar and calls onClose when Save clicked", async () => {
jest
.spyOn(eventThunks, "createCalendarAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
.mockImplementation(mockThunkWithUnwrap());
renderPopover();
fireEvent.change(screen.getByLabelText(/Name/i), {
@@ -77,9 +88,12 @@ describe("CalendarPopover", () => {
fireEvent.click(screen.getByRole("button", { name: /Create/i }));
expect(spy).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick");
await waitFor(() =>
expect(eventThunks.createCalendarAsync).toHaveBeenCalled()
);
await waitFor(() =>
expect(mockOnClose).toHaveBeenCalledWith({}, "backdropClick")
);
});
it("calls onClose when Cancel clicked", () => {
@@ -116,6 +130,9 @@ describe("CalendarPopover (editing mode)", () => {
beforeEach(() => {
jest.clearAllMocks();
jest
.spyOn(delegationThunks, "updateDelegationCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
});
it("prefills fields when calendar prop is given", () => {
@@ -151,11 +168,9 @@ describe("CalendarPopover (editing mode)", () => {
});
it("allows modifying and saving existing calendar", async () => {
const spy = jest
jest
.spyOn(eventThunks, "patchCalendarAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
.mockImplementation(mockThunkWithUnwrap());
renderWithProviders(
<CalendarPopover
@@ -175,7 +190,7 @@ describe("CalendarPopover (editing mode)", () => {
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
await waitFor(() =>
expect(spy).toHaveBeenCalledWith(
expect(eventThunks.patchCalendarAsync).toHaveBeenCalledWith(
expect.objectContaining({
calId: "user1/cal1",
calLink: "/calendars/user/cal1",
@@ -187,7 +202,7 @@ describe("CalendarPopover (editing mode)", () => {
})
)
);
expect(mockOnClose).toHaveBeenCalled();
await waitFor(() => expect(mockOnClose).toHaveBeenCalled());
});
});
@@ -220,6 +235,9 @@ describe("CalendarPopover - Tabs Scenarios", () => {
beforeEach(() => {
jest.clearAllMocks();
jest
.spyOn(delegationThunks, "updateDelegationCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
});
it("resets state after closing and reopening", () => {
@@ -265,11 +283,13 @@ describe("CalendarPopover - Tabs Scenarios", () => {
});
it("patches ACL when visibility changes", async () => {
const patchSpy = jest
jest
.spyOn(eventThunks, "patchCalendarAsync")
.mockImplementation(mockThunkWithUnwrap());
jest
.spyOn(eventThunks, "patchACLCalendarAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
.mockImplementation(mockThunkWithUnwrap());
renderWithProviders(
<CalendarPopover
@@ -297,7 +317,7 @@ describe("CalendarPopover - Tabs Scenarios", () => {
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
await waitFor(() =>
expect(patchSpy).toHaveBeenCalledWith(
expect(eventThunks.patchACLCalendarAsync).toHaveBeenCalledWith(
expect.objectContaining({
calId: "user1/cal1",
request: "",
@@ -352,16 +372,12 @@ describe("CalendarPopover - Tabs Scenarios", () => {
const file = new File(["test"], "events.ics", { type: "text/calendar" });
it("creates a new calendar and imports events when Import with 'new' target", async () => {
const createSpy = jest
jest
.spyOn(eventThunks, "createCalendarAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
const importSpy = jest
.mockImplementation(mockThunkWithUnwrap());
jest
.spyOn(eventThunks, "importEventFromFileAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
.mockImplementation(mockThunkWithUnwrap());
renderWithProviders(
<CalendarPopover open={true} onClose={mockOnClose} />,
@@ -383,16 +399,18 @@ describe("CalendarPopover - Tabs Scenarios", () => {
// Click Import
fireEvent.click(screen.getByRole("button", { name: "actions.import" }));
await waitFor(() => expect(createSpy).toHaveBeenCalled());
await waitFor(() => expect(importSpy).toHaveBeenCalled());
await waitFor(() =>
expect(eventThunks.createCalendarAsync).toHaveBeenCalled()
);
await waitFor(() =>
expect(eventThunks.importEventFromFileAsync).toHaveBeenCalled()
);
});
it("imports into an existing calendar when target is set", async () => {
const importSpy = jest
jest
.spyOn(eventThunks, "importEventFromFileAsync")
.mockImplementation((payload) => {
return () => Promise.resolve(payload) as any;
});
.mockImplementation(mockThunkWithUnwrap());
const calendars = {
"user1/cal1": existingCalendar,
@@ -414,7 +432,7 @@ describe("CalendarPopover - Tabs Scenarios", () => {
fireEvent.click(screen.getByRole("button", { name: "actions.import" }));
await waitFor(() =>
expect(importSpy).toHaveBeenCalledWith(
expect(eventThunks.importEventFromFileAsync).toHaveBeenCalledWith(
expect.objectContaining({
calLink: "/calendars/user1/cal1.json",
file,