diff --git a/src/components/common/__tests__/TradeDialog.sellPayoutDisplay.test.tsx b/src/components/common/__tests__/TradeDialog.sellPayoutDisplay.test.tsx new file mode 100644 index 0000000..b6fe1b3 --- /dev/null +++ b/src/components/common/__tests__/TradeDialog.sellPayoutDisplay.test.tsx @@ -0,0 +1,123 @@ +/** + * Unit tests for the sell confirmation dialog correctly displaying the + * estimated XLM payout and disabling the confirm button while the sell + * mutation is pending (#692). + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TradeDialog from '@/components/common/TradeDialog'; +import * as keyPriceDisplay from '@/utils/keyPriceDisplay.utils'; + +describe('TradeDialog – sell payout display (#692)', () => { + function renderSellDialog( + overrides: Partial> = {} + ) { + return render( + + ); + } + + it('displays the correct estimated XLM payout for a given sell quantity', () => { + renderSellDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // keyPriceStroops=500_000 * quantity=2 = 1_000_000 stroops = 0.1 XLM + fireEvent.change(input, { target: { value: '2' } }); + + expect(screen.getByText(/Estimated proceeds/i)).toBeInTheDocument(); + expect(screen.getByText('0.1 XLM')).toBeInTheDocument(); + }); + + it('updates the displayed payout as the sell quantity changes', () => { + renderSellDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '1' } }); + expect(screen.getByText('0.05 XLM')).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: '4' } }); + expect(screen.getByText('0.2 XLM')).toBeInTheDocument(); + expect(screen.queryByText('0.05 XLM')).not.toBeInTheDocument(); + }); + + it('shows "Estimated proceeds unavailable" when the payout cannot be computed', () => { + renderSellDialog({ keyPriceStroops: null, currentSupply: null }); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '2' } }); + + expect(screen.getByText('Estimated proceeds unavailable')).toBeInTheDocument(); + }); + + it('calls the bonding curve sell calculation with the correct arguments', () => { + const spy = vi.spyOn(keyPriceDisplay, 'estimateSellProceeds'); + renderSellDialog({ keyPriceStroops: 500_000, currentSupply: 100 }); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '3' } }); + + expect(spy).toHaveBeenCalledWith(500_000, 100, 3); + spy.mockRestore(); + }); + + it('does not compute a payout on the buy side', () => { + renderSellDialog({ side: 'buy' }); + + expect(screen.queryByText(/Estimated proceeds/i)).not.toBeInTheDocument(); + }); + + it('disables the confirm button while the sell mutation is pending', () => { + renderSellDialog({ isSubmitting: true }); + + expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled(); + }); + + it('re-enables the confirm button once the sell mutation is no longer pending', () => { + const { rerender } = renderSellDialog({ isSubmitting: true }); + expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled(); + + rerender( + + ); + + expect(screen.getByTestId('trade-dialog-confirm')).not.toBeDisabled(); + }); + + it('disables cancel and prevents closing while the sell mutation is pending', () => { + const onOpenChange = vi.fn(); + renderSellDialog({ isSubmitting: true, onOpenChange }); + + expect(screen.getByTestId('trade-dialog-cancel')).toBeDisabled(); + }); + + it('calls onConfirm with the parsed sell quantity when confirm is clicked', () => { + const onConfirm = vi.fn(); + renderSellDialog({ onConfirm }); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '5' } }); + fireEvent.click(screen.getByTestId('trade-dialog-confirm')); + + expect(onConfirm).toHaveBeenCalledWith(5); + }); +}); diff --git a/src/pages/__tests__/LandingPage.sellConfirmationModal.integration.test.tsx b/src/pages/__tests__/LandingPage.sellConfirmationModal.integration.test.tsx new file mode 100644 index 0000000..7a591c6 --- /dev/null +++ b/src/pages/__tests__/LandingPage.sellConfirmationModal.integration.test.tsx @@ -0,0 +1,258 @@ +/** + * Integration tests for the sell key confirmation modal's mutation-outcome + * behavior (#692): the confirm button is disabled while the sell is + * pending, and the modal stays open with an inline error toast when the + * sell fails, rather than closing as if it had succeeded. + * + * Complements LandingPage.sellFlow.integration.test.tsx (success path) and + * TradeDialog.sellPayoutDisplay.test.tsx (payout display, bonding curve + * call, disabled-prop unit coverage) — this file covers the two mutation + * outcomes that require the full LandingPage wiring (handleConfirmTrade), + * since TradeDialog itself has no mutation or toast logic of its own. + */ +import type { ComponentProps, ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; +import showToast from '@/utils/toast.util'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('@/components/common/FeaturedCreatorAudienceChip', async () => { + const React = await import('react'); + + return { + FeaturedCreatorAudienceChip: () => + React.createElement('div', { 'data-testid': 'mock-audience-chip' }), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); +const mockShowToast = vi.mocked(showToast); + +const featuredCreatorOnly: Course[] = [ + { + id: '1', + title: 'Alex Rivers', + description: 'Digital Artist & Illustrator', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 120, + instructorId: '1', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const installStorageStub = (property: 'localStorage' | 'sessionStorage') => { + const store = new Map(); + Object.defineProperty(window, property, { + configurable: true, + writable: true, + value: { + getItem: (key: string) => store.get(String(key)) ?? null, + setItem: (key: string, value: string) => { + store.set(String(key), String(value)); + }, + removeItem: (key: string) => { + store.delete(String(key)); + }, + clear: () => store.clear(), + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { + return store.size; + }, + }, + }); +}; + +const renderLandingPage = () => + render( + + + + + + ); + +describe('Sell confirmation modal mutation outcomes (#692)', () => { + beforeEach(() => { + mockMatchMedia(); + installStorageStub('localStorage'); + installStorageStub('sessionStorage'); + mockGetCourses.mockReset(); + vi.clearAllMocks(); + mockGetCourses.mockResolvedValue(featuredCreatorOnly); + }); + + afterEach(() => { + cleanup(); + }); + + it('disables the confirm button while the sell is pending and re-enables it once settled', async () => { + renderLandingPage(); + await screen.findByText('3 keys · 0.05 XLM'); + + const [sellButton] = screen.getAllByRole('button', { name: 'Sell' }); + fireEvent.click(sellButton); + + const amountInput = await screen.findByTestId('trade-dialog-amount'); + fireEvent.change(amountInput, { target: { value: '1' } }); + + const confirmButton = screen.getByTestId('trade-dialog-confirm'); + expect(confirmButton).not.toBeDisabled(); + + fireEvent.click(confirmButton); + + // Still mid-flight (sell's simulated confirmation takes ~900ms+250ms) — + // the button must be disabled so a second click can't double-submit. + expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled(); + + await waitFor( + () => expect(mockShowToast.transactionSuccess).toHaveBeenCalled(), + { timeout: 5000 } + ); + }); + + it('keeps the modal open and shows an inline error toast when the sell fails, instead of closing as if it succeeded', async () => { + // showToast.loading is the first call inside handleConfirmTrade's sell + // branch; making it throw exercises the real catch block exactly as a + // genuine rendering/toast-library failure would, without needing the + // sell path (which doesn't go through tradeMutation) to have a + // separate fake failure mode invented for the test. + mockShowToast.loading.mockImplementationOnce(() => { + throw new Error('simulated toast failure'); + }); + + renderLandingPage(); + await screen.findByText('3 keys · 0.05 XLM'); + + const [sellButton] = screen.getAllByRole('button', { name: 'Sell' }); + fireEvent.click(sellButton); + + const amountInput = await screen.findByTestId('trade-dialog-amount'); + fireEvent.change(amountInput, { target: { value: '1' } }); + fireEvent.click(screen.getByTestId('trade-dialog-confirm')); + + await waitFor(() => { + expect(mockShowToast.error).toHaveBeenCalledWith( + 'The signature request failed. Please ensure your wallet is unlocked and try again.' + ); + }); + + // The modal must still be open (not closed as though the sell succeeded). + expect(screen.getByTestId('trade-dialog-amount')).toBeInTheDocument(); + expect(screen.getByTestId('trade-dialog-confirm')).toBeInTheDocument(); + + // Holdings must be unchanged — a failed sell must not decrement the balance. + expect(screen.getByText('3 keys · 0.05 XLM')).toBeInTheDocument(); + + // No success toast fired for the failed attempt. + expect(mockShowToast.transactionSuccess).not.toHaveBeenCalled(); + + // The confirm button is re-enabled after the failure settles, so the + // user can retry rather than being stuck on a disabled button. + await waitFor(() => { + expect(screen.getByTestId('trade-dialog-confirm')).not.toBeDisabled(); + }); + }); +});