diff --git a/ably/__init__.py b/ably/__init__.py index b77548b7..ce1a6d0f 100644 --- a/ably/__init__.py +++ b/ably/__init__.py @@ -7,6 +7,8 @@ from ably.types.capability import Capability from ably.types.channelsubscription import PushChannelSubscription from ably.types.device import DeviceDetails +from ably.types.message import MessageAction, MessageVersion +from ably.types.operations import MessageOperation, PublishResult, UpdateDeleteResult from ably.types.options import Options, VCDiffDecoder from ably.util.crypto import CipherParams from ably.util.exceptions import AblyAuthException, AblyException, IncompatibleClientIdException @@ -15,5 +17,5 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -api_version = '3' +api_version = '5' lib_version = '2.1.3' diff --git a/ably/rest/channel.py b/ably/rest/channel.py index f925e4dd..ff70978c 100644 --- a/ably/rest/channel.py +++ b/ably/rest/channel.py @@ -10,10 +10,21 @@ from ably.http.paginatedresult import PaginatedResult, format_params from ably.types.channeldetails import ChannelDetails -from ably.types.message import Message, make_message_response_handler +from ably.types.message import ( + Message, + MessageAction, + MessageVersion, + make_message_response_handler, + make_single_message_response_handler, +) +from ably.types.operations import MessageOperation, PublishResult, UpdateDeleteResult from ably.types.presence import Presence from ably.util.crypto import get_cipher -from ably.util.exceptions import IncompatibleClientIdException, catch_all +from ably.util.exceptions import ( + AblyException, + IncompatibleClientIdException, + catch_all, +) log = logging.getLogger(__name__) @@ -99,7 +110,13 @@ async def publish_messages(self, messages, params=None, timeout=None): if params: params = {k: str(v).lower() if type(v) is bool else v for k, v in params.items()} path += '?' + parse.urlencode(params) - return await self.ably.http.post(path, body=request_body, timeout=timeout) + response = await self.ably.http.post(path, body=request_body, timeout=timeout) + + # Parse response to extract serials + result_data = response.to_native() + if result_data and isinstance(result_data, dict): + return PublishResult.from_dict(result_data) + return PublishResult() async def publish_name_data(self, name, data, timeout=None): messages = [Message(name, data)] @@ -141,6 +158,190 @@ async def status(self): obj = response.to_native() return ChannelDetails.from_dict(obj) + async def _send_update( + self, + message: Message, + action: MessageAction, + operation: MessageOperation = None, + params: dict = None, + ): + """Internal method to send update/delete/append operations.""" + if not message.serial: + raise AblyException( + "Message serial is required for update/delete/append operations", + 400, + 40000 + ) + + if not operation: + version = None + else: + version = MessageVersion( + client_id=operation.client_id, + description=operation.description, + metadata=operation.metadata + ) + + # Create a new message with the operation fields + update_message = Message( + name=message.name, + data=message.data, + client_id=message.client_id, + serial=message.serial, + action=action, + version=version, + ) + + # Encrypt if needed + if self.cipher: + update_message.encrypt(self.__cipher) + + # Serialize the message + request_body = update_message.as_dict(binary=self.ably.options.use_binary_protocol) + + if not self.ably.options.use_binary_protocol: + request_body = json.dumps(request_body, separators=(',', ':')) + else: + request_body = msgpack.packb(request_body, use_bin_type=True) + + # Build path with params + path = self.__base_path + 'messages/{}'.format(parse.quote_plus(message.serial, safe=':')) + if params: + params = {k: str(v).lower() if type(v) is bool else v for k, v in params.items()} + path += '?' + parse.urlencode(params) + + # Send request + response = await self.ably.http.patch(path, body=request_body) + + # Parse response + try: + result_data = response.to_native() + if result_data and isinstance(result_data, dict): + return UpdateDeleteResult.from_dict(result_data) + return UpdateDeleteResult() + except Exception: + return UpdateDeleteResult() + + async def update_message(self, message: Message, operation: MessageOperation = None, params: dict = None): + """Updates an existing message on this channel. + + Parameters: + - message: Message object to update. Must have a serial field. + - operation: Optional MessageOperation containing description and metadata for the update. + - params: Optional dict of query parameters. + + Returns: + - UpdateDeleteResult containing the version serial of the updated message. + """ + return await self._send_update(message, MessageAction.MESSAGE_UPDATE, operation, params) + + async def delete_message(self, message: Message, operation: MessageOperation = None, params: dict = None): + """Deletes a message on this channel. + + Parameters: + - message: Message object to delete. Must have a serial field. + - operation: Optional MessageOperation containing description and metadata for the delete. + - params: Optional dict of query parameters. + + Returns: + - UpdateDeleteResult containing the version serial of the deleted message. + """ + return await self._send_update(message, MessageAction.MESSAGE_DELETE, operation, params) + + async def append_message(self, message: Message, operation: MessageOperation = None, params: dict = None): + """Appends data to an existing message on this channel. + + Parameters: + - message: Message object with data to append. Must have a serial field. + - operation: Optional MessageOperation containing description and metadata for the append. + - params: Optional dict of query parameters. + + Returns: + - UpdateDeleteResult containing the version serial of the appended message. + """ + return await self._send_update(message, MessageAction.MESSAGE_APPEND, operation, params) + + async def get_message(self, serial_or_message, timeout=None): + """Retrieves a single message by its serial. + + Parameters: + - serial_or_message: Either a string serial or a Message object with a serial field. + + Returns: + - Message object for the requested serial. + + Raises: + - AblyException: If the serial is missing or the message cannot be retrieved. + """ + # Extract serial from string or Message object + if isinstance(serial_or_message, str): + serial = serial_or_message + elif isinstance(serial_or_message, Message): + serial = serial_or_message.serial + else: + serial = None + + if not serial: + raise AblyException( + 'This message lacks a serial. Make sure you have enabled "Message annotations, ' + 'updates, and deletes" in channel settings on your dashboard.', + 400, + 40003 + ) + + # Build the path + path = self.__base_path + 'messages/' + parse.quote_plus(serial, safe=':') + + # Make the request + response = await self.ably.http.get(path, timeout=timeout) + + # Create Message from the response + message_handler = make_single_message_response_handler(self.__cipher) + return message_handler(response) + + async def get_message_versions(self, serial_or_message, params=None): + """Retrieves version history for a message. + + Parameters: + - serial_or_message: Either a string serial or a Message object with a serial field. + - params: Optional dict of query parameters for pagination (e.g., limit, start, end, direction). + + Returns: + - PaginatedResult containing Message objects representing each version. + + Raises: + - AblyException: If the serial is missing or versions cannot be retrieved. + """ + # Extract serial from string or Message object + if isinstance(serial_or_message, str): + serial = serial_or_message + elif isinstance(serial_or_message, Message): + serial = serial_or_message.serial + else: + serial = None + + if not serial: + raise AblyException( + 'This message lacks a serial. Make sure you have enabled "Message annotations, ' + 'updates, and deletes" in channel settings on your dashboard.', + 400, + 40003 + ) + + # Build the path + params_str = format_params({}, **params) if params else '' + path = self.__base_path + 'messages/' + parse.quote_plus(serial, safe=':') + '/versions' + params_str + + # Create message handler for decoding + message_handler = make_message_response_handler(self.__cipher) + + # Return paginated result + return await PaginatedResult.paginated_query( + self.ably.http, + url=path, + response_processor=message_handler + ) + @property def ably(self): return self.__ably diff --git a/ably/types/message.py b/ably/types/message.py index 59dcb736..a7877fea 100644 --- a/ably/types/message.py +++ b/ably/types/message.py @@ -1,6 +1,7 @@ import base64 import json import logging +from enum import IntEnum from ably.types.mixins import DeltaExtras, EncodeDataMixin from ably.types.typedbuffer import TypedBuffer @@ -21,6 +22,91 @@ def to_text(value): raise TypeError(f"expected string or bytes, not {type(value)}") +class MessageVersion: + """ + Contains the details regarding the current version of the message - including when it was updated and by whom. + """ + + def __init__(self, + serial=None, + timestamp=None, + client_id=None, + description=None, + metadata=None): + """ + Args: + serial: A unique identifier for the version of the message, lexicographically-comparable with other + versions (that share the same Message.serial). Will differ from the Message.serial only if the + message has been updated or deleted. + timestamp: The timestamp of the message version. If the Message.action is message.create, + this will equal the Message.timestamp. + client_id: The client ID of the client that updated the message to this version. + description: The description provided by the client that updated the message to this version. + metadata: A dict of string key-value pairs that may contain metadata associated with the operation + to update the message to this version. + """ + self.__serial = to_text(serial) if serial is not None else None + self.__timestamp = timestamp + self.__client_id = to_text(client_id) if client_id is not None else None + self.__description = to_text(description) if description is not None else None + self.__metadata = metadata + + @property + def serial(self): + return self.__serial + + @property + def timestamp(self): + return self.__timestamp + + @property + def client_id(self): + return self.__client_id + + @property + def description(self): + return self.__description + + @property + def metadata(self): + return self.__metadata + + def as_dict(self): + """Convert MessageVersion to dictionary format.""" + result = { + 'serial': self.serial, + 'timestamp': self.timestamp, + 'clientId': self.client_id, + 'description': self.description, + 'metadata': self.metadata, + } + # Remove None values + return {k: v for k, v in result.items() if v is not None} + + @staticmethod + def from_dict(obj): + """Create MessageVersion from dictionary.""" + if obj is None: + return None + return MessageVersion( + serial=obj.get('serial'), + timestamp=obj.get('timestamp'), + client_id=obj.get('clientId'), + description=obj.get('description'), + metadata=obj.get('metadata'), + ) + + +class MessageAction(IntEnum): + """Message action types""" + MESSAGE_CREATE = 0 + MESSAGE_UPDATE = 1 + MESSAGE_DELETE = 2 + META = 3 + MESSAGE_SUMMARY = 4 + MESSAGE_APPEND = 5 + + class Message(EncodeDataMixin): def __init__(self, @@ -33,6 +119,9 @@ def __init__(self, encoding='', # TM2e timestamp=None, # TM2f extras=None, # TM2i + serial=None, # TM2r + action=None, # TM2j + version=None, # TM2s ): super().__init__(encoding) @@ -45,6 +134,9 @@ def __init__(self, self.__connection_key = connection_key self.__timestamp = timestamp self.__extras = extras + self.__serial = serial + self.__action = action + self.__version = version def __eq__(self, other): if isinstance(other, Message): @@ -97,6 +189,18 @@ def timestamp(self): def extras(self): return self.__extras + @property + def version(self): + return self.__version + + @property + def serial(self): + return self.__serial + + @property + def action(self): + return self.__action + def encrypt(self, channel_cipher): if isinstance(self.data, CipherData): return @@ -167,6 +271,9 @@ def as_dict(self, binary=False): 'connectionId': self.connection_id or None, 'connectionKey': self.connection_key or None, 'extras': self.extras, + 'version': self.version.as_dict() if self.version else None, + 'serial': self.serial, + 'action': int(self.action) if self.action is not None else None, } if encoding: @@ -187,6 +294,9 @@ def from_encoded(obj, cipher=None, context=None): timestamp = obj.get('timestamp') encoding = obj.get('encoding', '') extras = obj.get('extras', None) + serial = obj.get('serial') + action = obj.get('action') + version = obj.get('version', None) delta_extra = DeltaExtras(extras) if delta_extra.from_id and delta_extra.from_id != context.last_message_id: @@ -195,6 +305,20 @@ def from_encoded(obj, cipher=None, context=None): decoded_data = Message.decode(data, encoding, cipher, context) + if action is not None: + try: + action = MessageAction(action) + except ValueError: + # If it's not a valid action value, store as None + action = None + else: + action = None + + if version is not None: + version = MessageVersion.from_dict(version) + else: + version = MessageVersion(serial=serial, timestamp=timestamp) + return Message( id=id, name=name, @@ -202,6 +326,9 @@ def from_encoded(obj, cipher=None, context=None): client_id=client_id, timestamp=timestamp, extras=extras, + serial=serial, + action=action, + version=version, **decoded_data ) @@ -236,3 +363,9 @@ def encrypted_message_response_handler(response): messages = response.to_native() return Message.from_encoded_array(messages, cipher=cipher) return encrypted_message_response_handler + +def make_single_message_response_handler(cipher): + def encrypted_message_response_handler(response): + message = response.to_native() + return Message.from_encoded(message, cipher=cipher) + return encrypted_message_response_handler diff --git a/ably/types/operations.py b/ably/types/operations.py new file mode 100644 index 00000000..4e69db64 --- /dev/null +++ b/ably/types/operations.py @@ -0,0 +1,89 @@ +class MessageOperation: + """Metadata for message update/delete/append operations.""" + + def __init__(self, client_id=None, description=None, metadata=None): + """ + Args: + description: Optional description of the operation. + metadata: Optional dict of metadata key-value pairs associated with the operation. + """ + self.__client_id = client_id + self.__description = description + self.__metadata = metadata + + @property + def client_id(self): + return self.__client_id + + @property + def description(self): + return self.__description + + @property + def metadata(self): + return self.__metadata + + def as_dict(self): + """Convert MessageOperation to dictionary format.""" + result = { + 'clientId': self.client_id, + 'description': self.description, + 'metadata': self.metadata, + } + # Remove None values + return {k: v for k, v in result.items() if v is not None} + + @staticmethod + def from_dict(obj): + """Create MessageOperation from dictionary.""" + if obj is None: + return None + return MessageOperation( + client_id=obj.get('clientId'), + description=obj.get('description'), + metadata=obj.get('metadata'), + ) + + +class PublishResult: + """Result of a publish operation containing message serials.""" + + def __init__(self, serials=None): + """ + Args: + serials: List of message serials (strings or None) in 1:1 correspondence with published messages. + """ + self.__serials = serials or [] + + @property + def serials(self): + return self.__serials + + @staticmethod + def from_dict(obj): + """Create PublishResult from dictionary.""" + if obj is None: + return PublishResult() + return PublishResult(serials=obj.get('serials', [])) + + +class UpdateDeleteResult: + """Result of an update or delete operation containing version serial.""" + + def __init__(self, version_serial=None): + """ + Args: + version_serial: The serial of the resulting message version after the operation. + """ + self.__version_serial = version_serial + + @property + def version_serial(self): + return self.__version_serial + + @staticmethod + def from_dict(obj): + """Create UpdateDeleteResult from dictionary.""" + if obj is None: + return UpdateDeleteResult() + return UpdateDeleteResult(version_serial=obj.get('versionSerial')) diff --git a/test/ably/rest/restchannelmutablemessages_test.py b/test/ably/rest/restchannelmutablemessages_test.py new file mode 100644 index 00000000..7b144ab0 --- /dev/null +++ b/test/ably/rest/restchannelmutablemessages_test.py @@ -0,0 +1,296 @@ +import logging +from typing import List + +import pytest + +from ably import AblyException, CipherParams, MessageAction +from ably.types.message import Message +from ably.types.operations import MessageOperation +from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, assert_waiter + +log = logging.getLogger(__name__) + + +@pytest.mark.parametrize("transport", ["json", "msgpack"], ids=["JSON", "MsgPack"]) +class TestRestChannelMutableMessages(BaseAsyncTestCase): + + @pytest.fixture(autouse=True) + async def setup(self, transport): + self.test_vars = await TestApp.get_test_vars() + self.ably = await TestApp.get_ably_rest( + use_binary_protocol=True if transport == 'msgpack' else False, + ) + + async def test_update_message_success(self): + """Test successfully updating a message""" + channel = self.ably.channels[self.get_channel_name('mutable:update_test')] + + # First publish a message + result = await channel.publish('test-event', 'original data') + assert result.serials is not None + assert len(result.serials) > 0 + serial = result.serials[0] + + # Create message with serial for update + message = Message( + data='updated data', + serial=serial, + ) + + # Update the message + 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.version.serial == update_result.version_serial + assert updated_message.serial == serial + + async def test_update_message_without_serial_fails(self): + """Test that updating without a serial raises an exception""" + channel = self.ably.channels[self.get_channel_name('mutable:update_test_no_serial')] + + message = Message(name='test-event', data='data') + + with pytest.raises(AblyException) as exc_info: + await channel.update_message(message) + + assert exc_info.value.status_code == 400 + assert 'serial is required' in str(exc_info.value).lower() + + async def test_delete_message_success(self): + """Test successfully deleting a message""" + channel = self.ably.channels[self.get_channel_name('mutable:delete_test')] + + # First publish a message + result = await channel.publish('test-event', 'data to delete') + assert result.serials is not None + assert len(result.serials) > 0 + serial = result.serials[0] + + # Create message with serial for deletion + message = Message(serial=serial) + + operation = MessageOperation( + description='Inappropriate content', + metadata={'reason': 'moderation'} + ) + + # Delete the message + delete_result = await channel.delete_message(message, operation) + assert delete_result is not None + + # Verify the deletion propagated + deleted_message = await self.wait_until_message_with_action_appears( + channel, serial, MessageAction.MESSAGE_DELETE + ) + assert deleted_message.action == MessageAction.MESSAGE_DELETE + assert deleted_message.version.serial == delete_result.version_serial + assert deleted_message.version.description == 'Inappropriate content' + assert deleted_message.version.metadata == {'reason': 'moderation'} + assert deleted_message.serial == serial + + async def test_delete_message_without_serial_fails(self): + """Test that deleting without a serial raises an exception""" + channel = self.ably.channels[self.get_channel_name('mutable:delete_test_no_serial')] + + message = Message(name='test-event', data='data') + + with pytest.raises(AblyException) as exc_info: + await channel.delete_message(message) + + assert exc_info.value.status_code == 400 + assert 'serial is required' in str(exc_info.value).lower() + + async def test_append_message_success(self): + """Test successfully appending to a message""" + channel = self.ably.channels[self.get_channel_name('mutable:append_test')] + + # First publish a message + result = await channel.publish('test-event', 'original content') + assert result.serials is not None + assert len(result.serials) > 0 + serial = result.serials[0] + + # Create message with serial and data to append + message = Message( + data=' appended content', + serial=serial + ) + + operation = MessageOperation( + description='Added more info', + metadata={'type': 'amendment'} + ) + + # Append to the message + append_result = await channel.append_message(message, operation) + assert append_result is not None + + # Verify the append propagated - action will be MESSAGE_UPDATE, data should be concatenated + appended_message = await self.wait_until_message_with_action_appears( + channel, serial, MessageAction.MESSAGE_UPDATE + ) + assert appended_message.data == 'original content appended content' + assert appended_message.version.serial == append_result.version_serial + assert appended_message.version.description == 'Added more info' + assert appended_message.version.metadata == {'type': 'amendment'} + assert appended_message.serial == serial + + async def test_append_message_without_serial_fails(self): + """Test that appending without a serial raises an exception""" + channel = self.ably.channels[self.get_channel_name('mutable:append_test_no_serial')] + + message = Message(name='test-event', data='data to append') + + with pytest.raises(AblyException) as exc_info: + await channel.append_message(message) + + assert exc_info.value.status_code == 400 + assert 'serial is required' in str(exc_info.value).lower() + + async def test_update_message_with_encryption(self): + """Test updating an encrypted message""" + # Create channel with encryption + channel_name = self.get_channel_name('mutable:update_encrypted') + cipher_params = CipherParams(secret_key='keyfordecrypt_16', algorithm='aes') + channel = self.ably.channels.get(channel_name, cipher=cipher_params) + + # Publish encrypted message + result = await channel.publish('encrypted-event', 'secret data') + assert result.serials is not None + assert len(result.serials) > 0 + + # Update the encrypted message + message = Message( + name='encrypted-event', + data='updated secret data', + serial=result.serials[0] + ) + + operation = MessageOperation(description='Updated encrypted message') + update_result = await channel.update_message(message, operation) + assert update_result is not None + + async def test_update_message_with_params(self): + """Test updating a message with query parameters""" + channel = self.ably.channels[self.get_channel_name('mutable:update_params')] + + # Publish message + result = await channel.publish('test-event', 'original') + assert len(result.serials) > 0 + + # Update with params + message = Message( + name='test-event', + data='updated', + serial=result.serials[0] + ) + + operation = MessageOperation(description='Test with params') + params = {'testParam': 'value'} + + update_result = await channel.update_message(message, operation, params) + assert update_result is not None + + async def test_publish_returns_serials(self): + """Test that publish returns PublishResult with serials""" + channel = self.ably.channels[self.get_channel_name('mutable:publish_serials')] + + # Publish multiple messages + messages = [ + Message('event1', 'data1'), + Message('event2', 'data2'), + Message('event3', 'data3') + ] + + result = await channel.publish(messages=messages) + assert result is not None + assert hasattr(result, 'serials') + assert len(result.serials) == 3 + + async def test_complete_workflow_publish_update_delete(self): + """Test complete workflow: publish, update, delete""" + channel = self.ably.channels[self.get_channel_name('mutable:complete_workflow')] + + # 1. Publish a message + result = await channel.publish('workflow_event', 'Initial data') + assert result.serials is not None + assert len(result.serials) > 0 + serial = result.serials[0] + + # 2. Update the message + update_message = Message( + name='workflow_event_updated', + data='Updated data', + serial=serial + ) + update_operation = MessageOperation(description='Updated message') + update_result = await channel.update_message(update_message, update_operation) + assert update_result is not None + + # 3. Delete the message + delete_message = Message(serial=serial, data='Deleted') + delete_operation = MessageOperation(description='Deleted message') + delete_result = await channel.delete_message(delete_message, delete_operation) + assert delete_result is not None + + versions = await self.wait_until_get_all_message_version(channel, serial, 3) + + assert versions[0].version.serial == serial + assert versions[1].version.serial == update_result.version_serial + assert versions[2].version.serial == delete_result.version_serial + + async def test_append_message_with_string_data(self): + """Test appending string data to a message""" + channel = self.ably.channels[self.get_channel_name('mutable:append_string')] + + # Publish initial message + result = await channel.publish('append_event', 'Initial data') + assert len(result.serials) > 0 + serial = result.serials[0] + + # Append data + append_message = Message( + data=' appended data', + serial=serial + ) + append_operation = MessageOperation(description='Appended to message') + append_result = await channel.append_message(append_message, append_operation) + assert append_result is not None + + # Verify the append + appended_message = await self.wait_until_message_with_action_appears( + channel, serial, MessageAction.MESSAGE_UPDATE + ) + assert appended_message.data == 'Initial data appended data' + assert appended_message.version.serial == append_result.version_serial + assert appended_message.version.description == 'Appended to message' + assert appended_message.serial == serial + + async def wait_until_message_with_action_appears(self, channel, serial, action): + message: Message | None = None + async def check_message_action(): + nonlocal message + try: + message = await channel.get_message(serial) + return message.action == action + except Exception: + return False + + await assert_waiter(check_message_action) + + return message + + async def wait_until_get_all_message_version(self, channel, serial, count): + versions: List[Message] = [] + async def check_message_versions(): + nonlocal versions + versions = (await channel.get_message_versions(serial)).items + return len(versions) >= count + + await assert_waiter(check_message_versions) + + return versions diff --git a/test/ably/rest/restchannelpublish_test.py b/test/ably/rest/restchannelpublish_test.py index 71528b42..41c2018b 100644 --- a/test/ably/rest/restchannelpublish_test.py +++ b/test/ably/rest/restchannelpublish_test.py @@ -399,8 +399,7 @@ async def test_interoperability(self): expected_value = input_msg.get('expectedValue') # 1) - response = await channel.publish(data=expected_value) - assert response.status_code == 201 + await channel.publish(data=expected_value) async def check_data(encoding=encoding, msg_data=msg_data): async with httpx.AsyncClient(http2=True) as client: @@ -415,8 +414,7 @@ async def check_data(encoding=encoding, msg_data=msg_data): await assert_waiter(check_data) # 2) - response = await channel.publish(messages=[Message(data=msg_data, encoding=encoding)]) - assert response.status_code == 201 + await channel.publish(messages=[Message(data=msg_data, encoding=encoding)]) async def check_history(expected_value=expected_value, expected_type=expected_type): history = await channel.history() diff --git a/test/ably/rest/resthttp_test.py b/test/ably/rest/resthttp_test.py index ba101c21..df2becfc 100644 --- a/test/ably/rest/resthttp_test.py +++ b/test/ably/rest/resthttp_test.py @@ -180,7 +180,7 @@ async def test_request_headers(self): # API assert 'X-Ably-Version' in r.request.headers - assert r.request.headers['X-Ably-Version'] == '3' + assert r.request.headers['X-Ably-Version'] == '5' # Agent assert 'Ably-Agent' in r.request.headers diff --git a/test/ably/rest/restrequest_test.py b/test/ably/rest/restrequest_test.py index 7380ea07..f11f71a7 100644 --- a/test/ably/rest/restrequest_test.py +++ b/test/ably/rest/restrequest_test.py @@ -193,9 +193,8 @@ async def test_503_status_fallback_on_publish(self): headers=headers, text=fallback_response_text, ) - message_response = await ably.channels['test'].publish('test', 'data') + await ably.channels['test'].publish('test', 'data') assert default_route.called - assert message_response.to_native()['data'] == 'data' await ably.close() # RSC15l4 diff --git a/test/assets/testAppSpec.json b/test/assets/testAppSpec.json index 6af43268..90f1655e 100644 --- a/test/assets/testAppSpec.json +++ b/test/assets/testAppSpec.json @@ -26,7 +26,11 @@ { "id": "canpublish", "pushEnabled": true - } + }, + { + "id": "mutable", + "mutableMessages": true + } ], "channels": [ { diff --git a/test/unit/mutable_message_test.py b/test/unit/mutable_message_test.py new file mode 100644 index 00000000..6f5afc92 --- /dev/null +++ b/test/unit/mutable_message_test.py @@ -0,0 +1,117 @@ +from ably import MessageAction, MessageOperation, MessageVersion, UpdateDeleteResult +from ably.types.message import Message + + +def test_message_version_none_values_filtered(): + """Test that None values are filtered out in MessageVersion.as_dict()""" + version = MessageVersion( + serial='abc123', + timestamp=None, + client_id=None + ) + + version_dict = version.as_dict() + assert 'serial' in version_dict + assert 'timestamp' not in version_dict + assert 'clientId' not in version_dict + +def test_message_operation_none_values_filtered(): + """Test that None values are filtered out in MessageOperation.as_dict()""" + operation = MessageOperation( + client_id='client123', + description='Test', + metadata=None + ) + + op_dict = operation.as_dict() + assert 'clientId' in op_dict + assert 'description' in op_dict + assert 'metadata' not in op_dict + +def test_message_with_action_and_serial(): + """Test Message can store action and serial""" + message = Message( + name='test', + data='data', + serial='abc123', + action=MessageAction.MESSAGE_UPDATE + ) + + assert message.serial == 'abc123' + assert message.action == MessageAction.MESSAGE_UPDATE + + # Test as_dict includes action and serial + msg_dict = message.as_dict() + assert msg_dict['serial'] == 'abc123' + assert msg_dict['action'] == 1 # MESSAGE_UPDATE value + +def test_update_delete_result_from_dict(): + """Test UpdateDeleteResult can be created from dict""" + result_dict = {'versionSerial': 'abc123:v2'} + result = UpdateDeleteResult.from_dict(result_dict) + + assert result.version_serial == 'abc123:v2' + +def test_update_delete_result_empty(): + """Test UpdateDeleteResult handles None/empty correctly""" + result = UpdateDeleteResult.from_dict(None) + assert result.version_serial is None + + result2 = UpdateDeleteResult() + assert result2.version_serial is None + + +def test_message_action_enum_values(): + """Test MessageAction enum has correct values""" + assert MessageAction.MESSAGE_CREATE == 0 + assert MessageAction.MESSAGE_UPDATE == 1 + assert MessageAction.MESSAGE_DELETE == 2 + assert MessageAction.META == 3 + assert MessageAction.MESSAGE_SUMMARY == 4 + assert MessageAction.MESSAGE_APPEND == 5 + +def test_message_version_serialization(): + """Test MessageVersion can be serialized and deserialized""" + version = MessageVersion( + serial='abc123:v2', + timestamp=1234567890, + client_id='user1', + description='Test update', + metadata={'key': 'value'} + ) + + # Test as_dict + version_dict = version.as_dict() + assert version_dict['serial'] == 'abc123:v2' + assert version_dict['timestamp'] == 1234567890 + assert version_dict['clientId'] == 'user1' + assert version_dict['description'] == 'Test update' + assert version_dict['metadata'] == {'key': 'value'} + + # Test from_dict + reconstructed = MessageVersion.from_dict(version_dict) + assert reconstructed.serial == version.serial + assert reconstructed.timestamp == version.timestamp + assert reconstructed.client_id == version.client_id + assert reconstructed.description == version.description + assert reconstructed.metadata == version.metadata + +def test_message_operation_serialization(): + """Test MessageOperation can be serialized and deserialized""" + operation = MessageOperation( + client_id='user1', + description='Test operation', + metadata={'key': 'value'} + ) + + # Test as_dict + op_dict = operation.as_dict() + assert op_dict['clientId'] == 'user1' + assert op_dict['description'] == 'Test operation' + assert op_dict['metadata'] == {'key': 'value'} + + # Test from_dict + reconstructed = MessageOperation.from_dict(op_dict) + assert reconstructed.client_id == operation.client_id + assert reconstructed.description == operation.description + assert reconstructed.metadata == operation.metadata