test_month_cache.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. """Unit tests for service_api._month_cache (MonthCache / TTLCache).
  2. Client-side wiring of these caches is covered in test_service_cache_ttl.py
  3. and test_reservation_guards.py; this file tests the cache mechanics directly.
  4. """
  5. import asyncio
  6. import time
  7. from room_cal.service_api._month_cache import MonthCache, TTLCache
  8. from room_cal.service_api._utils import CACHE_TTL
  9. _KEY = (7, 2026)
  10. _EXPIRED = -(CACHE_TTL + 1)
  11. def _counting_fetch(results=None):
  12. calls = []
  13. async def _fetch(m, y):
  14. calls.append((m, y))
  15. if results:
  16. return results.pop(0)
  17. return ['data']
  18. return _fetch, calls
  19. class TestMonthCache:
  20. async def test_fetch_and_cache(self):
  21. fetch, calls = _counting_fetch()
  22. cache = MonthCache(fetch)
  23. assert await cache.get(7, 2026) == ['data']
  24. assert await cache.get(7, 2026) == ['data']
  25. assert calls == [_KEY]
  26. async def test_ttl_expiry_refetches(self):
  27. fetch, calls = _counting_fetch()
  28. cache = MonthCache(fetch)
  29. await cache.get(7, 2026)
  30. cache._fetched_at[_KEY] += _EXPIRED
  31. await cache.get(7, 2026)
  32. assert calls == [_KEY, _KEY]
  33. async def test_missing_timestamp_treated_as_expired(self):
  34. fetch, calls = _counting_fetch()
  35. cache = MonthCache(fetch)
  36. cache._data[_KEY] = ['seeded']
  37. assert await cache.get(7, 2026) == ['data']
  38. assert calls == [_KEY]
  39. async def test_inflight_dedup(self):
  40. calls = []
  41. async def _fetch(m, y):
  42. calls.append((m, y))
  43. await asyncio.sleep(0.01)
  44. return ['data']
  45. cache = MonthCache(_fetch)
  46. results = await asyncio.gather(cache.get(7, 2026), cache.get(7, 2026), cache.get(7, 2026))
  47. assert calls == [_KEY]
  48. assert all(r == ['data'] for r in results)
  49. async def test_suspect_retries_once(self, caplog):
  50. fetch, calls = _counting_fetch(results=[[], ['data']])
  51. cache = MonthCache(fetch, suspect=lambda key, r: not r, label='Test', retry_delay=0)
  52. assert await cache.get(7, 2026) == ['data']
  53. assert calls == [_KEY, _KEY]
  54. assert 'empty or priceless calendar for 2026-07; retrying' in caplog.text
  55. async def test_suspect_persists_returns_bad_data(self, caplog):
  56. fetch, calls = _counting_fetch(results=[[], []])
  57. cache = MonthCache(fetch, suspect=lambda key, r: not r, label='Test', retry_delay=0)
  58. assert await cache.get(7, 2026) == []
  59. assert calls == [_KEY, _KEY]
  60. assert 'still empty/priceless calendar for 2026-07 after retry' in caplog.text
  61. async def test_good_result_skips_retry(self):
  62. fetch, calls = _counting_fetch()
  63. cache = MonthCache(fetch, suspect=lambda key, r: not r, retry_delay=0)
  64. await cache.get(7, 2026)
  65. assert calls == [_KEY]
  66. async def test_revalidate_false_evicts_and_refetches(self):
  67. fetch, calls = _counting_fetch()
  68. seen = []
  69. cache = MonthCache(
  70. fetch,
  71. revalidate=lambda key, cached: False,
  72. on_fetch=lambda key, result, revalidation_failed: seen.append(revalidation_failed),
  73. )
  74. cache._data[_KEY] = ['stale']
  75. cache._fetched_at[_KEY] = time.monotonic()
  76. assert await cache.get(7, 2026) == ['data']
  77. assert calls == [_KEY]
  78. assert seen == [True]
  79. async def test_revalidate_true_serves_cache(self):
  80. fetch, calls = _counting_fetch()
  81. cache = MonthCache(fetch, revalidate=lambda key, cached: True)
  82. cache._data[_KEY] = ['cached']
  83. cache._fetched_at[_KEY] = time.monotonic()
  84. assert await cache.get(7, 2026) == ['cached']
  85. assert calls == []
  86. async def test_on_fetch_flag_false_on_plain_fetch(self):
  87. fetch, _ = _counting_fetch()
  88. seen = []
  89. cache = MonthCache(fetch, on_fetch=lambda key, result, revalidation_failed: seen.append(revalidation_failed))
  90. await cache.get(7, 2026)
  91. assert seen == [False]
  92. async def test_on_evict_called_on_expiry_and_invalidate_not_clear(self):
  93. fetch, _ = _counting_fetch()
  94. evicted = []
  95. cache = MonthCache(fetch, on_evict=evicted.append)
  96. await cache.get(7, 2026)
  97. cache._fetched_at[_KEY] += _EXPIRED
  98. await cache.get(7, 2026)
  99. assert evicted == [_KEY]
  100. cache.invalidate(7, 2026)
  101. assert evicted == [_KEY, _KEY]
  102. await cache.get(7, 2026)
  103. cache.clear()
  104. assert evicted == [_KEY, _KEY]
  105. assert cache._data == {}
  106. class TestTTLCache:
  107. def test_set_get(self):
  108. cache = TTLCache()
  109. cache.set('k', {'v': 1})
  110. assert cache.get('k') == {'v': 1}
  111. def test_missing_key(self):
  112. assert TTLCache().get('nope') is None
  113. def test_expired_entry_dropped(self):
  114. cache = TTLCache()
  115. cache.set('k', {'v': 1})
  116. cache._fetched_at['k'] += _EXPIRED
  117. assert cache.get('k') is None
  118. assert 'k' not in cache._data
  119. def test_missing_timestamp_treated_as_expired(self):
  120. cache = TTLCache()
  121. cache._data['k'] = {'v': 1}
  122. assert cache.get('k') is None
  123. def test_clear(self):
  124. cache = TTLCache()
  125. cache.set('k', {'v': 1})
  126. cache.clear()
  127. assert cache.get('k') is None