1+ """Tests for Redis Sentinel URL connection handling.
2+
3+ This module tests the RedisConnectionFactory's ability to create Redis clients
4+ from Sentinel URLs (redis+sentinel://...). It verifies:
5+
6+ 1. Correct Sentinel class selection (AsyncSentinel for async, Sentinel for sync)
7+ 2. URL parsing (hosts, ports, service name, database, authentication)
8+ 3. Proper kwargs passthrough to Sentinel and master_for()
9+ 4. Error handling for connection failures
10+
11+ These tests use mocking to avoid requiring a real Sentinel deployment.
12+
13+ Related: GitHub Issue #465 - Async Sentinel connections were incorrectly using
14+ the sync SentinelConnectionPool, causing runtime failures.
15+ """
16+
117from unittest .mock import MagicMock , patch
218
319import pytest
@@ -12,7 +28,14 @@ def test_sentinel_url_connection(use_async):
1228 "redis+sentinel://username:password@host1:26379,host2:26380/mymaster/0"
1329 )
1430
15- with patch ("redisvl.redis.connection.Sentinel" ) as mock_sentinel :
31+ # Use appropriate Sentinel class based on sync/async
32+ sentinel_patch_target = (
33+ "redisvl.redis.connection.AsyncSentinel"
34+ if use_async
35+ else "redisvl.redis.connection.Sentinel"
36+ )
37+
38+ with patch (sentinel_patch_target ) as mock_sentinel :
1639 mock_master = MagicMock ()
1740 mock_sentinel .return_value .master_for .return_value = mock_master
1841
@@ -42,7 +65,14 @@ def test_sentinel_url_connection(use_async):
4265def test_sentinel_url_connection_no_auth_no_db (use_async ):
4366 sentinel_url = "redis+sentinel://host1:26379,host2:26380/mymaster"
4467
45- with patch ("redisvl.redis.connection.Sentinel" ) as mock_sentinel :
68+ # Use appropriate Sentinel class based on sync/async
69+ sentinel_patch_target = (
70+ "redisvl.redis.connection.AsyncSentinel"
71+ if use_async
72+ else "redisvl.redis.connection.Sentinel"
73+ )
74+
75+ with patch (sentinel_patch_target ) as mock_sentinel :
4676 mock_master = MagicMock ()
4777 mock_sentinel .return_value .master_for .return_value = mock_master
4878
@@ -72,7 +102,14 @@ def test_sentinel_url_connection_no_auth_no_db(use_async):
72102def test_sentinel_url_connection_error (use_async ):
73103 sentinel_url = "redis+sentinel://host1:26379,host2:26380/mymaster"
74104
75- with patch ("redisvl.redis.connection.Sentinel" ) as mock_sentinel :
105+ # Use appropriate Sentinel class based on sync/async
106+ sentinel_patch_target = (
107+ "redisvl.redis.connection.AsyncSentinel"
108+ if use_async
109+ else "redisvl.redis.connection.Sentinel"
110+ )
111+
112+ with patch (sentinel_patch_target ) as mock_sentinel :
76113 mock_sentinel .return_value .master_for .side_effect = ConnectionError (
77114 "Test connection error"
78115 )
@@ -85,3 +122,137 @@ def test_sentinel_url_connection_error(use_async):
85122 RedisConnectionFactory .get_redis_connection (sentinel_url )
86123
87124 mock_sentinel .assert_called_once ()
125+
126+
127+ def test_async_sentinel_uses_async_sentinel_class ():
128+ """Test that async connections use AsyncSentinel (fix for issue #465)."""
129+ sentinel_url = "redis+sentinel://host1:26379/mymaster"
130+
131+ # Track which Sentinel class is called
132+ sync_sentinel_called = False
133+ async_sentinel_called = False
134+
135+ def track_sync_sentinel (* args , ** kwargs ):
136+ nonlocal sync_sentinel_called
137+ sync_sentinel_called = True
138+ mock = MagicMock ()
139+ mock .master_for .return_value = MagicMock ()
140+ return mock
141+
142+ def track_async_sentinel (* args , ** kwargs ):
143+ nonlocal async_sentinel_called
144+ async_sentinel_called = True
145+ mock = MagicMock ()
146+ mock .master_for .return_value = MagicMock ()
147+ return mock
148+
149+ with (
150+ patch ("redisvl.redis.connection.Sentinel" , side_effect = track_sync_sentinel ),
151+ patch (
152+ "redisvl.redis.connection.AsyncSentinel" , side_effect = track_async_sentinel
153+ ),
154+ ):
155+ with pytest .warns (DeprecationWarning ):
156+ RedisConnectionFactory .get_async_redis_connection (sentinel_url )
157+
158+ # Verify AsyncSentinel was called, not sync Sentinel
159+ assert async_sentinel_called , "AsyncSentinel should be called for async connections"
160+ assert (
161+ not sync_sentinel_called
162+ ), "Sync Sentinel should NOT be called for async connections"
163+
164+
165+ def test_sync_sentinel_uses_sync_sentinel_class ():
166+ """Test that sync connections use sync Sentinel."""
167+ sentinel_url = "redis+sentinel://host1:26379/mymaster"
168+
169+ # Track which Sentinel class is called
170+ sync_sentinel_called = False
171+ async_sentinel_called = False
172+
173+ def track_sync_sentinel (* args , ** kwargs ):
174+ nonlocal sync_sentinel_called
175+ sync_sentinel_called = True
176+ mock = MagicMock ()
177+ mock .master_for .return_value = MagicMock ()
178+ return mock
179+
180+ def track_async_sentinel (* args , ** kwargs ):
181+ nonlocal async_sentinel_called
182+ async_sentinel_called = True
183+ mock = MagicMock ()
184+ mock .master_for .return_value = MagicMock ()
185+ return mock
186+
187+ with (
188+ patch ("redisvl.redis.connection.Sentinel" , side_effect = track_sync_sentinel ),
189+ patch (
190+ "redisvl.redis.connection.AsyncSentinel" , side_effect = track_async_sentinel
191+ ),
192+ ):
193+ RedisConnectionFactory .get_redis_connection (sentinel_url )
194+
195+ # Verify sync Sentinel was called, not AsyncSentinel
196+ assert sync_sentinel_called , "Sync Sentinel should be called for sync connections"
197+ assert (
198+ not async_sentinel_called
199+ ), "AsyncSentinel should NOT be called for sync connections"
200+
201+
202+ # =============================================================================
203+ # Additional Edge Case Tests for Sentinel URL Parsing
204+ # =============================================================================
205+
206+
207+ class TestSentinelUrlParsingEdgeCases :
208+ """Tests for Sentinel URL parsing edge cases not covered by main tests."""
209+
210+ def test_sentinel_url_default_port_when_not_specified (self ):
211+ """Verify default port 26379 is used when port is omitted."""
212+ sentinel_url = "redis+sentinel://host1/mymaster"
213+
214+ with patch ("redisvl.redis.connection.Sentinel" ) as mock_sentinel :
215+ mock_sentinel .return_value .master_for .return_value = MagicMock ()
216+ RedisConnectionFactory .get_redis_connection (sentinel_url )
217+
218+ call_args = mock_sentinel .call_args
219+ assert call_args [0 ][0 ] == [("host1" , 26379 )]
220+
221+ def test_sentinel_url_default_service_name_when_path_empty (self ):
222+ """Verify default service name 'mymaster' when path is empty."""
223+ sentinel_url = "redis+sentinel://host1:26379"
224+
225+ with patch ("redisvl.redis.connection.Sentinel" ) as mock_sentinel :
226+ mock_sentinel .return_value .master_for .return_value = MagicMock ()
227+ RedisConnectionFactory .get_redis_connection (sentinel_url )
228+
229+ master_for_args = mock_sentinel .return_value .master_for .call_args
230+ assert master_for_args [0 ][0 ] == "mymaster"
231+
232+ def test_sentinel_url_password_only_auth (self ):
233+ """Verify password-only auth works (empty username)."""
234+ sentinel_url = "redis+sentinel://:secretpass@host1:26379/mymaster"
235+
236+ with patch ("redisvl.redis.connection.Sentinel" ) as mock_sentinel :
237+ mock_sentinel .return_value .master_for .return_value = MagicMock ()
238+ RedisConnectionFactory .get_redis_connection (sentinel_url )
239+
240+ call_kwargs = mock_sentinel .call_args [1 ]
241+ assert call_kwargs ["sentinel_kwargs" ]["password" ] == "secretpass"
242+ assert call_kwargs ["password" ] == "secretpass"
243+
244+ def test_sentinel_custom_kwargs_passed_to_master_for (self ):
245+ """Verify custom kwargs are passed through to master_for call."""
246+ sentinel_url = "redis+sentinel://host1:26379/mymaster"
247+
248+ with patch ("redisvl.redis.connection.AsyncSentinel" ) as mock_async_sentinel :
249+ mock_async_sentinel .return_value .master_for .return_value = MagicMock ()
250+
251+ with pytest .warns (DeprecationWarning ):
252+ RedisConnectionFactory .get_async_redis_connection (
253+ sentinel_url , decode_responses = True , socket_timeout = 5.0
254+ )
255+
256+ master_for_kwargs = mock_async_sentinel .return_value .master_for .call_args [1 ]
257+ assert master_for_kwargs ["decode_responses" ] is True
258+ assert master_for_kwargs ["socket_timeout" ] == 5.0
0 commit comments