Repository: getmoto/moto

Issue:
Route53: creating a health check with type of CALCULATED fails
When trying to create a CALCULATED health check for aggregating health check alarms, the response from the moto library fails to parse. However when executing the same request to an AWS account it succeeds with the creation.

Versions:
- Python @ 3.8
- Moto @ 2.0.2
- Botocore @ 1.20.30

Example test code:
```
@mock_route53
def test_create_calculated_health_check(self):
    client = boto3.client('route53')

    response = client.create_health_check(
        CallerReference='test-route53-health-HealthCheck-ZHV123',
        HealthCheckConfig={
            'Type':             'CALCULATED',
            'Inverted':         False,
            'Disabled':         False,
            'HealthThreshold':  1,
            'ChildHealthChecks': [
                '96f93979-70e9-4d55-b299-fab4db2931e6'
            ]
        },
    )

    self.assertIsNotNone(response)
```

Stacktrace:
```
======================================================================
ERROR: test_create_calculated_health_check (__main__.HealthChecksTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/moto/core/models.py", line 106, in wrapper
    result = func(*args, **kwargs)
  File "test_health_checks.py", line 43, in test_create_calculated_health_check
    response = client.create_health_check(
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/client.py", line 357, in _api_call
    return self._make_api_call(operation_name, kwargs)
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/client.py", line 662, in _make_api_call
    http, parsed_response = self._make_request(
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/client.py", line 682, in _make_request
    return self._endpoint.make_request(operation_model, request_dict)
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/endpoint.py", line 102, in make_request
    return self._send_request(request_dict, operation_model)
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/endpoint.py", line 134, in _send_request
    success_response, exception = self._get_response(
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/endpoint.py", line 166, in _get_response
    success_response, exception = self._do_get_response(
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/endpoint.py", line 217, in _do_get_response
    parsed_response = parser.parse(
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/parsers.py", line 245, in parse
    parsed = self._do_parse(response, shape)
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/parsers.py", line 809, in _do_parse
    self._add_modeled_parse(response, shape, final_parsed)
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/parsers.py", line 818, in _add_modeled_parse
    self._parse_payload(response, shape, member_shapes, final_parsed)
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/parsers.py", line 859, in _parse_payload
    body_parsed = self._parse_shape(shape, original_parsed)
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/parsers.py", line 312, in _parse_shape
    return handler(shape, node)
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/parsers.py", line 388, in _handle_structure
    parsed[member_name] = self._parse_shape(
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/parsers.py", line 312, in _parse_shape
    return handler(shape, node)
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/parsers.py", line 388, in _handle_structure
    parsed[member_name] = self._parse_shape(
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/parsers.py", line 312, in _parse_shape
    return handler(shape, node)
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/parsers.py", line 388, in _handle_structure
    parsed[member_name] = self._parse_shape(
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/parsers.py", line 312, in _parse_shape
    return handler(shape, node)
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/parsers.py", line 174, in _get_text_content
    return func(self, shape, text)
  File "/Users/craignewton/insided/ops-10/insided/community-setup/venv/lib/python3.8/site-packages/botocore/parsers.py", line 486, in _handle_integer
    return int(text)
ValueError: invalid literal for int() with base 10: 'None'
```


Discussion:
Looks like there are two issues (at least..) with the calculated health-check:
 - It returns IPAddress and Port in the response, when it shouldn't
 - It has port as 'None', with throws the above error.

Are you able to share an example response for the test case you provided, @newtondev? Just want to make sure I haven't missed anything else

Resolving patch:
diff --git a/moto/route53/models.py b/moto/route53/models.py
--- a/moto/route53/models.py
+++ b/moto/route53/models.py
@@ -19,16 +19,23 @@ def create_route53_zone_id():
 
 
 class HealthCheck(CloudFormationModel):
-    def __init__(self, health_check_id, health_check_args):
+    def __init__(self, health_check_id, caller_reference, health_check_args):
         self.id = health_check_id
         self.ip_address = health_check_args.get("ip_address")
-        self.port = health_check_args.get("port", 80)
+        self.port = health_check_args.get("port") or 80
         self.type_ = health_check_args.get("type")
         self.resource_path = health_check_args.get("resource_path")
         self.fqdn = health_check_args.get("fqdn")
         self.search_string = health_check_args.get("search_string")
