| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152 |
- """Unit tests for service_api._month_cache (MonthCache / TTLCache).
- Client-side wiring of these caches is covered in test_service_cache_ttl.py
- and test_reservation_guards.py; this file tests the cache mechanics directly.
- """
- import asyncio
- import time
- from room_cal.service_api._month_cache import MonthCache, TTLCache
- from room_cal.service_api._utils import CACHE_TTL
- _KEY = (7, 2026)
- _EXPIRED = -(CACHE_TTL + 1)
- def _counting_fetch(results=None):
- calls = []
- async def _fetch(m, y):
- calls.append((m, y))
- if results:
- return results.pop(0)
- return ['data']
- return _fetch, calls
- class TestMonthCache:
- async def test_fetch_and_cache(self):
- fetch, calls = _counting_fetch()
- cache = MonthCache(fetch)
- assert await cache.get(7, 2026) == ['data']
- assert await cache.get(7, 2026) == ['data']
- assert calls == [_KEY]
- async def test_ttl_expiry_refetches(self):
- fetch, calls = _counting_fetch()
- cache = MonthCache(fetch)
- await cache.get(7, 2026)
- cache._fetched_at[_KEY] += _EXPIRED
- await cache.get(7, 2026)
- assert calls == [_KEY, _KEY]
- async def test_missing_timestamp_treated_as_expired(self):
- fetch, calls = _counting_fetch()
- cache = MonthCache(fetch)
- cache._data[_KEY] = ['seeded']
- assert await cache.get(7, 2026) == ['data']
- assert calls == [_KEY]
- async def test_inflight_dedup(self):
- calls = []
- async def _fetch(m, y):
- calls.append((m, y))
- await asyncio.sleep(0.01)
- return ['data']
- cache = MonthCache(_fetch)
- results = await asyncio.gather(cache.get(7, 2026), cache.get(7, 2026), cache.get(7, 2026))
- assert calls == [_KEY]
- assert all(r == ['data'] for r in results)
- async def test_suspect_retries_once(self, caplog):
- fetch, calls = _counting_fetch(results=[[], ['data']])
- cache = MonthCache(fetch, suspect=lambda key, r: not r, label='Test', retry_delay=0)
- assert await cache.get(7, 2026) == ['data']
- assert calls == [_KEY, _KEY]
- assert 'empty or priceless calendar for 2026-07; retrying' in caplog.text
- async def test_suspect_persists_returns_bad_data(self, caplog):
- fetch, calls = _counting_fetch(results=[[], []])
- cache = MonthCache(fetch, suspect=lambda key, r: not r, label='Test', retry_delay=0)
- assert await cache.get(7, 2026) == []
- assert calls == [_KEY, _KEY]
- assert 'still empty/priceless calendar for 2026-07 after retry' in caplog.text
- async def test_good_result_skips_retry(self):
- fetch, calls = _counting_fetch()
- cache = MonthCache(fetch, suspect=lambda key, r: not r, retry_delay=0)
- await cache.get(7, 2026)
- assert calls == [_KEY]
- async def test_revalidate_false_evicts_and_refetches(self):
- fetch, calls = _counting_fetch()
- seen = []
- cache = MonthCache(
- fetch,
- revalidate=lambda key, cached: False,
- on_fetch=lambda key, result, revalidation_failed: seen.append(revalidation_failed),
- )
- cache._data[_KEY] = ['stale']
- cache._fetched_at[_KEY] = time.monotonic()
- assert await cache.get(7, 2026) == ['data']
- assert calls == [_KEY]
- assert seen == [True]
- async def test_revalidate_true_serves_cache(self):
- fetch, calls = _counting_fetch()
- cache = MonthCache(fetch, revalidate=lambda key, cached: True)
- cache._data[_KEY] = ['cached']
- cache._fetched_at[_KEY] = time.monotonic()
- assert await cache.get(7, 2026) == ['cached']
- assert calls == []
- async def test_on_fetch_flag_false_on_plain_fetch(self):
- fetch, _ = _counting_fetch()
- seen = []
- cache = MonthCache(fetch, on_fetch=lambda key, result, revalidation_failed: seen.append(revalidation_failed))
- await cache.get(7, 2026)
- assert seen == [False]
- async def test_on_evict_called_on_expiry_and_invalidate_not_clear(self):
- fetch, _ = _counting_fetch()
- evicted = []
- cache = MonthCache(fetch, on_evict=evicted.append)
- await cache.get(7, 2026)
- cache._fetched_at[_KEY] += _EXPIRED
- await cache.get(7, 2026)
- assert evicted == [_KEY]
- cache.invalidate(7, 2026)
- assert evicted == [_KEY, _KEY]
- await cache.get(7, 2026)
- cache.clear()
- assert evicted == [_KEY, _KEY]
- assert cache._data == {}
- class TestTTLCache:
- def test_set_get(self):
- cache = TTLCache()
- cache.set('k', {'v': 1})
- assert cache.get('k') == {'v': 1}
- def test_missing_key(self):
- assert TTLCache().get('nope') is None
- def test_expired_entry_dropped(self):
- cache = TTLCache()
- cache.set('k', {'v': 1})
- cache._fetched_at['k'] += _EXPIRED
- assert cache.get('k') is None
- assert 'k' not in cache._data
- def test_missing_timestamp_treated_as_expired(self):
- cache = TTLCache()
- cache._data['k'] = {'v': 1}
- assert cache.get('k') is None
- def test_clear(self):
- cache = TTLCache()
- cache.set('k', {'v': 1})
- cache.clear()
- assert cache.get('k') is None
|