[#90] added freebusy (#618)

This commit is contained in:
Camille Moussu
2026-03-12 18:07:25 +01:00
committed by GitHub
parent 047cbeef62
commit fd7581a8db
18 changed files with 917 additions and 29 deletions
@@ -1,3 +1,4 @@
import * as calendarsApi from "@/features/Calendars/CalendarApi";
import * as eventThunks from "@/features/Calendars/services";
import EventPopover from "@/features/Events/EventModal";
import { api } from "@/utils/apiUtils";
@@ -210,6 +211,9 @@ describe("EventPopover", () => {
});
it("adds a attendee", async () => {
jest.useFakeTimers();
jest
.spyOn(calendarsApi, "getCalendars")
.mockReturnValue({ json: jest.fn() });
renderPopover();
fireEvent.change(screen.getByLabelText("event.form.title"), {
target: { value: "newEvent" },
+401
View File
@@ -0,0 +1,401 @@
import {
hasFreeBusyConflict,
useAttendeesFreeBusy,
} from "@/components/Attendees/useFreeBusy";
import * as getFreeBusyREPORT from "@/features/Events/api/getFreeBusyForAddedAttendeesREPORT";
import * as getFreeBusyPOST from "@/features/Events/api/getFreeBusyForEventAttendeesPOST";
import * as getUserData from "@/features/Events/api/getUserDataFromEmail";
import { renderHook, waitFor } from "@testing-library/react";
jest.mock("moment-timezone", () => {
const actual = jest.requireActual("moment-timezone");
return actual;
});
jest.mock("@/features/Events/api/getUserDataFromEmail");
jest.mock("@/features/Events/api/getFreeBusyForAddedAttendeesREPORT");
jest.mock("@/features/Events/api/getFreeBusyForEventAttendeesPOST");
const mockGetUserData = getUserData.getUserDataFromEmail as jest.MockedFunction<
typeof getUserData.getUserDataFromEmail
>;
const mockREPORT =
getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT as jest.MockedFunction<
typeof getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT
>;
const mockPOST =
getFreeBusyPOST.getFreeBusyForEventAttendeesPOST as jest.MockedFunction<
typeof getFreeBusyPOST.getFreeBusyForEventAttendeesPOST
>;
const START = "2026-03-14T14:00:00";
const END = "2026-03-14T15:00:00";
const TZ = "Europe/Paris";
describe("hasFreeBusyConflict", () => {
it("returns false for non-vcalendar data", () => {
expect(hasFreeBusyConflict({ data: ["not-vcalendar", [], []] })).toBe(
false
);
});
it("returns false when vfreebusy has no freebusy property", () => {
const data = {
data: [
"vcalendar",
[],
[
[
"vfreebusy",
[["dtstart", {}, "date-time", "2026-03-14T14:00:00Z"]],
[],
],
],
],
};
expect(hasFreeBusyConflict(data)).toBe(false);
});
it("returns true when vfreebusy contains a freebusy property", () => {
const data = {
data: [
"vcalendar",
[],
[
[
"vfreebusy",
[
["dtstart", {}, "date-time", "2026-03-14T14:00:00Z"],
["freebusy", {}, "period", "2026-03-14T14:00:00Z/PT1H"],
],
[],
],
],
],
};
expect(hasFreeBusyConflict(data)).toBe(true);
});
it("returns false for malformed data without throwing", () => {
expect(hasFreeBusyConflict(null)).toBe(false);
expect(hasFreeBusyConflict("garbage")).toBe(false);
expect(hasFreeBusyConflict({ data: null })).toBe(false);
});
});
describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
beforeEach(() => jest.clearAllMocks());
const newAttendee = { email: "alice@example.com", userId: "user-alice" };
it("shows loading then free for a new attendee", async () => {
mockREPORT.mockResolvedValue(false);
const { result } = renderHook(() =>
useAttendeesFreeBusy({
existingAttendees: [],
newAttendees: [newAttendee],
start: START,
end: END,
timezone: TZ,
})
);
expect(result.current[newAttendee.email]).toBe("loading");
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free"));
});
it("shows busy when REPORT returns true", async () => {
mockREPORT.mockResolvedValue(true);
const { result } = renderHook(() =>
useAttendeesFreeBusy({
existingAttendees: [],
newAttendees: [newAttendee],
start: START,
end: END,
timezone: TZ,
})
);
await waitFor(() => expect(result.current[newAttendee.email]).toBe("busy"));
});
it("shows unknown when REPORT throws", async () => {
mockREPORT.mockRejectedValue(new Error("network error"));
const { result } = renderHook(() =>
useAttendeesFreeBusy({
existingAttendees: [],
newAttendees: [newAttendee],
start: START,
end: END,
timezone: TZ,
})
);
await waitFor(() =>
expect(result.current[newAttendee.email]).toBe("unknown")
);
});
it("resolves userId via getUserDataFromEmail when not provided", async () => {
mockGetUserData.mockResolvedValue([{ _id: "resolved-id" }] as never);
mockREPORT.mockResolvedValue(false);
const attendeeWithoutId = { email: "bob@example.com" };
const { result } = renderHook(() =>
useAttendeesFreeBusy({
existingAttendees: [],
newAttendees: [attendeeWithoutId],
start: START,
end: END,
timezone: TZ,
})
);
await waitFor(() =>
expect(result.current[attendeeWithoutId.email]).toBe("free")
);
expect(mockGetUserData).toHaveBeenCalledWith("bob@example.com");
expect(mockREPORT).toHaveBeenCalledWith(
"resolved-id",
expect.any(String),
expect.any(String)
);
});
it("shows unknown when userId cannot be resolved", async () => {
mockGetUserData.mockResolvedValue([]);
const { result } = renderHook(() =>
useAttendeesFreeBusy({
existingAttendees: [],
newAttendees: [{ email: "ghost@example.com" }],
start: START,
end: END,
timezone: TZ,
})
);
await waitFor(() =>
expect(result.current["ghost@example.com"]).toBe("unknown")
);
expect(mockREPORT).not.toHaveBeenCalled();
});
it("does not re-fetch an attendee already fetched", async () => {
mockREPORT.mockResolvedValue(false);
const { result, rerender } = renderHook(
({ attendees }) =>
useAttendeesFreeBusy({
existingAttendees: [],
newAttendees: attendees,
start: START,
end: END,
timezone: TZ,
}),
{ initialProps: { attendees: [newAttendee] } }
);
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free"));
rerender({ attendees: [newAttendee] });
expect(mockREPORT).toHaveBeenCalledTimes(1);
});
it("removes departed attendee from the map", async () => {
mockREPORT.mockResolvedValue(false);
const { result, rerender } = renderHook(
({ attendees }) =>
useAttendeesFreeBusy({
existingAttendees: [],
newAttendees: attendees,
start: START,
end: END,
timezone: TZ,
}),
{ initialProps: { attendees: [newAttendee] } }
);
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free"));
rerender({ attendees: [] });
await waitFor(() =>
expect(result.current[newAttendee.email]).toBeUndefined()
);
});
it("invalidates cache and re-fetches when time window changes", async () => {
mockREPORT.mockResolvedValue(false);
const { result, rerender } = renderHook(
({ start, end }) =>
useAttendeesFreeBusy({
existingAttendees: [],
newAttendees: [newAttendee],
start,
end,
timezone: TZ,
}),
{ initialProps: { start: START, end: END } }
);
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free"));
rerender({ start: "2026-03-15T14:00:00", end: "2026-03-15T15:00:00" });
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free"));
expect(mockREPORT).toHaveBeenCalledTimes(2);
});
it("passes UTC-converted iCal times to the API", async () => {
mockREPORT.mockResolvedValue(false);
renderHook(() =>
useAttendeesFreeBusy({
existingAttendees: [],
newAttendees: [newAttendee],
start: "2026-03-14T16:00:00", // 16:00 Paris = 15:00 UTC in winter
end: "2026-03-14T17:00:00",
timezone: "Europe/Paris",
})
);
await waitFor(() => expect(mockREPORT).toHaveBeenCalled());
// Paris is UTC+1 in March (before DST), so 16:00 → 15:00 UTC
expect(mockREPORT).toHaveBeenCalledWith(
"user-alice",
"20260314T150000",
"20260314T160000"
);
});
});
describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => {
beforeEach(() => jest.clearAllMocks());
const existingAttendee = { email: "carol@example.com", userId: "user-carol" };
it("does not fetch when eventUid is missing", () => {
renderHook(() =>
useAttendeesFreeBusy({
existingAttendees: [existingAttendee],
newAttendees: [],
start: START,
end: END,
timezone: TZ,
})
);
expect(mockPOST).not.toHaveBeenCalled();
});
it("shows loading then free for existing attendees", async () => {
mockPOST.mockResolvedValue({ "user-carol": false });
const { result } = renderHook(() =>
useAttendeesFreeBusy({
existingAttendees: [existingAttendee],
newAttendees: [],
start: START,
end: END,
timezone: TZ,
eventUid: "event-123",
})
);
expect(result.current[existingAttendee.email]).toBe("loading");
await waitFor(() =>
expect(result.current[existingAttendee.email]).toBe("free")
);
});
it("shows busy when POST returns busy for userId", async () => {
mockPOST.mockResolvedValue({ "user-carol": true });
const { result } = renderHook(() =>
useAttendeesFreeBusy({
existingAttendees: [existingAttendee],
newAttendees: [],
start: START,
end: END,
timezone: TZ,
eventUid: "event-123",
})
);
await waitFor(() =>
expect(result.current[existingAttendee.email]).toBe("busy")
);
});
it("shows unknown when POST throws", async () => {
mockPOST.mockRejectedValue(new Error("server error"));
const { result } = renderHook(() =>
useAttendeesFreeBusy({
existingAttendees: [existingAttendee],
newAttendees: [],
start: START,
end: END,
timezone: TZ,
eventUid: "event-123",
})
);
await waitFor(() =>
expect(result.current[existingAttendee.email]).toBe("unknown")
);
});
it("passes the eventUid to the POST API", async () => {
mockPOST.mockResolvedValue({ "user-carol": false });
renderHook(() =>
useAttendeesFreeBusy({
existingAttendees: [existingAttendee],
newAttendees: [],
start: START,
end: END,
timezone: TZ,
eventUid: "event-abc",
})
);
await waitFor(() => expect(mockPOST).toHaveBeenCalled());
expect(mockPOST).toHaveBeenCalledWith(
["user-carol"],
expect.any(String),
expect.any(String),
"event-abc"
);
});
});
describe("useAttendeesFreeBusy — enabled flag", () => {
it("does not fetch when disabled", () => {
renderHook(() =>
useAttendeesFreeBusy({
existingAttendees: [{ email: "x@x.com", userId: "uid" }],
newAttendees: [{ email: "y@y.com", userId: "uid2" }],
start: START,
end: END,
timezone: TZ,
eventUid: "event-123",
enabled: false,
})
);
expect(mockPOST).not.toHaveBeenCalled();
expect(mockREPORT).not.toHaveBeenCalled();
});
});