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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions docs/testing-conventions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Testing Conventions

How tests are structured in this repo, how to mock the seams (React Query,
wallet, browser APIs), and how to set up an integration test. For
util-specific guidance see the [Utils Testing Guide](./utils-testing-guide.md);
for what hooks should do on failure paths (and therefore what your tests
should assert), see [Error Handling in Hooks](./error-handling-in-hooks.md).

The runner is **Vitest** (`vitest.config.ts`: jsdom environment, globals
enabled, setup in `src/test/setup.ts`). Run everything with `pnpm test`, or a
single file with `pnpm test <path>`.

## File naming and co-location

Tests live in a `__tests__/` folder next to the code they exercise:

```
src/hooks/
├─ useFormatXlm.ts
└─ __tests__/
└─ useFormatXlm.test.ts
src/pages/
├─ LandingPage.tsx
└─ __tests__/
├─ LandingPage.holdings.test.tsx ← unit-ish page test
└─ LandingPage.sellFlow.integration.test.tsx ← integration test
```

- **Unit tests**: `<name>.test.ts` / `<name>.test.tsx`.
- **Integration tests**: `<Page>.<feature>.integration.test.tsx` — one flow
per file, named after the feature under test. Components may also co-locate
a test directly beside the file (e.g.
`src/components/common/__tests__/TradeDialog.clamp.integration.test.tsx`).
- Reference the issue number in the top-level `describe` when the test
exists to lock in an issue's acceptance criteria, e.g.
`describe('LandingPage sell flow end-to-end (#644)', …)`.

## Mocking React Query responses

There are two established patterns — pick based on what the test is about.

**1. Mock the service, keep React Query real** (preferred for integration
tests — caching, invalidation and optimistic updates stay honest):

```tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { courseService } from '@/services/course.service';

vi.mock('@/services/course.service', () => ({
courseService: { getCourses: vi.fn() },
}));
const mockGetCourses = vi.mocked(courseService.getCourses);

const renderPage = () =>
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<MemoryRouter>
<LandingPage />
</MemoryRouter>
</QueryClientProvider>
);

// in the test:
mockGetCourses.mockResolvedValue([…fixtures…]);
```

Always create a **fresh `QueryClient` per render** (never share one between
tests — cached data leaks across cases) and disable retries so failure-path
tests don't wait on backoff.

**2. Mock the hook module wholesale** (for unit tests where query machinery
is noise):

```tsx
vi.mock('@/hooks/useWallet', () => ({
useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }),
useWalletHoldings: () => ({ data: [] }),
}));
```

Anything rendering a component that calls `useQuery`/`useMutation` **must**
be wrapped in a `QueryClientProvider` unless every such hook is mocked out —
a missing provider fails with `No QueryClient set`.

## Mocking wallet connection state

Wallet state flows through the hooks in `src/hooks/useWallet.ts`
(`useWalletHoldings`, `useWalletActivity`, `useTradeMutation`). Component
tests mock at that seam:

```tsx
vi.mock('@/hooks/useWallet', () => ({
// "connected wallet holding 2 keys of creator-a"
useWalletHoldings: () => ({
data: [{ creatorId: 'creator-a', quantity: 2, priceStroops: 500_000, price: 0.05, pending: false }],
}),
useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
```

For full-flow tests, prefer **not** mocking `useWallet` at all: the demo
wallet seeds the featured creator with 3 held keys, and the real
`useTradeMutation` exercises the optimistic-update and invalidation paths
(see `LandingPage.sellFlow.integration.test.tsx`). Trade submissions resolve
on real timers (~1.2s), so assert with
`waitFor(…, { timeout: 5000 })` rather than fake timers.

## Integration test setup

The standard shell for a page-level integration test:

1. **Providers**: wrap in `QueryClientProvider` (fresh client) and
`MemoryRouter` — pages use react-router hooks.
2. **Service mocks**: `vi.mock('@/services/course.service')` and resolve
fixture data per test.
3. **Toast sink**: mock `@/utils/toast.util` and assert on
`showToast.success` / `error` / `transactionSuccess` calls instead of
scraping toast DOM (no `<Toaster/>` is mounted in tests).
4. **Presentation mocks** (copy from an existing integration test):
`framer-motion` (pass-through elements), `@/components/common/CreatorCard`
(lightweight article), `StellarConnectionQualityBadge`,
`FeaturedCreatorAudienceChip`, and network/staleness hooks
(`useNetworkMismatch`, `useStaleData`) pinned to healthy values.
5. **Browser API stubs**, in `beforeEach`:
- `matchMedia` — jsdom doesn't implement it; use the `mockMatchMedia`
helper pattern found in the page tests.
- `localStorage` / `sessionStorage` — newer Node versions (v22+
WebStorage, default in v25) shadow jsdom's storage with a global that
has no working methods, so `window.localStorage.clear()` throws. New
suites should install an in-memory stub (see `installStorageStub` in
`LandingPage.sellFlow.integration.test.tsx`) instead of touching the
global directly.
6. **Cleanup**: `afterEach(cleanup)` — automatic unmount is not enabled.

