Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
170 changes: 170 additions & 0 deletions src/__tests__/TransactionHistory.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import './__mocks__/rn-modules';
import React from 'react';
import renderer, { act } from 'react-test-renderer';
import { Text, TouchableOpacity } from 'react-native';
import TransactionHistory from '../components/TransactionHistory';
import { useWalletStore } from '../store/walletStore';
import { StellarPayment } from '../services/stellar';

jest.mock('../../services/stellar', () => ({
...jest.requireActual('../../services/stellar'),
getPayments: jest.fn(),
}));

const { getPayments } = require('../../services/stellar') as {
getPayments: jest.MockedFunction<(publicKey: string, limit?: number) => Promise<StellarPayment[]>>;
};

function payment(id: string, amount = '10'): StellarPayment {
return {
id,
type: 'payment',
amount,
asset_type: 'native',
from: 'GSOURCE',
to: 'GDEST',
created_at: new Date().toISOString(),
};
}

function textValues(tree: renderer.ReactTestRenderer): string[] {
return tree.root
.findAllByType(Text)
.flatMap(node =>
(Array.isArray(node.props.children)
? node.props.children
: [node.props.children]
).filter((child: unknown): child is string => typeof child === 'string'),
);
}

function buttonWithText(
tree: renderer.ReactTestRenderer,
label: string,
): renderer.ReactTestInstance {
const button = tree.root
.findAllByType(TouchableOpacity)
.find(node =>
node.findAllByType(Text).some(text => text.props.children === label),
);
if (!button) {
throw new Error(`Could not find a button labelled "${label}"`);
}
return button;
}

beforeEach(() => {
useWalletStore.setState({
publicKey: 'GABC',
payments: null,
paymentsLastFetchedAt: null,
isConnected: true,
status: 'connected',
connectError: null,
balance: '100',
ecoBalance: '10',
usdcBalance: '5',
walletType: 'inapp',
});
getPayments.mockReset();
});

