import asyncio from time import time import pytest from pytest_mock.plugin import MockerFixture from lnbits.settings import Settings from lnbits.utils.cache import Cache, Cached key = "foo" value = "bar" @pytest.fixture async def cache(): cache = Cache(interval=0.1) task = asyncio.create_task(cache.invalidate_forever()) yield cache task.cancel() @pytest.mark.anyio async def test_cache_get_set(cache): cache.set(key, value) assert cache.get(key) == value assert cache.get(key, default="default") == value assert cache.get("i-dont-exist", default="default") == "default" @pytest.mark.anyio async def test_cache_expiry(cache): # gets expired by `get` call cache.set(key, value, expiry=0.01) await asyncio.sleep(0.02) assert not cache.get(key) # gets expired by invalidation task cache.set(key, value, expiry=0.1) await asyncio.sleep(0.2) assert key not in cache._values assert not cache.get(key) @pytest.mark.anyio async def test_cache_pop(cache): cache.set(key, value) assert cache.pop(key) == value assert not cache.get(key) assert cache.pop(key, default="a") == "a" @pytest.mark.anyio async def test_cache_coro(cache): called = 0 async def test(): nonlocal called called += 1 return called await cache.save_result(test, key="test") result = await cache.save_result(test, key="test") assert result == called == 1 def test_cached_older_than(): cached = Cached(value="value", expiry=time() - 5) assert cached.older_than(1) is True assert cached.older_than(10) is False @pytest.mark.anyio async def test_cache_value_returns_cached_metadata(cache): cache.set(key, value, expiry=1) cached = cache.value(key) assert cached is not None assert cached.value == value assert cached.expiry > time() @pytest.mark.anyio async def test_cache_pop_expired_returns_default(cache): cache.set(key, value, expiry=0.01) await asyncio.sleep(0.02) assert cache.pop(key, default="fallback") == "fallback" @pytest.mark.anyio async def test_cache_coro_stale_returns_immediately(cache): """Stale entry is served immediately; background refresh updates the value.""" calls = 0 async def test(): nonlocal calls calls += 1 return calls # cold start result = await cache.save_result(test, key="test", expiry=0.01) assert result == 1 # let the entry expire await asyncio.sleep(0.02) # stale-while-revalidate: returns old value immediately result = await cache.save_result(test, key="test", expiry=0.5) assert result == 1 # stale value returned, not the new one # allow background refresh to complete await asyncio.sleep(0.05) assert calls == 2 # now the cache has the fresh value result = await cache.save_result(test, key="test", expiry=0.5) assert result == 2 @pytest.mark.anyio async def test_cache_coro_no_stampede(cache): """Multiple concurrent requests on a stale entry spawn only one refresh.""" calls = 0 async def slow_fetch(): nonlocal calls calls += 1 await asyncio.sleep(0.05) return calls await cache.save_result(slow_fetch, key="test", expiry=0.01) await asyncio.sleep(0.02) # fire multiple concurrent requests while stale results = await asyncio.gather( cache.save_result(slow_fetch, key="test", expiry=0.5), cache.save_result(slow_fetch, key="test", expiry=0.5), cache.save_result(slow_fetch, key="test", expiry=0.5), ) await asyncio.sleep(0.1) # let the single background task finish assert all(r == 1 for r in results) # all got stale value assert calls == 2 # cold start + exactly one background refresh @pytest.mark.anyio async def test_invalidate_forever_logs_and_recovers_from_errors( settings: Settings, mocker: MockerFixture ): test_cache = Cache(interval=0) logger_error = mocker.patch("lnbits.utils.cache.logger.error") original_running = settings.lnbits_running calls = 0 async def fake_sleep(_interval): nonlocal calls calls += 1 if calls == 1: raise RuntimeError("boom") settings.lnbits_running = False try: settings.lnbits_running = True mocker.patch("lnbits.utils.cache.asyncio.sleep", side_effect=fake_sleep) await test_cache.invalidate_forever() finally: settings.lnbits_running = original_running logger_error.assert_called_once_with("Error invalidating cache")