## Available test utilities

There is deliberately no shared custom `render` yet; each suite composes its
own providers. The reusable pieces to copy today:

| Utility | Where | What it does |
|---|---|---|
| `src/test/setup.ts` | global setup | registers `@testing-library/jest-dom` matchers |
| `mockMatchMedia()` | page test files | stubs `window.matchMedia` for jsdom |
| `installStorageStub()` | `LandingPage.sellFlow.integration.test.tsx` | Node-version-proof localStorage/sessionStorage stub |
| `makeQueryClient()` | `LandingPage.sort.integration.test.tsx` | fresh `QueryClient` with retries disabled |
| `confirmTrade(side, amount)` | `LandingPage.holdingsSellBalanceUpdate.integration.test.tsx` | drives the trade dialog: open → amount → confirm |
| `dispatchRejection(reason)` | `unhandledRejectionLogger.test.ts` | synthesizes an unhandled-rejection event |

If you find yourself copying more than two of these into a new file, that is
the signal to promote them into `src/test/` as shared utilities — do it in
the same PR.

## What good assertions look like here

- Assert **user-visible outcomes** (rendered text, toast calls, holdings
rows), not internal state.
- For flows with optimistic updates, assert both the intermediate state
(pending) and the settled state where practical.
- Error paths deserve their own tests — see
[Error Handling in Hooks](./error-handling-in-hooks.md) for the expected
failure behaviour to pin down.
61 changes: 61 additions & 0 deletions src/hooks/__tests__/useFormatXlm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,65 @@ describe('useFormatXlm', () => {
expect(result.current.format(10_000_000, { decimals: 0 })).toBe('1');
});
});
describe('bigint inputs (#645)', () => {
it('formats a safe-range bigint identically to the equivalent number', () => {
expect(formatXlm(15_000_000n)).toBe(formatXlm(15_000_000));
expect(formatXlm(500_000n)).toBe(formatXlm(500_000));
expect(formatXlm(70_000_000_000n)).toBe(formatXlm(70_000_000_000));
});

it('respects the decimals option for bigint inputs', () => {
expect(formatXlm(10_000_000n, { decimals: 0 })).toBe(
formatXlm(10_000_000, { decimals: 0 })
);
expect(formatXlm(15_000_000n, { decimals: 7 })).toBe(
formatXlm(15_000_000, { decimals: 7 })
);
});

it('formats a bigint above Number.MAX_SAFE_INTEGER without precision loss', () => {
// 9_007_199_254_740_993 is MAX_SAFE_INTEGER + 2; as a number it
// silently rounds to ...992, so the final displayed digit proves
// whether the bigint path avoided float conversion.
const stroops = 9_007_199_254_740_993n;
const result = formatXlm(stroops, { decimals: 7 });

const expectedWhole = new Intl.NumberFormat(undefined, {
useGrouping: true,
}).format(900_719_925n);
expect(result.startsWith(expectedWhole)).toBe(true);
expect(result.endsWith('4740993')).toBe(true);
});

it('never renders scientific notation for very large bigints', () => {
const result = formatXlm(123_456_789_012_345_678_901_234_567_890n);
expect(result).not.toMatch(/e/i);
});

it('keeps every digit of a very large bigint', () => {
// 12_345_678_901_234_567_890 stroops = 1_234_567_890_123.4567890 XLM
const result = formatXlm(12_345_678_901_234_567_890n, { decimals: 7 });
const digitsOnly = result.replace(/[^0-9]/g, '');
expect(digitsOnly).toBe('12345678901234567890');
});

it('formats 0n as 0.00', () => {
expect(formatXlm(0n)).toBe('0.00');
});

it('formats a negative bigint as a negative formatted string', () => {
expect(formatXlm(-15_000_000n)).toBe(`-${formatXlm(15_000_000n)}`);
expect(formatXlm(-15_000_000n)).toBe(formatXlm(-15_000_000));
});

it('does not emit a negative sign when a negative amount rounds to zero', () => {
// -1 stroop rounds to 0.00 at 2 decimals — "-0.00" would be wrong
expect(formatXlm(-1n)).toBe('0.00');
});

it('hook format function accepts bigint inputs', () => {
const { result } = renderHook(() => useFormatXlm());
expect(result.current.format(15_000_000n)).toBe(formatXlm(15_000_000));
});
});
});
53 changes: 52 additions & 1 deletion src/hooks/useFormatXlm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,74 @@ export interface FormatXlmOptions {
decimals?: number;
}

