From ecd1dd456a86f904ea880fa9f3c7c30f364acfd0 Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Sat, 7 Mar 2026 00:51:45 +0100 Subject: [PATCH 1/2] fix: preserve extras and annotations in _send_update() The _send_update() method in both RestChannel and RealtimeChannel reconstructed the Message object without copying extras or annotations from the user-supplied message. This violated RSL15b/RTL32b which require "whatever fields were in the user-supplied Message" to be sent on the wire. Bug was introduced in 1723f5d (REST) and 0b93c10 (Realtime). --- ably/realtime/channel.py | 2 + ably/rest/channel.py | 2 + .../realtimechannelmutablemessages_test.py | 37 ++++++++++++ .../rest/restchannelmutablemessages_test.py | 27 +++++++++ test/unit/mutable_message_test.py | 56 +++++++++++++++++++ 5 files changed, 124 insertions(+) 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..64430ed7 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 msg_dict.get('extras') is None + + +# 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( From fac1fe56e337d610a6b18734b05e075d2cdc6a8b Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Tue, 10 Mar 2026 10:33:25 +0100 Subject: [PATCH 2/2] fix: use stricter assertion for extras key absence Co-Authored-By: Claude Opus 4.6 --- test/unit/mutable_message_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/mutable_message_test.py b/test/unit/mutable_message_test.py index 64430ed7..8ce603b9 100644 --- a/test/unit/mutable_message_test.py +++ b/test/unit/mutable_message_test.py @@ -131,7 +131,7 @@ def test_message_extras_none_excluded_from_as_dict(): ) msg_dict = message.as_dict() - assert msg_dict.get('extras') is None + assert 'extras' not in msg_dict # RSL15b, RTL32b, TM2u