Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions __tests__/components/create-form.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { CreateForm } from '@/app/app/create/create-form'

// ─── Next.js navigation ───────────────────────────────────────────────────────
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => ({ get: () => null }),
}))

// ─── Wallet / contract hooks ──────────────────────────────────────────────────
vi.mock('@/hooks/use-wallet', () => ({
useWallet: () => ({ address: 'GSENDERTEST111111111111111111111111111111111111' }),
}))

vi.mock('@/hooks/use-contract', () => ({
useContract: () => ({
createStream: vi.fn(),
estimateFee: vi.fn(),
pending: false,
error: null,
}),
}))

vi.mock('@/hooks/use-token-price', () => ({
useTokenPrice: () => ({ usdPrice: null, stale: false, loading: false }),
}))

vi.mock('@/hooks/use-form-draft', () => ({
useFormDraft: () => ({ loadDraft: () => null, restore: vi.fn(), discard: vi.fn() }),
clearExpiredDrafts: vi.fn(),
}))

// ─── Stellar / contract lib ───────────────────────────────────────────────────
vi.mock('@/lib/stellar', () => ({
getAllTokens: () => [{ address: 'CNATIVE', symbol: 'XLM', decimals: 7 }],
saveCustomToken: vi.fn(),
checkAccountInfo: vi.fn().mockResolvedValue({ exists: true, funded: true, transactionCount: 1 }),
}))

vi.mock('@/lib/contract', () => ({
getTokenMetadata: vi.fn(),
getTokenBalance: vi.fn().mockResolvedValue(null),
}))

vi.mock('@/lib/federation', () => ({
isFederationAddress: () => false,
resolveFederationAddress: vi.fn(),
}))

vi.mock('@/lib/address-book', () => ({
addAddressBookEntry: vi.fn(),
deleteAddressBookEntry: vi.fn(),
getAddressBookEntries: () => [],
touchAddressBookEntry: vi.fn(),
updateAddressBookEntry: vi.fn(),
}))

vi.mock('@/lib/recurring', () => ({
buildNextRunAt: vi.fn(),
saveRecurringRule: vi.fn(),
}))

// ─── Network provider ─────────────────────────────────────────────────────────
vi.mock('@/components/providers/network-provider', () => ({
useNetwork: () => ({ network: 'testnet' }),
}))

// ─── Heavy UI components (not under test here) ────────────────────────────────
vi.mock('@/components/streams/stream-preview', () => ({
StreamPreview: () => null,
}))

vi.mock('@/components/streams/create-confirmation', () => ({
CreateConfirmation: () => null,
}))

vi.mock('@/components/streams/stream-templates', () => ({
StreamTemplates: () => null,
}))

vi.mock('@/components/ui/tx-preview-dialog', () => ({
TxPreviewDialog: () => null,
}))

vi.mock('sonner', () => ({
toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() },
}))

// ─── Helpers ──────────────────────────────────────────────────────────────────
const VALID_ADDRESS = 'GBWEDYWFGPNPAWCYOKWMCRPTR4IMV4SNZ7CVOZHPUXGHVXXPJSCFKVXQ'

function submitForm() {
const btn = screen.getByRole('button', { name: /create stream/i })
fireEvent.click(btn)
}

// ─── Tests ────────────────────────────────────────────────────────────────────
describe('CreateForm — validation', () => {
beforeEach(() => {
vi.clearAllMocks()
})

describe('required field validation', () => {
it('shows recipient and amount errors when form is submitted empty', async () => {
render(<CreateForm />)
submitForm()

await waitFor(() => {
expect(screen.getByText('Invalid Stellar address format')).toBeInTheDocument()
expect(screen.getByText('Enter a valid amount greater than 0')).toBeInTheDocument()
})
})
})

describe('invalid recipient address', () => {
it('shows address-format error for a plainly invalid recipient', async () => {
render(<CreateForm />)

fireEvent.change(screen.getByPlaceholderText(/GABC/i), {
target: { value: 'not-a-stellar-address' },
})
fireEvent.change(screen.getByLabelText(/total amount/i), {
target: { value: '100' },
})

submitForm()

await waitFor(() => {
expect(screen.getByText('Invalid Stellar address format')).toBeInTheDocument()
})

// No amount error should be present
expect(screen.queryByText('Enter a valid amount greater than 0')).not.toBeInTheDocument()
})
})

describe('cliff amount exceeds total amount', () => {
it('shows cliff-exceeds-total error when cliff amount > total amount', async () => {
render(<CreateForm />)

// Set a valid recipient
fireEvent.change(screen.getByPlaceholderText(/GABC/i), {
target: { value: VALID_ADDRESS },
})

// Set total amount
fireEvent.change(screen.getByLabelText(/total amount/i), {
target: { value: '100' },
})

// Enable cliff via checkbox
fireEvent.click(screen.getByLabelText(/add a cliff/i))

// Set cliff amount higher than total
await waitFor(() => {
expect(screen.getByLabelText(/cliff amount/i)).toBeInTheDocument()
})
fireEvent.change(screen.getByLabelText(/cliff amount/i), {
target: { value: '200' },
})

submitForm()

await waitFor(() => {
expect(screen.getByText('Cliff amount cannot exceed total amount')).toBeInTheDocument()
})
})
})
})
91 changes: 91 additions & 0 deletions __tests__/components/unlock-chart-render.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { UnlockChart } from '@/components/streams/unlock-chart'
import type { StreamData } from '@/types/stream'

