diff --git a/apps/frontend/src/components/cln/BTCWithdraw/BTCWithdraw.test.tsx b/apps/frontend/src/components/cln/BTCWithdraw/BTCWithdraw.test.tsx index aff0afbf..f1edec04 100644 --- a/apps/frontend/src/components/cln/BTCWithdraw/BTCWithdraw.test.tsx +++ b/apps/frontend/src/components/cln/BTCWithdraw/BTCWithdraw.test.tsx @@ -1,21 +1,318 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { CLNService } from '../../../services/http.service'; import { mockAppStore } from '../../../utilities/test-utilities/mockData'; import { renderWithProviders } from '../../../utilities/test-utilities/mockStore'; import BTCWithdraw from './BTCWithdraw'; -describe('BTCWithdraw component ', () => { - it('should show withdraw card when clicking withdraw action from BTC card', async () => { - await renderWithProviders(, { preloadedState: mockAppStore, initialRoute: ['/cln'] }); +const mockOnClose = jest.fn(); - // Initial state - expect(screen.getByTestId('btc-wallet-balance-card')).toBeInTheDocument(); +const renderBTCWithdraw = async () => { + await act(async () => { + renderWithProviders(, { + preloadedState: mockAppStore, + initialRoute: ['/cln'], + }); + }); + + const withdrawBtn = await screen.findByTestId('withdraw-button'); + await act(async () => { + fireEvent.click(withdrawBtn); + }); + + await waitFor(() => { + expect(screen.getByTestId('btc-withdraw-card')).toBeInTheDocument(); + }); +}; + +describe('BTCWithdraw component', () => { + + beforeEach(async () => { + jest.useFakeTimers({ advanceTimers: true }); + await renderBTCWithdraw(); + }); + + afterEach(() => { + mockOnClose.mockClear(); + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('should render the withdraw card with all fields', () => { + expect(screen.getByTestId('btc-withdraw-card')).toBeInTheDocument(); + expect(screen.getByLabelText('amount')).toBeInTheDocument(); + expect(screen.getByLabelText('address')).toBeInTheDocument(); + expect(screen.getByTestId('show-custom-fee-rate')).toBeInTheDocument(); + expect(screen.getByTestId('show-custom-fee-rate')).not.toBeChecked(); + expect(screen.getByTestId('button-withdraw')).toBeInTheDocument(); + expect(screen.getByTestId('button-withdraw')).not.toBeDisabled(); + }); + + it('should render the FeerateRange slider by default with no custom fee rate input', async () => { + await waitFor(() => { + expect(screen.getByTestId('feerate-range')).toBeInTheDocument(); + }); + expect(screen.queryByLabelText('feeRate')).not.toBeInTheDocument(); + expect(screen.queryByTestId('fee-rate-unit')).not.toBeInTheDocument(); + }); + + it('should show custom fee rate input when checkbox is checked', async () => { + const checkbox = screen.getByTestId('show-custom-fee-rate'); + expect(checkbox).not.toBeChecked(); + + await act(async () => { + fireEvent.click(checkbox); + }); - // Click the withdraw button - const withdrawButton = screen.getByTestId('withdraw-button'); - fireEvent.click(withdrawButton); await waitFor(() => { - expect(screen.getByTestId('btc-withdraw')).toBeInTheDocument(); - expect(screen.getByTestId('button-withdraw')).toBeInTheDocument(); + expect(checkbox).toBeChecked(); + expect(screen.getByLabelText('feeRate')).toBeInTheDocument(); + expect(screen.getByTestId('fee-rate-unit')).toHaveTextContent('perkw'); + }); + }); + + it('should hide custom fee rate input and show slider when checkbox is unchecked again', async () => { + const checkbox = screen.getByTestId('show-custom-fee-rate'); + + await act(async () => { fireEvent.click(checkbox); }); + await waitFor(() => expect(screen.getByLabelText('feeRate')).toBeInTheDocument()); + + await act(async () => { fireEvent.click(checkbox); }); + await waitFor(() => { + expect(screen.queryByLabelText('feeRate')).not.toBeInTheDocument(); + expect(screen.getByTestId('feerate-range')).toBeInTheDocument(); + }); + }); + + it('should not submit when form is empty', async () => { + const mockBtcWithdraw = jest.spyOn(CLNService, 'btcWithdraw').mockResolvedValue({ txid: 'tx123' }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-withdraw')); + }); + + expect(mockBtcWithdraw).not.toHaveBeenCalled(); + mockBtcWithdraw.mockRestore(); + }); + + it('should show invalid address error when address is blurred empty', async () => { + fireEvent.change(screen.getByLabelText('address'), { target: { value: '' } }); + fireEvent.blur(screen.getByLabelText('address')); + + await waitFor(() => { + expect(screen.getByText('Invalid Address')).toBeInTheDocument(); + }); + }); + + it('should show invalid amount error when amount is 0', async () => { + fireEvent.change(screen.getByLabelText('amount'), { target: { value: '0' } }); + fireEvent.blur(screen.getByLabelText('amount')); + + await waitFor(() => { + expect(screen.getByText('Amount should be greater than 0')).toBeInTheDocument(); + }); + }); + + it('should show invalid fee rate error when custom fee rate is 0', async () => { + await act(async () => { + fireEvent.click(screen.getByTestId('show-custom-fee-rate')); + }); + + const feeRateInput = await screen.findByLabelText('feeRate'); + fireEvent.change(feeRateInput, { target: { value: '0' } }); + fireEvent.blur(feeRateInput); + + await waitFor(() => { + expect(screen.getByText('Fee Rate should be greater than 0')).toBeInTheDocument(); + }); + }); + + it('should set amount to "All" when Send All is clicked', async () => { + await act(async () => { + fireEvent.click(screen.getByText('Send All')); + }); + + await waitFor(() => { + expect(screen.getByLabelText('amount')).toHaveValue('All'); + expect(screen.getByLabelText('amount')).toBeDisabled(); + }); + }); + + it('should clear amount when the close button on Send All is clicked', async () => { + await act(async () => { + fireEvent.click(screen.getByText('Send All')); + }); + + await waitFor(() => expect(screen.getByLabelText('amount')).toHaveValue('All')); + + await act(async () => { + fireEvent.click(document.querySelector('.btn-addon-close') as HTMLElement); + }); + + await waitFor(() => { + expect(screen.getByLabelText('amount')).toHaveValue(null); + }); + }); + + it('should disable submit button while submission is pending', async () => { + jest.spyOn(CLNService, 'btcWithdraw').mockImplementation(() => new Promise(() => {})); + + fireEvent.change(screen.getByLabelText('amount'), { target: { value: '100000' } }); + fireEvent.change(screen.getByLabelText('address'), { target: { value: 'bc1qaddress' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-withdraw')); + }); + + await waitFor(() => { + expect(screen.getByTestId('button-withdraw')).toBeDisabled(); + }); + }); + + it('should show success message after withdraw', async () => { + jest.spyOn(CLNService, 'btcWithdraw').mockResolvedValue({ txid: 'tx123' }); + + fireEvent.change(screen.getByLabelText('amount'), { target: { value: '100000' } }); + fireEvent.change(screen.getByLabelText('address'), { target: { value: 'bc1qaddress' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-withdraw')); + }); + + await waitFor(() => { + expect(screen.getByText(/transaction sent with transaction id tx123/i)).toBeInTheDocument(); + }); + }); + + it('should show error message when btcWithdraw fails', async () => { + jest.spyOn(CLNService, 'btcWithdraw').mockRejectedValue('Insufficient funds'); + + fireEvent.change(screen.getByLabelText('amount'), { target: { value: '100000' } }); + fireEvent.change(screen.getByLabelText('address'), { target: { value: 'bc1qaddress' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-withdraw')); + }); + + await waitFor(() => { + expect(screen.getByTestId('status-alert-message')).toHaveTextContent('Insufficient Funds'); + }); + }); + + describe('btcWithdraw fee rate argument', () => { + const fillRequiredFields = () => { + fireEvent.change(screen.getByLabelText('amount'), { target: { value: '100000' } }); + fireEvent.change(screen.getByLabelText('address'), { target: { value: 'bc1qaddress' } }); + }; + + describe('when showCustomFeeRate is false (default)', () => { + it('should call btcWithdraw with selFeeRate "normal" by default', async () => { + const mockBtcWithdraw = jest.spyOn(CLNService, 'btcWithdraw').mockResolvedValue({ txid: 'tx123' }); + fillRequiredFields(); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-withdraw')); + }); + + await waitFor(() => { + expect(mockBtcWithdraw).toHaveBeenCalledWith('bc1qaddress', '100000', 'normal'); + }); + mockBtcWithdraw.mockRestore(); + }); + + it('should call btcWithdraw with selFeeRate "slow" when slider is set to slow', async () => { + const mockBtcWithdraw = jest.spyOn(CLNService, 'btcWithdraw').mockResolvedValue({ txid: 'tx123' }); + fillRequiredFields(); + + fireEvent.change(screen.getByTestId('feerate-range'), { target: { value: '0' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-withdraw')); + }); + + await waitFor(() => { + expect(mockBtcWithdraw).toHaveBeenCalledWith('bc1qaddress', '100000', 'slow'); + }); + mockBtcWithdraw.mockRestore(); + }); + + it('should call btcWithdraw with selFeeRate "urgent" when slider is set to urgent', async () => { + const mockBtcWithdraw = jest.spyOn(CLNService, 'btcWithdraw').mockResolvedValue({ txid: 'tx123' }); + fillRequiredFields(); + + fireEvent.change(screen.getByTestId('feerate-range'), { target: { value: '2' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-withdraw')); + }); + + await waitFor(() => { + expect(mockBtcWithdraw).toHaveBeenCalledWith('bc1qaddress', '100000', 'urgent'); + }); + mockBtcWithdraw.mockRestore(); + }); + }); + + describe('when showCustomFeeRate is true', () => { + it('should call btcWithdraw with feeRateValue + "perkw"', async () => { + const mockBtcWithdraw = jest.spyOn(CLNService, 'btcWithdraw').mockResolvedValue({ txid: 'tx123' }); + fillRequiredFields(); + + await act(async () => { + fireEvent.click(screen.getByTestId('show-custom-fee-rate')); + }); + + const feeRateInput = await screen.findByLabelText('feeRate'); + fireEvent.change(feeRateInput, { target: { value: '500' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-withdraw')); + }); + + await waitFor(() => { + expect(mockBtcWithdraw).toHaveBeenCalledWith('bc1qaddress', '100000', '500perkw'); + }); + mockBtcWithdraw.mockRestore(); + }); + + it('should not call btcWithdraw if custom fee rate value is empty', async () => { + const mockBtcWithdraw = jest.spyOn(CLNService, 'btcWithdraw').mockResolvedValue({ txid: 'tx123' }); + fillRequiredFields(); + + await act(async () => { + fireEvent.click(screen.getByTestId('show-custom-fee-rate')); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-withdraw')); + }); + + expect(mockBtcWithdraw).not.toHaveBeenCalled(); + mockBtcWithdraw.mockRestore(); + }); + + it('should not use selFeeRate when showCustomFeeRate is true', async () => { + const mockBtcWithdraw = jest.spyOn(CLNService, 'btcWithdraw').mockResolvedValue({ txid: 'tx123' }); + fillRequiredFields(); + + fireEvent.change(screen.getByTestId('feerate-range'), { target: { value: '2' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('show-custom-fee-rate')); + }); + + const feeRateInput = await screen.findByLabelText('feeRate'); + fireEvent.change(feeRateInput, { target: { value: '300' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-withdraw')); + }); + + await waitFor(() => { + expect(mockBtcWithdraw).toHaveBeenCalledWith('bc1qaddress', '100000', '300perkw'); + expect(mockBtcWithdraw).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), 'urgent'); + }); + mockBtcWithdraw.mockRestore(); + }); }); }); }); diff --git a/apps/frontend/src/components/cln/BTCWithdraw/BTCWithdraw.tsx b/apps/frontend/src/components/cln/BTCWithdraw/BTCWithdraw.tsx index 7fd518ab..03a2bf2e 100755 --- a/apps/frontend/src/components/cln/BTCWithdraw/BTCWithdraw.tsx +++ b/apps/frontend/src/components/cln/BTCWithdraw/BTCWithdraw.tsx @@ -1,5 +1,6 @@ import './BTCWithdraw.scss'; -import { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useState, ChangeEvent } from 'react'; import { Spinner, Card, Row, Col, Button, Form, InputGroup } from 'react-bootstrap'; import logger from '../../../services/logger.service'; @@ -8,6 +9,7 @@ import { CallStatus, CLEAR_STATUS_ALERT_DELAY, FeeRate, FEE_RATES } from '../../ import { ActionSVG } from '../../../svgs/Action'; import { AmountSVG } from '../../../svgs/Amount'; import { AddressSVG } from '../../../svgs/Address'; +import { FeeRateSVG } from '../../../svgs/FeeRate'; import { BitcoinWalletSVG } from '../../../svgs/BitcoinWallet'; import FiatBox from '../../shared/FiatBox/FiatBox'; import InvalidInputMessage from '../../shared/InvalidInputMessage/InvalidInputMessage'; @@ -23,11 +25,13 @@ const BTCWithdraw = (props) => { const fiatConfig = useSelector(selectFiatConfig); const walletBalances = useSelector(selectWalletBalances); const [selFeeRate, setSelFeeRate] = useState(FeeRate.NORMAL); + const [showCustomFeeRate, setShowCustomFeeRate] = useState(false); const [responseStatus, setResponseStatus] = useState(CallStatus.NONE); const [responseMessage, setResponseMessage] = useState(''); const isValidAmount = (value) => value === 'All' || (value > 0 && value <= (walletBalances.btcSpendableBalance || 0)); const isValidAddress = (value) => value.trim() !== ''; + const isValidFeeRate = (value) => !showCustomFeeRate || (value.trim() !== '' && value > 0); const { value: addressValue, @@ -45,13 +49,21 @@ const BTCWithdraw = (props) => { inputBlurHandler: amountBlurHandler, reset: resetAmount, } = useInput(isValidAmount); + const { + value: feeRateValue, + isValid: feeRateIsValid, + hasError: feeRateHasError, + valueChangeHandler: feeRateChangeHandler, + inputBlurHandler: feeRateBlurHandler, + reset: resetFeeRate, + } = useInput(isValidFeeRate); let formIsValid = false; - if (addressIsValid && amountIsValid) { + if (addressIsValid && amountIsValid && feeRateIsValid) { formIsValid = true; }; - + const selFeeRateChangeHandler = (event) => { setSelFeeRate(FEE_RATES[+event.target.value]); }; @@ -59,11 +71,13 @@ const BTCWithdraw = (props) => { const touchFormControls = () => { addressBlurHandler(); amountBlurHandler(); + feeRateBlurHandler(); }; const resetFormValues = () => { resetAddress(); resetAmount(); + resetFeeRate(); setSelFeeRate(FeeRate.NORMAL); }; @@ -74,138 +88,203 @@ const BTCWithdraw = (props) => { }, CLEAR_STATUS_ALERT_DELAY); } + const showCustomFeeRateChangeHandler = (event: ChangeEvent) => { + setShowCustomFeeRate(event.target.checked); + }; + const withdrawHandler = (event) => { event.preventDefault(); touchFormControls(); if (!formIsValid) { return; } setResponseStatus(CallStatus.PENDING); setResponseMessage('Sending Transaction...'); - CLNService.btcWithdraw(addressValue, amountValue.toLowerCase(), selFeeRate.toLowerCase()) - .then((response: any) => { - logger.info(response); - if (response.txid) { - setResponseStatus(CallStatus.SUCCESS); - setResponseMessage('Transaction sent with transaction id ' + response.txid); - resetFormValues(); - delayedClearStatusAlert(); - } else { + CLNService.btcWithdraw(addressValue, amountValue.toLowerCase(), (!!showCustomFeeRate ? feeRateValue + 'perkw' : selFeeRate.toLowerCase())) + .then((response: any) => { + logger.info(response); + if (response.txid) { + setResponseStatus(CallStatus.SUCCESS); + setResponseMessage('Transaction sent with transaction id ' + response.txid); + resetFormValues(); + delayedClearStatusAlert(); + } else { + setResponseStatus(CallStatus.ERROR); + setResponseMessage(response.response || response.message || 'Unknown Error'); + delayedClearStatusAlert(); + } + }) + .catch(err => { + logger.error(err); setResponseStatus(CallStatus.ERROR); - setResponseMessage(response.response || response.message || 'Unknown Error'); + setResponseMessage(err); delayedClearStatusAlert(); - } - }) - .catch(err => { - logger.error(err); - setResponseStatus(CallStatus.ERROR); - setResponseMessage(err); - delayedClearStatusAlert(); - }); + }); }; return (
- -
- - Bitcoin Wallet -
- -
-

Withdraw

- - + +
+ + Bitcoin Wallet +
+ +
+

Withdraw

+ + - - Amount* - {amountValue !== 'All' ? - + + Amount* + {amountValue !== 'All' ? + : - <> - } - - - - + <> + } + + + + + + + {amountValue === 'All' ? + + resetAmount()}> - - { amountValue === 'All' ? - - resetAmount()}> - - : - <> - } - - { - !amountHasError ? - amountValue && amountValue !== 'All' ? -

- ~ -

+ : + <> + } + + { + !amountHasError ? + amountValue && amountValue !== 'All' ? +

+ ~ +

: -

+

: - (walletBalances.btcSpendableBalance || 0)) ? + : (+amountValue > (walletBalances.btcSpendableBalance || 0)) ? 'Amount should be lesser then ' + (walletBalances.btcSpendableBalance || 0) - : + : 'Invalid Amount' - } /> - } - - - Address* - - - - - - - {(addressHasError) ? - - : -
- } - - - - -
- -
- - - + } /> + } + + + Address* + + + + + + + {(addressHasError) ? + + : +
+ } + + + + + + {showCustomFeeRate ? ( + + + Fee Rate* + + + + + + + perkw + + + {(feeRateHasError) ? + :
+ } + +
+ ) : ( + + + + + + )} +
+
+ +
+ + +
diff --git a/apps/frontend/src/components/cln/ChannelOpen/ChannelOpen.test.tsx b/apps/frontend/src/components/cln/ChannelOpen/ChannelOpen.test.tsx index 1c8cd301..50a31ce0 100644 --- a/apps/frontend/src/components/cln/ChannelOpen/ChannelOpen.test.tsx +++ b/apps/frontend/src/components/cln/ChannelOpen/ChannelOpen.test.tsx @@ -1,21 +1,407 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { CLNService } from '../../../services/http.service'; import { mockAppStore } from '../../../utilities/test-utilities/mockData'; import { renderWithProviders } from '../../../utilities/test-utilities/mockStore'; import ChannelOpen from './ChannelOpen'; -describe('ChannelOpen component ', () => { +const mockOnClose = jest.fn(); + +const renderChannelOpen = async () => { + await act(async () => { + renderWithProviders(, { + preloadedState: mockAppStore, + initialRoute: ['/cln'], + }); + }); + + const openChannelBtn = await screen.findByTestId('button-open-channel'); + await act(async () => { + fireEvent.click(openChannelBtn); + }); + + await waitFor(() => { + expect(screen.getByTestId('channel-open-card')).toBeInTheDocument(); + }); +}; + +describe('ChannelOpen component', () => { + beforeEach(async () => { + jest.useFakeTimers({ advanceTimers: true }); + await renderChannelOpen(); + }); + + afterEach(() => { + mockOnClose.mockClear(); + jest.clearAllTimers(); + jest.useRealTimers(); + }); + it('should be in the document', async () => { - await renderWithProviders(, { preloadedState: mockAppStore, initialRoute: ['/cln'] }); + expect(screen.getByTestId('channel-open-card')).toBeInTheDocument(); + expect(screen.getByTestId('pubkey')).toBeInTheDocument(); + expect(screen.getByTestId('amount')).toBeInTheDocument(); + expect(screen.getByTestId('show-custom-fee-rate')).toBeInTheDocument(); + expect(screen.getByTestId('show-custom-fee-rate')).not.toBeChecked(); + }); + + it('should render the FeerateRange slider by default (custom fee rate hidden)', async () => { + await waitFor(() => { + expect(screen.getByTestId('pubkey')).toBeInTheDocument(); + expect(screen.getByTestId('feerate-range')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('fee-rate-input')).not.toBeInTheDocument(); + expect(screen.queryByTestId('fee-rate-unit')).not.toBeInTheDocument(); + }); + + it('should render the Announce toggle defaulting to on', () => { + const switchEl = document.querySelector('.switch'); + expect(switchEl).toBeInTheDocument(); + expect(switchEl).toHaveAttribute('data-isswitchon', 'true'); + }); + + it('should render the Open Channel submit button', () => { + const submitBtn = screen.getByTestId('button-open-channel-submit'); + expect(submitBtn).toBeInTheDocument(); + expect(submitBtn).not.toBeDisabled(); + expect(submitBtn).toHaveTextContent('Open Channel'); + }); + + it('should not submit and show no errors before form is touched', async () => { + const mockOpenChannel = jest.spyOn(CLNService, 'openChannel').mockResolvedValue({ channel_id: 'abc123' }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-open-channel-submit')); + }); + + expect(mockOpenChannel).not.toHaveBeenCalled(); + mockOpenChannel.mockRestore(); + }); + + it('should show invalid pubkey error when pubkey field is blurred with invalid value', async () => { + fireEvent.change(screen.getByTestId('pubkey'), { target: { value: 'invalidpubkey' } }); + fireEvent.blur(screen.getByTestId('pubkey')); + + await waitFor(() => { + expect(screen.getByText('Invalid Node ID')).toBeInTheDocument(); + }); + }); + + it('should not show pubkey error when pubkey is valid', async () => { + fireEvent.change(screen.getByTestId('pubkey'), { target: { value: 'pubkey@host:port' } }); + fireEvent.blur(screen.getByTestId('pubkey')); + + await waitFor(() => { + expect(screen.queryByText('Invalid Node ID')).not.toBeInTheDocument(); + }); + }); + + it('should show invalid amount error when amount is 0', async () => { + fireEvent.change(screen.getByTestId('amount'), { target: { value: '0' } }); + fireEvent.blur(screen.getByTestId('amount')); + + await waitFor(() => { + expect(screen.getByText('Amount should be greater than 0')).toBeInTheDocument(); + }); + }); + + it('should show invalid fee rate error when custom fee rate is 0', async () => { + await act(async () => { + fireEvent.click(screen.getByTestId('show-custom-fee-rate')); + }); + + const feeRateInput = await screen.findByTestId('fee-rate-input'); + fireEvent.change(feeRateInput, { target: { value: '0' } }); + fireEvent.blur(feeRateInput); + + await waitFor(() => { + expect(screen.getByText('Fee Rate should be greater than 0')).toBeInTheDocument(); + }); + }); + + it('should toggle Announce off when switch is clicked', async () => { + const switchEl = document.querySelector('.switch') as HTMLElement; + expect(switchEl).toHaveAttribute('data-isswitchon', 'true'); + + await act(async () => { fireEvent.click(switchEl); }); + + await waitFor(() => { + expect(switchEl).toHaveAttribute('data-isswitchon', 'false'); + }); + }); + + it('should toggle Announce back on when switch is clicked twice', async () => { + const switchEl = document.querySelector('.switch') as HTMLElement; + + await act(async () => { fireEvent.click(switchEl); }); + await act(async () => { fireEvent.click(switchEl); }); + + await waitFor(() => { + expect(switchEl).toHaveAttribute('data-isswitchon', 'true'); + }); + }); + + it('should pass announce=false to openChannel when toggle is off', async () => { + const mockOpenChannel = jest.spyOn(CLNService, 'openChannel').mockResolvedValue({ channel_id: 'abc123' }); + + const switchEl = document.querySelector('.switch') as HTMLElement; + await act(async () => { fireEvent.click(switchEl); }); + + fireEvent.change(screen.getByTestId('pubkey'), { target: { value: 'pubkey@host:port' } }); + fireEvent.change(screen.getByTestId('amount'), { target: { value: '100000' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-open-channel-submit')); + }); + + await waitFor(() => { + expect(mockOpenChannel).toHaveBeenCalledWith('pubkey@host:port', 100000, 'normal', false); + }); + + mockOpenChannel.mockRestore(); + }); + + it('should show custom fee rate input when checkbox is checked', async () => { + const customFeeRateCheckbox = screen.getByTestId('show-custom-fee-rate'); + expect(customFeeRateCheckbox).not.toBeChecked(); + + await act(async () => { + fireEvent.click(customFeeRateCheckbox); + }); + + await waitFor(() => { + expect(customFeeRateCheckbox).toBeChecked(); + expect(screen.getByTestId('fee-rate-input')).toBeInTheDocument(); + expect(screen.getByTestId('fee-rate-unit')).toHaveTextContent('perkw'); + }); + }); + + it('should hide custom fee rate input and show slider when checkbox is unchecked again', async () => { + const checkbox = screen.getByTestId('show-custom-fee-rate'); + + await act(async () => { fireEvent.click(checkbox); }); + await waitFor(() => expect(screen.getByTestId('fee-rate-input')).toBeInTheDocument()); + + await act(async () => { fireEvent.click(checkbox); }); + await waitFor(() => { + expect(screen.queryByTestId('fee-rate-input')).not.toBeInTheDocument(); + expect(screen.getByTestId('feerate-range')).toBeInTheDocument(); + }); + }); + + it('should use custom fee rate value suffixed with perkw on submit', async () => { + const mockOpenChannel = jest.spyOn(CLNService, 'openChannel').mockResolvedValue({ channel_id: 'abc123' }); + + fireEvent.change(screen.getByTestId('pubkey'), { target: { value: 'pubkey@host:port' } }); + fireEvent.change(screen.getByTestId('amount'), { target: { value: '100000' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('show-custom-fee-rate')); + }); + + const feeRateInput = await screen.findByTestId('fee-rate-input'); + fireEvent.change(feeRateInput, { target: { value: '500' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-open-channel-submit')); + }); + + await waitFor(() => { + expect(mockOpenChannel).toHaveBeenCalledWith('pubkey@host:port', 100000, '500perkw', true); + }); - // Channels list rendered - expect(screen.getByTestId('channels')).toBeInTheDocument(); - expect(screen.queryByTestId('channel-open-card')).not.toBeInTheDocument(); + mockOpenChannel.mockRestore(); + }); + + it('should use slider fee rate when custom fee rate checkbox is unchecked', async () => { + const mockOpenChannel = jest.spyOn(CLNService, 'openChannel').mockResolvedValue({ channel_id: 'abc123' }); + + fireEvent.change(screen.getByTestId('pubkey'), { target: { value: 'pubkey@host:port' } }); + fireEvent.change(screen.getByTestId('amount'), { target: { value: '100000' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-open-channel-submit')); + }); + + await waitFor(() => { + expect(mockOpenChannel).toHaveBeenCalledWith('pubkey@host:port', 100000, 'normal', true); + }); + + mockOpenChannel.mockRestore(); + }); + + it('should disable submit button while submission is pending', async () => { + jest.spyOn(CLNService, 'openChannel').mockImplementation( + () => new Promise(() => {}) + ); + + fireEvent.change(screen.getByTestId('pubkey'), { target: { value: 'pubkey@host:port' } }); + fireEvent.change(screen.getByTestId('amount'), { target: { value: '100000' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-open-channel-submit')); + }); + + await waitFor(() => { + expect(screen.getByTestId('button-open-channel-submit')).toBeDisabled(); + }); + }); + + it('should show success message after channel is opened', async () => { + jest.spyOn(CLNService, 'openChannel').mockResolvedValue({ channel_id: 'abc123' }); + + fireEvent.change(screen.getByTestId('pubkey'), { target: { value: 'pubkey@host:port' } }); + fireEvent.change(screen.getByTestId('amount'), { target: { value: '100000' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-open-channel-submit')); + }); + + await waitFor(() => { + expect(screen.getByText(/channel opened with channel id abc123/i)).toBeInTheDocument(); + }); + }); + + it('should show error message when openChannel fails', async () => { + jest.spyOn(CLNService, 'openChannel').mockRejectedValue('Connection refused'); + + fireEvent.change(screen.getByTestId('pubkey'), { target: { value: 'pubkey@host:port' } }); + fireEvent.change(screen.getByTestId('amount'), { target: { value: '100000' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-open-channel-submit')); + }); - // Click open channel - const openChannelBtn = screen.getByTestId('button-open-channel'); - await fireEvent.click(openChannelBtn); await waitFor(() => { - expect(screen.getByTestId('channel-open-card')).toBeInTheDocument(); + expect(screen.getByText(/connection refused/i)).toBeInTheDocument(); + }); + }); +}); + +describe('openChannel fee rate argument', () => { + beforeEach(async () => { + jest.useFakeTimers({ advanceTimers: true }); + await renderChannelOpen(); + }); + + afterEach(() => { + mockOnClose.mockClear(); + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + const fillRequiredFields = () => { + fireEvent.change(screen.getByTestId('pubkey'), { target: { value: 'pubkey@host:port' } }); + fireEvent.change(screen.getByTestId('amount'), { target: { value: '100000' } }); + }; + + describe('when showCustomFeeRate is false (default)', () => { + it('should call openChannel with selFeeRate "normal" by default', async () => { + const mockOpenChannel = jest.spyOn(CLNService, 'openChannel').mockResolvedValue({ channel_id: 'abc123' }); + fillRequiredFields(); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-open-channel-submit')); + }); + + await waitFor(() => { + expect(mockOpenChannel).toHaveBeenCalledWith('pubkey@host:port', 100000, 'normal', true); + }); + mockOpenChannel.mockRestore(); + }); + + it('should call openChannel with selFeeRate "slow" when slider is set to slow', async () => { + const mockOpenChannel = jest.spyOn(CLNService, 'openChannel').mockResolvedValue({ channel_id: 'abc123' }); + fillRequiredFields(); + + fireEvent.change(screen.getByTestId('feerate-range'), { target: { value: '0' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-open-channel-submit')); + }); + + await waitFor(() => { + expect(mockOpenChannel).toHaveBeenCalledWith('pubkey@host:port', 100000, 'slow', true); + }); + mockOpenChannel.mockRestore(); + }); + + it('should call openChannel with selFeeRate "urgent" when slider is set to urgent', async () => { + const mockOpenChannel = jest.spyOn(CLNService, 'openChannel').mockResolvedValue({ channel_id: 'abc123' }); + fillRequiredFields(); + + fireEvent.change(screen.getByTestId('feerate-range'), { target: { value: '2' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-open-channel-submit')); + }); + + await waitFor(() => { + expect(mockOpenChannel).toHaveBeenCalledWith('pubkey@host:port', 100000, 'urgent', true); + }); + mockOpenChannel.mockRestore(); + }); + }); + + describe('when showCustomFeeRate is true', () => { + it('should call openChannel with feeRateValue + "perkw"', async () => { + const mockOpenChannel = jest.spyOn(CLNService, 'openChannel').mockResolvedValue({ channel_id: 'abc123' }); + fillRequiredFields(); + + await act(async () => { + fireEvent.click(screen.getByTestId('show-custom-fee-rate')); + }); + + const feeRateInput = await screen.findByTestId('fee-rate-input'); + fireEvent.change(feeRateInput, { target: { value: '500' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-open-channel-submit')); + }); + + await waitFor(() => { + expect(mockOpenChannel).toHaveBeenCalledWith('pubkey@host:port', 100000, '500perkw', true); + }); + mockOpenChannel.mockRestore(); + }); + + it('should not call openChannel if custom fee rate value is empty', async () => { + const mockOpenChannel = jest.spyOn(CLNService, 'openChannel').mockResolvedValue({ channel_id: 'abc123' }); + fillRequiredFields(); + + await act(async () => { + fireEvent.click(screen.getByTestId('show-custom-fee-rate')); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-open-channel-submit')); + }); + + expect(mockOpenChannel).not.toHaveBeenCalled(); + mockOpenChannel.mockRestore(); + }); + + it('should not use selFeeRate when showCustomFeeRate is true', async () => { + const mockOpenChannel = jest.spyOn(CLNService, 'openChannel').mockResolvedValue({ channel_id: 'abc123' }); + fillRequiredFields(); + + fireEvent.change(screen.getByTestId('feerate-range'), { target: { value: '2' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('show-custom-fee-rate')); + }); + + const feeRateInput = await screen.findByTestId('fee-rate-input'); + fireEvent.change(feeRateInput, { target: { value: '300' } }); + + await act(async () => { + fireEvent.click(screen.getByTestId('button-open-channel-submit')); + }); + + await waitFor(() => { + expect(mockOpenChannel).toHaveBeenCalledWith('pubkey@host:port', 100000, '300perkw', true); + expect(mockOpenChannel).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), 'urgent', expect.anything()); + }); + mockOpenChannel.mockRestore(); }); }); }); diff --git a/apps/frontend/src/components/cln/ChannelOpen/ChannelOpen.tsx b/apps/frontend/src/components/cln/ChannelOpen/ChannelOpen.tsx index bdf45f07..c8ff621c 100755 --- a/apps/frontend/src/components/cln/ChannelOpen/ChannelOpen.tsx +++ b/apps/frontend/src/components/cln/ChannelOpen/ChannelOpen.tsx @@ -1,6 +1,6 @@ import './ChannelOpen.scss'; -import { useState } from 'react'; -import { motion } from 'framer-motion'; +import { useState, ChangeEvent } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; import { Spinner, Card, Row, Col, Form, InputGroup } from 'react-bootstrap'; import logger from '../../../services/logger.service'; @@ -9,6 +9,7 @@ import { CallStatus, FeeRate, BOUNCY_SPRING_VARIANTS_1, CLEAR_STATUS_ALERT_DELAY import { ActionSVG } from '../../../svgs/Action'; import { AmountSVG } from '../../../svgs/Amount'; import { AddressSVG } from '../../../svgs/Address'; +import { FeeRateSVG } from '../../../svgs/FeeRate'; import FiatBox from '../../shared/FiatBox/FiatBox'; import InvalidInputMessage from '../../shared/InvalidInputMessage/InvalidInputMessage'; import { CloseSVG } from '../../../svgs/Close'; @@ -24,10 +25,12 @@ const ChannelOpen = (props) => { const walletBalances = useSelector(selectWalletBalances); const [selFeeRate, setSelFeeRate] = useState(FeeRate.NORMAL); const [announce, setAnnounce] = useState(true); + const [showCustomFeeRate, setShowCustomFeeRate] = useState(false); const [responseStatus, setResponseStatus] = useState(CallStatus.NONE); const [responseMessage, setResponseMessage] = useState(''); const isValidAmount = (value) => value.trim() !== '' && value > 0 && value <= (walletBalances.btcSpendableBalance || 0); const isValidPubkey = (value) => value.includes('@') && value.includes(':'); + const isValidFeeRate = (value) => !showCustomFeeRate || (value.trim() !== '' && value > 0); const { value: pubkeyValue, @@ -45,10 +48,18 @@ const ChannelOpen = (props) => { inputBlurHandler: amountBlurHandler, reset: resetAmount, } = useInput(isValidAmount); + const { + value: feeRateValue, + isValid: feeRateIsValid, + hasError: feeRateHasError, + valueChangeHandler: feeRateChangeHandler, + inputBlurHandler: feeRateBlurHandler, + reset: resetFeeRate, + } = useInput(isValidFeeRate); let formIsValid = false; - if (pubkeyIsValid && amountIsValid) { + if (pubkeyIsValid && amountIsValid && feeRateIsValid) { formIsValid = true; } @@ -69,11 +80,13 @@ const ChannelOpen = (props) => { const touchFormControls = () => { pubkeyBlurHandler(); amountBlurHandler(); + feeRateBlurHandler(); }; const resetFormValues = () => { resetPubkey(); resetAmount(); + resetFeeRate(); setAnnounce(true); setSelFeeRate(FeeRate.NORMAL); }; @@ -89,6 +102,10 @@ const ChannelOpen = (props) => { }, CLEAR_STATUS_ALERT_DELAY); }; + const showCustomFeeRateChangeHandler = (event: ChangeEvent) => { + setShowCustomFeeRate(event.target.checked); + }; + const ChannelOpenHandler = event => { event.preventDefault(); touchFormControls(); @@ -97,16 +114,16 @@ const ChannelOpen = (props) => { } setResponseStatus(CallStatus.PENDING); setResponseMessage('Opening Channel...'); - CLNService.openChannel(pubkeyValue, +amountValue, selFeeRate.toLowerCase(), announce) + CLNService.openChannel(pubkeyValue, +amountValue, (!!showCustomFeeRate ? feeRateValue + 'perkw' : selFeeRate.toLowerCase()), announce) .then((response: any) => { logger.info(response); if (response.channel_id || response.txid) { setResponseStatus(CallStatus.SUCCESS); setResponseMessage( 'Channel opened with ' + - (response.channel_id - ? 'channel id ' + response.channel_id - : 'transaction id ' + response.txid), + (response.channel_id + ? 'channel id ' + response.channel_id + : 'transaction id ' + response.txid), ); resetFormValues(); delayedClearStatusAlert(true, response.channel_id); @@ -128,93 +145,160 @@ const ChannelOpen = (props) => {
- -
- Open Channel -
- -
- - - - Node ID* - - - - - - - {(pubkeyHasError) ? - :
- } - - - Amount* - - - - - - - { - !amountHasError ? - amountValue ? -

- ~ -

+ +
+ Open Channel +
+ +
+ + + + Node ID* + + + + + + + {(pubkeyHasError) ? + :
+ } + + + Amount* + + + + + + + { + !amountHasError ? + amountValue ? +

+ ~ +

: -

+

: - (walletBalances.btcSpendableBalance || 0)) ? + (walletBalances.btcSpendableBalance || 0)) ? 'Amount should be lesser then ' + (walletBalances.btcSpendableBalance || 0) - : + : 'Invalid Amount' - } /> - } - - - Announce -
setAnnounce(!announce)}> - -
- - - - -
- -
- - - + } /> + } + + + Announce +
setAnnounce(!announce)}> + +
+ + + + + + + {showCustomFeeRate ? ( + + + Fee Rate* + + + + + + + perkw + + + {(feeRateHasError) ? + :
+ } + +
+ ) : ( + + + + + + )} +
+
+ +
+ + +
diff --git a/apps/frontend/src/components/shared/FeerateRange/FeerateRange.tsx b/apps/frontend/src/components/shared/FeerateRange/FeerateRange.tsx index 7f6d7534..0d27fcdd 100755 --- a/apps/frontend/src/components/shared/FeerateRange/FeerateRange.tsx +++ b/apps/frontend/src/components/shared/FeerateRange/FeerateRange.tsx @@ -49,6 +49,7 @@ const FeerateRange = (props) => { max="2" onClick={props.selFeeRateChangeHandler} onChange={props.selFeeRateChangeHandler} + data-testid='feerate-range' /> diff --git a/apps/frontend/src/components/shared/StatusAlert/StatusAlert.tsx b/apps/frontend/src/components/shared/StatusAlert/StatusAlert.tsx index d1012cb5..25173f2d 100755 --- a/apps/frontend/src/components/shared/StatusAlert/StatusAlert.tsx +++ b/apps/frontend/src/components/shared/StatusAlert/StatusAlert.tsx @@ -22,6 +22,7 @@ const StatusAlert = props => { return props.responseStatus !== CallStatus.NONE ? ( { /> )} - + {titleCase(props.responseMessage)} {props.responseStatus !== CallStatus.PENDING ? ( diff --git a/apps/frontend/src/svgs/FeeRate.tsx b/apps/frontend/src/svgs/FeeRate.tsx new file mode 100644 index 00000000..cba62dc2 --- /dev/null +++ b/apps/frontend/src/svgs/FeeRate.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +export const FeeRateSVG = props => { + return ( + + + + ); +}; diff --git a/apps/frontend/src/utilities/test-utilities/mockFramer.tsx b/apps/frontend/src/utilities/test-utilities/mockFramer.tsx new file mode 100644 index 00000000..c4b6fc0c --- /dev/null +++ b/apps/frontend/src/utilities/test-utilities/mockFramer.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +const motion = new Proxy( + {}, + { + get: (_target, tag: string) => { + const Component = ({ children, ...props }: any) => + React.createElement(tag, props, children); + Component.displayName = `motion.${tag}`; + return Component; + }, + } +); + +const AnimatePresence = ({ children }: { children: React.ReactNode }) => <>{children}; + +const useMotionValue = (initial: number) => { + const value = { get: () => initial, set: jest.fn(), current: initial, prev: initial }; + return value; +}; + +const useTransform = (_value: any, transformer: (v: number) => any) => ({ + get: () => transformer(0), +}); + +const animate = jest.fn(() => ({ stop: jest.fn() })); + +const useAnimation = () => ({ start: jest.fn(), stop: jest.fn() }); +const useReducedMotion = () => false; + +export { + motion, + AnimatePresence, + useMotionValue, + useTransform, + animate, + useAnimation, + useReducedMotion, +};