Skip to content

Commit cd3c5bb

Browse files
authored
Merge pull request #649 from omarima-10/test/sell-flow-formatxlm-rejections-docs
test/docs: sell-flow E2E, bigint formatXlm coverage, rejection logging, testing conventions
2 parents f0f0e02 + 0b4880e commit cd3c5bb

8 files changed

Lines changed: 752 additions & 3 deletions

File tree

docs/testing-conventions.md

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Testing Conventions
2+
3+
How tests are structured in this repo, how to mock the seams (React Query,
4+
wallet, browser APIs), and how to set up an integration test. For
5+
util-specific guidance see the [Utils Testing Guide](./utils-testing-guide.md);
6+
for what hooks should do on failure paths (and therefore what your tests
7+
should assert), see [Error Handling in Hooks](./error-handling-in-hooks.md).
8+
9+
The runner is **Vitest** (`vitest.config.ts`: jsdom environment, globals
10+
enabled, setup in `src/test/setup.ts`). Run everything with `pnpm test`, or a
11+
single file with `pnpm test <path>`.
12+
13+
## File naming and co-location
14+
15+
Tests live in a `__tests__/` folder next to the code they exercise:
16+
17+
```
18+
src/hooks/
19+
├─ useFormatXlm.ts
20+
└─ __tests__/
21+
└─ useFormatXlm.test.ts
22+
src/pages/
23+
├─ LandingPage.tsx
24+
└─ __tests__/
25+
├─ LandingPage.holdings.test.tsx ← unit-ish page test
26+
└─ LandingPage.sellFlow.integration.test.tsx ← integration test
27+
```
28+
29+
- **Unit tests**: `<name>.test.ts` / `<name>.test.tsx`.
30+
- **Integration tests**: `<Page>.<feature>.integration.test.tsx` — one flow
31+
per file, named after the feature under test. Components may also co-locate
32+
a test directly beside the file (e.g.
33+
`src/components/common/__tests__/TradeDialog.clamp.integration.test.tsx`).
34+
- Reference the issue number in the top-level `describe` when the test
35+
exists to lock in an issue's acceptance criteria, e.g.
36+
`describe('LandingPage sell flow end-to-end (#644)', …)`.
37+
38+
## Mocking React Query responses
39+
40+
There are two established patterns — pick based on what the test is about.
41+
42+
**1. Mock the service, keep React Query real** (preferred for integration
43+
tests — caching, invalidation and optimistic updates stay honest):
44+
45+
```tsx
46+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
47+
import { courseService } from '@/services/course.service';
48+
49+
vi.mock('@/services/course.service', () => ({
50+
courseService: { getCourses: vi.fn() },
51+
}));
52+
const mockGetCourses = vi.mocked(courseService.getCourses);
53+
54+
const renderPage = () =>
55+
render(
56+
<QueryClientProvider
57+
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
58+
>
59+
<MemoryRouter>
60+
<LandingPage />
61+
</MemoryRouter>
62+
</QueryClientProvider>
63+
);
64+
65+
// in the test:
66+
mockGetCourses.mockResolvedValue([…fixtures…]);
67+
```
68+
69+
Always create a **fresh `QueryClient` per render** (never share one between
70+
tests — cached data leaks across cases) and disable retries so failure-path
71+
tests don't wait on backoff.
72+
73+
**2. Mock the hook module wholesale** (for unit tests where query machinery
74+
is noise):
75+
76+
```tsx
77+
vi.mock('@/hooks/useWallet', () => ({
78+
useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }),
79+
useWalletHoldings: () => ({ data: [] }),
80+
}));
81+
```
82+
83+
Anything rendering a component that calls `useQuery`/`useMutation` **must**
84+
be wrapped in a `QueryClientProvider` unless every such hook is mocked out —
85+
a missing provider fails with `No QueryClient set`.
86+
87+
## Mocking wallet connection state
88+
89+
Wallet state flows through the hooks in `src/hooks/useWallet.ts`
90+
(`useWalletHoldings`, `useWalletActivity`, `useTradeMutation`). Component
91+
tests mock at that seam:
92+
93+
```tsx
94+
vi.mock('@/hooks/useWallet', () => ({
95+
// "connected wallet holding 2 keys of creator-a"
96+
useWalletHoldings: () => ({
97+
data: [{ creatorId: 'creator-a', quantity: 2, priceStroops: 500_000, price: 0.05, pending: false }],
98+
}),
99+
useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }),
100+
}));
101+
```
102+
103+
For full-flow tests, prefer **not** mocking `useWallet` at all: the demo
104+
wallet seeds the featured creator with 3 held keys, and the real
105+
`useTradeMutation` exercises the optimistic-update and invalidation paths
106+
(see `LandingPage.sellFlow.integration.test.tsx`). Trade submissions resolve
107+
on real timers (~1.2s), so assert with
108+
`waitFor(…, { timeout: 5000 })` rather than fake timers.
109+
110+
## Integration test setup
111+
112+
The standard shell for a page-level integration test:
113+
114+
1. **Providers**: wrap in `QueryClientProvider` (fresh client) and
115+
`MemoryRouter` — pages use react-router hooks.
116+
2. **Service mocks**: `vi.mock('@/services/course.service')` and resolve
117+
fixture data per test.
118+
3. **Toast sink**: mock `@/utils/toast.util` and assert on
119+
`showToast.success` / `error` / `transactionSuccess` calls instead of
120+
scraping toast DOM (no `<Toaster/>` is mounted in tests).
121+
4. **Presentation mocks** (copy from an existing integration test):
122+
`framer-motion` (pass-through elements), `@/components/common/CreatorCard`
123+
(lightweight article), `StellarConnectionQualityBadge`,
124+
`FeaturedCreatorAudienceChip`, and network/staleness hooks
125+
(`useNetworkMismatch`, `useStaleData`) pinned to healthy values.
126+
5. **Browser API stubs**, in `beforeEach`:
127+
- `matchMedia` — jsdom doesn't implement it; use the `mockMatchMedia`
128+
helper pattern found in the page tests.
129+
- `localStorage` / `sessionStorage` — newer Node versions (v22+
130+
WebStorage, default in v25) shadow jsdom's storage with a global that
131+
has no working methods, so `window.localStorage.clear()` throws. New
132+
suites should install an in-memory stub (see `installStorageStub` in
133+
`LandingPage.sellFlow.integration.test.tsx`) instead of touching the
134+
global directly.
135+
6. **Cleanup**: `afterEach(cleanup)` — automatic unmount is not enabled.
136+
137+
## Available test utilities
138+
139+
There is deliberately no shared custom `render` yet; each suite composes its
140+
own providers. The reusable pieces to copy today:
141+
142+
| Utility | Where | What it does |
143+
|---|---|---|
144+
| `src/test/setup.ts` | global setup | registers `@testing-library/jest-dom` matchers |
145+
| `mockMatchMedia()` | page test files | stubs `window.matchMedia` for jsdom |
146+
| `installStorageStub()` | `LandingPage.sellFlow.integration.test.tsx` | Node-version-proof localStorage/sessionStorage stub |
147+
| `makeQueryClient()` | `LandingPage.sort.integration.test.tsx` | fresh `QueryClient` with retries disabled |
148+
| `confirmTrade(side, amount)` | `LandingPage.holdingsSellBalanceUpdate.integration.test.tsx` | drives the trade dialog: open → amount → confirm |
149+
| `dispatchRejection(reason)` | `unhandledRejectionLogger.test.ts` | synthesizes an unhandled-rejection event |
150+
151+
If you find yourself copying more than two of these into a new file, that is
152+
the signal to promote them into `src/test/` as shared utilities — do it in
153+
the same PR.
154+
155+
## What good assertions look like here
156+
157+
- Assert **user-visible outcomes** (rendered text, toast calls, holdings
158+
rows), not internal state.
159+
- For flows with optimistic updates, assert both the intermediate state
160+
(pending) and the settled state where practical.
161+
- Error paths deserve their own tests — see
162+
[Error Handling in Hooks](./error-handling-in-hooks.md) for the expected
163+
failure behaviour to pin down.

