diff --git a/src/firebase_functions/pubsub_fn.py b/src/firebase_functions/pubsub_fn.py index 7297599..8733822 100644 --- a/src/firebase_functions/pubsub_fn.py +++ b/src/firebase_functions/pubsub_fn.py @@ -41,9 +41,9 @@ class Message(_typing.Generic[T]): Autogenerated ID that uniquely identifies this message. """ - publish_time: str + publish_time: _dt.datetime """ - Time the message was published. + Time the message was published, as a timezone-aware datetime. """ attributes: dict[str, str] @@ -51,20 +51,22 @@ class Message(_typing.Generic[T]): User-defined attributes published with the message, if any. """ - data: str + data: str | None = None """ - The data payload of this message object as a base64-encoded string. + The data payload of this message object as a base64-encoded string, + or `None` if the message was published without a payload. """ - ordering_key: str + ordering_key: str | None = None """ - User-defined key used to ensure ordering amongst messages with the same key. + User-defined key used to ensure ordering amongst messages with the same key, + or `None` if the message was published without one. """ @property def json(self) -> T | None: try: - if self.data is not None: + if self.data: return _json.loads(_base64.b64decode(self.data).decode("utf-8")) else: return None @@ -137,6 +139,9 @@ def _message_handler( # Include empty attributes property if missing message_dict["attributes"] = message_dict.get("attributes", {}) + # The emulator sends "" for an attribute-only message. + message_dict["data"] = message_dict.get("data") or None + message: MessagePublishedData = MessagePublishedData( message=Message( **message_dict, diff --git a/tests/test_pubsub_fn.py b/tests/test_pubsub_fn.py index d0bf81b..d1c4c56 100644 --- a/tests/test_pubsub_fn.py +++ b/tests/test_pubsub_fn.py @@ -29,6 +29,33 @@ ) +def _raw_event(message: dict, time: str = "2023-03-11T13:25:37.403Z") -> _CloudEvent: + return _CloudEvent( + attributes={ + "id": "test-message", + "source": "https://example.com/pubsub", + "specversion": "1.0", + "time": time, + "type": "com.example.pubsub.message", + }, + data={ + "message": { + "message_id": "message-id-123", + "publish_time": time, + **message, + }, + "subscription": "my-subscription", + }, + ) + + +def _handled_message(message: dict) -> Message: + func = MagicMock() + _message_handler(func, _raw_event(message)) + func.assert_called_once() + return func.call_args.args[0].data.message + + class TestPubSub(unittest.TestCase): """ PubSub function tests. @@ -55,24 +82,12 @@ def test_message_handler(self): formatted CloudEvent instance. """ func = MagicMock() - raw_event = _CloudEvent( - attributes={ - "id": "test-message", - "source": "https://example.com/pubsub", - "specversion": "1.0", - "time": "2023-03-11T13:25:37.403Z", - "type": "com.example.pubsub.message", - }, - data={ - "message": { - "attributes": {"key": "value"}, - # {"test": "value"} - "data": "eyJ0ZXN0IjogInZhbHVlIn0=", - "message_id": "message-id-123", - "publish_time": "2023-03-11T13:25:37.403Z", - }, - "subscription": "my-subscription", - }, + raw_event = _raw_event( + { + "attributes": {"key": "value"}, + # {"test": "value"} + "data": "eyJ0ZXN0IjogInZhbHVlIn0=", + } ) _message_handler(func, raw_event) @@ -103,24 +118,7 @@ def init(): hello = "world" func = MagicMock() - raw_event = _CloudEvent( - attributes={ - "id": "test-message", - "source": "https://example.com/pubsub", - "specversion": "1.0", - "time": "2023-03-11T13:25:37.403Z", - "type": "com.example.pubsub.message", - }, - data={ - "message": { - "attributes": {"key": "value"}, - "data": "eyJ0ZXN0IjogInZhbHVlIn0=", - "message_id": "message-id-123", - "publish_time": "2023-03-11T13:25:37.403Z", - }, - "subscription": "my-subscription", - }, - ) + raw_event = _raw_event({"data": "eyJ0ZXN0IjogInZhbHVlIn0="}) _message_handler(func, raw_event) @@ -128,26 +126,59 @@ def init(): def test_datetime_without_mircroseconds_doesnt_throw(self): time = "2023-03-11T13:25:37Z" - raw_event = _CloudEvent( - attributes={ - "id": "test-message", - "source": "https://example.com/pubsub", - "specversion": "1.0", - "time": time, - "type": "com.example.pubsub.message", - }, - data={ - "message": { - "attributes": {"key": "value"}, - "data": "eyJ0ZXN0IjogInZhbHVlIn0=", - "message_id": "message-id-123", - "publish_time": time, - }, - "subscription": "my-subscription", - }, - ) + raw_event = _raw_event({"data": "eyJ0ZXN0IjogInZhbHVlIn0="}, time=time) try: _message_handler(lambda _: None, raw_event) # pylint: disable=broad-except except Exception: self.fail("Datetime without microseconds should not throw an exception") + + +class TestPubSubMessageData(unittest.TestCase): + def test_message_without_data_reaches_handler_with_none(self): + message = _handled_message({"attributes": {"key": "value"}}) + self.assertIsNone(message.data) + self.assertIsNone(message.json) + self.assertEqual(message.attributes, {"key": "value"}) + self.assertEqual(message.message_id, "message-id-123") + + def test_message_with_data_decodes_json(self): + # base64 of '{"test": "value"}' + message = _handled_message({"data": "eyJ0ZXN0IjogInZhbHVlIn0="}) + self.assertEqual(message.data, "eyJ0ZXN0IjogInZhbHVlIn0=") + self.assertEqual(message.json, {"test": "value"}) + self.assertEqual(message.attributes, {}) + + def test_message_with_empty_string_data_reaches_handler_with_none(self): + message = _handled_message({"data": ""}) + self.assertIsNone(message.data) + self.assertIsNone(message.json) + + def test_message_with_invalid_data_json_raises(self): + # base64 of "not json" + message = _handled_message({"data": "bm90IGpzb24="}) + with self.assertRaisesRegex(ValueError, "^Unable to parse Pub/Sub message data as JSON: "): + _ = message.json + + def test_message_with_ordering_key_keeps_it(self): + message = _handled_message({"data": "eyJ0ZXN0IjogInZhbHVlIn0=", "orderingKey": "abc"}) + self.assertEqual(message.ordering_key, "abc") + + def test_message_built_directly_defaults_data_and_ordering_key_to_none(self): + message = Message( + message_id="message-id-123", + publish_time=_dt.datetime(2023, 3, 11, tzinfo=_dt.timezone.utc), + attributes={}, + ) + self.assertIsNone(message.data) + self.assertIsNone(message.ordering_key) + self.assertIsNone(message.json) + + def test_publish_time_is_timezone_aware_datetime(self): + message = _handled_message({"data": "eyJ0ZXN0IjogInZhbHVlIn0="}) + self.assertIsInstance(message.publish_time, _dt.datetime) + self.assertEqual( + message.publish_time, + _dt.datetime(2023, 3, 11, 13, 25, 37, 403000, tzinfo=_dt.timezone.utc), + ) + self.assertEqual(message.publish_time.utcoffset(), _dt.timedelta(0))