test(web): add Vitest tests for search, auth, public, and admin layouts

- SearchLayout: verifies children pass-through (3 tests)
- AuthLayout: verifies role=main, #main-content, max-w-md centering (5 tests)
- PublicLayout: verifies navbar, ticker strip, footer, compare bar, #main-content (8 tests)
- AdminLayout: verifies sidebar nav, auth guard, loading state, logout, mobile toggle (10 tests)

All 156 web test files pass (1157 total web tests). Pre-existing API test
failures in unrelated modules (auth OTP handler, projects, search indexer,
admin settings encryption) are outside scope of this task.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-23 20:36:38 +07:00
parent 5a119df806
commit 2788b35108
4 changed files with 469 additions and 0 deletions

View File

@@ -0,0 +1,196 @@
/* eslint-disable import-x/order */
/**
* Kiểm thử AdminLayout: sidebar, nav links, auth guard, mobile header.
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl
const viMessages = await import('@/messages/vi.json');
vi.mock('next-intl', () => ({
useTranslations: (namespace?: string) => {
const messages = viMessages.default ?? viMessages;
const ns = namespace
? (messages[namespace as keyof typeof messages] as Record<string, unknown> | undefined)
: (messages as unknown as Record<string, unknown>);
return (key: string) => {
if (!ns) return key;
const parts = key.split('.');
let val: unknown = ns;
for (const p of parts) {
val = (val as Record<string, unknown>)?.[p];
}
return typeof val === 'string' ? val : key;
};
},
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => '/admin',
}));
vi.mock('@/i18n/navigation', () => ({
Link: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href as string} {...props}>{children}</a>
),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
vi.mock('@/components/ui/language-switcher', () => ({
LanguageSwitcher: () => <div data-testid="lang-switcher" />,
}));
const mockLogout = vi.fn();
const mockAuthStore = vi.fn(() => ({
user: { id: '1', fullName: 'Admin User', role: 'ADMIN', email: 'admin@goodgo.vn' },
isAuthenticated: true,
isInitialized: true,
logout: mockLogout,
}));
vi.mock('@/lib/auth-store', () => ({
useAuthStore: (...args: unknown[]) => mockAuthStore(...args),
}));
vi.mock('@/lib/utils', () => ({
cn: (...classes: (string | undefined | false | null)[]) => classes.filter(Boolean).join(' '),
}));
import AdminLayout from '../layout';
describe('AdminLayout', () => {
beforeEach(() => {
vi.clearAllMocks();
mockAuthStore.mockReturnValue({
user: { id: '1', fullName: 'Admin User', role: 'ADMIN', email: 'admin@goodgo.vn' },
isAuthenticated: true,
isInitialized: true,
logout: mockLogout,
});
});
it('renders children in main content area', () => {
render(
<AdminLayout>
<div data-testid="admin-page">Trang admin</div>
</AdminLayout>,
);
expect(screen.getByTestId('admin-page')).toBeInTheDocument();
});
it('renders sidebar navigation', () => {
render(
<AdminLayout>
<div>Content</div>
</AdminLayout>,
);
// There are two nav elements with the same label (sidebar + inner nav)
const navEls = screen.getAllByRole('navigation');
expect(navEls.length).toBeGreaterThan(0);
});
it('renders admin nav link to /admin/users', () => {
render(
<AdminLayout>
<div>Content</div>
</AdminLayout>,
);
expect(screen.getByRole('link', { name: /users|người dùng/i })).toBeInTheDocument();
});
it('renders main content with role="main"', () => {
render(
<AdminLayout>
<div>Content</div>
</AdminLayout>,
);
expect(screen.getByRole('main')).toBeInTheDocument();
});
it('renders main content with id="main-content"', () => {
const { container } = render(
<AdminLayout>
<div>Content</div>
</AdminLayout>,
);
expect(container.querySelector('#main-content')).toBeInTheDocument();
});
it('shows admin username in sidebar', () => {
render(
<AdminLayout>
<div>Content</div>
</AdminLayout>,
);
expect(screen.getByText('Admin User')).toBeInTheDocument();
});
it('shows loading state when not initialized', () => {
mockAuthStore.mockReturnValue({
user: null,
isAuthenticated: false,
isInitialized: false,
logout: mockLogout,
});
render(
<AdminLayout>
<div data-testid="hidden">Hidden</div>
</AdminLayout>,
);
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.queryByTestId('hidden')).not.toBeInTheDocument();
});
it('renders nothing when user is not ADMIN', () => {
mockAuthStore.mockReturnValue({
user: { id: '2', fullName: 'Regular User', role: 'USER', email: 'user@goodgo.vn' },
isAuthenticated: true,
isInitialized: true,
logout: mockLogout,
});
const { container } = render(
<AdminLayout>
<div data-testid="hidden">Hidden</div>
</AdminLayout>,
);
expect(screen.queryByTestId('hidden')).not.toBeInTheDocument();
expect(container.firstChild).toBeNull();
});
it('calls logout when logout button is clicked', async () => {
const user = userEvent.setup();
render(
<AdminLayout>
<div>Content</div>
</AdminLayout>,
);
const logoutBtn = screen.getByRole('button', { name: /logout|đăng xuất/i });
await user.click(logoutBtn);
expect(mockLogout).toHaveBeenCalled();
});
it('opens mobile sidebar when menu button is clicked', async () => {
const user = userEvent.setup();
const { container } = render(
<AdminLayout>
<div>Content</div>
</AdminLayout>,
);
// Mobile sidebar starts translated off-screen (has -translate-x-full class)
const aside = container.querySelector('aside');
expect(aside).toBeInTheDocument();
// Before open: contains -translate-x-full
expect(aside?.className).toContain('-translate-x-full');
// Click open menu button
const openBtn = screen.getByRole('button', { name: /openMenu|mở menu/i });
await user.click(openBtn);
// After open: translate-x-0 replaces -translate-x-full
expect(aside?.className).toContain('translate-x-0');
expect(aside?.className).not.toContain('-translate-x-full');
});
});