forked from baron/baron-sso
185 lines
5.5 KiB
TypeScript
185 lines
5.5 KiB
TypeScript
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import AppLayout from "./AppLayout";
|
|
|
|
const authState = {
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
activeNavigator: undefined as string | undefined,
|
|
error: null as Error | null,
|
|
user: {
|
|
access_token: "access-token",
|
|
expires_at: Math.floor(Date.now() / 1000) + 120,
|
|
profile: {
|
|
sub: "user-1",
|
|
name: "Dev Admin",
|
|
email: "dev@example.com",
|
|
role: "super_admin",
|
|
},
|
|
},
|
|
signinSilent: vi.fn(),
|
|
removeUser: vi.fn(),
|
|
};
|
|
|
|
vi.mock("react-oidc-context", () => ({
|
|
useAuth: () => authState,
|
|
}));
|
|
|
|
vi.mock("../../features/auth/authApi", () => ({
|
|
fetchMe: vi.fn(async () => ({
|
|
id: "user-1",
|
|
name: "Fetched Dev Admin",
|
|
email: "fetched@example.com",
|
|
role: "super_admin",
|
|
})),
|
|
}));
|
|
|
|
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[] = [];
|
|
|
|
beforeEach(() => {
|
|
authState.isAuthenticated = true;
|
|
authState.isLoading = false;
|
|
authState.activeNavigator = undefined;
|
|
authState.error = null;
|
|
authState.user.expires_at = Math.floor(Date.now() / 1000) + 120;
|
|
authState.signinSilent.mockReset();
|
|
authState.signinSilent.mockResolvedValue(undefined);
|
|
authState.removeUser.mockReset();
|
|
window.localStorage.clear();
|
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const root of roots.splice(0)) {
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
}
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function renderLayout(initialEntry = "/clients") {
|
|
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 initialEntries={[initialEntry]}>
|
|
<Routes>
|
|
<Route path="/" element={<AppLayout />}>
|
|
<Route path="clients" element={<div>Client outlet</div>} />
|
|
<Route path="profile" element={<div>Profile outlet</div>} />
|
|
</Route>
|
|
</Routes>
|
|
</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
});
|
|
|
|
return container;
|
|
}
|
|
|
|
describe("devfront AppLayout", () => {
|
|
it("renders shell navigation, profile summary, and outlet content", async () => {
|
|
const container = await renderLayout();
|
|
|
|
expect(container.textContent).toContain("Developer Console");
|
|
expect(container.textContent).toContain("Clients");
|
|
expect(container.textContent).toContain("Client outlet");
|
|
expect(container.textContent).toContain("Fetched Dev Admin");
|
|
expect(document.documentElement.classList.contains("light")).toBe(true);
|
|
});
|
|
|
|
it("toggles the sidebar and persists the collapsed state", async () => {
|
|
const container = await renderLayout();
|
|
|
|
const collapseButton = container.querySelector(
|
|
'button[aria-label="사이드바 접기"]',
|
|
) as HTMLButtonElement;
|
|
await act(async () => {
|
|
collapseButton.click();
|
|
});
|
|
|
|
expect(window.localStorage.getItem("baron_shell_sidebar_collapsed")).toBe(
|
|
"true",
|
|
);
|
|
expect(
|
|
container.querySelector('button[aria-label="사이드바 펼치기"]'),
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => {
|
|
const container = await renderLayout();
|
|
|
|
const themeButton = container.querySelector(
|
|
'button[aria-label="Toggle theme"]',
|
|
) as HTMLButtonElement;
|
|
await act(async () => {
|
|
themeButton.click();
|
|
});
|
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
|
|
|
const profileButton = container.querySelector(
|
|
'button[aria-label="Open account menu"]',
|
|
) as HTMLButtonElement;
|
|
await act(async () => {
|
|
profileButton.click();
|
|
});
|
|
expect(container.textContent).toContain("My Profile");
|
|
|
|
const profileMenuItem = Array.from(
|
|
container.querySelectorAll('button[role="menuitem"]'),
|
|
).find((button) => button.textContent?.includes("My Profile"));
|
|
await act(async () => {
|
|
(profileMenuItem as HTMLButtonElement).click();
|
|
});
|
|
expect(container.textContent).toContain("Profile outlet");
|
|
|
|
const logoutButton = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.includes("Logout"),
|
|
);
|
|
await act(async () => {
|
|
(logoutButton as HTMLButtonElement).click();
|
|
});
|
|
expect(window.confirm).toHaveBeenCalled();
|
|
expect(authState.removeUser).toHaveBeenCalled();
|
|
});
|
|
|
|
it("attempts silent renewal after user action when the session is expiring", async () => {
|
|
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
|
|
await renderLayout();
|
|
|
|
await act(async () => {
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
|
|
});
|
|
|
|
expect(authState.signinSilent).toHaveBeenCalled();
|
|
});
|
|
});
|