366 lines
15 KiB
TypeScript
366 lines
15 KiB
TypeScript
/**
|
|
* [TEC-3077] Smoke test các route chính — sàn giao dịch Goodgo Platform AI
|
|
*
|
|
* Chạy E2E với backend thật (apps/api + Postgres + Redis).
|
|
* Không stub/mock bất kỳ API call nào.
|
|
*
|
|
* npx playwright test --project=web e2e/web/trading-floor-smoke.spec.ts
|
|
*/
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Thu thập console errors và network errors trong suốt test */
|
|
function attachErrorListeners(page: import('@playwright/test').Page) {
|
|
const consoleErrors: string[] = [];
|
|
const networkErrors: { url: string; status: number }[] = [];
|
|
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') {
|
|
const text = msg.text();
|
|
// Bỏ qua nhiễu từ Mapbox token và NEXT_PUBLIC vars chưa set trong test env
|
|
if (
|
|
text.includes('mapbox') ||
|
|
text.includes('NEXT_PUBLIC_MAPBOX') ||
|
|
text.includes('Failed to load resource') ||
|
|
text.includes('net::ERR')
|
|
) return;
|
|
consoleErrors.push(text);
|
|
}
|
|
});
|
|
|
|
page.on('response', (res) => {
|
|
const status = res.status();
|
|
const url = res.url();
|
|
// Chỉ bắt lỗi từ API nội bộ (bỏ qua CDN, fonts, static assets)
|
|
if (
|
|
status >= 400 &&
|
|
(url.includes('/api/v1') || url.includes('localhost'))
|
|
) {
|
|
networkErrors.push({ url, status });
|
|
}
|
|
});
|
|
|
|
return { consoleErrors, networkErrors };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Route: / — Home dashboard ticker-style (TEC-3058)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('@smoke Home dashboard — ticker-style', () => {
|
|
test('tải trang, ticker hiển thị và không có lỗi', async ({ page }) => {
|
|
const { consoleErrors, networkErrors } = attachErrorListeners(page);
|
|
|
|
await page.goto('/');
|
|
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
|
|
|
|
// Không crash với 500 error boundary
|
|
await expect(
|
|
page.getByRole('heading', { name: /500|server error|lỗi máy chủ/i }),
|
|
).not.toBeVisible();
|
|
|
|
// Trang có tiêu đề hợp lệ
|
|
await expect(page).toHaveTitle(/GoodGo/i);
|
|
|
|
// Market dashboard shell must render; ticker is hidden when seed data has no price movers.
|
|
const heroOrTicker = page
|
|
.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 });
|
|
|
|
expect(consoleErrors, `Console errors: ${consoleErrors.join(', ')}`).toHaveLength(0);
|
|
expect(
|
|
networkErrors.filter((e) => e.status >= 500),
|
|
`Network 5xx: ${networkErrors.map((e) => `${e.status} ${e.url}`).join(', ')}`,
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
test('market data section render (KPI cards hoặc market summary)', async ({ page }) => {
|
|
await page.goto('/');
|
|
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
|
|
|
|
// KPI card hoặc market summary phải có ít nhất 1 phần tử
|
|
const marketSection = page
|
|
.locator('[data-testid="kpi-card"]')
|
|
.or(page.locator('[class*="kpi"]'))
|
|
.or(page.getByText(/giá tb|avg price|tổng tin|market/i));
|
|
await expect(marketSection.first()).toBeVisible({ timeout: 15_000 });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Route: /listings — Listings board high-density (TEC-3059)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('@smoke Listings board — high-density', () => {
|
|
test('tải trang, DataTable render và không có 5xx', async ({ page }) => {
|
|
const { consoleErrors, networkErrors } = attachErrorListeners(page);
|
|
|
|
await page.goto('/listings');
|
|
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
|
|
|
|
// Không có error boundary
|
|
await expect(
|
|
page.getByRole('heading', { name: /500|server error/i }),
|
|
).not.toBeVisible();
|
|
|
|
// Bảng listings hoặc danh sách BĐS phải hiển thị
|
|
const listingContainer = page
|
|
.locator('table')
|
|
.or(page.locator('[data-testid="data-table"]'))
|
|
.or(page.locator('[class*="listings"]'))
|
|
.or(page.getByRole('grid'));
|
|
await expect(listingContainer.first()).toBeVisible({ timeout: 15_000 });
|
|
|
|
expect(consoleErrors, `Console errors: ${consoleErrors.join(', ')}`).toHaveLength(0);
|
|
expect(
|
|
networkErrors.filter((e) => e.status >= 500),
|
|
`Network 5xx: ${networkErrors.map((e) => `${e.status} ${e.url}`).join(', ')}`,
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
test('DensityToggle hiển thị và chuyển đổi được', async ({ page }) => {
|
|
await page.goto('/listings');
|
|
|
|
const densityToggle = page
|
|
.locator('[data-testid="density-toggle"]')
|
|
.or(page.getByRole('button', { name: /compact|comfortable|density/i }))
|
|
.or(page.locator('[class*="density"]'));
|
|
|
|
// Chờ trang load xong
|
|
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
|
|
|
|
if (await densityToggle.first().isVisible()) {
|
|
await densityToggle.first().click();
|
|
// Sau click vẫn không crash
|
|
await expect(
|
|
page.getByRole('heading', { name: /500|server error/i }),
|
|
).not.toBeVisible();
|
|
}
|
|
});
|
|
|
|
test('bộ lọc loại BĐS hoạt động', async ({ page }) => {
|
|
await page.goto('/listings');
|
|
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
|
|
|
|
// Tìm filter select cụ thể — dùng aria-label chính xác để tránh match combobox ngôn ngữ
|
|
const filterSelect = page.locator('select[aria-label="Loại BĐS"]')
|
|
.or(page.locator('select[aria-label*="loại"]'))
|
|
.or(page.locator('select').filter({ has: page.locator('option[value="APARTMENT"]') }));
|
|
|
|
if (await filterSelect.first().isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
// Chọn APARTMENT — selectOption triggers router.replace
|
|
// Nếu trang navigate (locale redirect), chấp nhận — test chỉ verify không crash 500
|
|
await filterSelect.first().selectOption('APARTMENT').catch(() => {
|
|
// router.replace có thể làm page reference stale — bình thường
|
|
});
|
|
|
|
// Đợi page ổn định sau navigation
|
|
await page.waitForLoadState('domcontentloaded', { timeout: 8_000 }).catch(() => {});
|
|
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {});
|
|
|
|
// Sau filter trang không crash — check bằng URL/title thay vì heading
|
|
const url = page.url();
|
|
const title = await page.title().catch(() => '');
|
|
// Trang không được là 500 error page
|
|
expect(title, `Sau filter /listings, page title là: ${title}`).not.toMatch(/500|server error/i);
|
|
expect(url, `URL sau filter: ${url}`).not.toMatch(/500|error/i);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Route: /listings/[id] — Listing detail trader-style (TEC-3060)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('@smoke Listing detail — trader-style', () => {
|
|
let firstListingId: string | null = null;
|
|
|
|
test.beforeAll(async ({ browser }) => {
|
|
// Lấy ID listing đầu tiên từ seed data thật
|
|
const ctx = await browser.newContext();
|
|
const page = await ctx.newPage();
|
|
|
|
// Intercept API response để lấy ID
|
|
let captured = false;
|
|
page.on('response', async (res) => {
|
|
if (captured) return;
|
|
if (res.url().includes('/api/v1/listings') && !res.url().includes('/api/v1/listings/')) {
|
|
try {
|
|
const body = await res.json();
|
|
const data = body?.data ?? body;
|
|
if (Array.isArray(data) && data.length > 0) {
|
|
firstListingId = data[0].id;
|
|
captured = true;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
});
|
|
|
|
await page.goto('/listings');
|
|
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
|
|
await ctx.close();
|
|
});
|
|
|
|
test('tải trang listing detail, hiển thị tiêu đề và giá', async ({ page }) => {
|
|
const { consoleErrors, networkErrors } = attachErrorListeners(page);
|
|
|
|
const url = firstListingId ? `/listings/${firstListingId}` : '/listings';
|
|
await page.goto(url);
|
|
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
|
|
|
|
// Nếu có listing ID thật
|
|
if (firstListingId) {
|
|
await expect(
|
|
page.getByRole('heading', { name: /500|server error/i }),
|
|
).not.toBeVisible();
|
|
|
|
// Giá BĐS phải hiện (VND) — định dạng có thể là "8.500.000.000 đ", "8.5 tỷ", "VNĐ", v.v.
|
|
const priceEl = page
|
|
.getByText(/tỷ|triệu|vnđ|vnd|\d[\d.]+\s*đ/i)
|
|
.or(page.locator('[class*="price"]'));
|
|
await expect(priceEl.first()).toBeVisible({ timeout: 15_000 });
|
|
}
|
|
|
|
expect(consoleErrors, `Console errors: ${consoleErrors.join(', ')}`).toHaveLength(0);
|
|
expect(
|
|
networkErrors.filter((e) => e.status >= 500),
|
|
`Network 5xx: ${networkErrors.map((e) => `${e.status} ${e.url}`).join(', ')}`,
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
test('404 cho listing ID không tồn tại (không crash 500)', async ({ page }) => {
|
|
const res = await page.goto('/listings/nonexistent-trading-floor-qa');
|
|
const status = res?.status();
|
|
if (status && status >= 500) {
|
|
throw new Error(`Listing detail trả về ${status} cho ID không tồn tại (mong đợi 404)`);
|
|
}
|
|
// Không nên có 500 error heading
|
|
await expect(
|
|
page.getByRole('heading', { name: /500|server error/i }),
|
|
).not.toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Route: /agents/[id] — Agent profile trader-style (TEC-3061)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('@smoke Agent profile — trader-style', () => {
|
|
test('agent profile với ID từ seed data, hiển thị đúng', async ({ page }) => {
|
|
const { consoleErrors, networkErrors } = attachErrorListeners(page);
|
|
|
|
// Dùng agent seed ID (từ pnpm db:seed — agent đầu tiên thường có slug hoặc ID cố định)
|
|
// Thử truy cập trang listing rồi click vào agent link
|
|
await page.goto('/listings');
|
|
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
|
|
|
|
// Tìm link agent đầu tiên trên trang
|
|
const agentLink = page
|
|
.locator('a[href*="/agents/"]')
|
|
.first();
|
|
|
|
if (await agentLink.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
const href = await agentLink.getAttribute('href');
|
|
await page.goto(href!);
|
|
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
|
|
|
|
// Quality score, tên agent, hoặc thông tin liên hệ phải hiển thị
|
|
const agentInfo = page
|
|
.locator('[data-testid="agent-quality-score"]')
|
|
.or(page.locator('[class*="quality"]'))
|
|
.or(page.getByText(/điểm chất lượng|quality score/i))
|
|
.or(page.getByRole('heading', { level: 1 }));
|
|
await expect(agentInfo.first()).toBeVisible({ timeout: 10_000 });
|
|
}
|
|
|
|
expect(consoleErrors, `Console errors: ${consoleErrors.join(', ')}`).toHaveLength(0);
|
|
expect(
|
|
networkErrors.filter((e) => e.status >= 500),
|
|
`Network 5xx: ${networkErrors.map((e) => `${e.status} ${e.url}`).join(', ')}`,
|
|
).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Route: /my-listings — Dashboard CRUD flow (TEC-3086 — route rename)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('@smoke Dashboard /my-listings — route rename (TEC-3086)', () => {
|
|
test('redirect đến login khi chưa đăng nhập (không crash 500)', async ({ page }) => {
|
|
const res = await page.goto('/my-listings');
|
|
await page.waitForLoadState('domcontentloaded', { timeout: 10_000 });
|
|
|
|
const finalUrl = page.url();
|
|
const isLoginRedirect = finalUrl.includes('/login') || finalUrl.includes('/auth');
|
|
const is403 = res?.status() === 403;
|
|
|
|
expect(
|
|
isLoginRedirect || is403,
|
|
`/my-listings phải redirect login hoặc 403. Nhận: ${res?.status()} tại ${finalUrl}`,
|
|
).toBeTruthy();
|
|
|
|
// Không có webpack parallel-pages error hay 500
|
|
await expect(
|
|
page.getByRole('heading', { name: /500|server error|parallel pages/i }),
|
|
).not.toBeVisible();
|
|
});
|
|
|
|
test('/my-listings/new redirect đến login khi chưa đăng nhập', async ({ page }) => {
|
|
await page.goto('/my-listings/new');
|
|
await page.waitForLoadState('domcontentloaded', { timeout: 10_000 });
|
|
|
|
const finalUrl = page.url();
|
|
const isLoginRedirect = finalUrl.includes('/login') || finalUrl.includes('/auth');
|
|
expect(isLoginRedirect, `Nhận URL: ${finalUrl}`).toBeTruthy();
|
|
});
|
|
|
|
test('public /listings vẫn hoạt động (không bị ảnh hưởng bởi route rename)', async ({ page }) => {
|
|
const { consoleErrors } = attachErrorListeners(page);
|
|
const res = await page.goto('/listings');
|
|
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
|
|
|
|
// Public listings phải trả 200 — không redirect, không 500
|
|
expect(res?.status(), `Public /listings trả ${res?.status()}`).toBeLessThan(400);
|
|
await expect(
|
|
page.getByRole('heading', { name: /500|server error/i }),
|
|
).not.toBeVisible();
|
|
|
|
expect(consoleErrors, `Console errors: ${consoleErrors.join(', ')}`).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Route: /admin — Admin board moderation/KYC (TEC-3062)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('@smoke Admin board — moderation & KYC', () => {
|
|
test('redirect đến login khi chưa đăng nhập (không crash)', async ({ page }) => {
|
|
const res = await page.goto('/admin');
|
|
await page.waitForLoadState('domcontentloaded', { timeout: 10_000 });
|
|
|
|
// Admin phải redirect đến login hoặc hiển thị 403 — không được 500
|
|
const finalUrl = page.url();
|
|
const isLoginRedirect = finalUrl.includes('/login') || finalUrl.includes('/auth');
|
|
const is403 = res?.status() === 403;
|
|
const is404 = res?.status() === 404;
|
|
|
|
expect(
|
|
isLoginRedirect || is403 || is404,
|
|
`Admin page với unauthenticated user phải redirect hoặc trả 403/404. Nhận: ${res?.status()} tại ${finalUrl}`,
|
|
).toBeTruthy();
|
|
|
|
// Không có 500 error heading
|
|
await expect(
|
|
page.getByRole('heading', { name: /500|server error/i }),
|
|
).not.toBeVisible();
|
|
});
|
|
});
|