33"""
44
55import os
6+ import time
67from pathlib import Path
78from unittest import TestCase
89from unittest .mock import Mock , patch , mock_open
910from parameterized import parameterized
1011
1112import docker
13+ import requests
1214from click import ClickException
1315
1416from samcli .local .docker .durable_functions_emulator_container import DurableFunctionsEmulatorContainer
@@ -145,6 +147,47 @@ def test_stop_behavior(self, name, stop_exception, should_remove):
145147 else :
146148 self .mock_container .remove .assert_not_called ()
147149
150+ def test_stop_sets_container_to_none_after_successful_stop (self ):
151+ """Test that stop() sets self.container to None in the finally block after success"""
152+ container = self ._create_container (existing_container = self .mock_container )
153+ container ._capture_emulator_logs = Mock ()
154+
155+ container .stop ()
156+
157+ self .assertIsNone (container .container )
158+
159+ def test_stop_handles_not_found_and_sets_container_to_none (self ):
160+ """Test that stop() handles docker.errors.NotFound when container is already removed"""
161+ container = self ._create_container (existing_container = self .mock_container )
162+ container ._capture_emulator_logs = Mock ()
163+ self .mock_container .stop .side_effect = docker .errors .NotFound ("Already removed" )
164+
165+ container .stop ()
166+
167+ self .mock_container .stop .assert_called_once ()
168+ self .mock_container .remove .assert_not_called ()
169+ self .assertIsNone (container .container )
170+
171+ def test_stop_sets_container_to_none_after_generic_exception (self ):
172+ """Test that stop() sets self.container to None in the finally block even after exception"""
173+ container = self ._create_container (existing_container = self .mock_container )
174+ container ._capture_emulator_logs = Mock ()
175+ self .mock_container .stop .side_effect = Exception ("Unexpected error" )
176+
177+ container .stop ()
178+
179+ self .assertIsNone (container .container )
180+
181+ def test_stop_does_nothing_when_no_container (self ):
182+ """Test that stop() does nothing when self.container is None"""
183+ container = self ._create_container (existing_container = None )
184+
185+ container .stop ()
186+
187+ self .mock_container .stop .assert_not_called ()
188+ self .mock_container .remove .assert_not_called ()
189+ self .assertIsNone (container .container )
190+
148191 @parameterized .expand (
149192 [
150193 # (name, env_vars, container_exists, container_running, expected_reused, should_create_new)
@@ -204,6 +247,16 @@ def test_is_running_status(self, name, container_status, expected):
204247 if existing :
205248 self .mock_container .reload .assert_called_once ()
206249
250+ def test_is_running_returns_false_when_reload_raises_exception (self ):
251+ """Test that is_running() returns False when container.reload() raises an exception"""
252+ self .mock_container .reload .side_effect = Exception ("Connection error" )
253+ container = self ._create_container (existing_container = self .mock_container )
254+
255+ result = container .is_running ()
256+
257+ self .assertFalse (result )
258+ self .mock_container .reload .assert_called_once ()
259+
207260 @parameterized .expand (
208261 [
209262 ("with_container" , True , "test logs" ),
@@ -223,6 +276,15 @@ def test_get_logs(self, name, has_container, expected_logs):
223276 if existing :
224277 self .mock_container .logs .assert_called_once_with (tail = 100 )
225278
279+ def test_get_logs_returns_error_message_when_logs_raises_exception (self ):
280+ """Test that get_logs() returns error message when container.logs() raises an exception"""
281+ self .mock_container .logs .side_effect = Exception ("Docker API error" )
282+ container = self ._create_container (existing_container = self .mock_container )
283+
284+ result = container .get_logs ()
285+
286+ self .assertEqual (result , "Could not retrieve logs: Docker API error" )
287+
226288 @parameterized .expand (
227289 [
228290 ("x86_64" , "aws-durable-execution-emulator-x86_64" ),
@@ -359,6 +421,58 @@ def test_image_pull_failure_raises_click_exception(self):
359421 container ._pull_image_if_needed ()
360422 self .assertIn ("Failed to pull emulator image" , str (context .exception ))
361423
424+ @patch ("samcli.local.docker.durable_functions_emulator_container.requests" )
425+ def test_start_durable_execution_success (self , mock_requests ):
426+ """Test that start_durable_execution() posts correct payload and returns response json"""
427+ mock_response = Mock ()
428+ mock_response .json .return_value = {"executionId" : "abc-123" }
429+ mock_requests .post .return_value = mock_response
430+
431+ container = self ._create_container ()
432+ durable_config = {"ExecutionTimeout" : 300 , "RetentionPeriodInDays" : 7 }
433+ result = container .start_durable_execution ("my-exec" , '{"key": "val"}' , "http://host:3001" , durable_config )
434+
435+ self .assertEqual (result , {"executionId" : "abc-123" })
436+ mock_requests .post .assert_called_once ()
437+ call_kwargs = mock_requests .post .call_args
438+ payload = call_kwargs .kwargs ["json" ]
439+ self .assertEqual (payload ["ExecutionName" ], "my-exec" )
440+ self .assertEqual (payload ["Input" ], '{"key": "val"}' )
441+ self .assertEqual (payload ["LambdaEndpoint" ], "http://host:3001" )
442+ self .assertEqual (payload ["ExecutionTimeoutSeconds" ], 300 )
443+ self .assertEqual (payload ["ExecutionRetentionPeriodDays" ], 7 )
444+ mock_response .raise_for_status .assert_called_once ()
445+
446+ @patch ("samcli.local.docker.durable_functions_emulator_container.requests" )
447+ def test_start_durable_execution_raises_runtime_error_on_exception (self , mock_requests ):
448+ """Test that start_durable_execution() raises RuntimeError when request fails"""
449+ mock_requests .post .side_effect = Exception ("Connection refused" )
450+
451+ container = self ._create_container ()
452+ with self .assertRaises (RuntimeError ) as ctx :
453+ container .start_durable_execution ("exec" , "{}" , "http://host:3001" , {})
454+
455+ self .assertIn ("Failed to start durable execution" , str (ctx .exception ))
456+ self .assertIn ("Connection refused" , str (ctx .exception ))
457+
458+ @patch ("samcli.local.docker.durable_functions_emulator_container.requests" )
459+ def test_start_durable_execution_includes_response_details_in_error (self , mock_requests ):
460+ """Test that error message includes status and response text when available"""
461+ mock_resp = Mock ()
462+ mock_resp .status_code = 500
463+ mock_resp .text = "Internal Server Error"
464+ exc = Exception ("HTTP error" )
465+ exc .response = mock_resp
466+ mock_requests .post .side_effect = exc
467+
468+ container = self ._create_container ()
469+ with self .assertRaises (RuntimeError ) as ctx :
470+ container .start_durable_execution ("exec" , "{}" , "http://host:3001" , {})
471+
472+ error_msg = str (ctx .exception )
473+ self .assertIn ("Status: 500" , error_msg )
474+ self .assertIn ("Internal Server Error" , error_msg )
475+
362476 @patch ("samcli.local.docker.durable_functions_emulator_container.requests" )
363477 def test_wait_for_ready_succeeds_when_healthy (self , mock_requests ):
364478 """Test that _wait_for_ready() succeeds when health check passes"""
@@ -372,6 +486,102 @@ def test_wait_for_ready_succeeds_when_healthy(self, mock_requests):
372486 container ._wait_for_ready (timeout = 1 )
373487 mock_requests .get .assert_called ()
374488
489+ @patch ("samcli.local.docker.durable_functions_emulator_container.time" )
490+ @patch ("samcli.local.docker.durable_functions_emulator_container.requests" )
491+ def test_wait_for_ready_retries_on_request_exception_then_times_out (self , mock_requests , mock_time ):
492+ """Test that RequestException is caught and retried until timeout"""
493+ mock_requests .exceptions .RequestException = requests .exceptions .RequestException
494+ mock_requests .get .side_effect = requests .exceptions .RequestException ("Connection refused" )
495+ mock_time .time .side_effect = [0 , 0.1 , 0.6 , 1.1 ]
496+ mock_time .strftime = time .strftime
497+
498+ container = self ._create_container (existing_container = self .mock_container )
499+ self .mock_container .status = "running"
500+ self .mock_container .logs .return_value = b"some logs"
501+
502+ with self .assertRaises (RuntimeError ) as ctx :
503+ container ._wait_for_ready (timeout = 1 )
504+
505+ self .assertIn ("failed to become ready" , str (ctx .exception ))
506+ self .assertTrue (mock_requests .get .call_count >= 2 )
507+ mock_time .sleep .assert_called_with (0.5 )
508+
509+ @patch ("samcli.local.docker.durable_functions_emulator_container.time" )
510+ @patch ("samcli.local.docker.durable_functions_emulator_container.requests" )
511+ def test_wait_for_ready_breaks_on_non_request_exception (self , mock_requests , mock_time ):
512+ """Test that non-RequestException breaks the loop immediately"""
513+ mock_requests .exceptions .RequestException = requests .exceptions .RequestException
514+ self .mock_container .status = "running"
515+ self .mock_container .reload .side_effect = RuntimeError ("Docker daemon error" )
516+ self .mock_container .logs .return_value = b"error logs"
517+ mock_time .time .side_effect = [0 , 0.1 ]
518+ mock_time .strftime = time .strftime
519+
520+ container = self ._create_container (existing_container = self .mock_container )
521+
522+ with self .assertRaises (RuntimeError ) as ctx :
523+ container ._wait_for_ready (timeout = 30 )
524+
525+ self .assertIn ("failed to become ready" , str (ctx .exception ))
526+ self .mock_container .reload .assert_called_once ()
527+
528+ @patch ("samcli.local.docker.durable_functions_emulator_container.time" )
529+ @patch ("samcli.local.docker.durable_functions_emulator_container.requests" )
530+ def test_wait_for_ready_raises_when_container_not_running (self , mock_requests , mock_time ):
531+ """Test that RuntimeError is raised when container status is not running"""
532+ mock_requests .exceptions .RequestException = requests .exceptions .RequestException
533+ self .mock_container .status = "exited"
534+ self .mock_container .logs .return_value = b"crash logs"
535+ mock_time .time .side_effect = [0 , 0.1 ]
536+ mock_time .strftime = time .strftime
537+
538+ container = self ._create_container (existing_container = self .mock_container )
539+
540+ with self .assertRaises (RuntimeError ) as ctx :
541+ container ._wait_for_ready (timeout = 30 )
542+
543+ self .assertIn ("failed to become ready" , str (ctx .exception ))
544+
545+ @patch ("samcli.local.docker.durable_functions_emulator_container.time" )
546+ @patch ("samcli.local.docker.durable_functions_emulator_container.requests" )
547+ def test_wait_for_ready_logs_container_exited_status (self , mock_requests , mock_time ):
548+ """Test that the RuntimeError raised on line 390 includes the container exit status"""
549+ mock_requests .exceptions .RequestException = requests .exceptions .RequestException
550+ self .mock_container .status = "exited"
551+ self .mock_container .logs .return_value = b"logs"
552+ mock_time .time .side_effect = [0 , 0.1 ]
553+ mock_time .strftime = time .strftime
554+
555+ container = self ._create_container (existing_container = self .mock_container )
556+
557+ with (
558+ self .assertRaises (RuntimeError ),
559+ self .assertLogs ("samcli.local.docker.durable_functions_emulator_container" , level = "ERROR" ) as log ,
560+ ):
561+ container ._wait_for_ready (timeout = 30 )
562+
563+ self .assertTrue (
564+ any ("Durable Functions Emulator container exited with status: exited" in msg for msg in log .output )
565+ )
566+
567+ @patch ("samcli.local.docker.durable_functions_emulator_container.time" )
568+ @patch ("samcli.local.docker.durable_functions_emulator_container.requests" )
569+ def test_wait_for_ready_handles_log_retrieval_failure (self , mock_requests , mock_time ):
570+ """Test that failure to retrieve logs after timeout does not prevent RuntimeError"""
571+ mock_requests .exceptions .RequestException = requests .exceptions .RequestException
572+ mock_requests .get .side_effect = requests .exceptions .RequestException ("refused" )
573+ mock_time .time .side_effect = [0 , 1.1 ]
574+ mock_time .strftime = time .strftime
575+ self .mock_container .status = "running"
576+ self .mock_container .logs .side_effect = Exception ("Cannot get logs" )
577+
578+ container = self ._create_container (existing_container = self .mock_container )
579+
580+ with self .assertRaises (RuntimeError ) as ctx :
581+ container ._wait_for_ready (timeout = 1 )
582+
583+ self .assertIn ("failed to become ready" , str (ctx .exception ))
584+
375585 @parameterized .expand (
376586 [
377587 # (name, env_value, has_container, should_capture, expected_logs)
@@ -424,6 +634,52 @@ def test_log_capture_handles_exceptions_gracefully(self, mock_getcwd, mock_file)
424634
425635 container ._capture_emulator_logs () # Should not raise
426636
637+ @patch ("samcli.local.docker.durable_functions_emulator_container.DurableFunctionsClient" )
638+ def test_start_or_attach_stops_and_removes_non_running_container (self , mock_client_class ):
639+ """Test that start_or_attach stops/removes a non-running existing container and creates a new one"""
640+ container = self ._create_container ()
641+
642+ mock_existing = Mock ()
643+ mock_existing .status = "exited"
644+ self .mock_docker_client .containers .get .return_value = mock_existing
645+
646+ container .start = Mock ()
647+ result = container .start_or_attach ()
648+
649+ mock_existing .stop .assert_called_once ()
650+ mock_existing .remove .assert_called_once ()
651+ container .start .assert_called_once ()
652+ self .assertFalse (result )
653+
654+ @patch ("samcli.local.docker.durable_functions_emulator_container.DurableFunctionsClient" )
655+ def test_start_or_attach_handles_stop_remove_failure_gracefully (self , mock_client_class ):
656+ """Test that start_or_attach handles exceptions when stopping/removing a non-running container"""
657+ container = self ._create_container ()
658+
659+ mock_existing = Mock ()
660+ mock_existing .status = "exited"
661+ mock_existing .stop .side_effect = Exception ("Stop failed" )
662+ self .mock_docker_client .containers .get .return_value = mock_existing
663+
664+ container .start = Mock ()
665+ result = container .start_or_attach ()
666+
667+ mock_existing .stop .assert_called_once ()
668+ container .start .assert_called_once ()
669+ self .assertFalse (result )
670+
671+ def test_stop_skips_container_operations_in_external_mode (self ):
672+ """Test that stop() returns early without stopping container in external emulator mode"""
673+ with patch .dict ("os.environ" , {"DURABLE_EXECUTIONS_EXTERNAL_EMULATOR_PORT" : "8080" }, clear = True ):
674+ container = self ._create_container (existing_container = self .mock_container )
675+ container ._capture_emulator_logs = Mock ()
676+
677+ container .stop ()
678+
679+ container ._capture_emulator_logs .assert_not_called ()
680+ self .mock_container .stop .assert_not_called ()
681+ self .mock_container .remove .assert_not_called ()
682+
427683 def test_stop_captures_logs_before_stopping (self ):
428684 """Test that stop() captures logs before stopping container"""
429685 with patch .dict ("os.environ" , {"DURABLE_EXECUTIONS_CAPTURE_LOGS" : "1" }, clear = True ):
0 commit comments