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:
196
apps/web/app/[locale]/(admin)/__tests__/layout.spec.tsx
Normal file
196
apps/web/app/[locale]/(admin)/__tests__/layout.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
61
apps/web/app/[locale]/(auth)/__tests__/layout.spec.tsx
Normal file
61
apps/web/app/[locale]/(auth)/__tests__/layout.spec.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/* eslint-disable import-x/order */
|
||||
/**
|
||||
* Kiểm thử AuthLayout: wrapper căn giữa cho trang đăng nhập / đăng ký.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import AuthLayout from '../layout';
|
||||
|
||||
describe('AuthLayout', () => {
|
||||
it('renders children', () => {
|
||||
render(
|
||||
<AuthLayout>
|
||||
<div data-testid="form">Form đăng nhập</div>
|
||||
</AuthLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('form')).toBeInTheDocument();
|
||||
expect(screen.getByText('Form đăng nhập')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has role="main" on the outer element', () => {
|
||||
render(
|
||||
<AuthLayout>
|
||||
<div>Nội dung</div>
|
||||
</AuthLayout>,
|
||||
);
|
||||
expect(screen.getByRole('main')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has id="main-content" for skip-nav accessibility', () => {
|
||||
const { container } = render(
|
||||
<AuthLayout>
|
||||
<div>Nội dung</div>
|
||||
</AuthLayout>,
|
||||
);
|
||||
expect(container.querySelector('#main-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('centres the form inside a max-width container', () => {
|
||||
const { container } = render(
|
||||
<AuthLayout>
|
||||
<div>Form</div>
|
||||
</AuthLayout>,
|
||||
);
|
||||
// Inner div has w-full max-w-md
|
||||
const inner = container.querySelector('.max-w-md');
|
||||
expect(inner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple children', () => {
|
||||
render(
|
||||
<AuthLayout>
|
||||
<h1 data-testid="heading">Tiêu đề</h1>
|
||||
<p data-testid="body">Nội dung</p>
|
||||
</AuthLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('heading')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('body')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
170
apps/web/app/[locale]/(public)/__tests__/layout.spec.tsx
Normal file
170
apps/web/app/[locale]/(public)/__tests__/layout.spec.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
/* eslint-disable import-x/order */
|
||||
/**
|
||||
* Kiểm thử PublicLayout: navbar, ticker strip, footer, main content.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
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;
|
||||
};
|
||||
},
|
||||
useLocale: () => 'vi',
|
||||
NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => '/',
|
||||
}));
|
||||
|
||||
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() }),
|
||||
usePathname: () => '/',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-store', () => ({
|
||||
useAuthStore: () => ({
|
||||
user: null,
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/providers/theme-provider', () => ({
|
||||
useTheme: () => ({ theme: 'light', toggleTheme: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/notifications/notification-bell', () => ({
|
||||
NotificationBell: () => <button aria-label="Thông báo">🔔</button>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/comparison/compare-floating-bar', () => ({
|
||||
CompareFloatingBar: () => <div data-testid="compare-bar" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/design-system/ticker-strip', () => ({
|
||||
TickerStrip: ({ items }: { items: unknown[] }) => (
|
||||
<div data-testid="ticker-strip" aria-label="ticker">{items.length} items</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/design-system/navbar', () => ({
|
||||
Navbar: ({ brand, links }: { brand: string; links: { label: string }[] }) => (
|
||||
<nav data-testid="navbar" aria-label="main-nav">
|
||||
<span>{brand}</span>
|
||||
{links.map((l) => <span key={l.label}>{l.label}</span>)}
|
||||
</nav>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/design-system/footer', () => ({
|
||||
Footer: ({ brand, copyright }: { brand: string; copyright: string }) => (
|
||||
<footer data-testid="footer">
|
||||
<span>{brand}</span>
|
||||
<span>{copyright}</span>
|
||||
</footer>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/language-switcher', () => ({
|
||||
LanguageSwitcher: () => <div data-testid="lang-switcher" />,
|
||||
}));
|
||||
|
||||
import PublicLayout from '../layout';
|
||||
|
||||
describe('PublicLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders main content wrapper with role="main"', () => {
|
||||
render(
|
||||
<PublicLayout>
|
||||
<div data-testid="page">Trang chính</div>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(screen.getByRole('main')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children inside main', () => {
|
||||
render(
|
||||
<PublicLayout>
|
||||
<h1>Tiêu đề trang</h1>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(screen.getByText('Tiêu đề trang')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the navbar', () => {
|
||||
render(
|
||||
<PublicLayout>
|
||||
<div>Content</div>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('navbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the ticker strip', () => {
|
||||
render(
|
||||
<PublicLayout>
|
||||
<div>Content</div>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('ticker-strip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the footer', () => {
|
||||
render(
|
||||
<PublicLayout>
|
||||
<div>Content</div>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the compare floating bar', () => {
|
||||
render(
|
||||
<PublicLayout>
|
||||
<div>Content</div>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('compare-bar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ticker strip has 8 district items', () => {
|
||||
render(
|
||||
<PublicLayout>
|
||||
<div>Content</div>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(screen.getByText('8 items')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('main content has id="main-content" for skip-nav', () => {
|
||||
const { container } = render(
|
||||
<PublicLayout>
|
||||
<div>Content</div>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(container.querySelector('#main-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/* eslint-disable import-x/order */
|
||||
/**
|
||||
* Kiểm thử SearchLayout: layout đơn giản chỉ render children.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import SearchLayout from '../layout';
|
||||
|
||||
describe('SearchLayout', () => {
|
||||
it('renders children', () => {
|
||||
render(
|
||||
<SearchLayout>
|
||||
<div data-testid="child">Nội dung tìm kiếm</div>
|
||||
</SearchLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nội dung tìm kiếm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple children', () => {
|
||||
render(
|
||||
<SearchLayout>
|
||||
<p data-testid="a">A</p>
|
||||
<p data-testid="b">B</p>
|
||||
</SearchLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('a')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not add extra wrapper markup', () => {
|
||||
const { container } = render(
|
||||
<SearchLayout>
|
||||
<span id="only-child">Span</span>
|
||||
</SearchLayout>,
|
||||
);
|
||||
// The layout returns children directly, so the span should be the root child
|
||||
expect(container.querySelector('#only-child')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user