test(web): increase frontend test coverage to ~70% page coverage

- Fix vitest config to include [locale] directory tests (was excluded)
- Fix register.spec.tsx: use getByRole('heading') to avoid duplicate text match
- Fix search.spec.tsx: add QueryClientProvider wrapper and mock saved searches hook
- Add 12 new page test files covering dashboard, admin, public, and OAuth pages:
  - dashboard (main, profile, payments, subscription, KYC)
  - admin (dashboard, users)
  - public (landing, pricing)
  - analytics
  - OAuth callbacks (Google, Zalo)
- 29 test files, 174 tests, 16/23 pages covered (69.6%)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 23:14:16 +07:00
parent d62eb5f164
commit 68b65cb848
15 changed files with 1122 additions and 7 deletions

View File

@@ -0,0 +1,100 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl with Vietnamese messages
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, params?: Record<string, unknown>) => {
if (!ns) return key;
const parts = key.split('.');
let val: unknown = ns;
for (const p of parts) {
val = (val as Record<string, unknown>)?.[p];
}
if (typeof val === 'string' && params) {
return val.replace(/\{(\w+)\}/g, (_, k: string) => String(params[k] ?? `{${k}}`));
}
return typeof val === 'string' ? val : key;
};
},
useLocale: () => 'vi',
NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
}));
vi.mock('next/image', () => ({
default: (props: Record<string, unknown>) => <img {...props} />,
}));
vi.mock('@/i18n/navigation', () => ({
Link: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => '/',
redirect: vi.fn(),
}));
vi.mock('@/lib/listings-api', () => ({
listingsApi: {
search: vi.fn().mockResolvedValue({ data: [], total: 0 }),
},
}));
vi.mock('@/components/search/property-card', () => ({
PropertyCard: ({ listing }: { listing: { id: string } }) => <div data-testid={`listing-${listing.id}`}>Listing</div>,
}));
import LandingPage from '../page';
describe('LandingPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders hero section with search form', async () => {
render(<LandingPage />);
await waitFor(() => {
expect(screen.getByRole('search')).toBeInTheDocument();
});
});
it('renders property type badges', async () => {
render(<LandingPage />);
await waitFor(() => {
// Property type badges from Vietnamese messages
expect(screen.getAllByRole('link').length).toBeGreaterThan(0);
});
});
it('renders districts section', async () => {
render(<LandingPage />);
await waitFor(() => {
expect(screen.getByText('Quận 1')).toBeInTheDocument();
expect(screen.getByText('Quận 7')).toBeInTheDocument();
});
});
it('renders stats section', async () => {
render(<LandingPage />);
await waitFor(() => {
expect(screen.getByText('10,000+')).toBeInTheDocument();
expect(screen.getByText('50,000+')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,120 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl with Vietnamese messages
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, params?: Record<string, unknown>) => {
if (!ns) return key;
const parts = key.split('.');
let val: unknown = ns;
for (const p of parts) {
val = (val as Record<string, unknown>)?.[p];
}
if (typeof val === 'string' && params) {
return val.replace(/\{(\w+)\}/g, (_, k: string) => String(params[k] ?? `{${k}}`));
}
return typeof val === 'string' ? val : key;
};
},
useLocale: () => 'vi',
NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
}));
vi.mock('@/i18n/navigation', () => ({
Link: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => '/pricing',
redirect: vi.fn(),
}));
vi.mock('lucide-react', () => ({
Check: () => <span data-testid="icon-check"></span>,
Crown: () => <span data-testid="icon-crown" />,
Rocket: () => <span data-testid="icon-rocket" />,
Shield: () => <span data-testid="icon-shield" />,
X: () => <span data-testid="icon-x"></span>,
Zap: () => <span data-testid="icon-zap" />,
}));
const mockPlans = [
{
id: 'plan-free',
tier: 'FREE',
name: 'Miễn phí',
priceMonthlyVND: '0',
priceYearlyVND: '0',
maxListings: 3,
maxSavedSearches: 5,
features: { basicSearch: true, listingPost: true, maxPhotos: 5, analytics: false, prioritySupport: false, aiValuation: false, featuredListing: false },
isActive: true,
},
{
id: 'plan-pro',
tier: 'AGENT_PRO',
name: 'Agent Pro',
priceMonthlyVND: '499000',
priceYearlyVND: '4990000',
maxListings: 50,
maxSavedSearches: 30,
features: { basicSearch: true, listingPost: true, maxPhotos: 30, analytics: true, prioritySupport: true, aiValuation: true, featuredListing: true },
isActive: true,
},
];
vi.mock('@/lib/hooks/use-subscription', () => ({
usePlans: vi.fn(() => ({ data: mockPlans, isLoading: false, error: null })),
}));
vi.mock('@/lib/subscription-api', () => ({}));
import PricingPage from '../pricing/page';
describe('PricingPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders pricing page', () => {
render(<PricingPage />);
// The page uses translation keys - just verify it renders without error
expect(document.body.querySelector('.bg-background')).toBeTruthy();
});
it('renders billing cycle toggle buttons', () => {
render(<PricingPage />);
// Buttons exist in the page
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('renders plan cards', async () => {
render(<PricingPage />);
// Should render two plans as cards
await waitFor(() => {
const cards = document.querySelectorAll('[class*="card"]');
expect(cards.length).toBeGreaterThan(0);
});
});
it('renders feature comparison table', () => {
render(<PricingPage />);
const tables = document.querySelectorAll('table');
expect(tables.length).toBeGreaterThan(0);
});
});

View File

@@ -106,9 +106,28 @@ vi.mock('@/lib/listings-api', () => ({
},
}));
vi.mock('@/lib/hooks/use-saved-searches', () => ({
useCreateSavedSearch: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
useSavedSearches: () => ({ data: [], isLoading: false }),
}));
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { listingsApi } from '@/lib/listings-api';
import SearchPage from '../page';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
const mockedListingsApi = vi.mocked(listingsApi);
describe('SearchPage', () => {
@@ -118,7 +137,7 @@ describe('SearchPage', () => {
});
it('renders the search page title', async () => {
render(<SearchPage />);
render(<SearchPage />, { wrapper: Wrapper });
await waitFor(() => {
expect(screen.getByText('Tìm kiếm bất động sản')).toBeInTheDocument();
@@ -126,7 +145,7 @@ describe('SearchPage', () => {
});
it('renders view mode toggle buttons', async () => {
render(<SearchPage />);
render(<SearchPage />, { wrapper: Wrapper });
await waitFor(() => {
expect(screen.getByRole('button', { name: /danh sách/i })).toBeInTheDocument();
@@ -135,7 +154,7 @@ describe('SearchPage', () => {
});
it('calls listings API on mount', async () => {
render(<SearchPage />);
render(<SearchPage />, { wrapper: Wrapper });
await waitFor(() => {
expect(mockedListingsApi.search).toHaveBeenCalled();
@@ -143,7 +162,7 @@ describe('SearchPage', () => {
});
it('displays listing results after loading', async () => {
render(<SearchPage />);
render(<SearchPage />, { wrapper: Wrapper });
await waitFor(() => {
expect(screen.getByText(/căn hộ quận 7/i)).toBeInTheDocument();
@@ -151,7 +170,7 @@ describe('SearchPage', () => {
});
it('switches to map view when map button is clicked', async () => {
render(<SearchPage />);
render(<SearchPage />, { wrapper: Wrapper });
await waitFor(() => {
expect(screen.getByRole('button', { name: /bản đồ/i })).toBeInTheDocument();