From 58b0e6ba12acc32c73105bf4e1fd28d3bc8a7814 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sun, 19 Apr 2026 06:31:50 +0700 Subject: [PATCH] feat(web): typed error states for AVM v2 valuation page (cherry-pick of b6a5a2c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Map API 429/402/503 errors to Vietnamese banners (rate-limit, quota-exhausted, model-unavailable) via getValuationErrorMessage helper in dashboard/valuation/page.tsx. - Error banner now carries role="alert" + data-testid="valuation-error" for a11y and Playwright test targeting. - Add e2e/web/valuation.spec.ts covering happy-path render, rate-limit banner, and PDF export button visibility. Partial cherry-pick of TEC-2736 — skipped the sibling commit 4ee0129 (image upload progress + AVM v2 form fields) because its v2 schema additions (distanceToHospitalKm, floodZoneRisk, hasElevator, ...) are not yet modelled in master's valuation-api.ts Zod schema. Parking on the task/tec-2725 branch for later. Also fix 3 DI regressions from earlier cherry-picks: the branches were authored before the mass type-only import cleanup, so they brought back `type LoggerService` (analytics) and `type EventBus` (auth) on DI constructor params. Removed the `type` modifier so emitDecoratorMetadata sees runtime references. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../valuation-explanation.handler.ts | 2 +- .../verify-email-change.handler.ts | 2 +- .../verify-phone-change.handler.ts | 2 +- .../(dashboard)/dashboard/valuation/page.tsx | 51 ++++++- e2e/web/valuation.spec.ts | 129 ++++++++++++++++++ 5 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 e2e/web/valuation.spec.ts diff --git a/apps/api/src/modules/analytics/application/queries/valuation-explanation/valuation-explanation.handler.ts b/apps/api/src/modules/analytics/application/queries/valuation-explanation/valuation-explanation.handler.ts index 714a827..ef02ffa 100644 --- a/apps/api/src/modules/analytics/application/queries/valuation-explanation/valuation-explanation.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/valuation-explanation/valuation-explanation.handler.ts @@ -6,7 +6,7 @@ import { CacheTTL, DomainException, NotFoundException, - type LoggerService, + LoggerService, } from '@modules/shared'; import { VALUATION_REPOSITORY, diff --git a/apps/api/src/modules/auth/application/commands/verify-email-change/verify-email-change.handler.ts b/apps/api/src/modules/auth/application/commands/verify-email-change/verify-email-change.handler.ts index a20ab99..ffc27d2 100644 --- a/apps/api/src/modules/auth/application/commands/verify-email-change/verify-email-change.handler.ts +++ b/apps/api/src/modules/auth/application/commands/verify-email-change/verify-email-change.handler.ts @@ -1,5 +1,5 @@ import { Inject, InternalServerErrorException } from '@nestjs/common'; -import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { CachePrefix, CacheService, diff --git a/apps/api/src/modules/auth/application/commands/verify-phone-change/verify-phone-change.handler.ts b/apps/api/src/modules/auth/application/commands/verify-phone-change/verify-phone-change.handler.ts index 1017271..17ed115 100644 --- a/apps/api/src/modules/auth/application/commands/verify-phone-change/verify-phone-change.handler.ts +++ b/apps/api/src/modules/auth/application/commands/verify-phone-change/verify-phone-change.handler.ts @@ -1,5 +1,5 @@ import { Inject, InternalServerErrorException } from '@nestjs/common'; -import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { CachePrefix, CacheService, diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx index c2cb097..4712031 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx @@ -12,6 +12,7 @@ import { ValuationForm } from '@/components/valuation/valuation-form'; import { ValuationHistory } from '@/components/valuation/valuation-history'; import { ValuationResults } from '@/components/valuation/valuation-results'; import { ValueDriversChart } from '@/components/valuation/value-drivers-chart'; +import { ApiError } from '@/lib/api-client'; import { useAvmV2Flag } from '@/lib/hooks/use-avm-v2-flag'; import { useValuationPredict, @@ -20,6 +21,38 @@ import { } from '@/lib/hooks/use-valuation'; import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api'; +function getValuationErrorMessage(error: unknown): { title: string; detail: string } { + if (error instanceof ApiError) { + if (error.status === 429) { + return { + title: 'Quá nhiều yêu cầu', + detail: 'Bạn đã gửi quá nhiều yêu cầu định giá. Vui lòng đợi một lát rồi thử lại.', + }; + } + if (error.status === 402 || /quota|subscription/i.test(error.message)) { + return { + title: 'Đã hết hạn mức', + detail: + 'Gói đăng ký hiện tại đã hết lượt định giá AI. Hãy nâng cấp hoặc thử lại vào chu kỳ sau.', + }; + } + if (error.status === 503 || /model|unavailable/i.test(error.message)) { + return { + title: 'Dịch vụ AI tạm thời không khả dụng', + detail: 'Mô hình định giá đang bận hoặc bảo trì. Vui lòng thử lại sau vài phút.', + }; + } + return { + title: 'Không thể định giá', + detail: error.message || 'Đã xảy ra lỗi không xác định. Vui lòng thử lại sau.', + }; + } + return { + title: 'Không thể định giá', + detail: 'Vui lòng kiểm tra kết nối mạng và thử lại.', + }; +} + const ValuationHistoryChart = dynamic( () => import('@/components/valuation/valuation-history-chart').then( @@ -131,11 +164,19 @@ export default function ValuationPage() { isLoading={predictMutation.isPending} /> - {predictMutation.isError && ( -
- Không thể định giá. Vui lòng thử lại sau. -
- )} + {predictMutation.isError && (() => { + const { title, detail } = getValuationErrorMessage(predictMutation.error); + return ( +
+

{title}

+

{detail}

+
+ ); + })()} {currentResult && ( <> diff --git a/e2e/web/valuation.spec.ts b/e2e/web/valuation.spec.ts new file mode 100644 index 0000000..0dfb4c1 --- /dev/null +++ b/e2e/web/valuation.spec.ts @@ -0,0 +1,129 @@ +import { test, expect } from '@playwright/test'; + +const mockValuationResult = { + id: 'val-e2e-1', + propertyType: 'APARTMENT', + area: 75, + district: 'Quận 1', + city: 'Ho Chi Minh', + estimatedPriceVND: 5_500_000_000, + priceRangeLow: 5_100_000_000, + priceRangeHigh: 5_900_000_000, + pricePerM2: 73_333_333, + confidence: 0.82, + modelVersion: 'avm-v2.0', + priceDrivers: [ + { feature: 'area_m2', impact: 24.5, direction: 'positive' as const }, + { feature: 'distance_to_cbd_km', impact: 12.3, direction: 'negative' as const }, + ], + comparables: [ + { + id: 'c1', + listingId: 'l1', + propertyType: 'APARTMENT', + area: 72, + district: 'Quận 1', + pricePerM2: 74_500_000, + priceVnd: 5_364_000_000, + distanceKm: 0.8, + publishedAt: '2026-03-12T00:00:00Z', + }, + ], + modelPredictions: [ + { modelName: 'xgboost', weight: 0.6, predictedPriceVnd: 5_500_000_000, predictedPricePerM2Vnd: 73_333_333 }, + { modelName: 'lightgbm', weight: 0.4, predictedPriceVnd: 5_480_000_000, predictedPricePerM2Vnd: 73_066_666 }, + ], + ensembleMethod: 'weighted_average', + createdAt: '2026-04-18T00:00:00Z', +}; + +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) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockHistory) }), + ); + await page.route('**/analytics/valuation', (route) => { + if (route.request().method() === 'POST') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockValuationResult), + }); + } + return route.continue(); + }); +} + +test.describe('AVM v2 Valuation Page', () => { + test('submit form -> render result card with confidence + price range', async ({ page }) => { + await setupMocks(page); + await page.goto('/vi/dashboard/valuation'); + + await page.locator('#propertyType').selectOption('APARTMENT'); + await page.locator('#district').fill('Quận 1'); + await page.locator('#area').fill('75'); + + await page.getByRole('button', { name: /Định giá ngay/i }).click(); + + const results = page.locator('#valuation-results'); + await expect(results).toBeVisible(); + await expect(results).toContainText('5.500.000.000'); + await expect(results).toContainText('Độ tin cậy cao'); + await expect(results).toContainText('avm-v2.0'); + }); + + 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) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockHistory) }), + ); + await page.route('**/analytics/valuation', (route) => { + if (route.request().method() === 'POST') { + return route.fulfill({ + status: 429, + contentType: 'application/json', + body: JSON.stringify({ message: 'Too many requests' }), + }); + } + return route.continue(); + }); + + await page.goto('/vi/dashboard/valuation'); + await page.locator('#propertyType').selectOption('APARTMENT'); + await page.locator('#district').fill('Quận 1'); + await page.locator('#area').fill('75'); + await page.getByRole('button', { name: /Định giá ngay/i }).click(); + + const alert = page.getByTestId('valuation-error'); + await expect(alert).toBeVisible(); + await expect(alert).toContainText('Quá nhiều yêu cầu'); + }); + + test('export PDF button is visible after a successful valuation', async ({ page }) => { + await setupMocks(page); + await page.goto('/vi/dashboard/valuation'); + + await page.locator('#propertyType').selectOption('APARTMENT'); + await page.locator('#district').fill('Quận 1'); + await page.locator('#area').fill('75'); + await page.getByRole('button', { name: /Định giá ngay/i }).click(); + + await expect(page.locator('#valuation-results')).toBeVisible(); + // Export PDF button (uses Download icon + label) + await expect(page.getByRole('button', { name: /Xuất PDF|Export PDF|Tải PDF/i })).toBeVisible(); + }); +});