iwa 0.0.0__py3-none-any.whl → 0.0.1a2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. conftest.py +22 -0
  2. iwa/__init__.py +1 -0
  3. iwa/__main__.py +6 -0
  4. iwa/core/__init__.py +1 -0
  5. iwa/core/chain/__init__.py +68 -0
  6. iwa/core/chain/errors.py +47 -0
  7. iwa/core/chain/interface.py +514 -0
  8. iwa/core/chain/manager.py +38 -0
  9. iwa/core/chain/models.py +128 -0
  10. iwa/core/chain/rate_limiter.py +193 -0
  11. iwa/core/cli.py +210 -0
  12. iwa/core/constants.py +28 -0
  13. iwa/core/contracts/__init__.py +1 -0
  14. iwa/core/contracts/contract.py +297 -0
  15. iwa/core/contracts/erc20.py +79 -0
  16. iwa/core/contracts/multisend.py +71 -0
  17. iwa/core/db.py +317 -0
  18. iwa/core/keys.py +361 -0
  19. iwa/core/mnemonic.py +385 -0
  20. iwa/core/models.py +344 -0
  21. iwa/core/monitor.py +209 -0
  22. iwa/core/plugins.py +45 -0
  23. iwa/core/pricing.py +91 -0
  24. iwa/core/services/__init__.py +17 -0
  25. iwa/core/services/account.py +57 -0
  26. iwa/core/services/balance.py +113 -0
  27. iwa/core/services/plugin.py +88 -0
  28. iwa/core/services/safe.py +392 -0
  29. iwa/core/services/transaction.py +172 -0
  30. iwa/core/services/transfer/__init__.py +166 -0
  31. iwa/core/services/transfer/base.py +260 -0
  32. iwa/core/services/transfer/erc20.py +247 -0
  33. iwa/core/services/transfer/multisend.py +386 -0
  34. iwa/core/services/transfer/native.py +262 -0
  35. iwa/core/services/transfer/swap.py +326 -0
  36. iwa/core/settings.py +95 -0
  37. iwa/core/tables.py +60 -0
  38. iwa/core/test.py +27 -0
  39. iwa/core/tests/test_wallet.py +255 -0
  40. iwa/core/types.py +59 -0
  41. iwa/core/ui.py +99 -0
  42. iwa/core/utils.py +59 -0
  43. iwa/core/wallet.py +380 -0
  44. iwa/plugins/__init__.py +1 -0
  45. iwa/plugins/gnosis/__init__.py +5 -0
  46. iwa/plugins/gnosis/cow/__init__.py +6 -0
  47. iwa/plugins/gnosis/cow/quotes.py +148 -0
  48. iwa/plugins/gnosis/cow/swap.py +403 -0
  49. iwa/plugins/gnosis/cow/types.py +20 -0
  50. iwa/plugins/gnosis/cow_utils.py +44 -0
  51. iwa/plugins/gnosis/plugin.py +68 -0
  52. iwa/plugins/gnosis/safe.py +157 -0
  53. iwa/plugins/gnosis/tests/test_cow.py +227 -0
  54. iwa/plugins/gnosis/tests/test_safe.py +100 -0
  55. iwa/plugins/olas/__init__.py +5 -0
  56. iwa/plugins/olas/constants.py +106 -0
  57. iwa/plugins/olas/contracts/activity_checker.py +93 -0
  58. iwa/plugins/olas/contracts/base.py +10 -0
  59. iwa/plugins/olas/contracts/mech.py +49 -0
  60. iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
  61. iwa/plugins/olas/contracts/service.py +215 -0
  62. iwa/plugins/olas/contracts/staking.py +403 -0
  63. iwa/plugins/olas/importer.py +736 -0
  64. iwa/plugins/olas/mech_reference.py +135 -0
  65. iwa/plugins/olas/models.py +110 -0
  66. iwa/plugins/olas/plugin.py +243 -0
  67. iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
  68. iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
  69. iwa/plugins/olas/service_manager/__init__.py +60 -0
  70. iwa/plugins/olas/service_manager/base.py +113 -0
  71. iwa/plugins/olas/service_manager/drain.py +336 -0
  72. iwa/plugins/olas/service_manager/lifecycle.py +839 -0
  73. iwa/plugins/olas/service_manager/mech.py +322 -0
  74. iwa/plugins/olas/service_manager/staking.py +530 -0
  75. iwa/plugins/olas/tests/conftest.py +30 -0
  76. iwa/plugins/olas/tests/test_importer.py +128 -0
  77. iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
  78. iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
  79. iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
  80. iwa/plugins/olas/tests/test_olas_integration.py +561 -0
  81. iwa/plugins/olas/tests/test_olas_models.py +144 -0
  82. iwa/plugins/olas/tests/test_olas_view.py +258 -0
  83. iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
  84. iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
  85. iwa/plugins/olas/tests/test_plugin.py +70 -0
  86. iwa/plugins/olas/tests/test_plugin_full.py +212 -0
  87. iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
  88. iwa/plugins/olas/tests/test_service_manager.py +1065 -0
  89. iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
  90. iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
  91. iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
  92. iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
  93. iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
  94. iwa/plugins/olas/tests/test_service_staking.py +342 -0
  95. iwa/plugins/olas/tests/test_staking_integration.py +269 -0
  96. iwa/plugins/olas/tests/test_staking_validation.py +109 -0
  97. iwa/plugins/olas/tui/__init__.py +1 -0
  98. iwa/plugins/olas/tui/olas_view.py +952 -0
  99. iwa/tools/check_profile.py +67 -0
  100. iwa/tools/release.py +111 -0
  101. iwa/tools/reset_env.py +111 -0
  102. iwa/tools/reset_tenderly.py +362 -0
  103. iwa/tools/restore_backup.py +82 -0
  104. iwa/tui/__init__.py +1 -0
  105. iwa/tui/app.py +174 -0
  106. iwa/tui/modals/__init__.py +5 -0
  107. iwa/tui/modals/base.py +406 -0
  108. iwa/tui/rpc.py +63 -0
  109. iwa/tui/screens/__init__.py +1 -0
  110. iwa/tui/screens/wallets.py +749 -0
  111. iwa/tui/tests/test_app.py +125 -0
  112. iwa/tui/tests/test_rpc.py +139 -0
  113. iwa/tui/tests/test_wallets_refactor.py +30 -0
  114. iwa/tui/tests/test_widgets.py +123 -0
  115. iwa/tui/widgets/__init__.py +5 -0
  116. iwa/tui/widgets/base.py +100 -0
  117. iwa/tui/workers.py +42 -0
  118. iwa/web/dependencies.py +76 -0
  119. iwa/web/models.py +76 -0
  120. iwa/web/routers/accounts.py +115 -0
  121. iwa/web/routers/olas/__init__.py +24 -0
  122. iwa/web/routers/olas/admin.py +169 -0
  123. iwa/web/routers/olas/funding.py +135 -0
  124. iwa/web/routers/olas/general.py +29 -0
  125. iwa/web/routers/olas/services.py +378 -0
  126. iwa/web/routers/olas/staking.py +341 -0
  127. iwa/web/routers/state.py +65 -0
  128. iwa/web/routers/swap.py +617 -0
  129. iwa/web/routers/transactions.py +153 -0
  130. iwa/web/server.py +155 -0
  131. iwa/web/tests/test_web_endpoints.py +713 -0
  132. iwa/web/tests/test_web_olas.py +430 -0
  133. iwa/web/tests/test_web_swap.py +103 -0
  134. iwa-0.0.1a2.dist-info/METADATA +234 -0
  135. iwa-0.0.1a2.dist-info/RECORD +186 -0
  136. iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
  137. iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
  138. iwa-0.0.1a2.dist-info/top_level.txt +4 -0
  139. tests/legacy_cow.py +248 -0
  140. tests/legacy_safe.py +93 -0
  141. tests/legacy_transaction_retry_logic.py +51 -0
  142. tests/legacy_tui.py +440 -0
  143. tests/legacy_wallets_screen.py +554 -0
  144. tests/legacy_web.py +243 -0
  145. tests/test_account_service.py +120 -0
  146. tests/test_balance_service.py +186 -0
  147. tests/test_chain.py +490 -0
  148. tests/test_chain_interface.py +210 -0
  149. tests/test_cli.py +139 -0
  150. tests/test_contract.py +195 -0
  151. tests/test_db.py +180 -0
  152. tests/test_drain_coverage.py +174 -0
  153. tests/test_erc20.py +95 -0
  154. tests/test_gnosis_plugin.py +111 -0
  155. tests/test_keys.py +449 -0
  156. tests/test_legacy_wallet.py +1285 -0
  157. tests/test_main.py +13 -0
  158. tests/test_mnemonic.py +217 -0
  159. tests/test_modals.py +109 -0
  160. tests/test_models.py +213 -0
  161. tests/test_monitor.py +202 -0
  162. tests/test_multisend.py +84 -0
  163. tests/test_plugin_service.py +119 -0
  164. tests/test_pricing.py +143 -0
  165. tests/test_rate_limiter.py +199 -0
  166. tests/test_reset_tenderly.py +202 -0
  167. tests/test_rpc_view.py +73 -0
  168. tests/test_safe_coverage.py +139 -0
  169. tests/test_safe_service.py +168 -0
  170. tests/test_service_manager_integration.py +61 -0
  171. tests/test_service_manager_structure.py +31 -0
  172. tests/test_service_transaction.py +176 -0
  173. tests/test_staking_router.py +71 -0
  174. tests/test_staking_simple.py +31 -0
  175. tests/test_tables.py +76 -0
  176. tests/test_transaction_service.py +161 -0
  177. tests/test_transfer_multisend.py +179 -0
  178. tests/test_transfer_native.py +220 -0
  179. tests/test_transfer_security.py +93 -0
  180. tests/test_transfer_structure.py +37 -0
  181. tests/test_transfer_swap_unit.py +155 -0
  182. tests/test_ui_coverage.py +66 -0
  183. tests/test_utils.py +53 -0
  184. tests/test_workers.py +91 -0
  185. tools/verify_drain.py +183 -0
  186. __init__.py +0 -2
  187. hello.py +0 -6
  188. iwa-0.0.0.dist-info/METADATA +0 -10
  189. iwa-0.0.0.dist-info/RECORD +0 -6
  190. iwa-0.0.0.dist-info/top_level.txt +0 -2
  191. {iwa-0.0.0.dist-info → iwa-0.0.1a2.dist-info}/WHEEL +0 -0
