Skip to content

Commit b28b5da

Browse files
katspaughclaude
andauthored
test: add comprehensive unit tests for utility modules (#38)
Add 110 new unit tests covering 5 previously untested utility modules: - argument-parser: 40 tests for CLI argument parsing - command-helpers: 27 tests for command utilities and output formatting - safe-helpers: 16 tests for Safe selection and owner verification - password-handler: 18 tests for password input handling - command-context: 9 tests for command context initialization These tests increase overall code coverage from 30.67% to 36.03%, with the utils directory now at 98.29% coverage. All tests use proper mocking for external dependencies and cover edge cases, error conditions, and success scenarios. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f177fd commit b28b5da

File tree

5 files changed

+1481
-0
lines changed

5 files changed

+1481
-0
lines changed
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import { describe, it, expect } from 'vitest'
2+
import {
3+
parseOwnersArgument,
4+
parseJsonArgument,
5+
parseAddressArgument,
6+
parseFunctionCall,
7+
parseNumericArgument,
8+
parseChainArgument,
9+
} from '../../../utils/argument-parser.js'
10+
import { TEST_ADDRESSES } from '../../fixtures/index.js'
11+
import { writeFileSync, unlinkSync } from 'fs'
12+
import { resolve } from 'path'
13+
14+
describe('argument-parser', () => {
15+
describe('parseOwnersArgument', () => {
16+
it('should parse JSON array of addresses', () => {
17+
const input = `["${TEST_ADDRESSES.owner1}", "${TEST_ADDRESSES.owner2}"]`
18+
const result = parseOwnersArgument(input)
19+
expect(result).toEqual([TEST_ADDRESSES.owner1, TEST_ADDRESSES.owner2])
20+
})
21+
22+
it('should parse comma-separated addresses', () => {
23+
const input = `${TEST_ADDRESSES.owner1},${TEST_ADDRESSES.owner2}`
24+
const result = parseOwnersArgument(input)
25+
expect(result).toEqual([TEST_ADDRESSES.owner1, TEST_ADDRESSES.owner2])
26+
})
27+
28+
it('should parse comma-separated addresses with spaces', () => {
29+
const input = `${TEST_ADDRESSES.owner1} , ${TEST_ADDRESSES.owner2}`
30+
const result = parseOwnersArgument(input)
31+
expect(result).toEqual([TEST_ADDRESSES.owner1, TEST_ADDRESSES.owner2])
32+
})
33+
34+
it('should throw on invalid JSON', () => {
35+
expect(() => parseOwnersArgument('[invalid json')).toThrow('Invalid JSON array')
36+
})
37+
38+
it('should throw on non-array JSON', () => {
39+
expect(() => parseOwnersArgument('{"not": "array"}')).toThrow('Invalid address')
40+
})
41+
42+
it('should throw on invalid address in JSON array', () => {
43+
expect(() => parseOwnersArgument('["0xinvalid"]')).toThrow('Invalid address')
44+
})
45+
46+
it('should throw on invalid address in comma-separated list', () => {
47+
expect(() => parseOwnersArgument('0xinvalid,0xalsobad')).toThrow('Invalid address')
48+
})
49+
50+
it('should throw on empty string', () => {
51+
expect(() => parseOwnersArgument('')).toThrow('No owners provided')
52+
})
53+
54+
it('should filter out empty addresses from comma-separated list', () => {
55+
const input = `${TEST_ADDRESSES.owner1},,${TEST_ADDRESSES.owner2}`
56+
const result = parseOwnersArgument(input)
57+
expect(result).toEqual([TEST_ADDRESSES.owner1, TEST_ADDRESSES.owner2])
58+
})
59+
})
60+
61+
describe('parseJsonArgument', () => {
62+
it('should parse JSON string', () => {
63+
const input = '{"key": "value", "number": 42}'
64+
const result = parseJsonArgument(input)
65+
expect(result).toEqual({ key: 'value', number: 42 })
66+
})
67+
68+
it('should parse JSON array', () => {
69+
const input = '[1, 2, 3]'
70+
const result = parseJsonArgument(input)
71+
expect(result).toEqual([1, 2, 3])
72+
})
73+
74+
it('should throw on invalid JSON', () => {
75+
expect(() => parseJsonArgument('{invalid}')).toThrow('Invalid JSON')
76+
})
77+
78+
it('should read JSON from file with @ prefix', () => {
79+
const testFile = resolve('/tmp/test-json.json')
80+
const testData = { test: 'data', value: 123 }
81+
writeFileSync(testFile, JSON.stringify(testData))
82+
83+
try {
84+
const result = parseJsonArgument(`@${testFile}`)
85+
expect(result).toEqual(testData)
86+
} finally {
87+
unlinkSync(testFile)
88+
}
89+
})
90+
91+
it('should throw on non-existent file', () => {
92+
expect(() => parseJsonArgument('@/nonexistent/file.json')).toThrow('Failed to read JSON')
93+
})
94+
95+
it('should throw on invalid JSON in file', () => {
96+
const testFile = resolve('/tmp/test-invalid.json')
97+
writeFileSync(testFile, '{invalid json}')
98+
99+
try {
100+
expect(() => parseJsonArgument(`@${testFile}`)).toThrow('Failed to read JSON')
101+
} finally {
102+
unlinkSync(testFile)
103+
}
104+
})
105+
})
106+
107+
describe('parseAddressArgument', () => {
108+
it('should parse plain address', () => {
109+
const result = parseAddressArgument(TEST_ADDRESSES.owner1)
110+
expect(result).toEqual({
111+
address: TEST_ADDRESSES.owner1,
112+
})
113+
})
114+
115+
it('should parse EIP-3770 format address', () => {
116+
const input = `eth:${TEST_ADDRESSES.owner1}`
117+
const result = parseAddressArgument(input)
118+
expect(result).toEqual({
119+
shortName: 'eth',
120+
address: TEST_ADDRESSES.owner1,
121+
})
122+
})
123+
124+
it('should parse EIP-3770 format with spaces', () => {
125+
const input = `eth :${TEST_ADDRESSES.owner1}`
126+
const result = parseAddressArgument(input)
127+
expect(result).toEqual({
128+
shortName: 'eth',
129+
address: TEST_ADDRESSES.owner1,
130+
})
131+
})
132+
133+
it('should throw on invalid plain address', () => {
134+
expect(() => parseAddressArgument('0xinvalid')).toThrow('Invalid address')
135+
})
136+
137+
it('should throw on invalid EIP-3770 address', () => {
138+
expect(() => parseAddressArgument('eth:0xinvalid')).toThrow('Invalid address')
139+
})
140+
})
141+
142+
describe('parseFunctionCall', () => {
143+
it('should parse function signature with arguments', () => {
144+
const signature = 'transfer(address,uint256)'
145+
const args = '["0x1234567890123456789012345678901234567890", "1000000000000000000"]'
146+
const result = parseFunctionCall(signature, args)
147+
expect(result).toEqual({
148+
signature,
149+
args: ['0x1234567890123456789012345678901234567890', '1000000000000000000'],
150+
})
151+
})
152+
153+
it('should parse function signature without arguments', () => {
154+
const signature = 'balanceOf(address)'
155+
const result = parseFunctionCall(signature)
156+
expect(result).toEqual({
157+
signature,
158+
args: [],
159+
})
160+
})
161+
162+
it('should parse function signature with empty args string', () => {
163+
const signature = 'transfer(address,uint256)'
164+
const result = parseFunctionCall(signature, undefined)
165+
expect(result).toEqual({
166+
signature,
167+
args: [],
168+
})
169+
})
170+
171+
it('should throw on invalid JSON args', () => {
172+
const signature = 'transfer(address,uint256)'
173+
expect(() => parseFunctionCall(signature, '{invalid}')).toThrow('Invalid function arguments')
174+
})
175+
176+
it('should throw on non-array args', () => {
177+
const signature = 'transfer(address,uint256)'
178+
expect(() => parseFunctionCall(signature, '{"not": "array"}')).toThrow(
179+
'Function arguments must be an array'
180+
)
181+
})
182+
})
183+
184+
describe('parseNumericArgument', () => {
185+
it('should parse integer string', () => {
186+
const result = parseNumericArgument('1234567890')
187+
expect(result).toBe(1234567890n)
188+
})
189+
190+
it('should parse zero', () => {
191+
const result = parseNumericArgument('0')
192+
expect(result).toBe(0n)
193+
})
194+
195+
it('should parse large number', () => {
196+
const result = parseNumericArgument('1000000000000000000')
197+
expect(result).toBe(1000000000000000000n)
198+
})
199+
200+
it('should throw on decimal when not allowed', () => {
201+
expect(() => parseNumericArgument('123.45', false)).toThrow('Decimal values not allowed')
202+
})
203+
204+
it('should parse decimal when allowed', () => {
205+
const result = parseNumericArgument('123.45', true)
206+
expect(result).toBe(123450000000000000000n)
207+
})
208+
209+
it('should parse decimal with trailing zeros', () => {
210+
const result = parseNumericArgument('1.5', true)
211+
expect(result).toBe(1500000000000000000n)
212+
})
213+
214+
it('should handle decimal with many digits', () => {
215+
const result = parseNumericArgument('1.123456789012345678', true)
216+
expect(result).toBe(1123456789012345678n)
217+
})
218+
219+
it('should truncate decimals beyond 18 digits', () => {
220+
const result = parseNumericArgument('1.123456789012345678999', true)
221+
expect(result).toBe(1123456789012345678n)
222+
})
223+
224+
it('should throw on invalid numeric value', () => {
225+
expect(() => parseNumericArgument('abc')).toThrow('Invalid numeric value')
226+
})
227+
228+
it('should handle whitespace', () => {
229+
const result = parseNumericArgument(' 123 ')
230+
expect(result).toBe(123n)
231+
})
232+
})
233+
234+
describe('parseChainArgument', () => {
235+
it('should parse numeric chain ID', () => {
236+
const result = parseChainArgument('1')
237+
expect(result).toBe('1')
238+
})
239+
240+
it('should parse large numeric chain ID', () => {
241+
const result = parseChainArgument('11155111')
242+
expect(result).toBe('11155111')
243+
})
244+
245+
it('should pass through chain short name', () => {
246+
const result = parseChainArgument('eth')
247+
expect(result).toBe('eth')
248+
})
249+
250+
it('should pass through chain short name with hyphen', () => {
251+
const result = parseChainArgument('arbitrum-one')
252+
expect(result).toBe('arbitrum-one')
253+
})
254+
255+
it('should pass through alphanumeric short names', () => {
256+
const result = parseChainArgument('base2')
257+
expect(result).toBe('base2')
258+
})
259+
})
260+
})
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { createCommandContext } from '../../../utils/command-context.js'
3+
4+
// Mock storage modules
5+
vi.mock('../../../storage/config-store.js', () => ({
6+
getConfigStore: vi.fn(() => ({
7+
getAllChains: vi.fn(() => ({
8+
'1': { chainId: '1', name: 'Ethereum' },
9+
'11155111': { chainId: '11155111', name: 'Sepolia' },
10+
})),
11+
getChain: vi.fn(),
12+
})),
13+
}))
14+
15+
vi.mock('../../../storage/safe-store.js', () => ({
16+
getSafeStorage: vi.fn(() => ({
17+
getAllSafes: vi.fn(() => []),
18+
getSafe: vi.fn(),
19+
})),
20+
}))
21+
22+
vi.mock('../../../storage/wallet-store.js', () => ({
23+
getWalletStorage: vi.fn(() => ({
24+
getActiveWallet: vi.fn(),
25+
getAllWallets: vi.fn(() => []),
26+
})),
27+
}))
28+
29+
vi.mock('../../../storage/transaction-store.js', () => ({
30+
getTransactionStore: vi.fn(() => ({
31+
getAllTransactions: vi.fn(() => []),
32+
getTransaction: vi.fn(),
33+
})),
34+
}))
35+
36+
vi.mock('../../../services/validation-service.js', () => ({
37+
getValidationService: vi.fn(() => ({
38+
validateAddress: vi.fn(),
39+
validatePrivateKey: vi.fn(),
40+
})),
41+
}))
42+
43+
describe('command-context', () => {
44+
beforeEach(() => {
45+
vi.clearAllMocks()
46+
})
47+
48+
describe('createCommandContext', () => {
49+
it('should create context with all required services', () => {
50+
const context = createCommandContext()
51+
52+
expect(context).toHaveProperty('configStore')
53+
expect(context).toHaveProperty('safeStorage')
54+
expect(context).toHaveProperty('walletStorage')
55+
expect(context).toHaveProperty('transactionStore')
56+
expect(context).toHaveProperty('validator')
57+
expect(context).toHaveProperty('chains')
58+
})
59+
60+
it('should initialize configStore', () => {
61+
const context = createCommandContext()
62+
63+
expect(context.configStore).toBeDefined()
64+
expect(context.configStore.getAllChains).toBeDefined()
65+
})
66+
67+
it('should initialize safeStorage', () => {
68+
const context = createCommandContext()
69+
70+
expect(context.safeStorage).toBeDefined()
71+
expect(context.safeStorage.getAllSafes).toBeDefined()
72+
})
73+
74+
it('should initialize walletStorage', () => {
75+
const context = createCommandContext()
76+
77+
expect(context.walletStorage).toBeDefined()
78+
expect(context.walletStorage.getActiveWallet).toBeDefined()
79+
})
80+
81+
it('should initialize transactionStore', () => {
82+
const context = createCommandContext()
83+
84+
expect(context.transactionStore).toBeDefined()
85+
expect(context.transactionStore.getAllTransactions).toBeDefined()
86+
})
87+
88+
it('should initialize validator', () => {
89+
const context = createCommandContext()
90+
91+
expect(context.validator).toBeDefined()
92+
expect(context.validator.validateAddress).toBeDefined()
93+
})
94+
95+
it('should populate chains from configStore', () => {
96+
const context = createCommandContext()
97+
98+
expect(context.chains).toBeDefined()
99+
expect(context.chains).toHaveProperty('1')
100+
expect(context.chains).toHaveProperty('11155111')
101+
expect(context.chains['1'].name).toBe('Ethereum')
102+
expect(context.chains['11155111'].name).toBe('Sepolia')
103+
})
104+
105+
it('should call getAllChains on configStore', async () => {
106+
const { getConfigStore } = await import('../../../storage/config-store.js')
107+
const mockConfigStore = {
108+
getAllChains: vi.fn(() => ({ '1': { chainId: '1' } })),
109+
}
110+
vi.mocked(getConfigStore).mockReturnValue(mockConfigStore as any)
111+
112+
createCommandContext()
113+
114+
expect(mockConfigStore.getAllChains).toHaveBeenCalled()
115+
})
116+
117+
it('should return new instance each time', () => {
118+
const context1 = createCommandContext()
119+
const context2 = createCommandContext()
120+
121+
// While the underlying singleton stores are the same,
122+
// the context object itself should be different
123+
expect(context1).not.toBe(context2)
124+
})
125+
})
126+
})

0 commit comments

Comments
 (0)