"""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