diff --git a/app/frontend/src/app/[locale]/campaigns/__tests__/page.test.tsx b/app/frontend/src/app/[locale]/campaigns/__tests__/page.test.tsx new file mode 100644 index 00000000..1f0718c7 --- /dev/null +++ b/app/frontend/src/app/[locale]/campaigns/__tests__/page.test.tsx @@ -0,0 +1,225 @@ +/** @jest-environment jsdom */ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import CampaignsPage from '../page'; +import { useCampaigns, useCreateCampaign } from '@/hooks/useCampaigns'; +import { useCampaignAction } from '@/hooks/useOptimisticCampaignMutations'; +import { + canManageCampaigns, + getUserRole, + getUserRoleLabel, +} from '@/lib/user-role'; +import type { Campaign } from '@/types/campaign'; + +const replace = jest.fn(); +let searchParams = new URLSearchParams(); + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ replace }), + useSearchParams: () => searchParams, +})); + +jest.mock('next/link', () => ({ + __esModule: true, + default: ({ + href, + children, + className, + }: { + href: string; + children: React.ReactNode; + className?: string; + }) => ( + + {children} + + ), +})); + +jest.mock('@/components/dashboard/ExportControls', () => ({ + ExportControls: ({ context }: { context: string }) => ( +
+ ), +})); + +jest.mock('@/hooks/useCampaigns', () => ({ + useCampaigns: jest.fn(), + useCreateCampaign: jest.fn(), +})); + +jest.mock('@/hooks/useOptimisticCampaignMutations', () => ({ + useCampaignAction: jest.fn(), + useCampaignActions: jest.fn(), +})); + +jest.mock('@/lib/user-role', () => ({ + canManageCampaigns: jest.fn(), + getUserRole: jest.fn(), + getUserRoleLabel: jest.fn(), +})); + +const mockUseCampaigns = useCampaigns as jest.MockedFunction< + typeof useCampaigns +>; +const mockUseCreateCampaign = useCreateCampaign as jest.MockedFunction< + typeof useCreateCampaign +>; +const mockUseCampaignAction = useCampaignAction as jest.MockedFunction< + typeof useCampaignAction +>; +const mockCanManageCampaigns = canManageCampaigns as jest.MockedFunction< + typeof canManageCampaigns +>; +const mockGetUserRole = getUserRole as jest.MockedFunction; +const mockGetUserRoleLabel = getUserRoleLabel as jest.MockedFunction< + typeof getUserRoleLabel +>; + +const activeCampaign: Campaign = { + id: 'campaign-1', + name: 'Flood Relief', + budget: 15000, + status: 'active', + metadata: { token: 'USDC', expiry: '2026-12-31T00:00:00.000Z' }, +}; + +const pausedCampaign: Campaign = { + ...activeCampaign, + id: 'campaign-2', + name: 'Shelter Supplies', + status: 'paused', +}; + +const mutate = jest.fn(); +const mutateAsync = jest.fn(); + +function mockPageState({ + campaigns = [activeCampaign], + isLoading = false, + isError = false, + error = null, +}: { + campaigns?: Campaign[]; + isLoading?: boolean; + isError?: boolean; + error?: Error | null; +} = {}) { + mockUseCampaigns.mockReturnValue({ + data: campaigns, + isLoading, + isError, + error, + } as ReturnType); + mockUseCreateCampaign.mockReturnValue({ + mutateAsync, + isPending: false, + } as ReturnType); + mockUseCampaignAction.mockReturnValue({ + mutate, + isPending: false, + variables: undefined, + } as ReturnType); +} + +describe('CampaignsPage', () => { + beforeEach(() => { + searchParams = new URLSearchParams(); + replace.mockClear(); + mutate.mockClear(); + mutateAsync.mockResolvedValue(activeCampaign); + mutateAsync.mockClear(); + mockCanManageCampaigns.mockReturnValue(true); + mockGetUserRole.mockReturnValue('admin'); + mockGetUserRoleLabel.mockReturnValue('Admin'); + mockPageState(); + }); + + it('renders loading, error, and empty campaign states', () => { + mockPageState({ campaigns: [], isLoading: true }); + const { rerender } = render(); + expect(screen.getByText('Loading campaigns...')).toBeInTheDocument(); + + mockPageState({ + campaigns: [], + isError: true, + error: new Error('Campaign API failed'), + }); + rerender(); + expect( + screen.getByText('Error fetching campaigns: Campaign API failed'), + ).toBeInTheDocument(); + + mockPageState({ campaigns: [] }); + rerender(); + expect( + screen.getByText('There are no active campaigns to review'), + ).toBeInTheDocument(); + }); + + it('creates a campaign from the form payload', async () => { + render(); + + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Medical Supplies' }, + }); + fireEvent.change(screen.getByLabelText('Budget (USD)'), { + target: { value: '4200' }, + }); + fireEvent.change(screen.getByLabelText('Token'), { + target: { value: 'EURC' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create campaign' })); + + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledWith({ + name: 'Medical Supplies', + budget: 4200, + status: 'active', + metadata: { + token: 'EURC', + expiry: undefined, + }, + }); + }); + expect( + await screen.findByText('Campaign created successfully.'), + ).toBeInTheDocument(); + }); + + it('filters campaigns by URL status and updates the URL when a preset is applied', () => { + searchParams = new URLSearchParams('status=paused'); + mockPageState({ campaigns: [activeCampaign, pausedCampaign] }); + + render(); + + expect(screen.queryByText('Flood Relief')).not.toBeInTheDocument(); + expect(screen.getByText('Shelter Supplies')).toBeInTheDocument(); + }); + + it('dispatches pause, resume, and archive campaign actions', () => { + mockPageState({ campaigns: [activeCampaign, pausedCampaign] }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Pause' })); + expect(mutate).toHaveBeenCalledWith({ + id: 'campaign-1', + campaignName: 'Flood Relief', + action: { type: 'pause', targetStatus: 'paused' }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Resume' })); + expect(mutate).toHaveBeenCalledWith({ + id: 'campaign-2', + campaignName: 'Shelter Supplies', + action: { type: 'resume', targetStatus: 'active' }, + }); + + fireEvent.click(screen.getAllByRole('button', { name: 'Archive' })[0]); + expect(mutate).toHaveBeenCalledWith({ + id: 'campaign-1', + campaignName: 'Flood Relief', + action: { type: 'archive', targetStatus: 'archived' }, + }); + }); +}); diff --git a/app/frontend/src/components/__tests__/WalletConnect.test.tsx b/app/frontend/src/components/__tests__/WalletConnect.test.tsx new file mode 100644 index 00000000..21b57589 --- /dev/null +++ b/app/frontend/src/components/__tests__/WalletConnect.test.tsx @@ -0,0 +1,167 @@ +/** @jest-environment jsdom */ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + getAddress, + getNetworkDetails, + isConnected, + setAllowed, +} from '@stellar/freighter-api'; +import { WalletConnect } from '../WalletConnect'; +import { useWalletStore } from '@/lib/walletStore'; +import { useToast } from '../ToastProvider'; +import { useNetworkGuard } from '@/hooks/useNetworkGuard'; + +jest.mock('@stellar/freighter-api', () => ({ + isConnected: jest.fn(), + setAllowed: jest.fn(), + getAddress: jest.fn(), + getNetworkDetails: jest.fn(), +})); + +jest.mock('@/lib/walletStore', () => ({ + useWalletStore: jest.fn(), +})); + +jest.mock('../ToastProvider', () => ({ + useToast: jest.fn(), +})); + +jest.mock('@/hooks/useNetworkGuard', () => ({ + useNetworkGuard: jest.fn(), +})); + +jest.mock('@/lib/explorer', () => ({ + buildExplorerUrl: (_type: string, identifier: string) => + `https://stellar.expert/explorer/testnet/address/${identifier}`, +})); + +const mockIsConnected = isConnected as jest.MockedFunction; +const mockSetAllowed = setAllowed as jest.MockedFunction; +const mockGetAddress = getAddress as jest.MockedFunction; +const mockGetNetworkDetails = getNetworkDetails as jest.MockedFunction< + typeof getNetworkDetails +>; +const mockUseWalletStore = useWalletStore as unknown as jest.MockedFunction< + () => { + publicKey: string | null; + network: string | null; + setPublicKey: jest.Mock; + setNetwork: jest.Mock; + disconnect: jest.Mock; + } +>; +const mockUseToast = useToast as jest.MockedFunction; +const mockUseNetworkGuard = useNetworkGuard as jest.MockedFunction< + typeof useNetworkGuard +>; + +const toast = jest.fn(); +const setPublicKey = jest.fn(); +const setNetwork = jest.fn(); +const disconnect = jest.fn(); +let walletState = { + publicKey: null as string | null, + network: null as string | null, +}; + +function mockWallet() { + mockUseWalletStore.mockReturnValue({ + ...walletState, + setPublicKey, + setNetwork, + disconnect, + }); + mockUseToast.mockReturnValue({ toast } as ReturnType); + mockUseNetworkGuard.mockReturnValue({ + isCorrectNetwork: true, + isMismatch: false, + walletNetwork: walletState.network, + expectedNetwork: 'TESTNET', + }); +} + +describe('WalletConnect', () => { + beforeEach(() => { + Object.defineProperty(window, 'FreighterApi', { + configurable: true, + value: {}, + }); + walletState = { publicKey: null, network: null }; + toast.mockClear(); + setPublicKey.mockClear(); + setNetwork.mockClear(); + disconnect.mockClear(); + mockIsConnected.mockResolvedValue({ isConnected: false }); + mockSetAllowed.mockResolvedValue(undefined); + mockGetAddress.mockResolvedValue({ + address: 'GABC1234567890XYZ', + }); + mockGetNetworkDetails.mockResolvedValue({ network: 'TESTNET' }); + mockWallet(); + }); + + it('connects Freighter and stores the returned address and network', async () => { + render(); + + fireEvent.click( + await screen.findByRole('button', { name: 'Connect Freighter Wallet' }), + ); + + await waitFor(() => { + expect(mockSetAllowed).toHaveBeenCalled(); + expect(setPublicKey).toHaveBeenCalledWith('GABC1234567890XYZ'); + expect(setNetwork).toHaveBeenCalledWith('TESTNET'); + expect(toast).toHaveBeenCalledWith( + 'Wallet Connected', + 'Successfully connected to Freighter', + 'success', + ); + }); + }); + + it('renders connected wallet state and disconnects locally', async () => { + walletState = { + publicKey: 'GCONNECTED1234567890', + network: 'PUBLIC', + }; + mockIsConnected.mockResolvedValue({ isConnected: true }); + mockGetAddress.mockResolvedValue({ address: walletState.publicKey }); + mockGetNetworkDetails.mockResolvedValue({ network: 'PUBLIC' }); + mockWallet(); + + render(); + + expect(await screen.findByText('GCON...7890')).toBeInTheDocument(); + expect(screen.getByText('PUBLIC')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })); + + expect(disconnect).toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith( + 'Disconnected', + 'Wallet has been disconnected', + 'info', + ); + }); + + it('shows a retryable error when the user rejects connection', async () => { + mockSetAllowed.mockRejectedValue(new Error('User declined')); + + render(); + + fireEvent.click( + await screen.findByRole('button', { name: 'Connect Freighter Wallet' }), + ); + + expect( + await screen.findByText('Connection cancelled by user.'), + ).toBeInTheDocument(); + expect(setPublicKey).toHaveBeenCalledWith(null); + expect(toast).toHaveBeenCalledWith( + 'Connection Rejected', + 'Connection cancelled by user.', + 'error', + ); + }); +}); diff --git a/app/frontend/src/components/dashboard/__tests__/FilteredPackageList.test.tsx b/app/frontend/src/components/dashboard/__tests__/FilteredPackageList.test.tsx new file mode 100644 index 00000000..051521d4 --- /dev/null +++ b/app/frontend/src/components/dashboard/__tests__/FilteredPackageList.test.tsx @@ -0,0 +1,118 @@ +/** @jest-environment jsdom */ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { FilteredPackageList } from '../FilteredPackageList'; +import { useAidPackages } from '@/hooks/useAidPackages'; +import { getAppUserRole } from '@/lib/app-role'; +import type { AidPackage } from '@/types/aid-package'; + +jest.mock('@/hooks/useAidPackages', () => ({ + useAidPackages: jest.fn(), +})); + +jest.mock('@/lib/app-role', () => ({ + getAppUserRole: jest.fn(), + isOperationsRole: (role: string) => role === 'operator', +})); + +jest.mock('next/link', () => ({ + __esModule: true, + default: ({ + href, + children, + className, + }: { + href: string; + children: React.ReactNode; + className?: string; + }) => ( + + {children} + + ), +})); + +const mockUseAidPackages = useAidPackages as jest.MockedFunction< + typeof useAidPackages +>; +const mockGetAppUserRole = getAppUserRole as jest.MockedFunction< + typeof getAppUserRole +>; + +const aidPackage: AidPackage = { + id: 'aid-001', + title: 'Winter Relief', + region: 'North District', + amount: '$2,500', + recipients: 14, + status: 'Active', + token: 'USDC', +}; + +describe('FilteredPackageList', () => { + beforeEach(() => { + mockGetAppUserRole.mockReturnValue('operator'); + }); + + it('renders loading rows while aid packages are loading', () => { + mockUseAidPackages.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as ReturnType); + + const { container } = render(); + + expect(container.querySelectorAll('tbody tr')).toHaveLength(3); + expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan( + 0, + ); + }); + + it('renders an error state when the aid package query fails', () => { + mockUseAidPackages.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Backend unavailable'), + } as ReturnType); + + render(); + + expect( + screen.getByText('Error loading packages: Backend unavailable'), + ).toBeInTheDocument(); + }); + + it('renders an empty state with filtered guidance when filters return no rows', () => { + mockUseAidPackages.mockReturnValue({ + data: [], + isLoading: false, + error: null, + } as ReturnType); + + render(); + + expect( + screen.getAllByText('No aid packages match the current filters')[0], + ).toBeInTheDocument(); + expect(screen.getAllByText('Reset dashboard filters')[0]).toHaveAttribute( + 'href', + '/dashboard', + ); + }); + + it('renders package data in the desktop table and mobile cards', () => { + mockUseAidPackages.mockReturnValue({ + data: [aidPackage], + isLoading: false, + error: null, + } as ReturnType); + + render(); + + expect(screen.getAllByText('Winter Relief')).toHaveLength(2); + expect(screen.getAllByText('North District')).toHaveLength(2); + expect(screen.getAllByText('USDC')).toHaveLength(2); + expect(screen.getAllByText('aid-001')).toHaveLength(2); + }); +}); diff --git a/app/frontend/src/components/verification-review/__tests__/ReviewActionDialog.test.tsx b/app/frontend/src/components/verification-review/__tests__/ReviewActionDialog.test.tsx new file mode 100644 index 00000000..e5f2931f --- /dev/null +++ b/app/frontend/src/components/verification-review/__tests__/ReviewActionDialog.test.tsx @@ -0,0 +1,159 @@ +/** @jest-environment jsdom */ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ReviewActionDialog } from '../ReviewActionDialog'; +import { + useApproveVerification, + useRejectVerification, + useRequestResubmission, +} from '@/hooks/useVerificationInbox'; + +jest.mock('@/hooks/useVerificationInbox', () => ({ + useApproveVerification: jest.fn(), + useRejectVerification: jest.fn(), + useRequestResubmission: jest.fn(), +})); + +const mockUseApproveVerification = + useApproveVerification as jest.MockedFunction< + typeof useApproveVerification + >; +const mockUseRejectVerification = useRejectVerification as jest.MockedFunction< + typeof useRejectVerification +>; +const mockUseRequestResubmission = + useRequestResubmission as jest.MockedFunction< + typeof useRequestResubmission + >; + +const approveMutateAsync = jest.fn(); +const rejectMutateAsync = jest.fn(); +const resubmitMutateAsync = jest.fn(); + +function mockMutations() { + mockUseApproveVerification.mockReturnValue({ + mutateAsync: approveMutateAsync, + isPending: false, + } as ReturnType); + mockUseRejectVerification.mockReturnValue({ + mutateAsync: rejectMutateAsync, + isPending: false, + } as ReturnType); + mockUseRequestResubmission.mockReturnValue({ + mutateAsync: resubmitMutateAsync, + isPending: false, + } as ReturnType); +} + +describe('ReviewActionDialog', () => { + beforeEach(() => { + approveMutateAsync.mockReset(); + rejectMutateAsync.mockReset(); + resubmitMutateAsync.mockReset(); + approveMutateAsync.mockResolvedValue(undefined); + rejectMutateAsync.mockResolvedValue(undefined); + resubmitMutateAsync.mockResolvedValue(undefined); + mockMutations(); + }); + + it('approves a verification with optional reviewer messages', async () => { + const onOpenChange = jest.fn(); + render( + , + ); + + fireEvent.change(screen.getByPlaceholderText('Instructions shown to the applicant'), { + target: { value: 'Payment can proceed.' }, + }); + fireEvent.change(screen.getByPlaceholderText('Private note for the review team'), { + target: { value: 'Matched government ID.' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Approve' })); + + await waitFor(() => { + expect(approveMutateAsync).toHaveBeenCalledWith({ + id: 'ver-201', + payload: { + nextStepMessage: 'Payment can proceed.', + internalNote: 'Matched government ID.', + }, + }); + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('requires a rejection reason before submitting', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Reject' })); + + expect(screen.getByText('A reason is required.')).toBeInTheDocument(); + expect(rejectMutateAsync).not.toHaveBeenCalled(); + }); + + it('submits rejection and resubmission payloads through the correct hooks', async () => { + const { rerender } = render( + , + ); + + fireEvent.change(screen.getByPlaceholderText('e.g. Document appears fraudulent'), { + target: { value: 'Document image is unreadable.' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Reject' })); + + await waitFor(() => { + expect(rejectMutateAsync).toHaveBeenCalledWith({ + id: 'ver-203', + payload: { + rejectionReason: 'Document image is unreadable.', + nextStepMessage: undefined, + internalNote: undefined, + }, + }); + }); + + rerender( + , + ); + + fireEvent.change(screen.getByPlaceholderText('e.g. ID document is expired'), { + target: { value: 'ID document is expired.' }, + }); + fireEvent.click( + screen.getByRole('button', { name: 'Request Resubmission' }), + ); + + await waitFor(() => { + expect(resubmitMutateAsync).toHaveBeenCalledWith({ + id: 'ver-204', + payload: { + rejectionReason: 'ID document is expired.', + nextStepMessage: 'Please resubmit the required documents.', + internalNote: undefined, + }, + }); + }); + }); +}); diff --git a/app/frontend/src/components/verification-review/__tests__/ReviewFiltersBar.test.tsx b/app/frontend/src/components/verification-review/__tests__/ReviewFiltersBar.test.tsx new file mode 100644 index 00000000..bf0bc563 --- /dev/null +++ b/app/frontend/src/components/verification-review/__tests__/ReviewFiltersBar.test.tsx @@ -0,0 +1,64 @@ +/** @jest-environment jsdom */ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ReviewFiltersBar } from '../ReviewFiltersBar'; +import type { ReviewFilters } from '@/types/verification-review'; + +const baseFilters: ReviewFilters = { + status: '', + riskLevel: '', + dateFrom: '', + dateTo: '', + page: 1, +}; + +describe('ReviewFiltersBar', () => { + it('updates date filters and resets pagination', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + const [fromInput, toInput] = container.querySelectorAll( + 'input[type="date"]', + ); + + fireEvent.change(fromInput, { target: { value: '2026-07-01' } }); + fireEvent.change(toInput, { target: { value: '2026-07-05' } }); + + expect(onChange).toHaveBeenNthCalledWith(1, { + dateFrom: '2026-07-01', + page: 1, + }); + expect(onChange).toHaveBeenNthCalledWith(2, { + dateTo: '2026-07-05', + page: 1, + }); + }); + + it('clears active status, risk, and date filters', () => { + const onChange = jest.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + + expect(onChange).toHaveBeenCalledWith({ + status: '', + riskLevel: '', + dateFrom: '', + dateTo: '', + page: 1, + }); + }); +}); diff --git a/app/frontend/src/components/verification-review/__tests__/ReviewQueue.test.tsx b/app/frontend/src/components/verification-review/__tests__/ReviewQueue.test.tsx new file mode 100644 index 00000000..ad298f23 --- /dev/null +++ b/app/frontend/src/components/verification-review/__tests__/ReviewQueue.test.tsx @@ -0,0 +1,140 @@ +/** @jest-environment jsdom */ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ReviewQueue } from '../ReviewQueue'; +import { useInbox } from '@/hooks/useVerificationInbox'; +import type { + ReviewFilters, + VerificationInboxItem, + VerificationInboxResponse, +} from '@/types/verification-review'; + +jest.mock('@/hooks/useVerificationInbox', () => ({ + useInbox: jest.fn(), +})); + +jest.mock('../VerificationDetailPanel', () => ({ + VerificationDetailPanel: ({ + verificationId, + onClose, + }: { + verificationId: string; + onClose: () => void; + }) => ( +
+ Detail for {verificationId} + +
+ ), +})); + +const mockUseInbox = useInbox as jest.MockedFunction; + +const filters: ReviewFilters = { + status: '', + riskLevel: '', + dateFrom: '', + dateTo: '', + page: 1, +}; + +const inboxItem: VerificationInboxItem = { + id: 'ver-101', + status: 'pending_review', + createdAt: '2026-07-01T12:00:00.000Z', + reviewedAt: null, + reviewedBy: null, + rejectionReason: null, + nextStepMessage: 'Confirm identity document', + deepLink: '/verifications/ver-101', + riskLevel: 'high', +}; + +const response: VerificationInboxResponse = { + items: [inboxItem], + total: 7, + page: 2, + limit: 5, + totalPages: 3, +}; + +describe('ReviewQueue', () => { + beforeEach(() => { + mockUseInbox.mockReset(); + }); + + it('renders loading placeholders while the verification queue is loading', () => { + mockUseInbox.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + error: null, + } as ReturnType); + + const { container } = render( + , + ); + + expect(container.querySelectorAll('.animate-pulse .h-16')).toHaveLength(5); + }); + + it('renders queue errors from the inbox hook', () => { + mockUseInbox.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('Inbox unavailable'), + } as ReturnType); + + render(); + + expect( + screen.getByText('Failed to load queue: Inbox unavailable'), + ).toBeInTheDocument(); + }); + + it('renders an empty queue message when filters have no matches', () => { + mockUseInbox.mockReturnValue({ + data: { ...response, items: [], total: 0, totalPages: 1 }, + isLoading: false, + isError: false, + error: null, + } as ReturnType); + + render(); + + expect( + screen.getByText('No verification cases match the current filters.'), + ).toBeInTheDocument(); + }); + + it('renders queue rows, opens details, and pages through results', () => { + const onPageChange = jest.fn(); + mockUseInbox.mockReturnValue({ + data: response, + isLoading: false, + isError: false, + error: null, + } as ReturnType); + + const { container } = render( + , + ); + + fireEvent.click(screen.getByText('ver-101')); + expect(screen.getByTestId('verification-detail')).toHaveTextContent( + 'Detail for ver-101', + ); + expect(screen.getByText('Confirm identity document')).toBeInTheDocument(); + expect(screen.getByText('Page 2 of 3 ยท 7 total')).toBeInTheDocument(); + + const pagerButtons = container.querySelectorAll('button.h-8'); + fireEvent.click(pagerButtons[0]); + fireEvent.click(pagerButtons[1]); + + expect(onPageChange).toHaveBeenNthCalledWith(1, 1); + expect(onPageChange).toHaveBeenNthCalledWith(2, 3); + }); +});