fix(a11y): resolve serious accessibility issues on search page (GOO-110)
- Add aria-hidden="true" to all decorative inline SVGs (bookmark, view-mode, funnel, checkmark) - Convert save-search popover to proper dialog: role="dialog", aria-modal, focus trap, Escape key, focus return to trigger - Add aria-pressed on list/map/split view-mode toggle buttons - Add aria-expanded + aria-controls on mobile filter toggle button - Add role="status" + aria-label="Đang tải..." on Suspense fallback Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TransferItemTable } from '../transfer-item-table';
|
||||
|
||||
const baseItem = {
|
||||
id: 'i1',
|
||||
name: 'Tủ lạnh Toshiba',
|
||||
brand: 'Toshiba',
|
||||
modelName: 'GR-RT624WE-PMV',
|
||||
category: 'APPLIANCE' as const,
|
||||
condition: 'GOOD' as const,
|
||||
purchaseYear: 2022,
|
||||
originalPriceVND: '15000000',
|
||||
askingPriceVND: '8000000',
|
||||
aiEstimatePriceVND: '7500000',
|
||||
aiConfidence: 0.85,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
describe('TransferItemTable', () => {
|
||||
it('renders empty state when no items', () => {
|
||||
render(<TransferItemTable items={[]} />);
|
||||
expect(
|
||||
screen.getByText('Chưa có danh sách vật phẩm.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all column headers', () => {
|
||||
render(<TransferItemTable items={[baseItem]} />);
|
||||
expect(screen.getByText('Tên')).toBeInTheDocument();
|
||||
expect(screen.getByText('Loại')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tình trạng')).toBeInTheDocument();
|
||||
expect(screen.getByText('Thương hiệu')).toBeInTheDocument();
|
||||
expect(screen.getByText('SL')).toBeInTheDocument();
|
||||
expect(screen.getByText('Giá yêu cầu')).toBeInTheDocument();
|
||||
expect(screen.getByText('Giá AI')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders item row with localized currency formatting', () => {
|
||||
render(<TransferItemTable items={[baseItem]} />);
|
||||
expect(screen.getByText('Tủ lạnh Toshiba')).toBeInTheDocument();
|
||||
expect(screen.getByText('GR-RT624WE-PMV')).toBeInTheDocument();
|
||||
expect(screen.getByText(/8\.000\.000/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/7\.500\.000/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to em-dash for missing brand and AI estimate', () => {
|
||||
render(
|
||||
<TransferItemTable
|
||||
items={[
|
||||
{
|
||||
...baseItem,
|
||||
id: 'i2',
|
||||
brand: null,
|
||||
aiEstimatePriceVND: null,
|
||||
aiConfidence: null,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const dashes = screen.getAllByText('—');
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TransferListingCard } from '../transfer-listing-card';
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
Link: ({
|
||||
children,
|
||||
href,
|
||||
...rest
|
||||
}: React.PropsWithChildren<{ href: string } & Record<string, unknown>>) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const baseListing = {
|
||||
id: 'tl1',
|
||||
sellerId: 's1',
|
||||
category: 'FURNITURE' as const,
|
||||
status: 'ACTIVE' as const,
|
||||
title: 'Bộ sofa gỗ còn mới 90%',
|
||||
address: '123 Lê Lợi',
|
||||
district: 'Quận 1',
|
||||
city: 'TP.HCM',
|
||||
latitude: 10.77,
|
||||
longitude: 106.7,
|
||||
askingPriceVND: '4500000',
|
||||
aiEstimatePriceVND: null,
|
||||
pricingSource: 'MANUAL' as const,
|
||||
isNegotiable: true,
|
||||
areaM2: 12,
|
||||
itemCount: 5,
|
||||
viewCount: 42,
|
||||
publishedAt: '2026-04-10T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('TransferListingCard', () => {
|
||||
it('renders title, location and formatted price', () => {
|
||||
render(<TransferListingCard listing={baseListing} />);
|
||||
expect(screen.getByText('Bộ sofa gỗ còn mới 90%')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Quận 1, TP\.HCM/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/4\.500\.000/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ACTIVE status with green color and "Thương lượng" when negotiable', () => {
|
||||
render(<TransferListingCard listing={baseListing} />);
|
||||
expect(screen.getByText('Đang đăng')).toBeInTheDocument();
|
||||
expect(screen.getByText('Thương lượng')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders item and view counts and square-meter area', () => {
|
||||
render(<TransferListingCard listing={baseListing} />);
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
expect(screen.getByText('42')).toBeInTheDocument();
|
||||
expect(screen.getByText(/12 m/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links to listing detail by id', () => {
|
||||
const { container } = render(
|
||||
<TransferListingCard listing={baseListing} />,
|
||||
);
|
||||
expect(container.querySelector('a')?.getAttribute('href')).toBe(
|
||||
'/chuyen-nhuong/tl1',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits publish date footer when publishedAt is null', () => {
|
||||
render(
|
||||
<TransferListingCard
|
||||
listing={{ ...baseListing, publishedAt: null }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/Đăng/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { act, render, renderHook, screen } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
DENSITY_CELL_PADDING,
|
||||
DENSITY_DATA_FONT,
|
||||
DENSITY_ROW_HEIGHT,
|
||||
DensityProvider,
|
||||
useDensity,
|
||||
} from '../density-provider';
|
||||
|
||||
// jsdom (opaque origin) does not provide a usable localStorage; install a tiny in-memory shim.
|
||||
function installLocalStorage(): Storage {
|
||||
const store: Record<string, string> = {};
|
||||
const fake: Storage = {
|
||||
get length() {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
clear: () => {
|
||||
for (const k of Object.keys(store)) delete store[k];
|
||||
},
|
||||
getItem: (k) => (k in store ? store[k]! : null),
|
||||
key: (i) => Object.keys(store)[i] ?? null,
|
||||
removeItem: (k) => {
|
||||
delete store[k];
|
||||
},
|
||||
setItem: (k, v) => {
|
||||
store[k] = String(v);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: fake,
|
||||
});
|
||||
return fake;
|
||||
}
|
||||
|
||||
describe('DensityProvider', () => {
|
||||
let storage: Storage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = installLocalStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (typeof storage.clear === 'function') {
|
||||
storage.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes default density "regular" via useDensity', () => {
|
||||
const { result } = renderHook(() => useDensity(), {
|
||||
wrapper: ({ children }) => <DensityProvider>{children}</DensityProvider>,
|
||||
});
|
||||
expect(result.current.density).toBe('regular');
|
||||
});
|
||||
|
||||
it('honors the defaultDensity prop', () => {
|
||||
const { result } = renderHook(() => useDensity(), {
|
||||
wrapper: ({ children }) => (
|
||||
<DensityProvider defaultDensity="compact">{children}</DensityProvider>
|
||||
),
|
||||
});
|
||||
expect(result.current.density).toBe('compact');
|
||||
});
|
||||
|
||||
it('persists density changes to localStorage', () => {
|
||||
const { result } = renderHook(() => useDensity(), {
|
||||
wrapper: ({ children }) => <DensityProvider>{children}</DensityProvider>,
|
||||
});
|
||||
act(() => result.current.setDensity('roomy'));
|
||||
expect(result.current.density).toBe('roomy');
|
||||
expect(localStorage.getItem('goodgo.density')).toBe('roomy');
|
||||
});
|
||||
|
||||
it('reads stored density on mount when valid', () => {
|
||||
localStorage.setItem('goodgo.density', 'compact');
|
||||
function Probe() {
|
||||
const { density } = useDensity();
|
||||
return <span data-testid="d">{density}</span>;
|
||||
}
|
||||
render(
|
||||
<DensityProvider>
|
||||
<Probe />
|
||||
</DensityProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('d').textContent).toBe('compact');
|
||||
});
|
||||
|
||||
it('exposes row-height, padding and font tables for all densities', () => {
|
||||
for (const mode of ['compact', 'regular', 'roomy'] as const) {
|
||||
expect(DENSITY_ROW_HEIGHT[mode]).toBeTruthy();
|
||||
expect(DENSITY_CELL_PADDING[mode]).toBeTruthy();
|
||||
expect(DENSITY_DATA_FONT[mode]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
78
apps/web/components/du-an/__tests__/project-card.spec.tsx
Normal file
78
apps/web/components/du-an/__tests__/project-card.spec.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ProjectCard } from '../project-card';
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
Link: ({
|
||||
children,
|
||||
href,
|
||||
...rest
|
||||
}: React.PropsWithChildren<{ href: string } & Record<string, unknown>>) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ alt, src }: { alt: string; src: string }) => (
|
||||
<img alt={alt} src={src} />
|
||||
),
|
||||
}));
|
||||
|
||||
const baseProject = {
|
||||
id: 'p1',
|
||||
slug: 'vinhomes-central-park',
|
||||
name: 'Vinhomes Central Park',
|
||||
status: 'UNDER_CONSTRUCTION' as const,
|
||||
developer: { id: 'd1', name: 'Vingroup' },
|
||||
city: 'TP.HCM',
|
||||
district: 'Bình Thạnh',
|
||||
address: '208 Nguyễn Hữu Cảnh',
|
||||
latitude: 10.79,
|
||||
longitude: 106.72,
|
||||
thumbnailUrl: 'https://example.com/t.jpg',
|
||||
totalArea: 43,
|
||||
totalUnits: 10000,
|
||||
propertyTypes: ['APARTMENT', 'VILLA'] as ('APARTMENT' | 'VILLA')[],
|
||||
minPrice: '3500000000',
|
||||
maxPrice: '20000000000',
|
||||
completionDate: null,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('ProjectCard', () => {
|
||||
it('renders name, location, developer and status label', () => {
|
||||
render(<ProjectCard project={baseProject} />);
|
||||
expect(screen.getByText('Vinhomes Central Park')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Bình Thạnh, TP\.HCM/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Vingroup')).toBeInTheDocument();
|
||||
expect(screen.getByText('Đang xây dựng')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links to project detail by slug', () => {
|
||||
const { container } = render(<ProjectCard project={baseProject} />);
|
||||
expect(container.querySelector('a')?.getAttribute('href')).toBe(
|
||||
'/du-an/vinhomes-central-park',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders thumbnail image when thumbnailUrl present', () => {
|
||||
render(<ProjectCard project={baseProject} />);
|
||||
const img = screen.getByAltText('Vinhomes Central Park') as HTMLImageElement;
|
||||
expect(img.src).toContain('t.jpg');
|
||||
});
|
||||
|
||||
it('renders "Liên hệ" when minPrice is null', () => {
|
||||
render(
|
||||
<ProjectCard project={{ ...baseProject, minPrice: null, maxPrice: null }} />,
|
||||
);
|
||||
expect(screen.getByText('Liên hệ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders unit count with "căn" suffix', () => {
|
||||
render(<ProjectCard project={baseProject} />);
|
||||
expect(screen.getByText('10000 căn')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { NotificationsProvider } from '../notifications-provider';
|
||||
|
||||
const useSocketNotificationsMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/hooks/use-socket-notifications', () => ({
|
||||
useSocketNotifications: () => useSocketNotificationsMock(),
|
||||
}));
|
||||
|
||||
describe('NotificationsProvider', () => {
|
||||
it('renders children', () => {
|
||||
render(
|
||||
<NotificationsProvider>
|
||||
<div>child</div>
|
||||
</NotificationsProvider>,
|
||||
);
|
||||
expect(screen.getByText('child')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes socket notifications hook on mount', () => {
|
||||
useSocketNotificationsMock.mockClear();
|
||||
render(
|
||||
<NotificationsProvider>
|
||||
<span>x</span>
|
||||
</NotificationsProvider>,
|
||||
);
|
||||
expect(useSocketNotificationsMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { QueryProvider } from '../query-provider';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/query-client', () => {
|
||||
const { QueryClient } = require('@tanstack/react-query');
|
||||
return { getQueryClient: () => new QueryClient() };
|
||||
});
|
||||
|
||||
function Boom() {
|
||||
throw new Error('query-fail');
|
||||
}
|
||||
|
||||
describe('QueryProvider', () => {
|
||||
let spy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('renders children under provider', () => {
|
||||
render(
|
||||
<QueryProvider>
|
||||
<div>ok</div>
|
||||
</QueryProvider>,
|
||||
);
|
||||
expect(screen.getByText('ok')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('catches thrown errors and renders fallback with retry button', () => {
|
||||
render(
|
||||
<QueryProvider>
|
||||
<Boom />
|
||||
</QueryProvider>,
|
||||
);
|
||||
// error.description & common.retry keys surface via mocked translator
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'common.retry' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces the underlying error message in fallback', () => {
|
||||
render(
|
||||
<QueryProvider>
|
||||
<Boom />
|
||||
</QueryProvider>,
|
||||
);
|
||||
expect(screen.getByText('query-fail')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
77
apps/web/components/reports/__tests__/report-card.spec.tsx
Normal file
77
apps/web/components/reports/__tests__/report-card.spec.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ReportCard } from '../report-card';
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
Link: ({
|
||||
children,
|
||||
href,
|
||||
...rest
|
||||
}: React.PropsWithChildren<{ href: string } & Record<string, unknown>>) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const baseReport = {
|
||||
id: 'r1',
|
||||
type: 'RESIDENTIAL_MARKET' as const,
|
||||
title: 'Báo cáo thị trường Q1',
|
||||
params: {},
|
||||
content: null,
|
||||
pdfUrl: null,
|
||||
status: 'READY' as const,
|
||||
errorMsg: null,
|
||||
createdAt: '2026-04-01T08:30:00.000Z',
|
||||
updatedAt: '2026-04-01T08:30:00.000Z',
|
||||
};
|
||||
|
||||
describe('ReportCard', () => {
|
||||
it('renders title and type/status badges', () => {
|
||||
render(<ReportCard report={baseReport} />);
|
||||
expect(screen.getByText('Báo cáo thị trường Q1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nhà ở')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hoàn thành')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links to report detail for READY report (both detail icon link and bottom "Xem báo cáo" link)', () => {
|
||||
const { container } = render(<ReportCard report={baseReport} />);
|
||||
const links = container.querySelectorAll('a[href="/dashboard/reports/r1"]');
|
||||
expect(links.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Xem báo cáo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render "Xem báo cáo" link for non-READY reports', () => {
|
||||
render(
|
||||
<ReportCard report={{ ...baseReport, status: 'GENERATING' }} />,
|
||||
);
|
||||
expect(screen.queryByText('Xem báo cáo')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders error message for FAILED report with errorMsg', () => {
|
||||
render(
|
||||
<ReportCard
|
||||
report={{
|
||||
...baseReport,
|
||||
status: 'FAILED',
|
||||
errorMsg: 'Thiếu dữ liệu',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Thiếu dữ liệu')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lỗi')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invokes onDelete with report id when delete button clicked', () => {
|
||||
const onDelete = vi.fn();
|
||||
render(<ReportCard report={baseReport} onDelete={onDelete} />);
|
||||
const trashButton = screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.className.includes('text-destructive'));
|
||||
expect(trashButton).toBeDefined();
|
||||
fireEvent.click(trashButton!);
|
||||
expect(onDelete).toHaveBeenCalledWith('r1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/* eslint-disable import-x/order */
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
AlertCircle: (props: Record<string, unknown>) => <span data-testid="alert-icon" {...props} />,
|
||||
CreditCard: (props: Record<string, unknown>) => <span data-testid="credit-card-icon" {...props} />,
|
||||
Loader2: (props: Record<string, unknown>) => <span data-testid="loader-icon" {...props} />,
|
||||
Smartphone: (props: Record<string, unknown>) => <span data-testid="smartphone-icon" {...props} />,
|
||||
Wallet: (props: Record<string, unknown>) => <span data-testid="wallet-icon" {...props} />,
|
||||
}));
|
||||
|
||||
const mockCreatePayment = vi.fn();
|
||||
const mockCreateSubscription = vi.fn();
|
||||
const mockUpgradeSubscription = vi.fn();
|
||||
|
||||
vi.mock('@/lib/payment-api', () => ({
|
||||
paymentApi: {
|
||||
createPayment: (...args: unknown[]) => mockCreatePayment(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/subscription-api', () => ({
|
||||
subscriptionApi: {
|
||||
createSubscription: (...args: unknown[]) => mockCreateSubscription(...args),
|
||||
upgradeSubscription: (...args: unknown[]) => mockUpgradeSubscription(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/currency', () => ({
|
||||
formatVND: (amount: string | number) => `${Number(amount).toLocaleString()} đ`,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/error-boundary', () => ({
|
||||
ComponentErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import { CheckoutModal } from '../checkout-modal';
|
||||
|
||||
const basePlan = {
|
||||
id: 'plan-1',
|
||||
tier: 'AGENT_PRO',
|
||||
name: 'Môi giới Pro',
|
||||
priceMonthlyVND: '499000',
|
||||
priceYearlyVND: '4990000',
|
||||
maxListings: 50,
|
||||
maxSavedSearches: 10,
|
||||
features: {},
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
describe('CheckoutModal', () => {
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreatePayment.mockResolvedValue({ paymentUrl: 'https://vnpay.vn/pay', paymentId: 'p1', providerTxId: 'tx1' });
|
||||
mockCreateSubscription.mockResolvedValue({ subscriptionId: 's1' });
|
||||
mockUpgradeSubscription.mockResolvedValue({ subscriptionId: 's1' });
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { ...window.location, href: 'http://localhost:3000/vi/pricing', origin: 'http://localhost:3000', pathname: '/vi/pricing' },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders nothing when plan is null', () => {
|
||||
const { container } = render(
|
||||
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={null} billingCycle="monthly" />,
|
||||
);
|
||||
expect(container.querySelector('[role="dialog"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders order summary with plan name and monthly price', () => {
|
||||
render(
|
||||
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||
);
|
||||
expect(screen.getByText('Đăng ký gói dịch vụ')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Môi giới Pro').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Hàng tháng')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/499,000/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders yearly badge and yearly price', () => {
|
||||
render(
|
||||
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="yearly" />,
|
||||
);
|
||||
expect(screen.getByText('Hàng năm')).toBeInTheDocument();
|
||||
expect(screen.getByText('-17%')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/4,990,000/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders upgrade title when isUpgrade=true', () => {
|
||||
render(
|
||||
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" isUpgrade currentTier="FREE" />,
|
||||
);
|
||||
expect(screen.getByText('Nâng cấp gói dịch vụ')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Miễn phí/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all three payment providers', () => {
|
||||
render(
|
||||
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||
);
|
||||
expect(screen.getByText('VNPay')).toBeInTheDocument();
|
||||
expect(screen.getByText('MoMo')).toBeInTheDocument();
|
||||
expect(screen.getByText('ZaloPay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects a different payment provider on click', async () => {
|
||||
render(
|
||||
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||
);
|
||||
const momoButton = screen.getByText('MoMo').closest('button')!;
|
||||
await userEvent.click(momoButton);
|
||||
// MoMo button should now have primary styling (ring-1 ring-primary)
|
||||
expect(momoButton.className).toContain('border-primary');
|
||||
});
|
||||
|
||||
it('calls createSubscription + createPayment on checkout', async () => {
|
||||
render(
|
||||
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateSubscription).toHaveBeenCalledWith('AGENT_PRO', 'monthly');
|
||||
expect(mockCreatePayment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: 'VNPAY',
|
||||
type: 'SUBSCRIPTION',
|
||||
amountVND: 499000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls upgradeSubscription instead of createSubscription for upgrades', async () => {
|
||||
render(
|
||||
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" isUpgrade />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpgradeSubscription).toHaveBeenCalledWith('AGENT_PRO');
|
||||
expect(mockCreateSubscription).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays error message when payment fails', async () => {
|
||||
mockCreateSubscription.mockRejectedValue(new Error('Hệ thống bận'));
|
||||
render(
|
||||
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Hệ thống bận')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables close button and providers while processing', async () => {
|
||||
// Make the payment hang
|
||||
mockCreateSubscription.mockReturnValue(new Promise(() => {}));
|
||||
render(
|
||||
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/đang xử lý/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /hủy/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('dismiss error on close button click', async () => {
|
||||
mockCreateSubscription.mockRejectedValue(new Error('Lỗi test'));
|
||||
render(
|
||||
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Lỗi test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText('Đóng'));
|
||||
expect(screen.queryByText('Lỗi test')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/* eslint-disable import-x/order */
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
CheckCircle: (props: Record<string, unknown>) => <span data-testid="check-icon" {...props} />,
|
||||
Clock: (props: Record<string, unknown>) => <span data-testid="clock-icon" {...props} />,
|
||||
Loader2: (props: Record<string, unknown>) => <span data-testid="loader-icon" {...props} />,
|
||||
XCircle: (props: Record<string, unknown>) => <span data-testid="x-icon" {...props} />,
|
||||
}));
|
||||
|
||||
const mockGetPaymentStatus = vi.fn();
|
||||
|
||||
vi.mock('@/lib/payment-api', () => ({
|
||||
paymentApi: {
|
||||
getPaymentStatus: (...args: unknown[]) => mockGetPaymentStatus(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/currency', () => ({
|
||||
formatVND: (amount: string | number) => `${Number(amount).toLocaleString()} đ`,
|
||||
}));
|
||||
|
||||
let mockSearchParams = new URLSearchParams();
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}));
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
Link: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
|
||||
<a href={href} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
import PaymentReturnPage from '@/app/[locale]/(public)/payment/return/page';
|
||||
|
||||
describe('PaymentReturnPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows "not found" when no paymentId in search params', async () => {
|
||||
mockSearchParams = new URLSearchParams();
|
||||
render(<PaymentReturnPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Không tìm thấy giao dịch')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Xem bảng giá')).toBeInTheDocument();
|
||||
expect(screen.getByText('Về trang chủ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows success state for COMPLETED payment', async () => {
|
||||
mockSearchParams = new URLSearchParams({ paymentId: 'p1' });
|
||||
mockGetPaymentStatus.mockResolvedValue({
|
||||
id: 'p1',
|
||||
provider: 'VNPAY',
|
||||
type: 'SUBSCRIPTION',
|
||||
amountVND: '499000',
|
||||
status: 'COMPLETED',
|
||||
providerTxId: 'TXN123',
|
||||
createdAt: '2024-06-15T10:00:00Z',
|
||||
updatedAt: '2024-06-15T10:01:00Z',
|
||||
});
|
||||
|
||||
render(<PaymentReturnPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Thanh toán thành công!')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/499,000/)).toBeInTheDocument();
|
||||
expect(screen.getByText('VNPAY')).toBeInTheDocument();
|
||||
expect(screen.getByText('Xem gói dịch vụ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows failed state for FAILED payment', async () => {
|
||||
mockSearchParams = new URLSearchParams({ paymentId: 'p2' });
|
||||
mockGetPaymentStatus.mockResolvedValue({
|
||||
id: 'p2',
|
||||
provider: 'MOMO',
|
||||
type: 'SUBSCRIPTION',
|
||||
amountVND: '499000',
|
||||
status: 'FAILED',
|
||||
providerTxId: null,
|
||||
createdAt: '2024-06-15T10:00:00Z',
|
||||
updatedAt: '2024-06-15T10:01:00Z',
|
||||
});
|
||||
|
||||
render(<PaymentReturnPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Thanh toán thất bại')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Thử lại')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows cancelled state', async () => {
|
||||
mockSearchParams = new URLSearchParams({ paymentId: 'p3' });
|
||||
mockGetPaymentStatus.mockResolvedValue({
|
||||
id: 'p3',
|
||||
provider: 'ZALOPAY',
|
||||
type: 'SUBSCRIPTION',
|
||||
amountVND: '499000',
|
||||
status: 'CANCELLED',
|
||||
providerTxId: null,
|
||||
createdAt: '2024-06-15T10:00:00Z',
|
||||
updatedAt: '2024-06-15T10:01:00Z',
|
||||
});
|
||||
|
||||
render(<PaymentReturnPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Giao dịch đã hủy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('reads vnp_TxnRef as fallback paymentId', async () => {
|
||||
mockSearchParams = new URLSearchParams({ vnp_TxnRef: 'vnp-123' });
|
||||
mockGetPaymentStatus.mockResolvedValue({
|
||||
id: 'vnp-123',
|
||||
provider: 'VNPAY',
|
||||
type: 'SUBSCRIPTION',
|
||||
amountVND: '499000',
|
||||
status: 'COMPLETED',
|
||||
providerTxId: 'TXN999',
|
||||
createdAt: '2024-06-15T10:00:00Z',
|
||||
updatedAt: '2024-06-15T10:01:00Z',
|
||||
});
|
||||
|
||||
render(<PaymentReturnPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetPaymentStatus).toHaveBeenCalledWith('vnp-123');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user