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,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