|
| 1 | +import dataclasses |
| 2 | +import datetime |
| 3 | +from decimal import Decimal |
| 4 | +from enum import Enum, IntEnum |
| 5 | +from pathlib import PurePosixPath |
| 6 | +from typing import Any, NamedTuple |
| 7 | + |
| 8 | +import pytest |
| 9 | +from botocore import stub |
| 10 | +from jmespath import functions |
| 11 | + |
| 12 | +from aws_lambda_powertools.utilities.idempotency import IdempotencyConfig, idempotent_function |
| 13 | +from aws_lambda_powertools.utilities.idempotency.exceptions import ( |
| 14 | + IdempotencyPersistenceLayerError, |
| 15 | + IdempotencyValidationError, |
| 16 | +) |
| 17 | +from tests.functional.idempotency.utils import ( |
| 18 | + build_idempotency_put_item_stub, |
| 19 | + build_idempotency_update_item_stub, |
| 20 | + hash_idempotency_key, |
| 21 | +) |
| 22 | +from tests.functional.utils import json_serialize |
| 23 | + |
| 24 | + |
| 25 | +@dataclasses.dataclass |
| 26 | +class Payload: |
| 27 | + order_id: str |
| 28 | + value: Any |
| 29 | + |
| 30 | + |
| 31 | +class DecimalAmount(Decimal, Enum): |
| 32 | + SMALL = Decimal("1.00") |
| 33 | + |
| 34 | + |
| 35 | +class StringStatus(str, Enum): |
| 36 | + PAID = "paid" |
| 37 | + |
| 38 | + |
| 39 | +class IntegerStatus(IntEnum): |
| 40 | + PAID = 1 |
| 41 | + |
| 42 | + |
| 43 | +class FloatStatus(float, Enum): |
| 44 | + PAID = 1.5 |
| 45 | + |
| 46 | + |
| 47 | +class Point(NamedTuple): |
| 48 | + x: int |
| 49 | + y: int |
| 50 | + |
| 51 | + |
| 52 | +def _expected_put(key_data, validation_data): |
| 53 | + params = build_idempotency_put_item_stub(key_data) |
| 54 | + params["Item"]["id"] = {"S": f"orders#{hash_idempotency_key(key_data)}"} |
| 55 | + params["Item"]["validation"] = {"S": hash_idempotency_key(validation_data)} |
| 56 | + return params |
| 57 | + |
| 58 | + |
| 59 | +def _completed_record(key_data, validation_data, expiration): |
| 60 | + return { |
| 61 | + "Item": { |
| 62 | + "id": {"S": f"orders#{hash_idempotency_key(key_data)}"}, |
| 63 | + "expiration": {"N": expiration}, |
| 64 | + "status": {"S": "COMPLETED"}, |
| 65 | + "data": {"S": json_serialize({"status": "already processed"})}, |
| 66 | + "validation": {"S": hash_idempotency_key(validation_data)}, |
| 67 | + }, |
| 68 | + } |
| 69 | + |
| 70 | + |
| 71 | +def _assert_existing_record_is_reused(store, config, payload, key_data, validation_data, expiration): |
| 72 | + @idempotent_function( |
| 73 | + data_keyword_argument="payload", |
| 74 | + persistence_store=store, |
| 75 | + config=config, |
| 76 | + key_prefix="orders", |
| 77 | + ) |
| 78 | + def process(payload): |
| 79 | + pytest.fail("An existing record must be reused without executing the function") |
| 80 | + |
| 81 | + # Both hashes are produced by the existing shared Encoder, independently of the new hash encoder. |
| 82 | + with stub.Stubber(store.client) as stubber: |
| 83 | + stubber.add_client_error( |
| 84 | + "put_item", |
| 85 | + "ConditionalCheckFailedException", |
| 86 | + modeled_fields=_completed_record(key_data, validation_data, expiration), |
| 87 | + expected_params=_expected_put(key_data, validation_data), |
| 88 | + ) |
| 89 | + assert process(payload=payload) == {"status": "already processed"} |
| 90 | + stubber.assert_no_pending_responses() |
| 91 | + |
| 92 | + |
| 93 | +@pytest.mark.parametrize("as_dataclass", [True, False], ids=["dataclass", "dict"]) |
| 94 | +@pytest.mark.parametrize( |
| 95 | + "value", |
| 96 | + [ |
| 97 | + pytest.param({"text": "café", "integer": 1, "float": 1.5, "bool": True, "null": None}, id="primitives"), |
| 98 | + pytest.param(Decimal("1.00"), id="decimal"), |
| 99 | + pytest.param(Decimal("NaN"), id="decimal-nan"), |
| 100 | + pytest.param(DecimalAmount.SMALL, id="decimal-enum"), |
| 101 | + pytest.param(StringStatus.PAID, id="str-enum"), |
| 102 | + pytest.param(IntegerStatus.PAID, id="int-enum"), |
| 103 | + pytest.param(FloatStatus.PAID, id="float-enum"), |
| 104 | + pytest.param((1, "two", Decimal("3.00")), id="tuple"), |
| 105 | + pytest.param(Point(1, 2), id="namedtuple"), |
| 106 | + pytest.param(Payload("nested", Decimal("1.00")), id="nested-dataclass"), |
| 107 | + ], |
| 108 | +) |
| 109 | +def test_existing_key_and_validation_hashes_are_preserved( |
| 110 | + persistence_store, |
| 111 | + lambda_context, |
| 112 | + timestamp_future, |
| 113 | + as_dataclass, |
| 114 | + value, |
| 115 | +): |
| 116 | + payload = Payload("order-1", value) |
| 117 | + legacy_data = dataclasses.asdict(payload) |
| 118 | + config = IdempotencyConfig(payload_validation_jmespath="value", lambda_context=lambda_context) |
| 119 | + |
| 120 | + _assert_existing_record_is_reused( |
| 121 | + persistence_store, |
| 122 | + config, |
| 123 | + payload if as_dataclass else legacy_data, |
| 124 | + legacy_data, |
| 125 | + legacy_data["value"], |
| 126 | + timestamp_future, |
| 127 | + ) |
| 128 | + |
| 129 | + |
| 130 | +@pytest.mark.parametrize( |
| 131 | + "value,expected_type", |
| 132 | + [ |
| 133 | + pytest.param(datetime.datetime(2024, 3, 20, 14, 30), None, id="datetime"), |
| 134 | + pytest.param(DecimalAmount.SMALL, None, id="decimal-enum"), |
| 135 | + pytest.param(Point(1, 2), None, id="namedtuple"), |
| 136 | + pytest.param([1, 2], "array", id="list"), |
| 137 | + ], |
| 138 | +) |
| 139 | +@pytest.mark.parametrize("custom_function", [False, True], ids=["builtin", "custom"]) |
| 140 | +def test_jmespath_sees_original_types_before_hashing( |
| 141 | + persistence_store, |
| 142 | + lambda_context, |
| 143 | + timestamp_future, |
| 144 | + value, |
| 145 | + expected_type, |
| 146 | + custom_function, |
| 147 | +): |
| 148 | + class CustomFunctions(functions.Functions): |
| 149 | + @functions.signature({"types": []}) |
| 150 | + def _func_original_type(self, value): |
| 151 | + return type(value).__name__ |
| 152 | + |
| 153 | + if custom_function: |
| 154 | + expression = "original_type(value)" |
| 155 | + expected_type = type(value).__name__ |
| 156 | + else: |
| 157 | + expression = "type(value)" |
| 158 | + |
| 159 | + key_data = {"order": "order-1", "kind": expected_type} |
| 160 | + validation_data = {"kind": expected_type} |
| 161 | + config = IdempotencyConfig( |
| 162 | + event_key_jmespath=f"{{order: order_id, kind: {expression}}}", |
| 163 | + payload_validation_jmespath=f"{{kind: {expression}}}", |
| 164 | + jmespath_options={"custom_functions": CustomFunctions()}, |
| 165 | + lambda_context=lambda_context, |
| 166 | + ) |
| 167 | + _assert_existing_record_is_reused( |
| 168 | + persistence_store, |
| 169 | + config, |
| 170 | + Payload("order-1", value), |
| 171 | + key_data, |
| 172 | + validation_data, |
| 173 | + timestamp_future, |
| 174 | + ) |
| 175 | + |
| 176 | + |
| 177 | +@pytest.mark.parametrize("as_dataclass", [True, False], ids=["dataclass", "dict"]) |
| 178 | +def test_new_types_support_persistent_replay_and_payload_validation( |
| 179 | + persistence_store, |
| 180 | + lambda_context, |
| 181 | + timestamp_future, |
| 182 | + as_dataclass, |
| 183 | +): |
| 184 | + value = datetime.datetime(2024, 3, 20, 14, 30, tzinfo=datetime.timezone.utc) |
| 185 | + payload = Payload("order-1", {"created_at": value, "receipt": PurePosixPath("/orders/receipt.pdf")}) |
| 186 | + prepared_value = {"created_at": value.isoformat(), "receipt": "/orders/receipt.pdf"} |
| 187 | + config = IdempotencyConfig( |
| 188 | + event_key_jmespath="order_id", |
| 189 | + payload_validation_jmespath="value", |
| 190 | + lambda_context=lambda_context, |
| 191 | + use_local_cache=False, |
| 192 | + ) |
| 193 | + executions = [] |
| 194 | + |
| 195 | + @idempotent_function( |
| 196 | + data_keyword_argument="payload", |
| 197 | + persistence_store=persistence_store, |
| 198 | + config=config, |
| 199 | + key_prefix="orders", |
| 200 | + ) |
| 201 | + def process(payload): |
| 202 | + executions.append(payload) |
| 203 | + return {"status": "already processed"} |
| 204 | + |
| 205 | + expected_update = build_idempotency_update_item_stub("order-1", {"status": "already processed"}) |
| 206 | + expected_update["Key"]["id"] = {"S": f"orders#{hash_idempotency_key('order-1')}"} |
| 207 | + expected_update["ExpressionAttributeNames"]["#validation_key"] = "validation" |
| 208 | + expected_update["ExpressionAttributeValues"][":validation_key"] = {"S": hash_idempotency_key(prepared_value)} |
| 209 | + expected_update["UpdateExpression"] += ", #validation_key = :validation_key" |
| 210 | + |
| 211 | + with stub.Stubber(persistence_store.client) as stubber: |
| 212 | + stubber.add_response("put_item", {}, _expected_put("order-1", prepared_value)) |
| 213 | + stubber.add_response("update_item", {}, expected_update) |
| 214 | + stubber.add_client_error( |
| 215 | + "put_item", |
| 216 | + "ConditionalCheckFailedException", |
| 217 | + modeled_fields=_completed_record("order-1", prepared_value, timestamp_future), |
| 218 | + expected_params=_expected_put("order-1", prepared_value), |
| 219 | + ) |
| 220 | + |
| 221 | + request = payload if as_dataclass else dataclasses.asdict(payload) |
| 222 | + assert process(payload=request) == {"status": "already processed"} |
| 223 | + assert process(payload=request) == {"status": "already processed"} |
| 224 | + assert len(executions) == 1 |
| 225 | + |
| 226 | + changed = dataclasses.replace(payload, value={**payload.value, "receipt": PurePosixPath("/other.pdf")}) |
| 227 | + changed_value = {**prepared_value, "receipt": "/other.pdf"} |
| 228 | + stubber.add_client_error( |
| 229 | + "put_item", |
| 230 | + "ConditionalCheckFailedException", |
| 231 | + modeled_fields=_completed_record("order-1", prepared_value, timestamp_future), |
| 232 | + expected_params=_expected_put("order-1", changed_value), |
| 233 | + ) |
| 234 | + with pytest.raises(IdempotencyValidationError): |
| 235 | + process(payload=changed if as_dataclass else dataclasses.asdict(changed)) |
| 236 | + |
| 237 | + assert len(executions) == 1 |
| 238 | + stubber.assert_no_pending_responses() |
| 239 | + |
| 240 | + |
| 241 | +@pytest.mark.parametrize("value", [b"bytes", {1, 2}, complex(1, 2)], ids=["bytes", "set", "complex"]) |
| 242 | +def test_unsupported_key_types_still_raise(persistence_store, lambda_context, value): |
| 243 | + @idempotent_function( |
| 244 | + data_keyword_argument="payload", |
| 245 | + persistence_store=persistence_store, |
| 246 | + config=IdempotencyConfig(lambda_context=lambda_context), |
| 247 | + ) |
| 248 | + def process(payload): |
| 249 | + pytest.fail("Unsupported key types must fail before executing the function") |
| 250 | + |
| 251 | + with stub.Stubber(persistence_store.client) as stubber: |
| 252 | + with pytest.raises(IdempotencyPersistenceLayerError) as error: |
| 253 | + process(payload=Payload("order-1", value)) |
| 254 | + assert isinstance(error.value.__cause__, TypeError) |
| 255 | + assert "is not JSON serializable" in str(error.value.__cause__) |
| 256 | + stubber.assert_no_pending_responses() |
0 commit comments