diff --git a/ably/realtime/channel.py b/ably/realtime/channel.py index 768eeb7d..33e338d6 100644 --- a/ably/realtime/channel.py +++ b/ably/realtime/channel.py @@ -526,6 +526,8 @@ async def _send_update( serial=message.serial, action=action, version=version, + extras=message.extras, + annotations=message.annotations, ) # Encrypt if needed diff --git a/ably/rest/channel.py b/ably/rest/channel.py index f6b118b7..32cc7e7e 100644 --- a/ably/rest/channel.py +++ b/ably/rest/channel.py @@ -194,6 +194,8 @@ async def _send_update( serial=message.serial, action=action, version=version, + extras=message.extras, + annotations=message.annotations, ) # Encrypt if needed diff --git a/test/ably/realtime/realtimechannelmutablemessages_test.py b/test/ably/realtime/realtimechannelmutablemessages_test.py index 047ea3b6..a5866936 100644 --- a/test/ably/realtime/realtimechannelmutablemessages_test.py +++ b/test/ably/realtime/realtimechannelmutablemessages_test.py @@ -263,6 +263,43 @@ def on_message(message): assert appended_message.version.description == 'Appended to message' assert appended_message.serial == serial + # RTL32b, TM2i + async def test_update_message_preserves_extras(self): + """Test that extras are preserved when updating a message""" + channel = self.ably.channels[self.get_channel_name('mutable:update_extras')] + + # Publish a message + result = await channel.publish('test-event', 'original data') + assert len(result.serials) > 0 + serial = result.serials[0] + + messages_received = [] + update_received = WaitableEvent() + + def on_message(message): + if message.action == MessageAction.MESSAGE_UPDATE: + messages_received.append(message) + update_received.finish() + + await channel.subscribe(on_message) + + # Update with extras + message = Message( + data='updated data', + serial=serial, + extras={'headers': {'status': 'complete'}}, + ) + + update_result = await channel.update_message(message) + assert update_result is not None + + await update_received.wait() + + assert len(messages_received) > 0 + received = messages_received[0] + assert received.extras is not None + assert received.extras['headers']['status'] == 'complete' + async def wait_until_message_with_action_appears(self, channel, serial, action): message: Message | None = None async def check_message_action(): diff --git a/test/ably/rest/restchannelmutablemessages_test.py b/test/ably/rest/restchannelmutablemessages_test.py index 7b144ab0..b4f32ef4 100644 --- a/test/ably/rest/restchannelmutablemessages_test.py +++ b/test/ably/rest/restchannelmutablemessages_test.py @@ -270,6 +270,33 @@ async def test_append_message_with_string_data(self): assert appended_message.version.description == 'Appended to message' assert appended_message.serial == serial + # RSL15b, TM2i + async def test_update_message_preserves_extras(self): + """Test that extras are preserved when updating a message""" + channel = self.ably.channels[self.get_channel_name('mutable:update_extras')] + + # Publish a message + result = await channel.publish('test-event', 'original data') + assert len(result.serials) > 0 + serial = result.serials[0] + + # Update with extras + message = Message( + data='updated data', + serial=serial, + extras={'headers': {'status': 'complete'}}, + ) + + update_result = await channel.update_message(message) + assert update_result is not None + + updated_message = await self.wait_until_message_with_action_appears( + channel, serial, MessageAction.MESSAGE_UPDATE + ) + assert updated_message.data == 'updated data' + assert updated_message.extras is not None + assert updated_message.extras['headers']['status'] == 'complete' + async def wait_until_message_with_action_appears(self, channel, serial, action): message: Message | None = None async def check_message_action(): diff --git a/test/unit/mutable_message_test.py b/test/unit/mutable_message_test.py index 6f5afc92..8ce603b9 100644 --- a/test/unit/mutable_message_test.py +++ b/test/unit/mutable_message_test.py @@ -96,6 +96,62 @@ def test_message_version_serialization(): assert reconstructed.description == version.description assert reconstructed.metadata == version.metadata +# RSL15b, RTL32b, TM2i +def test_message_extras_preserved_in_as_dict(): + """Test that extras are included when a Message with extras is serialized. + + Regression test: _send_update() in both RestChannel and RealtimeChannel + constructed a new Message without copying extras or annotations from the + user-supplied message, violating RSL15b/RTL32b which require "whatever + fields were in the user-supplied Message" to be sent. + See commits 1723f5d (REST) and 0b93c10 (Realtime). + """ + extras = {'headers': {'status': 'complete'}} + message = Message( + name='test', + data='updated data', + serial='abc123', + action=MessageAction.MESSAGE_UPDATE, + extras=extras, + ) + + msg_dict = message.as_dict() + assert msg_dict['extras'] == extras + assert msg_dict['extras']['headers']['status'] == 'complete' + + +# RSL15b, RTL32b, TM2i +def test_message_extras_none_excluded_from_as_dict(): + """Test that extras=None does not appear in as_dict output.""" + message = Message( + name='test', + data='data', + serial='abc123', + action=MessageAction.MESSAGE_UPDATE, + ) + + msg_dict = message.as_dict() + assert 'extras' not in msg_dict + + +# RSL15b, RTL32b, TM2u +def test_message_annotations_preserved_in_as_dict(): + """Test that annotations are included when a Message with annotations is serialized.""" + from ably.types.message import MessageAnnotations + annotations = MessageAnnotations(summary={'reaction:distinct.v1': {'thumbsup': 5}}) + message = Message( + name='test', + data='data', + serial='abc123', + action=MessageAction.MESSAGE_UPDATE, + annotations=annotations, + ) + + msg_dict = message.as_dict() + assert msg_dict['annotations'] is not None + assert msg_dict['annotations']['summary']['reaction:distinct.v1'] == {'thumbsup': 5} + + def test_message_operation_serialization(): """Test MessageOperation can be serialized and deserialized""" operation = MessageOperation(