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');
+ });
+});