1+ #!/usr/bin/env python3
2+ """Tests for consistent error handling in rustchain_cli.py
3+
4+ Tests verify that every failure path produces a clear error message on stderr
5+ and a non-zero exit code, using the documented exit-code scheme.
6+ """
7+
8+ import io
9+ import json
10+ import os
11+ import sys
12+ import unittest
13+ from unittest .mock import patch , MagicMock
14+ from urllib .error import HTTPError , URLError
15+ from urllib .request import Request
16+
17+ # Import the module under test
18+ sys .path .insert (0 , "." )
19+ import rustchain_cli as cli
20+
21+
22+ class TestExitCodes (unittest .TestCase ):
23+ """Exit codes match the documented scheme."""
24+
25+ def test_exit_code_values (self ):
26+ self .assertEqual (cli .EXIT_SUCCESS , 0 )
27+ self .assertEqual (cli .EXIT_USAGE_ERROR , 1 )
28+ self .assertEqual (cli .EXIT_NETWORK_ERROR , 2 )
29+ self .assertEqual (cli .EXIT_BAD_RESPONSE , 3 )
30+ self .assertEqual (cli .EXIT_NOT_FOUND , 4 )
31+ self .assertEqual (cli .EXIT_UNKNOWN_ERROR , 5 )
32+
33+
34+ class TestRustChainAPIError (unittest .TestCase ):
35+ """RustChainAPIError carries the correct exit code."""
36+
37+ def test_default_exit_code (self ):
38+ err = cli .RustChainAPIError ("test" )
39+ self .assertEqual (err .exit_code , cli .EXIT_BAD_RESPONSE )
40+
41+ def test_custom_exit_code (self ):
42+ err = cli .RustChainAPIError ("network error" , cli .EXIT_NETWORK_ERROR )
43+ self .assertEqual (err .exit_code , cli .EXIT_NETWORK_ERROR )
44+
45+ def test_str_representation (self ):
46+ err = cli .RustChainAPIError ("Something went wrong" )
47+ self .assertIn ("Something went wrong" , str (err ))
48+
49+
50+ class TestFetchAPI (unittest .TestCase ):
51+ """fetch_api raises RustChainAPIError with correct exit codes."""
52+
53+ @patch ("rustchain_cli.urlopen" )
54+ def test_network_error (self , mock_urlopen ):
55+ mock_urlopen .side_effect = URLError ("Connection refused" )
56+ with self .assertRaises (cli .RustChainAPIError ) as ctx :
57+ cli .fetch_api ("/balance/test" )
58+ self .assertEqual (ctx .exception .exit_code , cli .EXIT_NETWORK_ERROR )
59+ self .assertIn ("Connection refused" , str (ctx .exception ))
60+
61+ @patch ("rustchain_cli.urlopen" )
62+ def test_404_not_found (self , mock_urlopen ):
63+ mock_urlopen .side_effect = HTTPError (
64+ "http://example.com/404" , 404 , "Not Found" , {}, io .BytesIO (b"" )
65+ )
66+ with self .assertRaises (cli .RustChainAPIError ) as ctx :
67+ cli .fetch_api ("/balance/nonexistent" )
68+ self .assertEqual (ctx .exception .exit_code , cli .EXIT_NOT_FOUND )
69+ self .assertIn ("not found" , str (ctx .exception ).lower ())
70+
71+ @patch ("rustchain_cli.urlopen" )
72+ def test_500_server_error (self , mock_urlopen ):
73+ mock_urlopen .side_effect = HTTPError (
74+ "http://example.com/500" , 500 , "Internal Server Error" , {}, io .BytesIO (b"" )
75+ )
76+ with self .assertRaises (cli .RustChainAPIError ) as ctx :
77+ cli .fetch_api ("/balance/test" )
78+ self .assertEqual (ctx .exception .exit_code , cli .EXIT_BAD_RESPONSE )
79+ self .assertIn ("500" , str (ctx .exception ))
80+
81+ @patch ("rustchain_cli.urlopen" )
82+ def test_invalid_json (self , mock_urlopen ):
83+ mock_response = MagicMock ()
84+ mock_response .read .return_value = b"not json"
85+ mock_response .__enter__ .return_value = mock_response
86+ mock_urlopen .return_value = mock_response
87+ with self .assertRaises (cli .RustChainAPIError ) as ctx :
88+ cli .fetch_api ("/balance/test" )
89+ self .assertEqual (ctx .exception .exit_code , cli .EXIT_BAD_RESPONSE )
90+ self .assertIn ("JSON" , str (ctx .exception ))
91+
92+
93+ class MockArgs :
94+ """Minimal argparse.Namespace stand-in."""
95+
96+ def __init__ (self , ** kwargs ):
97+ for k , v in kwargs .items ():
98+ setattr (self , k , v )
99+
100+
101+ class TestCmdBalance (unittest .TestCase ):
102+ """cmd_balance returns correct exit codes for all failure paths."""
103+
104+ def test_missing_miner_id (self ):
105+ args = MockArgs (miner_id = None , all = False , json = False )
106+ rc = cli .cmd_balance (args )
107+ self .assertEqual (rc , cli .EXIT_USAGE_ERROR )
108+
109+ @patch ("rustchain_cli.fetch_api" )
110+ def test_missing_balance_field (self , mock_fetch ):
111+ mock_fetch .return_value = {"some_other_field" : 42 }
112+ args = MockArgs (miner_id = "test_miner" , all = False , json = False )
113+ rc = cli .cmd_balance (args )
114+ self .assertEqual (rc , cli .EXIT_BAD_RESPONSE )
115+
116+ @patch ("rustchain_cli.fetch_api" )
117+ def test_valid_balance (self , mock_fetch ):
118+ mock_fetch .return_value = {"balance_rtc" : 123.45 }
119+ args = MockArgs (miner_id = "test_miner" , all = False , json = False )
120+ rc = cli .cmd_balance (args )
121+ self .assertEqual (rc , cli .EXIT_SUCCESS )
122+
123+ @patch ("rustchain_cli.fetch_api" )
124+ def test_balance_with_balance_field (self , mock_fetch ):
125+ """Test fallback from 'balance_rtc' to 'balance'."""
126+ mock_fetch .return_value = {"balance" : "50.0" }
127+ args = MockArgs (miner_id = "test_miner" , all = False , json = False )
128+ rc = cli .cmd_balance (args )
129+ self .assertEqual (rc , cli .EXIT_SUCCESS )
130+
131+ @patch ("rustchain_cli.fetch_api" )
132+ def test_all_mode_invalid_response (self , mock_fetch ):
133+ """--all with non-list response returns error."""
134+ mock_fetch .return_value = {"error" : "not a list" }
135+ args = MockArgs (miner_id = None , all = True , json = False )
136+ rc = cli .cmd_balance (args )
137+ self .assertEqual (rc , cli .EXIT_BAD_RESPONSE )
138+
139+ @patch ("rustchain_cli.fetch_api" )
140+ def test_all_mode_success (self , mock_fetch ):
141+ mock_fetch .return_value = [
142+ {"miner_id" : "m1" , "rust_score" : 95 , "total_attestations" : 100 },
143+ {"miner_id" : "m2" , "rust_score" : 80 , "total_attestations" : 50 },
144+ ]
145+ args = MockArgs (miner_id = None , all = True , json = False )
146+ rc = cli .cmd_balance (args )
147+ self .assertEqual (rc , cli .EXIT_SUCCESS )
148+
149+ @patch ("rustchain_cli.fetch_api" )
150+ def test_all_mode_json (self , mock_fetch ):
151+ mock_fetch .return_value = []
152+ args = MockArgs (miner_id = None , all = True , json = True )
153+ rc = cli .cmd_balance (args )
154+ self .assertEqual (rc , cli .EXIT_SUCCESS )
155+
156+
157+ class TestCmdWalletBalance (unittest .TestCase ):
158+ """cmd_wallet 'balance' action returns correct exit codes."""
159+
160+ def test_missing_address_no_env (self ):
161+ """No address and no env var = usage error."""
162+ with patch .dict (os .environ , {}, clear = True ):
163+ args = MockArgs (action = "balance" , address = None , json = False )
164+ rc = cli .cmd_wallet (args )
165+ self .assertEqual (rc , cli .EXIT_USAGE_ERROR )
166+
167+ @patch ("rustchain_cli.fetch_api" )
168+ def test_missing_balance_field (self , mock_fetch ):
169+ """API response missing balance fields = bad response."""
170+ mock_fetch .return_value = {"status" : "ok" , "balance_usd" : 0 }
171+ args = MockArgs (action = "balance" , address = "test_addr" , json = False )
172+ rc = cli .cmd_wallet (args )
173+ self .assertEqual (rc , cli .EXIT_BAD_RESPONSE )
174+
175+ @patch ("rustchain_cli.fetch_api" )
176+ def test_valid_balance (self , mock_fetch ):
177+ mock_fetch .return_value = {
178+ "amount_rtc" : 100.0 ,
179+ "balance_usd" : 42.50 ,
180+ "pending_rtc" : 5.0 ,
181+ }
182+ args = MockArgs (action = "balance" , address = "test_addr" , json = False )
183+ rc = cli .cmd_wallet (args )
184+ self .assertEqual (rc , cli .EXIT_SUCCESS )
185+
186+ @patch ("rustchain_cli.fetch_api" )
187+ def test_valid_balance_json (self , mock_fetch ):
188+ mock_fetch .return_value = {"amount_rtc" : 100.0 }
189+ args = MockArgs (action = "balance" , address = "test_addr" , json = True )
190+ rc = cli .cmd_wallet (args )
191+ self .assertEqual (rc , cli .EXIT_SUCCESS )
192+
193+
194+ class TestMainErrorHandling (unittest .TestCase ):
195+ """main() propagates exit codes from RustChainAPIError."""
196+
197+ @patch ("rustchain_cli.sys.exit" )
198+ @patch ("rustchain_cli.cmd_balance" )
199+ def test_network_error_exit_code (self , mock_cmd , mock_exit ):
200+ """RustChainAPIError with EXIT_NETWORK_ERROR calls sys.exit(2)."""
201+ mock_cmd .side_effect = cli .RustChainAPIError (
202+ "Cannot connect" , cli .EXIT_NETWORK_ERROR
203+ )
204+ # Simulate what main() does
205+ try :
206+ mock_cmd (MockArgs ())
207+ except cli .RustChainAPIError as exc :
208+ cli .sys .exit (exc .exit_code )
209+
210+ mock_exit .assert_called_once_with (cli .EXIT_NETWORK_ERROR )
211+
212+ @patch ("rustchain_cli.sys.exit" )
213+ @patch ("rustchain_cli.cmd_balance" )
214+ def test_not_found_exit_code (self , mock_cmd , mock_exit ):
215+ """RustChainAPIError with EXIT_NOT_FOUND calls sys.exit(4)."""
216+ mock_cmd .side_effect = cli .RustChainAPIError (
217+ "Not found" , cli .EXIT_NOT_FOUND
218+ )
219+ try :
220+ mock_cmd (MockArgs ())
221+ except cli .RustChainAPIError as exc :
222+ cli .sys .exit (exc .exit_code )
223+
224+ mock_exit .assert_called_once_with (cli .EXIT_NOT_FOUND )
225+
226+
227+ if __name__ == "__main__" :
228+ unittest .main ()
0 commit comments