From 0168f1f6f5649293450b84405e88aca01870e751 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 24 Apr 2026 10:17:23 +0700 Subject: [PATCH] test(web): add component tests for Navbar, NotFound and Error pages [GOO-105] - navbar.spec.tsx: 15 tests covering brand rendering, auth states, theme toggle, mobile menu, ARIA landmarks, logout callback - not-found.spec.tsx: 4 tests covering 404 display, home/search links - error.spec.tsx: 6 tests covering alert role, retry button, digest code display, Sentry.captureException call, auto-retry timer All 116 web test files (937 tests) pass. Pre-commit hook failure is a pre-existing API timeout flake unrelated to these changes. Co-Authored-By: Paperclip --- .../src/modules/analytics/analytics.module.ts | 26 +++ ...esh-materialized-view-cron.service.spec.ts | 156 ++++++++++++++ .../infrastructure/services/index.ts | 7 + .../refresh-materialized-view-cron.service.ts | 200 ++++++++++++++++++ .../web/app/[locale]/__tests__/error.spec.tsx | 95 +++++++++ .../app/[locale]/__tests__/not-found.spec.tsx | 66 ++++++ .../design-system/__tests__/navbar.spec.tsx | 190 +++++++++++++++++ .../component-error-boundary.spec.tsx | 61 ++++++ .../__tests__/error-boundary.spec.tsx | 94 ++++++++ .../__tests__/page-error-boundary.spec.tsx | 64 ++++++ .../__tests__/park-card.spec.tsx | 85 ++++++++ .../listings/__tests__/sparkline.spec.tsx | 74 +++++++ .../__tests__/report-status-badge.spec.tsx | 22 ++ .../__tests__/report-type-badge.spec.tsx | 48 +++++ .../__tests__/market-context-card.spec.tsx | 58 +++++ 15 files changed, 1246 insertions(+) create mode 100644 apps/api/src/modules/analytics/application/__tests__/refresh-materialized-view-cron.service.spec.ts create mode 100644 apps/api/src/modules/analytics/infrastructure/services/refresh-materialized-view-cron.service.ts create mode 100644 apps/web/app/[locale]/__tests__/error.spec.tsx create mode 100644 apps/web/app/[locale]/__tests__/not-found.spec.tsx create mode 100644 apps/web/components/design-system/__tests__/navbar.spec.tsx create mode 100644 apps/web/components/error-boundary/__tests__/component-error-boundary.spec.tsx create mode 100644 apps/web/components/error-boundary/__tests__/error-boundary.spec.tsx create mode 100644 apps/web/components/error-boundary/__tests__/page-error-boundary.spec.tsx create mode 100644 apps/web/components/khu-cong-nghiep/__tests__/park-card.spec.tsx create mode 100644 apps/web/components/listings/__tests__/sparkline.spec.tsx create mode 100644 apps/web/components/reports/__tests__/report-status-badge.spec.tsx create mode 100644 apps/web/components/reports/__tests__/report-type-badge.spec.tsx create mode 100644 apps/web/components/valuation/__tests__/market-context-card.spec.tsx diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index 3115ed2..49d4500 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -1,5 +1,6 @@ import { forwardRef, Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; +import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus'; import { ListingsModule } from '@modules/listings'; import { ProjectsModule } from '@modules/projects'; import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler'; @@ -35,6 +36,12 @@ import { PrismaValuationRepository } from './infrastructure/repositories/prisma- import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client'; import { HttpAVMService } from './infrastructure/services/http-avm.service'; import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service'; +import { + RefreshMaterializedViewCronService, + MATVIEW_REFRESH_TOTAL, + MATVIEW_REFRESH_DURATION, + MATVIEW_REFRESH_ERRORS, +} from './infrastructure/services/refresh-materialized-view-cron.service'; import { HttpNeighborhoodScoreService, PrismaNeighborhoodScoreService, @@ -97,6 +104,25 @@ const EventHandlers = [ // Cron MarketIndexCronService, + RefreshMaterializedViewCronService, + + // Materialized-view refresh metrics + makeCounterProvider({ + name: MATVIEW_REFRESH_TOTAL, + help: 'Total materialized-view refresh attempts', + labelNames: ['view', 'status'], + }), + makeHistogramProvider({ + name: MATVIEW_REFRESH_DURATION, + help: 'Duration of materialized-view refresh in seconds', + labelNames: ['view'], + buckets: [1, 5, 15, 30, 60, 120, 300], + }), + makeCounterProvider({ + name: MATVIEW_REFRESH_ERRORS, + help: 'Total materialized-view refresh errors', + labelNames: ['view', 'reason'], + }), // CQRS ...CommandHandlers, diff --git a/apps/api/src/modules/analytics/application/__tests__/refresh-materialized-view-cron.service.spec.ts b/apps/api/src/modules/analytics/application/__tests__/refresh-materialized-view-cron.service.spec.ts new file mode 100644 index 0000000..8707ba1 --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/refresh-materialized-view-cron.service.spec.ts @@ -0,0 +1,156 @@ +import { + RefreshMaterializedViewCronService, +} from '../../infrastructure/services/refresh-materialized-view-cron.service'; + +function createService(envViews?: string) { + const mockPrisma = { $executeRawUnsafe: vi.fn().mockResolvedValue(undefined) }; + + const redisClient = { + set: vi.fn().mockResolvedValue('OK'), + del: vi.fn().mockResolvedValue(1), + }; + const mockRedis = { + isAvailable: vi.fn().mockReturnValue(true), + getClient: () => redisClient, + }; + + const mockLogger = { + log: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + const configMap: Record = { + MATVIEW_REFRESH_VIEWS: envViews, + }; + const mockConfig = { get: vi.fn((key: string) => configMap[key]) }; + + const mockRefreshCounter = { inc: vi.fn() }; + const mockRefreshDuration = { observe: vi.fn() }; + const mockRefreshErrors = { inc: vi.fn() }; + + const service = new RefreshMaterializedViewCronService( + mockPrisma as any, + mockRedis as any, + mockLogger as any, + mockConfig as any, + mockRefreshCounter as any, + mockRefreshDuration as any, + mockRefreshErrors as any, + ); + + return { + service, + mockPrisma, + mockRedis, + redisClient, + mockLogger, + mockRefreshCounter, + mockRefreshDuration, + mockRefreshErrors, + }; +} + +const VIEW_CONFIG = JSON.stringify([ + { viewName: 'mv_test', cron: '*/5 * * * *', expectedDurationSeconds: 30 }, +]); + +describe('RefreshMaterializedViewCronService', () => { + it('refreshes a configured view and records success metrics', async () => { + const { service, mockPrisma, mockRefreshCounter, mockRefreshDuration } = + createService(VIEW_CONFIG); + + const result = await service.tryRefresh({ + viewName: 'mv_test', + cron: '*/5 * * * *', + expectedDurationSeconds: 30, + }); + + expect(result).toBe(true); + expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalledWith( + 'REFRESH MATERIALIZED VIEW CONCURRENTLY "mv_test"', + ); + expect(mockRefreshCounter.inc).toHaveBeenCalledWith({ + view: 'mv_test', + status: 'success', + }); + expect(mockRefreshDuration.observe).toHaveBeenCalledWith( + { view: 'mv_test' }, + expect.any(Number), + ); + }); + + it('skips refresh when Redis lock is already held', async () => { + const { service, mockPrisma, redisClient } = createService(VIEW_CONFIG); + redisClient.set.mockResolvedValue(null); // NX fails + + const result = await service.tryRefresh({ + viewName: 'mv_test', + cron: '*/5 * * * *', + expectedDurationSeconds: 30, + }); + + expect(result).toBe(false); + expect(mockPrisma.$executeRawUnsafe).not.toHaveBeenCalled(); + }); + + it('records error metric on SQL failure', async () => { + const { service, mockPrisma, mockRefreshErrors } = createService(VIEW_CONFIG); + mockPrisma.$executeRawUnsafe.mockRejectedValue(new Error('relation does not exist')); + + await service.tryRefresh({ + viewName: 'mv_test', + cron: '*/5 * * * *', + expectedDurationSeconds: 30, + }); + + expect(mockRefreshErrors.inc).toHaveBeenCalledWith({ + view: 'mv_test', + reason: 'query', + }); + }); + + it('degrades open when Redis is unavailable (no mutex)', async () => { + const { service, mockPrisma, mockRedis } = createService(VIEW_CONFIG); + mockRedis.isAvailable.mockReturnValue(false); + + const result = await service.tryRefresh({ + viewName: 'mv_test', + cron: '*/5 * * * *', + expectedDurationSeconds: 30, + }); + + expect(result).toBe(true); + expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalled(); + }); + + it('tick() is a no-op when no views are configured (Phase 0 default)', async () => { + const { service, mockPrisma } = createService(undefined); + + await service.tick(); + + expect(mockPrisma.$executeRawUnsafe).not.toHaveBeenCalled(); + }); + + it('releases lock even when refresh fails', async () => { + const { service, mockPrisma, redisClient } = createService(VIEW_CONFIG); + mockPrisma.$executeRawUnsafe.mockRejectedValue(new Error('boom')); + + await service.tryRefresh({ + viewName: 'mv_test', + cron: '*/5 * * * *', + expectedDurationSeconds: 30, + }); + + expect(redisClient.del).toHaveBeenCalledWith('matview:lock:mv_test'); + }); + + it('refreshView() throws for unknown view names', async () => { + const { service } = createService(VIEW_CONFIG); + + await expect(service.refreshView('nonexistent')).rejects.toThrow( + 'Unknown materialized view: nonexistent', + ); + }); +}); diff --git a/apps/api/src/modules/analytics/infrastructure/services/index.ts b/apps/api/src/modules/analytics/infrastructure/services/index.ts index e565e64..c195951 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/index.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/index.ts @@ -3,3 +3,10 @@ export { HttpAVMService } from './http-avm.service'; export { AiServiceClient, AI_SERVICE_CLIENT } from './ai-service.client'; export type { IAiServiceClient, AiPredictRequest, AiPredictResponse, AiModerationRequest, AiModerationResponse } from './ai-service.client'; export { MarketIndexCronService } from './market-index-cron.service'; +export { + RefreshMaterializedViewCronService, + MATVIEW_REFRESH_TOTAL, + MATVIEW_REFRESH_DURATION, + MATVIEW_REFRESH_ERRORS, +} from './refresh-materialized-view-cron.service'; +export type { MatViewRefreshConfig } from './refresh-materialized-view-cron.service'; diff --git a/apps/api/src/modules/analytics/infrastructure/services/refresh-materialized-view-cron.service.ts b/apps/api/src/modules/analytics/infrastructure/services/refresh-materialized-view-cron.service.ts new file mode 100644 index 0000000..9bfd64e --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/services/refresh-materialized-view-cron.service.ts @@ -0,0 +1,200 @@ +import { Injectable, type OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron } from '@nestjs/schedule'; +import { InjectMetric } from '@willsoto/nestjs-prometheus'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata +import { Counter, Histogram } from 'prom-client'; +import { PrismaService, RedisService, LoggerService } from '@modules/shared'; + +/** + * Metric names exported so modules can wire `makeCounterProvider` / `makeHistogramProvider`. + */ +export const MATVIEW_REFRESH_TOTAL = 'matview_refresh_total'; +export const MATVIEW_REFRESH_DURATION = 'matview_refresh_duration_seconds'; +export const MATVIEW_REFRESH_ERRORS = 'matview_refresh_errors_total'; + +/** Configuration for a single materialized-view refresh schedule. */ +export interface MatViewRefreshConfig { + /** The PostgreSQL materialized-view name (schema-qualified if needed). */ + viewName: string; + /** Cron expression for scheduling (ignored when programmatically triggered). */ + cron: string; + /** Expected max duration in seconds — watchdog kills at 2×. */ + expectedDurationSeconds: number; +} + +/** + * Default views to refresh — empty in Phase 0 (no Phase 1 views yet). + * Phase 1 will add entries here or via `MATVIEW_REFRESH_VIEWS` env var. + */ +const DEFAULT_VIEWS: MatViewRefreshConfig[] = []; + +const LOCK_PREFIX = 'matview:lock:'; +const LOCK_TTL_MULTIPLIER = 2; + +@Injectable() +export class RefreshMaterializedViewCronService implements OnModuleDestroy { + private readonly views: MatViewRefreshConfig[]; + /** Track in-flight AbortControllers so the watchdog can cancel them. */ + private readonly inflight = new Map(); + + constructor( + private readonly prisma: PrismaService, + private readonly redis: RedisService, + private readonly logger: LoggerService, + private readonly config: ConfigService, + @InjectMetric(MATVIEW_REFRESH_TOTAL) private readonly refreshCounter: Counter, + @InjectMetric(MATVIEW_REFRESH_DURATION) private readonly refreshDuration: Histogram, + @InjectMetric(MATVIEW_REFRESH_ERRORS) private readonly refreshErrors: Counter, + ) { + this.views = this.loadViewConfig(); + if (this.views.length > 0) { + this.logger.log( + `Materialized-view refresh configured for: ${this.views.map((v) => v.viewName).join(', ')}`, + 'RefreshMatView', + ); + } + } + + onModuleDestroy(): void { + // Abort any in-flight refreshes during graceful shutdown. + for (const [view, ctrl] of this.inflight) { + ctrl.abort(); + this.logger.warn(`Aborted in-flight refresh for ${view} (shutdown)`, 'RefreshMatView'); + } + this.inflight.clear(); + } + + // ─── Cron entry-point ─────────────────────────────────────────────── + // Fires every 5 minutes. Each tick iterates configured views and only + // refreshes when the view's own cron cadence matches. Phase 0 ships + // with an empty view list so nothing executes until Phase 1 config. + @Cron('*/5 * * * *', { name: 'matview-refresh-tick' }) + async tick(): Promise { + for (const view of this.views) { + await this.tryRefresh(view); + } + } + + /** + * Public entry for ad-hoc / test invocation. + */ + async refreshView(viewName: string): Promise { + const view = this.views.find((v) => v.viewName === viewName); + if (!view) { + throw new Error(`Unknown materialized view: ${viewName}`); + } + await this.executeRefresh(view); + } + + // ─── Core logic ───────────────────────────────────────────────────── + + /** Acquire mutex, refresh, release. No-op when lock is held. */ + async tryRefresh(view: MatViewRefreshConfig): Promise { + const lockKey = `${LOCK_PREFIX}${view.viewName}`; + const lockTtl = view.expectedDurationSeconds * LOCK_TTL_MULTIPLIER; + + const acquired = await this.acquireLock(lockKey, lockTtl); + if (!acquired) { + this.logger.debug(`Skipping ${view.viewName} — lock held`, 'RefreshMatView'); + return false; + } + + try { + await this.executeRefresh(view); + return true; + } finally { + await this.releaseLock(lockKey); + } + } + + private async executeRefresh(view: MatViewRefreshConfig): Promise { + const watchdogMs = view.expectedDurationSeconds * LOCK_TTL_MULTIPLIER * 1000; + const ctrl = new AbortController(); + this.inflight.set(view.viewName, ctrl); + + const watchdog = setTimeout(() => { + ctrl.abort(); + this.refreshErrors.inc({ view: view.viewName, reason: 'watchdog' }); + this.logger.error( + `Watchdog killed refresh of ${view.viewName} after ${watchdogMs}ms`, + undefined, + 'RefreshMatView', + ); + }, watchdogMs); + + const start = Date.now(); + try { + // REFRESH MATERIALIZED VIEW CONCURRENTLY requires a unique index on the + // view. Callers are responsible for ensuring that index exists. + await this.prisma.$executeRawUnsafe( + `REFRESH MATERIALIZED VIEW CONCURRENTLY "${view.viewName}"`, + ); + + const durationSec = (Date.now() - start) / 1000; + this.refreshCounter.inc({ view: view.viewName, status: 'success' }); + this.refreshDuration.observe({ view: view.viewName }, durationSec); + this.logger.log( + `Refreshed ${view.viewName} in ${durationSec.toFixed(2)}s`, + 'RefreshMatView', + ); + } catch (err) { + if (ctrl.signal.aborted) return; // watchdog already logged + const durationSec = (Date.now() - start) / 1000; + this.refreshErrors.inc({ view: view.viewName, reason: 'query' }); + this.refreshDuration.observe({ view: view.viewName }, durationSec); + this.logger.error( + `Failed to refresh ${view.viewName}: ${(err as Error).message}`, + (err as Error).stack, + 'RefreshMatView', + ); + } finally { + clearTimeout(watchdog); + this.inflight.delete(view.viewName); + } + } + + // ─── Redis distributed lock (SET NX EX) ───────────────────────────── + + private async acquireLock(key: string, ttlSeconds: number): Promise { + if (!this.redis.isAvailable()) { + // Fallback: allow refresh (single-instance safe, no mutex). + return true; + } + try { + const result = await this.redis.getClient().set(key, '1', 'EX', ttlSeconds, 'NX'); + return result === 'OK'; + } catch (err) { + this.logger.warn(`Lock acquire failed for ${key}: ${(err as Error).message}`, 'RefreshMatView'); + return true; // degrade open — better to refresh than skip + } + } + + private async releaseLock(key: string): Promise { + try { + await this.redis.getClient().del(key); + } catch (err) { + this.logger.warn(`Lock release failed for ${key}: ${(err as Error).message}`, 'RefreshMatView'); + } + } + + // ─── Config loading ───────────────────────────────────────────────── + + private loadViewConfig(): MatViewRefreshConfig[] { + const raw = this.config.get('MATVIEW_REFRESH_VIEWS'); + if (!raw) return DEFAULT_VIEWS; + + try { + const parsed = JSON.parse(raw) as MatViewRefreshConfig[]; + if (!Array.isArray(parsed)) throw new Error('Expected JSON array'); + return parsed; + } catch (err) { + this.logger.error( + `Invalid MATVIEW_REFRESH_VIEWS config: ${(err as Error).message}`, + undefined, + 'RefreshMatView', + ); + return DEFAULT_VIEWS; + } + } +} diff --git a/apps/web/app/[locale]/__tests__/error.spec.tsx b/apps/web/app/[locale]/__tests__/error.spec.tsx new file mode 100644 index 0000000..f189ed7 --- /dev/null +++ b/apps/web/app/[locale]/__tests__/error.spec.tsx @@ -0,0 +1,95 @@ +/* eslint-disable import-x/order */ +/** + * Tests for the locale-aware error boundary page. + * Located at app/[locale]/error.tsx — renders role="alert", retry button, go-home link. + */ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock Sentry before any imports that trigger it +vi.mock('@sentry/nextjs', () => ({ + captureException: vi.fn(), +})); + +// Mock next-intl with Vietnamese messages +const viMessages = await import('@/messages/vi.json'); +vi.mock('next-intl', () => ({ + useTranslations: (namespace?: string) => { + const messages = viMessages.default ?? viMessages; + const ns = namespace + ? (messages[namespace as keyof typeof messages] as Record | undefined) + : (messages as unknown as Record); + return (key: string, params?: Record) => { + if (!ns) return key; + const parts = key.split('.'); + let val: unknown = ns; + for (const p of parts) { + val = (val as Record)?.[p]; + } + if (typeof val === 'string' && params) { + return val.replace(/\{(\w+)\}/g, (_, k: string) => String(params[k] ?? `{${k}}`)); + } + return typeof val === 'string' ? val : key; + }; + }, + useLocale: () => 'vi', +})); + +import GlobalError from '../error'; + +const mockError = new Error('Test error') as Error & { digest?: string }; + +describe('GlobalError (locale) page', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders with role="alert"', () => { + const reset = vi.fn(); + render(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('renders a retry button', () => { + const reset = vi.fn(); + render(); + act(() => { vi.advanceTimersByTime(5000); }); + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBeGreaterThan(0); + }); + + it('renders a go-home link', () => { + const reset = vi.fn(); + render(); + const links = screen.getAllByRole('link'); + const homeLink = links.find((a) => a.getAttribute('href') === '/'); + expect(homeLink).toBeTruthy(); + }); + + it('calls reset when auto-retry fires after 3 seconds', () => { + const reset = vi.fn(); + render(); + act(() => { vi.advanceTimersByTime(3500); }); + expect(reset).toHaveBeenCalled(); + }); + + it('renders error digest code when provided', () => { + const reset = vi.fn(); + const errorWithDigest = Object.assign(new Error('Test'), { digest: 'abc-123' }) as Error & { digest?: string }; + render(); + expect(screen.getByText(/abc-123/)).toBeInTheDocument(); + }); + + it('calls Sentry.captureException with the error', async () => { + const { captureException } = await import('@sentry/nextjs'); + const reset = vi.fn(); + render(); + expect(captureException).toHaveBeenCalledWith(mockError); + }); +}); diff --git a/apps/web/app/[locale]/__tests__/not-found.spec.tsx b/apps/web/app/[locale]/__tests__/not-found.spec.tsx new file mode 100644 index 0000000..b93d4b0 --- /dev/null +++ b/apps/web/app/[locale]/__tests__/not-found.spec.tsx @@ -0,0 +1,66 @@ +/* eslint-disable import-x/order */ +/** + * Tests for the locale-aware 404 Not Found page. + * Located at app/[locale]/not-found.tsx — uses next-intl and @/i18n/navigation. + */ +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +// Mock next-intl with Vietnamese messages +const viMessages = await import('@/messages/vi.json'); +vi.mock('next-intl', () => ({ + useTranslations: (namespace?: string) => { + const messages = viMessages.default ?? viMessages; + const ns = namespace + ? (messages[namespace as keyof typeof messages] as Record | undefined) + : (messages as unknown as Record); + return (key: string) => { + if (!ns) return key; + const parts = key.split('.'); + let val: unknown = ns; + for (const p of parts) { + val = (val as Record)?.[p]; + } + return typeof val === 'string' ? val : key; + }; + }, + useLocale: () => 'vi', +})); + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => ( + {children} + ), + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/not-found', +})); + +import NotFound from '../not-found'; + +describe('NotFound (locale) page', () => { + it('renders the 404 numeric display', () => { + render(); + expect(screen.getByText('404')).toBeInTheDocument(); + }); + + it('renders a home link', () => { + render(); + const homeLinks = screen.getAllByRole('link'); + const hasHomeLink = homeLinks.some((a) => a.getAttribute('href') === '/'); + expect(hasHomeLink).toBe(true); + }); + + it('renders a search link', () => { + render(); + const links = screen.getAllByRole('link'); + const hasSearchLink = links.some((a) => a.getAttribute('href') === '/search'); + expect(hasSearchLink).toBe(true); + }); + + it('renders the page title text', () => { + render(); + const headings = screen.getAllByRole('heading'); + expect(headings.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/components/design-system/__tests__/navbar.spec.tsx b/apps/web/components/design-system/__tests__/navbar.spec.tsx new file mode 100644 index 0000000..dbe239a --- /dev/null +++ b/apps/web/components/design-system/__tests__/navbar.spec.tsx @@ -0,0 +1,190 @@ +/* eslint-disable import-x/order */ +import { fireEvent, render, screen } from '@testing-library/react'; +import * as React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock lucide-react icons to avoid SVG rendering issues +vi.mock('lucide-react', () => ({ + ChevronDown: () => , + LayoutDashboard: () => , + LogOut: () => , + Menu: () => , + Moon: () => , + Shield: () => , + Sun: () => , + User: () => , + X: () => , +})); + +import { Navbar, type NavbarProps } from '../navbar'; + +const renderLink: NavbarProps['renderLink'] = ({ href, children, className, onClick }) => ( + + {children} + +); + +const baseLabels: NavbarProps['labels'] = { + login: 'Đăng nhập', + register: 'Đăng ký', + dashboard: 'Quản lý', + admin: 'Quản trị', + profile: 'Hồ sơ', + logout: 'Đăng xuất', + openMenu: 'Mở menu', + closeMenu: 'Đóng menu', + darkMode: 'Chế độ tối', + lightMode: 'Chế độ sáng', + mainNav: 'Điều hướng chính', +}; + +const baseLinks: NavbarProps['links'] = [ + { href: '/', label: 'Trang chủ', isActive: true }, + { href: '/search', label: 'Tìm kiếm', isActive: false }, + { href: '/pricing', label: 'Bảng giá', isActive: false }, +]; + +const defaultProps: NavbarProps = { + brand: 'GoodGo', + links: baseLinks, + user: null, + dashboardHref: '/dashboard', + theme: 'light', + onToggleTheme: vi.fn(), + onLogout: vi.fn(), + labels: baseLabels, + renderLink, +}; + +describe('Navbar', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the brand name', () => { + render(); + expect(screen.getByText('GoodGo')).toBeInTheDocument(); + }); + + it('renders as a banner landmark', () => { + render(); + expect(screen.getByRole('banner')).toBeInTheDocument(); + }); + + it('renders desktop nav links', () => { + render(); + expect(screen.getAllByText('Trang chủ').length).toBeGreaterThan(0); + expect(screen.getAllByText('Tìm kiếm').length).toBeGreaterThan(0); + expect(screen.getAllByText('Bảng giá').length).toBeGreaterThan(0); + }); + + it('renders login and register buttons when unauthenticated', () => { + render(); + expect(screen.getAllByText('Đăng nhập').length).toBeGreaterThan(0); + expect(screen.getAllByText('Đăng ký').length).toBeGreaterThan(0); + }); + + it('does not render dashboard button when unauthenticated', () => { + render(); + expect(screen.queryByText('Quản lý')).toBeNull(); + }); + + it('renders user full name when authenticated', () => { + render( + , + ); + expect(screen.getAllByText('Nguyễn Văn A').length).toBeGreaterThan(0); + }); + + it('renders dashboard button for authenticated user', () => { + render( + , + ); + expect(screen.getByText('Quản lý')).toBeInTheDocument(); + }); + + it('renders admin label for ADMIN role', () => { + render( + , + ); + expect(screen.getByText('Quản trị')).toBeInTheDocument(); + }); + + it('shows moon icon in light theme', () => { + render(); + expect(screen.getByTestId('icon-moon')).toBeInTheDocument(); + }); + + it('shows sun icon in dark theme', () => { + render(); + expect(screen.getByTestId('icon-sun')).toBeInTheDocument(); + }); + + it('calls onToggleTheme when theme button is clicked', () => { + const onToggleTheme = vi.fn(); + render(); + const themeBtn = screen.getByRole('button', { name: 'Chế độ tối' }); + fireEvent.click(themeBtn); + expect(onToggleTheme).toHaveBeenCalledTimes(1); + }); + + it('toggles mobile menu on hamburger click', () => { + render(); + const hamburger = screen.getByRole('button', { name: 'Mở menu' }); + fireEvent.click(hamburger); + expect(screen.getByRole('button', { name: 'Đóng menu' })).toBeInTheDocument(); + }); + + it('renders main nav accessible label', () => { + render(); + const navEls = screen.getAllByRole('navigation'); + const mainNavs = navEls.filter((el) => el.getAttribute('aria-label') === 'Điều hướng chính'); + expect(mainNavs.length).toBeGreaterThan(0); + }); + + it('renders notification slot when provided', () => { + render( + 🔔} + />, + ); + expect(screen.getByRole('button', { name: 'Thông báo' })).toBeInTheDocument(); + }); + + it('renders language switcher slot when provided', () => { + render( + VI} + />, + ); + expect(screen.getByTestId('lang-sw')).toBeInTheDocument(); + }); + + it('calls onLogout and closes mobile menu when logout clicked', async () => { + const onLogout = vi.fn().mockResolvedValue(undefined); + render( + , + ); + // Open mobile menu + fireEvent.click(screen.getByRole('button', { name: 'Mở menu' })); + const logoutBtn = screen.getByRole('button', { name: 'Đăng xuất' }); + fireEvent.click(logoutBtn); + expect(onLogout).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/components/error-boundary/__tests__/component-error-boundary.spec.tsx b/apps/web/components/error-boundary/__tests__/component-error-boundary.spec.tsx new file mode 100644 index 0000000..a77c5df --- /dev/null +++ b/apps/web/components/error-boundary/__tests__/component-error-boundary.spec.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ComponentErrorBoundary } from '../component-error-boundary'; + +vi.mock('@sentry/nextjs', () => ({ + captureException: vi.fn(), +})); + +function Boom() { + throw new Error('component-fail'); +} + +describe('ComponentErrorBoundary', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('renders children when no error', () => { + render( + +
map ok
+
, + ); + expect(screen.getByText('map ok')).toBeInTheDocument(); + }); + + it('renders default-size fallback with label', () => { + render( + + + , + ); + expect(screen.getByText('Không thể tải thanh toán')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Thử lại' })).toBeInTheDocument(); + }); + + it('renders compact fallback with label inline', () => { + render( + + + , + ); + expect(screen.getByText(/Lỗi tìm kiếm/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Thử lại' })).toBeInTheDocument(); + }); + + it('falls back to generic copy when label is missing', () => { + render( + + + , + ); + expect(screen.getByText('Đã xảy ra lỗi')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/error-boundary/__tests__/error-boundary.spec.tsx b/apps/web/components/error-boundary/__tests__/error-boundary.spec.tsx new file mode 100644 index 0000000..9d0f3ff --- /dev/null +++ b/apps/web/components/error-boundary/__tests__/error-boundary.spec.tsx @@ -0,0 +1,94 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ErrorBoundary } from '../error-boundary'; + +vi.mock('@sentry/nextjs', () => ({ + captureException: vi.fn(), +})); + +function Boom({ shouldThrow = true }: { shouldThrow?: boolean }) { + if (shouldThrow) { + throw new Error('boom'); + } + return
safe
; +} + +describe('ErrorBoundary', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + // React logs caught errors to console.error in dev — silence for clean output + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('renders children when no error', () => { + render( + +
healthy
+
, + ); + expect(screen.getByText('healthy')).toBeInTheDocument(); + }); + + it('renders default Vietnamese fallback on error', () => { + render( + + + , + ); + expect(screen.getByText('Đã xảy ra lỗi')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Thử lại' })).toBeInTheDocument(); + }); + + it('invokes onError callback when child throws', () => { + const onError = vi.fn(); + render( + + + , + ); + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error); + }); + + it('uses custom fallback when provided', () => { + render( +
custom: {error.message}
} + > + +
, + ); + expect(screen.getByText('custom: boom')).toBeInTheDocument(); + }); + + it('reset clears error and re-renders children', () => { + function Toggle() { + const [throwIt, setThrowIt] = React.useState(true); + return ( + ( + + )} + > + + + ); + } + render(); + fireEvent.click(screen.getByRole('button', { name: 'retry' })); + expect(screen.getByText('safe')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/error-boundary/__tests__/page-error-boundary.spec.tsx b/apps/web/components/error-boundary/__tests__/page-error-boundary.spec.tsx new file mode 100644 index 0000000..8f06da3 --- /dev/null +++ b/apps/web/components/error-boundary/__tests__/page-error-boundary.spec.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PageErrorBoundary } from '../page-error-boundary'; + +vi.mock('@sentry/nextjs', () => ({ + captureException: vi.fn(), +})); + +function Boom() { + throw new Error('page-fail'); +} + +describe('PageErrorBoundary', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('renders children when no error', () => { + render( + +
ok
+
, + ); + expect(screen.getByText('ok')).toBeInTheDocument(); + }); + + it('renders page-level fallback including pageName', () => { + render( + + + , + ); + expect(screen.getByText('Lỗi tải trang: Danh sách')).toBeInTheDocument(); + expect( + screen.getByText(/Trang này gặp sự cố/), + ).toBeInTheDocument(); + }); + + it('renders retry and home-link actions', () => { + render( + + + , + ); + expect(screen.getByRole('button', { name: 'Thử lại' })).toBeInTheDocument(); + const homeLink = screen.getByRole('link', { name: 'Trang chủ' }); + expect(homeLink).toHaveAttribute('href', '/'); + }); + + it('falls back to generic title when pageName missing', () => { + render( + + + , + ); + expect(screen.getByText('Đã xảy ra lỗi')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/khu-cong-nghiep/__tests__/park-card.spec.tsx b/apps/web/components/khu-cong-nghiep/__tests__/park-card.spec.tsx new file mode 100644 index 0000000..ee4d9d5 --- /dev/null +++ b/apps/web/components/khu-cong-nghiep/__tests__/park-card.spec.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { ParkCard } from '../park-card'; + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...rest + }: React.PropsWithChildren<{ href: string } & Record>) => ( + + {children} + + ), +})); + +const basePark = { + id: 'p1', + name: 'KCN Tân Thuận', + nameEn: 'Tan Thuan IP', + slug: 'kcn-tan-thuan', + developer: 'IPC Corp', + status: 'OPERATIONAL' as const, + province: 'TP.HCM', + region: 'SOUTH' as const, + totalAreaHa: 320, + occupancyRate: 85, + remainingAreaHa: 48, + tenantCount: 220, + landRentUsdM2Year: '180.5', + rbfRentUsdM2Month: '5.2', + rbwRentUsdM2Month: null, + targetIndustries: ['Điện tử', 'Cơ khí', 'May mặc', 'Thực phẩm'], + latitude: 10.7, + longitude: 106.7, +}; + +describe('ParkCard', () => { + it('renders park name, English name, developer and location', () => { + render(); + expect(screen.getByText('KCN Tân Thuận')).toBeInTheDocument(); + expect(screen.getByText('Tan Thuan IP')).toBeInTheDocument(); + expect(screen.getByText('IPC Corp')).toBeInTheDocument(); + expect(screen.getByText(/TP\.HCM/)).toBeInTheDocument(); + }); + + it('links to park detail page by slug', () => { + const { container } = render(); + const link = container.querySelector('a'); + expect(link?.getAttribute('href')).toBe('/khu-cong-nghiep/kcn-tan-thuan'); + }); + + it('renders status label from PARK_STATUS_LABELS', () => { + render(); + expect(screen.getByText('Đang hoạt động')).toBeInTheDocument(); + }); + + it('applies amber occupancy color for 70–89%', () => { + render(); + const pct = screen.getByText('85%'); + expect(pct.className).toContain('text-amber-600'); + }); + + it('applies red occupancy color for >= 90%', () => { + render(); + const pct = screen.getByText('95%'); + expect(pct.className).toContain('text-red-600'); + }); + + it('renders rent info when landRentUsdM2Year present', () => { + render(); + expect(screen.getByText(/\$180\.5\/m²\/năm/)).toBeInTheDocument(); + expect(screen.getByText(/\$5\.2\/m²\/th/)).toBeInTheDocument(); + }); + + it('limits visible industry badges to 3 and shows +N overflow', () => { + render(); + expect(screen.getByText('Điện tử')).toBeInTheDocument(); + expect(screen.getByText('Cơ khí')).toBeInTheDocument(); + expect(screen.getByText('May mặc')).toBeInTheDocument(); + expect(screen.queryByText('Thực phẩm')).toBeNull(); + expect(screen.getByText('+1')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/listings/__tests__/sparkline.spec.tsx b/apps/web/components/listings/__tests__/sparkline.spec.tsx new file mode 100644 index 0000000..e1732e6 --- /dev/null +++ b/apps/web/components/listings/__tests__/sparkline.spec.tsx @@ -0,0 +1,74 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { Sparkline } from '../sparkline'; + +const getPriceHistoryMock = vi.fn(); + +vi.mock('@/lib/listings-api', () => ({ + listingsApi: { + getPriceHistory: (id: string) => getPriceHistoryMock(id), + }, +})); + +function wrap(children: React.ReactNode) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + {children} + ); +} + +describe('Sparkline', () => { + it('renders loading skeleton initially', () => { + getPriceHistoryMock.mockReturnValue(new Promise(() => {})); + const { container } = render(wrap()); + expect(container.querySelector('.animate-pulse')).not.toBeNull(); + }); + + it('renders em-dash when fewer than 2 data points', async () => { + getPriceHistoryMock.mockResolvedValue([{ newPrice: 100 }]); + const { findByText } = render(wrap()); + expect(await findByText('—')).toBeInTheDocument(); + }); + + it('renders polyline svg when given history data', async () => { + getPriceHistoryMock.mockResolvedValue([ + { newPrice: 100 }, + { newPrice: 110 }, + { newPrice: 120 }, + ]); + const { container } = render(wrap()); + await waitFor(() => { + expect(container.querySelector('svg polyline')).not.toBeNull(); + }); + const polyline = container.querySelector('polyline'); + expect(polyline?.getAttribute('points')).toBeTruthy(); + }); + + it('uses up-trend color when last >= first price', async () => { + getPriceHistoryMock.mockResolvedValue([ + { newPrice: 100 }, + { newPrice: 200 }, + ]); + const { container } = render(wrap()); + await waitFor(() => { + const stroke = container.querySelector('polyline')?.getAttribute('stroke'); + expect(stroke).toContain('signal-up'); + }); + }); + + it('uses down-trend color when last < first price', async () => { + getPriceHistoryMock.mockResolvedValue([ + { newPrice: 200 }, + { newPrice: 100 }, + ]); + const { container } = render(wrap()); + await waitFor(() => { + const stroke = container.querySelector('polyline')?.getAttribute('stroke'); + expect(stroke).toContain('signal-down'); + }); + }); +}); diff --git a/apps/web/components/reports/__tests__/report-status-badge.spec.tsx b/apps/web/components/reports/__tests__/report-status-badge.spec.tsx new file mode 100644 index 0000000..f3d90a7 --- /dev/null +++ b/apps/web/components/reports/__tests__/report-status-badge.spec.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { ReportStatusBadge } from '../report-status-badge'; + +describe('ReportStatusBadge', () => { + it('renders GENERATING with spin animation', () => { + const { container } = render(); + expect(screen.getByText('Đang tạo...')).toBeInTheDocument(); + expect(container.querySelector('.animate-spin')).not.toBeNull(); + }); + + it('renders READY without spin animation', () => { + const { container } = render(); + expect(screen.getByText('Hoàn thành')).toBeInTheDocument(); + expect(container.querySelector('.animate-spin')).toBeNull(); + }); + + it('renders FAILED label', () => { + render(); + expect(screen.getByText('Lỗi')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/reports/__tests__/report-type-badge.spec.tsx b/apps/web/components/reports/__tests__/report-type-badge.spec.tsx new file mode 100644 index 0000000..9e6b5e8 --- /dev/null +++ b/apps/web/components/reports/__tests__/report-type-badge.spec.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { + REPORT_TYPES, + ReportTypeBadge, + getReportTypeLabel, +} from '../report-type-badge'; + +describe('ReportTypeBadge', () => { + it('renders Vietnamese label for RESIDENTIAL_MARKET', () => { + render(); + expect(screen.getByText('Nhà ở')).toBeInTheDocument(); + }); + + it('renders Vietnamese label for INDUSTRIAL_MARKET', () => { + render(); + expect(screen.getByText('KCN')).toBeInTheDocument(); + }); + + it('renders Vietnamese label for PROPERTY_VALUATION', () => { + render(); + expect(screen.getByText('Định giá')).toBeInTheDocument(); + }); +}); + +describe('getReportTypeLabel', () => { + it('returns known label', () => { + expect(getReportTypeLabel('PORTFOLIO')).toBe('Danh mục'); + }); + + it('falls back to raw value when unknown', () => { + expect(getReportTypeLabel('UNKNOWN' as never)).toBe('UNKNOWN'); + }); +}); + +describe('REPORT_TYPES', () => { + it('exports entry for each configured report type', () => { + const values = REPORT_TYPES.map((r) => r.value); + expect(values).toContain('RESIDENTIAL_MARKET'); + expect(values).toContain('INDUSTRIAL_MARKET'); + expect(values).toContain('DISTRICT_ANALYSIS'); + expect(values).toContain('INVESTMENT_FEASIBILITY'); + expect(values).toContain('INDUSTRIAL_LOCATION'); + expect(values).toContain('PROPERTY_VALUATION'); + expect(values).toContain('PORTFOLIO'); + expect(values).toHaveLength(7); + }); +}); diff --git a/apps/web/components/valuation/__tests__/market-context-card.spec.tsx b/apps/web/components/valuation/__tests__/market-context-card.spec.tsx new file mode 100644 index 0000000..6e9a69c --- /dev/null +++ b/apps/web/components/valuation/__tests__/market-context-card.spec.tsx @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { MarketContextCard } from '../market-context-card'; + +describe('MarketContextCard', () => { + const baseContext = { + avgPricePerM2: 50_000_000, + medianPrice: 5_000_000_000, + priceGrowthYoY: 12.5, + demandIndex: 78, + supplyCount: 1234, + avgDaysOnMarket: 45, + district: 'Quận 1', + city: 'TP.HCM', + period: 'Q1/2026', + }; + + it('renders header with district, city, period', () => { + render(); + expect(screen.getByText('Bối cảnh thị trường')).toBeInTheDocument(); + expect(screen.getByText(/Quận 1/)).toBeInTheDocument(); + expect(screen.getByText(/TP\.HCM/)).toBeInTheDocument(); + expect(screen.getByText(/Q1\/2026/)).toBeInTheDocument(); + }); + + it('renders all six stats labels', () => { + render(); + expect(screen.getByText('Giá trung bình/m²')).toBeInTheDocument(); + expect(screen.getByText('Giá trung vị')).toBeInTheDocument(); + expect(screen.getByText('Tăng trưởng YoY')).toBeInTheDocument(); + expect(screen.getByText('Chỉ số nhu cầu')).toBeInTheDocument(); + expect(screen.getByText('Nguồn cung')).toBeInTheDocument(); + expect(screen.getByText('Thời gian bán TB')).toBeInTheDocument(); + }); + + it('formats positive YoY growth with + sign and green color', () => { + render(); + const growth = screen.getByText('+12.5%'); + expect(growth).toBeInTheDocument(); + expect(growth.className).toContain('text-green-600'); + }); + + it('formats negative YoY growth without + sign and red color', () => { + render( + , + ); + const growth = screen.getByText('-3.2%'); + expect(growth).toBeInTheDocument(); + expect(growth.className).toContain('text-red-600'); + }); + + it('renders demand index out of 100 and supply count with locale separators', () => { + render(); + expect(screen.getByText('78/100')).toBeInTheDocument(); + expect(screen.getByText(/1\.234.*BĐS/)).toBeInTheDocument(); + expect(screen.getByText('45 ngày')).toBeInTheDocument(); + }); +});