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