Files
goodgo-platform/e2e/web/trading-floor-regression.spec.ts
Ho Ngoc Hai 3a9e44758c fix(web): unwrap CacheMetaInterceptor envelope + dev port migration + homepage diacritic
Several fixes discovered while smoke-testing the homepage under the new
port layout (web 3200 / api 3201) to avoid clashing with a sibling project:

- analytics-api: add `unwrap<T>()` helper for the `{ data, cacheMeta }`
  envelope the backend CacheMetaInterceptor appends to every
  `/analytics/*` response. Apply to all 9 analytics methods. Without this
  `data.activeCount` (etc.) were `undefined`, crashing KpiStrip with
  `TypeError: Cannot read properties of undefined (reading 'toLocaleString')`.
- public page: hard-coded `city = 'Ho Chi Minh'` returned 0 rows because
  the DB stores `'Hồ Chí Minh'` and the SQL filter is case-insensitive but
  not diacritic-insensitive. Use the accented spelling.
- use-analytics hooks: add `useAuthedAnalytics()` gate so unauthenticated
  visitors on public routes no longer fire 401s from analytics queries.
- next.config.js CSP: add localhost:3200/3201 (http + ws) to connect-src so
  the web origin can reach the relocated API. Without this fetches hit
  `TypeError: Failed to fetch` on login.
- .claude/launch.json + package.json: web → 3200, api → 3201 (was 3000/3001,
  conflicting with the sibling psyforge project also using 3000).
