"""Tests for DS-11: Cache invalidation with key-pattern tracking.""" from unittest.mock import AsyncMock, MagicMock, patch import pytest @pytest.fixture def mock_redis(): """Patch upstash_redis.Redis so the cache client is usable.""" with patch("upstash_redis.Redis") as mock_cls: mock_instance = MagicMock() mock_cls.return_value = mock_instance # By default, all Redis operations return something truthy mock_instance.get.return_value = None mock_instance.set.return_value = "OK" mock_instance.sadd.return_value = 1 mock_instance.smembers.return_value = [] mock_instance.delete.return_value = 1 mock_instance.exists.return_value = 0 yield mock_instance @pytest.fixture def cache_with_mock_creds(mock_redis): """Return an UpstashRedisCache with mocked credentials and Redis client.""" from app.core.cache import UpstashRedisCache from app.core.config import settings with ( patch.object(settings, "upstash_redis_rest_url", "https://test.upstash.io"), patch.object(settings, "upstash_redis_rest_token", "test-token"), ): cache = UpstashRedisCache() # Force client creation so mock_redis is used cache._sync_client = mock_redis._mock_new_parent if hasattr(mock_redis, '_mock_new_parent') else mock_redis cache._async_client = mock_redis yield cache class TestCacheKeyTracking: """DS-11: Cache operations should track keys per user.""" @patch("app.core.cache.UpstashRedisCache._get_async_client") async def test_cache_summary_tracks_key( self, mock_get_async, cache_with_mock_creds ) -> None: """cache_summary should track the cache key under the user's key set.""" mock_async = AsyncMock() mock_async.set.return_value = "OK" mock_async.sadd.return_value = 1 # Setting mock_get_async to return a mock client mock_get_async.return_value = mock_async from app.core.cache import UpstashRedisCache cache = cache_with_mock_creds result = await cache.cache_summary( user_id="user_abc", start_date="2024-01-01", end_date="2024-01-31", data={"total_runs": 42}, ) assert result is True # Should have called set AND sadd (for key tracking) assert mock_async.set.called assert mock_async.sadd.called @patch("app.core.cache.UpstashRedisCache._get_async_client") async def test_cache_trends_tracks_key( self, mock_get_async, cache_with_mock_creds ) -> None: """cache_trends should track the cache key under the user's key set.""" mock_async = AsyncMock() mock_async.set.return_value = "OK" mock_async.sadd.return_value = 1 mock_get_async.return_value = mock_async cache = cache_with_mock_creds result = await cache.cache_trends( user_id="user_abc", period="7d", data={"trend": "up"}, ) assert result is True assert mock_async.set.called assert mock_async.sadd.called @patch("app.core.cache.UpstashRedisCache._get_async_client") async def test_cache_projects_tracks_key( self, mock_get_async, cache_with_mock_creds ) -> None: """cache_projects should track the cache key under the user's key set.""" mock_async = AsyncMock() mock_async.set.return_value = "OK" mock_async.sadd.return_value = 1 mock_get_async.return_value = mock_async cache = cache_with_mock_creds result = await cache.cache_projects( user_id="user_abc", data={"projects": []}, ) assert result is True assert mock_async.set.called assert mock_async.sadd.called class TestCacheInvalidation: """DS-11: invalidate_user_cache should clear all keys for a user.""" @patch("app.core.cache.UpstashRedisCache._get_sync_client") async def test_invalidate_user_cache_clears_keys( self, mock_get_sync, cache_with_mock_creds ) -> None: """invalidate_user_cache should smembers, delete each key, then delete the set.""" mock_sync = MagicMock() # Return some tracked keys mock_sync.smembers.return_value = [ "cache_key_1", "cache_key_2", "cache_key_3", ] mock_sync.delete.return_value = 3 mock_get_sync.return_value = mock_sync cache = cache_with_mock_creds await cache.invalidate_user_cache("user_abc") # Should have called smembers to get keys mock_sync.smembers.assert_called_once() # Should have called delete for each key (pipeline or individual) assert mock_sync.delete.call_count >= 1 @patch("app.core.cache.UpstashRedisCache._get_sync_client") async def test_invalidate_user_cache_empty_set( self, mock_get_sync, cache_with_mock_creds ) -> None: """invalidate_user_cache handles empty key sets gracefully.""" mock_sync = MagicMock() mock_sync.smembers.return_value = [] # No tracked keys mock_sync.delete.return_value = 1 # Deleting the set itself mock_get_sync.return_value = mock_sync cache = cache_with_mock_creds # Should not raise await cache.invalidate_user_cache("user_xyz") mock_sync.smembers.assert_called_once() # Should delete the set itself mock_sync.delete.assert_called_once() @patch("app.core.cache.UpstashRedisCache._get_sync_client") async def test_invalidate_user_cache_no_crash_when_redis_unavailable( self, mock_get_sync, cache_with_mock_creds ) -> None: """invalidate_user_cache should not crash when Redis client returns None.""" mock_get_sync.return_value = None cache = cache_with_mock_creds # Should not raise result = await cache.invalidate_user_cache("user_no_redis") assert result == 0 # No keys cleared when Redis unavailable @patch("app.core.cache.UpstashRedisCache._get_sync_client") async def test_invalidate_user_cache_returns_key_count( self, mock_get_sync, cache_with_mock_creds ) -> None: """invalidate_user_cache should return the number of keys cleared.""" mock_sync = MagicMock() mock_sync.smembers.return_value = ["key1", "key2"] mock_sync.delete.return_value = 2 mock_get_sync.return_value = mock_sync cache = cache_with_mock_creds result = await cache.invalidate_user_cache("user_abc") assert result == 2 mock_sync.smembers.assert_called_once() assert mock_sync.delete.call_count >= 1 class TestCacheHealth: """DS-11: get_cache_health should report cache status.""" @patch("app.core.cache.UpstashRedisCache._get_sync_client") async def test_cache_health_ok( self, mock_get_sync, cache_with_mock_creds ) -> None: """get_cache_health returns ok status when Redis is connected.""" mock_sync = MagicMock() mock_sync.exists.return_value = 1 mock_sync.scard.return_value = 5 mock_get_sync.return_value = mock_sync cache = cache_with_mock_creds health = await cache.get_cache_health("user_abc") assert health["status"] == "ok" assert health["keys_tracked"] == 5 assert health["ttl_seconds"] > 0 @patch("app.core.cache.UpstashRedisCache._get_sync_client") async def test_cache_health_degraded( self, mock_get_sync, cache_with_mock_creds ) -> None: """get_cache_health returns degraded status when Redis is unavailable.""" mock_get_sync.return_value = None cache = cache_with_mock_creds health = await cache.get_cache_health("user_abc") assert health["status"] == "degraded" assert health["keys_tracked"] == 0 @patch("app.core.cache.UpstashRedisCache._get_sync_client") async def test_cache_health_no_keys( self, mock_get_sync, cache_with_mock_creds ) -> None: """get_cache_health returns 0 keys when no key set exists.""" mock_sync = MagicMock() mock_sync.exists.return_value = 0 # Key set doesn't exist mock_get_sync.return_value = mock_sync cache = cache_with_mock_creds health = await cache.get_cache_health("user_abc") assert health["status"] == "ok" assert health["keys_tracked"] == 0