describe('TransactionHistory cache behavior', () => {
it('renders skeleton when cache is empty and fetches once', async () => {
getPayments.mockResolvedValueOnce([payment('1')]);

let tree: renderer.ReactTestRenderer;
await act(async () => {
tree = renderer.create(<TransactionHistory publicKey="GABC" />);
});

expect(getPayments).toHaveBeenCalledTimes(1);
expect(textValues(tree!).toContain('Recent Transactions'));
expect(textValues(tree!).toContain('1.00');

Check failure on line 83 in src/__tests__/TransactionHistory.test.tsx

View workflow job for this annotation

GitHub Actions / typecheck

')' expected.
});

it('does not fetch again within the cache TTL on remount', async () => {
getPayments.mockResolvedValueOnce([payment('1')]);

let tree: renderer.ReactTestRenderer;
await act(async () => {
tree = renderer.create(<TransactionHistory publicKey="GABC" />);
});
expect(getPayments).toHaveBeenCalledTimes(1);

await act(async () => {
tree!.unmount();
});

await act(async () => {
tree = renderer.create(<TransactionHistory publicKey="GABC" />);
});

expect(getPayments).toHaveBeenCalledTimes(1);
expect(textValues(tree!).toContain('1.00'));
});

it('fetches again after the cache TTL expires', async () => {
getPayments.mockResolvedValueOnce([payment('1')]);
getPayments.mockResolvedValueOnce([payment('2')]);

let tree: renderer.ReactTestRenderer;
await act(async () => {
tree = renderer.create(<TransactionHistory publicKey="GABC" />);
});
expect(getPayments).toHaveBeenCalledTimes(1);

await act(async () => {
tree!.unmount();
});

useWalletStore.setState({
paymentsLastFetchedAt: Date.now() - 60_001,
});

await act(async () => {
tree = renderer.create(<TransactionHistory publicKey="GABC" />);
});

expect(getPayments).toHaveBeenCalledTimes(2);
expect(textValues(tree!).toContain('2.00'));
});

it('manual refresh triggers a fresh call even within TTL', async () => {
getPayments.mockResolvedValueOnce([payment('1')]);
getPayments.mockResolvedValueOnce([payment('2')]);

let tree: renderer.ReactTestRenderer;
await act(async () => {
tree = renderer.create(<TransactionHistory publicKey="GABC" />);
});
expect(getPayments).toHaveBeenCalledTimes(1);

await act(async () => {
buttonWithText(tree!, 'Refresh').props.onPress();
});

expect(getPayments).toHaveBeenCalledTimes(2);
expect(textValues(tree!).toContain('2.00'));
});

it('shows error and retry when fetch fails', async () => {
getPayments.mockRejectedValueOnce(new Error('Network down'));

let tree: renderer.ReactTestRenderer;
await act(async () => {
tree = renderer.create(<TransactionHistory publicKey="GABC" />);
});

expect(textValues(tree!)).toContain('Failed to load transaction history');
expect(textValues(tree!)).toContain('Retry');

getPayments.mockResolvedValueOnce([payment('1')]);
await act(async () => {
buttonWithText(tree!, 'Retry').props.onPress();
});

expect(getPayments).toHaveBeenCalledTimes(2);
expect(textValues(tree!)).toContain('1.00');
});
});
141 changes: 141 additions & 0 deletions src/__tests__/paymentsCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import './__mocks__/rn-modules';
import { Horizon } from '@stellar/stellar-sdk';
import { getPayments, StellarPayment } from '../services/stellar';

Check failure on line 3 in src/__tests__/paymentsCache.test.ts

View workflow job for this annotation

GitHub Actions / lint

'getPayments' is defined but never used
import { useWalletStore, PAYMENTS_CACHE_TTL_MS } from '../store/walletStore';

type PaymentsCall = Horizon.Server['payments'];

const paymentsSpy = jest.spyOn(Horizon.Server.prototype, 'payments');

function mockPayments(records: Partial<StellarPayment>[]) {
const callMock = jest.fn().mockResolvedValue({
records: records.map(r => ({
id: r.id ?? 'id',
type: r.type ?? 'payment',
amount: r.amount ?? '1',
asset_type: r.asset_type ?? 'native',
asset_code: r.asset_code,
from: r.from ?? 'GSOURCE',
to: r.to ?? 'GDEST',
created_at: r.created_at ?? new Date().toISOString(),
})),
});
paymentsSpy.mockReturnValue({
forAccount: jest.fn().mockReturnValue({
order: jest.fn().mockReturnValue({
limit: jest.fn().mockReturnValue({
call: callMock,
}),
}),
}),
} as unknown as ReturnType<PaymentsCall>);
return callMock;
}

beforeEach(() => {
useWalletStore.setState({
payments: null,
paymentsLastFetchedAt: null,
publicKey: null,
isConnected: false,
status: 'disconnected',
connectError: null,
balance: null,
ecoBalance: null,
usdcBalance: null,
walletType: null,
});
paymentsSpy.mockReset();
});

afterAll(() => {
paymentsSpy.mockRestore();
});

describe('walletStore payments cache', () => {
it('fetches payments when cache is empty', async () => {
const callMock = mockPayments([
{ id: '1', amount: '10', from: 'GA', to: 'GB' },
]);

useWalletStore.getState().connect('GABC');
const { refreshPayments } = useWalletStore.getState();
const result = await refreshPayments();

expect(callMock).toHaveBeenCalledTimes(1);
expect(result).toHaveLength(1);
expect(result?.[0]!.id).toBe('1');
expect(useWalletStore.getState().payments).toHaveLength(1);
expect(useWalletStore.getState().paymentsLastFetchedAt).not.toBeNull();
});

it('does not call Horizon again within the TTL', async () => {
const callMock = mockPayments([{ id: '1', amount: '10' }]);

useWalletStore.getState().connect('GABC');
const { refreshPayments } = useWalletStore.getState();

await refreshPayments();
expect(callMock).toHaveBeenCalledTimes(1);

await refreshPayments();
expect(callMock).toHaveBeenCalledTimes(1);
});

it('calls Horizon again after the TTL expires', async () => {
const firstCall = mockPayments([{ id: '1', amount: '10' }]);
mockPayments([{ id: '2', amount: '20' }]);

useWalletStore.getState().connect('GABC');
const { refreshPayments } = useWalletStore.getState();

await refreshPayments();
expect(firstCall).toHaveBeenCalledTimes(1);

// Advance past the TTL
useWalletStore.setState({
paymentsLastFetchedAt: Date.now() - PAYMENTS_CACHE_TTL_MS - 1000,
});

await refreshPayments();
expect(firstCall).toHaveBeenCalledTimes(2);
});

it('preserves existing cache on network failure', async () => {
const callMock = mockPayments([{ id: '1', amount: '10' }]);

Check failure on line 105 in src/__tests__/paymentsCache.test.ts

View workflow job for this annotation

GitHub Actions / lint

'callMock' is assigned a value but never used
useWalletStore.getState().connect('GABC');

const { refreshPayments } = useWalletStore.getState();
await refreshPayments();
expect(useWalletStore.getState().payments).toHaveLength(1);

paymentsSpy.mockReset();
paymentsSpy.mockReturnValue({
forAccount: jest.fn().mockReturnValue({
order: jest.fn().mockReturnValue({
limit: jest.fn().mockReturnValue({
call: jest.fn().mockRejectedValue(new Error('Network down')),
}),
}),
}),
} as unknown as ReturnType<PaymentsCall>);

const result = await refreshPayments();
expect(result).toHaveLength(1);
expect(useWalletStore.getState().payments).toHaveLength(1);
expect(useWalletStore.getState().paymentsLastFetchedAt).not.toBeNull();
});

it('clears cache timestamp after setPayments', async () => {
useWalletStore.getState().connect('GABC');
const { setPayments } = useWalletStore.getState();

const before = useWalletStore.getState().paymentsLastFetchedAt;
setPayments([{ id: '1', amount: '5', type: 'payment', from: 'GA', to: 'GB', created_at: new Date().toISOString() }]);

Check failure on line 134 in src/__tests__/paymentsCache.test.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `{·id:·'1',·amount:·'5',·type:·'payment',·from:·'GA',·to:·'GB',·created_at:·new·Date().toISOString()·}` with `⏎······{⏎········id:·'1',⏎········amount:·'5',⏎········type:·'payment',⏎········from:·'GA',⏎········to:·'GB',⏎········created_at:·new·Date().toISOString(),⏎······},⏎····`
const after = useWalletStore.getState().paymentsLastFetchedAt;

expect(before).toBeNull();
expect(after).not.toBeNull();
expect(after).toBeGreaterThanOrEqual(before ?? 0);
});
});
Loading
Loading