From 846176f1149007592288dbb81217b38b0db7014b Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 26 Jan 2026 19:48:35 +0100 Subject: [PATCH] Task 8: Cache Service Tests - 66 tests, 80.06% coverage --- tests/unit/test_cache_service.py | 800 +++++++++++++++++++++++++++++++ 1 file changed, 800 insertions(+) create mode 100644 tests/unit/test_cache_service.py diff --git a/tests/unit/test_cache_service.py b/tests/unit/test_cache_service.py new file mode 100644 index 0000000..e3fcd14 --- /dev/null +++ b/tests/unit/test_cache_service.py @@ -0,0 +1,800 @@ +"""Unit tests for Cache Service. + +Tests cover: +- In-memory cache operations (get, set, delete, exists) +- TTL expiration and eviction +- Cache key generation +- Pattern matching and deletion +- Get-or-set logic +- Multiple values operations +- Error handling +""" +import asyncio +from datetime import datetime, timedelta +from typing import Any, Dict +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.server.services.cache_service import ( + CacheService, + InMemoryCacheBackend, + RedisCacheBackend, +) + + +@pytest.fixture +def in_memory_cache(): + """Create in-memory cache backend.""" + return InMemoryCacheBackend(max_size=100) + + +@pytest.fixture +def cache_service(in_memory_cache): + """Create cache service with in-memory backend.""" + return CacheService(backend=in_memory_cache, default_ttl=300) + + +class TestInMemoryCacheBackend: + """Tests for in-memory cache backend.""" + + @pytest.mark.asyncio + async def test_get_set_basic(self, in_memory_cache): + """Test basic get/set operations.""" + await in_memory_cache.set("key1", "value1") + result = await in_memory_cache.get("key1") + assert result == "value1" + + @pytest.mark.asyncio + async def test_get_nonexistent_key(self, in_memory_cache): + """Test get returns None for nonexistent keys.""" + result = await in_memory_cache.get("nonexistent") + assert result is None + + @pytest.mark.asyncio + async def test_delete_key(self, in_memory_cache): + """Test deleting a key.""" + await in_memory_cache.set("key1", "value1") + result = await in_memory_cache.delete("key1") + assert result is True + assert await in_memory_cache.get("key1") is None + + @pytest.mark.asyncio + async def test_delete_nonexistent_key(self, in_memory_cache): + """Test deleting nonexistent key returns False.""" + result = await in_memory_cache.delete("nonexistent") + assert result is False + + @pytest.mark.asyncio + async def test_exists_key(self, in_memory_cache): + """Test checking key existence.""" + await in_memory_cache.set("key1", "value1") + assert await in_memory_cache.exists("key1") is True + assert await in_memory_cache.exists("nonexistent") is False + + @pytest.mark.asyncio + async def test_clear_cache(self, in_memory_cache): + """Test clearing entire cache.""" + await in_memory_cache.set("key1", "value1") + await in_memory_cache.set("key2", "value2") + result = await in_memory_cache.clear() + assert result is True + assert await in_memory_cache.get("key1") is None + assert await in_memory_cache.get("key2") is None + + @pytest.mark.asyncio + async def test_get_many(self, in_memory_cache): + """Test getting multiple keys.""" + await in_memory_cache.set("key1", "value1") + await in_memory_cache.set("key2", "value2") + + result = await in_memory_cache.get_many(["key1", "key2", "key3"]) + assert result == {"key1": "value1", "key2": "value2"} + + @pytest.mark.asyncio + async def test_set_many(self, in_memory_cache): + """Test setting multiple keys.""" + items = {"key1": "value1", "key2": "value2"} + result = await in_memory_cache.set_many(items) + assert result is True + + assert await in_memory_cache.get("key1") == "value1" + assert await in_memory_cache.get("key2") == "value2" + + @pytest.mark.asyncio + async def test_ttl_expiration(self, in_memory_cache): + """Test TTL expiration.""" + await in_memory_cache.set("key1", "value1", ttl=1) + assert await in_memory_cache.get("key1") == "value1" + + # Wait for expiration + await asyncio.sleep(1.1) + assert await in_memory_cache.get("key1") is None + + @pytest.mark.asyncio + async def test_ttl_none(self, in_memory_cache): + """Test that None TTL means no expiration.""" + await in_memory_cache.set("key1", "value1", ttl=None) + await asyncio.sleep(0.1) + assert await in_memory_cache.get("key1") == "value1" + + @pytest.mark.asyncio + async def test_delete_pattern(self, in_memory_cache): + """Test pattern-based deletion.""" + await in_memory_cache.set("user:1", "data1") + await in_memory_cache.set("user:2", "data2") + await in_memory_cache.set("post:1", "data3") + + result = await in_memory_cache.delete_pattern("user:*") + assert result == 2 + + assert await in_memory_cache.get("user:1") is None + assert await in_memory_cache.get("post:1") == "data3" + + @pytest.mark.asyncio + async def test_max_size_eviction(self, in_memory_cache): + """Test that oldest item is evicted when cache is full.""" + # Set max_size to 2 + cache = InMemoryCacheBackend(max_size=2) + + await cache.set("key1", "value1") + await asyncio.sleep(0.01) + await cache.set("key2", "value2") + await asyncio.sleep(0.01) + await cache.set("key3", "value3") # Should evict key1 + + # key1 should be evicted (oldest) + assert await cache.get("key1") is None + assert await cache.get("key2") == "value2" + assert await cache.get("key3") == "value3" + + @pytest.mark.asyncio + async def test_concurrent_operations(self, in_memory_cache): + """Test concurrent cache operations.""" + async def set_key(key, value): + await in_memory_cache.set(key, value) + + tasks = [set_key(f"key{i}", f"value{i}") for i in range(10)] + await asyncio.gather(*tasks) + + # All values should be set + for i in range(10): + assert await in_memory_cache.get(f"key{i}") == f"value{i}" + + +class TestCacheService: + """Tests for cache service.""" + + @pytest.mark.asyncio + async def test_get_set(self, cache_service): + """Test basic get/set operations.""" + await cache_service.set("key1", "value1") + result = await cache_service.get("key1") + assert result == "value1" + + @pytest.mark.asyncio + async def test_get_with_default(self, cache_service): + """Test get with default value.""" + result = await cache_service.get("nonexistent", "default") + assert result == "default" + + @pytest.mark.asyncio + async def test_exists(self, cache_service): + """Test exists operation.""" + await cache_service.set("key1", "value1") + assert await cache_service.exists("key1") is True + assert await cache_service.exists("nonexistent") is False + + @pytest.mark.asyncio + async def test_delete(self, cache_service): + """Test delete operation.""" + await cache_service.set("key1", "value1") + result = await cache_service.delete("key1") + assert result is True + assert await cache_service.get("key1") is None + + @pytest.mark.asyncio + async def test_clear(self, cache_service): + """Test clear operation.""" + await cache_service.set("key1", "value1") + await cache_service.set("key2", "value2") + result = await cache_service.clear() + assert result is True + assert await cache_service.get("key1") is None + + @pytest.mark.asyncio + async def test_make_key_simple(self, cache_service): + """Test key generation from arguments.""" + key = cache_service._make_key("user", 123, type="profile") + assert "user" in key + assert "123" in key + assert "type=profile" in key + + @pytest.mark.asyncio + async def test_make_key_long_hashing(self, cache_service): + """Test long keys are hashed.""" + long_arg = "x" * 300 + key = cache_service._make_key(long_arg) + # Should be hashed, so much shorter + assert len(key) < len(long_arg) + + @pytest.mark.asyncio + async def test_get_or_set_exists(self, cache_service): + """Test get_or_set returns cached value if exists.""" + await cache_service.set("key1", "cached") + factory = MagicMock(return_value="computed") + + result = await cache_service.get_or_set("key1", factory) + assert result == "cached" + factory.assert_not_called() + + @pytest.mark.asyncio + async def test_get_or_set_compute_sync(self, cache_service): + """Test get_or_set computes and caches value.""" + factory = MagicMock(return_value="computed") + + result = await cache_service.get_or_set("key1", factory) + assert result == "computed" + factory.assert_called_once() + + # Should be cached + cached = await cache_service.get("key1") + assert cached == "computed" + + @pytest.mark.asyncio + async def test_get_or_set_compute_async(self, cache_service): + """Test get_or_set with async factory.""" + async def async_factory(): + return "async_computed" + + result = await cache_service.get_or_set("key1", async_factory) + assert result == "async_computed" + + cached = await cache_service.get("key1") + assert cached == "async_computed" + + @pytest.mark.asyncio + async def test_invalidate_pattern(self, cache_service): + """Test pattern-based invalidation.""" + await cache_service.set("user:1", "data1") + await cache_service.set("user:2", "data2") + await cache_service.set("post:1", "data3") + + result = await cache_service.invalidate_pattern("user:*") + assert result == 2 + + assert await cache_service.get("user:1") is None + assert await cache_service.get("post:1") == "data3" + + @pytest.mark.asyncio + async def test_cache_anime_list(self, cache_service): + """Test caching anime list.""" + anime_list = [ + {"id": 1, "name": "Anime 1"}, + {"id": 2, "name": "Anime 2"}, + ] + + result = await cache_service.cache_anime_list(anime_list) + assert result is True + + cached = await cache_service.get_anime_list() + assert cached == anime_list + + @pytest.mark.asyncio + async def test_get_anime_list_empty(self, cache_service): + """Test getting anime list when not cached.""" + result = await cache_service.get_anime_list() + assert result is None + + @pytest.mark.asyncio + async def test_ttl_default(self, cache_service): + """Test default TTL is applied.""" + service = CacheService(default_ttl=1) + await service.set("key1", "value1") # No TTL specified + + assert await service.get("key1") == "value1" + await asyncio.sleep(1.1) + assert await service.get("key1") is None + + @pytest.mark.asyncio + async def test_ttl_override(self, cache_service): + """Test TTL can be overridden.""" + service = CacheService(default_ttl=10) + await service.set("key1", "value1", ttl=1) # Override + + await asyncio.sleep(1.1) + assert await service.get("key1") is None + + @pytest.mark.asyncio + async def test_key_prefix(self, cache_service): + """Test key prefix is applied.""" + service = CacheService(key_prefix="app:") + key = service._make_key("test") + assert key.startswith("app:") + + @pytest.mark.asyncio + async def test_complex_value_types(self, cache_service): + """Test caching complex value types.""" + values = { + "dict": {"a": 1, "b": 2}, + "list": [1, 2, 3], + "tuple": (1, 2, 3), + "set": {1, 2, 3}, + } + + for key, value in values.items(): + await cache_service.set(key, value) + cached = await cache_service.get(key) + if isinstance(value, set): + assert set(cached) == value + else: + assert cached == value + + +class TestRedisCacheBackendMock: + """Tests for Redis cache backend (with mocks).""" + + @pytest.mark.asyncio + async def test_redis_prefix(self): + """Test Redis key prefixing.""" + backend = RedisCacheBackend(prefix="test:") + key = backend._make_key("mykey") + assert key == "test:mykey" + + @pytest.mark.asyncio + async def test_redis_get_error_handling(self): + """Test Redis backend error handling on get.""" + backend = RedisCacheBackend() + + # Mock _get_redis to raise error + with patch.object(backend, "_get_redis", side_effect=Exception("Connection failed")): + result = await backend.get("key1") + assert result is None + + @pytest.mark.asyncio + async def test_redis_delete_error_handling(self): + """Test Redis backend error handling on delete.""" + backend = RedisCacheBackend() + + # Mock _get_redis to raise error + with patch.object(backend, "_get_redis", side_effect=Exception("Connection failed")): + result = await backend.delete("key1") + assert result is False + + @pytest.mark.asyncio + async def test_redis_set_error_handling(self): + """Test Redis backend error handling on set.""" + backend = RedisCacheBackend() + + with patch.object(backend, "_get_redis", side_effect=Exception("Connection failed")): + result = await backend.set("key1", "value1") + assert result is False + + @pytest.mark.asyncio + async def test_redis_exists_error_handling(self): + """Test Redis backend error handling on exists.""" + backend = RedisCacheBackend() + + with patch.object(backend, "_get_redis", side_effect=Exception("Connection failed")): + result = await backend.exists("key1") + assert result is False + + @pytest.mark.asyncio + async def test_redis_clear_error_handling(self): + """Test Redis backend error handling on clear.""" + backend = RedisCacheBackend() + + with patch.object(backend, "_get_redis", side_effect=Exception("Connection failed")): + result = await backend.clear() + assert result is False + + @pytest.mark.asyncio + async def test_redis_get_many_error_handling(self): + """Test Redis backend error handling on get_many.""" + backend = RedisCacheBackend() + + with patch.object(backend, "_get_redis", side_effect=Exception("Connection failed")): + result = await backend.get_many(["key1", "key2"]) + assert result == {} + + @pytest.mark.asyncio + async def test_redis_set_many_error_handling(self): + """Test Redis backend error handling on set_many.""" + backend = RedisCacheBackend() + + # set_many calls set individually, so first call will fail + with patch.object(backend, "set", return_value=False): + result = await backend.set_many({"key1": "value1"}) + # It still returns True as it iterates, but set() fails + assert result is True # set_many doesn't check individual results + + @pytest.mark.asyncio + async def test_redis_delete_pattern_error_handling(self): + """Test Redis backend error handling on delete_pattern.""" + backend = RedisCacheBackend() + + with patch.object(backend, "_get_redis", side_effect=Exception("Connection failed")): + result = await backend.delete_pattern("key*") + assert result == 0 + + @pytest.mark.asyncio + async def test_redis_close(self): + """Test Redis connection close.""" + backend = RedisCacheBackend() + + # Create mock Redis connection + mock_redis = AsyncMock() + mock_redis.close = MagicMock() + mock_redis.wait_closed = AsyncMock() + backend._redis = mock_redis + + await backend.close() + + mock_redis.close.assert_called_once() + mock_redis.wait_closed.assert_called_once() + + @pytest.mark.asyncio + async def test_redis_close_no_connection(self): + """Test Redis close when no connection exists.""" + backend = RedisCacheBackend() + backend._redis = None + + # Should not raise error + await backend.close() + + +class TestCacheEdgeCases: + """Tests for edge cases and error scenarios.""" + + @pytest.mark.asyncio + async def test_cache_none_value(self, cache_service): + """Test that None values are treated as cache miss.""" + await cache_service.set("key1", None) + # None values return default since they're treated as cache miss + result = await cache_service.get("key1", "default") + assert result == "default" + + @pytest.mark.asyncio + async def test_cache_zero_and_false(self, cache_service): + """Test caching falsy values.""" + await cache_service.set("zero", 0) + await cache_service.set("false", False) + await cache_service.set("empty", "") + + assert await cache_service.get("zero") == 0 + assert await cache_service.get("false") is False + assert await cache_service.get("empty") == "" + + @pytest.mark.asyncio + async def test_cache_overwrite(self, cache_service): + """Test overwriting cached values.""" + await cache_service.set("key1", "value1") + await cache_service.set("key1", "value2") + + result = await cache_service.get("key1") + assert result == "value2" + + @pytest.mark.asyncio + async def test_thread_safety(self, in_memory_cache): + """Test concurrent access to cache is thread-safe.""" + async def worker(worker_id): + for i in range(10): + key = f"worker:{worker_id}:key{i}" + await in_memory_cache.set(key, i) + result = await in_memory_cache.get(key) + assert result == i + + tasks = [worker(i) for i in range(5)] + await asyncio.gather(*tasks) + + @pytest.mark.asyncio + async def test_get_many_partial(self, in_memory_cache): + """Test get_many with partial keys found.""" + await in_memory_cache.set("key1", "value1") + await in_memory_cache.set("key3", "value3") + + result = await in_memory_cache.get_many(["key1", "key2", "key3"]) + assert len(result) == 2 + assert result["key1"] == "value1" + assert result["key3"] == "value3" + assert "key2" not in result + + @pytest.mark.asyncio + async def test_expired_on_exists_check(self, in_memory_cache): + """Test that expired items are removed on exists check.""" + await in_memory_cache.set("key1", "value1", ttl=1) + + # Key exists initially + assert await in_memory_cache.exists("key1") is True + + # Wait for expiration + await asyncio.sleep(1.1) + + # Now should not exist + assert await in_memory_cache.exists("key1") is False + + # And should be removed from cache dict + assert "key1" not in in_memory_cache.cache + + +class TestCacheBackendAbstraction: + """Tests for cache backend abstraction.""" + + @pytest.mark.asyncio + async def test_backend_switching(self): + """Test switching between different backends.""" + # Start with in-memory + backend1 = InMemoryCacheBackend() + service1 = CacheService(backend=backend1) + + await service1.set("key1", "value1") + assert await service1.get("key1") == "value1" + + # Switch to different in-memory instance + backend2 = InMemoryCacheBackend() + service2 = CacheService(backend=backend2) + + # New instance has fresh cache + assert await service2.get("key1") is None + + # Original still has data + assert await service1.get("key1") == "value1" + + +class TestAnimeSpecificCaching: + """Tests for anime-specific caching methods.""" + + @pytest.mark.asyncio + async def test_cache_anime_detail(self, cache_service): + """Test caching anime detail.""" + anime_data = {"id": "123", "name": "Test Anime", "episodes": 12} + + result = await cache_service.cache_anime_detail("123", anime_data) + assert result is True + + cached = await cache_service.get_anime_detail("123") + assert cached == anime_data + + @pytest.mark.asyncio + async def test_get_anime_detail_not_cached(self, cache_service): + """Test getting uncached anime detail.""" + result = await cache_service.get_anime_detail("nonexistent") + assert result is None + + @pytest.mark.asyncio + async def test_invalidate_anime_cache(self, cache_service): + """Test invalidating all anime cache.""" + await cache_service.cache_anime_list([{"id": 1}]) + await cache_service.cache_anime_detail("123", {"name": "Test"}) + + # Both should be cached + assert await cache_service.get_anime_list() is not None + assert await cache_service.get_anime_detail("123") is not None + + # Invalidate all anime cache + count = await cache_service.invalidate_anime_cache() + assert count >= 2 + + # Both should be gone + assert await cache_service.get_anime_list() is None + assert await cache_service.get_anime_detail("123") is None + + @pytest.mark.asyncio + async def test_cache_config(self, cache_service): + """Test caching configuration.""" + config = {"setting1": "value1", "setting2": "value2"} + + result = await cache_service.cache_config(config) + assert result is True + + cached = await cache_service.get_config() + assert cached == config + + @pytest.mark.asyncio + async def test_get_config_not_cached(self, cache_service): + """Test getting uncached config.""" + result = await cache_service.get_config() + assert result is None + + @pytest.mark.asyncio + async def test_invalidate_config_cache(self, cache_service): + """Test invalidating config cache.""" + config = {"setting": "value"} + await cache_service.cache_config(config) + + assert await cache_service.get_config() is not None + + result = await cache_service.invalidate_config_cache() + assert result is True + + assert await cache_service.get_config() is None + + @pytest.mark.asyncio + async def test_cache_with_custom_ttl(self, cache_service): + """Test anime caching with custom TTL.""" + anime_list = [{"id": 1}, {"id": 2}] + + result = await cache_service.cache_anime_list(anime_list, ttl=1) + assert result is True + + # Should be cached + assert await cache_service.get_anime_list() == anime_list + + # Wait for expiration + await asyncio.sleep(1.1) + + # Should be expired + assert await cache_service.get_anime_list() is None + + +class TestGlobalCacheService: + """Tests for global cache service functions.""" + + def test_get_cache_service_singleton(self): + """Test global cache service is singleton.""" + # Reset global instance + import src.server.services.cache_service as cache_module + from src.server.services.cache_service import _cache_service, get_cache_service + cache_module._cache_service = None + + service1 = get_cache_service() + service2 = get_cache_service() + + assert service1 is service2 + + def test_configure_cache_service_memory(self): + """Test configuring cache service with memory backend.""" + from src.server.services.cache_service import configure_cache_service + + service = configure_cache_service( + backend_type="memory", + default_ttl=600, + max_size=500, + ) + + assert service is not None + assert isinstance(service.backend, InMemoryCacheBackend) + assert service.default_ttl == 600 + assert service.key_prefix == "aniworld:" + + def test_configure_cache_service_redis(self): + """Test configuring cache service with Redis backend.""" + from src.server.services.cache_service import configure_cache_service + + service = configure_cache_service( + backend_type="redis", + redis_url="redis://localhost:6379", + default_ttl=1200, + ) + + assert service is not None + assert isinstance(service.backend, RedisCacheBackend) + assert service.default_ttl == 1200 + + +class TestSetManyWithTTL: + """Tests for set_many with TTL.""" + + @pytest.mark.asyncio + async def test_set_many_with_ttl(self, in_memory_cache): + """Test set_many with TTL parameter.""" + items = {"key1": "value1", "key2": "value2"} + + result = await in_memory_cache.set_many(items, ttl=1) + assert result is True + + # Both should be cached + assert await in_memory_cache.get("key1") == "value1" + assert await in_memory_cache.get("key2") == "value2" + + # Wait for expiration + await asyncio.sleep(1.1) + + # Both should be expired + assert await in_memory_cache.get("key1") is None + assert await in_memory_cache.get("key2") is None + + @pytest.mark.asyncio + async def test_set_many_no_ttl(self, in_memory_cache): + """Test set_many without TTL.""" + items = {"key1": "value1", "key2": "value2"} + + result = await in_memory_cache.set_many(items) + assert result is True + + await asyncio.sleep(0.1) + + # Should still be cached + assert await in_memory_cache.get("key1") == "value1" + assert await in_memory_cache.get("key2") == "value2" + + +class TestDeletePatternEdgeCases: + """Tests for delete_pattern edge cases.""" + + @pytest.mark.asyncio + async def test_delete_pattern_no_matches(self, in_memory_cache): + """Test delete_pattern with no matching keys.""" + await in_memory_cache.set("key1", "value1") + + result = await in_memory_cache.delete_pattern("nomatch:*") + assert result == 0 + + # Original key should still exist + assert await in_memory_cache.get("key1") == "value1" + + @pytest.mark.asyncio + async def test_delete_pattern_complex(self, in_memory_cache): + """Test delete_pattern with complex patterns.""" + await in_memory_cache.set("user:1:profile", "data1") + await in_memory_cache.set("user:1:settings", "data2") + await in_memory_cache.set("user:2:profile", "data3") + await in_memory_cache.set("post:1", "data4") + + # Delete all user:1 keys + result = await in_memory_cache.delete_pattern("user:1:*") + assert result == 2 + + # user:1 keys should be gone + assert await in_memory_cache.get("user:1:profile") is None + assert await in_memory_cache.get("user:1:settings") is None + + # Others should remain + assert await in_memory_cache.get("user:2:profile") == "data3" + assert await in_memory_cache.get("post:1") == "data4" + + @pytest.mark.asyncio + async def test_delete_pattern_exact_match(self, in_memory_cache): + """Test delete_pattern with exact key (no wildcards).""" + await in_memory_cache.set("exact_key", "value") + + result = await in_memory_cache.delete_pattern("exact_key") + assert result == 1 + + assert await in_memory_cache.get("exact_key") is None + + +class TestCacheServiceWithDifferentTTL: + """Tests for CacheService with different default TTL.""" + + @pytest.mark.asyncio + async def test_service_default_ttl_applied(self): + """Test that service default TTL is applied.""" + backend = InMemoryCacheBackend() + service = CacheService(backend=backend, default_ttl=1) + + await service.set("key1", "value1") + + assert await service.get("key1") == "value1" + + await asyncio.sleep(1.1) + + assert await service.get("key1") is None + + @pytest.mark.asyncio + async def test_service_ttl_override(self): + """Test overriding service default TTL.""" + backend = InMemoryCacheBackend() + service = CacheService(backend=backend, default_ttl=10) + + # Override with shorter TTL + await service.set("key1", "value1", ttl=1) + + await asyncio.sleep(1.1) + + assert await service.get("key1") is None + + @pytest.mark.asyncio + async def test_service_with_key_prefix(self): + """Test service with key prefix.""" + backend = InMemoryCacheBackend() + service = CacheService(backend=backend, key_prefix="test:") + + key = service._make_key("mykey") + assert key.startswith("test:") + + await service.set("mykey", "value") + + # CacheService passes raw key to backend + result = await service.get("mykey") + assert result == "value"