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
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
**/*.test.ts
**/*.test.tsx
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ jobs:

- name: Build
run: npm run build

- name: Test
run: npm run test
56 changes: 56 additions & 0 deletions __mocks__/@stellar/freighter-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// __mocks__/@stellar/freighter-api.ts

let mockIsConnected = false;
let mockIsAllowed = false;
let mockPublicKey = '';
let mockNetwork = {
network: 'Test SDF Network ; September 2015',
networkUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2015',
};

export const setMockConnected = (connected: boolean) => {
mockIsConnected = connected;
};

export const setMockAllowed = (allowed: boolean) => {
mockIsAllowed = allowed;
};

export const setMockPublicKey = (publicKey: string) => {
mockPublicKey = publicKey;
};

export const setMockNetwork = (network: any) => {
mockNetwork = network;
};

export const isConnected = jest.fn().mockImplementation(async () => mockIsConnected);

export const isAllowed = jest.fn().mockImplementation(async () => mockIsAllowed);

export const getUserInfo = jest.fn().mockImplementation(async () => {
if (!mockPublicKey) return { publicKey: '' };
return { publicKey: mockPublicKey };
});

export const getNetworkDetails = jest.fn().mockImplementation(async () => {
if (!mockNetwork) return null;
return mockNetwork;
});

export const signTransaction = jest.fn().mockImplementation(async (xdr: string, opts?: any) => {
return `${xdr}-signed-by-${opts?.accountToSign || 'unknown'}`;
});

export const resetFreighterMocks = () => {
mockIsConnected = false;
mockIsAllowed = false;
mockPublicKey = '';
mockNetwork = {
network: 'Test SDF Network ; September 2015',
networkUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2015',
};
jest.clearAllMocks();
};
79 changes: 79 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Testing Guide

This document outlines the testing patterns and infrastructure used in the TrustFlow frontend. We use Jest and React Testing Library for testing React components, hooks, and API endpoints.

## Mocking the Freighter Wallet

We heavily rely on the Freighter extension for wallet connections and transaction signing. Since the extension isn't available in a Node.js test environment, we mock the `@stellar/freighter-api` globally in our tests.

### Setup

The mock is located in `__mocks__/@stellar/freighter-api.ts` and is automatically picked up by Jest when you call:

```typescript
jest.mock('@stellar/freighter-api');
```

### Controlling the Mock State

The mocked API provides several helper methods to simulate different wallet states during your tests. These helpers are exposed on the mocked module itself:

```typescript
import * as FreighterApiMock from '@stellar/freighter-api';

const {
setMockConnected,
setMockAllowed,
setMockPublicKey,
setMockNetwork,
resetFreighterMocks,
} = FreighterApiMock as any;
```

Before each test, it is recommended to reset the mock state:

```typescript
beforeEach(() => {
resetFreighterMocks();
});
```

### Simulating Scenarios

**1. Connecting a Wallet**

```typescript
setMockConnected(true);
setMockAllowed(true);
setMockPublicKey('GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890');
setMockNetwork({
network: 'Test SDF Network ; September 2015',
networkUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2015',
});
```

**2. Simulating a Disconnected Wallet**

By default, or after calling `resetFreighterMocks()`, the wallet simulates a disconnected state.

## Mocking Soroban RPC

For testing interactions with the Stellar network and smart contracts, we use a utility mock for Soroban RPC.

### Setup

The Soroban mock utilities are located in `test-utils/soroban-rpc-mock.ts`. You can import and use them in your tests to simulate responses from the Soroban RPC server, such as ledger entries or transaction submissions.

This allows developers to write robust tests for heavily on-chain or wallet-dependent hooks and components without relying on a live network.

## Testing Best Practices

- **Avoid act() warnings**: When testing hooks or components with asynchronous state updates (like polling), ensure that you wait for promises to resolve and wrap timer advances in `act()` blocks. Example:
```typescript
await act(async () => {
await Promise.resolve(); // flush pending microtasks
jest.advanceTimersByTime(2000); // advance timers if using fake timers
});
```
- **Cleanup**: Always clear timers and reset mocks after each test to prevent state leakage between tests.
12 changes: 12 additions & 0 deletions fix-wallet-tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const fs = require('fs');

let content = fs.readFileSync('hooks/useWallet.test.ts', 'utf8');

// Replace all instances of `const { result } = renderHook(() => useWallet());`
// with the same line followed by `await act(async () => { await Promise.resolve(); });`
content = content.replace(
/const { result } = renderHook\(\(\) => useWallet\(\)\);/g,
`const { result } = renderHook(() => useWallet());\n await act(async () => { await Promise.resolve(); });`
);

