66
77import pandas as pd
88import pytest
9+ import requests
910from websockets .exceptions import ConnectionClosedError , InvalidStatus
1011
1112from hbnmigration .from_curious .alerts_to_redcap import (
@@ -345,6 +346,72 @@ async def test_websocket_listener_skips_non_answer_messages(mock_alerts_dependen
345346 assert not mock_alerts_dependencies ["push" ].called
346347
347348
349+ @pytest .mark .asyncio
350+ async def test_main_with_reconnect_reauths_on_401_then_succeeds (caplog ):
351+ """Test that main_with_reconnect re-authenticates on 401 and succeeds."""
352+ with (
353+ patch (
354+ "hbnmigration.from_curious.alerts_to_redcap.connect_to_websocket"
355+ ) as mock_ws ,
356+ patch (
357+ "hbnmigration.from_curious.alerts_to_redcap.websocket_listener" ,
358+ new_callable = AsyncMock ,
359+ ) as mock_listener ,
360+ patch (
361+ "hbnmigration.from_curious.alerts_to_redcap.asyncio.sleep" ,
362+ new_callable = AsyncMock ,
363+ ),
364+ patch (
365+ "hbnmigration.from_curious.alerts_to_redcap.curious_authenticate"
366+ ) as mock_auth ,
367+ ):
368+ mock_auth .return_value = create_mock_tokens_ws ()
369+ # First connect raises 401, second succeeds
370+ mock_ctx = AsyncMock ()
371+ mock_ctx .__aenter__ .return_value = AsyncMock ()
372+ mock_ws .side_effect = [
373+ create_mock_invalid_status (requests .codes ["unauthorized" ]),
374+ mock_ctx ,
375+ ]
376+
377+ await main_with_reconnect (
378+ applet_name = "test_applet" , uri = "wss://test.com/alerts" , max_attempts = 2
379+ )
380+ # Initial auth + re-auth on 401
381+ assert mock_auth .call_count == 2
382+ assert mock_listener .called
383+ assert "Re-authentication successful" in caplog .text
384+
385+
386+ @pytest .mark .asyncio
387+ async def test_main_with_reconnect_reauth_failure_raises (caplog ):
388+ """Test that main_with_reconnect raises when re-authentication itself fails."""
389+ with (
390+ patch (
391+ "hbnmigration.from_curious.alerts_to_redcap.connect_to_websocket"
392+ ) as mock_ws ,
393+ patch (
394+ "hbnmigration.from_curious.alerts_to_redcap.asyncio.sleep" ,
395+ new_callable = AsyncMock ,
396+ ),
397+ patch (
398+ "hbnmigration.from_curious.alerts_to_redcap.curious_authenticate"
399+ ) as mock_auth ,
400+ ):
401+ # Initial auth succeeds, re-auth on 401 fails
402+ mock_auth .side_effect = [
403+ create_mock_tokens_ws (),
404+ Exception ("Auth server down" ),
405+ ]
406+ mock_ws .side_effect = create_mock_invalid_status (requests .codes ["unauthorized" ])
407+
408+ with pytest .raises (Exception , match = "Auth server down" ):
409+ await main_with_reconnect (
410+ applet_name = "test_applet" , uri = "wss://test.com/alerts" , max_attempts = 2
411+ )
412+ assert "Re-authentication failed" in caplog .text
413+
414+
348415# ============================================================================
349416# Tests - main_with_reconnect
350417# ============================================================================
@@ -357,9 +424,8 @@ async def test_main_with_reconnect_successful_connection(
357424 """Test that main_with_reconnect handles successful connection."""
358425 setup_standard_alert_mocks (mock_alerts_dependencies , redcap_alerts_metadata )
359426 with setup_reconnect_mocks () as mocks :
360- tokens = create_mock_tokens_ws ()
361427 await main_with_reconnect (
362- tokens = tokens , uri = "wss://test.com/alerts" , max_attempts = 1
428+ applet_name = "test_applet" , uri = "wss://test.com/alerts" , max_attempts = 1
363429 )
364430 assert mocks ["ws" ].called
365431 assert mocks ["listener" ].called
@@ -369,9 +435,8 @@ async def test_main_with_reconnect_successful_connection(
369435async def test_main_with_reconnect_handles_connection_error (caplog ):
370436 """Test that main_with_reconnect reconnects on ConnectionClosedError."""
371437 with setup_reconnect_mocks ([ConnectionClosedError (None , None ), None ]) as mocks :
372- tokens = create_mock_tokens_ws ()
373438 await main_with_reconnect (
374- tokens = tokens , uri = "wss://test.com/alerts" , max_attempts = 2
439+ applet_name = "test_applet" , uri = "wss://test.com/alerts" , max_attempts = 2
375440 )
376441 assert mocks ["listener" ].call_count == 2
377442 assert "Reconnecting in" in caplog .text
@@ -382,10 +447,9 @@ async def test_main_with_reconnect_handles_connection_error(caplog):
382447async def test_main_with_reconnect_max_attempts_exceeded ():
383448 """Test that main_with_reconnect stops after max attempts."""
384449 with setup_reconnect_mocks (ConnectionClosedError (None , None )) as mocks :
385- tokens = create_mock_tokens_ws ()
386450 with pytest .raises (ConnectionClosedError ):
387451 await main_with_reconnect (
388- tokens = tokens , uri = "wss://test.com/alerts" , max_attempts = 1
452+ applet_name = "test_applet" , uri = "wss://test.com/alerts" , max_attempts = 1
389453 )
390454 assert mocks ["listener" ].call_count == 1
391455
@@ -398,25 +462,34 @@ async def test_main_with_reconnect_handles_auth_error(caplog):
398462 "hbnmigration.from_curious.alerts_to_redcap.connect_to_websocket"
399463 ) as mock_ws ,
400464 patch ("hbnmigration.from_curious.alerts_to_redcap.asyncio.sleep" ) as mock_sleep ,
465+ patch (
466+ "hbnmigration.from_curious.alerts_to_redcap.curious_authenticate"
467+ ) as mock_auth ,
401468 ):
402- mock_ws .side_effect = create_mock_invalid_status (401 )
469+ mock_ws .side_effect = create_mock_invalid_status (requests . codes [ "unauthorized" ] )
403470 mock_sleep .return_value = None
404- tokens = create_mock_tokens_ws ()
471+ # First call is the initial auth; second call is the re-auth on 401
472+ mock_auth .side_effect = [
473+ create_mock_tokens_ws (),
474+ create_mock_tokens_ws (),
475+ ]
405476 with pytest .raises (InvalidStatus ):
406477 await main_with_reconnect (
407- tokens = tokens , uri = "wss://test.com/alerts" , max_attempts = 1
478+ applet_name = "test_applet" , uri = "wss://test.com/alerts" , max_attempts = 1
408479 )
409- assert "Authentication failed" in caplog .text
480+ assert (
481+ "Token expired or invalid" in caplog .text
482+ or "Authentication failed" in caplog .text
483+ )
410484
411485
412486@pytest .mark .asyncio
413487async def test_main_with_reconnect_infinite_retries ():
414488 """Test that main_with_reconnect runs indefinitely with None max_attempts."""
415489 side_effects = [ConnectionClosedError (None , None )] * 5 + [None ]
416490 with setup_reconnect_mocks (side_effects ) as mocks :
417- tokens = create_mock_tokens_ws ()
418491 await main_with_reconnect (
419- tokens = tokens , uri = "wss://test.com/alerts" , max_attempts = None
492+ applet_name = "test_applet" , uri = "wss://test.com/alerts" , max_attempts = None
420493 )
421494 assert mocks ["listener" ].call_count == 6
422495 assert mocks ["sleep" ].call_count == 5
@@ -448,22 +521,16 @@ async def test_main_processes_websocket_messages(
448521 await main (applet_names = ["Healthy Brain Network Questionnaires" ])
449522 assert mock_reconnect .called
450523 call_kwargs = mock_reconnect .call_args [1 ]
451- assert "tokens " in call_kwargs
524+ assert "applet_name " in call_kwargs
452525 assert "wss://" in call_kwargs ["uri" ]
453526
454527
455528@pytest .mark .asyncio
456529async def test_main_passes_max_attempts ():
457530 """Test that main() passes max_attempts parameter."""
458- with (
459- patch (
460- "hbnmigration.from_curious.alerts_to_redcap.curious_authenticate"
461- ) as mock_auth ,
462- patch (
463- "hbnmigration.from_curious.alerts_to_redcap.main_with_reconnect"
464- ) as mock_reconnect ,
465- ):
466- mock_auth .return_value = create_mock_tokens_ws ()
531+ with patch (
532+ "hbnmigration.from_curious.alerts_to_redcap.main_with_reconnect"
533+ ) as mock_reconnect :
467534 mock_reconnect .return_value = None
468535 await main (
469536 applet_names = ["Healthy Brain Network Questionnaires" ],
0 commit comments