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,127 @@
/* 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', () => {
const icon = (name: string) => (props: Record<string, unknown>) => <span data-testid={`icon-${name}`} {...props} />;
return {
CheckCircle: icon('check'),
XCircle: icon('x'),
RefreshCw: icon('refresh'),
ChevronLeft: icon('chevron-left'),
ChevronRight: icon('chevron-right'),
ShieldCheck: icon('shield'),
X: icon('close'),
User: icon('user'),
};
});
vi.mock('next/image', () => ({
default: (props: Record<string, unknown>) => <img {...props} />,
}));
vi.mock('@/components/design-system/status-chip', () => ({
StatusChip: ({ status }: { status: string }) => <span data-testid="status-chip">{status}</span>,
}));
const mockGetKycQueue = vi.fn();
const mockApproveKyc = vi.fn();
const mockRejectKyc = vi.fn();
vi.mock('@/lib/admin-api', () => ({
adminApi: {
getKycQueue: (...args: unknown[]) => mockGetKycQueue(...args),
approveKyc: (...args: unknown[]) => mockApproveKyc(...args),
rejectKyc: (...args: unknown[]) => mockRejectKyc(...args),
},
}));
import AdminKycPage from '../kyc/page';
const mockQueueData = {
data: [
{
userId: 'u1',
fullName: 'Nguyen Van A',
phone: '0912345678',
email: 'a@test.com',
role: 'AGENT',
kycStatus: 'PENDING',
kycData: { idType: 'CCCD', idNumber: '012345678901', frontImageUrl: 'https://img.test/front.jpg' },
createdAt: '2024-06-15T10:00:00Z',
},
{
userId: 'u2',
fullName: 'Tran Thi B',
phone: '0987654321',
email: null,
role: 'USER',
kycStatus: 'PENDING',
kycData: null,
createdAt: '2024-06-16T10:00:00Z',
},
],
total: 2,
page: 1,
limit: 20,
totalPages: 1,
};
describe('AdminKycPage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetKycQueue.mockResolvedValue(mockQueueData);
mockApproveKyc.mockResolvedValue({});
mockRejectKyc.mockResolvedValue({});
});
it('renders heading and fetches queue', async () => {
render(<AdminKycPage />);
await waitFor(() => {
expect(screen.getByText('Duyệt KYC')).toBeInTheDocument();
});
expect(mockGetKycQueue).toHaveBeenCalledWith(1, 20);
});
it('renders queue items in table', async () => {
render(<AdminKycPage />);
await waitFor(() => {
expect(screen.getByText('Nguyen Van A')).toBeInTheDocument();
expect(screen.getByText('Tran Thi B')).toBeInTheDocument();
});
});
it('shows empty state when no requests', async () => {
mockGetKycQueue.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20, totalPages: 0 });
render(<AdminKycPage />);
await waitFor(() => {
expect(screen.getByText('Không có yêu cầu KYC nào đang chờ')).toBeInTheDocument();
});
});
it('shows error state when fetch fails', async () => {
mockGetKycQueue.mockRejectedValue(new Error('Network error'));
render(<AdminKycPage />);
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument();
});
expect(screen.getByText('Thử lại')).toBeInTheDocument();
});
it('refreshes queue on refresh button click', async () => {
render(<AdminKycPage />);
await waitFor(() => {
expect(screen.getByText('Nguyen Van A')).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: /làm mới/i }));
expect(mockGetKycQueue).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,120 @@
/* eslint-disable import-x/order */
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('next/dynamic', () => ({
default: () => {
const Mock = () => <div data-testid="chart-placeholder">Chart</div>;
Mock.displayName = 'MockChart';
return Mock;
},
}));
vi.mock('next/image', () => ({
default: (props: Record<string, unknown>) => <img {...props} />,
}));
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
}));
const mockUseMarketReport = vi.fn();
const mockUseHeatmap = vi.fn();
const mockUseListingsSearch = vi.fn();
vi.mock('@/lib/hooks/use-analytics', () => ({
useMarketReport: (...args: unknown[]) => mockUseMarketReport(...args),
useHeatmap: (...args: unknown[]) => mockUseHeatmap(...args),
}));
vi.mock('@/lib/hooks/use-listings', () => ({
useListingsSearch: (...args: unknown[]) => mockUseListingsSearch(...args),
}));
vi.mock('@/components/listings/listing-status-badge', () => ({
ListingStatusBadge: ({ status }: { status: string }) => <span data-testid="status-badge">{status}</span>,
}));
import DashboardPage from '../page';
const fullData = {
marketReport: {
districts: [
{ district: 'Quan 1', totalListings: 100, avgPriceM2: 120000000, medianPrice: '15000000000', daysOnMarket: 45, yoyChange: 5.2, inventoryLevel: 50 },
],
},
heatmap: { dataPoints: [{ district: 'Quan 1', avgPriceM2: 120000000, totalListings: 100, lat: 10.77, lng: 106.7 }] },
listings: {
data: [{
id: '1', status: 'ACTIVE', transactionType: 'SALE', priceVND: '5000000000', viewCount: 10,
saveCount: 2, inquiryCount: 3, publishedAt: '2024-01-01', createdAt: '2024-01-01',
pricePerM2: null, rentPriceMonthly: null, commissionPct: null,
property: {
id: 'p1', propertyType: 'APARTMENT', title: 'Căn hộ Quận 7', description: 'Test',
address: '123 Nguyễn Hữu Thọ', ward: 'Tân Hưng', district: 'Quận 7',
city: 'Hồ Chí Minh', areaM2: 75, bedrooms: 2, bathrooms: 2, floors: null,
direction: null, yearBuilt: null, legalStatus: null, amenities: null, projectName: null, media: [],
},
seller: { id: 's1', fullName: 'Nguyen Van A', phone: '0912345678' },
agent: null,
}],
total: 1, page: 1, limit: 6, totalPages: 1,
},
};
describe('DashboardPage — deep tests', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading state with placeholders', () => {
mockUseMarketReport.mockReturnValue({ data: undefined, isLoading: true });
mockUseHeatmap.mockReturnValue({ data: undefined, isLoading: true });
mockUseListingsSearch.mockReturnValue({ data: undefined, isLoading: true });
render(<DashboardPage />);
expect(screen.getByText('Bảng điều khiển')).toBeInTheDocument();
});
it('renders stat cards with computed values', () => {
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
render(<DashboardPage />);
expect(screen.getByText('Tin đăng của tôi')).toBeInTheDocument();
expect(screen.getByText('Lượt xem')).toBeInTheDocument();
expect(screen.getByText('Liên hệ')).toBeInTheDocument();
expect(screen.getByText('Giá TB thị trường')).toBeInTheDocument();
});
it('renders recent listings with property title', () => {
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
render(<DashboardPage />);
expect(screen.getByText('Tin đăng gần đây')).toBeInTheDocument();
expect(screen.getByText(/căn hộ quận 7/i)).toBeInTheDocument();
});
it('renders "Đăng tin mới" link', () => {
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
render(<DashboardPage />);
expect(screen.getByRole('link', { name: /đăng tin mới/i })).toBeInTheDocument();
});
it('renders empty listings state', () => {
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
mockUseListingsSearch.mockReturnValue({ data: { data: [], total: 0, page: 1, limit: 6, totalPages: 0 }, isLoading: false });
render(<DashboardPage />);
expect(screen.getByText('Bảng điều khiển')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /đăng tin mới/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,153 @@
/* 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', () => ({
Check: (props: Record<string, unknown>) => <span data-testid="check-icon" {...props} />,
}));
const mockFetchProfile = vi.fn();
const mockUseAuthStore = vi.fn();
vi.mock('@/lib/auth-store', () => ({
useAuthStore: (...args: unknown[]) => mockUseAuthStore(...args),
}));
vi.mock('@/lib/api-client', () => ({
apiClient: { patch: vi.fn().mockResolvedValue({}) },
}));
import KycPage from '../kyc/page';
function setupStore(overrides: Record<string, unknown> = {}) {
const store = {
user: {
id: 'user-1',
fullName: 'Nguyen Van A',
phone: '0912345678',
kycStatus: 'NONE',
...overrides,
},
fetchProfile: mockFetchProfile,
};
mockUseAuthStore.mockImplementation((selector?: (s: typeof store) => unknown) => {
if (typeof selector === 'function') return selector(store);
return store;
});
}
describe('KycPage — deep tests', () => {
beforeEach(() => {
vi.clearAllMocks();
setupStore();
});
it('renders heading and NONE status', () => {
render(<KycPage />);
expect(screen.getByText('Xác minh danh tính (KYC)')).toBeInTheDocument();
expect(screen.getByText('Chưa xác minh')).toBeInTheDocument();
});
it('renders step 1 with document type selector and number input', () => {
render(<KycPage />);
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
expect(screen.getByLabelText(/số giấy tờ/i)).toBeInTheDocument();
});
it('shows error and stays on step 1 when doc number is empty', async () => {
render(<KycPage />);
await userEvent.click(screen.getByTestId('kyc-next-button'));
await waitFor(() => {
expect(screen.getByText(/vui lòng nhập số giấy tờ/i)).toBeInTheDocument();
});
});
it('advances from step 1 → step 2 after filling doc number', async () => {
render(<KycPage />);
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
await userEvent.click(screen.getByTestId('kyc-next-button'));
await waitFor(() => {
expect(screen.getByLabelText(/ảnh mặt trước/i)).toBeInTheDocument();
});
});
it('shows error on step 2 when front image is missing', async () => {
render(<KycPage />);
// Step 1 → 2
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
await userEvent.click(screen.getByTestId('kyc-next-button'));
await waitFor(() => {
expect(screen.getByTestId('kyc-front-input')).toBeInTheDocument();
});
// Try to advance without uploading
await userEvent.click(screen.getByTestId('kyc-next-button'));
await waitFor(() => {
expect(screen.getByText(/vui lòng tải ảnh mặt trước/i)).toBeInTheDocument();
});
});
it('goes back from step 2 → step 1', async () => {
render(<KycPage />);
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
await userEvent.click(screen.getByTestId('kyc-next-button'));
await waitFor(() => {
expect(screen.getByTestId('kyc-back-button')).toBeInTheDocument();
});
await userEvent.click(screen.getByTestId('kyc-back-button'));
await waitFor(() => {
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
});
});
it('renders VERIFIED state without form', () => {
setupStore({ kycStatus: 'VERIFIED' });
render(<KycPage />);
expect(screen.getByText('Đã xác minh')).toBeInTheDocument();
expect(screen.getByText('Danh tính đã được xác minh')).toBeInTheDocument();
expect(screen.queryByTestId('kyc-next-button')).not.toBeInTheDocument();
});
it('renders PENDING state without form', () => {
setupStore({ kycStatus: 'PENDING' });
render(<KycPage />);
expect(screen.getByText('Đang chờ duyệt')).toBeInTheDocument();
expect(screen.getByText('Đang xem xét hồ sơ')).toBeInTheDocument();
expect(screen.queryByTestId('kyc-next-button')).not.toBeInTheDocument();
});
it('renders REJECTED state with form available', () => {
setupStore({ kycStatus: 'REJECTED' });
render(<KycPage />);
expect(screen.getByText('Bị từ chối')).toBeInTheDocument();
// Form should still show for resubmission
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
});
it('dismisses error when close button is clicked', async () => {
render(<KycPage />);
await userEvent.click(screen.getByTestId('kyc-next-button'));
await waitFor(() => {
expect(screen.getByTestId('kyc-error')).toBeInTheDocument();
});
await userEvent.click(screen.getByText('Đóng'));
expect(screen.queryByTestId('kyc-error')).not.toBeInTheDocument();
});
it('changes document type via select', async () => {
render(<KycPage />);
const select = screen.getByLabelText(/loại giấy tờ/i);
await userEvent.selectOptions(select, 'PASSPORT');
expect(select).toHaveValue('PASSPORT');
});
});

View File

@@ -0,0 +1,134 @@
/* 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';
const mockUseTransactions = vi.fn();
const mockTransactions = {
items: [
{
id: 'tx-1',
type: 'SUBSCRIPTION',
status: 'COMPLETED',
amountVND: '499000',
provider: 'VNPAY',
providerTxId: 'TXN123456789012',
createdAt: '2024-06-15T10:00:00.000Z',
},
{
id: 'tx-2',
type: 'LISTING_FEE',
status: 'PENDING',
amountVND: '100000',
provider: 'MOMO',
providerTxId: null,
createdAt: '2024-06-20T10:00:00.000Z',
},
{
id: 'tx-3',
type: 'FEATURED_LISTING',
status: 'FAILED',
amountVND: '200000',
provider: 'ZALOPAY',
providerTxId: 'ZLP999',
createdAt: '2024-06-21T10:00:00.000Z',
},
],
total: 3,
};
vi.mock('@/lib/hooks/use-payments', () => ({
useTransactions: (...args: unknown[]) => mockUseTransactions(...args),
}));
vi.mock('@/lib/currency', () => ({
formatVND: (amount: string | number) => `${Number(amount).toLocaleString()} đ`,
}));
import PaymentsPage from '../payments/page';
describe('PaymentsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseTransactions.mockReturnValue({ data: mockTransactions, isLoading: false });
});
it('renders payment page heading and description', () => {
render(<PaymentsPage />);
expect(screen.getByRole('heading', { level: 1, name: 'Thanh toán' })).toBeInTheDocument();
expect(screen.getAllByText(/lịch sử giao dịch/i).length).toBeGreaterThanOrEqual(1);
});
it('renders summary cards with correct values', () => {
render(<PaymentsPage />);
expect(screen.getByText('Tổng giao dịch')).toBeInTheDocument();
expect(screen.getByText('Đã thanh toán')).toBeInTheDocument();
expect(screen.getByText('Đang chờ')).toBeInTheDocument();
});
it('renders transaction table with type/provider/status labels', () => {
render(<PaymentsPage />);
// Type labels appear in desktop table + mobile cards, so use getAllByText
expect(screen.getAllByText('Gói dịch vụ').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Phí đăng tin').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Tin nổi bật').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Thành công').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Chờ xử lý').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Thất bại').length).toBeGreaterThanOrEqual(1);
});
it('renders loading state', () => {
mockUseTransactions.mockReturnValue({ data: undefined, isLoading: true });
render(<PaymentsPage />);
expect(screen.getAllByText('...').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('Đang tải...')).toBeInTheDocument();
});
it('renders empty state', () => {
mockUseTransactions.mockReturnValue({ data: { items: [], total: 0 }, isLoading: false });
render(<PaymentsPage />);
expect(screen.getByText('Chưa có giao dịch nào')).toBeInTheDocument();
});
it('changes status filter via select', async () => {
render(<PaymentsPage />);
const select = screen.getByDisplayValue('Tất cả');
await userEvent.selectOptions(select, 'COMPLETED');
expect(mockUseTransactions).toHaveBeenCalledWith(
expect.objectContaining({ status: 'COMPLETED' }),
);
});
it('truncates long providerTxId', () => {
render(<PaymentsPage />);
expect(screen.getByText('TXN123456789...')).toBeInTheDocument();
});
it('shows dash for missing providerTxId', () => {
render(<PaymentsPage />);
expect(screen.getByText('—')).toBeInTheDocument();
});
it('renders pagination when more than 1 page', () => {
// 25 total with limit 20 = 2 pages
const manyItems = Array.from({ length: 20 }, (_, i) => ({
id: `tx-${i}`,
type: 'SUBSCRIPTION',
status: 'COMPLETED',
amountVND: '100000',
provider: 'VNPAY',
providerTxId: null,
createdAt: '2024-06-15T10:00:00.000Z',
}));
mockUseTransactions.mockReturnValue({
data: { items: manyItems, total: 25 },
isLoading: false,
});
render(<PaymentsPage />);
expect(screen.getByText(/trang 1\/2/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /trước/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /sau/i })).not.toBeDisabled();
});
});

View File

@@ -64,8 +64,49 @@ function SearchContent() {
const [saveAlertEnabled, setSaveAlertEnabled] = React.useState(true);
const [saveSuccess, setSaveSuccess] = React.useState(false);
const saveDialogRef = React.useRef<HTMLDivElement>(null);
const saveButtonRef = React.useRef<HTMLButtonElement>(null);
const saveNameInputRef = React.useRef<HTMLInputElement>(null);
const createSavedSearch = useCreateSavedSearch();
// Focus management for save-search dialog
React.useEffect(() => {
if (showSaveDialog) {
saveNameInputRef.current?.focus();
}
}, [showSaveDialog]);
// Focus trap + Escape key for save-search dialog
React.useEffect(() => {
if (!showSaveDialog) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowSaveDialog(false);
saveButtonRef.current?.focus();
return;
}
if (e.key === 'Tab') {
const dialog = saveDialogRef.current;
if (!dialog) return;
const focusable = dialog.querySelectorAll<HTMLElement>(
'button, input, [tabindex]:not([tabindex="-1"])',
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [showSaveDialog]);
const handleMarkerClick = (listing: ListingDetail) => {
setSelectedListingId(listing.id);
};
@@ -163,11 +204,15 @@ function SearchContent() {
{activeFilterCount > 0 && (
<div className="relative">
<Button
ref={saveButtonRef}
variant="outline"
size="sm"
onClick={() => setShowSaveDialog(!showSaveDialog)}
aria-expanded={showSaveDialog}
aria-controls="save-search-dialog"
aria-haspopup="dialog"
>
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
Lưu tìm kiếm
@@ -175,10 +220,17 @@ function SearchContent() {
{/* Save search dialog */}
{showSaveDialog && (
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg">
<div
id="save-search-dialog"
ref={saveDialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="save-search-heading"
className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg"
>
{saveSuccess ? (
<div className="flex items-center gap-2 text-green-600">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm font-medium">Đã lưu tìm kiếm!</span>
@@ -188,6 +240,7 @@ function SearchContent() {
<h3 className="mb-3 font-medium" id="save-search-heading">Lưu bộ lọc tìm kiếm</h3>
<label htmlFor="save-search-name" className="sr-only">Tên tìm kiếm</label>
<input
ref={saveNameInputRef}
id="save-search-name"
type="text"
value={saveName}
@@ -246,8 +299,9 @@ function SearchContent() {
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
aria-pressed={viewMode === 'list'}
>
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
Danh sách
@@ -256,8 +310,9 @@ function SearchContent() {
variant={viewMode === 'map' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('map')}
aria-pressed={viewMode === 'map'}
>
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
Bản đ
@@ -267,8 +322,9 @@ function SearchContent() {
size="sm"
className="hidden lg:flex"
onClick={() => setViewMode('split')}
aria-pressed={viewMode === 'split'}
>
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
</svg>
Chia đôi
@@ -280,8 +336,10 @@ function SearchContent() {
size="sm"
className="lg:hidden"
onClick={() => setShowMobileFilters(!showMobileFilters)}
aria-expanded={showMobileFilters}
aria-controls="mobile-filter-panel"
>
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
Bộ lọc
@@ -305,7 +363,7 @@ function SearchContent() {
{/* Mobile filter panel */}
{showMobileFilters && (
<div className="mb-4 rounded-lg border bg-card p-4 lg:hidden">
<div id="mobile-filter-panel" className="mb-4 rounded-lg border bg-card p-4 lg:hidden">
<FilterBar
filters={filters}
onChange={handleFilterChange}
@@ -392,7 +450,11 @@ export default function SearchPage() {
return (
<React.Suspense
fallback={
<div className="flex min-h-[400px] items-center justify-center">
<div
role="status"
aria-label="Đang tải..."
className="flex min-h-[400px] items-center justify-center"
>
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
}

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