Repository: getmoto/moto
Pull request: Implement parentGroup, namePrefixFilter and recursive for list_thing_groups()
Closes #2961 

Problem statement:
iot list_thing_groups is incomplete
Current implementation of list_thing_groups doesn't take into account any function inputs. These are the inputs listed in boto3.
```
response = client.list_thing_groups(
    nextToken='string',
    maxResults=123,
    parentGroup='string',
    namePrefixFilter='string',
    recursive=True|False
)
```
This is the current implementation in moto, returning all the groups always:
```
def list_thing_groups(self, parent_group, name_prefix_filter, recursive):
    thing_groups = self.thing_groups.values()
    return thing_groups
```

It must also take into account `ResourceNotFoundException`

I'll be looking into this right now. 
I need at least an implementation for parentGroup for my project.

Hints from issue discussion:
This is the output for a tree of groups in boto3, using `parentGroup=DeleteMePlease`:
```
{
    "thingGroups": [
        {
            "groupName": "DeleteMePlease_sub_1",
        },
        {
            "groupName": "DeleteMePlease_sub_2",
        },
        {
            "groupName": "DeleteMePlease_sub_3",
        },
        {
            "groupName": "DeleteMePlease_sub_1_sub_1",
        },
        {
            "groupName": "DeleteMePlease_sub_1_sub_2",
        },
        {
            "groupName": "DeleteMePlease_sub_1_sub_3",
        },
        {
            "groupName": "DeleteMePlease_sub_1_sub_1_sub_1",
        },
        {
            "groupName": "DeleteMePlease_sub_2_sub_1",
        },
        {
            "groupName": "DeleteMePlease_sub_2_sub_2",
        },
        {
            "groupName": "DeleteMePlease_sub_2_sub_2_sub_1",
        },
        {
            "groupName": "DeleteMePlease_sub_3_sub_1",
        }
    ]
}
```
This suggests the following traversal strategy:
![image](https://user-images.githubusercontent.com/3289167/81088562-1af42500-8ec9-11ea-9901-f1f6399d15ef.png)

Commit message: Added test test_list_thing_groups()

Patch to review:
diff --git a/moto/iot/models.py b/moto/iot/models.py
index 2e9979bda203..5b74b353cb21 100644
--- a/moto/iot/models.py
+++ b/moto/iot/models.py
@@ -857,8 +857,30 @@ def delete_thing_group(self, thing_group_name, expected_version):
         del self.thing_groups[thing_group.arn]
 
     def list_thing_groups(self, parent_group, name_prefix_filter, recursive):
-        thing_groups = self.thing_groups.values()
-        return thing_groups
+        if recursive is None:
+            recursive = True
+        if name_prefix_filter is None:
+            name_prefix_filter = ""
+        if parent_group and parent_group not in [
+            _.thing_group_name for _ in self.thing_groups.values()
+        ]:
+            raise ResourceNotFoundException()
+        thing_groups = [
+            _ for _ in self.thing_groups.values() if _.parent_group_name == parent_group
+        ]
+        if recursive:
+            for g in thing_groups:
+                thing_groups.extend(
+                    self.list_thing_groups(
+                        parent_group=g.thing_group_name,
+                        name_prefix_filter=None,
+                        recursive=False,
+                    )
+                )
+        # thing_groups = groups_to_process.values()
+        return [
+            _ for _ in thing_groups if _.thing_group_name.startswith(name_prefix_filter)
+        ]
 
     def update_thing_group(
         self, thing_group_name, thing_group_properties, expected_version
diff --git a/moto/iot/responses.py b/moto/iot/responses.py
index c12d4b5c5ebb..07a8c10c2299 100644
--- a/moto/iot/responses.py
+++ b/moto/iot/responses.py
@@ -535,7 +535,7 @@ def list_thing_groups(self):
         # max_results = self._get_int_param("maxResults")
         parent_group = self._get_param("parentGroup")
         name_prefix_filter = self._get_param("namePrefixFilter")
-        recursive = self._get_param("recursive")
+        recursive = self._get_bool_param("recursive")
         thing_groups = self.iot_backend.list_thing_groups(
             parent_group=parent_group,
             name_prefix_filter=name_prefix_filter,
diff --git a/tests/test_iot/test_iot.py b/tests/test_iot/test_iot.py
index 58a820fee6f4..edf6235329ab 100644
--- a/tests/test_iot/test_iot.py
+++ b/tests/test_iot/test_iot.py
@@ -756,6 +756,140 @@ def test_delete_principal_thing():
     client.delete_certificate(certificateId=cert_id)
 
 
+@mock_iot
+def test_list_thing_groups():
+    client = boto3.client("iot", region_name="ap-northeast-1")
+    group_name_1a = "my-group-name-1a"
+    group_name_1b = "my-group-name-1b"
+    group_name_2a = "my-group-name-2a"
+    group_name_2b = "my-group-name-2b"
+    group_name_3a = "my-group-name-3a"
+    group_name_3b = "my-group-name-3b"
+    group_name_3c = "my-group-name-3c"
+    group_name_3d = "my-group-name-3d"
+
+    # --1a
+    #    |--2a
+    #    |   |--3a
+    #    |   |--3b
+    #    |
+    #    |--2b
+    #        |--3c
+    #        |--3d
+    # --1b
+
+    # create thing groups tree
+    # 1
+    thing_group1a = client.create_thing_group(thingGroupName=group_name_1a)
+    thing_group1a.should.have.key("thingGroupName").which.should.equal(group_name_1a)
+    thing_group1a.should.have.key("thingGroupArn")
+    thing_group1b = client.create_thing_group(thingGroupName=group_name_1b)
+    thing_group1b.should.have.key("thingGroupName").which.should.equal(group_name_1b)
+    thing_group1b.should.have.key("thingGroupArn")
+    # 2
+    thing_group2a = client.create_thing_group(
+        thingGroupName=group_name_2a, parentGroupName=group_name_1a
+    )
+    thing_group2a.should.have.key("thingGroupName").which.should.equal(group_name_2a)
+    thing_group2a.should.have.key("thingGroupArn")
+    thing_group2b = client.create_thing_group(
+        thingGroupName=group_name_2b, parentGroupName=group_name_1a
+    )
+    thing_group2b.should.have.key("thingGroupName").which.should.equal(group_name_2b)
+    thing_group2b.should.have.key("thingGroupArn")
+    # 3
+    thing_group3a = client.create_thing_group(
+        thingGroupName=group_name_3a, parentGroupName=group_name_2a
+    )
+    thing_group3a.should.have.key("thingGroupName").which.should.equal(group_name_3a)
+    thing_group3a.should.have.key("thingGroupArn")
+    thing_group3b = client.create_thing_group(
+        thingGroupName=group_name_3b, parentGroupName=group_name_2a
+    )
+    thing_group3b.should.have.key("thingGroupName").which.should.equal(group_name_3b)
+    thing_group3b.should.have.key("thingGroupArn")
+    thing_group3c = client.create_thing_group(
+        thingGroupName=group_name_3c, parentGroupName=group_name_2b
+    )
+    thing_group3c.should.have.key("thingGroupName").which.should.equal(group_name_3c)
+    thing_group3c.should.have.key("thingGroupArn")
+    thing_group3d = client.create_thing_group(
+        thingGroupName=group_name_3d, parentGroupName=group_name_2b
+    )
+    thing_group3d.should.have.key("thingGroupName").which.should.equal(group_name_3d)
+    thing_group3d.should.have.key("thingGroupArn")
+
+    # begin tests
+    # should list all groups
+    resp = client.list_thing_groups()
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(8)
+    # should list all groups non-recursively
+    resp = client.list_thing_groups(recursive=False)
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(2)
+
+    # should list all groups filtered by parent
+    resp = client.list_thing_groups(parentGroup=group_name_1a)
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(6)
+    resp = client.list_thing_groups(parentGroup=group_name_2a)
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(2)
+    resp = client.list_thing_groups(parentGroup=group_name_1b)
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(0)
+    try:
+        client.list_thing_groups(parentGroup="inexistant-group-name")
+    except client.exceptions.ResourceNotFoundException as exc:
+        error_code = exc.response["Error"]["Code"]
+        error_code.should.equal("ResourceNotFoundException")
+    else:
+        raise Exception("Should have raised error")
+    # should list all groups filtered by parent non-recursively
+    resp = client.list_thing_groups(parentGroup=group_name_1a, recursive=False)
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(2)
+    resp = client.list_thing_groups(parentGroup=group_name_2a, recursive=False)
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(2)
+
+    # should list all groups filtered by name prefix
+    resp = client.list_thing_groups(namePrefixFilter="my-group-name-1")
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(2)
+    resp = client.list_thing_groups(namePrefixFilter="my-group-name-3")
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(4)
+    resp = client.list_thing_groups(namePrefixFilter="prefix-which-doesn-not-match")
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(0)
+    # should list all groups filtered by name prefix non-recursively
+    resp = client.list_thing_groups(namePrefixFilter="my-group-name-1", recursive=False)
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(2)
+    resp = client.list_thing_groups(namePrefixFilter="my-group-name-3", recursive=False)
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(0)
+
+    # should list all groups filtered by name prefix and parent
+    resp = client.list_thing_groups(
+        namePrefixFilter="my-group-name-2", parentGroup=group_name_1a
+    )
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(2)
+    resp = client.list_thing_groups(
+        namePrefixFilter="my-group-name-3", parentGroup=group_name_1a
+    )
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(4)
+    resp = client.list_thing_groups(
+        namePrefixFilter="prefix-which-doesn-not-match", parentGroup=group_name_1a
+    )
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(0)
+
+
 @mock_iot
 def test_delete_thing_group():
     client = boto3.client("iot", region_name="ap-northeast-1")


Code review comments:
diff_hunk: @@ -756,6 +756,140 @@ def test_delete_principal_thing():
     client.delete_certificate(certificateId=cert_id)
 
 
+@mock_iot
+def test_list_thing_groups():
+    client = boto3.client("iot", region_name="ap-northeast-1")
+    group_name_1a = "my-group-name-1a"
+    group_name_1b = "my-group-name-1b"
+    group_name_2a = "my-group-name-2a"
+    group_name_2b = "my-group-name-2b"
+    group_name_3a = "my-group-name-3a"
+    group_name_3b = "my-group-name-3b"
+    group_name_3c = "my-group-name-3c"
+    group_name_3d = "my-group-name-3d"
+
+    # --1a
+    #    |--2a
+    #    |   |--3a
+    #    |   |--3b
+    #    |
+    #    |--2b
+    #        |--3c
+    #        |--3d
+    # --1b
+
+    # create thing groups tree
+    # 1
+    thing_group1a = client.create_thing_group(thingGroupName=group_name_1a)
+    thing_group1a.should.have.key("thingGroupName").which.should.equal(group_name_1a)
+    thing_group1a.should.have.key("thingGroupArn")
+    thing_group1b = client.create_thing_group(thingGroupName=group_name_1b)
+    thing_group1b.should.have.key("thingGroupName").which.should.equal(group_name_1b)
+    thing_group1b.should.have.key("thingGroupArn")
+    # 2
+    thing_group2a = client.create_thing_group(
+        thingGroupName=group_name_2a, parentGroupName=group_name_1a
+    )
+    thing_group2a.should.have.key("thingGroupName").which.should.equal(group_name_2a)
+    thing_group2a.should.have.key("thingGroupArn")
+    thing_group2b = client.create_thing_group(
+        thingGroupName=group_name_2b, parentGroupName=group_name_1a
+    )
+    thing_group2b.should.have.key("thingGroupName").which.should.equal(group_name_2b)
+    thing_group2b.should.have.key("thingGroupArn")
+    # 3
+    thing_group3a = client.create_thing_group(
+        thingGroupName=group_name_3a, parentGroupName=group_name_2a
+    )
+    thing_group3a.should.have.key("thingGroupName").which.should.equal(group_name_3a)
+    thing_group3a.should.have.key("thingGroupArn")
+    thing_group3b = client.create_thing_group(
+        thingGroupName=group_name_3b, parentGroupName=group_name_2a
+    )
+    thing_group3b.should.have.key("thingGroupName").which.should.equal(group_name_3b)
+    thing_group3b.should.have.key("thingGroupArn")
+    thing_group3c = client.create_thing_group(
+        thingGroupName=group_name_3c, parentGroupName=group_name_2b
+    )
+    thing_group3c.should.have.key("thingGroupName").which.should.equal(group_name_3c)
+    thing_group3c.should.have.key("thingGroupArn")
+    thing_group3d = client.create_thing_group(
+        thingGroupName=group_name_3d, parentGroupName=group_name_2b
+    )
+    thing_group3d.should.have.key("thingGroupName").which.should.equal(group_name_3d)
+    thing_group3d.should.have.key("thingGroupArn")
+
+    # begin tests
+    # should list all groups
+    resp = client.list_thing_groups()
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(8)
+    # should list all groups non-recursively
+    resp = client.list_thing_groups(recursive=False)
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(2)
+
+    # should list all groups filtered by parent
+    resp = client.list_thing_groups(parentGroup=group_name_1a)
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(6)
+    resp = client.list_thing_groups(parentGroup=group_name_2a)
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(2)
+    resp = client.list_thing_groups(parentGroup=group_name_1b)
+    resp.should.have.key("thingGroups")
+    resp["thingGroups"].should.have.length_of(0)
+    try:
path: tests/test_iot/test_iot.py
text: @user1:
Please use the `with assert_raises(ClientException) as e`, here, similar to https://github.com/spulec/moto/blob/111d0519f5bcde1fa915c3f3aefbae1c70b6c745/tests/test_config/test_config.py#L31

@author:
Done