Repository: getmoto/moto
Pull request #6918 (2023-10-15 14:19:43)

IOT: update_thing() now behaves correctly with merge=True
The last patch missed some cases and the behaviour was wrong.
I added all those cases in the test and fixed the problems.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/6918?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
> Merging [#6918](https://app.codecov.io/gh/getmoto/moto/pull/6918?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) (669c742) into [master](https://app.codecov.io/gh/getmoto/moto/commit/681f117b630dafda0cc07bba8a7dd85b126f38f3?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) (681f117) will **decrease** coverage by `0.01%`.
> Report is 4 commits behind head on master.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##           master    #6918      +/-   ##
==========================================
- Coverage   95.82%   95.82%   -0.01%     
==========================================
  Files         826      826              
  Lines       81060    81059       -1     
==========================================
- Hits        77678    77674       -4     
- Misses       3382     3385       +3     
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/6918/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/6918/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `36.74% <14.28%> (-0.01%)` | :arrow_down: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/6918/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `95.76% <100.00%> (-0.01%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files](https://app.codecov.io/gh/getmoto/moto/pull/6918?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [moto/\_\_init\_\_.py](https://app.codecov.io/gh/getmoto/moto/pull/6918?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by9fX2luaXRfXy5weQ==) | `98.74% <100.00%> (ø)` | |
| [moto/dynamodb/models/\_\_init\_\_.py](https://app.codecov.io/gh/getmoto/moto/pull/6918?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by9keW5hbW9kYi9tb2RlbHMvX19pbml0X18ucHk=) | `92.49% <100.00%> (-0.08%)` | :arrow_down: |
| [moto/iot/models.py](https://app.codecov.io/gh/getmoto/moto/pull/6918?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#diff-bW90by9pb3QvbW9kZWxzLnB5) | `93.41% <100.00%> (+0.02%)` | :arrow_up: |

... and [1 file with indirect coverage changes](https://app.codecov.io/gh/getmoto/moto/pull/6918/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto)



Patch:
diff --git a/moto/iot/models.py b/moto/iot/models.py
index 1ebce5eaa..9ef417831 100644
--- a/moto/iot/models.py
+++ b/moto/iot/models.py
@@ -883,7 +883,8 @@ class IoTBackend(BaseBackend):
             if not do_merge:
                 thing.attributes = attributes
             else:
-                thing.attributes = {k: v for k, v in attributes.items() if v}
+                thing.attributes.update(attributes)
+            thing.attributes = {k: v for k, v in thing.attributes.items() if v}
 
     def create_keys_and_certificate(
         self, set_as_active: bool
diff --git a/moto/route53/models.py b/moto/route53/models.py
index a6077cfb2..a6bbeaeec 100644
--- a/moto/route53/models.py
+++ b/moto/route53/models.py
@@ -4,6 +4,8 @@ import itertools
 import re
 import string
 from collections import defaultdict
+from datetime import datetime
+
 from jinja2 import Template
 from typing import Any, Dict, List, Optional, Tuple
 
@@ -36,6 +38,12 @@ def create_route53_zone_id() -> str:
     return "".join([random.choice(ROUTE53_ID_CHOICE) for _ in range(0, 15)])
 
 
+def create_route53_caller_reference() -> str:
+    timestamp = datetime.now().strftime("%H:%M:%S.%f")
+    random_string = "".join(random.choice(string.ascii_letters) for _ in range(6))
+    return f"{random_string} {timestamp}"
+
+
 class DelegationSet(BaseModel):
     def __init__(
         self,
@@ -310,6 +318,7 @@ class FakeZone(CloudFormationModel):
         name: str,
         id_: str,
         private_zone: bool,
+        caller_reference: str,
         comment: Optional[str] = None,
         delegation_set: Optional[DelegationSet] = None,
     ):
@@ -318,6 +327,7 @@ class FakeZone(CloudFormationModel):
         self.vpcs: List[Dict[str, Any]] = []
         if comment is not None:
             self.comment = comment
+        self.caller_reference = caller_reference
         self.private_zone = private_zone
         self.rrsets: List[RecordSet] = []
         self.delegation_set = delegation_set
@@ -503,12 +513,14 @@ class Route53Backend(BaseBackend):
         self,
         name: str,
         private_zone: bool,
+        caller_reference: Optional[str] = None,
         vpcid: Optional[str] = None,
         vpcregion: Optional[str] = None,
         comment: Optional[str] = None,
         delegation_set_id: Optional[str] = None,
     ) -> FakeZone:
         new_id = create_route53_zone_id()
+        caller_reference = caller_reference or create_route53_caller_reference()
         delegation_set = self.create_reusable_delegation_set(
             caller_reference=f"DelSet_{name}", delegation_set_id=delegation_set_id
         )
@@ -518,6 +530,7 @@ class Route53Backend(BaseBackend):
         new_zone = FakeZone(
             name,
             new_id,
+            caller_reference=caller_reference,
             private_zone=private_zone,
             comment=comment,
             delegation_set=delegation_set,
diff --git a/moto/route53/responses.py b/moto/route53/responses.py
index 514b75793..eea217093 100644
--- a/moto/route53/responses.py
+++ b/moto/route53/responses.py
@@ -66,6 +66,7 @@ class Route53(BaseResponse):
                     vpcregion = zone_request["VPC"].get("VPCRegion", None)
 
             name = zone_request["Name"]
+            caller_reference = zone_request["CallerReference"]
 
             if name[-1] != ".":
                 name += "."
@@ -75,6 +76,7 @@ class Route53(BaseResponse):
                 name,
                 comment=comment,
                 private_zone=private_zone,
+                caller_reference=caller_reference,
                 vpcid=vpcid,
                 vpcregion=vpcregion,
                 delegation_set_id=delegation_set_id,
@@ -619,6 +621,7 @@ GET_HOSTED_ZONE_RESPONSE = """<GetHostedZoneResponse xmlns="https://route53.amaz
    <HostedZone>
       <Id>/hostedzone/{{ zone.id }}</Id>
       <Name>{{ zone.name }}</Name>
+        <CallerReference>{{ zone.caller_reference }}</CallerReference>
       <ResourceRecordSetCount>{{ zone.rrsets|count }}</ResourceRecordSetCount>
       <Config>
         {% if zone.comment %}


Test patch:
diff --git a/tests/test_iot/test_iot_things.py b/tests/test_iot/test_iot_things.py
index 773bdab3e..ddaffdc66 100644
--- a/tests/test_iot/test_iot_things.py
+++ b/tests/test_iot/test_iot_things.py
@@ -52,7 +52,32 @@ def test_update_thing():
     assert len(res["things"]) == 1
     assert res["things"][0]["thingName"] is not None
     assert res["things"][0]["thingArn"] is not None
-    assert res["things"][0]["attributes"]["k1"] == "v1"
+    assert res["things"][0]["attributes"] == {"k1": "v1"}
+
+    client.update_thing(thingName=name, attributePayload={"attributes": {"k2": "v2"}})
+    res = client.list_things()
+    assert len(res["things"]) == 1
+    assert res["things"][0]["thingName"] is not None
+    assert res["things"][0]["thingArn"] is not None
+    assert res["things"][0]["attributes"] == {"k2": "v2"}
+
+    client.update_thing(
+        thingName=name, attributePayload={"attributes": {"k1": "v1"}, "merge": True}
+    )
+    res = client.list_things()
+    assert len(res["things"]) == 1
+    assert res["things"][0]["thingName"] is not None
+    assert res["things"][0]["thingArn"] is not None
+    assert res["things"][0]["attributes"] == {"k1": "v1", "k2": "v2"}
+
+    client.update_thing(
+        thingName=name, attributePayload={"attributes": {"k1": "v1.1"}, "merge": True}
+    )
+    res = client.list_things()
+    assert len(res["things"]) == 1
+    assert res["things"][0]["thingName"] is not None
+    assert res["things"][0]["thingArn"] is not None
+    assert res["things"][0]["attributes"] == {"k1": "v1.1", "k2": "v2"}
 
     client.update_thing(
         thingName=name, attributePayload={"attributes": {"k1": ""}, "merge": True}
@@ -61,6 +86,13 @@ def test_update_thing():
     assert len(res["things"]) == 1
     assert res["things"][0]["thingName"] is not None
     assert res["things"][0]["thingArn"] is not None
+    assert res["things"][0]["attributes"] == {"k2": "v2"}
+
+    client.update_thing(thingName=name, attributePayload={"attributes": {"k2": ""}})
+    res = client.list_things()
+    assert len(res["things"]) == 1
+    assert res["things"][0]["thingName"] is not None
+    assert res["things"][0]["thingArn"] is not None
     assert res["things"][0]["attributes"] == {}
 
 
diff --git a/tests/test_route53/test_route53.py b/tests/test_route53/test_route53.py
index 60c7a9621..c19f7531f 100644
--- a/tests/test_route53/test_route53.py
+++ b/tests/test_route53/test_route53.py
@@ -32,6 +32,19 @@ def test_create_hosted_zone():
         assert "<Name>testdns.aws.com.</Name>" in requests.get(location).text
 
 
+@mock_route53
+def test_get_hosted_zone():
+    conn = boto3.client("route53", region_name="us-east-1")
+    name = "testdns.aws.com."
+    caller_ref = str(hash("foo"))
+
+    zone = conn.create_hosted_zone(Name=name, CallerReference=caller_ref)["HostedZone"]
+
+    res = conn.get_hosted_zone(Id=zone["Id"])
+    assert res["HostedZone"]["Name"] == name
+    assert res["HostedZone"]["CallerReference"] == caller_ref
+
+
 @mock_route53
 def test_list_hosted_zones():
     conn = boto3.client("route53", region_name="us-east-1")