Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
4dc8ce4
test(hooks): add useStxPrice unit tests
Mosas2000 Mar 15, 2026
e0c820f
test(hooks): add useBlockCheck unit tests
Mosas2000 Mar 15, 2026
be27cf8
test(lib): add parseTipEvent unit tests
Mosas2000 Mar 15, 2026
7cbae55
test(lib): add tipBackValidation unit tests
Mosas2000 Mar 15, 2026
f9050c5
fix(test): rewrite useBalance tests with fake timers for retry logic
Mosas2000 Mar 15, 2026
05d0f23
test(validation): add Stacks address and contract ID validation tests
Mosas2000 Mar 15, 2026
d8d9c7c
test(batch-tip): add duplicate detection and amount validation tests
Mosas2000 Mar 15, 2026
362296a
test(token-tip): add parseContractId and validation flow tests
Mosas2000 Mar 15, 2026
2ae9dd1
test(hooks): add toUsd formatting and zero price edge cases
Mosas2000 Mar 15, 2026
741b74b
test(lib): add parseTipEvent edge cases for whitespace, case, malform…
Mosas2000 Mar 15, 2026
c734877
test(send-tip): add amount validation and self-tip detection tests
Mosas2000 Mar 15, 2026
ec7c7af
docs(changelog): add Issue 248 test coverage entries
Mosas2000 Mar 15, 2026
8bddc6a
test(hooks): add sequential call and error completion tests to useBlo…
Mosas2000 Mar 15, 2026
45adaa2
test(lib): add micro/STX roundtrip conversion tests
Mosas2000 Mar 15, 2026
ed757be
test(hooks): add lastFetched and refetch exposure tests to useBalance
Mosas2000 Mar 15, 2026
8b3e920
test(hooks): verify refetch returns updated balance data
Mosas2000 Mar 15, 2026
cdae5f4
test(hooks): verify useBalance re-fetches on address change and reset…
Mosas2000 Mar 15, 2026
e47001b
test(hooks): verify useStxPrice 60s interval polling and unmount cleanup
Mosas2000 Mar 15, 2026
81da11a
test(hooks): assert useStxPrice calls the correct CoinGecko endpoint
Mosas2000 Mar 15, 2026
0206b84
test(lib): add parseTipEvent edge cases for u0 values and contract pr…
Mosas2000 Mar 15, 2026
03978cb
test(lib): add contractEvents pagination edge cases and error handling
Mosas2000 Mar 15, 2026
a0cd1fe
test(lib): add parseRawEvents tests for empty repr and falsy block_time
Mosas2000 Mar 15, 2026
6bf2684
test(batch-tip): add message length, self-tip, totalAmount, and const…
Mosas2000 Mar 15, 2026
58de395
test(send-tip): add balance-insufficient, constants, and default mess…
Mosas2000 Mar 15, 2026
7e90375
test(token-tip): add integer parsing, whitelist status, and multi-dot…
Mosas2000 Mar 15, 2026
957d4af
test(lib): add tipBackValidation edge cases for NaN and Infinity strings
Mosas2000 Mar 15, 2026
bf10525
test(validation): add address boundary tests for length edges and spe…
Mosas2000 Mar 15, 2026
f7e0e37
test(utils): add stacks.js utility tests for wallet detection and app…
Mosas2000 Mar 15, 2026
fb76947
test(pwa): add runtime cache strategy validation tests
Mosas2000 Mar 15, 2026
e925261
test(components): add Leaderboard rendering and interaction tests
Mosas2000 Mar 15, 2026
9dd5daf
test(lib): add buildLeaderboardStats aggregation and edge case tests
Mosas2000 Mar 15, 2026
7dc9375
docs(changelog): update Issue 248 test counts with final coverage num…
Mosas2000 Mar 15, 2026
1f65574
fix(test): update Leaderboard refresh button tests to match current c…
Mosas2000 Mar 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
251 changes: 251 additions & 0 deletions frontend/src/test/Leaderboard.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<Leaderboard />);
expect(screen.getByText('Leaderboard')).toBeInTheDocument();
});

it('shows loading skeleton when eventsLoading is true', () => {
useTipContext.mockReturnValue(defaultContext({ eventsLoading: true }));
const { container } = render(<Leaderboard />);
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(<Leaderboard />);
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(<Leaderboard />);
expect(screen.getByText('Top Senders')).toBeInTheDocument();
expect(screen.getByText('Top Receivers')).toBeInTheDocument();
});

it('switches between tabs', () => {
render(<Leaderboard />);
fireEvent.click(screen.getByText('Top Receivers'));
const items = screen.getAllByText(/tips received/);
expect(items.length).toBeGreaterThan(0);
});

it('shows the Refresh button', () => {
render(<Leaderboard />);
expect(screen.getByLabelText('Refresh leaderboard')).toBeInTheDocument();
});

it('calls refreshEvents when Refresh is clicked', () => {
const refreshEvents = vi.fn();
useTipContext.mockReturnValue(defaultContext({ refreshEvents }));
render(<Leaderboard />);
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(<Leaderboard />);
const btn = screen.getByLabelText('Refresh leaderboard');
expect(btn).toBeInTheDocument();
expect(btn.textContent).toBe('Refresh');
});

it('shows Refresh text when not refreshing', () => {
render(<Leaderboard />);
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(<Leaderboard />);
const expected = ts.toLocaleTimeString();
expect(screen.getByText(expected)).toBeInTheDocument();
});

it('shows empty state when no events', () => {
useTipContext.mockReturnValue(defaultContext({ events: [] }));
render(<Leaderboard />);
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(<Leaderboard />);
expect(screen.getByLabelText('Load more events for accurate rankings')).toBeInTheDocument();
});

it('hides Load More button when hasMore is false', () => {
render(<Leaderboard />);
expect(screen.queryByLabelText('Load more events for accurate rankings')).not.toBeInTheDocument();
});

it('displays ranked users with STX amounts', () => {
render(<Leaderboard />);
expect(screen.getByText(/7\.00 STX/)).toBeInTheDocument();
});

it('ranks senders by total sent descending', () => {
render(<Leaderboard />);
// 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(<Leaderboard />);
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(<Leaderboard />);
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(<Leaderboard />);
// 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(<Leaderboard />);
expect(screen.getByText(/Total:/)).toBeInTheDocument();
});

it('shows user count in footer text', () => {
render(<Leaderboard />);
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(<Leaderboard />);
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(<Leaderboard />);
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(<Leaderboard />);
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(<Leaderboard />);
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(<Leaderboard />);
expect(screen.getByText(/Showing top 20/)).toBeInTheDocument();
});

it('renders five skeleton rows during loading', () => {
useTipContext.mockReturnValue(defaultContext({ eventsLoading: true }));
const { container } = render(<Leaderboard />);
const rows = container.querySelectorAll('.h-14');
expect(rows.length).toBe(5);
});

it('renders copy buttons for user addresses', () => {
render(<Leaderboard />);
const copyButtons = screen.getAllByTitle('Copy to clipboard');
expect(copyButtons.length).toBeGreaterThan(0);
});

it('shows tip count next to ranked users', () => {
render(<Leaderboard />);
const tipCounts = screen.getAllByText(/tips sent/);
expect(tipCounts.length).toBeGreaterThan(0);
});
});
Loading
Loading