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:
100
apps/web/app/[locale]/(public)/__tests__/landing.spec.tsx
Normal file
100
apps/web/app/[locale]/(public)/__tests__/landing.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
120
apps/web/app/[locale]/(public)/__tests__/pricing.spec.tsx
Normal file
120
apps/web/app/[locale]/(public)/__tests__/pricing.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user