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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<string, string | undefined> = {
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, AbortController>();
|
||||
|
||||
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<void> {
|
||||
for (const view of this.views) {
|
||||
await this.tryRefresh(view);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public entry for ad-hoc / test invocation.
|
||||
*/
|
||||
async refreshView(viewName: string): Promise<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<string>('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;
|
||||
}
|
||||
}
|
||||
}
|
||||
95
apps/web/app/[locale]/__tests__/error.spec.tsx
Normal file
95
apps/web/app/[locale]/__tests__/error.spec.tsx
Normal file
@@ -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<string, unknown> | undefined)
|
||||
: (messages as unknown as Record<string, unknown>);
|
||||
return (key: string, params?: Record<string, unknown>) => {
|
||||
if (!ns) return key;
|
||||
const parts = key.split('.');
|
||||
let val: unknown = ns;
|
||||
for (const p of parts) {
|
||||
val = (val as Record<string, unknown>)?.[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(<GlobalError error={mockError} reset={reset} />);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a retry button', () => {
|
||||
const reset = vi.fn();
|
||||
render(<GlobalError error={mockError} reset={reset} />);
|
||||
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(<GlobalError error={mockError} reset={reset} />);
|
||||
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(<GlobalError error={mockError} reset={reset} />);
|
||||
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(<GlobalError error={errorWithDigest} reset={reset} />);
|
||||
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(<GlobalError error={mockError} reset={reset} />);
|
||||
expect(captureException).toHaveBeenCalledWith(mockError);
|
||||
});
|
||||
});
|
||||
66
apps/web/app/[locale]/__tests__/not-found.spec.tsx
Normal file
66
apps/web/app/[locale]/__tests__/not-found.spec.tsx
Normal file
@@ -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<string, unknown> | undefined)
|
||||
: (messages as unknown as Record<string, unknown>);
|
||||
return (key: string) => {
|
||||
if (!ns) return key;
|
||||
const parts = key.split('.');
|
||||
let val: unknown = ns;
|
||||
for (const p of parts) {
|
||||
val = (val as Record<string, unknown>)?.[p];
|
||||
}
|
||||
return typeof val === 'string' ? val : key;
|
||||
};
|
||||
},
|
||||
useLocale: () => 'vi',
|
||||
}));
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
Link: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
|
||||
<a href={href} className={className}>{children}</a>
|
||||
),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/not-found',
|
||||
}));
|
||||
|
||||
import NotFound from '../not-found';
|
||||
|
||||
describe('NotFound (locale) page', () => {
|
||||
it('renders the 404 numeric display', () => {
|
||||
render(<NotFound />);
|
||||
expect(screen.getByText('404')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a home link', () => {
|
||||
render(<NotFound />);
|
||||
const homeLinks = screen.getAllByRole('link');
|
||||
const hasHomeLink = homeLinks.some((a) => a.getAttribute('href') === '/');
|
||||
expect(hasHomeLink).toBe(true);
|
||||
});
|
||||
|
||||
it('renders a search link', () => {
|
||||
render(<NotFound />);
|
||||
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(<NotFound />);
|
||||
const headings = screen.getAllByRole('heading');
|
||||
expect(headings.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
190
apps/web/components/design-system/__tests__/navbar.spec.tsx
Normal file
190
apps/web/components/design-system/__tests__/navbar.spec.tsx
Normal file
@@ -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: () => <span data-testid="icon-chevron-down" />,
|
||||
LayoutDashboard: () => <span data-testid="icon-layout-dashboard" />,
|
||||
LogOut: () => <span data-testid="icon-logout" />,
|
||||
Menu: () => <span data-testid="icon-menu" />,
|
||||
Moon: () => <span data-testid="icon-moon" />,
|
||||
Shield: () => <span data-testid="icon-shield" />,
|
||||
Sun: () => <span data-testid="icon-sun" />,
|
||||
User: () => <span data-testid="icon-user" />,
|
||||
X: () => <span data-testid="icon-x" />,
|
||||
}));
|
||||
|
||||
import { Navbar, type NavbarProps } from '../navbar';
|
||||
|
||||
const renderLink: NavbarProps['renderLink'] = ({ href, children, className, onClick }) => (
|
||||
<a href={href} className={className} onClick={onClick}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
|
||||
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(<Navbar {...defaultProps} />);
|
||||
expect(screen.getByText('GoodGo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders as a banner landmark', () => {
|
||||
render(<Navbar {...defaultProps} />);
|
||||
expect(screen.getByRole('banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders desktop nav links', () => {
|
||||
render(<Navbar {...defaultProps} />);
|
||||
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(<Navbar {...defaultProps} />);
|
||||
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(<Navbar {...defaultProps} />);
|
||||
expect(screen.queryByText('Quản lý')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders user full name when authenticated', () => {
|
||||
render(
|
||||
<Navbar
|
||||
{...defaultProps}
|
||||
user={{ fullName: 'Nguyễn Văn A', role: 'BUYER', email: 'a@test.com' }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText('Nguyễn Văn A').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders dashboard button for authenticated user', () => {
|
||||
render(
|
||||
<Navbar
|
||||
{...defaultProps}
|
||||
user={{ fullName: 'Nguyễn Văn A', role: 'BUYER' }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Quản lý')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders admin label for ADMIN role', () => {
|
||||
render(
|
||||
<Navbar
|
||||
{...defaultProps}
|
||||
user={{ fullName: 'Admin User', role: 'ADMIN' }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Quản trị')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows moon icon in light theme', () => {
|
||||
render(<Navbar {...defaultProps} theme="light" />);
|
||||
expect(screen.getByTestId('icon-moon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows sun icon in dark theme', () => {
|
||||
render(<Navbar {...defaultProps} theme="dark" />);
|
||||
expect(screen.getByTestId('icon-sun')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onToggleTheme when theme button is clicked', () => {
|
||||
const onToggleTheme = vi.fn();
|
||||
render(<Navbar {...defaultProps} onToggleTheme={onToggleTheme} />);
|
||||
const themeBtn = screen.getByRole('button', { name: 'Chế độ tối' });
|
||||
fireEvent.click(themeBtn);
|
||||
expect(onToggleTheme).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toggles mobile menu on hamburger click', () => {
|
||||
render(<Navbar {...defaultProps} />);
|
||||
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(<Navbar {...defaultProps} />);
|
||||
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(
|
||||
<Navbar
|
||||
{...defaultProps}
|
||||
user={{ fullName: 'User', role: 'BUYER' }}
|
||||
notifications={<button aria-label="Thông báo">🔔</button>}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Thông báo' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders language switcher slot when provided', () => {
|
||||
render(
|
||||
<Navbar
|
||||
{...defaultProps}
|
||||
languageSwitcher={<div data-testid="lang-sw">VI</div>}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('lang-sw')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onLogout and closes mobile menu when logout clicked', async () => {
|
||||
const onLogout = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<Navbar
|
||||
{...defaultProps}
|
||||
user={{ fullName: 'User', role: 'BUYER' }}
|
||||
onLogout={onLogout}
|
||||
/>,
|
||||
);
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('renders children when no error', () => {
|
||||
render(
|
||||
<ComponentErrorBoundary label="bản đồ">
|
||||
<div>map ok</div>
|
||||
</ComponentErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText('map ok')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders default-size fallback with label', () => {
|
||||
render(
|
||||
<ComponentErrorBoundary label="thanh toán">
|
||||
<Boom />
|
||||
</ComponentErrorBoundary>,
|
||||
);
|
||||
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(
|
||||
<ComponentErrorBoundary label="tìm kiếm" compact>
|
||||
<Boom />
|
||||
</ComponentErrorBoundary>,
|
||||
);
|
||||
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(
|
||||
<ComponentErrorBoundary>
|
||||
<Boom />
|
||||
</ComponentErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText('Đã xảy ra lỗi')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 <div>safe</div>;
|
||||
}
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
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(
|
||||
<ErrorBoundary>
|
||||
<div>healthy</div>
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText('healthy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders default Vietnamese fallback on error', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Boom />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
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(
|
||||
<ErrorBoundary onError={onError}>
|
||||
<Boom />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('uses custom fallback when provided', () => {
|
||||
render(
|
||||
<ErrorBoundary
|
||||
fallback={({ error }) => <div>custom: {error.message}</div>}
|
||||
>
|
||||
<Boom />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText('custom: boom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reset clears error and re-renders children', () => {
|
||||
function Toggle() {
|
||||
const [throwIt, setThrowIt] = React.useState(true);
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={({ reset }) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
setThrowIt(false);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<Boom shouldThrow={throwIt} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
render(<Toggle />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'retry' }));
|
||||
expect(screen.getByText('safe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('renders children when no error', () => {
|
||||
render(
|
||||
<PageErrorBoundary pageName="Trang chủ">
|
||||
<div>ok</div>
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText('ok')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders page-level fallback including pageName', () => {
|
||||
render(
|
||||
<PageErrorBoundary pageName="Danh sách">
|
||||
<Boom />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
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(
|
||||
<PageErrorBoundary>
|
||||
<Boom />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
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(
|
||||
<PageErrorBoundary>
|
||||
<Boom />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText('Đã xảy ra lỗi')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>>) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<ParkCard park={basePark} />);
|
||||
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(<ParkCard park={basePark} />);
|
||||
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(<ParkCard park={basePark} />);
|
||||
expect(screen.getByText('Đang hoạt động')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies amber occupancy color for 70–89%', () => {
|
||||
render(<ParkCard park={basePark} />);
|
||||
const pct = screen.getByText('85%');
|
||||
expect(pct.className).toContain('text-amber-600');
|
||||
});
|
||||
|
||||
it('applies red occupancy color for >= 90%', () => {
|
||||
render(<ParkCard park={{ ...basePark, occupancyRate: 95 }} />);
|
||||
const pct = screen.getByText('95%');
|
||||
expect(pct.className).toContain('text-red-600');
|
||||
});
|
||||
|
||||
it('renders rent info when landRentUsdM2Year present', () => {
|
||||
render(<ParkCard park={basePark} />);
|
||||
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(<ParkCard park={basePark} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
74
apps/web/components/listings/__tests__/sparkline.spec.tsx
Normal file
74
apps/web/components/listings/__tests__/sparkline.spec.tsx
Normal file
@@ -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 (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Sparkline', () => {
|
||||
it('renders loading skeleton initially', () => {
|
||||
getPriceHistoryMock.mockReturnValue(new Promise(() => {}));
|
||||
const { container } = render(wrap(<Sparkline listingId="1" />));
|
||||
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(<Sparkline listingId="2" />));
|
||||
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(<Sparkline listingId="3" />));
|
||||
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(<Sparkline listingId="4" />));
|
||||
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(<Sparkline listingId="5" />));
|
||||
await waitFor(() => {
|
||||
const stroke = container.querySelector('polyline')?.getAttribute('stroke');
|
||||
expect(stroke).toContain('signal-down');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(<ReportStatusBadge status="GENERATING" />);
|
||||
expect(screen.getByText('Đang tạo...')).toBeInTheDocument();
|
||||
expect(container.querySelector('.animate-spin')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders READY without spin animation', () => {
|
||||
const { container } = render(<ReportStatusBadge status="READY" />);
|
||||
expect(screen.getByText('Hoàn thành')).toBeInTheDocument();
|
||||
expect(container.querySelector('.animate-spin')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders FAILED label', () => {
|
||||
render(<ReportStatusBadge status="FAILED" />);
|
||||
expect(screen.getByText('Lỗi')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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(<ReportTypeBadge type="RESIDENTIAL_MARKET" />);
|
||||
expect(screen.getByText('Nhà ở')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Vietnamese label for INDUSTRIAL_MARKET', () => {
|
||||
render(<ReportTypeBadge type="INDUSTRIAL_MARKET" />);
|
||||
expect(screen.getByText('KCN')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Vietnamese label for PROPERTY_VALUATION', () => {
|
||||
render(<ReportTypeBadge type="PROPERTY_VALUATION" />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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(<MarketContextCard context={baseContext} />);
|
||||
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(<MarketContextCard context={baseContext} />);
|
||||
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(<MarketContextCard context={baseContext} />);
|
||||
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(
|
||||
<MarketContextCard context={{ ...baseContext, priceGrowthYoY: -3.2 }} />,
|
||||
);
|
||||
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(<MarketContextCard context={baseContext} />);
|
||||
expect(screen.getByText('78/100')).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.234.*BĐS/)).toBeInTheDocument();
|
||||
expect(screen.getByText('45 ngày')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user