Skip to content

Commit 172c046

Browse files
authored
Merge pull request #152 from internet-dot/fix/issue-14
test: add Jest tests for walletService
2 parents e7641ba + 7956c01 commit 172c046

1 file changed

Lines changed: 333 additions & 0 deletions

File tree

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
import { WalletServiceManager, WalletConnection, TokenBalance, GasEstimate } from '../walletService';
2+
import { ethers } from 'ethers';
3+
4+
// ── Mock dependencies ──────────────────────────────────────────────
5+
6+
jest.mock('ethers', () => {
7+
const actual = jest.requireActual('ethers') as Record<string, unknown>;
8+
return {
9+
...actual,
10+
providers: {
11+
JsonRpcProvider: jest.fn().mockImplementation(() => ({
12+
getBalance: jest.fn(),
13+
getGasPrice: jest.fn(),
14+
})),
15+
Web3Provider: jest.fn().mockImplementation(() => ({
16+
getSigner: jest.fn(),
17+
})),
18+
},
19+
};
20+
});
21+
22+
jest.mock('@superfluid-finance/sdk-core', () => ({
23+
Framework: {
24+
create: jest.fn(),
25+
},
26+
SFError: class extends Error {
27+
constructor(msg: string) {
28+
super(msg);
29+
this.name = 'SFError';
30+
}
31+
},
32+
}));
33+
34+
jest.mock('../../contracts', () => ({
35+
ERC20__factory: {
36+
connect: jest.fn(),
37+
},
38+
getContractAddress: jest.fn(),
39+
}));
40+
41+
jest.mock('../../config/evm', () => ({
42+
getEvmRpcUrl: jest.fn().mockReturnValue('https://rpc.example.com'),
43+
}));
44+
45+
import { ethers as mockedEthers } from 'ethers';
46+
import { getContractAddress } from '../../contracts';
47+
import { getEvmRpcUrl } from '../../config/evm';
48+
import { Framework } from '@superfluid-finance/sdk-core';
49+
50+
const mockedGetContractAddress = getContractAddress as jest.MockedFunction<typeof getContractAddress>;
51+
const mockedJsonRpcProvider = mockedEthers.providers.JsonRpcProvider as jest.Mock;
52+
53+
// ── Helpers ────────────────────────────────────────────────────────
54+
55+
function createMockConnection(overrides?: Partial<WalletConnection>): WalletConnection {
56+
return {
57+
address: '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B',
58+
chainId: 1,
59+
isConnected: true,
60+
eip1193Provider: {} as ethers.providers.ExternalProvider,
61+
...overrides,
62+
};
63+
}
64+
65+
function freshManager(): WalletServiceManager {
66+
// Reset singleton state by re-instantiating via reflection
67+
const mgr = new WalletServiceManager() as WalletServiceManager & { connection: WalletConnection | null; listeners: Function[] };
68+
mgr.connection = null;
69+
mgr.listeners = [];
70+
return mgr;
71+
}
72+
73+
// ── Tests ──────────────────────────────────────────────────────────
74+
75+
describe('WalletServiceManager', () => {
76+
describe('Singleton', () => {
77+
it('getInstance returns the same instance', () => {
78+
const a = WalletServiceManager.getInstance();
79+
const b = WalletServiceManager.getInstance();
80+
expect(a).toBe(b);
81+
});
82+
});
83+
84+
describe('Connection management', () => {
85+
let mgr: WalletServiceManager & { connection: WalletConnection | null; listeners: Function[] };
86+
87+
beforeEach(() => {
88+
mgr = freshManager() as typeof mgr;
89+
});
90+
91+
it('getConnection returns null by default', () => {
92+
expect(mgr.getConnection()).toBeNull();
93+
});
94+
95+
it('setConnection updates and notifies listeners', () => {
96+
const listener = jest.fn();
97+
const conn = createMockConnection();
98+
mgr.addListener(listener);
99+
mgr.setConnection(conn);
100+
101+
expect(mgr.getConnection()).toBe(conn);
102+
expect(listener).toHaveBeenCalledWith(conn);
103+
});
104+
105+
it('removeListener stops notification', () => {
106+
const listener = jest.fn();
107+
mgr.addListener(listener);
108+
mgr.removeListener(listener);
109+
mgr.setConnection(createMockConnection());
110+
expect(listener).not.toHaveBeenCalled();
111+
});
112+
113+
it('isConnected returns false when no connection', () => {
114+
expect(mgr.isConnected()).toBe(false);
115+
});
116+
117+
it('isConnected returns true when connected', () => {
118+
mgr.setConnection(createMockConnection());
119+
expect(mgr.isConnected()).toBe(true);
120+
});
121+
});
122+
123+
describe('disconnectWallet', () => {
124+
it('clears connection and notifies listeners', async () => {
125+
const mgr = freshManager() as WalletServiceManager & { connection: WalletConnection | null; listeners: Function[] };
126+
const listener = jest.fn();
127+
mgr.addListener(listener);
128+
mgr.setConnection(createMockConnection());
129+
130+
await mgr.disconnectWallet();
131+
132+
expect(mgr.getConnection()).toBeNull();
133+
expect(listener).toHaveBeenCalledWith(null);
134+
});
135+
});
136+
137+
describe('initialize', () => {
138+
it('resolves without error', async () => {
139+
const mgr = freshManager();
140+
await expect(mgr.initialize()).resolves.toBeUndefined();
141+
});
142+
});
143+
144+
describe('getTokenBalances', () => {
145+
let mgr: WalletServiceManager & { connection: WalletConnection | null; listeners: Function[] };
146+
let mockProvider: { getBalance: jest.Mock; getGasPrice: jest.Mock };
147+
148+
beforeEach(() => {
149+
mgr = freshManager() as typeof mgr;
150+
mockProvider = {
151+
getBalance: jest.fn().mockResolvedValue(ethers.BigNumber.from('1000000000000000000')),
152+
getGasPrice: jest.fn(),
153+
};
154+
(mockedEthers.providers.JsonRpcProvider as jest.Mock).mockReturnValue(mockProvider);
155+
});
156+
157+
it('returns native balance for chainId 1', async () => {
158+
const balances = await mgr.getTokenBalances('0xAddr', 1);
159+
expect(balances.length).toBeGreaterThanOrEqual(1);
160+
expect(balances[0].symbol).toBe('ETH');
161+
expect(balances[0].balance).toBe('1.0');
162+
});
163+
164+
it('returns MATIC native balance for chainId 137', async () => {
165+
const balances = await mgr.getTokenBalances('0xAddr', 137);
166+
expect(balances[0].symbol).toBe('MATIC');
167+
});
168+
169+
it('returns ETH for chainId 42161 (Arbitrum)', async () => {
170+
const balances = await mgr.getTokenBalances('0xAddr', 42161);
171+
expect(balances[0].symbol).toBe('ETH');
172+
});
173+
174+
it('returns ETH as default for unknown chainId', async () => {
175+
const balances = await mgr.getTokenBalances('0xAddr', 999);
176+
expect(balances[0].symbol).toBe('ETH');
177+
});
178+
179+
it('includes USDC for supported chains when contract address exists', async () => {
180+
mockedGetContractAddress.mockReturnValue('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48');
181+
182+
const mockBalanceOf = jest.fn().mockResolvedValue(ethers.BigNumber.from('5000000'));
183+
const mockContract = { balanceOf: mockBalanceOf, decimals: jest.fn() };
184+
const ERC20Factory = require('../../contracts').ERC20__factory;
185+
ERC20Factory.connect.mockReturnValue(mockContract);
186+
187+
const balances = await mgr.getTokenBalances('0xAddr', 1);
188+
const usdc = balances.find((b: TokenBalance) => b.symbol === 'USDC');
189+
expect(usdc).toBeDefined();
190+
expect(usdc!.balance).toBe('5.0');
191+
});
192+
193+
it('skips USDC when contract address is null', async () => {
194+
mockedGetContractAddress.mockReturnValue(null);
195+
const balances = await mgr.getTokenBalances('0xAddr', 1);
196+
const usdc = balances.find((b: TokenBalance) => b.symbol === 'USDC');
197+
expect(usdc).toBeUndefined();
198+
});
199+
200+
it('handles USDC contract errors gracefully', async () => {
201+
mockedGetContractAddress.mockReturnValue('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48');
202+
203+
const mockContract = {
204+
balanceOf: jest.fn().mockRejectedValue(new Error('call revert')),
205+
decimals: jest.fn(),
206+
};
207+
const ERC20Factory = require('../../contracts').ERC20__factory;
208+
ERC20Factory.connect.mockReturnValue(mockContract);
209+
210+
const balances = await mgr.getTokenBalances('0xAddr', 1);
211+
const usdc = balances.find((b: TokenBalance) => b.symbol === 'USDC');
212+
expect(usdc).toBeUndefined();
213+
});
214+
215+
it('throws when provider fails for native balance', async () => {
216+
mockProvider.getBalance.mockRejectedValue(new Error('RPC down'));
217+
await expect(mgr.getTokenBalances('0xAddr', 1)).rejects.toThrow('RPC down');
218+
});
219+
});
220+
221+
describe('estimateGas', () => {
222+
let mgr: WalletServiceManager & { connection: WalletConnection | null; listeners: Function[] };
223+
let mockProvider: { getBalance: jest.Mock; getGasPrice: jest.Mock };
224+
225+
beforeEach(() => {
226+
mgr = freshManager() as typeof mgr;
227+
mockProvider = {
228+
getBalance: jest.fn(),
229+
getGasPrice: jest.fn().mockResolvedValue(ethers.BigNumber.from('20000000000')), // 20 gwei
230+
};
231+
(mockedEthers.providers.JsonRpcProvider as jest.Mock).mockReturnValue(mockProvider);
232+
});
233+
234+
it('returns a valid gas estimate', async () => {
235+
const estimate: GasEstimate = await mgr.estimateGas('0xFrom', '0xTo', '1.0', 1);
236+
expect(estimate.gasLimit).toBe('21000');
237+
expect(estimate.gasPrice).toBe('20.0');
238+
expect(parseFloat(estimate.estimatedCost)).toBeGreaterThan(0);
239+
});
240+
241+
it('throws when provider fails', async () => {
242+
mockProvider.getGasPrice.mockRejectedValue(new Error('network error'));
243+
await expect(mgr.estimateGas('0xFrom', '0xTo', '1.0', 1)).rejects.toThrow('network error');
244+
});
245+
});
246+
247+
describe('getWalletSigner (private)', () => {
248+
it('throws when no connection', () => {
249+
const mgr = freshManager();
250+
// Access private via casting
251+
expect(() => (mgr as any).getWalletSigner()).toThrow('Wallet is not connected');
252+
});
253+
254+
it('throws when connection has no eip1193Provider', () => {
255+
const mgr = freshManager();
256+
mgr.setConnection(createMockConnection({ eip1193Provider: undefined }));
257+
expect(() => (mgr as any).getWalletSigner()).toThrow('does not expose a signing provider');
258+
});
259+
});
260+
261+
describe('createSuperfluidStream – user rejection', () => {
262+
it('throws friendly error when user rejects transaction', async () => {
263+
const mgr = freshManager();
264+
const mockSigner = {
265+
provider: { getNetwork: jest.fn().mockResolvedValue({ chainId: 1 }) },
266+
getAddress: jest.fn().mockResolvedValue('0xSender'),
267+
};
268+
jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner);
269+
270+
// Mock buildSuperfluidCreateFlowContext to throw rejection-like error
271+
jest.spyOn(mgr as any, 'buildSuperfluidCreateFlowContext').mockRejectedValue({
272+
code: 4001,
273+
message: 'User rejected',
274+
});
275+
276+
await expect(
277+
mgr.createSuperfluidStream('ETH', '10', '0xRecipient', 1)
278+
).rejects.toThrow('Transaction was rejected in your wallet.');
279+
});
280+
});
281+
282+
describe('createSuperfluidStream – user denied (string code)', () => {
283+
it('throws friendly error for ACTION_REJECTED code', async () => {
284+
const mgr = freshManager();
285+
const mockSigner = {
286+
provider: { getNetwork: jest.fn().mockResolvedValue({ chainId: 1 }) },
287+
getAddress: jest.fn().mockResolvedValue('0xSender'),
288+
};
289+
jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner);
290+
jest.spyOn(mgr as any, 'buildSuperfluidCreateFlowContext').mockRejectedValue({
291+
code: 'ACTION_REJECTED',
292+
});
293+
294+
await expect(
295+
mgr.createSuperfluidStream('ETH', '10', '0xRecipient', 1)
296+
).rejects.toThrow('Transaction was rejected in your wallet.');
297+
});
298+
});
299+
300+
describe('estimateSuperfluidCreateFlow – network mismatch', () => {
301+
it('throws when wallet chainId differs from requested chainId', async () => {
302+
const mgr = freshManager();
303+
const mockSigner = {
304+
provider: { getNetwork: jest.fn().mockResolvedValue({ chainId: 137 }) },
305+
};
306+
jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner);
307+
308+
await expect(
309+
mgr.estimateSuperfluidCreateFlow('ETH', '10', '0xRecipient', 1)
310+
).rejects.toThrow('does not match selected chain');
311+
});
312+
});
313+
314+
describe('createSablierStream – user denied via message', () => {
315+
it('throws friendly error for user denied message', async () => {
316+
const mgr = freshManager();
317+
const mockSigner = {
318+
provider: { getNetwork: jest.fn().mockResolvedValue({ chainId: 1 }) },
319+
getAddress: jest.fn().mockResolvedValue('0xSender'),
320+
};
321+
jest.spyOn(mgr as any, 'getWalletSigner').mockReturnValue(mockSigner);
322+
323+
// Simulate a generic error with "user denied" in message
324+
jest.spyOn(ethers, 'Contract' as any).mockImplementation(() => {
325+
throw new Error('user denied transaction');
326+
});
327+
328+
await expect(
329+
mgr.createSablierStream('0xToken', '10', Date.now(), Date.now() + 86400000, '0xRecipient', 1)
330+
).rejects.toThrow('Transaction was rejected in your wallet.');
331+
});
332+
});
333+
});

0 commit comments

Comments
 (0)