src/hooks/__tests__/useFormatXlm.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,65 @@ describe('useFormatXlm', () => {
101101
expect(result.current.format(10_000_000, { decimals: 0 })).toBe('1');
102102
});
103103
});
104+
describe('bigint inputs (#645)', () => {
105+
it('formats a safe-range bigint identically to the equivalent number', () => {
106+
expect(formatXlm(15_000_000n)).toBe(formatXlm(15_000_000));
107+
expect(formatXlm(500_000n)).toBe(formatXlm(500_000));
108+
expect(formatXlm(70_000_000_000n)).toBe(formatXlm(70_000_000_000));
109+
});
110+
111+
it('respects the decimals option for bigint inputs', () => {
112+
expect(formatXlm(10_000_000n, { decimals: 0 })).toBe(
113+
formatXlm(10_000_000, { decimals: 0 })
114+
);
115+
expect(formatXlm(15_000_000n, { decimals: 7 })).toBe(
116+
formatXlm(15_000_000, { decimals: 7 })
117+
);
118+
});
119+
120+
it('formats a bigint above Number.MAX_SAFE_INTEGER without precision loss', () => {
121+
// 9_007_199_254_740_993 is MAX_SAFE_INTEGER + 2; as a number it
122+
// silently rounds to ...992, so the final displayed digit proves
123+
// whether the bigint path avoided float conversion.
124+
const stroops = 9_007_199_254_740_993n;
125+
const result = formatXlm(stroops, { decimals: 7 });
126+
127+
const expectedWhole = new Intl.NumberFormat(undefined, {
128+
useGrouping: true,
129+
}).format(900_719_925n);
130+
expect(result.startsWith(expectedWhole)).toBe(true);
131+
expect(result.endsWith('4740993')).toBe(true);
132+
});
133+
134+
it('never renders scientific notation for very large bigints', () => {
135+
const result = formatXlm(123_456_789_012_345_678_901_234_567_890n);
136+
expect(result).not.toMatch(/e/i);
137+
});
138+
139+
it('keeps every digit of a very large bigint', () => {
140+
// 12_345_678_901_234_567_890 stroops = 1_234_567_890_123.4567890 XLM
141+
const result = formatXlm(12_345_678_901_234_567_890n, { decimals: 7 });
142+
const digitsOnly = result.replace(/[^0-9]/g, '');
143+
expect(digitsOnly).toBe('12345678901234567890');
144+
});
145+
146+
it('formats 0n as 0.00', () => {
147+
expect(formatXlm(0n)).toBe('0.00');
148+
});
149+
150+
it('formats a negative bigint as a negative formatted string', () => {
151+
expect(formatXlm(-15_000_000n)).toBe(`-${formatXlm(15_000_000n)}`);
152+
expect(formatXlm(-15_000_000n)).toBe(formatXlm(-15_000_000));
153+
});
154+
155+
it('does not emit a negative sign when a negative amount rounds to zero', () => {
156+
// -1 stroop rounds to 0.00 at 2 decimals — "-0.00" would be wrong
157+
expect(formatXlm(-1n)).toBe('0.00');
158+
});
159+
160+
it('hook format function accepts bigint inputs', () => {
161+
const { result } = renderHook(() => useFormatXlm());
162+
expect(result.current.format(15_000_000n)).toBe(formatXlm(15_000_000));
163+
});
164+
});
104165
});

