diff --git a/e2e/web/admin-dashboard.spec.ts b/e2e/web/admin-dashboard.spec.ts new file mode 100644 index 0000000..f7ab47e --- /dev/null +++ b/e2e/web/admin-dashboard.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from '@playwright/test'; + +const mockDashboardStats = { + totalUsers: 1250, + newUsersLast30Days: 85, + totalListings: 3400, + newListingsLast30Days: 320, + activeListings: 2800, + pendingModeration: 45, + totalAgents: 180, + verifiedAgents: 120, + totalTransactions: 560, +}; + +const mockRevenue = { + data: [ + { period: '2025-10', totalRevenue: 150000000, subscriptionRevenue: 100000000, transactionRevenue: 50000000 }, + { period: '2025-11', totalRevenue: 180000000, subscriptionRevenue: 120000000, transactionRevenue: 60000000 }, + { period: '2025-12', totalRevenue: 200000000, subscriptionRevenue: 130000000, transactionRevenue: 70000000 }, + { period: '2026-01', totalRevenue: 220000000, subscriptionRevenue: 140000000, transactionRevenue: 80000000 }, + { period: '2026-02', totalRevenue: 250000000, subscriptionRevenue: 160000000, transactionRevenue: 90000000 }, + { period: '2026-03', totalRevenue: 280000000, subscriptionRevenue: 180000000, transactionRevenue: 100000000 }, + ], +}; + +test.describe('Admin Dashboard', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/admin/dashboard**', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockDashboardStats) }), + ); + await page.route('**/admin/revenue**', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockRevenue) }), + ); + }); + + test('renders admin dashboard with stat cards', async ({ page }) => { + await page.goto('/admin'); + + // Stat values should be visible + await expect(page.getByText('1.250')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('3.400')).toBeVisible(); + }); + + test('shows refresh button', async ({ page }) => { + await page.goto('/admin'); + + const refreshButton = page.getByRole('button').filter({ has: page.locator('svg') }).first(); + await expect(refreshButton).toBeVisible({ timeout: 10000 }); + }); + + test('handles API failure gracefully', async ({ page }) => { + await page.route('**/admin/dashboard**', (route) => + route.fulfill({ status: 500, body: 'Error' }), + ); + + await page.goto('/admin'); + + // Page should still render without crashing + await page.waitForTimeout(2000); + await expect(page.locator('body')).toBeVisible(); + }); +}); diff --git a/e2e/web/admin-kyc.spec.ts b/e2e/web/admin-kyc.spec.ts new file mode 100644 index 0000000..8ce989d --- /dev/null +++ b/e2e/web/admin-kyc.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from '@playwright/test'; + +const mockKycQueue = { + data: [ + { + id: 'kyc-1', userId: 'u1', fullName: 'Nguyen Van A', phone: '0912345678', + email: 'a@test.com', role: 'AGENT', kycStatus: 'PENDING', + submittedAt: '2026-03-01T00:00:00Z', + kycData: { idType: 'CCCD', idNumber: '123456789012', frontImageUrl: '/id-front.jpg', backImageUrl: '/id-back.jpg', selfieUrl: '/selfie.jpg' }, + }, + { + id: 'kyc-2', userId: 'u2', fullName: 'Tran Thi B', phone: '0987654321', + email: null, role: 'AGENT', kycStatus: 'PENDING', + submittedAt: '2026-03-02T00:00:00Z', + kycData: { idType: 'PASSPORT', idNumber: 'B1234567' }, + }, + ], + total: 2, page: 1, limit: 20, totalPages: 1, +}; + +test.describe('Admin KYC Page', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/admin/kyc**', (route) => { + if (route.request().method() === 'GET') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockKycQueue), + }); + } + return route.continue(); + }); + }); + + test('renders KYC queue with applicants', async ({ page }) => { + await page.goto('/admin/kyc'); + + await expect(page.getByText('Nguyen Van A')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Tran Thi B')).toBeVisible(); + }); + + test('displays KYC status badges', async ({ page }) => { + await page.goto('/admin/kyc'); + + await expect(page.getByText('Nguyen Van A')).toBeVisible({ timeout: 10000 }); + // Should show pending status badges + const pendingBadges = page.getByText(/Chờ duyệt|PENDING/i); + await expect(pendingBadges.first()).toBeVisible(); + }); + + test('has refresh button', async ({ page }) => { + await page.goto('/admin/kyc'); + + const refreshButton = page.getByRole('button').filter({ has: page.locator('svg') }).first(); + await expect(refreshButton).toBeVisible({ timeout: 10000 }); + }); + + test('handles empty KYC queue', async ({ page }) => { + await page.route('**/admin/kyc**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], total: 0, page: 1, limit: 20, totalPages: 0 }), + }), + ); + + await page.goto('/admin/kyc'); + await page.waitForTimeout(2000); + await expect(page.locator('body')).toBeVisible(); + }); +}); diff --git a/e2e/web/admin-moderation.spec.ts b/e2e/web/admin-moderation.spec.ts new file mode 100644 index 0000000..d0084c3 --- /dev/null +++ b/e2e/web/admin-moderation.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; + +const mockModerationQueue = { + data: [ + { + id: 'mod-1', listingId: 'l1', title: 'Căn hộ cần duyệt', propertyType: 'APARTMENT', + transactionType: 'SALE', price: 5000000000, sellerName: 'Nguyen Van A', + aiModerationScore: 85, submittedAt: '2026-03-01T00:00:00Z', status: 'PENDING', + }, + { + id: 'mod-2', listingId: 'l2', title: 'Nhà phố cần duyệt', propertyType: 'HOUSE', + transactionType: 'RENT', price: 15000000, sellerName: 'Tran Thi B', + aiModerationScore: 42, submittedAt: '2026-03-02T00:00:00Z', status: 'PENDING', + }, + ], + total: 2, page: 1, limit: 20, totalPages: 1, +}; + +test.describe('Admin Moderation Page', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/admin/moderation**', (route) => { + if (route.request().method() === 'GET') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockModerationQueue), + }); + } + return route.continue(); + }); + }); + + test('renders moderation queue with listings', async ({ page }) => { + await page.goto('/admin/moderation'); + + await expect(page.getByText('Căn hộ cần duyệt')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Nhà phố cần duyệt')).toBeVisible(); + }); + + test('displays AI moderation scores', async ({ page }) => { + await page.goto('/admin/moderation'); + + await expect(page.getByText('Căn hộ cần duyệt')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('85')).toBeVisible(); + await expect(page.getByText('42')).toBeVisible(); + }); + + test('shows seller names', async ({ page }) => { + await page.goto('/admin/moderation'); + + await expect(page.getByText('Nguyen Van A')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Tran Thi B')).toBeVisible(); + }); + + test('has refresh button', async ({ page }) => { + await page.goto('/admin/moderation'); + + const refreshButton = page.getByRole('button').filter({ has: page.locator('svg') }).first(); + await expect(refreshButton).toBeVisible({ timeout: 10000 }); + }); + + test('handles empty moderation queue', async ({ page }) => { + await page.route('**/admin/moderation**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], total: 0, page: 1, limit: 20, totalPages: 0 }), + }), + ); + + await page.goto('/admin/moderation'); + await page.waitForTimeout(2000); + await expect(page.locator('body')).toBeVisible(); + }); +}); diff --git a/e2e/web/admin-users.spec.ts b/e2e/web/admin-users.spec.ts new file mode 100644 index 0000000..7654007 --- /dev/null +++ b/e2e/web/admin-users.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; + +const mockUsers = { + data: [ + { + id: 'u1', fullName: 'Nguyen Van A', phone: '0912345678', email: 'a@test.com', + role: 'USER', kycStatus: 'VERIFIED', status: 'ACTIVE', createdAt: '2025-12-01T00:00:00Z', + }, + { + id: 'u2', fullName: 'Tran Thi B', phone: '0987654321', email: 'b@test.com', + role: 'AGENT', kycStatus: 'PENDING', status: 'ACTIVE', createdAt: '2026-01-15T00:00:00Z', + }, + { + id: 'u3', fullName: 'Le Van C', phone: '0909123456', email: null, + role: 'ADMIN', kycStatus: 'VERIFIED', status: 'LOCKED', createdAt: '2025-11-01T00:00:00Z', + }, + ], + total: 3, page: 1, limit: 20, totalPages: 1, +}; + +test.describe('Admin Users Management', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/admin/users**', (route) => { + if (route.request().method() === 'GET') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockUsers), + }); + } + return route.continue(); + }); + }); + + test('renders user management page with table', async ({ page }) => { + await page.goto('/admin/users'); + + await expect(page.getByText('Nguyen Van A')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Tran Thi B')).toBeVisible(); + await expect(page.getByText('Le Van C')).toBeVisible(); + }); + + test('displays user roles and statuses', async ({ page }) => { + await page.goto('/admin/users'); + + await expect(page.getByText('Nguyen Van A')).toBeVisible({ timeout: 10000 }); + // Role badges + await expect(page.getByText('AGENT').first()).toBeVisible(); + await expect(page.getByText('ADMIN').first()).toBeVisible(); + }); + + test('renders search and filter controls', async ({ page }) => { + await page.goto('/admin/users'); + + // Search input should exist + const searchInput = page.getByPlaceholder(/Tim kiem|Search/i); + await expect(searchInput).toBeVisible({ timeout: 10000 }); + }); + + test('handles empty user list', async ({ page }) => { + await page.route('**/admin/users**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], total: 0, page: 1, limit: 20, totalPages: 0 }), + }), + ); + + await page.goto('/admin/users'); + await page.waitForTimeout(2000); + + // Page should still render without crash + await expect(page.locator('body')).toBeVisible(); + }); +}); diff --git a/e2e/web/analytics.spec.ts b/e2e/web/analytics.spec.ts new file mode 100644 index 0000000..02ed1ae --- /dev/null +++ b/e2e/web/analytics.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from '@playwright/test'; + +const mockMarketReport = { + districts: [ + { district: 'Quan 1', propertyType: 'APARTMENT', avgPriceM2: 85000000, medianPriceM2: 80000000, totalListings: 150, daysOnMarket: 45, yoyChange: 5.2 }, + { district: 'Quan 7', propertyType: 'HOUSE', avgPriceM2: 65000000, medianPriceM2: 60000000, totalListings: 200, daysOnMarket: 60, yoyChange: -2.1 }, + ], +}; + +const mockHeatmap = { + dataPoints: [ + { district: 'Quan 1', avgPriceM2: 85000000, totalListings: 150, lat: 10.7769, lng: 106.7009 }, + { district: 'Quan 7', avgPriceM2: 65000000, totalListings: 200, lat: 10.7385, lng: 106.7218 }, + ], +}; + +const mockDistrictStats = { + districts: [ + { district: 'Quan 1', propertyType: 'APARTMENT', medianPrice: 5000000000, pricePerM2: 85000000, totalListings: 150, daysOnMarket: 45, yoyChange: 5.2 }, + ], +}; + +const mockTrends = { + dataPoints: [ + { period: '2025-Q1', avgPriceM2: 78000000, totalListings: 130, transactionVolume: 80 }, + { period: '2025-Q2', avgPriceM2: 80000000, totalListings: 140, transactionVolume: 85 }, + { period: '2026-Q1', avgPriceM2: 85000000, totalListings: 150, transactionVolume: 95 }, + ], +}; + +test.describe('Analytics Page', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/analytics/market-report**', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockMarketReport) }), + ); + await page.route('**/analytics/heatmap**', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockHeatmap) }), + ); + await page.route('**/analytics/district-stats**', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockDistrictStats) }), + ); + await page.route('**/analytics/price-trends**', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockTrends) }), + ); + }); + + test('renders analytics page with city selector', async ({ page }) => { + await page.goto('/analytics'); + + // City selector buttons should be visible + await expect(page.getByRole('button', { name: /Ho Chi Minh/i })).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('button', { name: /Ha Noi/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /Da Nang/i })).toBeVisible(); + }); + + test('displays tabs for different views', async ({ page }) => { + await page.goto('/analytics'); + + await expect(page.getByRole('tab', { name: /Overview/i }).or(page.getByText('Overview'))).toBeVisible({ timeout: 10000 }); + }); + + test('switches city when selector clicked', async ({ page }) => { + await page.goto('/analytics'); + await expect(page.getByRole('button', { name: /Ha Noi/i })).toBeVisible({ timeout: 10000 }); + + await page.getByRole('button', { name: /Ha Noi/i }).click(); + + // The Ha Noi button should now appear selected/active + // Page should re-fetch data for the new city + await expect(page.getByRole('button', { name: /Ha Noi/i })).toBeVisible(); + }); + + test('handles empty data gracefully', async ({ page }) => { + await page.route('**/analytics/market-report**', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ districts: [] }) }), + ); + await page.route('**/analytics/heatmap**', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ dataPoints: [] }) }), + ); + + await page.goto('/analytics'); + + // Page should still render without crashing + await expect(page.getByRole('button', { name: /Ho Chi Minh/i })).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/e2e/web/auth-login.spec.ts b/e2e/web/auth-login.spec.ts new file mode 100644 index 0000000..0a74987 --- /dev/null +++ b/e2e/web/auth-login.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Login Page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login'); + }); + + test('renders login form with all elements', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Đăng nhập' })).toBeVisible(); + await expect(page.getByText('Nhập số điện thoại và mật khẩu để đăng nhập')).toBeVisible(); + + await expect(page.getByLabel('Số điện thoại')).toBeVisible(); + await expect(page.getByLabel('Mật khẩu')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Đăng nhập' })).toBeVisible(); + + // OAuth buttons + await expect(page.getByRole('button', { name: /Google/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /Zalo/i })).toBeVisible(); + + // Register link + await expect(page.getByText('Chưa có tài khoản?')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Đăng ký' })).toBeVisible(); + }); + + test('shows validation errors for empty submission', async ({ page }) => { + await page.getByRole('button', { name: 'Đăng nhập' }).click(); + + // Form validation should show error messages + const alerts = page.locator('[role="alert"]'); + await expect(alerts.first()).toBeVisible(); + }); + + test('validates phone number format', async ({ page }) => { + await page.getByLabel('Số điện thoại').fill('123'); + await page.getByLabel('Mật khẩu').fill('Test@1234!'); + await page.getByRole('button', { name: 'Đăng nhập' }).click(); + + const alerts = page.locator('[role="alert"]'); + await expect(alerts.first()).toBeVisible(); + }); + + test('toggles password visibility', async ({ page }) => { + const passwordInput = page.getByLabel('Mật khẩu'); + await expect(passwordInput).toHaveAttribute('type', 'password'); + + // Click "Hiện" button to show password + await page.getByRole('button', { name: 'Hiện' }).click(); + await expect(passwordInput).toHaveAttribute('type', 'text'); + + // Click "Ẩn" button to hide password + await page.getByRole('button', { name: 'Ẩn' }).click(); + await expect(passwordInput).toHaveAttribute('type', 'password'); + }); + + test('navigates to register page', async ({ page }) => { + await page.getByRole('link', { name: 'Đăng ký' }).click(); + await expect(page).toHaveURL(/\/register/); + }); + + test('shows OAuth error message from query params', async ({ page }) => { + await page.goto('/login?error=oauth_failed'); + await expect( + page.getByText('Đăng nhập bằng mạng xã hội thất bại'), + ).toBeVisible(); + }); + + test('shows access denied OAuth error', async ({ page }) => { + await page.goto('/login?error=access_denied'); + await expect( + page.getByText('Bạn đã từ chối quyền truy cập'), + ).toBeVisible(); + }); + + test('submit button shows loading state during submission', async ({ page }) => { + // Fill valid-looking data + await page.getByLabel('Số điện thoại').fill('0912345678'); + await page.getByLabel('Mật khẩu').fill('Test@1234!'); + + // Intercept the API call to delay response + await page.route('**/auth/login', async (route) => { + await new Promise((r) => setTimeout(r, 1000)); + await route.fulfill({ status: 401, body: JSON.stringify({ message: 'Invalid credentials' }) }); + }); + + await page.getByRole('button', { name: 'Đăng nhập' }).click(); + + // Button should be disabled during loading + await expect(page.getByRole('button', { name: 'Đăng nhập' })).toBeDisabled(); + }); + + test('displays server error on failed login', async ({ page }) => { + await page.route('**/auth/login', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ message: 'Sai số điện thoại hoặc mật khẩu' }), + }), + ); + + await page.getByLabel('Số điện thoại').fill('0912345678'); + await page.getByLabel('Mật khẩu').fill('WrongPass1!'); + await page.getByRole('button', { name: 'Đăng nhập' }).click(); + + const errorAlert = page.locator('[role="alert"]').filter({ hasNotText: /Số điện thoại|Mật khẩu/ }); + await expect(errorAlert.first()).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/e2e/web/auth-oauth-callback.spec.ts b/e2e/web/auth-oauth-callback.spec.ts new file mode 100644 index 0000000..ea2b8fc --- /dev/null +++ b/e2e/web/auth-oauth-callback.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; + +test.describe('OAuth Callback Pages', () => { + test.describe('Google callback', () => { + test('shows loading state while processing', async ({ page }) => { + // Intercept token exchange to keep it pending + await page.route('**/auth/google/callback**', (route) => + new Promise(() => { + // Never resolve — keeps loading state visible + }), + ); + + await page.goto('/auth/callback/google?code=test-code'); + // Should show a loading/spinner state + await expect(page.locator('.animate-spin').first()).toBeVisible({ timeout: 5000 }); + }); + + test('redirects to login with error on failure', async ({ page }) => { + await page.route('**/auth/google/callback**', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ message: 'OAuth failed' }), + }), + ); + + await page.goto('/auth/callback/google?code=bad-code'); + await expect(page).toHaveURL(/\/login\?error=/, { timeout: 10000 }); + }); + }); + + test.describe('Zalo callback', () => { + test('shows loading state while processing', async ({ page }) => { + await page.route('**/auth/zalo/callback**', (route) => + new Promise(() => { + // Never resolve + }), + ); + + await page.goto('/auth/callback/zalo?code=test-code'); + await expect(page.locator('.animate-spin').first()).toBeVisible({ timeout: 5000 }); + }); + + test('redirects to login with error on failure', async ({ page }) => { + await page.route('**/auth/zalo/callback**', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ message: 'OAuth failed' }), + }), + ); + + await page.goto('/auth/callback/zalo?code=bad-code'); + await expect(page).toHaveURL(/\/login\?error=/, { timeout: 10000 }); + }); + }); +}); diff --git a/e2e/web/auth-register.spec.ts b/e2e/web/auth-register.spec.ts new file mode 100644 index 0000000..3980880 --- /dev/null +++ b/e2e/web/auth-register.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Register Page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/register'); + }); + + test('renders registration form with all fields', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Tạo tài khoản' })).toBeVisible(); + await expect(page.getByText('Nhập thông tin để đăng ký tài khoản GoodGo')).toBeVisible(); + + await expect(page.getByLabel('Họ và tên')).toBeVisible(); + await expect(page.getByLabel('Số điện thoại')).toBeVisible(); + await expect(page.getByLabel('Email (tùy chọn)')).toBeVisible(); + await expect(page.getByLabel('Mật khẩu', { exact: false }).first()).toBeVisible(); + await expect(page.getByLabel('Xác nhận mật khẩu')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Đăng ký' })).toBeVisible(); + + // OAuth buttons + await expect(page.getByRole('button', { name: /Google/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /Zalo/i })).toBeVisible(); + + // Login link + await expect(page.getByText('Đã có tài khoản?')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Đăng nhập' })).toBeVisible(); + }); + + test('shows validation errors for empty submission', async ({ page }) => { + await page.getByRole('button', { name: 'Đăng ký' }).click(); + + const alerts = page.locator('[role="alert"]'); + await expect(alerts.first()).toBeVisible(); + }); + + test('validates password mismatch', async ({ page }) => { + await page.getByLabel('Họ và tên').fill('Test User'); + await page.getByLabel('Số điện thoại').fill('0912345678'); + await page.getByLabel('Mật khẩu', { exact: false }).first().fill('Test@1234!'); + await page.getByLabel('Xác nhận mật khẩu').fill('DifferentPass1!'); + await page.getByRole('button', { name: 'Đăng ký' }).click(); + + const alerts = page.locator('[role="alert"]'); + await expect(alerts.first()).toBeVisible(); + }); + + test('validates phone number format', async ({ page }) => { + await page.getByLabel('Họ và tên').fill('Test User'); + await page.getByLabel('Số điện thoại').fill('abc'); + await page.getByLabel('Mật khẩu', { exact: false }).first().fill('Test@1234!'); + await page.getByLabel('Xác nhận mật khẩu').fill('Test@1234!'); + await page.getByRole('button', { name: 'Đăng ký' }).click(); + + const alerts = page.locator('[role="alert"]'); + await expect(alerts.first()).toBeVisible(); + }); + + test('toggles password visibility for both fields', async ({ page }) => { + const passwordInput = page.locator('#password'); + const confirmInput = page.getByLabel('Xác nhận mật khẩu'); + + await expect(passwordInput).toHaveAttribute('type', 'password'); + await expect(confirmInput).toHaveAttribute('type', 'password'); + + await page.getByRole('button', { name: 'Hiện' }).click(); + await expect(passwordInput).toHaveAttribute('type', 'text'); + await expect(confirmInput).toHaveAttribute('type', 'text'); + }); + + test('navigates to login page', async ({ page }) => { + await page.getByRole('link', { name: 'Đăng nhập' }).click(); + await expect(page).toHaveURL(/\/login/); + }); + + test('successful registration redirects to home', async ({ page }) => { + await page.route('**/auth/register', (route) => + route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + accessToken: 'fake-access-token', + refreshToken: 'fake-refresh-token', + }), + }), + ); + + await page.getByLabel('Họ và tên').fill('Test User'); + await page.getByLabel('Số điện thoại').fill('0912345678'); + await page.getByLabel('Mật khẩu', { exact: false }).first().fill('Test@1234!'); + await page.getByLabel('Xác nhận mật khẩu').fill('Test@1234!'); + await page.getByRole('button', { name: 'Đăng ký' }).click(); + + await expect(page).toHaveURL('/', { timeout: 5000 }); + }); + + test('displays server error on failed registration', async ({ page }) => { + await page.route('**/auth/register', (route) => + route.fulfill({ + status: 409, + contentType: 'application/json', + body: JSON.stringify({ message: 'Số điện thoại đã được đăng ký' }), + }), + ); + + await page.getByLabel('Họ và tên').fill('Test User'); + await page.getByLabel('Số điện thoại').fill('0912345678'); + await page.getByLabel('Mật khẩu', { exact: false }).first().fill('Test@1234!'); + await page.getByLabel('Xác nhận mật khẩu').fill('Test@1234!'); + await page.getByRole('button', { name: 'Đăng ký' }).click(); + + const errorAlert = page.locator('[role="alert"]').filter({ hasNotText: /Họ và tên|Số điện thoại|Mật khẩu|Xác nhận/ }); + await expect(errorAlert.first()).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/e2e/web/create-listing.spec.ts b/e2e/web/create-listing.spec.ts new file mode 100644 index 0000000..827bd48 --- /dev/null +++ b/e2e/web/create-listing.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Create Listing Page (Multi-step Form)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/listings/new'); + }); + + test('renders step 1 - basic info form', async ({ page }) => { + // Step indicators should be visible + await expect(page.getByText('Thông tin')).toBeVisible(); + await expect(page.getByText('Vị trí')).toBeVisible(); + await expect(page.getByText('Chi tiết')).toBeVisible(); + await expect(page.getByText('Giá cả')).toBeVisible(); + await expect(page.getByText('Hình ảnh')).toBeVisible(); + }); + + test('shows validation errors when advancing without filling required fields', async ({ page }) => { + // Try to go to next step without filling anything + const nextButton = page.getByRole('button', { name: /Tiep|Next|Tiếp/i }); + if (await nextButton.isVisible()) { + await nextButton.click(); + // Should show validation errors + const alerts = page.locator('[role="alert"], .text-destructive'); + await expect(alerts.first()).toBeVisible({ timeout: 5000 }); + } + }); + + test('has back button disabled on first step', async ({ page }) => { + const backButton = page.getByRole('button', { name: /Quay lai|Back|Quay lại/i }); + if (await backButton.isVisible()) { + await expect(backButton).toBeDisabled(); + } + }); + + test('shows error alert on submission failure', async ({ page }) => { + await page.route('**/listings', (route) => { + if (route.request().method() === 'POST') { + return route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ message: 'Validation failed' }), + }); + } + return route.continue(); + }); + + // Page should render without errors + await expect(page.getByText('Thông tin')).toBeVisible(); + }); +}); diff --git a/e2e/web/dashboard.spec.ts b/e2e/web/dashboard.spec.ts new file mode 100644 index 0000000..2bc639e --- /dev/null +++ b/e2e/web/dashboard.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from '@playwright/test'; + +const mockMarketReport = { + districts: [ + { district: 'Quan 1', propertyType: 'APARTMENT', avgPriceM2: 85000000, medianPriceM2: 80000000, totalListings: 150, daysOnMarket: 45, yoyChange: 5.2 }, + { district: 'Quan 7', propertyType: 'HOUSE', avgPriceM2: 65000000, medianPriceM2: 60000000, totalListings: 200, daysOnMarket: 60, yoyChange: -2.1 }, + ], +}; + +const mockHeatmap = { + dataPoints: [ + { district: 'Quan 1', avgPriceM2: 85000000, totalListings: 150, lat: 10.7769, lng: 106.7009 }, + { district: 'Quan 7', avgPriceM2: 65000000, totalListings: 200, lat: 10.7385, lng: 106.7218 }, + ], +}; + +const mockListings = { + data: [ + { + id: 'l1', transactionType: 'SALE', priceVND: '5000000000', pricePerM2: 66666667, + rentPriceMonthly: null, commissionPct: null, status: 'ACTIVE', viewCount: 120, + saveCount: 15, inquiryCount: 8, publishedAt: '2026-01-15T00:00:00Z', + property: { + id: 'p1', propertyType: 'APARTMENT', title: 'Căn hộ test', description: 'Desc', + address: '123 Test', ward: 'W1', district: 'Quận 1', city: 'Hồ Chí Minh', + latitude: 10.77, longitude: 106.70, areaM2: 75, bedrooms: 2, bathrooms: 2, + floors: 1, direction: 'SOUTH', yearBuilt: null, legalStatus: null, + projectName: null, amenities: [], media: [], + }, + seller: { id: 's1', fullName: 'Test Seller', phone: '0912345678' }, + agent: null, + }, + ], + total: 1, page: 1, limit: 6, totalPages: 1, +}; + +test.describe('Dashboard Page', () => { + test.beforeEach(async ({ page }) => { + // Mock all API calls + await page.route('**/analytics/market-report**', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockMarketReport) }), + ); + await page.route('**/analytics/heatmap**', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockHeatmap) }), + ); + await page.route('**/listings**', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockListings) }), + ); + }); + + test('renders dashboard with title and post button', async ({ page }) => { + await page.goto('/dashboard'); + + await expect(page.getByRole('heading', { name: 'Bang dieu khien' })).toBeVisible(); + await expect(page.getByText('Tong quan thi truong va tin dang cua ban')).toBeVisible(); + await expect(page.getByRole('link', { name: /Dang tin moi/i })).toBeVisible(); + }); + + test('displays stat cards', async ({ page }) => { + await page.goto('/dashboard'); + + await expect(page.getByText('Tin dang cua toi')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Luot xem')).toBeVisible(); + await expect(page.getByText('Lien he')).toBeVisible(); + await expect(page.getByText('Gia TB thi truong')).toBeVisible(); + }); + + test('shows market summary card', async ({ page }) => { + await page.goto('/dashboard'); + + await expect(page.getByText('Tin dang cua toi')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Tong tin dang')).toBeVisible(); + await expect(page.getByText('Gia TB/m2')).toBeVisible(); + await expect(page.getByText('Ngay TB de ban')).toBeVisible(); + await expect(page.getByText('So quan')).toBeVisible(); + }); + + test('shows recent listings section', async ({ page }) => { + await page.goto('/dashboard'); + + await expect(page.getByText('Tin dang gan day')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Căn hộ test')).toBeVisible(); + }); + + test('navigates to create listing page', async ({ page }) => { + await page.goto('/dashboard'); + await expect(page.getByRole('heading', { name: 'Bang dieu khien' })).toBeVisible(); + + await page.getByRole('link', { name: /Dang tin moi/i }).click(); + await expect(page).toHaveURL(/\/listings\/new/); + }); + + test('navigates to analytics page', async ({ page }) => { + await page.goto('/dashboard'); + await expect(page.getByText('Xem phan tich chi tiet')).toBeVisible({ timeout: 10000 }); + + await page.getByText('Xem phan tich chi tiet').click(); + await expect(page).toHaveURL(/\/analytics/); + }); + + test('handles API failures gracefully', async ({ page }) => { + await page.route('**/analytics/market-report**', (route) => + route.fulfill({ status: 500, body: 'Error' }), + ); + await page.route('**/analytics/heatmap**', (route) => + route.fulfill({ status: 500, body: 'Error' }), + ); + await page.route('**/listings**', (route) => + route.fulfill({ status: 500, body: 'Error' }), + ); + + await page.goto('/dashboard'); + + // Page should still render (with fallback states) + await expect(page.getByRole('heading', { name: 'Bang dieu khien' })).toBeVisible(); + }); +}); diff --git a/e2e/web/listing-detail.spec.ts b/e2e/web/listing-detail.spec.ts new file mode 100644 index 0000000..2594928 --- /dev/null +++ b/e2e/web/listing-detail.spec.ts @@ -0,0 +1,193 @@ +import { test, expect } from '@playwright/test'; + +const mockListing = { + id: 'listing-1', + transactionType: 'SALE', + priceVND: '5000000000', + pricePerM2: 66666667, + rentPriceMonthly: null, + commissionPct: 2.5, + status: 'ACTIVE', + viewCount: 120, + saveCount: 15, + inquiryCount: 8, + publishedAt: '2026-01-15T00:00:00Z', + property: { + id: 'prop-1', + propertyType: 'APARTMENT', + title: 'Căn hộ cao cấp Quận 1', + description: 'Căn hộ đẹp view sông Sài Gòn, nội thất cao cấp, tiện ích đầy đủ.', + address: '123 Nguyễn Huệ', + ward: 'Bến Nghé', + district: 'Quận 1', + city: 'Hồ Chí Minh', + latitude: 10.7769, + longitude: 106.7009, + areaM2: 75, + bedrooms: 2, + bathrooms: 2, + floors: 1, + direction: 'SOUTH', + yearBuilt: 2022, + legalStatus: 'Sổ hồng', + projectName: 'Vinhomes Central Park', + amenities: ['Hồ bơi', 'Gym', 'Bãi đỗ xe'], + media: [ + { id: 'm1', url: '/placeholder.jpg', type: 'IMAGE', order: 0 }, + { id: 'm2', url: '/placeholder2.jpg', type: 'IMAGE', order: 1 }, + ], + }, + seller: { id: 's1', fullName: 'Nguyen Van A', phone: '0912345678' }, + agent: { id: 'a1', agency: 'GoodGo Realty', licenseNumber: 'AGT-001' }, +}; + +test.describe('Listing Detail Page', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/listings/listing-1', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockListing), + }), + ); + }); + + test('renders listing title and price', async ({ page }) => { + await page.goto('/listings/listing-1'); + + await expect(page.getByRole('heading', { name: 'Căn hộ cao cấp Quận 1' })).toBeVisible({ + timeout: 10000, + }); + await expect(page.getByText(/5\.0 tỷ/)).toBeVisible(); + await expect(page.getByText('VND')).toBeVisible(); + }); + + test('displays breadcrumb navigation', async ({ page }) => { + await page.goto('/listings/listing-1'); + + await expect(page.getByText('Căn hộ cao cấp Quận 1').first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('link', { name: 'Trang chu' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Tim kiem' })).toBeVisible(); + }); + + test('shows property badges (transaction type and property type)', async ({ page }) => { + await page.goto('/listings/listing-1'); + + await expect(page.getByText('Căn hộ cao cấp Quận 1').first()).toBeVisible({ timeout: 10000 }); + // Transaction type and property type badges + const badges = page.locator('[class*="badge"]'); + await expect(badges.first()).toBeVisible(); + }); + + test('displays address information', async ({ page }) => { + await page.goto('/listings/listing-1'); + + await expect(page.getByText('Căn hộ cao cấp Quận 1').first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(/123 Nguyễn Huệ/)).toBeVisible(); + await expect(page.getByText(/Bến Nghé/)).toBeVisible(); + await expect(page.getByText(/Quận 1/)).toBeVisible(); + }); + + test('shows quick stats bar', async ({ page }) => { + await page.goto('/listings/listing-1'); + + await expect(page.getByText('Căn hộ cao cấp Quận 1').first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('75 m²')).toBeVisible(); + await expect(page.getByText('Dien tich')).toBeVisible(); + await expect(page.getByText('Phong ngu')).toBeVisible(); + await expect(page.getByText('Phong tam')).toBeVisible(); + }); + + test('displays description section', async ({ page }) => { + await page.goto('/listings/listing-1'); + + await expect(page.getByText('Căn hộ cao cấp Quận 1').first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Mo ta')).toBeVisible(); + await expect(page.getByText('Căn hộ đẹp view sông Sài Gòn')).toBeVisible(); + }); + + test('shows detailed property info grid', async ({ page }) => { + await page.goto('/listings/listing-1'); + + await expect(page.getByText('Căn hộ cao cấp Quận 1').first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Thong tin chi tiet')).toBeVisible(); + await expect(page.getByText('Loai BDS')).toBeVisible(); + await expect(page.getByText('Sổ hồng')).toBeVisible(); + await expect(page.getByText('Vinhomes Central Park')).toBeVisible(); + }); + + test('displays amenities', async ({ page }) => { + await page.goto('/listings/listing-1'); + + await expect(page.getByText('Căn hộ cao cấp Quận 1').first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Tien ich')).toBeVisible(); + await expect(page.getByText('Hồ bơi')).toBeVisible(); + await expect(page.getByText('Gym')).toBeVisible(); + await expect(page.getByText('Bãi đỗ xe')).toBeVisible(); + }); + + test('shows seller contact card', async ({ page }) => { + await page.goto('/listings/listing-1'); + + await expect(page.getByText('Căn hộ cao cấp Quận 1').first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Lien he')).toBeVisible(); + await expect(page.getByText('Nguyen Van A')).toBeVisible(); + await expect(page.getByText('0912345678')).toBeVisible(); + await expect(page.getByRole('button', { name: /Goi ngay/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /Nhan tin/i })).toBeVisible(); + }); + + test('shows agent info when available', async ({ page }) => { + await page.goto('/listings/listing-1'); + + await expect(page.getByText('Căn hộ cao cấp Quận 1').first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Moi gioi')).toBeVisible(); + await expect(page.getByText('GoodGo Realty')).toBeVisible(); + await expect(page.getByText(/2\.5%/)).toBeVisible(); + }); + + test('displays listing statistics', async ({ page }) => { + await page.goto('/listings/listing-1'); + + await expect(page.getByText('Căn hộ cao cấp Quận 1').first()).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('120')).toBeVisible(); // viewCount + await expect(page.getByText('Luot xem')).toBeVisible(); + await expect(page.getByText('Luot luu')).toBeVisible(); + }); + + test('shows error state for non-existent listing', async ({ page }) => { + await page.route('**/listings/nonexistent', (route) => + route.fulfill({ status: 404, contentType: 'application/json', body: '{}' }), + ); + + await page.goto('/listings/nonexistent'); + + await expect(page.getByText(/Khong/)).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('link', { name: /Quay lai tim kiem/i })).toBeVisible(); + }); + + test('shows loading skeleton initially', async ({ page }) => { + await page.route('**/listings/listing-1', async (route) => { + await new Promise((r) => setTimeout(r, 2000)); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockListing), + }); + }); + + await page.goto('/listings/listing-1'); + + // Skeleton elements should be visible during loading + const skeleton = page.locator('.animate-pulse'); + await expect(skeleton.first()).toBeVisible({ timeout: 3000 }); + }); + + test('breadcrumb navigates to search page', async ({ page }) => { + await page.goto('/listings/listing-1'); + + await expect(page.getByText('Căn hộ cao cấp Quận 1').first()).toBeVisible({ timeout: 10000 }); + await page.getByRole('link', { name: 'Tim kiem' }).click(); + await expect(page).toHaveURL(/\/search/); + }); +}); diff --git a/e2e/web/navigation.spec.ts b/e2e/web/navigation.spec.ts new file mode 100644 index 0000000..45ea152 --- /dev/null +++ b/e2e/web/navigation.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Navigation and Routing', () => { + test('homepage loads and has navigation links', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + // Header navigation should have links + const nav = page.locator('header nav, header'); + await expect(nav.first()).toBeVisible(); + }); + + test('navigates from homepage to search', async ({ page }) => { + await page.goto('/'); + + // Click on search-related link or button + const searchLink = page.getByRole('link', { name: /Tim kiem|Tìm kiếm|Search/i }).first(); + if (await searchLink.isVisible()) { + await searchLink.click(); + await expect(page).toHaveURL(/\/search/); + } + }); + + test('navigates from homepage to login', async ({ page }) => { + await page.goto('/'); + + const loginLink = page.getByRole('link', { name: /Dang nhap|Đăng nhập|Login/i }).first(); + if (await loginLink.isVisible()) { + await loginLink.click(); + await expect(page).toHaveURL(/\/login/); + } + }); + + test('navigates from homepage to register', async ({ page }) => { + await page.goto('/'); + + const registerLink = page.getByRole('link', { name: /Dang ky|Đăng ký|Register/i }).first(); + if (await registerLink.isVisible()) { + await registerLink.click(); + await expect(page).toHaveURL(/\/register/); + } + }); + + test('login page links to register and vice versa', async ({ page }) => { + await page.goto('/login'); + await page.getByRole('link', { name: 'Đăng ký' }).click(); + await expect(page).toHaveURL(/\/register/); + + await page.getByRole('link', { name: 'Đăng nhập' }).click(); + await expect(page).toHaveURL(/\/login/); + }); + + test('404 page does not crash', async ({ page }) => { + const response = await page.goto('/nonexistent-page-xyz'); + // Page should load (even if 404) + await expect(page.locator('body')).toBeVisible(); + // Should either show 404 or redirect + expect(response?.status()).toBeLessThan(500); + }); + + test('search page with query params loads correctly', async ({ page }) => { + await page.route('**/listings**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], total: 0, page: 1, limit: 12, totalPages: 0 }), + }), + ); + + await page.goto('/search?transactionType=SALE&city=Ho+Chi+Minh'); + await expect(page.getByRole('heading', { name: 'Tìm kiếm bất động sản' })).toBeVisible(); + }); +}); diff --git a/e2e/web/responsive.spec.ts b/e2e/web/responsive.spec.ts new file mode 100644 index 0000000..0ce77d3 --- /dev/null +++ b/e2e/web/responsive.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Responsive Design', () => { + test('homepage renders on mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/'); + + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + const main = page.locator('main'); + await expect(main).toBeVisible(); + }); + + test('homepage renders on tablet viewport', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + await page.goto('/'); + + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + }); + + test('login page is usable on mobile', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/login'); + + await expect(page.getByRole('heading', { name: 'Đăng nhập' })).toBeVisible(); + await expect(page.getByLabel('Số điện thoại')).toBeVisible(); + await expect(page.getByLabel('Mật khẩu')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Đăng nhập' })).toBeVisible(); + }); + + test('register page is usable on mobile', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/register'); + + await expect(page.getByRole('heading', { name: 'Tạo tài khoản' })).toBeVisible(); + await expect(page.getByLabel('Họ và tên')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Đăng ký' })).toBeVisible(); + }); + + test('search page shows mobile filter button on small screen', async ({ page }) => { + await page.route('**/listings**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], total: 0, page: 1, limit: 12, totalPages: 0 }), + }), + ); + + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/search'); + + await expect(page.getByRole('heading', { name: 'Tìm kiếm bất động sản' })).toBeVisible(); + // Mobile filter button should be visible + await expect(page.getByRole('button', { name: /Bộ lọc/i })).toBeVisible(); + }); + + test('search page hides sidebar filters on mobile', async ({ page }) => { + await page.route('**/listings**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], total: 0, page: 1, limit: 12, totalPages: 0 }), + }), + ); + + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/search'); + + // Sidebar should be hidden on mobile (has 'hidden lg:block' class) + const sidebar = page.locator('aside'); + await expect(sidebar).toBeHidden(); + }); + + test('split view button is hidden on mobile search', async ({ page }) => { + await page.route('**/listings**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [], total: 0, page: 1, limit: 12, totalPages: 0 }), + }), + ); + + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/search'); + + // Split button should be hidden on mobile (has 'hidden lg:flex' class) + await expect(page.getByRole('button', { name: /Chia đôi/i })).toBeHidden(); + }); +}); diff --git a/e2e/web/search.spec.ts b/e2e/web/search.spec.ts new file mode 100644 index 0000000..c354952 --- /dev/null +++ b/e2e/web/search.spec.ts @@ -0,0 +1,171 @@ +import { test, expect } from '@playwright/test'; + +const mockListings = { + data: [ + { + id: 'listing-1', + transactionType: 'SALE', + priceVND: '5000000000', + pricePerM2: 66666667, + rentPriceMonthly: null, + commissionPct: null, + status: 'ACTIVE', + viewCount: 120, + saveCount: 15, + inquiryCount: 8, + publishedAt: '2026-01-15T00:00:00Z', + property: { + id: 'prop-1', + propertyType: 'APARTMENT', + title: 'Căn hộ cao cấp Quận 1', + description: 'Căn hộ đẹp view sông', + address: '123 Nguyễn Huệ', + ward: 'Bến Nghé', + district: 'Quận 1', + city: 'Hồ Chí Minh', + latitude: 10.7769, + longitude: 106.7009, + areaM2: 75, + bedrooms: 2, + bathrooms: 2, + floors: 1, + direction: 'SOUTH', + yearBuilt: null, + legalStatus: null, + projectName: null, + amenities: [], + media: [], + }, + seller: { id: 's1', fullName: 'Nguyen Van A', phone: '0912345678' }, + agent: null, + }, + { + id: 'listing-2', + transactionType: 'RENT', + priceVND: '15000000', + pricePerM2: null, + rentPriceMonthly: '15000000', + commissionPct: null, + status: 'ACTIVE', + viewCount: 50, + saveCount: 5, + inquiryCount: 3, + publishedAt: '2026-02-01T00:00:00Z', + property: { + id: 'prop-2', + propertyType: 'HOUSE', + title: 'Nhà phố Quận 7', + description: 'Nhà phố đẹp khu an ninh', + address: '456 Nguyễn Thị Thập', + ward: 'Tân Phú', + district: 'Quận 7', + city: 'Hồ Chí Minh', + latitude: 10.7385, + longitude: 106.7218, + areaM2: 120, + bedrooms: 4, + bathrooms: 3, + floors: 3, + direction: 'EAST', + yearBuilt: 2020, + legalStatus: null, + projectName: null, + amenities: [], + media: [], + }, + seller: { id: 's2', fullName: 'Tran Thi B', phone: '0987654321' }, + agent: null, + }, + ], + total: 2, + page: 1, + limit: 12, + totalPages: 1, +}; + +test.describe('Search Page', () => { + test.beforeEach(async ({ page }) => { + // Mock the listings API to return consistent data + await page.route('**/listings**', (route) => { + if (route.request().method() === 'GET') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockListings), + }); + } + return route.continue(); + }); + }); + + test('renders search page with title and filters', async ({ page }) => { + await page.goto('/search'); + + await expect(page.getByRole('heading', { name: 'Tìm kiếm bất động sản' })).toBeVisible(); + await expect( + page.getByText('Tìm bất động sản phù hợp với nhu cầu của bạn'), + ).toBeVisible(); + }); + + test('displays view mode toggle buttons', async ({ page }) => { + await page.goto('/search'); + + await expect(page.getByRole('button', { name: /Danh sách/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /Bản đồ/i })).toBeVisible(); + }); + + test('displays listing results', async ({ page }) => { + await page.goto('/search'); + + await expect(page.getByText('Căn hộ cao cấp Quận 1')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Nhà phố Quận 7')).toBeVisible(); + }); + + test('switches to map view mode', async ({ page }) => { + await page.goto('/search'); + await page.getByRole('button', { name: /Bản đồ/i }).click(); + + // Map view should be active — list results should not be visible + await expect(page.getByRole('button', { name: /Bản đồ/i })).toHaveAttribute( + 'data-state', + /.*/, + ); + }); + + test('syncs filters to URL query parameters', async ({ page }) => { + await page.goto('/search?transactionType=SALE'); + + // The URL should contain the filter + await expect(page).toHaveURL(/transactionType=SALE/); + }); + + test('shows error state on API failure', async ({ page }) => { + await page.route('**/listings**', (route) => + route.fulfill({ status: 500, body: 'Internal Server Error' }), + ); + + await page.goto('/search'); + + // Should show some error indication + await page.waitForTimeout(2000); + // The page should still be navigable (not crash) + await expect(page.getByRole('heading', { name: 'Tìm kiếm bất động sản' })).toBeVisible(); + }); + + test('shows loading spinner initially', async ({ page }) => { + await page.route('**/listings**', async (route) => { + await new Promise((r) => setTimeout(r, 2000)); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockListings), + }); + }); + + await page.goto('/search'); + + // Should show loading indication (spinner or skeleton) + const spinner = page.locator('.animate-spin, .animate-pulse'); + await expect(spinner.first()).toBeVisible({ timeout: 3000 }); + }); +});