diff --git a/localstack-core/localstack/aws/api/events/__init__.py b/localstack-core/localstack/aws/api/events/__init__.py index b1f621adb398f..5fd1845841a0b 100644 --- a/localstack-core/localstack/aws/api/events/__init__.py +++ b/localstack-core/localstack/aws/api/events/__init__.py @@ -919,7 +919,7 @@ class EventSource(TypedDict, total=False): EventSourceList = List[EventSource] -EventTime = datetime +EventTime = datetime | str HeaderParametersMap = Dict[HeaderKey, HeaderValue] QueryStringParametersMap = Dict[QueryStringKey, QueryStringValue] PathParameterList = List[PathParameter] diff --git a/localstack-core/localstack/services/events/utils.py b/localstack-core/localstack/services/events/utils.py index fa263c35f62b1..3dff68e157ebf 100644 --- a/localstack-core/localstack/services/events/utils.py +++ b/localstack-core/localstack/services/events/utils.py @@ -181,6 +181,9 @@ def format_event( message_id = message.get("original_id", str(long_uid())) region = message.get("original_region", region) account_id = message.get("original_account", account_id) + # Format the datetime to ISO-8601 string + event_time = get_event_time(event) + formatted_time = event_time_to_time_string(event_time) formatted_event = { "version": "0", @@ -188,7 +191,7 @@ def format_event( "detail-type": event.get("DetailType"), "source": event.get("Source"), "account": account_id, - "time": get_event_time(event), + "time": formatted_time, "region": region, "resources": event.get("Resources", []), "detail": json.loads(event.get("Detail", "{}")), diff --git a/tests/aws/services/events/test_events.py b/tests/aws/services/events/test_events.py index fbe59dc980490..35f0aa7696e89 100644 --- a/tests/aws/services/events/test_events.py +++ b/tests/aws/services/events/test_events.py @@ -3,8 +3,10 @@ """ import base64 +import datetime import json import os +import re import time import uuid @@ -578,6 +580,68 @@ def test_put_events_with_target_delivery_failure( assert len(messages) == 0, "No messages should be delivered when queue doesn't exist" + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="Test specific for v2 provider") + def test_put_events_with_time_field( + self, events_put_rule, create_sqs_events_target, aws_client, snapshot + ): + """Test that EventBridge correctly handles datetime serialization in events.""" + rule_name = f"test-rule-{short_uid()}" + queue_url, queue_arn = create_sqs_events_target() + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("MD5OfBody", reference_replacement=False), + *snapshot.transform.sqs_api(), + ] + ) + + events_put_rule( + Name=rule_name, + EventPattern=json.dumps( + {"source": ["test-source"], "detail-type": ["test-detail-type"]} + ), + ) + + aws_client.events.put_targets(Rule=rule_name, Targets=[{"Id": "id1", "Arn": queue_arn}]) + + timestamp = datetime.datetime.utcnow() + event = { + "Source": "test-source", + "DetailType": "test-detail-type", + "Time": timestamp, + "Detail": json.dumps({"message": "test message"}), + } + + response = aws_client.events.put_events(Entries=[event]) + snapshot.match("put-events", response) + + messages = sqs_collect_messages(aws_client, queue_url, expected_events_count=1) + assert len(messages) == 1 + snapshot.match("sqs-messages", messages) + + received_event = json.loads(messages[0]["Body"]) + # Explicit assertions for time field format GH issue: https://github.com/localstack/localstack/issues/11630#issuecomment-2506187279 + assert "time" in received_event, "Time field missing in the event" + time_str = received_event["time"] + + # Verify ISO8601 format: YYYY-MM-DDThh:mm:ssZ + # Example: "2024-11-28T13:44:36Z" + assert re.match( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", time_str + ), f"Time field '{time_str}' does not match ISO8601 format (YYYY-MM-DDThh:mm:ssZ)" + + # Verify we can parse it back to datetime + datetime_obj = datetime.datetime.strptime(time_str, "%Y-%m-%dT%H:%M:%SZ") + assert isinstance( + datetime_obj, datetime.datetime + ), f"Failed to parse time string '{time_str}' back to datetime object" + + time_difference = abs((datetime_obj - timestamp.replace(microsecond=0)).total_seconds()) + assert ( + time_difference <= 60 + ), f"Time in event '{time_str}' differs too much from sent time '{timestamp.isoformat()}'" + class TestEventBus: @markers.aws.validated diff --git a/tests/aws/services/events/test_events.snapshot.json b/tests/aws/services/events/test_events.snapshot.json index c5c841f9cfad8..3df20f8468b1c 100644 --- a/tests/aws/services/events/test_events.snapshot.json +++ b/tests/aws/services/events/test_events.snapshot.json @@ -2695,5 +2695,42 @@ } } } + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_with_time_field": { + "recorded-date": "28-11-2024, 21:25:00", + "recorded-content": { + "put-events": { + "Entries": [ + { + "EventId": "" + } + ], + "FailedEntryCount": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sqs-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "test-detail-type", + "source": "test-source", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "message": "test message" + } + } + } + ] + } } } diff --git a/tests/aws/services/events/test_events.validation.json b/tests/aws/services/events/test_events.validation.json index 95d3f89a3cf9b..7adc430877f4e 100644 --- a/tests/aws/services/events/test_events.validation.json +++ b/tests/aws/services/events/test_events.validation.json @@ -188,6 +188,9 @@ "tests/aws/services/events/test_events.py::TestEvents::test_put_events_with_target_delivery_failure": { "last_validated_date": "2024-11-20T17:19:19+00:00" }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_with_time_field": { + "last_validated_date": "2024-11-28T21:25:00+00:00" + }, "tests/aws/services/events/test_events.py::TestEvents::test_put_events_without_source": { "last_validated_date": "2024-06-19T10:40:50+00:00" }