src/hooks/useFormatXlm.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,74 @@ export interface FormatXlmOptions {
55
decimals?: number;
66
}
77

8+
/**
9+
* Formats a bigint stroop amount without ever passing through `number`,
10+
* so values beyond Number.MAX_SAFE_INTEGER keep every digit. The whole-XLM
11+
* part is formatted by Intl (which accepts bigint natively) for locale
12+
* grouping; the fractional digits are computed with integer arithmetic and
13+
* joined with the locale's decimal separator so output matches the number
14+
* path in any locale.
15+
*/
16+
function formatBigintXlm(stroops: bigint, decimals: number): string {
17+
const negative = stroops < 0n;
18+
const abs = negative ? -stroops : stroops;
19+
const stroopsPerXlm = BigInt(STROOPS_PER_XLM);
20+
const scale = 10n ** BigInt(decimals);
21+
22+
// Round half up on the last displayed digit, mirroring Intl's rounding
23+
const scaled = (abs * scale + stroopsPerXlm / 2n) / stroopsPerXlm;
24+
const whole = scaled / scale;
25+
const fraction = scaled % scale;
26+
27+
const wholeStr = new Intl.NumberFormat(undefined, {
28+
useGrouping: true,
29+
}).format(whole);
30+
31+
const sign = negative && scaled !== 0n ? '-' : '';
32+
33+
if (decimals === 0) {
34+
return `${sign}${wholeStr}`;
35+
}
36+
37+
const decimalSeparator =
38+
new Intl.NumberFormat(undefined, { minimumFractionDigits: 1 })
39+
.formatToParts(1.1)
40+
.find(part => part.type === 'decimal')?.value ?? '.';
41+
42+
const fractionStr = fraction.toString().padStart(decimals, '0');
43+
44+
return `${sign}${wholeStr}${decimalSeparator}${fractionStr}`;
45+
}
46+
847
/**
948
* Converts a stroop amount to a formatted XLM string.
1049
*
50+
* Accepts both `number` and `bigint` stroops. Bigint inputs are formatted
51+
* with integer arithmetic end to end, so amounts above
52+
* `Number.MAX_SAFE_INTEGER` render with full precision and never fall back
53+
* to scientific notation. Negative amounts (either type) format with a
54+
* leading minus sign.
55+
*
1156
* @param stroops - Amount in stroops (1 XLM = 10,000,000 stroops)
1257
* @param options - Formatting options
1358
* @returns Formatted XLM string, e.g. "1.50" for 15,000,000 stroops
1459
*
1560
* @example
1661
* formatXlm(10_000_000) // "1.00"
62+
* formatXlm(10_000_000n) // "1.00"
1763
* formatXlm(10_000_000, { decimals: 0 }) // "1"
1864
* formatXlm(15_000_000, { decimals: 7 }) // "1.5000000"
1965
*/
2066
export function formatXlm(
21-
stroops: number,
67+
stroops: number | bigint,
2268
options: FormatXlmOptions = {}
2369
): string {
2470
const { decimals = 2 } = options;
71+
72+
if (typeof stroops === 'bigint') {
73+
return formatBigintXlm(stroops, decimals);
74+
}
75+
2576
const xlm = stroops / STROOPS_PER_XLM;
2677

2778
return new Intl.NumberFormat(undefined, {

src/main.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { StrictMode } from 'react';
22
import { createRoot } from 'react-dom/client';
33
import './index.css';
44
import App from './App.tsx';
5+
import { registerUnhandledRejectionLogger } from './utils/unhandledRejectionLogger';
6+
7+
registerUnhandledRejectionLogger();
58

69
createRoot(document.getElementById('root')!).render(
710
<StrictMode>

src/pages/LandingPage.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ const FEATURED_CREATOR_FOLLOWER_COUNT: number | null = null;
8787
const FEATURED_CREATOR_KEY_HOLDER_COUNT = 0;
8888
const FEATURED_CREATOR_STELLAR_ADDRESS =
8989
'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5';
90+
const FEATURED_CREATOR_NAME = 'Alex Rivers';
9091

9192
// Fallback demo data in case API fails
9293
const DEMO_CREATORS: Course[] = [
@@ -817,7 +818,7 @@ function LandingPage() {
817818
await new Promise<void>(resolve => window.setTimeout(resolve, 250));
818819
showToast.transactionSuccess(
819820
'Trade confirmed',
820-
`Holdings refreshed: -${formatNumber(amount)} keys.`
821+
`Sold ${formatNumber(amount)} key${amount === 1 ? '' : 's'} from ${FEATURED_CREATOR_NAME}`
821822
);
822823
}
823824
setTradeDialogOpen(false);
@@ -1730,7 +1731,7 @@ function LandingPage() {
17301731
<TradeDialog
17311732
open={tradeDialogOpen}
17321733
side={tradeSide}
1733-
creatorName="Alex Rivers"
1734+
creatorName={FEATURED_CREATOR_NAME}
17341735
availableHoldings={featuredHoldings}
17351736
keyPriceStroops={resolveCreatorKeyPriceStroops(featuredCreator)}
17361737
isSubmitting={tradeSubmitting}

0 commit comments

Comments
 (0)