fs.writeFileSync('hooks/useWallet.test.ts', content);
24 changes: 24 additions & 0 deletions hooks/useAccount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useAccount } from './useAccount';
import { useWallet } from './useWallet';

jest.mock('./useWallet', () => ({
useWallet: jest.fn(),
}));

describe('useAccount', () => {
it('should return null when wallet is disconnected', () => {
(useWallet as jest.Mock).mockReturnValue({ account: null });

const { result } = renderHook(() => useAccount());
expect(result.current).toBeNull();
});

it('should return the account when wallet is connected', () => {
const mockAccount = { address: 'G123', displayName: 'G123...456' };
(useWallet as jest.Mock).mockReturnValue({ account: mockAccount });

const { result } = renderHook(() => useAccount());
expect(result.current).toEqual(mockAccount);
});
});
161 changes: 161 additions & 0 deletions hooks/useWallet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useWallet } from './useWallet';
// @ts-ignore
import * as FreighterApiMock from '@stellar/freighter-api';

const {
setMockConnected,
setMockAllowed,
setMockPublicKey,
setMockNetwork,
resetFreighterMocks,
} = FreighterApiMock as any;

jest.mock('@stellar/freighter-api');

describe('useWallet', () => {
beforeEach(() => {
resetFreighterMocks();
jest.useFakeTimers();
});

afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});

it('should initialize with null state if disconnected', async () => {
const { result } = renderHook(() => useWallet());
await act(async () => { await Promise.resolve(); });

// Fast forward for initial sync
await act(async () => {
jest.advanceTimersByTime(2000);
});

expect(result.current.account).toBeNull();
expect(result.current.network).toBeNull();
expect(result.current.isAllowed).toBe(false);
});

it('should sync connected state on mount', async () => {
setMockConnected(true);
setMockAllowed(true);
setMockPublicKey('GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890');
setMockNetwork({
network: 'Test SDF Network ; September 2015',
networkUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2015',
});

const { result } = renderHook(() => useWallet());
await act(async () => { await Promise.resolve(); });

await act(async () => {
jest.advanceTimersByTime(2000);
});

expect(result.current.account).toEqual({
address: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890',
displayName: 'GABC...7890',
});

expect(result.current.isAllowed).toBe(true);
expect(result.current.network?.network).toBe('Test SDF Network ; September 2015');
});

it('should handle connect action', async () => {
const { result } = renderHook(() => useWallet());
await act(async () => { await Promise.resolve(); });

setMockConnected(true);
setMockAllowed(true);
setMockPublicKey('GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890');

await act(async () => {
await result.current.connect();
});

expect(result.current.account?.address).toBe('GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890');
expect(result.current.isAllowed).toBe(true);
});

it('should handle disconnect action', async () => {
setMockConnected(true);
setMockPublicKey('GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890');

const { result } = renderHook(() => useWallet());
await act(async () => { await Promise.resolve(); });

await act(async () => {
jest.advanceTimersByTime(2000);
});

expect(result.current.account?.address).toBe('GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890');

act(() => {
result.current.disconnect();
});

expect(result.current.account).toBeNull();

// Polling shouldn't reconnect if manually disconnected
await act(async () => {
jest.advanceTimersByTime(2000);
});

expect(result.current.account).toBeNull();
});

it('should poll and update state on changes', async () => {
setMockConnected(true);
setMockPublicKey('G111');

const { result } = renderHook(() => useWallet());
await act(async () => { await Promise.resolve(); });

await act(async () => {
jest.advanceTimersByTime(2000);
});

expect(result.current.account?.address).toBe('G111');

setMockPublicKey('G222');

await act(async () => {
jest.advanceTimersByTime(2000);
});

expect(result.current.account?.address).toBe('G222');
});

it('should sign transaction', async () => {
setMockConnected(true);
setMockPublicKey('G111');

const { result } = renderHook(() => useWallet());
await act(async () => { await Promise.resolve(); });

await act(async () => {
jest.advanceTimersByTime(2000);
});

expect(result.current.account?.address).toBe('G111');

let signed: string;
await act(async () => {
signed = await result.current.signTransaction('unsigned-xdr');
});

expect(signed!).toBe('unsigned-xdr-signed-by-G111');
});

it('should throw when signing transaction without an account', async () => {
const { result } = renderHook(() => useWallet());
await act(async () => { await Promise.resolve(); });

await expect(result.current.signTransaction('unsigned-xdr')).rejects.toThrow(
'Connect a wallet before signing a transaction'
);
});
});
12 changes: 12 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jest-environment-jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
};
2 changes: 2 additions & 0 deletions jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
require('@testing-library/jest-dom');
require('isomorphic-fetch');
Loading
Loading