iwa 0.1.3__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.
@@ -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()