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,