forked from baron/baron-sso
208 lines
6.1 KiB
TypeScript
208 lines
6.1 KiB
TypeScript
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createI18nMock } from "../../test/i18nMock";
|
|
import UserListPage from "./UserListPage";
|
|
|
|
const selectRenderCounter = vi.hoisted(() => ({ count: 0 }));
|
|
|
|
const users = Array.from({ length: 200 }, (_, index) => ({
|
|
id: `user-${index}`,
|
|
name: `User ${index}`,
|
|
email: `user${index}@example.com`,
|
|
phone: `010-${String(index).padStart(4, "0")}-0000`,
|
|
role: "user",
|
|
status: "active",
|
|
tenantSlug: "hanmac",
|
|
tenant: { id: "tenant-1", name: "한맥", slug: "hanmac" },
|
|
metadata: {},
|
|
createdAt: "2026-05-01T00:00:00Z",
|
|
updatedAt: "2026-05-01T00:00:00Z",
|
|
}));
|
|
|
|
const fetchUsersMock = vi.hoisted(() => vi.fn());
|
|
const searchRenderBudgetMs =
|
|
process.env.npm_lifecycle_event === "test:coverage" ? 500 : 200;
|
|
|
|
vi.mock("../../lib/i18n", () => createI18nMock());
|
|
|
|
vi.mock("../../lib/adminApi", () => ({
|
|
fetchMe: vi.fn(async () => ({
|
|
id: "admin-user",
|
|
role: "super_admin",
|
|
name: "Admin",
|
|
email: "admin@example.com",
|
|
})),
|
|
fetchAllTenants: vi.fn(async () => ({
|
|
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
|
|
total: 1,
|
|
})),
|
|
fetchTenant: vi.fn(async () => ({
|
|
id: "tenant-1",
|
|
name: "한맥",
|
|
slug: "hanmac",
|
|
config: { userSchema: [] },
|
|
})),
|
|
fetchUsers: fetchUsersMock,
|
|
bulkCreateUsers: vi.fn(),
|
|
bulkDeleteUsers: vi.fn(),
|
|
bulkUpdateUsers: vi.fn(),
|
|
deleteUser: vi.fn(),
|
|
exportUsersCSV: vi.fn(),
|
|
updateUser: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../components/ui/select", () => ({
|
|
Select: ({ children }: { children: React.ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
SelectTrigger: ({
|
|
children,
|
|
...props
|
|
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
|
selectRenderCounter.count += 1;
|
|
return (
|
|
<button type="button" {...props}>
|
|
{children}
|
|
</button>
|
|
);
|
|
},
|
|
SelectValue: () => <span />,
|
|
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
SelectItem: ({
|
|
children,
|
|
value: _value,
|
|
}: {
|
|
children: React.ReactNode;
|
|
value: string;
|
|
}) => <div>{children}</div>,
|
|
}));
|
|
|
|
function renderUserListPage() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
return render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<MemoryRouter>
|
|
<UserListPage />
|
|
</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
function createDeferred<T>() {
|
|
let resolve: (value: T) => void = () => {};
|
|
const promise = new Promise<T>((promiseResolve) => {
|
|
resolve = promiseResolve;
|
|
});
|
|
|
|
return { promise, resolve };
|
|
}
|
|
|
|
describe("UserListPage search rendering", () => {
|
|
beforeEach(() => {
|
|
selectRenderCounter.count = 0;
|
|
fetchUsersMock.mockReset();
|
|
fetchUsersMock.mockImplementation(
|
|
async (_limit: number, _offset: number, search?: string) => {
|
|
const normalizedSearch = search?.trim().toLowerCase();
|
|
const items = normalizedSearch
|
|
? users.filter((user) =>
|
|
`${user.name} ${user.email}`
|
|
.toLowerCase()
|
|
.includes(normalizedSearch),
|
|
)
|
|
: users;
|
|
return { items, total: items.length };
|
|
},
|
|
);
|
|
});
|
|
|
|
it("does not rerender user table controls while typing a draft search", async () => {
|
|
renderUserListPage();
|
|
|
|
await screen.findByText("User 0");
|
|
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색");
|
|
const renderCountBeforeTyping = selectRenderCounter.count;
|
|
|
|
fireEvent.change(searchInput, { target: { value: "u" } });
|
|
|
|
expect(searchInput).toHaveValue("u");
|
|
expect(selectRenderCounter.count).toBe(renderCountBeforeTyping);
|
|
});
|
|
|
|
it("keeps rendered row controls below the full 200-user result set", async () => {
|
|
renderUserListPage();
|
|
|
|
await screen.findByText("User 0");
|
|
|
|
expect(screen.getAllByTestId(/^user-status-select-/).length).toBeLessThan(
|
|
200,
|
|
);
|
|
});
|
|
|
|
it("renders compact vertically centered user table headers", async () => {
|
|
renderUserListPage();
|
|
|
|
await screen.findByText("User 0");
|
|
const nameHeader = screen.getByRole("columnheader", { name: /이름/ });
|
|
const content = nameHeader.firstElementChild;
|
|
|
|
expect(nameHeader).toHaveClass("h-9", "py-1", "align-middle", "text-xs");
|
|
expect(content).toHaveClass("flex", "h-full", "items-center");
|
|
});
|
|
|
|
it("centers the initial loading message across the user table", async () => {
|
|
const deferred = createDeferred<{ items: typeof users; total: number }>();
|
|
fetchUsersMock.mockReturnValueOnce(deferred.promise);
|
|
|
|
renderUserListPage();
|
|
|
|
const loadingCell = await screen.findByTestId("user-table-loading-cell");
|
|
expect(loadingCell).toHaveClass(
|
|
"flex",
|
|
"items-center",
|
|
"justify-center",
|
|
"text-center",
|
|
);
|
|
expect(loadingCell).toHaveStyle({ gridColumn: "1 / -1" });
|
|
|
|
deferred.resolve({ items: users, total: users.length });
|
|
});
|
|
|
|
it("renders a 200-user search result update within 200ms after search submit", async () => {
|
|
renderUserListPage();
|
|
|
|
await screen.findByText("User 0");
|
|
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색");
|
|
const startedAt = performance.now();
|
|
|
|
fireEvent.change(searchInput, { target: { value: "user 19" } });
|
|
fireEvent.keyDown(searchInput, { key: "Enter" });
|
|
|
|
expect(await screen.findByText("User 19")).toBeInTheDocument();
|
|
expect(screen.queryByText("User 0")).not.toBeInTheDocument();
|
|
expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
|
|
});
|
|
|
|
it("keeps rendered form fields identifiable for browser autofill diagnostics", async () => {
|
|
const { container } = renderUserListPage();
|
|
|
|
await screen.findByText("User 0");
|
|
const anonymousFields = Array.from(
|
|
container.querySelectorAll("input, select, textarea"),
|
|
).filter(
|
|
(field) =>
|
|
!field.getAttribute("id")?.trim() &&
|
|
!field.getAttribute("name")?.trim(),
|
|
);
|
|
|
|
expect(anonymousFields).toHaveLength(0);
|
|
});
|
|
});
|