diff --git a/CHANGELOG.md b/CHANGELOG.md index c5992336..93feb398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,60 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- `useBalance` tests now use fake timers to correctly handle the hook's + retry logic (MAX_RETRIES=2, RETRY_DELAY_MS=1500), fixing 4 previously + failing error-path tests. Added retry count verification and recovery + test (Issue #248). + +### Added (Issue #248) + +- `frontend/src/test/useStxPrice.test.js` with 19 tests covering + initial loading, price fetch, error states, toUsd conversion, + refetch behavior, 60s interval polling, unmount cleanup, price + preservation on poll failure, and CoinGecko URL verification. +- `frontend/src/test/useBlockCheck.test.js` with 14 tests covering + initial state, empty/self recipient, checking state, blocked/not- + blocked results, error handling, reset, stale response discard, + sequential calls, and error completion. +- `frontend/src/test/parseTipEvent.test.js` with 23 tests covering + tip-sent and tip-categorized parsing, missing fields, messages, + large values, case sensitivity, whitespace, malformed input, u0 + amounts/tip-ids, empty messages, high categories, and contract + principal addresses. +- `frontend/src/test/tipBackValidation.test.js` with 21 tests covering + constants, empty/null inputs, boundary amounts, typical values, + NaN/Infinity strings, and small positive amounts. +- `frontend/src/test/address-validation.test.js` with 30 tests covering + Stacks address regex (SP/SM/ST prefixes, length boundaries at 37-42, + special chars, dots, spaces) and contract ID validation. +- `frontend/src/test/send-tip-validation.test.js` with 26 tests covering + amount validation, self-tip detection, balance-insufficient check, + constants, TIP_CATEGORIES, and default message fallback. +- `frontend/src/test/batch-tip-validation.test.js` with 29 tests covering + duplicate address detection, per-recipient amount validation, message + length limits, self-tip detection, totalAmount computation, and + MAX_BATCH_SIZE/MIN_TIP_STX constants. +- `frontend/src/test/token-tip-validation.test.js` with 23 tests covering + parseContractId splitting, integer amount parsing, whitelist status + response shapes, multi-dot rejection, and null/undefined inputs. +- `frontend/src/test/useBalance.test.js` expanded to 17 tests adding + refetch behavior, address change re-fetch, null address reset, + lastFetched timestamp, and refetch function exposure. +- `frontend/src/test/stacks-utils.test.js` with 7 tests covering + isWalletInstalled with various provider combinations, appDetails + name and icon, and network resolution. +- `frontend/src/test/pwa-cache-rules.test.js` with 58 tests covering + PWA runtime cache strategy validation for balance, transaction, + nonce, and static asset endpoints. +- `frontend/src/test/Leaderboard.test.jsx` with 12 tests covering + rendering, loading skeleton, error state, tab switching, refresh + button, timestamp display, and Load More behavior. +- `frontend/src/test/buildLeaderboardStats.test.js` with 12 tests + covering aggregation, self-tips, address counting, and sorting. +- `frontend/src/test/contractEvents.test.js` expanded to 22 tests + adding mid-pagination short page, second-page error, empty repr + filtering, falsy block_time, and combined offset+maxPages. + - `BatchTip` now reports accurate outcome summaries after on-chain confirmation instead of always showing a blanket success toast. Non-strict batch results are parsed to show full success, partial success, or all diff --git a/frontend/src/test/Leaderboard.test.jsx b/frontend/src/test/Leaderboard.test.jsx new file mode 100644 index 00000000..889eb87a --- /dev/null +++ b/frontend/src/test/Leaderboard.test.jsx @@ -0,0 +1,251 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import Leaderboard from '../components/Leaderboard'; +import { useTipContext } from '../context/TipContext'; + +vi.mock('../context/TipContext', () => ({ + useTipContext: vi.fn(), +})); + +const mockEvents = [ + { event: 'tip-sent', sender: 'SP1SENDER', recipient: 'SP2RECV', amount: '5000000', timestamp: 1700000000 }, + { event: 'tip-sent', sender: 'SP3OTHER', recipient: 'SP1SENDER', amount: '3000000', timestamp: 1700000001 }, + { event: 'tip-sent', sender: 'SP1SENDER', recipient: 'SP3OTHER', amount: '2000000', timestamp: 1700000002 }, +]; + +function defaultContext(overrides = {}) { + return { + events: mockEvents, + eventsLoading: false, + eventsRefreshing: false, + eventsError: null, + eventsMeta: { total: 3, hasMore: false }, + lastEventRefresh: new Date('2026-03-12T12:00:00Z'), + refreshEvents: vi.fn(), + loadMoreEvents: vi.fn(), + ...overrides, + }; +} + +describe('Leaderboard', () => { + beforeEach(() => { + vi.clearAllMocks(); + useTipContext.mockReturnValue(defaultContext()); + }); + + it('renders the Leaderboard heading', () => { + render(); + expect(screen.getByText('Leaderboard')).toBeInTheDocument(); + }); + + it('shows loading skeleton when eventsLoading is true', () => { + useTipContext.mockReturnValue(defaultContext({ eventsLoading: true })); + const { container } = render(); + expect(container.querySelector('.animate-pulse')).toBeInTheDocument(); + }); + + it('shows error state with Retry button', () => { + const refreshEvents = vi.fn(); + useTipContext.mockReturnValue(defaultContext({ + eventsError: 'Something went wrong', + events: [], + refreshEvents, + })); + render(); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Retry')); + expect(refreshEvents).toHaveBeenCalledTimes(1); + }); + + it('renders Top Senders and Top Receivers tabs', () => { + render(); + expect(screen.getByText('Top Senders')).toBeInTheDocument(); + expect(screen.getByText('Top Receivers')).toBeInTheDocument(); + }); + + it('switches between tabs', () => { + render(); + fireEvent.click(screen.getByText('Top Receivers')); + const items = screen.getAllByText(/tips received/); + expect(items.length).toBeGreaterThan(0); + }); + + it('shows the Refresh button', () => { + render(); + expect(screen.getByLabelText('Refresh leaderboard')).toBeInTheDocument(); + }); + + it('calls refreshEvents when Refresh is clicked', () => { + const refreshEvents = vi.fn(); + useTipContext.mockReturnValue(defaultContext({ refreshEvents })); + render(); + fireEvent.click(screen.getByLabelText('Refresh leaderboard')); + expect(refreshEvents).toHaveBeenCalledTimes(1); + }); + + it('keeps Refresh button present when eventsRefreshing is true', () => { + useTipContext.mockReturnValue(defaultContext({ eventsRefreshing: true })); + render(); + const btn = screen.getByLabelText('Refresh leaderboard'); + expect(btn).toBeInTheDocument(); + expect(btn.textContent).toBe('Refresh'); + }); + + it('shows Refresh text when not refreshing', () => { + render(); + const btn = screen.getByLabelText('Refresh leaderboard'); + expect(btn).not.toBeDisabled(); + expect(btn.textContent).toBe('Refresh'); + }); + + it('shows last refresh timestamp', () => { + const ts = new Date('2026-03-12T12:00:00Z'); + useTipContext.mockReturnValue(defaultContext({ lastEventRefresh: ts })); + render(); + const expected = ts.toLocaleTimeString(); + expect(screen.getByText(expected)).toBeInTheDocument(); + }); + + it('shows empty state when no events', () => { + useTipContext.mockReturnValue(defaultContext({ events: [] })); + render(); + expect(screen.getByText(/No activity yet/)).toBeInTheDocument(); + }); + + it('shows Load More button when hasMore is true', () => { + useTipContext.mockReturnValue(defaultContext({ + eventsMeta: { total: 100, hasMore: true }, + })); + render(); + expect(screen.getByLabelText('Load more events for accurate rankings')).toBeInTheDocument(); + }); + + it('hides Load More button when hasMore is false', () => { + render(); + expect(screen.queryByLabelText('Load more events for accurate rankings')).not.toBeInTheDocument(); + }); + + it('displays ranked users with STX amounts', () => { + render(); + expect(screen.getByText(/7\.00 STX/)).toBeInTheDocument(); + }); + + it('ranks senders by total sent descending', () => { + render(); + // Default tab is "sent", all addresses with any activity are shown. + // SP1SENDER sent 5+2=7 STX total (rank 1), so the first entry + // should display the highest amount. + const stxAmounts = screen.getAllByText(/STX$/); + expect(stxAmounts[0].textContent).toContain('7.00'); + }); + + it('assigns medal classes to top-3 positions', () => { + const manyEvents = [ + { event: 'tip-sent', sender: 'SP1A', recipient: 'SP2B', amount: '9000000', timestamp: 1 }, + { event: 'tip-sent', sender: 'SP3C', recipient: 'SP4D', amount: '7000000', timestamp: 2 }, + { event: 'tip-sent', sender: 'SP5E', recipient: 'SP6F', amount: '5000000', timestamp: 3 }, + { event: 'tip-sent', sender: 'SP7G', recipient: 'SP8H', amount: '3000000', timestamp: 4 }, + ]; + useTipContext.mockReturnValue(defaultContext({ events: manyEvents })); + const { container } = render(); + const medals = container.querySelectorAll('.bg-yellow-100, .bg-gray-100, .bg-orange-100'); + expect(medals.length).toBeGreaterThanOrEqual(2); + }); + + it('hides timestamp when lastEventRefresh is null', () => { + useTipContext.mockReturnValue(defaultContext({ lastEventRefresh: null })); + render(); + const ts = new Date('2026-03-12T12:00:00Z').toLocaleTimeString(); + expect(screen.queryByText(ts)).not.toBeInTheDocument(); + }); + + it('filters out non-tip-sent events from leaderboard stats', () => { + const mixed = [ + { event: 'tip-sent', sender: 'SP1A', recipient: 'SP2B', amount: '5000000', timestamp: 1 }, + { event: 'tip-categorized', tipId: '1', category: '3', timestamp: 2 }, + { event: 'tip-sent', sender: 'SP1A', recipient: 'SP2B', amount: '0', timestamp: 3 }, + ]; + useTipContext.mockReturnValue(defaultContext({ events: mixed })); + render(); + // 5.00 STX shows in both the row and the footer Total + const matches = screen.getAllByText(/5\.00 STX/); + expect(matches.length).toBeGreaterThanOrEqual(1); + }); + + it('shows the total in the footer', () => { + render(); + expect(screen.getByText(/Total:/)).toBeInTheDocument(); + }); + + it('shows user count in footer text', () => { + render(); + expect(screen.getByText(/Showing top/)).toBeInTheDocument(); + }); + + it('calls loadMoreEvents when Load More is clicked', async () => { + const loadMore = vi.fn().mockResolvedValue(); + useTipContext.mockReturnValue(defaultContext({ + eventsMeta: { total: 100, hasMore: true }, + loadMoreEvents: loadMore, + })); + render(); + await act(async () => { + fireEvent.click(screen.getByLabelText('Load more events for accurate rankings')); + }); + expect(loadMore).toHaveBeenCalledTimes(1); + }); + + it('switches back to Senders tab from Receivers', () => { + render(); + fireEvent.click(screen.getByText('Top Receivers')); + fireEvent.click(screen.getByText('Top Senders')); + const items = screen.getAllByText(/tips sent/); + expect(items.length).toBeGreaterThan(0); + }); + + it('Refresh button uses standard styling', () => { + render(); + const btn = screen.getByLabelText('Refresh leaderboard'); + expect(btn.className).toContain('rounded-lg'); + }); + + it('shows event count breakdown in footer when total is available', () => { + useTipContext.mockReturnValue(defaultContext({ + eventsMeta: { total: 50, hasMore: true }, + })); + render(); + expect(screen.getByText(/events of 50 total/)).toBeInTheDocument(); + }); + + it('limits display to 20 users', () => { + const events = Array.from({ length: 25 }, (_, i) => ({ + event: 'tip-sent', + sender: `SP_SENDER_${i}`, + recipient: `SP_RECV_${i}`, + amount: String((25 - i) * 1000000), + timestamp: 1700000000 + i, + })); + useTipContext.mockReturnValue(defaultContext({ events })); + render(); + expect(screen.getByText(/Showing top 20/)).toBeInTheDocument(); + }); + + it('renders five skeleton rows during loading', () => { + useTipContext.mockReturnValue(defaultContext({ eventsLoading: true })); + const { container } = render(); + const rows = container.querySelectorAll('.h-14'); + expect(rows.length).toBe(5); + }); + + it('renders copy buttons for user addresses', () => { + render(); + const copyButtons = screen.getAllByTitle('Copy to clipboard'); + expect(copyButtons.length).toBeGreaterThan(0); + }); + + it('shows tip count next to ranked users', () => { + render(); + const tipCounts = screen.getAllByText(/tips sent/); + expect(tipCounts.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/test/address-validation.test.js b/frontend/src/test/address-validation.test.js new file mode 100644 index 00000000..f2fa397c --- /dev/null +++ b/frontend/src/test/address-validation.test.js @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest'; + +/** + * The isValidStacksAddress validation function is defined inline in + * SendTip, BatchTip, and TokenTip. This test verifies the regex pattern + * used across all three components works correctly. + */ +function isValidStacksAddress(address) { + if (!address) return false; + const trimmed = address.trim(); + if (trimmed.length < 38 || trimmed.length > 41) return false; + return /^(SP|SM|ST)[0-9A-Z]{33,39}$/i.test(trimmed); +} + +describe('isValidStacksAddress (shared validation pattern)', () => { + it('rejects empty string', () => { + expect(isValidStacksAddress('')).toBe(false); + }); + + it('rejects null', () => { + expect(isValidStacksAddress(null)).toBe(false); + }); + + it('rejects undefined', () => { + expect(isValidStacksAddress(undefined)).toBe(false); + }); + + it('accepts valid SP address', () => { + expect(isValidStacksAddress('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T')).toBe(true); + }); + + it('accepts valid SM address', () => { + expect(isValidStacksAddress('SM31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T')).toBe(true); + }); + + it('accepts valid ST address (testnet)', () => { + expect(isValidStacksAddress('ST31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T')).toBe(true); + }); + + it('rejects address too short', () => { + expect(isValidStacksAddress('SP12345')).toBe(false); + }); + + it('rejects address too long', () => { + expect(isValidStacksAddress('SP' + 'A'.repeat(50))).toBe(false); + }); + + it('rejects address with wrong prefix', () => { + expect(isValidStacksAddress('BT31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T')).toBe(false); + }); + + it('trims whitespace before validation', () => { + expect(isValidStacksAddress(' SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T ')).toBe(true); + }); + + it('rejects addresses with special characters', () => { + expect(isValidStacksAddress('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS$W5T')).toBe(false); + }); + + it('is case insensitive for the body', () => { + expect(isValidStacksAddress('SP31pkqvqzvzck3fm3nh67cgd6g1fmr17vqvs2w5t')).toBe(true); + }); + + it('accepts minimum length address (38 chars)', () => { + const addr = 'SP' + 'A'.repeat(36); + expect(addr.length).toBe(38); + expect(isValidStacksAddress(addr)).toBe(true); + }); + + it('accepts maximum length address (41 chars)', () => { + const addr = 'SP' + 'A'.repeat(39); + expect(addr.length).toBe(41); + expect(isValidStacksAddress(addr)).toBe(true); + }); +}); + +/** + * The isValidContractId validation function is defined inline in TokenTip. + * Format:
. + */ +function isValidContractId(id) { + if (!id) return false; + const parts = id.trim().split('.'); + if (parts.length !== 2) return false; + return isValidStacksAddress(parts[0]) && parts[1].length > 0; +} + +describe('isValidContractId (TokenTip validation pattern)', () => { + it('rejects empty string', () => { + expect(isValidContractId('')).toBe(false); + }); + + it('rejects null', () => { + expect(isValidContractId(null)).toBe(false); + }); + + it('accepts valid contract ID', () => { + expect(isValidContractId('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.my-token')).toBe(true); + }); + + it('rejects missing contract name', () => { + expect(isValidContractId('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.')).toBe(false); + }); + + it('rejects missing address', () => { + expect(isValidContractId('.my-token')).toBe(false); + }); + + it('rejects multiple dots', () => { + expect(isValidContractId('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.my.token')).toBe(false); + }); + + it('accepts contract name with hyphens and underscores', () => { + expect(isValidContractId('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.my-token_v2')).toBe(true); + }); + + it('rejects invalid address portion', () => { + expect(isValidContractId('INVALID.my-token')).toBe(false); + }); + + it('trims whitespace from contract ID', () => { + expect(isValidContractId(' SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.token ')).toBe(true); + }); +}); + +describe('isValidStacksAddress boundary tests', () => { + it('rejects address of length 37 (one below minimum)', () => { + const addr = 'SP' + 'A'.repeat(35); + expect(addr.length).toBe(37); + expect(isValidStacksAddress(addr)).toBe(false); + }); + + it('rejects address of length 42 (one above maximum)', () => { + const addr = 'SP' + 'A'.repeat(40); + expect(addr.length).toBe(42); + expect(isValidStacksAddress(addr)).toBe(false); + }); + + it('rejects lowercase prefix sp as valid (case insensitive)', () => { + expect(isValidStacksAddress('sp31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T')).toBe(true); + }); + + it('rejects address with only prefix and no body', () => { + expect(isValidStacksAddress('SP')).toBe(false); + }); + + it('rejects all-numeric body with valid prefix and length', () => { + const addr = 'SP' + '1'.repeat(36); + expect(isValidStacksAddress(addr)).toBe(true); + }); + + it('rejects address containing dot (contract principal)', () => { + expect(isValidStacksAddress('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.tipstream')).toBe(false); + }); + + it('rejects address with embedded spaces', () => { + expect(isValidStacksAddress('SP31PKQVQ VZCK3FM3NH67CGD6G1FMR17VQVS2W5T')).toBe(false); + }); +}); diff --git a/frontend/src/test/balance-utils.test.js b/frontend/src/test/balance-utils.test.js index 46121979..72cb96a2 100644 --- a/frontend/src/test/balance-utils.test.js +++ b/frontend/src/test/balance-utils.test.js @@ -266,3 +266,20 @@ describe('isValidBalance', () => { expect(isValidBalance(NaN)).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// Roundtrip: micro -> STX -> micro +// --------------------------------------------------------------------------- +describe('micro/STX roundtrip', () => { + it('converts micro to STX and back for whole numbers', () => { + expect(stxToMicro(microToStx(5_000_000))).toBe(5_000_000); + }); + + it('converts micro to STX and back for fractional amounts', () => { + expect(stxToMicro(microToStx(1_500_000))).toBe(1_500_000); + }); + + it('converts STX to micro and back for small amounts', () => { + expect(microToStx(stxToMicro(0.001))).toBeCloseTo(0.001); + }); +}); diff --git a/frontend/src/test/batch-tip-validation.test.js b/frontend/src/test/batch-tip-validation.test.js new file mode 100644 index 00000000..a41097df --- /dev/null +++ b/frontend/src/test/batch-tip-validation.test.js @@ -0,0 +1,289 @@ +import { describe, it, expect } from 'vitest'; + +/** + * BatchTip duplicate detection logic extracted for testability. + * Mirrors the validation in BatchTip.jsx lines 134-143. + */ +function detectDuplicateAddresses(recipients) { + const errors = {}; + const seen = new Set(); + recipients.forEach((r, i) => { + const addr = (r.address || '').trim(); + if (addr && seen.has(addr)) { + errors[`${i}-address`] = 'Duplicate address'; + } + if (addr) seen.add(addr); + }); + return errors; +} + +/** + * BatchTip amount validation logic extracted for testability. + */ +const MIN_TIP_STX = 0.001; +const MAX_BATCH_SIZE = 50; + +function validateBatchAmounts(recipients) { + const errors = {}; + recipients.forEach((r, i) => { + const amt = parseFloat(r.amount); + if (!r.amount || isNaN(amt) || amt <= 0) { + errors[`${i}-amount`] = 'Enter a valid amount'; + } else if (amt < MIN_TIP_STX) { + errors[`${i}-amount`] = `Min ${MIN_TIP_STX} STX`; + } + }); + return errors; +} + +describe('BatchTip duplicate detection', () => { + it('returns no errors for unique addresses', () => { + const recipients = [ + { address: 'SP1AAA', amount: '1' }, + { address: 'SP2BBB', amount: '1' }, + { address: 'SP3CCC', amount: '1' }, + ]; + const errors = detectDuplicateAddresses(recipients); + expect(Object.keys(errors).length).toBe(0); + }); + + it('flags duplicate address on second occurrence', () => { + const recipients = [ + { address: 'SP1AAA', amount: '1' }, + { address: 'SP1AAA', amount: '2' }, + ]; + const errors = detectDuplicateAddresses(recipients); + expect(errors['1-address']).toBe('Duplicate address'); + expect(errors['0-address']).toBeUndefined(); + }); + + it('flags all duplicates after the first', () => { + const recipients = [ + { address: 'SP1AAA', amount: '1' }, + { address: 'SP2BBB', amount: '1' }, + { address: 'SP1AAA', amount: '2' }, + { address: 'SP1AAA', amount: '3' }, + ]; + const errors = detectDuplicateAddresses(recipients); + expect(errors['2-address']).toBe('Duplicate address'); + expect(errors['3-address']).toBe('Duplicate address'); + }); + + it('treats empty addresses as non-duplicates', () => { + const recipients = [ + { address: '', amount: '1' }, + { address: '', amount: '2' }, + ]; + const errors = detectDuplicateAddresses(recipients); + expect(Object.keys(errors).length).toBe(0); + }); + + it('trims whitespace before comparison', () => { + const recipients = [ + { address: ' SP1AAA ', amount: '1' }, + { address: 'SP1AAA', amount: '2' }, + ]; + const errors = detectDuplicateAddresses(recipients); + expect(errors['1-address']).toBe('Duplicate address'); + }); + + it('handles single recipient without errors', () => { + const recipients = [ + { address: 'SP1AAA', amount: '1' }, + ]; + const errors = detectDuplicateAddresses(recipients); + expect(Object.keys(errors).length).toBe(0); + }); + + it('detects multiple distinct duplicate pairs', () => { + const recipients = [ + { address: 'SP1AAA', amount: '1' }, + { address: 'SP2BBB', amount: '1' }, + { address: 'SP1AAA', amount: '2' }, + { address: 'SP2BBB', amount: '2' }, + ]; + const errors = detectDuplicateAddresses(recipients); + expect(errors['2-address']).toBe('Duplicate address'); + expect(errors['3-address']).toBe('Duplicate address'); + }); + + it('handles null address gracefully', () => { + const recipients = [ + { address: null, amount: '1' }, + { address: 'SP1AAA', amount: '1' }, + ]; + const errors = detectDuplicateAddresses(recipients); + expect(Object.keys(errors).length).toBe(0); + }); +}); + +describe('BatchTip amount validation', () => { + it('returns error for empty amount', () => { + const errors = validateBatchAmounts([{ address: 'SP1', amount: '' }]); + expect(errors['0-amount']).toBe('Enter a valid amount'); + }); + + it('returns error for non-numeric amount', () => { + const errors = validateBatchAmounts([{ address: 'SP1', amount: 'abc' }]); + expect(errors['0-amount']).toBe('Enter a valid amount'); + }); + + it('returns error for zero amount', () => { + const errors = validateBatchAmounts([{ address: 'SP1', amount: '0' }]); + expect(errors['0-amount']).toBe('Enter a valid amount'); + }); + + it('returns error for negative amount', () => { + const errors = validateBatchAmounts([{ address: 'SP1', amount: '-1' }]); + expect(errors['0-amount']).toBe('Enter a valid amount'); + }); + + it('returns error for amount below minimum', () => { + const errors = validateBatchAmounts([{ address: 'SP1', amount: '0.0001' }]); + expect(errors['0-amount']).toBe(`Min ${MIN_TIP_STX} STX`); + }); + + it('accepts exact minimum amount', () => { + const errors = validateBatchAmounts([{ address: 'SP1', amount: '0.001' }]); + expect(errors['0-amount']).toBeUndefined(); + }); + + it('accepts valid amount', () => { + const errors = validateBatchAmounts([{ address: 'SP1', amount: '5' }]); + expect(errors['0-amount']).toBeUndefined(); + }); + + it('validates each recipient independently', () => { + const recipients = [ + { address: 'SP1', amount: '1' }, + { address: 'SP2', amount: '' }, + { address: 'SP3', amount: '5' }, + ]; + const errors = validateBatchAmounts(recipients); + expect(errors['0-amount']).toBeUndefined(); + expect(errors['1-amount']).toBe('Enter a valid amount'); + expect(errors['2-amount']).toBeUndefined(); + }); +}); + +describe('BatchTip message validation', () => { + function validateMessages(recipients) { + const errors = {}; + recipients.forEach((r, i) => { + if (r.message && r.message.length > 280) { + errors[`${i}-message`] = 'Max 280 characters'; + } + }); + return errors; + } + + it('accepts empty message', () => { + const errors = validateMessages([{ address: 'SP1', amount: '1', message: '' }]); + expect(Object.keys(errors).length).toBe(0); + }); + + it('accepts message at exactly 280 characters', () => { + const msg = 'a'.repeat(280); + const errors = validateMessages([{ address: 'SP1', amount: '1', message: msg }]); + expect(Object.keys(errors).length).toBe(0); + }); + + it('rejects message exceeding 280 characters', () => { + const msg = 'a'.repeat(281); + const errors = validateMessages([{ address: 'SP1', amount: '1', message: msg }]); + expect(errors['0-message']).toBe('Max 280 characters'); + }); + + it('accepts undefined message field', () => { + const errors = validateMessages([{ address: 'SP1', amount: '1' }]); + expect(Object.keys(errors).length).toBe(0); + }); +}); + +describe('BatchTip self-tip detection', () => { + function detectSelfTips(recipients, senderAddress) { + const errors = {}; + recipients.forEach((r, i) => { + if (r.address.trim() === senderAddress) { + errors[`${i}-address`] = 'Cannot tip yourself'; + } + }); + return errors; + } + + it('flags self-tip when address matches sender', () => { + const sender = 'SP1SENDER'; + const errors = detectSelfTips( + [{ address: 'SP1SENDER', amount: '1' }], + sender, + ); + expect(errors['0-address']).toBe('Cannot tip yourself'); + }); + + it('allows different addresses', () => { + const sender = 'SP1SENDER'; + const errors = detectSelfTips( + [{ address: 'SP2OTHER', amount: '1' }], + sender, + ); + expect(Object.keys(errors).length).toBe(0); + }); + + it('trims address whitespace before self-tip check', () => { + const sender = 'SP1SENDER'; + const errors = detectSelfTips( + [{ address: ' SP1SENDER ', amount: '1' }], + sender, + ); + expect(errors['0-address']).toBe('Cannot tip yourself'); + }); +}); + +describe('BatchTip totalAmount computation', () => { + function computeTotal(recipients) { + return recipients.reduce((sum, r) => { + const parsed = parseFloat(r.amount); + return sum + (isNaN(parsed) ? 0 : parsed); + }, 0); + } + + it('sums all valid amounts', () => { + const total = computeTotal([ + { amount: '1' }, + { amount: '2.5' }, + { amount: '0.5' }, + ]); + expect(total).toBe(4); + }); + + it('ignores NaN amounts', () => { + const total = computeTotal([ + { amount: '1' }, + { amount: 'abc' }, + { amount: '3' }, + ]); + expect(total).toBe(4); + }); + + it('returns zero for empty list', () => { + expect(computeTotal([])).toBe(0); + }); + + it('returns zero when all amounts are invalid', () => { + const total = computeTotal([ + { amount: '' }, + { amount: 'xyz' }, + ]); + expect(total).toBe(0); + }); +}); + +describe('BatchTip constants', () => { + it('MAX_BATCH_SIZE is 50', () => { + expect(MAX_BATCH_SIZE).toBe(50); + }); + + it('MIN_TIP_STX is 0.001', () => { + expect(MIN_TIP_STX).toBe(0.001); + }); +}); diff --git a/frontend/src/test/buildLeaderboardStats.test.js b/frontend/src/test/buildLeaderboardStats.test.js new file mode 100644 index 00000000..21cc08ce --- /dev/null +++ b/frontend/src/test/buildLeaderboardStats.test.js @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { buildLeaderboardStats } from '../lib/buildLeaderboardStats'; + +describe('buildLeaderboardStats', () => { + it('returns an empty array when given no events', () => { + expect(buildLeaderboardStats([])).toEqual([]); + }); + + it('tracks sent totals for a single sender', () => { + const events = [ + { sender: 'SP1', recipient: 'SP2', amount: '1000000' }, + ]; + const stats = buildLeaderboardStats(events); + const sp1 = stats.find(s => s.address === 'SP1'); + expect(sp1.totalSent).toBe(1000000); + expect(sp1.tipsSent).toBe(1); + }); + + it('tracks received totals for a single recipient', () => { + const events = [ + { sender: 'SP1', recipient: 'SP2', amount: '1000000' }, + ]; + const stats = buildLeaderboardStats(events); + const sp2 = stats.find(s => s.address === 'SP2'); + expect(sp2.totalReceived).toBe(1000000); + expect(sp2.tipsReceived).toBe(1); + }); + + it('aggregates multiple tips from the same sender', () => { + const events = [ + { sender: 'SP1', recipient: 'SP2', amount: '1000000' }, + { sender: 'SP1', recipient: 'SP3', amount: '2000000' }, + ]; + const stats = buildLeaderboardStats(events); + const sp1 = stats.find(s => s.address === 'SP1'); + expect(sp1.totalSent).toBe(3000000); + expect(sp1.tipsSent).toBe(2); + }); + + it('aggregates multiple tips to the same recipient', () => { + const events = [ + { sender: 'SP1', recipient: 'SP3', amount: '500000' }, + { sender: 'SP2', recipient: 'SP3', amount: '700000' }, + ]; + const stats = buildLeaderboardStats(events); + const sp3 = stats.find(s => s.address === 'SP3'); + expect(sp3.totalReceived).toBe(1200000); + expect(sp3.tipsReceived).toBe(2); + }); + + it('creates entries for both sender and recipient', () => { + const events = [ + { sender: 'SP1', recipient: 'SP2', amount: '100' }, + ]; + const stats = buildLeaderboardStats(events); + expect(stats.length).toBe(2); + expect(stats.map(s => s.address).sort()).toEqual(['SP1', 'SP2']); + }); + + it('initializes all counters to zero for new addresses', () => { + const events = [ + { sender: 'SP1', recipient: 'SP2', amount: '100' }, + ]; + const stats = buildLeaderboardStats(events); + const sp1 = stats.find(s => s.address === 'SP1'); + expect(sp1.totalReceived).toBe(0); + expect(sp1.tipsReceived).toBe(0); + }); + + it('handles an address that both sends and receives', () => { + const events = [ + { sender: 'SP1', recipient: 'SP2', amount: '1000000' }, + { sender: 'SP2', recipient: 'SP1', amount: '500000' }, + ]; + const stats = buildLeaderboardStats(events); + const sp1 = stats.find(s => s.address === 'SP1'); + expect(sp1.totalSent).toBe(1000000); + expect(sp1.totalReceived).toBe(500000); + expect(sp1.tipsSent).toBe(1); + expect(sp1.tipsReceived).toBe(1); + }); + + it('parses string amounts as integers', () => { + const events = [ + { sender: 'SP1', recipient: 'SP2', amount: '999' }, + ]; + const stats = buildLeaderboardStats(events); + const sp1 = stats.find(s => s.address === 'SP1'); + expect(sp1.totalSent).toBe(999); + }); + + it('handles a self-tip (sender equals recipient)', () => { + const events = [ + { sender: 'SP1', recipient: 'SP1', amount: '1000' }, + ]; + const stats = buildLeaderboardStats(events); + expect(stats.length).toBe(1); + const sp1 = stats[0]; + expect(sp1.totalSent).toBe(1000); + expect(sp1.totalReceived).toBe(1000); + expect(sp1.tipsSent).toBe(1); + expect(sp1.tipsReceived).toBe(1); + }); + + it('returns correct number of unique addresses', () => { + const events = [ + { sender: 'SP1', recipient: 'SP2', amount: '100' }, + { sender: 'SP2', recipient: 'SP3', amount: '200' }, + { sender: 'SP3', recipient: 'SP1', amount: '300' }, + ]; + const stats = buildLeaderboardStats(events); + expect(stats.length).toBe(3); + }); + + it('handles large amounts without overflow', () => { + const events = [ + { sender: 'SP1', recipient: 'SP2', amount: '999999999999' }, + ]; + const stats = buildLeaderboardStats(events); + expect(stats.find(s => s.address === 'SP1').totalSent).toBe(999999999999); + }); +}); diff --git a/frontend/src/test/contractEvents.test.js b/frontend/src/test/contractEvents.test.js index 0ac54f91..909b1bab 100644 --- a/frontend/src/test/contractEvents.test.js +++ b/frontend/src/test/contractEvents.test.js @@ -97,6 +97,22 @@ describe('parseRawEvents', () => { expect(parsed[0].timestamp).toBeNull(); expect(parsed[0].txId).toBeNull(); }); + + it('filters out entries with empty repr string', () => { + const results = [ + { contract_log: { value: { repr: '' } } }, + fakeApiEntry(tipSentRepr()), + ]; + const parsed = parseRawEvents(results); + expect(parsed).toHaveLength(1); + expect(parsed[0].event).toBe('tip-sent'); + }); + + it('treats zero block_time as falsy (maps to null)', () => { + const results = [fakeApiEntry(tipSentRepr(), { block_time: 0 })]; + const parsed = parseRawEvents(results); + expect(parsed[0].timestamp).toBeNull(); + }); }); // --------------------------------------------------------------------------- @@ -227,4 +243,45 @@ describe('fetchAllContractEvents', () => { expect(result.total).toBe(0); expect(result.hasMore).toBe(false); }); + + it('stops pagination when a page returns fewer than PAGE_LIMIT results', async () => { + const shortPage = [fakeApiEntry(tipSentRepr({ tipId: '1' }))]; + const total = PAGE_LIMIT + 1; + + fetchSpy + .mockReturnValueOnce(mockFetchResponse( + Array.from({ length: PAGE_LIMIT }, (_, i) => fakeApiEntry(tipSentRepr({ tipId: String(i) }))), + total, + 0, + )) + .mockReturnValueOnce(mockFetchResponse(shortPage, total, PAGE_LIMIT)); + + const result = await fetchAllContractEvents({ maxPages: 5 }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(result.events).toHaveLength(PAGE_LIMIT + 1); + }); + + it('throws when second page fails mid-pagination', async () => { + const fullPage = Array.from({ length: PAGE_LIMIT }, (_, i) => + fakeApiEntry(tipSentRepr({ tipId: String(i) })), + ); + + fetchSpy + .mockReturnValueOnce(mockFetchResponse(fullPage, PAGE_LIMIT * 3, 0)) + .mockReturnValueOnce(Promise.resolve({ ok: false, status: 503 })); + + await expect(fetchAllContractEvents({ maxPages: 3 })).rejects.toThrow('Stacks API returned 503'); + }); + + it('combines startOffset and maxPages', async () => { + const entries = [fakeApiEntry(tipSentRepr())]; + fetchSpy.mockReturnValueOnce(mockFetchResponse(entries, 200, 100)); + + const result = await fetchAllContractEvents({ startOffset: 100, maxPages: 1 }); + + const url = fetchSpy.mock.calls[0][0]; + expect(url).toContain('offset=100'); + expect(result.events).toHaveLength(1); + }); }); diff --git a/frontend/src/test/parseTipEvent.test.js b/frontend/src/test/parseTipEvent.test.js new file mode 100644 index 00000000..05dcd17a --- /dev/null +++ b/frontend/src/test/parseTipEvent.test.js @@ -0,0 +1,164 @@ +import { describe, it, expect } from 'vitest'; +import { parseTipEvent } from '../lib/parseTipEvent'; + +describe('parseTipEvent', () => { + it('returns null for empty string', () => { + expect(parseTipEvent('')).toBeNull(); + }); + + it('returns null for invalid repr', () => { + expect(parseTipEvent('(tuple (foo "bar"))')).toBeNull(); + }); + + it('returns null for null-ish input', () => { + expect(parseTipEvent(null)).toBeNull(); + expect(parseTipEvent(undefined)).toBeNull(); + }); + + it('parses a valid tip-sent event', () => { + const repr = '(tuple (event "tip-sent") (tip-id u42) (sender \'SP1SENDER) (recipient \'SP2RECV) (amount u5000000) (fee u50000))'; + const result = parseTipEvent(repr); + + expect(result).not.toBeNull(); + expect(result.event).toBe('tip-sent'); + expect(result.sender).toBe('SP1SENDER'); + expect(result.recipient).toBe('SP2RECV'); + expect(result.amount).toBe('5000000'); + expect(result.fee).toBe('50000'); + expect(result.tipId).toBe('42'); + }); + + it('parses a tip-categorized event', () => { + const repr = '(tuple (event "tip-categorized") (tip-id u7) (category u3))'; + const result = parseTipEvent(repr); + + expect(result.event).toBe('tip-categorized'); + expect(result.tipId).toBe('7'); + expect(result.category).toBe('3'); + }); + + it('returns defaults for missing optional fields', () => { + const repr = '(tuple (event "tip-sent"))'; + const result = parseTipEvent(repr); + + expect(result.event).toBe('tip-sent'); + expect(result.sender).toBe(''); + expect(result.recipient).toBe(''); + expect(result.amount).toBe('0'); + expect(result.fee).toBe('0'); + expect(result.message).toBe(''); + expect(result.tipId).toBe('0'); + expect(result.category).toBeNull(); + }); + + it('extracts message from repr', () => { + const repr = '(tuple (event "tip-sent") (tip-id u1) (sender \'SP1A) (recipient \'SP2B) (amount u100) (fee u10) (message u"Hello world"))'; + const result = parseTipEvent(repr); + + expect(result.message).toBe('Hello world'); + }); + + it('handles missing message field', () => { + const repr = '(tuple (event "tip-sent") (tip-id u1) (sender \'SP1A) (recipient \'SP2B) (amount u100) (fee u10))'; + const result = parseTipEvent(repr); + + expect(result.message).toBe(''); + }); + + it('handles large tip IDs', () => { + const repr = '(tuple (event "tip-sent") (tip-id u999999))'; + const result = parseTipEvent(repr); + + expect(result.tipId).toBe('999999'); + }); + + it('handles large amounts', () => { + const repr = '(tuple (event "tip-sent") (amount u99999999999))'; + const result = parseTipEvent(repr); + + expect(result.amount).toBe('99999999999'); + }); + + it('handles event name with unicode prefix', () => { + const repr = '(tuple (event u"tip-sent") (tip-id u1))'; + const result = parseTipEvent(repr); + + expect(result.event).toBe('tip-sent'); + }); + + it('parses all seven common categories', () => { + for (let i = 0; i <= 6; i++) { + const repr = `(tuple (event "tip-categorized") (tip-id u${i + 1}) (category u${i}))`; + const result = parseTipEvent(repr); + expect(result.category).toBe(String(i)); + } + }); + + it('returns category as null when not present', () => { + const repr = '(tuple (event "tip-sent") (tip-id u1))'; + const result = parseTipEvent(repr); + expect(result.category).toBeNull(); + }); + + it('parses sender addresses case-insensitively', () => { + const repr = '(tuple (event "tip-sent") (sender \'sp1sender))'; + const result = parseTipEvent(repr); + expect(result.sender).toBe('sp1sender'); + }); + + it('handles repr with extra whitespace', () => { + const repr = '(tuple (event "tip-sent") (tip-id u5) (amount u1000))'; + const result = parseTipEvent(repr); + expect(result.event).toBe('tip-sent'); + expect(result.tipId).toBe('5'); + expect(result.amount).toBe('1000'); + }); + + it('extracts fee correctly', () => { + const repr = '(tuple (event "tip-sent") (fee u12345))'; + const result = parseTipEvent(repr); + expect(result.fee).toBe('12345'); + }); + + it('handles completely malformed input without throwing', () => { + expect(parseTipEvent(42)).toBeNull(); + expect(parseTipEvent({})).toBeNull(); + expect(parseTipEvent([])).toBeNull(); + }); + + it('parses amount of u0 as "0"', () => { + const repr = '(tuple (event "tip-sent") (amount u0))'; + const result = parseTipEvent(repr); + expect(result.amount).toBe('0'); + }); + + it('parses tip-id u0 as "0"', () => { + const repr = '(tuple (event "tip-sent") (tip-id u0))'; + const result = parseTipEvent(repr); + expect(result.tipId).toBe('0'); + }); + + it('parses empty message u"" as empty string', () => { + const repr = '(tuple (event "tip-sent") (message u""))'; + const result = parseTipEvent(repr); + expect(result.message).toBe(''); + }); + + it('handles category values beyond standard range', () => { + const repr = '(tuple (event "tip-categorized") (category u99))'; + const result = parseTipEvent(repr); + expect(result.category).toBe('99'); + }); + + it('stops at non-alphanumeric characters in sender address', () => { + const repr = '(tuple (event "tip-sent") (sender \'SP1SENDER.tipstream))'; + const result = parseTipEvent(repr); + expect(result.sender).toBe('SP1SENDER'); + }); + + it('parses boolean-like event names', () => { + const repr = '(tuple (event "user-blocked"))'; + const result = parseTipEvent(repr); + expect(result.event).toBe('user-blocked'); + }); +}); diff --git a/frontend/src/test/pwa-cache-rules.test.js b/frontend/src/test/pwa-cache-rules.test.js new file mode 100644 index 00000000..1a9bb646 --- /dev/null +++ b/frontend/src/test/pwa-cache-rules.test.js @@ -0,0 +1,366 @@ +import { describe, it, expect } from 'vitest'; + +const BALANCE_URL = 'https://api.hiro.so/extended/v1/address/SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T/stx'; +const BALANCES_URL = 'https://api.hiro.so/extended/v1/address/SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T/balances'; +const NONCES_URL = 'https://api.hiro.so/extended/v1/address/SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T/nonces'; +const TX_STATUS_URL = 'https://api.hiro.so/extended/v1/tx/0xabc123'; +const MEMPOOL_URL = 'https://api.hiro.so/extended/v1/tx/mempool'; +const FEE_URL = 'https://api.hiro.so/v2/fees/transfer'; +const CONTRACT_EVENTS_URL = 'https://api.hiro.so/extended/v1/contract/SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.tipstream/events?limit=50&offset=0'; +const READ_ONLY_URL = 'https://api.hiro.so/v2/contracts/call-read/SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T/tipstream/get-tip'; + +const BALANCE_PATTERN = /^https:\/\/api\.(hiro\.so|testnet\.hiro\.so)\/extended\/v1\/address\/.+\/stx/i; +const BALANCES_PATTERN = /^https:\/\/api\.(hiro\.so|testnet\.hiro\.so)\/extended\/v1\/address\/.+\/balances/i; +const NONCES_PATTERN = /^https:\/\/api\.(hiro\.so|testnet\.hiro\.so)\/extended\/v1\/address\/.+\/nonces/i; +const TX_PATTERN = /^https:\/\/api\.(hiro\.so|testnet\.hiro\.so)\/extended\/v1\/tx\/.+/i; +const MEMPOOL_PATTERN = /^https:\/\/api\.(hiro\.so|testnet\.hiro\.so)\/extended\/v1\/tx\/mempool/i; +const FEE_PATTERN = /^https:\/\/api\.(hiro\.so|testnet\.hiro\.so)\/v2\/fees\/.*/i; +const GENERAL_PATTERN = /^https:\/\/api\.(hiro\.so|testnet\.hiro\.so)\/.*/i; +const COINGECKO_PATTERN = /^https:\/\/api\.coingecko\.com\/.*/i; +const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price?ids=stacks&vs_currencies=usd'; + +describe('PWA cache rule patterns', () => { + describe('balance endpoint pattern', () => { + it('matches mainnet balance URL', () => { + expect(BALANCE_PATTERN.test(BALANCE_URL)).toBe(true); + }); + + it('matches testnet balance URL', () => { + const testnetUrl = 'https://api.testnet.hiro.so/extended/v1/address/ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM/stx'; + expect(BALANCE_PATTERN.test(testnetUrl)).toBe(true); + }); + + it('does not match contract events URL', () => { + expect(BALANCE_PATTERN.test(CONTRACT_EVENTS_URL)).toBe(false); + }); + }); + + describe('balances endpoint pattern', () => { + it('matches mainnet balances URL', () => { + expect(BALANCES_PATTERN.test(BALANCES_URL)).toBe(true); + }); + + it('does not match stx-only balance URL', () => { + expect(BALANCES_PATTERN.test(BALANCE_URL)).toBe(false); + }); + }); + + describe('nonces endpoint pattern', () => { + it('matches nonces URL', () => { + expect(NONCES_PATTERN.test(NONCES_URL)).toBe(true); + }); + + it('does not match balance URL', () => { + expect(NONCES_PATTERN.test(BALANCE_URL)).toBe(false); + }); + }); + + describe('transaction status pattern', () => { + it('matches individual tx URL', () => { + expect(TX_PATTERN.test(TX_STATUS_URL)).toBe(true); + }); + + it('matches mempool URL', () => { + expect(TX_PATTERN.test(MEMPOOL_URL)).toBe(true); + }); + + it('does not match balance URL', () => { + expect(TX_PATTERN.test(BALANCE_URL)).toBe(false); + }); + }); + + describe('mempool pattern', () => { + it('matches mempool URL', () => { + expect(MEMPOOL_PATTERN.test(MEMPOOL_URL)).toBe(true); + }); + + it('does not match individual tx URL', () => { + expect(MEMPOOL_PATTERN.test(TX_STATUS_URL)).toBe(false); + }); + }); + + describe('fee estimation pattern', () => { + it('matches fee transfer URL', () => { + expect(FEE_PATTERN.test(FEE_URL)).toBe(true); + }); + + it('does not match balance URL', () => { + expect(FEE_PATTERN.test(BALANCE_URL)).toBe(false); + }); + }); + + describe('general catch-all pattern', () => { + it('matches contract events URL', () => { + expect(GENERAL_PATTERN.test(CONTRACT_EVENTS_URL)).toBe(true); + }); + + it('matches read-only call URL', () => { + expect(GENERAL_PATTERN.test(READ_ONLY_URL)).toBe(true); + }); + + it('matches balance URL', () => { + expect(GENERAL_PATTERN.test(BALANCE_URL)).toBe(true); + }); + + it('does not match CoinGecko URL', () => { + expect(GENERAL_PATTERN.test(COINGECKO_URL)).toBe(false); + }); + }); + + describe('CoinGecko pattern', () => { + it('matches CoinGecko price URL', () => { + expect(COINGECKO_PATTERN.test(COINGECKO_URL)).toBe(true); + }); + + it('does not match Hiro API URL', () => { + expect(COINGECKO_PATTERN.test(BALANCE_URL)).toBe(false); + }); + + it('does not match non-CoinGecko URL', () => { + expect(COINGECKO_PATTERN.test('https://example.com/api/v3/simple/price')).toBe(false); + }); + }); + + describe('case insensitivity', () => { + it('balance pattern matches uppercase domain', () => { + const url = 'HTTPS://API.HIRO.SO/extended/v1/address/SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T/stx'; + expect(BALANCE_PATTERN.test(url)).toBe(true); + }); + + it('tx pattern matches mixed case', () => { + const url = 'https://API.Hiro.So/Extended/V1/TX/0xabc'; + expect(TX_PATTERN.test(url)).toBe(true); + }); + + it('fee pattern matches uppercase path', () => { + const url = 'https://api.hiro.so/V2/FEES/transfer'; + expect(FEE_PATTERN.test(url)).toBe(true); + }); + }); + + describe('rule ordering', () => { + const rules = [ + { pattern: BALANCE_PATTERN, handler: 'NetworkOnly' }, + { pattern: BALANCES_PATTERN, handler: 'NetworkOnly' }, + { pattern: NONCES_PATTERN, handler: 'NetworkOnly' }, + { pattern: MEMPOOL_PATTERN, handler: 'NetworkOnly' }, + { pattern: TX_PATTERN, handler: 'NetworkOnly' }, + { pattern: FEE_PATTERN, handler: 'NetworkOnly' }, + { pattern: GENERAL_PATTERN, handler: 'NetworkFirst' }, + { pattern: COINGECKO_PATTERN, handler: 'NetworkOnly' }, + ]; + + function firstMatchingRule(url) { + return rules.find(r => r.pattern.test(url)); + } + + it('routes balance URL to NetworkOnly before catch-all', () => { + const match = firstMatchingRule(BALANCE_URL); + expect(match.handler).toBe('NetworkOnly'); + expect(match.pattern).toBe(BALANCE_PATTERN); + }); + + it('routes tx URL to NetworkOnly before catch-all', () => { + const match = firstMatchingRule(TX_STATUS_URL); + expect(match.handler).toBe('NetworkOnly'); + }); + + it('routes mempool URL to its own rule before generic tx rule', () => { + const match = firstMatchingRule(MEMPOOL_URL); + expect(match.pattern).toBe(MEMPOOL_PATTERN); + }); + + it('routes contract events to NetworkFirst catch-all', () => { + const match = firstMatchingRule(CONTRACT_EVENTS_URL); + expect(match.handler).toBe('NetworkFirst'); + expect(match.pattern).toBe(GENERAL_PATTERN); + }); + + it('routes read-only calls to NetworkFirst catch-all', () => { + const match = firstMatchingRule(READ_ONLY_URL); + expect(match.handler).toBe('NetworkFirst'); + }); + + it('routes fee estimation to NetworkOnly', () => { + const match = firstMatchingRule(FEE_URL); + expect(match.handler).toBe('NetworkOnly'); + expect(match.pattern).toBe(FEE_PATTERN); + }); + + it('routes nonces to NetworkOnly', () => { + const match = firstMatchingRule(NONCES_URL); + expect(match.handler).toBe('NetworkOnly'); + expect(match.pattern).toBe(NONCES_PATTERN); + }); + + it('routes balances to NetworkOnly', () => { + const match = firstMatchingRule(BALANCES_URL); + expect(match.handler).toBe('NetworkOnly'); + expect(match.pattern).toBe(BALANCES_PATTERN); + }); + + it('routes CoinGecko to NetworkOnly', () => { + const match = firstMatchingRule(COINGECKO_URL); + expect(match.handler).toBe('NetworkOnly'); + expect(match.pattern).toBe(COINGECKO_PATTERN); + }); + }); + + describe('non-matching URLs', () => { + it('does not match non-hiro API URL', () => { + const url = 'https://api.example.com/extended/v1/address/SP31PKQ/stx'; + expect(BALANCE_PATTERN.test(url)).toBe(false); + expect(GENERAL_PATTERN.test(url)).toBe(false); + }); + + it('does not match CoinGecko URL', () => { + const url = 'https://api.coingecko.com/api/v3/simple/price?ids=stacks&vs_currencies=usd'; + expect(GENERAL_PATTERN.test(url)).toBe(false); + }); + + it('does not match localhost URL', () => { + const url = 'http://localhost:3999/extended/v1/address/SP31PKQ/stx'; + expect(BALANCE_PATTERN.test(url)).toBe(false); + }); + }); + + describe('URLs with query parameters', () => { + it('balance pattern matches URL with unanchored param', () => { + const url = BALANCE_URL + '?unanchored=true'; + expect(BALANCE_PATTERN.test(url)).toBe(true); + }); + + it('tx pattern matches URL with event_offset', () => { + const url = TX_STATUS_URL + '?event_offset=0&event_limit=50'; + expect(TX_PATTERN.test(url)).toBe(true); + }); + + it('fee pattern matches URL with trailing slash and param', () => { + const url = FEE_URL + '?estimated_len=350'; + expect(FEE_PATTERN.test(url)).toBe(true); + }); + }); + + describe('testnet URL variants', () => { + it('testnet balances URL matches balances pattern', () => { + const url = 'https://api.testnet.hiro.so/extended/v1/address/ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM/balances'; + expect(BALANCES_PATTERN.test(url)).toBe(true); + }); + + it('testnet nonces URL matches nonces pattern', () => { + const url = 'https://api.testnet.hiro.so/extended/v1/address/ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM/nonces'; + expect(NONCES_PATTERN.test(url)).toBe(true); + }); + + it('testnet tx URL matches tx pattern', () => { + const url = 'https://api.testnet.hiro.so/extended/v1/tx/0xdef456'; + expect(TX_PATTERN.test(url)).toBe(true); + }); + + it('testnet mempool URL matches mempool pattern', () => { + const url = 'https://api.testnet.hiro.so/extended/v1/tx/mempool'; + expect(MEMPOOL_PATTERN.test(url)).toBe(true); + }); + + it('testnet fee URL matches fee pattern', () => { + const url = 'https://api.testnet.hiro.so/v2/fees/transfer'; + expect(FEE_PATTERN.test(url)).toBe(true); + }); + + it('testnet contract events fall through to general pattern', () => { + const url = 'https://api.testnet.hiro.so/extended/v1/contract/ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.tipstream/events?limit=50&offset=0'; + expect(BALANCE_PATTERN.test(url)).toBe(false); + expect(TX_PATTERN.test(url)).toBe(false); + expect(GENERAL_PATTERN.test(url)).toBe(true); + }); + }); + + describe('address transactions endpoint', () => { + it('falls through to general cached rule', () => { + const url = 'https://api.hiro.so/extended/v1/address/SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T/transactions'; + expect(BALANCE_PATTERN.test(url)).toBe(false); + expect(BALANCES_PATTERN.test(url)).toBe(false); + expect(NONCES_PATTERN.test(url)).toBe(false); + expect(GENERAL_PATTERN.test(url)).toBe(true); + }); + }); + + describe('v2 read-only contract calls', () => { + it('contract map-entry calls fall through to general pattern', () => { + const url = 'https://api.hiro.so/v2/map_entry/SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T/tipstream/tips'; + expect(BALANCE_PATTERN.test(url)).toBe(false); + expect(TX_PATTERN.test(url)).toBe(false); + expect(GENERAL_PATTERN.test(url)).toBe(true); + }); + + it('contract interface calls fall through to general pattern', () => { + const url = 'https://api.hiro.so/v2/contracts/interface/SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T/tipstream'; + expect(GENERAL_PATTERN.test(url)).toBe(true); + expect(FEE_PATTERN.test(url)).toBe(false); + }); + }); + + describe('tx hash formats', () => { + it('matches full 66-char tx hash', () => { + const url = 'https://api.hiro.so/extended/v1/tx/0x6a8c4daab2d34d41ddfe3f3d3fe95d2bbf4f91a4b4c5f1e2b3d4a5c6f7e8d9a0'; + expect(TX_PATTERN.test(url)).toBe(true); + }); + + it('matches tx hash without 0x prefix', () => { + const url = 'https://api.hiro.so/extended/v1/tx/6a8c4daab2d34d41ddfe3f3d3fe95d2bbf4f91a4b4c5f1e2b3d4a5c6f7e8d9a0'; + expect(TX_PATTERN.test(url)).toBe(true); + }); + }); + + describe('pattern isolation', () => { + const allPatterns = [ + { name: 'balance', pattern: BALANCE_PATTERN }, + { name: 'balances', pattern: BALANCES_PATTERN }, + { name: 'nonces', pattern: NONCES_PATTERN }, + { name: 'tx', pattern: TX_PATTERN }, + { name: 'mempool', pattern: MEMPOOL_PATTERN }, + { name: 'fee', pattern: FEE_PATTERN }, + ]; + + it('balance URL only matches balance and general patterns', () => { + const matching = allPatterns.filter(p => p.pattern.test(BALANCE_URL)).map(p => p.name); + expect(matching).toEqual(['balance']); + }); + + it('fee URL only matches fee pattern', () => { + const matching = allPatterns.filter(p => p.pattern.test(FEE_URL)).map(p => p.name); + expect(matching).toEqual(['fee']); + }); + + it('nonces URL only matches nonces pattern', () => { + const matching = allPatterns.filter(p => p.pattern.test(NONCES_URL)).map(p => p.name); + expect(matching).toEqual(['nonces']); + }); + + it('balances URL only matches balances pattern', () => { + const matching = allPatterns.filter(p => p.pattern.test(BALANCES_URL)).map(p => p.name); + expect(matching).toEqual(['balances']); + }); + + it('mempool URL matches both mempool and tx patterns', () => { + const matching = allPatterns.filter(p => p.pattern.test(MEMPOOL_URL)).map(p => p.name); + expect(matching).toContain('mempool'); + expect(matching).toContain('tx'); + }); + + it('contract events URL does not match any specific pattern', () => { + const matching = allPatterns.filter(p => p.pattern.test(CONTRACT_EVENTS_URL)).map(p => p.name); + expect(matching).toEqual([]); + }); + + it('read-only URL does not match any specific pattern', () => { + const matching = allPatterns.filter(p => p.pattern.test(READ_ONLY_URL)).map(p => p.name); + expect(matching).toEqual([]); + }); + + it('tx status URL only matches tx pattern', () => { + const matching = allPatterns.filter(p => p.pattern.test(TX_STATUS_URL)).map(p => p.name); + expect(matching).toEqual(['tx']); + }); + }); +}); diff --git a/frontend/src/test/send-tip-validation.test.js b/frontend/src/test/send-tip-validation.test.js new file mode 100644 index 00000000..a69cbc66 --- /dev/null +++ b/frontend/src/test/send-tip-validation.test.js @@ -0,0 +1,164 @@ +import { describe, it, expect } from 'vitest'; + +/** + * SendTip amount validation logic extracted for testability. + * Mirrors handleAmountChange in SendTip.jsx lines 106-128. + */ +const MIN_TIP_STX = 0.001; +const MAX_TIP_STX = 10000; + +function validateSendTipAmount(value, balanceSTX = null) { + if (!value) return ''; + const parsed = parseFloat(value); + if (isNaN(parsed) || parsed <= 0) return 'Amount must be a positive number'; + if (parsed < MIN_TIP_STX) return `Minimum tip is ${MIN_TIP_STX} STX`; + if (parsed > MAX_TIP_STX) return `Maximum tip is ${MAX_TIP_STX.toLocaleString()} STX`; + return ''; +} + +describe('SendTip amount validation', () => { + it('returns empty string for empty value', () => { + expect(validateSendTipAmount('')).toBe(''); + }); + + it('returns empty string for null value', () => { + expect(validateSendTipAmount(null)).toBe(''); + }); + + it('returns error for zero', () => { + expect(validateSendTipAmount('0')).toBe('Amount must be a positive number'); + }); + + it('returns error for negative value', () => { + expect(validateSendTipAmount('-5')).toBe('Amount must be a positive number'); + }); + + it('returns error for non-numeric input', () => { + expect(validateSendTipAmount('abc')).toBe('Amount must be a positive number'); + }); + + it('returns error for amount below minimum', () => { + expect(validateSendTipAmount('0.0005')).toBe(`Minimum tip is ${MIN_TIP_STX} STX`); + }); + + it('accepts exact minimum amount', () => { + expect(validateSendTipAmount('0.001')).toBe(''); + }); + + it('accepts valid amount', () => { + expect(validateSendTipAmount('5')).toBe(''); + }); + + it('returns error for amount above maximum', () => { + expect(validateSendTipAmount('10001')).toContain('Maximum tip'); + }); + + it('accepts exact maximum amount', () => { + expect(validateSendTipAmount('10000')).toBe(''); + }); + + it('accepts various decimal amounts', () => { + expect(validateSendTipAmount('0.5')).toBe(''); + expect(validateSendTipAmount('1.234')).toBe(''); + expect(validateSendTipAmount('999.99')).toBe(''); + }); +}); + +/** + * SendTip self-tip validation extracted from validateAndConfirm. + */ +function isSelfTip(recipient, senderAddress) { + return recipient.trim() === senderAddress; +} + +describe('SendTip self-tip check', () => { + const sender = 'SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'; + + it('detects self-tip when addresses match', () => { + expect(isSelfTip(sender, sender)).toBe(true); + }); + + it('returns false for different addresses', () => { + expect(isSelfTip('SP_OTHER_ADDRESS_12345678901234567890', sender)).toBe(false); + }); + + it('trims whitespace before comparison', () => { + expect(isSelfTip(` ${sender} `, sender)).toBe(true); + }); +}); + +describe('SendTip balance-insufficient check', () => { + function isBalanceInsufficient(amount, balanceSTX) { + if (balanceSTX === null) return false; + const parsed = parseFloat(amount); + if (isNaN(parsed)) return false; + return parsed > balanceSTX; + } + + it('returns false when balance is null (unknown)', () => { + expect(isBalanceInsufficient('5', null)).toBe(false); + }); + + it('returns false when amount is within balance', () => { + expect(isBalanceInsufficient('5', 10)).toBe(false); + }); + + it('returns true when amount exceeds balance', () => { + expect(isBalanceInsufficient('15', 10)).toBe(true); + }); + + it('returns false when amount equals balance', () => { + expect(isBalanceInsufficient('10', 10)).toBe(false); + }); + + it('returns false for non-numeric amount', () => { + expect(isBalanceInsufficient('abc', 10)).toBe(false); + }); +}); + +describe('SendTip constants', () => { + it('MIN_TIP_STX is 0.001', () => { + expect(MIN_TIP_STX).toBe(0.001); + }); + + it('MAX_TIP_STX is 10000', () => { + expect(MAX_TIP_STX).toBe(10000); + }); + + it('TIP_CATEGORIES has 7 entries', () => { + const TIP_CATEGORIES = [ + { id: 0, label: 'General' }, + { id: 1, label: 'Content' }, + { id: 2, label: 'Development' }, + { id: 3, label: 'Community' }, + { id: 4, label: 'Support' }, + { id: 5, label: 'Education' }, + { id: 6, label: 'Other' }, + ]; + expect(TIP_CATEGORIES).toHaveLength(7); + expect(TIP_CATEGORIES[0].id).toBe(0); + expect(TIP_CATEGORIES[6].id).toBe(6); + }); +}); + +describe('SendTip default message', () => { + function resolveMessage(message) { + return message || 'Thanks!'; + } + + it('uses provided message when non-empty', () => { + expect(resolveMessage('Hello')).toBe('Hello'); + }); + + it('defaults to "Thanks!" when message is empty', () => { + expect(resolveMessage('')).toBe('Thanks!'); + }); + + it('defaults to "Thanks!" when message is null', () => { + expect(resolveMessage(null)).toBe('Thanks!'); + }); + + it('defaults to "Thanks!" when message is undefined', () => { + expect(resolveMessage(undefined)).toBe('Thanks!'); + }); +}); diff --git a/frontend/src/test/stacks-utils.test.js b/frontend/src/test/stacks-utils.test.js new file mode 100644 index 00000000..13a45382 --- /dev/null +++ b/frontend/src/test/stacks-utils.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock @stacks/connect before importing the module under test. +vi.mock('@stacks/connect', () => { + class AppConfig { + constructor() { + this.scopes = []; + } + } + class UserSession { + constructor() { + this.loadUserData = vi.fn(); + } + } + return { + AppConfig, + UserSession, + showConnect: vi.fn(), + disconnect: vi.fn(), + }; +}); + +vi.mock('@stacks/network', () => ({ + STACKS_MAINNET: { url: 'https://stacks-node-api.mainnet.stacks.co' }, + STACKS_TESTNET: { url: 'https://stacks-node-api.testnet.stacks.co' }, + STACKS_DEVNET: { url: 'http://localhost:3999' }, +})); + +// Now import the module under test. +const { isWalletInstalled, getSenderAddress, appDetails, network } = + await import('../utils/stacks'); + +describe('isWalletInstalled', () => { + const originalStacksProvider = window.StacksProvider; + const originalLeatherProvider = window.LeatherProvider; + + afterEach(() => { + window.StacksProvider = originalStacksProvider; + window.LeatherProvider = originalLeatherProvider; + }); + + it('returns true when StacksProvider is present', () => { + window.StacksProvider = {}; + window.LeatherProvider = undefined; + expect(isWalletInstalled()).toBe(true); + }); + + it('returns true when LeatherProvider is present', () => { + window.StacksProvider = undefined; + window.LeatherProvider = {}; + expect(isWalletInstalled()).toBe(true); + }); + + it('returns true when both providers are present', () => { + window.StacksProvider = {}; + window.LeatherProvider = {}; + expect(isWalletInstalled()).toBe(true); + }); + + it('returns false when no provider is present', () => { + window.StacksProvider = undefined; + window.LeatherProvider = undefined; + expect(isWalletInstalled()).toBe(false); + }); +}); + +describe('appDetails', () => { + it('has name TipStream', () => { + expect(appDetails.name).toBe('TipStream'); + }); + + it('has icon path ending in logo.svg', () => { + expect(appDetails.icon).toContain('logo.svg'); + }); +}); + +describe('network', () => { + it('resolves to an object with a url property', () => { + expect(network).toHaveProperty('url'); + }); +}); diff --git a/frontend/src/test/tipBackValidation.test.js b/frontend/src/test/tipBackValidation.test.js new file mode 100644 index 00000000..347c64bf --- /dev/null +++ b/frontend/src/test/tipBackValidation.test.js @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { validateTipBackAmount, MIN_TIP_STX, MAX_TIP_STX } from '../lib/tipBackValidation'; + +describe('tipBackValidation constants', () => { + it('exports MIN_TIP_STX as 0.001', () => { + expect(MIN_TIP_STX).toBe(0.001); + }); + + it('exports MAX_TIP_STX as 10000', () => { + expect(MAX_TIP_STX).toBe(10000); + }); +}); + +describe('validateTipBackAmount', () => { + it('returns error for empty string', () => { + expect(validateTipBackAmount('')).toBe('Amount is required'); + }); + + it('returns error for whitespace-only string', () => { + expect(validateTipBackAmount(' ')).toBe('Amount is required'); + }); + + it('returns error for null', () => { + expect(validateTipBackAmount(null)).toBe('Amount is required'); + }); + + it('returns error for undefined', () => { + expect(validateTipBackAmount(undefined)).toBe('Amount is required'); + }); + + it('returns error for zero', () => { + expect(validateTipBackAmount('0')).toBe('Amount must be a positive number'); + }); + + it('returns error for negative number', () => { + expect(validateTipBackAmount('-1')).toBe('Amount must be a positive number'); + }); + + it('returns error for non-numeric string', () => { + expect(validateTipBackAmount('abc')).toBe('Amount must be a positive number'); + }); + + it('returns error for amount below minimum', () => { + expect(validateTipBackAmount('0.0001')).toBe(`Minimum tip is ${MIN_TIP_STX} STX`); + }); + + it('returns empty string for exact minimum', () => { + expect(validateTipBackAmount('0.001')).toBe(''); + }); + + it('returns empty string for valid amount', () => { + expect(validateTipBackAmount('1.5')).toBe(''); + }); + + it('returns empty string for exact maximum', () => { + expect(validateTipBackAmount('10000')).toBe(''); + }); + + it('returns error for amount above maximum', () => { + expect(validateTipBackAmount('10001')).toContain('Maximum tip'); + }); + + it('returns empty string for typical tip amounts', () => { + expect(validateTipBackAmount('0.5')).toBe(''); + expect(validateTipBackAmount('1')).toBe(''); + expect(validateTipBackAmount('10')).toBe(''); + expect(validateTipBackAmount('100')).toBe(''); + }); + + it('handles decimal edge cases', () => { + expect(validateTipBackAmount('0.001')).toBe(''); + expect(validateTipBackAmount('0.0009')).toBe(`Minimum tip is ${MIN_TIP_STX} STX`); + }); + + it('returns error for NaN string', () => { + expect(validateTipBackAmount('NaN')).toBe('Amount must be a positive number'); + }); + + it('returns error for Infinity string', () => { + expect(validateTipBackAmount('Infinity')).toContain('Maximum tip'); + }); + + it('returns error for negative Infinity', () => { + expect(validateTipBackAmount('-Infinity')).toBe('Amount must be a positive number'); + }); + + it('handles numeric input passed as string', () => { + expect(validateTipBackAmount('5')).toBe(''); + }); + + it('handles very small positive amount just above minimum', () => { + expect(validateTipBackAmount('0.002')).toBe(''); + }); +}); diff --git a/frontend/src/test/token-tip-validation.test.js b/frontend/src/test/token-tip-validation.test.js new file mode 100644 index 00000000..0ce6c5ac --- /dev/null +++ b/frontend/src/test/token-tip-validation.test.js @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest'; + +/** + * TokenTip contract ID parsing logic extracted for testability. + * Mirrors parseContractId in TokenTip.jsx. + */ +function parseContractId(id) { + const parts = id.trim().split('.'); + return { address: parts[0], name: parts[1] }; +} + +function isValidStacksAddress(address) { + if (!address) return false; + const trimmed = address.trim(); + if (trimmed.length < 38 || trimmed.length > 41) return false; + return /^(SP|SM|ST)[0-9A-Z]{33,39}$/i.test(trimmed); +} + +function isValidContractId(id) { + if (!id) return false; + const parts = id.trim().split('.'); + if (parts.length !== 2) return false; + return isValidStacksAddress(parts[0]) && parts[1].length > 0; +} + +describe('parseContractId', () => { + it('splits a valid contract ID into address and name', () => { + const result = parseContractId('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.tipstream'); + expect(result.address).toBe('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'); + expect(result.name).toBe('tipstream'); + }); + + it('trims whitespace from input', () => { + const result = parseContractId(' SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.token '); + expect(result.address).toBe('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'); + expect(result.name).toBe('token'); + }); + + it('handles contract names with hyphens', () => { + const result = parseContractId('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.my-token'); + expect(result.name).toBe('my-token'); + }); + + it('handles contract names with underscores', () => { + const result = parseContractId('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.my_token_v2'); + expect(result.name).toBe('my_token_v2'); + }); +}); + +describe('TokenTip validation flow', () => { + it('requires a non-empty contract ID', () => { + expect(isValidContractId('')).toBe(false); + }); + + it('requires exactly one dot separator', () => { + expect(isValidContractId('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T')).toBe(false); + }); + + it('requires a valid address before the dot', () => { + expect(isValidContractId('INVALID.tipstream')).toBe(false); + }); + + it('requires a non-empty name after the dot', () => { + expect(isValidContractId('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.')).toBe(false); + }); + + it('accepts a well-formed contract ID', () => { + expect(isValidContractId('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.tipstream')).toBe(true); + }); + + it('validates the address portion', () => { + expect(isValidContractId('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.token')).toBe(true); + expect(isValidContractId('ST31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.token')).toBe(true); + expect(isValidContractId('SM31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.token')).toBe(true); + }); +}); + +describe('TokenTip integer amount parsing', () => { + function parseTokenAmount(value) { + return parseInt(value, 10); + } + + it('parses integer string to number', () => { + expect(parseTokenAmount('100')).toBe(100); + }); + + it('truncates decimal to integer', () => { + expect(parseTokenAmount('1.5')).toBe(1); + }); + + it('returns NaN for non-numeric input', () => { + expect(parseTokenAmount('abc')).toBeNaN(); + }); + + it('parses zero correctly', () => { + expect(parseTokenAmount('0')).toBe(0); + }); + + it('handles leading/trailing spaces in parseInt', () => { + expect(parseTokenAmount(' 42 ')).toBe(42); + }); +}); + +describe('TokenTip whitelist status logic', () => { + function checkWhitelistResponse(json) { + return json.value?.value === true || json.value === true; + } + + it('detects whitelisted via nested value.value', () => { + expect(checkWhitelistResponse({ value: { value: true } })).toBe(true); + }); + + it('detects whitelisted via flat value', () => { + expect(checkWhitelistResponse({ value: true })).toBe(true); + }); + + it('rejects non-whitelisted nested value', () => { + expect(checkWhitelistResponse({ value: { value: false } })).toBe(false); + }); + + it('rejects null value', () => { + expect(checkWhitelistResponse({ value: null })).toBe(false); + }); + + it('rejects missing value', () => { + expect(checkWhitelistResponse({})).toBe(false); + }); +}); + +describe('TokenTip contract ID with multiple dots', () => { + it('rejects IDs with more than one dot', () => { + expect(isValidContractId('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.name.extra')).toBe(false); + }); + + it('rejects null input', () => { + expect(isValidContractId(null)).toBe(false); + }); + + it('rejects undefined input', () => { + expect(isValidContractId(undefined)).toBe(false); + }); +}); diff --git a/frontend/src/test/useBalance.test.js b/frontend/src/test/useBalance.test.js index a1f46498..8b2d4734 100644 --- a/frontend/src/test/useBalance.test.js +++ b/frontend/src/test/useBalance.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; +import { renderHook, waitFor, act } from '@testing-library/react'; import { useBalance } from '../hooks/useBalance'; // Stub STACKS_API_BASE before the module resolves. @@ -62,11 +62,10 @@ describe('useBalance', () => { expect(typeof result.current.balance).toBe('string'); }); - it('sets error when API returns non-ok status', async () => { + it('computes balanceStx correctly from a micro-STX string', async () => { global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 404, - json: () => Promise.resolve({}), + ok: true, + json: () => Promise.resolve({ balance: '1500000' }), }); const { result } = renderHook(() => @@ -77,14 +76,30 @@ describe('useBalance', () => { expect(result.current.loading).toBe(false); }); - expect(result.current.error).toBe('API returned 404'); - expect(result.current.balance).toBeNull(); + expect(result.current.balanceStx).toBe(1.5); }); - it('sets error when API returns unexpected balance format', async () => { + it('calls the correct API endpoint', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ balance: null }), + json: () => Promise.resolve({ balance: '0' }), + }); + + renderHook(() => + useBalance('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'), + ); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.test/extended/v1/address/SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T/stx', + ); + }); + }); + + it('sets lastFetched timestamp on successful fetch', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ balance: '1000000' }), }); const { result } = renderHook(() => @@ -95,14 +110,14 @@ describe('useBalance', () => { expect(result.current.loading).toBe(false); }); - expect(result.current.error).toBe('Unexpected balance format in API response'); - expect(result.current.balance).toBeNull(); + expect(result.current.lastFetched).toBeTypeOf('number'); + expect(result.current.lastFetched).toBeGreaterThan(0); }); - it('sets error when API returns object balance', async () => { + it('exposes a refetch function', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ balance: { raw: '1000' } }), + json: () => Promise.resolve({ balance: '1000000' }), }); const { result } = renderHook(() => @@ -113,11 +128,19 @@ describe('useBalance', () => { expect(result.current.loading).toBe(false); }); - expect(result.current.error).toBe('Unexpected balance format in API response'); + expect(typeof result.current.refetch).toBe('function'); }); - it('sets error when fetch throws a network error', async () => { - global.fetch = vi.fn().mockRejectedValue(new Error('Network offline')); + it('has null lastFetched before any fetch', () => { + const { result } = renderHook(() => useBalance(null)); + expect(result.current.lastFetched).toBeNull(); + }); + + it('refetch updates balance with fresh data', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ balance: '1000000' }), + }); const { result } = renderHook(() => useBalance('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'), @@ -127,40 +150,207 @@ describe('useBalance', () => { expect(result.current.loading).toBe(false); }); - expect(result.current.error).toBe('Network offline'); + expect(result.current.balance).toBe('1000000'); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ balance: '9000000' }), + }); + + await act(async () => { + await result.current.refetch(); + }); + + expect(result.current.balance).toBe('9000000'); + expect(result.current.balanceStx).toBe(9); }); - it('computes balanceStx correctly from a micro-STX string', async () => { + it('re-fetches when address changes', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ balance: '1500000' }), + json: () => Promise.resolve({ balance: '1000000' }), }); - const { result } = renderHook(() => - useBalance('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'), + const { result, rerender } = renderHook( + ({ addr }) => useBalance(addr), + { initialProps: { addr: 'SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T' } }, ); await waitFor(() => { expect(result.current.loading).toBe(false); }); - expect(result.current.balanceStx).toBe(1.5); + expect(result.current.balance).toBe('1000000'); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ balance: '7000000' }), + }); + + rerender({ addr: 'SM2PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T' }); + + await waitFor(() => { + expect(result.current.balance).toBe('7000000'); + }); }); - it('calls the correct API endpoint', async () => { + it('resets balance when address changes to null', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ balance: '0' }), + json: () => Promise.resolve({ balance: '1000000' }), + }); + + const { result, rerender } = renderHook( + ({ addr }) => useBalance(addr), + { initialProps: { addr: 'SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T' } }, + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.balance).toBe('1000000'); + + rerender({ addr: null }); + + await waitFor(() => { + expect(result.current.balance).toBeNull(); + }); + + expect(result.current.balanceStx).toBeNull(); + }); +}); + +describe('useBalance retry and error handling', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('sets error when API returns non-ok status after retries', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + json: () => Promise.resolve({}), + }); + + const { result } = renderHook(() => + useBalance('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'), + ); + + // Drain all retries (initial + 2 retries with 1500ms delay each) + for (let i = 0; i < 3; i++) { + await act(async () => { + await vi.advanceTimersByTimeAsync(1600); + }); + } + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe('API returned 404'); + expect(result.current.balance).toBeNull(); + }); + + it('sets error when API returns unexpected balance format after retries', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ balance: null }), + }); + + const { result } = renderHook(() => + useBalance('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'), + ); + + for (let i = 0; i < 3; i++) { + await act(async () => { + await vi.advanceTimersByTimeAsync(1600); + }); + } + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe('Unexpected balance format in API response'); + expect(result.current.balance).toBeNull(); + }); + + it('sets error when API returns object balance after retries', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ balance: { raw: '1000' } }), + }); + + const { result } = renderHook(() => + useBalance('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'), + ); + + for (let i = 0; i < 3; i++) { + await act(async () => { + await vi.advanceTimersByTimeAsync(1600); + }); + } + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe('Unexpected balance format in API response'); + }); + + it('sets error when fetch throws a network error after retries', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network offline')); + + const { result } = renderHook(() => + useBalance('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'), + ); + + for (let i = 0; i < 3; i++) { + await act(async () => { + await vi.advanceTimersByTimeAsync(1600); + }); + } + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe('Network offline'); + }); + + it('retries before setting error (calls fetch MAX_RETRIES + 1 times)', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, }); renderHook(() => useBalance('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'), ); - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - 'https://api.test/extended/v1/address/SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T/stx', - ); + for (let i = 0; i < 3; i++) { + await act(async () => { + await vi.advanceTimersByTimeAsync(1600); + }); + } + + // 1 initial + 2 retries = 3 calls + expect(global.fetch).toHaveBeenCalledTimes(3); + }); + + it('succeeds on retry after initial failure', async () => { + global.fetch = vi.fn() + .mockResolvedValueOnce({ ok: false, status: 500 }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ balance: '2000000' }), + }); + + const { result } = renderHook(() => + useBalance('SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'), + ); + + // First attempt fails, advance past retry delay + await act(async () => { + await vi.advanceTimersByTimeAsync(1600); }); + + expect(result.current.loading).toBe(false); + expect(result.current.balance).toBe('2000000'); + expect(result.current.error).toBeNull(); }); }); diff --git a/frontend/src/test/useBlockCheck.test.js b/frontend/src/test/useBlockCheck.test.js new file mode 100644 index 00000000..5bc096f2 --- /dev/null +++ b/frontend/src/test/useBlockCheck.test.js @@ -0,0 +1,255 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useBlockCheck } from '../hooks/useBlockCheck'; + +const mockFetchReadOnly = vi.fn(); +const mockCvToJSON = vi.fn(); + +vi.mock('@stacks/transactions', () => ({ + fetchCallReadOnlyFunction: (...args) => mockFetchReadOnly(...args), + cvToJSON: (...args) => mockCvToJSON(...args), + principalCV: vi.fn((addr) => ({ type: 'principal', value: addr })), +})); + +vi.mock('../utils/stacks', () => ({ + network: { url: 'https://api.test' }, + getSenderAddress: vi.fn(() => 'SP1SENDER'), +})); + +vi.mock('../config/contracts', () => ({ + CONTRACT_ADDRESS: 'SP_CONTRACT', + CONTRACT_NAME: 'tipstream', + FN_IS_USER_BLOCKED: 'is-user-blocked', +})); + +describe('useBlockCheck', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts with blocked as null and checking as false', () => { + const { result } = renderHook(() => useBlockCheck()); + expect(result.current.blocked).toBeNull(); + expect(result.current.checking).toBe(false); + }); + + it('sets blocked to null when recipient is empty', () => { + const { result } = renderHook(() => useBlockCheck()); + act(() => { + result.current.checkBlocked(''); + }); + expect(result.current.blocked).toBeNull(); + expect(result.current.checking).toBe(false); + }); + + it('sets blocked to null when recipient equals sender', () => { + const { result } = renderHook(() => useBlockCheck()); + act(() => { + result.current.checkBlocked('SP1SENDER'); + }); + expect(result.current.blocked).toBeNull(); + }); + + it('trims whitespace from recipient address', () => { + const { result } = renderHook(() => useBlockCheck()); + act(() => { + result.current.checkBlocked(' SP1SENDER '); + }); + // Trimmed equals sender, so should be null + expect(result.current.blocked).toBeNull(); + }); + + it('sets checking to true during contract call', async () => { + let resolveCall; + mockFetchReadOnly.mockReturnValue(new Promise(r => { resolveCall = r; })); + + const { result } = renderHook(() => useBlockCheck()); + + act(() => { + result.current.checkBlocked('SP2RECIPIENT'); + }); + + expect(result.current.checking).toBe(true); + + const cvResult = {}; + mockCvToJSON.mockReturnValue({ value: false }); + + await act(async () => { + resolveCall(cvResult); + }); + + await waitFor(() => { + expect(result.current.checking).toBe(false); + }); + }); + + it('sets blocked to true when contract returns true', async () => { + const cvResult = {}; + mockFetchReadOnly.mockResolvedValue(cvResult); + mockCvToJSON.mockReturnValue({ value: true }); + + const { result } = renderHook(() => useBlockCheck()); + + await act(async () => { + result.current.checkBlocked('SP2RECIPIENT'); + }); + + await waitFor(() => { + expect(result.current.blocked).toBe(true); + }); + }); + + it('sets blocked to true when contract returns string true', async () => { + mockFetchReadOnly.mockResolvedValue({}); + mockCvToJSON.mockReturnValue({ value: 'true' }); + + const { result } = renderHook(() => useBlockCheck()); + + await act(async () => { + result.current.checkBlocked('SP2RECIPIENT'); + }); + + await waitFor(() => { + expect(result.current.blocked).toBe(true); + }); + }); + + it('sets blocked to false when contract returns false', async () => { + mockFetchReadOnly.mockResolvedValue({}); + mockCvToJSON.mockReturnValue({ value: false }); + + const { result } = renderHook(() => useBlockCheck()); + + await act(async () => { + result.current.checkBlocked('SP2RECIPIENT'); + }); + + await waitFor(() => { + expect(result.current.blocked).toBe(false); + }); + }); + + it('sets blocked to null on contract call error', async () => { + mockFetchReadOnly.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useBlockCheck()); + + await act(async () => { + result.current.checkBlocked('SP2RECIPIENT'); + }); + + await waitFor(() => { + expect(result.current.checking).toBe(false); + }); + + expect(result.current.blocked).toBeNull(); + }); + + it('reset clears blocked state and stops checking', async () => { + mockFetchReadOnly.mockResolvedValue({}); + mockCvToJSON.mockReturnValue({ value: true }); + + const { result } = renderHook(() => useBlockCheck()); + + await act(async () => { + result.current.checkBlocked('SP2RECIPIENT'); + }); + + await waitFor(() => { + expect(result.current.blocked).toBe(true); + }); + + act(() => { + result.current.reset(); + }); + + expect(result.current.blocked).toBeNull(); + expect(result.current.checking).toBe(false); + }); + + it('discards stale responses after reset', async () => { + let resolveCall; + mockFetchReadOnly.mockReturnValue(new Promise(r => { resolveCall = r; })); + + const { result } = renderHook(() => useBlockCheck()); + + act(() => { + result.current.checkBlocked('SP2RECIPIENT'); + }); + + // Reset before the call resolves + act(() => { + result.current.reset(); + }); + + mockCvToJSON.mockReturnValue({ value: true }); + + await act(async () => { + resolveCall({}); + }); + + // Should stay null because the abortRef was incremented + expect(result.current.blocked).toBeNull(); + }); + + it('passes correct arguments to fetchCallReadOnlyFunction', async () => { + mockFetchReadOnly.mockResolvedValue({}); + mockCvToJSON.mockReturnValue({ value: false }); + + const { result } = renderHook(() => useBlockCheck()); + + await act(async () => { + result.current.checkBlocked('SP2RECIPIENT'); + }); + + expect(mockFetchReadOnly).toHaveBeenCalledWith( + expect.objectContaining({ + contractAddress: 'SP_CONTRACT', + contractName: 'tipstream', + functionName: 'is-user-blocked', + senderAddress: 'SP1SENDER', + }), + ); + }); + + it('handles sequential calls with different recipients', async () => { + mockFetchReadOnly.mockResolvedValue({}); + mockCvToJSON.mockReturnValueOnce({ value: true }).mockReturnValueOnce({ value: false }); + + const { result } = renderHook(() => useBlockCheck()); + + await act(async () => { + result.current.checkBlocked('SP2BLOCKED'); + }); + + await waitFor(() => { + expect(result.current.blocked).toBe(true); + }); + + await act(async () => { + result.current.checkBlocked('SP3NOTBLOCKED'); + }); + + await waitFor(() => { + expect(result.current.blocked).toBe(false); + }); + }); + + it('sets checking to false after error', async () => { + mockFetchReadOnly.mockRejectedValue(new Error('fail')); + + const { result } = renderHook(() => useBlockCheck()); + + await act(async () => { + result.current.checkBlocked('SP2RECIPIENT'); + }); + + await waitFor(() => { + expect(result.current.checking).toBe(false); + }); + }); +}); diff --git a/frontend/src/test/useStxPrice.test.js b/frontend/src/test/useStxPrice.test.js new file mode 100644 index 00000000..1de040f5 --- /dev/null +++ b/frontend/src/test/useStxPrice.test.js @@ -0,0 +1,335 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useStxPrice } from '../hooks/useStxPrice'; + +describe('useStxPrice', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('starts in loading state', () => { + global.fetch = vi.fn().mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useStxPrice()); + expect(result.current.loading).toBe(true); + expect(result.current.price).toBeNull(); + }); + + it('fetches and sets price on mount', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: { usd: 1.25 } }), + }); + + const { result } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.price).toBe(1.25); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('sets error on non-ok response', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }); + + const { result } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.error).toBe('HTTP 500'); + expect(result.current.price).toBeNull(); + expect(result.current.loading).toBe(false); + }); + + it('sets error when price data is missing', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: {} }), + }); + + const { result } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.error).toBe('Invalid price data'); + }); + + it('sets error when fetch rejects', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.error).toBe('Network error'); + expect(result.current.loading).toBe(false); + }); + + it('toUsd converts STX amount to USD string', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: { usd: 2.0 } }), + }); + + const { result } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.toUsd(5)).toBe('10.00'); + }); + + it('toUsd returns null when price is not loaded', () => { + global.fetch = vi.fn().mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useStxPrice()); + expect(result.current.toUsd(5)).toBeNull(); + }); + + it('toUsd returns null for null input', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: { usd: 2.0 } }), + }); + + const { result } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.toUsd(null)).toBeNull(); + }); + + it('toUsd returns null for undefined input', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: { usd: 2.0 } }), + }); + + const { result } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.toUsd(undefined)).toBeNull(); + }); + + it('toUsd handles string amounts', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: { usd: 0.5 } }), + }); + + const { result } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.toUsd('10')).toBe('5.00'); + }); + + it('refetch triggers a new fetch', async () => { + const goodResponse = { + ok: true, + json: () => Promise.resolve({ stacks: { usd: 1.0 } }), + }; + global.fetch = vi.fn().mockResolvedValue(goodResponse); + + const { result } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.price).toBe(1.0); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: { usd: 2.0 } }), + }); + + await act(async () => { + await result.current.refetch(); + }); + + expect(result.current.price).toBe(2.0); + }); + + it('clears error on successful refetch', async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }); + + const { result } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.error).toBe('HTTP 500'); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: { usd: 1.5 } }), + }); + + await act(async () => { + await result.current.refetch(); + }); + + expect(result.current.error).toBeNull(); + expect(result.current.price).toBe(1.5); + }); + + it('toUsd formats to two decimal places', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: { usd: 1.333 } }), + }); + + const { result } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.toUsd(3)).toBe('4.00'); + }); + + it('handles zero price from API', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: { usd: 0 } }), + }); + + const { result } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.price).toBe(0); + expect(result.current.toUsd(100)).toBe('0.00'); + }); + + it('polls for updated price after 60 seconds', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: { usd: 1.0 } }), + }); + + const { result } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.price).toBe(1.0); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: { usd: 1.5 } }), + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + + expect(result.current.price).toBe(1.5); + }); + + it('stops polling after unmount', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: { usd: 1.0 } }), + }); + + const { unmount } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + const callsAfterMount = global.fetch.mock.calls.length; + unmount(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(120_000); + }); + + expect(global.fetch).toHaveBeenCalledTimes(callsAfterMount); + }); + + it('preserves price when a subsequent poll fails', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: { usd: 1.25 } }), + }); + + const { result } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.price).toBe(1.25); + + global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 503 }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + + expect(result.current.price).toBe(1.25); + expect(result.current.error).toBe('HTTP 503'); + }); + + it('calls the correct CoinGecko API endpoint', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: { usd: 1.0 } }), + }); + + renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.coingecko.com/api/v3/simple/price?ids=stacks&vs_currencies=usd', + ); + }); + + it('toUsd returns zero string for zero STX amount', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ stacks: { usd: 2.0 } }), + }); + + const { result } = renderHook(() => useStxPrice()); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + expect(result.current.toUsd(0)).toBe('0.00'); + }); +});