[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:
@@ -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