diff --git a/__tests__/pages/app/analytics.test.tsx b/__tests__/pages/app/analytics.test.tsx new file mode 100644 index 0000000..8ba4790 --- /dev/null +++ b/__tests__/pages/app/analytics.test.tsx @@ -0,0 +1,275 @@ +/** + * Tests for app/app/analytics/page.tsx — Analytics page composition (#770) + * + * Strategy + * ───────── + * AnalyticsPage is a page-level composition component. Its job is to: + * 1. Read streams from useStreams (with loading flag) and build a snapshot + * 2. Render four stat cards: Total volume, Active streams, Total streams, Avg duration + * 3. Render loading skeletons inside the stat cards while data is loading + * (fix #674 — the `loading` flag was previously unused) + * 4. Render AnalyticsCharts (via dynamic import, stubbed here) with snapshot data + * 5. Render the Network context card listing available tokens + * + * We test composition and the loading-skeleton fix. + * Heavy children (AnalyticsCharts) are stubbed; tested separately. + * + * Mocked boundaries + * ───────────────── + * • useStreams — primary data source; controls loading + stream arrays + * • useNetwork — returns a static 'testnet' network + * • next/dynamic — strips the dynamic import wrapper so AnalyticsCharts + * can be replaced by a simple stub + * • AnalyticsCharts — stubbed sentinel + * • SectionErrorBoundary — pass-through + * • lib/stellar — getAllTokens returns a fixed token list + * • lib/address-book — getFederationNameForAddress returns null + * • next/link — thin stub + * + * Following the pattern established in __tests__/pages/app/settings.test.tsx. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import type { StreamData, TokenInfo } from '@/types/stream' + +// ─── Static constants ───────────────────────────────────────────────────────── + +const NOW_SEC = 1_700_050_000 +const USDC: TokenInfo = { address: 'CUSDC', symbol: 'USDC', decimals: 6 } + +// ─── Mock holders ───────────────────────────────────────────────────────────── + +const mockUseStreams = vi.fn() +const mockUseNetwork = vi.fn() + +// ─── vi.mock declarations ───────────────────────────────────────────────────── + +vi.mock('@/hooks/use-streams', () => ({ + useStreams: () => mockUseStreams(), +})) + +vi.mock('@/components/providers/network-provider', () => ({ + useNetwork: () => mockUseNetwork(), +})) + +// Stub next/dynamic so AnalyticsCharts renders synchronously in tests +vi.mock('next/dynamic', () => ({ + default: (loader: () => Promise) => { + // Return the stub immediately (ignores async loading) + return function DynamicStub(props: Record) { + return
+ } + }, +})) + +vi.mock('@/components/analytics/charts', () => ({ + AnalyticsCharts: () =>
, +})) + +vi.mock('@/components/error-boundary/section-error-boundary', () => ({ + SectionErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock('@/lib/stellar', () => ({ + getAllTokens: vi.fn(() => [ + { address: 'CXLM', symbol: 'XLM', decimals: 7 }, + { address: 'CUSDC', symbol: 'USDC', decimals: 6 }, + { address: 'CEURC', symbol: 'EURC', decimals: 6 }, + ]), + explorerUrl: vi.fn(() => 'https://stellar.expert/testnet/contract/test'), +})) + +vi.mock('@/lib/address-book', () => ({ + getFederationNameForAddress: vi.fn(() => null), +})) + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: React.AnchorHTMLAttributes & { href: string }) => ( + {children} + ), +})) + +// ─── Import after all mocks ─────────────────────────────────────────────────── + +import AnalyticsPage from '@/app/app/analytics/page' + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeStream(overrides: Partial = {}): StreamData { + return { + id: 'stream-default', + sender: 'GSENDER111AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + recipient: 'GRCPT222AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + token: USDC, + depositedAmount: 1_000_000_000n, + withdrawnAmount: 0n, + startTime: BigInt(NOW_SEC - 1000), + endTime: BigInt(NOW_SEC + 1000), + cliffTime: BigInt(NOW_SEC - 1000), + cliffAmount: 0n, + amountPerSecond: 500_000n, + linearAmount: 1_000_000_000n, + duration: 2000n, + cancelled: false, + ...overrides, + } +} + +function defaultStreamsResult(overrides: Partial<{ all: StreamData[]; loading: boolean }> = {}) { + return { + all: [], + sent: [], + received: [], + loading: false, + isRefreshingAfterHidden: false, + stale: false, + lastUpdated: null, + refetch: vi.fn(), + ...overrides, + } +} + +// ─── Default mock setup (reset before each test) ───────────────────────────── + +beforeEach(() => { + vi.clearAllMocks() + + mockUseNetwork.mockReturnValue({ network: 'testnet' }) + mockUseStreams.mockReturnValue(defaultStreamsResult()) +}) + +// ─── Loading state (fix #674) ───────────────────────────────────────────────── + +describe('loading state — fix #674', () => { + beforeEach(() => { + mockUseStreams.mockReturnValue(defaultStreamsResult({ loading: true })) + }) + + it('renders loading skeleton in the Total volume stat card', () => { + render() + // The skeleton is a with animate-pulse; we find the stat card by title + const card = screen.getByText('Total volume streamed').closest('[class*="card"], [class]')! + // When loading, the value cell renders a skeleton span instead of a number + const skeleton = card?.querySelector('span.animate-pulse') + expect(skeleton).toBeTruthy() + }) + + it('renders loading skeleton in the Active streams stat card', () => { + render() + const card = screen.getByText('Active streams').closest('[class*="card"], [class]')! + expect(card?.querySelector('span.animate-pulse')).toBeTruthy() + }) + + it('renders loading skeleton in the Total streams stat card', () => { + render() + const card = screen.getByText('Total streams created').closest('[class*="card"], [class]')! + expect(card?.querySelector('span.animate-pulse')).toBeTruthy() + }) + + it('renders loading skeleton in the Average duration stat card', () => { + render() + const card = screen.getByText('Average duration').closest('[class*="card"], [class]')! + expect(card?.querySelector('span.animate-pulse')).toBeTruthy() + }) +}) + +// ─── Empty dataset ──────────────────────────────────────────────────────────── + +describe('empty dataset (loaded, no streams)', () => { + it('renders the page heading', () => { + render() + expect(screen.getByRole('heading', { name: /platform analytics/i })).toBeInTheDocument() + }) + + it('renders the page subheading', () => { + render() + expect( + screen.getByText(/public signals that highlight traction/i), + ).toBeInTheDocument() + }) + + it('renders the "Back to dashboard" link', () => { + render() + const link = screen.getByRole('link', { name: /back to dashboard/i }) + expect(link).toHaveAttribute('href', '/app') + }) + + it('renders all four stat card titles', () => { + render() + expect(screen.getByText('Total volume streamed')).toBeInTheDocument() + expect(screen.getByText('Active streams')).toBeInTheDocument() + expect(screen.getByText('Total streams created')).toBeInTheDocument() + expect(screen.getByText('Average duration')).toBeInTheDocument() + }) + + it('renders stat card values of 0 / 0.0d when no streams', () => { + render() + // Active count and Total streams should both show 0 + const zeros = screen.getAllByText('0') + expect(zeros.length).toBeGreaterThanOrEqual(2) + // Average duration should show 0.0d + expect(screen.getByText('0.0d')).toBeInTheDocument() + }) + + it('renders the Network context card', () => { + render() + expect(screen.getByText('Network context')).toBeInTheDocument() + }) + + it('renders available token badges in the Network context card', () => { + render() + expect(screen.getByText('XLM')).toBeInTheDocument() + expect(screen.getByText('USDC')).toBeInTheDocument() + expect(screen.getByText('EURC')).toBeInTheDocument() + }) +}) + +// ─── Populated dataset ──────────────────────────────────────────────────────── + +describe('populated dataset', () => { + const activeStream = makeStream({ id: 'stream-a' }) + const cancelledStream = makeStream({ + id: 'stream-b', + cancelled: true, + endTime: BigInt(NOW_SEC + 5000), + }) + + beforeEach(() => { + mockUseStreams.mockReturnValue( + defaultStreamsResult({ all: [activeStream, cancelledStream], loading: false }), + ) + }) + + it('renders non-zero Total streams count', () => { + render() + // Both streams are in the all-time range ("30d" default filters by startTime >= cutoff) + // The makeStream default startTime is relative to NOW_SEC so it falls within 30 days + // We check that at least one of the numeric values is non-zero + const twoText = screen.queryByText('2') + const oneText = screen.queryByText('1') + expect(twoText ?? oneText).toBeTruthy() + }) + + it('renders the AnalyticsCharts component', () => { + render() + // The stub or the dynamic-wrapped real component renders with this testid + expect(screen.getByTestId('analytics-charts')).toBeInTheDocument() + }) + + it('does not render loading skeletons when data is loaded', () => { + render() + const skeletons = document.querySelectorAll('span.animate-pulse') + expect(skeletons.length).toBe(0) + }) +}) + +// ─── Range selector ─────────────────────────────────────────────────────────── + +describe('range selector', () => { + it('renders a time-range select control', () => { + render() + // The Select renders with a visible "30 days" default value + expect(screen.getByText('30 days')).toBeInTheDocument() + }) +}) diff --git a/__tests__/pages/app/landing.test.tsx b/__tests__/pages/app/landing.test.tsx new file mode 100644 index 0000000..02d98e0 --- /dev/null +++ b/__tests__/pages/app/landing.test.tsx @@ -0,0 +1,130 @@ +/** + * Tests for app/page.tsx — Landing page composition (#765) + * + * Strategy + * ───────── + * LandingPage is a page-level composition component that assembles the full + * marketing page. Its job is to render: + * 1. LandingHeader — top nav + * 2. Hero — headline + CTA + * 3. Features — feature-section grid + * 4. HowItWorks — step-by-step explainer + * 5. UseCases — use-case cards + * 6. CTA — bottom call-to-action + * 7. Footer — site footer + * + * We test: + * • Each section is present in the rendered output. + * • Sections appear in the expected document order (hero before features, + * features before footer, etc.). + * + * Mocked boundaries + * ───────────────── + * All landing sub-components are stubbed so the test doesn't need a router, + * wallet context, or any data-fetching. Each stub renders a sentinel + * data-testid that the composition assertions target. + * + * Following the pattern established in __tests__/pages/app/settings.test.tsx + * (the reference for page-level composition tests in this codebase). + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' + +// ─── Stub landing sub-components ───────────────────────────────────────────── + +vi.mock('@/components/landing/hero', () => ({ + LandingHeader: () =>
Landing Header
, + Hero: () =>
Hero
, +})) + +vi.mock('@/components/landing/sections', () => ({ + Features: () =>
Features
, + HowItWorks: () =>
How It Works
, + UseCases: () =>
Use Cases
, + CTA: () =>
CTA
, + Footer: () =>
Footer
, +})) + +// ─── Import after mocks ─────────────────────────────────────────────────────── + +import LandingPage from '@/app/page' + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('LandingPage composition (#765)', () => { + it('renders the landing header', () => { + render() + expect(screen.getByTestId('landing-header')).toBeInTheDocument() + }) + + it('renders the Hero section', () => { + render() + expect(screen.getByTestId('hero')).toBeInTheDocument() + }) + + it('renders the Features section', () => { + render() + expect(screen.getByTestId('features')).toBeInTheDocument() + }) + + it('renders the HowItWorks section', () => { + render() + expect(screen.getByTestId('how-it-works')).toBeInTheDocument() + }) + + it('renders the UseCases section', () => { + render() + expect(screen.getByTestId('use-cases')).toBeInTheDocument() + }) + + it('renders the CTA section', () => { + render() + expect(screen.getByTestId('cta')).toBeInTheDocument() + }) + + it('renders the Footer', () => { + render() + expect(screen.getByTestId('footer')).toBeInTheDocument() + }) + + it('renders all seven sections together in a single pass', () => { + render() + + expect(screen.getByTestId('landing-header')).toBeInTheDocument() + expect(screen.getByTestId('hero')).toBeInTheDocument() + expect(screen.getByTestId('features')).toBeInTheDocument() + expect(screen.getByTestId('how-it-works')).toBeInTheDocument() + expect(screen.getByTestId('use-cases')).toBeInTheDocument() + expect(screen.getByTestId('cta')).toBeInTheDocument() + expect(screen.getByTestId('footer')).toBeInTheDocument() + }) + + it('renders sections in the expected document order (header → hero → features → footer)', () => { + render() + + const elements = [ + screen.getByTestId('landing-header'), + screen.getByTestId('hero'), + screen.getByTestId('features'), + screen.getByTestId('how-it-works'), + screen.getByTestId('use-cases'), + screen.getByTestId('cta'), + screen.getByTestId('footer'), + ] + + // Each element should appear later in the document than the previous one. + for (let i = 1; i < elements.length; i++) { + expect( + elements[i - 1].compareDocumentPosition(elements[i]) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + } + }) + + it('wraps everything in a single root div', () => { + const { container } = render() + // The page root is a single
+ expect(container.firstElementChild?.tagName).toBe('DIV') + }) +}) diff --git a/__tests__/pages/app/stream-detail.test.tsx b/__tests__/pages/app/stream-detail.test.tsx new file mode 100644 index 0000000..e0d57df --- /dev/null +++ b/__tests__/pages/app/stream-detail.test.tsx @@ -0,0 +1,525 @@ +/** + * Tests for app/app/stream/[id]/page.tsx — Stream detail page composition (#771) + * + * Strategy + * ───────── + * StreamPage renders StreamDetail, which is the largest single page in the + * app (withdraw/cancel dialogs, share menu, auto-withdraw, timeline). Its job: + * 1. Loading state — show StreamDetailSkeleton while `useStream` resolves + * 2. Not-found state — show "Stream not found" message for a bad/missing ID + * 3. Found state — render header card, details section, timeline, charts, + * and action buttons wired to the stream data + * 4. Connect prompt for unauthenticated visitors + * 5. Withdraw / Cancel buttons for the right parties + * + * We test the two states required by the issue (found stream / not-found ID) + * and the most important composition assertions — skeleton, not-found message, + * stream detail sections, and party-specific action buttons. + * + * Mocked boundaries + * ───────────────── + * • useStream — primary data source; controls loading + stream + * • useWallet — wallet address / connection state + * • useContract — withdraw / cancel / cleanup + * • useNow — stable timestamp + * • useNetwork — testnet + * • useUndoableCancel / useIsStreamCancelling — cancel-undo state + * • useAutoWithdraw — auto-withdraw settings + * • useTokenPrice — returns null price (no USD display needed) + * • next/navigation — router stub + * • next/link — thin stub + * • All heavy child components — stubbed with sentinels + * • lib/contract — bumpStreamTtl no-op + * • lib/address-book — getFederationNameForAddress returns null + * • lib/stellar — explorerUrl returns a test URL + * + * Following the pattern established in __tests__/pages/app/settings.test.tsx + * (the reference for page-level composition tests). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import type { StreamData, TokenInfo } from '@/types/stream' + +// ─── Static constants ───────────────────────────────────────────────────────── + +const SENDER_ADDRESS = 'GSENDER111AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const RECIPIENT_ADDRESS = 'GRCPT222AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const OTHER_ADDRESS = 'GOTHER333AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const NOW_SEC = 1_700_050_000 + +const USDC: TokenInfo = { address: 'CUSDC', symbol: 'USDC', decimals: 6 } + +// ─── Mock holders ───────────────────────────────────────────────────────────── + +const mockUseStream = vi.fn() +const mockUseWallet = vi.fn() +const mockUseContract = vi.fn() +const mockUseNetwork = vi.fn() +const mockUseUndoableCancel = vi.fn() +const mockUseIsStreamCancelling = vi.fn() +const mockUseAutoWithdraw = vi.fn() +const mockUseTokenPrice = vi.fn() + +const mockRouterPush = vi.fn() + +// ─── vi.mock declarations ───────────────────────────────────────────────────── + +vi.mock('@/hooks/use-streams', () => ({ + useStream: (id: string) => mockUseStream(id), +})) + +vi.mock('@/hooks/use-wallet', () => ({ + useWallet: () => mockUseWallet(), +})) + +vi.mock('@/hooks/use-contract', () => ({ + useContract: () => mockUseContract(), +})) + +vi.mock('@/hooks/use-now', () => ({ + useNow: () => NOW_SEC, +})) + +vi.mock('@/components/providers/network-provider', () => ({ + useNetwork: () => mockUseNetwork(), +})) + +vi.mock('@/hooks/use-undo-cancel', () => ({ + useUndoableCancel: () => mockUseUndoableCancel(), + useIsStreamCancelling: (id: string) => mockUseIsStreamCancelling(id), +})) + +vi.mock('@/hooks/use-auto-withdraw', () => ({ + useAutoWithdraw: () => mockUseAutoWithdraw(), +})) + +vi.mock('@/hooks/use-token-price', () => ({ + useTokenPrice: () => mockUseTokenPrice(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockRouterPush, replace: vi.fn() }), +})) + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + + {children} + + ), +})) + +// ─── Heavy child component stubs ───────────────────────────────────────────── + +vi.mock('@/components/streams/unlock-chart', () => ({ + UnlockChart: () =>
, +})) + +vi.mock('@/components/streams/stream-timeline', () => ({ + StreamTimeline: () =>
, +})) + +vi.mock('@/components/streams/stream-status-badge', () => ({ + StreamStatusBadge: ({ status }: { status: string }) => ( + {status} + ), +})) + +vi.mock('@/components/streams/download-receipt-button', () => ({ + DownloadReceiptButton: () => , +})) + +vi.mock('@/components/streams/qr-share-dialog', () => ({ + QrShareDialog: () =>
, +})) + +vi.mock('@/components/ui/progress-bar', () => ({ + ProgressBar: () =>
, +})) + +vi.mock('@/components/ui/token-amount', () => ({ + TokenAmount: ({ + amount, + token, + }: { + amount: bigint + token: { symbol: string; decimals: number } + }) => ( + + {(Number(amount) / 10 ** token.decimals).toFixed(2)} {token.symbol} + + ), +})) + +vi.mock('@/components/ui/countdown-timer', () => ({ + CountdownTimer: () =>
, +})) + +vi.mock('@/components/ui/accessible-countdown-timer', () => ({ + AccessibleCountdownTimer: () =>
, +})) + +vi.mock('@/components/ui/accessible-unlock-amount', () => ({ + AccessibleUnlockAmount: () =>
, +})) + +vi.mock('@/components/ui/fee-estimate-dialog', () => ({ + FeeEstimateDialog: () =>
, +})) + +vi.mock('@/components/layout/connect-wallet-button', () => ({ + ConnectWalletButton: () => , +})) + +// ─── Lib stubs ──────────────────────────────────────────────────────────────── + +vi.mock('@/lib/contract', () => ({ + bumpStreamTtl: vi.fn(), +})) + +vi.mock('@/lib/address-book', () => ({ + getFederationNameForAddress: vi.fn(() => null), +})) + +vi.mock('@/lib/stellar', () => ({ + explorerUrl: vi.fn(() => 'https://stellar.expert/testnet/account/test'), +})) + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +// ─── Import after all mocks ─────────────────────────────────────────────────── + +import StreamPage from '@/app/app/stream/[id]/page' + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeStream(overrides: Partial = {}): StreamData { + return { + id: 'stream-abc', + sender: SENDER_ADDRESS, + recipient: RECIPIENT_ADDRESS, + token: USDC, + depositedAmount: 1_000_000_000n, + withdrawnAmount: 0n, + startTime: BigInt(NOW_SEC - 1000), + endTime: BigInt(NOW_SEC + 1000), + cliffTime: BigInt(NOW_SEC - 1000), + cliffAmount: 0n, + amountPerSecond: 500_000n, + linearAmount: 1_000_000_000n, + duration: 2000n, + cancelled: false, + ...overrides, + } +} + +/** Render the page component with a resolved params Promise. */ +function renderPage(id: string) { + return render() +} + +// ─── Default mock setup ─────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks() + + mockUseWallet.mockReturnValue({ + address: SENDER_ADDRESS, + isConnected: true, + reconnecting: false, + }) + + mockUseNetwork.mockReturnValue({ + network: 'testnet', + config: { streamContractId: 'CCONTRACT123' }, + }) + + mockUseContract.mockReturnValue({ + withdraw: vi.fn(), + cancel: vi.fn(), + cleanup: vi.fn(), + pending: false, + error: null, + }) + + mockUseUndoableCancel.mockReturnValue({ + scheduleCancel: vi.fn(), + }) + + mockUseIsStreamCancelling.mockReturnValue(false) + + mockUseAutoWithdraw.mockReturnValue({ + settings: { + enabled: false, + strategy: 'time-based', + intervalHours: 24, + thresholdPercentage: 50, + minAmountRaw: '0', + maxSafetyLimitRaw: '0', + }, + updateSettings: vi.fn(), + lastAutoWithdraw: null, + autoWithdrawPending: false, + withdrawalHistory: [], + }) + + mockUseTokenPrice.mockReturnValue({ usdPrice: null }) + + // Default: stream loaded with data + mockUseStream.mockReturnValue({ + stream: makeStream(), + loading: false, + refetch: vi.fn(), + }) +}) + +// ─── Loading state ──────────────────────────────────────────────────────────── + +describe('loading state', () => { + beforeEach(() => { + mockUseStream.mockReturnValue({ stream: null, loading: true, refetch: vi.fn() }) + }) + + it('renders the skeleton while loading', async () => { + const { container } = renderPage('stream-abc') + // The skeleton has animate-pulse class on its root + await vi.waitFor(() => { + expect(container.querySelector('.animate-pulse')).toBeTruthy() + }) + }) + + it('does not render the header card content while loading', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect(screen.queryByTestId('stream-status-badge')).not.toBeInTheDocument() + }) + }) +}) + +// ─── Not-found state ────────────────────────────────────────────────────────── + +describe('not-found state (bad ID)', () => { + beforeEach(() => { + mockUseStream.mockReturnValue({ stream: null, loading: false, refetch: vi.fn() }) + }) + + it('renders the "Stream not found" message', async () => { + renderPage('nonexistent-id') + await vi.waitFor(() => { + expect(screen.getByText(/stream not found/i)).toBeInTheDocument() + }) + }) + + it('renders the not-found body text', async () => { + renderPage('nonexistent-id') + await vi.waitFor(() => { + expect( + screen.getByText(/this stream may not exist or may have expired/i), + ).toBeInTheDocument() + }) + }) + + it('renders a "Back to dashboard" link for the not-found state', async () => { + renderPage('nonexistent-id') + await vi.waitFor(() => { + const link = screen.getByRole('link', { name: /back to dashboard/i }) + expect(link).toHaveAttribute('href', '/app') + }) + }) + + it('does not render the stream header card for a missing stream', async () => { + renderPage('nonexistent-id') + await vi.waitFor(() => { + expect(screen.queryByTestId('stream-status-badge')).not.toBeInTheDocument() + expect(screen.queryByTestId('unlock-chart')).not.toBeInTheDocument() + }) + }) +}) + +// ─── Found state — composition ──────────────────────────────────────────────── + +describe('found state — page composition', () => { + it('renders the back link to the dashboard', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + const link = screen.getByRole('link', { name: /dashboard/i }) + expect(link).toHaveAttribute('href', '/app') + }) + }) + + it('renders the stream status badge', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect(screen.getByTestId('stream-status-badge')).toBeInTheDocument() + }) + }) + + it('renders the live unlock counter (AccessibleUnlockAmount)', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect(screen.getByTestId('accessible-unlock-amount')).toBeInTheDocument() + }) + }) + + it('renders the progress bar', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect(screen.getByTestId('progress-bar')).toBeInTheDocument() + }) + }) + + it('renders the UnlockChart section', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect(screen.getByTestId('unlock-chart')).toBeInTheDocument() + }) + }) + + it('renders the StreamTimeline section', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect(screen.getByTestId('stream-timeline')).toBeInTheDocument() + }) + }) + + it('renders the Details section heading', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect(screen.getByText('Details')).toBeInTheDocument() + }) + }) + + it('renders the Share button', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect(screen.getByRole('button', { name: /share stream/i })).toBeInTheDocument() + }) + }) + + it('renders the download receipt button', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect(screen.getByTestId('download-receipt-btn')).toBeInTheDocument() + }) + }) +}) + +// ─── Sender-specific actions ────────────────────────────────────────────────── + +describe('sender actions', () => { + beforeEach(() => { + // Active stream that the sender can cancel + mockUseStream.mockReturnValue({ + stream: makeStream({ + sender: SENDER_ADDRESS, + recipient: RECIPIENT_ADDRESS, + cancelled: false, + }), + loading: false, + refetch: vi.fn(), + }) + mockUseWallet.mockReturnValue({ + address: SENDER_ADDRESS, + isConnected: true, + reconnecting: false, + }) + }) + + it('renders the "Cancel stream" button for the sender', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect(screen.getByRole('button', { name: /cancel stream/i })).toBeInTheDocument() + }) + }) + + it('renders the "Duplicate stream" button for the sender', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect(screen.getByRole('button', { name: /duplicate stream/i })).toBeInTheDocument() + }) + }) + + it('does not render the "Withdraw" button for the sender', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect(screen.queryByRole('button', { name: /^withdraw/i })).not.toBeInTheDocument() + }) + }) +}) + +// ─── Recipient-specific actions ─────────────────────────────────────────────── + +describe('recipient actions', () => { + beforeEach(() => { + // Active stream where wallet is the recipient with withdrawable balance + mockUseStream.mockReturnValue({ + stream: makeStream({ + sender: OTHER_ADDRESS, + recipient: RECIPIENT_ADDRESS, + withdrawnAmount: 0n, + amountPerSecond: 500_000n, + startTime: BigInt(NOW_SEC - 1000), + endTime: BigInt(NOW_SEC + 1000), + }), + loading: false, + refetch: vi.fn(), + }) + mockUseWallet.mockReturnValue({ + address: RECIPIENT_ADDRESS, + isConnected: true, + reconnecting: false, + }) + }) + + it('renders the "Withdraw" button for the recipient with withdrawable balance', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect(screen.getByRole('button', { name: /withdraw/i })).toBeInTheDocument() + }) + }) + + it('does not render the "Cancel stream" button for the recipient', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect(screen.queryByRole('button', { name: /cancel stream/i })).not.toBeInTheDocument() + }) + }) +}) + +// ─── Unauthenticated visitor ────────────────────────────────────────────────── + +describe('unauthenticated visitor', () => { + beforeEach(() => { + mockUseWallet.mockReturnValue({ + address: undefined, + isConnected: false, + reconnecting: false, + }) + }) + + it('renders the connect-wallet prompt for unauthenticated visitors', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect( + screen.getByText(/connect your wallet to withdraw, cancel, or interact/i), + ).toBeInTheDocument() + }) + }) + + it('renders the ConnectWalletButton inside the connect prompt', async () => { + renderPage('stream-abc') + await vi.waitFor(() => { + expect(screen.getByTestId('connect-wallet-btn')).toBeInTheDocument() + }) + }) +}) diff --git a/__tests__/pages/app/streams.test.tsx b/__tests__/pages/app/streams.test.tsx new file mode 100644 index 0000000..0a63cee --- /dev/null +++ b/__tests__/pages/app/streams.test.tsx @@ -0,0 +1,426 @@ +/** + * Tests for app/app/streams/page.tsx — Streams list page composition (#766) + * + * Strategy + * ───────── + * StreamsRoute is a page-level composition component. Its job is to: + * 1. Gate the page behind RequireWallet + * 2. Render search, token, sort and status filter controls + * 3. Render the bulk-select toggle (and bulk-action bar when selections exist) + * 4. Render the stream list (VirtualStreamList) or EmptyStreams sentinel, + * all wired to a mocked useStreams + * 5. Render the Active / Archived tab switcher + * + * We test composition and wallet gating. Heavy child components (VirtualStreamList, + * EmptyStreams, StreamGanttView, ArchivedStreamsTab) are stubbed; tested separately. + * + * Mocked boundaries + * ───────────────── + * • useStreams — primary data source (all streams array) + * • useArchivedStreams — archived stream IDs + * • useWallet — wallet address / connection state + * • useContract — withdraw / cancel / cleanup + * • useNow — stable timestamp + * • useHiddenStreams — empty by default + * • useStreamsViewPreference — list view by default + * • useBulkSelect — selection state + * • useBulkActions — bulk-action state + * • next/navigation — router + searchParams stubs + * • RequireWallet — controlled by wallet mock + * • VirtualStreamList — renders stream ids as sentinels + * • EmptyStreams — sentinel + * • StreamGanttView — sentinel + * • lib/export — no-ops (no file-download side-effects) + * + * Following the pattern established in __tests__/pages/app/settings.test.tsx. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { StreamData, TokenInfo } from '@/types/stream' + +// ─── Static constants ───────────────────────────────────────────────────────── + +const WALLET_ADDRESS = 'GSENDER111AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const RECIPIENT_ADDRESS = 'GRCPT222AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const NOW_SEC = 1_700_050_000 + +const USDC: TokenInfo = { address: 'CUSDC', symbol: 'USDC', decimals: 6 } + +// ─── Mock holders ───────────────────────────────────────────────────────────── + +const mockUseStreams = vi.fn() +const mockUseWallet = vi.fn() +const mockUseContract = vi.fn() +const mockUseHiddenStreams = vi.fn() +const mockUseStreamsViewPreference = vi.fn() +const mockUseBulkSelect = vi.fn() +const mockUseBulkActions = vi.fn() +const mockUseArchivedStreams = vi.fn() + +// ─── vi.mock declarations ───────────────────────────────────────────────────── + +vi.mock('@/hooks/use-streams', () => ({ + useStreams: () => mockUseStreams(), +})) + +vi.mock('@/hooks/use-wallet', () => ({ + useWallet: () => mockUseWallet(), +})) + +vi.mock('@/hooks/use-contract', () => ({ + useContract: () => mockUseContract(), +})) + +vi.mock('@/hooks/use-now', () => ({ + useNow: () => NOW_SEC, +})) + +vi.mock('@/hooks/use-hidden-streams', () => ({ + useHiddenStreams: () => mockUseHiddenStreams(), +})) + +vi.mock('@/hooks/use-streams-view-preference', () => ({ + useStreamsViewPreference: () => mockUseStreamsViewPreference(), +})) + +vi.mock('@/hooks/use-bulk-select', () => ({ + useBulkSelect: () => mockUseBulkSelect(), +})) + +vi.mock('@/hooks/use-bulk-actions', () => ({ + useBulkActions: () => mockUseBulkActions(), +})) + +vi.mock('@/hooks/use-archived-streams', () => ({ + useArchivedStreams: () => mockUseArchivedStreams(), +})) + +// Stub next/navigation with controllable searchParams +const mockRouterReplace = vi.fn() +const mockSearchParams = new URLSearchParams() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: mockRouterReplace }), + useSearchParams: () => mockSearchParams, +})) + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: React.AnchorHTMLAttributes & { href: string }) => ( + {children} + ), +})) + +// Stub RequireWallet — passes through when wallet is connected, shows gate otherwise +vi.mock('@/components/layout/require-wallet', () => ({ + RequireWallet: ({ children }: { children: React.ReactNode }) => { + const { isConnected, reconnecting } = mockUseWallet() + if (reconnecting) return null + if (!isConnected) return
Connect your wallet
+ return <>{children} + }, +})) + +// Stream list stubs +vi.mock('@/components/streams/virtual-stream-list', () => ({ + VirtualStreamList: ({ streams }: { streams: StreamData[] }) => ( +
+ {streams.map((s) => ( +
+ ))} +
+ ), +})) + +vi.mock('@/components/streams/empty-state', () => ({ + EmptyStreams: () =>
No streams yet
, +})) + +vi.mock('@/components/streams/stream-gantt-view', () => ({ + StreamGanttView: () =>
Gantt view
, +})) + +// No-op export utilities to prevent file-download side-effects +vi.mock('@/lib/export', () => ({ + streamsToCSV: vi.fn(() => ''), + downloadCSV: vi.fn(), +})) + +// ─── Import after all mocks ─────────────────────────────────────────────────── + +import StreamsRoute from '@/app/app/streams/page' + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeStream(overrides: Partial = {}): StreamData { + return { + id: 'stream-default', + sender: WALLET_ADDRESS, + recipient: RECIPIENT_ADDRESS, + token: USDC, + depositedAmount: 1_000_000_000n, + withdrawnAmount: 0n, + startTime: BigInt(NOW_SEC - 1000), + endTime: BigInt(NOW_SEC + 1000), + cliffTime: BigInt(NOW_SEC - 1000), + cliffAmount: 0n, + amountPerSecond: 500_000n, + linearAmount: 1_000_000_000n, + duration: 2000n, + cancelled: false, + ...overrides, + } +} + +function defaultStreamsResult(overrides: Partial> = {}) { + return { + all: [], + sent: [], + received: [], + loading: false, + isRefreshingAfterHidden: false, + stale: false, + lastUpdated: null, + refetch: vi.fn(), + ...overrides, + } +} + +function defaultBulkSelect(overrides = {}) { + return { + selected: new Set(), + selectedItems: [], + allSelected: false, + toggle: vi.fn(), + toggleAll: vi.fn(), + clear: vi.fn(), + ...overrides, + } +} + +function defaultBulkActions(overrides = {}) { + return { + status: 'idle' as const, + results: [], + succeeded: 0, + failed: 0, + runBulk: vi.fn(), + reset: vi.fn(), + ...overrides, + } +} + +// ─── Default mock setup (reset before each test) ───────────────────────────── + +beforeEach(() => { + vi.clearAllMocks() + mockSearchParams.forEach((_, key) => mockSearchParams.delete(key)) + + mockUseWallet.mockReturnValue({ + address: WALLET_ADDRESS, + isConnected: true, + reconnecting: false, + }) + + mockUseContract.mockReturnValue({ + withdraw: vi.fn(), + cancel: vi.fn(), + cleanup: vi.fn(), + pending: false, + }) + + mockUseHiddenStreams.mockReturnValue({ + hiddenIds: new Set(), + blockedSenders: new Set(), + hideStream: vi.fn(), + }) + + mockUseStreamsViewPreference.mockReturnValue({ + view: 'list', + setView: vi.fn(), + }) + + mockUseBulkSelect.mockReturnValue(defaultBulkSelect()) + mockUseBulkActions.mockReturnValue(defaultBulkActions()) + + mockUseArchivedStreams.mockReturnValue({ + sent: [], + received: [], + loading: false, + refetch: vi.fn(), + }) + + mockUseStreams.mockReturnValue(defaultStreamsResult()) +}) + +// ─── Wallet gating ──────────────────────────────────────────────────────────── + +describe('wallet gating', () => { + it('shows wallet gate when not connected', () => { + mockUseWallet.mockReturnValue({ isConnected: false, reconnecting: false, address: undefined }) + render() + expect(screen.getByTestId('wallet-gate')).toBeInTheDocument() + }) + + it('renders nothing while reconnecting', () => { + mockUseWallet.mockReturnValue({ isConnected: false, reconnecting: true, address: undefined }) + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('renders page content when wallet is connected', () => { + render() + expect(screen.queryByTestId('wallet-gate')).not.toBeInTheDocument() + }) +}) + +// ─── Page header and structure ──────────────────────────────────────────────── + +describe('page header and structure', () => { + it('renders the "Streams" page heading', () => { + render() + expect(screen.getByRole('heading', { name: /streams/i })).toBeInTheDocument() + }) + + it('renders the page description', () => { + render() + expect(screen.getByText(/all streams you've sent or received/i)).toBeInTheDocument() + }) + + it('renders the Download CSV button', () => { + render() + expect(screen.getByRole('button', { name: /download csv/i })).toBeInTheDocument() + }) +}) + +// ─── Active / Archived tab switcher ────────────────────────────────────────── + +describe('Active / Archived tabs', () => { + it('renders the Active tab button', () => { + render() + expect(screen.getByRole('button', { name: /^active$/i })).toBeInTheDocument() + }) + + it('renders the Archived tab button', () => { + render() + expect(screen.getByRole('button', { name: /archived/i })).toBeInTheDocument() + }) + + it('Active tab is selected by default (aria-pressed=true)', () => { + render() + const activeBtn = screen.getByRole('button', { name: /^active$/i }) + expect(activeBtn).toHaveAttribute('aria-pressed', 'true') + }) + + it('switching to Archived tab renders the archived content area', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /archived/i })) + // Archived tab shows the empty message since mock returns no archived streams + expect( + screen.getByText(/no archived streams yet/i), + ).toBeInTheDocument() + }) +}) + +// ─── Filter controls ────────────────────────────────────────────────────────── + +describe('filter controls', () => { + it('renders the search input', () => { + render() + expect(screen.getByTestId('streams-search-input')).toBeInTheDocument() + }) + + it('renders token filter pills (All tokens, XLM, USDC, EURC)', () => { + render() + expect(screen.getByRole('button', { name: /all tokens/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^xlm$/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^usdc$/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^eurc$/i })).toBeInTheDocument() + }) + + it('renders the status filter pills', () => { + render() + // The status filter renders "All", "Streaming", "Scheduled", "Completed", "Cancelled" + // "All" appears twice (token + status), both should be present + const allButtons = screen.getAllByRole('button', { name: /^all$/i }) + expect(allButtons.length).toBeGreaterThanOrEqual(1) + expect(screen.getByRole('button', { name: /streaming/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /scheduled/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /completed/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /cancelled/i })).toBeInTheDocument() + }) + + it('renders the sort select', () => { + render() + expect(screen.getByTestId('streams-sort-select')).toBeInTheDocument() + }) +}) + +// ─── Bulk-select toggle ─────────────────────────────────────────────────────── + +describe('bulk-select toggle', () => { + it('renders the bulk-select toggle button', () => { + render() + expect(screen.getByTestId('bulk-select-toggle')).toBeInTheDocument() + }) + + it('bulk-select toggle is disabled when there are no streams', () => { + mockUseStreams.mockReturnValue(defaultStreamsResult({ all: [] })) + render() + expect(screen.getByTestId('bulk-select-toggle')).toBeDisabled() + }) + + it('bulk-select toggle is enabled when streams are present', () => { + const s = makeStream({ id: 'test-stream' }) + mockUseStreams.mockReturnValue(defaultStreamsResult({ all: [s] })) + render() + expect(screen.getByTestId('bulk-select-toggle')).not.toBeDisabled() + }) +}) + +// ─── Stream list / empty state ──────────────────────────────────────────────── + +describe('stream list and empty state', () => { + it('shows EmptyStreams when there are no streams and no filters', () => { + mockUseStreams.mockReturnValue(defaultStreamsResult({ all: [] })) + render() + expect(screen.getByTestId('empty-streams')).toBeInTheDocument() + expect(screen.queryByTestId('virtual-stream-list')).not.toBeInTheDocument() + }) + + it('renders VirtualStreamList when streams are present', () => { + const s1 = makeStream({ id: 'abc' }) + const s2 = makeStream({ id: 'def' }) + mockUseStreams.mockReturnValue(defaultStreamsResult({ all: [s1, s2] })) + render() + expect(screen.getByTestId('virtual-stream-list')).toBeInTheDocument() + expect(screen.queryByTestId('empty-streams')).not.toBeInTheDocument() + }) + + it('renders one sentinel row per stream in VirtualStreamList', () => { + const streams = ['id-1', 'id-2', 'id-3'].map((id) => makeStream({ id })) + mockUseStreams.mockReturnValue(defaultStreamsResult({ all: streams })) + render() + expect(screen.getByTestId('stream-row-id-1')).toBeInTheDocument() + expect(screen.getByTestId('stream-row-id-2')).toBeInTheDocument() + expect(screen.getByTestId('stream-row-id-3')).toBeInTheDocument() + }) +}) + +// ─── View toggles ───────────────────────────────────────────────────────────── + +describe('view toggle buttons', () => { + it('renders List, Compact and Timeline view buttons', () => { + render() + expect(screen.getByRole('button', { name: /list view/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /compact view/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /timeline view/i })).toBeInTheDocument() + }) + + it('renders the show-hidden toggle button', () => { + render() + expect(screen.getByTestId('show-hidden-toggle')).toBeInTheDocument() + }) +})