forked from baron/baron-sso
367 lines
10 KiB
TypeScript
367 lines
10 KiB
TypeScript
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<typeof import("react-router-dom")>(
|
|
"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<string, unknown>) => {
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<MemoryRouter>
|
|
<ClientsPage />
|
|
</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|