@@ -24,12 +24,16 @@ class TestQbDownloaderConstructor:
2424
2525 def test_ssl_true_no_scheme_uses_https (self ):
2626 """ssl=True with bare host prepends https://."""
27- qb = QbDownloader (host = "192.168.1.10:8080" , username = "admin" , password = "pass" , ssl = True )
27+ qb = QbDownloader (
28+ host = "192.168.1.10:8080" , username = "admin" , password = "pass" , ssl = True
29+ )
2830 assert qb .host == "https://192.168.1.10:8080"
2931
3032 def test_ssl_false_no_scheme_uses_http (self ):
3133 """ssl=False with bare host prepends http://."""
32- qb = QbDownloader (host = "192.168.1.10:8080" , username = "admin" , password = "pass" , ssl = False )
34+ qb = QbDownloader (
35+ host = "192.168.1.10:8080" , username = "admin" , password = "pass" , ssl = False
36+ )
3337 assert qb .host == "http://192.168.1.10:8080"
3438
3539 def test_explicit_http_scheme_preserved_when_ssl_true (self ):
@@ -42,18 +46,25 @@ def test_explicit_http_scheme_preserved_when_ssl_true(self):
4246 def test_explicit_https_scheme_preserved_when_ssl_false (self ):
4347 """Explicit https:// scheme is kept even if ssl=False."""
4448 qb = QbDownloader (
45- host = "https://192.168.1.10:8080" , username = "admin" , password = "pass" , ssl = False
49+ host = "https://192.168.1.10:8080" ,
50+ username = "admin" ,
51+ password = "pass" ,
52+ ssl = False ,
4653 )
4754 assert qb .host == "https://192.168.1.10:8080"
4855
4956 def test_explicit_http_scheme_preserved_ssl_false (self ):
5057 """Explicit http:// URL with ssl=False stays as http://."""
51- qb = QbDownloader (host = "http://nas.local:8080" , username = "u" , password = "p" , ssl = False )
58+ qb = QbDownloader (
59+ host = "http://nas.local:8080" , username = "u" , password = "p" , ssl = False
60+ )
5261 assert qb .host == "http://nas.local:8080"
5362
5463 def test_explicit_https_scheme_preserved_ssl_true (self ):
5564 """Explicit https:// URL with ssl=True stays as https://."""
56- qb = QbDownloader (host = "https://nas.local:8080" , username = "u" , password = "p" , ssl = True )
65+ qb = QbDownloader (
66+ host = "https://nas.local:8080" , username = "u" , password = "p" , ssl = True
67+ )
5768 assert qb .host == "https://nas.local:8080"
5869
5970 def test_credentials_stored (self ):
@@ -67,7 +78,9 @@ def test_credentials_stored(self):
6778
6879 def test_client_initially_none (self ):
6980 """_client starts as None before any auth call."""
70- qb = QbDownloader (host = "localhost:8080" , username = "admin" , password = "pass" , ssl = False )
81+ qb = QbDownloader (
82+ host = "localhost:8080" , username = "admin" , password = "pass" , ssl = False
83+ )
7184 assert qb ._client is None
7285
7386
@@ -110,7 +123,9 @@ class TestAuthClientCreation:
110123
111124 async def test_auth_creates_client_with_verify_false_when_ssl_true (self ):
112125 """verify=False is used even when ssl=True (self-signed certs are common)."""
113- qb = QbDownloader (host = "192.168.1.10:8080" , username = "admin" , password = "pass" , ssl = True )
126+ qb = QbDownloader (
127+ host = "192.168.1.10:8080" , username = "admin" , password = "pass" , ssl = True
128+ )
114129
115130 captured : list [dict ] = []
116131
@@ -127,7 +142,9 @@ async def post(self, url, data=None):
127142 async def aclose (self ):
128143 pass
129144
130- with patch ("module.downloader.client.qb_downloader.httpx.AsyncClient" , _FakeClient ):
145+ with patch (
146+ "module.downloader.client.qb_downloader.httpx.AsyncClient" , _FakeClient
147+ ):
131148 result = await qb .auth ()
132149
133150 assert result is True
@@ -136,7 +153,9 @@ async def aclose(self):
136153
137154 async def test_auth_creates_client_with_verify_false_when_ssl_false (self ):
138155 """verify=False is used even when ssl=False."""
139- qb = QbDownloader (host = "192.168.1.10:8080" , username = "admin" , password = "pass" , ssl = False )
156+ qb = QbDownloader (
157+ host = "192.168.1.10:8080" , username = "admin" , password = "pass" , ssl = False
158+ )
140159
141160 captured : list [dict ] = []
142161
@@ -153,7 +172,9 @@ async def post(self, url, data=None):
153172 async def aclose (self ):
154173 pass
155174
156- with patch ("module.downloader.client.qb_downloader.httpx.AsyncClient" , _FakeClient ):
175+ with patch (
176+ "module.downloader.client.qb_downloader.httpx.AsyncClient" , _FakeClient
177+ ):
157178 result = await qb .auth ()
158179
159180 assert result is True
@@ -178,12 +199,46 @@ async def post(self, url, data=None):
178199 async def aclose (self ):
179200 pass
180201
181- with patch ("module.downloader.client.qb_downloader.httpx.AsyncClient" , _FakeClient ):
202+ with patch (
203+ "module.downloader.client.qb_downloader.httpx.AsyncClient" , _FakeClient
204+ ):
182205 await qb .auth ()
183206
184207 assert len (captured_timeouts ) == 1
185208 assert captured_timeouts [0 ].connect == pytest .approx (5.0 )
186209
210+ async def test_auth_sets_connection_limits_for_keepalive (self ):
211+ """Regression for #984: qB client must cap keepalive so idle TCP
212+ sockets don't linger past proxy / NAS idle-reap timeouts, otherwise
213+ parallel renamer calls cascade into 'Server disconnected' errors."""
214+ qb = QbDownloader (host = "localhost:8080" , username = "u" , password = "p" , ssl = False )
215+
216+ captured : list [dict ] = []
217+
218+ class _FakeClient :
219+ def __init__ (self , ** kwargs ):
220+ captured .append (kwargs )
221+
222+ async def post (self , url , data = None ):
223+ resp = MagicMock ()
224+ resp .status_code = 200
225+ resp .text = "Ok."
226+ return resp
227+
228+ async def aclose (self ):
229+ pass
230+
231+ with patch (
232+ "module.downloader.client.qb_downloader.httpx.AsyncClient" , _FakeClient
233+ ):
234+ await qb .auth ()
235+
236+ limits = captured [0 ].get ("limits" )
237+ assert limits is not None
238+ assert limits .keepalive_expiry is not None
239+ assert limits .keepalive_expiry > 0
240+ assert limits .max_connections is not None
241+
187242
188243# ---------------------------------------------------------------------------
189244# auth: success / failure paths
@@ -195,7 +250,9 @@ class TestAuthSuccessFailure:
195250
196251 async def test_auth_returns_true_on_ok_response (self ):
197252 """Returns True when server responds 200 + 'Ok.'."""
198- qb = QbDownloader (host = "localhost:8080" , username = "admin" , password = "pass" , ssl = False )
253+ qb = QbDownloader (
254+ host = "localhost:8080" , username = "admin" , password = "pass" , ssl = False
255+ )
199256
200257 mock_client = AsyncMock ()
201258 mock_resp = MagicMock ()
@@ -213,7 +270,9 @@ async def test_auth_returns_true_on_ok_response(self):
213270
214271 async def test_auth_returns_false_on_403 (self ):
215272 """Returns False and stops retrying immediately on 403 Forbidden."""
216- qb = QbDownloader (host = "localhost:8080" , username = "admin" , password = "pass" , ssl = False )
273+ qb = QbDownloader (
274+ host = "localhost:8080" , username = "admin" , password = "pass" , ssl = False
275+ )
217276
218277 mock_client = AsyncMock ()
219278 mock_resp = MagicMock ()
@@ -233,7 +292,9 @@ async def test_auth_returns_false_on_403(self):
233292
234293 async def test_auth_retries_up_to_limit_on_server_error (self ):
235294 """Retries up to the retry limit on non-200/non-403 responses."""
236- qb = QbDownloader (host = "localhost:8080" , username = "admin" , password = "pass" , ssl = False )
295+ qb = QbDownloader (
296+ host = "localhost:8080" , username = "admin" , password = "pass" , ssl = False
297+ )
237298
238299 mock_client = AsyncMock ()
239300 mock_resp = MagicMock ()
@@ -271,7 +332,9 @@ async def test_https_url_logs_https_specific_guidance(self, caplog):
271332 )
272333
273334 mock_client = AsyncMock ()
274- mock_client .post = AsyncMock (side_effect = httpx .ConnectError ("Connection refused" ))
335+ mock_client .post = AsyncMock (
336+ side_effect = httpx .ConnectError ("Connection refused" )
337+ )
275338
276339 with patch (
277340 "module.downloader.client.qb_downloader.httpx.AsyncClient" ,
@@ -287,7 +350,9 @@ async def test_https_url_logs_https_specific_guidance(self, caplog):
287350 result = await qb .auth (retry = 1 )
288351
289352 assert result is False
290- error_messages = [r .message for r in caplog .records if r .levelno == logging .ERROR ]
353+ error_messages = [
354+ r .message for r in caplog .records if r .levelno == logging .ERROR
355+ ]
291356 assert any ("HTTPS" in msg for msg in error_messages )
292357 assert any (
293358 "disable SSL" in msg or "plain HTTP" in msg for msg in error_messages
@@ -296,7 +361,9 @@ async def test_https_url_logs_https_specific_guidance(self, caplog):
296361 async def test_https_url_derived_from_ssl_flag_logs_https_guidance (self , caplog ):
297362 """HTTPS guidance also fires when scheme comes from ssl=True (bare host)."""
298363 # Bare host + ssl=True -> self.host becomes https://... -> use_https=True in auth()
299- qb = QbDownloader (host = "192.168.1.10:8080" , username = "u" , password = "p" , ssl = True )
364+ qb = QbDownloader (
365+ host = "192.168.1.10:8080" , username = "u" , password = "p" , ssl = True
366+ )
300367 assert qb .host .startswith ("https://" )
301368
302369 mock_client = AsyncMock ()
@@ -315,7 +382,9 @@ async def test_https_url_derived_from_ssl_flag_logs_https_guidance(self, caplog)
315382 ):
316383 await qb .auth (retry = 1 )
317384
318- error_messages = [r .message for r in caplog .records if r .levelno == logging .ERROR ]
385+ error_messages = [
386+ r .message for r in caplog .records if r .levelno == logging .ERROR
387+ ]
319388 assert any ("HTTPS" in msg for msg in error_messages )
320389
321390 async def test_http_url_logs_generic_message_without_ssl_hint (self , caplog ):
@@ -325,7 +394,9 @@ async def test_http_url_logs_generic_message_without_ssl_hint(self, caplog):
325394 )
326395
327396 mock_client = AsyncMock ()
328- mock_client .post = AsyncMock (side_effect = httpx .ConnectError ("Connection refused" ))
397+ mock_client .post = AsyncMock (
398+ side_effect = httpx .ConnectError ("Connection refused" )
399+ )
329400
330401 with patch (
331402 "module.downloader.client.qb_downloader.httpx.AsyncClient" ,
@@ -341,14 +412,20 @@ async def test_http_url_logs_generic_message_without_ssl_hint(self, caplog):
341412 result = await qb .auth (retry = 1 )
342413
343414 assert result is False
344- error_messages = [r .message for r in caplog .records if r .levelno == logging .ERROR ]
345- assert any ("Cannot connect to qBittorrent Server" in msg for msg in error_messages )
415+ error_messages = [
416+ r .message for r in caplog .records if r .levelno == logging .ERROR
417+ ]
418+ assert any (
419+ "Cannot connect to qBittorrent Server" in msg for msg in error_messages
420+ )
346421 # SSL-disable hint must NOT appear for plain HTTP connections
347422 assert not any ("disable SSL" in msg for msg in error_messages )
348423
349424 async def test_http_url_derived_from_ssl_flag_false_no_ssl_hint (self , caplog ):
350425 """SSL-disable hint is absent when scheme comes from ssl=False (bare host)."""
351- qb = QbDownloader (host = "192.168.1.10:8080" , username = "u" , password = "p" , ssl = False )
426+ qb = QbDownloader (
427+ host = "192.168.1.10:8080" , username = "u" , password = "p" , ssl = False
428+ )
352429 assert qb .host .startswith ("http://" )
353430
354431 mock_client = AsyncMock ()
@@ -420,7 +497,9 @@ async def test_explicit_http_with_ssl_true_still_uses_generic_message(self, capl
420497 ):
421498 await qb .auth (retry = 1 )
422499
423- error_messages = [r .message for r in caplog .records if r .levelno == logging .ERROR ]
500+ error_messages = [
501+ r .message for r in caplog .records if r .levelno == logging .ERROR
502+ ]
424503 assert not any ("disable SSL" in msg for msg in error_messages )
425504 assert not any ("HTTPS" in msg for msg in error_messages )
426505
@@ -445,5 +524,7 @@ def test_url_format_with_https(self):
445524
446525 def test_url_with_explicit_http_scheme_overriding_ssl_true (self ):
447526 """_url works correctly when explicit http:// scheme overrides ssl=True."""
448- qb = QbDownloader (host = "http://nas.local:8080" , username = "u" , password = "p" , ssl = True )
527+ qb = QbDownloader (
528+ host = "http://nas.local:8080" , username = "u" , password = "p" , ssl = True
529+ )
449530 assert qb ._url ("torrents/info" ) == "http://nas.local:8080/api/v2/torrents/info"
0 commit comments