Spaces:
Sleeping
Sleeping
File size: 8,539 Bytes
c62301e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 | """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
|