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:
Ho Ngoc Hai
2026-04-24 10:26:50 +07:00
parent 1d26393f16
commit f5118244b7
34 changed files with 2321 additions and 9 deletions

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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();
}
});
});

View 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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View 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');
});
});

View File

@@ -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();
});
});

View File

@@ -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');
});
});
});