tests/test_monitor.py ADDED
@@ -0,0 +1,202 @@
1
+ from unittest.mock import MagicMock, PropertyMock, patch
2
+
3
+ import pytest
4
+ from web3 import Web3
5
+
6
+ from iwa.core.monitor import EventMonitor
7
+
8
+
9
+ @pytest.fixture
10
+ def mock_chain_interfaces():
11
+ with patch("iwa.core.monitor.ChainInterfaces") as mock:
12
+ instance = mock.return_value
13
+ gnosis_interface = MagicMock()
14
+ gnosis_interface.chain.name = "Gnosis"
15
+ gnosis_interface.chain.rpc = "https://rpc"
16
+ gnosis_interface.web3 = MagicMock()
17
+ instance.get.return_value = gnosis_interface
18
+ yield instance
19
+
20
+
21
+ @pytest.fixture
22
+ def mock_callback():
23
+ return MagicMock()
24
+
25
+
26
+ def test_monitor_init_success(mock_chain_interfaces, mock_callback):
27
+ chain_interface = mock_chain_interfaces.get.return_value
28
+ chain_interface.web3.eth.block_number = 100
29
+
30
+ monitor = EventMonitor(["0x1234567890123456789012345678901234567890"], mock_callback)
31
+
32
+ assert monitor.last_checked_block == 100
33
+ assert monitor.callback == mock_callback
34
+ assert len(monitor.addresses) == 1
35
+
36
+
37
+ def test_monitor_init_rpc_fail(mock_chain_interfaces, mock_callback):
38
+ chain_interface = mock_chain_interfaces.get.return_value
39
+ # Use PropertyMock to raise exception on attribute access
40
+ type(chain_interface.web3.eth).block_number = PropertyMock(side_effect=Exception("RPC Error"))
41
+
42
+ monitor = EventMonitor(["0x1234567890123456789012345678901234567890"], mock_callback)
43
+ # Should catch exception and set to 0
44
+ assert monitor.last_checked_block == 0
45
+
46
+
47
+ def test_monitor_init_no_rpc(mock_chain_interfaces, mock_callback):
48
+ chain_interface = mock_chain_interfaces.get.return_value
49
+ chain_interface.chain.rpc = ""
50
+
51
+ monitor = EventMonitor(["0x1234567890123456789012345678901234567890"], mock_callback)
52
+ assert monitor.last_checked_block == 0
53
+
54
+
55
+ def test_start_no_rpc(mock_chain_interfaces, mock_callback):
56
+ chain_interface = mock_chain_interfaces.get.return_value
57
+ chain_interface.chain.rpc = ""
58
+
59
+ monitor = EventMonitor(["0x1234567890123456789012345678901234567890"], mock_callback)
60
+
61
+ # Patch time.sleep to raise SystemExit if called (invoking cleanup/failure), preventing infinite loop
62
+ # SystemExit is not caught by 'except Exception'
63
+ with patch("time.sleep", side_effect=SystemExit):
64
+ monitor.start()
65
+
66
+ assert monitor.running is False
67
+
68
+
69
+ def test_check_activity_no_new_block(mock_chain_interfaces, mock_callback):
70
+ chain_interface = mock_chain_interfaces.get.return_value
71
+ chain_interface.web3.eth.block_number = 100
72
+
73
+ monitor = EventMonitor(["0x1234567890123456789012345678901234567890"], mock_callback)
74
+ monitor.last_checked_block = 100
75
+
76
+ monitor.check_activity()
77
+
78
+ mock_callback.assert_not_called()
79
+ assert monitor.last_checked_block == 100
80
+
81
+
82
+ def test_check_activity_block_fetch_failed(mock_chain_interfaces, mock_callback, caplog):
83
+ chain_interface = mock_chain_interfaces.get.return_value
84
+ # Reset property mock if needed or just use consistent mock
85
+
86
+ monitor = EventMonitor(["0x1234567890123456789012345678901234567890"], mock_callback)
87
+
88
+ # We need to mock block_number raising specifically during check_activity
89
+ # Since we can't easily switch PropertyMock on an instance dynamically without patching the class or instance dict
90
+ # Let's just create a monitor where web3.eth is a specific mock
91
+
92
+ # Actually, patch.object on the INSTANCE attribute works for properties if they are data descriptors or if we patch the class.
93
+ # Easier: Just set the side_effect on the PropertyMock if we used one, or re-assign.
94
+ type(chain_interface.web3.eth).block_number = PropertyMock(side_effect=Exception("RPC Fail"))
95
+
96
+ monitor.check_activity()
97
+
98
+ assert "Failed to get block number" in caplog.text
99
+
100
+
101
+ def test_check_activity_new_native_tx(mock_chain_interfaces, mock_callback):
102
+ chain_interface = mock_chain_interfaces.get.return_value
103
+ chain_interface.web3.eth.block_number = 101
104
+
105
+ # Mock Block
106
+ block = MagicMock()
107
+ block.timestamp = 12345
108
+ # Transaction matching address
109
+ tx = {
110
+ "hash": b"hash",
111
+ "from": "0x1234567890123456789012345678901234567890",
112
+ "to": "0x0000000000000000000000000000000000000000",
113
+ "value": 100,
114
+ }
115
+ block.transactions = [tx]
116
+
117
+ chain_interface.web3.eth.get_block.return_value = block
118
+ chain_interface.web3.eth.get_logs.return_value = [] # No logs
119
+
120
+ monitor = EventMonitor(["0x1234567890123456789012345678901234567890"], mock_callback)
121
+ monitor.last_checked_block = 100
122
+
123
+ monitor.check_activity()
124
+
125
+ assert monitor.last_checked_block == 101
126
+ mock_callback.assert_called_once()
127
+ args, _ = mock_callback.call_args
128
+ found_txs = args[0]
129
+ assert len(found_txs) == 1
130
+ assert found_txs[0]["token"] == "NATIVE"
131
+ assert found_txs[0]["from"] == "0x1234567890123456789012345678901234567890" # Checksummed
132
+
133
+
134
+ def test_check_activity_hash_in_block(mock_chain_interfaces, mock_callback):
135
+ # Case where get_block returns tx hash strings instead of objects
136
+ chain_interface = mock_chain_interfaces.get.return_value
137
+ chain_interface.web3.eth.block_number = 101
138
+
139
+ monitor = EventMonitor(["0x1234567890123456789012345678901234567890"], mock_callback)
140
+ monitor.last_checked_block = 100
141
+
142
+ block = MagicMock()
143
+ block.timestamp = 12345
144
+ block.transactions = [b"hash_bytes"]
145
+ chain_interface.web3.eth.get_block.return_value = block
146
+
147
+ tx_obj = {
148
+ "hash": b"hash_bytes",
149
+ "from": "0x1234567890123456789012345678901234567890",
150
+ "to": "0x0000000000000000000000000000000000000000",
151
+ "value": 100,
152
+ }
153
+ chain_interface.web3.eth.get_transaction.return_value = tx_obj
154
+ chain_interface.web3.eth.get_logs.return_value = []
155
+
156
+ monitor.check_activity()
157
+
158
+ chain_interface.web3.eth.get_transaction.assert_called_with(b"hash_bytes")
159
+ mock_callback.assert_called()
160
+
161
+
162
+ def test_check_activity_logs(mock_chain_interfaces, mock_callback):
163
+ chain_interface = mock_chain_interfaces.get.return_value
164
+ chain_interface.web3.eth.block_number = 101
165
+ chain_interface.web3.eth.get_block.return_value = MagicMock(transactions=[])
166
+
167
+ # Mock Log matching address
168
+
169
+ my_addr = "0x1234567890123456789012345678901234567890".lower()
170
+ monitor = EventMonitor([my_addr], mock_callback)
171
+ monitor.last_checked_block = 100
172
+
173
+ # 20 bytes address
174
+ addr_bytes = Web3.to_bytes(hexstr=my_addr) # 20 bytes
175
+ padded_addr_bytes = b"\x00" * 12 + addr_bytes # 32 bytes
176
+
177
+ log = {
178
+ "topics": [
179
+ b"sig",
180
+ b"\x00" * 32, # from (don't care)
181
+ padded_addr_bytes, # to (me) -- This MUST match the padded address logic in monitor.py
182
+ ],
183
+ "transactionHash": MagicMock(hex=lambda: "0xloghash"),
184
+ "address": "0xContractAddr",
185
+ }
186
+
187
+ chain_interface.web3.eth.get_logs.side_effect = [[], [log]] # sent, received
188
+
189
+ monitor.check_activity()
190
+
191
+ mock_callback.assert_called()
192
+ found = mock_callback.call_args[0][0]
193
+ assert len(found) == 1
194
+ assert found[0]["token"] == "TOKEN"
195
+ assert found[0]["to"] == my_addr
196
+
197
+
198
+ def test_stop(mock_chain_interfaces, mock_callback):
199
+ monitor = EventMonitor(["0x1234567890123456789012345678901234567890"], mock_callback)
200
+ monitor.running = True
201
+ monitor.stop()
202
+ assert monitor.running is False
@@ -0,0 +1,84 @@
1
+ from unittest.mock import patch
2
+
3
+ import pytest
4
+ from safe_eth.safe import SafeOperationEnum
5
+
6
+ from iwa.core.contracts.multisend import MultiSendCallOnlyContract, MultiSendContract
7
+
8
+
9
+ @pytest.fixture
10
+ def mock_contract_instance():
11
+ with (
12
+ patch("iwa.core.contracts.contract.ContractInstance.__init__", return_value=None),
13
+ patch("iwa.core.contracts.contract.ContractInstance.call") as mock_call,
14
+ patch("iwa.core.contracts.contract.ContractInstance.prepare_transaction") as mock_prep,
15
+ ):
16
+ yield mock_call, mock_prep
17
+
18
+
19
+ def test_encode_data():
20
+ tx = {
21
+ "operation": SafeOperationEnum.CALL,
22
+ "to": "0x1111111111111111111111111111111111111111",
23
+ "value": 100,
24
+ "data": b"\x01\x02",
25
+ }
26
+ encoded = MultiSendCallOnlyContract.encode_data(tx)
27
+ # Operation (1 byte) + To (20 bytes) + Value (32 bytes) + Data Length (32 bytes) + Data (2 bytes)
28
+ # But wait, implementation uses HexBytes formatting which might produce different output if not careful.
29
+ # Let's check length.
30
+ # 1 + 20 + 32 + 32 + 2 = 87 bytes.
31
+ assert len(encoded) == 87
32
+ assert encoded[0] == 0 # CALL is 0
33
+
34
+
35
+ def test_to_bytes():
36
+ tx1 = {
37
+ "operation": SafeOperationEnum.CALL,
38
+ "to": "0x1111111111111111111111111111111111111111",
39
+ "value": 100,
40
+ "data": b"\x01",
41
+ }
42
+ tx2 = {
43
+ "operation": SafeOperationEnum.DELEGATE_CALL,
44
+ "to": "0x2222222222222222222222222222222222222222",
45
+ "value": 0,
46
+ "data": b"",
47
+ }
48
+ encoded = MultiSendCallOnlyContract.to_bytes([tx1, tx2])
49
+ # tx1: 1+20+32+32+1 = 86
50
+ # tx2: 1+20+32+32+0 = 85
51
+ # Total: 171
52
+ assert len(encoded) == 171
53
+
54
+
55
+ def test_prepare_tx(mock_contract_instance):
56
+ mock_call, mock_prep = mock_contract_instance
57
+ mock_prep.return_value = {"data": "0x"}
58
+
59
+ multisend = MultiSendCallOnlyContract("0xMulti", "gnosis")
60
+
61
+ transactions = [
62
+ {
63
+ "operation": SafeOperationEnum.CALL,
64
+ "to": "0x1111111111111111111111111111111111111111",
65
+ "value": 100,
66
+ "data": b"",
67
+ }
68
+ ]
69
+
70
+ tx = multisend.prepare_tx("0xFrom", transactions)
71
+ assert tx == {"data": "0x"}
72
+
73
+ mock_prep.assert_called_once()
74
+ call_args = mock_prep.call_args[1]
75
+ assert call_args["method_name"] == "multiSend"
76
+ assert "encoded_multisend_data" in call_args["method_kwargs"]
77
+ assert call_args["tx_params"]["from"] == "0xFrom"
78
+ assert call_args["tx_params"]["value"] == 100
79
+
80
+
81
+ def test_multisend_contract_init(mock_contract_instance):
82
+ # Just verify it can be instantiated and has correct name
83
+ ms = MultiSendContract("0xMulti", "gnosis")
84
+ assert ms.name == "multisend"
@@ -0,0 +1,119 @@
1
+ """Tests for PluginService."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ from iwa.core.plugins import Plugin
6
+ from iwa.core.services.plugin import PluginService
7
+
8
+
9
+ class MockPlugin(Plugin):
10
+ """Mock plugin for testing."""
11
+
12
+ @property
13
+ def name(self):
14
+ return "mock_plugin"
15
+
16
+ def get_cli_commands(self):
17
+ return {"mock": lambda: None}
18
+
19
+
20
+ def test_plugin_service_init():
21
+ """Test PluginService initialization loads plugins."""
22
+ with patch.object(PluginService, "_load_plugins"):
23
+ service = PluginService()
24
+ assert service.plugins_package == "iwa.plugins"
25
+
26
+
27
+ def test_discover_plugins_import_error():
28
+ """Test _discover_plugins handles ImportError."""
29
+ with patch.object(PluginService, "_load_plugins"):
30
+ service = PluginService()
31
+
32
+ with patch(
33
+ "iwa.core.services.plugin.importlib.import_module",
34
+ side_effect=ImportError("Module not found"),
35
+ ):
36
+ plugins = service._discover_plugins()
37
+ assert plugins == []
38
+
39
+
40
+ def test_discover_plugins_no_path():
41
+ """Test _discover_plugins handles package without __path__."""
42
+ with patch.object(PluginService, "_load_plugins"):
43
+ service = PluginService()
44
+
45
+ with patch("iwa.core.services.plugin.importlib.import_module") as mock_import:
46
+ mock_package = MagicMock(spec=[]) # No __path__
47
+ del mock_package.__path__ # Ensure hasattr returns False
48
+ mock_import.return_value = mock_package
49
+
50
+ plugins = service._discover_plugins()
51
+ assert plugins == []
52
+
53
+
54
+ def test_load_plugins_module_error():
55
+ """Test _load_plugins handles module import errors."""
56
+ with (
57
+ patch.object(PluginService, "_discover_plugins", return_value=["bad_module"]),
58
+ patch(
59
+ "iwa.core.services.plugin.importlib.import_module",
60
+ side_effect=ImportError("Bad module"),
61
+ ),
62
+ ):
63
+ # Should not raise, just log error
64
+ service = PluginService()
65
+
66
+ assert "bad_module" not in service.loaded_plugins
67
+
68
+
69
+ def test_get_plugin():
70
+ """Test get_plugin returns correct plugin."""
71
+ with patch.object(PluginService, "_load_plugins"):
72
+ service = PluginService()
73
+ mock_plugin = MockPlugin()
74
+ service.loaded_plugins["mock_plugin"] = mock_plugin
75
+
76
+ result = service.get_plugin("mock_plugin")
77
+
78
+ assert result == mock_plugin
79
+
80
+
81
+ def test_get_plugin_not_found():
82
+ """Test get_plugin returns None for unknown plugin."""
83
+ with patch.object(PluginService, "_load_plugins"):
84
+ service = PluginService()
85
+
86
+ result = service.get_plugin("nonexistent")
87
+
88
+ assert result is None
89
+
90
+
91
+ def test_get_all_plugins():
92
+ """Test get_all_plugins returns copy of plugins."""
93
+ with patch.object(PluginService, "_load_plugins"):
94
+ service = PluginService()
95
+ mock_plugin = MockPlugin()
96
+ service.loaded_plugins["mock_plugin"] = mock_plugin
97
+
98
+ result = service.get_all_plugins()
99
+
100
+ assert "mock_plugin" in result
101
+ # Verify it's a copy
102
+ result["new_plugin"] = None
103
+ assert "new_plugin" not in service.loaded_plugins
104
+
105
+
106
+ def test_skip_already_loaded():
107
+ """Test _load_plugins skips already loaded plugins."""
108
+ with patch.object(PluginService, "_discover_plugins", return_value=["mock"]):
109
+ with patch.object(PluginService, "_load_plugins"):
110
+ service = PluginService()
111
+
112
+ # Pre-populate loaded plugins with key matching discovered name
113
+ mock_plugin = MockPlugin()
114
+ service.loaded_plugins["mock"] = mock_plugin
115
+
116
+ # Now load - should skip "mock" since it's in loaded_plugins
117
+ with patch("iwa.core.services.plugin.importlib.import_module") as mock_import:
118
+ service._load_plugins()
119
+ mock_import.assert_not_called()
tests/test_pricing.py ADDED
@@ -0,0 +1,143 @@
1
+ from datetime import timedelta
2
+ from unittest.mock import patch
3
+
4
+ import pytest
5
+
6
+ from iwa.core.pricing import PriceService
7
+
8
+
9
+ @pytest.fixture
10
+ def mock_secrets():
11
+ with patch("iwa.core.pricing.settings") as mock:
12
+ mock.coingecko_api_key.get_secret_value.return_value = "test_api_key"
13
+ yield mock
14
+
15
+
16
+ @pytest.fixture
17
+ def price_service(mock_secrets):
18
+ return PriceService()
19
+
20
+
21
+ def test_get_token_price_success(price_service):
22
+ with patch("iwa.core.pricing.requests.get") as mock_get:
23
+ mock_get.return_value.status_code = 200
24
+ mock_get.return_value.json.return_value = {"ethereum": {"eur": 2000.50}}
25
+
26
+ price = price_service.get_token_price("ethereum", "eur")
27
+
28
+ assert price == 2000.50
29
+ mock_get.assert_called_once()
30
+ # Verify API key in headers
31
+ args, kwargs = mock_get.call_args
32
+ assert kwargs["headers"]["x-cg-demo-api-key"] == "test_api_key"
33
+
34
+
35
+ def test_get_token_price_cached(price_service):
36
+ # Pre-populate cache
37
+ from datetime import datetime
38
+
39
+ price_service.cache["ethereum_eur"] = {"price": 100.0, "timestamp": datetime.now()}
40
+
41
+ with patch("iwa.core.pricing.requests.get") as mock_get:
42
+ price = price_service.get_token_price("ethereum", "eur")
43
+ assert price == 100.0
44
+ mock_get.assert_not_called()
45
+
46
+
47
+ def test_get_token_price_cache_expired(price_service):
48
+ # Pre-populate expired cache
49
+ from datetime import datetime
50
+
51
+ price_service.cache["ethereum_eur"] = {
52
+ "price": 100.0,
53
+ "timestamp": datetime.now() - timedelta(minutes=10),
54
+ }
55
+
56
+ with patch("iwa.core.pricing.requests.get") as mock_get:
57
+ mock_get.return_value.status_code = 200
58
+ mock_get.return_value.json.return_value = {"ethereum": {"eur": 200.0}}
59
+
60
+ price = price_service.get_token_price("ethereum", "eur")
61
+ assert price == 200.0
62
+ mock_get.assert_called_once()
63
+
64
+
65
+ def test_get_token_price_api_error(price_service):
66
+ with patch("iwa.core.pricing.requests.get") as mock_get:
67
+ mock_get.side_effect = Exception("API Error")
68
+
69
+ price = price_service.get_token_price("ethereum", "eur")
70
+ assert price is None
71
+
72
+
73
+ def test_get_token_price_key_not_found(price_service):
74
+ with patch("iwa.core.pricing.requests.get") as mock_get:
75
+ mock_get.return_value.status_code = 200
76
+ mock_get.return_value.json.return_value = {} # Empty response
77
+
78
+ price = price_service.get_token_price("ethereum", "eur")
79
+ assert price is None
80
+
81
+
82
+ def test_get_token_price_rate_limit():
83
+ """Test rate limit (429) handling with retries."""
84
+ with patch("iwa.core.pricing.settings") as mock_settings:
85
+ mock_settings.coingecko_api_key = None
86
+
87
+ service = PriceService()
88
+
89
+ with patch("iwa.core.pricing.requests.get") as mock_get, patch("time.sleep"):
90
+ # Return 429 for all attempts
91
+ mock_response = type("Response", (), {"status_code": 429})()
92
+ mock_get.return_value = mock_response
93
+
94
+ price = service.get_token_price("ethereum", "eur")
95
+
96
+ assert price is None
97
+ # Should have tried max_retries + 1 times
98
+ assert mock_get.call_count == 3
99
+
100
+
101
+ def test_get_token_price_rate_limit_then_success():
102
+ """Test rate limit recovery on retry."""
103
+ from unittest.mock import MagicMock
104
+
105
+ with patch("iwa.core.pricing.settings") as mock_settings:
106
+ mock_settings.coingecko_api_key = None
107
+
108
+ service = PriceService()
109
+
110
+ with patch("iwa.core.pricing.requests.get") as mock_get, patch("time.sleep"):
111
+ # First call returns 429, second succeeds
112
+ mock_429 = MagicMock()
113
+ mock_429.status_code = 429
114
+
115
+ mock_ok = MagicMock()
116
+ mock_ok.status_code = 200
117
+ mock_ok.json.return_value = {"ethereum": {"eur": 1500.0}}
118
+
119
+ mock_get.side_effect = [mock_429, mock_ok]
120
+
121
+ price = service.get_token_price("ethereum", "eur")
122
+
123
+ assert price == 1500.0
124
+ assert mock_get.call_count == 2
125
+
126
+
127
+ def test_get_token_price_no_api_key():
128
+ """Test getting price without API key."""
129
+ with patch("iwa.core.pricing.settings") as mock_settings:
130
+ mock_settings.coingecko_api_key = None
131
+
132
+ service = PriceService()
133
+
134
+ with patch("iwa.core.pricing.requests.get") as mock_get:
135
+ mock_get.return_value.status_code = 200
136
+ mock_get.return_value.json.return_value = {"gnosis": {"eur": 100.0}}
137
+
138
+ price = service.get_token_price("gnosis", "eur")
139
+
140
+ assert price == 100.0
141
+ # Verify no API key header
142
+ args, kwargs = mock_get.call_args
143
+ assert "x-cg-demo-api-key" not in kwargs.get("headers", {})