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>
189 lines
5.6 KiB
TypeScript
189 lines
5.6 KiB
TypeScript
/**
|
|
* Agents Page E2E Tests
|
|
*
|
|
* Tests the agent profile page at /agents/[id].
|
|
* The app does not have a public agent listing page, only individual agent profiles.
|
|
* Tests use route mocking to avoid API dependency.
|
|
*/
|
|
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,
|
|
};
|
|
|
|
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 },
|
|
},
|
|
};
|
|
|
|
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('renders agent name and verified badge', async ({ page }) => {
|
|
await page.goto('/agents/agent-1');
|
|
|
|
await expect(page.getByText('Nguyễn Văn Minh')).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test('shows agent agency and contact info', async ({ page }) => {
|
|
await page.goto('/agents/agent-1');
|
|
|
|
await expect(page.getByText('Nguyễn Văn Minh')).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.getByText(/GoodGo Realty/)).toBeVisible();
|
|
});
|
|
|
|
test('shows active listings section', async ({ page }) => {
|
|
await page.goto('/agents/agent-1');
|
|
|
|
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();
|
|
});
|
|
|
|
test('has breadcrumb back to homepage', async ({ page }) => {
|
|
await page.goto('/agents/agent-1');
|
|
|
|
await expect(page.getByText('Nguyễn Văn Minh')).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.getByRole('link', { name: /Trang chủ/i })).toBeVisible();
|
|
});
|
|
|
|
test('renders without critical console errors', async ({ page }) => {
|
|
const criticalErrors: string[] = [];
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') {
|
|
const text = msg.text();
|
|
if (
|
|
text.includes('mapbox') ||
|
|
text.includes('NEXT_PUBLIC') ||
|
|
text.includes('net::ERR') ||
|
|
text.includes('Failed to load resource')
|
|
) {
|
|
return;
|
|
}
|
|
criticalErrors.push(text);
|
|
}
|
|
});
|
|
|
|
await page.goto('/agents/agent-1');
|
|
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) {
|
|
throw new Error(`Agent page returned ${status} for unknown ID (expected 404 or redirect)`);
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Agent Profile — Responsive', () => {
|
|
const viewports = [
|
|
{ label: '375px (mobile)', width: 375, height: 667 },
|
|
{ label: '768px (tablet)', width: 768, height: 1024 },
|
|
{ label: '1280px (laptop)', width: 1280, height: 800 },
|
|
{ label: '1920px (desktop)', width: 1920, height: 1080 },
|
|
];
|
|
|
|
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),
|
|
}),
|
|
);
|
|
|
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
await page.goto('/agents/agent-1');
|
|
|
|
await expect(page.getByText('Nguyễn Văn Minh')).toBeVisible({ timeout: 10_000 });
|
|
|
|
// No horizontal overflow (layout break indicator)
|
|
const bodyWidth = await page.evaluate(() => document.body.scrollWidth);
|
|
const viewportWidth = await page.evaluate(() => window.innerWidth);
|
|
expect(bodyWidth).toBeLessThanOrEqual(viewportWidth + 2); // 2px tolerance
|
|
});
|
|
}
|
|
});
|