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,79 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('lucide-react', () => ({
Users: () => <span data-testid="icon-users" />,
Home: () => <span data-testid="icon-home" />,
ClipboardCheck: () => <span data-testid="icon-clipboard" />,
Clock: () => <span data-testid="icon-clock" />,
UserCheck: () => <span data-testid="icon-usercheck" />,
ShieldCheck: () => <span data-testid="icon-shieldcheck" />,
ArrowUpRight: () => <span data-testid="icon-arrowup" />,
ArrowDownRight: () => <span data-testid="icon-arrowdown" />,
TrendingUp: () => <span data-testid="icon-trending" />,
RefreshCw: () => <span data-testid="icon-refresh" />,
}));
vi.mock('@/lib/admin-api', () => ({
adminApi: {
getDashboardStats: vi.fn().mockResolvedValue({
totalUsers: 5000,
totalListings: 1200,
activeListings: 800,
pendingModerationCount: 15,
totalAgents: 300,
verifiedAgents: 150,
totalTransactions: 4500,
newUsersLast30Days: 200,
newListingsLast30Days: 50,
}),
getRevenueStats: vi.fn().mockResolvedValue([
{ period: '2025-11', totalRevenue: 50000000, subscriptionRevenue: 30000000, listingFeeRevenue: 20000000, transactionCount: 100 },
{ period: '2025-12', totalRevenue: 65000000, subscriptionRevenue: 40000000, listingFeeRevenue: 25000000, transactionCount: 130 },
]),
},
}));
import AdminDashboardPage from '../page';
describe('AdminDashboardPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders admin dashboard heading', async () => {
render(<AdminDashboardPage />);
await waitFor(() => {
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
});
it('renders stat cards with data', async () => {
render(<AdminDashboardPage />);
await waitFor(() => {
expect(screen.getByText('Tổng người dùng')).toBeInTheDocument();
expect(screen.getByText('Tổng tin đăng')).toBeInTheDocument();
expect(screen.getByText('Tin đang hoạt động')).toBeInTheDocument();
expect(screen.getByText('Chờ kiểm duyệt')).toBeInTheDocument();
});
});
it('renders revenue chart section', async () => {
render(<AdminDashboardPage />);
await waitFor(() => {
expect(screen.getByText('Doanh thu 6 tháng gần nhất')).toBeInTheDocument();
});
});
it('renders refresh button', async () => {
render(<AdminDashboardPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /làm mới/i })).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,97 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('lucide-react', () => ({
Search: () => <span data-testid="icon-search" />,
RefreshCw: () => <span data-testid="icon-refresh" />,
ChevronLeft: () => <span data-testid="icon-left" />,
ChevronRight: () => <span data-testid="icon-right" />,
UserX: () => <span data-testid="icon-userx" />,
UserCheck: () => <span data-testid="icon-usercheck" />,
Eye: () => <span data-testid="icon-eye" />,
X: () => <span data-testid="icon-x" />,
}));
vi.mock('@/lib/admin-api', () => ({
adminApi: {
getUsers: vi.fn().mockResolvedValue({
data: [
{
id: 'u1',
fullName: 'Nguyen Van A',
phone: '0912345678',
email: 'a@test.com',
role: 'BUYER',
kycStatus: 'NONE',
isActive: true,
createdAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'u2',
fullName: 'Tran Thi B',
phone: '0987654321',
email: null,
role: 'AGENT',
kycStatus: 'VERIFIED',
isActive: true,
createdAt: '2024-02-01T00:00:00.000Z',
},
],
total: 2,
page: 1,
totalPages: 1,
}),
getUserDetail: vi.fn(),
banUser: vi.fn(),
},
}));
import AdminUsersPage from '../users/page';
describe('AdminUsersPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders user management heading', async () => {
render(<AdminUsersPage />);
await waitFor(() => {
expect(screen.getByText('Quản lý người dùng')).toBeInTheDocument();
});
});
it('renders user list', async () => {
render(<AdminUsersPage />);
await waitFor(() => {
expect(screen.getByText('Nguyen Van A')).toBeInTheDocument();
expect(screen.getByText('Tran Thi B')).toBeInTheDocument();
});
});
it('renders search input', async () => {
render(<AdminUsersPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(/tìm theo tên/i)).toBeInTheDocument();
});
});
it('renders role filter', async () => {
render(<AdminUsersPage />);
await waitFor(() => {
expect(screen.getByText('Tất cả vai trò')).toBeInTheDocument();
});
});
it('renders empty detail panel placeholder', async () => {
render(<AdminUsersPage />);
await waitFor(() => {
expect(screen.getByText(/chọn người dùng để xem chi tiết/i)).toBeInTheDocument();
});
});
});

