forked from baron/baron-sso
test: raise frontend coverage baselines
This commit is contained in:
166
devfront/src/components/layout/AppLayout.test.tsx
Normal file
166
devfront/src/components/layout/AppLayout.test.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
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 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user