- Minor follow-ups from parallel QA work on this branch (analytics modules,
  notifications gateway, auth test fixtures, trending-areas handler + DTO
  + tests, a few E2E smoke specs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:54:44 +07:00

332 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* [TEC-3078] Visual/density regression — breakpoint 1920/1440/1280/1024/768px
* [TEC-3079] Console errors = 0 và network 4xx/5xx = 0
* [TEC-3080] Interaction: sort/filter bảng, ticker, chart render
*
* Goodgo Platform AI — sàn giao dịch refactor QA
* Chạy E2E với backend thật. Không stub/mock.
*
* npx playwright test --project=web e2e/web/trading-floor-regression.spec.ts
*/
import { test, expect } from '@playwright/test';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const BREAKPOINTS = [
{ name: '1920p', width: 1920, height: 1080 },
{ name: '1440p', width: 1440, height: 900 },
{ name: '1280p', width: 1280, height: 800 },
{ name: '1024p', width: 1024, height: 768 },
{ name: '768p', width: 768, height: 1024 },
] as const;
function attachNetworkMonitor(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();
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();
if (status >= 400 && (url.includes('/api/v1') || url.includes('localhost'))) {
networkErrors.push({ url, status });
}
});
return { consoleErrors, networkErrors };
}
// ---------------------------------------------------------------------------
// [TEC-3078] Breakpoint regression
// ---------------------------------------------------------------------------
test.describe('Breakpoint regression — sàn giao dịch', () => {
for (const bp of BREAKPOINTS) {
test(`/ home dashboard — ${bp.name} (${bp.width}×${bp.height})`, async ({ page }) => {
await page.setViewportSize({ width: bp.width, height: bp.height });
await page.goto('/');
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
// Không crash
await expect(
page.getByRole('heading', { name: /500|server error/i }),
).not.toBeVisible();
// Main content vẫn visible ở mọi breakpoint
await expect(page.locator('main').or(page.locator('#__next'))).toBeVisible();
// Không có horizontal overflow (scrollWidth > clientWidth = layout break)
const hasHorizontalOverflow = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth + 2;
});
expect(
hasHorizontalOverflow,
`Horizontal overflow tại ${bp.name} — kiểm tra CSS layout`,
).toBeFalsy();
});
test(`/listings board — ${bp.name} (${bp.width}×${bp.height})`, async ({ page }) => {
await page.setViewportSize({ width: bp.width, height: bp.height });
await page.goto('/listings');
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
await expect(
page.getByRole('heading', { name: /500|server error/i }),
).not.toBeVisible();
// Bảng hoặc card listings phải visible
const listingEl = page
.locator('table')
.or(page.locator('[data-testid="data-table"]'))
.or(page.locator('[class*="listing"]'))
.or(page.getByRole('grid'));
await expect(listingEl.first()).toBeVisible({ timeout: 15_000 });
});
}
});
// ---------------------------------------------------------------------------
// [TEC-3079] Console errors = 0 / Network 4xx/5xx = 0
// ---------------------------------------------------------------------------
test.describe('Zero console errors & zero 4xx/5xx — các route chính', () => {
const ROUTES = ['/', '/listings', '/login'] as const;
for (const route of ROUTES) {
test(`${route} — không có console error và không có API 5xx`, async ({ page }) => {
const { consoleErrors, networkErrors } = attachNetworkMonitor(page);
await page.goto(route);
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
expect(
consoleErrors,
`Console errors tại ${route}: ${consoleErrors.join(' | ')}`,
).toHaveLength(0);
const fiveXX = networkErrors.filter((e) => e.status >= 500);
expect(
fiveXX,
`Network 5xx tại ${route}: ${fiveXX.map((e) => `${e.status} ${e.url}`).join(' | ')}`,
).toHaveLength(0);
});
}
test('/listings — không có API 4xx (unauthorized ngoại lệ cho /admin)', async ({ page }) => {
const { networkErrors } = attachNetworkMonitor(page);
await page.goto('/listings');
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
const unexpectedFourXX = networkErrors.filter(
(e) =>
e.status >= 400 &&
e.status < 500 &&
// Bỏ qua 401 cho các endpoint yêu cầu auth (bình thường khi chưa đăng nhập)
e.status !== 401 &&
// Bỏ qua preflight OPTIONS
!e.url.includes('OPTIONS'),
);
expect(
unexpectedFourXX,
`Unexpected 4xx tại /listings: ${unexpectedFourXX.map((e) => `${e.status} ${e.url}`).join(' | ')}`,
).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// [TEC-3080] Interaction: sort/filter bảng, DensityToggle, preview panel
// ---------------------------------------------------------------------------
test.describe('Interaction — Listings board (sort, filter, density, preview)', () => {
test('sort cột giá hoạt động (click header)', async ({ page }) => {
await page.goto('/listings');
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
// Tìm header cột giá trong DataTable
const priceHeader = page
.getByRole('columnheader', { name: /giá|price/i })
.or(page.locator('th').filter({ hasText: /giá|price/i }));
if (await priceHeader.first().isVisible({ timeout: 5_000 }).catch(() => false)) {
await priceHeader.first().click();
// Sau click không crash
await expect(
page.getByRole('heading', { name: /500|server error/i }),
).not.toBeVisible();
// Table vẫn hiện
await expect(
page.locator('table').or(page.getByRole('grid')).first(),
).toBeVisible();
}
});
test('DensityToggle compact/comfortable chuyển đổi không crash', async ({ page }) => {
await page.goto('/listings');
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
const toggle = page
.locator('[data-testid="density-toggle"]')
.or(page.getByRole('button', { name: /compact|comfortable|density/i }))
.or(page.locator('button[class*="density"]'));
if (await toggle.first().isVisible({ timeout: 5_000 }).catch(() => false)) {
// Click 2 lần (compact → comfortable → compact)
await toggle.first().click();
await page.waitForTimeout(300);
await toggle.first().click();
await page.waitForTimeout(300);
await expect(
page.getByRole('heading', { name: /500|server error/i }),
).not.toBeVisible();
}
});
test('click row điều hướng đến listing detail (hoặc mở PreviewPanel)', async ({ page }) => {
await page.goto('/listings');
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
// Tìm row đầu tiên trong table
const firstRow = page
.locator('tbody tr')
.or(page.locator('[role="row"]').nth(1)) // nth(0) là header
.first();
if (await firstRow.isVisible({ timeout: 10_000 }).catch(() => false)) {
// Design hiện tại: click row → router.push('/listings/{id}') → navigate sang detail page
// Có thể trong tương lai sẽ mở PreviewPanel thay thế
await Promise.race([
firstRow.click({ timeout: 5_000 }),
page.waitForURL(/\/listings\/[\w-]+/, { timeout: 10_000 }).catch(() => {}),
]).catch(() => {});
// Sau khi click: hoặc đang ở detail page, hoặc panel mở, hoặc vẫn ở /listings
// Không quan trọng outcome — chỉ cần không crash 500
await page.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => {});
// Kiểm tra side panel nếu có (design tương lai)
const panel = page
.locator('[data-testid="listing-preview-panel"]')
.or(page.locator('[class*="preview-panel"]'))
.or(page.locator('[class*="PreviewPanel"]'))
.or(page.locator('[role="dialog"]'));
const panelVisible = await panel.first().isVisible({ timeout: 2_000 }).catch(() => false);
if (panelVisible) {
await expect(panel.first()).toBeVisible();
}
// Không crash dù navigate hay panel
await expect(
page.getByRole('heading', { name: /500|server error/i }),
).not.toBeVisible();
}
});
});
// ---------------------------------------------------------------------------
// [TEC-3080] Interaction — Home dashboard ticker
// ---------------------------------------------------------------------------
test.describe('Interaction — Home dashboard ticker & market data', () => {
test('ticker hoặc market data section render đúng', async ({ page }) => {
const { consoleErrors } = attachNetworkMonitor(page);
await page.goto('/');
// Đợi ticker animation hoặc data load
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
await page.waitForTimeout(1_000); // cho animation chạy 1 giây
// Ticker hoặc market section phải visible
const ticker = page
.locator('[data-testid="ticker"]')
.or(page.locator('[class*="ticker"]'))
.or(page.locator('[class*="Ticker"]'))
.or(page.getByText(/giá tb|avg|m²|tỷ/i));
if (await ticker.first().isVisible({ timeout: 8_000 }).catch(() => false)) {
await expect(ticker.first()).toBeVisible();
}
// Không có error boundary dù ticker chưa render
await expect(
page.getByRole('heading', { name: /500|server error/i }),
).not.toBeVisible();
expect(
consoleErrors,
`Console errors tại /: ${consoleErrors.join(' | ')}`,
).toHaveLength(0);
});
test('navigation chính hoạt động từ home', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
// Click vào link Listings từ nav
const listingsLink = page
.getByRole('link', { name: /tin đăng|listings|bất động sản/i })
.first();
if (await listingsLink.isVisible({ timeout: 5_000 }).catch(() => false)) {
await listingsLink.click();
await page.waitForLoadState('domcontentloaded', { timeout: 10_000 });
await expect(page).toHaveURL(/\/listings/);
}
});
});
// ---------------------------------------------------------------------------
// [TEC-3080] Interaction — Listing detail inquiry modal
// ---------------------------------------------------------------------------
test.describe('Interaction — Listing detail (inquiry modal, breadcrumb)', () => {
test('breadcrumb và back navigation hoạt động', async ({ page }) => {
// Đi từ /listings → listing detail → back
await page.goto('/listings');
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
// Click vào listing đầu tiên
const listingLink = page
.locator('a[href*="/listings/"]')
.filter({ hasNot: page.locator('a[href="/listings"]') })
.first();
if (await listingLink.isVisible({ timeout: 10_000 }).catch(() => false)) {
await listingLink.click();
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {});
// Breadcrumb hoặc back button hiển thị
const breadcrumb = page
.locator('[aria-label="breadcrumb"]')
.or(page.locator('nav[class*="breadcrumb"]'))
.or(page.getByRole('link', { name: /trang chủ|home|quay lại/i }));
if (await breadcrumb.first().isVisible({ timeout: 3_000 }).catch(() => false)) {
await expect(breadcrumb.first()).toBeVisible();
}
// Không có 500
await expect(
page.getByRole('heading', { name: /500|server error/i }),
).not.toBeVisible();
}
});
});