From c83b584de83e9feccca762fbece392d08d4c9efd Mon Sep 17 00:00:00 2001 From: go165 <196723798+go165@users.noreply.github.com> Date: Thu, 18 Jun 2026 07:58:10 +0800 Subject: [PATCH] test: cover confirmation modal flow --- .../__tests__/ConfirmationModal.test.tsx | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 src/components/__tests__/ConfirmationModal.test.tsx diff --git a/src/components/__tests__/ConfirmationModal.test.tsx b/src/components/__tests__/ConfirmationModal.test.tsx new file mode 100644 index 0000000..c47e431 --- /dev/null +++ b/src/components/__tests__/ConfirmationModal.test.tsx @@ -0,0 +1,190 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConfirmationModal } from '../ConfirmationModal'; + +const focusTrapState = vi.hoisted(() => ({ + options: undefined as undefined | { + allowOutsideClick?: boolean; + escapeDeactivates?: boolean; + onDeactivate?: () => void; + }, +})); + +vi.mock('focus-trap-react', () => ({ + default: ({ + children, + focusTrapOptions, + }: { + children: React.ReactNode; + focusTrapOptions?: typeof focusTrapState.options; + }) => { + focusTrapState.options = focusTrapOptions; + return
{children}
; + }, +})); + +const baseProps = { + isOpen: true, + onClose: vi.fn(), + onConfirm: vi.fn(), +}; + +function renderModal(props: Partial> = {}) { + const mergedProps = { + ...baseProps, + onClose: vi.fn(), + onConfirm: vi.fn(), + ...props, + }; + + render(); + return mergedProps; +} + +describe('ConfirmationModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + focusTrapState.options = undefined; + }); + + it('does not render the dialog while closed', () => { + renderModal({ isOpen: false }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByText('Confirm Validation')).not.toBeInTheDocument(); + }); + + it('starts with an explicit neutral state when no decision is selected', () => { + const props = renderModal(); + const dialog = screen.getByRole('dialog', { name: 'Confirm Validation' }); + + const confirmButton = screen.getByRole('button', { name: 'Confirm' }); + expect(confirmButton).toBeDisabled(); + expect(confirmButton).toHaveClass('bg-blue-600'); + expect(screen.queryByText('Irreversible Action')).not.toBeInTheDocument(); + expect(screen.queryByText('Action Notification')).not.toBeInTheDocument(); + + fireEvent.click(confirmButton); + + expect(props.onConfirm).not.toHaveBeenCalled(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'Approve' })); + + expect(screen.getByText('Irreversible Action')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Confirm Approve' })).not.toBeDisabled(); + }); + + it('renders the approval flow and confirms with notes', () => { + const props = renderModal({ initialDecision: 'approve', initialNotes: 'Looks complete.' }); + + expect(screen.getByRole('dialog', { name: 'Confirm Validation' })).toBeInTheDocument(); + expect(screen.getByText('Irreversible Action')).toBeInTheDocument(); + expect(screen.getByText(/release vault funds/i)).toBeInTheDocument(); + + const notes = screen.getByLabelText(/Verification Notes/i); + expect(notes).toHaveValue('Looks complete.'); + + fireEvent.click(screen.getByRole('button', { name: 'Confirm Approve' })); + + expect(props.onConfirm).toHaveBeenCalledWith('approve', 'Looks complete.'); + expect(props.onClose).not.toHaveBeenCalled(); + }); + + it('requires rejection notes and passes them through on confirm', () => { + const props = renderModal({ initialDecision: 'reject' }); + + expect(screen.getByText('Action Notification')).toBeInTheDocument(); + expect(screen.getByText(/Funds will remain locked/i)).toBeInTheDocument(); + + const confirmButton = screen.getByRole('button', { name: 'Confirm Reject' }); + expect(confirmButton).toBeDisabled(); + expect(screen.getByText('Notes are required for rejection.')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText(/Verification Notes/i), { + target: { value: 'Evidence does not match the milestone.' }, + }); + + expect(confirmButton).not.toBeDisabled(); + fireEvent.click(confirmButton); + + expect(props.onConfirm).toHaveBeenCalledWith('reject', 'Evidence does not match the milestone.'); + }); + + it('supports cancel, close, and focus-trap deactivation without confirming', () => { + const props = renderModal({ initialDecision: 'approve' }); + const dialog = screen.getByRole('dialog', { name: 'Confirm Validation' }); + + expect(focusTrapState.options).toMatchObject({ + allowOutsideClick: true, + escapeDeactivates: true, + }); + expect(focusTrapState.options?.onDeactivate).toBe(props.onClose); + + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + fireEvent.click(within(dialog).getByRole('button', { name: 'Close modal' })); + focusTrapState.options?.onDeactivate?.(); + + expect(props.onClose).toHaveBeenCalledTimes(3); + expect(props.onConfirm).not.toHaveBeenCalled(); + }); + + it('does not close or confirm when the backdrop is clicked', () => { + const props = renderModal({ initialDecision: 'approve' }); + const dialog = screen.getByRole('dialog', { name: 'Confirm Validation' }); + + fireEvent.click(dialog); + + expect(props.onClose).not.toHaveBeenCalled(); + expect(props.onConfirm).not.toHaveBeenCalled(); + }); + + it('renders the evidence link and allows switching decisions', () => { + renderModal({ + initialDecision: 'approve', + evidenceUrl: 'https://example.com/evidence', + }); + + const evidenceLink = screen.getByRole('link', { name: /View submitted proof/i }); + expect(evidenceLink).toHaveAttribute('href', 'https://example.com/evidence'); + expect(evidenceLink).toHaveAttribute('target', '_blank'); + expect(evidenceLink).toHaveAttribute('rel', 'noopener noreferrer'); + + const dialog = screen.getByRole('dialog', { name: 'Confirm Validation' }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Reject' })); + + expect(screen.getByText('Action Notification')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Confirm Reject' })).toBeDisabled(); + }); + + it('resets decision and notes from props each time it opens', () => { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + const { rerender } = render( + + ); + + fireEvent.change(screen.getByLabelText(/Verification Notes/i), { + target: { value: 'Edited before close' }, + }); + + rerender(); + rerender( + + ); + + expect(screen.getByRole('button', { name: 'Confirm Reject' })).not.toBeDisabled(); + expect(screen.getByLabelText(/Verification Notes/i)).toHaveValue('Fresh rejection note'); + }); +});