View File

@@ -112,7 +112,7 @@ describe('RegisterPage', () => {
it('renders register form with all fields', () => {
render(<RegisterPage />);
expect(screen.getByText('Đăng ký')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Đăng ký' })).toBeInTheDocument();
expect(screen.getByLabelText('Họ và tên')).toBeInTheDocument();
expect(screen.getByLabelText('Số điện thoại')).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();

View File

@@ -0,0 +1,78 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('next/dynamic', () => ({
default: () => {
const Mock = () => <div data-testid="chart-placeholder">Chart</div>;
Mock.displayName = 'MockChart';
return Mock;
},
}));
const mockMarketReport = {
districts: [
{ district: 'Quan 1', totalListings: 100, avgPriceM2: 120000000, medianPrice: '15000000000', daysOnMarket: 45, yoyChange: 5.2, inventoryLevel: 50 },
],
};
const mockHeatmap = {
dataPoints: [
{ district: 'Quan 1', avgPriceM2: 120000000, totalListings: 100, lat: 10.77, lng: 106.7 },
],
};
const mockDistrictStats = {
districts: [
{ district: 'Quan 1', propertyType: 'APARTMENT', medianPrice: '15000000000', avgPriceM2: 120000000, totalListings: 100, daysOnMarket: 45, yoyChange: 5.2 },
],
};
const mockPriceTrend = {
trend: [
{ period: '2025-Q4', avgPriceM2: 115000000, totalListings: 90 },
{ period: '2026-Q1', avgPriceM2: 120000000, totalListings: 100 },
],
};
vi.mock('@/lib/hooks/use-analytics', () => ({
useMarketReport: vi.fn(() => ({ data: mockMarketReport, isLoading: false, error: null })),
useHeatmap: vi.fn(() => ({ data: mockHeatmap, isLoading: false })),
useDistrictStats: vi.fn(() => ({ data: mockDistrictStats, isLoading: false })),
usePriceTrend: vi.fn(() => ({ data: mockPriceTrend, isLoading: false })),
}));
import AnalyticsPage from '../page';
describe('AnalyticsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders analytics page heading', () => {
render(<AnalyticsPage />);
expect(screen.getByText('Phân tích thị trường')).toBeInTheDocument();
});
it('renders summary cards', () => {
render(<AnalyticsPage />);
expect(screen.getByText('Tổng tin đăng')).toBeInTheDocument();
expect(screen.getByText('Giá TB/m²')).toBeInTheDocument();
expect(screen.getByText('Ngày trung bình để bán')).toBeInTheDocument();
});
it('renders city selection buttons', () => {
render(<AnalyticsPage />);
expect(screen.getByRole('button', { name: 'Ho Chi Minh' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Ha Noi' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Da Nang' })).toBeInTheDocument();
});
it('renders tab navigation', () => {
render(<AnalyticsPage />);
expect(screen.getByText('Tổng quan')).toBeInTheDocument();
expect(screen.getByText('Xu hướng giá')).toBeInTheDocument();
expect(screen.getByText('Chi tiết quận')).toBeInTheDocument();
expect(screen.getByText('Hiệu suất')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,130 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('next/dynamic', () => ({
default: () => {
const Mock = () => <div data-testid="chart-placeholder">Chart</div>;
Mock.displayName = 'MockChart';
return Mock;
},
}));
vi.mock('next/image', () => ({
default: (props: Record<string, unknown>) => <img {...props} />,
}));
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
}));
const mockMarketReport = {
districts: [
{ district: 'Quan 1', totalListings: 100, avgPriceM2: 120000000, medianPrice: '15000000000', daysOnMarket: 45, yoyChange: 5.2, inventoryLevel: 50 },
{ district: 'Quan 7', totalListings: 80, avgPriceM2: 60000000, medianPrice: '8000000000', daysOnMarket: 30, yoyChange: -2.1, inventoryLevel: 30 },
],
};
const mockHeatmap = {
dataPoints: [
{ district: 'Quan 1', avgPriceM2: 120000000, totalListings: 100, lat: 10.77, lng: 106.7 },
{ district: 'Quan 7', avgPriceM2: 60000000, totalListings: 80, lat: 10.73, lng: 106.72 },
],
};
const mockListings = {
data: [
{
id: '1',
status: 'ACTIVE',
transactionType: 'SALE',
priceVND: '5000000000',
viewCount: 10,
saveCount: 2,
inquiryCount: 3,
publishedAt: '2024-01-01',
createdAt: '2024-01-01',
pricePerM2: null,
rentPriceMonthly: null,
commissionPct: null,
property: {
id: 'p1',
propertyType: 'APARTMENT',
title: 'Căn hộ Quận 7',
description: 'Test',
address: '123 Nguyễn Hữu Thọ',
ward: 'Tân Hưng',
district: 'Quận 7',
city: 'Hồ Chí Minh',
areaM2: 75,
bedrooms: 2,
bathrooms: 2,
floors: null,
direction: null,
yearBuilt: null,
legalStatus: null,
amenities: null,
projectName: null,
media: [],
},
seller: { id: 's1', fullName: 'Nguyen Van A', phone: '0912345678' },
agent: null,
},
],
total: 1,
page: 1,
limit: 6,
totalPages: 1,
};
vi.mock('@/lib/hooks/use-analytics', () => ({
useMarketReport: vi.fn(() => ({ data: mockMarketReport, isLoading: false })),
useHeatmap: vi.fn(() => ({ data: mockHeatmap, isLoading: false })),
}));
vi.mock('@/lib/hooks/use-listings', () => ({
useListingsSearch: vi.fn(() => ({ data: mockListings, isLoading: false })),
}));
vi.mock('@/components/listings/listing-status-badge', () => ({
ListingStatusBadge: ({ status }: { status: string }) => <span data-testid="status-badge">{status}</span>,
}));
import DashboardPage from '../page';
describe('DashboardPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the dashboard heading', () => {
render(<DashboardPage />);
expect(screen.getByText('Bảng điều khiển')).toBeInTheDocument();
});
it('renders stat cards with data', () => {
render(<DashboardPage />);
expect(screen.getByText('Tin đăng của tôi')).toBeInTheDocument();
expect(screen.getByText('Lượt xem')).toBeInTheDocument();
expect(screen.getByText('Liên hệ')).toBeInTheDocument();
expect(screen.getByText('Giá TB thị trường')).toBeInTheDocument();
});
it('renders recent listings section', () => {
render(<DashboardPage />);
expect(screen.getByText('Tin đăng gần đây')).toBeInTheDocument();
expect(screen.getByText(/căn hộ quận 7/i)).toBeInTheDocument();
});
it('renders "Đăng tin mới" button', () => {
render(<DashboardPage />);
expect(screen.getByRole('link', { name: /đăng tin mới/i })).toBeInTheDocument();
});
it('renders market summary card', () => {
render(<DashboardPage />);
expect(screen.getByText(/thị trường ho chi minh/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,81 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/lib/auth-store';
vi.mock('@/lib/auth-store', () => {
const store = {
user: {
id: 'user-1',
fullName: 'Nguyen Van A',
phone: '0912345678',
kycStatus: 'NONE',
},
fetchProfile: vi.fn(),
};
return {
useAuthStore: vi.fn((selector) => {
if (typeof selector === 'function') return selector(store);
return store;
}),
};
});
vi.mock('@/lib/api-client', () => ({
apiClient: {
patch: vi.fn().mockResolvedValue({}),
},
}));
import KycPage from '../kyc/page';
describe('KycPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders KYC page heading', () => {
render(<KycPage />);
expect(screen.getByText('Xác minh danh tính (KYC)')).toBeInTheDocument();
});
it('renders KYC status badge as not verified', () => {
render(<KycPage />);
expect(screen.getByText('Chưa xác minh')).toBeInTheDocument();
});
it('renders step indicator', () => {
render(<KycPage />);
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
});
it('renders document type selector on step 1', () => {
render(<KycPage />);
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
expect(screen.getByLabelText(/số giấy tờ/i)).toBeInTheDocument();
});
it('shows error when continuing without document number', async () => {
render(<KycPage />);
await userEvent.click(screen.getByRole('button', { name: /tiếp tục/i }));
await waitFor(() => {
expect(screen.getByText(/vui lòng nhập số giấy tờ/i)).toBeInTheDocument();
});
});
it('advances to step 2 after filling document number', async () => {
render(<KycPage />);
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
await userEvent.click(screen.getByRole('button', { name: /tiếp tục/i }));
await waitFor(() => {
expect(screen.getByLabelText(/ảnh mặt trước/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,66 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockTransactions = {
items: [
{
id: 'tx-1',
type: 'SUBSCRIPTION',
status: 'COMPLETED',
amountVND: '499000',
provider: 'VNPAY',
providerTxId: 'TXN123456789012',
createdAt: '2024-06-15T10:00:00.000Z',
},
{
id: 'tx-2',
type: 'LISTING_FEE',
status: 'PENDING',
amountVND: '100000',
provider: 'MOMO',
providerTxId: null,
createdAt: '2024-06-20T10:00:00.000Z',
},
],
total: 2,
};
vi.mock('@/lib/hooks/use-payments', () => ({
useTransactions: vi.fn(() => ({ data: mockTransactions, isLoading: false })),
}));
import PaymentsPage from '../payments/page';
describe('PaymentsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders payment page heading', () => {
render(<PaymentsPage />);
expect(screen.getByText('Thanh toán')).toBeInTheDocument();
});
it('renders summary cards', () => {
render(<PaymentsPage />);
expect(screen.getByText('Tổng giao dịch')).toBeInTheDocument();
expect(screen.getByText('Đã thanh toán')).toBeInTheDocument();
expect(screen.getByText('Đang chờ')).toBeInTheDocument();
});
it('renders transaction history section', () => {
render(<PaymentsPage />);
expect(screen.getByText('Lịch sử giao dịch')).toBeInTheDocument();
});
it('renders transaction count correctly', () => {
render(<PaymentsPage />);
expect(screen.getByText('2')).toBeInTheDocument();
});
it('renders status filter', () => {
render(<PaymentsPage />);
expect(screen.getByText('Tất cả')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,96 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/lib/auth-store';
vi.mock('@/lib/auth-store', () => {
const store = {
user: {
id: 'user-1',
fullName: 'Nguyen Van A',
phone: '0912345678',
email: 'test@example.com',
role: 'BUYER',
kycStatus: 'NONE',
isActive: true,
createdAt: '2024-01-01T00:00:00.000Z',
},
fetchProfile: vi.fn(),
};
return {
useAuthStore: vi.fn((selector) => {
if (typeof selector === 'function') return selector(store);
return store;
}),
};
});
vi.mock('@/lib/profile-api', () => ({
profileApi: {
getAgentProfile: vi.fn().mockRejectedValue(new Error('Not an agent')),
updateProfile: vi.fn().mockResolvedValue({}),
},
}));
import ProfilePage from '../profile/page';
describe('ProfilePage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders profile heading', async () => {
render(<ProfilePage />);
expect(screen.getByText('Hồ sơ cá nhân')).toBeInTheDocument();
});
it('renders user information', async () => {
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText('Nguyen Van A')).toBeInTheDocument();
expect(screen.getByText('0912345678')).toBeInTheDocument();
});
});
it('renders KYC status badge', async () => {
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText('Chưa xác minh')).toBeInTheDocument();
});
});
it('renders edit button', async () => {
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /chỉnh sửa/i })).toBeInTheDocument();
});
});
it('enters edit mode when edit button is clicked', async () => {
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /chỉnh sửa/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: /chỉnh sửa/i }));
await waitFor(() => {
expect(screen.getByRole('button', { name: /lưu thay đổi/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /hủy/i })).toBeInTheDocument();
});
});
it('renders account status section', async () => {
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText('Trạng thái tài khoản')).toBeInTheDocument();
expect(screen.getByText('Hoạt động')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,87 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockPlans = [
{
id: 'plan-1',
tier: 'FREE',
name: 'Miễn phí',
priceMonthlyVND: '0',
priceYearlyVND: '0',
maxListings: 3,
maxSavedSearches: 5,
features: {},
isActive: true,
},
{
id: 'plan-2',
tier: 'AGENT_PRO',
name: 'Agent Pro',
priceMonthlyVND: '499000',
priceYearlyVND: '4990000',
maxListings: 50,
maxSavedSearches: 30,
features: {},
isActive: true,
},
];
const mockBilling = {
subscription: null,
payments: [],
};
vi.mock('@/lib/hooks/use-subscription', () => ({
usePlans: vi.fn(() => ({ data: mockPlans, isLoading: false })),
useBillingHistory: vi.fn(() => ({ data: mockBilling, isLoading: false })),
useQuota: vi.fn(() => ({ data: null })),
subscriptionKeys: { billing: () => ['billing'] },
}));
vi.mock('@/lib/subscription-api', () => ({
subscriptionApi: {
createSubscription: vi.fn(),
upgradeSubscription: vi.fn(),
},
}));
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
useQueryClient: () => ({
invalidateQueries: vi.fn(),
}),
};
});
import SubscriptionPage from '../subscription/page';
describe('SubscriptionPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders subscription page heading', () => {
render(<SubscriptionPage />);
expect(screen.getByText('Gói dịch vụ')).toBeInTheDocument();
});
it('renders tab navigation', () => {
render(<SubscriptionPage />);
expect(screen.getByText('Gói hiện tại')).toBeInTheDocument();
expect(screen.getByText('So sánh gói')).toBeInTheDocument();
expect(screen.getByText('Lịch sử thanh toán')).toBeInTheDocument();
});
it('renders current plan info for free tier', () => {
render(<SubscriptionPage />);
expect(screen.getByText(/bạn đang sử dụng gói miễn phí/i)).toBeInTheDocument();
});
it('renders plan description text', () => {
render(<SubscriptionPage />);
expect(screen.getByText(/quản lý gói đăng ký/i)).toBeInTheDocument();
});
});

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();

View File

@@ -0,0 +1,81 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/lib/auth-store';
const mockReplace = vi.fn();
let mockSearchParams = new URLSearchParams();
vi.mock('next/navigation', () => ({
useRouter: () => ({ replace: mockReplace, push: vi.fn() }),
useSearchParams: () => mockSearchParams,
}));
vi.mock('lucide-react', () => ({
Loader2: ({ className }: { className?: string }) => <div data-testid="loader" className={className} />,
}));
vi.mock('@/lib/auth-store', () => {
const store = {
handleOAuthCallback: vi.fn(),
};
return {
useAuthStore: vi.fn((selector) => {
if (typeof selector === 'function') return selector(store);
return store;
}),
};
});
import GoogleCallbackPage from '../google/page';
const mockedUseAuthStore = vi.mocked(useAuthStore);
describe('GoogleCallbackPage', () => {
let mockStore: { handleOAuthCallback: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
mockStore = {
handleOAuthCallback: vi.fn().mockResolvedValue(undefined),
};
mockedUseAuthStore.mockImplementation((selector) => {
if (typeof selector === 'function') return (selector as (s: typeof mockStore) => unknown)(mockStore);
return mockStore as ReturnType<typeof useAuthStore>;
});
});
it('renders loading spinner and text', () => {
mockSearchParams = new URLSearchParams();
render(<GoogleCallbackPage />);
expect(screen.getByTestId('loader')).toBeInTheDocument();
expect(screen.getByText(/đang xử lý đăng nhập google/i)).toBeInTheDocument();
});
it('redirects to login on error param', async () => {
mockSearchParams = new URLSearchParams('error=access_denied');
render(<GoogleCallbackPage />);
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/login?error=access_denied');
});
});
it('redirects to login when tokens are missing', async () => {
mockSearchParams = new URLSearchParams();
render(<GoogleCallbackPage />);
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/login?error=oauth_failed');
});
});
it('calls handleOAuthCallback with tokens', async () => {
mockSearchParams = new URLSearchParams('accessToken=abc&refreshToken=def&expiresIn=3600');
render(<GoogleCallbackPage />);
await waitFor(() => {
expect(mockStore.handleOAuthCallback).toHaveBeenCalledWith('abc', 'def', 3600);
});
});
});

