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(); }); });