From 26115c4184d66f241774a13f1a166db86de80a01 Mon Sep 17 00:00:00 2001 From: chizube Date: Tue, 28 Jul 2026 13:19:27 +0100 Subject: [PATCH] test: cover sell confirmation modal payout display and mutation outcomes (#692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the coverage the issue asks for across the two components actually involved: TradeDialog (payout display, bonding curve call, disabled-prop) and LandingPage's handleConfirmTrade (the sell mutation outcome wiring — TradeDialog itself holds no mutation or toast state). - TradeDialog.sellPayoutDisplay.test.tsx: correct estimated XLM payout for a given quantity, payout updates as quantity changes, "unavailable" fallback when price/supply are missing, estimateSellProceeds (the bonding curve calculation) called with the right arguments, no payout computed on the buy side, confirm/cancel disabled while isSubmitting, onConfirm receives the parsed quantity. - LandingPage.sellConfirmationModal.integration.test.tsx: confirm button disabled while the sell is in flight and re-enabled once it settles; modal stays open with an inline error toast on failure (rather than closing as if it succeeded) and holdings are left unchanged. Failure is injected via showToast.loading throwing once, which exercises the real catch-block path in handleConfirmTrade — the sell branch doesn't run through tradeMutation (it's a hardcoded simulated delay), so this is the one legitimate seam without inventing behavior that doesn't exist. Verified against the full suite via git stash: 34 pre-existing failing test files (localStorage/jsdom shadowing on newer Node versions, unrelated to this component) are identical before/after — 11 new tests added, 0 regressions. --- .../TradeDialog.sellPayoutDisplay.test.tsx | 123 +++++++++ ...sellConfirmationModal.integration.test.tsx | 258 ++++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 src/components/common/__tests__/TradeDialog.sellPayoutDisplay.test.tsx create mode 100644 src/pages/__tests__/LandingPage.sellConfirmationModal.integration.test.tsx 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(); + }); + }); +});