Files
workavia-calendar-front/__test__/components/PeopleSearch.test.tsx
T
Benoit TELLIER c9710d7651 feat: parse multiple attendees from pasted text (#723) (#731)
When pasting text containing multiple email addresses into the attendee
input, the component now splits by comma, semicolon, newline, or space
and adds each valid email as an individual attendee chip. Duplicates
are silently skipped. Any invalid chunks remain in the input field
with an error message so the user can correct them manually.

Co-authored-by: JacobiusMakes <jgalperin98@gmail.com>
2026-03-30 13:23:07 +02:00

413 lines
11 KiB
TypeScript

import { PeopleSearch, User } from "@/components/Attendees/PeopleSearch";
import { searchUsers } from "@/features/User/userAPI";
import { act, fireEvent, 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("PeopleSearch", () => {
const baseUser: User = {
email: "test@example.com",
displayName: "Test User",
avatarUrl: "https://example.com/avatar.png",
openpaasId: "1234567890",
};
function setup(
selectedUsers: User[] = [],
props?: Partial<React.ComponentProps<typeof PeopleSearch>>
) {
const onChange = jest.fn();
renderWithProviders(
<PeopleSearch
objectTypes={["user"]}
selectedUsers={selectedUsers}
onChange={onChange}
{...props}
/>
);
return { onChange };
}
beforeEach(() => {
jest.useFakeTimers();
mockedSearchUsers.mockReset();
});
afterEach(() => {
jest.useRealTimers();
});
it("calls searchUsers after debounce when typing", async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
setup();
const input = screen.getByRole("combobox");
await userEvent.type(input, "Test");
await act(async () => {
jest.advanceTimersByTime(300);
});
await waitFor(() => {
expect(mockedSearchUsers).toHaveBeenCalledWith("Test", ["user"]);
});
});
it("renders search results and allows selection", async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
const { onChange } = setup();
const input = screen.getByRole("combobox");
await userEvent.type(input, "Test");
await act(async () => {
jest.advanceTimersByTime(300);
});
const option = await screen.findByText("Test User");
await userEvent.click(option);
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
});
it("does not show already selected users in options", async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
setup([baseUser]);
const input = screen.getByRole("combobox");
await userEvent.type(input, "Test");
await act(async () => {
jest.advanceTimersByTime(300);
});
await waitFor(() => {
expect(screen.queryByText("test@example.com")).not.toBeInTheDocument();
});
});
it("triggers onToggleEventPreview on Enter key press", () => {
const onToggleEventPreview = jest.fn();
setup([], { onToggleEventPreview });
const input = screen.getByRole("combobox");
fireEvent.keyDown(input, { key: "Enter" });
expect(onToggleEventPreview).toHaveBeenCalled();
});
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([baseUser]);
setup();
const input = screen.getByRole("combobox");
userEvent.type(input, "Test");
await act(async () => {
jest.advanceTimersByTime(300);
});
await waitFor(() => {
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
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, "Test");
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve();
await Promise.resolve();
});
const noResults = await screen.findByText(
"peopleSearch.noResults",
{},
{ timeout: 5000 }
);
expect(noResults).toBeInTheDocument();
});
it("does not clear options when search fails and shows error snackbar", async () => {
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
setup();
const input = screen.getByRole("combobox");
await userEvent.type(input, "Test");
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve();
});
await waitFor(() => {
expect(screen.getByText("Test User")).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("peopleSearch.searchError");
expect(errorMessage).toBeInTheDocument();
expect(screen.queryByText("Test User")).not.toBeInTheDocument();
mockedSearchUsers.mockResolvedValueOnce([baseUser]);
await userEvent.clear(input);
await userEvent.type(input, "Test");
await act(async () => {
jest.advanceTimersByTime(300);
await Promise.resolve();
});
await waitFor(() => {
expect(screen.getByText("Test User")).toBeInTheDocument();
});
});
it("shows loading text when searching", async () => {
let resolveSearch: (value: User[]) => void;
const searchPromise = new Promise<User[]>((resolve) => {
resolveSearch = resolve;
});
mockedSearchUsers.mockReturnValueOnce(searchPromise);
setup();
const input = screen.getByRole("combobox");
await userEvent.type(input, "Test");
await act(async () => {
jest.advanceTimersByTime(300);
});
const loadingText = await screen.findByText(
"peopleSearch.loading",
{},
{ timeout: 5000 }
);
expect(loadingText).toBeInTheDocument();
await act(async () => {
resolveSearch!([baseUser]);
await searchPromise;
});
});
describe("paste multiple attendees", () => {
function setupFreeSolo(selectedUsers: User[] = []) {
const onChange = jest.fn();
renderWithProviders(
<PeopleSearch
objectTypes={["user"]}
selectedUsers={selectedUsers}
onChange={onChange}
freeSolo
/>
);
return { onChange };
}
it("splits pasted comma-separated emails into individual attendees", async () => {
const { onChange } = setupFreeSolo();
const input = screen.getByRole("combobox");
fireEvent.paste(input, {
clipboardData: {
getData: () => "alice@example.com, bob@example.com",
},
});
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([
expect.objectContaining({ email: "alice@example.com" }),
expect.objectContaining({ email: "bob@example.com" }),
])
);
});
});
it("splits pasted semicolon-separated emails", async () => {
const { onChange } = setupFreeSolo();
const input = screen.getByRole("combobox");
fireEvent.paste(input, {
clipboardData: {
getData: () => "alice@example.com;bob@example.com",
},
});
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([
expect.objectContaining({ email: "alice@example.com" }),
expect.objectContaining({ email: "bob@example.com" }),
])
);
});
});
it("splits pasted newline-separated emails", async () => {
const { onChange } = setupFreeSolo();
const input = screen.getByRole("combobox");
fireEvent.paste(input, {
clipboardData: {
getData: () => "alice@example.com\nbob@example.com",
},
});
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([
expect.objectContaining({ email: "alice@example.com" }),
expect.objectContaining({ email: "bob@example.com" }),
])
);
});
});
it("splits pasted space-separated emails", async () => {
const { onChange } = setupFreeSolo();
const input = screen.getByRole("combobox");
fireEvent.paste(input, {
clipboardData: {
getData: () => "alice@example.com bob@example.com",
},
});
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([
expect.objectContaining({ email: "alice@example.com" }),
expect.objectContaining({ email: "bob@example.com" }),
])
);
});
});
it("skips duplicate emails already in selectedUsers", async () => {
const existing: User = {
email: "alice@example.com",
displayName: "Alice",
};
const { onChange } = setupFreeSolo([existing]);
const input = screen.getByRole("combobox");
fireEvent.paste(input, {
clipboardData: {
getData: () => "alice@example.com, bob@example.com",
},
});
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([
existing,
expect.objectContaining({ email: "bob@example.com" }),
])
);
// Should NOT have alice duplicated
const call = onChange.mock.calls[0][1];
const aliceCount = call.filter(
(u: User) => u.email === "alice@example.com"
).length;
expect(aliceCount).toBe(1);
});
});
it("leaves invalid text in input when some emails are invalid", async () => {
const { onChange } = setupFreeSolo();
const input = screen.getByRole("combobox");
fireEvent.paste(input, {
clipboardData: {
getData: () => "alice@example.com, not-an-email, bob@example.com",
},
});
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([
expect.objectContaining({ email: "alice@example.com" }),
expect.objectContaining({ email: "bob@example.com" }),
])
);
});
});
it("does not intercept paste of a single email", async () => {
const { onChange } = setupFreeSolo();
const input = screen.getByRole("combobox");
fireEvent.paste(input, {
clipboardData: {
getData: () => "alice@example.com",
},
});
// Single email should NOT trigger multi-paste handler
expect(onChange).not.toHaveBeenCalled();
});
});
it("retains input value when field loses focus (blur)", async () => {
let resolveSearch: (value: User[]) => void;
const searchPromise = new Promise<User[]>((resolve) => {
resolveSearch = resolve;
});
mockedSearchUsers.mockReturnValueOnce(searchPromise);
await act(async () => {
setup();
});
const input = screen.getByRole("combobox");
await act(async () => {
userEvent.type(input, "Test");
});
await waitFor(() => {
expect(mockedSearchUsers).toHaveBeenCalledWith("Test", ["user"]);
});
expect(input).toHaveValue("Test");
await act(async () => {
input.blur();
});
await waitFor(() => {
expect(input).toHaveValue("Test");
});
});
});