From 68b65cb8483cffbaa95af3db8fead039a01afdf8 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 10 Apr 2026 23:14:16 +0700 Subject: [PATCH] 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 --- .../admin/__tests__/admin-dashboard.spec.tsx | 79 +++++++++++ .../(admin)/admin/__tests__/users.spec.tsx | 97 +++++++++++++ .../(auth)/__tests__/register.spec.tsx | 2 +- .../analytics/__tests__/analytics.spec.tsx | 78 +++++++++++ .../dashboard/__tests__/dashboard.spec.tsx | 130 ++++++++++++++++++ .../dashboard/__tests__/kyc.spec.tsx | 81 +++++++++++ .../dashboard/__tests__/payments.spec.tsx | 66 +++++++++ .../dashboard/__tests__/profile.spec.tsx | 96 +++++++++++++ .../dashboard/__tests__/subscription.spec.tsx | 87 ++++++++++++ .../(public)/__tests__/landing.spec.tsx | 100 ++++++++++++++ .../(public)/__tests__/pricing.spec.tsx | 120 ++++++++++++++++ .../(public)/search/__tests__/search.spec.tsx | 29 +++- .../__tests__/google-callback.spec.tsx | 81 +++++++++++ .../callback/__tests__/zalo-callback.spec.tsx | 81 +++++++++++ apps/web/vitest.config.ts | 2 +- 15 files changed, 1122 insertions(+), 7 deletions(-) create mode 100644 apps/web/app/[locale]/(admin)/admin/__tests__/admin-dashboard.spec.tsx create mode 100644 apps/web/app/[locale]/(admin)/admin/__tests__/users.spec.tsx create mode 100644 apps/web/app/[locale]/(dashboard)/analytics/__tests__/analytics.spec.tsx create mode 100644 apps/web/app/[locale]/(dashboard)/dashboard/__tests__/dashboard.spec.tsx create mode 100644 apps/web/app/[locale]/(dashboard)/dashboard/__tests__/kyc.spec.tsx create mode 100644 apps/web/app/[locale]/(dashboard)/dashboard/__tests__/payments.spec.tsx create mode 100644 apps/web/app/[locale]/(dashboard)/dashboard/__tests__/profile.spec.tsx create mode 100644 apps/web/app/[locale]/(dashboard)/dashboard/__tests__/subscription.spec.tsx create mode 100644 apps/web/app/[locale]/(public)/__tests__/landing.spec.tsx create mode 100644 apps/web/app/[locale]/(public)/__tests__/pricing.spec.tsx create mode 100644 apps/web/app/[locale]/auth/callback/__tests__/google-callback.spec.tsx create mode 100644 apps/web/app/[locale]/auth/callback/__tests__/zalo-callback.spec.tsx diff --git a/apps/web/app/[locale]/(admin)/admin/__tests__/admin-dashboard.spec.tsx b/apps/web/app/[locale]/(admin)/admin/__tests__/admin-dashboard.spec.tsx new file mode 100644 index 0000000..13ab7e6 --- /dev/null +++ b/apps/web/app/[locale]/(admin)/admin/__tests__/admin-dashboard.spec.tsx @@ -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: () => , + Home: () => , + ClipboardCheck: () => , + Clock: () => , + UserCheck: () => , + ShieldCheck: () => , + ArrowUpRight: () => , + ArrowDownRight: () => , + TrendingUp: () => , + RefreshCw: () => , +})); + +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(); + + await waitFor(() => { + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + }); + }); + + it('renders stat cards with data', async () => { + render(); + + 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(); + + await waitFor(() => { + expect(screen.getByText('Doanh thu 6 tháng gần nhất')).toBeInTheDocument(); + }); + }); + + it('renders refresh button', async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /làm mới/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/app/[locale]/(admin)/admin/__tests__/users.spec.tsx b/apps/web/app/[locale]/(admin)/admin/__tests__/users.spec.tsx new file mode 100644 index 0000000..5d77988 --- /dev/null +++ b/apps/web/app/[locale]/(admin)/admin/__tests__/users.spec.tsx @@ -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: () => , + RefreshCw: () => , + ChevronLeft: () => , + ChevronRight: () => , + UserX: () => , + UserCheck: () => , + Eye: () => , + 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(); + + await waitFor(() => { + expect(screen.getByText('Quản lý người dùng')).toBeInTheDocument(); + }); + }); + + it('renders user list', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Nguyen Van A')).toBeInTheDocument(); + expect(screen.getByText('Tran Thi B')).toBeInTheDocument(); + }); + }); + + it('renders search input', async () => { + render(); + + await waitFor(() => { + expect(screen.getByPlaceholderText(/tìm theo tên/i)).toBeInTheDocument(); + }); + }); + + it('renders role filter', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Tất cả vai trò')).toBeInTheDocument(); + }); + }); + + it('renders empty detail panel placeholder', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/chọn người dùng để xem chi tiết/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/app/[locale]/(auth)/__tests__/register.spec.tsx b/apps/web/app/[locale]/(auth)/__tests__/register.spec.tsx index 4274704..226aaef 100644 --- a/apps/web/app/[locale]/(auth)/__tests__/register.spec.tsx +++ b/apps/web/app/[locale]/(auth)/__tests__/register.spec.tsx @@ -112,7 +112,7 @@ describe('RegisterPage', () => { it('renders register form with all fields', () => { render(); - 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(); diff --git a/apps/web/app/[locale]/(dashboard)/analytics/__tests__/analytics.spec.tsx b/apps/web/app/[locale]/(dashboard)/analytics/__tests__/analytics.spec.tsx new file mode 100644 index 0000000..b07e14c --- /dev/null +++ b/apps/web/app/[locale]/(dashboard)/analytics/__tests__/analytics.spec.tsx @@ -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 = () =>
Chart
; + 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(); + expect(screen.getByText('Phân tích thị trường')).toBeInTheDocument(); + }); + + it('renders summary cards', () => { + render(); + 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(); + 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(); + 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(); + }); +}); diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/dashboard.spec.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/dashboard.spec.tsx new file mode 100644 index 0000000..7d3b5e5 --- /dev/null +++ b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/dashboard.spec.tsx @@ -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 = () =>
Chart
; + Mock.displayName = 'MockChart'; + return Mock; + }, +})); + +vi.mock('next/image', () => ({ + default: (props: Record) => , +})); + +vi.mock('next/link', () => ({ + default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => ( + {children} + ), +})); + +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 }) => {status}, +})); + +import DashboardPage from '../page'; + +describe('DashboardPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the dashboard heading', () => { + render(); + expect(screen.getByText('Bảng điều khiển')).toBeInTheDocument(); + }); + + it('renders stat cards with data', () => { + render(); + 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(); + 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(); + expect(screen.getByRole('link', { name: /đăng tin mới/i })).toBeInTheDocument(); + }); + + it('renders market summary card', () => { + render(); + expect(screen.getByText(/thị trường ho chi minh/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/kyc.spec.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/kyc.spec.tsx new file mode 100644 index 0000000..48e4386 --- /dev/null +++ b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/kyc.spec.tsx @@ -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(); + expect(screen.getByText('Xác minh danh tính (KYC)')).toBeInTheDocument(); + }); + + it('renders KYC status badge as not verified', () => { + render(); + expect(screen.getByText('Chưa xác minh')).toBeInTheDocument(); + }); + + it('renders step indicator', () => { + render(); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('renders document type selector on step 1', () => { + render(); + 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(); + + 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(); + + 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(); + }); + }); +}); diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/payments.spec.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/payments.spec.tsx new file mode 100644 index 0000000..1de181f --- /dev/null +++ b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/payments.spec.tsx @@ -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(); + expect(screen.getByText('Thanh toán')).toBeInTheDocument(); + }); + + it('renders summary cards', () => { + render(); + 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(); + expect(screen.getByText('Lịch sử giao dịch')).toBeInTheDocument(); + }); + + it('renders transaction count correctly', () => { + render(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('renders status filter', () => { + render(); + expect(screen.getByText('Tất cả')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/profile.spec.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/profile.spec.tsx new file mode 100644 index 0000000..32813bd --- /dev/null +++ b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/profile.spec.tsx @@ -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(); + expect(screen.getByText('Hồ sơ cá nhân')).toBeInTheDocument(); + }); + + it('renders user information', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Nguyen Van A')).toBeInTheDocument(); + expect(screen.getByText('0912345678')).toBeInTheDocument(); + }); + }); + + it('renders KYC status badge', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Chưa xác minh')).toBeInTheDocument(); + }); + }); + + it('renders edit button', async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /chỉnh sửa/i })).toBeInTheDocument(); + }); + }); + + it('enters edit mode when edit button is clicked', async () => { + render(); + + 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(); + + await waitFor(() => { + expect(screen.getByText('Trạng thái tài khoản')).toBeInTheDocument(); + expect(screen.getByText('Hoạt động')).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/subscription.spec.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/subscription.spec.tsx new file mode 100644 index 0000000..89fcf2d --- /dev/null +++ b/apps/web/app/[locale]/(dashboard)/dashboard/__tests__/subscription.spec.tsx @@ -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; + return { + ...actual, + useQueryClient: () => ({ + invalidateQueries: vi.fn(), + }), + }; +}); + +import SubscriptionPage from '../subscription/page'; + +describe('SubscriptionPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders subscription page heading', () => { + render(); + expect(screen.getByText('Gói dịch vụ')).toBeInTheDocument(); + }); + + it('renders tab navigation', () => { + render(); + 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(); + expect(screen.getByText(/bạn đang sử dụng gói miễn phí/i)).toBeInTheDocument(); + }); + + it('renders plan description text', () => { + render(); + expect(screen.getByText(/quản lý gói đăng ký/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/[locale]/(public)/__tests__/landing.spec.tsx b/apps/web/app/[locale]/(public)/__tests__/landing.spec.tsx new file mode 100644 index 0000000..f45c96d --- /dev/null +++ b/apps/web/app/[locale]/(public)/__tests__/landing.spec.tsx @@ -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 | undefined) + : (messages as unknown as Record); + return (key: string, params?: Record) => { + if (!ns) return key; + const parts = key.split('.'); + let val: unknown = ns; + for (const p of parts) { + val = (val as Record)?.[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 }) => ( + {children} + ), +})); + +vi.mock('next/image', () => ({ + default: (props: Record) => , +})); + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => ( + {children} + ), + 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 } }) =>
Listing
, +})); + +import LandingPage from '../page'; + +describe('LandingPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders hero section with search form', async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole('search')).toBeInTheDocument(); + }); + }); + + it('renders property type badges', async () => { + render(); + + await waitFor(() => { + // Property type badges from Vietnamese messages + expect(screen.getAllByRole('link').length).toBeGreaterThan(0); + }); + }); + + it('renders districts section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Quận 1')).toBeInTheDocument(); + expect(screen.getByText('Quận 7')).toBeInTheDocument(); + }); + }); + + it('renders stats section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('10,000+')).toBeInTheDocument(); + expect(screen.getByText('50,000+')).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/app/[locale]/(public)/__tests__/pricing.spec.tsx b/apps/web/app/[locale]/(public)/__tests__/pricing.spec.tsx new file mode 100644 index 0000000..f6ffd77 --- /dev/null +++ b/apps/web/app/[locale]/(public)/__tests__/pricing.spec.tsx @@ -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 | undefined) + : (messages as unknown as Record); + return (key: string, params?: Record) => { + if (!ns) return key; + const parts = key.split('.'); + let val: unknown = ns; + for (const p of parts) { + val = (val as Record)?.[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 }) => ( + {children} + ), +})); + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => ( + {children} + ), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => '/pricing', + redirect: vi.fn(), +})); + +vi.mock('lucide-react', () => ({ + Check: () => , + Crown: () => , + Rocket: () => , + Shield: () => , + X: () => , + 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(); + // 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(); + // Buttons exist in the page + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBeGreaterThan(0); + }); + + it('renders plan cards', async () => { + render(); + + // 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(); + const tables = document.querySelectorAll('table'); + expect(tables.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/app/[locale]/(public)/search/__tests__/search.spec.tsx b/apps/web/app/[locale]/(public)/search/__tests__/search.spec.tsx index 6e7f851..e06c3fb 100644 --- a/apps/web/app/[locale]/(public)/search/__tests__/search.spec.tsx +++ b/apps/web/app/[locale]/(public)/search/__tests__/search.spec.tsx @@ -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 ( + {children} + ); +} + const mockedListingsApi = vi.mocked(listingsApi); describe('SearchPage', () => { @@ -118,7 +137,7 @@ describe('SearchPage', () => { }); it('renders the search page title', async () => { - render(); + render(, { 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(); + render(, { 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(); + render(, { wrapper: Wrapper }); await waitFor(() => { expect(mockedListingsApi.search).toHaveBeenCalled(); @@ -143,7 +162,7 @@ describe('SearchPage', () => { }); it('displays listing results after loading', async () => { - render(); + render(, { 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(); + render(, { wrapper: Wrapper }); await waitFor(() => { expect(screen.getByRole('button', { name: /bản đồ/i })).toBeInTheDocument(); diff --git a/apps/web/app/[locale]/auth/callback/__tests__/google-callback.spec.tsx b/apps/web/app/[locale]/auth/callback/__tests__/google-callback.spec.tsx new file mode 100644 index 0000000..fe15229 --- /dev/null +++ b/apps/web/app/[locale]/auth/callback/__tests__/google-callback.spec.tsx @@ -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 }) =>
, +})); + +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 }; + + 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; + }); + }); + + it('renders loading spinner and text', () => { + mockSearchParams = new URLSearchParams(); + render(); + 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(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/login?error=access_denied'); + }); + }); + + it('redirects to login when tokens are missing', async () => { + mockSearchParams = new URLSearchParams(); + render(); + + 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(); + + await waitFor(() => { + expect(mockStore.handleOAuthCallback).toHaveBeenCalledWith('abc', 'def', 3600); + }); + }); +}); diff --git a/apps/web/app/[locale]/auth/callback/__tests__/zalo-callback.spec.tsx b/apps/web/app/[locale]/auth/callback/__tests__/zalo-callback.spec.tsx new file mode 100644 index 0000000..de87fd7 --- /dev/null +++ b/apps/web/app/[locale]/auth/callback/__tests__/zalo-callback.spec.tsx @@ -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 }) =>
, +})); + +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 }; + + 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; + }); + }); + + it('renders loading spinner and Zalo text', () => { + mockSearchParams = new URLSearchParams(); + render(); + 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(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/login?error=access_denied'); + }); + }); + + it('redirects to login when tokens are missing', async () => { + mockSearchParams = new URLSearchParams(); + render(); + + 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(); + + await waitFor(() => { + expect(mockStore.handleOAuthCallback).toHaveBeenCalledWith('zalo123', 'zaloref', 1800); + }); + }); +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index aacf8a3..0d1d000 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -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,