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:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
97
apps/web/app/[locale]/(admin)/admin/__tests__/users.spec.tsx
Normal file
97
apps/web/app/[locale]/(admin)/admin/__tests__/users.spec.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -112,7 +112,7 @@ describe('RegisterPage', () => {
|
|||||||
it('renders register form with all fields', () => {
|
it('renders register form with all fields', () => {
|
||||||
render(<RegisterPage />);
|
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('Họ và tên')).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Số điện thoại')).toBeInTheDocument();
|
expect(screen.getByLabelText('Số điện thoại')).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
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 { listingsApi } from '@/lib/listings-api';
|
||||||
import SearchPage from '../page';
|
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);
|
const mockedListingsApi = vi.mocked(listingsApi);
|
||||||
|
|
||||||
describe('SearchPage', () => {
|
describe('SearchPage', () => {
|
||||||
@@ -118,7 +137,7 @@ describe('SearchPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders the search page title', async () => {
|
it('renders the search page title', async () => {
|
||||||
render(<SearchPage />);
|
render(<SearchPage />, { wrapper: Wrapper });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Tìm kiếm bất động sản')).toBeInTheDocument();
|
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 () => {
|
it('renders view mode toggle buttons', async () => {
|
||||||
render(<SearchPage />);
|
render(<SearchPage />, { wrapper: Wrapper });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('button', { name: /danh sách/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /danh sách/i })).toBeInTheDocument();
|
||||||
@@ -135,7 +154,7 @@ describe('SearchPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calls listings API on mount', async () => {
|
it('calls listings API on mount', async () => {
|
||||||
render(<SearchPage />);
|
render(<SearchPage />, { wrapper: Wrapper });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockedListingsApi.search).toHaveBeenCalled();
|
expect(mockedListingsApi.search).toHaveBeenCalled();
|
||||||
@@ -143,7 +162,7 @@ describe('SearchPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays listing results after loading', async () => {
|
it('displays listing results after loading', async () => {
|
||||||
render(<SearchPage />);
|
render(<SearchPage />, { wrapper: Wrapper });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/căn hộ quận 7/i)).toBeInTheDocument();
|
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 () => {
|
it('switches to map view when map button is clicked', async () => {
|
||||||
render(<SearchPage />);
|
render(<SearchPage />, { wrapper: Wrapper });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('button', { name: /bản đồ/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /bản đồ/i })).toBeInTheDocument();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
test: {
|
test: {
|
||||||
include: ['**/__tests__/**/*.spec.ts', '**/__tests__/**/*.test.ts', '**/__tests__/**/*.spec.tsx', '**/__tests__/**/*.test.tsx'],
|
include: ['**/__tests__/**/*.spec.ts', '**/__tests__/**/*.test.ts', '**/__tests__/**/*.spec.tsx', '**/__tests__/**/*.test.tsx'],
|
||||||
exclude: ['**/node_modules/**', '**/\\[locale\\]/**'],
|
exclude: ['**/node_modules/**'],
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./vitest.setup.ts'],
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
globals: true,
|
globals: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user