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