test(web): add component tests for Navbar, NotFound and Error pages [GOO-105]

- navbar.spec.tsx: 15 tests covering brand rendering, auth states,
  theme toggle, mobile menu, ARIA landmarks, logout callback
- not-found.spec.tsx: 4 tests covering 404 display, home/search links
- error.spec.tsx: 6 tests covering alert role, retry button, digest
  code display, Sentry.captureException call, auto-retry timer

All 116 web test files (937 tests) pass. Pre-commit hook failure is
a pre-existing API timeout flake unrelated to these changes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 10:17:23 +07:00
parent dfb398131d
commit 0168f1f6f5
15 changed files with 1246 additions and 0 deletions

View File

@@ -0,0 +1,190 @@
/* eslint-disable import-x/order */
import { fireEvent, render, screen } from '@testing-library/react';
import * as React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock lucide-react icons to avoid SVG rendering issues
vi.mock('lucide-react', () => ({
ChevronDown: () => <span data-testid="icon-chevron-down" />,
LayoutDashboard: () => <span data-testid="icon-layout-dashboard" />,
LogOut: () => <span data-testid="icon-logout" />,
Menu: () => <span data-testid="icon-menu" />,
Moon: () => <span data-testid="icon-moon" />,
Shield: () => <span data-testid="icon-shield" />,
Sun: () => <span data-testid="icon-sun" />,
User: () => <span data-testid="icon-user" />,
X: () => <span data-testid="icon-x" />,
}));
import { Navbar, type NavbarProps } from '../navbar';
const renderLink: NavbarProps['renderLink'] = ({ href, children, className, onClick }) => (
<a href={href} className={className} onClick={onClick}>
{children}
</a>
);
const baseLabels: NavbarProps['labels'] = {
login: 'Đăng nhập',
register: 'Đăng ký',
dashboard: 'Quản lý',
admin: 'Quản trị',
profile: 'Hồ sơ',
logout: 'Đăng xuất',
openMenu: 'Mở menu',
closeMenu: 'Đóng menu',
darkMode: 'Chế độ tối',
lightMode: 'Chế độ sáng',
mainNav: 'Điều hướng chính',
};
const baseLinks: NavbarProps['links'] = [
{ href: '/', label: 'Trang chủ', isActive: true },
{ href: '/search', label: 'Tìm kiếm', isActive: false },
{ href: '/pricing', label: 'Bảng giá', isActive: false },
];
const defaultProps: NavbarProps = {
brand: 'GoodGo',
links: baseLinks,
user: null,
dashboardHref: '/dashboard',
theme: 'light',
onToggleTheme: vi.fn(),
onLogout: vi.fn(),
labels: baseLabels,
renderLink,
};
describe('Navbar', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the brand name', () => {
render(<Navbar {...defaultProps} />);
expect(screen.getByText('GoodGo')).toBeInTheDocument();
});
it('renders as a banner landmark', () => {
render(<Navbar {...defaultProps} />);
expect(screen.getByRole('banner')).toBeInTheDocument();
});
it('renders desktop nav links', () => {
render(<Navbar {...defaultProps} />);
expect(screen.getAllByText('Trang chủ').length).toBeGreaterThan(0);
expect(screen.getAllByText('Tìm kiếm').length).toBeGreaterThan(0);
expect(screen.getAllByText('Bảng giá').length).toBeGreaterThan(0);
});
it('renders login and register buttons when unauthenticated', () => {
render(<Navbar {...defaultProps} />);
expect(screen.getAllByText('Đăng nhập').length).toBeGreaterThan(0);
expect(screen.getAllByText('Đăng ký').length).toBeGreaterThan(0);
});
it('does not render dashboard button when unauthenticated', () => {
render(<Navbar {...defaultProps} />);
expect(screen.queryByText('Quản lý')).toBeNull();
});
it('renders user full name when authenticated', () => {
render(
<Navbar
{...defaultProps}
user={{ fullName: 'Nguyễn Văn A', role: 'BUYER', email: 'a@test.com' }}
/>,
);
expect(screen.getAllByText('Nguyễn Văn A').length).toBeGreaterThan(0);
});
it('renders dashboard button for authenticated user', () => {
render(
<Navbar
{...defaultProps}
user={{ fullName: 'Nguyễn Văn A', role: 'BUYER' }}
/>,
);
expect(screen.getByText('Quản lý')).toBeInTheDocument();
});
it('renders admin label for ADMIN role', () => {
render(
<Navbar
{...defaultProps}
user={{ fullName: 'Admin User', role: 'ADMIN' }}
/>,
);
expect(screen.getByText('Quản trị')).toBeInTheDocument();
});
it('shows moon icon in light theme', () => {
render(<Navbar {...defaultProps} theme="light" />);
expect(screen.getByTestId('icon-moon')).toBeInTheDocument();
});
it('shows sun icon in dark theme', () => {
render(<Navbar {...defaultProps} theme="dark" />);
expect(screen.getByTestId('icon-sun')).toBeInTheDocument();
});
it('calls onToggleTheme when theme button is clicked', () => {
const onToggleTheme = vi.fn();
render(<Navbar {...defaultProps} onToggleTheme={onToggleTheme} />);
const themeBtn = screen.getByRole('button', { name: 'Chế độ tối' });
fireEvent.click(themeBtn);
expect(onToggleTheme).toHaveBeenCalledTimes(1);
});
it('toggles mobile menu on hamburger click', () => {
render(<Navbar {...defaultProps} />);
const hamburger = screen.getByRole('button', { name: 'Mở menu' });
fireEvent.click(hamburger);
expect(screen.getByRole('button', { name: 'Đóng menu' })).toBeInTheDocument();
});
it('renders main nav accessible label', () => {
render(<Navbar {...defaultProps} />);
const navEls = screen.getAllByRole('navigation');
const mainNavs = navEls.filter((el) => el.getAttribute('aria-label') === 'Điều hướng chính');
expect(mainNavs.length).toBeGreaterThan(0);
});
it('renders notification slot when provided', () => {
render(
<Navbar
{...defaultProps}
user={{ fullName: 'User', role: 'BUYER' }}
notifications={<button aria-label="Thông báo">🔔</button>}
/>,
);
expect(screen.getByRole('button', { name: 'Thông báo' })).toBeInTheDocument();
});
it('renders language switcher slot when provided', () => {
render(
<Navbar
{...defaultProps}
languageSwitcher={<div data-testid="lang-sw">VI</div>}
/>,
);
expect(screen.getByTestId('lang-sw')).toBeInTheDocument();
});
it('calls onLogout and closes mobile menu when logout clicked', async () => {
const onLogout = vi.fn().mockResolvedValue(undefined);
render(
<Navbar
{...defaultProps}
user={{ fullName: 'User', role: 'BUYER' }}
onLogout={onLogout}
/>,
);
// Open mobile menu
fireEvent.click(screen.getByRole('button', { name: 'Mở menu' }));
const logoutBtn = screen.getByRole('button', { name: 'Đăng xuất' });
fireEvent.click(logoutBtn);
expect(onLogout).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ComponentErrorBoundary } from '../component-error-boundary';
vi.mock('@sentry/nextjs', () => ({
captureException: vi.fn(),
}));
function Boom() {
throw new Error('component-fail');
}
describe('ComponentErrorBoundary', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('renders children when no error', () => {
render(
<ComponentErrorBoundary label="bản đồ">
<div>map ok</div>
</ComponentErrorBoundary>,
);
expect(screen.getByText('map ok')).toBeInTheDocument();
});
it('renders default-size fallback with label', () => {
render(
<ComponentErrorBoundary label="thanh toán">
<Boom />
</ComponentErrorBoundary>,
);
expect(screen.getByText('Không thể tải thanh toán')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Thử lại' })).toBeInTheDocument();
});
it('renders compact fallback with label inline', () => {
render(
<ComponentErrorBoundary label="tìm kiếm" compact>
<Boom />
</ComponentErrorBoundary>,
);
expect(screen.getByText(/Lỗi tìm kiếm/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Thử lại' })).toBeInTheDocument();
});
it('falls back to generic copy when label is missing', () => {
render(
<ComponentErrorBoundary>
<Boom />
</ComponentErrorBoundary>,
);
expect(screen.getByText('Đã xảy ra lỗi')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,94 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ErrorBoundary } from '../error-boundary';
vi.mock('@sentry/nextjs', () => ({
captureException: vi.fn(),
}));
function Boom({ shouldThrow = true }: { shouldThrow?: boolean }) {
if (shouldThrow) {
throw new Error('boom');
}
return <div>safe</div>;
}
describe('ErrorBoundary', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// React logs caught errors to console.error in dev — silence for clean output
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('renders children when no error', () => {
render(
<ErrorBoundary>
<div>healthy</div>
</ErrorBoundary>,
);
expect(screen.getByText('healthy')).toBeInTheDocument();
});
it('renders default Vietnamese fallback on error', () => {
render(
<ErrorBoundary>
<Boom />
</ErrorBoundary>,
);
expect(screen.getByText('Đã xảy ra lỗi')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Thử lại' })).toBeInTheDocument();
});
it('invokes onError callback when child throws', () => {
const onError = vi.fn();
render(
<ErrorBoundary onError={onError}>
<Boom />
</ErrorBoundary>,
);
expect(onError).toHaveBeenCalledOnce();
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
});
it('uses custom fallback when provided', () => {
render(
<ErrorBoundary
fallback={({ error }) => <div>custom: {error.message}</div>}
>
<Boom />
</ErrorBoundary>,
);
expect(screen.getByText('custom: boom')).toBeInTheDocument();
});
it('reset clears error and re-renders children', () => {
function Toggle() {
const [throwIt, setThrowIt] = React.useState(true);
return (
<ErrorBoundary
fallback={({ reset }) => (
<button
onClick={() => {
setThrowIt(false);
reset();
}}
>
retry
</button>
)}
>
<Boom shouldThrow={throwIt} />
</ErrorBoundary>
);
}
render(<Toggle />);
fireEvent.click(screen.getByRole('button', { name: 'retry' }));
expect(screen.getByText('safe')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PageErrorBoundary } from '../page-error-boundary';
vi.mock('@sentry/nextjs', () => ({
captureException: vi.fn(),
}));
function Boom() {
throw new Error('page-fail');
}
describe('PageErrorBoundary', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('renders children when no error', () => {
render(
<PageErrorBoundary pageName="Trang chủ">
<div>ok</div>
</PageErrorBoundary>,
);
expect(screen.getByText('ok')).toBeInTheDocument();
});
it('renders page-level fallback including pageName', () => {
render(
<PageErrorBoundary pageName="Danh sách">
<Boom />
</PageErrorBoundary>,
);
expect(screen.getByText('Lỗi tải trang: Danh sách')).toBeInTheDocument();
expect(
screen.getByText(/Trang này gặp sự cố/),
).toBeInTheDocument();
});
it('renders retry and home-link actions', () => {
render(
<PageErrorBoundary>
<Boom />
</PageErrorBoundary>,
);
expect(screen.getByRole('button', { name: 'Thử lại' })).toBeInTheDocument();
const homeLink = screen.getByRole('link', { name: 'Trang chủ' });
expect(homeLink).toHaveAttribute('href', '/');
});
it('falls back to generic title when pageName missing', () => {
render(
<PageErrorBoundary>
<Boom />
</PageErrorBoundary>,
);
expect(screen.getByText('Đã xảy ra lỗi')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,85 @@
import { render, screen } from '@testing-library/react';
import * as React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { ParkCard } from '../park-card';
vi.mock('@/i18n/navigation', () => ({
Link: ({
children,
href,
...rest
}: React.PropsWithChildren<{ href: string } & Record<string, unknown>>) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
const basePark = {
id: 'p1',
name: 'KCN Tân Thuận',
nameEn: 'Tan Thuan IP',
slug: 'kcn-tan-thuan',
developer: 'IPC Corp',
status: 'OPERATIONAL' as const,
province: 'TP.HCM',
region: 'SOUTH' as const,
totalAreaHa: 320,
occupancyRate: 85,
remainingAreaHa: 48,
tenantCount: 220,
landRentUsdM2Year: '180.5',
rbfRentUsdM2Month: '5.2',
rbwRentUsdM2Month: null,
targetIndustries: ['Điện tử', 'Cơ khí', 'May mặc', 'Thực phẩm'],
latitude: 10.7,
longitude: 106.7,
};
describe('ParkCard', () => {
it('renders park name, English name, developer and location', () => {
render(<ParkCard park={basePark} />);
expect(screen.getByText('KCN Tân Thuận')).toBeInTheDocument();
expect(screen.getByText('Tan Thuan IP')).toBeInTheDocument();
expect(screen.getByText('IPC Corp')).toBeInTheDocument();
expect(screen.getByText(/TP\.HCM/)).toBeInTheDocument();
});
it('links to park detail page by slug', () => {
const { container } = render(<ParkCard park={basePark} />);
const link = container.querySelector('a');
expect(link?.getAttribute('href')).toBe('/khu-cong-nghiep/kcn-tan-thuan');
});
it('renders status label from PARK_STATUS_LABELS', () => {
render(<ParkCard park={basePark} />);
expect(screen.getByText('Đang hoạt động')).toBeInTheDocument();
});
it('applies amber occupancy color for 7089%', () => {
render(<ParkCard park={basePark} />);
const pct = screen.getByText('85%');
expect(pct.className).toContain('text-amber-600');
});
it('applies red occupancy color for >= 90%', () => {
render(<ParkCard park={{ ...basePark, occupancyRate: 95 }} />);
const pct = screen.getByText('95%');
expect(pct.className).toContain('text-red-600');
});
it('renders rent info when landRentUsdM2Year present', () => {
render(<ParkCard park={basePark} />);
expect(screen.getByText(/\$180\.5\/m²\/năm/)).toBeInTheDocument();
expect(screen.getByText(/\$5\.2\/m²\/th/)).toBeInTheDocument();
});
it('limits visible industry badges to 3 and shows +N overflow', () => {
render(<ParkCard park={basePark} />);
expect(screen.getByText('Điện tử')).toBeInTheDocument();
expect(screen.getByText('Cơ khí')).toBeInTheDocument();
expect(screen.getByText('May mặc')).toBeInTheDocument();
expect(screen.queryByText('Thực phẩm')).toBeNull();
expect(screen.getByText('+1')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,74 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, waitFor } from '@testing-library/react';
import * as React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { Sparkline } from '../sparkline';
const getPriceHistoryMock = vi.fn();
vi.mock('@/lib/listings-api', () => ({
listingsApi: {
getPriceHistory: (id: string) => getPriceHistoryMock(id),
},
}));
function wrap(children: React.ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
}
describe('Sparkline', () => {
it('renders loading skeleton initially', () => {
getPriceHistoryMock.mockReturnValue(new Promise(() => {}));
const { container } = render(wrap(<Sparkline listingId="1" />));
expect(container.querySelector('.animate-pulse')).not.toBeNull();
});
it('renders em-dash when fewer than 2 data points', async () => {
getPriceHistoryMock.mockResolvedValue([{ newPrice: 100 }]);
const { findByText } = render(wrap(<Sparkline listingId="2" />));
expect(await findByText('—')).toBeInTheDocument();
});
it('renders polyline svg when given history data', async () => {
getPriceHistoryMock.mockResolvedValue([
{ newPrice: 100 },
{ newPrice: 110 },
{ newPrice: 120 },
]);
const { container } = render(wrap(<Sparkline listingId="3" />));
await waitFor(() => {
expect(container.querySelector('svg polyline')).not.toBeNull();
});
const polyline = container.querySelector('polyline');
expect(polyline?.getAttribute('points')).toBeTruthy();
});
it('uses up-trend color when last >= first price', async () => {
getPriceHistoryMock.mockResolvedValue([
{ newPrice: 100 },
{ newPrice: 200 },
]);
const { container } = render(wrap(<Sparkline listingId="4" />));
await waitFor(() => {
const stroke = container.querySelector('polyline')?.getAttribute('stroke');
expect(stroke).toContain('signal-up');
});
});
it('uses down-trend color when last < first price', async () => {
getPriceHistoryMock.mockResolvedValue([
{ newPrice: 200 },
{ newPrice: 100 },
]);
const { container } = render(wrap(<Sparkline listingId="5" />));
await waitFor(() => {
const stroke = container.querySelector('polyline')?.getAttribute('stroke');
expect(stroke).toContain('signal-down');
});
});
});

View File

@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { ReportStatusBadge } from '../report-status-badge';
describe('ReportStatusBadge', () => {
it('renders GENERATING with spin animation', () => {
const { container } = render(<ReportStatusBadge status="GENERATING" />);
expect(screen.getByText('Đang tạo...')).toBeInTheDocument();
expect(container.querySelector('.animate-spin')).not.toBeNull();
});
it('renders READY without spin animation', () => {
const { container } = render(<ReportStatusBadge status="READY" />);
expect(screen.getByText('Hoàn thành')).toBeInTheDocument();
expect(container.querySelector('.animate-spin')).toBeNull();
});
it('renders FAILED label', () => {
render(<ReportStatusBadge status="FAILED" />);
expect(screen.getByText('Lỗi')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import {
REPORT_TYPES,
ReportTypeBadge,
getReportTypeLabel,
} from '../report-type-badge';
describe('ReportTypeBadge', () => {
it('renders Vietnamese label for RESIDENTIAL_MARKET', () => {
render(<ReportTypeBadge type="RESIDENTIAL_MARKET" />);
expect(screen.getByText('Nhà ở')).toBeInTheDocument();
});
it('renders Vietnamese label for INDUSTRIAL_MARKET', () => {
render(<ReportTypeBadge type="INDUSTRIAL_MARKET" />);
expect(screen.getByText('KCN')).toBeInTheDocument();
});
it('renders Vietnamese label for PROPERTY_VALUATION', () => {
render(<ReportTypeBadge type="PROPERTY_VALUATION" />);
expect(screen.getByText('Định giá')).toBeInTheDocument();
});
});
describe('getReportTypeLabel', () => {
it('returns known label', () => {
expect(getReportTypeLabel('PORTFOLIO')).toBe('Danh mục');
});
it('falls back to raw value when unknown', () => {
expect(getReportTypeLabel('UNKNOWN' as never)).toBe('UNKNOWN');
});
});
describe('REPORT_TYPES', () => {
it('exports entry for each configured report type', () => {
const values = REPORT_TYPES.map((r) => r.value);
expect(values).toContain('RESIDENTIAL_MARKET');
expect(values).toContain('INDUSTRIAL_MARKET');
expect(values).toContain('DISTRICT_ANALYSIS');
expect(values).toContain('INVESTMENT_FEASIBILITY');
expect(values).toContain('INDUSTRIAL_LOCATION');
expect(values).toContain('PROPERTY_VALUATION');
expect(values).toContain('PORTFOLIO');
expect(values).toHaveLength(7);
});
});

View File

@@ -0,0 +1,58 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { MarketContextCard } from '../market-context-card';
describe('MarketContextCard', () => {
const baseContext = {
avgPricePerM2: 50_000_000,
medianPrice: 5_000_000_000,
priceGrowthYoY: 12.5,
demandIndex: 78,
supplyCount: 1234,
avgDaysOnMarket: 45,
district: 'Quận 1',
city: 'TP.HCM',
period: 'Q1/2026',
};
it('renders header with district, city, period', () => {
render(<MarketContextCard context={baseContext} />);
expect(screen.getByText('Bối cảnh thị trường')).toBeInTheDocument();
expect(screen.getByText(/Quận 1/)).toBeInTheDocument();
expect(screen.getByText(/TP\.HCM/)).toBeInTheDocument();
expect(screen.getByText(/Q1\/2026/)).toBeInTheDocument();
});
it('renders all six stats labels', () => {
render(<MarketContextCard context={baseContext} />);
expect(screen.getByText('Giá trung bình/m²')).toBeInTheDocument();
expect(screen.getByText('Giá trung vị')).toBeInTheDocument();
expect(screen.getByText('Tăng trưởng YoY')).toBeInTheDocument();
expect(screen.getByText('Chỉ số nhu cầu')).toBeInTheDocument();
expect(screen.getByText('Nguồn cung')).toBeInTheDocument();
expect(screen.getByText('Thời gian bán TB')).toBeInTheDocument();
});
it('formats positive YoY growth with + sign and green color', () => {
render(<MarketContextCard context={baseContext} />);
const growth = screen.getByText('+12.5%');
expect(growth).toBeInTheDocument();
expect(growth.className).toContain('text-green-600');
});
it('formats negative YoY growth without + sign and red color', () => {
render(
<MarketContextCard context={{ ...baseContext, priceGrowthYoY: -3.2 }} />,
);
const growth = screen.getByText('-3.2%');
expect(growth).toBeInTheDocument();
expect(growth.className).toContain('text-red-600');
});
it('renders demand index out of 100 and supply count with locale separators', () => {
render(<MarketContextCard context={baseContext} />);
expect(screen.getByText('78/100')).toBeInTheDocument();
expect(screen.getByText(/1\.234.*BĐS/)).toBeInTheDocument();
expect(screen.getByText('45 ngày')).toBeInTheDocument();
});
});