View File

@@ -0,0 +1,81 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/lib/auth-store';
const mockReplace = vi.fn();
let mockSearchParams = new URLSearchParams();
vi.mock('next/navigation', () => ({
useRouter: () => ({ replace: mockReplace, push: vi.fn() }),
useSearchParams: () => mockSearchParams,
}));
vi.mock('lucide-react', () => ({
Loader2: ({ className }: { className?: string }) => <div data-testid="loader" className={className} />,
}));
vi.mock('@/lib/auth-store', () => {
const store = {
handleOAuthCallback: vi.fn(),
};
return {
useAuthStore: vi.fn((selector) => {
if (typeof selector === 'function') return selector(store);
return store;
}),
};
});
import ZaloCallbackPage from '../zalo/page';
const mockedUseAuthStore = vi.mocked(useAuthStore);
describe('ZaloCallbackPage', () => {
let mockStore: { handleOAuthCallback: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
mockStore = {
handleOAuthCallback: vi.fn().mockResolvedValue(undefined),
};
mockedUseAuthStore.mockImplementation((selector) => {
if (typeof selector === 'function') return (selector as (s: typeof mockStore) => unknown)(mockStore);
return mockStore as ReturnType<typeof useAuthStore>;
});
});
it('renders loading spinner and Zalo text', () => {
mockSearchParams = new URLSearchParams();
render(<ZaloCallbackPage />);
expect(screen.getByTestId('loader')).toBeInTheDocument();
expect(screen.getByText(/đang xử lý đăng nhập zalo/i)).toBeInTheDocument();
});
it('redirects to login on error param', async () => {
mockSearchParams = new URLSearchParams('error=access_denied');
render(<ZaloCallbackPage />);
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/login?error=access_denied');
});
});
it('redirects to login when tokens are missing', async () => {
mockSearchParams = new URLSearchParams();
render(<ZaloCallbackPage />);
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/login?error=oauth_failed');
});
});
it('calls handleOAuthCallback with tokens', async () => {
mockSearchParams = new URLSearchParams('accessToken=zalo123&refreshToken=zaloref&expiresIn=1800');
render(<ZaloCallbackPage />);
await waitFor(() => {
expect(mockStore.handleOAuthCallback).toHaveBeenCalledWith('zalo123', 'zaloref', 1800);
});
});
});

View File

@@ -6,7 +6,7 @@ export default defineConfig({
plugins: [react()],
test: {
include: ['**/__tests__/**/*.spec.ts', '**/__tests__/**/*.test.ts', '**/__tests__/**/*.spec.tsx', '**/__tests__/**/*.test.tsx'],
exclude: ['**/node_modules/**', '**/\\[locale\\]/**'],
exclude: ['**/node_modules/**'],
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
globals: true,