11import 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
712class 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