| """Tests for backend/cache.py β TTLCache and module-level singletons.""" |
| import time |
| import threading |
| import pytest |
|
|
| from backend.cache import TTLCache, plans_cache, stats_cache |
|
|
|
|
| |
|
|
| def test_set_and_get_basic(): |
| cache = TTLCache(ttl=60) |
| cache.set("key1", "value1") |
| assert cache.get("key1") == "value1" |
|
|
|
|
| def test_get_missing_key_returns_none(): |
| cache = TTLCache(ttl=60) |
| assert cache.get("nonexistent") is None |
|
|
|
|
| def test_set_various_value_types(): |
| cache = TTLCache(ttl=60) |
| cache.set("int_key", 42) |
| cache.set("dict_key", {"a": 1, "b": [1, 2, 3]}) |
| cache.set("list_key", [1, 2, 3]) |
| assert cache.get("int_key") == 42 |
| assert cache.get("dict_key") == {"a": 1, "b": [1, 2, 3]} |
| assert cache.get("list_key") == [1, 2, 3] |
|
|
|
|
| |
|
|
| def test_expired_entry_returns_none(): |
| cache = TTLCache(ttl=1) |
| cache.set("expiring", "will_expire", ttl=1) |
| time.sleep(1.1) |
| assert cache.get("expiring") is None |
|
|
|
|
| def test_ttl_not_yet_expired(): |
| cache = TTLCache(ttl=60) |
| cache.set("fresh", "still_alive", ttl=60) |
| assert cache.get("fresh") == "still_alive" |
|
|
|
|
| def test_per_entry_ttl_overrides_default(): |
| cache = TTLCache(ttl=60) |
| |
| cache.set("short_lived", "gone_soon", ttl=1) |
| assert cache.get("short_lived") == "gone_soon" |
| time.sleep(1.1) |
| assert cache.get("short_lived") is None |
|
|
|
|
| def test_default_ttl_is_used_when_not_specified(): |
| """Setting an entry without explicit TTL uses the cache's default TTL.""" |
| cache = TTLCache(ttl=300) |
| cache.set("default_ttl_key", "hello") |
| |
| assert cache.get("default_ttl_key") == "hello" |
|
|
|
|
| |
|
|
| def test_delete_removes_entry(): |
| cache = TTLCache(ttl=60) |
| cache.set("to_delete", "present") |
| assert cache.get("to_delete") == "present" |
| cache.delete("to_delete") |
| assert cache.get("to_delete") is None |
|
|
|
|
| def test_delete_nonexistent_key_does_not_raise(): |
| cache = TTLCache(ttl=60) |
| cache.delete("ghost_key") |
|
|
|
|
| |
|
|
| def test_clear_empties_cache(): |
| cache = TTLCache(ttl=60) |
| cache.set("a", 1) |
| cache.set("b", 2) |
| cache.set("c", 3) |
| cache.clear() |
| assert cache.get("a") is None |
| assert cache.get("b") is None |
| assert cache.get("c") is None |
|
|
|
|
| def test_clear_on_empty_cache_does_not_raise(): |
| cache = TTLCache(ttl=60) |
| cache.clear() |
|
|
|
|
| |
|
|
| def test_size_returns_correct_count(): |
| cache = TTLCache(ttl=60) |
| assert cache.size() == 0 |
| cache.set("x", 1) |
| assert cache.size() == 1 |
| cache.set("y", 2) |
| assert cache.size() == 2 |
| cache.delete("x") |
| assert cache.size() == 1 |
| cache.clear() |
| assert cache.size() == 0 |
|
|
|
|
| def test_size_does_not_count_expired_entries(): |
| """Expired entries that have been accessed are removed, shrinking size.""" |
| cache = TTLCache(ttl=1) |
| cache.set("expires", "soon", ttl=1) |
| assert cache.size() == 1 |
| time.sleep(1.1) |
| |
| cache.get("expires") |
| assert cache.size() == 0 |
|
|
|
|
| |
|
|
| def test_concurrent_reads_writes_do_not_crash(): |
| """Multiple threads reading and writing should not raise or corrupt data.""" |
| cache = TTLCache(ttl=60) |
| errors = [] |
|
|
| def writer(n): |
| try: |
| for i in range(50): |
| cache.set(f"key_{n}_{i}", f"val_{n}_{i}") |
| except Exception as e: |
| errors.append(e) |
|
|
| def reader(n): |
| try: |
| for i in range(50): |
| cache.get(f"key_{n}_{i}") |
| except Exception as e: |
| errors.append(e) |
|
|
| threads = [] |
| for t in range(5): |
| threads.append(threading.Thread(target=writer, args=(t,))) |
| threads.append(threading.Thread(target=reader, args=(t,))) |
|
|
| for th in threads: |
| th.start() |
| for th in threads: |
| th.join() |
|
|
| assert errors == [], f"Thread errors: {errors}" |
|
|
|
|
| |
|
|
| def test_plans_cache_is_ttlcache_instance(): |
| assert isinstance(plans_cache, TTLCache) |
|
|
|
|
| def test_stats_cache_is_ttlcache_instance(): |
| assert isinstance(stats_cache, TTLCache) |
|
|
|
|
| def test_plans_cache_ttl_is_600(): |
| assert plans_cache.ttl == 600 |
|
|
|
|
| def test_stats_cache_ttl_is_120(): |
| assert stats_cache.ttl == 120 |
|
|
|
|
| def test_plans_cache_and_stats_cache_are_different_objects(): |
| assert plans_cache is not stats_cache |
|
|
|
|
| def test_singletons_are_same_object_on_reimport(): |
| from backend.cache import plans_cache as pc2, stats_cache as sc2 |
| assert plans_cache is pc2 |
| assert stats_cache is sc2 |
|
|