From 2788b3510859df9dcb6602644d0810bbadbb5bb2 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 23 Apr 2026 20:36:38 +0700 Subject: [PATCH] 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 --- .../(admin)/__tests__/layout.spec.tsx | 196 ++++++++++++++++++ .../[locale]/(auth)/__tests__/layout.spec.tsx | 61 ++++++ .../(public)/__tests__/layout.spec.tsx | 170 +++++++++++++++ .../(public)/search/__tests__/layout.spec.tsx | 42 ++++ 4 files changed, 469 insertions(+) create mode 100644 apps/web/app/[locale]/(admin)/__tests__/layout.spec.tsx create mode 100644 apps/web/app/[locale]/(auth)/__tests__/layout.spec.tsx create mode 100644 apps/web/app/[locale]/(public)/__tests__/layout.spec.tsx create mode 100644 apps/web/app/[locale]/(public)/search/__tests__/layout.spec.tsx diff --git a/apps/web/app/[locale]/(admin)/__tests__/layout.spec.tsx b/apps/web/app/[locale]/(admin)/__tests__/layout.spec.tsx new file mode 100644 index 0000000..878d4d4 --- /dev/null +++ b/apps/web/app/[locale]/(admin)/__tests__/layout.spec.tsx @@ -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 | undefined) + : (messages as unknown as Record); + return (key: string) => { + if (!ns) return key; + const parts = key.split('.'); + let val: unknown = ns; + for (const p of parts) { + val = (val as Record)?.[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 }) => ( + {children} + ), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock('@/components/ui/language-switcher', () => ({ + LanguageSwitcher: () =>
, +})); + +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( + +
Trang admin
+
, + ); + expect(screen.getByTestId('admin-page')).toBeInTheDocument(); + }); + + it('renders sidebar navigation', () => { + render( + +
Content
+
, + ); + // 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( + +
Content
+
, + ); + expect(screen.getByRole('link', { name: /users|người dùng/i })).toBeInTheDocument(); + }); + + it('renders main content with role="main"', () => { + render( + +
Content
+
, + ); + expect(screen.getByRole('main')).toBeInTheDocument(); + }); + + it('renders main content with id="main-content"', () => { + const { container } = render( + +
Content
+
, + ); + expect(container.querySelector('#main-content')).toBeInTheDocument(); + }); + + it('shows admin username in sidebar', () => { + render( + +
Content
+
, + ); + expect(screen.getByText('Admin User')).toBeInTheDocument(); + }); + + it('shows loading state when not initialized', () => { + mockAuthStore.mockReturnValue({ + user: null, + isAuthenticated: false, + isInitialized: false, + logout: mockLogout, + }); + render( + +
Hidden
+
, + ); + 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( + +
Hidden
+
, + ); + expect(screen.queryByTestId('hidden')).not.toBeInTheDocument(); + expect(container.firstChild).toBeNull(); + }); + + it('calls logout when logout button is clicked', async () => { + const user = userEvent.setup(); + render( + +
Content
+
, + ); + 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( + +
Content
+
, + ); + // 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'); + }); +}); diff --git a/apps/web/app/[locale]/(auth)/__tests__/layout.spec.tsx b/apps/web/app/[locale]/(auth)/__tests__/layout.spec.tsx new file mode 100644 index 0000000..3f3b905 --- /dev/null +++ b/apps/web/app/[locale]/(auth)/__tests__/layout.spec.tsx @@ -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( + +
Form đăng nhập
+
, + ); + expect(screen.getByTestId('form')).toBeInTheDocument(); + expect(screen.getByText('Form đăng nhập')).toBeInTheDocument(); + }); + + it('has role="main" on the outer element', () => { + render( + +
Nội dung
+
, + ); + expect(screen.getByRole('main')).toBeInTheDocument(); + }); + + it('has id="main-content" for skip-nav accessibility', () => { + const { container } = render( + +
Nội dung
+
, + ); + expect(container.querySelector('#main-content')).toBeInTheDocument(); + }); + + it('centres the form inside a max-width container', () => { + const { container } = render( + +
Form
+
, + ); + // Inner div has w-full max-w-md + const inner = container.querySelector('.max-w-md'); + expect(inner).toBeInTheDocument(); + }); + + it('renders multiple children', () => { + render( + +

Tiêu đề

+

Nội dung

+
, + ); + expect(screen.getByTestId('heading')).toBeInTheDocument(); + expect(screen.getByTestId('body')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/[locale]/(public)/__tests__/layout.spec.tsx b/apps/web/app/[locale]/(public)/__tests__/layout.spec.tsx new file mode 100644 index 0000000..f79a6a7 --- /dev/null +++ b/apps/web/app/[locale]/(public)/__tests__/layout.spec.tsx @@ -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 | undefined) + : (messages as unknown as Record); + return (key: string) => { + if (!ns) return key; + const parts = key.split('.'); + let val: unknown = ns; + for (const p of parts) { + val = (val as Record)?.[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 }) => ( + {children} + ), + 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: () => , +})); + +vi.mock('@/components/comparison/compare-floating-bar', () => ({ + CompareFloatingBar: () =>
, +})); + +vi.mock('@/components/design-system/ticker-strip', () => ({ + TickerStrip: ({ items }: { items: unknown[] }) => ( +
{items.length} items
+ ), +})); + +vi.mock('@/components/design-system/navbar', () => ({ + Navbar: ({ brand, links }: { brand: string; links: { label: string }[] }) => ( + + ), +})); + +vi.mock('@/components/design-system/footer', () => ({ + Footer: ({ brand, copyright }: { brand: string; copyright: string }) => ( +
+ {brand} + {copyright} +
+ ), +})); + +vi.mock('@/components/ui/language-switcher', () => ({ + LanguageSwitcher: () =>
, +})); + +import PublicLayout from '../layout'; + +describe('PublicLayout', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders main content wrapper with role="main"', () => { + render( + +
Trang chính
+
, + ); + expect(screen.getByRole('main')).toBeInTheDocument(); + expect(screen.getByTestId('page')).toBeInTheDocument(); + }); + + it('renders children inside main', () => { + render( + +

Tiêu đề trang

+
, + ); + expect(screen.getByText('Tiêu đề trang')).toBeInTheDocument(); + }); + + it('renders the navbar', () => { + render( + +
Content
+
, + ); + expect(screen.getByTestId('navbar')).toBeInTheDocument(); + }); + + it('renders the ticker strip', () => { + render( + +
Content
+
, + ); + expect(screen.getByTestId('ticker-strip')).toBeInTheDocument(); + }); + + it('renders the footer', () => { + render( + +
Content
+
, + ); + expect(screen.getByTestId('footer')).toBeInTheDocument(); + }); + + it('renders the compare floating bar', () => { + render( + +
Content
+
, + ); + expect(screen.getByTestId('compare-bar')).toBeInTheDocument(); + }); + + it('ticker strip has 8 district items', () => { + render( + +
Content
+
, + ); + expect(screen.getByText('8 items')).toBeInTheDocument(); + }); + + it('main content has id="main-content" for skip-nav', () => { + const { container } = render( + +
Content
+
, + ); + expect(container.querySelector('#main-content')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/[locale]/(public)/search/__tests__/layout.spec.tsx b/apps/web/app/[locale]/(public)/search/__tests__/layout.spec.tsx new file mode 100644 index 0000000..f8346f4 --- /dev/null +++ b/apps/web/app/[locale]/(public)/search/__tests__/layout.spec.tsx @@ -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( + +
Nội dung tìm kiếm
+
, + ); + expect(screen.getByTestId('child')).toBeInTheDocument(); + expect(screen.getByText('Nội dung tìm kiếm')).toBeInTheDocument(); + }); + + it('renders multiple children', () => { + render( + +

A

+

B

+
, + ); + expect(screen.getByTestId('a')).toBeInTheDocument(); + expect(screen.getByTestId('b')).toBeInTheDocument(); + }); + + it('does not add extra wrapper markup', () => { + const { container } = render( + + Span + , + ); + // The layout returns children directly, so the span should be the root child + expect(container.querySelector('#only-child')).toBeInTheDocument(); + }); +});