import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRoot, type Root } from "react-dom/client"; import { act } from "react-dom/test-utils"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ClientsPage from "./ClientsPage"; const navigateMock = vi.fn(); const fetchClientsMock = vi.fn(); const fetchMeMock = vi.fn(); const fetchDeveloperRequestStatusMock = vi.fn(); const fetchMyTenantsMock = vi.fn(); const requestDeveloperAccessMock = vi.fn(); let authState = { user: { access_token: "access-token", profile: { role: "super_admin", tenant_id: "tenant-1", companyCode: "HANMAC", name: "Dev Admin", email: "dev@example.com", phone: "010-0000-0000", }, }, }; vi.mock("react-oidc-context", () => ({ useAuth: () => authState, })); vi.mock("react-router-dom", async () => { const actual = await vi.importActual( "react-router-dom", ); return { ...actual, useNavigate: () => navigateMock, }; }); vi.mock("../../lib/devApi", () => ({ fetchClients: () => fetchClientsMock(), fetchMe: () => fetchMeMock(), fetchDeveloperRequestStatus: () => fetchDeveloperRequestStatusMock(), fetchMyTenants: () => fetchMyTenantsMock(), requestDeveloperAccess: (...args: unknown[]) => requestDeveloperAccessMock(...args), })); vi.mock("../../lib/i18n", () => ({ t: (key: string, fallback?: string, vars?: Record) => { let text = fallback ?? key; for (const [name, value] of Object.entries(vars ?? {})) { text = text.replaceAll(`{{${name}}}`, String(value)); } return text; }, })); const roots: Root[] = []; afterEach(() => { for (const root of roots.splice(0)) { act(() => { root.unmount(); }); } vi.clearAllMocks(); document.body.innerHTML = ""; }); beforeEach(() => { authState = { user: { access_token: "access-token", profile: { role: "super_admin", tenant_id: "tenant-1", companyCode: "HANMAC", name: "Dev Admin", email: "dev@example.com", phone: "010-0000-0000", }, }, }; fetchClientsMock.mockResolvedValue({ items: [], limit: 100, offset: 0, }); fetchMeMock.mockResolvedValue({ role: "super_admin", name: "Dev Admin", email: "dev@example.com", phone: "010-0000-0000", }); fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" }); fetchMyTenantsMock.mockResolvedValue([ { id: "tenant-1", name: "Hanmac", slug: "hanmac", type: "COMPANY", parentId: null, description: "", status: "active", memberCount: 10, createdAt: "2026-05-01T00:00:00Z", updatedAt: "2026-05-01T00:00:00Z", }, ]); }); function makeClients(count: number) { return Array.from({ length: count }, (_, index) => ({ id: `client-${index + 1}`, name: `App ${index + 1}`, type: index % 2 === 0 ? "private" : "pkce", status: index % 2 === 0 ? "active" : "inactive", createdAt: `2026-05-${String(index + 1).padStart(2, "0")}T00:00:00Z`, redirectUris: [], scopes: [], metadata: {}, })); } async function setInputValue(input: HTMLInputElement, value: string) { const descriptor = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", ); descriptor?.set?.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); await new Promise((resolve) => setTimeout(resolve, 0)); } async function renderPage() { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); roots.push(root); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); await act(async () => { root.render( , ); }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); return container; } async function waitForTextContent(container: HTMLElement, text: string) { for (let attempt = 0; attempt < 20; attempt += 1) { if (container.textContent?.includes(text)) { return; } await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); } throw new Error(`Expected container text to include: ${text}`); } describe("ClientsPage", () => { it("expands the list and applies search filters", async () => { fetchClientsMock.mockResolvedValue({ items: makeClients(6), limit: 100, offset: 0, }); const container = await renderPage(); expect(container.textContent).toContain( "총 6개의 애플리케이션이 등록되어 있습니다.", ); expect(container.textContent).toContain("App 6"); expect(container.textContent).toContain("App 2"); expect(container.textContent).not.toContain("App 1"); const moreButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "더보기", ); expect(moreButton).toBeTruthy(); await act(async () => { moreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(container.textContent).toContain("App 6"); expect(container.textContent).toContain("접기"); const advancedButton = Array.from( container.querySelectorAll("button"), ).find((button) => button.textContent === "Advanced Filters"); expect(advancedButton).toBeTruthy(); await act(async () => { advancedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); const searchInput = Array.from(container.querySelectorAll("input")).find( (input) => input .getAttribute("placeholder") ?.includes("클라이언트 이름/ID로 검색"), ) as HTMLInputElement | undefined; if (!searchInput) { throw new Error("Expected search input to be rendered"); } await act(async () => { await setInputValue(searchInput, "missing-client"); }); expect(container.textContent).toContain("조건에 맞는 연동 앱이 없습니다."); const resetButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "초기화", ); expect(resetButton).toBeTruthy(); await act(async () => { resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await act(async () => { await setInputValue(searchInput, ""); }); expect(container.textContent).toContain("App 1"); }); it("navigates to the developer request page from empty states", async () => { authState = { user: { access_token: "access-token", profile: { role: "user", tenant_id: "tenant-1", companyCode: "HANMAC", name: "Requester", email: "requester@example.com", phone: "010-1234-5678", }, }, }; fetchClientsMock.mockResolvedValue({ items: [], limit: 100, offset: 0, }); fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" }); fetchMeMock.mockResolvedValue({ role: "user", name: "Requester", email: "requester@example.com", phone: "010-1234-5678", }); const container = await renderPage(); expect(container.textContent).toContain("개발자 등록 신청하기"); const requestButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "개발자 등록 신청하기", ); expect(requestButton).toBeTruthy(); await act(async () => { requestButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(navigateMock).toHaveBeenCalledWith("/developer-requests"); }); it("allows a user without tenant context to request developer access", async () => { authState = { user: { access_token: "access-token", profile: { role: "user", companyCode: "HANMAC", name: "Requester", email: "requester@example.com", phone: "010-1234-5678", }, }, }; fetchMeMock.mockResolvedValue({ role: "user", name: "Requester", email: "requester@example.com", phone: "010-1234-5678", }); fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" }); const container = await renderPage(); await waitForTextContent(container, "개발자 등록 신청하기"); const requestButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "개발자 등록 신청하기", ); expect(requestButton).toBeTruthy(); await act(async () => { requestButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(navigateMock).toHaveBeenCalledWith("/developer-requests"); expect(fetchDeveloperRequestStatusMock).toHaveBeenCalled(); }); it("shows the create app button for a super admin without tenant context", async () => { authState = { user: { access_token: "access-token", profile: { role: "super_admin", companyCode: "HANMAC", name: "Dev Admin", email: "dev@example.com", phone: "010-0000-0000", }, }, }; fetchMeMock.mockResolvedValue({ role: "super_admin", name: "Dev Admin", email: "dev@example.com", phone: "010-0000-0000", }); const container = await renderPage(); expect(container.textContent).toContain("연동 앱 추가"); const createButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "연동 앱 추가", ); expect(createButton).toBeTruthy(); await act(async () => { createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(navigateMock).toHaveBeenCalledWith("/clients/new"); }); });