#599 ability to register to a resource calendar (#631)

Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
lethemanh
2026-03-17 15:33:15 +07:00
committed by GitHub
parent a9e7b1ac82
commit 0dd7f1b2fd
20 changed files with 1647 additions and 75 deletions
+1 -1
View File
@@ -572,7 +572,7 @@ describe("calendar Availability search", () => {
const callCount = spy.mock.calls.length;
expect(callCount).toBeGreaterThan(0);
expect(callCount).toBeLessThanOrEqual(24);
expect(callCount).toBeLessThanOrEqual(36);
});
it("prefetches hidden calendars only after active load completes", async () => {
@@ -0,0 +1,188 @@
import CalendarResources from "@/components/Calendar/CalendarResources";
import { getCalendars } from "@/features/Calendars/CalendarApi";
import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../utils/Renderwithproviders";
jest.mock("@/features/Calendars/CalendarApi");
jest.mock("@/features/Calendars/api/addCalendarResourceAsync");
jest.mock("@/components/Attendees/ResourceSearch", () => ({
ResourceSearch: ({
onChange,
}: {
onChange: (
event: null,
value: { displayName: string; openpaasId: string }[]
) => void;
}) => (
<div data-testid="resource-search">
<button
data-testid="mock-resource-search-select"
onClick={() =>
onChange(null, [
{ displayName: "Room A", openpaasId: "room-a-id" },
{ displayName: "Room B", openpaasId: "room-b-id" },
])
}
>
Select Resources
</button>
</div>
),
}));
const mockedGetCalendars = getCalendars as jest.Mock;
const mockedAddCalendarResourceAsync =
addCalendarResourceAsync as unknown as jest.Mock;
describe("CalendarResources", () => {
beforeEach(() => {
jest.clearAllMocks();
});
const baseUser = {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "user1",
},
tokens: { accessToken: "token" },
};
const setup = (isOpen = true) => {
const onClose = jest.fn();
renderWithProviders(<CalendarResources onClose={onClose} open={isOpen} />, {
user: baseUser,
});
return { onClose };
};
it("renders correctly and closes on cancel", () => {
const { onClose } = setup();
expect(screen.getByText("calendar.browseResources")).toBeInTheDocument();
const cancelButton = screen.getByRole("button", {
name: "common.cancel",
});
fireEvent.click(cancelButton);
expect(onClose).toHaveBeenCalled();
});
it("does not render when isOpen is false", () => {
setup(false);
expect(
screen.queryByText("calendar.browseResources")
).not.toBeInTheDocument();
});
it("fetches resource calendars and adds them using Promise.allSettled logic", async () => {
const mockCalendarDataA = {
_embedded: {
"dav:calendar": [
{
_links: { self: { href: "/calendars/room-a-id/cal-a.json" } },
"dav:name": "Room A Calendar",
"caldav:description": "Main room A calendar",
color: "red",
},
],
},
};
mockedGetCalendars.mockImplementation((userId) => {
if (userId === "room-a-id") return Promise.resolve(mockCalendarDataA);
// Simulate failure for room-b
if (userId === "room-b-id")
return Promise.reject(new Error("Failed fetching Room B"));
return Promise.resolve([]);
});
const mockDispatchResult = {
unwrap: () => Promise.resolve({ type: "success" }),
};
mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult);
const { onClose } = setup();
// Trigger resource selection
const mockSelectBtn = screen.getByTestId("mock-resource-search-select");
await act(async () => {
fireEvent.click(mockSelectBtn);
});
// After selection, the button should be enabled and say Add
const addButton = await screen.findByRole("button", {
name: "actions.add",
});
expect(addButton).not.toBeDisabled();
// Click add to trigger addCalendarResourceAsync
await act(async () => {
fireEvent.click(addButton);
});
await waitFor(() => {
// It should only have been called for Room A because Room B failed (Promise.allSettled)
expect(mockedAddCalendarResourceAsync).toHaveBeenCalledTimes(1);
const payload = mockedAddCalendarResourceAsync.mock.calls[0][0];
expect(payload).toEqual(
expect.objectContaining({
userId: "user1",
calId: expect.any(String),
cal: expect.objectContaining({
cal: mockCalendarDataA._embedded["dav:calendar"][0],
}),
})
);
});
expect(onClose).toHaveBeenCalled();
});
it("adds existing user details fallback logic within submit handler", async () => {
const mockCalendarDataA = {
_embedded: {
"dav:calendar": [
{
_links: { self: { href: "/calendars/room-a-id/cal-a.json" } },
"dav:name": "Room A Calendar",
"caldav:description": "Main room A calendar",
color: "red",
},
],
},
};
mockedGetCalendars.mockResolvedValueOnce(mockCalendarDataA);
mockedGetCalendars.mockResolvedValueOnce([]); // Mock array of size 2 (for both A and B but second resolves empty)
// Simulate successful API response
const mockDispatchResult = {
unwrap: () => Promise.resolve(),
};
mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult);
const { onClose } = setup();
// Trigger resource selection
const mockSelectBtn = screen.getByTestId("mock-resource-search-select");
await act(async () => {
fireEvent.click(mockSelectBtn);
});
const addButton = await screen.findByRole("button", {
name: "actions.add",
});
await act(async () => {
fireEvent.click(addButton);
});
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
@@ -98,5 +98,5 @@ describe("Event Error Handling", () => {
}
consoleWarnSpy.mockRestore();
});
}, 15000);
});
+224
View File
@@ -0,0 +1,224 @@
import {
ResourceSearch,
Resource,
} from "@/components/Attendees/ResourceSearch";
import { searchUsers } from "@/features/User/userAPI";
import { act, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../utils/Renderwithproviders";
jest.mock("@/features/User/userAPI");
const mockedSearchUsers = searchUsers as jest.MockedFunction<
typeof searchUsers
>;
describe("ResourceSearch", () => {
const baseResource: Resource = {
displayName: "Projector Room",
};
function setup(
selectedResources: Resource[] = [],
props?: Partial<React.ComponentProps<typeof ResourceSearch>>
) {
const onChange = jest.fn();
renderWithProviders(
<ResourceSearch
objectTypes={["resource"]}
selectedResources={selectedResources}
onChange={onChange}
{...props}
/>
);
return { onChange };
}
beforeEach(() => {
jest.useFakeTimers();
mockedSearchUsers.mockReset();
});
afterEach(() => {
jest.useRealTimers();
});
it("calls searchUsers after debounce when typing", async () => {
mockedSearchUsers.mockResolvedValueOnce([
baseResource,
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
setup();
const input = screen.getByRole("combobox");
await userEvent.type(input, "Room");
await act(async () => {
jest.advanceTimersByTime(300);
});
await waitFor(() => {
expect(mockedSearchUsers).toHaveBeenCalledWith("Room", ["resource"]);
});
});
it("renders search results and allows selection", async () => {
mockedSearchUsers.mockResolvedValueOnce([
baseResource,
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
const { onChange } = setup();
const input = screen.getByRole("combobox");
await userEvent.type(input, "Room");
await act(async () => {
jest.advanceTimersByTime(300);
});
const option = await screen.findByText("Projector Room");
await userEvent.click(option);
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
});
it("does not show already selected resources in options", async () => {
mockedSearchUsers.mockResolvedValueOnce([
baseResource,
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
setup([baseResource]);
const input = screen.getByRole("combobox");
await userEvent.type(input, "Projector");
await act(async () => {
jest.advanceTimersByTime(300);
});
await waitFor(() => {
// It shouldn't be in the dropdown options anymore
const options = screen.queryAllByRole("option");
expect(
options.find((opt) => opt.textContent === "Projector Room")
).toBeUndefined();
});
});
it("respects disabled state", () => {
setup([], { disabled: true });
expect(screen.getByRole("combobox")).toBeDisabled();
});
it("no options doesn't show dropdown when input is empty", async () => {
mockedSearchUsers.mockResolvedValueOnce([
baseResource,
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
setup();
const input = screen.getByRole("combobox");
await userEvent.type(input, "Room");
await act(async () => {
jest.advanceTimersByTime(300);
});
await waitFor(() => {
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
await userEvent.clear(input);
await waitFor(() => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
it("shows 'No results' when search succeeds but returns empty array", async () => {
mockedSearchUsers.mockResolvedValueOnce([]);
setup();
const input = screen.getByRole("combobox");
await userEvent.type(input, "Room");
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve();
await Promise.resolve();
});
const noResults = await screen.findByText(
"resourceSearch.noResults",
{},
{ timeout: 5000 }
);
expect(noResults).toBeInTheDocument();
});
it("clears options when search fails and shows error snackbar", async () => {
mockedSearchUsers.mockResolvedValueOnce([
baseResource,
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
setup();
const input = screen.getByRole("combobox");
await userEvent.type(input, "Room");
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve();
});
await waitFor(() => {
expect(screen.getByText("Projector Room")).toBeInTheDocument();
});
mockedSearchUsers.mockRejectedValueOnce(new Error("Network error"));
await userEvent.clear(input);
await userEvent.type(input, "Error");
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve();
});
const errorMessage = await screen.findByText("resourceSearch.searchError");
expect(errorMessage).toBeInTheDocument();
expect(screen.queryByText("Projector Room")).not.toBeInTheDocument();
mockedSearchUsers.mockResolvedValueOnce([
baseResource,
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
await userEvent.clear(input);
await userEvent.type(input, "Room");
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve();
});
await waitFor(() => {
expect(screen.getByText("Projector Room")).toBeInTheDocument();
});
});
it("shows loading text when searching", async () => {
let resolveSearch: (value: Resource[]) => void;
const searchPromise = new Promise<Resource[]>((resolve) => {
resolveSearch = resolve;
});
mockedSearchUsers.mockReturnValueOnce(
searchPromise as unknown as ReturnType<typeof searchUsers>
);
setup();
const input = screen.getByRole("combobox");
await userEvent.type(input, "Room");
await act(async () => {
jest.advanceTimersByTime(300);
});
const loadingText = await screen.findByText(
"resourceSearch.loading",
{},
{ timeout: 5000 }
);
expect(loadingText).toBeInTheDocument();
await act(async () => {
resolveSearch!([baseResource]);
await searchPromise;
});
});
});
+102
View File
@@ -0,0 +1,102 @@
import { renderHook, act } from "@testing-library/react";
import { useUserSearch } from "@/components/Attendees/useUserSearch";
import { searchUsers } from "@/features/User/userAPI";
jest.mock("@/features/User/userAPI", () => ({
searchUsers: jest.fn(),
}));
describe("useUserSearch", () => {
const mockSearchUsers = searchUsers as jest.Mock;
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
});
afterEach(() => {
jest.useRealTimers();
});
it("should initialize with default values", () => {
const { result } = renderHook(() =>
useUserSearch({ objectTypes: ["user"], errorMessage: "Error" })
);
expect(result.current.query).toBe("");
expect(result.current.loading).toBe(false);
expect(result.current.options).toEqual([]);
expect(result.current.hasSearched).toBe(false);
expect(result.current.isOpen).toBe(false);
expect(result.current.inputError).toBeNull();
expect(result.current.snackbarOpen).toBe(false);
expect(result.current.snackbarMessage).toBe("");
});
it("should debounce and fetch users when query changes", async () => {
const mockUsers = [{ displayName: "John Doe", email: "john@example.com" }];
mockSearchUsers.mockResolvedValueOnce(mockUsers);
const { result } = renderHook(() =>
useUserSearch({ objectTypes: ["user"], errorMessage: "Error" })
);
act(() => {
result.current.setQuery("John");
});
expect(result.current.loading).toBe(false); // Before debounce
// Wait for the mock promise to resolve within act
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve(); // allow microtasks to flush
});
expect(mockSearchUsers).toHaveBeenCalledWith("John", ["user"]);
expect(result.current.loading).toBe(false);
expect(result.current.options).toEqual(mockUsers);
expect(result.current.hasSearched).toBe(true);
});
it("should clear options and handle empty query", () => {
const { result } = renderHook(() =>
useUserSearch({ objectTypes: ["user"], errorMessage: "Error" })
);
act(() => {
result.current.setQuery(" "); // empty query with spaces
});
act(() => {
jest.advanceTimersByTime(300);
});
expect(mockSearchUsers).not.toHaveBeenCalled();
expect(result.current.options).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.hasSearched).toBe(false);
});
it("should handle search errors and show the custom error message", async () => {
mockSearchUsers.mockRejectedValueOnce(new Error("API Error"));
const { result } = renderHook(() =>
useUserSearch({ objectTypes: ["user"], errorMessage: "Custom Error" })
);
act(() => {
result.current.setQuery("FailedSearch");
});
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve(); // allow microtasks to flush
});
expect(result.current.hasSearched).toBe(false);
expect(result.current.loading).toBe(false);
expect(result.current.snackbarOpen).toBe(true);
expect(result.current.snackbarMessage).toBe("Custom Error");
});
});
@@ -0,0 +1,128 @@
import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync";
import { addSharedCalendar } from "@/features/Calendars/CalendarApi";
import { getResourceDetails } from "@/features/User/userAPI";
import { toRejectedError } from "@/utils/errorUtils";
import { configureStore } from "@reduxjs/toolkit";
jest.mock("@/features/Calendars/CalendarApi");
jest.mock("@/features/User/userAPI");
jest.mock("@/utils/errorUtils");
const mockedAddSharedCalendar = addSharedCalendar as jest.Mock;
const mockedGetResourceDetails = getResourceDetails as jest.Mock;
const mockedToRejectedError = toRejectedError as jest.Mock;
describe("addCalendarResourceAsync thunk", () => {
let store: ReturnType<typeof configureStore>;
const dispatch = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
store = configureStore({
reducer: () => ({}),
});
});
const mockPayload = {
userId: "user-123",
calId: "cal-123",
cal: {
color: {
background: "#000000",
foreground: "#FFFFFF",
},
cal: {
"dav:name": "Resource Room A",
"caldav:description": "A meeting room",
_links: {
self: {
href: "/calendars/res-456/cal-123.json",
},
},
},
},
};
const mockResolvedResourceData = {
_id: "res-456",
id: "res-456",
name: "Resource Room A",
description: "A meeting room",
creator: "user-789",
deleted: false,
_rev: "1",
};
it("should add shared calendar, fetch resource details, map userData", async () => {
mockedGetResourceDetails.mockResolvedValueOnce(mockResolvedResourceData);
mockedAddSharedCalendar.mockResolvedValueOnce({});
const result = await addCalendarResourceAsync(
mockPayload as unknown as Parameters<typeof addCalendarResourceAsync>[0]
)(dispatch, store.getState, undefined);
expect(mockedAddSharedCalendar).toHaveBeenCalledWith(
mockPayload.userId,
mockPayload.calId,
mockPayload.cal
);
expect(mockedGetResourceDetails).toHaveBeenCalledWith("res-456");
expect(result.type).toBe("calendars/addCalendarResource/fulfilled");
expect(result.payload).toEqual({
calId: "res-456/cal-123",
color: { background: "#000000", foreground: "#FFFFFF" },
desc: "A meeting room",
link: "/calendars/user-123/cal-123.json",
name: "Resource Room A",
owner: mockResolvedResourceData,
});
});
it("should fallback to name if resource details fetch fails", async () => {
mockedAddSharedCalendar.mockResolvedValueOnce({});
const errorDetails = new Error("Fetch failed");
mockedGetResourceDetails.mockRejectedValueOnce(errorDetails);
const mockRejectedErrorResult = { message: "Fetch failed" };
mockedToRejectedError.mockReturnValueOnce(mockRejectedErrorResult);
const result = await addCalendarResourceAsync(
mockPayload as unknown as Parameters<typeof addCalendarResourceAsync>[0]
)(dispatch, store.getState, undefined);
expect(mockedAddSharedCalendar).toHaveBeenCalledWith(
mockPayload.userId,
mockPayload.calId,
mockPayload.cal
);
expect(mockedGetResourceDetails).toHaveBeenCalledWith("res-456");
expect(mockedToRejectedError).toHaveBeenCalledWith(errorDetails);
expect(result.type).toBe("calendars/addCalendarResource/rejected");
expect(result.payload).toEqual(mockRejectedErrorResult);
});
it("should handle error if addSharedCalendar fails", async () => {
const errorAdd = new Error("Add failed");
mockedAddSharedCalendar.mockRejectedValueOnce(errorAdd);
const mockRejectedErrorResult = { message: "Add failed" };
mockedToRejectedError.mockReturnValueOnce(mockRejectedErrorResult);
const result = await addCalendarResourceAsync(
mockPayload as unknown as Parameters<typeof addCalendarResourceAsync>[0]
)(dispatch, store.getState, undefined);
expect(mockedAddSharedCalendar).toHaveBeenCalledWith(
mockPayload.userId,
mockPayload.calId,
mockPayload.cal
);
expect(mockedGetResourceDetails).not.toHaveBeenCalled();
expect(mockedToRejectedError).toHaveBeenCalledWith(errorAdd);
expect(result.type).toBe("calendars/addCalendarResource/rejected");
expect(result.payload).toEqual(mockRejectedErrorResult);
});
});
@@ -15,10 +15,9 @@ jest.mock("@/features/Calendars/utils/normalizeCalendar");
const mockedGetOpenPaasUser = getOpenPaasUser as jest.Mock;
const mockedGetUserDetails = getUserDetails as jest.Mock;
const mockedgetResourceDetails = getResourceDetails as jest.Mock;
const mockedGetResourceDetails = getResourceDetails as jest.Mock;
const mockedGetCalendars = getCalendars as jest.Mock;
const mockedFormatReduxError = formatReduxError as jest.Mock;
const mockedToRejectedError = toRejectedError as jest.Mock;
const mockedNormalizeCalendar = normalizeCalendar as jest.Mock;
describe("getCalendarsListAsync", () => {
@@ -143,7 +142,12 @@ describe("getCalendarsListAsync", () => {
response: { status: 500 },
message: "Server Error",
});
mockedToRejectedError.mockReturnValueOnce({
// toRejectedError is imported from a mocked module; provide the expected return
const { toRejectedError } = jest.requireMock("@/utils/errorUtils") as {
toRejectedError: jest.Mock;
};
toRejectedError.mockReturnValueOnce({
status: 500,
message: "Server Error",
});
@@ -175,7 +179,7 @@ describe("getCalendarsListAsync", () => {
// getUserDetails fails with 404 for the resource ID
mockedGetUserDetails.mockRejectedValueOnce({ response: { status: 404 } });
// Then getResourceDetails is called and succeeds
mockedgetResourceDetails.mockResolvedValueOnce({ creator: "creator-456" });
mockedGetResourceDetails.mockResolvedValueOnce({ creator: "creator-456" });
// Then getUserDetails is called for the creator and succeeds
mockedGetUserDetails.mockResolvedValueOnce({
firstname: "Creator",
@@ -187,7 +191,7 @@ describe("getCalendarsListAsync", () => {
const result = await thunk(dispatch, getState, undefined);
const payload = result.payload as any;
expect(mockedgetResourceDetails).toHaveBeenCalledWith("resource-123");
expect(mockedGetResourceDetails).toHaveBeenCalledWith("resource-123");
expect(mockedGetUserDetails).toHaveBeenCalledWith("creator-456");
expect(payload.importedCalendars["cal-1"].owner).toEqual({
firstname: "Creator",