diff --git a/lib/src/main/java/io/ably/lib/types/Message.java b/lib/src/main/java/io/ably/lib/types/Message.java index 99eed55f5..afdea4bc4 100644 --- a/lib/src/main/java/io/ably/lib/types/Message.java +++ b/lib/src/main/java/io/ably/lib/types/Message.java @@ -3,6 +3,9 @@ import java.io.IOException; import java.lang.reflect.Type; import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + import com.google.gson.JsonArray; import com.google.gson.JsonDeserializer; import com.google.gson.JsonDeserializationContext; @@ -74,6 +77,100 @@ public class Message extends BaseMessage { */ public Long createdAt; + /** + * (TM2l) ref string – an opaque string that uniquely identifies some referenced message. + */ + public String refSerial; + + /** + * (TM2m) refType string – an opaque string that identifies the type of this reference. + */ + public String refType; + + /** + * (TM2n) operation object – data object that may contain the `optional` attributes. + */ + public Operation operation; + + public static class Operation { + public String clientId; + public String description; + public Map metadata; + + void write(MessagePacker packer) throws IOException { + int fieldCount = 0; + if (clientId != null) fieldCount++; + if (description != null) fieldCount++; + if (metadata != null) fieldCount++; + + packer.packMapHeader(fieldCount); + + if (clientId != null) { + packer.packString("clientId"); + packer.packString(clientId); + } + if (description != null) { + packer.packString("description"); + packer.packString(description); + } + if (metadata != null) { + packer.packString("metadata"); + packer.packMapHeader(metadata.size()); + for (Map.Entry entry : metadata.entrySet()) { + packer.packString(entry.getKey()); + packer.packString(entry.getValue()); + } + } + } + + protected static Operation read(final MessageUnpacker unpacker) throws IOException { + Operation operation = new Operation(); + int fieldCount = unpacker.unpackMapHeader(); + for (int i = 0; i < fieldCount; i++) { + String fieldName = unpacker.unpackString().intern(); + switch (fieldName) { + case "clientId": + operation.clientId = unpacker.unpackString(); + break; + case "description": + operation.description = unpacker.unpackString(); + break; + case "metadata": + int mapSize = unpacker.unpackMapHeader(); + operation.metadata = new HashMap<>(mapSize); + for (int j = 0; j < mapSize; j++) { + String key = unpacker.unpackString(); + String value = unpacker.unpackString(); + operation.metadata.put(key, value); + } + break; + default: + unpacker.skipValue(); + break; + } + } + return operation; + } + + protected static Operation read(final JsonObject jsonObject) throws MessageDecodeException { + Operation operation = new Operation(); + if (jsonObject.has("clientId")) { + operation.clientId = jsonObject.get("clientId").getAsString(); + } + if (jsonObject.has("description")) { + operation.description = jsonObject.get("description").getAsString(); + } + if (jsonObject.has("metadata")) { + JsonObject metadataObject = jsonObject.getAsJsonObject("metadata"); + operation.metadata = new HashMap<>(); + for (Map.Entry entry : metadataObject.entrySet()) { + operation.metadata.put(entry.getKey(), entry.getValue().getAsString()); + } + } + return operation; + } + } + private static final String NAME = "name"; private static final String EXTRAS = "extras"; private static final String CONNECTION_KEY = "connectionKey"; @@ -81,6 +178,9 @@ public class Message extends BaseMessage { private static final String VERSION = "version"; private static final String ACTION = "action"; private static final String CREATED_AT = "createdAt"; + private static final String REF_SERIAL = "refSerial"; + private static final String REF_TYPE = "refType"; + private static final String OPERATION = "operation"; /** * Default constructor @@ -160,10 +260,15 @@ void writeMsgpack(MessagePacker packer) throws IOException { int fieldCount = super.countFields(); if(name != null) ++fieldCount; if(extras != null) ++fieldCount; + if(connectionKey != null) ++fieldCount; if(serial != null) ++fieldCount; if(version != null) ++fieldCount; if(action != null) ++fieldCount; if(createdAt != null) ++fieldCount; + if(refSerial != null) ++fieldCount; + if(refType != null) ++fieldCount; + if(operation != null) ++fieldCount; + packer.packMapHeader(fieldCount); super.writeFields(packer); if(name != null) { @@ -174,6 +279,10 @@ void writeMsgpack(MessagePacker packer) throws IOException { packer.packString(EXTRAS); extras.write(packer); } + if(connectionKey != null) { + packer.packString(CONNECTION_KEY); + packer.packString(connectionKey); + } if(serial != null) { packer.packString(SERIAL); packer.packString(serial); @@ -190,6 +299,18 @@ void writeMsgpack(MessagePacker packer) throws IOException { packer.packString(CREATED_AT); packer.packLong(createdAt); } + if(refSerial != null) { + packer.packString(REF_SERIAL); + packer.packString(refSerial); + } + if(refType != null) { + packer.packString(REF_TYPE); + packer.packString(refType); + } + if(operation != null) { + packer.packString(OPERATION); + operation.write(packer); + } } Message readMsgpack(MessageUnpacker unpacker) throws IOException { @@ -209,6 +330,8 @@ Message readMsgpack(MessageUnpacker unpacker) throws IOException { name = unpacker.unpackString(); } else if (fieldName.equals(EXTRAS)) { extras = MessageExtras.read(unpacker); + } else if (fieldName.equals(CONNECTION_KEY)) { + connectionKey = unpacker.unpackString(); } else if (fieldName.equals(SERIAL)) { serial = unpacker.unpackString(); } else if (fieldName.equals(VERSION)) { @@ -217,7 +340,14 @@ Message readMsgpack(MessageUnpacker unpacker) throws IOException { action = MessageAction.tryFindByOrdinal(unpacker.unpackInt()); } else if (fieldName.equals(CREATED_AT)) { createdAt = unpacker.unpackLong(); - } else { + } else if (fieldName.equals(REF_SERIAL)) { + refSerial = unpacker.unpackString(); + } else if (fieldName.equals(REF_TYPE)) { + refType = unpacker.unpackString(); + } else if (fieldName.equals(OPERATION)) { + operation = Operation.read(unpacker); + } + else { Log.v(TAG, "Unexpected field: " + fieldName); unpacker.skipValue(); } @@ -373,12 +503,23 @@ protected void read(final JsonObject map) throws MessageDecodeException { } extras = MessageExtras.read((JsonObject) extrasElement); } + connectionKey = readString(map, CONNECTION_KEY); serial = readString(map, SERIAL); version = readString(map, VERSION); Integer actionOrdinal = readInt(map, ACTION); action = actionOrdinal == null ? null : MessageAction.tryFindByOrdinal(actionOrdinal); createdAt = readLong(map, CREATED_AT); + refSerial = readString(map, REF_SERIAL); + refType = readString(map, REF_TYPE); + + final JsonElement operationElement = map.get(OPERATION); + if (null != operationElement) { + if (!(operationElement instanceof JsonObject)) { + throw MessageDecodeException.fromDescription("Message operation is of type \"" + operationElement.getClass() + "\" when expected a JSON object."); + } + operation = Operation.read((JsonObject) operationElement); + } } public static class Serializer implements JsonSerializer, JsonDeserializer { @@ -406,6 +547,15 @@ public JsonElement serialize(Message message, Type typeOfMessage, JsonSerializat if (message.createdAt != null) { json.addProperty(CREATED_AT, message.createdAt); } + if (message.refSerial != null) { + json.addProperty(REF_SERIAL, message.refSerial); + } + if (message.refType != null) { + json.addProperty(REF_TYPE, message.refType); + } + if (message.operation != null) { + json.add(OPERATION, Serialisation.gson.toJsonTree(message.operation)); + } return json; } diff --git a/lib/src/test/java/io/ably/lib/chat/ChatMessagesTest.java b/lib/src/test/java/io/ably/lib/chat/ChatMessagesTest.java new file mode 100644 index 000000000..940071a75 --- /dev/null +++ b/lib/src/test/java/io/ably/lib/chat/ChatMessagesTest.java @@ -0,0 +1,523 @@ +package io.ably.lib.chat; + +import com.google.gson.JsonObject; +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; +import io.ably.lib.realtime.ChannelState; +import io.ably.lib.test.common.Helpers; +import io.ably.lib.test.common.ParameterizedTest; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.Message; +import io.ably.lib.types.MessageAction; +import org.junit.Assert; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class ChatMessagesTest extends ParameterizedTest { + /** + * Test that a message sent via rest API is sent to a messages channel. + * It should be received by the client that is subscribed to the messages channel. + */ + @Test + public void test_room_message_is_published() { + String roomId = "1234"; + String channelName = roomId + "::$chat::$chatMessages"; + AblyRealtime ably = null; + try { + ClientOptions opts = createOptions(testVars.keys[7].keyStr); + opts.clientId = "sandbox-client"; + ably = new AblyRealtime(opts); + ChatRoom room = new ChatRoom(roomId, ably); + + /* create a channel and attach */ + final Channel channel = ably.channels.get(channelName); + channel.attach(); + (new Helpers.ChannelWaiter(channel)).waitFor(ChannelState.attached); + + /* subscribe to messages */ + List receivedMsg = new ArrayList<>(); + channel.subscribe(receivedMsg::add); + + // send message to room + ChatRoom.SendMessageParams params = new ChatRoom.SendMessageParams(); + params.text = "hello there"; + params.metadata = new JsonObject(); + JsonObject foo = new JsonObject(); + foo.addProperty("bar", 1); + params.metadata.add("foo", foo); + Map headers = new HashMap<>(); + headers.put("header1", "value1"); + headers.put("baz", "qux"); + params.headers = headers; + + JsonObject sendMessageResult = (JsonObject) room.sendMessage(params); + // check sendMessageResult has 2 fields and are not null + Assert.assertEquals(2, sendMessageResult.entrySet().size()); + String resultSerial = sendMessageResult.get("serial").getAsString(); + Assert.assertFalse(resultSerial.isEmpty()); + String resultCreatedAt = sendMessageResult.get("createdAt").getAsString(); + Assert.assertFalse(resultCreatedAt.isEmpty()); + + Exception err = new Helpers.ConditionalWaiter().wait(() -> !receivedMsg.isEmpty(), 10_000); + Assert.assertNull(err); + + Assert.assertEquals(1, receivedMsg.size()); + Message message = receivedMsg.get(0); + + Assert.assertFalse("Message ID should not be empty", message.id.isEmpty()); + Assert.assertEquals("chat.message", message.name); + Assert.assertEquals("sandbox-client", message.clientId); + + JsonObject data = (JsonObject) message.data; + // has two fields "text" and "metadata" + Assert.assertEquals(2, data.entrySet().size()); + // Assert for received text + Assert.assertEquals("hello there", data.get("text").getAsString()); + // Assert on received metadata + JsonObject metadata = data.getAsJsonObject("metadata"); + Assert.assertTrue(metadata.has("foo")); + Assert.assertTrue(metadata.get("foo").isJsonObject()); + Assert.assertEquals(1, metadata.getAsJsonObject("foo").get("bar").getAsInt()); + + // Assert sent headers as a part of message.extras.headers + JsonObject extrasJson = message.extras.asJsonObject(); + Assert.assertTrue(extrasJson.has("headers")); + JsonObject headersJson = extrasJson.getAsJsonObject("headers"); + Assert.assertEquals(2, headersJson.entrySet().size()); + Assert.assertEquals("value1", headersJson.get("header1").getAsString()); + Assert.assertEquals("qux", headersJson.get("baz").getAsString()); + + Assert.assertEquals(resultCreatedAt, String.valueOf(message.timestamp)); + + Assert.assertEquals(resultCreatedAt, message.createdAt.toString()); + Assert.assertEquals(resultSerial, message.serial); + Assert.assertEquals(resultSerial, message.version); + + Assert.assertEquals(MessageAction.MESSAGE_CREATE, message.action); + Assert.assertEquals(resultCreatedAt, message.createdAt.toString()); + + } catch (Exception e) { + e.printStackTrace(); + Assert.fail("init0: Unexpected exception instantiating library"); + } finally { + if(ably != null) + ably.close(); + } + } + + /** + * Test that a message updated via rest API is sent to a messages channel. + * It should be received by another client that is subscribed to the same messages channel. + * Make sure to use two clientIds: clientId1 and clientId2 + */ + @Test + public void test_room_message_is_updated() { + String roomId = "1234"; + String channelName = roomId + "::$chat::$chatMessages"; + AblyRealtime ablyClient1 = null; + AblyRealtime ablyClient2 = null; + try { + ClientOptions opts1 = createOptions(testVars.keys[7].keyStr); + opts1.clientId = "clientId1"; + ablyClient1 = new AblyRealtime(opts1); + + ClientOptions opts2 = createOptions(testVars.keys[7].keyStr); + opts2.clientId = "clientId2"; + ablyClient2 = new AblyRealtime(opts2); + + ChatRoom room = new ChatRoom(roomId, ablyClient1); + + // Create a channel and attach with client1 + final Channel channel1 = ablyClient1.channels.get(channelName); + channel1.attach(); + (new Helpers.ChannelWaiter(channel1)).waitFor(ChannelState.attached); + + // Subscribe to messages with client2 + final Channel channel2 = ablyClient2.channels.get(channelName); + channel2.attach(); + (new Helpers.ChannelWaiter(channel2)).waitFor(ChannelState.attached); + + List receivedMsg = new ArrayList<>(); + channel2.subscribe(receivedMsg::add); + + // Send message to room + ChatRoom.SendMessageParams params = new ChatRoom.SendMessageParams(); + params.text = "hello there"; + JsonObject sendMessageResult = (JsonObject) room.sendMessage(params); + String originalSerial = sendMessageResult.get("serial").getAsString(); + String originalCreatedAt = sendMessageResult.get("createdAt").getAsString(); + + // Wait for the message to be received + Exception err = new Helpers.ConditionalWaiter().wait(() -> !receivedMsg.isEmpty(), 10_000); + Assert.assertNull(err); + + // Update the message + ChatRoom.UpdateMessageParams updateParams = new ChatRoom.UpdateMessageParams(); + // Update message context + updateParams.message = new ChatRoom.SendMessageParams(); + updateParams.message.text = "updated text"; + JsonObject metaData = new JsonObject(); + JsonObject foo = new JsonObject(); + foo.addProperty("bar", 1); + metaData.add("foo", foo); + updateParams.message.metadata = metaData; + // Update description + updateParams.description = "message updated by clientId1"; + + // Update metadata, add few random fields + Map operationMetadata = new HashMap<>(); + operationMetadata.put("foo", "bar"); + operationMetadata.put("naruto", "hero"); + updateParams.metadata = operationMetadata; + + JsonObject updateMessageResult = (JsonObject) room.updateMessage(originalSerial, updateParams); + String updateResultVersion = updateMessageResult.get("version").getAsString(); + String updateResultTimestamp = updateMessageResult.get("timestamp").getAsString(); + + // Wait for the updated message to be received + err = new Helpers.ConditionalWaiter().wait(() -> receivedMsg.size() == 2, 10_000); + Assert.assertNull(err); + + // Verify the updated message + Message updatedMessage = receivedMsg.get(1); + + Assert.assertEquals(MessageAction.MESSAGE_UPDATE, updatedMessage.action); + + Assert.assertFalse("Message ID should not be empty", updatedMessage.id.isEmpty()); + Assert.assertEquals("chat.message", updatedMessage.name); + Assert.assertEquals("clientId1", updatedMessage.clientId); + + JsonObject data = (JsonObject) updatedMessage.data; + Assert.assertEquals(2, data.entrySet().size()); + Assert.assertEquals("updated text", data.get("text").getAsString()); + JsonObject metadata = data.getAsJsonObject("metadata"); + Assert.assertTrue(metadata.has("foo")); + Assert.assertTrue(metadata.get("foo").isJsonObject()); + Assert.assertEquals(1, metadata.getAsJsonObject("foo").get("bar").getAsInt()); + + Assert.assertEquals(originalSerial, updatedMessage.serial); + Assert.assertEquals(originalCreatedAt, updatedMessage.createdAt.toString()); + + Assert.assertEquals(updateResultVersion, updatedMessage.version); + Assert.assertEquals(updateResultTimestamp, String.valueOf(updatedMessage.timestamp)); + + // updatedMessage contains `operation` with fields as clientId, description, metadata, assert for these fields + Message.Operation operation = updatedMessage.operation; + Assert.assertEquals("clientId1", operation.clientId); + Assert.assertEquals("message updated by clientId1", operation.description); + Assert.assertEquals(2, operation.metadata.size()); + Assert.assertEquals("bar", operation.metadata.get("foo")); + Assert.assertEquals("hero", operation.metadata.get("naruto")); + + } catch (Exception e) { + e.printStackTrace(); + Assert.fail("Unexpected exception instantiating library"); + } finally { + if (ablyClient1 != null) ablyClient1.close(); + if (ablyClient2 != null) ablyClient2.close(); + } + } + + /** + * Test that a message deleted via rest API is sent to a messages channel. + * It should be received by another client that is subscribed to the same messages channel. + * Make sure to use two clientIds: clientId1 and clientId2 + */ + @Test + public void test_room_message_is_deleted() { + String roomId = "1234"; + String channelName = roomId + "::$chat::$chatMessages"; + AblyRealtime ablyClient1 = null; + AblyRealtime ablyClient2 = null; + try { + ClientOptions opts1 = createOptions(testVars.keys[7].keyStr); + opts1.clientId = "clientId1"; + ablyClient1 = new AblyRealtime(opts1); + + ClientOptions opts2 = createOptions(testVars.keys[7].keyStr); + opts2.clientId = "clientId2"; + ablyClient2 = new AblyRealtime(opts2); + + ChatRoom room = new ChatRoom(roomId, ablyClient1); + + // Create a channel and attach with client1 + final Channel channel1 = ablyClient1.channels.get(channelName); + channel1.attach(); + (new Helpers.ChannelWaiter(channel1)).waitFor(ChannelState.attached); + + // Subscribe to messages with client2 + final Channel channel2 = ablyClient2.channels.get(channelName); + channel2.attach(); + (new Helpers.ChannelWaiter(channel2)).waitFor(ChannelState.attached); + + List receivedMsg = new ArrayList<>(); + channel2.subscribe(receivedMsg::add); + + // Send message to room + ChatRoom.SendMessageParams params = new ChatRoom.SendMessageParams(); + params.text = "hello there"; + JsonObject sendMessageResult = (JsonObject) room.sendMessage(params); + String originalSerial = sendMessageResult.get("serial").getAsString(); + String originalCreatedAt = sendMessageResult.get("createdAt").getAsString(); + + // Wait for the message to be received + Exception err = new Helpers.ConditionalWaiter().wait(() -> !receivedMsg.isEmpty(), 10_000); + Assert.assertNull(err); + + // Delete the message + ChatRoom.DeleteMessageParams deleteParams = new ChatRoom.DeleteMessageParams(); + deleteParams.description = "message deleted by clientId1"; + Map deleteMetadata = new HashMap<>(); + deleteMetadata.put("foo", "bar"); + deleteMetadata.put("naruto", "hero"); + deleteParams.metadata = deleteMetadata; + + JsonObject deleteMessageResult = (JsonObject) room.deleteMessage(originalSerial, deleteParams); + String deleteResultVersion = deleteMessageResult.get("version").getAsString(); + String deleteResultTimestamp = deleteMessageResult.get("timestamp").getAsString(); + + // Wait for the deleted message to be received + err = new Helpers.ConditionalWaiter().wait(() -> receivedMsg.size() == 2, 10_000); + Assert.assertNull(err); + + // Verify the deleted message + Message deletedMessage = receivedMsg.get(1); + + Assert.assertEquals(MessageAction.MESSAGE_DELETE, deletedMessage.action); + + Assert.assertFalse("Message ID should not be empty", deletedMessage.id.isEmpty()); + Assert.assertEquals("chat.message", deletedMessage.name); + Assert.assertEquals("clientId1", deletedMessage.clientId); + + Assert.assertEquals(originalSerial, deletedMessage.serial); + Assert.assertEquals(originalCreatedAt, deletedMessage.createdAt.toString()); + + Assert.assertEquals(deleteResultVersion, deletedMessage.version); + Assert.assertEquals(deleteResultTimestamp, String.valueOf(deletedMessage.timestamp)); + + // deletedMessage contains `operation` with fields as clientId, reason + Message.Operation operation = deletedMessage.operation; + Assert.assertEquals("clientId1", operation.clientId); + Assert.assertEquals("message deleted by clientId1", operation.description); + // assert on metadata + Assert.assertEquals(2, operation.metadata.size()); + Assert.assertEquals("bar", operation.metadata.get("foo")); + Assert.assertEquals("hero", operation.metadata.get("naruto")); + + } catch (Exception e) { + e.printStackTrace(); + Assert.fail("Unexpected exception instantiating library"); + } finally { + if (ablyClient1 != null) ablyClient1.close(); + if (ablyClient2 != null) ablyClient2.close(); + } + } + + /** + * Test that message is created, updated and then deleted serially + */ + @Test + public void test_room_message_create_update_delete() { + String roomId = "1234"; + String channelName = roomId + "::$chat::$chatMessages"; + AblyRealtime ablyClient1 = null; + AblyRealtime ablyClient2 = null; + try { + ClientOptions opts1 = createOptions(testVars.keys[7].keyStr); + opts1.clientId = "clientId1"; + ablyClient1 = new AblyRealtime(opts1); + + ClientOptions opts2 = createOptions(testVars.keys[7].keyStr); + opts2.clientId = "clientId2"; + ablyClient2 = new AblyRealtime(opts2); + + ChatRoom room = new ChatRoom(roomId, ablyClient1); + + // Create a channel and attach with client1 + final Channel channel1 = ablyClient1.channels.get(channelName); + channel1.attach(); + (new Helpers.ChannelWaiter(channel1)).waitFor(ChannelState.attached); + + // Subscribe to messages with client2 + final Channel channel2 = ablyClient2.channels.get(channelName); + channel2.attach(); + (new Helpers.ChannelWaiter(channel2)).waitFor(ChannelState.attached); + + List receivedMsg = new ArrayList<>(); + channel2.subscribe(receivedMsg::add); + + // Send message to room + ChatRoom.SendMessageParams sendParams = new ChatRoom.SendMessageParams(); + sendParams.text = "hello there"; + + JsonObject sendMessageResult = (JsonObject) room.sendMessage(sendParams); + String originalSerial = sendMessageResult.get("serial").getAsString(); + String originalCreatedAt = sendMessageResult.get("createdAt").getAsString(); + + // Wait for the message to be received + Exception err = new Helpers.ConditionalWaiter().wait(() -> !receivedMsg.isEmpty(), 10_000); + Assert.assertNull(err); + + // Update the message + ChatRoom.UpdateMessageParams updateParams = new ChatRoom.UpdateMessageParams(); + updateParams.message = new ChatRoom.SendMessageParams(); + updateParams.message.text = "updated text"; + + JsonObject updateMessageResult = (JsonObject) room.updateMessage(originalSerial, updateParams); + String updateResultVersion = updateMessageResult.get("version").getAsString(); + String updateResultTimestamp = updateMessageResult.get("timestamp").getAsString(); + + // Wait for the updated message to be received + err = new Helpers.ConditionalWaiter().wait(() -> receivedMsg.size() == 2, 10_000); + Assert.assertNull(err); + + // Delete the message + ChatRoom.DeleteMessageParams deleteParams = new ChatRoom.DeleteMessageParams(); + deleteParams.description = "message deleted by clientId1"; + + JsonObject deleteMessageResult = (JsonObject) room.deleteMessage(originalSerial, deleteParams); + String deleteResultVersion = deleteMessageResult.get("version").getAsString(); + String deleteResultTimestamp = deleteMessageResult.get("timestamp").getAsString(); + + // Wait for the deleted message to be received + err = new Helpers.ConditionalWaiter().wait(() -> receivedMsg.size() == 3, 10_000); + Assert.assertNull(err); + + // Verify the created message + Message createdMessage = receivedMsg.get(0); + Assert.assertEquals(MessageAction.MESSAGE_CREATE, createdMessage.action); + Assert.assertFalse("Message ID should not be empty", createdMessage.id.isEmpty()); + Assert.assertEquals("chat.message", createdMessage.name); + Assert.assertEquals("clientId1", createdMessage.clientId); + JsonObject createdData = (JsonObject) createdMessage.data; + Assert.assertEquals("hello there", createdData.get("text").getAsString()); + + // Verify the updated message + Message updatedMessage = receivedMsg.get(1); + Assert.assertEquals(MessageAction.MESSAGE_UPDATE, updatedMessage.action); + Assert.assertFalse("Message ID should not be empty", updatedMessage.id.isEmpty()); + Assert.assertEquals("chat.message", updatedMessage.name); + Assert.assertEquals("clientId1", updatedMessage.clientId); + JsonObject updatedData = (JsonObject) updatedMessage.data; + Assert.assertEquals("updated text", updatedData.get("text").getAsString()); + + Assert.assertEquals(updateResultVersion, updatedMessage.version); + Assert.assertEquals(updateResultTimestamp, String.valueOf(updatedMessage.timestamp)); + + // Verify the deleted message + Message deletedMessage = receivedMsg.get(2); + Assert.assertEquals(MessageAction.MESSAGE_DELETE, deletedMessage.action); + Assert.assertFalse("Message ID should not be empty", deletedMessage.id.isEmpty()); + Assert.assertEquals("chat.message", deletedMessage.name); + Assert.assertEquals("clientId1", deletedMessage.clientId); + + Assert.assertEquals(deleteResultVersion, deletedMessage.version); + Assert.assertEquals(deleteResultTimestamp, String.valueOf(deletedMessage.timestamp)); + + // Check original serials + Assert.assertEquals(originalSerial, createdMessage.serial); + Assert.assertEquals(originalSerial, updatedMessage.serial); + Assert.assertEquals(originalSerial, deletedMessage.serial); + + // Check original message createdAt + Assert.assertEquals(originalCreatedAt, createdMessage.createdAt.toString()); + Assert.assertEquals(originalCreatedAt, updatedMessage.createdAt.toString()); + Assert.assertEquals(originalCreatedAt, deletedMessage.createdAt.toString()); + + } catch (Exception e) { + e.printStackTrace(); + Assert.fail("Unexpected exception instantiating library"); + } finally { + if (ablyClient1 != null) ablyClient1.close(); + if (ablyClient2 != null) ablyClient2.close(); + } + } + + /** + * Test that update/delete operations are allowed on a deleted message. + */ + @Test + public void test_operations_allowed_on_deleted_message() { + String roomId = "1234"; + String channelName = roomId + "::$chat::$chatMessages"; + AblyRealtime ablyClient1 = null; + AblyRealtime ablyClient2 = null; + try { + ClientOptions opts1 = createOptions(testVars.keys[7].keyStr); + opts1.clientId = "clientId1"; + ablyClient1 = new AblyRealtime(opts1); + + ClientOptions opts2 = createOptions(testVars.keys[7].keyStr); + opts2.clientId = "clientId2"; + ablyClient2 = new AblyRealtime(opts2); + + ChatRoom room = new ChatRoom(roomId, ablyClient1); + + // Create a channel and attach with client1 + final Channel channel1 = ablyClient1.channels.get(channelName); + channel1.attach(); + (new Helpers.ChannelWaiter(channel1)).waitFor(ChannelState.attached); + + // Subscribe to messages with client2 + final Channel channel2 = ablyClient2.channels.get(channelName); + channel2.attach(); + (new Helpers.ChannelWaiter(channel2)).waitFor(ChannelState.attached); + + List receivedMsg = new ArrayList<>(); + channel2.subscribe(receivedMsg::add); + + // Send message to room + ChatRoom.SendMessageParams sendParams = new ChatRoom.SendMessageParams(); + sendParams.text = "hello there"; + + JsonObject sendMessageResult = (JsonObject) room.sendMessage(sendParams); + String originalSerial = sendMessageResult.get("serial").getAsString(); + + // Wait for the message to be received + Exception err = new Helpers.ConditionalWaiter().wait(() -> !receivedMsg.isEmpty(), 10_000); + Assert.assertNull(err); + + // Delete the message + ChatRoom.DeleteMessageParams deleteParams = new ChatRoom.DeleteMessageParams(); + deleteParams.description = "message deleted by clientId1"; + + room.deleteMessage(originalSerial, deleteParams); + + // Wait for the deleted message to be received + err = new Helpers.ConditionalWaiter().wait(() -> receivedMsg.size() == 2, 10_000); + Assert.assertNull(err); + + // Attempt to update the deleted message + ChatRoom.UpdateMessageParams updateParams = new ChatRoom.UpdateMessageParams(); + updateParams.message = new ChatRoom.SendMessageParams(); + updateParams.message.text = "updated text"; + room.updateMessage(originalSerial, updateParams); + + // wait for updated message to be received + err = new Helpers.ConditionalWaiter().wait(() -> receivedMsg.size() == 3, 10_000); + Assert.assertNull(err); + + // Attempt to delete the already deleted message + room.deleteMessage(originalSerial, deleteParams); + // wait for delete message received + err = new Helpers.ConditionalWaiter().wait(() -> receivedMsg.size() == 4, 10_000); + Assert.assertNull(err); + + Assert.assertEquals(4, receivedMsg.size()); + for (Message msg : receivedMsg) { + Assert.assertEquals("Serial should match original serial", originalSerial, msg.serial); + } + + } catch (Exception e) { + e.printStackTrace(); + Assert.fail("Unexpected exception instantiating library"); + } finally { + if (ablyClient1 != null) ablyClient1.close(); + if (ablyClient2 != null) ablyClient2.close(); + } + } +} diff --git a/lib/src/test/java/io/ably/lib/chat/ChatRoom.java b/lib/src/test/java/io/ably/lib/chat/ChatRoom.java new file mode 100644 index 000000000..5c784a9c9 --- /dev/null +++ b/lib/src/test/java/io/ably/lib/chat/ChatRoom.java @@ -0,0 +1,65 @@ +package io.ably.lib.chat; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.ably.lib.http.HttpCore; +import io.ably.lib.http.HttpUtils; +import io.ably.lib.rest.AblyRest; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.HttpPaginatedResponse; +import io.ably.lib.types.Param; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; + +public class ChatRoom { + private final AblyRest ablyRest; + private final String roomId; + private final Gson gson = new Gson(); + + protected ChatRoom(String roomId, AblyRest ablyRest) { + this.roomId = roomId; + this.ablyRest = ablyRest; + } + + public JsonElement sendMessage(SendMessageParams params) throws Exception { + return makeAuthorizedRequest("/chat/v2/rooms/" + roomId + "/messages", "POST", gson.toJsonTree(params)) + .orElseThrow(() -> AblyException.fromErrorInfo(new ErrorInfo("Failed to send message", 500))); + } + + public JsonElement updateMessage(String serial, UpdateMessageParams params) throws Exception { + return makeAuthorizedRequest("/chat/v2/rooms/" + roomId + "/messages/" + serial, "PUT", gson.toJsonTree(params)) + .orElseThrow(() -> AblyException.fromErrorInfo(new ErrorInfo("Failed to update message", 500))); + } + + public JsonElement deleteMessage(String serial, DeleteMessageParams params) throws Exception { + return makeAuthorizedRequest("/chat/v2/rooms/" + roomId + "/messages/" + serial + "/delete", "POST", gson.toJsonTree(params)) + .orElseThrow(() -> AblyException.fromErrorInfo(new ErrorInfo("Failed to delete message", 500))); + } + + public static class SendMessageParams { + public String text; + public JsonObject metadata; + public Map headers; + } + + public static class UpdateMessageParams { + public SendMessageParams message; + public String description; + public Map metadata; + } + + public static class DeleteMessageParams { + public String description; + public Map metadata; + } + + protected Optional makeAuthorizedRequest(String url, String method, JsonElement body) throws AblyException { + HttpCore.RequestBody httpRequestBody = HttpUtils.requestBodyFromGson(body, ablyRest.options.useBinaryProtocol); + HttpPaginatedResponse response = ablyRest.request(method, url, new Param[] { new Param("v", 3) }, httpRequestBody, null); + return Arrays.stream(response.items()).findFirst(); + } +} diff --git a/lib/src/test/java/io/ably/lib/types/MessageTest.java b/lib/src/test/java/io/ably/lib/types/MessageTest.java index 1873aa7af..18dcf81d7 100644 --- a/lib/src/test/java/io/ably/lib/types/MessageTest.java +++ b/lib/src/test/java/io/ably/lib/types/MessageTest.java @@ -6,7 +6,13 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import io.ably.lib.types.Message.Serializer; +import io.ably.lib.util.Serialisation; import org.junit.Test; +import org.msgpack.core.MessagePacker; +import org.msgpack.core.MessageUnpacker; + +import java.io.ByteArrayOutputStream; +import java.util.HashMap; public class MessageTest { @@ -72,12 +78,12 @@ public void serialize_message_with_serial() { @Test public void deserialize_message_with_serial() throws Exception { // Given - JsonObject jsonObject = new JsonObject(); - jsonObject.addProperty("clientId", "test-client-id"); - jsonObject.addProperty("data", "test-data"); - jsonObject.addProperty("name", "test-name"); - jsonObject.addProperty("action", 0); - jsonObject.addProperty("serial", "01826232498871-001@abcdefghij:001"); + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("clientId", "test-client-id"); + jsonObject.addProperty("data", "test-data"); + jsonObject.addProperty("name", "test-name"); + jsonObject.addProperty("action", 0); + jsonObject.addProperty("serial", "01826232498871-001@abcdefghij:001"); // When Message message = Message.fromEncoded(jsonObject, new ChannelOptions()); @@ -90,6 +96,75 @@ public void deserialize_message_with_serial() throws Exception { assertEquals("01826232498871-001@abcdefghij:001", message.serial); } + @Test + public void serialize_message_with_operation() { + // Given + Message message = new Message("test-name", "test-data"); + message.clientId = "test-client-id"; + message.connectionKey = "test-key"; + message.refSerial = "test-ref-serial"; + message.refType = "test-ref-type"; + Message.Operation operation = new Message.Operation(); + operation.clientId = "operation-client-id"; + operation.description = "operation-description"; + operation.metadata = new HashMap<>(); + operation.metadata.put("key1", "value1"); + operation.metadata.put("key2", "value2"); + message.operation = operation; + + // When + JsonElement serializedElement = serializer.serialize(message, null, null); + + // Then + JsonObject serializedObject = serializedElement.getAsJsonObject(); + assertEquals("test-client-id", serializedObject.get("clientId").getAsString()); + assertEquals("test-key", serializedObject.get("connectionKey").getAsString()); + assertEquals("test-data", serializedObject.get("data").getAsString()); + assertEquals("test-name", serializedObject.get("name").getAsString()); + assertEquals("test-ref-serial", serializedObject.get("refSerial").getAsString()); + assertEquals("test-ref-type", serializedObject.get("refType").getAsString()); + JsonObject operationObject = serializedObject.getAsJsonObject("operation"); + assertEquals("operation-client-id", operationObject.get("clientId").getAsString()); + assertEquals("operation-description", operationObject.get("description").getAsString()); + JsonObject metadataObject = operationObject.getAsJsonObject("metadata"); + assertEquals("value1", metadataObject.get("key1").getAsString()); + assertEquals("value2", metadataObject.get("key2").getAsString()); + } + + @Test + public void deserialize_message_with_operation() throws Exception { + // Given + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("clientId", "test-client-id"); + jsonObject.addProperty("data", "test-data"); + jsonObject.addProperty("name", "test-name"); + jsonObject.addProperty("refSerial", "test-ref-serial"); + jsonObject.addProperty("refType", "test-ref-type"); + jsonObject.addProperty("connectionKey", "test-key"); + JsonObject operationObject = new JsonObject(); + operationObject.addProperty("clientId", "operation-client-id"); + operationObject.addProperty("description", "operation-description"); + JsonObject metadataObject = new JsonObject(); + metadataObject.addProperty("key1", "value1"); + metadataObject.addProperty("key2", "value2"); + operationObject.add("metadata", metadataObject); + jsonObject.add("operation", operationObject); + + // When + Message message = Message.fromEncoded(jsonObject, new ChannelOptions()); + + // Then + assertEquals("test-client-id", message.clientId); + assertEquals("test-data", message.data); + assertEquals("test-name", message.name); + assertEquals("test-ref-serial", message.refSerial); + assertEquals("test-ref-type", message.refType); + assertEquals("test-key", message.connectionKey); + assertEquals("operation-client-id", message.operation.clientId); + assertEquals("operation-description", message.operation.description); + assertEquals("value1", message.operation.metadata.get("key1")); + assertEquals("value2", message.operation.metadata.get("key2")); + } @Test public void deserialize_message_with_unknown_action() throws Exception { @@ -111,4 +186,48 @@ public void deserialize_message_with_unknown_action() throws Exception { assertNull(message.action); assertEquals("01826232498871-001@abcdefghij:001", message.serial); } + + @Test + public void serialize_and_deserialize_with_msgpack() throws Exception { + // Given + Message message = new Message("test-name", "test-data"); + message.clientId = "test-client-id"; + message.connectionKey = "test-key"; + message.refSerial = "test-ref-serial"; + message.refType = "test-ref-type"; + message.action = MessageAction.MESSAGE_CREATE; + message.serial = "01826232498871-001@abcdefghij:001"; + Message.Operation operation = new Message.Operation(); + operation.clientId = "operation-client-id"; + operation.description = "operation-description"; + operation.metadata = new HashMap<>(); + operation.metadata.put("key1", "value1"); + operation.metadata.put("key2", "value2"); + message.operation = operation; + + // When Encode to MessagePack + ByteArrayOutputStream out = new ByteArrayOutputStream(); + MessagePacker packer = Serialisation.msgpackPackerConfig.newPacker(out); + message.writeMsgpack(packer); + packer.close(); + + // Decode from MessagePack + MessageUnpacker unpacker = Serialisation.msgpackUnpackerConfig.newUnpacker(out.toByteArray()); + Message unpacked = Message.fromMsgpack(unpacker); + unpacker.close(); + + // Then + assertEquals("test-client-id", unpacked.clientId); + assertEquals("test-key", unpacked.connectionKey); + assertEquals("test-data", unpacked.data); + assertEquals("test-name", unpacked.name); + assertEquals("test-ref-serial", unpacked.refSerial); + assertEquals("test-ref-type", unpacked.refType); + assertEquals(MessageAction.MESSAGE_CREATE, unpacked.action); + assertEquals("01826232498871-001@abcdefghij:001", unpacked.serial); + assertEquals("operation-client-id", unpacked.operation.clientId); + assertEquals("operation-description", unpacked.operation.description); + assertEquals("value1", unpacked.operation.metadata.get("key1")); + assertEquals("value2", unpacked.operation.metadata.get("key2")); + } }