-        self.request_interval = health_check_args.get("request_interval", 30)
-        self.failure_threshold = health_check_args.get("failure_threshold", 3)
+        self.request_interval = health_check_args.get("request_interval") or 30
+        self.failure_threshold = health_check_args.get("failure_threshold") or 3
+        self.health_threshold = health_check_args.get("health_threshold")
+        self.measure_latency = health_check_args.get("measure_latency") or False
+        self.inverted = health_check_args.get("inverted") or False
+        self.disabled = health_check_args.get("disabled") or False
+        self.enable_sni = health_check_args.get("enable_sni") or False
+        self.children = health_check_args.get("children") or None
+        self.caller_reference = caller_reference
 
     @property
     def physical_resource_id(self):
@@ -58,25 +65,49 @@ def create_from_cloudformation_json(
             "request_interval": properties.get("RequestInterval"),
             "failure_threshold": properties.get("FailureThreshold"),
         }
-        health_check = route53_backend.create_health_check(health_check_args)
+        health_check = route53_backend.create_health_check(
+            caller_reference=resource_name, health_check_args=health_check_args
+        )
         return health_check
 
     def to_xml(self):
         template = Template(
             """<HealthCheck>
             <Id>{{ health_check.id }}</Id>
-            <CallerReference>example.com 192.0.2.17</CallerReference>
+            <CallerReference>{{ health_check.caller_reference }}</CallerReference>
             <HealthCheckConfig>
-                <IPAddress>{{ health_check.ip_address }}</IPAddress>
-                <Port>{{ health_check.port }}</Port>
+                {% if health_check.type_ != "CALCULATED" %}
+                    <IPAddress>{{ health_check.ip_address }}</IPAddress>
+                    <Port>{{ health_check.port }}</Port>
+                {% endif %}
                 <Type>{{ health_check.type_ }}</Type>
-                <ResourcePath>{{ health_check.resource_path }}</ResourcePath>
-                <FullyQualifiedDomainName>{{ health_check.fqdn }}</FullyQualifiedDomainName>
-                <RequestInterval>{{ health_check.request_interval }}</RequestInterval>
-                <FailureThreshold>{{ health_check.failure_threshold }}</FailureThreshold>
+                {% if health_check.resource_path %}
+                    <ResourcePath>{{ health_check.resource_path }}</ResourcePath>
+                {% endif %}
+                {% if health_check.fqdn %}
+                    <FullyQualifiedDomainName>{{ health_check.fqdn }}</FullyQualifiedDomainName>
+                {% endif %}
+                {% if health_check.type_ != "CALCULATED" %}
+                    <RequestInterval>{{ health_check.request_interval }}</RequestInterval>
+                    <FailureThreshold>{{ health_check.failure_threshold }}</FailureThreshold>
+                    <MeasureLatency>{{ health_check.measure_latency }}</MeasureLatency>
+                {% endif %}
+                {% if health_check.type_ == "CALCULATED" %}
+                    <HealthThreshold>{{ health_check.health_threshold }}</HealthThreshold>
+                {% endif %}
+                <Inverted>{{ health_check.inverted }}</Inverted>
+                <Disabled>{{ health_check.disabled }}</Disabled>
+                <EnableSNI>{{ health_check.enable_sni }}</EnableSNI>
                 {% if health_check.search_string %}
                     <SearchString>{{ health_check.search_string }}</SearchString>
                 {% endif %}
+                {% if health_check.children %}
+                    <ChildHealthChecks>
+                    {% for child in health_check.children %}
+                        <member>{{ child }}</member>
+                    {% endfor %}
+                    </ChildHealthChecks>
+                {% endif %}
             </HealthCheckConfig>
             <HealthCheckVersion>1</HealthCheckVersion>
         </HealthCheck>"""
@@ -388,9 +419,9 @@ def get_hosted_zone_by_name(self, name):
     def delete_hosted_zone(self, id_):
         return self.zones.pop(id_.replace("/hostedzone/", ""), None)
 
-    def create_health_check(self, health_check_args):
+    def create_health_check(self, caller_reference, health_check_args):
         health_check_id = str(uuid.uuid4())
-        health_check = HealthCheck(health_check_id, health_check_args)
+        health_check = HealthCheck(health_check_id, caller_reference, health_check_args)
         self.health_checks[health_check_id] = health_check
         return health_check
 
diff --git a/moto/route53/responses.py b/moto/route53/responses.py
--- a/moto/route53/responses.py
+++ b/moto/route53/responses.py
@@ -177,20 +177,28 @@ def health_check_response(self, request, full_url, headers):
         method = request.method
 
         if method == "POST":
-            properties = xmltodict.parse(self.body)["CreateHealthCheckRequest"][
-                "HealthCheckConfig"
-            ]
+            json_body = xmltodict.parse(self.body)["CreateHealthCheckRequest"]
+            caller_reference = json_body["CallerReference"]
+            config = json_body["HealthCheckConfig"]
             health_check_args = {
-                "ip_address": properties.get("IPAddress"),
-                "port": properties.get("Port"),
-                "type": properties["Type"],
-                "resource_path": properties.get("ResourcePath"),
-                "fqdn": properties.get("FullyQualifiedDomainName"),
-                "search_string": properties.get("SearchString"),
-                "request_interval": properties.get("RequestInterval"),
-                "failure_threshold": properties.get("FailureThreshold"),
+                "ip_address": config.get("IPAddress"),
+                "port": config.get("Port"),
+                "type": config["Type"],
+                "resource_path": config.get("ResourcePath"),
+                "fqdn": config.get("FullyQualifiedDomainName"),
+                "search_string": config.get("SearchString"),
+                "request_interval": config.get("RequestInterval"),
+                "failure_threshold": config.get("FailureThreshold"),
+                "health_threshold": config.get("HealthThreshold"),
+                "measure_latency": config.get("MeasureLatency"),
+                "inverted": config.get("Inverted"),
+                "disabled": config.get("Disabled"),
+                "enable_sni": config.get("EnableSNI"),
+                "children": config.get("ChildHealthChecks", {}).get("ChildHealthCheck"),
             }
-            health_check = route53_backend.create_health_check(health_check_args)
+            health_check = route53_backend.create_health_check(
+                caller_reference, health_check_args
+            )
             template = Template(CREATE_HEALTH_CHECK_RESPONSE)
             return 201, headers, template.render(health_check=health_check)
         elif method == "DELETE":


Test patch:
diff --git a/tests/test_route53/test_route53_boto3.py b/tests/test_route53/test_route53_boto3.py
new file mode 100644
--- /dev/null
+++ b/tests/test_route53/test_route53_boto3.py
@@ -0,0 +1,169 @@
+import boto3
+import sure  # noqa
+from moto import mock_route53
+
+
+@mock_route53
+def test_create_health_check():
+    client = boto3.client("route53", region_name="us-east-1")
+
+    response = client.create_health_check(
+        CallerReference="test-route53-health-HealthCheck-asdf",
+        HealthCheckConfig={
+            "IPAddress": "93.184.216.34",
+            "Port": 80,
+            "Type": "HTTP",
+            "ResourcePath": "/",
+            "FullyQualifiedDomainName": "example.com",
+            "RequestInterval": 10,
+            "FailureThreshold": 2,
+        },
+    )
+    response["ResponseMetadata"]["HTTPStatusCode"].should.equal(201)
+    #
+    check = response["HealthCheck"]
+    check.should.have.key("Id")
+    check.should.have.key("CallerReference").being.equal(
+        "test-route53-health-HealthCheck-asdf"
+    )
+    check.should.have.key("HealthCheckConfig")
+    #
+    config = check["HealthCheckConfig"]
+    config.should.have.key("IPAddress").being.equal("93.184.216.34")
+    config.should.have.key("Port").being.equal(80)
+    config.should.have.key("Type").being.equal("HTTP")
+    config.should.have.key("ResourcePath").being.equal("/")
+    config.should.have.key("FullyQualifiedDomainName").being.equal("example.com")
+    config.should.have.key("RequestInterval").being.equal(10)
+    config.should.have.key("FailureThreshold").being.equal(2)
+    config.should.have.key("MeasureLatency").being.equal(False)
+    config.should.have.key("Inverted").being.equal(False)
+    config.should.have.key("Disabled").being.equal(False)
+    config.should.have.key("EnableSNI").being.equal(False)
+
+    config.shouldnt.have.key("ChildHealthChecks")
+    config.shouldnt.have.key("HealthThreshold")
+
+
+@mock_route53
+def test_create_health_check_with_additional_options():
+    client = boto3.client("route53", region_name="us-east-1")
+
+    response = client.create_health_check(
+        CallerReference="test-route53-health-HealthCheck-asdf",
+        HealthCheckConfig={
+            "IPAddress": "93.184.216.34",
+            "Port": 80,
+            "Type": "HTTP",
+            "ResourcePath": "/",
+            "FullyQualifiedDomainName": "example.com",
+            "RequestInterval": 10,
+            "FailureThreshold": 2,
+            "MeasureLatency": True,
+            "Inverted": True,
+            "Disabled": True,
+            "EnableSNI": True,
+        },
+    )
+    response["ResponseMetadata"]["HTTPStatusCode"].should.equal(201)
+    #
+    check = response["HealthCheck"]
+    check.should.have.key("CallerReference").being.equal(
+        "test-route53-health-HealthCheck-asdf"
+    )
+    check.should.have.key("HealthCheckConfig")
+    #
+    config = check["HealthCheckConfig"]
+    config.should.have.key("MeasureLatency").being.equal(True)
+    config.should.have.key("Inverted").being.equal(True)
+    config.should.have.key("Disabled").being.equal(True)
+    config.should.have.key("EnableSNI").being.equal(True)
+
+
+@mock_route53
+def test_create_calculated_health_check():
+    client = boto3.client("route53", region_name="us-east-1")
+
+    response = client.create_health_check(
+        CallerReference="test-route53-health-HealthCheck-ZHV123",
+        HealthCheckConfig={
+            "Type": "CALCULATED",
+            "Inverted": False,
+            "Disabled": False,
+            "HealthThreshold": 1,
+        },
+    )
+
+    check = response["HealthCheck"]
+    check.should.have.key("Id")
+    check.should.have.key("CallerReference").being.equal(
+        "test-route53-health-HealthCheck-ZHV123"
+    )
+    #
+    config = check["HealthCheckConfig"]
+    config.should.have.key("Type").being.equal("CALCULATED")
+    config.should.have.key("Inverted").being.equal(False)
+    config.should.have.key("Disabled").being.equal(False)
+    config.should.have.key("HealthThreshold").being.equal(1)
+    #
+    config.shouldnt.have.key("IPAddress")
+    config.shouldnt.have.key("Port")
+    config.shouldnt.have.key("ResourcePath")
+    config.shouldnt.have.key("FullyQualifiedDomainName")
+    config.shouldnt.have.key("RequestInterval")
+    config.shouldnt.have.key("FailureThreshold")
+    config.shouldnt.have.key("MeasureLatency")
+
+
+@mock_route53
+def test_create_calculated_health_check_with_children():
+    client = boto3.client("route53", region_name="us-east-1")
+
+    child1 = client.create_health_check(
+        CallerReference="test-route53-health-HealthCheck-child1",
+        HealthCheckConfig={
+            "Type": "CALCULATED",
+            "Inverted": False,
+            "Disabled": False,
+            "HealthThreshold": 1,
+        },
+    )
+
+    child2 = client.create_health_check(
+        CallerReference="test-route53-health-HealthCheck-child2",
+        HealthCheckConfig={
+            "Type": "CALCULATED",
+            "Inverted": False,
+            "Disabled": False,
+            "HealthThreshold": 1,
+        },
+    )
+
+    parent = client.create_health_check(
+        CallerReference="test-route53-health-HealthCheck-parent",
+        HealthCheckConfig={
+            "Type": "CALCULATED",
+            "Inverted": False,
+            "Disabled": False,
+            "HealthThreshold": 1,
+            "ChildHealthChecks": [
+                child1["HealthCheck"]["Id"],
+                child2["HealthCheck"]["Id"],
+            ],
+        },
+    )
+
+    check = parent["HealthCheck"]
+    check.should.have.key("Id")
+    check.should.have.key("CallerReference").being.equal(
+        "test-route53-health-HealthCheck-parent"
+    )
+    #
+    config = check["HealthCheckConfig"]
+    config.should.have.key("Type").being.equal("CALCULATED")
+    config.should.have.key("Inverted").being.equal(False)
+    config.should.have.key("Disabled").being.equal(False)
+    config.should.have.key("HealthThreshold").being.equal(1)
+    config.should.have.key("ChildHealthChecks").being.equal(
+        [child1["HealthCheck"]["Id"], child2["HealthCheck"]["Id"]]
+    )