test(e2e): align web specs with current app routes

This commit is contained in:
Ho Ngoc Hai
2026-05-04 20:11:09 +07:00
parent f112045826
commit 39156fc107
21 changed files with 334 additions and 458 deletions

View File

@@ -51,6 +51,12 @@ const SOCIAL_ICON: Record<string, React.ElementType> = {
youtube: ExternalLink,
};
const SOCIAL_LABEL: Record<string, string> = {
facebook: 'GoodGo Facebook',
instagram: 'GoodGo Instagram',
youtube: 'GoodGo YouTube',
};
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
@@ -123,8 +129,10 @@ export function Footer({
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label={SOCIAL_LABEL[s.platform] ?? s.platform}
title={SOCIAL_LABEL[s.platform] ?? s.platform}
>
{Icon && <Icon className="h-4 w-4" />}
{Icon && <Icon className="h-4 w-4" aria-hidden="true" />}
</a>
);
})}

View File

@@ -90,6 +90,7 @@ function SearchResultsInner({
value={sort}
onChange={(e) => onSortChange(e.target.value)}
className="w-full sm:w-48"
aria-label="Sắp xếp kết quả tìm kiếm"
>
<option value="">Mới nhất</option>
<option value="price_asc">Giá: Thấp đến cao</option>

View File

@@ -12,7 +12,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { AxeBuilder } from '@axe-core/playwright';
import { test, expect } from '@playwright/test';
import { test } from '@playwright/test';
const REPORTS_DIR = path.join(__dirname, 'reports');
@@ -169,7 +169,7 @@ for (const [routeKey, urlPath] of ROUTES) {
const summary = blocking
.map((v) => ` [${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s)) — ${v.helpUrl}`)
.join('\n');
expect.fail(
throw new Error(
`${blocking.length} blocking a11y violation(s) on ${urlPath}:\n${summary}\n\nSee full report: e2e/a11y/reports/${routeKey}.json`,
);
}

View File

@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { mockAuthenticatedUser } from './support/auth';
const mockDashboardStats = {
totalUsers: 1250,
@@ -6,29 +7,29 @@ const mockDashboardStats = {
totalListings: 3400,
newListingsLast30Days: 320,
activeListings: 2800,
pendingModeration: 45,
pendingModerationCount: 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 },
],
};
const mockRevenue = [
{ period: '2025-10', totalRevenue: 150000000, subscriptionRevenue: 100000000, listingFeeRevenue: 30000000, featuredListingRevenue: 20000000, transactionCount: 12 },
{ period: '2025-11', totalRevenue: 180000000, subscriptionRevenue: 120000000, listingFeeRevenue: 35000000, featuredListingRevenue: 25000000, transactionCount: 14 },
{ period: '2025-12', totalRevenue: 200000000, subscriptionRevenue: 130000000, listingFeeRevenue: 40000000, featuredListingRevenue: 30000000, transactionCount: 15 },
{ period: '2026-01', totalRevenue: 220000000, subscriptionRevenue: 140000000, listingFeeRevenue: 50000000, featuredListingRevenue: 30000000, transactionCount: 16 },
{ period: '2026-02', totalRevenue: 250000000, subscriptionRevenue: 160000000, listingFeeRevenue: 55000000, featuredListingRevenue: 35000000, transactionCount: 18 },
{ period: '2026-03', totalRevenue: 280000000, subscriptionRevenue: 180000000, listingFeeRevenue: 60000000, featuredListingRevenue: 40000000, transactionCount: 20 },
];
test.describe('Admin Dashboard', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/admin/dashboard**', (route) =>
test.beforeEach(async ({ page, context, baseURL }) => {
await mockAuthenticatedUser(page, context, baseURL, { role: 'ADMIN' });
await page.route('**/api/v1/admin/dashboard**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockDashboardStats) }),
);
await page.route('**/admin/revenue**', (route) =>
await page.route('**/api/v1/admin/revenue**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockRevenue) }),
);
});
@@ -49,7 +50,7 @@ test.describe('Admin Dashboard', () => {
});
test('handles API failure gracefully', async ({ page }) => {
await page.route('**/admin/dashboard**', (route) =>
await page.route('**/api/v1/admin/dashboard**', (route) =>
route.fulfill({ status: 500, body: 'Error' }),
);

View File

@@ -1,17 +1,18 @@
import { test, expect } from '@playwright/test';
import { mockAuthenticatedUser } from './support/auth';
const mockKycQueue = {
data: [
{
id: 'kyc-1', userId: 'u1', fullName: 'Nguyen Van A', phone: '0912345678',
userId: 'u1', fullName: 'Nguyen Van A', phone: '0912345678',
email: 'a@test.com', role: 'AGENT', kycStatus: 'PENDING',
submittedAt: '2026-03-01T00:00:00Z',
createdAt: '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',
userId: 'u2', fullName: 'Tran Thi B', phone: '0987654321',
email: null, role: 'AGENT', kycStatus: 'PENDING',
submittedAt: '2026-03-02T00:00:00Z',
createdAt: '2026-03-02T00:00:00Z',
kycData: { idType: 'PASSPORT', idNumber: 'B1234567' },
},
],
@@ -19,8 +20,10 @@ const mockKycQueue = {
};
test.describe('Admin KYC Page', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/admin/kyc**', (route) => {
test.beforeEach(async ({ page, context, baseURL }) => {
await mockAuthenticatedUser(page, context, baseURL, { role: 'ADMIN' });
await page.route('**/api/v1/admin/kyc**', (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({
status: 200,
@@ -56,7 +59,7 @@ test.describe('Admin KYC Page', () => {
});
test('handles empty KYC queue', async ({ page }) => {
await page.route('**/admin/kyc**', (route) =>
await page.route('**/api/v1/admin/kyc**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',

View File

@@ -1,24 +1,27 @@
import { test, expect } from '@playwright/test';
import { mockAuthenticatedUser } from './support/auth';
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',
listingId: 'l1', propertyTitle: 'Căn hộ cần duyệt', propertyType: 'APARTMENT',
transactionType: 'SALE', priceVND: 5000000000, sellerName: 'Nguyen Van A',
moderationScore: 85, createdAt: '2026-03-01T00:00:00Z',
},
{
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',
listingId: 'l2', propertyTitle: 'Nhà phố cần duyệt', propertyType: 'HOUSE',
transactionType: 'RENT', priceVND: 15000000, sellerName: 'Tran Thi B',
moderationScore: 42, createdAt: '2026-03-02T00:00:00Z',
},
],
total: 2, page: 1, limit: 20, totalPages: 1,
};
test.describe('Admin Moderation Page', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/admin/moderation**', (route) => {
test.beforeEach(async ({ page, context, baseURL }) => {
await mockAuthenticatedUser(page, context, baseURL, { role: 'ADMIN' });
await page.route('**/api/v1/admin/moderation**', (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({
status: 200,
@@ -60,7 +63,7 @@ test.describe('Admin Moderation Page', () => {
});
test('handles empty moderation queue', async ({ page }) => {
await page.route('**/admin/moderation**', (route) =>
await page.route('**/api/v1/admin/moderation**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',

View File

@@ -1,26 +1,29 @@
import { test, expect } from '@playwright/test';
import { mockAuthenticatedUser } from './support/auth';
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',
role: 'USER', kycStatus: 'VERIFIED', isActive: true, 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',
role: 'AGENT', kycStatus: 'PENDING', isActive: true, 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',
role: 'ADMIN', kycStatus: 'VERIFIED', isActive: false, 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) => {
test.beforeEach(async ({ page, context, baseURL }) => {
await mockAuthenticatedUser(page, context, baseURL, { role: 'ADMIN' });
await page.route('**/api/v1/admin/users**', (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({
status: 200,
@@ -53,12 +56,12 @@ test.describe('Admin Users Management', () => {
await page.goto('/admin/users');
// Search input should exist
const searchInput = page.getByPlaceholder(/Tim kiem|Search/i);
const searchInput = page.getByPlaceholder(/Tìm theo tên|Tim kiem|Search/i);
await expect(searchInput).toBeVisible({ timeout: 10000 });
});
test('handles empty user list', async ({ page }) => {
await page.route('**/admin/users**', (route) =>
await page.route('**/api/v1/admin/users**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',

View File

@@ -7,110 +7,55 @@
*/
import { test, expect } from '@playwright/test';
const mockAgent = {
id: 'agent-1',
fullName: 'Nguyễn Văn Minh',
avatarUrl: null,
phone: '0912345678',
email: 'minh@goodgo.vn',
agency: 'GoodGo Realty',
licenseNumber: 'GPHN-2025-001',
bio: 'Chuyên viên tư vấn bất động sản khu vực Quận 7 và Quận 2 với hơn 5 năm kinh nghiệm.',
qualityScore: 88,
totalDeals: 45,
isVerified: true,
serviceAreas: ['Quận 7', 'Quận 2', 'Nhà Bè'],
memberSince: '2023-06-15T00:00:00Z',
activeListings: [
{
id: 'listing-1',
transactionType: 'SALE',
priceVND: '5000000000',
status: 'ACTIVE',
property: {
id: 'prop-1',
title: 'Căn hộ cao cấp Quận 7',
propertyType: 'APARTMENT',
address: '123 Nguyễn Thị Thập',
district: 'Quận 7',
city: 'Hồ Chí Minh',
areaM2: 75,
bedrooms: 2,
bathrooms: 2,
imageUrl: null,
},
},
],
avgReviewRating: 4.8,
totalReviews: 12,
};
import { mockAuthenticatedUser } from './support/auth';
const mockReviews = {
data: [
{
id: 'review-1',
userId: 'user-1',
userName: 'Trần Thị B',
targetType: 'AGENT',
targetId: 'agent-1',
rating: 5,
comment: 'Môi giới tận tình, hỗ trợ nhiệt tình.',
createdAt: '2026-03-01T00:00:00Z',
},
],
stats: {
targetType: 'AGENT',
targetId: 'agent-1',
averageRating: 4.8,
totalReviews: 12,
distribution: { 5: 10, 4: 2 },
},
};
const seededAgentId = 'seed-agentprofile-001';
const seededAgentName = 'Nguyễn Văn An';
const seededAgentAgency = 'GoodGo Premium Realty';
test.describe('Agent Profile Page', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/agents/agent-1', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockAgent),
}),
);
await page.route('**/agents/agent-1/reviews**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockReviews),
}),
);
test.beforeEach(async ({ page, context, baseURL }) => {
await mockAuthenticatedUser(page, context, baseURL, { role: 'AGENT' });
});
test('renders agent name and verified badge', async ({ page }) => {
await page.goto('/agents/agent-1');
await page.goto(`/agents/${seededAgentId}`);
await expect(page.getByText('Nguyễn Văn Minh')).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('heading', { name: seededAgentName })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText('KYC xác minh')).toBeVisible();
});
test('shows agent agency and contact info', async ({ page }) => {
await page.goto('/agents/agent-1');
await page.goto(`/agents/${seededAgentId}`);
await expect(page.getByText('Nguyễn Văn Minh')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/GoodGo Realty/)).toBeVisible();
await expect(page.getByRole('heading', { name: seededAgentName })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText(seededAgentAgency)).toBeVisible();
await expect(page.getByText('+84900000002').first()).toBeVisible();
});
test('shows active listings section', async ({ page }) => {
await page.goto('/agents/agent-1');
test('shows listings and reviews sections', async ({ page }) => {
await page.goto(`/agents/${seededAgentId}`);
await expect(page.getByText('Nguyễn Văn Minh')).toBeVisible({ timeout: 10_000 });
// Listing should appear
await expect(page.getByText(/Căn hộ cao cấp Quận 7/)).toBeVisible();
await expect(page.getByRole('heading', { name: seededAgentName })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText('Danh mục bất động sản')).toBeVisible();
await expect(page.getByRole('heading', { name: /Đánh giá/ })).toBeVisible();
});
test('has breadcrumb back to homepage', async ({ page }) => {
await page.goto('/agents/agent-1');
await page.goto(`/agents/${seededAgentId}`);
await expect(page.getByText('Nguyễn Văn Minh')).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('link', { name: /Trang chủ/i })).toBeVisible();
await expect(page.getByRole('heading', { name: seededAgentName })).toBeVisible({
timeout: 10_000,
});
await expect(
page.locator('#main-content').getByRole('link', { name: /Trang chủ/i }),
).toBeVisible();
});
test('renders without critical console errors', async ({ page }) => {
@@ -130,17 +75,13 @@ test.describe('Agent Profile Page', () => {
}
});
await page.goto('/agents/agent-1');
await page.goto(`/agents/${seededAgentId}`);
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
expect(criticalErrors).toHaveLength(0);
});
test('handles 404 for unknown agent gracefully', async ({ page }) => {
await page.route('**/agents/nonexistent**', (route) =>
route.fulfill({ status: 404, body: JSON.stringify({ message: 'Not found' }) }),
);
const res = await page.goto('/agents/nonexistent-agent-id');
const status = res?.status();
if (status && status >= 500) {
@@ -158,26 +99,15 @@ test.describe('Agent Profile — Responsive', () => {
];
for (const vp of viewports) {
test(`renders at ${vp.label}`, async ({ page }) => {
await page.route('**/agents/agent-1', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockAgent),
}),
);
await page.route('**/agents/agent-1/reviews**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockReviews),
}),
);
test(`renders at ${vp.label}`, async ({ page, context, baseURL }) => {
await mockAuthenticatedUser(page, context, baseURL, { role: 'AGENT' });
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto('/agents/agent-1');
await page.goto(`/agents/${seededAgentId}`);
await expect(page.getByText('Nguyễn Văn Minh')).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('heading', { name: seededAgentName })).toBeVisible({
timeout: 10_000,
});
// No horizontal overflow (layout break indicator)
const bodyWidth = await page.evaluate(() => document.body.scrollWidth);

View File

@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { mockAuthenticatedUser } from './support/auth';
const mockMarketReport = {
districts: [
@@ -29,17 +30,19 @@ const mockTrends = {
};
test.describe('Analytics Page', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/analytics/market-report**', (route) =>
test.beforeEach(async ({ page, context, baseURL }) => {
await mockAuthenticatedUser(page, context, baseURL, { role: 'AGENT' });
await page.route('**/api/v1/analytics/market-report**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockMarketReport) }),
);
await page.route('**/analytics/heatmap**', (route) =>
await page.route('**/api/v1/analytics/heatmap**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockHeatmap) }),
);
await page.route('**/analytics/district-stats**', (route) =>
await page.route('**/api/v1/analytics/district-stats**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockDistrictStats) }),
);
await page.route('**/analytics/price-trends**', (route) =>
await page.route('**/api/v1/analytics/price-trend**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockTrends) }),
);
});
@@ -56,7 +59,8 @@ test.describe('Analytics Page', () => {
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 });
await expect(page.getByRole('tab', { name: /Tổng quan/i })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('tab', { name: /Xu hướng giá/i })).toBeVisible();
});
test('switches city when selector clicked', async ({ page }) => {
@@ -71,10 +75,10 @@ test.describe('Analytics Page', () => {
});
test('handles empty data gracefully', async ({ page }) => {
await page.route('**/analytics/market-report**', (route) =>
await page.route('**/api/v1/analytics/market-report**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ districts: [] }) }),
);
await page.route('**/analytics/heatmap**', (route) =>
await page.route('**/api/v1/analytics/heatmap**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ dataPoints: [] }) }),
);

View File

@@ -1,17 +1,19 @@
import { test, expect } from '@playwright/test';
import { mockAuthenticatedUser } from './support/auth';
test.describe('Create Listing Page (Multi-step Form)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/listings/new');
test.beforeEach(async ({ page, context, baseURL }) => {
await mockAuthenticatedUser(page, context, baseURL, { role: 'AGENT' });
await page.goto('/my-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();
await expect(page.getByText('Thông tin', { exact: true })).toBeVisible();
await expect(page.getByText('Vị trí', { exact: true })).toBeVisible();
await expect(page.getByText('Chi tiết', { exact: true })).toBeVisible();
await expect(page.getByText('Giá cả', { exact: true })).toBeVisible();
await expect(page.getByText('Hình ảnh', { exact: true })).toBeVisible();
});
test('shows validation errors when advancing without filling required fields', async ({ page }) => {
@@ -33,7 +35,7 @@ test.describe('Create Listing Page (Multi-step Form)', () => {
});
test('shows error alert on submission failure', async ({ page }) => {
await page.route('**/listings', (route) => {
await page.route('**/api/v1/listings', (route) => {
if (route.request().method() === 'POST') {
return route.fulfill({
status: 400,
@@ -45,6 +47,6 @@ test.describe('Create Listing Page (Multi-step Form)', () => {
});
// Page should render without errors
await expect(page.getByText('Thông tin')).toBeVisible();
await expect(page.getByText('Thông tin', { exact: true })).toBeVisible();
});
});

View File

@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { mockAuthenticatedUser } from './support/auth';
const mockMarketReport = {
districts: [
@@ -35,15 +36,17 @@ const mockListings = {
};
test.describe('Dashboard Page', () => {
test.beforeEach(async ({ page }) => {
test.beforeEach(async ({ page, context, baseURL }) => {
await mockAuthenticatedUser(page, context, baseURL, { role: 'AGENT' });
// Mock all API calls
await page.route('**/analytics/market-report**', (route) =>
await page.route('**/api/v1/analytics/market-report**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockMarketReport) }),
);
await page.route('**/analytics/heatmap**', (route) =>
await page.route('**/api/v1/analytics/heatmap**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockHeatmap) }),
);
await page.route('**/listings**', (route) =>
await page.route('**/api/v1/listings**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockListings) }),
);
});
@@ -51,67 +54,68 @@ test.describe('Dashboard Page', () => {
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();
await expect(page.getByRole('heading', { name: 'Bng điều khin' })).toBeVisible();
await expect(page.getByText('Tng quan th trường và tin đăng ca bn')).toBeVisible();
await expect(page.getByRole('link', { name: /Đăng tin mi/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();
const main = page.getByRole('main');
await expect(main.getByText('Tin đăng của tôi', { exact: true })).toBeVisible({ timeout: 10000 });
await expect(main.getByText('Lượt xem', { exact: true })).toBeVisible();
await expect(main.getByText('Liên hệ', { exact: true })).toBeVisible();
await expect(main.getByText('Giá TB thị trường', { exact: true })).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();
await expect(page.getByText('Tin đăng ca tôi')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Tng tin đăng')).toBeVisible();
await expect(page.getByText('Giá TB/m²')).toBeVisible();
await expect(page.getByText('Ngày TB để bán')).toBeVisible();
await expect(page.getByText('S qun')).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('Tin đăng gn đây')).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 expect(page.getByRole('heading', { name: 'Bng điều khin' })).toBeVisible();
await page.getByRole('link', { name: /Dang tin moi/i }).click();
await expect(page).toHaveURL(/\/listings\/new/);
await page.getByRole('link', { name: /Đăng tin mi/i }).click();
await expect(page).toHaveURL(/\/my-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 expect(page.getByText('Xem phân tích chi tiết')).toBeVisible({ timeout: 10000 });
await page.getByText('Xem phan tich chi tiet').click();
await page.getByText('Xem phân tích chi tiết').click();
await expect(page).toHaveURL(/\/analytics/);
});
test('handles API failures gracefully', async ({ page }) => {
await page.route('**/analytics/market-report**', (route) =>
await page.route('**/api/v1/analytics/market-report**', (route) =>
route.fulfill({ status: 500, body: 'Error' }),
);
await page.route('**/analytics/heatmap**', (route) =>
await page.route('**/api/v1/analytics/heatmap**', (route) =>
route.fulfill({ status: 500, body: 'Error' }),
);
await page.route('**/listings**', (route) =>
await page.route('**/api/v1/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();
await expect(page.getByRole('heading', { name: 'Bng điều khin' })).toBeVisible();
});
});

View File

@@ -4,8 +4,8 @@ test.describe('Homepage', () => {
test('loads and displays hero content', async ({ page }) => {
await page.goto('/');
// The hero section renders "Find your perfect property" per i18n
await expect(page.locator('h1').first()).toBeVisible();
await expect(page.locator('main')).toBeVisible();
await expect(page.getByText(/GGI HCM|Top biến động giá|Khu vực xu hướng/i).first()).toBeVisible();
});
test('has correct page title', async ({ page }) => {
@@ -24,7 +24,9 @@ test.describe('Homepage', () => {
text.includes('mapbox') ||
text.includes('NEXT_PUBLIC_MAPBOX_TOKEN') ||
text.includes('hydration') ||
text.includes('Content Security Policy')
text.includes('Content Security Policy') ||
text.includes('401') ||
text.includes('Unauthorized')
) {
return;
}
@@ -45,7 +47,6 @@ test.describe('Homepage', () => {
const main = page.locator('main');
await expect(main).toBeVisible();
const h1 = page.locator('h1');
await expect(h1).toBeVisible();
await expect(page.getByText(/GGI HCM|Top biến động giá|Khu vực xu hướng/i).first()).toBeVisible();
});
});

View File

@@ -1,193 +1,123 @@
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' },
};
const seededListingId = 'seed-listing-001';
const listingPath = `/listings/${seededListingId}`;
const listingTitle = /Căn hộ Vinhomes Central Park|Vinhomes Central Park/i;
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 page.goto(listingPath);
await expect(page.getByRole('heading', { name: 'Căn hộ cao cấp Quận 1' })).toBeVisible({
await expect(page.getByRole('heading', { name: listingTitle })).toBeVisible({
timeout: 10000,
});
await expect(page.getByText(/5\.0 tỷ/)).toBeVisible();
await expect(page.getByText('VND')).toBeVisible();
await expect(page.getByText('8.500.000.000 đ').first()).toBeVisible();
});
test('displays breadcrumb navigation', async ({ page }) => {
await page.goto('/listings/listing-1');
await page.goto(listingPath);
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();
await expect(page.getByText(listingTitle).first()).toBeVisible({ timeout: 10000 });
await expect(page.locator('#main-content').getByRole('link', { name: /Trang chủ|Trang chu/i })).toBeVisible();
await expect(page.locator('#main-content').getByRole('link', { name: /Tìm kiếm|Tim kiem/i })).toBeVisible();
});
test('shows property badges (transaction type and property type)', async ({ page }) => {
await page.goto('/listings/listing-1');
test('shows property badges', async ({ page }) => {
await page.goto(listingPath);
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();
await expect(page.getByText(listingTitle).first()).toBeVisible({ timeout: 10000 });
await expect(page.getByText(/Bán|Sale/i).first()).toBeVisible();
await expect(page.getByText(/Căn hộ|Apartment/i).first()).toBeVisible();
});
test('displays address information', async ({ page }) => {
await page.goto('/listings/listing-1');
await page.goto(listingPath);
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();
await expect(page.getByText(listingTitle).first()).toBeVisible({ timeout: 10000 });
await expect(page.getByText(/208 Nguyễn Hữu Cảnh/i)).toBeVisible();
await expect(page.getByText(/Phường 22/i)).toBeVisible();
await expect(page.getByText(/Bình Thạnh/i)).toBeVisible();
});
test('shows quick stats bar', async ({ page }) => {
await page.goto('/listings/listing-1');
await page.goto(listingPath);
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();
await expect(page.getByText(listingTitle).first()).toBeVisible({ timeout: 10000 });
await expect(page.getByText('108 m²').first()).toBeVisible();
await expect(page.getByText(/Diện tích|Dien tich/i).first()).toBeVisible();
await expect(page.getByText(/Phòng ngủ|Phong ngu/i).first()).toBeVisible();
await expect(page.getByText(/Phòng tắm|Phong tam/i).first()).toBeVisible();
});
test('displays description section', async ({ page }) => {
await page.goto('/listings/listing-1');
await page.goto(listingPath);
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();
await expect(page.getByText(listingTitle).first()).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('heading', { name: /Mô tả|Mo ta/i })).toBeVisible();
await expect(page.locator('#main-content').getByText(/Căn hộ 3 phòng ngủ tại/i).first()).toBeVisible();
});
test('shows detailed property info grid', async ({ page }) => {
await page.goto('/listings/listing-1');
await page.goto(listingPath);
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();
await expect(page.getByText(listingTitle).first()).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('heading', { name: /Thông tin chi tiết|Thong tin chi tiet/i })).toBeVisible();
await expect(page.locator('#main-content').getByText(/Loại BĐS|Loai BDS|Loại bất động sản/i).first()).toBeVisible();
await expect(page.getByText(/SO_HONG|Sổ hồng|So hong/i).first()).toBeVisible();
await expect(page.getByText(/Vinhomes Central Park/i).first()).toBeVisible();
});
test('displays amenities', async ({ page }) => {
await page.goto('/listings/listing-1');
await page.goto(listingPath);
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();
await expect(page.getByText(listingTitle).first()).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('heading', { name: /Tiện ích|Tien ich/i })).toBeVisible();
await expect(page.getByText(/hồ bơi/i)).toBeVisible();
await expect(page.getByText(/gym/i)).toBeVisible();
});
test('shows seller contact card', async ({ page }) => {
await page.goto('/listings/listing-1');
await page.goto(listingPath);
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();
await expect(page.getByText(listingTitle).first()).toBeVisible({ timeout: 10000 });
await expect(page.getByText(/Liên hệ người đăng|Liên hệ|Lien he/i).first()).toBeVisible();
await expect(page.getByRole('button', { name: /Gọi ngay|Goi ngay/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Nhắn tin|Nhan tin/i })).toBeVisible();
});
test('shows agent info when available', async ({ page }) => {
await page.goto('/listings/listing-1');
await page.goto(listingPath);
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();
await expect(page.getByText(listingTitle).first()).toBeVisible({ timeout: 10000 });
await expect(page.getByText(/Môi giới|Moi gioi|Hoa hồng|Hoa hong/i).first()).toBeVisible();
await expect(page.getByText(/2%|2\.0%/)).toBeVisible();
});
test('displays listing statistics', async ({ page }) => {
await page.goto('/listings/listing-1');
await page.goto(listingPath);
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();
await expect(page.getByText(listingTitle).first()).toBeVisible({ timeout: 10000 });
await expect(page.getByText(/Lượt xem|Luot xem/i)).toBeVisible();
await expect(page.getByText(/Lượt lưu|Luot luu/i)).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();
await expect(page.getByRole('heading', { name: /Không tìm thấy trang|not found/i })).toBeVisible({ timeout: 10000 });
});
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),
});
});
test('renders page after server fetch', async ({ page }) => {
await page.goto(listingPath);
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 });
await expect(page.getByRole('heading', { name: listingTitle })).toBeVisible({ timeout: 10000 });
});
test('breadcrumb navigates to search page', async ({ page }) => {
await page.goto('/listings/listing-1');
await page.goto(listingPath);
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.getByText(listingTitle).first()).toBeVisible({ timeout: 10000 });
await page.locator('#main-content').getByRole('link', { name: /Tìm kiếm|Tim kiem/i }).click();
await expect(page).toHaveURL(/\/search/);
});
});

View File

@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { mockAuthenticatedUser } from './support/auth';
/**
* E2E coverage for the listing inquiry modal (TEC-2751 / TEC-2738.10).
@@ -9,71 +10,16 @@ import { test, expect } from '@playwright/test';
* profile fetch (to make the user look authenticated) and the inquiry POST.
*/
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.',
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'],
media: [{ id: 'm1', url: '/placeholder.jpg', type: 'IMAGE', order: 0 }],
},
seller: { id: 's1', fullName: 'Nguyen Van A', phone: '0912345678' },
agent: { id: 'a1', agency: 'GoodGo Realty', licenseNumber: 'AGT-001' },
};
const mockProfile = {
id: 'user-1',
email: 'buyer@example.com',
fullName: 'Buyer Test',
phone: '0911222333',
role: 'USER',
};
const seededListingId = 'seed-listing-001';
const seededListingTitle = /Căn hộ Vinhomes Central Park|Vinhomes Central Park/i;
test.describe('Listing inquiry modal', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/listings/listing-1', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockListing),
}),
);
});
test('opens the inquiry modal when clicking "Nhắn tin"', async ({ page }) => {
await page.goto('/listings/listing-1');
await page.goto(`/listings/${seededListingId}`);
await expect(
page.getByRole('heading', { name: 'Căn hộ cao cấp Quận 1' }),
).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('heading', { name: seededListingTitle })).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /Nhan tin/i }).click();
await page.getByRole('button', { name: /Nhắn tin|Nhan tin/i }).click();
await expect(
page.getByRole('heading', { name: /Nhắn tin cho người bán/ }),
@@ -82,13 +28,17 @@ test.describe('Listing inquiry modal', () => {
await expect(page.getByLabel(/Số điện thoại/)).toBeVisible();
});
test('shows validation errors when fields are missing or invalid', async ({ page }) => {
await page.goto('/listings/listing-1');
await page.getByRole('button', { name: /Nhan tin/i }).click();
test('shows validation errors when fields are missing or invalid', async ({ page, context, baseURL }) => {
await mockAuthenticatedUser(page, context, baseURL, { role: 'BUYER' });
// Submit empty form — zod should flag both fields.
await page.goto(`/listings/${seededListingId}`);
await page.getByRole('button', { name: /Nhắn tin|Nhan tin/i }).click();
// Native required validation keeps the modal open before zod validation runs.
await page.getByRole('button', { name: 'Gửi tin nhắn' }).click();
await expect(page.getByText('Vui lòng nhập nội dung tin nhắn')).toBeVisible();
await expect(
page.getByRole('heading', { name: /Nhắn tin cho người bán/ }),
).toBeVisible();
// Provide message but an obviously-invalid phone.
await page.getByLabel(/Nội dung tin nhắn/).fill('Tôi quan tâm tin đăng này.');
@@ -102,27 +52,12 @@ test.describe('Listing inquiry modal', () => {
test('submits the inquiry and calls POST /api/v1/inquiries (201)', async ({
page,
context,
baseURL,
}) => {
// Mark the user as authenticated for the client-side check in auth-store.
await context.addCookies([
{
name: 'goodgo_authenticated',
value: '1',
url: 'http://localhost:3000',
},
]);
// Stub the profile load so useAuthStore.isAuthenticated flips to true.
await page.route('**/auth/me', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockProfile),
}),
);
await mockAuthenticatedUser(page, context, baseURL, { role: 'BUYER' });
let inquiryRequestBody: Record<string, unknown> | null = null;
await page.route('**/inquiries', async (route) => {
await page.route('**/api/v1/inquiries', async (route) => {
if (route.request().method() !== 'POST') {
return route.fallback();
}
@@ -132,11 +67,11 @@ test.describe('Listing inquiry modal', () => {
contentType: 'application/json',
body: JSON.stringify({
id: 'inq-1',
listingId: 'listing-1',
listingTitle: mockListing.property.title,
userId: mockProfile.id,
userName: mockProfile.fullName,
userPhone: mockProfile.phone,
listingId: seededListingId,
listingTitle: 'Căn hộ Vinhomes Central Park 3PN view sông Sài Gòn',
userId: 'e2e-buyer-user',
userName: 'E2E BUYER',
userPhone: '+84900000002',
message: 'Tôi quan tâm tin đăng này.',
phone: '0911222333',
isRead: false,
@@ -145,8 +80,8 @@ test.describe('Listing inquiry modal', () => {
});
});
await page.goto('/listings/listing-1');
await page.getByRole('button', { name: /Nhan tin/i }).click();
await page.goto(`/listings/${seededListingId}`);
await page.getByRole('button', { name: /Nhắn tin|Nhan tin/i }).click();
await page.getByLabel(/Nội dung tin nhắn/).fill('Tôi quan tâm tin đăng này.');
// Phone pre-fills from the mocked profile; overwrite to ensure stability.
@@ -160,7 +95,7 @@ test.describe('Listing inquiry modal', () => {
]);
expect(request.postDataJSON()).toMatchObject({
listingId: 'listing-1',
listingId: seededListingId,
message: 'Tôi quan tâm tin đăng này.',
phone: '0911222333',
});
@@ -177,8 +112,8 @@ test.describe('Listing inquiry modal', () => {
});
test('redirects anonymous users to /login on submit', async ({ page }) => {
await page.goto('/listings/listing-1');
await page.getByRole('button', { name: /Nhan tin/i }).click();
await page.goto(`/listings/${seededListingId}`);
await page.getByRole('button', { name: /Nhắn tin|Nhan tin/i }).click();
await page.getByLabel(/Nội dung tin nhắn/).fill('Tôi quan tâm tin đăng này.');
await page.getByLabel(/Số điện thoại/).fill('0911222333');

View File

@@ -4,7 +4,8 @@ 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();
await expect(page.getByRole('main')).toBeVisible();
await expect(page.getByText(/GGI HCM|Top biến động giá|Khu vực xu hướng/i).first()).toBeVisible();
// Header navigation should have links
const nav = page.locator('header nav, header');
await expect(nav.first()).toBeVisible();

View File

@@ -5,16 +5,16 @@ test.describe('Responsive Design', () => {
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();
await expect(page.getByRole('main')).toBeVisible();
await expect(page.getByText(/GGI HCM|Top biến động giá|Khu vực xu hướng/i).first()).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();
await expect(page.getByRole('main')).toBeVisible();
await expect(page.getByText(/GGI HCM|Top biến động giá|Khu vực xu hướng/i).first()).toBeVisible();
});
test('login page is usable on mobile', async ({ page }) => {
@@ -31,7 +31,7 @@ test.describe('Responsive Design', () => {
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.getByRole('heading', { name: 'Đăng ký' })).toBeVisible();
await expect(page.getByLabel('Họ và tên')).toBeVisible();
await expect(page.getByRole('button', { name: 'Đăng ký' })).toBeVisible();
});

View File

@@ -125,10 +125,9 @@ test.describe('Search 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',
/.*/,
'aria-pressed',
'true',
);
});

View File

@@ -16,9 +16,10 @@ import { test, expect } from '@playwright/test';
test('@smoke homepage loads', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/.+/);
// Search bar or hero section must be visible
const searchInput = page.getByRole('searchbox').or(page.getByPlaceholder(/tìm kiếm|search/i));
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
await expect(page.locator('main')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/GGI HCM|Top biến động giá|Khu vực xu hướng/i).first()).toBeVisible({
timeout: 10_000,
});
});
// ── Auth pages ────────────────────────────────────────────────────────────────

59
e2e/web/support/auth.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { BrowserContext, Page } from '@playwright/test';
type E2ERole = 'ADMIN' | 'AGENT' | 'SELLER' | 'BUYER' | 'USER';
interface MockUserOptions {
role?: E2ERole;
}
export async function mockAuthenticatedUser(
page: Page,
context: BrowserContext,
baseURL?: string,
options: MockUserOptions = {},
) {
const role = options.role ?? 'AGENT';
const cookieUrl = baseURL ?? 'http://localhost:3000';
await context.addCookies([
{
name: 'goodgo_authenticated',
value: '1',
url: cookieUrl,
},
]);
await page.route('**/auth/profile', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: `e2e-${role.toLowerCase()}-user`,
email: `${role.toLowerCase()}@e2e.goodgo.test`,
phone: '+84900000002',
fullName: `E2E ${role}`,
avatarUrl: null,
role,
kycStatus: 'VERIFIED',
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
}),
}),
);
await page.route('**/auth/refresh', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ message: 'refreshed' }),
}),
);
await page.route('**/notifications/unread-count', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ count: 0 }),
}),
);
}

View File

@@ -65,9 +65,10 @@ test.describe('@smoke Home dashboard — ticker-style', () => {
// Trang có tiêu đề hợp lệ
await expect(page).toHaveTitle(/GoodGo/i);
// Heading H1 hoặc ticker bar phải render
// Market dashboard shell must render; ticker is hidden when seed data has no price movers.
const heroOrTicker = page
.locator('h1')
.locator('main')
.or(page.getByText(/GGI HCM|Top biến động giá|Khu vực xu hướng/i))
.or(page.locator('[data-testid="ticker"]'))
.or(page.locator('[class*="ticker"]'));
await expect(heroOrTicker.first()).toBeVisible({ timeout: 15_000 });

View File

@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { mockAuthenticatedUser } from './support/auth';
const mockValuationResult = {
id: 'val-e2e-1',
@@ -40,17 +41,10 @@ const mockValuationResult = {
const mockHistory = { data: [], total: 0, page: 1, totalPages: 1, limit: 10 };
async function setupMocks(page: import('@playwright/test').Page) {
await page.route('**/auth/me', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'u1', email: 'e2e@test.vn', fullName: 'E2E User', role: 'USER' }),
}),
);
await page.route('**/analytics/valuation/history**', (route) =>
await page.route('**/api/v1/analytics/valuation/user-history**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockHistory) }),
);
await page.route('**/analytics/valuation', (route) => {
await page.route('**/api/v1/analytics/valuation', (route) => {
if (route.request().method() === 'POST') {
return route.fulfill({
status: 200,
@@ -63,7 +57,8 @@ async function setupMocks(page: import('@playwright/test').Page) {
}
test.describe('AVM v2 Valuation Page', () => {
test('submit form -> render result card with confidence + price range', async ({ page }) => {
test('submit form -> render result card with confidence + price range', async ({ page, context, baseURL }) => {
await mockAuthenticatedUser(page, context, baseURL, { role: 'AGENT' });
await setupMocks(page);
await page.goto('/vi/dashboard/valuation');
@@ -75,23 +70,17 @@ test.describe('AVM v2 Valuation Page', () => {
const results = page.locator('#valuation-results');
await expect(results).toBeVisible();
await expect(results).toContainText('5.500.000.000');
await expect(results).toContainText('5.5 tỷ VNĐ');
await expect(results).toContainText('Độ tin cậy cao');
await expect(results).toContainText('avm-v2.0');
await expect(results).toContainText('Khoảng giá');
});
test('renders rate-limit error state on HTTP 429', async ({ page }) => {
await page.route('**/auth/me', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'u1', email: 'e2e@test.vn', fullName: 'E2E User', role: 'USER' }),
}),
);
await page.route('**/analytics/valuation/history**', (route) =>
test('renders rate-limit error state on HTTP 429', async ({ page, context, baseURL }) => {
await mockAuthenticatedUser(page, context, baseURL, { role: 'AGENT' });
await page.route('**/api/v1/analytics/valuation/user-history**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockHistory) }),
);
await page.route('**/analytics/valuation', (route) => {
await page.route('**/api/v1/analytics/valuation', (route) => {
if (route.request().method() === 'POST') {
return route.fulfill({
status: 429,
@@ -113,7 +102,8 @@ test.describe('AVM v2 Valuation Page', () => {
await expect(alert).toContainText('Quá nhiều yêu cầu');
});
test('export PDF button is visible after a successful valuation', async ({ page }) => {
test('export PDF button is visible after a successful valuation', async ({ page, context, baseURL }) => {
await mockAuthenticatedUser(page, context, baseURL, { role: 'AGENT' });
await setupMocks(page);
await page.goto('/vi/dashboard/valuation');