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