iwa 0.0.62__py3-none-any.whl → 0.0.65__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.
- iwa/core/services/transfer/__init__.py +2 -2
- iwa/plugins/olas/models.py +5 -22
- iwa/plugins/olas/service_manager/drain.py +41 -12
- iwa/plugins/olas/service_manager/lifecycle.py +3 -3
- iwa/plugins/olas/tests/test_olas_models.py +5 -5
- iwa/plugins/olas/tests/test_olas_view_actions.py +1 -1
- iwa/plugins/olas/tests/test_service_manager_rewards.py +6 -1
- iwa/plugins/olas/tests/test_service_staking.py +1 -0
- iwa/web/routers/state.py +8 -0
- iwa/web/static/app.js +15 -1
- iwa/web/static/index.html +1 -1
- iwa/web/tests/test_web_olas.py +1 -1
- {iwa-0.0.62.dist-info → iwa-0.0.65.dist-info}/METADATA +1 -1
- {iwa-0.0.62.dist-info → iwa-0.0.65.dist-info}/RECORD +22 -21
- tests/test_chainlist_enrichment.py +233 -0
- tests/test_contract_cache.py +253 -0
- tests/test_drain_coverage.py +265 -3
- tests/test_staking_simple.py +478 -6
- {iwa-0.0.62.dist-info → iwa-0.0.65.dist-info}/WHEEL +0 -0
- {iwa-0.0.62.dist-info → iwa-0.0.65.dist-info}/entry_points.txt +0 -0
- {iwa-0.0.62.dist-info → iwa-0.0.65.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.0.62.dist-info → iwa-0.0.65.dist-info}/top_level.txt +0 -0
|
@@ -352,3 +352,236 @@ class TestEnrichFromChainlist:
|
|
|
352
352
|
|
|
353
353
|
# Already at MAX_RPCS=20, ChainlistRPC should not be called
|
|
354
354
|
mock_cl_cls.assert_not_called()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class TestRPCNode:
|
|
358
|
+
"""Test RPCNode dataclass."""
|
|
359
|
+
|
|
360
|
+
def test_is_tracking_privacy(self):
|
|
361
|
+
"""Test is_tracking returns True for privacy tracking."""
|
|
362
|
+
node = RPCNode(url="https://example.com", is_working=True, privacy="privacy")
|
|
363
|
+
assert node.is_tracking is True
|
|
364
|
+
|
|
365
|
+
def test_is_tracking_limited(self):
|
|
366
|
+
"""Test is_tracking returns True for limited tracking."""
|
|
367
|
+
node = RPCNode(url="https://example.com", is_working=True, tracking="limited")
|
|
368
|
+
assert node.is_tracking is True
|
|
369
|
+
|
|
370
|
+
def test_is_tracking_yes(self):
|
|
371
|
+
"""Test is_tracking returns True for explicit yes tracking."""
|
|
372
|
+
node = RPCNode(url="https://example.com", is_working=True, tracking="yes")
|
|
373
|
+
assert node.is_tracking is True
|
|
374
|
+
|
|
375
|
+
def test_is_tracking_none(self):
|
|
376
|
+
"""Test is_tracking returns False for no tracking."""
|
|
377
|
+
node = RPCNode(url="https://example.com", is_working=True, tracking="none")
|
|
378
|
+
assert node.is_tracking is False
|
|
379
|
+
|
|
380
|
+
def test_is_tracking_default(self):
|
|
381
|
+
"""Test is_tracking returns False by default."""
|
|
382
|
+
node = RPCNode(url="https://example.com", is_working=True)
|
|
383
|
+
assert node.is_tracking is False
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class TestFilterCandidates:
|
|
387
|
+
"""Test _filter_candidates function."""
|
|
388
|
+
|
|
389
|
+
def test_max_candidates_limit(self):
|
|
390
|
+
"""Test that _filter_candidates respects MAX_CHAINLIST_CANDIDATES."""
|
|
391
|
+
from iwa.core.chainlist import MAX_CHAINLIST_CANDIDATES, _filter_candidates
|
|
392
|
+
|
|
393
|
+
# Create more nodes than MAX_CHAINLIST_CANDIDATES
|
|
394
|
+
nodes = [
|
|
395
|
+
RPCNode(url=f"https://rpc{i}.example.com", is_working=True)
|
|
396
|
+
for i in range(MAX_CHAINLIST_CANDIDATES + 10)
|
|
397
|
+
]
|
|
398
|
+
|
|
399
|
+
result = _filter_candidates(nodes, set())
|
|
400
|
+
|
|
401
|
+
# Should be limited to MAX_CHAINLIST_CANDIDATES
|
|
402
|
+
assert len(result) == MAX_CHAINLIST_CANDIDATES
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class TestChainlistRPCFetchData:
|
|
406
|
+
"""Test ChainlistRPC.fetch_data with caching."""
|
|
407
|
+
|
|
408
|
+
def test_fetch_data_uses_cache(self, tmp_path):
|
|
409
|
+
"""Test fetch_data uses cached data when valid."""
|
|
410
|
+
import json
|
|
411
|
+
from unittest.mock import patch
|
|
412
|
+
|
|
413
|
+
cache_file = tmp_path / "chainlist_rpcs.json"
|
|
414
|
+
cache_data = [{"chainId": 100, "name": "Test", "rpc": []}]
|
|
415
|
+
cache_file.write_text(json.dumps(cache_data))
|
|
416
|
+
|
|
417
|
+
with patch.object(ChainlistRPC, "CACHE_PATH", cache_file):
|
|
418
|
+
with patch("iwa.core.chainlist.requests.Session") as mock_session:
|
|
419
|
+
cl = ChainlistRPC()
|
|
420
|
+
cl.fetch_data()
|
|
421
|
+
|
|
422
|
+
# Should not make network request when cache is valid
|
|
423
|
+
mock_session.return_value.get.assert_not_called()
|
|
424
|
+
assert cl._data == cache_data
|
|
425
|
+
|
|
426
|
+
def test_fetch_data_force_refresh(self, tmp_path):
|
|
427
|
+
"""Test fetch_data ignores cache when force_refresh=True."""
|
|
428
|
+
import json
|
|
429
|
+
|
|
430
|
+
cache_file = tmp_path / "chainlist_rpcs.json"
|
|
431
|
+
cache_data = [{"chainId": 100, "name": "Cached", "rpc": []}]
|
|
432
|
+
cache_file.write_text(json.dumps(cache_data))
|
|
433
|
+
|
|
434
|
+
fresh_data = [{"chainId": 100, "name": "Fresh", "rpc": []}]
|
|
435
|
+
|
|
436
|
+
with patch.object(ChainlistRPC, "CACHE_PATH", cache_file):
|
|
437
|
+
with patch("iwa.core.chainlist.requests.Session") as mock_session_cls:
|
|
438
|
+
mock_session = MagicMock()
|
|
439
|
+
mock_session_cls.return_value.__enter__ = MagicMock(
|
|
440
|
+
return_value=mock_session
|
|
441
|
+
)
|
|
442
|
+
mock_session_cls.return_value.__exit__ = MagicMock(return_value=False)
|
|
443
|
+
mock_resp = MagicMock()
|
|
444
|
+
mock_resp.json.return_value = fresh_data
|
|
445
|
+
mock_session.get.return_value = mock_resp
|
|
446
|
+
|
|
447
|
+
cl = ChainlistRPC()
|
|
448
|
+
cl.fetch_data(force_refresh=True)
|
|
449
|
+
|
|
450
|
+
mock_session.get.assert_called_once()
|
|
451
|
+
assert cl._data == fresh_data
|
|
452
|
+
|
|
453
|
+
def test_fetch_data_network_error_falls_back_to_cache(self, tmp_path):
|
|
454
|
+
"""Test fetch_data falls back to expired cache on network error."""
|
|
455
|
+
import json
|
|
456
|
+
|
|
457
|
+
cache_file = tmp_path / "chainlist_rpcs.json"
|
|
458
|
+
cache_data = [{"chainId": 100, "name": "ExpiredCache", "rpc": []}]
|
|
459
|
+
cache_file.write_text(json.dumps(cache_data))
|
|
460
|
+
|
|
461
|
+
with patch.object(ChainlistRPC, "CACHE_PATH", cache_file):
|
|
462
|
+
# Force cache to be expired by setting CACHE_TTL to 0
|
|
463
|
+
with patch.object(ChainlistRPC, "CACHE_TTL", 0):
|
|
464
|
+
with patch("iwa.core.chainlist.requests.Session") as mock_session_cls:
|
|
465
|
+
mock_session = MagicMock()
|
|
466
|
+
mock_session_cls.return_value.__enter__ = MagicMock(
|
|
467
|
+
return_value=mock_session
|
|
468
|
+
)
|
|
469
|
+
mock_session_cls.return_value.__exit__ = MagicMock(return_value=False)
|
|
470
|
+
mock_session.get.side_effect = requests.RequestException("Network error")
|
|
471
|
+
|
|
472
|
+
cl = ChainlistRPC()
|
|
473
|
+
cl.fetch_data()
|
|
474
|
+
|
|
475
|
+
# Should fall back to expired cache
|
|
476
|
+
assert cl._data == cache_data
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class TestChainlistRPCGetRpcs:
|
|
480
|
+
"""Test ChainlistRPC.get_rpcs and related methods."""
|
|
481
|
+
|
|
482
|
+
def test_get_chain_data_no_data(self):
|
|
483
|
+
"""Test get_chain_data returns None when no data."""
|
|
484
|
+
with patch.object(ChainlistRPC, "fetch_data"):
|
|
485
|
+
cl = ChainlistRPC()
|
|
486
|
+
cl._data = []
|
|
487
|
+
result = cl.get_chain_data(999)
|
|
488
|
+
assert result is None
|
|
489
|
+
|
|
490
|
+
def test_get_chain_data_found(self):
|
|
491
|
+
"""Test get_chain_data returns chain data when found."""
|
|
492
|
+
with patch.object(ChainlistRPC, "fetch_data"):
|
|
493
|
+
cl = ChainlistRPC()
|
|
494
|
+
cl._data = [{"chainId": 100, "name": "Gnosis"}, {"chainId": 1, "name": "Ethereum"}]
|
|
495
|
+
result = cl.get_chain_data(100)
|
|
496
|
+
assert result == {"chainId": 100, "name": "Gnosis"}
|
|
497
|
+
|
|
498
|
+
def test_get_rpcs_parses_nodes(self):
|
|
499
|
+
"""Test get_rpcs parses RPC data into RPCNode objects."""
|
|
500
|
+
with patch.object(ChainlistRPC, "fetch_data"):
|
|
501
|
+
cl = ChainlistRPC()
|
|
502
|
+
cl._data = [
|
|
503
|
+
{
|
|
504
|
+
"chainId": 100,
|
|
505
|
+
"rpc": [
|
|
506
|
+
{"url": "https://rpc1.example.com", "privacy": "privacy"},
|
|
507
|
+
{"url": "https://rpc2.example.com", "tracking": "none"},
|
|
508
|
+
],
|
|
509
|
+
}
|
|
510
|
+
]
|
|
511
|
+
result = cl.get_rpcs(100)
|
|
512
|
+
|
|
513
|
+
assert len(result) == 2
|
|
514
|
+
assert result[0].url == "https://rpc1.example.com"
|
|
515
|
+
assert result[0].privacy == "privacy"
|
|
516
|
+
assert result[1].url == "https://rpc2.example.com"
|
|
517
|
+
assert result[1].tracking == "none"
|
|
518
|
+
|
|
519
|
+
def test_get_rpcs_chain_not_found(self):
|
|
520
|
+
"""Test get_rpcs returns empty list when chain not found."""
|
|
521
|
+
with patch.object(ChainlistRPC, "fetch_data"):
|
|
522
|
+
cl = ChainlistRPC()
|
|
523
|
+
cl._data = [{"chainId": 1, "rpc": []}]
|
|
524
|
+
result = cl.get_rpcs(999)
|
|
525
|
+
assert result == []
|
|
526
|
+
|
|
527
|
+
def test_get_https_rpcs(self):
|
|
528
|
+
"""Test get_https_rpcs filters to HTTPS/HTTP URLs."""
|
|
529
|
+
with patch.object(ChainlistRPC, "fetch_data"):
|
|
530
|
+
cl = ChainlistRPC()
|
|
531
|
+
cl._data = [
|
|
532
|
+
{
|
|
533
|
+
"chainId": 100,
|
|
534
|
+
"rpc": [
|
|
535
|
+
{"url": "https://rpc1.example.com"},
|
|
536
|
+
{"url": "http://rpc2.example.com"},
|
|
537
|
+
{"url": "wss://ws.example.com"},
|
|
538
|
+
],
|
|
539
|
+
}
|
|
540
|
+
]
|
|
541
|
+
result = cl.get_https_rpcs(100)
|
|
542
|
+
|
|
543
|
+
assert len(result) == 2
|
|
544
|
+
assert "https://rpc1.example.com" in result
|
|
545
|
+
assert "http://rpc2.example.com" in result
|
|
546
|
+
assert "wss://ws.example.com" not in result
|
|
547
|
+
|
|
548
|
+
def test_get_wss_rpcs(self):
|
|
549
|
+
"""Test get_wss_rpcs filters to WSS/WS URLs."""
|
|
550
|
+
with patch.object(ChainlistRPC, "fetch_data"):
|
|
551
|
+
cl = ChainlistRPC()
|
|
552
|
+
cl._data = [
|
|
553
|
+
{
|
|
554
|
+
"chainId": 100,
|
|
555
|
+
"rpc": [
|
|
556
|
+
{"url": "https://rpc1.example.com"},
|
|
557
|
+
{"url": "wss://ws.example.com"},
|
|
558
|
+
{"url": "ws://ws2.example.com"},
|
|
559
|
+
],
|
|
560
|
+
}
|
|
561
|
+
]
|
|
562
|
+
result = cl.get_wss_rpcs(100)
|
|
563
|
+
|
|
564
|
+
assert len(result) == 2
|
|
565
|
+
assert "wss://ws.example.com" in result
|
|
566
|
+
assert "ws://ws2.example.com" in result
|
|
567
|
+
assert "https://rpc1.example.com" not in result
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class TestGetValidatedRpcsEdgeCases:
|
|
571
|
+
"""Test edge cases in get_validated_rpcs."""
|
|
572
|
+
|
|
573
|
+
def _make_node(self, url):
|
|
574
|
+
return RPCNode(url=url, is_working=True)
|
|
575
|
+
|
|
576
|
+
@patch.object(ChainlistRPC, "get_rpcs")
|
|
577
|
+
def test_returns_empty_when_all_filtered(self, mock_get_rpcs):
|
|
578
|
+
"""Test returns empty list when all candidates are filtered."""
|
|
579
|
+
mock_get_rpcs.return_value = [
|
|
580
|
+
self._make_node("https://template.com/${API_KEY}"),
|
|
581
|
+
self._make_node("http://insecure.com"), # Not HTTPS
|
|
582
|
+
]
|
|
583
|
+
|
|
584
|
+
cl = ChainlistRPC()
|
|
585
|
+
result = cl.get_validated_rpcs(100, existing_rpcs=[])
|
|
586
|
+
|
|
587
|
+
assert result == []
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Tests for contract instance caching."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture(autouse=True)
|
|
10
|
+
def reset_singleton():
|
|
11
|
+
"""Reset the ContractCache singleton before each test."""
|
|
12
|
+
from iwa.core.contracts.cache import ContractCache
|
|
13
|
+
|
|
14
|
+
ContractCache._instance = None
|
|
15
|
+
yield
|
|
16
|
+
ContractCache._instance = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestContractCache:
|
|
20
|
+
"""Test ContractCache singleton and caching behavior."""
|
|
21
|
+
|
|
22
|
+
def test_singleton_pattern(self):
|
|
23
|
+
"""Test that ContractCache is a singleton."""
|
|
24
|
+
from iwa.core.contracts.cache import ContractCache
|
|
25
|
+
|
|
26
|
+
c1 = ContractCache()
|
|
27
|
+
c2 = ContractCache()
|
|
28
|
+
assert c1 is c2
|
|
29
|
+
|
|
30
|
+
def test_get_contract_creates_new_instance(self):
|
|
31
|
+
"""Test get_contract creates new instance when not cached."""
|
|
32
|
+
from iwa.core.contracts.cache import ContractCache
|
|
33
|
+
|
|
34
|
+
cache = ContractCache()
|
|
35
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
36
|
+
mock_instance = MagicMock()
|
|
37
|
+
mock_cls.return_value = mock_instance
|
|
38
|
+
|
|
39
|
+
result = cache.get_contract(
|
|
40
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
mock_cls.assert_called_once_with(
|
|
44
|
+
"0x1234567890123456789012345678901234567890", chain_name="gnosis"
|
|
45
|
+
)
|
|
46
|
+
assert result is mock_instance
|
|
47
|
+
|
|
48
|
+
def test_get_contract_returns_cached_instance(self):
|
|
49
|
+
"""Test get_contract returns cached instance on second call."""
|
|
50
|
+
from iwa.core.contracts.cache import ContractCache
|
|
51
|
+
|
|
52
|
+
cache = ContractCache()
|
|
53
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
54
|
+
mock_instance = MagicMock()
|
|
55
|
+
mock_cls.return_value = mock_instance
|
|
56
|
+
|
|
57
|
+
result1 = cache.get_contract(
|
|
58
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
59
|
+
)
|
|
60
|
+
result2 = cache.get_contract(
|
|
61
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Should only create once
|
|
65
|
+
mock_cls.assert_called_once()
|
|
66
|
+
assert result1 is result2
|
|
67
|
+
|
|
68
|
+
def test_get_contract_raises_on_empty_address(self):
|
|
69
|
+
"""Test get_contract raises ValueError for empty address."""
|
|
70
|
+
from iwa.core.contracts.cache import ContractCache
|
|
71
|
+
|
|
72
|
+
cache = ContractCache()
|
|
73
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
74
|
+
|
|
75
|
+
with pytest.raises(ValueError, match="Address is required"):
|
|
76
|
+
cache.get_contract(mock_cls, "", "gnosis")
|
|
77
|
+
|
|
78
|
+
def test_get_contract_respects_ttl_expiry(self):
|
|
79
|
+
"""Test get_contract recreates instance after TTL expires."""
|
|
80
|
+
from iwa.core.contracts.cache import ContractCache
|
|
81
|
+
|
|
82
|
+
cache = ContractCache()
|
|
83
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
84
|
+
mock_cls.return_value = MagicMock()
|
|
85
|
+
|
|
86
|
+
# First call
|
|
87
|
+
cache.get_contract(
|
|
88
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis", ttl=0
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Wait for expiry (TTL=0 means immediate expiry)
|
|
92
|
+
time.sleep(0.01)
|
|
93
|
+
|
|
94
|
+
# Second call should create new instance
|
|
95
|
+
cache.get_contract(
|
|
96
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis", ttl=0
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
assert mock_cls.call_count == 2
|
|
100
|
+
|
|
101
|
+
def test_get_if_cached_returns_cached_instance(self):
|
|
102
|
+
"""Test get_if_cached returns cached instance."""
|
|
103
|
+
from iwa.core.contracts.cache import ContractCache
|
|
104
|
+
|
|
105
|
+
cache = ContractCache()
|
|
106
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
107
|
+
mock_instance = MagicMock()
|
|
108
|
+
mock_cls.return_value = mock_instance
|
|
109
|
+
|
|
110
|
+
# First populate cache
|
|
111
|
+
cache.get_contract(
|
|
112
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# get_if_cached should return it
|
|
116
|
+
result = cache.get_if_cached(
|
|
117
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
assert result is mock_instance
|
|
121
|
+
|
|
122
|
+
def test_get_if_cached_returns_none_when_not_cached(self):
|
|
123
|
+
"""Test get_if_cached returns None when not cached."""
|
|
124
|
+
from iwa.core.contracts.cache import ContractCache
|
|
125
|
+
|
|
126
|
+
cache = ContractCache()
|
|
127
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
128
|
+
|
|
129
|
+
result = cache.get_if_cached(
|
|
130
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
assert result is None
|
|
134
|
+
|
|
135
|
+
def test_get_if_cached_returns_none_for_empty_address(self):
|
|
136
|
+
"""Test get_if_cached returns None for empty address."""
|
|
137
|
+
from iwa.core.contracts.cache import ContractCache
|
|
138
|
+
|
|
139
|
+
cache = ContractCache()
|
|
140
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
141
|
+
|
|
142
|
+
result = cache.get_if_cached(mock_cls, "", "gnosis")
|
|
143
|
+
|
|
144
|
+
assert result is None
|
|
145
|
+
|
|
146
|
+
def test_get_if_cached_returns_none_after_expiry(self):
|
|
147
|
+
"""Test get_if_cached returns None after TTL expires."""
|
|
148
|
+
from iwa.core.contracts.cache import ContractCache
|
|
149
|
+
|
|
150
|
+
cache = ContractCache()
|
|
151
|
+
cache.ttl = 0 # Immediate expiry
|
|
152
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
153
|
+
mock_cls.return_value = MagicMock()
|
|
154
|
+
|
|
155
|
+
# Populate cache
|
|
156
|
+
cache.get_contract(
|
|
157
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
time.sleep(0.01)
|
|
161
|
+
|
|
162
|
+
# get_if_cached should return None due to expiry
|
|
163
|
+
result = cache.get_if_cached(
|
|
164
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
assert result is None
|
|
168
|
+
|
|
169
|
+
def test_clear_removes_all_entries(self):
|
|
170
|
+
"""Test clear removes all cached contracts."""
|
|
171
|
+
from iwa.core.contracts.cache import ContractCache
|
|
172
|
+
|
|
173
|
+
cache = ContractCache()
|
|
174
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
175
|
+
mock_cls.return_value = MagicMock()
|
|
176
|
+
|
|
177
|
+
# Populate cache
|
|
178
|
+
cache.get_contract(
|
|
179
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
cache.clear()
|
|
183
|
+
|
|
184
|
+
# get_if_cached should return None
|
|
185
|
+
result = cache.get_if_cached(
|
|
186
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
187
|
+
)
|
|
188
|
+
assert result is None
|
|
189
|
+
|
|
190
|
+
def test_invalidate_removes_specific_entry(self):
|
|
191
|
+
"""Test invalidate removes specific cached contract."""
|
|
192
|
+
from iwa.core.contracts.cache import ContractCache
|
|
193
|
+
|
|
194
|
+
cache = ContractCache()
|
|
195
|
+
mock_cls1 = MagicMock(__name__="Contract1")
|
|
196
|
+
mock_cls2 = MagicMock(__name__="Contract2")
|
|
197
|
+
mock_cls1.return_value = MagicMock()
|
|
198
|
+
mock_cls2.return_value = MagicMock()
|
|
199
|
+
|
|
200
|
+
# Populate cache with two contracts
|
|
201
|
+
cache.get_contract(
|
|
202
|
+
mock_cls1, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
203
|
+
)
|
|
204
|
+
cache.get_contract(
|
|
205
|
+
mock_cls2, "0xABCDEF1234567890123456789012345678901234", "gnosis"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Invalidate only the first one
|
|
209
|
+
cache.invalidate(
|
|
210
|
+
mock_cls1, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# First should be gone
|
|
214
|
+
result1 = cache.get_if_cached(
|
|
215
|
+
mock_cls1, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
216
|
+
)
|
|
217
|
+
assert result1 is None
|
|
218
|
+
|
|
219
|
+
# Second should still exist
|
|
220
|
+
result2 = cache.get_if_cached(
|
|
221
|
+
mock_cls2, "0xABCDEF1234567890123456789012345678901234", "gnosis"
|
|
222
|
+
)
|
|
223
|
+
assert result2 is not None
|
|
224
|
+
|
|
225
|
+
def test_invalidate_nonexistent_does_nothing(self):
|
|
226
|
+
"""Test invalidate does nothing for non-existent entry."""
|
|
227
|
+
from iwa.core.contracts.cache import ContractCache
|
|
228
|
+
|
|
229
|
+
cache = ContractCache()
|
|
230
|
+
mock_cls = MagicMock(__name__="Contract")
|
|
231
|
+
|
|
232
|
+
# Should not raise
|
|
233
|
+
cache.invalidate(
|
|
234
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def test_env_ttl_configuration(self):
|
|
238
|
+
"""Test TTL is configurable via environment variable."""
|
|
239
|
+
from iwa.core.contracts.cache import ContractCache
|
|
240
|
+
|
|
241
|
+
with patch.dict("os.environ", {"IWA_CONTRACT_CACHE_TTL": "7200"}):
|
|
242
|
+
ContractCache._instance = None
|
|
243
|
+
cache = ContractCache()
|
|
244
|
+
assert cache.ttl == 7200
|
|
245
|
+
|
|
246
|
+
def test_invalid_env_ttl_uses_default(self):
|
|
247
|
+
"""Test invalid TTL env var uses default value."""
|
|
248
|
+
from iwa.core.contracts.cache import ContractCache
|
|
249
|
+
|
|
250
|
+
with patch.dict("os.environ", {"IWA_CONTRACT_CACHE_TTL": "invalid"}):
|
|
251
|
+
ContractCache._instance = None
|
|
252
|
+
cache = ContractCache()
|
|
253
|
+
assert cache.ttl == 3600
|