Skip to content

Commit c92fe18

Browse files
Alok SaldanhaAlok Saldanha
authored andcommitted
add some backend cache tests
1 parent 54aacab commit c92fe18

3 files changed

Lines changed: 338 additions & 2 deletions

File tree

.github/workflows/pr-checks.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ jobs:
4040
eval "$(conda shell.bash hook)"
4141
conda activate cellxgene-gateway
4242
python setup.py install
43+
pip install -r requirements-test.txt
4344
4445
- name: Run tests
4546
run: |

requirements-test.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
freezegun~=1.5.5

tests/test_backend_cache.py

Lines changed: 336 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import unittest
2-
from unittest.mock import MagicMock, patch
2+
from http import HTTPStatus
3+
from unittest.mock import MagicMock, Mock, patch, call
4+
from freezegun import freeze_time
35

4-
from cellxgene_gateway.backend_cache import is_port_in_use
6+
from cellxgene_gateway.backend_cache import BackendCache, is_port_in_use
7+
from cellxgene_gateway.cache_entry import CacheEntry, CacheEntryStatus
8+
from cellxgene_gateway.cache_key import CacheKey
9+
from cellxgene_gateway.cellxgene_exception import CellxgeneException
510

611

712
class TestIsPortInUse(unittest.TestCase):
@@ -26,3 +31,332 @@ def test_GIVEN_used_port_THEN_returns_false(self, socketMock):
2631
self.assertTrue(connectMock.connect_ex.calledOnceWith("a"))
2732
self.assertTrue(socketMock.calledOnceWith("a"))
2833
self.assertEqual(is_port_in_use(123), False)
34+
35+
36+
class TestBackendCacheInit(unittest.TestCase):
37+
def test_GIVEN_new_backend_cache_THEN_entry_list_is_empty(self):
38+
"""BackendCache should initialize with an empty entry list."""
39+
cache = BackendCache()
40+
self.assertEqual(cache.entry_list, [])
41+
42+
43+
class TestBackendCacheGetPorts(unittest.TestCase):
44+
def test_GIVEN_empty_cache_THEN_get_ports_returns_empty_list(self):
45+
"""get_ports should return empty list when no entries exist."""
46+
cache = BackendCache()
47+
ports = cache.get_ports()
48+
self.assertEqual(ports, [])
49+
50+
def test_GIVEN_cache_with_entries_THEN_get_ports_returns_all_ports(self):
51+
"""get_ports should return list of all ports from entries."""
52+
cache = BackendCache()
53+
54+
# Create mock entries with different ports
55+
entry1 = Mock()
56+
entry1.port = 8000
57+
entry2 = Mock()
58+
entry2.port = 8001
59+
entry3 = Mock()
60+
entry3.port = 8002
61+
62+
cache.entry_list = [entry1, entry2, entry3]
63+
64+
ports = cache.get_ports()
65+
self.assertEqual(ports, [8000, 8001, 8002])
66+
67+
def test_GIVEN_single_entry_THEN_get_ports_returns_single_port(self):
68+
"""get_ports should correctly handle a single entry."""
69+
cache = BackendCache()
70+
entry = Mock()
71+
entry.port = 9000
72+
73+
cache.entry_list = [entry]
74+
75+
ports = cache.get_ports()
76+
self.assertEqual(ports, [9000])
77+
78+
79+
class TestBackendCacheCheckPath(unittest.TestCase):
80+
def setUp(self):
81+
self.cache = BackendCache()
82+
83+
def test_GIVEN_no_matching_path_THEN_check_path_returns_none(self):
84+
"""check_path should return None when no entries match the path."""
85+
source = Mock()
86+
source.name = "test_source"
87+
88+
cache_entry = Mock()
89+
cache_entry.status = CacheEntryStatus.loaded
90+
cache_entry.key = Mock()
91+
cache_entry.key.source.name = "other_source"
92+
cache_entry.key.descriptor = "/some/path"
93+
94+
self.cache.entry_list = [cache_entry]
95+
96+
result = self.cache.check_path(source, "/test/path")
97+
self.assertIsNone(result)
98+
99+
def test_GIVEN_terminated_entry_THEN_check_path_ignores_it(self):
100+
"""check_path should ignore entries with terminated status."""
101+
source = Mock()
102+
source.name = "test_source"
103+
104+
cache_entry = Mock()
105+
cache_entry.status = CacheEntryStatus.terminated
106+
cache_entry.key = Mock()
107+
cache_entry.key.source.name = "test_source"
108+
cache_entry.key.descriptor = "/data"
109+
110+
self.cache.entry_list = [cache_entry]
111+
112+
result = self.cache.check_path(source, "/data/file.txt")
113+
self.assertIsNone(result)
114+
115+
def test_GIVEN_single_matching_entry_THEN_check_path_returns_it(self):
116+
"""check_path should return the matching entry when exactly one matches."""
117+
source = Mock()
118+
source.name = "test_source"
119+
120+
cache_entry = Mock()
121+
cache_entry.status = CacheEntryStatus.loaded
122+
cache_entry.key = Mock()
123+
cache_entry.key.source.name = "test_source"
124+
cache_entry.key.descriptor = "/data"
125+
126+
self.cache.entry_list = [cache_entry]
127+
128+
result = self.cache.check_path(source, "/data/file.txt")
129+
self.assertIs(result, cache_entry)
130+
131+
def test_GIVEN_path_that_does_not_start_with_descriptor_THEN_check_path_returns_none(
132+
self,
133+
):
134+
"""check_path should return None if path doesn't start with descriptor."""
135+
source = Mock()
136+
source.name = "test_source"
137+
138+
cache_entry = Mock()
139+
cache_entry.status = CacheEntryStatus.loaded
140+
cache_entry.key = Mock()
141+
cache_entry.key.source.name = "test_source"
142+
cache_entry.key.descriptor = "/data"
143+
144+
self.cache.entry_list = [cache_entry]
145+
146+
result = self.cache.check_path(source, "/other/file.txt")
147+
self.assertIsNone(result)
148+
149+
def test_GIVEN_multiple_matching_entries_THEN_check_path_raises_exception(self):
150+
"""check_path should raise exception when multiple entries match."""
151+
source = Mock()
152+
source.name = "test_source"
153+
154+
cache_entry1 = Mock()
155+
cache_entry1.status = CacheEntryStatus.loaded
156+
cache_entry1.key = Mock()
157+
cache_entry1.key.source.name = "test_source"
158+
cache_entry1.key.descriptor = "/data"
159+
160+
cache_entry2 = Mock()
161+
cache_entry2.status = CacheEntryStatus.loaded
162+
cache_entry2.key = Mock()
163+
cache_entry2.key.source.name = "test_source"
164+
cache_entry2.key.descriptor = "/data"
165+
166+
self.cache.entry_list = [cache_entry1, cache_entry2]
167+
168+
with self.assertRaises(CellxgeneException) as context:
169+
self.cache.check_path(source, "/data/file.txt")
170+
171+
# The CellxgeneException is raised with HTTPStatus as first arg and message as second
172+
self.assertEqual(context.exception.message, HTTPStatus.INTERNAL_SERVER_ERROR)
173+
self.assertIn("Found 2", context.exception.http_status)
174+
175+
def test_GIVEN_mixed_entries_THEN_check_path_returns_only_matching_active_entry(
176+
self,
177+
):
178+
"""check_path should correctly filter by source, path, and status."""
179+
source = Mock()
180+
source.name = "target_source"
181+
182+
# Terminated entry - should be ignored
183+
terminated_entry = Mock()
184+
terminated_entry.status = CacheEntryStatus.terminated
185+
terminated_entry.key = Mock()
186+
terminated_entry.key.source.name = "target_source"
187+
terminated_entry.key.descriptor = "/data"
188+
189+
# Different source - should be ignored
190+
other_source_entry = Mock()
191+
other_source_entry.status = CacheEntryStatus.loaded
192+
other_source_entry.key = Mock()
193+
other_source_entry.key.source.name = "other_source"
194+
other_source_entry.key.descriptor = "/data"
195+
196+
# Matching entry - should be returned
197+
matching_entry = Mock()
198+
matching_entry.status = CacheEntryStatus.loaded
199+
matching_entry.key = Mock()
200+
matching_entry.key.source.name = "target_source"
201+
matching_entry.key.descriptor = "/data"
202+
203+
self.cache.entry_list = [terminated_entry, other_source_entry, matching_entry]
204+
205+
result = self.cache.check_path(source, "/data/file.txt")
206+
self.assertIs(result, matching_entry)
207+
208+
209+
class TestBackendCacheCheckEntry(unittest.TestCase):
210+
def setUp(self):
211+
self.cache = BackendCache()
212+
213+
def test_GIVEN_no_matching_entry_THEN_check_entry_returns_none(self):
214+
"""check_entry should return None when no entries match the key."""
215+
key = Mock()
216+
217+
cache_entry = Mock()
218+
cache_entry.status = CacheEntryStatus.loaded
219+
cache_entry.key = Mock()
220+
cache_entry.key.equals.return_value = False
221+
222+
self.cache.entry_list = [cache_entry]
223+
224+
result = self.cache.check_entry(key)
225+
self.assertIsNone(result)
226+
227+
def test_GIVEN_terminated_entry_THEN_check_entry_ignores_it(self):
228+
"""check_entry should ignore entries with terminated status."""
229+
key = Mock()
230+
231+
cache_entry = Mock()
232+
cache_entry.status = CacheEntryStatus.terminated
233+
cache_entry.key = Mock()
234+
cache_entry.key.equals.return_value = True
235+
236+
self.cache.entry_list = [cache_entry]
237+
238+
result = self.cache.check_entry(key)
239+
self.assertIsNone(result)
240+
241+
def test_GIVEN_single_matching_entry_THEN_check_entry_returns_it(self):
242+
"""check_entry should return the matching entry when exactly one matches."""
243+
key = Mock()
244+
245+
cache_entry = Mock()
246+
cache_entry.status = CacheEntryStatus.loaded
247+
cache_entry.key = Mock()
248+
cache_entry.key.equals.return_value = True
249+
250+
self.cache.entry_list = [cache_entry]
251+
252+
result = self.cache.check_entry(key)
253+
self.assertIs(result, cache_entry)
254+
255+
def test_GIVEN_multiple_matching_entries_THEN_check_entry_raises_exception(self):
256+
"""check_entry should raise exception when multiple entries match."""
257+
key = Mock()
258+
key.dataset = "test_dataset"
259+
260+
cache_entry1 = Mock()
261+
cache_entry1.status = CacheEntryStatus.loaded
262+
cache_entry1.key = Mock()
263+
cache_entry1.key.equals.return_value = True
264+
265+
cache_entry2 = Mock()
266+
cache_entry2.status = CacheEntryStatus.loaded
267+
cache_entry2.key = Mock()
268+
cache_entry2.key.equals.return_value = True
269+
270+
self.cache.entry_list = [cache_entry1, cache_entry2]
271+
272+
with self.assertRaises(CellxgeneException) as context:
273+
self.cache.check_entry(key)
274+
275+
# The CellxgeneException is raised with HTTPStatus as first arg and message as second
276+
self.assertEqual(context.exception.message, HTTPStatus.INTERNAL_SERVER_ERROR)
277+
self.assertIn("Found 2", context.exception.http_status)
278+
279+
def test_GIVEN_mixed_entries_THEN_check_entry_returns_only_matching_active_entry(
280+
self,
281+
):
282+
"""check_entry should correctly filter by key equality and status."""
283+
key = Mock()
284+
285+
# Terminated entry - should be ignored
286+
terminated_entry = Mock()
287+
terminated_entry.status = CacheEntryStatus.terminated
288+
terminated_entry.key = Mock()
289+
terminated_entry.key.equals.return_value = True
290+
291+
# Non-matching entry - should be ignored
292+
non_matching_entry = Mock()
293+
non_matching_entry.status = CacheEntryStatus.loaded
294+
non_matching_entry.key = Mock()
295+
non_matching_entry.key.equals.return_value = False
296+
297+
# Matching entry - should be returned
298+
matching_entry = Mock()
299+
matching_entry.status = CacheEntryStatus.loaded
300+
matching_entry.key = Mock()
301+
matching_entry.key.equals.return_value = True
302+
303+
self.cache.entry_list = [terminated_entry, non_matching_entry, matching_entry]
304+
305+
result = self.cache.check_entry(key)
306+
self.assertIs(result, matching_entry)
307+
308+
309+
class TestBackendCachePrune(unittest.TestCase):
310+
def test_GIVEN_entry_in_cache_THEN_prune_removes_it(self):
311+
"""prune should remove the entry from the cache."""
312+
cache = BackendCache()
313+
314+
entry_mock = Mock()
315+
cache.entry_list = [entry_mock]
316+
317+
cache.prune(entry_mock)
318+
319+
self.assertEqual(len(cache.entry_list), 0)
320+
self.assertNotIn(entry_mock, cache.entry_list)
321+
322+
def test_GIVEN_entry_in_cache_THEN_prune_terminates_it(self):
323+
"""prune should call terminate on the entry."""
324+
cache = BackendCache()
325+
326+
entry_mock = Mock()
327+
cache.entry_list = [entry_mock]
328+
329+
cache.prune(entry_mock)
330+
331+
entry_mock.terminate.assert_called_once()
332+
333+
def test_GIVEN_multiple_entries_THEN_prune_removes_only_target_entry(self):
334+
"""prune should only remove the specified entry, not others."""
335+
cache = BackendCache()
336+
337+
entry1 = Mock()
338+
entry2 = Mock()
339+
entry3 = Mock()
340+
341+
cache.entry_list = [entry1, entry2, entry3]
342+
343+
cache.prune(entry2)
344+
345+
self.assertEqual(len(cache.entry_list), 2)
346+
self.assertIn(entry1, cache.entry_list)
347+
self.assertNotIn(entry2, cache.entry_list)
348+
self.assertIn(entry3, cache.entry_list)
349+
350+
# Verify only entry2 was terminated
351+
entry1.terminate.assert_not_called()
352+
entry2.terminate.assert_called_once()
353+
entry3.terminate.assert_not_called()
354+
355+
def test_GIVEN_empty_cache_THEN_prune_raises_value_error(self):
356+
"""prune should raise ValueError if entry is not in cache."""
357+
cache = BackendCache()
358+
359+
entry_mock = Mock()
360+
361+
with self.assertRaises(ValueError):
362+
cache.prune(entry_mock)

0 commit comments

Comments
 (0)