/**
* Formats a bigint stroop amount without ever passing through `number`,
* so values beyond Number.MAX_SAFE_INTEGER keep every digit. The whole-XLM
* part is formatted by Intl (which accepts bigint natively) for locale
* grouping; the fractional digits are computed with integer arithmetic and
* joined with the locale's decimal separator so output matches the number
* path in any locale.
*/
function formatBigintXlm(stroops: bigint, decimals: number): string {
const negative = stroops < 0n;
const abs = negative ? -stroops : stroops;
const stroopsPerXlm = BigInt(STROOPS_PER_XLM);
const scale = 10n ** BigInt(decimals);

// Round half up on the last displayed digit, mirroring Intl's rounding
const scaled = (abs * scale + stroopsPerXlm / 2n) / stroopsPerXlm;
const whole = scaled / scale;
const fraction = scaled % scale;

const wholeStr = new Intl.NumberFormat(undefined, {
useGrouping: true,
}).format(whole);

const sign = negative && scaled !== 0n ? '-' : '';

if (decimals === 0) {
return `${sign}${wholeStr}`;
}

const decimalSeparator =
new Intl.NumberFormat(undefined, { minimumFractionDigits: 1 })
.formatToParts(1.1)
.find(part => part.type === 'decimal')?.value ?? '.';

const fractionStr = fraction.toString().padStart(decimals, '0');

return `${sign}${wholeStr}${decimalSeparator}${fractionStr}`;
}

/**
* Converts a stroop amount to a formatted XLM string.
*
* Accepts both `number` and `bigint` stroops. Bigint inputs are formatted
* with integer arithmetic end to end, so amounts above
* `Number.MAX_SAFE_INTEGER` render with full precision and never fall back
* to scientific notation. Negative amounts (either type) format with a
* leading minus sign.
*
* @param stroops - Amount in stroops (1 XLM = 10,000,000 stroops)
* @param options - Formatting options
* @returns Formatted XLM string, e.g. "1.50" for 15,000,000 stroops
*
* @example
* formatXlm(10_000_000) // "1.00"
* formatXlm(10_000_000n) // "1.00"
* formatXlm(10_000_000, { decimals: 0 }) // "1"
* formatXlm(15_000_000, { decimals: 7 }) // "1.5000000"
*/
export function formatXlm(
stroops: number,
stroops: number | bigint,
options: FormatXlmOptions = {}
): string {
const { decimals = 2 } = options;

if (typeof stroops === 'bigint') {
return formatBigintXlm(stroops, decimals);
}

const xlm = stroops / STROOPS_PER_XLM;

return new Intl.NumberFormat(undefined, {
Expand Down
3 changes: 3 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import { registerUnhandledRejectionLogger } from './utils/unhandledRejectionLogger';

registerUnhandledRejectionLogger();

createRoot(document.getElementById('root')!).render(
<StrictMode>
Expand Down
5 changes: 3 additions & 2 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const FEATURED_CREATOR_FOLLOWER_COUNT: number | null = null;
const FEATURED_CREATOR_KEY_HOLDER_COUNT = 0;
const FEATURED_CREATOR_STELLAR_ADDRESS =
'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5';
const FEATURED_CREATOR_NAME = 'Alex Rivers';

// Fallback demo data in case API fails
const DEMO_CREATORS: Course[] = [
Expand Down Expand Up @@ -817,7 +818,7 @@ function LandingPage() {
await new Promise<void>(resolve => window.setTimeout(resolve, 250));
showToast.transactionSuccess(
'Trade confirmed',
`Holdings refreshed: -${formatNumber(amount)} keys.`
`Sold ${formatNumber(amount)} key${amount === 1 ? '' : 's'} from ${FEATURED_CREATOR_NAME}`
);
}
setTradeDialogOpen(false);
Expand Down Expand Up @@ -1730,7 +1731,7 @@ function LandingPage() {
<TradeDialog
open={tradeDialogOpen}
side={tradeSide}
creatorName="Alex Rivers"
creatorName={FEATURED_CREATOR_NAME}
availableHoldings={featuredHoldings}
keyPriceStroops={resolveCreatorKeyPriceStroops(featuredCreator)}
isSubmitting={tradeSubmitting}
Expand Down
Loading
Loading