Skip to content

Commit 6efae88

Browse files
committed
chore: update test coverage + imports for durable_functions_emulator_container.py
1 parent a3f908f commit 6efae88

2 files changed

Lines changed: 261 additions & 1 deletion

File tree

samcli/local/docker/durable_functions_emulator_container.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515

1616
from samcli.lib.build.utils import _get_host_architecture
1717
from samcli.lib.clients.lambda_client import DurableFunctionsClient
18-
from samcli.local.docker.utils import is_image_current, to_posix_path
18+
from samcli.local.docker.utils import (
19+
get_validated_container_client,
20+
is_image_current,
21+
to_posix_path,
22+
)
1923

2024
LOG = logging.getLogger(__name__)
2125

tests/unit/local/docker/test_durable_functions_emulator_container.py

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
"""
44

55
import os
6+
import time
67
from pathlib import Path
78
from unittest import TestCase
89
from unittest.mock import Mock, patch, mock_open
910
from parameterized import parameterized
1011

1112
import docker
13+
import requests
1214
from click import ClickException
1315

1416
from 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

Comments
 (0)