Coverage for agentos/tests/test_cache.py: 30%

361 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-07 00:47 +0800

1"""Tests for agentos.core.cache — MemoryCacheBackend, Cache, TieredCache, Serializers.""" 

2 

3import asyncio 

4 

5import pytest 

6 

7from agentos.core.cache import ( 

8 Cache, 

9 CacheConfig, 

10 CacheStats, 

11 JSONSerializer, 

12 MemoryCacheBackend, 

13 PickleSerializer, 

14 RedisCacheBackend, 

15 TieredCache, 

16 _build_signature, 

17 cached, 

18) 

19 

20# ============================================================================ 

21# CacheStats 

22# ============================================================================ 

23 

24class TestCacheStats: 

25 def test_hit_rate_empty(self): 

26 s = CacheStats() 

27 assert s.hit_rate == 0.0 

28 

29 def test_hit_rate_half(self): 

30 s = CacheStats(hits=5, misses=5) 

31 assert s.hit_rate == 0.5 

32 

33 def test_hit_rate_all_hits(self): 

34 s = CacheStats(hits=10) 

35 assert s.hit_rate == 1.0 

36 

37 def test_snapshot(self): 

38 s = CacheStats(hits=3, misses=2, errors=1) 

39 snap = s.snapshot() 

40 assert snap == {"hits": 3, "misses": 2, "sets": 0, "deletes": 0, "evictions": 0, "errors": 1} 

41 

42 

43# ============================================================================ 

44# Serializers 

45# ============================================================================ 

46 

47class TestPickleSerializer: 

48 def test_roundtrip(self): 

49 s = PickleSerializer() 

50 data = {"hello": [1, 2, 3], "nested": {"a": True}} 

51 raw = s.dumps(data) 

52 assert s.loads(raw) == data 

53 

54 def test_primitives(self): 

55 s = PickleSerializer() 

56 assert s.loads(s.dumps(42)) == 42 

57 assert s.loads(s.dumps("hello")) == "hello" 

58 

59 

60class TestJSONSerializer: 

61 def test_roundtrip(self): 

62 s = JSONSerializer() 

63 data = {"hello": "world", "n": 123} 

64 raw = s.dumps(data) 

65 assert s.loads(raw) == data 

66 

67 def test_default_str(self): 

68 s = JSONSerializer() 

69 # bytes aren't JSON-serializable; default=str uses repr 

70 raw = s.dumps({"key": b"value"}) 

71 result = s.loads(raw) 

72 assert isinstance(result["key"], str) 

73 

74 

75# ============================================================================ 

76# MemoryCacheBackend 

77# ============================================================================ 

78 

79class TestMemoryCacheBackend: 

80 @pytest.mark.asyncio 

81 async def test_set_get(self): 

82 b = MemoryCacheBackend() 

83 await b.set("k", b"v") 

84 assert await b.get("k") == b"v" 

85 

86 @pytest.mark.asyncio 

87 async def test_get_missing(self): 

88 b = MemoryCacheBackend() 

89 assert await b.get("missing") is None 

90 

91 @pytest.mark.asyncio 

92 async def test_ttl_expiry(self): 

93 b = MemoryCacheBackend(default_ttl=0.001) 

94 await b.set("k", b"v") 

95 await asyncio.sleep(0.01) 

96 assert await b.get("k") is None 

97 

98 @pytest.mark.asyncio 

99 async def test_custom_ttl(self): 

100 b = MemoryCacheBackend(default_ttl=60) 

101 await b.set("k", b"v", ttl=0.001) 

102 await asyncio.sleep(0.01) 

103 assert await b.get("k") is None 

104 

105 @pytest.mark.asyncio 

106 async def test_no_ttl(self): 

107 b = MemoryCacheBackend(default_ttl=None) 

108 await b.set("k", b"v") 

109 await asyncio.sleep(0.01) 

110 assert await b.get("k") == b"v" 

111 

112 @pytest.mark.asyncio 

113 async def test_delete(self): 

114 b = MemoryCacheBackend() 

115 await b.set("k", b"v") 

116 assert await b.delete("k") is True 

117 assert await b.get("k") is None 

118 

119 @pytest.mark.asyncio 

120 async def test_delete_missing(self): 

121 b = MemoryCacheBackend() 

122 assert await b.delete("missing") is False 

123 

124 @pytest.mark.asyncio 

125 async def test_exists(self): 

126 b = MemoryCacheBackend() 

