iwa 0.1.4__py3-none-any.whl → 0.1.6__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/plugins/olas/service_manager/base.py +26 -7
- iwa/plugins/olas/service_manager/drain.py +12 -0
- iwa/plugins/olas/service_manager/lifecycle.py +11 -0
- iwa/plugins/olas/service_manager/staking.py +36 -2
- iwa/plugins/olas/tests/test_service_manager_errors.py +3 -3
- iwa/plugins/olas/tests/test_service_staking.py +1 -1
- iwa/web/cache.py +143 -0
- iwa/web/routers/accounts.py +55 -27
- iwa/web/routers/olas/services.py +109 -41
- iwa/web/routers/olas/staking.py +4 -0
- iwa/web/server.py +1 -0
- iwa/web/tests/test_response_cache.py +660 -0
- {iwa-0.1.4.dist-info → iwa-0.1.6.dist-info}/METADATA +1 -1
- {iwa-0.1.4.dist-info → iwa-0.1.6.dist-info}/RECORD +18 -16
- {iwa-0.1.4.dist-info → iwa-0.1.6.dist-info}/WHEEL +0 -0
- {iwa-0.1.4.dist-info → iwa-0.1.6.dist-info}/entry_points.txt +0 -0
- {iwa-0.1.4.dist-info → iwa-0.1.6.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.1.4.dist-info → iwa-0.1.6.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"""Tests for response cache functionality."""
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
from iwa.web.cache import CacheTTL, ResponseCache, response_cache
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestResponseCache(unittest.TestCase):
|
|
10
|
+
"""Tests for the ResponseCache class."""
|
|
11
|
+
|
|
12
|
+
def setUp(self):
|
|
13
|
+
"""Reset cache before each test."""
|
|
14
|
+
response_cache.invalidate()
|
|
15
|
+
|
|
16
|
+
def test_cache_miss_returns_none(self):
|
|
17
|
+
"""Test that cache miss returns None."""
|
|
18
|
+
result = response_cache.get("nonexistent_key", ttl_seconds=60)
|
|
19
|
+
self.assertIsNone(result)
|
|
20
|
+
|
|
21
|
+
def test_cache_set_and_get(self):
|
|
22
|
+
"""Test basic set and get operations."""
|
|
23
|
+
response_cache.set("test_key", {"data": "value"})
|
|
24
|
+
result = response_cache.get("test_key", ttl_seconds=60)
|
|
25
|
+
self.assertEqual(result, {"data": "value"})
|
|
26
|
+
|
|
27
|
+
def test_cache_expiry(self):
|
|
28
|
+
"""Test that cached values expire after TTL."""
|
|
29
|
+
response_cache.set("expiring_key", "value")
|
|
30
|
+
|
|
31
|
+
# Should be available immediately
|
|
32
|
+
result = response_cache.get("expiring_key", ttl_seconds=60)
|
|
33
|
+
self.assertEqual(result, "value")
|
|
34
|
+
|
|
35
|
+
# Should expire with very short TTL
|
|
36
|
+
result = response_cache.get("expiring_key", ttl_seconds=0)
|
|
37
|
+
self.assertIsNone(result)
|
|
38
|
+
|
|
39
|
+
def test_invalidate_all(self):
|
|
40
|
+
"""Test that invalidate() clears all cache."""
|
|
41
|
+
response_cache.set("key1", "value1")
|
|
42
|
+
response_cache.set("key2", "value2")
|
|
43
|
+
|
|
44
|
+
response_cache.invalidate()
|
|
45
|
+
|
|
46
|
+
self.assertIsNone(response_cache.get("key1", 60))
|
|
47
|
+
self.assertIsNone(response_cache.get("key2", 60))
|
|
48
|
+
|
|
49
|
+
def test_invalidate_pattern(self):
|
|
50
|
+
"""Test that invalidate(pattern) only clears matching keys."""
|
|
51
|
+
response_cache.set("service_state:svc1", "state1")
|
|
52
|
+
response_cache.set("service_state:svc2", "state2")
|
|
53
|
+
response_cache.set("balances:svc1", "balance1")
|
|
54
|
+
|
|
55
|
+
# Invalidate only service_state keys
|
|
56
|
+
response_cache.invalidate("service_state:")
|
|
57
|
+
|
|
58
|
+
# Service state should be gone
|
|
59
|
+
self.assertIsNone(response_cache.get("service_state:svc1", 60))
|
|
60
|
+
self.assertIsNone(response_cache.get("service_state:svc2", 60))
|
|
61
|
+
|
|
62
|
+
# Balances should remain
|
|
63
|
+
self.assertEqual(response_cache.get("balances:svc1", 60), "balance1")
|
|
64
|
+
|
|
65
|
+
def test_get_or_compute_caches_result(self):
|
|
66
|
+
"""Test that get_or_compute caches the computed result."""
|
|
67
|
+
call_count = 0
|
|
68
|
+
|
|
69
|
+
def expensive_compute():
|
|
70
|
+
nonlocal call_count
|
|
71
|
+
call_count += 1
|
|
72
|
+
return {"computed": True}
|
|
73
|
+
|
|
74
|
+
# First call should compute
|
|
75
|
+
result1 = response_cache.get_or_compute(
|
|
76
|
+
"computed_key", expensive_compute, ttl_seconds=60
|
|
77
|
+
)
|
|
78
|
+
self.assertEqual(result1, {"computed": True})
|
|
79
|
+
self.assertEqual(call_count, 1)
|
|
80
|
+
|
|
81
|
+
# Second call should use cache
|
|
82
|
+
result2 = response_cache.get_or_compute(
|
|
83
|
+
"computed_key", expensive_compute, ttl_seconds=60
|
|
84
|
+
)
|
|
85
|
+
self.assertEqual(result2, {"computed": True})
|
|
86
|
+
self.assertEqual(call_count, 1) # Still 1, not 2
|
|
87
|
+
|
|
88
|
+
def test_get_or_compute_recomputes_after_expiry(self):
|
|
89
|
+
"""Test that get_or_compute recomputes after cache expires."""
|
|
90
|
+
call_count = 0
|
|
91
|
+
|
|
92
|
+
def expensive_compute():
|
|
93
|
+
nonlocal call_count
|
|
94
|
+
call_count += 1
|
|
95
|
+
return call_count
|
|
96
|
+
|
|
97
|
+
# First call
|
|
98
|
+
result1 = response_cache.get_or_compute(
|
|
99
|
+
"expiring_compute", expensive_compute, ttl_seconds=0
|
|
100
|
+
)
|
|
101
|
+
self.assertEqual(result1, 1)
|
|
102
|
+
|
|
103
|
+
# Immediately expired, should recompute
|
|
104
|
+
result2 = response_cache.get_or_compute(
|
|
105
|
+
"expiring_compute", expensive_compute, ttl_seconds=0
|
|
106
|
+
)
|
|
107
|
+
self.assertEqual(result2, 2)
|
|
108
|
+
|
|
109
|
+
def test_singleton_pattern(self):
|
|
110
|
+
"""Test that ResponseCache is a singleton."""
|
|
111
|
+
cache1 = ResponseCache()
|
|
112
|
+
cache2 = ResponseCache()
|
|
113
|
+
self.assertIs(cache1, cache2)
|
|
114
|
+
|
|
115
|
+
def test_cache_disabled_via_env(self):
|
|
116
|
+
"""Test that cache can be disabled via environment variable."""
|
|
117
|
+
with patch.dict("os.environ", {"IWA_RESPONSE_CACHE": "0"}):
|
|
118
|
+
# Create a new instance to pick up env var
|
|
119
|
+
# Note: singleton means we need to reset the instance
|
|
120
|
+
ResponseCache._instance = None
|
|
121
|
+
disabled_cache = ResponseCache()
|
|
122
|
+
|
|
123
|
+
disabled_cache.set("test", "value")
|
|
124
|
+
result = disabled_cache.get("test", 60)
|
|
125
|
+
|
|
126
|
+
# Should return None when disabled
|
|
127
|
+
self.assertIsNone(result)
|
|
128
|
+
|
|
129
|
+
# Restore for other tests
|
|
130
|
+
ResponseCache._instance = None
|
|
131
|
+
response_cache.__class__._instance = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TestCacheTTLConstants(unittest.TestCase):
|
|
135
|
+
"""Tests for CacheTTL constants."""
|
|
136
|
+
|
|
137
|
+
def test_ttl_values_are_reasonable(self):
|
|
138
|
+
"""Test that TTL values are within reasonable bounds."""
|
|
139
|
+
# Service state should be at least 1 minute
|
|
140
|
+
self.assertGreaterEqual(CacheTTL.SERVICE_STATE, 60)
|
|
141
|
+
|
|
142
|
+
# Balances should be shorter than service state
|
|
143
|
+
self.assertLess(CacheTTL.BALANCES, CacheTTL.SERVICE_STATE)
|
|
144
|
+
|
|
145
|
+
# All values should be positive
|
|
146
|
+
self.assertGreater(CacheTTL.SERVICE_STATE, 0)
|
|
147
|
+
self.assertGreater(CacheTTL.STAKING_STATUS, 0)
|
|
148
|
+
self.assertGreater(CacheTTL.BALANCES, 0)
|
|
149
|
+
self.assertGreater(CacheTTL.ACCOUNTS, 0)
|
|
150
|
+
self.assertGreater(CacheTTL.SERVICE_BASIC, 0)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestServicesCaching(unittest.TestCase):
|
|
154
|
+
"""Tests for services router caching behavior."""
|
|
155
|
+
|
|
156
|
+
def setUp(self):
|
|
157
|
+
"""Reset cache before each test."""
|
|
158
|
+
response_cache.invalidate()
|
|
159
|
+
|
|
160
|
+
def test_service_state_caching_via_cache_api(self):
|
|
161
|
+
"""Test that service state lookups use the cache correctly.
|
|
162
|
+
|
|
163
|
+
This test verifies caching behavior using the cache API directly,
|
|
164
|
+
avoiding the need to mock complex wallet dependencies.
|
|
165
|
+
"""
|
|
166
|
+
call_count = 0
|
|
167
|
+
|
|
168
|
+
def mock_fetch_state():
|
|
169
|
+
nonlocal call_count
|
|
170
|
+
call_count += 1
|
|
171
|
+
return "DEPLOYED"
|
|
172
|
+
|
|
173
|
+
cache_key = "service_state:test_svc"
|
|
174
|
+
|
|
175
|
+
# First call should compute
|
|
176
|
+
result1 = response_cache.get_or_compute(
|
|
177
|
+
cache_key, mock_fetch_state, CacheTTL.SERVICE_STATE
|
|
178
|
+
)
|
|
179
|
+
self.assertEqual(result1, "DEPLOYED")
|
|
180
|
+
self.assertEqual(call_count, 1)
|
|
181
|
+
|
|
182
|
+
# Second call should use cache (not recompute)
|
|
183
|
+
result2 = response_cache.get_or_compute(
|
|
184
|
+
cache_key, mock_fetch_state, CacheTTL.SERVICE_STATE
|
|
185
|
+
)
|
|
186
|
+
self.assertEqual(result2, "DEPLOYED")
|
|
187
|
+
self.assertEqual(call_count, 1) # Still 1, not 2
|
|
188
|
+
|
|
189
|
+
def test_cache_invalidation_on_service_change(self):
|
|
190
|
+
"""Test that caches are properly invalidated after service changes."""
|
|
191
|
+
# Pre-populate cache
|
|
192
|
+
response_cache.set("service_state:test_svc", "DEPLOYED")
|
|
193
|
+
response_cache.set("staking_status:test_svc", {"is_staked": True})
|
|
194
|
+
response_cache.set("balances:test_svc:gnosis", {"agent": {}})
|
|
195
|
+
|
|
196
|
+
# Simulate what stake_service does
|
|
197
|
+
response_cache.invalidate("service_state:test_svc")
|
|
198
|
+
response_cache.invalidate("staking_status:test_svc")
|
|
199
|
+
response_cache.invalidate("balances:test_svc")
|
|
200
|
+
|
|
201
|
+
# All should be invalidated
|
|
202
|
+
self.assertIsNone(response_cache.get("service_state:test_svc", 60))
|
|
203
|
+
self.assertIsNone(response_cache.get("staking_status:test_svc", 60))
|
|
204
|
+
self.assertIsNone(response_cache.get("balances:test_svc:gnosis", 60))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class TestAccountsCaching(unittest.TestCase):
|
|
208
|
+
"""Tests for accounts router caching behavior."""
|
|
209
|
+
|
|
210
|
+
def setUp(self):
|
|
211
|
+
"""Reset cache before each test."""
|
|
212
|
+
response_cache.invalidate()
|
|
213
|
+
|
|
214
|
+
def test_accounts_cache_key_includes_tokens(self):
|
|
215
|
+
"""Test that accounts cache key includes token list for proper isolation."""
|
|
216
|
+
# Different token combinations should have different cache keys
|
|
217
|
+
key1 = f"accounts:gnosis:{'OLAS,WXDAI'}"
|
|
218
|
+
key2 = f"accounts:gnosis:{'OLAS,USDC'}"
|
|
219
|
+
|
|
220
|
+
response_cache.set(key1, [{"address": "0x1"}])
|
|
221
|
+
response_cache.set(key2, [{"address": "0x2"}])
|
|
222
|
+
|
|
223
|
+
# Should be separate cache entries
|
|
224
|
+
self.assertEqual(
|
|
225
|
+
response_cache.get(key1, 60), [{"address": "0x1"}]
|
|
226
|
+
)
|
|
227
|
+
self.assertEqual(
|
|
228
|
+
response_cache.get(key2, 60), [{"address": "0x2"}]
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def test_accounts_cache_invalidation(self):
|
|
232
|
+
"""Test that accounts cache is invalidated on account creation."""
|
|
233
|
+
response_cache.set("accounts:gnosis:native,OLAS", [{"addr": "0x1"}])
|
|
234
|
+
|
|
235
|
+
# Simulate what create_eoa does
|
|
236
|
+
response_cache.invalidate("accounts:")
|
|
237
|
+
|
|
238
|
+
self.assertIsNone(
|
|
239
|
+
response_cache.get("accounts:gnosis:native,OLAS", 60)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class TestRefreshParameter(unittest.TestCase):
|
|
244
|
+
"""Tests for the refresh parameter in web endpoints."""
|
|
245
|
+
|
|
246
|
+
def setUp(self):
|
|
247
|
+
"""Reset cache before each test."""
|
|
248
|
+
response_cache.invalidate()
|
|
249
|
+
|
|
250
|
+
def test_refresh_true_invalidates_and_refetches(self):
|
|
251
|
+
"""Test that refresh=True invalidates cache and fetches fresh data."""
|
|
252
|
+
call_count = 0
|
|
253
|
+
|
|
254
|
+
def fetch_data():
|
|
255
|
+
nonlocal call_count
|
|
256
|
+
call_count += 1
|
|
257
|
+
return f"data_v{call_count}"
|
|
258
|
+
|
|
259
|
+
cache_key = "test:refresh"
|
|
260
|
+
|
|
261
|
+
# First call - caches data_v1
|
|
262
|
+
result1 = response_cache.get_or_compute(cache_key, fetch_data, 300)
|
|
263
|
+
self.assertEqual(result1, "data_v1")
|
|
264
|
+
self.assertEqual(call_count, 1)
|
|
265
|
+
|
|
266
|
+
# Second call without refresh - should return cached data_v1
|
|
267
|
+
result2 = response_cache.get_or_compute(cache_key, fetch_data, 300)
|
|
268
|
+
self.assertEqual(result2, "data_v1")
|
|
269
|
+
self.assertEqual(call_count, 1) # Still 1
|
|
270
|
+
|
|
271
|
+
# Simulate refresh=True: invalidate then fetch
|
|
272
|
+
response_cache.invalidate(cache_key)
|
|
273
|
+
result3 = response_cache.get_or_compute(cache_key, fetch_data, 300)
|
|
274
|
+
self.assertEqual(result3, "data_v2") # Fresh data
|
|
275
|
+
self.assertEqual(call_count, 2) # Now 2
|
|
276
|
+
|
|
277
|
+
def test_refresh_only_affects_requested_service(self):
|
|
278
|
+
"""Test that refresh invalidates only the specific service cache."""
|
|
279
|
+
response_cache.set("service_state:svc1", "DEPLOYED")
|
|
280
|
+
response_cache.set("service_state:svc2", "STAKED")
|
|
281
|
+
response_cache.set("staking_status:svc1", {"rewards": 100})
|
|
282
|
+
|
|
283
|
+
# Refresh svc1 only
|
|
284
|
+
response_cache.invalidate("service_state:svc1")
|
|
285
|
+
response_cache.invalidate("staking_status:svc1")
|
|
286
|
+
|
|
287
|
+
# svc1 caches should be gone
|
|
288
|
+
self.assertIsNone(response_cache.get("service_state:svc1", 300))
|
|
289
|
+
self.assertIsNone(response_cache.get("staking_status:svc1", 300))
|
|
290
|
+
|
|
291
|
+
# svc2 should still be cached
|
|
292
|
+
self.assertEqual(response_cache.get("service_state:svc2", 300), "STAKED")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class TestServiceManagerCachingIntegration(unittest.TestCase):
|
|
296
|
+
"""Tests for ServiceManager caching integration."""
|
|
297
|
+
|
|
298
|
+
def setUp(self):
|
|
299
|
+
"""Reset cache before each test."""
|
|
300
|
+
response_cache.invalidate()
|
|
301
|
+
|
|
302
|
+
def test_force_refresh_pattern(self):
|
|
303
|
+
"""Test the force_refresh pattern used by ServiceManager methods."""
|
|
304
|
+
call_count = 0
|
|
305
|
+
|
|
306
|
+
def get_data_with_cache(force_refresh: bool = False):
|
|
307
|
+
cache_key = "integration:test"
|
|
308
|
+
|
|
309
|
+
if force_refresh:
|
|
310
|
+
response_cache.invalidate(cache_key)
|
|
311
|
+
|
|
312
|
+
def fetch():
|
|
313
|
+
nonlocal call_count
|
|
314
|
+
call_count += 1
|
|
315
|
+
return {"count": call_count}
|
|
316
|
+
|
|
317
|
+
return response_cache.get_or_compute(cache_key, fetch, 300)
|
|
318
|
+
|
|
319
|
+
# Normal calls use cache
|
|
320
|
+
result1 = get_data_with_cache()
|
|
321
|
+
result2 = get_data_with_cache()
|
|
322
|
+
self.assertEqual(result1["count"], 1)
|
|
323
|
+
self.assertEqual(result2["count"], 1)
|
|
324
|
+
self.assertEqual(call_count, 1)
|
|
325
|
+
|
|
326
|
+
# force_refresh=True bypasses cache
|
|
327
|
+
result3 = get_data_with_cache(force_refresh=True)
|
|
328
|
+
self.assertEqual(result3["count"], 2)
|
|
329
|
+
self.assertEqual(call_count, 2)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class TestBalancesCaching(unittest.TestCase):
|
|
333
|
+
"""Tests for balance caching behavior."""
|
|
334
|
+
|
|
335
|
+
def setUp(self):
|
|
336
|
+
"""Reset cache before each test."""
|
|
337
|
+
response_cache.invalidate()
|
|
338
|
+
|
|
339
|
+
def test_balances_cache_key_includes_chain(self):
|
|
340
|
+
"""Test that balance cache keys are chain-specific."""
|
|
341
|
+
response_cache.set("balances:svc1:gnosis", {"native": "1.0"})
|
|
342
|
+
response_cache.set("balances:svc1:ethereum", {"native": "2.0"})
|
|
343
|
+
|
|
344
|
+
# Different chains should have different cached values
|
|
345
|
+
gnosis_bal = response_cache.get("balances:svc1:gnosis", 300)
|
|
346
|
+
eth_bal = response_cache.get("balances:svc1:ethereum", 300)
|
|
347
|
+
|
|
348
|
+
self.assertEqual(gnosis_bal["native"], "1.0")
|
|
349
|
+
self.assertEqual(eth_bal["native"], "2.0")
|
|
350
|
+
|
|
351
|
+
def test_balances_invalidation_by_service(self):
|
|
352
|
+
"""Test that balance invalidation can target specific services."""
|
|
353
|
+
response_cache.set("balances:svc1:gnosis", {"native": "1.0"})
|
|
354
|
+
response_cache.set("balances:svc2:gnosis", {"native": "2.0"})
|
|
355
|
+
|
|
356
|
+
# Invalidate only svc1 balances
|
|
357
|
+
response_cache.invalidate("balances:svc1")
|
|
358
|
+
|
|
359
|
+
# svc1 balance gone, svc2 remains
|
|
360
|
+
self.assertIsNone(response_cache.get("balances:svc1:gnosis", 300))
|
|
361
|
+
self.assertIsNotNone(response_cache.get("balances:svc2:gnosis", 300))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class TestCacheAfterWriteOperations(unittest.TestCase):
|
|
365
|
+
"""Tests for cache invalidation after write operations."""
|
|
366
|
+
|
|
367
|
+
def setUp(self):
|
|
368
|
+
"""Reset cache before each test."""
|
|
369
|
+
response_cache.invalidate()
|
|
370
|
+
|
|
371
|
+
def test_stake_invalidates_relevant_caches(self):
|
|
372
|
+
"""Test that staking invalidates service_state, staking_status, and balances."""
|
|
373
|
+
service_key = "gnosis:123"
|
|
374
|
+
|
|
375
|
+
# Pre-populate caches
|
|
376
|
+
response_cache.set(f"service_state:{service_key}", "DEPLOYED")
|
|
377
|
+
response_cache.set(f"staking_status:{service_key}", {"is_staked": False})
|
|
378
|
+
response_cache.set(f"balances:{service_key}:gnosis", {"native": "1.0"})
|
|
379
|
+
|
|
380
|
+
# Simulate what stake_service does on success
|
|
381
|
+
response_cache.invalidate(f"service_state:{service_key}")
|
|
382
|
+
response_cache.invalidate(f"staking_status:{service_key}")
|
|
383
|
+
response_cache.invalidate(f"balances:{service_key}")
|
|
384
|
+
|
|
385
|
+
# All should be invalidated
|
|
386
|
+
self.assertIsNone(response_cache.get(f"service_state:{service_key}", 300))
|
|
387
|
+
self.assertIsNone(response_cache.get(f"staking_status:{service_key}", 300))
|
|
388
|
+
self.assertIsNone(response_cache.get(f"balances:{service_key}:gnosis", 300))
|
|
389
|
+
|
|
390
|
+
def test_claim_rewards_invalidates_rewards_and_balances(self):
|
|
391
|
+
"""Test that claiming rewards invalidates staking_status and balances."""
|
|
392
|
+
service_key = "gnosis:456"
|
|
393
|
+
|
|
394
|
+
response_cache.set(f"service_state:{service_key}", "DEPLOYED")
|
|
395
|
+
response_cache.set(f"staking_status:{service_key}", {"rewards": 100})
|
|
396
|
+
response_cache.set(f"balances:{service_key}:gnosis", {"olas": "10.0"})
|
|
397
|
+
|
|
398
|
+
# Simulate what claim_rewards does on success
|
|
399
|
+
response_cache.invalidate(f"staking_status:{service_key}")
|
|
400
|
+
response_cache.invalidate(f"balances:{service_key}")
|
|
401
|
+
|
|
402
|
+
# service_state should remain (claim doesn't change state)
|
|
403
|
+
self.assertEqual(
|
|
404
|
+
response_cache.get(f"service_state:{service_key}", 300), "DEPLOYED"
|
|
405
|
+
)
|
|
406
|
+
# staking_status and balances should be invalidated
|
|
407
|
+
self.assertIsNone(response_cache.get(f"staking_status:{service_key}", 300))
|
|
408
|
+
self.assertIsNone(response_cache.get(f"balances:{service_key}:gnosis", 300))
|
|
409
|
+
|
|
410
|
+
def test_create_service_invalidates_all_service_caches(self):
|
|
411
|
+
"""Test that creating a new service invalidates all service caches."""
|
|
412
|
+
# Pre-populate caches for existing services
|
|
413
|
+
response_cache.set("service_state:gnosis:1", "STAKED")
|
|
414
|
+
response_cache.set("service_state:gnosis:2", "DEPLOYED")
|
|
415
|
+
response_cache.set("staking_status:gnosis:1", {"is_staked": True})
|
|
416
|
+
|
|
417
|
+
# Simulate what create_service does - invalidates ALL caches
|
|
418
|
+
response_cache.invalidate("service_state:")
|
|
419
|
+
response_cache.invalidate("staking_status:")
|
|
420
|
+
response_cache.invalidate("balances:")
|
|
421
|
+
|
|
422
|
+
# All service caches should be gone
|
|
423
|
+
self.assertIsNone(response_cache.get("service_state:gnosis:1", 300))
|
|
424
|
+
self.assertIsNone(response_cache.get("service_state:gnosis:2", 300))
|
|
425
|
+
self.assertIsNone(response_cache.get("staking_status:gnosis:1", 300))
|
|
426
|
+
|
|
427
|
+
def test_unstake_invalidates_relevant_caches(self):
|
|
428
|
+
"""Test that unstaking invalidates service_state, staking_status, and balances."""
|
|
429
|
+
service_key = "gnosis:789"
|
|
430
|
+
|
|
431
|
+
# Pre-populate caches
|
|
432
|
+
response_cache.set(f"service_state:{service_key}", "STAKED")
|
|
433
|
+
response_cache.set(f"staking_status:{service_key}", {"is_staked": True})
|
|
434
|
+
response_cache.set(f"balances:{service_key}:gnosis", {"native": "0.5"})
|
|
435
|
+
|
|
436
|
+
# Simulate what unstake does on success
|
|
437
|
+
response_cache.invalidate(f"service_state:{service_key}")
|
|
438
|
+
response_cache.invalidate(f"staking_status:{service_key}")
|
|
439
|
+
response_cache.invalidate(f"balances:{service_key}")
|
|
440
|
+
|
|
441
|
+
# All should be invalidated
|
|
442
|
+
self.assertIsNone(response_cache.get(f"service_state:{service_key}", 300))
|
|
443
|
+
self.assertIsNone(response_cache.get(f"staking_status:{service_key}", 300))
|
|
444
|
+
self.assertIsNone(response_cache.get(f"balances:{service_key}:gnosis", 300))
|
|
445
|
+
|
|
446
|
+
def test_checkpoint_invalidates_staking_status(self):
|
|
447
|
+
"""Test that checkpoint only invalidates staking_status (epoch info)."""
|
|
448
|
+
service_key = "gnosis:321"
|
|
449
|
+
|
|
450
|
+
# Pre-populate caches
|
|
451
|
+
response_cache.set(f"service_state:{service_key}", "STAKED")
|
|
452
|
+
response_cache.set(f"staking_status:{service_key}", {"epoch": 5})
|
|
453
|
+
response_cache.set(f"balances:{service_key}:gnosis", {"native": "1.0"})
|
|
454
|
+
|
|
455
|
+
# Simulate what call_checkpoint does on success
|
|
456
|
+
response_cache.invalidate(f"staking_status:{service_key}")
|
|
457
|
+
|
|
458
|
+
# Only staking_status should be invalidated
|
|
459
|
+
self.assertEqual(
|
|
460
|
+
response_cache.get(f"service_state:{service_key}", 300), "STAKED"
|
|
461
|
+
)
|
|
462
|
+
self.assertIsNone(response_cache.get(f"staking_status:{service_key}", 300))
|
|
463
|
+
self.assertEqual(
|
|
464
|
+
response_cache.get(f"balances:{service_key}:gnosis", 300), {"native": "1.0"}
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class TestLifecycleOperationsCaching(unittest.TestCase):
|
|
469
|
+
"""Tests for cache invalidation after lifecycle operations."""
|
|
470
|
+
|
|
471
|
+
def setUp(self):
|
|
472
|
+
"""Reset cache before each test."""
|
|
473
|
+
response_cache.invalidate()
|
|
474
|
+
|
|
475
|
+
def test_deploy_invalidates_service_state(self):
|
|
476
|
+
"""Test that deploy only invalidates service_state."""
|
|
477
|
+
service_key = "gnosis:100"
|
|
478
|
+
|
|
479
|
+
# Pre-populate caches
|
|
480
|
+
response_cache.set(f"service_state:{service_key}", "FINISHED_REGISTRATION")
|
|
481
|
+
response_cache.set(f"staking_status:{service_key}", {"is_staked": False})
|
|
482
|
+
|
|
483
|
+
# Simulate what deploy does on success
|
|
484
|
+
response_cache.invalidate(f"service_state:{service_key}")
|
|
485
|
+
|
|
486
|
+
# Only service_state should be invalidated
|
|
487
|
+
self.assertIsNone(response_cache.get(f"service_state:{service_key}", 300))
|
|
488
|
+
self.assertEqual(
|
|
489
|
+
response_cache.get(f"staking_status:{service_key}", 300),
|
|
490
|
+
{"is_staked": False},
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
def test_terminate_invalidates_service_state(self):
|
|
494
|
+
"""Test that terminate only invalidates service_state."""
|
|
495
|
+
service_key = "gnosis:200"
|
|
496
|
+
|
|
497
|
+
response_cache.set(f"service_state:{service_key}", "DEPLOYED")
|
|
498
|
+
response_cache.set(f"balances:{service_key}:gnosis", {"native": "0.1"})
|
|
499
|
+
|
|
500
|
+
# Simulate what terminate does on success
|
|
501
|
+
response_cache.invalidate(f"service_state:{service_key}")
|
|
502
|
+
|
|
503
|
+
# Only service_state should be invalidated
|
|
504
|
+
self.assertIsNone(response_cache.get(f"service_state:{service_key}", 300))
|
|
505
|
+
self.assertEqual(
|
|
506
|
+
response_cache.get(f"balances:{service_key}:gnosis", 300), {"native": "0.1"}
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
def test_unbond_invalidates_service_state(self):
|
|
510
|
+
"""Test that unbond only invalidates service_state."""
|
|
511
|
+
service_key = "gnosis:300"
|
|
512
|
+
|
|
513
|
+
response_cache.set(f"service_state:{service_key}", "TERMINATED_BONDED")
|
|
514
|
+
|
|
515
|
+
# Simulate what unbond does on success
|
|
516
|
+
response_cache.invalidate(f"service_state:{service_key}")
|
|
517
|
+
|
|
518
|
+
self.assertIsNone(response_cache.get(f"service_state:{service_key}", 300))
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class TestDrainOperationsCaching(unittest.TestCase):
|
|
522
|
+
"""Tests for cache invalidation after drain operations."""
|
|
523
|
+
|
|
524
|
+
def setUp(self):
|
|
525
|
+
"""Reset cache before each test."""
|
|
526
|
+
response_cache.invalidate()
|
|
527
|
+
|
|
528
|
+
def test_withdraw_rewards_invalidates_balances(self):
|
|
529
|
+
"""Test that withdraw_rewards invalidates balances cache."""
|
|
530
|
+
service_key = "gnosis:400"
|
|
531
|
+
|
|
532
|
+
response_cache.set(f"service_state:{service_key}", "STAKED")
|
|
533
|
+
response_cache.set(f"staking_status:{service_key}", {"rewards": 50})
|
|
534
|
+
response_cache.set(f"balances:{service_key}:gnosis", {"olas": "100.0"})
|
|
535
|
+
|
|
536
|
+
# Simulate what withdraw_rewards does on success
|
|
537
|
+
response_cache.invalidate(f"balances:{service_key}")
|
|
538
|
+
|
|
539
|
+
# service_state and staking_status should remain
|
|
540
|
+
self.assertEqual(
|
|
541
|
+
response_cache.get(f"service_state:{service_key}", 300), "STAKED"
|
|
542
|
+
)
|
|
543
|
+
self.assertEqual(
|
|
544
|
+
response_cache.get(f"staking_status:{service_key}", 300), {"rewards": 50}
|
|
545
|
+
)
|
|
546
|
+
# balances should be invalidated
|
|
547
|
+
self.assertIsNone(response_cache.get(f"balances:{service_key}:gnosis", 300))
|
|
548
|
+
|
|
549
|
+
def test_drain_service_invalidates_balances(self):
|
|
550
|
+
"""Test that drain_service invalidates balances cache."""
|
|
551
|
+
service_key = "gnosis:500"
|
|
552
|
+
|
|
553
|
+
response_cache.set(f"balances:{service_key}:gnosis", {"native": "5.0", "olas": "10.0"})
|
|
554
|
+
|
|
555
|
+
# Simulate what drain_service does on success
|
|
556
|
+
response_cache.invalidate(f"balances:{service_key}")
|
|
557
|
+
|
|
558
|
+
self.assertIsNone(response_cache.get(f"balances:{service_key}:gnosis", 300))
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class TestCacheThreadSafety(unittest.TestCase):
|
|
562
|
+
"""Tests for cache thread safety."""
|
|
563
|
+
|
|
564
|
+
def setUp(self):
|
|
565
|
+
"""Reset cache before each test."""
|
|
566
|
+
response_cache.invalidate()
|
|
567
|
+
|
|
568
|
+
def test_concurrent_set_and_get(self): # noqa: C901
|
|
569
|
+
"""Test that concurrent set/get operations are thread-safe."""
|
|
570
|
+
import threading
|
|
571
|
+
|
|
572
|
+
errors = []
|
|
573
|
+
iterations = 100
|
|
574
|
+
|
|
575
|
+
def writer():
|
|
576
|
+
for i in range(iterations):
|
|
577
|
+
try:
|
|
578
|
+
response_cache.set(f"thread_key:{i}", {"value": i})
|
|
579
|
+
except Exception as e:
|
|
580
|
+
errors.append(f"Writer error: {e}")
|
|
581
|
+
|
|
582
|
+
def reader():
|
|
583
|
+
for i in range(iterations):
|
|
584
|
+
try:
|
|
585
|
+
response_cache.get(f"thread_key:{i}", 300)
|
|
586
|
+
except Exception as e:
|
|
587
|
+
errors.append(f"Reader error: {e}")
|
|
588
|
+
|
|
589
|
+
def invalidator():
|
|
590
|
+
for _ in range(iterations // 10):
|
|
591
|
+
try:
|
|
592
|
+
response_cache.invalidate("thread_key:")
|
|
593
|
+
except Exception as e:
|
|
594
|
+
errors.append(f"Invalidator error: {e}")
|
|
595
|
+
|
|
596
|
+
threads = [
|
|
597
|
+
threading.Thread(target=writer),
|
|
598
|
+
threading.Thread(target=reader),
|
|
599
|
+
threading.Thread(target=writer),
|
|
600
|
+
threading.Thread(target=reader),
|
|
601
|
+
threading.Thread(target=invalidator),
|
|
602
|
+
]
|
|
603
|
+
|
|
604
|
+
for t in threads:
|
|
605
|
+
t.start()
|
|
606
|
+
for t in threads:
|
|
607
|
+
t.join()
|
|
608
|
+
|
|
609
|
+
self.assertEqual(errors, [], f"Thread safety errors: {errors}")
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
class TestCacheEdgeCases(unittest.TestCase):
|
|
613
|
+
"""Tests for cache edge cases."""
|
|
614
|
+
|
|
615
|
+
def setUp(self):
|
|
616
|
+
"""Reset cache before each test."""
|
|
617
|
+
response_cache.invalidate()
|
|
618
|
+
|
|
619
|
+
def test_empty_string_key(self):
|
|
620
|
+
"""Test cache with empty string key."""
|
|
621
|
+
response_cache.set("", "empty_key_value")
|
|
622
|
+
result = response_cache.get("", 300)
|
|
623
|
+
self.assertEqual(result, "empty_key_value")
|
|
624
|
+
|
|
625
|
+
def test_special_characters_in_key(self):
|
|
626
|
+
"""Test cache with special characters in key."""
|
|
627
|
+
key = "service:gnosis:123:special/chars:test"
|
|
628
|
+
response_cache.set(key, {"data": True})
|
|
629
|
+
result = response_cache.get(key, 300)
|
|
630
|
+
self.assertEqual(result, {"data": True})
|
|
631
|
+
|
|
632
|
+
def test_large_value(self):
|
|
633
|
+
"""Test cache with large value."""
|
|
634
|
+
large_data = {"items": list(range(10000))}
|
|
635
|
+
response_cache.set("large_key", large_data)
|
|
636
|
+
result = response_cache.get("large_key", 300)
|
|
637
|
+
self.assertEqual(len(result["items"]), 10000)
|
|
638
|
+
|
|
639
|
+
def test_invalidate_nonexistent_pattern(self):
|
|
640
|
+
"""Test invalidating a pattern that doesn't match anything."""
|
|
641
|
+
response_cache.set("existing_key", "value")
|
|
642
|
+
# Should not raise an error
|
|
643
|
+
response_cache.invalidate("nonexistent_pattern")
|
|
644
|
+
# Original key should still exist
|
|
645
|
+
self.assertEqual(response_cache.get("existing_key", 300), "value")
|
|
646
|
+
|
|
647
|
+
def test_get_or_compute_with_exception(self):
|
|
648
|
+
"""Test get_or_compute when compute function raises exception."""
|
|
649
|
+
def failing_compute():
|
|
650
|
+
raise ValueError("Compute failed")
|
|
651
|
+
|
|
652
|
+
with self.assertRaises(ValueError):
|
|
653
|
+
response_cache.get_or_compute("fail_key", failing_compute, 300)
|
|
654
|
+
|
|
655
|
+
# Key should not be cached after failure
|
|
656
|
+
self.assertIsNone(response_cache.get("fail_key", 300))
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
if __name__ == "__main__":
|
|
660
|
+
unittest.main()
|