vi.mock('@/lib/stream-utils', () => ({
formatTokenAmount: (raw: bigint) => String(raw),
SECONDS_PER_DAY: 86400,
}))

// ─── Fixtures ─────────────────────────────────────────────────────────────────

const NOW = 1_700_050_000

const baseStream: StreamData = {
id: '1',
sender: 'GSENDER1111111111111111111111111111111111111111',
recipient: 'GRECIPIENT111111111111111111111111111111111111',
token: { address: 'CXLM', symbol: 'XLM', decimals: 7 },
depositedAmount: 1_000_000n,
withdrawnAmount: 0n,
startTime: 1_700_000_000n,
endTime: 1_700_100_000n,
cliffTime: 1_700_000_000n, // cliff == start → no cliff
cliffAmount: 0n,
amountPerSecond: 10n,
linearAmount: 1_000_000n,
duration: 100_000n,
cancelled: false,
}

const noCliffStream: StreamData = { ...baseStream }

const cliffStream: StreamData = {
...baseStream,
id: '2',
cliffTime: 1_700_030_000n, // 30,000 s after start
cliffAmount: 300_000n,
}

// ─── Tests ────────────────────────────────────────────────────────────────────

describe('UnlockChart — no-cliff stream', () => {
it('renders the SVG chart element', () => {
render(<UnlockChart stream={noCliffStream} nowSeconds={NOW} />)
expect(screen.getByRole('img')).toBeInTheDocument()
})

it('renders the unlock curve path', () => {
const { container } = render(<UnlockChart stream={noCliffStream} nowSeconds={NOW} />)
// The unlock curve is the <path> with stroke and strokeWidth=2
const curvePath = container.querySelector('path[stroke="currentColor"][stroke-width="2"]')
expect(curvePath).not.toBeNull()
})

it('does NOT render a dashed cliff indicator line', () => {
const { container } = render(<UnlockChart stream={noCliffStream} nowSeconds={NOW} />)
const cliffLine = container.querySelector('line[stroke-dasharray="4 3"]')
expect(cliffLine).toBeNull()
})

it('does NOT show the Cliff legend item', () => {
render(<UnlockChart stream={noCliffStream} nowSeconds={NOW} />)
expect(screen.queryByText('Cliff')).not.toBeInTheDocument()
})
})

describe('UnlockChart — cliff stream', () => {
it('renders the SVG chart element', () => {
render(<UnlockChart stream={cliffStream} nowSeconds={NOW} />)
expect(screen.getByRole('img')).toBeInTheDocument()
})

it('renders the unlock curve path', () => {
const { container } = render(<UnlockChart stream={cliffStream} nowSeconds={NOW} />)
const curvePath = container.querySelector('path[stroke="currentColor"][stroke-width="2"]')
expect(curvePath).not.toBeNull()
})

it('renders a dashed cliff indicator line', () => {
const { container } = render(<UnlockChart stream={cliffStream} nowSeconds={NOW} />)
const cliffLine = container.querySelector('line[stroke-dasharray="4 3"]')
expect(cliffLine).not.toBeNull()
})

it('shows the Cliff legend item', () => {
render(<UnlockChart stream={cliffStream} nowSeconds={NOW} />)
// The legend renders "Cliff" as a text node in the DOM
expect(screen.getAllByText('Cliff').length).toBeGreaterThan(0)
})
})