127 await b.set("k", b"v") 

128 assert await b.exists("k") is True 

129 assert await b.exists("missing") is False 

130 

131 @pytest.mark.asyncio 

132 async def test_clear(self): 

133 b = MemoryCacheBackend() 

134 await b.set("a", b"1") 

135 await b.set("b", b"2") 

136 await b.clear() 

137 assert await b.get("a") is None 

138 assert await b.get("b") is None 

139 

140 @pytest.mark.asyncio 

141 async def test_lru_eviction(self): 

142 b = MemoryCacheBackend(max_size=2) 

143 await b.set("a", b"1") 

144 await b.set("b", b"2") 

145 await b.set("c", b"3") 

146 assert await b.get("a") is None # evicted 

147 assert await b.get("b") == b"2" 

148 assert await b.get("c") == b"3" 

149 

150 @pytest.mark.asyncio 

151 async def test_lru_promotion(self): 

152 b = MemoryCacheBackend(max_size=2) 

153 await b.set("a", b"1") 

154 await b.set("b", b"2") 

155 await b.get("a") # promote a 

156 await b.set("c", b"3") 

157 assert await b.get("a") == b"1" # not evicted 

158 assert await b.get("b") is None # evicted 

159 

160 @pytest.mark.asyncio 

161 async def test_size(self): 

162 b = MemoryCacheBackend(max_size=100) 

163 await b.set("a", b"1") 

164 await b.set("b", b"2") 

165 assert b.size == 2 

166 

167 @pytest.mark.asyncio 

168 async def test_get_many(self): 

169 b = MemoryCacheBackend() 

170 await b.set("a", b"1") 

171 await b.set("b", b"2") 

172 result = await b.get_many(["a", "b", "c"]) 

173 assert result == {"a": b"1", "b": b"2"} 

174 

175 @pytest.mark.asyncio 

176 async def test_set_many(self): 

177 b = MemoryCacheBackend() 

178 await b.set_many({"a": b"1", "b": b"2"}) 

179 assert await b.get("a") == b"1" 

180 assert await b.get("b") == b"2" 

181 

182 @pytest.mark.asyncio 

183 async def test_delete_many(self): 

184 b = MemoryCacheBackend() 

185 await b.set("a", b"1") 

186 await b.set("b", b"2") 

187 count = await b.delete_many(["a", "b", "c"]) 

188 assert count == 2 

189 assert await b.get("a") is None 

190 

191 

192# ============================================================================ 

193# Cache (high-level API) 

194# ============================================================================ 

195 

196class TestCache: 

197 @pytest.mark.asyncio 

198 async def test_set_get(self): 

199 c = Cache[str](MemoryCacheBackend()) 

200 await c.set("k", "hello") 

201 assert await c.get("k") == "hello" 

202 

203 @pytest.mark.asyncio 

204 async def test_get_missing(self): 

205 c = Cache[int](MemoryCacheBackend()) 

206 assert await c.get("k") is None 

207 

208 @pytest.mark.asyncio 

209 async def test_exists(self): 

210 c = Cache[str](MemoryCacheBackend()) 

211 await c.set("k", "v") 

212 assert await c.exists("k") 

213 assert not await c.exists("missing") 

214 

215 @pytest.mark.asyncio 

216 async def test_delete(self): 

217 c = Cache[str](MemoryCacheBackend()) 

218 await c.set("k", "v") 

219 assert await c.delete("k") 

220 assert await c.get("k") is None 

221 

222 @pytest.mark.asyncio 

223 async def test_clear(self): 

224 c = Cache[str](MemoryCacheBackend()) 

225 await c.set("a", "1") 

226 await c.set("b", "2") 

227 await c.clear() 

228 assert await c.get("a") is None 

229 

230 @pytest.mark.asyncio 

231 async def test_get_or_default(self): 

232 c = Cache[str](MemoryCacheBackend()) 

233 assert await c.get_or_default("k", "default") == "default" 

234 await c.set("k", "real") 

235 assert await c.get_or_default("k", "default") == "real" 

236 

237 @pytest.mark.asyncio 

238 async def test_get_or_set(self): 

239 c = Cache[int](MemoryCacheBackend()) 

240 call_count = 0 

241 

242 def factory(): 

243 nonlocal call_count 

244 call_count += 1 

245 return 42 

246 

247 v1 = await c.get_or_set("k", factory) 

248 v2 = await c.get_or_set("k", factory) 

