@@ -887,6 +887,100 @@ async def test_start_container_existing_multiple_ports(self, docker_container_cl
887887 assert result ["status" ] == "existing"
888888 assert result ["host_port" ] == "5020"
889889
890+ @pytest .mark .asyncio
891+ async def test_start_container_existing_no_port_mapping_with_host_port (self , docker_container_client , mock_container ):
892+ """Test starting container when existing container has no port mapping but host_port is provided (line 228-229)"""
893+ docker_container_client .client .containers .get .return_value = mock_container
894+ mock_container .status = "running"
895+ mock_container .attrs = {
896+ "NetworkSettings" : {
897+ "Ports" : {} # No port mappings
898+ }
899+ }
900+
901+ with patch .object (DockerContainerClient , "_is_running_in_docker" , return_value = False ), \
902+ patch .object (DockerContainerClient , "_get_service_host" , return_value = "localhost" ):
903+ result = await docker_container_client .start_container (
904+ service_name = "test-service" ,
905+ tenant_id = "tenant123" ,
906+ user_id = "user12345" ,
907+ full_command = ["npx" , "-y" , "test-mcp" ],
908+ host_port = 5025 , # Provide host_port parameter
909+ )
910+
911+ # Should use existing container with provided host_port
912+ assert result ["status" ] == "existing"
913+ assert result ["host_port" ] == "5025"
914+
915+ @pytest .mark .asyncio
916+ async def test_start_container_empty_full_command (self , docker_container_client ):
917+ """Test starting container with empty full_command raises error (line 272-273)"""
918+ docker_container_client .client .containers .get .side_effect = NotFound ("Container not found" )
919+
920+ with pytest .raises (ContainerError , match = "full_command is required to start container" ):
921+ await docker_container_client .start_container (
922+ service_name = "test-service" ,
923+ tenant_id = "tenant123" ,
924+ user_id = "user12345" ,
925+ full_command = [], # Empty command
926+ )
927+
928+ @pytest .mark .asyncio
929+ async def test_start_container_with_custom_image (self , docker_container_client ):
930+ """Test starting container with custom image parameter (line 276-277)"""
931+ docker_container_client .client .containers .get .side_effect = NotFound ("Container not found" )
932+
933+ new_container = MagicMock ()
934+ new_container .id = "new-container-id"
935+ new_container .status = "running"
936+ new_container .reload .return_value = None
937+ docker_container_client .client .containers .run .return_value = new_container
938+
939+ with patch .object (DockerContainerClient , "find_free_port" , return_value = 5020 ), \
940+ patch .object (DockerContainerClient , "_get_service_host" , return_value = "localhost" ), \
941+ patch .object (DockerContainerClient , "_wait_for_service_ready" , new_callable = AsyncMock ), \
942+ patch ("asyncio.sleep" , new_callable = AsyncMock ):
943+ await docker_container_client .start_container (
944+ service_name = "test-service" ,
945+ tenant_id = "tenant123" ,
946+ user_id = "user12345" ,
947+ full_command = ["python" , "script.py" ],
948+ image = "python:3.11-alpine" , # Custom image
949+ )
950+
951+ call_args = docker_container_client .client .containers .run .call_args
952+ assert call_args is not None
953+ assert call_args .kwargs ["image" ] == "python:3.11-alpine"
954+
955+ @pytest .mark .asyncio
956+ async def test_start_container_with_host_port_provided (self , docker_container_client ):
957+ """Test starting container when host_port is provided (line 252 - skip find_free_port)"""
958+ docker_container_client .client .containers .get .side_effect = NotFound ("Container not found" )
959+
960+ new_container = MagicMock ()
961+ new_container .id = "new-container-id"
962+ new_container .status = "running"
963+ new_container .reload .return_value = None
964+ docker_container_client .client .containers .run .return_value = new_container
965+
966+ with patch .object (DockerContainerClient , "find_free_port" ) as mock_find_port , \
967+ patch .object (DockerContainerClient , "_get_service_host" , return_value = "localhost" ), \
968+ patch .object (DockerContainerClient , "_wait_for_service_ready" , new_callable = AsyncMock ), \
969+ patch ("asyncio.sleep" , new_callable = AsyncMock ), \
970+ patch .object (DockerContainerClient , "_is_running_in_docker" , return_value = False ):
971+ await docker_container_client .start_container (
972+ service_name = "test-service" ,
973+ tenant_id = "tenant123" ,
974+ user_id = "user12345" ,
975+ full_command = ["npx" , "-y" , "test-mcp" ],
976+ host_port = 8080 , # Provide host_port, should skip find_free_port
977+ )
978+
979+ # find_free_port should not be called when host_port is provided
980+ mock_find_port .assert_not_called ()
981+ call_args = docker_container_client .client .containers .run .call_args
982+ assert call_args .kwargs ["environment" ]["PORT" ] == "8080"
983+
890984
891985# ---------------------------------------------------------------------------
892986# Test _wait_for_service_ready
@@ -950,6 +1044,27 @@ async def test_wait_for_service_ready_exception(self, docker_container_client):
9501044 with pytest .raises (ContainerConnectionError ):
9511045 await docker_container_client ._wait_for_service_ready ("http://localhost:5020/mcp" , max_retries = 3 , retry_delay = 0.1 )
9521046
1047+ @pytest .mark .asyncio
1048+ async def test_wait_for_service_ready_loop_iterations (self , docker_container_client ):
1049+ """Test waiting for service ready with multiple loop iterations (line 356)"""
1050+ mock_client = MagicMock ()
1051+ # Simulate multiple failures before success to test loop
1052+ call_count = 0
1053+ def is_connected ():
1054+ nonlocal call_count
1055+ call_count += 1
1056+ return call_count >= 5 # Success on 5th attempt
1057+ mock_client .is_connected .side_effect = is_connected
1058+ mock_client .__aenter__ = AsyncMock (return_value = mock_client )
1059+ mock_client .__aexit__ = AsyncMock (return_value = False )
1060+
1061+ with patch ("nexent.container.docker_client.Client" , return_value = mock_client ), \
1062+ patch ("asyncio.sleep" , new_callable = AsyncMock ) as mock_sleep :
1063+ await docker_container_client ._wait_for_service_ready ("http://localhost:5020/mcp" , max_retries = 10 , retry_delay = 0.01 )
1064+
1065+ # Should have slept 4 times (before 5th attempt succeeds)
1066+ assert mock_sleep .call_count == 4
1067+
9531068
9541069# ---------------------------------------------------------------------------
9551070# Test stop_container
@@ -1111,6 +1226,32 @@ def test_list_containers_empty_port_mapping(self, docker_container_client):
11111226 assert len (result ) == 1
11121227 assert result [0 ]["host_port" ] is None
11131228
1229+ def test_list_containers_host_port_none_or_empty (self , docker_container_client ):
1230+ """Test listing containers when host_port is None or empty string (line 448)"""
1231+ container = MagicMock ()
1232+ container .id = "test-container-id"
1233+ container .name = "mcp-test-service-user12345"
1234+ container .status = "running"
1235+ container .attrs = {
1236+ "NetworkSettings" : {
1237+ "Ports" : {
1238+ "5020/tcp" : [{"HostPort" : None }], # None value
1239+ "5021/tcp" : [{"HostPort" : "" }], # Empty string
1240+ }
1241+ }
1242+ }
1243+ docker_container_client .client .containers .list .return_value = [container ]
1244+
1245+ with patch .object (DockerContainerClient , "_get_service_host" , return_value = "localhost" ), \
1246+ patch .object (DockerContainerClient , "_is_running_in_docker" , return_value = False ):
1247+ result = docker_container_client .list_containers ()
1248+
1249+ assert len (result ) == 1
1250+ # Should not break on None or empty HostPort
1251+ # When HostPort is empty string, it will be returned as empty string (not None)
1252+ # Since the last HostPort value is empty string, host_port will be empty string
1253+ assert result [0 ]["host_port" ] == ""
1254+
11141255 def test_list_containers_exception (self , docker_container_client ):
11151256 """Test listing containers when exception occurs"""
11161257 docker_container_client .client .containers .list .side_effect = Exception ("Connection error" )
@@ -1269,6 +1410,34 @@ def test_get_container_status_empty_port_mapping(self, docker_container_client):
12691410 assert result is not None
12701411 assert result ["host_port" ] is None
12711412
1413+ def test_get_container_status_host_port_none_or_empty (self , docker_container_client ):
1414+ """Test getting container status when host_port is None or empty string (line 513)"""
1415+ container = MagicMock ()
1416+ container .id = "test-container-id"
1417+ container .name = "mcp-test-service-user12345"
1418+ container .status = "running"
1419+ container .attrs = {
1420+ "NetworkSettings" : {
1421+ "Ports" : {
1422+ "5020/tcp" : [{"HostPort" : None }], # None value
1423+ "5021/tcp" : [{"HostPort" : "" }], # Empty string
1424+ }
1425+ },
1426+ "Created" : "2024-01-01T00:00:00Z" ,
1427+ "Config" : {"Image" : "node:22-alpine" },
1428+ }
1429+ docker_container_client .client .containers .get .return_value = container
1430+
1431+ with patch .object (DockerContainerClient , "_get_service_host" , return_value = "localhost" ), \
1432+ patch .object (DockerContainerClient , "_is_running_in_docker" , return_value = False ):
1433+ result = docker_container_client .get_container_status ("test-container-id" )
1434+
1435+ assert result is not None
1436+ # Should not break on None or empty HostPort
1437+ # When HostPort is empty string, it will be returned as empty string (not None)
1438+ # Since the last HostPort value is empty string, host_port will be empty string
1439+ assert result ["host_port" ] == ""
1440+
12721441
12731442# ---------------------------------------------------------------------------
12741443# Test _ensure_network
@@ -1503,6 +1672,22 @@ def test_get_container_service_port_no_hostport(self):
15031672 port = DockerContainerClient ._get_container_service_port (container )
15041673 assert port is None
15051674
1675+ def test_get_container_service_port_non_string_env_item (self ):
1676+ """Test getting port when env list contains non-string items (line 131)"""
1677+ container = MagicMock ()
1678+ container .attrs = {
1679+ "Config" : {
1680+ "Env" : [123 , "PORT=5020" , None , {"key" : "value" }] # Mixed types
1681+ },
1682+ "NetworkSettings" : {
1683+ "Ports" : {}
1684+ }
1685+ }
1686+
1687+ port = DockerContainerClient ._get_container_service_port (container )
1688+ # Should still find PORT=5020 despite non-string items
1689+ assert port == "5020"
1690+
15061691
15071692# ---------------------------------------------------------------------------
15081693# Test start_container in Docker mode
0 commit comments