249 assert v1 == 42 

250 assert v2 == 42 

251 assert call_count == 1 # factory called once 

252 

253 @pytest.mark.asyncio 

254 async def test_get_or_set_force_refresh(self): 

255 c = Cache[int](MemoryCacheBackend()) 

256 call_count = 0 

257 

258 def factory(): 

259 nonlocal call_count 

260 call_count += 1 

261 return call_count 

262 

263 await c.set("k", 0) 

264 v = await c.get_or_set("k", factory, force_refresh=True) 

265 assert v == 1 

266 

267 @pytest.mark.asyncio 

268 async def test_get_or_set_async_factory(self): 

269 c = Cache[int](MemoryCacheBackend()) 

270 

271 async def factory(): 

272 return 99 

273 

274 v = await c.get_or_set("k", factory) 

275 assert v == 99 

276 

277 @pytest.mark.asyncio 

278 async def test_stats(self): 

279 c = Cache[str](MemoryCacheBackend()) 

280 await c.set("a", "1") 

281 await c.get("a") 

282 await c.get("missing") 

283 assert c.stats.hits == 1 

284 assert c.stats.misses == 1 

285 assert c.stats.sets == 1 

286 

287 @pytest.mark.asyncio 

288 async def test_key_prefix(self): 

289 config = CacheConfig(key_prefix="pfx:") 

290 c = Cache[str](MemoryCacheBackend(), config) 

291 await c.set("k", "v") 

292 assert await c.get("k") == "v" 

293 

294 @pytest.mark.asyncio 

295 async def test_hash_keys(self): 

296 config = CacheConfig(hash_keys=True) 

297 c = Cache[str](MemoryCacheBackend(), config) 

298 await c.set("very_long_key" * 10, "v") 

299 assert await c.get("very_long_key" * 10) == "v" 

300 

301 @pytest.mark.asyncio 

302 async def test_get_many(self): 

303 c = Cache[str](MemoryCacheBackend()) 

304 await c.set("a", "1") 

305 await c.set("b", "2") 

306 result = await c.get_many(["a", "b", "c"]) 

307 assert result == {"a": "1", "b": "2", "c": None} 

308 

309 @pytest.mark.asyncio 

310 async def test_set_many(self): 

311 c = Cache[str](MemoryCacheBackend()) 

312 await c.set_many({"a": "1", "b": "2"}) 

313 assert await c.get("a") == "1" 

314 assert await c.get("b") == "2" 

315 

316 @pytest.mark.asyncio 

317 async def test_delete_many(self): 

318 c = Cache[str](MemoryCacheBackend()) 

319 await c.set("a", "1") 

320 await c.set("b", "2") 

321 count = await c.delete_many(["a", "b", "c"]) 

322 assert count == 2 

323 

324 @pytest.mark.asyncio 

325 async def test_json_serializer(self): 

326 config = CacheConfig(serializer=JSONSerializer()) 

327 c = Cache[dict](MemoryCacheBackend(), config) 

328 await c.set("k", {"a": 1}) 

329 assert await c.get("k") == {"a": 1} 

330 

331 @pytest.mark.asyncio 

332 async def test_pickle_complex(self): 

333 c = Cache[dict](MemoryCacheBackend()) 

334 await c.set("k", {"nested": {1, 2, 3}}) 

335 assert await c.get("k") == {"nested": {1, 2, 3}} 

336 

337 

338# ============================================================================ 

339# TieredCache 

340# ============================================================================ 

341 

342class TestTieredCache: 

343 @pytest.mark.asyncio 

344 async def test_get_l1_hit(self): 

345 l1 = Cache[int](MemoryCacheBackend()) 

346 l2 = Cache[int](MemoryCacheBackend()) 

347 tc = TieredCache(l1, l2) 

348 await l1.set("k", 1) 

349 assert await tc.get("k") == 1 

350 

351 @pytest.mark.asyncio 

352 async def test_get_l2_fallback_and_promote(self): 

353 l1 = Cache[int](MemoryCacheBackend()) 

354 l2 = Cache[int](MemoryCacheBackend()) 

355 tc = TieredCache(l1, l2) 

356 await l2.set("k", 42) 

357 # Not in L1, should hit L2 and promote 

358 assert await tc.get("k") == 42 

359 assert await l1.get("k") == 42 # promoted 

360 

361 @pytest.mark.asyncio 

362 async def test_get_l2_no_promote(self): 

363 l1 = Cache[int](MemoryCacheBackend()) 

364 l2 = Cache[int](MemoryCacheBackend()) 

365 tc = TieredCache(l1, l2, promote_on_read=False) 

366 await l2.set("k", 42) 

367 assert await tc.get("k") == 42 

368 assert await l1.get("k") is None # not promoted 

369 

370 @pytest.mark.asyncio 

371 async def test_set(self): 

372 l1 = Cache[int](MemoryCacheBackend()) 

373 l2 = Cache[int](MemoryCacheBackend()) 

374 tc = TieredCache(l1, l2) 

375 await tc.set("k", 99) 

376 assert await l1.get("k") == 99 

377 assert await l2.get("k") == 99 

378 

379 @pytest.mark.asyncio 

380 async def test_delete(self): 

381 l1 = Cache[int](MemoryCacheBackend()) 

382 l2 = Cache[int](MemoryCacheBackend()) 

383 tc = TieredCache(l1, l2) 

384 await tc.set("k", 1) 

385 assert await tc.delete("k") 

386 assert await l1.get("k") is None 

387 assert await l2.get("k") is None 

388 

389 @pytest.mark.asyncio 

390 async def test_clear(self): 

391 l1 = Cache[int](MemoryCacheBackend()) 

392 l2 = Cache[int](MemoryCacheBackend()) 

393 tc = TieredCache(l1, l2) 

394 await tc.set("a", 1) 

395 await tc.clear() 

396 assert await l1.get("a") is None 

397 

398 @pytest.mark.asyncio 

399 async def test_stats(self): 

400 l1 = Cache[int](MemoryCacheBackend()) 

401 l2 = Cache[int](MemoryCacheBackend()) 

402 tc = TieredCache(l1, l2) 

403 stats = tc.stats 

404 assert "l1" in stats 

405 assert "l2" in stats 

406 

407 

408# ============================================================================ 

409# cached decorator 

410# ============================================================================ 

411 

412class TestCachedDecorator: 

413 @pytest.mark.asyncio 

414 async def test_cached(self): 

415 c = Cache[int](MemoryCacheBackend()) 

416 call_count = 0 

417 

418 @cached(c, key_prefix="test") 

419 async def compute(x: int) -> int: 

420 nonlocal call_count 

421 call_count += 1 

422 return x * 2 

423 

424 v1 = await compute(5) 

425 v2 = await compute(5) 

426 assert v1 == 10 

427 assert v2 == 10 

428 assert call_count == 1 

429 

430 @pytest.mark.asyncio 

431 async def test_different_args_bypass_cache(self): 

432 c = Cache[int](MemoryCacheBackend()) 

433 call_count = 0 

434 

435 @cached(c) 

436 async def compute(x: int) -> int: 

437 nonlocal call_count 

438 call_count += 1 

439 return x 

440 

441 await compute(1) 

442 await compute(2) 

443 assert call_count == 2 

444 

445 @pytest.mark.asyncio 

446 async def test_custom_key_builder(self): 

447 c = Cache[str](MemoryCacheBackend()) 

448 call_count = 0 

449 

450 @cached(c, key_builder=lambda user_id: f"user:{user_id}") 

451 async def fetch(user_id: str) -> str: 

452 nonlocal call_count 

453 call_count += 1 

454 return f"data-{user_id}" 

455 

456 v1 = await fetch("u1") 

457 assert v1 == "data-u1" 

458 _ = await fetch("u1") 

459 assert call_count == 1 

460 

461 

462# ============================================================================ 

463# _build_signature 

464# ============================================================================ 

465 

466class TestBuildSignature: 

467 def test_short(self): 

468 sig = _build_signature((1, 2), {"name": "test"}) 

469 assert "1" in sig 

470 assert "name=test" in sig 

471 

472 def test_long_uses_md5(self): 

473 long_arg = "x" * 300 

474 sig = _build_signature((long_arg,), {}) 

475 assert len(sig) == 32 # MD5 hex length 

476 

477 

478# ============================================================================ 

479# RedisCacheBackend (light — import check only) 

480# ============================================================================ 

481 

482class TestRedisCacheBackend: 

483 def test_init(self): 

484 b = RedisCacheBackend(url="redis://localhost:6379/0") 

485 assert b._url == "redis://localhost:6379/0" 

486 

487 def test_key_prefix(self): 

488 b = RedisCacheBackend(prefix="test:") 

489 assert b._key("foo") == "test:foo"