diff --git a/java/src/main/java/io/ably/lib/realtime/Channel.java b/java/src/main/java/io/ably/lib/realtime/Channel.java index 539d08adf..e3f21de54 100644 --- a/java/src/main/java/io/ably/lib/realtime/Channel.java +++ b/java/src/main/java/io/ably/lib/realtime/Channel.java @@ -1,6 +1,6 @@ package io.ably.lib.realtime; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.object.LiveObjectsPlugin; import io.ably.lib.types.AblyException; import io.ably.lib.types.ChannelOptions; import org.jetbrains.annotations.Nullable; diff --git a/lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java b/lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java index 20d78fc1f..4c92d7692 100644 --- a/lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java +++ b/lib/src/main/java/io/ably/lib/object/LiveObjectsPlugin.java @@ -2,7 +2,6 @@ import io.ably.lib.object.adapter.AblyClientAdapter; import io.ably.lib.object.adapter.Adapter; -import io.ably.lib.objects.RealtimeObjects; import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.ChannelState; import io.ably.lib.types.ProtocolMessage; @@ -28,7 +27,7 @@ public interface LiveObjectsPlugin { * @return the RealtimeObjects instance associated with the specified channel name. */ @NotNull - RealtimeObjects getInstance(@NotNull String channelName); + RealtimeObject getInstance(@NotNull String channelName); /** * Handles a protocol message. diff --git a/lib/src/main/java/io/ably/lib/objects/Adapter.java b/lib/src/main/java/io/ably/lib/objects/Adapter.java deleted file mode 100644 index 76c35cc37..000000000 --- a/lib/src/main/java/io/ably/lib/objects/Adapter.java +++ /dev/null @@ -1,45 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.realtime.AblyRealtime; -import io.ably.lib.realtime.ChannelBase; -import io.ably.lib.realtime.Connection; -import io.ably.lib.types.AblyException; -import io.ably.lib.types.ClientOptions; -import io.ably.lib.types.ErrorInfo; -import io.ably.lib.util.Log; -import org.jetbrains.annotations.NotNull; - -public class Adapter implements ObjectsAdapter { - private final AblyRealtime ably; - private static final String TAG = ObjectsAdapter.class.getName(); - - public Adapter(@NotNull AblyRealtime ably) { - this.ably = ably; - } - - @Override - public @NotNull ClientOptions getClientOptions() { - return ably.options; - } - - @Override - public @NotNull Connection getConnection() { - return ably.connection; - } - - @Override - public long getTime() throws AblyException { - return ably.time(); - } - - @Override - public @NotNull ChannelBase getChannel(@NotNull String channelName) throws AblyException { - if (ably.channels.containsKey(channelName)) { - return ably.channels.get(channelName); - } else { - Log.e(TAG, "attachChannel(): channel not found: " + channelName); - ErrorInfo errorInfo = new ErrorInfo("Channel not found: " + channelName, 404); - throw AblyException.fromErrorInfo(errorInfo); - } - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java b/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java deleted file mode 100644 index 1f34cafdd..000000000 --- a/lib/src/main/java/io/ably/lib/objects/LiveObjectsPlugin.java +++ /dev/null @@ -1,60 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.realtime.ChannelState; -import io.ably.lib.types.ProtocolMessage; -import org.jetbrains.annotations.NotNull; - -/** - * The LiveObjectsPlugin interface provides a mechanism for managing and interacting with - * live data objects in a real-time environment. It allows for the retrieval, disposal, and - * management of Objects instances associated with specific channel names. - */ -public interface LiveObjectsPlugin { - - /** - * Retrieves an instance of RealtimeObjects associated with the specified channel name. - * This method ensures that a RealtimeObjects instance is available for the given channel, - * creating one if it does not already exist. - * - * @param channelName the name of the channel for which the RealtimeObjects instance is to be retrieved. - * @return the RealtimeObjects instance associated with the specified channel name. - */ - @NotNull - RealtimeObjects getInstance(@NotNull String channelName); - - /** - * Handles a protocol message. - * This method is invoked whenever a protocol message is received, allowing the implementation - * to process the message and take appropriate actions. - * - * @param message the protocol message to handle. - */ - void handle(@NotNull ProtocolMessage message); - - /** - * Handles state changes for a specific channel. - * This method is invoked whenever a channel's state changes, allowing the implementation - * to update the RealtimeObjects instances accordingly based on the new state and presence of objects. - * - * @param channelName the name of the channel whose state has changed. - * @param state the new state of the channel. - * @param hasObjects flag indicates whether the channel has any associated objects. - */ - void handleStateChange(@NotNull String channelName, @NotNull ChannelState state, boolean hasObjects); - - /** - * Disposes of the RealtimeObjects instance associated with the specified channel name. - * This method removes the RealtimeObjects instance for the given channel, releasing any - * resources associated with it. - * This is invoked when ablyRealtimeClient.channels.release(channelName) is called - * - * @param channelName the name of the channel whose RealtimeObjects instance is to be removed. - */ - void dispose(@NotNull String channelName); - - /** - * Disposes of the plugin instance and all underlying resources. - * This is invoked when ablyRealtimeClient.close() is called - */ - void dispose(); -} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsAdapter.java b/lib/src/main/java/io/ably/lib/objects/ObjectsAdapter.java deleted file mode 100644 index b6054e71a..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsAdapter.java +++ /dev/null @@ -1,46 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.realtime.ChannelBase; -import io.ably.lib.realtime.Connection; -import io.ably.lib.types.AblyException; -import io.ably.lib.types.ClientOptions; -import org.jetbrains.annotations.Blocking; -import org.jetbrains.annotations.NotNull; - -public interface ObjectsAdapter { - /** - * Retrieves the client options configured for the Ably client. - * Used to access client configuration parameters such as echoMessages setting - * that affect the behavior of Objects operations. - * - * @return the client options containing configuration parameters - */ - @NotNull ClientOptions getClientOptions(); - - /** - * Retrieves the connection instance for handling connection state and operations. - * Used to check connection status, obtain error information, and manage - * message transmission across the Ably connection. - * - * @return the connection instance - */ - @NotNull Connection getConnection(); - - /** - * Retrieves the current time in milliseconds from the Ably server. - * Spec: RTO16 - */ - @Blocking - long getTime() throws AblyException; - - /** - * Retrieves the channel instance for the specified channel name. - * If the channel does not exist, an AblyException is thrown. - * - * @param channelName the name of the channel to retrieve - * @return the ChannelBase instance for the specified channel - * @throws AblyException if the channel is not found or cannot be retrieved - */ - @NotNull ChannelBase getChannel(@NotNull String channelName) throws AblyException; -} - diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java b/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java deleted file mode 100644 index 0afd5ef2f..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsCallback.java +++ /dev/null @@ -1,31 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.types.AblyException; - -/** - * Callback interface for handling results of asynchronous Objects operations. - * Used for operations like creating LiveMaps/LiveCounters, modifying entries, and retrieving objects. - * Callbacks are executed on background threads managed by the Objects system. - * - * @param the type of the result returned by the asynchronous operation - */ -public interface ObjectsCallback { - - /** - * Called when the asynchronous operation completes successfully. - * For modification operations (set, remove, increment), result is typically Void. - * For creation/retrieval operations, result contains the created/retrieved object. - * - * @param result the result of the operation, may be null for modification operations - */ - void onSuccess(T result); - - /** - * Called when the asynchronous operation fails. - * The exception contains detailed error information including error codes and messages. - * Common errors include network issues, authentication failures, and validation errors. - * - * @param exception the exception that occurred during the operation - */ - void onError(AblyException exception); -} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java b/lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java deleted file mode 100644 index 81e7f3c08..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsHelper.java +++ /dev/null @@ -1,48 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.realtime.AblyRealtime; -import io.ably.lib.util.Log; -import org.jetbrains.annotations.Nullable; - -import java.lang.reflect.InvocationTargetException; - -public class ObjectsHelper { - - private static final String TAG = ObjectsHelper.class.getName(); - private static volatile ObjectsSerializer objectsSerializer; - - @Nullable - public static LiveObjectsPlugin tryInitializeObjectsPlugin(AblyRealtime ablyRealtime) { - try { - Class objectsImplementation = Class.forName("io.ably.lib.objects.DefaultLiveObjectsPlugin"); - ObjectsAdapter adapter = new Adapter(ablyRealtime); - return (LiveObjectsPlugin) objectsImplementation - .getDeclaredConstructor(ObjectsAdapter.class) - .newInstance(adapter); - } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | NoSuchMethodException | - InvocationTargetException e) { - Log.i(TAG, "LiveObjects plugin not found in classpath. LiveObjects functionality will not be available.", e); - return null; - } - } - - @Nullable - public static ObjectsSerializer getSerializer() { - if (objectsSerializer == null) { - synchronized (ObjectsHelper.class) { - if (objectsSerializer == null) { // Double-Checked Locking (DCL) - try { - Class serializerClass = Class.forName("io.ably.lib.objects.serialization.DefaultObjectsSerializer"); - objectsSerializer = (ObjectsSerializer) serializerClass.getDeclaredConstructor().newInstance(); - } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | - NoSuchMethodException | - InvocationTargetException e) { - Log.w(TAG, "Failed to init ObjectsSerializer, LiveObjects plugin not included in the classpath", e); - return null; - } - } - } - } - return objectsSerializer; - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java b/lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java deleted file mode 100644 index b96954ca8..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsJsonSerializer.java +++ /dev/null @@ -1,39 +0,0 @@ -package io.ably.lib.objects; - -import com.google.gson.JsonDeserializationContext; -import com.google.gson.JsonDeserializer; -import com.google.gson.JsonElement; -import com.google.gson.JsonNull; -import com.google.gson.JsonParseException; -import com.google.gson.JsonSerializationContext; -import com.google.gson.JsonSerializer; -import io.ably.lib.util.Log; - -import java.lang.reflect.Type; - -public class ObjectsJsonSerializer implements JsonSerializer, JsonDeserializer { - private static final String TAG = ObjectsJsonSerializer.class.getName(); - - @Override - public Object[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { - ObjectsSerializer serializer = ObjectsHelper.getSerializer(); - if (serializer == null) { - Log.w(TAG, "Skipping 'state' field json deserialization because ObjectsSerializer not found."); - return null; - } - if (!json.isJsonArray()) { - throw new JsonParseException("Expected a JSON array for 'state' field, but got: " + json); - } - return serializer.readFromJsonArray(json.getAsJsonArray()); - } - - @Override - public JsonElement serialize(Object[] src, Type typeOfSrc, JsonSerializationContext context) { - ObjectsSerializer serializer = ObjectsHelper.getSerializer(); - if (serializer == null) { - Log.w(TAG, "Skipping 'state' field json serialization because ObjectsSerializer not found."); - return JsonNull.INSTANCE; - } - return serializer.asJsonArray(src); - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java b/lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java deleted file mode 100644 index 9bee9a8fd..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsSerializer.java +++ /dev/null @@ -1,50 +0,0 @@ -package io.ably.lib.objects; - -import com.google.gson.JsonArray; -import org.jetbrains.annotations.NotNull; -import org.msgpack.core.MessagePacker; -import org.msgpack.core.MessageUnpacker; - -import java.io.IOException; - -/** - * Serializer interface for converting between objects and their MessagePack or JSON representations. - */ -public interface ObjectsSerializer { - /** - * Reads a MessagePack array from the given unpacker and deserializes it into an Object array. - * - * @param unpacker the MessageUnpacker to read from - * @return the deserialized Object array - * @throws IOException if an I/O error occurs during unpacking - */ - @NotNull - Object[] readMsgpackArray(@NotNull MessageUnpacker unpacker) throws IOException; - - /** - * Serializes the given Object array as a MessagePack array using the provided packer. - * - * @param objects the Object array to serialize - * @param packer the MessagePacker to write to - * @throws IOException if an I/O error occurs during packing - */ - void writeMsgpackArray(@NotNull Object[] objects, @NotNull MessagePacker packer) throws IOException; - - /** - * Reads a JSON array from the given {@link JsonArray} and deserializes it into an Object array. - * - * @param json the {@link JsonArray} representing the array to deserialize - * @return the deserialized Object array - */ - @NotNull - Object[] readFromJsonArray(@NotNull JsonArray json); - - /** - * Serializes the given Object array as a JSON array. - * - * @param objects the Object array to serialize - * @return the resulting JsonArray - */ - @NotNull - JsonArray asJsonArray(@NotNull Object[] objects); -} diff --git a/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java b/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java deleted file mode 100644 index 2b22d71d4..000000000 --- a/lib/src/main/java/io/ably/lib/objects/ObjectsSubscription.java +++ /dev/null @@ -1,24 +0,0 @@ -package io.ably.lib.objects; - -/** - * Represents a objects subscription that can be unsubscribed from. - * This interface provides a way to clean up and remove subscriptions when they are no longer needed. - * Example usage: - *
- * {@code
- * ObjectsSubscription s = objects.subscribe(ObjectsStateEvent.SYNCING, new ObjectsStateListener() {});
- * // Later when done with the subscription
- * s.unsubscribe();
- * }
- * 
- * Spec: RTLO4b5 - */ -public interface ObjectsSubscription { - /** - * This method should be called when the subscription is no longer needed, - * it will make sure no further events will be sent to the subscriber and - * that references to the subscriber are cleaned up. - * Spec: RTLO4b5a - */ - void unsubscribe(); -} diff --git a/lib/src/main/java/io/ably/lib/objects/RealtimeObjects.java b/lib/src/main/java/io/ably/lib/objects/RealtimeObjects.java deleted file mode 100644 index 6e111b304..000000000 --- a/lib/src/main/java/io/ably/lib/objects/RealtimeObjects.java +++ /dev/null @@ -1,166 +0,0 @@ -package io.ably.lib.objects; - -import io.ably.lib.objects.state.ObjectsStateChange; -import io.ably.lib.objects.type.counter.LiveCounter; -import io.ably.lib.objects.type.map.LiveMap; -import io.ably.lib.objects.type.map.LiveMapValue; -import org.jetbrains.annotations.Blocking; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.NotNull; - -import java.util.Map; - -/** - * The RealtimeObjects interface provides methods to interact with live data objects, - * such as maps and counters, in a real-time environment. It supports both synchronous - * and asynchronous operations for retrieving and creating objects. - * - *

Implementations of this interface must be thread-safe as they may be accessed - * from multiple threads concurrently. - */ -public interface RealtimeObjects extends ObjectsStateChange { - - /** - * Retrieves the root LiveMap object. - * When called without a type variable, we return a default root type which is based on globally defined interface for Objects feature. - * A user can provide an explicit type for the getRoot method to explicitly set the type structure on this particular channel. - * This is useful when working with multiple channels with different underlying data structure. - * - * @return the root LiveMap instance. - */ - @Blocking - @NotNull - LiveMap getRoot(); - - /** - * Creates a new empty LiveMap with no entries. - * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * and returns it. - * - * @return the newly created empty LiveMap instance. - */ - @Blocking - @NotNull - LiveMap createMap(); - - /** - * Creates a new LiveMap with type-safe entries that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. - * Implements spec RTO11 : createMap(Dict entries?) - * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * using the provided data and returns it. - * - *

Example:

- *
{@code
-     * Map entries = Map.of(
-     *     "string", LiveMapValue.of("Hello"),
-     *     "number", LiveMapValue.of(42),
-     *     "boolean", LiveMapValue.of(true),
-     *     "binary", LiveMapValue.of(new byte[]{1, 2, 3}),
-     *     "array", LiveMapValue.of(new JsonArray()),
-     *     "object", LiveMapValue.of(new JsonObject()),
-     *     "counter", LiveMapValue.of(realtimeObjects.createCounter()),
-     *     "nested", LiveMapValue.of(realtimeObjects.createMap())
-     * );
-     * LiveMap map = realtimeObjects.createMap(entries);
-     * }
- * - * @param entries the type-safe map entries with values that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. - * @return the newly created LiveMap instance. - */ - @Blocking - @NotNull - LiveMap createMap(@NotNull Map entries); - - /** - * Creates a new LiveCounter with an initial value of 0. - * Send a COUNTER_CREATE operation to the realtime system to create a new counter object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * using the provided data and returns it. - * - * @return the newly created LiveCounter instance with initial value of 0. - */ - @Blocking - @NotNull - LiveCounter createCounter(); - - /** - * Creates a new LiveCounter with an initial value. - * Send a COUNTER_CREATE operation to the realtime system to create a new counter object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * using the provided data and returns it. - * - * @param initialValue the initial value of the LiveCounter. - * @return the newly created LiveCounter instance. - */ - @Blocking - @NotNull - LiveCounter createCounter(@NotNull Number initialValue); - - /** - * Asynchronously retrieves the root LiveMap object. - * When called without a type variable, we return a default root type which is based on globally defined interface for Objects feature. - * A user can provide an explicit type for the getRoot method to explicitly set the type structure on this particular channel. - * This is useful when working with multiple channels with different underlying data structure. - * - * @param callback the callback to handle the result or error. - */ - @NonBlocking - void getRootAsync(@NotNull ObjectsCallback<@NotNull LiveMap> callback); - - /** - * Asynchronously creates a new empty LiveMap with no entries. - * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * and returns it. - * - * @param callback the callback to handle the result or error. - */ - @NonBlocking - void createMapAsync(@NotNull ObjectsCallback<@NotNull LiveMap> callback); - - /** - * Asynchronously creates a new LiveMap with type-safe entries that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. - * This method implements the spec RTO11 signature: createMap(Dict entries?) - * Send a MAP_CREATE operation to the realtime system to create a new map object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed MAP_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * using the provided data and returns it. - * - * @param entries the type-safe map entries with values that can be Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounter, or LiveMap. - * @param callback the callback to handle the result or error. - */ - @NonBlocking - void createMapAsync(@NotNull Map entries, @NotNull ObjectsCallback<@NotNull LiveMap> callback); - - /** - * Asynchronously creates a new LiveCounter with an initial value of 0. - * Send a COUNTER_CREATE operation to the realtime system to create a new counter object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * using the provided data and returns it. - * - * @param callback the callback to handle the result or error. - */ - @NonBlocking - void createCounterAsync(@NotNull ObjectsCallback<@NotNull LiveCounter> callback); - - /** - * Asynchronously creates a new LiveCounter with an initial value. - * Send a COUNTER_CREATE operation to the realtime system to create a new counter object in the pool. - * Once the ACK message is received, the method returns the object from the local pool if it got created due to - * the echoed COUNTER_CREATE operation, or if it wasn't received yet, the method creates a new object locally - * using the provided data and returns it. - * - * @param initialValue the initial value of the LiveCounter. - * @param callback the callback to handle the result or error. - */ - @NonBlocking - void createCounterAsync(@NotNull Number initialValue, @NotNull ObjectsCallback<@NotNull LiveCounter> callback); -} diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java deleted file mode 100644 index 180645f3c..000000000 --- a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateChange.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.ably.lib.objects.state; - -import io.ably.lib.objects.ObjectsSubscription; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.NotNull; - -public interface ObjectsStateChange { - /** - * Subscribes to a specific Objects synchronization state event. - * - *

This method registers the provided listener to be notified when the specified - * synchronization state event occurs. The returned subscription can be used to - * unsubscribe later when the notifications are no longer needed. - * - * @param event the synchronization state event to subscribe to (SYNCING or SYNCED) - * @param listener the listener that will be called when the event occurs - * @return a subscription object that can be used to unsubscribe from the event - */ - @NonBlocking - ObjectsSubscription on(@NotNull ObjectsStateEvent event, @NotNull ObjectsStateChange.Listener listener); - - /** - * Unsubscribes the specified listener from all synchronization state events. - * - *

After calling this method, the provided listener will no longer receive - * any synchronization state event notifications. - * - * @param listener the listener to unregister from all events - */ - @NonBlocking - void off(@NotNull ObjectsStateChange.Listener listener); - - /** - * Unsubscribes all listeners from all synchronization state events. - * - *

After calling this method, no listeners will receive any synchronization - * state event notifications until new listeners are registered. - */ - @NonBlocking - void offAll(); - - /** - * Interface for receiving notifications about Objects synchronization state changes. - *

- * Implement this interface and register it with an ObjectsStateEmitter to be notified - * when synchronization state transitions occur. - */ - interface Listener { - /** - * Called when the synchronization state changes. - * - * @param objectsStateEvent The new state event (SYNCING or SYNCED) - */ - void onStateChanged(ObjectsStateEvent objectsStateEvent); - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java b/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java deleted file mode 100644 index 1aa27203a..000000000 --- a/lib/src/main/java/io/ably/lib/objects/state/ObjectsStateEvent.java +++ /dev/null @@ -1,19 +0,0 @@ -package io.ably.lib.objects.state; - -/** - * Represents the synchronization state of Ably Objects. - *

- * This enum is used to notify listeners about state changes in the synchronization process. - * Clients can register an {@link ObjectsStateChange.Listener} to receive these events. - */ -public enum ObjectsStateEvent { - /** - * Indicates that synchronization between local and remote objects is in progress. - */ - SYNCING, - - /** - * Indicates that synchronization has completed successfully and objects are in sync. - */ - SYNCED -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleChange.java b/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleChange.java deleted file mode 100644 index c8d0f5745..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleChange.java +++ /dev/null @@ -1,69 +0,0 @@ -package io.ably.lib.objects.type; - -import io.ably.lib.objects.ObjectsSubscription; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.NotNull; - -/** - * Interface for managing subscriptions to Object lifecycle events. - *

- * This interface provides methods to subscribe to and manage notifications about significant lifecycle - * changes that occur to Object, such as deletion. More events can be added in the future. - * Multiple listeners can be registered independently, and each can be managed separately. - *

- * Lifecycle events are different from data update events - they represent changes - * to the object's existence state rather than changes to the object's data content. - * - * @see ObjectLifecycleEvent for the available lifecycle events - */ -public interface ObjectLifecycleChange { - /** - * Subscribes to a specific Object lifecycle event. - * - *

This method registers the provided listener to be notified when the specified - * lifecycle event occurs. The returned subscription can be used to - * unsubscribe later when the notifications are no longer needed. - * - * @param event the lifecycle event to subscribe to - * @param listener the listener that will be called when the event occurs - * @return a subscription object that can be used to unsubscribe from the event - */ - @NonBlocking - ObjectsSubscription on(@NotNull ObjectLifecycleEvent event, @NotNull ObjectLifecycleChange.Listener listener); - - /** - * Unsubscribes the specified listener from all lifecycle events. - * - *

After calling this method, the provided listener will no longer receive - * any lifecycle event notifications. - * - * @param listener the listener to unregister from all events - */ - @NonBlocking - void off(@NotNull ObjectLifecycleChange.Listener listener); - - /** - * Unsubscribes all listeners from all lifecycle events. - * - *

After calling this method, no listeners will receive any lifecycle - * event notifications until new listeners are registered. - */ - @NonBlocking - void offAll(); - - /** - * Interface for receiving notifications about Object lifecycle changes. - *

- * Implement this interface and register it with an ObjectLifecycleChange provider - * to be notified when lifecycle events occur, such as object creation or deletion. - */ - @FunctionalInterface - interface Listener { - /** - * Called when a lifecycle event occurs. - * - * @param lifecycleEvent The lifecycle event that occurred - */ - void onLifecycleEvent(@NotNull ObjectLifecycleEvent lifecycleEvent); - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleEvent.java b/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleEvent.java deleted file mode 100644 index 7a2d1aa7d..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/ObjectLifecycleEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package io.ably.lib.objects.type; - -/** - * Represents lifecycle events for an Ably Object. - *

- * This enum notifies listeners about significant lifecycle changes that occur to an Object during its lifetime. - * Clients can register a {@link ObjectLifecycleChange.Listener} to receive these events. - */ -public enum ObjectLifecycleEvent { - /** - * Indicates that an Object has been deleted (tombstoned). - * Emitted once when the object is tombstoned server-side (i.e., deleted and no longer addressable). - * Not re-emitted during client-side garbage collection of tombstones. - */ - DELETED -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java b/lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java deleted file mode 100644 index 8ee1e1578..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/ObjectUpdate.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.ably.lib.objects.type; - -import org.jetbrains.annotations.Nullable; - -/** - * Abstract base class for all LiveMap/LiveCounter update notifications. - * Provides common structure for updates that occur on LiveMap and LiveCounter objects. - * Contains the update data that describes what changed in the object. - * Spec: RTLO4b4 - */ -public abstract class ObjectUpdate { - /** - * The update data containing details about the change that occurred - * Spec: RTLO4b4a - */ - @Nullable - protected final Object update; - - /** - * Creates a ObjectUpdate with the specified update data. - * - * @param update the data describing the change, or null for no-op updates - */ - protected ObjectUpdate(@Nullable Object update) { - this.update = update; - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java deleted file mode 100644 index 958cf05b1..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounter.java +++ /dev/null @@ -1,73 +0,0 @@ -package io.ably.lib.objects.type.counter; - -import io.ably.lib.objects.ObjectsCallback; -import io.ably.lib.objects.type.ObjectLifecycleChange; -import org.jetbrains.annotations.Blocking; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Contract; - -/** - * The LiveCounter interface provides methods to interact with a live counter. - * It allows incrementing, decrementing, and retrieving the current value of the counter, - * both synchronously and asynchronously. - */ -public interface LiveCounter extends LiveCounterChange, ObjectLifecycleChange { - - /** - * Increments the value of the counter by the specified amount. - * Send a COUNTER_INC operation to the realtime system to increment a value on this LiveCounter object. - * This does not modify the underlying data of this LiveCounter object. Instead, the change will be applied when - * the published COUNTER_INC operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLC12 - * - * @param amount the amount by which to increment the counter - */ - @Blocking - void increment(@NotNull Number amount); - - /** - * Decrements the value of the counter by the specified amount. - * An alias for calling {@link LiveCounter#increment(Number)} with a negative amount. - * Spec: RTLC13 - * - * @param amount the amount by which to decrement the counter - */ - @Blocking - void decrement(@NotNull Number amount); - - /** - * Increments the value of the counter by the specified amount asynchronously. - * Send a COUNTER_INC operation to the realtime system to increment a value on this LiveCounter object. - * This does not modify the underlying data of this LiveCounter object. Instead, the change will be applied when - * the published COUNTER_INC operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLC12 - * - * @param amount the amount by which to increment the counter - * @param callback the callback to be invoked upon completion of the operation. - */ - @NonBlocking - void incrementAsync(@NotNull Number amount, @NotNull ObjectsCallback callback); - - /** - * Decrements the value of the counter by the specified amount asynchronously. - * An alias for calling {@link LiveCounter#incrementAsync(Number, ObjectsCallback)} with a negative amount. - * Spec: RTLC13 - * - * @param amount the amount by which to decrement the counter - * @param callback the callback to be invoked upon completion of the operation. - */ - @NonBlocking - void decrementAsync(@NotNull Number amount, @NotNull ObjectsCallback callback); - - /** - * Retrieves the current value of the counter. - * - * @return the current value of the counter as a Double. - */ - @NotNull - @Contract(pure = true) // Indicates this method does not modify the state of the object. - Double value(); -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java deleted file mode 100644 index 79f842e74..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterChange.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.ably.lib.objects.type.counter; - -import io.ably.lib.objects.ObjectsSubscription; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.NotNull; - -/** - * Provides methods to subscribe to real-time updates on LiveCounter objects. - * Enables clients to receive notifications when counter values change due to - * operations performed by any client connected to the same channel. - */ -public interface LiveCounterChange { - - /** - * Subscribes to real-time updates on this LiveCounter object. - * Multiple listeners can be subscribed to the same object independently. - * Spec: RTLO4b - * - * @param listener the listener to be notified of counter updates - * @return an ObjectsSubscription for managing this specific listener - */ - @NonBlocking - @NotNull ObjectsSubscription subscribe(@NotNull Listener listener); - - /** - * Unsubscribes a specific listener from receiving updates. - * Has no effect if the listener is not currently subscribed. - * Spec: RTLO4c - * - * @param listener the listener to be unsubscribed - */ - @NonBlocking - void unsubscribe(@NotNull Listener listener); - - /** - * Unsubscribes all listeners from receiving updates. - * No notifications will be delivered until new listeners are subscribed. - * Spec: RTLO4d - */ - @NonBlocking - void unsubscribeAll(); - - /** - * Listener interface for receiving LiveCounter updates. - * Spec: RTLO4b3 - */ - interface Listener { - /** - * Called when the LiveCounter has been updated. - * Should execute quickly as it's called from the real-time processing thread. - * - * @param update details about the counter change - */ - void onUpdated(@NotNull LiveCounterUpdate update); - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java b/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java deleted file mode 100644 index d7921a0b5..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/counter/LiveCounterUpdate.java +++ /dev/null @@ -1,80 +0,0 @@ -package io.ably.lib.objects.type.counter; - -import io.ably.lib.objects.type.ObjectUpdate; -import org.jetbrains.annotations.NotNull; - -/** - * Represents an update that occurred on a LiveCounter object. - * Contains information about counter value changes from increment/decrement operations. - * Updates can represent positive changes (increments) or negative changes (decrements). - * - * @spec RTLC11, RTLC11a - LiveCounter update structure and behavior - */ -public class LiveCounterUpdate extends ObjectUpdate { - - /** - * Creates a no-op LiveCounterUpdate representing no actual change. - */ - public LiveCounterUpdate() { - super(null); - } - - /** - * Creates a LiveCounterUpdate with the specified amount change. - * - * @param amount the amount by which the counter changed (positive = increment, negative = decrement) - */ - public LiveCounterUpdate(@NotNull Double amount) { - super(new Update(amount)); - } - - /** - * Gets the update information containing the amount of change. - * - * @return the Update object with the counter modification amount - */ - @NotNull - public LiveCounterUpdate.Update getUpdate() { - return (Update) update; - } - - /** - * Returns a string representation of this LiveCounterUpdate. - * - * @return a string showing the amount of change to the counter - */ - @Override - public String toString() { - if (update == null) { - return "LiveCounterUpdate{no change}"; - } - return "LiveCounterUpdate{amount=" + getUpdate().getAmount() + "}"; - } - - /** - * Contains the specific details of a counter update operation. - * - * @spec RTLC11b, RTLC11b1 - Counter update data structure - */ - public static class Update { - private final @NotNull Double amount; - - /** - * Creates an Update with the specified amount. - * - * @param amount the counter change amount (positive = increment, negative = decrement) - */ - public Update(@NotNull Double amount) { - this.amount = amount; - } - - /** - * Gets the amount by which the counter value was modified. - * - * @return the change amount (positive for increments, negative for decrements) - */ - public @NotNull Double getAmount() { - return amount; - } - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java deleted file mode 100644 index f180fe168..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMap.java +++ /dev/null @@ -1,131 +0,0 @@ -package io.ably.lib.objects.type.map; - -import io.ably.lib.objects.ObjectsCallback; -import io.ably.lib.objects.type.ObjectLifecycleChange; -import org.jetbrains.annotations.Blocking; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.Contract; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.Unmodifiable; - -import java.util.Map; - -/** - * The LiveMap interface provides methods to interact with a live, real-time map structure. - * It supports both synchronous and asynchronous operations for managing key-value pairs. - */ -public interface LiveMap extends LiveMapChange, ObjectLifecycleChange { - - /** - * Retrieves the value associated with the specified key. - * If this map object is tombstoned (deleted), null is returned. - * If no entry is associated with the specified key, null is returned. - * If map entry is tombstoned (deleted), null is returned. - * If the value associated with the provided key is an objectId string of another RealtimeObject, a reference to - * that RealtimeObject is returned, provided it exists in the local pool and is not tombstoned. Otherwise, null is returned. - * If the value is not an objectId, then that value is returned. - * Spec: RTLM5, RTLM5a - * - * @param keyName the key whose associated value is to be returned. - * @return the value associated with the specified key, or null if the key does not exist. - */ - @Nullable - LiveMapValue get(@NotNull String keyName); - - /** - * Retrieves all entries (key-value pairs) in the map. - * Spec: RTLM11, RTLM11a - * - * @return an iterable collection of all entries in the map. - */ - @NotNull - @Unmodifiable - Iterable> entries(); - - /** - * Retrieves all keys in the map. - * Spec: RTLM12, RTLM12a - * - * @return an iterable collection of all keys in the map. - */ - @NotNull - @Unmodifiable - Iterable keys(); - - /** - * Retrieves all values in the map. - * Spec: RTLM13, RTLM13a - * - * @return an iterable collection of all values in the map. - */ - @NotNull - @Unmodifiable - Iterable values(); - - /** - * Sets the specified key to the given value in the map. - * Send a MAP_SET operation to the realtime system to set a key on this LiveMap object to a specified value. - * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when - * the published MAP_SET operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLM20 - * - * @param keyName the key to be set. - * @param value the value to be associated with the key. - */ - @Blocking - void set(@NotNull String keyName, @NotNull LiveMapValue value); - - /** - * Removes the specified key and its associated value from the map. - * Send a MAP_REMOVE operation to the realtime system to tombstone a key on this LiveMap object. - * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when - * the published MAP_REMOVE operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLM21 - * - * @param keyName the key to be removed. - */ - @Blocking - void remove(@NotNull String keyName); - - /** - * Retrieves the number of entries in the map. - * Spec: RTLM10, RTLM10a - * - * @return the size of the map. - */ - @Contract(pure = true) // Indicates this method does not modify the state of the object. - @NotNull - Long size(); - - /** - * Asynchronously sets the specified key to the given value in the map. - * Send a MAP_SET operation to the realtime system to set a key on this LiveMap object to a specified value. - * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when - * the published MAP_SET operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLM20 - * - * @param keyName the key to be set. - * @param value the value to be associated with the key. - * @param callback the callback to handle the result or any errors. - */ - @NonBlocking - void setAsync(@NotNull String keyName, @NotNull LiveMapValue value, @NotNull ObjectsCallback callback); - - /** - * Asynchronously removes the specified key and its associated value from the map. - * Send a MAP_REMOVE operation to the realtime system to tombstone a key on this LiveMap object. - * This does not modify the underlying data of this LiveMap object. Instead, the change will be applied when - * the published MAP_REMOVE operation is echoed back to the client and applied to the object following the regular - * operation application procedure. - * Spec: RTLM21 - * - * @param keyName the key to be removed. - * @param callback the callback to handle the result or any errors. - */ - @NonBlocking - void removeAsync(@NotNull String keyName, @NotNull ObjectsCallback callback); -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java deleted file mode 100644 index c30ae7850..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapChange.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.ably.lib.objects.type.map; - -import io.ably.lib.objects.ObjectsSubscription; -import org.jetbrains.annotations.NonBlocking; -import org.jetbrains.annotations.NotNull; - -/** - * Provides methods to subscribe to real-time updates on LiveMap objects. - * Enables clients to receive notifications when map entries are added, updated, or removed. - * Uses last-write-wins conflict resolution when multiple clients modify the same key. - */ -public interface LiveMapChange { - - /** - * Subscribes to real-time updates on this LiveMap object. - * Multiple listeners can be subscribed to the same object independently. - * Spec: RTLO4b - * - * @param listener the listener to be notified of map updates - * @return an ObjectsSubscription for managing this specific listener - */ - @NonBlocking - @NotNull ObjectsSubscription subscribe(@NotNull Listener listener); - - /** - * Unsubscribes a specific listener from receiving updates. - * Has no effect if the listener is not currently subscribed. - * Spec: RTLO4c - * - * @param listener the listener to be unsubscribed - */ - @NonBlocking - void unsubscribe(@NotNull Listener listener); - - /** - * Unsubscribes all listeners from receiving updates. - * No notifications will be delivered until new listeners are subscribed. - * Spec: RTLO4d - */ - @NonBlocking - void unsubscribeAll(); - - /** - * Listener interface for receiving LiveMap updates. - * Spec: RTLO4b3 - */ - interface Listener { - /** - * Called when the LiveMap has been updated. - * Should execute quickly as it's called from the real-time processing thread. - * - * @param update details about which keys were modified and how - */ - void onUpdated(@NotNull LiveMapUpdate update); - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java deleted file mode 100644 index 08fe2fc39..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapUpdate.java +++ /dev/null @@ -1,66 +0,0 @@ -package io.ably.lib.objects.type.map; - -import io.ably.lib.objects.type.ObjectUpdate; -import org.jetbrains.annotations.NotNull; - -import java.util.Map; - -/** - * Represents an update that occurred on a LiveMap object. - * Contains information about which keys were modified and whether they were updated or removed. - * - * @spec RTLM18, RTLM18a - LiveMap update structure and behavior - */ -public class LiveMapUpdate extends ObjectUpdate { - - /** - * Creates a no-op LiveMapUpdate representing no actual change. - */ - public LiveMapUpdate() { - super(null); - } - - /** - * Creates a LiveMapUpdate with the specified key changes. - * - * @param update map of key names to their change types (UPDATED or REMOVED) - */ - public LiveMapUpdate(@NotNull Map update) { - super(update); - } - - /** - * Gets the map of key changes that occurred in this update. - * - * @return map of key names to their change types - */ - @NotNull - public Map getUpdate() { - return (Map) update; - } - - /** - * Returns a string representation of this LiveMapUpdate. - * - * @return a string showing the map key changes in this update - */ - @Override - public String toString() { - if (update == null) { - return "LiveMapUpdate{no change}"; - } - return "LiveMapUpdate{changes=" + getUpdate() + "}"; - } - - /** - * Indicates the type of change that occurred to a map key. - * - * @spec RTLM18b - Map change types - */ - public enum Change { - /** The key was added or its value was modified */ - UPDATED, - /** The key was removed from the map */ - REMOVED - } -} diff --git a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java b/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java deleted file mode 100644 index ccba80330..000000000 --- a/lib/src/main/java/io/ably/lib/objects/type/map/LiveMapValue.java +++ /dev/null @@ -1,443 +0,0 @@ -package io.ably.lib.objects.type.map; - -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import io.ably.lib.objects.type.counter.LiveCounter; -import org.jetbrains.annotations.NotNull; - -/** - * Abstract class representing the union type for LiveMap values. - * Provides strict compile-time type safety, implementation is similar to Gson's JsonElement pattern. - * Spec: RTO11a1 - Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap - */ -public abstract class LiveMapValue { - - /** - * Gets the underlying value. - * - * @return the value as an Object - */ - @NotNull - public abstract Object getValue(); - - /** - * Type checking methods with default implementations - */ - - /** - * Returns true if this LiveMapValue represents a Boolean value. - * - * @return true if this is a Boolean value - */ - public boolean isBoolean() { return false; } - - /** - * Returns true if this LiveMapValue represents a Binary value. - * - * @return true if this is a Binary value - */ - public boolean isBinary() { return false; } - - /** - * Returns true if this LiveMapValue represents a Number value. - * - * @return true if this is a Number value - */ - public boolean isNumber() { return false; } - - /** - * Returns true if this LiveMapValue represents a String value. - * - * @return true if this is a String value - */ - public boolean isString() { return false; } - - /** - * Returns true if this LiveMapValue represents a JsonArray value. - * - * @return true if this is a JsonArray value - */ - public boolean isJsonArray() { return false; } - - /** - * Returns true if this LiveMapValue represents a JsonObject value. - * - * @return true if this is a JsonObject value - */ - public boolean isJsonObject() { return false; } - - /** - * Returns true if this LiveMapValue represents a LiveCounter value. - * - * @return true if this is a LiveCounter value - */ - public boolean isLiveCounter() { return false; } - - /** - * Returns true if this LiveMapValue represents a LiveMap value. - * - * @return true if this is a LiveMap value - */ - public boolean isLiveMap() { return false; } - - /** - * Getter methods with default implementations that throw exceptions - */ - - /** - * Gets the Boolean value if this LiveMapValue represents a Boolean. - * - * @return the Boolean value - * @throws IllegalStateException if this is not a Boolean value - */ - @NotNull - public Boolean getAsBoolean() { - throw new IllegalStateException("Not a Boolean value"); - } - - /** - * Gets the Binary value if this LiveMapValue represents a Binary. - * - * @return the Binary value - * @throws IllegalStateException if this is not a Binary value - */ - public byte @NotNull [] getAsBinary() { - throw new IllegalStateException("Not a Binary value"); - } - - /** - * Gets the Number value if this LiveMapValue represents a Number. - * - * @return the Number value - * @throws IllegalStateException if this is not a Number value - */ - @NotNull - public Number getAsNumber() { - throw new IllegalStateException("Not a Number value"); - } - - /** - * Gets the String value if this LiveMapValue represents a String. - * - * @return the String value - * @throws IllegalStateException if this is not a String value - */ - @NotNull - public String getAsString() { - throw new IllegalStateException("Not a String value"); - } - - /** - * Gets the JsonArray value if this LiveMapValue represents a JsonArray. - * - * @return the JsonArray value - * @throws IllegalStateException if this is not a JsonArray value - */ - @NotNull - public JsonArray getAsJsonArray() { - throw new IllegalStateException("Not a JsonArray value"); - } - - /** - * Gets the JsonObject value if this LiveMapValue represents a JsonObject. - * - * @return the JsonObject value - * @throws IllegalStateException if this is not a JsonObject value - */ - @NotNull - public JsonObject getAsJsonObject() { - throw new IllegalStateException("Not a JsonObject value"); - } - - /** - * Gets the LiveCounter value if this LiveMapValue represents a LiveCounter. - * - * @return the LiveCounter value - * @throws IllegalStateException if this is not a LiveCounter value - */ - @NotNull - public LiveCounter getAsLiveCounter() { - throw new IllegalStateException("Not a LiveCounter value"); - } - - /** - * Gets the LiveMap value if this LiveMapValue represents a LiveMap. - * - * @return the LiveMap value - * @throws IllegalStateException if this is not a LiveMap value - */ - @NotNull - public LiveMap getAsLiveMap() { - throw new IllegalStateException("Not a LiveMap value"); - } - - /** - * Static factory methods similar to JsonElement constructors - */ - - /** - * Creates a LiveMapValue from a Boolean. - * - * @param value the boolean value - * @return a LiveMapValue containing the boolean - */ - @NotNull - public static LiveMapValue of(@NotNull Boolean value) { - return new BooleanValue(value); - } - - /** - * Creates a LiveMapValue from a Binary. - * - * @param value the binary value - * @return a LiveMapValue containing the binary - */ - @NotNull - public static LiveMapValue of(byte @NotNull [] value) { - return new BinaryValue(value); - } - - /** - * Creates a LiveMapValue from a Number. - * - * @param value the number value - * @return a LiveMapValue containing the number - */ - @NotNull - public static LiveMapValue of(@NotNull Number value) { - return new NumberValue(value); - } - - /** - * Creates a LiveMapValue from a String. - * - * @param value the string value - * @return a LiveMapValue containing the string - */ - @NotNull - public static LiveMapValue of(@NotNull String value) { - return new StringValue(value); - } - - /** - * Creates a LiveMapValue from a JsonArray. - * - * @param value the JsonArray value - * @return a LiveMapValue containing the JsonArray - */ - @NotNull - public static LiveMapValue of(@NotNull JsonArray value) { - return new JsonArrayValue(value); - } - - /** - * Creates a LiveMapValue from a JsonObject. - * - * @param value the JsonObject value - * @return a LiveMapValue containing the JsonObject - */ - @NotNull - public static LiveMapValue of(@NotNull JsonObject value) { - return new JsonObjectValue(value); - } - - /** - * Creates a LiveMapValue from a LiveCounter. - * - * @param value the LiveCounter value - * @return a LiveMapValue containing the LiveCounter - */ - @NotNull - public static LiveMapValue of(@NotNull LiveCounter value) { - return new LiveCounterValue(value); - } - - /** - * Creates a LiveMapValue from a LiveMap. - * - * @param value the LiveMap value - * @return a LiveMapValue containing the LiveMap - */ - @NotNull - public static LiveMapValue of(@NotNull LiveMap value) { - return new LiveMapValueWrapper(value); - } - - // Concrete implementations for each allowed type - - /** - * Boolean value implementation. - */ - private static final class BooleanValue extends LiveMapValue { - private final Boolean value; - - BooleanValue(@NotNull Boolean value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isBoolean() { return true; } - - @Override - public @NotNull Boolean getAsBoolean() { return value; } - } - - /** - * Binary value implementation. - */ - private static final class BinaryValue extends LiveMapValue { - private final byte[] value; - - BinaryValue(byte @NotNull [] value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isBinary() { return true; } - - @Override - public byte @NotNull [] getAsBinary() { return value; } - } - - /** - * Number value implementation. - */ - private static final class NumberValue extends LiveMapValue { - private final Number value; - - NumberValue(@NotNull Number value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isNumber() { return true; } - - @Override - public @NotNull Number getAsNumber() { return value; } - } - - /** - * String value implementation. - */ - private static final class StringValue extends LiveMapValue { - private final String value; - - StringValue(@NotNull String value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isString() { return true; } - - @Override - public @NotNull String getAsString() { return value; } - } - - /** - * JsonArray value implementation. - */ - private static final class JsonArrayValue extends LiveMapValue { - private final JsonArray value; - - JsonArrayValue(@NotNull JsonArray value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isJsonArray() { return true; } - - @Override - public @NotNull JsonArray getAsJsonArray() { return value; } - } - - /** - * JsonObject value implementation. - */ - private static final class JsonObjectValue extends LiveMapValue { - private final JsonObject value; - - JsonObjectValue(@NotNull JsonObject value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isJsonObject() { return true; } - - @Override - public @NotNull JsonObject getAsJsonObject() { return value; } - } - - /** - * LiveCounter value implementation. - */ - private static final class LiveCounterValue extends LiveMapValue { - private final LiveCounter value; - - LiveCounterValue(@NotNull LiveCounter value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isLiveCounter() { return true; } - - @Override - public @NotNull LiveCounter getAsLiveCounter() { return value; } - } - - /** - * LiveMap value implementation. - */ - private static final class LiveMapValueWrapper extends LiveMapValue { - private final LiveMap value; - - LiveMapValueWrapper(@NotNull LiveMap value) { - this.value = value; - } - - @Override - public @NotNull Object getValue() { - return value; - } - - @Override - public boolean isLiveMap() { return true; } - - @Override - public @NotNull LiveMap getAsLiveMap() { return value; } - } -} diff --git a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java index d9053c8d2..3d1c4a663 100644 --- a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java +++ b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java @@ -5,8 +5,7 @@ import java.util.List; import java.util.Map; -import io.ably.lib.objects.ObjectsHelper; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.object.LiveObjectsPlugin; import io.ably.lib.rest.AblyRest; import io.ably.lib.rest.Auth; import io.ably.lib.transport.ConnectionManager; @@ -74,7 +73,7 @@ public AblyRealtime(ClientOptions options) throws AblyException { final InternalChannels channels = new InternalChannels(); this.channels = channels; - liveObjectsPlugin = ObjectsHelper.tryInitializeObjectsPlugin(this); + liveObjectsPlugin = LiveObjectsPlugin.tryInitialize(this); connection = new Connection(this, channels, platformAgentProvider, liveObjectsPlugin); diff --git a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java index d521f0b4a..3b9d4c41f 100644 --- a/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java +++ b/lib/src/main/java/io/ably/lib/realtime/ChannelBase.java @@ -14,8 +14,7 @@ import io.ably.lib.http.HttpCore; import io.ably.lib.http.HttpUtils; import io.ably.lib.object.RealtimeObject; -import io.ably.lib.objects.RealtimeObjects; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.object.LiveObjectsPlugin; import io.ably.lib.rest.MessageEditsMixin; import io.ably.lib.rest.RestAnnotations; import io.ably.lib.transport.ConnectionManager; @@ -115,16 +114,6 @@ public abstract class ChannelBase extends EventEmitter') to your dependency tree", 400, 40019) - ); - } - return liveObjectsPlugin.getInstance(name); - } - public final RealtimeAnnotations annotations; /*** diff --git a/lib/src/main/java/io/ably/lib/realtime/Connection.java b/lib/src/main/java/io/ably/lib/realtime/Connection.java index 3ba28a434..8f0898550 100644 --- a/lib/src/main/java/io/ably/lib/realtime/Connection.java +++ b/lib/src/main/java/io/ably/lib/realtime/Connection.java @@ -1,6 +1,6 @@ package io.ably.lib.realtime; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.object.LiveObjectsPlugin; import io.ably.lib.realtime.ConnectionStateListener.ConnectionStateChange; import io.ably.lib.transport.ConnectionManager; import io.ably.lib.types.AblyException; diff --git a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java index c9985ef61..9b76f628a 100644 --- a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java +++ b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java @@ -14,7 +14,7 @@ import io.ably.lib.debug.DebugOptions; import io.ably.lib.debug.DebugOptions.RawProtocolListener; import io.ably.lib.http.HttpHelpers; -import io.ably.lib.objects.LiveObjectsPlugin; +import io.ably.lib.object.LiveObjectsPlugin; import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.Channel; import io.ably.lib.realtime.ChannelState; diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultLiveObjectsPlugin.kt similarity index 75% rename from liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt rename to liveobjects/src/main/kotlin/io/ably/lib/object/DefaultLiveObjectsPlugin.kt index 786eb594b..5060d3397 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultLiveObjectsPlugin.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultLiveObjectsPlugin.kt @@ -1,15 +1,16 @@ -package io.ably.lib.objects +package io.ably.lib.`object` +import io.ably.lib.`object`.adapter.AblyClientAdapter import io.ably.lib.realtime.ChannelState import io.ably.lib.types.ProtocolMessage import java.util.concurrent.ConcurrentHashMap -public class DefaultLiveObjectsPlugin(private val adapter: ObjectsAdapter) : LiveObjectsPlugin { +public class DefaultLiveObjectsPlugin(private val adapter: AblyClientAdapter) : LiveObjectsPlugin { - private val objects = ConcurrentHashMap() + private val objects = ConcurrentHashMap() - override fun getInstance(channelName: String): RealtimeObjects { - return objects.getOrPut(channelName) { DefaultRealtimeObjects(channelName, adapter) } + override fun getInstance(channelName: String): RealtimeObject { + return objects.getOrPut(channelName) { DefaultRealtimeObject(channelName, adapter) } } override fun handle(msg: ProtocolMessage) { diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObjects.kt similarity index 82% rename from liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt rename to liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObjects.kt index 617388fb6..8381dfec4 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/DefaultRealtimeObjects.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/DefaultRealtimeObjects.kt @@ -1,12 +1,21 @@ -package io.ably.lib.objects - -import io.ably.lib.objects.serialization.gson +package io.ably.lib.`object` + +import io.ably.lib.`object`.message.* +import io.ably.lib.`object`.message.WireCounterCreateWithObjectId +import io.ably.lib.`object`.message.WireMapCreateWithObjectId +import io.ably.lib.`object`.message.WireObjectMessage +import io.ably.lib.`object`.message.WireObjectOperation +import io.ably.lib.`object`.message.WireObjectOperationAction +import io.ably.lib.`object`.serialization.gson +import io.ably.lib.`object`.value.LiveCounter +import io.ably.lib.`object`.value.LiveMap +import io.ably.lib.`object`.value.LiveMapValue +import io.ably.lib.`object`.value.ObjectType import io.ably.lib.objects.state.ObjectsStateChange import io.ably.lib.objects.state.ObjectsStateEvent -import io.ably.lib.objects.type.ObjectType import io.ably.lib.objects.type.counter.LiveCounter -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.objects.type.livecounter.InternalLiveCounter +import io.ably.lib.objects.type.livemap.InternalLiveMap import io.ably.lib.objects.type.map.LiveMap import io.ably.lib.objects.type.map.LiveMapValue import io.ably.lib.realtime.ChannelState @@ -58,41 +67,12 @@ internal class DefaultRealtimeObjects(internal val channelName: String, internal private val objectsEventBus = MutableSharedFlow(extraBufferCapacity = UNLIMITED) private val incomingObjectsHandler: Job - /** - * Provides a channel-specific scope for safely executing asynchronous operations with callbacks. - */ - internal val asyncScope = ObjectsAsyncScope(channelName) - init { incomingObjectsHandler = initializeHandlerForIncomingObjectMessages() } override fun getRoot(): LiveMap = runBlocking { getRootAsync() } - override fun createMap(): LiveMap = createMap(mutableMapOf()) - - override fun createMap(entries: MutableMap): LiveMap = runBlocking { createMapAsync(entries) } - - override fun createCounter(): LiveCounter = createCounter(0) - - override fun createCounter(initialValue: Number): LiveCounter = runBlocking { createCounterAsync(initialValue) } - - override fun getRootAsync(callback: ObjectsCallback) { - asyncScope.launchWithCallback(callback) { getRootAsync() } - } - - override fun createMapAsync(callback: ObjectsCallback) = createMapAsync(mutableMapOf(), callback) - - override fun createMapAsync(entries: MutableMap, callback: ObjectsCallback) { - asyncScope.launchWithCallback(callback) { createMapAsync(entries) } - } - - override fun createCounterAsync(callback: ObjectsCallback) = createCounterAsync(0, callback) - - override fun createCounterAsync(initialValue: Number, callback: ObjectsCallback) { - asyncScope.launchWithCallback(callback) { createCounterAsync(initialValue) } - } - override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription = objectsManager.on(event, listener) @@ -115,7 +95,7 @@ internal class DefaultRealtimeObjects(internal val channelName: String, internal } // RTO11f14 - Create initial value operation - val initialMapValue = DefaultLiveMap.initialValue(entries) + val initialMapValue = InternalLiveMap.initialValue(entries) // RTO11f15 - Create initial value JSON string val initialValueJSONString = gson.toJson(initialMapValue) @@ -124,11 +104,11 @@ internal class DefaultRealtimeObjects(internal val channelName: String, internal val (objectId, nonce) = getObjectIdStringWithNonce(ObjectType.Map, initialValueJSONString) // Create ObjectMessage with the operation - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, + val msg = WireObjectMessage( + operation = WireObjectOperation( + action = WireObjectOperationAction.MapCreate, objectId = objectId, - mapCreateWithObjectId = MapCreateWithObjectId( + mapCreateWithObjectId = WireMapCreateWithObjectId( nonce = nonce, initialValue = initialValueJSONString, derivedFrom = initialMapValue, @@ -153,7 +133,7 @@ internal class DefaultRealtimeObjects(internal val channelName: String, internal } // RTO12f12 - val initialCounterValue = DefaultLiveCounter.initialValue(initialValue) + val initialCounterValue = InternalLiveCounter.initialValue(initialValue) // RTO12f13 - Create initial value operation val initialValueJSONString = gson.toJson(initialCounterValue) @@ -161,11 +141,11 @@ internal class DefaultRealtimeObjects(internal val channelName: String, internal val (objectId, nonce) = getObjectIdStringWithNonce(ObjectType.Counter, initialValueJSONString) // Create ObjectMessage with the operation - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, + val msg = WireObjectMessage( + operation = WireObjectOperation( + action = WireObjectOperationAction.CounterCreate, objectId = objectId, - counterCreateWithObjectId = CounterCreateWithObjectId( + counterCreateWithObjectId = WireCounterCreateWithObjectId( nonce = nonce, initialValue = initialValueJSONString, derivedFrom = initialCounterValue, @@ -193,13 +173,13 @@ internal class DefaultRealtimeObjects(internal val channelName: String, internal /** * Spec: RTO15 */ - internal suspend fun publish(objectMessages: Array): PublishResult { + internal suspend fun publish(wireObjectMessages: Array): PublishResult { // RTO15b, RTL6c - Ensure that the channel is in a valid state for publishing adapter.throwIfUnpublishableState(channelName) - adapter.ensureMessageSizeWithinLimit(objectMessages) + adapter.ensureMessageSizeWithinLimit(wireObjectMessages) // RTO15e - Must construct the ProtocolMessage as per RTO15e1, RTO15e2, RTO15e3 val protocolMessage = ProtocolMessage(ProtocolMessage.Action.`object`, channelName) - protocolMessage.state = objectMessages + protocolMessage.state = wireObjectMessages // RTO15f, RTO15g - Send the ProtocolMessage using the adapter and capture success/failure return adapter.sendAsync(protocolMessage) // RTO15h } @@ -210,9 +190,9 @@ internal class DefaultRealtimeObjects(internal val channelName: String, internal * * Spec: RTO20 */ - internal suspend fun publishAndApply(objectMessages: Array) { + internal suspend fun publishAndApply(wireObjectMessages: Array) { // RTO20b - publish, propagate failure - val publishResult = publish(objectMessages) + val publishResult = publish(wireObjectMessages) // RTO20c - validate required info val siteCode = adapter.connectionManager.siteCode @@ -221,14 +201,14 @@ internal class DefaultRealtimeObjects(internal val channelName: String, internal return } val serials = publishResult.serials - if (serials == null || serials.size != objectMessages.size) { + if (serials == null || serials.size != wireObjectMessages.size) { Log.e(tag, "RTO20c2: PublishResult.serials unavailable or wrong length; operations will be applied when echoed") return } // RTO20d - create synthetic inbound ObjectMessages - val syntheticMessages = mutableListOf() - objectMessages.forEachIndexed { i, msg -> + val syntheticMessages = mutableListOf() + wireObjectMessages.forEachIndexed { i, msg -> val serial = serials[i] if (serial == null) { Log.d(tag, "RTO20d1: serial null at index $i (conflated), skipping") @@ -270,7 +250,7 @@ internal class DefaultRealtimeObjects(internal val channelName: String, internal return sequentialScope.launch { objectsEventBus.collect { protocolMessage -> // OM2 - Populate missing fields from parent - val objects = protocolMessage.state.filterIsInstance() + val objects = protocolMessage.state.filterIsInstance() .mapIndexed { index, objMsg -> objMsg.copy( connectionId = objMsg.connectionId ?: protocolMessage.connectionId, // OM2c @@ -334,8 +314,8 @@ internal class DefaultRealtimeObjects(internal val channelName: String, internal } val error = ablyException( "publishAndApply could not be applied locally: channel entered $state whilst waiting for objects sync", - ErrorCode.PublishAndApplyFailedDueToChannelState, - HttpStatusCode.BadRequest, + ObjectErrorCode.PublishAndApplyFailedDueToChannelState, + ObjectHttpStatusCode.BadRequest, cause = errorReason?.let { AblyException.fromErrorInfo(it) } ) objectsManager.failBufferedAcks(error) // RTO20e1 @@ -360,7 +340,6 @@ internal class DefaultRealtimeObjects(internal val channelName: String, internal objectsManager.dispose() // Don't cancel sequentialScope (needed in getRoot method), just cancel ongoing coroutines sequentialScope.coroutineContext.cancelChildren(disposeReason) - asyncScope.cancel(disposeReason) // cancel all ongoing callbacks } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectId.kt similarity index 96% rename from liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt rename to liveobjects/src/main/kotlin/io/ably/lib/object/ObjectId.kt index 64a040ddc..c110e099d 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectId.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectId.kt @@ -1,6 +1,6 @@ -package io.ably.lib.objects +package io.ably.lib.`object` -import io.ably.lib.objects.type.ObjectType +import io.ably.lib.`object`.value.ObjectType import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.util.Base64 diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsManager.kt similarity index 80% rename from liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt rename to liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsManager.kt index 9c669f033..5756d87ad 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsManager.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsManager.kt @@ -1,9 +1,15 @@ -package io.ably.lib.objects - -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.ObjectUpdate -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap +package io.ably.lib.`object` + +import io.ably.lib.`object`.message.* +import io.ably.lib.`object`.message.WireObjectMessage +import io.ably.lib.`object`.message.WireObjectOperation +import io.ably.lib.`object`.message.WireObjectOperationAction +import io.ably.lib.`object`.message.WireObjectState +import io.ably.lib.`object`.message.WireObjectsMap +import io.ably.lib.`object`.value.BaseRealtimeObject +import io.ably.lib.`object`.value.ObjectUpdate +import io.ably.lib.objects.type.livecounter.InternalLiveCounter +import io.ably.lib.objects.type.livemap.InternalLiveMap import io.ably.lib.types.AblyException import io.ably.lib.util.Log import kotlinx.coroutines.CompletableDeferred @@ -17,12 +23,12 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject /** * @spec RTO5 - Sync objects pool for collecting sync messages */ - private val syncObjectsPool = mutableMapOf() + private val syncObjectsPool = mutableMapOf() private var currentSyncId: String? = null /** * @spec RTO7 - Buffered object operations during sync */ - private val bufferedObjectOperations = mutableListOf() // RTO7a + private val bufferedObjectOperations = mutableListOf() // RTO7a private var syncCompletionWaiter: CompletableDeferred? = null /** @@ -30,19 +36,19 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject * * @spec RTO8 - Buffers messages if not synced, applies immediately if synced */ - internal fun handleObjectMessages(objectMessages: List) { + internal fun handleObjectMessages(wireObjectMessages: List) { if (realtimeObjects.state != ObjectsState.Synced) { // RTO7 - The client receives object messages in realtime over the channel concurrently with the sync sequence. // Some of the incoming object messages may have already been applied to the objects described in // the sync sequence, but others may not; therefore we must buffer these messages so that we can apply // them to the objects once the sync is complete. - Log.v(tag, "Buffering ${objectMessages.size} object messages, state: ${realtimeObjects.state}") - bufferedObjectOperations.addAll(objectMessages) // RTO8a + Log.v(tag, "Buffering ${wireObjectMessages.size} object messages, state: ${realtimeObjects.state}") + bufferedObjectOperations.addAll(wireObjectMessages) // RTO8a return } // Apply messages immediately if synced - applyObjectMessages(objectMessages, ObjectsOperationSource.CHANNEL) // RTO8b + applyObjectMessages(wireObjectMessages, ObjectsOperationSource.CHANNEL) // RTO8b } /** @@ -50,7 +56,7 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject * * @spec RTO5 - Parses sync channel serial and manages sync sequences */ - internal fun handleObjectSyncMessages(objectMessages: List, syncChannelSerial: String?) { + internal fun handleObjectSyncMessages(wireObjectMessages: List, syncChannelSerial: String?) { val syncTracker = ObjectsSyncTracker(syncChannelSerial) val isNewSync = syncTracker.hasSyncStarted(currentSyncId) if (isNewSync) { @@ -59,7 +65,7 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject } // RTO5a3 - continue current sync sequence - applyObjectSyncMessages(objectMessages) // RTO5f + applyObjectSyncMessages(wireObjectMessages) // RTO5f // RTO5a4 - if this is the last (or only) message in a sequence of sync updates, end the sync if (syncTracker.hasSyncEnded()) { @@ -106,7 +112,7 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject * If SYNCED: apply immediately with LOCAL source. * If not SYNCED: suspend until endSync transitions to SYNCED (RTO20e), then apply. */ - internal suspend fun applyAckResult(messages: List) { + internal suspend fun applyAckResult(messages: List) { if (realtimeObjects.state != ObjectsState.Synced) { if (syncCompletionWaiter == null) syncCompletionWaiter = CompletableDeferred() syncCompletionWaiter?.await() // suspends; resumes after endSync transitions to SYNCED (RTO20e1) @@ -155,7 +161,7 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject // RTO5c1 for ((objectId, objectMessage) in syncObjectsPool) { - val objectState = objectMessage.objectState as ObjectState // we have non-null objectState here due to RTO5f + val wireObjectState = objectMessage.objectState as WireObjectState // we have non-null objectState here due to RTO5f receivedObjectIds.add(objectId) val existingObject = realtimeObjects.objectsPool.get(objectId) @@ -166,7 +172,7 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject existingObjectUpdates.add(Pair(existingObject, update)) } else { // RTO5c1b // RTO5c1b1, RTO5c1b1a, RTO5c1b1b - Create new object and add it to the pool - val newObject = createObjectFromState(objectState) ?: continue // RTO5c1b1c - skip unsupported + val newObject = createObjectFromState(wireObjectState) ?: continue // RTO5c1b1c - skip unsupported newObject.applyObjectSync(objectMessage) realtimeObjects.objectsPool.set(objectId, newObject) } @@ -187,19 +193,19 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject * @spec RTO9 - Creates zero-value objects if they don't exist */ private fun applyObjectMessages( - objectMessages: List, + wireObjectMessages: List, source: ObjectsOperationSource = ObjectsOperationSource.CHANNEL, ) { // RTO9a - for (objectMessage in objectMessages) { + for (objectMessage in wireObjectMessages) { if (objectMessage.operation == null) { // RTO9a1 Log.w(tag, "Object message received without operation field, skipping message: ${objectMessage.id}") continue } - val objectOperation: ObjectOperation = objectMessage.operation // RTO9a2 - if (objectOperation.action == ObjectOperationAction.Unknown) { + val wireObjectOperation: WireObjectOperation = objectMessage.operation // RTO9a2 + if (wireObjectOperation.action == WireObjectOperationAction.Unknown) { // RTO9a2b - object operation action is unknown, skip the message Log.w(tag, "Object operation action is unknown, skipping message: ${objectMessage.id}") continue @@ -223,7 +229,7 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject // since they need to be able to eventually initialize themselves from that *_CREATE op. // so to simplify operations handling, we always try to create a zero-value object in the pool first, // and then we can always apply the operation on the existing object in the pool. - val obj = realtimeObjects.objectsPool.createZeroValueObjectIfNotExists(objectOperation.objectId) // RTO9a2a1 + val obj = realtimeObjects.objectsPool.createZeroValueObjectIfNotExists(wireObjectOperation.objectId) // RTO9a2a1 val applied = obj.applyObject(objectMessage, source) // RTO9a2a2, RTO9a2a3 if (source == ObjectsOperationSource.LOCAL && applied && objectMessage.serial != null) { realtimeObjects.appliedOnAckSerials.add(objectMessage.serial) // RTO9a2a4 @@ -236,20 +242,20 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject * * @spec RTO5f - Collects and merges object states during sync sequence */ - private fun applyObjectSyncMessages(objectMessages: List) { - for (objectMessage in objectMessages) { + private fun applyObjectSyncMessages(wireObjectMessages: List) { + for (objectMessage in wireObjectMessages) { if (objectMessage.objectState == null) { Log.w(tag, "Object message received during OBJECT_SYNC without object field, skipping message: ${objectMessage.id}") continue } - val objectState: ObjectState = objectMessage.objectState - val objectId = objectState.objectId + val wireObjectState: WireObjectState = objectMessage.objectState + val objectId = wireObjectState.objectId val existingEntry = syncObjectsPool[objectId] if (existingEntry == null) { // RTO5f1 - objectId not in pool, store directly - if (objectState.counter != null || objectState.map != null) { + if (wireObjectState.counter != null || wireObjectState.map != null) { syncObjectsPool[objectId] = objectMessage } else { // RTO5c1b1c - object state must contain either counter or map data @@ -260,21 +266,21 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject // RTO5f2 - objectId already in pool; this is a partial sync message, merge based on type when { - objectState.map != null -> { + wireObjectState.map != null -> { // RTO5f2a - map object: merge entries - if (objectState.tombstone) { + if (wireObjectState.tombstone) { // RTO5f2a1 - tombstone: replace pool entry entirely syncObjectsPool[objectId] = objectMessage } else { // RTO5f2a2 - merge map entries; server guarantees no duplicate keys across partials val existingState = existingEntry.objectState!! // non-null for existing entry - val mergedEntries = existingState.map?.entries.orEmpty() + objectState.map.entries.orEmpty() - val mergedMap = (existingState.map ?: ObjectsMap()).copy(entries = mergedEntries) + val mergedEntries = existingState.map?.entries.orEmpty() + wireObjectState.map.entries.orEmpty() + val mergedMap = (existingState.map ?: WireObjectsMap()).copy(entries = mergedEntries) val mergedState = existingState.copy(map = mergedMap) syncObjectsPool[objectId] = existingEntry.copy(objectState = mergedState) } } - objectState.counter != null -> { + wireObjectState.counter != null -> { // RTO5f2b - counter objects must never be split across messages Log.e(tag, "Received partial sync message for a counter object, skipping: ${objectMessage.id}") } @@ -291,13 +297,13 @@ internal class ObjectsManager(private val realtimeObjects: DefaultRealtimeObject * * @spec RTO5c1b - Creates objects from object state based on type */ - private fun createObjectFromState(objectState: ObjectState): BaseRealtimeObject? { + private fun createObjectFromState(wireObjectState: WireObjectState): BaseRealtimeObject? { return when { - objectState.counter != null -> DefaultLiveCounter.zeroValue(objectState.objectId, realtimeObjects) // RTO5c1b1a - objectState.map != null -> DefaultLiveMap.zeroValue(objectState.objectId, realtimeObjects) // RTO5c1b1b + wireObjectState.counter != null -> InternalLiveCounter.zeroValue(wireObjectState.objectId, realtimeObjects) // RTO5c1b1a + wireObjectState.map != null -> InternalLiveMap.zeroValue(wireObjectState.objectId, realtimeObjects) // RTO5c1b1b else -> { // RTO5c1b1c - unsupported object type, skip gracefully - Log.w(tag, "Received unsupported object state during OBJECT_SYNC (no counter or map), skipping objectId: ${objectState.objectId}") + Log.w(tag, "Received unsupported object state during OBJECT_SYNC (no counter or map), skipping objectId: ${wireObjectState.objectId}") null } } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsOperationSource.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsOperationSource.kt similarity index 85% rename from liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsOperationSource.kt rename to liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsOperationSource.kt index e850d31b8..555c5b823 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsOperationSource.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsOperationSource.kt @@ -1,4 +1,4 @@ -package io.ably.lib.objects +package io.ably.lib.`object` /** @spec RTO22 */ internal enum class ObjectsOperationSource { diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsPool.kt similarity index 89% rename from liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt rename to liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsPool.kt index 224cd606f..1529dbb1c 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsPool.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsPool.kt @@ -1,9 +1,9 @@ -package io.ably.lib.objects +package io.ably.lib.`object` -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap +import io.ably.lib.`object`.value.BaseRealtimeObject +import io.ably.lib.`object`.value.ObjectType +import io.ably.lib.objects.type.livecounter.InternalLiveCounter +import io.ably.lib.objects.type.livemap.InternalLiveMap import io.ably.lib.util.Log import kotlinx.coroutines.* import java.util.concurrent.ConcurrentHashMap @@ -53,11 +53,11 @@ internal class ObjectsPool( private var gcJob: Job // Job for the garbage collection coroutine @Volatile private var gcGracePeriod = ObjectsPoolDefaults.GC_GRACE_PERIOD_MS - private var gcPeriodSubscription: ObjectsSubscription + private var gcPeriodSubscription: Subscription init { // RTO3b - Initialize pool with root object - pool[ROOT_OBJECT_ID] = DefaultLiveMap.zeroValue(ROOT_OBJECT_ID, realtimeObjects) + pool[ROOT_OBJECT_ID] = InternalLiveMap.zeroValue(ROOT_OBJECT_ID, realtimeObjects) // Start garbage collection coroutine with server-provided grace period if available gcPeriodSubscription = realtimeObjects.adapter.onGCGracePeriodUpdated { period -> period?.let { @@ -123,8 +123,8 @@ internal class ObjectsPool( val parsedObjectId = ObjectId.fromString(objectId) // RTO6b return when (parsedObjectId.type) { - ObjectType.Map -> DefaultLiveMap.zeroValue(objectId, realtimeObjects) // RTO6b2 - ObjectType.Counter -> DefaultLiveCounter.zeroValue(objectId, realtimeObjects) // RTO6b3 + ObjectType.Map -> InternalLiveMap.zeroValue(objectId, realtimeObjects) // RTO6b2 + ObjectType.Counter -> InternalLiveCounter.zeroValue(objectId, realtimeObjects) // RTO6b3 }.apply { set(objectId, this) // RTO6b4 - Add the zero-value object to the pool } diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsState.kt similarity index 78% rename from liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt rename to liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsState.kt index cdd742ec0..8de3e7388 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsState.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsState.kt @@ -1,7 +1,7 @@ -package io.ably.lib.objects +package io.ably.lib.`object` -import io.ably.lib.objects.state.ObjectsStateChange -import io.ably.lib.objects.state.ObjectsStateEvent +import io.ably.lib.`object`.state.ObjectStateChange +import io.ably.lib.`object`.state.ObjectStateEvent import io.ably.lib.util.EventEmitter import io.ably.lib.util.Log import kotlinx.coroutines.* @@ -22,8 +22,8 @@ internal enum class ObjectsState { */ private val objectsStateToEventMap = mapOf( ObjectsState.Initialized to null, - ObjectsState.Syncing to ObjectsStateEvent.SYNCING, - ObjectsState.Synced to ObjectsStateEvent.SYNCED + ObjectsState.Syncing to ObjectStateEvent.SYNCING, + ObjectsState.Synced to ObjectStateEvent.SYNCED ) /** @@ -57,20 +57,20 @@ internal interface HandlesObjectsStateChange { } -internal abstract class ObjectsStateCoordinator : ObjectsStateChange, HandlesObjectsStateChange { +internal abstract class ObjectsStateCoordinator : ObjectStateChange, HandlesObjectsStateChange { private val tag = "ObjectsStateCoordinator" private val internalObjectStateEmitter = ObjectsStateEmitter() // related to RTC10, should have a separate EventEmitter for users of the library private val externalObjectStateEmitter = ObjectsStateEmitter() - override fun on(event: ObjectsStateEvent, listener: ObjectsStateChange.Listener): ObjectsSubscription { + override fun on(event: ObjectStateEvent, listener: ObjectStateChange.Listener): Subscription { externalObjectStateEmitter.on(event, listener) - return ObjectsSubscription { + return onceSubscription { externalObjectStateEmitter.off(event, listener) } } - override fun off(listener: ObjectsStateChange.Listener) = externalObjectStateEmitter.off(listener) + override fun off(listener: ObjectStateChange.Listener) = externalObjectStateEmitter.off(listener) override fun offAll() = externalObjectStateEmitter.off() @@ -84,7 +84,7 @@ internal abstract class ObjectsStateCoordinator : ObjectsStateChange, HandlesObj override suspend fun ensureSynced(currentState: ObjectsState) { if (currentState != ObjectsState.Synced) { val deferred = CompletableDeferred() - internalObjectStateEmitter.once(ObjectsStateEvent.SYNCED) { + internalObjectStateEmitter.once(ObjectStateEvent.SYNCED) { Log.v(tag, "Objects state changed to SYNCED, resuming ensureSynced") deferred.complete(Unit) } @@ -95,9 +95,9 @@ internal abstract class ObjectsStateCoordinator : ObjectsStateChange, HandlesObj override fun disposeObjectsStateListeners() = offAll() } -private class ObjectsStateEmitter : EventEmitter() { +private class ObjectsStateEmitter : EventEmitter() { private val tag = "ObjectsStateEmitter" - override fun apply(listener: ObjectsStateChange.Listener?, event: ObjectsStateEvent?, vararg args: Any?) { + override fun apply(listener: ObjectStateChange.Listener?, event: ObjectStateEvent?, vararg args: Any?) { try { event?.let { listener?.onStateChanged(it) } ?: Log.w(tag, "Null event passed to ObjectsStateChange Listener callback") diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsSyncTracker.kt similarity index 98% rename from liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt rename to liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsSyncTracker.kt index 5c2a193d5..9398a3912 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectsSyncTracker.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/ObjectsSyncTracker.kt @@ -1,4 +1,4 @@ -package io.ably.lib.objects +package io.ably.lib.`object` /** * @spec RTO5 - SyncTracker class for tracking objects sync status diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/Utils.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/Utils.kt similarity index 53% rename from liveobjects/src/main/kotlin/io/ably/lib/objects/Utils.kt rename to liveobjects/src/main/kotlin/io/ably/lib/object/Utils.kt index 3e136163e..170ff522b 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/Utils.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/Utils.kt @@ -1,4 +1,4 @@ -package io.ably.lib.objects +package io.ably.lib.`object` import io.ably.lib.types.AblyException import io.ably.lib.types.ErrorInfo @@ -54,60 +54,6 @@ internal fun invalidInputError(errorMessage: String, cause: Throwable? = null): internal val String.byteSize: Int get() = this.toByteArray(StandardCharsets.UTF_8).size -/** - * A channel-specific coroutine scope for executing callbacks asynchronously in the RealtimeObjects system. - * Provides safe execution of suspend functions with results delivered via callbacks. - * Supports proper error handling and cancellation during DefaultRealtimeObjects disposal. - */ -internal class ObjectsAsyncScope(channelName: String) { - private val tag = "ObjectsCallbackScope-$channelName" - - private val scope = - CoroutineScope(Dispatchers.Default + CoroutineName(tag) + SupervisorJob()) - - internal fun launchWithCallback(callback: ObjectsCallback, block: suspend () -> T) { - scope.launch { - try { - val result = block() - try { callback.onSuccess(result) } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing callback's onSuccess handler", t) - } // catch and don't rethrow error from callback - } catch (throwable: Throwable) { - when (throwable) { - is AblyException -> { callback.onError(throwable) } - else -> { - val ex = ablyException("Error executing operation", ErrorCode.BadRequest, cause = throwable) - callback.onError(ex) - } - } - } - } - } - - internal fun launchWithVoidCallback(callback: ObjectsCallback, block: suspend () -> Unit) { - scope.launch { - try { - block() - try { callback.onSuccess(null) } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing callback's onSuccess handler", t) - } // catch and don't rethrow error from callback - } catch (throwable: Throwable) { - when (throwable) { - is AblyException -> { callback.onError(throwable) } - else -> { - val ex = ablyException("Error executing operation", ErrorCode.BadRequest, cause = throwable) - callback.onError(ex) - } - } - } - } - } - - internal fun cancel(cause: CancellationException) { - scope.coroutineContext.cancelChildren(cause) - } -} - /** * Generates a random nonce string for object creation. */ diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt index b6f2f63f4..28b58d56e 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/message/WireObjectMessage.kt @@ -1,12 +1,12 @@ package io.ably.lib.`object`.message -import com.google.gson.Gson import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.annotations.JsonAdapter import com.google.gson.annotations.SerializedName +import io.ably.lib.`object`.byteSize import io.ably.lib.`object`.serialization.WireObjectDataJsonSerializer -import java.nio.charset.StandardCharsets +import io.ably.lib.`object`.serialization.gson import java.util.Base64 /** @@ -156,18 +156,6 @@ internal data class WireObjectMessage( val siteCode: String? = null, // OM2i ) -// Gson instance for serializing the opaque `extras` field during size calculation. -// Kept file-local so this package has no dependency on `io.ably.lib.objects`. -private val gson = Gson() - -/** - * Calculates the byte size of a string. - * For non-ASCII, the byte size can be 2–4x the character count. For ASCII, there is no difference. - * e.g. "Hello" has a byte size of 5, while "你" has a byte size of 3 and "😊" has a byte size of 4. - */ -private val String.byteSize: Int - get() = this.toByteArray(StandardCharsets.UTF_8).size - /** * Calculates the size of an ObjectMessage in bytes. * Spec: OM3 diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/value/BaseRealtimeObject.kt similarity index 80% rename from liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt rename to liveobjects/src/main/kotlin/io/ably/lib/object/value/BaseRealtimeObject.kt index 934789789..1f4c6464b 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/BaseRealtimeObject.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/value/BaseRealtimeObject.kt @@ -1,12 +1,10 @@ -package io.ably.lib.objects.type - -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.ObjectsOperationSource -import io.ably.lib.objects.objectError -import io.ably.lib.objects.type.livecounter.noOpCounterUpdate -import io.ably.lib.objects.type.livemap.noOpMapUpdate +package io.ably.lib.`object`.value + +import io.ably.lib.`object`.ObjectsOperationSource +import io.ably.lib.`object`.message.WireObjectMessage +import io.ably.lib.`object`.message.WireObjectOperation +import io.ably.lib.`object`.message.WireObjectState +import io.ably.lib.`object`.objectError import io.ably.lib.util.Clock import io.ably.lib.util.Log import io.ably.lib.util.SystemClock @@ -17,7 +15,8 @@ internal enum class ObjectType(val value: String) { } // Spec: RTLO4b4b -internal val ObjectUpdate.noOp get() = this.update == null +// TODO - Check what to do about `ObjectUpdate` field, whether we really need to keep it or not +internal object ObjectUpdate /** * Provides common functionality and base implementation for LiveMap and LiveCounter. @@ -30,7 +29,7 @@ internal abstract class BaseRealtimeObject( internal val objectId: String, // // RTLO3a internal val objectType: ObjectType, internal val clock: Clock = SystemClock.INSTANCE, -) : ObjectLifecycleCoordinator() { +) { protected open val tag = "BaseRealtimeObject" @@ -49,22 +48,22 @@ internal abstract class BaseRealtimeObject( * * @spec RTLM6/RTLC6 - Overrides ObjectMessage with object data state from sync to LiveMap/LiveCounter */ - internal fun applyObjectSync(objectMessage: ObjectMessage): ObjectUpdate { - val objectState = objectMessage.objectState as ObjectState // we have non-null objectState here due to RTO5f - validate(objectState) + internal fun applyObjectSync(wireObjectMessage: WireObjectMessage): ObjectUpdate { + val wireObjectState = wireObjectMessage.objectState as WireObjectState // we have non-null objectState here due to RTO5f + validate(wireObjectState) // object's site serials are still updated even if it is tombstoned, so always use the site serials received from the operation. // should default to empty map if site serials do not exist on the object state, so that any future operation may be applied to this object. siteTimeserials.clear() - siteTimeserials.putAll(objectState.siteTimeserials) // RTLC6a, RTLM6a + siteTimeserials.putAll(wireObjectState.siteTimeserials) // RTLC6a, RTLM6a if (isTombstoned) { // this object is tombstoned. this is a terminal state which can't be overridden. skip the rest of object state message processing if (objectType == ObjectType.Map) { - return noOpMapUpdate + return ObjectUpdate } - return noOpCounterUpdate + return ObjectUpdate } - return applyObjectState(objectState, objectMessage) // RTLM6, RTLC6 + return applyObjectState(wireObjectState, wireObjectMessage) // RTLM6, RTLC6 } /** @@ -73,18 +72,18 @@ internal abstract class BaseRealtimeObject( * * @spec RTLM15/RTLC7 - Applies ObjectMessage with object data operations to LiveMap/LiveCounter */ - internal fun applyObject(objectMessage: ObjectMessage, source: ObjectsOperationSource): Boolean { - validateObjectId(objectMessage.operation?.objectId) + internal fun applyObject(wireObjectMessage: WireObjectMessage, source: ObjectsOperationSource): Boolean { + validateObjectId(wireObjectMessage.operation?.objectId) - val msgTimeSerial = objectMessage.serial - val msgSiteCode = objectMessage.siteCode - val objectOperation = objectMessage.operation as ObjectOperation + val msgTimeSerial = wireObjectMessage.serial + val msgSiteCode = wireObjectMessage.siteCode + val wireObjectOperation = wireObjectMessage.operation as WireObjectOperation if (!canApplyOperation(msgSiteCode, msgTimeSerial)) { // RTLC7b, RTLM15b Log.v( tag, - "Skipping ${objectOperation.action} op: op serial $msgTimeSerial <= site serial ${siteTimeserials[msgSiteCode]}; " + + "Skipping ${wireObjectOperation.action} op: op serial $msgTimeSerial <= site serial ${siteTimeserials[msgSiteCode]}; " + "objectId=$objectId" ) return false // RTLC7b / RTLM15b @@ -98,7 +97,7 @@ internal abstract class BaseRealtimeObject( // this object is tombstoned so the operation cannot be applied return false // RTLC7e / RTLM15e } - return applyObjectOperation(objectOperation, objectMessage) // RTLC7d + return applyObjectOperation(wireObjectOperation, wireObjectMessage) // RTLC7d } /** @@ -133,8 +132,7 @@ internal abstract class BaseRealtimeObject( isTombstoned = true tombstonedAt = serialTimestamp?: clock.currentTimeMillis() val update = clearData() - // Emit object lifecycle event for deletion - objectLifecycleChanged(ObjectLifecycle.Deleted) + // TODO - Emit object lifecycle event for deletion return update } @@ -160,18 +158,18 @@ internal abstract class BaseRealtimeObject( * Validates that the provided object state is compatible with this object. * Checks object ID, type-specific validations, and any included create operations. */ - abstract fun validate(state: ObjectState) + abstract fun validate(state: WireObjectState) /** * Applies an object state received during synchronization to this object. * This method should update the internal data structure with the complete state * received from the server. * - * @param objectState The complete state to apply to this object + * @param wireObjectState The complete state to apply to this object * @return A map describing the changes made to the object's data * */ - abstract fun applyObjectState(objectState: ObjectState, message: ObjectMessage): ObjectUpdate + abstract fun applyObjectState(wireObjectState: WireObjectState, message: WireObjectMessage): ObjectUpdate /** * Applies an operation to this object. @@ -183,7 +181,7 @@ internal abstract class BaseRealtimeObject( * @return true if the operation was meaningfully applied, false otherwise * */ - abstract fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage): Boolean + abstract fun applyObjectOperation(operation: WireObjectOperation, message: WireObjectMessage): Boolean /** * Clears the object's data and returns an update describing the changes. diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveCounter.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/value/livecounter/DefaultLiveCounter.kt similarity index 89% rename from liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveCounter.kt rename to liveobjects/src/main/kotlin/io/ably/lib/object/value/livecounter/DefaultLiveCounter.kt index 43fec3909..c0a94fb78 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveCounter.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/value/livecounter/DefaultLiveCounter.kt @@ -1,4 +1,6 @@ -package io.ably.lib.`object`.value +package io.ably.lib.`object`.value.livecounter + +import io.ably.lib.`object`.value.LiveCounter /** * Default implementation of the [LiveCounter] value type - an immutable holder for diff --git a/liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveMap.kt b/liveobjects/src/main/kotlin/io/ably/lib/object/value/livemap/DefaultLiveMap.kt similarity index 82% rename from liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveMap.kt rename to liveobjects/src/main/kotlin/io/ably/lib/object/value/livemap/DefaultLiveMap.kt index 4f6520b39..cecae6788 100644 --- a/liveobjects/src/main/kotlin/io/ably/lib/object/value/DefaultLiveMap.kt +++ b/liveobjects/src/main/kotlin/io/ably/lib/object/value/livemap/DefaultLiveMap.kt @@ -1,4 +1,7 @@ -package io.ably.lib.`object`.value +package io.ably.lib.`object`.value.livemap + +import io.ably.lib.`object`.value.LiveMap +import io.ably.lib.`object`.value.LiveMapValue /** * Default implementation of the [LiveMap] value type - an immutable holder for the @@ -16,7 +19,7 @@ package io.ably.lib.`object`.value * Spec: RTLMV1, RTLMV2, RTLMV3 */ internal class DefaultLiveMap( - internal val entries: Map, + internal val entries: Map, ) : LiveMap() { // TODO - build the MAP_CREATE ObjectMessage (plus nested object create messages) // from `entries`, mirroring ably-js LiveMapValueType.createMapCreateMessage. diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt deleted file mode 100644 index 1a8d1b8ad..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.ably.lib.objects - -internal enum class ErrorCode(public val code: Int) { - BadRequest(40_000), - InternalError(50_000), - MaxMessageSizeExceeded(40_009), - InvalidObject(92_000), - // LiveMap specific error codes - InvalidInputParams(40_003), - MapValueDataTypeUnsupported(40_013), - // Channel mode and state validation error codes - ChannelModeRequired(40_024), - ChannelStateError(90_001), - PublishAndApplyFailedDueToChannelState(92_008), -} - -internal enum class HttpStatusCode(public val code: Int) { - BadRequest(400), - InternalServerError(500), -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/Helpers.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/Helpers.kt deleted file mode 100644 index 683971510..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/Helpers.kt +++ /dev/null @@ -1,178 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.realtime.ChannelState -import io.ably.lib.realtime.CompletionListener -import io.ably.lib.types.Callback -import io.ably.lib.realtime.ConnectionEvent -import io.ably.lib.realtime.ConnectionStateListener -import io.ably.lib.types.ChannelMode -import io.ably.lib.types.ErrorInfo -import io.ably.lib.types.ProtocolMessage -import io.ably.lib.types.PublishResult -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -internal val ObjectsAdapter.connectionManager get() = connection.connectionManager - -/** - * Spec: RTO15g - */ -internal suspend fun ObjectsAdapter.sendAsync(message: ProtocolMessage): PublishResult = suspendCancellableCoroutine { continuation -> - try { - connectionManager.send(message, clientOptions.queueMessages, object : Callback { - override fun onSuccess(result: PublishResult) { - continuation.resume(result) - } - - override fun onError(reason: ErrorInfo) { - continuation.resumeWithException(ablyException(reason)) - } - }) - } catch (e: Exception) { - continuation.resumeWithException(e) - } -} - -internal suspend fun ObjectsAdapter.attachAsync(channelName: String) = suspendCancellableCoroutine { continuation -> - try { - getChannel(channelName).attach(object : CompletionListener { - override fun onSuccess() { - continuation.resume(Unit) - } - - override fun onError(reason: ErrorInfo) { - continuation.resumeWithException(ablyException(reason)) - } - }) - } catch (e: Exception) { - continuation.resumeWithException(e) - } -} - -internal fun ObjectsAdapter.onGCGracePeriodUpdated(block : (Long?) -> Unit) : ObjectsSubscription { - connectionManager.objectsGCGracePeriod?.let { block(it) } - // Return new objectsGCGracePeriod whenever connection state changes to connected - val listener: (_: ConnectionStateListener.ConnectionStateChange) -> Unit = { - block(connectionManager.objectsGCGracePeriod) - } - connection.on(ConnectionEvent.connected, listener) - return ObjectsSubscription { connection.off(listener) } -} - -/** - * Retrieves the channel modes for a specific channel. - * This method returns the modes that are set for the specified channel. - * - * @param channelName the name of the channel for which to retrieve the modes - * @return the array of channel modes for the specified channel, or null if the channel is not found - * Spec: RTO2a, RTO2b - */ -internal fun ObjectsAdapter.getChannelModes(channelName: String): Array? { - val channel = getChannel(channelName) - - // RTO2a - channel.modes is only populated on channel attachment, so use it only if it is set - channel.modes?.let { modes -> - if (modes.isNotEmpty()) { - return modes - } - } - - // RTO2b - otherwise as a best effort use user provided channel options - channel.options?.let { options -> - if (options.hasModes()) { - return options.modes - } - } - return null -} - -/** - * Spec: RTO15d - */ -internal fun ObjectsAdapter.ensureMessageSizeWithinLimit(objectMessages: Array) { - val maximumAllowedSize = connectionManager.maxMessageSize - val objectsTotalMessageSize = objectMessages.sumOf { it.size() } - if (objectsTotalMessageSize > maximumAllowedSize) { - throw ablyException("ObjectMessages size $objectsTotalMessageSize exceeds maximum allowed size of $maximumAllowedSize bytes", - ErrorCode.MaxMessageSizeExceeded) - } -} - -internal fun ObjectsAdapter.setChannelSerial(channelName: String, protocolMessage: ProtocolMessage) { - if (protocolMessage.action != ProtocolMessage.Action.`object`) return - val channelSerial = protocolMessage.channelSerial - if (channelSerial.isNullOrEmpty()) return - getChannel(channelName).properties.channelSerial = channelSerial -} - -internal suspend fun ObjectsAdapter.ensureAttached(channelName: String) { - val channel = getChannel(channelName) - when (val currentChannelStatus = channel.state) { - ChannelState.initialized -> attachAsync(channelName) - ChannelState.attached -> return - ChannelState.attaching -> { - val attachDeferred = CompletableDeferred() - getChannel(channelName).once { - when(it.current) { - ChannelState.attached -> attachDeferred.complete(Unit) - else -> { - val exception = ablyException("Channel $channelName is in invalid state: ${it.current}, " + - "error: ${it.reason}", ErrorCode.ChannelStateError) - attachDeferred.completeExceptionally(exception) - } - } - } - if (channel.state == ChannelState.attached) { - attachDeferred.complete(Unit) - } - attachDeferred.await() - } - else -> - throw ablyException("Channel $channelName is in invalid state: $currentChannelStatus", ErrorCode.ChannelStateError) - } -} - -// Spec: RTLO4b1, RTLO4b2 -internal fun ObjectsAdapter.throwIfInvalidAccessApiConfiguration(channelName: String) { - throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed)) - throwIfMissingChannelMode(channelName, ChannelMode.object_subscribe) -} - -internal fun ObjectsAdapter.throwIfInvalidWriteApiConfiguration(channelName: String) { - throwIfEchoMessagesDisabled() - throwIfInChannelState(channelName, arrayOf(ChannelState.detached, ChannelState.failed, ChannelState.suspended)) - throwIfMissingChannelMode(channelName, ChannelMode.object_publish) -} - -internal fun ObjectsAdapter.throwIfUnpublishableState(channelName: String) { - if (!connectionManager.isActive) { - throw ablyException(connectionManager.stateErrorInfo) - } - throwIfInChannelState(channelName, arrayOf(ChannelState.failed, ChannelState.suspended)) -} - -// Spec: RTO2 -private fun ObjectsAdapter.throwIfMissingChannelMode(channelName: String, channelMode: ChannelMode) { - val channelModes = getChannelModes(channelName) - if (channelModes == null || !channelModes.contains(channelMode)) { - // Spec: RTO2a2, RTO2b2 - throw ablyException("\"${channelMode.name}\" channel mode must be set for this operation", ErrorCode.ChannelModeRequired) - } -} - -private fun ObjectsAdapter.throwIfInChannelState(channelName: String, channelStates: Array) { - val currentState = getChannel(channelName).state - if (currentState == null || channelStates.contains(currentState)) { - throw ablyException("Channel is in invalid state: $currentState", ErrorCode.ChannelStateError) - } -} - -internal fun ObjectsAdapter.throwIfEchoMessagesDisabled() { - if (!clientOptions.echoMessages) { - throw clientError("\"echoMessages\" client option must be enabled for this operation") - } -} - - diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt deleted file mode 100644 index 7f3e9b372..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ObjectMessage.kt +++ /dev/null @@ -1,545 +0,0 @@ -package io.ably.lib.objects - -import com.google.gson.JsonElement -import com.google.gson.JsonObject - -import com.google.gson.annotations.JsonAdapter -import com.google.gson.annotations.SerializedName -import io.ably.lib.objects.serialization.ObjectDataJsonSerializer -import io.ably.lib.objects.serialization.gson -import java.util.Base64 - -/** - * An enum class representing the different actions that can be performed on an object. - * Spec: OOP2 - */ -internal enum class ObjectOperationAction(val code: Int) { - MapCreate(0), - MapSet(1), - MapRemove(2), - CounterCreate(3), - CounterInc(4), - ObjectDelete(5), - MapClear(6), - Unknown(-1); // code for unknown value during deserialization -} - -/** - * An enum class representing the conflict-resolution semantics used by a Map object. - * Spec: OMP2 - */ -internal enum class ObjectsMapSemantics(val code: Int) { - LWW(0), - Unknown(-1); // code for unknown value during deserialization -} - -/** - * An ObjectData represents a value in an object on a channel. - * Spec: OD1 - */ -@JsonAdapter(ObjectDataJsonSerializer::class) -internal data class ObjectData( - /** - * A reference to another object, used to support composable object structures. - * Spec: OD2a - */ - val objectId: String? = null, - - /** String value. Spec: OD2c */ - val string: String? = null, - - /** Numeric value. Spec: OD2c */ - val number: Double? = null, - - /** Boolean value. Spec: OD2c */ - val boolean: Boolean? = null, - - /** Binary value encoded as a base64 string. Spec: OD2c */ - val bytes: String? = null, - - /** JSON object or array value. Spec: OD2c */ - val json: JsonElement? = null, -) - -/** - * Payload for MAP_CREATE operation. - * Spec: MCR* - */ -internal data class MapCreate( - val semantics: ObjectsMapSemantics, // MCR2a - val entries: Map // MCR2b -) - -/** - * Payload for MAP_SET operation. - * Spec: MST* - */ -internal data class MapSet( - val key: String, // MST2a - val value: ObjectData // MST2b - REQUIRED -) - -/** - * Payload for MAP_REMOVE operation. - * Spec: MRM* - */ -internal data class MapRemove( - val key: String // MRM2a -) - -/** - * Payload for COUNTER_CREATE operation. - * Spec: CCR* - */ -internal data class CounterCreate( - val count: Double // CCR2a - REQUIRED -) - -/** - * Payload for COUNTER_INC operation. - * Spec: CIN* - */ -internal data class CounterInc( - val number: Double // CIN2a - REQUIRED -) - -/** - * Payload for OBJECT_DELETE operation. - * Spec: ODE* - * No fields - action is sufficient - */ -internal object ObjectDelete - -/** - * Payload for MAP_CLEAR operation. - * Spec: MCL* - * No fields - action is sufficient - */ -internal object MapClear - -/** - * Payload for MAP_CREATE_WITH_OBJECT_ID operation. - * Spec: MCRO* - */ -internal data class MapCreateWithObjectId( - val initialValue: String, // MCRO2a - val nonce: String, // MCRO2b - @Transient val derivedFrom: MapCreate? = null, -) - -/** - * Payload for COUNTER_CREATE_WITH_OBJECT_ID operation. - * Spec: CCRO* - */ -internal data class CounterCreateWithObjectId( - val initialValue: String, // CCRO2a - val nonce: String, // CCRO2b - @Transient val derivedFrom: CounterCreate? = null, -) - -/** - * A MapEntry represents the value at a given key in a Map object. - * Spec: ME1 - */ -internal data class ObjectsMapEntry( - /** - * Indicates whether the map entry has been removed. - * Spec: OME2a - */ - val tombstone: Boolean? = null, - - /** - * The serial value of the latest operation that was applied to the map entry. - * It is optional in a MAP_CREATE operation and might be missing, in which case the client should use a null value for it - * and treat it as the "earliest possible" serial for comparison purposes. - * Spec: OME2b - */ - val timeserial: String? = null, - - /** - * A timestamp from the [timeserial] field. Only present if [tombstone] is `true` - * Spec: OME2d - */ - val serialTimestamp: Long? = null, - - /** - * The data that represents the value of the map entry. - * Spec: OME2c - */ - val data: ObjectData? = null -) - -/** - * An ObjectMap object represents a map of key-value pairs. - * Spec: OMP1 - */ -internal data class ObjectsMap( - /** - * The conflict-resolution semantics used by the map object. - * Spec: OMP3a - */ - val semantics: ObjectsMapSemantics? = null, - - /** - * The map entries, indexed by key. - * Spec: OMP3b - */ - val entries: Map? = null, - - /** - * The serial value of the last MAP_CLEAR operation applied to the map. - * Spec: OMP3c - */ - val clearTimeserial: String? = null, -) - -/** - * An ObjectCounter object represents an incrementable and decrementable value - * Spec: OCN1 - */ -internal data class ObjectsCounter( - /** - * The value of the counter - * Spec: OCN2a - */ - val count: Double? = null -) - -/** - * An ObjectOperation describes an operation to be applied to an object on a channel. - * Spec: OOP1 - */ -internal data class ObjectOperation( - /** - * Defines the operation to be applied to the object. - * Spec: OOP3a - */ - val action: ObjectOperationAction, - - /** - * The object ID of the object on a channel to which the operation should be applied. - * Spec: OOP3b - */ - val objectId: String, - - /** - * Payload for MAP_CREATE operation. - * Spec: OOP3j - */ - val mapCreate: MapCreate? = null, - - /** - * Payload for MAP_SET operation. - * Spec: OOP3k - */ - val mapSet: MapSet? = null, - - /** - * Payload for MAP_REMOVE operation. - * Spec: OOP3l - */ - val mapRemove: MapRemove? = null, - - /** - * Payload for COUNTER_CREATE operation. - * Spec: OOP3m - */ - val counterCreate: CounterCreate? = null, - - /** - * Payload for COUNTER_INC operation. - * Spec: OOP3n - */ - val counterInc: CounterInc? = null, - - /** - * Payload for OBJECT_DELETE operation. - * Spec: OOP3o - */ - val objectDelete: ObjectDelete? = null, - - /** - * Payload for MAP_CREATE_WITH_OBJECT_ID operation. - * Spec: OOP3p - */ - val mapCreateWithObjectId: MapCreateWithObjectId? = null, - - /** - * Payload for COUNTER_CREATE_WITH_OBJECT_ID operation. - * Spec: OOP3q - */ - val counterCreateWithObjectId: CounterCreateWithObjectId? = null, - - /** - * Payload for MAP_CLEAR operation. - * Spec: OOP3r - */ - val mapClear: MapClear? = null, -) - -/** - * An ObjectState describes the instantaneous state of an object on a channel. - * Spec: OST1 - */ -internal data class ObjectState( - /** - * The identifier of the object. - * Spec: OST2a - */ - val objectId: String, - - /** - * A map of serials keyed by a {@link ObjectMessage.siteCode}, - * representing the last operations applied to this object - * Spec: OST2b - */ - val siteTimeserials: Map, - - /** - * True if the object has been tombstoned. - * Spec: OST2c - */ - val tombstone: Boolean, - - /** - * The operation that created the object. - * Can be missing if create operation for the object is not known at this point. - * Spec: OST2d - */ - val createOp: ObjectOperation? = null, - - /** - * The data that represents the result of applying all operations to a Map object - * excluding the initial value from the create operation if it is a Map object type. - * Spec: OST2e - */ - val map: ObjectsMap? = null, - - /** - * The data that represents the result of applying all operations to a Counter object - * excluding the initial value from the create operation if it is a Counter object type. - * Spec: OST2f - */ - val counter: ObjectsCounter? = null -) - -/** - * An @ObjectMessage@ represents an individual object message to be sent or received via the Ably Realtime service. - * Spec: OM1 - */ -internal data class ObjectMessage( - /** - * unique ID for this object message. This attribute is always populated for object messages received over REST. - * For object messages received over Realtime, if the object message does not contain an @id@, - * it should be set to @protocolMsgId:index@, where @protocolMsgId@ is the id of the @ProtocolMessage@ encapsulating it, - * and @index@ is the index of the object message inside the @state@ array of the @ProtocolMessage@ - * Spec: OM2a - */ - val id: String? = null, - - /** - * time in milliseconds since epoch. If an object message received from Ably does not contain a @timestamp@, - * it should be set to the @timestamp@ of the encapsulating @ProtocolMessage@ - * Spec: OM2e - */ - val timestamp: Long? = null, - - /** - * Spec: OM2b - */ - val clientId: String? = null, - - /** - * If an object message received from Ably does not contain a @connectionId@, - * it should be set to the @connectionId@ of the encapsulating @ProtocolMessage@ - * Spec: OM2c - */ - val connectionId: String? = null, - - /** - * JSON-encodable object, used to contain any arbitrary key value pairs which may also contain other primitive JSON types, - * JSON-encodable objects or JSON-encodable arrays. The @extras@ field is provided to contain message metadata and/or - * ancillary payloads in support of specific functionality. For 3.1 no specific functionality is specified for - * @extras@ in object messages. Unless otherwise specified, the client library should not attempt to do any filtering - * or validation of the @extras@ field itself, but should treat it opaquely, encoding it and passing it to realtime unaltered - * Spec: OM2d - */ - val extras: JsonObject? = null, - - /** - * Describes an operation to be applied to an object. - * Mutually exclusive with the `object` field. This field is only set on object messages if the `action` field of the - * `ProtocolMessage` encapsulating it is `OBJECT`. - * Spec: OM2f - */ - val operation: ObjectOperation? = null, - - /** - * Describes the instantaneous state of an object. - * Mutually exclusive with the `operation` field. This field is only set on object messages if the `action` field of - * the `ProtocolMessage` encapsulating it is `OBJECT_SYNC`. - * Spec: OM2g - */ - @SerializedName("object") - val objectState: ObjectState? = null, - - /** - * An opaque string that uniquely identifies this object message. - * Spec: OM2h - */ - val serial: String? = null, - - /** - * A timestamp from the [serial] field. - * Spec: OM2j - */ - val serialTimestamp: Long? = null, - - /** - * An opaque string used as a key to update the map of serial values on an object. - * Spec: OM2i - */ - val siteCode: String? = null -) - -/** - * Calculates the size of an ObjectMessage in bytes. - * Spec: OM3 - */ -internal fun ObjectMessage.size(): Int { - val clientIdSize = clientId?.byteSize ?: 0 // Spec: OM3f - val operationSize = operation?.size() ?: 0 // Spec: OM3b, OOP4 - val objectStateSize = objectState?.size() ?: 0 // Spec: OM3c, OST3 - val extrasSize = extras?.let { gson.toJson(it).length } ?: 0 // Spec: OM3d - - return clientIdSize + operationSize + objectStateSize + extrasSize -} - -/** - * Calculates the size of an ObjectOperation in bytes. - * Spec: OOP4 - */ -private fun ObjectOperation.size(): Int { - val mapCreateSize = mapCreate?.size() ?: mapCreateWithObjectId?.derivedFrom?.size() ?: 0 - val mapSetSize = mapSet?.size() ?: 0 - val mapRemoveSize = mapRemove?.size() ?: 0 - val counterCreateSize = counterCreate?.size() ?: counterCreateWithObjectId?.derivedFrom?.size() ?: 0 - val counterIncSize = counterInc?.size() ?: 0 - - return mapCreateSize + mapSetSize + mapRemoveSize + - counterCreateSize + counterIncSize -} - -/** - * Calculates the size of an ObjectState in bytes. - * Spec: OST3 - */ -private fun ObjectState.size(): Int { - val mapSize = map?.size() ?: 0 // Spec: OST3b, OMP4 - val counterSize = counter?.size() ?: 0 // Spec: OST3c, OCN3 - val createOpSize = createOp?.size() ?: 0 // Spec: OST3d, OOP4 - - return mapSize + counterSize + createOpSize -} - -/** - * Calculates the size of a MapCreate payload in bytes. - */ -private fun MapCreate.size(): Int { - return entries.entries.sumOf { it.key.byteSize + it.value.size() } -} - -/** - * Calculates the size of a MapSet payload in bytes. - */ -private fun MapSet.size(): Int { - return key.byteSize + value.size() -} - -/** - * Calculates the size of a MapRemove payload in bytes. - */ -private fun MapRemove.size(): Int { - return key.byteSize -} - -/** - * Calculates the size of a CounterCreate payload in bytes. - */ -private fun CounterCreate.size(): Int { - return 8 // Double is 8 bytes -} - -/** - * Calculates the size of a CounterInc payload in bytes. - */ -private fun CounterInc.size(): Int { - return 8 // Double is 8 bytes -} - -/** - * Calculates the size of a MapCreateWithObjectId payload in bytes. - */ -private fun MapCreateWithObjectId.size(): Int { - return initialValue.byteSize + nonce.byteSize -} - -/** - * Calculates the size of a CounterCreateWithObjectId payload in bytes. - */ -private fun CounterCreateWithObjectId.size(): Int { - return initialValue.byteSize + nonce.byteSize -} - -/** - * Calculates the size of an ObjectMap in bytes. - * Spec: OMP4 - */ -private fun ObjectsMap.size(): Int { - // Calculate the size of all map entries in the map property - val entriesSize = entries?.entries?.sumOf { - it.key.length + it.value.size() // // Spec: OMP4a1, OMP4a2 - } ?: 0 - - return entriesSize -} - -/** - * Calculates the size of an ObjectCounter in bytes. - * Spec: OCN3 - */ -private fun ObjectsCounter.size(): Int { - // Size is 8 if count is a number, 0 if count is null or omitted - return if (count != null) 8 else 0 -} - -/** - * Calculates the size of a MapEntry in bytes. - * Spec: OME3 - */ -private fun ObjectsMapEntry.size(): Int { - // The size is equal to the size of the data property, calculated per "OD3" - return data?.size() ?: 0 -} - -/** - * Calculates the size of an ObjectData in bytes. - * Spec: OD3 - */ -private fun ObjectData.size(): Int { - string?.let { return it.byteSize } // Spec: OD3e - number?.let { return 8 } // Spec: OD3d - boolean?.let { return 1 } // Spec: OD3b - bytes?.let { return Base64.getDecoder().decode(it).size } // Spec: OD3c - json?.let { return it.toString().byteSize } // Spec: OD3e - return 0 -} - -internal fun ObjectData?.isInvalid(): Boolean { - return this?.objectId.isNullOrEmpty() && - this?.string == null && - this?.number == null && - this?.boolean == null && - this?.bytes == null && - this?.json == null -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt deleted file mode 100644 index 09b8b1c14..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/ServerTime.kt +++ /dev/null @@ -1,37 +0,0 @@ -package io.ably.lib.objects - -import io.ably.lib.types.AblyException -import io.ably.lib.util.SystemClock -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlin.concurrent.Volatile - -/** - * ServerTime is a utility object that provides the current server time - * Spec: RTO16 - */ -internal object ServerTime { - @Volatile - private var serverTimeOffset: Long? = null - private val mutex = Mutex() - - /** - * Spec: RTO16a - */ - @Throws(AblyException::class) - internal suspend fun getCurrentTime(adapter: ObjectsAdapter): Long { - val clock = SystemClock.clockFrom(adapter.clientOptions) - if (serverTimeOffset == null) { - mutex.withLock { - if (serverTimeOffset == null) { // Double-checked locking to ensure thread safety - val serverTime: Long = withContext(Dispatchers.IO) { adapter.time } - serverTimeOffset = serverTime - clock.currentTimeMillis() - return serverTime - } - } - } - return clock.currentTimeMillis() + serverTimeOffset!! - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt deleted file mode 100644 index 8267a360d..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/DefaultSerialization.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.ably.lib.objects.serialization - -import com.google.gson.* -import io.ably.lib.objects.* - -import io.ably.lib.objects.ObjectMessage -import org.msgpack.core.MessagePacker -import org.msgpack.core.MessageUnpacker - -/** - * Default implementation of {@link ObjectsSerializer} that handles serialization/deserialization - * of ObjectMessage arrays for both JSON and MessagePack formats using Jackson and Gson. - * Dynamically loaded by ObjectsHelper#getSerializer() to avoid hard dependencies. - */ -@Suppress("unused") // Used via reflection in ObjectsHelper -internal class DefaultObjectsSerializer : ObjectsSerializer { - - override fun readMsgpackArray(unpacker: MessageUnpacker): Array { - val objectMessagesCount = unpacker.unpackArrayHeader() - return Array(objectMessagesCount) { readObjectMessage(unpacker) } - } - - override fun writeMsgpackArray(objects: Array, packer: MessagePacker) { - val objectMessages = objects.map { it as ObjectMessage } - packer.packArrayHeader(objectMessages.size) - objectMessages.forEach { it.writeMsgpack(packer) } - } - - override fun readFromJsonArray(json: JsonArray): Array { - return json.map { element -> - if (element.isJsonObject) element.asJsonObject.toObjectMessage() - else throw JsonParseException("Expected JsonObject, but found: $element") - }.toTypedArray() - } - - override fun asJsonArray(objects: Array): JsonArray { - val objectMessages = objects.map { it as ObjectMessage } - val jsonArray = JsonArray() - for (objectMessage in objectMessages) { - jsonArray.add(objectMessage.toJsonObject()) - } - return jsonArray - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt deleted file mode 100644 index fbf5acb88..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/JsonSerialization.kt +++ /dev/null @@ -1,67 +0,0 @@ -package io.ably.lib.objects.serialization - -import com.google.gson.* -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperationAction -import java.lang.reflect.Type -import kotlin.enums.EnumEntries - -// Gson instance for JSON serialization/deserialization -internal val gson = GsonBuilder() - .registerTypeAdapter(ObjectOperationAction::class.java, EnumCodeTypeAdapter({ it.code }, ObjectOperationAction.entries)) - .registerTypeAdapter(ObjectsMapSemantics::class.java, EnumCodeTypeAdapter({ it.code }, ObjectsMapSemantics.entries)) - .create() - -internal fun ObjectMessage.toJsonObject(): JsonObject { - return gson.toJsonTree(this).asJsonObject -} - -internal fun JsonObject.toObjectMessage(): ObjectMessage { - return gson.fromJson(this, ObjectMessage::class.java) -} - -internal class EnumCodeTypeAdapter>( - private val getCode: (T) -> Int, - private val enumValues: EnumEntries -) : JsonSerializer, JsonDeserializer { - - override fun serialize(src: T, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { - return JsonPrimitive(getCode(src)) - } - - override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): T { - val code = json.asInt - return enumValues.firstOrNull { getCode(it) == code } ?: enumValues.firstOrNull { getCode(it) == -1 } - ?: throw JsonParseException("Unknown enum code: $code and no Unknown fallback found") - } -} - -internal class ObjectDataJsonSerializer : JsonSerializer, JsonDeserializer { - override fun serialize(src: ObjectData, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { - val obj = JsonObject() - src.objectId?.let { obj.addProperty("objectId", it) } - src.string?.let { obj.addProperty("string", it) } - src.number?.let { obj.addProperty("number", it) } - src.boolean?.let { obj.addProperty("boolean", it) } - src.bytes?.let { obj.addProperty("bytes", it) } - src.json?.let { obj.addProperty("json", it.toString()) } // Spec: OD4c5 - return obj - } - - override fun deserialize(json: JsonElement, typeOfT: Type?, context: JsonDeserializationContext?): ObjectData { - val obj = if (json.isJsonObject) json.asJsonObject else throw JsonParseException("Expected JsonObject") - val objectId = if (obj.has("objectId")) obj.get("objectId").asString else null - val string = if (obj.has("string")) obj.get("string").asString else null - val number = if (obj.has("number")) obj.get("number").asDouble else null - val boolean = if (obj.has("boolean")) obj.get("boolean").asBoolean else null - val bytes = if (obj.has("bytes")) obj.get("bytes").asString else null - val json = if (obj.has("json")) JsonParser.parseString(obj.get("json").asString) else null - - if (objectId == null && string == null && number == null && boolean == null && bytes == null && json == null) { - throw JsonParseException("Since objectId is not present, at least one of the value fields must be present") - } - return ObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json) - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt deleted file mode 100644 index 2eb10d0bd..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/serialization/MsgpackSerialization.kt +++ /dev/null @@ -1,909 +0,0 @@ -package io.ably.lib.objects.serialization - -import com.google.gson.JsonElement -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterCreateWithObjectId -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.ErrorCode -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapCreateWithObjectId -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.MapClear -import io.ably.lib.objects.ObjectDelete -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectsCounter -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectsMap -import io.ably.lib.objects.ObjectsMapEntry -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectState -import java.util.Base64 -import io.ably.lib.util.Serialisation -import org.msgpack.core.MessageFormat -import org.msgpack.core.MessagePacker -import org.msgpack.core.MessageUnpacker - -/** - * Write ObjectMessage to MessagePacker - */ -internal fun ObjectMessage.writeMsgpack(packer: MessagePacker) { - var fieldCount = 0 - - if (id != null) fieldCount++ - if (timestamp != null) fieldCount++ - if (clientId != null) fieldCount++ - if (connectionId != null) fieldCount++ - if (extras != null) fieldCount++ - if (operation != null) fieldCount++ - if (objectState != null) fieldCount++ - if (serial != null) fieldCount++ - if (serialTimestamp != null) fieldCount++ - if (siteCode != null) fieldCount++ - - packer.packMapHeader(fieldCount) - - if (id != null) { - packer.packString("id") - packer.packString(id) - } - - if (timestamp != null) { - packer.packString("timestamp") - packer.packLong(timestamp) - } - - if (clientId != null) { - packer.packString("clientId") - packer.packString(clientId) - } - - if (connectionId != null) { - packer.packString("connectionId") - packer.packString(connectionId) - } - - if (extras != null) { - packer.packString("extras") - packer.writePayload(Serialisation.gsonToMsgpack(extras)) - } - - if (operation != null) { - packer.packString("operation") - operation.writeMsgpack(packer) - } - - if (objectState != null) { - packer.packString("object") - objectState.writeMsgpack(packer) - } - - if (serial != null) { - packer.packString("serial") - packer.packString(serial) - } - - if (serialTimestamp != null) { - packer.packString("serialTimestamp") - packer.packLong(serialTimestamp) - } - - if (siteCode != null) { - packer.packString("siteCode") - packer.packString(siteCode) - } -} - -/** - * Read an ObjectMessage from MessageUnpacker - */ -internal fun readObjectMessage(unpacker: MessageUnpacker): ObjectMessage { - if (unpacker.nextFormat == MessageFormat.NIL) { - unpacker.unpackNil() - return ObjectMessage() // default/empty message - } - - val fieldCount = unpacker.unpackMapHeader() - - var id: String? = null - var timestamp: Long? = null - var clientId: String? = null - var connectionId: String? = null - var extras: JsonObject? = null - var operation: ObjectOperation? = null - var objectState: ObjectState? = null - var serial: String? = null - var serialTimestamp: Long? = null - var siteCode: String? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - - if (fieldFormat == MessageFormat.NIL) { - unpacker.unpackNil() - continue - } - - when (fieldName) { - "id" -> id = unpacker.unpackString() - "timestamp" -> timestamp = unpacker.unpackLong() - "clientId" -> clientId = unpacker.unpackString() - "connectionId" -> connectionId = unpacker.unpackString() - "extras" -> extras = Serialisation.msgpackToGson(unpacker.unpackValue()) as? JsonObject - "operation" -> operation = readObjectOperation(unpacker) - "object" -> objectState = readObjectState(unpacker) - "serial" -> serial = unpacker.unpackString() - "serialTimestamp" -> serialTimestamp = unpacker.unpackLong() - "siteCode" -> siteCode = unpacker.unpackString() - else -> unpacker.skipValue() - } - } - - return ObjectMessage( - id = id, - timestamp = timestamp, - clientId = clientId, - connectionId = connectionId, - extras = extras, - operation = operation, - objectState = objectState, - serial = serial, - serialTimestamp = serialTimestamp, - siteCode = siteCode - ) -} - -/** - * Write ObjectOperation to MessagePacker - */ -private fun ObjectOperation.writeMsgpack(packer: MessagePacker) { - var fieldCount = 1 // action is always required - require(objectId.isNotEmpty()) { "objectId must be non-empty per Objects protocol" } - fieldCount++ - - if (mapCreate != null) fieldCount++ - if (mapSet != null) fieldCount++ - if (mapRemove != null) fieldCount++ - if (counterCreate != null) fieldCount++ - if (counterInc != null) fieldCount++ - if (objectDelete != null) fieldCount++ - if (mapCreateWithObjectId != null) fieldCount++ - if (counterCreateWithObjectId != null) fieldCount++ - if (mapClear != null) fieldCount++ - - packer.packMapHeader(fieldCount) - - packer.packString("action") - packer.packInt(action.code) - - // Always include objectId as per Objects protocol - packer.packString("objectId") - packer.packString(objectId) - - if (mapCreate != null) { - packer.packString("mapCreate") - mapCreate.writeMsgpack(packer) - } - - if (mapSet != null) { - packer.packString("mapSet") - mapSet.writeMsgpack(packer) - } - - if (mapRemove != null) { - packer.packString("mapRemove") - mapRemove.writeMsgpack(packer) - } - - if (counterCreate != null) { - packer.packString("counterCreate") - counterCreate.writeMsgpack(packer) - } - - if (counterInc != null) { - packer.packString("counterInc") - counterInc.writeMsgpack(packer) - } - - if (objectDelete != null) { - packer.packString("objectDelete") - packer.packMapHeader(0) // empty map - } - - if (mapCreateWithObjectId != null) { - packer.packString("mapCreateWithObjectId") - mapCreateWithObjectId.writeMsgpack(packer) - } - - if (counterCreateWithObjectId != null) { - packer.packString("counterCreateWithObjectId") - counterCreateWithObjectId.writeMsgpack(packer) - } - - if (mapClear != null) { - packer.packString("mapClear") - packer.packMapHeader(0) // empty map, no fields - } - -} - -/** - * Read ObjectOperation from MessageUnpacker - */ -private fun readObjectOperation(unpacker: MessageUnpacker): ObjectOperation { - val fieldCount = unpacker.unpackMapHeader() - - var action: ObjectOperationAction? = null - var objectId: String = "" - var mapCreate: MapCreate? = null - var mapSet: MapSet? = null - var mapRemove: MapRemove? = null - var counterCreate: CounterCreate? = null - var counterInc: CounterInc? = null - var objectDelete: ObjectDelete? = null - var mapCreateWithObjectId: MapCreateWithObjectId? = null - var counterCreateWithObjectId: CounterCreateWithObjectId? = null - var mapClear: MapClear? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - - if (fieldFormat == MessageFormat.NIL) { - unpacker.unpackNil() - continue - } - - when (fieldName) { - "action" -> { - val actionCode = unpacker.unpackInt() - action = ObjectOperationAction.entries.firstOrNull { it.code == actionCode } - ?: ObjectOperationAction.entries.firstOrNull { it.code == -1 } - ?: throw objectError("Unknown ObjectOperationAction code: $actionCode and no Unknown fallback found") - } - "objectId" -> objectId = unpacker.unpackString() - "mapCreate" -> mapCreate = readMapCreate(unpacker) - "mapSet" -> mapSet = readMapSet(unpacker) - "mapRemove" -> mapRemove = readMapRemove(unpacker) - "counterCreate" -> counterCreate = readCounterCreate(unpacker) - "counterInc" -> counterInc = readCounterInc(unpacker) - "objectDelete" -> { - unpacker.skipValue() // empty map, just consume it - objectDelete = ObjectDelete - } - "mapCreateWithObjectId" -> mapCreateWithObjectId = readMapCreateWithObjectId(unpacker) - "counterCreateWithObjectId" -> counterCreateWithObjectId = readCounterCreateWithObjectId(unpacker) - "mapClear" -> { - unpacker.skipValue() // empty map, consume it - mapClear = MapClear - } - else -> unpacker.skipValue() - } - } - - if (action == null) { - throw objectError("Missing required 'action' field in ObjectOperation") - } - - return ObjectOperation( - action = action, - objectId = objectId, - mapCreate = mapCreate, - mapSet = mapSet, - mapRemove = mapRemove, - counterCreate = counterCreate, - counterInc = counterInc, - objectDelete = objectDelete, - mapCreateWithObjectId = mapCreateWithObjectId, - counterCreateWithObjectId = counterCreateWithObjectId, - mapClear = mapClear, - ) -} - -/** - * Write ObjectState to MessagePacker - */ -private fun ObjectState.writeMsgpack(packer: MessagePacker) { - var fieldCount = 3 // objectId, siteTimeserials, and tombstone are required - - if (createOp != null) fieldCount++ - if (map != null) fieldCount++ - if (counter != null) fieldCount++ - - packer.packMapHeader(fieldCount) - - packer.packString("objectId") - packer.packString(objectId) - - packer.packString("siteTimeserials") - packer.packMapHeader(siteTimeserials.size) - for ((key, value) in siteTimeserials) { - packer.packString(key) - packer.packString(value) - } - - packer.packString("tombstone") - packer.packBoolean(tombstone) - - if (createOp != null) { - packer.packString("createOp") - createOp.writeMsgpack(packer) - } - - if (map != null) { - packer.packString("map") - map.writeMsgpack(packer) - } - - if (counter != null) { - packer.packString("counter") - counter.writeMsgpack(packer) - } -} - -/** - * Read ObjectState from MessageUnpacker - */ -private fun readObjectState(unpacker: MessageUnpacker): ObjectState { - val fieldCount = unpacker.unpackMapHeader() - - var objectId = "" - var siteTimeserials = mapOf() - var tombstone = false - var createOp: ObjectOperation? = null - var map: ObjectsMap? = null - var counter: ObjectsCounter? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - - if (fieldFormat == MessageFormat.NIL) { - unpacker.unpackNil() - continue - } - - when (fieldName) { - "objectId" -> objectId = unpacker.unpackString() - "siteTimeserials" -> { - val mapSize = unpacker.unpackMapHeader() - val tempMap = mutableMapOf() - for (j in 0 until mapSize) { - val key = unpacker.unpackString() - val value = unpacker.unpackString() - tempMap[key] = value - } - siteTimeserials = tempMap - } - "tombstone" -> tombstone = unpacker.unpackBoolean() - "createOp" -> createOp = readObjectOperation(unpacker) - "map" -> map = readObjectMap(unpacker) - "counter" -> counter = readObjectCounter(unpacker) - else -> unpacker.skipValue() - } - } - - return ObjectState( - objectId = objectId, - siteTimeserials = siteTimeserials, - tombstone = tombstone, - createOp = createOp, - map = map, - counter = counter - ) -} - -/** - * Write MapCreate to MessagePacker - */ -private fun MapCreate.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(2) - packer.packString("semantics") - packer.packInt(semantics.code) - packer.packString("entries") - packer.packMapHeader(entries.size) - for ((key, value) in entries) { - packer.packString(key) - value.writeMsgpack(packer) - } -} - -/** - * Read MapCreate from MessageUnpacker - */ -private fun readMapCreate(unpacker: MessageUnpacker): MapCreate { - val fieldCount = unpacker.unpackMapHeader() - var semantics: ObjectsMapSemantics = ObjectsMapSemantics.LWW - var entries: Map = emptyMap() - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } - when (fieldName) { - "semantics" -> { - val code = unpacker.unpackInt() - semantics = ObjectsMapSemantics.entries.firstOrNull { it.code == code } - ?: ObjectsMapSemantics.entries.firstOrNull { it.code == -1 } - ?: throw objectError("Unknown MapSemantics code: $code and no UNKNOWN fallback found") - } - "entries" -> { - val mapSize = unpacker.unpackMapHeader() - val tempMap = mutableMapOf() - for (j in 0 until mapSize) { - tempMap[unpacker.unpackString()] = readObjectMapEntry(unpacker) - } - entries = tempMap - } - else -> unpacker.skipValue() - } - } - return MapCreate(semantics = semantics, entries = entries) -} - -/** - * Write MapSet to MessagePacker - */ -private fun MapSet.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(2) - packer.packString("key") - packer.packString(key) - packer.packString("value") - value.writeMsgpack(packer) -} - -/** - * Read MapSet from MessageUnpacker - */ -private fun readMapSet(unpacker: MessageUnpacker): MapSet { - val fieldCount = unpacker.unpackMapHeader() - var key: String? = null - var value: ObjectData? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } - when (fieldName) { - "key" -> key = unpacker.unpackString() - "value" -> value = readObjectData(unpacker) - else -> unpacker.skipValue() - } - } - return MapSet( - key = key ?: throw objectError("Missing 'key' in MapSet payload"), - value = value ?: throw objectError("Missing 'value' in MapSet payload") - ) -} - -/** - * Write MapRemove to MessagePacker - */ -private fun MapRemove.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(1) - packer.packString("key") - packer.packString(key) -} - -/** - * Read MapRemove from MessageUnpacker - */ -private fun readMapRemove(unpacker: MessageUnpacker): MapRemove { - val fieldCount = unpacker.unpackMapHeader() - var key: String? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } - when (fieldName) { - "key" -> key = unpacker.unpackString() - else -> unpacker.skipValue() - } - } - return MapRemove(key = key ?: throw objectError("Missing 'key' in MapRemove payload")) -} - -/** - * Write CounterCreate to MessagePacker - */ -private fun CounterCreate.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(1) - packer.packString("count") - packer.packDouble(count) -} - -/** - * Read CounterCreate from MessageUnpacker - */ -private fun readCounterCreate(unpacker: MessageUnpacker): CounterCreate { - val fieldCount = unpacker.unpackMapHeader() - var count: Double? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } - when (fieldName) { - "count" -> count = unpacker.unpackDouble() - else -> unpacker.skipValue() - } - } - return CounterCreate(count = count ?: throw objectError("Missing 'count' in CounterCreate payload")) -} - -/** - * Write CounterInc to MessagePacker - */ -private fun CounterInc.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(1) - packer.packString("number") - packer.packDouble(number) -} - -/** - * Read CounterInc from MessageUnpacker - */ -private fun readCounterInc(unpacker: MessageUnpacker): CounterInc { - val fieldCount = unpacker.unpackMapHeader() - var number: Double? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } - when (fieldName) { - "number" -> number = unpacker.unpackDouble() - else -> unpacker.skipValue() - } - } - return CounterInc(number = number ?: throw objectError("Missing 'number' in CounterInc payload")) -} - -/** - * Write MapCreateWithObjectId to MessagePacker - */ -private fun MapCreateWithObjectId.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(2) - packer.packString("initialValue") - packer.packString(initialValue) - packer.packString("nonce") - packer.packString(nonce) -} - -/** - * Read MapCreateWithObjectId from MessageUnpacker - */ -private fun readMapCreateWithObjectId(unpacker: MessageUnpacker): MapCreateWithObjectId { - val fieldCount = unpacker.unpackMapHeader() - var initialValue: String? = null - var nonce: String? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } - when (fieldName) { - "initialValue" -> initialValue = unpacker.unpackString() - "nonce" -> nonce = unpacker.unpackString() - else -> unpacker.skipValue() - } - } - return MapCreateWithObjectId( - initialValue = initialValue ?: throw objectError("Missing 'initialValue' in MapCreateWithObjectId payload"), - nonce = nonce ?: throw objectError("Missing 'nonce' in MapCreateWithObjectId payload") - ) -} - -/** - * Write CounterCreateWithObjectId to MessagePacker - */ -private fun CounterCreateWithObjectId.writeMsgpack(packer: MessagePacker) { - packer.packMapHeader(2) - packer.packString("initialValue") - packer.packString(initialValue) - packer.packString("nonce") - packer.packString(nonce) -} - -/** - * Read CounterCreateWithObjectId from MessageUnpacker - */ -private fun readCounterCreateWithObjectId(unpacker: MessageUnpacker): CounterCreateWithObjectId { - val fieldCount = unpacker.unpackMapHeader() - var initialValue: String? = null - var nonce: String? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - if (fieldFormat == MessageFormat.NIL) { unpacker.unpackNil(); continue } - when (fieldName) { - "initialValue" -> initialValue = unpacker.unpackString() - "nonce" -> nonce = unpacker.unpackString() - else -> unpacker.skipValue() - } - } - return CounterCreateWithObjectId( - initialValue = initialValue ?: throw objectError("Missing 'initialValue' in CounterCreateWithObjectId payload"), - nonce = nonce ?: throw objectError("Missing 'nonce' in CounterCreateWithObjectId payload") - ) -} - -/** - * Write ObjectMap to MessagePacker - */ -private fun ObjectsMap.writeMsgpack(packer: MessagePacker) { - var fieldCount = 0 - - if (semantics != null) fieldCount++ - if (entries != null) fieldCount++ - if (clearTimeserial != null) fieldCount++ - - packer.packMapHeader(fieldCount) - - if (semantics != null) { - packer.packString("semantics") - packer.packInt(semantics.code) - } - - if (entries != null) { - packer.packString("entries") - packer.packMapHeader(entries.size) - for ((key, value) in entries) { - packer.packString(key) - value.writeMsgpack(packer) - } - } - - if (clearTimeserial != null) { - packer.packString("clearTimeserial") - packer.packString(clearTimeserial) - } -} - -/** - * Read ObjectMap from MessageUnpacker - */ -private fun readObjectMap(unpacker: MessageUnpacker): ObjectsMap { - val fieldCount = unpacker.unpackMapHeader() - - var semantics: ObjectsMapSemantics? = null - var entries: Map? = null - var clearTimeserial: String? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - - if (fieldFormat == MessageFormat.NIL) { - unpacker.unpackNil() - continue - } - - when (fieldName) { - "semantics" -> { - val semanticsCode = unpacker.unpackInt() - semantics = ObjectsMapSemantics.entries.firstOrNull { it.code == semanticsCode } - ?: ObjectsMapSemantics.entries.firstOrNull { it.code == -1 } - ?: throw objectError("Unknown MapSemantics code: $semanticsCode and no UNKNOWN fallback found") - } - "entries" -> { - val mapSize = unpacker.unpackMapHeader() - val tempMap = mutableMapOf() - for (j in 0 until mapSize) { - val key = unpacker.unpackString() - val value = readObjectMapEntry(unpacker) - tempMap[key] = value - } - entries = tempMap - } - "clearTimeserial" -> clearTimeserial = unpacker.unpackString() - else -> unpacker.skipValue() - } - } - - return ObjectsMap(semantics = semantics, entries = entries, clearTimeserial = clearTimeserial) -} - -/** - * Write ObjectCounter to MessagePacker - */ -private fun ObjectsCounter.writeMsgpack(packer: MessagePacker) { - var fieldCount = 0 - - if (count != null) fieldCount++ - - packer.packMapHeader(fieldCount) - - if (count != null) { - packer.packString("count") - packer.packDouble(count) - } -} - -/** - * Read ObjectCounter from MessageUnpacker - */ -private fun readObjectCounter(unpacker: MessageUnpacker): ObjectsCounter { - val fieldCount = unpacker.unpackMapHeader() - - var count: Double? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - - if (fieldFormat == MessageFormat.NIL) { - unpacker.unpackNil() - continue - } - - when (fieldName) { - "count" -> count = unpacker.unpackDouble() - else -> unpacker.skipValue() - } - } - - return ObjectsCounter(count = count) -} - -/** - * Write ObjectMapEntry to MessagePacker - */ -private fun ObjectsMapEntry.writeMsgpack(packer: MessagePacker) { - var fieldCount = 0 - - if (tombstone != null) fieldCount++ - if (timeserial != null) fieldCount++ - if (serialTimestamp != null) fieldCount++ - if (data != null) fieldCount++ - - packer.packMapHeader(fieldCount) - - if (tombstone != null) { - packer.packString("tombstone") - packer.packBoolean(tombstone) - } - - if (timeserial != null) { - packer.packString("timeserial") - packer.packString(timeserial) - } - - if (serialTimestamp != null) { - packer.packString("serialTimestamp") - packer.packLong(serialTimestamp) - } - - if (data != null) { - packer.packString("data") - data.writeMsgpack(packer) - } -} - -/** - * Read ObjectMapEntry from MessageUnpacker - */ -private fun readObjectMapEntry(unpacker: MessageUnpacker): ObjectsMapEntry { - val fieldCount = unpacker.unpackMapHeader() - - var tombstone: Boolean? = null - var timeserial: String? = null - var serialTimestamp: Long? = null - var data: ObjectData? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - - if (fieldFormat == MessageFormat.NIL) { - unpacker.unpackNil() - continue - } - - when (fieldName) { - "tombstone" -> tombstone = unpacker.unpackBoolean() - "timeserial" -> timeserial = unpacker.unpackString() - "serialTimestamp" -> serialTimestamp = unpacker.unpackLong() - "data" -> data = readObjectData(unpacker) - else -> unpacker.skipValue() - } - } - - return ObjectsMapEntry(tombstone = tombstone, timeserial = timeserial, serialTimestamp = serialTimestamp, data = data) -} - -/** - * Write ObjectData to MessagePacker - */ -private fun ObjectData.writeMsgpack(packer: MessagePacker) { - var fieldCount = 0 - - if (objectId != null) fieldCount++ - if (string != null) fieldCount++ - if (number != null) fieldCount++ - if (boolean != null) fieldCount++ - if (bytes != null) fieldCount++ - if (json != null) fieldCount++ - - packer.packMapHeader(fieldCount) - - if (objectId != null) { - packer.packString("objectId") - packer.packString(objectId) - } - - if (string != null) { - packer.packString("string") - packer.packString(string) - } - - if (number != null) { - packer.packString("number") - packer.packDouble(number) - } - - if (boolean != null) { - packer.packString("boolean") - packer.packBoolean(boolean) - } - - if (bytes != null) { - val rawBytes = Base64.getDecoder().decode(bytes) - packer.packString("bytes") - packer.packBinaryHeader(rawBytes.size) - packer.writePayload(rawBytes) - } - - if (json != null) { - packer.packString("json") - packer.packString(json.toString()) - } -} - -/** - * Read ObjectData from MessageUnpacker - */ -private fun readObjectData(unpacker: MessageUnpacker): ObjectData { - val fieldCount = unpacker.unpackMapHeader() - var objectId: String? = null - var string: String? = null - var number: Double? = null - var boolean: Boolean? = null - var bytes: String? = null - var json: JsonElement? = null - - for (i in 0 until fieldCount) { - val fieldName = unpacker.unpackString().intern() - val fieldFormat = unpacker.nextFormat - - if (fieldFormat == MessageFormat.NIL) { - unpacker.unpackNil() - continue - } - - when (fieldName) { - "objectId" -> objectId = unpacker.unpackString() - "string" -> string = unpacker.unpackString() - "number" -> number = unpacker.unpackDouble() - "boolean" -> boolean = unpacker.unpackBoolean() - "bytes" -> { - val size = unpacker.unpackBinaryHeader() - val rawBytes = ByteArray(size) - unpacker.readPayload(rawBytes) - bytes = Base64.getEncoder().encodeToString(rawBytes) - } - "json" -> json = JsonParser.parseString(unpacker.unpackString()) - else -> unpacker.skipValue() - } - } - - return ObjectData(objectId = objectId, string = string, number = number, boolean = boolean, bytes = bytes, json = json) -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/ObjectLifecycle.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/ObjectLifecycle.kt deleted file mode 100644 index 70abdea85..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/ObjectLifecycle.kt +++ /dev/null @@ -1,84 +0,0 @@ -package io.ably.lib.objects.type - -import io.ably.lib.objects.ObjectsSubscription -import io.ably.lib.util.EventEmitter -import io.ably.lib.util.Log - -/** - * Internal enum representing object lifecycle states - */ -internal enum class ObjectLifecycle { - Created, - Active, - Deleted -} - -/** - * Maps internal ObjectLifecycle values to their corresponding public ObjectLifecycleEvent values. - * Used to determine which events should be emitted when lifecycle changes occur. - * CREATED and ACTIVE map to null (no public event), while DELETED maps to the public DELETED event. - */ -private val objectLifecycleToEventMap = mapOf( - ObjectLifecycle.Created to null, - ObjectLifecycle.Active to null, - ObjectLifecycle.Deleted to ObjectLifecycleEvent.DELETED -) - -/** - * An interface for managing and communicating changes in the lifecycle state of objects. - * - * Implementations should ensure thread-safe event emission and proper lifecycle - * event notifications. - */ -internal interface HandlesObjectLifecycleChange { - /** - * Handles changes in the lifecycle of objects by notifying all registered listeners. - * Implementations should ensure thread-safe event emission to both internal and public listeners. - * Makes sure every event is processed in the order they were received. - * @param newLifecycle The new lifecycle state of the object. - */ - fun objectLifecycleChanged(newLifecycle: ObjectLifecycle) - - /** - * Disposes all registered lifecycle change listeners and cancels any pending operations. - * Should be called when the associated object is no longer needed. - */ - fun disposeObjectLifecycleListeners() -} - -internal abstract class ObjectLifecycleCoordinator : ObjectLifecycleChange, HandlesObjectLifecycleChange { - private val tag = "ObjectLifecycleCoordinator" - // EventEmitter for users of the library - private val objectLifecycleEmitter = ObjectLifecycleEmitter() - - override fun on(event: ObjectLifecycleEvent, listener: ObjectLifecycleChange.Listener): ObjectsSubscription { - objectLifecycleEmitter.on(event, listener) - return ObjectsSubscription { - objectLifecycleEmitter.off(event, listener) - } - } - - override fun off(listener: ObjectLifecycleChange.Listener) = objectLifecycleEmitter.off(listener) - - override fun offAll() = objectLifecycleEmitter.off() - - override fun objectLifecycleChanged(newLifecycle: ObjectLifecycle) { - objectLifecycleToEventMap[newLifecycle]?.let { objectLifecycleEvent -> - objectLifecycleEmitter.emit(objectLifecycleEvent) - } - } - - override fun disposeObjectLifecycleListeners() = offAll() -} - -private class ObjectLifecycleEmitter : EventEmitter() { - private val tag = "ObjectLifecycleEmitter" - override fun apply(listener: ObjectLifecycleChange.Listener?, event: ObjectLifecycleEvent?, vararg args: Any?) { - try { - event?.let { listener?.onLifecycleEvent(it) } - ?: Log.w(tag, "Null event passed to ObjectLifecycleChange listener callback") - } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing listener callback for event: $event", t) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt deleted file mode 100644 index f6a9ee6c6..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/DefaultLiveCounter.kt +++ /dev/null @@ -1,137 +0,0 @@ -package io.ably.lib.objects.type.livecounter - -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.ObjectUpdate -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.objects.type.counter.LiveCounter -import io.ably.lib.objects.type.counter.LiveCounterChange -import io.ably.lib.objects.type.counter.LiveCounterUpdate -import io.ably.lib.objects.type.noOp -import java.util.concurrent.atomic.AtomicReference -import io.ably.lib.util.Log -import io.ably.lib.util.SystemClock -import kotlinx.coroutines.runBlocking - -/** - * @spec RTLC1/RTLC2 - LiveCounter implementation extends BaseRealtimeObject - */ -internal class DefaultLiveCounter private constructor( - objectId: String, - private val realtimeObjects: DefaultRealtimeObjects, -) : LiveCounter, BaseRealtimeObject(objectId, ObjectType.Counter, realtimeObjects.clock) { - - override val tag = "LiveCounter" - - /** - * Thread-safe reference to hold the counter data value. - * Accessed from public API for LiveCounter and updated by LiveCounterManager. - */ - internal val data = AtomicReference(0.0) // RTLC3 - - /** - * liveCounterManager instance for managing LiveCounter operations - */ - private val liveCounterManager = LiveCounterManager(this) - - private val channelName = realtimeObjects.channelName - private val adapter: ObjectsAdapter get() = realtimeObjects.adapter - private val asyncScope get() = realtimeObjects.asyncScope - - override fun increment(amount: Number) = runBlocking { incrementAsync(amount.toDouble()) } - - override fun decrement(amount: Number) = runBlocking { incrementAsync(-amount.toDouble()) } - - override fun incrementAsync(amount: Number, callback: ObjectsCallback) { - asyncScope.launchWithVoidCallback(callback) { incrementAsync(amount.toDouble()) } - } - - override fun decrementAsync(amount: Number, callback: ObjectsCallback) { - asyncScope.launchWithVoidCallback(callback) { incrementAsync(-amount.toDouble()) } - } - - override fun value(): Double { - adapter.throwIfInvalidAccessApiConfiguration(channelName) - return data.get() - } - - override fun subscribe(listener: LiveCounterChange.Listener): ObjectsSubscription { - adapter.throwIfInvalidAccessApiConfiguration(channelName) - return liveCounterManager.subscribe(listener) - } - - override fun unsubscribe(listener: LiveCounterChange.Listener) = liveCounterManager.unsubscribe(listener) - - override fun unsubscribeAll() = liveCounterManager.unsubscribeAll() - - override fun validate(state: ObjectState) = liveCounterManager.validate(state) - - private suspend fun incrementAsync(amount: Double) { - // RTLC12b, RTLC12c, RTLC12d - Validate write API configuration - adapter.throwIfInvalidWriteApiConfiguration(channelName) - - // RTLC12e1 - Validate input parameter - if (amount.isNaN() || amount.isInfinite()) { - throw invalidInputError("Counter value increment should be a valid number") - } - - // RTLC12e2, RTLC12e3, RTLC12e4 - Create ObjectMessage with the COUNTER_INC operation - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = objectId, - counterInc = CounterInc(number = amount) - ) - ) - - // RTLC12g - publish and apply locally on ACK - realtimeObjects.publishAndApply(arrayOf(msg)) - } - - override fun applyObjectState(objectState: ObjectState, message: ObjectMessage): LiveCounterUpdate { - return liveCounterManager.applyState(objectState, message.serialTimestamp) - } - - override fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage): Boolean { - return liveCounterManager.applyOperation(operation, message.serialTimestamp) - } - - override fun clearData(): LiveCounterUpdate { - return liveCounterManager.calculateUpdateFromDataDiff(data.get(), 0.0).apply { data.set(0.0) } - } - - override fun notifyUpdated(update: ObjectUpdate) { - if (update.noOp) { - return - } - Log.v(tag, "Object $objectId updated: $update") - liveCounterManager.notify(update as LiveCounterUpdate) - } - - override fun onGCInterval(gcGracePeriod: Long) { - // Nothing to GC for a counter object - return - } - - companion object { - /** - * Creates a zero-value counter object. - * @spec RTLC4 - Returns LiveCounter with 0 value - */ - internal fun zeroValue(objectId: String, realtimeObjects: DefaultRealtimeObjects): DefaultLiveCounter { - return DefaultLiveCounter(objectId, realtimeObjects) - } - - /** - * Creates initial value payload for counter creation. - * Spec: RTO12f12 - */ - internal fun initialValue(count: Number): CounterCreate { - return CounterCreate(count = count.toDouble()) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt deleted file mode 100644 index a1940dc04..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterChangeCoordinator.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.ably.lib.objects.type.livecounter - -import io.ably.lib.objects.ObjectsSubscription -import io.ably.lib.objects.type.counter.LiveCounterChange -import io.ably.lib.objects.type.counter.LiveCounterUpdate -import io.ably.lib.util.EventEmitter -import io.ably.lib.util.Log - -internal val noOpCounterUpdate = LiveCounterUpdate() - -/** - * Interface for handling live counter changes by notifying subscribers of updates. - * Implementations typically propagate updates through event emission to registered listeners. - */ -internal interface HandlesLiveCounterChange { - /** - * Notifies all registered listeners about a counter update by propagating the change through the event system. - * This method is called when counter data changes and triggers the emission of update events to subscribers. - */ - fun notify(update: LiveCounterUpdate) -} - -internal abstract class LiveCounterChangeCoordinator: LiveCounterChange, HandlesLiveCounterChange { - private val counterChangeEmitter = LiveCounterChangeEmitter() - - override fun subscribe(listener: LiveCounterChange.Listener): ObjectsSubscription { - counterChangeEmitter.on(listener) - return ObjectsSubscription { - counterChangeEmitter.off(listener) - } - } - - override fun unsubscribe(listener: LiveCounterChange.Listener) = counterChangeEmitter.off(listener) - - override fun unsubscribeAll() = counterChangeEmitter.off() - - override fun notify(update: LiveCounterUpdate) = counterChangeEmitter.emit(update) -} - -private class LiveCounterChangeEmitter : EventEmitter() { - private val tag = "LiveCounterChangeEmitter" - - override fun apply(listener: LiveCounterChange.Listener?, event: LiveCounterUpdate?, vararg args: Any?) { - try { - event?.let { listener?.onUpdated(it) } - ?: Log.w(tag, "Null event passed to LiveCounterChange listener callback") - } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing listener callback for event: $event", t) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt deleted file mode 100644 index b9c35bc37..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livecounter/LiveCounterManager.kt +++ /dev/null @@ -1,134 +0,0 @@ -package io.ably.lib.objects.type.livecounter - -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.objectError -import io.ably.lib.objects.type.counter.LiveCounterUpdate -import io.ably.lib.util.Log - -internal class LiveCounterManager(private val liveCounter: DefaultLiveCounter): LiveCounterChangeCoordinator() { - - private val objectId = liveCounter.objectId - - private val tag = "LiveCounterManager" - - /** - * @spec RTLC6 - Overrides counter data with state from sync - */ - internal fun applyState(objectState: ObjectState, serialTimestamp: Long?): LiveCounterUpdate { - val previousData = liveCounter.data.get() - - if (objectState.tombstone) { - liveCounter.tombstone(serialTimestamp) - } else { - // override data for this object with data from the object state - liveCounter.createOperationIsMerged = false // RTLC6b - liveCounter.data.set(objectState.counter?.count ?: 0.0) // RTLC6c - - // RTLC6d - objectState.createOp?.let { createOp -> - mergeInitialDataFromCreateOperation(createOp) - } - } - - return calculateUpdateFromDataDiff(previousData, liveCounter.data.get()) - } - - /** - * @spec RTLC7 - Applies operations to LiveCounter - */ - internal fun applyOperation(operation: ObjectOperation, serialTimestamp: Long?): Boolean { - return when (operation.action) { - ObjectOperationAction.CounterCreate -> { - val update = applyCounterCreate(operation) // RTLC7d1 - liveCounter.notifyUpdated(update) // RTLC7d1a - true // RTLC7d1b - } - ObjectOperationAction.CounterInc -> { - if (operation.counterInc != null) { - val update = applyCounterInc(operation.counterInc) // RTLC7d2 - liveCounter.notifyUpdated(update) // RTLC7d2a - true // RTLC7d2b - } else { - throw objectError("No payload found for ${operation.action} op for LiveCounter objectId=${objectId}") - } - } - ObjectOperationAction.ObjectDelete -> { - val update = liveCounter.tombstone(serialTimestamp) - liveCounter.notifyUpdated(update) - true // RTLC7d4b - } - else -> { - Log.w(tag, "Invalid ${operation.action} op for LiveCounter objectId=${objectId}") // RTLC7d3 - false - } - } - } - - /** - * @spec RTLC8 - Applies counter create operation - */ - private fun applyCounterCreate(operation: ObjectOperation): LiveCounterUpdate { - if (liveCounter.createOperationIsMerged) { - // RTLC8b - // There can't be two different create operation for the same object id, because the object id - // fully encodes that operation. This means we can safely ignore any new incoming create operations - // if we already merged it once. - Log.v( - tag, - "Skipping applying COUNTER_CREATE op on a counter instance as it was already applied before; objectId=$objectId" - ) - return noOpCounterUpdate // RTLC8c - } - - return mergeInitialDataFromCreateOperation(operation) // RTLC8c - } - - /** - * @spec RTLC9 - Applies counter increment operation - */ - private fun applyCounterInc(counterInc: CounterInc): LiveCounterUpdate { - val amount = counterInc.number - val previousValue = liveCounter.data.get() - liveCounter.data.set(previousValue + amount) // RTLC9f - return LiveCounterUpdate(amount) - } - - internal fun calculateUpdateFromDataDiff(prevData: Double, newData: Double): LiveCounterUpdate { - return LiveCounterUpdate(newData - prevData) - } - - /** - * @spec RTLC16 - Merges initial data from create operation - */ - private fun mergeInitialDataFromCreateOperation(operation: ObjectOperation): LiveCounterUpdate { - // if a counter object is missing for the COUNTER_CREATE op, the initial value is implicitly 0 in this case. - // note that it is intentional to SUM the incoming count from the create op. - // if we got here, it means that current counter instance is missing the initial value in its data reference, - // which we're going to add now. - val count = operation.counterCreateWithObjectId?.derivedFrom?.count - ?: operation.counterCreate?.count - ?: 0.0 - val previousValue = liveCounter.data.get() - liveCounter.data.set(previousValue + count) // RTLC16 - liveCounter.createOperationIsMerged = true // RTLC16 - return LiveCounterUpdate(count) - } - - internal fun validate(state: ObjectState) { - liveCounter.validateObjectId(state.objectId) - state.createOp?.let { createOp -> - liveCounter.validateObjectId(createOp.objectId) - validateCounterCreateAction(createOp.action) - } - } - - private fun validateCounterCreateAction(action: ObjectOperationAction) { - if (action != ObjectOperationAction.CounterCreate) { - throw objectError("Invalid create operation action $action for LiveCounter objectId=${objectId}") - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt deleted file mode 100644 index da5cee9b4..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/DefaultLiveMap.kt +++ /dev/null @@ -1,247 +0,0 @@ -package io.ably.lib.objects.type.livemap - -import io.ably.lib.objects.* -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.ObjectUpdate -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.objects.type.map.LiveMap -import io.ably.lib.objects.type.map.LiveMapChange -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.objects.type.map.LiveMapValue -import io.ably.lib.objects.type.noOp -import io.ably.lib.util.Log -import io.ably.lib.util.SystemClock -import kotlinx.coroutines.runBlocking -import java.util.Base64 -import java.util.concurrent.ConcurrentHashMap -import java.util.AbstractMap - -/** - * @spec RTLM1/RTLM2 - LiveMap implementation extends BaseRealtimeObject - */ -internal class DefaultLiveMap private constructor( - objectId: String, - private val realtimeObjects: DefaultRealtimeObjects, - internal val semantics: ObjectsMapSemantics = ObjectsMapSemantics.LWW -) : LiveMap, BaseRealtimeObject(objectId, ObjectType.Map, realtimeObjects.clock) { - - override val tag = "LiveMap" - - /** - * ConcurrentHashMap for thread-safe access from public APIs in LiveMap and LiveMapManager. - */ - internal val data = ConcurrentHashMap() - - /** @spec RTLM25 */ - internal var clearTimeserial: String? = null - - /** - * LiveMapManager instance for managing LiveMap operations - */ - private val liveMapManager = LiveMapManager(this) - - private val channelName = realtimeObjects.channelName - private val adapter: ObjectsAdapter get() = realtimeObjects.adapter - internal val objectsPool: ObjectsPool get() = realtimeObjects.objectsPool - private val asyncScope get() = realtimeObjects.asyncScope - - override fun get(keyName: String): LiveMapValue? { - adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM5b, RTLM5c - if (isTombstoned) { - return null - } - data[keyName]?.let { liveMapEntry -> - return liveMapEntry.getResolvedValue(objectsPool) - } - return null // RTLM5d1 - } - - override fun entries(): Iterable> { - adapter.throwIfInvalidAccessApiConfiguration(channelName) // RTLM11b, RTLM11c - - return sequence> { - for ((key, entry) in data.entries) { - val value = entry.getResolvedValue(objectsPool) // RTLM11d, RTLM11d2 - value?.let { - yield(AbstractMap.SimpleImmutableEntry(key, it)) - } - } - }.asIterable() - } - - override fun keys(): Iterable { - val iterableEntries = entries() - return sequence { - for (entry in iterableEntries) { - yield(entry.key) // RTLM12b - } - }.asIterable() - } - - override fun values(): Iterable { - val iterableEntries = entries() - return sequence { - for (entry in iterableEntries) { - yield(entry.value) // RTLM13b - } - }.asIterable() - } - - override fun size(): Long { - adapter.throwIfInvalidAccessApiConfiguration(channelName) - return data.values.count { !it.isEntryOrRefTombstoned(objectsPool) }.toLong() // RTLM10d - } - - override fun set(keyName: String, value: LiveMapValue) = runBlocking { setAsync(keyName, value) } - - override fun remove(keyName: String) = runBlocking { removeAsync(keyName) } - - override fun setAsync(keyName: String, value: LiveMapValue, callback: ObjectsCallback) { - asyncScope.launchWithVoidCallback(callback) { setAsync(keyName, value) } - } - - override fun removeAsync(keyName: String, callback: ObjectsCallback) { - asyncScope.launchWithVoidCallback(callback) { removeAsync(keyName) } - } - - override fun validate(state: ObjectState) = liveMapManager.validate(state) - - override fun subscribe(listener: LiveMapChange.Listener): ObjectsSubscription { - adapter.throwIfInvalidAccessApiConfiguration(channelName) - return liveMapManager.subscribe(listener) - } - - override fun unsubscribe(listener: LiveMapChange.Listener) = liveMapManager.unsubscribe(listener) - - override fun unsubscribeAll() = liveMapManager.unsubscribeAll() - - private suspend fun setAsync(keyName: String, value: LiveMapValue) { - // RTLM20b, RTLM20c, RTLM20d - Validate write API configuration - adapter.throwIfInvalidWriteApiConfiguration(channelName) - - // Validate input parameters - if (keyName.isEmpty()) { - throw invalidInputError("Map key should not be empty") - } - - // RTLM20e - Create ObjectMessage with the MAP_SET operation - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = objectId, - mapSet = MapSet( - key = keyName, - value = fromLiveMapValue(value) - ) - ) - ) - - // RTLM20g - publish and apply locally on ACK - realtimeObjects.publishAndApply(arrayOf(msg)) - } - - private suspend fun removeAsync(keyName: String) { - // RTLM21b, RTLM21cm RTLM21d - Validate write API configuration - adapter.throwIfInvalidWriteApiConfiguration(channelName) - - // Validate input parameter - if (keyName.isEmpty()) { - throw invalidInputError("Map key should not be empty") - } - - // RTLM21e - Create ObjectMessage with the MAP_REMOVE operation - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = objectId, - mapRemove = MapRemove(key = keyName) - ) - ) - - // RTLM21g - publish and apply locally on ACK - realtimeObjects.publishAndApply(arrayOf(msg)) - } - - override fun applyObjectState(objectState: ObjectState, message: ObjectMessage): LiveMapUpdate { - return liveMapManager.applyState(objectState, message.serialTimestamp) - } - - override fun applyObjectOperation(operation: ObjectOperation, message: ObjectMessage): Boolean { - return liveMapManager.applyOperation(operation, message.serial, message.serialTimestamp) - } - - override fun clearData(): LiveMapUpdate { - clearTimeserial = null // RTLM4 - return liveMapManager.calculateUpdateFromDataDiff(data.toMap(), emptyMap()) - .apply { data.clear() } - } - - override fun notifyUpdated(update: ObjectUpdate) { - if (update.noOp) { - return - } - Log.v(tag, "Object $objectId updated: $update") - liveMapManager.notify(update as LiveMapUpdate) - } - - override fun onGCInterval(gcGracePeriod: Long) { - data.entries.removeIf { (_, entry) -> entry.isEligibleForGc(gcGracePeriod, clock) } - } - - companion object { - /** - * Creates a zero-value map object. - * @spec RTLM4 - Returns LiveMap with empty map data - */ - internal fun zeroValue(objectId: String, objects: DefaultRealtimeObjects): DefaultLiveMap { - return DefaultLiveMap(objectId, objects) - } - - /** - * Creates a MapCreate payload from map entries. - * Spec: RTO11f14 - */ - internal fun initialValue(entries: MutableMap): MapCreate { - return MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = entries.mapValues { (_, value) -> - ObjectsMapEntry( - tombstone = false, - data = fromLiveMapValue(value) - ) - } - ) - } - - /** - * Spec: RTLM20e5 - */ - private fun fromLiveMapValue(value: LiveMapValue): ObjectData { - return when { - value.isLiveMap || value.isLiveCounter -> - ObjectData(objectId = (value.value as BaseRealtimeObject).objectId) - value.isBoolean -> - ObjectData(boolean = value.asBoolean) - value.isBinary -> - ObjectData(bytes = Base64.getEncoder().encodeToString(value.asBinary)) - value.isNumber -> - ObjectData(number = value.asNumber.toDouble()) - value.isString -> - ObjectData(string = value.asString) - value.isJsonObject -> - ObjectData(json = value.asJsonObject) - value.isJsonArray -> - ObjectData(json = value.asJsonArray) - else -> - throw IllegalArgumentException("Unsupported value type") - } - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt deleted file mode 100644 index 8bed43497..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapChangeCoordinator.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.ably.lib.objects.type.livemap - -import io.ably.lib.objects.ObjectsSubscription -import io.ably.lib.objects.type.map.LiveMapChange -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.util.EventEmitter -import io.ably.lib.util.Log - -internal val noOpMapUpdate = LiveMapUpdate() - -/** - * Interface for handling live map changes by notifying subscribers of updates. - * Implementations typically propagate updates through event emission to registered listeners. - */ -internal interface HandlesLiveMapChange { - /** - * Notifies all registered listeners about a map update by propagating the change through the event system. - * This method is called when map data changes and triggers the emission of update events to subscribers. - */ - fun notify(update: LiveMapUpdate) -} - -internal abstract class LiveMapChangeCoordinator: LiveMapChange, HandlesLiveMapChange { - private val mapChangeEmitter = LiveMapChangeEmitter() - - override fun subscribe(listener: LiveMapChange.Listener): ObjectsSubscription { - mapChangeEmitter.on(listener) - return ObjectsSubscription { - mapChangeEmitter.off(listener) - } - } - - override fun unsubscribe(listener: LiveMapChange.Listener) = mapChangeEmitter.off(listener) - - override fun unsubscribeAll() = mapChangeEmitter.off() - - override fun notify(update: LiveMapUpdate) = mapChangeEmitter.emit(update) -} - -private class LiveMapChangeEmitter : EventEmitter() { - private val tag = "LiveMapChangeEmitter" - - override fun apply(listener: LiveMapChange.Listener?, event: LiveMapUpdate?, vararg args: Any?) { - try { - event?.let { listener?.onUpdated(it) } - ?: Log.w(tag, "Null event passed to LiveMapChange listener callback") - } catch (t: Throwable) { - Log.e(tag, "Error occurred while executing listener callback for event: $event", t) - } - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt deleted file mode 100644 index 2b21a7f2f..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapEntry.kt +++ /dev/null @@ -1,86 +0,0 @@ -package io.ably.lib.objects.type.livemap - -import io.ably.lib.objects.* -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectsPool -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.objects.type.counter.LiveCounter -import io.ably.lib.objects.type.map.LiveMap -import io.ably.lib.objects.type.map.LiveMapValue -import io.ably.lib.util.Clock -import java.util.Base64 - -/** - * @spec RTLM3 - Map data structure storing entries - */ -internal data class LiveMapEntry( - val isTombstoned: Boolean = false, - val tombstonedAt: Long? = null, - val timeserial: String? = null, - val data: ObjectData? = null -) - -/** - * Checks if entry is directly tombstoned or references a tombstoned object. Spec: RTLM14 - * @param objectsPool The object pool containing referenced DefaultRealtimeObjects - */ -internal fun LiveMapEntry.isEntryOrRefTombstoned(objectsPool: ObjectsPool): Boolean { - if (isTombstoned) { - return true // RTLM14a - } - data?.objectId?.let { refId -> // RTLM5d2f -has an objectId reference - objectsPool.get(refId)?.let { refObject -> - if (refObject.isTombstoned) { - return true - } - } - } - return false // RTLM14b -} - -/** - * Returns value as is if object data stores a primitive type or - * a reference to another RealtimeObject from the pool if it stores an objectId. - */ -internal fun LiveMapEntry.getResolvedValue(objectsPool: ObjectsPool): LiveMapValue? { - if (isTombstoned) { return null } // RTLM5d2a - - data?.let { d -> // RTLM5d2b, RTLM5d2c, RTLM5d2d, RTLM5d2e - d.string?.let { return LiveMapValue.of(it) } - d.number?.let { return LiveMapValue.of(it) } - d.boolean?.let { return LiveMapValue.of(it) } - d.bytes?.let { return LiveMapValue.of(Base64.getDecoder().decode(it)) } - d.json?.let { parsed -> - return when { - parsed.isJsonObject -> LiveMapValue.of(parsed.asJsonObject) - parsed.isJsonArray -> LiveMapValue.of(parsed.asJsonArray) - else -> null - } - } - d.objectId?.let { refId -> // RTLM5d2f - has an objectId reference - objectsPool.get(refId)?.let { refObject -> - if (refObject.isTombstoned) { - return null // tombstoned objects must not be surfaced to the end users - } - return fromRealtimeObject(refObject) // RTLM5d2f2 - } - } - } - return null // RTLM5d2g, RTLM5d2f1 -} - -/** - * Extension function to check if a LiveMapEntry is expired and ready for garbage collection - */ -internal fun LiveMapEntry.isEligibleForGc(gcGracePeriod: Long, clock: Clock): Boolean { - val currentTime = clock.currentTimeMillis() - return isTombstoned && tombstonedAt?.let { currentTime - it >= gcGracePeriod } == true -} - -private fun fromRealtimeObject(realtimeObject: BaseRealtimeObject): LiveMapValue { - return when (realtimeObject.objectType) { - ObjectType.Map -> LiveMapValue.of(realtimeObject as LiveMap) - ObjectType.Counter -> LiveMapValue.of(realtimeObject as LiveCounter) - } -} diff --git a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt b/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt deleted file mode 100644 index 71cd4e4a2..000000000 --- a/liveobjects/src/main/kotlin/io/ably/lib/objects/type/livemap/LiveMapManager.kt +++ /dev/null @@ -1,410 +0,0 @@ -package io.ably.lib.objects.type.livemap - -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.isInvalid -import io.ably.lib.objects.objectError -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.objects.type.noOp -import io.ably.lib.util.Log - -internal class LiveMapManager(private val liveMap: DefaultLiveMap): LiveMapChangeCoordinator() { - - private val objectId = liveMap.objectId - - private val tag = "LiveMapManager" - - /** - * @spec RTLM6 - Overrides object data with state from sync - */ - internal fun applyState(objectState: ObjectState, serialTimestamp: Long?): LiveMapUpdate { - val previousData = liveMap.data.toMap() - - if (objectState.tombstone) { - liveMap.tombstone(serialTimestamp) - } else { - // override data for this object with data from the object state - liveMap.createOperationIsMerged = false // RTLM6b - liveMap.data.clear() - - liveMap.clearTimeserial = objectState.map?.clearTimeserial // RTLM6i - - objectState.map?.entries?.forEach { (key, entry) -> - liveMap.data[key] = LiveMapEntry( - isTombstoned = entry.tombstone ?: false, - tombstonedAt = if (entry.tombstone == true) entry.serialTimestamp ?: liveMap.clock.currentTimeMillis() else null, - timeserial = entry.timeserial, - data = entry.data - ) - } // RTLM6c - - // RTLM6d - objectState.createOp?.let { createOp -> - mergeInitialDataFromCreateOperation(createOp) - } - } - - return calculateUpdateFromDataDiff(previousData, liveMap.data.toMap()) - } - - /** - * @spec RTLM15 - Applies operations to LiveMap - */ - internal fun applyOperation(operation: ObjectOperation, serial: String?, serialTimestamp: Long?): Boolean { - return when (operation.action) { - ObjectOperationAction.MapCreate -> { - val update = applyMapCreate(operation) // RTLM15d1 - liveMap.notifyUpdated(update) // RTLM15d1a - true // RTLM15d1b - } - ObjectOperationAction.MapSet -> { - if (operation.mapSet != null) { - val update = applyMapSet(operation.mapSet, serial) // RTLM15d2 - liveMap.notifyUpdated(update) // RTLM15d2a - true // RTLM15d2b - } else { - throw objectError("No payload found for ${operation.action} op for LiveMap objectId=${objectId}") - } - } - ObjectOperationAction.MapRemove -> { - if (operation.mapRemove != null) { - val update = applyMapRemove(operation.mapRemove, serial, serialTimestamp) // RTLM15d3 - liveMap.notifyUpdated(update) // RTLM15d3a - true // RTLM15d3b - } else { - throw objectError("No payload found for ${operation.action} op for LiveMap objectId=${objectId}") - } - } - ObjectOperationAction.ObjectDelete -> { - val update = liveMap.tombstone(serialTimestamp) - liveMap.notifyUpdated(update) - true // RTLM15d5b - } - ObjectOperationAction.MapClear -> { - val update = applyMapClear(serial) // RTLM15d8 - liveMap.notifyUpdated(update) // RTLM15d8a - true // RTLM15d8b - } - else -> { - Log.w(tag, "Invalid ${operation.action} op for LiveMap objectId=${objectId}") // RTLM15d4 - false - } - } - } - - /** - * @spec RTLM16 - Applies map create operation - */ - private fun applyMapCreate(operation: ObjectOperation): LiveMapUpdate { - if (liveMap.createOperationIsMerged) { - // RTLM16b - // There can't be two different create operation for the same object id, because the object id - // fully encodes that operation. This means we can safely ignore any new incoming create operations - // if we already merged it once. - Log.v( - tag, - "Skipping applying MAP_CREATE op on a map instance as it was already applied before; objectId=${objectId}" - ) - return noOpMapUpdate - } - - validateMapSemantics(getEffectiveMapCreate(operation)?.semantics) // RTLM16c - - return mergeInitialDataFromCreateOperation(operation) // RTLM16d - } - - /** - * @spec RTLM7 - Applies MAP_SET operation to LiveMap - */ - private fun applyMapSet( - mapSet: MapSet, // RTLM7d1 - timeSerial: String?, // RTLM7d2 - ): LiveMapUpdate { - // RTLM7h - skip if operation is older than the last MAP_CLEAR - val clearSerial = liveMap.clearTimeserial - if (clearSerial != null && (timeSerial == null || clearSerial >= timeSerial)) { - Log.v(tag, - "Skipping MAP_SET for key=\"${mapSet.key}\": op serial $timeSerial <= clear serial $clearSerial; objectId=$objectId") - return noOpMapUpdate - } - - val existingEntry = liveMap.data[mapSet.key] - - // RTLM7a - if (existingEntry != null && !canApplyMapOperation(existingEntry.timeserial, timeSerial)) { - // RTLM7a1 - the operation's serial <= the entry's serial, ignore the operation - Log.v(tag, - "Skipping update for key=\"${mapSet.key}\": op serial $timeSerial <= entry serial ${existingEntry.timeserial};" + - " objectId=${objectId}" - ) - return noOpMapUpdate - } - - if (mapSet.value.isInvalid()) { - throw objectError("Invalid object data for MAP_SET op on objectId=${objectId} on key=${mapSet.key}") - } - - // RTLM7c - mapSet.value.objectId?.let { - // this MAP_SET op is setting a key to point to another object via its object id, - // but it is possible that we don't have the corresponding object in the pool yet (for example, we haven't seen the *_CREATE op for it). - // we don't want to return undefined from this map's .get() method even if we don't have the object, - // so instead we create a zero-value object for that object id if it not exists. - liveMap.objectsPool.createZeroValueObjectIfNotExists(it) // RTLM7c1 - } - - if (existingEntry != null) { - // RTLM7a2 - Replace existing entry with new one instead of mutating - liveMap.data[mapSet.key] = LiveMapEntry( - isTombstoned = false, // RTLM7a2c - timeserial = timeSerial, // RTLM7a2b - data = mapSet.value // RTLM7a2a - ) - } else { - // RTLM7b, RTLM7b1 - liveMap.data[mapSet.key] = LiveMapEntry( - isTombstoned = false, // RTLM7b2 - timeserial = timeSerial, - data = mapSet.value - ) - } - - return LiveMapUpdate(mapOf(mapSet.key to LiveMapUpdate.Change.UPDATED)) - } - - /** - * @spec RTLM8 - Applies MAP_REMOVE operation to LiveMap - */ - private fun applyMapRemove( - mapRemove: MapRemove, // RTLM8c1 - timeSerial: String?, // RTLM8c2 - timeStamp: Long?, // RTLM8c3 - ): LiveMapUpdate { - // RTLM8g - skip if operation is older than the last MAP_CLEAR - val clearSerial = liveMap.clearTimeserial - if (clearSerial != null && (timeSerial == null || clearSerial >= timeSerial)) { - Log.v(tag, - "Skipping MAP_REMOVE for key=\"${mapRemove.key}\": op serial $timeSerial <= clear serial $clearSerial; objectId=$objectId") - return noOpMapUpdate - } - - val existingEntry = liveMap.data[mapRemove.key] - - // RTLM8a - if (existingEntry != null && !canApplyMapOperation(existingEntry.timeserial, timeSerial)) { - // RTLM8a1 - the operation's serial <= the entry's serial, ignore the operation - Log.v( - tag, - "Skipping remove for key=\"${mapRemove.key}\": op serial $timeSerial <= entry serial ${existingEntry.timeserial}; " + - "objectId=${objectId}" - ) - return noOpMapUpdate - } - - val tombstonedAt = if (timeStamp != null) timeStamp else { - Log.w( - tag, - "No timestamp provided for MAP_REMOVE op on key=\"${mapRemove.key}\"; using current time as tombstone time; " + - "objectId=${objectId}" - ) - liveMap.clock.currentTimeMillis() - } - - if (existingEntry != null) { - // RTLM8a2 - Replace existing entry with new one instead of mutating - liveMap.data[mapRemove.key] = LiveMapEntry( - isTombstoned = true, // RTLM8a2c - tombstonedAt = tombstonedAt, - timeserial = timeSerial, // RTLM8a2b - data = null // RTLM8a2a - ) - } else { - // RTLM8b, RTLM8b1 - liveMap.data[mapRemove.key] = LiveMapEntry( - isTombstoned = true, // RTLM8b2 - tombstonedAt = tombstonedAt, - timeserial = timeSerial - ) - } - - return LiveMapUpdate(mapOf(mapRemove.key to LiveMapUpdate.Change.REMOVED)) - } - - /** - * @spec RTLM24 - Applies MAP_CLEAR operation to LiveMap - */ - private fun applyMapClear(timeSerial: String?): LiveMapUpdate { - val clearSerial = liveMap.clearTimeserial - - // RTLM24c - skip if existing clear serial is strictly newer than incoming op serial - if (clearSerial != null && (timeSerial == null || clearSerial > timeSerial)) { - Log.v(tag, - "Skipping MAP_CLEAR: op serial $timeSerial <= current clear serial $clearSerial; objectId=$objectId") - return noOpMapUpdate - } - - Log.v(tag, - "Updating clearTimeserial; previous=$clearSerial, new=$timeSerial; objectId=$objectId") - liveMap.clearTimeserial = timeSerial // RTLM24d - - val update = mutableMapOf() - - // RTLM24e - remove all entries whose serial is older than (or equal to missing) the clear serial - liveMap.data.entries.removeIf { - val (key, entry) = it - val entrySerial = entry.timeserial - if (entrySerial == null || (timeSerial != null && timeSerial > entrySerial)) { - update[key] = LiveMapUpdate.Change.REMOVED - true - } else { - false - } - } - - return LiveMapUpdate(update) - } - - /** - * For Lww CRDT semantics (the only supported LiveMap semantic) an operation - * Should only be applied if incoming serial is strictly greater than existing entry's serial. - * @spec RTLM9 - Serial comparison logic for map operations - */ - private fun canApplyMapOperation(existingMapEntrySerial: String?, timeSerial: String?): Boolean { - if (existingMapEntrySerial.isNullOrEmpty() && timeSerial.isNullOrEmpty()) { // RTLM9b - return false - } - if (existingMapEntrySerial.isNullOrEmpty()) { // RTLM9d - If true, means timeSerial is not empty based on previous checks - return true - } - if (timeSerial.isNullOrEmpty()) { // RTLM9c - Check reached here means existingMapEntrySerial is not empty - return false - } - return timeSerial > existingMapEntrySerial // RTLM9e - both are not empty - } - - /** - * @spec RTLM23 - Merges initial data from create operation - */ - private fun getEffectiveMapCreate(operation: ObjectOperation): MapCreate? = - operation.mapCreateWithObjectId?.derivedFrom ?: operation.mapCreate - - private fun mergeInitialDataFromCreateOperation(operation: ObjectOperation): LiveMapUpdate { - val effectiveMapCreate = getEffectiveMapCreate(operation) - if (effectiveMapCreate?.entries.isNullOrEmpty()) { // no map entries in MAP_CREATE op - return noOpMapUpdate - } - - val aggregatedUpdate = mutableListOf() - - // RTLM23a - // in order to apply MAP_CREATE op for an existing map, we should merge their underlying entries keys. - // we can do this by iterating over entries from MAP_CREATE op and apply changes on per-key basis as if we had MAP_SET, MAP_REMOVE operations. - effectiveMapCreate?.entries?.forEach { (key, entry) -> - // for a MAP_CREATE operation we must use the serial value available on an entry, instead of a serial on a message - val opTimeserial = entry.timeserial - val update = if (entry.tombstone == true) { - // RTLM23a2 - entry in MAP_CREATE op is removed, try to apply MAP_REMOVE op - applyMapRemove(MapRemove(key), opTimeserial, entry.serialTimestamp) - } else { - // RTLM23a1 - entry in MAP_CREATE op is not removed, try to set it via MAP_SET op - applyMapSet(MapSet(key, entry.data ?: throw objectError("MAP_SET operation without data")), opTimeserial) - } - - // skip noop updates - if (update.noOp) { - return@forEach - } - - aggregatedUpdate.add(update) - } - - liveMap.createOperationIsMerged = true // RTLM23b - - return LiveMapUpdate( - aggregatedUpdate.map { it.update }.fold(emptyMap()) { acc, map -> acc + map } - ) - } - - internal fun calculateUpdateFromDataDiff( - prevData: Map, - newData: Map - ): LiveMapUpdate { - val update = mutableMapOf() - - // Check for removed entries - for ((key, prevEntry) in prevData) { - if (!prevEntry.isTombstoned && !newData.containsKey(key)) { - update[key] = LiveMapUpdate.Change.REMOVED - } - } - - // Check for added/updated entries - for ((key, newEntry) in newData) { - if (!prevData.containsKey(key)) { - // if property does not exist in current map, but new data has it as non-tombstoned property - got updated - if (!newEntry.isTombstoned) { - update[key] = LiveMapUpdate.Change.UPDATED - } - // otherwise, if new data has this prop tombstoned - do nothing, as property didn't exist anyway - continue - } - - // properties that exist both in current and new map data need to have their values compared to decide on update type - val prevEntry = prevData[key]!! - - // compare tombstones first - if (prevEntry.isTombstoned && !newEntry.isTombstoned) { - // prev prop is tombstoned, but new is not. it means prop was updated to a meaningful value - update[key] = LiveMapUpdate.Change.UPDATED - continue - } - if (!prevEntry.isTombstoned && newEntry.isTombstoned) { - // prev prop is not tombstoned, but new is. it means prop was removed - update[key] = LiveMapUpdate.Change.REMOVED - continue - } - if (prevEntry.isTombstoned && newEntry.isTombstoned) { - // props are tombstoned - treat as noop, as there is no data to compare - continue - } - - // both props exist and are not tombstoned, need to compare values to see if it was changed - val valueChanged = prevEntry.data != newEntry.data - if (valueChanged) { - update[key] = LiveMapUpdate.Change.UPDATED - continue - } - } - - return LiveMapUpdate(update) - } - - internal fun validate(state: ObjectState) { - liveMap.validateObjectId(state.objectId) - validateMapSemantics(state.map?.semantics) - state.createOp?.let { createOp -> - liveMap.validateObjectId(createOp.objectId) - validateMapCreateAction(createOp.action) - validateMapSemantics(getEffectiveMapCreate(createOp)?.semantics) - } - } - - private fun validateMapCreateAction(action: ObjectOperationAction) { - if (action != ObjectOperationAction.MapCreate) { - throw objectError("Invalid create operation action $action for LiveMap objectId=${objectId}") - } - } - - private fun validateMapSemantics(semantics: ObjectsMapSemantics?) { - if (semantics != liveMap.semantics) { - throw objectError( - "Invalid object: incoming object map semantics=$semantics; current map semantics=${ObjectsMapSemantics.LWW}" - ) - } - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt deleted file mode 100644 index 79a99de32..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveCounterTest.kt +++ /dev/null @@ -1,367 +0,0 @@ -package io.ably.lib.objects.integration - -import io.ably.lib.objects.assertWaiter -import io.ably.lib.objects.integration.helpers.ObjectId -import io.ably.lib.objects.integration.helpers.fixtures.createUserEngagementMatrixMap -import io.ably.lib.objects.integration.helpers.fixtures.createUserMapWithCountersObject -import io.ably.lib.objects.integration.setup.IntegrationTest -import io.ably.lib.objects.type.map.LiveMapValue -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class DefaultLiveCounterTest: IntegrationTest() { - /** - * Tests the synchronization process when a user map object with counters is initialized before channel attach. - * This includes checking the initial values of all counter objects and nested maps in the - * comprehensive user engagement counter structure. - */ - @Test - fun testLiveCounterSync() = runTest { - val channelName = generateChannelName() - val userMapObjectId = restObjects.createUserMapWithCountersObject(channelName) - restObjects.setMapRef(channelName, "root", "user", userMapObjectId) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - // Get the user map object from the root map - val userMap = rootMap.get("user")?.asLiveMap - assertNotNull(userMap, "User map should be synchronized") - assertEquals(7L, userMap.size(), "User map should contain 7 top-level entries") - - // Assert direct counter objects at the top level of the user map - // Test profileViews counter - should have initial value of 127 - val profileViewsCounter = userMap.get("profileViews")?.asLiveCounter - assertNotNull(profileViewsCounter, "Profile views counter should exist") - assertEquals(127.0, profileViewsCounter.value(), "Profile views counter should have initial value of 127") - - // Test postLikes counter - should have initial value of 45 - val postLikesCounter = userMap.get("postLikes")?.asLiveCounter - assertNotNull(postLikesCounter, "Post likes counter should exist") - assertEquals(45.0, postLikesCounter.value(), "Post likes counter should have initial value of 45") - - // Test commentCount counter - should have initial value of 23 - val commentCountCounter = userMap.get("commentCount")?.asLiveCounter - assertNotNull(commentCountCounter, "Comment count counter should exist") - assertEquals(23.0, commentCountCounter.value(), "Comment count counter should have initial value of 23") - - // Test followingCount counter - should have initial value of 89 - val followingCountCounter = userMap.get("followingCount")?.asLiveCounter - assertNotNull(followingCountCounter, "Following count counter should exist") - assertEquals(89.0, followingCountCounter.value(), "Following count counter should have initial value of 89") - - // Test followersCount counter - should have initial value of 156 - val followersCountCounter = userMap.get("followersCount")?.asLiveCounter - assertNotNull(followersCountCounter, "Followers count counter should exist") - assertEquals(156.0, followersCountCounter.value(), "Followers count counter should have initial value of 156") - - // Test loginStreak counter - should have initial value of 7 - val loginStreakCounter = userMap.get("loginStreak")?.asLiveCounter - assertNotNull(loginStreakCounter, "Login streak counter should exist") - assertEquals(7.0, loginStreakCounter.value(), "Login streak counter should have initial value of 7") - - // Assert the nested engagement metrics map - val engagementMetrics = userMap.get("engagementMetrics")?.asLiveMap - assertNotNull(engagementMetrics, "Engagement metrics map should exist") - assertEquals(4L, engagementMetrics.size(), "Engagement metrics map should contain 4 counter entries") - - // Assert counter objects within the engagement metrics map - // Test totalShares counter - should have initial value of 34 - val totalSharesCounter = engagementMetrics.get("totalShares")?.asLiveCounter - assertNotNull(totalSharesCounter, "Total shares counter should exist") - assertEquals(34.0, totalSharesCounter.value(), "Total shares counter should have initial value of 34") - - // Test totalBookmarks counter - should have initial value of 67 - val totalBookmarksCounter = engagementMetrics.get("totalBookmarks")?.asLiveCounter - assertNotNull(totalBookmarksCounter, "Total bookmarks counter should exist") - assertEquals(67.0, totalBookmarksCounter.value(), "Total bookmarks counter should have initial value of 67") - - // Test totalReactions counter - should have initial value of 189 - val totalReactionsCounter = engagementMetrics.get("totalReactions")?.asLiveCounter - assertNotNull(totalReactionsCounter, "Total reactions counter should exist") - assertEquals(189.0, totalReactionsCounter.value(), "Total reactions counter should have initial value of 189") - - // Test dailyActiveStreak counter - should have initial value of 12 - val dailyActiveStreakCounter = engagementMetrics.get("dailyActiveStreak")?.asLiveCounter - assertNotNull(dailyActiveStreakCounter, "Daily active streak counter should exist") - assertEquals(12.0, dailyActiveStreakCounter.value(), "Daily active streak counter should have initial value of 12") - - // Verify that all expected counter keys exist at the top level - val topLevelKeys = userMap.keys().toSet() - val expectedTopLevelKeys = setOf( - "profileViews", "postLikes", "commentCount", "followingCount", - "followersCount", "loginStreak", "engagementMetrics" - ) - assertEquals(expectedTopLevelKeys, topLevelKeys, "Top-level keys should match expected counter keys") - - // Verify that all expected counter keys exist in the engagement metrics map - val engagementKeys = engagementMetrics.keys().toSet() - val expectedEngagementKeys = setOf( - "totalShares", "totalBookmarks", "totalReactions", "dailyActiveStreak" - ) - assertEquals(expectedEngagementKeys, engagementKeys, "Engagement metrics keys should match expected counter keys") - - // Verify total counter values match expectations (useful for integration testing) - val totalUserCounterValues = listOf(127.0, 45.0, 23.0, 89.0, 156.0, 7.0).sum() - val totalEngagementCounterValues = listOf(34.0, 67.0, 189.0, 12.0).sum() - assertEquals(447.0, totalUserCounterValues, "Sum of user counter values should be 447") - assertEquals(302.0, totalEngagementCounterValues, "Sum of engagement counter values should be 302") - } - - /** - * Tests sequential counter operations including creation with initial value, incrementing by various amounts, - * decrementing by various amounts, and validates the resulting counter value after each operation. - */ - @Test - fun testLiveCounterOperations() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - // Step 1: Create a new counter with initial value of 10 - val testCounterObjectId = restObjects.createCounter(channelName, initialValue = 10.0) - restObjects.setMapRef(channelName, "root", "testCounter", testCounterObjectId) - - // Wait for updated testCounter to be available in the root map - assertWaiter { rootMap.get("testCounter") != null } - - // Assert initial state after creation - val testCounter = rootMap.get("testCounter")?.asLiveCounter - assertNotNull(testCounter, "Test counter should be created and accessible") - assertEquals(10.0, testCounter.value(), "Counter should have initial value of 10") - - // Step 2: Increment counter by 5 (10 + 5 = 15) - restObjects.incrementCounter(channelName, testCounterObjectId, 5.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 15.0 } - - // Assert after first increment - assertEquals(15.0, testCounter.value(), "Counter should be incremented to 15") - - // Step 3: Increment counter by 3 (15 + 3 = 18) - restObjects.incrementCounter(channelName, testCounterObjectId, 3.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 18.0 } - - // Assert after second increment - assertEquals(18.0, testCounter.value(), "Counter should be incremented to 18") - - // Step 4: Increment counter by a larger amount: 12 (18 + 12 = 30) - restObjects.incrementCounter(channelName, testCounterObjectId, 12.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 30.0 } - - // Assert after third increment - assertEquals(30.0, testCounter.value(), "Counter should be incremented to 30") - - // Step 5: Decrement counter by 7 (30 - 7 = 23) - restObjects.decrementCounter(channelName, testCounterObjectId, 7.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 23.0 } - - // Assert after first decrement - assertEquals(23.0, testCounter.value(), "Counter should be decremented to 23") - - // Step 6: Decrement counter by 4 (23 - 4 = 19) - restObjects.decrementCounter(channelName, testCounterObjectId, 4.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 19.0 } - - // Assert after second decrement - assertEquals(19.0, testCounter.value(), "Counter should be decremented to 19") - - // Step 7: Increment counter by 1 (19 + 1 = 20) - restObjects.incrementCounter(channelName, testCounterObjectId, 1.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 20.0 } - - // Assert after final increment - assertEquals(20.0, testCounter.value(), "Counter should be incremented to 20") - - // Step 8: Decrement counter by a larger amount: 15 (20 - 15 = 5) - restObjects.decrementCounter(channelName, testCounterObjectId, 15.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 5.0 } - - // Assert after large decrement - assertEquals(5.0, testCounter.value(), "Counter should be decremented to 5") - - // Final verification - test final increment to ensure counter still works - restObjects.incrementCounter(channelName, testCounterObjectId, 25.0) - assertWaiter { testCounter.value() == 30.0 } - - // Assert final state - assertEquals(30.0, testCounter.value(), "Counter should have final value of 30") - - // Verify the counter object is still accessible and functioning - assertNotNull(testCounter, "Counter should still be accessible at the end") - - // Verify we can still access it from the root map - val finalCounterCheck = rootMap.get("testCounter")?.asLiveCounter - assertNotNull(finalCounterCheck, "Counter should still be accessible from root map") - assertEquals(30.0, finalCounterCheck.value(), "Final counter value should be 30 when accessed from root map") - } - - @Test - fun testLiveCounterOperationsUsingRealtime() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val objects = channel.objects - val rootMap = channel.objects.root - - // Step 1: Create a new counter with initial value of 10 - val testCounterObject = objects.createCounter( 10.0) - rootMap.set("testCounter", LiveMapValue.of(testCounterObject)) - - // Wait for updated testCounter to be available in the root map - assertWaiter { rootMap.get("testCounter") != null } - - // Assert initial state after creation - val testCounter = rootMap.get("testCounter")?.asLiveCounter - assertNotNull(testCounter, "Test counter should be created and accessible") - assertEquals(10.0, testCounter.value(), "Counter should have initial value of 10") - - // Step 2: Increment counter by 5 (10 + 5 = 15) - testCounter.increment(5.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 15.0 } - - // Assert after first increment - assertEquals(15.0, testCounter.value(), "Counter should be incremented to 15") - - // Step 3: Increment counter by 3 (15 + 3 = 18) - testCounter.increment(3.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 18.0 } - - // Assert after second increment - assertEquals(18.0, testCounter.value(), "Counter should be incremented to 18") - - // Step 4: Increment counter by a larger amount: 12 (18 + 12 = 30) - testCounter.increment(12.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 30.0 } - - // Assert after third increment - assertEquals(30.0, testCounter.value(), "Counter should be incremented to 30") - - // Step 5: Decrement counter by 7 (30 - 7 = 23) - testCounter.decrement(7.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 23.0 } - - // Assert after first decrement - assertEquals(23.0, testCounter.value(), "Counter should be decremented to 23") - - // Step 6: Decrement counter by 4 (23 - 4 = 19) - testCounter.decrement(4.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 19.0 } - - // Assert after second decrement - assertEquals(19.0, testCounter.value(), "Counter should be decremented to 19") - - // Step 7: Increment counter by 1 (19 + 1 = 20) - testCounter.increment(1.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 20.0 } - - // Assert after final increment - assertEquals(20.0, testCounter.value(), "Counter should be incremented to 20") - - // Step 8: Decrement counter by a larger amount: 15 (20 - 15 = 5) - testCounter.decrement(15.0) - // Wait for the counter to be updated - assertWaiter { testCounter.value() == 5.0 } - - // Assert after large decrement - assertEquals(5.0, testCounter.value(), "Counter should be decremented to 5") - - // Final verification - test final increment to ensure counter still works - testCounter.increment(25.0) - assertWaiter { testCounter.value() == 30.0 } - - // Assert final state - assertEquals(30.0, testCounter.value(), "Counter should have final value of 30") - - // Verify the counter object is still accessible and functioning - assertNotNull(testCounter, "Counter should still be accessible at the end") - - // Verify we can still access it from the root map - val finalCounterCheck = rootMap.get("testCounter")?.asLiveCounter - assertNotNull(finalCounterCheck, "Counter should still be accessible from root map") - assertEquals(30.0, finalCounterCheck.value(), "Final counter value should be 30 when accessed from root map") - } - - @Test - fun testLiveCounterChangesUsingSubscription() = runTest { - val channelName = generateChannelName() - val userEngagementMapId = restObjects.createUserEngagementMatrixMap(channelName) - restObjects.setMapRef(channelName, "root", "userMatrix", userEngagementMapId) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - val userEngagementMap = rootMap.get("userMatrix")?.asLiveMap - assertEquals(4L, userEngagementMap!!.size(), "User engagement map should contain 4 top-level entries") - - val totalReactions = userEngagementMap.get("totalReactions")?.asLiveCounter - assertEquals(189.0, totalReactions!!.value(), "Total reactions counter should have initial value of 189") - - // Subscribe to changes on the totalReactions counter - val counterUpdates = mutableListOf() - val totalReactionsSubscription = totalReactions.subscribe { update -> - counterUpdates.add(update.update.amount) - } - - // Step 1: Increment the totalReactions counter by 10 (189 + 10 = 199) - restObjects.incrementCounter(channelName, totalReactions.ObjectId, 10.0) - - // Wait for the update to be received - assertWaiter { counterUpdates.isNotEmpty() } - - // Verify the increment update was received - assertEquals(1, counterUpdates.size, "Should receive one update for increment") - assertEquals(10.0, counterUpdates.first(), "Update should contain increment amount of 10") - assertEquals(199.0, totalReactions.value(), "Counter should be incremented to 199") - - // Step 2: Decrement the totalReactions counter by 5 (199 - 5 = 194) - counterUpdates.clear() - restObjects.decrementCounter(channelName, totalReactions.ObjectId, 5.0) - - // Wait for the second update - assertWaiter { counterUpdates.isNotEmpty() } - - // Verify the decrement update was received - assertEquals(1, counterUpdates.size, "Should receive one update for decrement") - assertEquals(-5.0, counterUpdates.first(), "Update should contain decrement amount of -5") - assertEquals(194.0, totalReactions.value(), "Counter should be decremented to 194") - - // Step 3: Increment the totalReactions counter by 15 (194 + 15 = 209) - counterUpdates.clear() - restObjects.incrementCounter(channelName, totalReactions.ObjectId, 15.0) - - // Wait for the third update - assertWaiter { counterUpdates.isNotEmpty() } - - // Verify the third increment update was received - assertEquals(1, counterUpdates.size, "Should receive one update for third increment") - assertEquals(15.0, counterUpdates.first(), "Update should contain increment amount of 15") - assertEquals(209.0, totalReactions.value(), "Counter should be incremented to 209") - - // Clean up subscription - counterUpdates.clear() - totalReactionsSubscription.unsubscribe() - - // No updates should be received after unsubscribing - restObjects.incrementCounter(channelName, totalReactions.ObjectId, 20.0) - - // Wait for a moment to ensure no updates are received - assertWaiter { totalReactions.value() == 229.0 } - - assertTrue(counterUpdates.isEmpty(), "No updates should be received after unsubscribing") - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt deleted file mode 100644 index 0f2abb567..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultLiveMapTest.kt +++ /dev/null @@ -1,423 +0,0 @@ -package io.ably.lib.objects.integration - -import io.ably.lib.objects.* -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.integration.helpers.fixtures.createUserMapObject -import io.ably.lib.objects.integration.helpers.fixtures.createUserProfileMapObject -import io.ably.lib.objects.integration.setup.IntegrationTest -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.objects.type.map.LiveMapValue -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class DefaultLiveMapTest: IntegrationTest() { - /** - * Tests the synchronization process when a user map object is initialized before channel attach. - * This includes checking the initial values of all nested maps, counters, and primitive data types - * in the comprehensive user map object structure. - */ - @Test - fun testLiveMapSync() = runTest { - val channelName = generateChannelName() - val userMapObjectId = restObjects.createUserMapObject(channelName) - restObjects.setMapRef(channelName, "root", "user", userMapObjectId) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - // Get the user map object from the root map - val userMap = rootMap.get("user")?.asLiveMap - assertNotNull(userMap, "User map should be synchronized") - assertEquals(5L, userMap.size(), "User map should contain 5 top-level entries") - - // Assert Counter Objects - // Test loginCounter - should have initial value of 5 - val loginCounter = userMap.get("loginCounter")?.asLiveCounter - assertNotNull(loginCounter, "Login counter should exist") - assertEquals(5.0, loginCounter.value(), "Login counter should have initial value of 5") - - // Test sessionCounter - should have initial value of 0 - val sessionCounter = userMap.get("sessionCounter")?.asLiveCounter - assertNotNull(sessionCounter, "Session counter should exist") - assertEquals(0.0, sessionCounter.value(), "Session counter should have initial value of 0") - - // Assert User Profile Map - val userProfile = userMap.get("userProfile")?.asLiveMap - assertNotNull(userProfile, "User profile map should exist") - assertEquals(6L, userProfile.size(), "User profile should contain 6 entries") - - // Assert user profile primitive values - assertEquals("user123", userProfile.get("userId")?.asString, "User ID should match expected value") - assertEquals("John Doe", userProfile.get("name")?.asString, "User name should match expected value") - assertEquals("john@example.com", userProfile.get("email")?.asString, "User email should match expected value") - assertEquals(true, userProfile.get("isActive")?.asBoolean, "User should be active") - - // Assert Preferences Map (nested within user profile) - val preferences = userProfile.get("preferences")?.asLiveMap - assertNotNull(preferences, "Preferences map should exist") - assertEquals(4L, preferences.size(), "Preferences should contain 4 entries") - assertEquals("dark", preferences.get("theme")?.asString, "Theme preference should be dark") - assertEquals(true, preferences.get("notifications")?.asBoolean, "Notifications should be enabled") - assertEquals("en", preferences.get("language")?.asString, "Language should be English") - assertEquals(3.0, preferences.get("maxRetries")?.asNumber, "Max retries should be 3") - - // Assert Metrics Map (nested within user profile) - val metrics = userProfile.get("metrics")?.asLiveMap - assertNotNull(metrics, "Metrics map should exist") - assertEquals(4L, metrics.size(), "Metrics should contain 4 entries") - assertEquals("2024-01-01T08:30:00Z", metrics.get("lastLoginTime")?.asString, "Last login time should match") - assertEquals(42.0, metrics.get("profileViews")?.asNumber, "Profile views should be 42") - - // Test counter references within metrics map - val totalLoginsCounter = metrics.get("totalLogins")?.asLiveCounter - assertNotNull(totalLoginsCounter, "Total logins counter should exist") - assertEquals(5.0, totalLoginsCounter.value(), "Total logins should reference login counter with value 5") - - val activeSessionsCounter = metrics.get("activeSessions")?.asLiveCounter - assertNotNull(activeSessionsCounter, "Active sessions counter should exist") - assertEquals(0.0, activeSessionsCounter.value(), "Active sessions should reference session counter with value 0") - - // Assert direct references to maps from top-level user map - val preferencesMapRef = userMap.get("preferencesMap")?.asLiveMap - assertNotNull(preferencesMapRef, "Preferences map reference should exist") - assertEquals(4L, preferencesMapRef.size(), "Referenced preferences map should have 4 entries") - assertEquals("dark", preferencesMapRef.get("theme")?.asString, "Referenced preferences should match nested preferences") - - val metricsMapRef = userMap.get("metricsMap")?.asLiveMap - assertNotNull(metricsMapRef, "Metrics map reference should exist") - assertEquals(4L, metricsMapRef.size(), "Referenced metrics map should have 4 entries") - assertEquals("2024-01-01T08:30:00Z", metricsMapRef.get("lastLoginTime")?.asString, "Referenced metrics should match nested metrics") - - // Verify that references point to the same objects - assertEquals(preferences.get("theme")?.asString, preferencesMapRef.get("theme")?.asString, "Preference references should point to same data") - assertEquals(metrics.get("profileViews")?.asNumber, metricsMapRef.get("profileViews")?.asNumber, "Metrics references should point to same data") - } - - /** - * Tests sequential map operations including creation with initial data, updating existing fields, - * adding new fields, and removing fields. Validates the resulting data after each operation. - */ - @Test - fun testLiveMapOperations() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - // Step 1: Create a new map with initial data - val testMapObjectId = restObjects.createMap( - channelName, - data = mapOf( - "name" to ObjectData(string = "Alice"), - "age" to ObjectData(number = 30.0), - "isActive" to ObjectData(boolean = true) - ) - ) - restObjects.setMapRef(channelName, "root", "testMap", testMapObjectId) - - // wait for updated testMap to be available in the root map - assertWaiter { rootMap.get("testMap") != null } - - // Assert initial state after creation - val testMap = rootMap.get("testMap")?.asLiveMap - assertNotNull(testMap, "Test map should be created and accessible") - assertEquals(3L, testMap.size(), "Test map should have 3 initial entries") - assertEquals("Alice", testMap.get("name")?.asString, "Initial name should be Alice") - assertEquals(30.0, testMap.get("age")?.asNumber, "Initial age should be 30") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Initial active status should be true") - - // Step 2: Update an existing field (name from "Alice" to "Bob") - restObjects.setMapValue(channelName, testMapObjectId, "name", ObjectData(string = "Bob")) - // Wait for the map to be updated - assertWaiter { testMap.get("name")?.asString == "Bob" } - - // Assert after updating existing field - assertEquals(3L, testMap.size(), "Map size should remain the same after update") - assertEquals("Bob", testMap.get("name")?.asString, "Name should be updated to Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - - // Step 3: Add a new field (email) - restObjects.setMapValue(channelName, testMapObjectId, "email", ObjectData(string = "bob@example.com")) - // Wait for the map to be updated - assertWaiter { testMap.get("email")?.asString == "bob@example.com" } - - // Assert after adding new field - assertEquals(4L, testMap.size(), "Map size should increase after adding new field") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should be added successfully") - - // Step 4: Add another new field with different data type (score as number) - restObjects.setMapValue(channelName, testMapObjectId, "score", ObjectData(number = 85.0)) - // Wait for the map to be updated - assertWaiter { testMap.get("score")?.asNumber == 85.0 } - - // Assert after adding second new field - assertEquals(5L, testMap.size(), "Map size should increase to 5 after adding score") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should be added as numeric value") - - // Step 5: Update the boolean field - restObjects.setMapValue(channelName, testMapObjectId, "isActive", ObjectData(boolean = false)) - // Wait for the map to be updated - assertWaiter { testMap.get("isActive")?.asBoolean == false } - - // Assert after updating boolean field - assertEquals(5L, testMap.size(), "Map size should remain 5 after boolean update") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should be updated to false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") - - // Step 6: Remove a field (age) - restObjects.removeMapValue(channelName, testMapObjectId, "age") - // Wait for the map to be updated - assertWaiter { testMap.get("age") == null } - - // Assert after removing field - assertEquals(4L, testMap.size(), "Map size should decrease to 4 after removing age") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertNull(testMap.get("age"), "Age should be removed and return null") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") - - // Step 7: Remove another field (score) - restObjects.removeMapValue(channelName, testMapObjectId, "score") - // Wait for the map to be updated - assertWaiter { testMap.get("score") == null } - - // Assert final state after second removal - assertEquals(3L, testMap.size(), "Map size should decrease to 3 after removing score") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertNull(testMap.get("score"), "Score should be removed and return null") - assertNull(testMap.get("age"), "Age should remain null") - - // Final verification - ensure all expected keys exist and unwanted keys don't - assertEquals(3, testMap.size(), "Final map should have exactly 3 entries") - - val finalKeys = testMap.keys().toSet() - assertEquals(setOf("name", "isActive", "email"), finalKeys, "Final keys should match expected set") - - val finalValues = testMap.values().map { it.value }.toSet() - assertEquals(setOf("Bob", false, "bob@example.com"), finalValues, "Final string values should match expected set") - } - - /** - * Tests sequential map operations including creation with initial data, updating existing fields, - * adding new fields, and removing fields. Validates the resulting data after each operation. - */ - @Test - fun testLiveMapOperationsUsingRealtime() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val objects = channel.objects - val rootMap = channel.objects.root - - // Step 1: Create a new map with initial data - val testMapObject = objects.createMap( - mapOf( - "name" to LiveMapValue.of("Alice"), - "age" to LiveMapValue.of(30), - "isActive" to LiveMapValue.of(true), - ) - ) - rootMap.set("testMap", LiveMapValue.of(testMapObject)) - - // wait for updated testMap to be available in the root map - assertWaiter { rootMap.get("testMap") != null } - - // Assert initial state after creation - val testMap = rootMap.get("testMap")?.asLiveMap - assertNotNull(testMap, "Test map should be created and accessible") - assertEquals(3L, testMap.size(), "Test map should have 3 initial entries") - assertEquals("Alice", testMap.get("name")?.asString, "Initial name should be Alice") - assertEquals(30.0, testMap.get("age")?.asNumber, "Initial age should be 30") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Initial active status should be true") - - // Step 2: Update an existing field (name from "Alice" to "Bob") - testMap.set("name", LiveMapValue.of("Bob")) - // Wait for the map to be updated - assertWaiter { testMap.get("name")?.asString == "Bob" } - - // Assert after updating existing field - assertEquals(3L, testMap.size(), "Map size should remain the same after update") - assertEquals("Bob", testMap.get("name")?.asString, "Name should be updated to Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - - // Step 3: Add a new field (email) - testMap.set("email", LiveMapValue.of("bob@example.com")) - // Wait for the map to be updated - assertWaiter { testMap.get("email")?.asString == "bob@example.com" } - - // Assert after adding new field - assertEquals(4L, testMap.size(), "Map size should increase after adding new field") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should be added successfully") - - // Step 4: Add another new field with different data type (score as number) - testMap.set("score", LiveMapValue.of(85)) - // Wait for the map to be updated - assertWaiter { testMap.get("score")?.asNumber == 85.0 } - - // Assert after adding second new field - assertEquals(5L, testMap.size(), "Map size should increase to 5 after adding score") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(true, testMap.get("isActive")?.asBoolean, "Active status should remain unchanged") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should be added as numeric value") - - // Step 5: Update the boolean field - testMap.set("isActive", LiveMapValue.of(false)) - // Wait for the map to be updated - assertWaiter { testMap.get("isActive")?.asBoolean == false } - - // Assert after updating boolean field - assertEquals(5L, testMap.size(), "Map size should remain 5 after boolean update") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(30.0, testMap.get("age")?.asNumber, "Age should remain unchanged") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should be updated to false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") - - // Step 6: Remove a field (age) - testMap.remove("age") - // Wait for the map to be updated - assertWaiter { testMap.get("age") == null } - - // Assert after removing field - assertEquals(4L, testMap.size(), "Map size should decrease to 4 after removing age") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertNull(testMap.get("age"), "Age should be removed and return null") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertEquals(85.0, testMap.get("score")?.asNumber, "Score should remain unchanged") - - // Step 7: Remove another field (score) - testMap.remove("score") - // Wait for the map to be updated - assertWaiter { testMap.get("score") == null } - - // Assert final state after second removal - assertEquals(3L, testMap.size(), "Map size should decrease to 3 after removing score") - assertEquals("Bob", testMap.get("name")?.asString, "Name should remain Bob") - assertEquals(false, testMap.get("isActive")?.asBoolean, "Active status should remain false") - assertEquals("bob@example.com", testMap.get("email")?.asString, "Email should remain unchanged") - assertNull(testMap.get("score"), "Score should be removed and return null") - assertNull(testMap.get("age"), "Age should remain null") - - // Final verification - ensure all expected keys exist and unwanted keys don't - assertEquals(3, testMap.size(), "Final map should have exactly 3 entries") - - val finalKeys = testMap.keys().toSet() - assertEquals(setOf("name", "isActive", "email"), finalKeys, "Final keys should match expected set") - - val finalValues = testMap.values().map { it.value }.toSet() - assertEquals(setOf("Bob", false, "bob@example.com"), finalValues, "Final string values should match expected set") - } - - @Test - fun testLiveMapChangesUsingSubscription() = runTest { - val channelName = generateChannelName() - val userProfileObjectId = restObjects.createUserProfileMapObject(channelName) - restObjects.setMapRef(channelName, "root", "userProfile", userProfileObjectId) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - - // Get the user profile map object from the root map - val userProfile = rootMap.get("userProfile")?.asLiveMap - assertNotNull(userProfile, "User profile should be synchronized") - assertEquals(4L, userProfile.size(), "User profile should contain 4 entries") - - // Verify initial values - assertEquals("user123", userProfile.get("userId")?.asString, "Initial userId should be user123") - assertEquals("John Doe", userProfile.get("name")?.asString, "Initial name should be John Doe") - assertEquals("john@example.com", userProfile.get("email")?.asString, "Initial email should be john@example.com") - assertEquals(true, userProfile.get("isActive")?.asBoolean, "Initial isActive should be true") - - // Subscribe to changes in the user profile map - val userProfileUpdates = mutableListOf() - val userProfileSubscription = userProfile.subscribe { update -> userProfileUpdates.add(update) } - - // Step 1: Update an existing field in the user profile map (change the name) - restObjects.setMapValue(channelName, userProfileObjectId, "name", ObjectData(string = "Bob Smith")) - - // Wait for the update to be received - assertWaiter { userProfileUpdates.isNotEmpty() } - - // Verify the update was received - assertEquals(1, userProfileUpdates.size, "Should receive one update") - val firstUpdateMap = userProfileUpdates.first().update - assertEquals(1, firstUpdateMap.size, "Should have one key change") - assertTrue(firstUpdateMap.containsKey("name"), "Update should contain name key") - assertEquals(LiveMapUpdate.Change.UPDATED, firstUpdateMap["name"], "name should be marked as UPDATED") - - // Verify the value was actually updated - assertEquals("Bob Smith", userProfile.get("name")?.asString, "Name should be updated to Bob Smith") - - // Step 2: Update another field in the user profile map (change the email) - userProfileUpdates.clear() - restObjects.setMapValue(channelName, userProfileObjectId, "email", ObjectData(string = "bob@example.com")) - - // Wait for the second update - assertWaiter { userProfileUpdates.isNotEmpty() } - - // Verify the second update - assertEquals(1, userProfileUpdates.size, "Should receive one update for the second change") - val secondUpdateMap = userProfileUpdates.first().update - assertEquals(1, secondUpdateMap.size, "Should have one key change") - assertTrue(secondUpdateMap.containsKey("email"), "Update should contain email key") - assertEquals(LiveMapUpdate.Change.UPDATED, secondUpdateMap["email"], "email should be marked as UPDATED") - - // Verify the value was actually updated - assertEquals("bob@example.com", userProfile.get("email")?.asString, "Email should be updated to bob@example.com") - - // Step 3: Remove an existing field from the user profile map (remove isActive) - userProfileUpdates.clear() - restObjects.removeMapValue(channelName, userProfileObjectId, "isActive") - - // Wait for the removal update - assertWaiter { userProfileUpdates.isNotEmpty() } - - // Verify the removal update - assertEquals(1, userProfileUpdates.size, "Should receive one update for removal") - val removalUpdateMap = userProfileUpdates.first().update - assertEquals(1, removalUpdateMap.size, "Should have one key change") - assertTrue(removalUpdateMap.containsKey("isActive"), "Update should contain isActive key") - assertEquals(LiveMapUpdate.Change.REMOVED, removalUpdateMap["isActive"], "isActive should be marked as REMOVED") - - // Verify final state of the user profile map - assertEquals(3L, userProfile.size(), "User profile should have 3 entries after removing isActive") - assertEquals("user123", userProfile.get("userId")?.asString, "userId should remain unchanged") - assertEquals("Bob Smith", userProfile.get("name")?.asString, "name should remain updated") - assertEquals("bob@example.com", userProfile.get("email")?.asString, "email should remain updated") - assertNull(userProfile.get("isActive"), "isActive should be removed") - - // Clean up subscription - userProfileUpdates.clear() - userProfileSubscription.unsubscribe() - // No updates should be received after unsubscribing - restObjects.setMapValue(channelName, userProfileObjectId, "country", ObjectData(string = "uk")) - - // Wait for a moment to ensure no updates are received - assertWaiter { userProfile.size() == 4L } - - assertTrue(userProfileUpdates.isEmpty(), "No updates should be received after unsubscribing") - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultRealtimeObjectsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultRealtimeObjectsTest.kt deleted file mode 100644 index 428fed56a..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/DefaultRealtimeObjectsTest.kt +++ /dev/null @@ -1,256 +0,0 @@ -package io.ably.lib.objects.integration - -import io.ably.lib.objects.* -import io.ably.lib.objects.integration.helpers.State -import io.ably.lib.objects.integration.helpers.fixtures.initializeRootMap -import io.ably.lib.objects.integration.helpers.simulateObjectDelete -import io.ably.lib.objects.integration.setup.IntegrationTest -import io.ably.lib.objects.state.ObjectsStateEvent -import io.ably.lib.objects.type.ObjectLifecycleEvent -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.type.map.LiveMapUpdate -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.text.toByteArray - -class DefaultRealtimeObjectsTest : IntegrationTest() { - - @Test - fun testChannelObjects() = runTest { - val channelName = generateChannelName() - val channel = getRealtimeChannel(channelName) - val objects = channel.objects - assertNotNull(objects) - } - - @Test - fun testObjectsSyncEvents() = runTest { - val channelName = generateChannelName() - // Initialize the root map on the channel with initial data - restObjects.initializeRootMap(channelName) - - val channel = getRealtimeChannel(channelName) - val objects = channel.objects - assertNotNull(objects) - - assertEquals(ObjectsState.Initialized, objects.State, "Initial state should be INITIALIZED") - - val syncStates = mutableListOf() - objects.on(ObjectsStateEvent.SYNCING) { - syncStates.add(it) - } - objects.on(ObjectsStateEvent.SYNCED) { - syncStates.add(it) - } - - channel.attach() - - assertWaiter { syncStates.size == 2 } // Wait for both SYNCING and SYNCED events - - assertEquals(ObjectsStateEvent.SYNCING, syncStates[0], "First event should be SYNCING") - assertEquals(ObjectsStateEvent.SYNCED, syncStates[1], "Second event should be SYNCED") - - val rootMap = objects.root - assertEquals(6, rootMap.size(), "Root map should have 6 entries after sync") - } - - /** - * This will test objects sync process when the root map is initialized before channel attach. - * This includes checking the initial values of counters, maps, and other data types. - */ - @Test - fun testObjectsSync() = runTest { - val channelName = generateChannelName() - // Initialize the root map on the channel with initial data - restObjects.initializeRootMap(channelName) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - assertNotNull(rootMap) - - // Assert Counter Objects - // Test emptyCounter - should have initial value of 0 - val emptyCounter = rootMap.get("emptyCounter")?.asLiveCounter - assertNotNull(emptyCounter) - assertEquals(0.0, emptyCounter.value()) - - // Test initialValueCounter - should have initial value of 10 - val initialValueCounter = rootMap.get("initialValueCounter")?.asLiveCounter - assertNotNull(initialValueCounter) - assertEquals(10.0, initialValueCounter.value()) - - // Test referencedCounter - should have initial value of 20 - val referencedCounter = rootMap.get("referencedCounter")?.asLiveCounter - assertNotNull(referencedCounter) - assertEquals(20.0, referencedCounter.value()) - - // Assert Map Objects - // Test emptyMap - should be an empty map - val emptyMap = rootMap.get("emptyMap")?.asLiveMap - assertNotNull(emptyMap) - assertEquals(0L, emptyMap.size()) - - // Test referencedMap - should contain one key "counterKey" pointing to referencedCounter - val referencedMap = rootMap.get("referencedMap")?.asLiveMap - assertNotNull(referencedMap) - assertEquals(1L, referencedMap.size()) - val referencedMapCounter = referencedMap.get("counterKey")?.asLiveCounter - assertNotNull(referencedMapCounter) - assertEquals(20.0, referencedMapCounter.value()) // Should point to the same counter with value 20 - - // Test valuesMap - should contain all primitive data types and one map reference - val valuesMap = rootMap.get("valuesMap")?.asLiveMap - assertNotNull(valuesMap) - assertEquals(13L, valuesMap.size()) // Should have 13 entries - - // Assert string values - assertEquals("stringValue", valuesMap.get("string")?.asString) - assertEquals("", valuesMap.get("emptyString")?.asString) - - // Assert binary values - val bytesValue = valuesMap.get("bytes")?.asBinary - assertNotNull(bytesValue) - val expectedBinary = "eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9".toByteArray() - assertTrue(expectedBinary.contentEquals(bytesValue)) // Should contain encoded JSON data - - val emptyBytesValue = valuesMap.get("emptyBytes")?.asBinary - assertNotNull(emptyBytesValue) - assertEquals(0, emptyBytesValue.size) // Should be empty byte array - - // Assert numeric values - assertEquals(99999999.0, valuesMap.get("maxSafeNumber")?.asNumber) - assertEquals(-99999999.0, valuesMap.get("negativeMaxSafeNumber")?.asNumber) - assertEquals(1.0, valuesMap.get("number")?.asNumber) - assertEquals(0.0, valuesMap.get("zero")?.asNumber) - - // Assert boolean values - assertEquals(true, valuesMap.get("true")?.asBoolean) - assertEquals(false, valuesMap.get("false")?.asBoolean) - - // Assert JSON object value - should contain {"foo": "bar"} - val jsonObjectValue = valuesMap.get("object")?.asJsonObject - assertNotNull(jsonObjectValue) - assertEquals("bar", jsonObjectValue.get("foo").asString) - - // Assert JSON array value - should contain ["foo", "bar", "baz"] - val jsonArrayValue = valuesMap.get("array")?.asJsonArray - assertNotNull(jsonArrayValue) - assertEquals(3, jsonArrayValue.size()) - assertEquals("foo", jsonArrayValue[0].asString) - assertEquals("bar", jsonArrayValue[1].asString) - assertEquals("baz", jsonArrayValue[2].asString) - - // Assert map reference - should point to the same referencedMap - val mapRefValue = valuesMap.get("mapRef")?.asLiveMap - assertNotNull(mapRefValue) - assertEquals(1L, mapRefValue.size()) - val mapRefCounter = mapRefValue.get("counterKey")?.asLiveCounter - assertNotNull(mapRefCounter) - assertEquals(20.0, mapRefCounter.value()) // Should point to the same counter with value 20 - } - - /** - * Server runs periodic garbage collection (GC) to remove orphaned objects and will send - * OBJECT_DELETE events for objects that are no longer referenced. - * So, we simulate the deletion of an object by sending an object delete ProtocolMessage. - * This does not actually delete the object from the server, only simulates the deletion locally. - * Spec: RTLO4e - */ - @Test - fun testObjectDelete() = runTest { - val channelName = generateChannelName() - // Initialize the root map on the channel with initial data - restObjects.initializeRootMap(channelName) - - val channel = getRealtimeChannel(channelName) - val rootMap = channel.objects.root - assertEquals(6L, rootMap.size()) // Should have 6 entries initially - - // Collection to track all lifecycle events - val lifecycleEvents = mutableListOf() - - // Remove the "referencedCounter" from the root map - val refCounter = rootMap.get("referencedCounter")?.asLiveCounter - assertNotNull(refCounter) - // Subscribe to counter updates to verify removal - val counterUpdates = mutableListOf() - refCounter.subscribe { event -> - counterUpdates.add(event.update.amount) - } - // Subscribe to lifecycle events for this counter - refCounter.on(ObjectLifecycleEvent.DELETED) { event -> - lifecycleEvents.add(event) - } - - // Simulate the deletion of the referencedCounter object - channel.objects.simulateObjectDelete(refCounter as DefaultLiveCounter) - - assertWaiter { rootMap.size() == 5L } // Wait for the removal to complete - assertNull(rootMap.get("referencedCounter")) // Should be null after removal - assertEquals(1, counterUpdates.size) // Should have received one update for deletion - assertEquals(-20.0, counterUpdates[0]) // The update should indicate counter was removed with value 20 - - // Remove the "referencedMap" from the root map - val referencedMap = rootMap.get("referencedMap")?.asLiveMap - assertNotNull(referencedMap) - // Subscribe to map updates to verify removal - val mapUpdates = mutableListOf>() - referencedMap.subscribe { event -> - mapUpdates.add(event.update) - } - // Subscribe to lifecycle events for this map - referencedMap.on(ObjectLifecycleEvent.DELETED) { event -> - lifecycleEvents.add(event) - } - - // Simulate the deletion of the referencedMap object - channel.objects.simulateObjectDelete(referencedMap as DefaultLiveMap) - - assertWaiter { rootMap.size() == 4L } // Wait for the removal to complete - assertNull(rootMap.get("referencedMap")) // Should be null after removal - assertEquals(1, mapUpdates.size) // Should have received one update for deletion - - val updatedMap = mapUpdates.first() - assertEquals(1, updatedMap.size) // Should have one change - assertEquals("counterKey", updatedMap.keys.first()) // The change should be for the "counterKey" - assertEquals(LiveMapUpdate.Change.REMOVED, updatedMap.values.first()) // Should indicate removal - - // Remove the "valuesMap" from the root map - val valuesMap = rootMap.get("valuesMap")?.asLiveMap - assertNotNull(valuesMap) - // Subscribe to map updates to verify removal - val valuesMapUpdates = mutableListOf>() - valuesMap.subscribe { event -> - valuesMapUpdates.add(event.update) - } - // Subscribe to lifecycle events for this map - valuesMap.on(ObjectLifecycleEvent.DELETED) { event -> - lifecycleEvents.add(event) - } - - // Simulate the deletion of the valuesMap object - channel.objects.simulateObjectDelete(valuesMap as DefaultLiveMap) - - assertWaiter { rootMap.size() == 3L } // Wait for the removal to complete - assertNull(rootMap.get("valuesMap")) // Should be null after removal - assertEquals(1, valuesMapUpdates.size) // Should have received one update for deletion - - val updatedValuesMap = valuesMapUpdates.first() - assertEquals(13, updatedValuesMap.size) // Should have 13 changes (one for each entry in valuesMap) - // Verify that all entries in valuesMap were marked as REMOVED - updatedValuesMap.values.forEach { change -> - assertEquals(LiveMapUpdate.Change.REMOVED, change) - } - - // Assert lifecycle events - assertEquals(3, lifecycleEvents.size) // Should have received 3 DELETED lifecycle events - lifecycleEvents.forEach { event -> - assertEquals(ObjectLifecycleEvent.DELETED, event) // All events should be DELETED - } - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt deleted file mode 100644 index 283d11a4f..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/PayloadBuilder.kt +++ /dev/null @@ -1,130 +0,0 @@ -package io.ably.lib.objects.integration.helpers - -import com.google.gson.JsonObject -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.generateNonce -import io.ably.lib.objects.serialization.gson - -internal object PayloadBuilder { - /** - * Action strings for REST API operations. - * Maps ObjectOperationAction enum values to their string representations. - */ - private val ACTION_STRINGS = mapOf( - ObjectOperationAction.MapCreate to "MAP_CREATE", - ObjectOperationAction.MapSet to "MAP_SET", - ObjectOperationAction.MapRemove to "MAP_REMOVE", - ObjectOperationAction.CounterCreate to "COUNTER_CREATE", - ObjectOperationAction.CounterInc to "COUNTER_INC", - ) - - /** - * Creates a MAP_CREATE operation payload for REST API. - * - * @param objectId Optional specific object ID - * @param data Optional initial data for the map - * @param nonce Optional nonce for deterministic object ID generation - */ - internal fun mapCreateRestOp( - objectId: String? = null, - data: Map? = null, - nonce: String? = null, - ): JsonObject { - val opBody = JsonObject().apply { - addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapCreate]) - } - - if (data != null) { - opBody.add("data", gson.toJsonTree(data)) - } - - if (objectId != null) { - opBody.addProperty("objectId", objectId) - opBody.addProperty("nonce", nonce ?: generateNonce()) - } - - return opBody - } - - - /** - * Creates a MAP_SET operation payload for REST API. - */ - internal fun mapSetRestOp(objectId: String, key: String, value: ObjectData): JsonObject { - val opBody = JsonObject().apply { - addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapSet]) - addProperty("objectId", objectId) - } - - val dataObj = JsonObject().apply { - addProperty("key", key) - add("value", gson.toJsonTree(value)) - } - opBody.add("data", dataObj) - - return opBody - } - - /** - * Creates a MAP_REMOVE operation payload for REST API. - */ - internal fun mapRemoveRestOp(objectId: String, key: String): JsonObject { - val opBody = JsonObject().apply { - addProperty("operation", ACTION_STRINGS[ObjectOperationAction.MapRemove]) - addProperty("objectId", objectId) - } - - val dataObj = JsonObject().apply { - addProperty("key", key) - } - opBody.add("data", dataObj) - - return opBody - } - - /** - * Creates a COUNTER_CREATE operation payload for REST API. - * - * @param objectId Optional specific object ID - * @param nonce Optional nonce for deterministic object ID generation - * @param number Optional initial counter value - */ - internal fun counterCreateRestOp( - objectId: String? = null, - number: Double? = null, - nonce: String? = null, - ): JsonObject { - val opBody = JsonObject().apply { - addProperty("operation", ACTION_STRINGS[ObjectOperationAction.CounterCreate]) - } - - if (number != null) { - val dataObj = JsonObject().apply { - addProperty("number", number) - } - opBody.add("data", dataObj) - } - - if (objectId != null) { - opBody.addProperty("objectId", objectId) - opBody.addProperty("nonce", nonce ?: generateNonce()) - } - - return opBody - } - - /** - * Creates a COUNTER_INC operation payload for REST API. - */ - internal fun counterIncRestOp(objectId: String, number: Double): JsonObject { - val opBody = JsonObject().apply { - addProperty("operation", ACTION_STRINGS[ObjectOperationAction.CounterInc]) - addProperty("objectId", objectId) - add("data", JsonObject().apply { - addProperty("number", number) - }) - } - return opBody - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt deleted file mode 100644 index d06559377..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/RestObjects.kt +++ /dev/null @@ -1,117 +0,0 @@ -package io.ably.lib.objects.integration.helpers - -import com.google.gson.JsonObject -import io.ably.lib.objects.ObjectData -import io.ably.lib.rest.AblyRest -import io.ably.lib.http.HttpUtils -import io.ably.lib.objects.integration.helpers.fixtures.DataFixtures -import io.ably.lib.types.ClientOptions - -/** - * Helper class to create pre-determined objects and modify them on channels using rest api. - */ -internal class RestObjects(options: ClientOptions) { - - private val ablyRest: AblyRest = AblyRest(options) - - /** - * Creates a new map object on the channel with optional initial data. - * @return The object ID of the created map - */ - internal fun createMap(channelName: String, data: Map? = null): String { - val mapCreateOp = PayloadBuilder.mapCreateRestOp(data = data) - return operationRequest(channelName, mapCreateOp).objectId ?: - throw Exception("Failed to create map: no objectId returned") - } - - /** - * Sets a value (primitives, JsonObject, JsonArray, etc.) at the specified key in an existing map. - */ - internal fun setMapValue(channelName: String, mapObjectId: String, key: String, data: ObjectData) { - val mapCreateOp = PayloadBuilder.mapSetRestOp(mapObjectId, key, data) - operationRequest(channelName, mapCreateOp) - } - - /** - * Sets an object reference at the specified key in an existing map. - */ - internal fun setMapRef(channelName: String, mapObjectId: String, key: String, refMapObjectId: String) { - val mapCreateOp = PayloadBuilder.mapSetRestOp(mapObjectId, key, DataFixtures.mapRef(refMapObjectId)) - operationRequest(channelName, mapCreateOp) - } - - /** - * Removes a key-value pair from an existing map. - */ - internal fun removeMapValue(channelName: String, mapObjectId: String, key: String) { - val mapRemoveOp = PayloadBuilder.mapRemoveRestOp(mapObjectId, key) - operationRequest(channelName, mapRemoveOp) - } - - /** - * Creates a new counter object with an optional initial value (defaults to 0). - * @return The object ID of the created counter - */ - internal fun createCounter(channelName: String, initialValue: Double? = null): String { - val counterCreateOp = PayloadBuilder.counterCreateRestOp(number = initialValue) - return operationRequest(channelName, counterCreateOp).objectId - ?: throw Exception("Failed to create counter: no objectId returned") - } - - /** - * Increments an existing counter by the specified amount. - */ - internal fun incrementCounter(channelName: String, counterObjectId: String, incrementBy: Double) { - val counterIncrementOp = PayloadBuilder.counterIncRestOp(counterObjectId, incrementBy) - operationRequest(channelName, counterIncrementOp) - } - - /** - * Decrements an existing counter by the specified amount. - */ - internal fun decrementCounter(channelName: String, counterObjectId: String, decrementBy: Double) { - val counterDecrementOp = PayloadBuilder.counterIncRestOp(counterObjectId, -decrementBy) - operationRequest(channelName, counterDecrementOp) - } - - /** - * Core method that executes object operations by sending POST requests to Ably's Objects REST API. - * All public methods delegate to this for actual API communication. - */ - private fun operationRequest(channelName: String, opBody: JsonObject): OperationResult { - try { - val path = "/channels/$channelName/objects" - val requestBody = HttpUtils.requestBodyFromGson(opBody, ablyRest.options.useBinaryProtocol) - - val response = ablyRest.request("POST", path, null, requestBody, null) - - if (!response.success) { - throw Exception("REST operation failed: HTTP ${response.statusCode} - ${response.errorMessage}") - } - - val responseItems = response.items() - if (responseItems.isEmpty()) { - return OperationResult(null, null, success = true) - } - - // Process first response item - responseItems[0].asJsonObject.let { firstItem -> - val objectIds = firstItem.get("objectIds")?.let { element -> - if (element.isJsonArray) element.asJsonArray.map { it.asString } else null - } - return OperationResult(objectIds?.firstOrNull(), objectIds, success = true) - } - } catch (e: Exception) { - throw Exception("Failed to execute operation request: ${e.message}", e) - } - } - - /** - * Result class for operation requests containing the response data and extracted object ID. - */ - private data class OperationResult( - val objectId: String?, - val objectIds: List? = null, // Seems only used for batch operations - val success: Boolean = true - ) -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt deleted file mode 100644 index 05b50b7dc..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/Utils.kt +++ /dev/null @@ -1,40 +0,0 @@ -package io.ably.lib.objects.integration.helpers - -import io.ably.lib.objects.* -import io.ably.lib.objects.DefaultRealtimeObjects -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.counter.LiveCounter -import io.ably.lib.objects.type.map.LiveMap -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.types.ProtocolMessage - -internal val LiveMap.ObjectId get() = (this as DefaultLiveMap).objectId - -internal val LiveCounter.ObjectId get() = (this as DefaultLiveCounter).objectId - -internal val RealtimeObjects.State get() = (this as DefaultRealtimeObjects).state - -/** - * Server runs periodic garbage collection (GC) to remove orphaned objects and will send - * OBJECT_DELETE events for objects that are no longer referenced. - * So, we simulate the deletion of an object by sending a ProtocolMessage. - */ -internal fun RealtimeObjects.simulateObjectDelete(baseObject: BaseRealtimeObject) { - val defaultRealtimeObjects = this as DefaultRealtimeObjects - val existingSiteCode = baseObject.siteTimeserials.keys.first() - val existingSiteSerial = baseObject.siteTimeserials[existingSiteCode]!! - - val deleteObjectProtoMsg = ProtocolMessage(ProtocolMessage.Action.`object`, channelName) - deleteObjectProtoMsg.state = arrayOf(ObjectMessage( - siteCode = existingSiteCode, - serial = existingSiteSerial + "1", // Increment serial to accept new operation - operation = ObjectOperation( - action = ObjectOperationAction.ObjectDelete, - objectId = baseObject.objectId, - ) - )) - defaultRealtimeObjects.handle(deleteObjectProtoMsg) -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt deleted file mode 100644 index a8135a9e4..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/CounterFixtures.kt +++ /dev/null @@ -1,88 +0,0 @@ -package io.ably.lib.objects.integration.helpers.fixtures - -import io.ably.lib.objects.integration.helpers.RestObjects - -/** - * Creates a comprehensive test fixture object tree focused on user-context counters. - * - * This method establishes a hierarchical structure of live counter objects for testing - * counter operations in a realistic user engagement context, creating various types of - * counters and establishing references between them through nested maps. - * - * **Object Tree Structure:** - * ``` - * userMap (Map) - * ├── profileViews → Counter(value=127) - * ├── postLikes → Counter(value=45) - * ├── commentCount → Counter(value=23) - * ├── followingCount → Counter(value=89) - * ├── followersCount → Counter(value=156) - * ├── loginStreak → Counter(value=7) - * └── engagementMetrics → Map{ - * ├── "totalShares" → Counter(value=34) - * ├── "totalBookmarks" → Counter(value=67) - * ├── "totalReactions" → Counter(value=189) - * └── "dailyActiveStreak" → Counter(value=12) - * } - * ``` - * - * @param channelName The channel where the counter object tree will be created - * @return The object ID of the root test map containing all counter references - */ -internal fun RestObjects.createUserMapWithCountersObject(channelName: String): String { - // Create the main test map first - val testMapObjectId = createMap(channelName) - - // Create various user-context relevant counters - val profileViewsCounterObjectId = createCounter(channelName, 127.0) - val postLikesCounterObjectId = createCounter(channelName, 45.0) - val commentCountCounterObjectId = createCounter(channelName, 23.0) - val followingCountCounterObjectId = createCounter(channelName, 89.0) - val followersCountCounterObjectId = createCounter(channelName, 156.0) - val loginStreakCounterObjectId = createCounter(channelName, 7.0) - - // Create engagement metrics nested map with counters - val engagementMetricsMapObjectId = createUserEngagementMatrixMap(channelName) - - // Set up the main test map structure with references to all created counters - setMapRef(channelName, testMapObjectId, "profileViews", profileViewsCounterObjectId) - setMapRef(channelName, testMapObjectId, "postLikes", postLikesCounterObjectId) - setMapRef(channelName, testMapObjectId, "commentCount", commentCountCounterObjectId) - setMapRef(channelName, testMapObjectId, "followingCount", followingCountCounterObjectId) - setMapRef(channelName, testMapObjectId, "followersCount", followersCountCounterObjectId) - setMapRef(channelName, testMapObjectId, "loginStreak", loginStreakCounterObjectId) - setMapRef(channelName, testMapObjectId, "engagementMetrics", engagementMetricsMapObjectId) - - return testMapObjectId -} - -/** - * Creates a user engagement matrix map object with counter references for testing. - * - * This method creates a simple engagement metrics map containing counter objects - * that track various user engagement metrics. The map contains references to - * counter objects representing different types of user interactions and activities. - * - * **Object Structure:** - * ``` - * userEngagementMatrixMap (Map) - * ├── "totalShares" → Counter(value=34) - * ├── "totalBookmarks" → Counter(value=67) - * ├── "totalReactions" → Counter(value=189) - * └── "dailyActiveStreak" → Counter(value=12) - * ``` - * - * @param channelName The channel where the user engagement matrix map will be created - * @return The object ID of the created user engagement matrix map - */ -internal fun RestObjects.createUserEngagementMatrixMap(channelName: String): String { - return createMap( - channelName, - data = mapOf( - "totalShares" to DataFixtures.mapRef(createCounter(channelName, 34.0)), - "totalBookmarks" to DataFixtures.mapRef(createCounter(channelName, 67.0)), - "totalReactions" to DataFixtures.mapRef(createCounter(channelName, 189.0)), - "dailyActiveStreak" to DataFixtures.mapRef(createCounter(channelName, 12.0)) - ) - ) -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt deleted file mode 100644 index f6f305aba..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/DataFixtures.kt +++ /dev/null @@ -1,83 +0,0 @@ -package io.ably.lib.objects.integration.helpers.fixtures - -import com.google.gson.JsonArray -import com.google.gson.JsonObject -import io.ably.lib.objects.ObjectData -import java.util.Base64 - -internal object DataFixtures { - - /** Test fixture for string value ("stringValue") data type */ - internal val stringData = ObjectData(string = "stringValue") - - /** Test fixture for empty string data type */ - internal val emptyStringData = ObjectData(string = "") - - /** Test fixture for binary data containing encoded JSON */ - internal val bytesData = ObjectData( - bytes = Base64.getEncoder().encodeToString("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9".toByteArray())) - - /** Test fixture for empty binary data (zero-length byte array) */ - internal val emptyBytesData = ObjectData(bytes = Base64.getEncoder().encodeToString(ByteArray(0))) - - /** Test fixture for maximum safe number value */ - internal val maxSafeNumberData = ObjectData(number = 99999999.0) - - /** Test fixture for minimum safe number value */ - internal val negativeMaxSafeNumberData = ObjectData(number = -99999999.0) - - /** Test fixture for positive number value (1) */ - internal val numberData = ObjectData(number = 1.0) - - /** Test fixture for zero number value */ - internal val zeroData = ObjectData(number = 0.0) - - /** Test fixture for boolean true value */ - internal val trueData = ObjectData(boolean = true) - - /** Test fixture for boolean false value */ - internal val falseData = ObjectData(boolean = false) - - /** Test fixture for JSON object value with single property */ - internal val objectData = ObjectData(json = JsonObject().apply { addProperty("foo", "bar") }) - - /** Test fixture for JSON array value with three string elements */ - internal val arrayData = ObjectData( - json = JsonArray().apply { - add("foo") - add("bar") - add("baz") - } - ) - - /** - * Creates an ObjectData instance that references another map object. - * @param referencedMapObjectId The object ID of the referenced map - */ - internal fun mapRef(referencedMapObjectId: String) = ObjectData(objectId = referencedMapObjectId) - - /** - * Creates a test fixture map containing all supported data types and values. - * @param referencedMapObjectId The object ID to be used for the map reference entry - */ - internal fun mapWithAllValues(referencedMapObjectId: String? = null): Map { - val baseMap = mapOf( - "string" to stringData, - "emptyString" to emptyStringData, - "bytes" to bytesData, - "emptyBytes" to emptyBytesData, - "maxSafeNumber" to maxSafeNumberData, - "negativeMaxSafeNumber" to negativeMaxSafeNumberData, - "number" to numberData, - "zero" to zeroData, - "true" to trueData, - "false" to falseData, - "object" to objectData, - "array" to arrayData - ) - referencedMapObjectId?.let { - return baseMap + ("mapRef" to mapRef(it)) - } - return baseMap - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt deleted file mode 100644 index 475bbe86a..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/helpers/fixtures/MapFixtures.kt +++ /dev/null @@ -1,183 +0,0 @@ -package io.ably.lib.objects.integration.helpers.fixtures - -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.integration.helpers.RestObjects - -/** - * Initializes a comprehensive test fixture object tree on the specified channel. - * - * This method creates a predetermined object hierarchy rooted at a "root" map object, - * establishing references between different types of objects to enable comprehensive testing. - * - * **Object Tree Structure:** - * ``` - * root (Map) - * ├── emptyCounter → Counter(value=0) - * ├── initialValueCounter → Counter(value=10) - * ├── referencedCounter → Counter(value=20) - * ├── emptyMap → Map{} - * ├── referencedMap → Map{ - * │ └── "counterKey" → referencedCounter - * │ } - * └── valuesMap → Map{ - * ├── "string" → "stringValue" - * ├── "emptyString" → "" - * ├── "bytes" → - * ├── "emptyBytes" → - * ├── "maxSafeInteger" → Long.MAX_VALUE - * ├── "negativeMaxSafeInteger" → Long.MIN_VALUE - * ├── "number" → 1 - * ├── "zero" → 0 - * ├── "true" → true - * ├── "false" → false - * ├── "object" → {"foo": "bar"} - * ├── "array" → ["foo", "bar", "baz"] - * └── "mapRef" → referencedMap - * } - * ``` - * - * @param channelName The channel where the object tree will be created - */ -internal fun RestObjects.initializeRootMap(channelName: String) { - // Create counters - val emptyCounterObjectId = createCounter(channelName) - setMapRef(channelName, "root", "emptyCounter", emptyCounterObjectId) - - val initialValueCounterObjectId = createCounter(channelName, 10.0) - setMapRef(channelName, "root", "initialValueCounter", initialValueCounterObjectId) - - val referencedCounterObjectId = createCounter(channelName, 20.0) - setMapRef(channelName, "root", "referencedCounter", referencedCounterObjectId) - - // Create maps - val emptyMapObjectId = createMap(channelName) - setMapRef(channelName, "root", "emptyMap", emptyMapObjectId) - - val referencedMapObjectId = createMap( - channelName, - data = mapOf("counterKey" to DataFixtures.mapRef(referencedCounterObjectId)) - ) - setMapRef(channelName, "root", "referencedMap", referencedMapObjectId) - - val valuesMapObjectId = createMap( - channelName, - data = DataFixtures.mapWithAllValues(referencedMapObjectId) - ) - setMapRef(channelName, "root", "valuesMap", valuesMapObjectId) -} - - -/** - * Creates a comprehensive test fixture object tree on the specified channel using - * - * This method establishes a hierarchical structure of objects for testing map operations, - * creating various types of objects and establishing references between them. - * - * **Object Tree Structure:** - * ``` - * testMap (Map) - * ├── userProfile → Map{ - * │ ├── "userId" → "user123" - * │ ├── "name" → "John Doe" - * │ ├── "email" → "john@example.com" - * │ ├── "isActive" → true - * │ ├── "metrics" → metricsMap - * │ └── "preferences" → preferencesMap - * │ } - * ├── loginCounter → Counter(value=5) - * ├── sessionCounter → Counter(value=0) - * ├── preferencesMap → Map{ - * │ ├── "theme" → "dark" - * │ ├── "notifications" → true - * │ ├── "language" → "en" - * │ └── "maxRetries" → 3 - * │ } - * └── metricsMap → Map{ - * ├── "totalLogins" → loginCounter - * ├── "activeSessions" → sessionCounter - * ├── "lastLoginTime" → "2024-01-01T08:30:00Z" - * └── "profileViews" → 42 - * } - * ``` - * - * @param channelName The channel where the test object tree will be created - */ -internal fun RestObjects.createUserMapObject(channelName: String): String { - // Create the main test map first - val testMapObjectId = createMap(channelName) - - // Create counter objects for testing numeric operations - val loginCounterObjectId = createCounter(channelName, 5.0) - val sessionCounterObjectId = createCounter(channelName, 0.0) - - // Create a preferences map with various data types - val preferencesMapObjectId = createMap( - channelName, - data = mapOf( - "theme" to ObjectData(string = "dark"), - "notifications" to ObjectData(boolean = true), - "language" to ObjectData(string = "en"), - "maxRetries" to ObjectData(number = 3.0) - ) - ) - - // Create a metrics map that tracks single user activity - val metricsMapObjectId = createMap( - channelName, - data = mapOf( - "totalLogins" to DataFixtures.mapRef(loginCounterObjectId), - "activeSessions" to DataFixtures.mapRef(sessionCounterObjectId), - "lastLoginTime" to ObjectData(string = "2024-01-01T08:30:00Z"), - "profileViews" to ObjectData(number = 42.0) - ) - ) - - // Create a user profile map with mixed data types and references - val userProfileMapObjectId = createUserProfileMapObject(channelName) - setMapRef(channelName, userProfileMapObjectId, "metrics", metricsMapObjectId) - setMapRef(channelName, userProfileMapObjectId, "preferences", preferencesMapObjectId) - - // Set up the main test map structure with references to all created objects - setMapRef(channelName, testMapObjectId, "userProfile", userProfileMapObjectId) - setMapRef(channelName, testMapObjectId, "loginCounter", loginCounterObjectId) - setMapRef(channelName, testMapObjectId, "sessionCounter", sessionCounterObjectId) - setMapRef(channelName, testMapObjectId, "preferencesMap", preferencesMapObjectId) - setMapRef(channelName, testMapObjectId, "metricsMap", metricsMapObjectId) - - return testMapObjectId -} - -/** - * Creates a user profile map object with basic user information for testing. - * - * This method creates a simple user profile map containing essential user data fields - * that are commonly used in user management systems. The map contains primitive data types - * representing basic user information. - * - * **Object Structure:** - * ``` - * userProfileMap (Map) - * ├── "userId" → "user123" - * ├── "name" → "John Doe" - * ├── "email" → "john@example.com" - * └── "isActive" → true - * ``` - * - * This structure provides a foundation for testing map operations on user profile data, - * including field updates, additions, and removals. The map contains a mix of string, - * boolean, and numeric data types to test various primitive value handling. - * - * @param channelName The channel where the user profile map will be created - * @return The object ID of the created user profile map - */ -internal fun RestObjects.createUserProfileMapObject(channelName: String): String { - return createMap( - channelName, - data = mapOf( - "userId" to ObjectData(string = "user123"), - "name" to ObjectData(string = "John Doe"), - "email" to ObjectData(string = "john@example.com"), - "isActive" to ObjectData(boolean = true), - ) - ) -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt deleted file mode 100644 index cb46f2f89..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt +++ /dev/null @@ -1,97 +0,0 @@ -package io.ably.lib.objects.integration.setup - -import io.ably.lib.objects.integration.helpers.RestObjects -import io.ably.lib.realtime.AblyRealtime -import io.ably.lib.realtime.Channel -import io.ably.lib.types.ChannelMode -import io.ably.lib.types.ChannelOptions -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.AfterClass -import org.junit.BeforeClass -import org.junit.Rule -import org.junit.rules.Timeout -import org.junit.runner.RunWith -import org.junit.runners.Parameterized -import java.util.UUID - -@RunWith(Parameterized::class) -abstract class IntegrationTest { - @Parameterized.Parameter - lateinit var testParams: String - - @JvmField - @Rule - val timeout: Timeout = Timeout.seconds(15) - - private val realtimeClients = mutableMapOf() - - /** - * Retrieves a realtime channel for the specified channel name and client ID - * If a client with the given clientID does not exist, a new client is created using the provided options. - * - * @param channelName Name of the channel - * @param clientId The ID of the client to use or create. Defaults to "client1". - * @return The realtime channel in the INITIALIZED state. - * @throws Exception If the client fails to connect. - */ - internal suspend fun getRealtimeChannel(channelName: String, clientId: String = "client1"): Channel { - val client = realtimeClients.getOrPut(clientId) { - sandbox.createRealtimeClient { - this.clientId = clientId - useBinaryProtocol = testParams == "msgpack_protocol" - }. apply { ensureConnected() } - } - val channelOpts = ChannelOptions().apply { - modes = arrayOf(ChannelMode.object_publish, ChannelMode.object_subscribe) - } - return client.channels.get(channelName, channelOpts) - } - - /** - * Generates a unique channel name for testing purposes. - * This is mainly to avoid channel name/state/history collisions across tests in same file. - */ - internal fun generateChannelName(): String { - return "test-channel-${UUID.randomUUID()}" - } - - @After - fun afterEach() { - for (ablyRealtime in realtimeClients.values) { - for ((channelName, channel) in ablyRealtime.channels.entrySet()) { - channel.off() - ablyRealtime.channels.release(channelName) - } - ablyRealtime.close() - } - realtimeClients.clear() - } - - companion object { - private lateinit var sandbox: Sandbox - internal lateinit var restObjects: RestObjects - - @JvmStatic - @Parameterized.Parameters(name = "{0}") - fun data(): Iterable { - return listOf("msgpack_protocol", "json_protocol") - } - - @JvmStatic - @BeforeClass - @Throws(Exception::class) - fun setUpBeforeClass() { - runBlocking { - sandbox = Sandbox.createInstance() - restObjects = sandbox.createRestObjects() - } - } - - @JvmStatic - @AfterClass - @Throws(Exception::class) - fun tearDownAfterClass() { - } - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt deleted file mode 100644 index cfcd4ed2b..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt +++ /dev/null @@ -1,94 +0,0 @@ -package io.ably.lib.objects.integration.setup - -import com.google.gson.JsonElement -import com.google.gson.JsonParser -import io.ably.lib.objects.ablyException -import io.ably.lib.objects.integration.helpers.RestObjects -import io.ably.lib.realtime.* -import io.ably.lib.types.ClientOptions -import io.ktor.client.* -import io.ktor.client.engine.cio.* -import io.ktor.client.network.sockets.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.bodyAsText -import io.ktor.http.* -import kotlinx.coroutines.CompletableDeferred - -private val client = HttpClient(CIO) { - install(HttpRequestRetry) { - maxRetries = 5 - retryIf { _, response -> - !response.status.isSuccess() - } - retryOnExceptionIf { _, cause -> - cause is ConnectTimeoutException || - cause is HttpRequestTimeoutException || - cause is SocketTimeoutException - } - exponentialDelay() - } -} - -class Sandbox private constructor(val appId: String, val apiKey: String) { - companion object { - private suspend fun loadAppCreationJson(): JsonElement = - JsonParser.parseString( - client.get("https://raw.githubusercontent.com/ably/ably-common/refs/heads/main/test-resources/test-app-setup.json") { - contentType(ContentType.Application.Json) - }.bodyAsText(), - ).asJsonObject.get("post_apps") - - internal suspend fun createInstance(): Sandbox { - val response: HttpResponse = client.post("https://sandbox.realtime.ably-nonprod.net/apps") { - contentType(ContentType.Application.Json) - setBody(loadAppCreationJson().toString()) - } - val body = JsonParser.parseString(response.bodyAsText()) - - return Sandbox( - appId = body.asJsonObject["appId"].asString, - // From JS chat repo at 7985ab7 — "The key we need to use is the one at index 5, which gives enough permissions to interact with Chat and Channels" - apiKey = body.asJsonObject["keys"].asJsonArray[0].asJsonObject["keyStr"].asString, - ) - } - } -} - - -internal fun Sandbox.createRealtimeClient(options: ClientOptions.() -> Unit): AblyRealtime { - val clientOptions = ClientOptions().apply { - apply(options) - key = apiKey - environment = "sandbox" - } - return AblyRealtime(clientOptions) -} - -internal fun Sandbox.createRestObjects(): RestObjects { - val options = ClientOptions().apply { - key = apiKey - environment = "sandbox" - useBinaryProtocol = false - } - return RestObjects(options) -} - -internal suspend fun AblyRealtime.ensureConnected() { - if (this.connection.state == ConnectionState.connected) { - return - } - val connectedDeferred = CompletableDeferred() - this.connection.on { - if (it.event == ConnectionEvent.connected) { - connectedDeferred.complete(Unit) - this.connection.off() - } else if (it.event != ConnectionEvent.connecting) { - connectedDeferred.completeExceptionally(ablyException(it.reason)) - this.connection.off() - this.close() - } - } - connectedDeferred.await() -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/HelpersTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/HelpersTest.kt deleted file mode 100644 index 21f5c6792..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/HelpersTest.kt +++ /dev/null @@ -1,473 +0,0 @@ -package io.ably.lib.objects.unit - -import io.ably.lib.objects.* -import io.ably.lib.realtime.Channel -import io.ably.lib.realtime.ChannelState -import io.ably.lib.realtime.ChannelStateListener -import io.ably.lib.realtime.CompletionListener -import io.ably.lib.realtime.ConnectionEvent -import io.ably.lib.realtime.ConnectionStateListener -import io.ably.lib.types.* -import io.mockk.* -import kotlinx.coroutines.test.runTest -import org.junit.Assert.* -import org.junit.Test -import kotlin.test.assertFailsWith - -class HelpersTest { - - // sendAsync - @Test - fun testSendAsyncShouldQueueAccordingToClientOptions() = runTest { - val adapter = getMockObjectsAdapter() - val connManager = adapter.connectionManager - val clientOptions = ClientOptions().apply { queueMessages = false } - - every { adapter.clientOptions } returns clientOptions - - every { connManager.send(any(), any(), any()) } answers { - val listener = thirdArg>() - listener.onSuccess(PublishResult(null)) - } - - val pm = ProtocolMessage(ProtocolMessage.Action.message) - adapter.sendAsync(pm) - verify(exactly = 1) { connManager.send(any(), false, any()) } - - clientOptions.queueMessages = true - adapter.sendAsync(pm) - verify(exactly = 1) { connManager.send(any(), true, any()) } - } - - @Test - fun testSendAsyncErrorPropagatesAblyException() = runTest { - val adapter = getMockObjectsAdapter() - val connManager = adapter.connectionManager - val clientOptions = ClientOptions() - - every { adapter.clientOptions } returns clientOptions - - every { connManager.send(any(), any(), any()) } answers { - val listener = thirdArg>() - listener.onError(clientError("boom").errorInfo) - } - - val ex = assertFailsWith { - adapter.sendAsync(ProtocolMessage(ProtocolMessage.Action.message)) - } - assertEquals(400, ex.errorInfo.statusCode) - assertEquals(40000, ex.errorInfo.code) - } - - @Test - fun testOnGCGracePeriodImmediateInvokesBlock() { - val adapter = getMockObjectsAdapter() - val connManager = adapter.connectionManager - connManager.setPrivateField("objectsGCGracePeriod", 123L) - - var value: Long? = null - adapter.onGCGracePeriodUpdated { v -> value = v } - - assertEquals(123L, value) - verify(exactly = 1) { adapter.connection.on(ConnectionEvent.connected, any()) } - } - - @Test - fun testOnGCGracePeriodDeferredInvokesOnConnectedWithValue() { - val adapter = getMockObjectsAdapter() - val connManager = adapter.connectionManager - val connection = adapter.connection - - var value: Long? = null - every { connection.on(ConnectionEvent.connected, any()) } answers { - val listener = secondArg() - connManager.setPrivateField("objectsGCGracePeriod", 456L) - listener.onConnectionStateChanged(mockk(relaxed = true)) - } - - adapter.onGCGracePeriodUpdated { v -> value = v } - - assertEquals(456L, value) - verify(exactly = 1) { connection.on(ConnectionEvent.connected, any()) } - } - - @Test - fun testOnGCGracePeriodDeferredInvokesOnConnectedWithNull() { - val adapter = getMockObjectsAdapter() - val connection = adapter.connection - - var value: Long? = null - every { connection.on(ConnectionEvent.connected, any()) } answers { - val listener = secondArg() - listener.onConnectionStateChanged(mockk(relaxed = true)) - } - - adapter.onGCGracePeriodUpdated { v -> value = v } - - assertNull(value) - verify(exactly = 1) { connection.on(ConnectionEvent.connected, any()) } - } - - @Test - fun testSendAsyncThrowsWhenConnectionManagerThrows() = runTest { - val adapter = getMockObjectsAdapter() - val connManager = adapter.connectionManager - val clientOptions = ClientOptions() - - every { adapter.clientOptions } returns clientOptions - - every { connManager.send(any(), any(), any()) } throws RuntimeException("send failed hard") - - val ex = assertFailsWith { - adapter.sendAsync(ProtocolMessage(ProtocolMessage.Action.message)) - } - assertEquals("send failed hard", ex.message) - } - - // attachAsync - @Test - fun testAttachAsyncSuccess() = runTest { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - every { channel.attach(any()) } answers { - val listener = firstArg() - listener.onSuccess() - } - - adapter.attachAsync("ch") - verify(exactly = 1) { channel.attach(any()) } - } - - @Test - fun testAttachAsyncError() = runTest { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - every { channel.attach(any()) } answers { - val listener = firstArg() - listener.onError(serverError("attach failed").errorInfo) - } - - val ex = assertFailsWith { adapter.attachAsync("ch") } - assertEquals(50000, ex.errorInfo.code) - assertEquals(500, ex.errorInfo.statusCode) - } - - // getChannelModes - @Test - fun testGetChannelModesPrefersChannelModes() { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - every { channel.modes } returns arrayOf(ChannelMode.object_publish) - every { channel.options } returns ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe) } - - val modes = adapter.getChannelModes("ch") - assertArrayEquals(arrayOf(ChannelMode.object_publish), modes) - } - - @Test - fun testGetChannelModesFallsBackToOptions() { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - every { channel.modes } returns emptyArray() - every { channel.options } returns ChannelOptions().apply { modes = arrayOf(ChannelMode.object_subscribe) } - - val modes = adapter.getChannelModes("ch") - assertArrayEquals(arrayOf(ChannelMode.object_subscribe), modes) - } - - @Test - fun testGetChannelModesReturnsNullWhenNoModes() { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - every { channel.modes } returns null - every { channel.options } returns ChannelOptions().apply { modes = null } - - val modes = adapter.getChannelModes("ch") - assertNull(modes) - } - - @Test - fun testGetChannelModesIgnoresEmptyModes() { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - every { channel.modes } returns emptyArray() - every { channel.options } returns ChannelOptions().apply { modes = null } - - val modes = adapter.getChannelModes("ch") - assertNull(modes) - } - - // setChannelSerial - @Test - fun testSetChannelSerialSetsWhenObjectActionAndNonEmpty() { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - val props = ChannelProperties() - channel.properties = props - every { adapter.getChannel("ch") } returns channel - - val msg = ProtocolMessage(ProtocolMessage.Action.`object`) - msg.channelSerial = "abc:123" - - adapter.setChannelSerial("ch", msg) - assertEquals("abc:123", props.channelSerial) - } - - @Test - fun testSetChannelSerialNoOpForNonObjectActionOrEmpty() { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - val props = ChannelProperties() - channel.properties = props - every { adapter.getChannel("ch") } returns channel - - // Non-object action - val msg1 = ProtocolMessage(ProtocolMessage.Action.message) - msg1.channelSerial = "abc" - adapter.setChannelSerial("ch", msg1) - assertNull(props.channelSerial) - - // Object action but empty serial - val msg2 = ProtocolMessage(ProtocolMessage.Action.`object`) - msg2.channelSerial = "" - adapter.setChannelSerial("ch", msg2) - assertNull(props.channelSerial) - } - - // ensureAttached - @Test - fun testEnsureAttachedFromInitializedAttaches() = runTest { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - - every { adapter.getChannel("ch") } returns channel - channel.state = ChannelState.initialized - - val attachCalled = slot() - every { channel.attach(capture(attachCalled)) } answers { - attachCalled.captured.onSuccess() - } - - adapter.ensureAttached("ch") - verify(exactly = 1) { channel.attach(any()) } - } - - @Test - fun testEnsureAttachedWhenAlreadyAttachedReturns() = runTest { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - channel.state = ChannelState.attached - - adapter.ensureAttached("ch") - // no attach call - verify(exactly = 0) { channel.attach(any()) } - } - - @Test - fun testEnsureAttachedWaitsForAttachingThenAttached() = runTest { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - channel.state = ChannelState.attaching - - every { channel.once(any()) } answers { - val listener = firstArg() - val stateChange = mockk(relaxed = true) { - setPrivateField("current", ChannelState.attached) - } - listener.onChannelStateChanged(stateChange) - } - - adapter.ensureAttached("ch") - verify(exactly = 1) { channel.once(any()) } - } - - @Test - fun testEnsureAttachedAttachingButReceivesNonAttachedEmitsError() = runTest { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - channel.state = ChannelState.attaching - every { channel.once(any()) } answers { - val listener = firstArg() - val stateChange = mockk(relaxed = true) { - setPrivateField("current", ChannelState.suspended) - setPrivateField("reason", clientError("Not attached").errorInfo) - } - listener.onChannelStateChanged(stateChange) - } - val ex = assertFailsWith { adapter.ensureAttached("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) - assertTrue(ex.errorInfo.message.contains("Not attached")) - verify(exactly = 1) { channel.once(any()) } - } - - @Test - fun testEnsureAttachedThrowsForInvalidState() = runTest { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - channel.state = ChannelState.failed - - val ex = assertFailsWith { adapter.ensureAttached("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) - } - - // throwIfInvalidAccessApiConfiguration - @Test - fun testThrowIfInvalidAccessApiConfigurationStateDetached() { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - channel.state = ChannelState.detached - - val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) - } - - @Test - fun testThrowIfInvalidAccessApiConfigurationMissingMode() { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - channel.state = ChannelState.attached - every { channel.modes } returns emptyArray() - every { channel.options } returns ChannelOptions().apply { modes = null } - - val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) - assertTrue(ex.errorInfo.message.contains("object_subscribe")) - } - - // throwIfInvalidWriteApiConfiguration - @Test - fun testThrowIfInvalidWriteApiConfigurationEchoDisabled() { - val adapter = mockk(relaxed = true) - val clientOptions = ClientOptions().apply { echoMessages = false } - every { adapter.clientOptions } returns clientOptions - - val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.BadRequest.code, ex.errorInfo.code) - assertTrue(ex.errorInfo.message.contains("echoMessages")) - } - - @Test - fun testThrowIfInvalidWriteApiConfigurationInvalidState() { - val adapter = mockk(relaxed = true) - every { adapter.clientOptions } returns ClientOptions() - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - channel.state = ChannelState.suspended - - val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) - } - - @Test - fun testThrowIfInvalidWriteApiConfigurationMissingMode() { - val adapter = mockk(relaxed = true) - every { adapter.clientOptions } returns ClientOptions() - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - channel.state = ChannelState.attached - every { channel.modes } returns emptyArray() - every { channel.options } returns ChannelOptions().apply { modes = null } - - val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) - assertTrue(ex.errorInfo.message.contains("object_publish")) - } - - // throwIfUnpublishableState - @Test - fun testThrowIfUnpublishableStateInactiveConnection() { - val adapter = getMockObjectsAdapter() - val connManager = adapter.connectionManager - every { connManager.isActive } returns false - every { connManager.stateErrorInfo } returns serverError("not active").errorInfo - - val ex = assertFailsWith { adapter.throwIfUnpublishableState("ch") } - assertEquals(500, ex.errorInfo.statusCode) - assertEquals(50000, ex.errorInfo.code) - } - - @Test - fun testThrowIfUnpublishableStateChannelFailed() { - val adapter = getMockObjectsAdapter() - val connManager = adapter.connectionManager - every { connManager.isActive } returns true - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - channel.state = ChannelState.failed - - val ex = assertFailsWith { adapter.throwIfUnpublishableState("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) - } - - @Test - fun testAccessConfigThrowsWhenRequiredModeMissing() { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - channel.state = ChannelState.attached - // No modes anywhere - every { channel.modes } returns null - every { channel.options } returns ChannelOptions().apply { modes = null } - - val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) - assertTrue(ex.errorInfo.message.contains("object_subscribe")) - } - - @Test - fun testWriteConfigThrowsWhenRequiredModeMissing() { - val adapter = mockk(relaxed = true) - every { adapter.clientOptions } returns ClientOptions() // echo enabled - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - channel.state = ChannelState.attached - every { channel.modes } returns emptyArray() - every { channel.options } returns ChannelOptions().apply { modes = null } - - val ex = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelModeRequired.code, ex.errorInfo.code) - assertTrue(ex.errorInfo.message.contains("object_publish")) - } - - @Test - fun testAccessConfigThrowsOnInvalidChannelState() { - val adapter = mockk(relaxed = true) - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - channel.state = ChannelState.detached - - val ex = assertFailsWith { adapter.throwIfInvalidAccessApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex.errorInfo.code) - } - - @Test - fun testWriteConfigThrowsOnInvalidChannelStates() { - val adapter = mockk(relaxed = true) - every { adapter.clientOptions } returns ClientOptions() - val channel = mockk(relaxed = true) - every { adapter.getChannel("ch") } returns channel - - // Suspended should be rejected for write config - channel.state = ChannelState.suspended - val ex1 = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex1.errorInfo.code) - - // Failed should also be rejected - channel.state = ChannelState.failed - val ex2 = assertFailsWith { adapter.throwIfInvalidWriteApiConfiguration("ch") } - assertEquals(ErrorCode.ChannelStateError.code, ex2.errorInfo.code) - } - -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt deleted file mode 100644 index d8eaaf697..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectIdTest.kt +++ /dev/null @@ -1,75 +0,0 @@ -package io.ably.lib.objects.unit - -import io.ably.lib.objects.ObjectId -import io.ably.lib.objects.type.ObjectType -import io.ably.lib.types.AblyException -import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows -import org.junit.Test -import kotlin.test.assertTrue - -class ObjectIdTest { - - @Test - fun testValidMapObjectId() { - val objectIdString = "map:abc123@1640995200000" - val objectId = ObjectId.fromString(objectIdString) - - assertEquals(ObjectType.Map, objectId.type) - assertEquals("map:abc123@1640995200000", objectId.toString()) - } - - @Test - fun testValidCounterObjectId() { - val objectIdString = "counter:def456@1640995200000" - val objectId = ObjectId.fromString(objectIdString) - - assertEquals(ObjectType.Counter, objectId.type) - assertEquals("counter:def456@1640995200000", objectId.toString()) - } - - @Test - fun testInvalidObjectType() { - val exception = assertThrows(AblyException::class.java) { - ObjectId.fromString("invalid:abc123@1640995200000") - } - assertAblyExceptionError(exception) - } - - @Test - fun testEmptyObjectId() { - val exception1 = assertThrows(AblyException::class.java) { - ObjectId.fromString("") - } - assertAblyExceptionError(exception1) - } - - private fun assertAblyExceptionError( - exception: AblyException - ) { - assertTrue(exception.errorInfo?.message?.contains("Invalid object id:") == true || - exception.errorInfo?.message?.contains("Invalid object type in object id:") == true) - assertEquals(92_000, exception.errorInfo?.code) - assertEquals(500, exception.errorInfo?.statusCode) - } - - @Test - fun testFromInitialValue() { - val objectType = ObjectType.Map - val initialValue = "test-value" - val nonce = "test-nonce" - val msTimestamp = 1640995200000L - - val objectId = ObjectId.fromInitialValue(objectType, initialValue, nonce, msTimestamp) - // Verify the string format follows the expected pattern: type:hash@timestamp - val objectIdString = objectId.toString() - assertTrue(objectIdString.startsWith("map:")) - assertTrue(objectIdString.contains("@")) - assertTrue(objectIdString.endsWith(msTimestamp.toString())) - - val expectedHash = "GSjv-adTaJPL8-382qF3JuIyE4TCc6QKIIqb577pz00" - // Verify the hash value matches expected - val hashPart = objectIdString.substring(4, objectIdString.indexOf("@")) - assertEquals(expectedHash, hashPart) - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt deleted file mode 100644 index 776006c41..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSerializationTest.kt +++ /dev/null @@ -1,179 +0,0 @@ -package io.ably.lib.objects.unit - -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.JsonElement -import com.google.gson.JsonNull -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.unit.fixtures.* -import io.ably.lib.types.ProtocolMessage -import io.ably.lib.types.ProtocolMessage.ActionSerializer -import io.ably.lib.types.ProtocolSerializer -import io.ably.lib.util.Serialisation -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Test -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class ObjectMessageSerializationTest { - - private val objectMessages = arrayOf( - dummyObjectMessageWithStringData(), - dummyObjectMessageWithBinaryData(), - dummyObjectMessageWithNumberData(), - dummyObjectMessageWithBooleanData(), - dummyObjectMessageWithJsonObjectData(), - dummyObjectMessageWithJsonArrayData() - ) - - @Test - fun testObjectMessageMsgPackSerialization() = runTest { - val protocolMessage = ProtocolMessage() - protocolMessage.action = ProtocolMessage.Action.`object` - protocolMessage.state = objectMessages - - // Serialize the ProtocolMessage containing ObjectMessages to MsgPack format - val serializedProtoMsg = ProtocolSerializer.writeMsgpack(protocolMessage) - assertNotNull(serializedProtoMsg) - - // Deserialize back to ProtocolMessage - val deserializedProtoMsg = ProtocolSerializer.readMsgpack(serializedProtoMsg) - assertNotNull(deserializedProtoMsg) - - deserializedProtoMsg.state.zip(objectMessages).forEach { (actual, expected) -> - assertEquals(expected, actual as? ObjectMessage) - } - } - - @Test - fun testObjectMessageJsonSerialization() = runTest { - val protocolMessage = ProtocolMessage() - protocolMessage.action = ProtocolMessage.Action.`object` - protocolMessage.state = objectMessages - - // Serialize the ProtocolMessage containing ObjectMessages to Json format - val serializedProtoMsg = ProtocolSerializer.writeJSON(protocolMessage).toString(Charsets.UTF_8) - assertNotNull(serializedProtoMsg) - - // Deserialize back to ProtocolMessage - val deserializedProtoMsg = ProtocolSerializer.fromJSON(serializedProtoMsg) - assertNotNull(deserializedProtoMsg) - - deserializedProtoMsg.state.zip(objectMessages).forEach { (actual, expected) -> - assertEquals(expected, (actual as? ObjectMessage)) - } - } - - @Test - fun testOmitNullsInObjectMessageSerialization() = runTest { - val objectMessage = dummyObjectMessageWithStringData() - val objectMessageWithNullFields = objectMessage.copy( - id = null, - timestamp = null, - clientId = "test-client", - connectionId = "test-connection", - extras = null, - operation = null, - objectState = null, - serial = null, - siteCode = null - ) - val protocolMessage = ProtocolMessage() - protocolMessage.action = ProtocolMessage.Action.`object` - protocolMessage.state = arrayOf(objectMessageWithNullFields) - - // check if Gson/Msgpack serialization omits null fields - fun assertSerializedObjectMessage(serializedProtoMsg: String) { - val deserializedProtoMsg = Gson().fromJson(serializedProtoMsg, JsonElement::class.java).asJsonObject - val serializedObjectMessage = deserializedProtoMsg.get("state").asJsonArray[0].asJsonObject.toString() - assertEquals("""{"clientId":"test-client","connectionId":"test-connection"}""", serializedObjectMessage) - } - - // Serialize using Gson - val serializedProtoMsg = ProtocolSerializer.writeJSON(protocolMessage).toString(Charsets.UTF_8) - assertSerializedObjectMessage(serializedProtoMsg) - - // Serialize using MsgPack - val serializedMsgpackBytes = ProtocolSerializer.writeMsgpack(protocolMessage) - val serializedJsonStringFromMsgpackBytes = Serialisation.msgpackToGson(serializedMsgpackBytes).toString() - assertSerializedObjectMessage(serializedJsonStringFromMsgpackBytes) - } - - @Test - fun testSerializeEnumsIntoOrdinalValues() = runTest { - val objectMessage = dummyObjectMessageWithStringData() - val protocolMessage = ProtocolMessage() - protocolMessage.action = ProtocolMessage.Action.`object` - protocolMessage.state = arrayOf(objectMessage) - - fun assertSerializedObjectMessage(serializedProtoMsg: String) { - val deserializedProtoMsg = Gson().fromJson(serializedProtoMsg, JsonElement::class.java).asJsonObject - val serializedObjectMessage = deserializedProtoMsg.get("state").asJsonArray[0].asJsonObject - val operation = serializedObjectMessage.get("operation").asJsonObject - assertTrue(operation.has("action")) - assertEquals(0, operation.get("action").asInt) // Check if action is serialized as code - } - - // Serialize using Gson - val serializedProtoMsg = ProtocolSerializer.writeJSON(protocolMessage).toString(Charsets.UTF_8) - assertSerializedObjectMessage(serializedProtoMsg) - // Serialize using MsgPack - val serializedMsgpackBytes = ProtocolSerializer.writeMsgpack(protocolMessage) - val serializedJsonStringFromMsgpackBytes = Serialisation.msgpackToGson(serializedMsgpackBytes).toString() - assertSerializedObjectMessage(serializedJsonStringFromMsgpackBytes) - } - - @Test - fun testHandleNullsInObjectMessageDeserialization() = runTest { - val protocolMessage = ProtocolMessage() - protocolMessage.id = "id" - protocolMessage.action = ProtocolMessage.Action.`object` - protocolMessage.state = null - - // Serialize using Gson with serializeNulls enabled - val gsonBuilderCreatingNulls = GsonBuilder() - .registerTypeAdapter(ProtocolMessage.Action::class.java, ActionSerializer()) - .serializeNulls().create() - - var protoMsgJsonObject = gsonBuilderCreatingNulls.toJsonTree(protocolMessage).asJsonObject - assertTrue(protoMsgJsonObject.has("state")) - assertEquals(JsonNull.INSTANCE, protoMsgJsonObject.get("state")) - - var deserializedProtoMsg = ProtocolSerializer.fromJSON(protoMsgJsonObject.toString()) - assertNull(deserializedProtoMsg.state) - - var serializedMsgpackBytes = Serialisation.gsonToMsgpack(protoMsgJsonObject) - deserializedProtoMsg = ProtocolSerializer.readMsgpack(serializedMsgpackBytes) - assertNull(deserializedProtoMsg.state) - - // Create ObjectMessage and serialize in a way that resulting string/bytes include null fields - val objectMessage = dummyObjectMessageWithStringData() - val objectMessageWithNullFields = objectMessage.copy( - id = null, - timestamp = null, - clientId = "test-client", - connectionId = "test-connection", - extras = null, - operation = objectMessage.operation?.copy( - mapCreateWithObjectId = null, - mapCreate = null - ), - objectState = null, - serial = null, - siteCode = null - ) - protocolMessage.state = arrayOf(objectMessageWithNullFields) - protoMsgJsonObject = gsonBuilderCreatingNulls.toJsonTree(protocolMessage).asJsonObject - - // Check if gson deserialization works correctly - deserializedProtoMsg = ProtocolSerializer.fromJSON(protoMsgJsonObject.toString()) - assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? ObjectMessage) - - // Check if msgpack deserialization works correctly - serializedMsgpackBytes = Serialisation.gsonToMsgpack(protoMsgJsonObject) - deserializedProtoMsg = ProtocolSerializer.readMsgpack(serializedMsgpackBytes) - assertEquals(objectMessageWithNullFields, deserializedProtoMsg.state[0] as? ObjectMessage) - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt deleted file mode 100644 index 4c413649e..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectMessageSizeTest.kt +++ /dev/null @@ -1,182 +0,0 @@ -package io.ably.lib.objects.unit - -import com.google.gson.JsonObject -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterCreateWithObjectId -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapCreateWithObjectId -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ensureMessageSizeWithinLimit -import io.ably.lib.objects.size -import io.ably.lib.transport.Defaults -import io.ably.lib.types.AblyException -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -class ObjectMessageSizeTest { - @Test - fun testObjectMessageSizeWithinLimit() = runTest { - val mockAdapter = getMockObjectsAdapter() - mockAdapter.connectionManager.maxMessageSize = Defaults.maxMessageSize // 64 kb - assertEquals(65536, mockAdapter.connectionManager.maxMessageSize) - - // ObjectMessage with all size-contributing fields - val objectMessage = ObjectMessage( - id = "msg_12345", // Not counted in size calculation - timestamp = 1699123456789L, // Not counted in size calculation - clientId = "test-client", // Size: 11 bytes (UTF-8 byte length) - connectionId = "conn_98765", // Not counted in size calculation - extras = JsonObject().apply { // Size: JSON serialization byte length - addProperty("meta", "data") // JSON: {"meta":"data","count":42} - addProperty("count", 42) - }, // Total extras size: 26 bytes (verified by gson.toJson().length) - operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "obj_54321", // Not counted in operation size - - // MapSet contributes to operation size - mapSet = MapSet( - key = "mapKey", // Size: 6 bytes (UTF-8 byte length) - value = ObjectData( - objectId = "ref_obj", // Not counted in data size - string = "sample" // Size: 6 bytes (UTF-8 byte length) - ) // Total ObjectData size: 6 bytes - ), // Total MapSet size: 6 + 6 = 12 bytes - - // CounterInc contributes to operation size - counterInc = CounterInc( - number = 10.0 // Size: 8 bytes (number is always 8 bytes) - ), // Total CounterInc size: 8 bytes - - // mapCreateWithObjectId.derivedFrom contributes to operation size (for client-initiated MAP_CREATE operations) - mapCreateWithObjectId = MapCreateWithObjectId( - nonce = "dummy-nonce", // Not counted in derivedFrom size - initialValue = "{}", // Not counted in derivedFrom size - derivedFrom = MapCreate( - semantics = ObjectsMapSemantics.LWW, // Not counted in size - entries = mapOf( - "entry1" to ObjectsMapEntry( // Key size: 6 bytes - tombstone = false, // Not counted in entry size - timeserial = "ts_123", // Not counted in entry size - data = ObjectData( - string = "value1" // Size: 6 bytes - ) // ObjectMapEntry size: 6 bytes - ), // Total for this entry: 6 (key) + 6 (entry) = 12 bytes - "entry2" to ObjectsMapEntry( // Key size: 6 bytes - data = ObjectData( - number = 42.0 // Size: 8 bytes (number) - ) // ObjectMapEntry size: 8 bytes - ) // Total for this entry: 6 (key) + 8 (entry) = 14 bytes - ) // Total entries size: 12 + 14 = 26 bytes - ), // Total derivedFrom (MapCreate) size: 26 bytes - ), // Total mapCreateWithObjectId size (via derivedFrom): 26 bytes - - // counterCreateWithObjectId.derivedFrom contributes to operation size (for client-initiated COUNTER_CREATE operations) - counterCreateWithObjectId = CounterCreateWithObjectId( - nonce = "dummy-nonce", // Not counted in derivedFrom size - initialValue = "{}", // Not counted in derivedFrom size - derivedFrom = CounterCreate( - count = 100.0 // Size: 8 bytes (number is always 8 bytes) - ), // Total derivedFrom (CounterCreate) size: 8 bytes - ), // Total counterCreateWithObjectId size (via derivedFrom): 8 bytes - - ), // Total ObjectOperation size: 12 + 8 + 26 + 8 = 54 bytes - - objectState = ObjectState( - objectId = "state_obj", // Not counted in state size - siteTimeserials = mapOf("site1" to "serial1"), // Not counted in state size - tombstone = false, // Not counted in state size - - // createOp contributes to state size - createOp = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "create_obj", - mapSet = MapSet( - key = "createKey", // Size: 9 bytes - value = ObjectData( - string = "createValue" // Size: 11 bytes - ) // ObjectData size: 11 bytes - ) // MapSet size: 9 + 11 = 20 bytes - ), // Total createOp size: 20 bytes - - // map contributes to state size - map = ObjectsMap( - entries = mapOf( - "stateKey" to ObjectsMapEntry( // Key size: 8 bytes - data = ObjectData( - string = "stateValue" // Size: 10 bytes - ) // ObjectMapEntry size: 10 bytes - ) // Total: 8 + 10 = 18 bytes - ) - ), // Total ObjectMap size: 18 bytes - - // counter contributes to state size - counter = ObjectsCounter( - count = 50.0 // Size: 8 bytes - ) // Total ObjectCounter size: 8 bytes - ), // Total ObjectState size: 20 + 18 + 8 = 46 bytes - - serial = "serial_123", // Not counted in size calculation - siteCode = "site_abc" // Not counted in size calculation - ) - - // clientId: 11 bytes + operation: 54 bytes + objectState: 46 bytes + extras: 26 bytes = 137 bytes - val messageSize = objectMessage.size() - assertEquals(137, messageSize) - - // Verify the message doesn't exceed the maxMessageSize limit - mockAdapter.ensureMessageSizeWithinLimit(arrayOf(objectMessage)) - } - - @Test - fun testObjectMessageSizeForUnicodeCharacters() = runTest { - val objectMessage = ObjectMessage( - operation = ObjectOperation( - objectId = "", - action = ObjectOperationAction.MapSet, - mapSet = MapSet( - key = "", - value = ObjectData( - string = "你😊" // 你 -> 3 bytes, 😊 -> 4 bytes - ), - ), - ) - ) - assertEquals(7, objectMessage.size()) - } - - @Test - fun testObjectMessageSizeAboveLimit() = runTest { - val mockAdapter = getMockObjectsAdapter() - mockAdapter.connectionManager.maxMessageSize = Defaults.maxMessageSize // 64 kb - assertEquals(65536, mockAdapter.connectionManager.maxMessageSize) - - // Create ObjectMessage with dummy data that results in size 60kb - val objectMessage1 = ObjectMessage( - clientId = CharArray(60 * 1024) { ('a'..'z').random() }.concatToString() - ) - assertEquals(60 * 1024, objectMessage1.size()) - - // Create ObjectMessage with dummy data that results in size 5kb - val objectMessage2 = ObjectMessage( - clientId = CharArray(5 * 1024) { ('a'..'z').random() }.concatToString() - ) - assertEquals(5 * 1024, objectMessage2.size()) - - val exception = assertFailsWith { - mockAdapter.ensureMessageSizeWithinLimit(arrayOf(objectMessage1, objectMessage2)) // sum size = 65kb exceeds limit - } - // Assert on error code and message - assertEquals(40009, exception.errorInfo.code) - val expectedMessage = "ObjectMessages size 66560 exceeds maximum allowed size of 65536 bytes" - assertEquals(expectedMessage, exception.errorInfo.message) - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt deleted file mode 100644 index 3f63a2d82..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/ObjectsSyncTrackerTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package io.ably.lib.objects.unit - -import io.ably.lib.objects.ObjectsSyncTracker -import org.junit.Test -import org.junit.Assert.* - -class ObjectsSyncTrackerTest { - - @Test - fun `(RTO5a, RTO5a1, RTO5a2) Should parse valid sync channel serial with syncId and cursor`() { - val syncTracker = ObjectsSyncTracker("sync-123:cursor-456") - - assertEquals("sync-123", syncTracker.syncId) - assertFalse(syncTracker.hasSyncStarted("sync-123")) - assertTrue(syncTracker.hasSyncStarted(null)) - assertTrue(syncTracker.hasSyncStarted("sync-124")) - - assertEquals("cursor-456", syncTracker.syncCursor) - assertFalse(syncTracker.hasSyncEnded()) - } - - @Test - fun `(RTO5a5) Should handle null sync channel serial`() { - val syncTracker = ObjectsSyncTracker(null) - - assertNull(syncTracker.syncId) - assertTrue(syncTracker.hasSyncStarted(null)) - - assertNull(syncTracker.syncCursor) - assertTrue(syncTracker.hasSyncEnded()) - } - - @Test - fun `(RTO5a5) Should handle empty sync channel serial`() { - val syncTracker = ObjectsSyncTracker("") - - assertNull(syncTracker.syncId) - assertTrue(syncTracker.hasSyncStarted(null)) - - assertNull(syncTracker.syncCursor) - assertTrue(syncTracker.hasSyncEnded()) - } - - @Test - fun `should handle sync channel serial with special characters`() { - val syncTracker = ObjectsSyncTracker("sync_123-456:cursor_789-012") - - assertEquals("sync_123-456", syncTracker.syncId) - - assertEquals("cursor_789-012", syncTracker.syncCursor) - assertFalse(syncTracker.hasSyncEnded()) - } - - @Test - fun `(RTO5a4) should detect sync sequence ended when sync cursor is empty`() { - val syncTracker = ObjectsSyncTracker("sync-123:") - - assertEquals("sync-123", syncTracker.syncId) - assertTrue(syncTracker.hasSyncStarted(null)) - assertTrue(syncTracker.hasSyncStarted("")) - - assertEquals("", syncTracker.syncCursor) - assertTrue(syncTracker.hasSyncEnded()) - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt deleted file mode 100644 index ec8824e1a..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/RealtimeObjectsTest.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.ably.lib.objects.unit - -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertNotNull - -class RealtimeObjectsTest { - @Test - fun testChannelObjectGetterTest() = runTest { - val channel = getMockRealtimeChannel("test-channel") - val objects = channel.objects - assertNotNull(objects) - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt deleted file mode 100644 index 17be76951..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/TestHelpers.kt +++ /dev/null @@ -1,169 +0,0 @@ -package io.ably.lib.objects.unit - -import io.ably.lib.objects.* -import io.ably.lib.objects.DefaultRealtimeObjects -import io.ably.lib.objects.ObjectsManager -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livecounter.LiveCounterManager -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.type.livemap.LiveMapManager -import io.ably.lib.realtime.AblyRealtime -import io.ably.lib.realtime.Channel -import io.ably.lib.realtime.ChannelState -import io.ably.lib.transport.ConnectionManager -import io.ably.lib.types.ChannelMode -import io.ably.lib.types.ChannelOptions -import io.ably.lib.types.ClientOptions -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.spyk -import kotlinx.coroutines.CompletableDeferred - -internal fun getMockRealtimeChannel( - channelName: String, - clientId: String = "client1", - channelModes: Array = arrayOf(ChannelMode.object_publish, ChannelMode.object_subscribe)): Channel { - val client = AblyRealtime(ClientOptions().apply { - autoConnect = false - key = "keyName:Value" - this.clientId = clientId - }) - val channelOpts = ChannelOptions().apply { modes = channelModes } - val channel = client.channels.get(channelName, channelOpts) - return spyk(channel) { - every { attach() } answers { - state = ChannelState.attached - } - every { detach() } answers { - state = ChannelState.detached - } - every { subscribe(any(), any()) } returns mockk(relaxUnitFun = true) - every { subscribe(any>(), any()) } returns mockk(relaxUnitFun = true) - every { subscribe(any()) } returns mockk(relaxUnitFun = true) - }.apply { - state = ChannelState.attached - } -} - -internal fun getMockObjectsAdapter(): ObjectsAdapter { - mockkStatic("io.ably.lib.objects.HelpersKt") - return mockk(relaxed = true) { - every { getChannel(any()) } returns getMockRealtimeChannel("testChannelName") - every { connectionManager } returns mockk(relaxed = true) - } -} - -internal fun getMockObjectsPool(): ObjectsPool { - return mockk(relaxed = true) -} - -internal fun ObjectsPool.size(): Int { - val pool = this.getPrivateField>("pool") - return pool.size -} - -internal val BaseRealtimeObject.TombstonedAt: Long? - get() = this.getPrivateField("tombstonedAt") - -/** - * ====================================== - * START - DefaultRealtimeObjects dep mocks - * ====================================== - */ -internal val ObjectsManager.SyncObjectsPool: Map - get() = this.getPrivateField("syncObjectsPool") - -internal val ObjectsManager.BufferedObjectOperations: List - get() = this.getPrivateField("bufferedObjectOperations") - -internal val ObjectsManager.SyncCompletionWaiter: CompletableDeferred? - get() = this.getPrivateField("syncCompletionWaiter") - -internal var DefaultRealtimeObjects.ObjectsManager: ObjectsManager - get() = this.getPrivateField("objectsManager") - set(value) = this.setPrivateField("objectsManager", value) - -internal var DefaultRealtimeObjects.ObjectsPool: ObjectsPool - get() = this.objectsPool - set(value) = this.setPrivateField("objectsPool", value) - -internal fun getDefaultRealtimeObjectsWithMockedDeps( - channelName: String = "testChannelName", - relaxed: Boolean = false -): DefaultRealtimeObjects { - val defaultRealtimeObjects = DefaultRealtimeObjects(channelName, getMockObjectsAdapter()) - // mock objectsPool to allow verification of method calls - if (relaxed) { - defaultRealtimeObjects.ObjectsPool = mockk(relaxed = true) - } else { - defaultRealtimeObjects.ObjectsPool = spyk(defaultRealtimeObjects.objectsPool, recordPrivateCalls = true) - } - // mock objectsManager to allow verification of method calls - if (relaxed) { - defaultRealtimeObjects.ObjectsManager = mockk(relaxed = true) - } else { - defaultRealtimeObjects.ObjectsManager = spyk(defaultRealtimeObjects.ObjectsManager, recordPrivateCalls = true) - } - return defaultRealtimeObjects -} -/** - * ====================================== - * END - DefaultRealtimeObjects dep mocks - * ====================================== - */ - -/** - * ====================================== - * START - DefaultLiveCounter dep mocks - * ====================================== - */ -internal var DefaultLiveCounter.LiveCounterManager: LiveCounterManager - get() = this.getPrivateField("liveCounterManager") - set(value) = this.setPrivateField("liveCounterManager", value) - -internal fun getDefaultLiveCounterWithMockedDeps( - objectId: String = "counter:testCounter@1", - relaxed: Boolean = false -): DefaultLiveCounter { - val defaultLiveCounter = DefaultLiveCounter.zeroValue(objectId, getDefaultRealtimeObjectsWithMockedDeps()) - if (relaxed) { - defaultLiveCounter.LiveCounterManager = mockk(relaxed = true) - } else { - defaultLiveCounter.LiveCounterManager = spyk(defaultLiveCounter.LiveCounterManager, recordPrivateCalls = true) - } - return defaultLiveCounter -} -/** - * ====================================== - * END - DefaultLiveCounter dep mocks - * ====================================== - */ - -/** - * ====================================== - * START - DefaultLiveMap dep mocks - * ====================================== - */ -internal var DefaultLiveMap.LiveMapManager: LiveMapManager - get() = this.getPrivateField("liveMapManager") - set(value) = this.setPrivateField("liveMapManager", value) - -internal fun getDefaultLiveMapWithMockedDeps( - objectId: String = "map:testMap@1", - relaxed: Boolean = false -): DefaultLiveMap { - val defaultLiveMap = DefaultLiveMap.zeroValue(objectId, getDefaultRealtimeObjectsWithMockedDeps()) - if (relaxed) { - defaultLiveMap.LiveMapManager = mockk(relaxed = true) - } else { - defaultLiveMap.LiveMapManager = spyk(defaultLiveMap.LiveMapManager, recordPrivateCalls = true) - } - return defaultLiveMap -} -/** - * ====================================== - * END - DefaultLiveMap dep mocks - * ====================================== - */ diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt deleted file mode 100644 index a6cd9bcf8..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/UtilsTest.kt +++ /dev/null @@ -1,301 +0,0 @@ -package io.ably.lib.objects.unit - -import io.ably.lib.objects.* -import io.ably.lib.objects.assertWaiter -import io.ably.lib.types.AblyException -import io.ably.lib.types.ErrorInfo -import kotlinx.coroutines.* -import kotlinx.coroutines.test.* -import org.junit.Test -import org.junit.Assert.* -import java.util.concurrent.CancellationException - -class UtilsTest { - - @Test - fun testGenerateNonce() { - // Test basic functionality - val nonce1 = generateNonce() - val nonce2 = generateNonce() - - assertEquals(16, nonce1.length) - assertEquals(16, nonce2.length) - assertNotEquals(nonce1, nonce2) // Should be random - - // Test character set - val validChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - val nonce = generateNonce() - nonce.forEach { char -> - assertTrue("Nonce should only contain valid characters", validChars.contains(char)) - } - } - - @Test - fun testStringByteSize() { - // Test ASCII strings - assertEquals(5, "Hello".byteSize) - assertEquals(0, "".byteSize) - assertEquals(1, "A".byteSize) - - // Test non-ASCII strings - assertEquals(3, "你".byteSize) // Chinese character - assertEquals(4, "😊".byteSize) // Emoji - assertEquals(6, "你好".byteSize) // Two Chinese characters - } - - @Test - fun testErrorCreationFunctions() { - // Test clientError - val clientEx = clientError("Bad request") - assertEquals("Bad request", clientEx.errorInfo.message) - assertEquals(ErrorCode.BadRequest.code, clientEx.errorInfo.code) - assertEquals(HttpStatusCode.BadRequest.code, clientEx.errorInfo.statusCode) - - // Test serverError - val serverEx = serverError("Internal error") - assertEquals("Internal error", serverEx.errorInfo.message) - assertEquals(ErrorCode.InternalError.code, serverEx.errorInfo.code) - assertEquals(HttpStatusCode.InternalServerError.code, serverEx.errorInfo.statusCode) - - // Test objectError - val objectEx = objectError("Invalid object") - assertEquals("Invalid object", objectEx.errorInfo.message) - assertEquals(ErrorCode.InvalidObject.code, objectEx.errorInfo.code) - assertEquals(HttpStatusCode.InternalServerError.code, objectEx.errorInfo.statusCode) - - // Test objectError with cause - val cause = RuntimeException("Original error") - val objectExWithCause = objectError("Invalid object", cause) - assertEquals("Invalid object", objectExWithCause.errorInfo.message) - assertEquals(cause, objectExWithCause.cause) - } - - @Test - fun testAblyExceptionCreation() { - // Test with error message and codes - val ex = ablyException("Test error", ErrorCode.BadRequest, HttpStatusCode.BadRequest) - assertEquals("Test error", ex.errorInfo.message) - assertEquals(ErrorCode.BadRequest.code, ex.errorInfo.code) - assertEquals(HttpStatusCode.BadRequest.code, ex.errorInfo.statusCode) - - // Test with ErrorInfo - val errorInfo = ErrorInfo("Custom error", 400, 40000) - val ex2 = ablyException(errorInfo) - assertEquals("Custom error", ex2.errorInfo.message) - assertEquals(400, ex2.errorInfo.statusCode) - assertEquals(40000, ex2.errorInfo.code) - - // Test with cause - val cause = RuntimeException("Cause") - val ex3 = ablyException(errorInfo, cause) - assertEquals(cause, ex3.cause) - } - - @Test - fun testObjectsAsyncScopeLaunchWithCallback() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var callbackExecuted = false - var resultReceived: String? = null - - val callback = object : ObjectsCallback { - override fun onSuccess(result: String) { - callbackExecuted = true - resultReceived = result - } - - override fun onError(exception: AblyException) { - fail("Should not call onError for successful execution") - } - } - - asyncScope.launchWithCallback(callback) { - delay(10) // Simulate async work - "test result" - } - - // Wait for callback to be executed - assertWaiter { callbackExecuted } - - assertTrue("Callback should be executed", callbackExecuted) - assertEquals("test result", resultReceived) - } - - @Test - fun testObjectsAsyncScopeLaunchWithCallbackError() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var errorReceived: AblyException? = null - - val callback = object : ObjectsCallback { - override fun onSuccess(result: String) { - fail("Should not call onSuccess for error case") - } - - override fun onError(exception: AblyException) { - errorReceived = exception - } - } - - asyncScope.launchWithCallback(callback) { - delay(10) - throw AblyException.fromErrorInfo(ErrorInfo("Test error", 400, 40000)) - } - - // Wait for error to be received - assertWaiter { errorReceived != null } - - assertNotNull("Error should be received", errorReceived) - assertEquals("Test error", errorReceived?.errorInfo?.message) - assertEquals(400, errorReceived?.errorInfo?.statusCode) - } - - @Test - fun testObjectsAsyncScopeLaunchWithVoidCallback() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var callbackExecuted = false - - val callback = object : ObjectsCallback { - override fun onSuccess(result: Void?) { - callbackExecuted = true - } - - override fun onError(exception: AblyException) { - fail("Should not call onError for successful execution") - } - } - - asyncScope.launchWithVoidCallback(callback) { - delay(10) // Simulate async work - } - - // Wait for callback to be executed - assertWaiter { callbackExecuted } - - assertTrue("Callback should be executed", callbackExecuted) - } - - @Test - fun testObjectsAsyncScopeLaunchWithVoidCallbackError() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var errorReceived: AblyException? = null - - val callback = object : ObjectsCallback { - override fun onSuccess(result: Void?) { - fail("Should not call onSuccess for error case") - } - - override fun onError(exception: AblyException) { - errorReceived = exception - } - } - - asyncScope.launchWithVoidCallback(callback) { - delay(10) - throw AblyException.fromErrorInfo(ErrorInfo("Test error", 500, 50000)) - } - - // Wait for error to be received - assertWaiter { errorReceived != null } - - assertNotNull("Error should be received", errorReceived) - assertEquals("Test error", errorReceived?.errorInfo?.message) - assertEquals(500, errorReceived?.errorInfo?.statusCode) - } - - @Test - fun testObjectsAsyncScopeCallbackExceptionHandling() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var callback1Called = false - var callback2Called = false - - val callback1 = object : ObjectsCallback { - override fun onSuccess(result: String) { - callback1Called = true - throw RuntimeException("Callback exception") - } - - override fun onError(exception: AblyException) { - fail("Should not call onError when onSuccess throws") - } - } - - asyncScope.launchWithCallback(callback1) { "test result" } - // Wait for callback to be called - assertWaiter { callback1Called } - - val callback2 = object : ObjectsCallback { - override fun onSuccess(result: String) { - callback2Called = true - } - - override fun onError(exception: AblyException) { - fail("Should not call onError when onSuccess throws") - } - } - - asyncScope.launchWithCallback(callback2) { "test result" } - // Callback 2 should be called even if callback 1 throws an exception - assertWaiter { callback2Called } - } - - @Test - fun testObjectsAsyncScopeCancel() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var errorReceived = false - - val callback = object : ObjectsCallback { - override fun onSuccess(result: String) { - fail("Should not call onSuccess") - } - - override fun onError(exception: AblyException) { - errorReceived = true - } - } - - asyncScope.launchWithCallback(callback) { - delay(10000) // Long delay - "test result" - } - - // Cancel immediately - asyncScope.cancel(CancellationException("Test cancellation")) - - // Wait a bit to ensure cancellation takes effect - assertWaiter { errorReceived } - } - - @Test - fun testObjectsAsyncScopeNonAblyException() = runTest { - val asyncScope = ObjectsAsyncScope("test-channel") - var errorReceived = false - var error: AblyException? = null - - val callback = object : ObjectsCallback { - override fun onSuccess(result: String) { - fail("Should not call onSuccess for error case") - } - - override fun onError(exception: AblyException) { - errorReceived = true - error = exception - } - } - - asyncScope.launchWithCallback(callback) { - delay(10) - throw RuntimeException("Non-Ably exception") - } - - // Wait for error to be received - assertWaiter { errorReceived } - - // Non-Ably exceptions should be wrapped in AblyException - assertNotNull("Non-Ably exceptions should be wrapped in AblyException", error) - assertEquals("Error executing operation", error?.errorInfo?.message) - assertEquals(ErrorCode.BadRequest.code, error?.errorInfo?.code) - assertEquals(HttpStatusCode.BadRequest.code, error?.errorInfo?.statusCode) - - assertTrue(error?.cause is RuntimeException) - assertEquals("Non-Ably exception", error?.cause?.message) - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt deleted file mode 100644 index 6c2f60ccf..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/fixtures/ObjectMessageFixtures.kt +++ /dev/null @@ -1,160 +0,0 @@ -package io.ably.lib.objects.unit.fixtures - -import com.google.gson.JsonArray -import com.google.gson.JsonObject -import io.ably.lib.objects.* -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapCreateWithObjectId -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectState -import java.util.Base64 - -internal val dummyObjectDataStringValue = ObjectData(objectId = "object-id", string = "dummy string") - -internal val dummyBinaryObjectValue = ObjectData(objectId = "object-id", bytes = Base64.getEncoder().encodeToString(byteArrayOf(1, 2, 3))) - -internal val dummyNumberObjectValue = ObjectData(objectId = "object-id", number = 42.0) - -internal val dummyBooleanObjectValue = ObjectData(objectId = "object-id", boolean = true) - -val dummyJsonObject = JsonObject().apply { addProperty("foo", "bar") } -internal val dummyJsonObjectValue = ObjectData(objectId = "object-id", json = dummyJsonObject) - -val dummyJsonArray = JsonArray().apply { add(1); add(2); add(3) } -internal val dummyJsonArrayValue = ObjectData(objectId = "object-id", json = dummyJsonArray) - -internal val dummyObjectsMapEntry = ObjectsMapEntry( - tombstone = false, - timeserial = "dummy-timeserial", - data = dummyObjectDataStringValue -) - -internal val dummyObjectsMap = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf("dummy-key" to dummyObjectsMapEntry) -) - -internal val dummyObjectsCounter = ObjectsCounter( - count = 123.0 -) - -internal val dummyMapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf("dummy-key" to dummyObjectsMapEntry) -) - -internal val dummyObjectOperation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "dummy-object-id", - mapCreate = dummyMapCreate, - mapCreateWithObjectId = MapCreateWithObjectId(nonce = "dummy-nonce", initialValue = "{\"foo\":\"bar\"}"), -) - -internal val dummyObjectState = ObjectState( - objectId = "dummy-object-id", - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - createOp = dummyObjectOperation, - map = dummyObjectsMap, - counter = dummyObjectsCounter -) - -internal val dummyObjectMessage = ObjectMessage( - id = "dummy-id", - timestamp = 1234567890L, - clientId = "dummy-client-id", - connectionId = "dummy-connection-id", - extras = JsonObject().apply { addProperty("meta", "data") }, - operation = dummyObjectOperation, - objectState = dummyObjectState, - serial = "dummy-serial", - siteCode = "dummy-site-code" -) - -internal fun dummyObjectMessageWithStringData(): ObjectMessage { - return dummyObjectMessage -} - -internal fun dummyObjectMessageWithBinaryData(): ObjectMessage { - val binaryObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyBinaryObjectValue) - val binaryObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to binaryObjectMapEntry)) - val binaryMapCreate = dummyMapCreate.copy(entries = mapOf("dummy-key" to binaryObjectMapEntry)) - val binaryObjectOperation = dummyObjectOperation.copy(mapCreate = binaryMapCreate) - val binaryObjectState = dummyObjectState.copy( - map = binaryObjectMap, - createOp = binaryObjectOperation - ) - return dummyObjectMessage.copy( - operation = binaryObjectOperation, - objectState = binaryObjectState - ) -} - -internal fun dummyObjectMessageWithNumberData(): ObjectMessage { - val numberObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyNumberObjectValue) - val numberObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to numberObjectMapEntry)) - val numberMapCreate = dummyMapCreate.copy(entries = mapOf("dummy-key" to numberObjectMapEntry)) - val numberObjectOperation = dummyObjectOperation.copy(mapCreate = numberMapCreate) - val numberObjectState = dummyObjectState.copy( - map = numberObjectMap, - createOp = numberObjectOperation - ) - return dummyObjectMessage.copy( - operation = numberObjectOperation, - objectState = numberObjectState - ) -} - -internal fun dummyObjectMessageWithBooleanData(): ObjectMessage { - val booleanObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyBooleanObjectValue) - val booleanObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to booleanObjectMapEntry)) - val booleanMapCreate = dummyMapCreate.copy(entries = mapOf("dummy-key" to booleanObjectMapEntry)) - val booleanObjectOperation = dummyObjectOperation.copy(mapCreate = booleanMapCreate) - val booleanObjectState = dummyObjectState.copy( - map = booleanObjectMap, - createOp = booleanObjectOperation - ) - return dummyObjectMessage.copy( - operation = booleanObjectOperation, - objectState = booleanObjectState - ) -} - -internal fun dummyObjectMessageWithJsonObjectData(): ObjectMessage { - val jsonObjectMapEntry = dummyObjectsMapEntry.copy(data = dummyJsonObjectValue) - val jsonObjectMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to jsonObjectMapEntry)) - val jsonMapCreate = dummyMapCreate.copy(entries = mapOf("dummy-key" to jsonObjectMapEntry)) - val jsonObjectOperation = dummyObjectOperation.copy( - action = ObjectOperationAction.MapSet, - mapCreate = null, - mapSet = MapSet(key = "dummy-key", value = dummyJsonObjectValue) - ) - val jsonObjectState = dummyObjectState.copy( - map = jsonObjectMap, - createOp = jsonObjectOperation - ) - return dummyObjectMessage.copy( - operation = jsonObjectOperation, - objectState = jsonObjectState - ) -} - -internal fun dummyObjectMessageWithJsonArrayData(): ObjectMessage { - val jsonArrayMapEntry = dummyObjectsMapEntry.copy(data = dummyJsonArrayValue) - val jsonArrayMap = dummyObjectsMap.copy(entries = mapOf("dummy-key" to jsonArrayMapEntry)) - val jsonArrayOperation = dummyObjectOperation.copy( - action = ObjectOperationAction.MapSet, - mapCreate = null, - mapSet = MapSet(key = "dummy-key", value = dummyJsonArrayValue) - ) - val jsonArrayState = dummyObjectState.copy( - map = jsonArrayMap, - createOp = jsonArrayOperation - ) - return dummyObjectMessage.copy( - operation = jsonArrayOperation, - objectState = jsonArrayState - ) -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultRealtimeObjectsTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultRealtimeObjectsTest.kt deleted file mode 100644 index 0a0ae9907..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/DefaultRealtimeObjectsTest.kt +++ /dev/null @@ -1,489 +0,0 @@ -package io.ably.lib.objects.unit.objects - -import io.ably.lib.objects.* -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.ObjectsState -import io.ably.lib.objects.ROOT_OBJECT_ID -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.type.livemap.LiveMapEntry -import io.ably.lib.objects.unit.BufferedObjectOperations -import io.ably.lib.objects.unit.ObjectsManager -import io.ably.lib.objects.unit.SyncObjectsPool -import io.ably.lib.objects.unit.getMockObjectsAdapter -import io.ably.lib.objects.unit.getDefaultRealtimeObjectsWithMockedDeps -import io.ably.lib.objects.unit.getMockRealtimeChannel -import io.ably.lib.objects.unit.size -import io.ably.lib.realtime.ChannelState -import io.ably.lib.types.AblyException -import io.ably.lib.types.ErrorInfo -import io.ably.lib.types.ProtocolMessage -import io.mockk.every -import io.mockk.verify -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -class DefaultRealtimeObjectsTest { - - private val testInstances = mutableListOf() - - @After - fun tearDown() { - val cleanupError = AblyException.fromErrorInfo(ErrorInfo("test cleanup", 500)) - testInstances.forEach { it.dispose(cleanupError) } - testInstances.clear() - } - - @Test - fun `(RTO4, RTO4a) When channel ATTACHED with HAS_OBJECTS flag true should start sync sequence`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // RTO4a - If the HAS_OBJECTS flag is 1, the server will shortly perform an OBJECT_SYNC sequence - defaultRealtimeObjects.handleStateChange(ChannelState.attached, true) - - assertWaiter { defaultRealtimeObjects.state == ObjectsState.Syncing } - - // It is expected that the client will start a new sync sequence - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.startNewSync(null) - } - verify(exactly = 0) { - defaultRealtimeObjects.ObjectsManager.endSync() - } - } - - @Test - fun `(RTO4, RTO4b) When channel ATTACHED with HAS_OBJECTS flag false should complete sync immediately`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Set up some objects in objectPool that should be cleared - val rootObject = defaultRealtimeObjects.objectsPool.get(ROOT_OBJECT_ID) as DefaultLiveMap - rootObject.data["key1"] = LiveMapEntry(data = ObjectData("testValue1")) - defaultRealtimeObjects.objectsPool.set("counter:testObject@1", DefaultLiveCounter.zeroValue("counter:testObject@1", defaultRealtimeObjects)) - assertEquals(2, defaultRealtimeObjects.objectsPool.size(), "RTO4b - Should have 2 objects before state change") - - // RTO4b - If the HAS_OBJECTS flag is 0, the sync sequence must be considered complete immediately - defaultRealtimeObjects.handleStateChange(ChannelState.attached, false) - - // Verify expected outcomes - assertWaiter { defaultRealtimeObjects.state == ObjectsState.Synced } // RTO4b4 - - verify(exactly = 1) { - defaultRealtimeObjects.objectsPool.resetToInitialPool(true) - } - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.endSync() - } - - assertEquals(0, defaultRealtimeObjects.ObjectsManager.SyncObjectsPool.size) // RTO4b3 - assertEquals(0, defaultRealtimeObjects.ObjectsManager.BufferedObjectOperations.size) // RTO4d - assertEquals(1, defaultRealtimeObjects.objectsPool.size()) // RTO4b1 - Only root remains - assertEquals(rootObject, defaultRealtimeObjects.objectsPool.get(ROOT_OBJECT_ID)) // points to previously created root object - assertEquals(0, rootObject.data.size) // RTO4b2 - root object must be empty - } - - @Test - fun `(RTO4) When channel ATTACHED from INITIALIZED state should always start sync`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Ensure we're in INITIALIZED state - defaultRealtimeObjects.state = ObjectsState.Initialized - - // RTO4a - Should start sync even with HAS_OBJECTS flag false when in INITIALIZED state - defaultRealtimeObjects.handleStateChange(ChannelState.attached, false) - - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.startNewSync(null) - } - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.endSync() - } - } - - @Test - fun `(RTO5, RTO7) Should delegate OBJECT and OBJECT_SYNC protocolMessage to ObjectManager`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps(relaxed = true) - - // Create test ObjectMessage for OBJECT action - val objectMessage = ObjectMessage( - id = "testId", - timestamp = 1234567890L, - connectionId = "testConnectionId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:testObject@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "serial1", - siteCode = "site1" - ) - // Create ProtocolMessage with OBJECT action - val objectProtocolMessage = ProtocolMessage(ProtocolMessage.Action.`object`).apply { - id = "protocolId1" - channel = "testChannel" - channelSerial = "channelSerial1" - timestamp = 1234567890L - state = arrayOf(objectMessage) - } - // Test OBJECT action delegation - defaultRealtimeObjects.handle(objectProtocolMessage) - - // Verify that handleObjectMessages was called with the correct parameters - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.handleObjectMessages(listOf(objectMessage)) - } - - // Create test ObjectMessage for OBJECT_SYNC action - val objectSyncMessage = ObjectMessage( - id = "testSyncId", - timestamp = 1234567890L, - connectionId = "testSyncConnectionId", - objectState = ObjectState( - objectId = "map:testObject@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "syncSerial1"), - ), - serial = "syncSerial1", - siteCode = "site1" - ) - // Create ProtocolMessage with OBJECT_SYNC action - val objectSyncProtocolMessage = ProtocolMessage(ProtocolMessage.Action.object_sync).apply { - id = "protocolId2" - channel = "testChannel" - channelSerial = "syncChannelSerial1" - timestamp = 1234567890L - state = arrayOf(objectSyncMessage) - } - // Test OBJECT_SYNC action delegation - defaultRealtimeObjects.handle(objectSyncProtocolMessage) - // Verify that handleObjectSyncMessages was called with the correct parameters - verify(exactly = 1) { - defaultRealtimeObjects.ObjectsManager.handleObjectSyncMessages(listOf(objectSyncMessage), "syncChannelSerial1") - } - } - - @Test - fun `(RTO20e1) handleStateChange(DETACHED) fails pending ACK waiters with error 92008`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Capture the error passed to failBufferedAcks via a CompletableDeferred - val capturedError = CompletableDeferred() - every { defaultRealtimeObjects.ObjectsManager.failBufferedAcks(any()) } answers { - capturedError.complete(firstArg()) - callOriginal() - } - - defaultRealtimeObjects.handleStateChange(ChannelState.detached, false) - - val error = capturedError.await() - assertEquals(92008, error.errorInfo.code) // PublishAndApplyFailedDueToChannelState - } - - @Test - fun `(RTO20e1) handleStateChange(SUSPENDED) fails pending ACK waiters with error 92008`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - val capturedError = CompletableDeferred() - every { defaultRealtimeObjects.ObjectsManager.failBufferedAcks(any()) } answers { - capturedError.complete(firstArg()) - callOriginal() - } - - defaultRealtimeObjects.handleStateChange(ChannelState.suspended, false) - - val error = capturedError.await() - assertEquals(92008, error.errorInfo.code) // PublishAndApplyFailedDueToChannelState - } - - @Test - fun `(RTO20e1) handleStateChange(FAILED) fails pending ACK waiters and propagates channel reason`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Override the channel returned by the adapter to carry a non-null reason - val channelReason = ErrorInfo("channel failed due to auth error", 40100, 401) - val channelWithReason = getMockRealtimeChannel("testChannelName") - channelWithReason.reason = channelReason - every { defaultRealtimeObjects.adapter.getChannel(any()) } returns channelWithReason - - val capturedError = CompletableDeferred() - every { defaultRealtimeObjects.ObjectsManager.failBufferedAcks(any()) } answers { - capturedError.complete(firstArg()) - callOriginal() - } - - defaultRealtimeObjects.handleStateChange(ChannelState.failed, false) - - val error = capturedError.await() - assertEquals(92008, error.errorInfo.code) - val causeException = error.cause as? AblyException - assertNotNull(causeException, "Error cause must include the channel's reason") - assertEquals(channelReason.code, causeException.errorInfo.code) - assertEquals(channelReason.message, causeException.errorInfo.message) - } - - @Test - fun `(RTO4) handleStateChange(SUSPENDED) does NOT clear objects data`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Use the failBufferedAcks call as a signal that the state-change coroutine has run to completion - val failCalled = CompletableDeferred() - every { defaultRealtimeObjects.ObjectsManager.failBufferedAcks(any()) } answers { - callOriginal() - failCalled.complete(Unit) - } - - defaultRealtimeObjects.handleStateChange(ChannelState.suspended, false) - - // For SUSPENDED, the coroutine ends immediately after failBufferedAcks (no clear calls) - failCalled.await() - - verify(exactly = 0) { defaultRealtimeObjects.objectsPool.clearObjectsData(any()) } - verify(exactly = 0) { defaultRealtimeObjects.ObjectsManager.clearSyncObjectsPool() } - } - - @Test - fun `(RTO4) handleStateChange(DETACHED) clears objects data and sync pool`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Use clearSyncObjectsPool (the last operation in the coroutine) as the completion signal - val syncPoolCleared = CompletableDeferred() - every { defaultRealtimeObjects.ObjectsManager.clearSyncObjectsPool() } answers { - callOriginal() - syncPoolCleared.complete(Unit) - } - - defaultRealtimeObjects.handleStateChange(ChannelState.detached, false) - - syncPoolCleared.await() - - verify(exactly = 1) { defaultRealtimeObjects.objectsPool.clearObjectsData(false) } - verify(exactly = 1) { defaultRealtimeObjects.ObjectsManager.clearSyncObjectsPool() } - } - - @Test - fun `(RTO4d) ATTACHED with hasObjects=true still clears bufferedObjectOperations`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - val manager = defaultRealtimeObjects.ObjectsManager - - // Pre-populate bufferedObjectOperations with a dummy operation - @Suppress("UNCHECKED_CAST") - (manager.BufferedObjectOperations as MutableList).add( - ObjectMessage( - id = "pre-attach-op", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ) - ) - ) - assertEquals(1, manager.BufferedObjectOperations.size) - - // ATTACHED with hasObjects=true — RTO4d must clear the buffer before starting sync - defaultRealtimeObjects.handleStateChange(ChannelState.attached, true) - - assertWaiter { defaultRealtimeObjects.state == ObjectsState.Syncing } - assertEquals(0, manager.BufferedObjectOperations.size, "RTO4d - buffer must be cleared unconditionally on ATTACHED") - } - - @Test - fun `(RTO4d) Pre-ATTACHED buffered operations are discarded, not applied after sync`() = runTest { - val defaultRealtimeObjects = DefaultRealtimeObjects("testChannel", getMockObjectsAdapter()) - .also { testInstances.add(it) } - - // Set up a counter in the pool - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - // Pre-populate bufferedObjectOperations with a COUNTER_INC — simulates an op received before ATTACHED - @Suppress("UNCHECKED_CAST") - (objectsManager.BufferedObjectOperations as MutableList).add( - ObjectMessage( - id = "pre-attach-inc", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ) - ) - ) - assertEquals(1, objectsManager.BufferedObjectOperations.size) - - // ATTACHED with hasObjects=true: RTO4d clears the buffer, then starts sync - defaultRealtimeObjects.handleStateChange(ChannelState.attached, true) - assertWaiter { defaultRealtimeObjects.state == ObjectsState.Syncing } - assertEquals(0, objectsManager.BufferedObjectOperations.size, "buffer must be cleared by RTO4d") - - // Complete sync by calling handleObjectSyncMessages directly (sequentialScope is idle now) - objectsManager.handleObjectSyncMessages( - listOf( - ObjectMessage( - id = "sync-msg-1", - objectState = ObjectState( - objectId = "counter:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - counter = ObjectsCounter(count = 0.0) - ) - ) - ), - "sync-id:" // empty cursor — ends sync (RTO5a4) - ) - - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state) - - // The pre-ATTACHED COUNTER_INC was discarded — counter should remain at 0 - assertEquals(0.0, counter.data.get(), "RTO4d - pre-ATTACHED buffered op must be discarded, not applied after sync") - } - - @Test - fun `(RTO5a2b removed) Buffered operations survive a server-initiated resync (new OBJECT_SYNC without ATTACHED)`() { - val defaultRealtimeObjects = DefaultRealtimeObjects("testChannel", getMockObjectsAdapter()) - .also { testInstances.add(it) } - - // Set up a counter in the pool - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(5.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - // sync-1 is in progress - objectsManager.startNewSync("sync-1") - assertEquals(ObjectsState.Syncing, defaultRealtimeObjects.state) - - // Buffer a COUNTER_INC during sync-1 - objectsManager.handleObjectMessages( - listOf( - ObjectMessage( - id = "channel-op-1", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 3.0) - ), - serial = "serial-op-1", - siteCode = "site1" - ) - ) - ) - assertEquals(1, objectsManager.BufferedObjectOperations.size, "op buffered during sync-1") - - // Server sends a new OBJECT_SYNC with a different sync-id — triggers startNewSync("sync-2") internally - // OLD behaviour (RTO5a2b): startNewSync would have cleared bufferedObjectOperations here - // NEW behaviour (RTO5a2b removed): buffer is preserved - objectsManager.handleObjectSyncMessages( - listOf( - ObjectMessage( - id = "sync2-msg-1", - objectState = ObjectState( - objectId = "counter:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "resync-serial"), - counter = ObjectsCounter(count = 5.0) - ) - ) - ), - "sync-2:cursor-1" // has cursor — not ending yet - ) - - assertEquals(1, objectsManager.BufferedObjectOperations.size, - "startNewSync must NOT clear bufferedObjectOperations (RTO5a2b removed)") - - // Complete sync-2 (ending serial, no new messages) - objectsManager.handleObjectSyncMessages(emptyList(), "sync-2:") - - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state) - // sync-2 restored counter to 5.0; buffered COUNTER_INC (+3.0) applied after sync → 8.0 - assertEquals(8.0, counter.data.get(), - "buffered COUNTER_INC from before server-initiated resync must be applied after sync completes") - } - - @Test - fun `(OM2) Populate objectMessage missing id, timestamp and connectionId from protocolMessage`() = runTest { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - // Capture the ObjectMessages that are passed to ObjectsManager methods - var capturedObjectMessages: List? = null - var capturedObjectSyncMessages: List? = null - - // Mock the ObjectsManager to capture the messages - defaultRealtimeObjects.ObjectsManager.apply { - every { handleObjectMessages(any>()) } answers { - capturedObjectMessages = firstArg() - } - every { handleObjectSyncMessages(any(), any()) } answers { - capturedObjectSyncMessages = firstArg() - } - } - - // Create ObjectMessage with missing fields (id, timestamp, connectionId) - val objectMessageWithMissingFields = ObjectMessage( - id = null, // OM2a - missing id - timestamp = null, // OM2e - missing timestamp - connectionId = null, // OM2c - missing connectionId - ) - - // Create ProtocolMessage with OBJECT action and populated fields - val objectProtocolMessage = ProtocolMessage(ProtocolMessage.Action.`object`).apply { - id = "protocolId1" - channel = "testChannel" - channelSerial = "channelSerial1" - connectionId = "protocolConnectionId" - timestamp = 1234567890L - state = arrayOf(objectMessageWithMissingFields) - } - - // Test OBJECT action - should populate missing fields - defaultRealtimeObjects.handle(objectProtocolMessage) - - // Verify that the captured ObjectMessage has populated fields - assertWaiter { capturedObjectMessages != null } - assertEquals(1, capturedObjectMessages!!.size) - - val populatedObjectMessage = capturedObjectMessages!![0] - assertEquals("protocolId1:0", populatedObjectMessage.id) // OM2a - id should be protocolId:index - assertEquals(1234567890L, populatedObjectMessage.timestamp) // OM2e - timestamp from protocol message - assertEquals("protocolConnectionId", populatedObjectMessage.connectionId) // OM2c - connectionId from protocol message - - - // Create ObjectMessage with missing fields for OBJECT_SYNC - val objectSyncMessageWithMissingFields = ObjectMessage( - id = null, // OM2a - missing id - timestamp = null, // OM2e - missing timestamp - connectionId = null, // OM2c - missing connectionId - ) - - // Create ProtocolMessage with OBJECT_SYNC action and populated fields - val objectSyncProtocolMessage = ProtocolMessage(ProtocolMessage.Action.object_sync).apply { - id = "protocolId2" - channel = "testChannel" - channelSerial = "syncChannelSerial1" - connectionId = "protocolConnectionId" - timestamp = 9876543210L - state = arrayOf(objectSyncMessageWithMissingFields) - } - - // Test OBJECT_SYNC action - should populate missing fields - defaultRealtimeObjects.handle(objectSyncProtocolMessage) - - // Verify that the captured ObjectMessage has populated fields - assertWaiter { capturedObjectSyncMessages != null } - assertEquals(1, capturedObjectSyncMessages!!.size) - - val populatedObjectSyncMessage = capturedObjectSyncMessages!![0] - assertEquals("protocolId2:0", populatedObjectSyncMessage.id) // OM2a - id should be protocolId:index - assertEquals(9876543210L, populatedObjectSyncMessage.timestamp) // OM2e - timestamp from protocol message - assertEquals("protocolConnectionId", populatedObjectSyncMessage.connectionId) // OM2c - connectionId from protocol message - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt deleted file mode 100644 index 2a9ac5b13..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsManagerTest.kt +++ /dev/null @@ -1,944 +0,0 @@ -package io.ably.lib.objects.unit.objects - -import io.ably.lib.objects.* -import io.ably.lib.objects.DefaultRealtimeObjects -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.ObjectsOperationSource -import io.ably.lib.objects.ObjectsState -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.unit.* -import io.ably.lib.objects.unit.getDefaultRealtimeObjectsWithMockedDeps -import io.ably.lib.types.AblyException -import io.ably.lib.types.ErrorInfo -import io.ably.lib.util.Log -import io.mockk.* -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.yield -import org.junit.Test -import kotlin.test.* - -class ObjectsManagerTest { - - // Track instances created in tests to ensure background coroutines are cancelled at teardown - private val testInstances = mutableListOf() - - private fun makeRealtimeObjects(channelName: String = "testChannel"): DefaultRealtimeObjects { - return DefaultRealtimeObjects(channelName, getMockObjectsAdapter()).also { testInstances.add(it) } - } - - @Test - fun `(RTO5) ObjectsManager should handle object sync messages`() { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - assertEquals(ObjectsState.Initialized, defaultRealtimeObjects.state, "Initial state should be INITIALIZED") - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - mockZeroValuedObjects() - - // Populate objectsPool with existing objects - val objectsPool = defaultRealtimeObjects.ObjectsPool - objectsPool.set("map:testObject@1", mockk(relaxed = true)) - objectsPool.set("counter:testObject@4", mockk(relaxed = true)) - - // Incoming object messages - val objectMessage1 = ObjectMessage( - id = "testId1", - objectState = ObjectState( - objectId = "map:testObject@1", // already exists in pool - tombstone = false, - siteTimeserials = mapOf("site1" to "syncSerial1"), - map = ObjectsMap(), - ) - ) - val objectMessage2 = ObjectMessage( - id = "testId2", - objectState = ObjectState( - objectId = "counter:testObject@2", // Does not exist in pool - tombstone = false, - siteTimeserials = mapOf("site1" to "syncSerial1"), - counter = ObjectsCounter(count = 20.0) - ) - ) - val objectMessage3 = ObjectMessage( - id = "testId3", - objectState = ObjectState( - objectId = "map:testObject@3", // Does not exist in pool - tombstone = false, - siteTimeserials = mapOf("site1" to "syncSerial1"), - map = ObjectsMap(), - ) - ) - // Should start and end sync, apply object states, and create new objects for missing ones - objectsManager.handleObjectSyncMessages(listOf(objectMessage1, objectMessage2, objectMessage3), "sync-123:") - - verify(exactly = 1) { - objectsManager.startNewSync("sync-123") - } - verify(exactly = 1) { - objectsManager.endSync() // - } - val newlyCreatedObjects = mutableListOf() - verify(exactly = 2) { - objectsManager["createObjectFromState"](capture(newlyCreatedObjects)) - } - assertEquals("counter:testObject@2", newlyCreatedObjects[0].objectId) - assertEquals("map:testObject@3", newlyCreatedObjects[1].objectId) - - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state, "State should be SYNCED after sync sequence") - // After sync `counter:testObject@4` will be removed from pool - assertNull(objectsPool.get("counter:testObject@4")) - assertEquals(4, objectsPool.size(), "Objects pool should contain 4 objects after sync including root") - assertNotNull(objectsPool.get(ROOT_OBJECT_ID), "Root object should still exist in pool") - val testObject1 = objectsPool.get("map:testObject@1") - assertNotNull(testObject1, "map:testObject@1 should exist in pool after sync") - verify(exactly = 1) { - testObject1.applyObjectSync(any()) - } - val testObject2 = objectsPool.get("counter:testObject@2") - assertNotNull(testObject2, "counter:testObject@2 should exist in pool after sync") - verify(exactly = 1) { - testObject2.applyObjectSync(any()) - } - val testObject3 = objectsPool.get("map:testObject@3") - assertNotNull(testObject3, "map:testObject@3 should exist in pool after sync") - verify(exactly = 1) { - testObject3.applyObjectSync(any()) - } - } - - @Test - fun `(RTO8) ObjectsManager should apply object operation when state is synced`() { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - defaultRealtimeObjects.state = ObjectsState.Synced // Ensure we're in SYNCED state - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - mockZeroValuedObjects() - - // Populate objectsPool with existing objects - val objectsPool = defaultRealtimeObjects.ObjectsPool - objectsPool.set("map:testObject@1", mockk(relaxed = true)) - - // Incoming object messages with operation field instead of objectState - val objectMessage1 = ObjectMessage( - id = "testId1", - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, // Assuming this is the right action for maps - objectId = "map:testObject@1", // already exists in pool - ), - serial = "serial1", - siteCode = "site1" - ) - - val objectMessage2 = ObjectMessage( - id = "testId2", - operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, // Set the counter value - objectId = "counter:testObject@2", // Does not exist in pool - ), - serial = "serial2", - siteCode = "site1" - ) - - val objectMessage3 = ObjectMessage( - id = "testId3", - operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testObject@3", // Does not exist in pool - ), - serial = "serial3", - siteCode = "site1" - ) - - // RTO8b - Apply messages immediately if synced - objectsManager.handleObjectMessages(listOf(objectMessage1, objectMessage2, objectMessage3)) - assertEquals(0, objectsManager.BufferedObjectOperations.size, "No buffer needed in SYNCED state") - - assertEquals(4, objectsPool.size(), "Objects pool should contain 4 objects including root") - assertNotNull(objectsPool.get(ROOT_OBJECT_ID), "Root object should still exist in pool") - - val testObject1 = objectsPool.get("map:testObject@1") - assertNotNull(testObject1, "map:testObject@1 should exist in pool after sync") - verify(exactly = 1) { - testObject1.applyObject(objectMessage1, any()) - } - val testObject2 = objectsPool.get("counter:testObject@2") - assertNotNull(testObject2, "counter:testObject@2 should exist in pool after sync") - verify(exactly = 1) { - testObject2.applyObject(objectMessage2, any()) - } - val testObject3 = objectsPool.get("map:testObject@3") - assertNotNull(testObject3, "map:testObject@3 should exist in pool after sync") - verify(exactly = 1) { - testObject3.applyObject(objectMessage3, any()) - } - } - - @Test - fun `(RTO7) ObjectsManager should buffer operations when not in sync, apply them after synced`() { - val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - assertEquals(ObjectsState.Initialized, defaultRealtimeObjects.state, "Initial state should be INITIALIZED") - - val objectsManager = defaultRealtimeObjects.ObjectsManager - assertEquals(0, objectsManager.BufferedObjectOperations.size, "RTO7a1 - Initial buffer should be empty") - - val objectsPool = defaultRealtimeObjects.ObjectsPool - assertEquals(1, objectsPool.size(), "RTO7a2 - Initial pool should contain only root object") - - mockZeroValuedObjects() - - // Set state to SYNCING - defaultRealtimeObjects.state = ObjectsState.Syncing - - val objectMessage = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "counter:testObject@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTO7a - Buffer operations during sync - objectsManager.handleObjectMessages(listOf(objectMessage)) - - verify(exactly = 0) { - objectsManager["applyObjectMessages"](any>(), any()) - } - assertEquals(1, objectsManager.BufferedObjectOperations.size) - assertEquals(objectMessage, objectsManager.BufferedObjectOperations[0]) - assertEquals(1, objectsPool.size(), "Pool should still contain only root object during sync") - - // RTO7 - Apply buffered operations after sync - objectsManager.endSync() // End sync without new sync - verify(exactly = 1) { - objectsManager["applyObjectMessages"](any>(), any()) - } - assertEquals(0, objectsManager.BufferedObjectOperations.size) - assertEquals(2, objectsPool.size(), "Pool should contain 2 objects after applying buffered operations") - assertNotNull(objectsPool.get("counter:testObject@1"), "Counter object should be created after sync") - assertTrue(objectsPool.get("counter:testObject@1") is DefaultLiveCounter, "Should create a DefaultLiveCounter object") - } - - @Test - fun `(RTO23 COUNTER_INC) applyAckResult applies COUNTER_INC locally and tracks serial in appliedOnAckSerials`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-ack-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - objectsManager.applyAckResult(listOf(msg)) - - // Verify operation applied locally (RTO23) - assertEquals(5.0, counter.data.get(), "COUNTER_INC should be applied locally on ACK") - // Serial added to appliedOnAckSerials (RTO9a2a4) - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-ack-01"), - "serial should be in appliedOnAckSerials") - // siteTimeserials NOT updated (LOCAL source, RTLC7c) - assertFalse(counter.siteTimeserials.containsKey("site1"), - "siteTimeserials should NOT be updated for LOCAL source") - } - - @Test - fun `(RTO23 MAP_SET) applyAckResult applies MAP_SET locally and tracks serial in appliedOnAckSerials`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val liveMap = DefaultLiveMap.zeroValue("map:testMap@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("map:testMap@1", liveMap) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet(key = "key1", value = ObjectData(string = "value1")) - ), - serial = "ser-map-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - objectsManager.applyAckResult(listOf(msg)) - - // Verify entry was set (LOCAL source) - assertEquals("value1", liveMap.data["key1"]?.data?.string, - "MAP_SET should be applied locally on ACK") - // Entry timeserial should be updated (within LiveMapManager, regardless of source) - assertEquals("ser-map-01", liveMap.data["key1"]?.timeserial, - "entry timeserial should be set by MAP_SET") - // Serial added to appliedOnAckSerials - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-map-01"), - "serial should be in appliedOnAckSerials") - // Object-level siteTimeserials NOT updated (LOCAL source, RTLM15c) - assertFalse(liveMap.siteTimeserials.containsKey("site1"), - "siteTimeserials should NOT be updated for LOCAL source") - } - - @Test - fun `(RTO9a3) echo CHANNEL message is deduplicated - serial removed, data NOT re-applied`() { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(10.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - // Simulate: serial already applied locally on ACK - defaultRealtimeObjects.appliedOnAckSerials.add("ser-echo-01") - - val echoMsg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-echo-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - objectsManager.handleObjectMessages(listOf(echoMsg)) - - // Data NOT double-applied (RTO9a3) - assertEquals(10.0, counter.data.get(), "data should NOT be re-applied on echo dedup") - // Serial removed from appliedOnAckSerials (RTO9a3) - assertFalse(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-echo-01"), - "serial should be removed from appliedOnAckSerials after dedup") - // siteTimeserials NOT updated - discarded without further action (RTO9a3) - assertNull(counter.siteTimeserials["site1"], - "siteTimeserials should NOT be updated by echo dedup (RTO9a3: discard without further action)") - } - - @Test - fun `(RTO9) non-echo CHANNEL message is applied normally when serial not in appliedOnAckSerials`() { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(10.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 3.0) - ), - serial = "ser-channel-01", - siteCode = "site1" - ) - - // serial NOT in appliedOnAckSerials — this is a regular (non-echo) CHANNEL message - assertFalse(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-channel-01")) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - objectsManager.handleObjectMessages(listOf(msg)) - - // Should be applied normally (CHANNEL source) - assertEquals(13.0, counter.data.get(), "counter should be incremented by CHANNEL message") - // siteTimeserials IS updated for CHANNEL source (RTLC7c) - assertEquals("ser-channel-01", counter.siteTimeserials["site1"], - "siteTimeserials should be updated for CHANNEL source") - } - - @Test - fun `(RTO22) applyAckResult waits for SYNCED state and applies with LOCAL source after endSync`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Syncing - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-ack-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - // Launch applyAckResult in background — will suspend while SYNCING - val ackJob = launch { - objectsManager.applyAckResult(listOf(msg)) - } - - // Allow the coroutine to start and reach deferred.await() - yield() - - // During SYNCING — waiter is pending, message NOT yet applied - assertNotNull(objectsManager.SyncCompletionWaiter, "sync completion should be pending during SYNCING") - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.isEmpty(), - "appliedOnAckSerials should be empty while waiting") - assertEquals(0.0, counter.data.get(), "data should not be applied while SYNCING") - - // End sync — completes waiters (schedules resume), then transitions to SYNCED - objectsManager.endSync() - ackJob.join() - - // After endSync — message applied with LOCAL source, serial tracked - assertEquals(5.0, counter.data.get(), "counter should be incremented after endSync") - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-ack-01"), - "serial should be tracked in appliedOnAckSerials after LOCAL apply") - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state) - } - - @Test - fun `(RTO5c6) endSync applies buffered CHANNEL messages then unblocks pending ACK waiters`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(10.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - val incMsg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-01", - siteCode = "site1" - ) - - // Start a new sync (state → SYNCING) - objectsManager.startNewSync(null) - assertEquals(ObjectsState.Syncing, defaultRealtimeObjects.state) - - // Suspend the ACK waiter (SYNCING) - val ackJob = launch { - objectsManager.applyAckResult(listOf(incMsg)) - } - yield() - assertNotNull(objectsManager.SyncCompletionWaiter) - - // Buffer the echo OBJECT message (also buffered since SYNCING) - objectsManager.handleObjectMessages(listOf(incMsg)) - assertEquals(1, objectsManager.BufferedObjectOperations.size) - - // End sync — applies CHANNEL buffered messages first, clears appliedOnAckSerials, then unblocks waiters - objectsManager.endSync() - ackJob.join() - - // After endSync: - // 1. CHANNEL echo applied: counter = 10 + 5 = 15; siteTimeserials["site1"] = "ser-01" - // 2. appliedOnAckSerials cleared (was empty since no LOCAL applied during sync) - // 3. Waiter resumes → LOCAL apply → canApplyOperation rejects (serial not newer) → applied=false - assertEquals(15.0, counter.data.get(), "counter should be incremented exactly once") - assertEquals("ser-01", counter.siteTimeserials["site1"], - "siteTimeserials should be updated by CHANNEL echo") - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.isEmpty(), - "appliedOnAckSerials should be empty (LOCAL apply was rejected by canApplyOperation)") - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state) - } - - @Test - fun `(RTO5c9) endSync applies buffered CHANNEL messages then clears appliedOnAckSerials`() { - val defaultRealtimeObjects = makeRealtimeObjects() - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(10.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - // Start a sync - objectsManager.startNewSync(null) - assertEquals(ObjectsState.Syncing, defaultRealtimeObjects.state) - - // Buffer a CHANNEL message during sync - val channelMsg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 3.0) - ), - serial = "ser-channel-01", - siteCode = "site1" - ) - objectsManager.handleObjectMessages(listOf(channelMsg)) - assertEquals(1, objectsManager.BufferedObjectOperations.size) - - // Simulate a serial that was somehow added during sync - defaultRealtimeObjects.appliedOnAckSerials.add("ser-during-sync") - - // End sync — CHANNEL messages applied first, then appliedOnAckSerials cleared (RTO5c9) - objectsManager.endSync() - - // CHANNEL message was applied (counter incremented) - assertEquals(13.0, counter.data.get(), - "buffered CHANNEL message should be applied by endSync") - // appliedOnAckSerials cleared at sync end (RTO5c9) - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.isEmpty(), - "appliedOnAckSerials should be cleared at sync end (RTO5c9)") - assertEquals(ObjectsState.Synced, defaultRealtimeObjects.state) - } - - @Test - fun `(RTO20e1) failBufferedAcks fails pending deferreds with error code 92008`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Syncing - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - val error = AblyException.fromErrorInfo( - ErrorInfo("channel failed while waiting for sync", 400, 92008) - ) - - var caughtException: Exception? = null - val ackJob = launch { - try { - objectsManager.applyAckResult(listOf(msg)) - } catch (e: Exception) { - caughtException = e - } - } - - // Allow the coroutine to start and suspend on deferred.await() - yield() - - // Fail the buffered ACK (RTO20e1) - objectsManager.failBufferedAcks(error) - - ackJob.join() - - assertNotNull(caughtException, "buffered ACK should fail with an exception") - val ablyEx = caughtException as? AblyException - assertNotNull(ablyEx, "exception should be an AblyException") - assertEquals(92008, ablyEx.errorInfo.code, - "error code should be 92008 (PublishAndApplyFailedDueToChannelState)") - assertEquals(400, ablyEx.errorInfo.statusCode, "status code should be 400") - } - - @Test - fun `Echo arrives before ACK - operation applied exactly once via canApplyOperation`() = runTest { - val defaultRealtimeObjects = makeRealtimeObjects() - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - counter.data.set(10.0) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ), - serial = "ser-01", - siteCode = "site1" - ) - - val objectsManager = defaultRealtimeObjects.ObjectsManager - - // Step 1: echo arrives first as CHANNEL message — applied normally - objectsManager.handleObjectMessages(listOf(msg)) - assertEquals(15.0, counter.data.get(), "echo should be applied as CHANNEL message") - assertEquals("ser-01", counter.siteTimeserials["site1"], - "siteTimeserials should be updated by CHANNEL echo") - - // Step 2: ACK fires — applyAckResult with same serial (state is SYNCED, no suspend) - objectsManager.applyAckResult(listOf(msg)) - - // canApplyOperation rejects (serial "ser-01" is not newer than siteTimeserials["site1"] = "ser-01") - assertEquals(15.0, counter.data.get(), "counter should NOT be incremented again by late ACK apply") - // applied=false → serial NOT added to appliedOnAckSerials - assertFalse(defaultRealtimeObjects.appliedOnAckSerials.contains("ser-01"), - "serial should NOT be in appliedOnAckSerials when LOCAL apply was rejected") - } - - @Test - fun `publishAndApply logs error and returns without apply when siteCode is null`() = runTest { - val adapter = getMockObjectsAdapter() - // Create a ConnectionManager mock with all fields needed for publish() to succeed - val cm = mockk(relaxed = true) - cm.maxMessageSize = 65536 // direct field assignment bypasses mock interception issues - every { cm.isActive } returns true - every { cm.send(any(), any(), any()) } answers { - @Suppress("UNCHECKED_CAST") - val callback = thirdArg>() - callback.onSuccess(io.ably.lib.types.PublishResult(null)) // null serials → RTO20c2 path - } - every { adapter.connectionManager } returns cm - // siteCode is null (relaxed mock default) — triggers RTO20c1 graceful degradation path - - val defaultRealtimeObjects = DefaultRealtimeObjects("testChannel", adapter).also { testInstances.add(it) } - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ) - ) - - // Should not throw even when siteCode is null (RTO20c1 graceful degradation) - defaultRealtimeObjects.publishAndApply(arrayOf(msg)) - - assertEquals(0.0, counter.data.get(), "no local apply should happen when siteCode is null") - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.isEmpty(), - "appliedOnAckSerials should be empty when siteCode is null") - } - - @Test - fun `(issue 7b) publishAndApply logs error and returns without apply when serials length mismatches`() = runTest { - val adapter = getMockObjectsAdapter() - // Create a ConnectionManager mock that returns a PublishResult with wrong-length serials - val cm = mockk(relaxed = true) - cm.maxMessageSize = 65536 // direct field assignment bypasses mock interception issues - every { cm.isActive } returns true - cm.siteCode = "site1" // direct field assignment (siteCode is a Java public field) - every { cm.send(any(), any(), any()) } answers { - @Suppress("UNCHECKED_CAST") - val callback = thirdArg>() - callback.onSuccess(io.ably.lib.types.PublishResult(arrayOfNulls(0))) // wrong length (0 instead of 1) - } - every { adapter.connectionManager } returns cm - - val defaultRealtimeObjects = DefaultRealtimeObjects("testChannel", adapter).also { testInstances.add(it) } - defaultRealtimeObjects.state = ObjectsState.Synced - - val counter = DefaultLiveCounter.zeroValue("counter:test@1", defaultRealtimeObjects) - defaultRealtimeObjects.objectsPool.set("counter:test@1", counter) - - val msg = ObjectMessage( - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:test@1", - counterInc = CounterInc(number = 5.0) - ) - ) - - // Should not throw even when serials length mismatches (RTO20c2 graceful degradation) - defaultRealtimeObjects.publishAndApply(arrayOf(msg)) - - assertEquals(0.0, counter.data.get(), "no local apply should happen when serials length mismatches") - assertTrue(defaultRealtimeObjects.appliedOnAckSerials.isEmpty(), - "appliedOnAckSerials should be empty when serials length mismatches") - } - - @Test - fun `(RTO5f2a2) partial sync map entries are merged across two messages with the same objectId`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - val msg1 = ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = mapOf("key1" to ObjectsMapEntry(data = ObjectData(string = "value1")))) - ) - ) - val msg2 = ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial2"), - map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = mapOf("key2" to ObjectsMapEntry(data = ObjectData(string = "value2")))) - ) - ) - - objectsManager.handleObjectSyncMessages(listOf(msg1, msg2), "sync-1:") - - val liveMap = defaultRealtimeObjects.objectsPool.get("map:test@1") as DefaultLiveMap - assertNotNull(liveMap.data["key1"], "key1 should be present after merge") - assertNotNull(liveMap.data["key2"], "key2 should be present after merge") - assertEquals("value1", liveMap.data["key1"]?.data?.string) - assertEquals("value2", liveMap.data["key2"]?.data?.string) - } - - @Test - fun `(RTO5f2a2) partial sync map entries merged across separate protocol messages`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - val objectId = "map:test@1" - val siteTimeserials = mapOf("site1" to "serial1") - - // Protocol message 1: first partial (has cursor → not ending) - objectsManager.handleObjectSyncMessages( - listOf( - ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = objectId, - tombstone = false, - siteTimeserials = siteTimeserials, - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf("key1" to ObjectsMapEntry(data = ObjectData(string = "value1"))) - ) - ) - ) - ), - "sync-1:cursor1" - ) - - // Protocol message 2: second partial for same objectId (has cursor → not ending) - objectsManager.handleObjectSyncMessages( - listOf( - ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = objectId, - tombstone = false, - siteTimeserials = siteTimeserials, - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf("key2" to ObjectsMapEntry(data = ObjectData(string = "value2"))) - ) - ) - ) - ), - "sync-1:cursor2" - ) - - // Protocol message 3: third partial for same objectId (empty cursor → ends sync) - objectsManager.handleObjectSyncMessages( - listOf( - ObjectMessage( - id = "msg3", - objectState = ObjectState( - objectId = objectId, - tombstone = false, - siteTimeserials = siteTimeserials, - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf("key3" to ObjectsMapEntry(data = ObjectData(string = "value3"))) - ) - ) - ) - ), - "sync-1:" // empty cursor → sync ends, applySync() runs - ) - - // Verify all 3 keys from 3 separate protocol messages are merged into the live map - val liveMap = defaultRealtimeObjects.objectsPool.get(objectId) as DefaultLiveMap - assertNotNull(liveMap.data["key1"], "key1 from first protocol message should be present") - assertNotNull(liveMap.data["key2"], "key2 from second protocol message should be present") - assertNotNull(liveMap.data["key3"], "key3 from third protocol message should be present") - assertEquals("value1", liveMap.data["key1"]?.data?.string) - assertEquals("value2", liveMap.data["key2"]?.data?.string) - assertEquals("value3", liveMap.data["key3"]?.data?.string) - } - - @Test - fun `(RTO5c1b1c) unsupported object type during sync is skipped without breaking other objects`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - mockkStatic(Log::class) - every { Log.w(any(), any()) } returns 0 - - // msg1: valid map object - val msg1 = ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf("key1" to ObjectsMapEntry(data = ObjectData(string = "value1"))) - ) - ) - ) - // msg2: unsupported type (neither counter nor map) - val msg2 = ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = "unknown:test@2", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - ) - ) - // msg3: valid counter object - val msg3 = ObjectMessage( - id = "msg3", - objectState = ObjectState( - objectId = "counter:test@3", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - counter = ObjectsCounter(count = 42.0) - ) - ) - - // Send all three in one sync — msg2 should be skipped, msg1 and msg3 should be applied - objectsManager.handleObjectSyncMessages(listOf(msg1, msg2, msg3), "sync-1:") - - val liveMap = defaultRealtimeObjects.objectsPool.get("map:test@1") as DefaultLiveMap - assertNotNull(liveMap.data["key1"], "valid map object should be created despite unsupported object in same sync") - - val counter = defaultRealtimeObjects.objectsPool.get("counter:test@3") as DefaultLiveCounter - assertEquals(42.0, counter.data.get(), "valid counter should be created despite unsupported object in same sync") - - // Unsupported object should NOT be in the pool - assertNull(defaultRealtimeObjects.objectsPool.get("unknown:test@2"), "unsupported object type should not be in pool") - } - - @Test - fun `(RTO5f2a1) tombstone on second partial message replaces pool entry entirely`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - val msg1 = ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = mapOf("key1" to ObjectsMapEntry(data = ObjectData(string = "value1")))) - ) - ) - val msg2 = ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = true, - siteTimeserials = mapOf("site1" to "serial2"), - map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = emptyMap()) - ) - ) - - objectsManager.handleObjectSyncMessages(listOf(msg1, msg2), "sync-1:") - - val liveMap = defaultRealtimeObjects.objectsPool.get("map:test@1") as DefaultLiveMap - // After tombstone replaces the entry, the map should have no key1 - assertNull(liveMap.data["key1"], "key1 should not be present after tombstone replaced the pool entry") - } - - @Test - fun `(RTO5f2b) partial sync counter message logs error and is skipped`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - mockkStatic(Log::class) - every { Log.e(any(), any()) } returns 0 - - val msg1 = ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = "counter:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - counter = ObjectsCounter(count = 10.0) - ) - ) - val msg2 = ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = "counter:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial2"), - counter = ObjectsCounter(count = 5.0) - ) - ) - - objectsManager.handleObjectSyncMessages(listOf(msg1, msg2), "sync-1:") - - // Pool should contain only msg1 (msg2 skipped) - val counter = defaultRealtimeObjects.objectsPool.get("counter:test@1") as DefaultLiveCounter - assertEquals(10.0, counter.data.get(), "counter value should be from msg1 only (msg2 skipped)") - verify { Log.e(any(), match { it.contains("partial sync message for a counter") }) } - } - - @Test - fun `(RTO5f2c) partial sync message with unsupported type logs warning and is skipped`() { - val defaultRealtimeObjects = makeRealtimeObjects() - val objectsManager = defaultRealtimeObjects.ObjectsManager - - mockkStatic(Log::class) - every { Log.w(any(), any()) } returns 0 - - val msg1 = ObjectMessage( - id = "msg1", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial1"), - map = ObjectsMap(semantics = ObjectsMapSemantics.LWW, entries = mapOf("key1" to ObjectsMapEntry(data = ObjectData(string = "value1")))) - ) - ) - // msg2 has neither map nor counter — hits the else branch (RTO5f2c) - val msg2 = ObjectMessage( - id = "msg2", - objectState = ObjectState( - objectId = "map:test@1", - tombstone = false, - siteTimeserials = mapOf("site1" to "serial2"), - ) - ) - - objectsManager.handleObjectSyncMessages(listOf(msg1, msg2), "sync-1:") - - // Pool entry should still be msg1 (msg2 was skipped) - val liveMap = defaultRealtimeObjects.objectsPool.get("map:test@1") as DefaultLiveMap - assertNotNull(liveMap.data["key1"], "key1 should still be present (msg2 skipped)") - verify { Log.w(any(), match { it.contains("unsupported object type") }) } - } - - private fun mockZeroValuedObjects() { - mockkObject(DefaultLiveMap.Companion) - every { - DefaultLiveMap.zeroValue(any(), any()) - } answers { - mockk(relaxed = true) - } - mockkObject(DefaultLiveCounter.Companion) - every { - DefaultLiveCounter.zeroValue(any(), any()) - } answers { - mockk(relaxed = true) - } - } - - @AfterTest - fun tearDown() { - val cleanupError = AblyException.fromErrorInfo(ErrorInfo("test cleanup", 500)) - testInstances.forEach { it.dispose(cleanupError) } - testInstances.clear() - unmockkAll() // Clean up all mockk objects after each test - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt deleted file mode 100644 index aff4f9d1a..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/objects/ObjectsPoolTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -package io.ably.lib.objects.unit.objects - -import io.ably.lib.objects.DefaultRealtimeObjects -import io.ably.lib.objects.ObjectData -import io.ably.lib.objects.ROOT_OBJECT_ID -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.type.livemap.LiveMapEntry -import io.ably.lib.objects.unit.* -import io.mockk.mockk -import io.mockk.spyk -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class ObjectsPoolTest { - - @Test - fun `(RTO3, RTO3a, RTO3b) An internal ObjectsPool should be used to maintain the list of objects present on a channel`() { - val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", getMockObjectsAdapter()) - val objectsPool = defaultRealtimeObjects.objectsPool - assertNotNull(objectsPool) - - // RTO3b - It must always contain a LiveMap object with id root - val rootLiveMap = objectsPool.get(ROOT_OBJECT_ID) - assertNotNull(rootLiveMap) - assertTrue(rootLiveMap is DefaultLiveMap) - assertTrue(rootLiveMap.data.isEmpty()) - assertEquals(ROOT_OBJECT_ID, rootLiveMap.objectId) - assertEquals(1, objectsPool.size(), "RTO3 - Should only contain the root object initially") - - // RTO3a - ObjectsPool is a Dict, a map of RealtimeObjects keyed by objectId string - val testLiveMap = DefaultLiveMap.zeroValue("map:testObject@1", mockk(relaxed = true)) - objectsPool.set("map:testObject@1", testLiveMap) - val testLiveCounter = DefaultLiveCounter.zeroValue("counter:testObject@1", mockk(relaxed = true)) - objectsPool.set("counter:testObject@1", testLiveCounter) - // Assert that the objects are stored in the pool - assertEquals(testLiveMap, objectsPool.get("map:testObject@1")) - assertEquals(testLiveCounter, objectsPool.get("counter:testObject@1")) - assertEquals(3, objectsPool.size(), "RTO3 - Should have 3 objects in pool (root + testLiveMap + testLiveCounter)") - } - - @Test - fun `(RTO6) ObjectsPool should create zero-value objects if not exists`() { - val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", getMockObjectsAdapter()) - val objectsPool = spyk(defaultRealtimeObjects.objectsPool) - assertEquals(1, objectsPool.size(), "RTO3 - Should only contain the root object initially") - - // Test creating zero-value map - // RTO6b1, RTO6b2 - Type is parsed from the objectId format (map:hash@timestamp) - val mapId = "map:xyz789@67890" - val map = objectsPool.createZeroValueObjectIfNotExists(mapId) - assertNotNull(map, "Should create a map object") - assertTrue(map is DefaultLiveMap, "RTO6b2 - Should create a LiveMap for map type") - assertEquals(mapId, map.objectId) - assertTrue(map.data.isEmpty(), "RTO6b2 - Should create an empty map") - assertEquals(2, objectsPool.size(), "RTO6 - root + map should be in pool after creation") - - // Test creating zero-value counter - // RTO6b1, RTO6b3 - Type is parsed from the objectId format (counter:hash@timestamp) - val counterId = "counter:abc123@12345" - val counter = objectsPool.createZeroValueObjectIfNotExists(counterId) - assertNotNull(counter, "Should create a counter object") - assertTrue(counter is DefaultLiveCounter, "RTO6b3 - Should create a LiveCounter for counter type") - assertEquals(counterId, counter.objectId) - assertEquals(0.0, counter.data.get(), "RTO6b3 - Should create a zero-value counter") - assertEquals(3, objectsPool.size(), "RTO6 - root + map + counter should be in pool after creation") - - // RTO6a - If object exists in pool, do not create a new one - val existingMap = objectsPool.createZeroValueObjectIfNotExists(mapId) - assertEquals(map, existingMap, "RTO6a - Should return existing object, not create a new one") - val existingCounter = objectsPool.createZeroValueObjectIfNotExists(counterId) - assertEquals(counter, existingCounter, "RTO6a - Should return existing object, not create a new one") - assertEquals(3, objectsPool.size(), "RTO6 - Should still have 3 objects in pool after re-creation attempt") - } - - @Test - fun `(RTO4b1, RTO4b2) ObjectsPool should reset to initial pool retaining original root map`() { - val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", getMockObjectsAdapter()) - val objectsPool = defaultRealtimeObjects.objectsPool - assertEquals(1, objectsPool.size()) - val rootMap = objectsPool.get(ROOT_OBJECT_ID) as DefaultLiveMap - // add some data to the root map - rootMap.data["initialKey1"] = LiveMapEntry(data = ObjectData("testValue1")) - rootMap.data["initialKey2"] = LiveMapEntry(data = ObjectData("testValue2")) - assertEquals(2, rootMap.data.size, "RTO3 - Root map should have initial data") - - // Add some objects - objectsPool.set("counter:testObject@1", DefaultLiveCounter.zeroValue("counter:testObject@1", mockk(relaxed = true))) - assertEquals(2, objectsPool.size()) // root + testObject - objectsPool.set("counter:testObject@2", DefaultLiveCounter.zeroValue("counter:testObject@2", mockk(relaxed = true))) - assertEquals(3, objectsPool.size()) // root + testObject + anotherObject - objectsPool.set("map:testObject@1", DefaultLiveMap.zeroValue("map:testObject@1", mockk(relaxed = true))) - assertEquals(4, objectsPool.size()) // root + testObject + anotherObject + testMap - - // Reset to initial pool - objectsPool.resetToInitialPool(true) - - // RTO4b1 - Should only contain root object - assertEquals(1, objectsPool.size()) - assertEquals(rootMap, objectsPool.get(ROOT_OBJECT_ID)) - // RTO4b2 - RootMap should be empty after reset - assertTrue(rootMap.data.isEmpty(), "RTO3 - Root map should be empty after reset") - } - - @Test - fun `(RTO5c2, RTO5c2a) ObjectsPool should delete extra object IDs`() { - val defaultRealtimeObjects = DefaultRealtimeObjects("dummyChannel", getMockObjectsAdapter()) - val objectsPool = defaultRealtimeObjects.objectsPool - - // Add some objects - objectsPool.set("counter:testObject@1", DefaultLiveCounter.zeroValue("counter:testObject@1", mockk(relaxed = true))) - objectsPool.set("counter:testObject@2", DefaultLiveCounter.zeroValue("counter:testObject@2", mockk(relaxed = true))) - objectsPool.set("counter:testObject@3", DefaultLiveCounter.zeroValue("counter:testObject@3", mockk(relaxed = true))) - assertEquals(4, objectsPool.size()) // root + 3 objects - - // Delete extra object IDs (keep only object1 and object2) - val receivedObjectIds = mutableSetOf("counter:testObject@1", "counter:testObject@2") - objectsPool.deleteExtraObjectIds(receivedObjectIds) - - // Should only contain root, object1, and object2 - assertEquals(3, objectsPool.size()) - // RTO5c2a - Should keep the root object - assertNotNull(objectsPool.get(ROOT_OBJECT_ID)) - // RTO5c2 - Should delete object3 and keep object1 and object2 - assertNotNull(objectsPool.get("counter:testObject@1")) - assertNotNull(objectsPool.get("counter:testObject@2")) - assertNull(objectsPool.get("counter:testObject@3")) // Should be deleted - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseRealtimeObjectTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseRealtimeObjectTest.kt deleted file mode 100644 index 9868bf680..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/BaseRealtimeObjectTest.kt +++ /dev/null @@ -1,172 +0,0 @@ -package io.ably.lib.objects.unit.type - -import io.ably.lib.objects.* -import io.ably.lib.objects.type.BaseRealtimeObject -import io.ably.lib.objects.type.livecounter.DefaultLiveCounter -import io.ably.lib.objects.type.livemap.DefaultLiveMap -import io.ably.lib.objects.unit.getDefaultRealtimeObjectsWithMockedDeps -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlin.test.assertFailsWith - -class BaseRealtimeObjectTest { - - private val defaultRealtimeObjects = getDefaultRealtimeObjectsWithMockedDeps() - - @Test - fun `(RTLO1, RTLO2) BaseRealtimeObject should be abstract base class for LiveMap and LiveCounter`() { - // RTLO2 - Check that BaseRealtimeObject is abstract - val isAbstract = java.lang.reflect.Modifier.isAbstract(BaseRealtimeObject::class.java.modifiers) - assertTrue(isAbstract, "BaseRealtimeObject should be an abstract class") - - // RTLO1 - Check that BaseRealtimeObject is the parent class of DefaultLiveMap and DefaultLiveCounter - assertTrue(BaseRealtimeObject::class.java.isAssignableFrom(DefaultLiveMap::class.java), - "DefaultLiveMap should extend BaseRealtimeObject") - assertTrue(BaseRealtimeObject::class.java.isAssignableFrom(DefaultLiveCounter::class.java), - "DefaultLiveCounter should extend BaseRealtimeObject") - } - - @Test - fun `(RTLO3) BaseRealtimeObject should have required properties`() { - val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) - val liveCounter: BaseRealtimeObject = DefaultLiveCounter.zeroValue("counter:testObject@1", defaultRealtimeObjects) - // RTLO3a - check that objectId is set correctly - assertEquals("map:testObject@1", liveMap.objectId) - assertEquals("counter:testObject@1", liveCounter.objectId) - - // RTLO3b, RTLO3b1 - check that siteTimeserials is initialized as an empty map - assertEquals(emptyMap(), liveMap.siteTimeserials) - assertEquals(emptyMap(), liveCounter.siteTimeserials) - - // RTLO3c - Create operation merged flag - assertFalse(liveMap.createOperationIsMerged, "Create operation should not be merged by default") - assertFalse(liveCounter.createOperationIsMerged, "Create operation should not be merged by default") - } - - @Test - fun `(RTLO4a1, RTLO4a2) canApplyOperation should accept ObjectMessage params and return boolean`() { - // RTLO4a1a - Assert parameter types and return type based on method signature using reflection - val method = BaseRealtimeObject::class.java.findMethod("canApplyOperation") - - // RTLO4a1a - Verify parameter types - val parameters = method.parameters - assertEquals(2, parameters.size, "canApplyOperation should have exactly 2 parameters") - - // First parameter should be String? (siteCode) - assertEquals(String::class.java, parameters[0].type, "First parameter should be of type String?") - assertTrue(parameters[0].isVarArgs.not(), "First parameter should not be varargs") - - // Second parameter should be String? (timeSerial) - assertEquals(String::class.java, parameters[1].type, "Second parameter should be of type String?") - assertTrue(parameters[1].isVarArgs.not(), "Second parameter should not be varargs") - - // RTLO4a2 - Verify return type - assertEquals(Boolean::class.java, method.returnType, "canApplyOperation should return Boolean") - } - - @Test - fun `(RTLO4a3) canApplyOperation should throw error for null or empty incoming siteSerial`() { - val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) - - // Test null serial - assertFailsWith("Should throw error for null serial") { - liveMap.canApplyOperation("site1", null) - } - - // Test empty serial - assertFailsWith("Should throw error for empty serial") { - liveMap.canApplyOperation("site1", "") - } - - // Test null siteCode - assertFailsWith("Should throw error for null site code") { - liveMap.canApplyOperation(null, "serial1") - } - - // Test empty siteCode - assertFailsWith("Should throw error for empty site code") { - liveMap.canApplyOperation("", "serial1") - } - } - - @Test - fun `(RTLO4a4, RTLO4a5) canApplyOperation should return true when existing siteSerial is null or empty`() { - val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) - assertTrue(liveMap.siteTimeserials.isEmpty(), "Initial siteTimeserials should be empty") - - // RTLO4a4 - Get siteSerial from siteTimeserials map - // RTLO4a5 - Return true when siteSerial is null (no entry in map) - assertTrue(liveMap.canApplyOperation("site1", "serial1"), - "Should return true when no siteSerial exists for the site") - - // RTLO4a5 - Return true when siteSerial is empty string - liveMap.siteTimeserials["site1"] = "" - assertTrue(liveMap.canApplyOperation("site1", "serial1"), - "Should return true when siteSerial is empty string") - } - - @Test - fun `(RTLO4a6) canApplyOperation should return true when message siteSerial is greater than existing siteSerial`() { - val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) - - // Set existing siteSerial - liveMap.siteTimeserials["site1"] = "serial1" - - // RTLO4a6 - Return true when message serial is greater (lexicographically) - assertTrue(liveMap.canApplyOperation("site1", "serial2"), - "Should return true when message serial 'serial2' > siteSerial 'serial1'") - - assertTrue(liveMap.canApplyOperation("site1", "serial10"), - "Should return true when message serial 'serial10' > siteSerial 'serial1'") - - assertTrue(liveMap.canApplyOperation("site1", "serialA"), - "Should return true when message serial 'serialA' > siteSerial 'serial1'") - } - - @Test - fun `(RTLO4a6) canApplyOperation should return false when message siteSerial is less than or equal to siteSerial`() { - val liveMap: BaseRealtimeObject = DefaultLiveMap.zeroValue("map:testObject@1", defaultRealtimeObjects) - - // Set existing siteSerial - liveMap.siteTimeserials["site1"] = "serial2" - - // RTLO4a6 - Return false when message serial is less than siteSerial - assertFalse(liveMap.canApplyOperation("site1", "serial1"), - "Should return false when message serial 'serial1' < siteSerial 'serial2'") - - // RTLO4a6 - Return false when message serial equals siteSerial - assertFalse(liveMap.canApplyOperation("site1", "serial2"), - "Should return false when message serial equals siteSerial") - - // RTLO4a6 - Return false when message serial is less (lexicographically) - assertTrue(liveMap.canApplyOperation("site1", "serialA"), - "Should return true when message serial 'serialA' > siteSerial 'serial2'") - } - - @Test - fun `(RTLO4a) canApplyOperation should work with different site codes`() { - val liveMap: BaseRealtimeObject = DefaultLiveCounter.zeroValue("counter:testObject@1", defaultRealtimeObjects) - - // Set serials for different sites - liveMap.siteTimeserials["site1"] = "serial1" - liveMap.siteTimeserials["site2"] = "serial5" - - // Test site1 - assertTrue(liveMap.canApplyOperation("site1", "serial2"), - "Should return true for site1 when serial2 > serial1") - assertFalse(liveMap.canApplyOperation("site1", "serial1"), - "Should return false for site1 when serial1 = serial1") - - // Test site2 - assertTrue(liveMap.canApplyOperation("site2", "serial6"), - "Should return true for site2 when serial6 > serial5") - assertFalse(liveMap.canApplyOperation("site2", "serial4"), - "Should return false for site2 when serial4 < serial5") - - // Test new site (should return true) - assertTrue(liveMap.canApplyOperation("site3", "serial1"), - "Should return true for new site with any serial") - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt deleted file mode 100644 index 3e82cebc9..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/DefaultLiveCounterTest.kt +++ /dev/null @@ -1,262 +0,0 @@ -package io.ably.lib.objects.unit.type.livecounter - -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.ObjectsOperationSource -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.unit.getDefaultLiveCounterWithMockedDeps -import io.ably.lib.types.AblyException -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class DefaultLiveCounterTest { - @Test - fun `(RTLC6, RTLC6a) DefaultLiveCounter should override serials with state serials from sync`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - // Set initial data - liveCounter.siteTimeserials["site1"] = "serial1" - liveCounter.siteTimeserials["site2"] = "serial2" - - val objectState = ObjectState( - objectId = "counter:testCounter@1", - siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), - tombstone = false, - ) - - val objectMessage = ObjectMessage( - id = "testId", - objectState = objectState, - serial = "serial1", - siteCode = "site1" - ) - - liveCounter.applyObjectSync(objectMessage) - assertEquals(mapOf("site3" to "serial3", "site4" to "serial4"), liveCounter.siteTimeserials) // RTLC6a - } - - @Test - fun `(RTLC7, RTLC7a) DefaultLiveCounter should check objectId before applying operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "counter:testCounter@2", // Different objectId - counterCreate = CounterCreate(count = 20.0) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7a - Should throw error when objectId doesn't match - val exception = assertFailsWith { - liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - } - val errorInfo = exception.errorInfo - assertNotNull(errorInfo) - - // Assert on error codes - assertEquals(92000, exception.errorInfo?.code) // InvalidObject error code - assertEquals(500, exception.errorInfo?.statusCode) // InternalServerError status code - } - - @Test - fun `(RTLC7, RTLC7b) DefaultLiveCounter should validate site serial before applying operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - // Set existing site serial that is newer than the incoming message - liveCounter.siteTimeserials["site1"] = "serial2" // Newer than "serial1" - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "counter:testCounter@1", // Matching objectId - counterCreate = CounterCreate(count = 20.0) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial1", // Older serial - siteCode = "site1" - ) - - // RTLC7b - Should skip operation when serial is not newer - liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - // Verify that the site serial was not updated (operation was skipped) - assertEquals("serial2", liveCounter.siteTimeserials["site1"]) - } - - @Test - fun `(RTLC7, RTLC7c) DefaultLiveCounter should update site serial if valid`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - // Set existing site serial that is older than the incoming message - liveCounter.siteTimeserials["site1"] = "serial1" // Older than "serial2" - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "counter:testCounter@1", // Matching objectId - counterCreate = CounterCreate(count = 20.0) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial2", // Newer serial - siteCode = "site1" - ) - - // RTLC7c - Should update site serial when operation is valid - liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - // Verify that the site serial was updated - assertEquals("serial2", liveCounter.siteTimeserials["site1"]) - } - - @Test - fun `(RTLC7c LOCAL) applyObject with LOCAL source updates data but does NOT update siteTimeserials`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - assertTrue(liveCounter.siteTimeserials.isEmpty(), "siteTimeserials should start empty") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:testCounter@1", - counterInc = io.ably.lib.objects.CounterInc(number = 5.0) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7c - LOCAL source: data IS updated, siteTimeserials is NOT updated - val result = liveCounter.applyObject(message, ObjectsOperationSource.LOCAL) - - assertTrue(result, "applyObject should return true for successful COUNTER_INC") - assertEquals(5.0, liveCounter.data.get(), "data should be updated for LOCAL source") - assertFalse(liveCounter.siteTimeserials.containsKey("site1"), - "siteTimeserials should NOT be updated for LOCAL source") - } - - @Test - fun `(RTLC7b return) applyObject returns false when incoming serial is not newer than existing`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - liveCounter.siteTimeserials["site1"] = "serial5" // Newer than incoming "serial1" - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:testCounter@1", - counterInc = io.ably.lib.objects.CounterInc(number = 5.0) - ), - serial = "serial1", // Older than "serial5" - siteCode = "site1" - ) - - // RTLC7b - Should return false when canApplyOperation fails - val result = liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertFalse(result, "applyObject should return false when serial is not newer") - assertEquals(0.0, liveCounter.data.get(), "data should not be changed") - assertEquals("serial5", liveCounter.siteTimeserials["site1"], "siteTimeserials should not change") - } - - @Test - fun `(RTLC7e return) applyObject returns false when object is tombstoned`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - liveCounter.tombstone(null) // Tombstone the object - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:testCounter@1", - counterInc = io.ably.lib.objects.CounterInc(number = 5.0) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7e - Should return false when object is tombstoned - val result = liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertFalse(result, "applyObject should return false when object is tombstoned") - } - - @Test - fun `(RTLC7d2b) applyObject returns true for successful COUNTER_INC`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "counter:testCounter@1", - counterInc = io.ably.lib.objects.CounterInc(number = 5.0) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7d2b - Should return true for successful COUNTER_INC - val result = liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for successful COUNTER_INC") - assertEquals(5.0, liveCounter.data.get()) - } - - @Test - fun `(RTLC7d1b) applyObject returns true for successful COUNTER_CREATE`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "counter:testCounter@1", - counterCreate = CounterCreate(count = 20.0) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7d1b - Should return true for successful COUNTER_CREATE - val result = liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for successful COUNTER_CREATE") - } - - @Test - fun `(RTLC7d4b) applyObject returns true for OBJECT_DELETE (tombstone)`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps("counter:testCounter@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.ObjectDelete, - objectId = "counter:testCounter@1", - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLC7d4b - Should return true for OBJECT_DELETE (tombstone applied) - val result = liveCounter.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for OBJECT_DELETE") - assertTrue(liveCounter.isTombstoned, "object should be tombstoned") - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt deleted file mode 100644 index e7dda488f..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livecounter/LiveCounterManagerTest.kt +++ /dev/null @@ -1,356 +0,0 @@ -package io.ably.lib.objects.unit.type.livecounter - -import io.ably.lib.objects.* -import io.ably.lib.objects.CounterCreate -import io.ably.lib.objects.CounterInc -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.unit.LiveCounterManager -import io.ably.lib.objects.unit.TombstonedAt -import io.ably.lib.objects.unit.getDefaultLiveCounterWithMockedDeps -import io.ably.lib.types.AblyException -import org.junit.Test -import kotlin.test.* - -class DefaultLiveCounterManagerTest { - - @Test - fun `(RTLC6, RTLC6b, RTLC6c) DefaultLiveCounter should override counter data with state from sync`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val objectState = ObjectState( - objectId = "testCounterId", - counter = ObjectsCounter(count = 25.0), - siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), - tombstone = false, - ) - - val update = liveCounterManager.applyState(objectState, null) - - assertFalse(liveCounter.createOperationIsMerged) // RTLC6b - assertEquals(25.0, liveCounter.data.get()) // RTLC6c - assertEquals(15.0, update.update.amount) // Difference between old and new data - } - - - @Test - fun `(RTLC6, RTLC6d) DefaultLiveCounter should merge create operation in state from sync`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(5.0) - - val createOp = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = CounterCreate(count = 10.0) - ) - - val objectState = ObjectState( - objectId = "testCounterId", - counter = ObjectsCounter(count = 15.0), - createOp = createOp, - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - // RTLC6d - Merge initial data from create operation - val update = liveCounterManager.applyState(objectState, null) - - assertEquals(25.0, liveCounter.data.get()) // 15 from state + 10 from create op - assertEquals(20.0, update.update.amount) // Total change - } - - - @Test - fun `(RTLC7d1b) LiveCounterManager applyOperation returns true for COUNTER_CREATE`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = CounterCreate(count = 10.0) - ) - - // RTLC7d1b - Should return true for successful COUNTER_CREATE - val result = liveCounterManager.applyOperation(operation, null) - assertTrue(result, "applyOperation should return true for COUNTER_CREATE") - } - - @Test - fun `(RTLC7d2b) LiveCounterManager applyOperation returns true for COUNTER_INC`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "testCounterId", - counterInc = CounterInc(number = 5.0) - ) - - // RTLC7d2b - Should return true for successful COUNTER_INC - val result = liveCounterManager.applyOperation(operation, null) - assertTrue(result, "applyOperation should return true for COUNTER_INC") - } - - @Test - fun `(RTLC7d4b) LiveCounterManager applyOperation returns true for OBJECT_DELETE`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.ObjectDelete, - objectId = "testCounterId", - ) - - // RTLC7d4b - Should return true for OBJECT_DELETE (tombstone) - val result = liveCounterManager.applyOperation(operation, null) - assertTrue(result, "applyOperation should return true for OBJECT_DELETE") - assertTrue(liveCounter.isTombstoned, "counter should be tombstoned after ObjectDelete") - } - - @Test - fun `(RTLC7, RTLC7d3) LiveCounterManager should return false for unsupported action`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, // Unsupported action for counter - objectId = "testCounterId", - mapCreate = MapCreate(semantics = ObjectsMapSemantics.LWW, entries = emptyMap()) - ) - - // RTLC7d3 - Should return false for unsupported action (no longer throws) - val result = liveCounterManager.applyOperation(operation, null) - assertFalse(result, "Should return false for unsupported action") - } - - @Test - fun `(RTLC7, RTLC7d1, RTLC8) LiveCounterManager should apply counter create operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = CounterCreate(count = 20.0) - ) - - // RTLC7d1 - Apply counter create operation - liveCounterManager.applyOperation(operation, null) - - assertEquals(20.0, liveCounter.data.get()) // Should be set to counter count - assertTrue(liveCounter.createOperationIsMerged) // Should be marked as merged - } - - @Test - fun `(RTLC8, RTLC8b) LiveCounterManager should skip counter create operation if already merged`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - liveCounter.data.set(4.0) // Start with 4 - - // Set create operation as already merged - liveCounter.createOperationIsMerged = true - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = CounterCreate(count = 20.0) - ) - - // RTLC8b - Should skip if already merged - liveCounterManager.applyOperation(operation, null) - - assertEquals(4.0, liveCounter.data.get()) // Should not change (still 0) - assertTrue(liveCounter.createOperationIsMerged) // Should remain merged - } - - @Test - fun `(RTLC8, RTLC8c) LiveCounterManager should apply counter create operation if not merged`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - // Set initial data - liveCounter.data.set(10.0) // Start with 10 - - // Set create operation as not merged - liveCounter.createOperationIsMerged = false - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = CounterCreate(count = 20.0) - ) - - // RTLC8c - Should apply if not merged - liveCounterManager.applyOperation(operation, null) - assertTrue(liveCounter.createOperationIsMerged) // Should be marked as merged - - assertEquals(30.0, liveCounter.data.get()) // Should be set to counter count - assertTrue(liveCounter.createOperationIsMerged) // RTLC16b - Should be marked as merged - } - - @Test - fun `(RTLC8, RTLC16) LiveCounterManager should handle null count in create operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, - objectId = "testCounterId", - counterCreate = null // No count specified - ) - - // RTLC16a - Should default to 0 - liveCounterManager.applyOperation(operation, null) - - assertEquals(10.0, liveCounter.data.get()) // No change (null defaults to 0) - assertTrue(liveCounter.createOperationIsMerged) // RTLC16b - } - - @Test - fun `(RTLC7, RTLC7d2, RTLC9) LiveCounterManager should apply counter increment operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "testCounterId", - counterInc = CounterInc(number = 5.0) - ) - - // RTLC7d2 - Apply counter increment operation - liveCounterManager.applyOperation(operation, null) - - assertEquals(15.0, liveCounter.data.get()) // RTLC9f - 10 + 5 - } - - @Test - fun `(RTLC7, RTLC7d2) LiveCounterManager should throw error for missing payload for counter increment operation`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "testCounterId", - counterInc = null // Missing payload - ) - - // RTLC7d2 - Should throw error for missing payload - val exception = assertFailsWith { - liveCounterManager.applyOperation(operation, null) - } - - val errorInfo = exception.errorInfo - assertNotNull(errorInfo) - assertEquals(92000, errorInfo.code) // InvalidObject error code - assertEquals(500, errorInfo.statusCode) // InternalServerError status code - } - - - @Test - fun `(RTLC9, RTLC9f) LiveCounterManager should apply counter increment operation correctly`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val counterInc = CounterInc(number = 7.0) - - // RTLC9f - Apply counter increment - liveCounterManager.applyOperation(ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "testCounterId", - counterInc = counterInc - ), null) - - assertEquals(17.0, liveCounter.data.get()) // 10 + 7 - } - - @Test - fun `(RTLC7, RTLC7d2) LiveCounterManager should throw error when counterInc payload missing`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - // RTLC7d2 - Apply counter increment with no payload - throws error - val exception = assertFailsWith { - liveCounterManager.applyOperation(ObjectOperation( - action = ObjectOperationAction.CounterInc, - objectId = "testCounterId", - counterInc = null - ), null) - } - assertNotNull(exception.errorInfo) - assertEquals(92000, exception.errorInfo.code) - } - - @Test - fun `(RTLC6, OM2j) DefaultLiveCounter should handle tombstone with serialTimestamp in state`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val expectedTimestamp = 1234567890L - val objectState = ObjectState( - objectId = "testCounterId", - counter = null, // Null counter for tombstone - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = true, // Object is tombstoned - ) - - val update = liveCounterManager.applyState(objectState, expectedTimestamp) - - assertTrue(liveCounter.isTombstoned) // Should be tombstoned - assertEquals(expectedTimestamp, liveCounter.TombstonedAt) // Should use provided timestamp - assertEquals(0.0, liveCounter.data.get()) // Should be reset after tombstone - - // Assert on update field - should show the change - assertEquals(-10.0, update.update.amount) // Difference from 10.0 to 0.0 - } - - @Test - fun `(RTLC6, OM2j) DefaultLiveCounter should handle tombstone without serialTimestamp in state`() { - val liveCounter = getDefaultLiveCounterWithMockedDeps() - val liveCounterManager = liveCounter.LiveCounterManager - - // Set initial data - liveCounter.data.set(10.0) - - val objectState = ObjectState( - objectId = "testCounterId", - counter = null, // Null counter for tombstone - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = true, // Object is tombstoned - ) - - val beforeOperation = System.currentTimeMillis() - val update = liveCounterManager.applyState(objectState, null) - val afterOperation = System.currentTimeMillis() - - assertTrue(liveCounter.isTombstoned) // Should be tombstoned - assertNotNull(liveCounter.TombstonedAt) // Should have timestamp - assertTrue(liveCounter.TombstonedAt!! >= beforeOperation) // Should be after operation start - assertTrue(liveCounter.TombstonedAt!! <= afterOperation) // Should be before operation end - assertEquals(0.0, liveCounter.data.get()) // Should be reset after tombstone - - // Assert on update field - should show the change - assertEquals(-10.0, update.update.amount) // Difference from 10.0 to 0.0 - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt deleted file mode 100644 index 7ddd43937..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/DefaultLiveMapTest.kt +++ /dev/null @@ -1,276 +0,0 @@ -package io.ably.lib.objects.unit.type.livemap - -import io.ably.lib.objects.ObjectsMapSemantics -import io.ably.lib.objects.ObjectsMap -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.ObjectsOperationSource -import io.ably.lib.objects.ObjectState -import io.ably.lib.objects.ObjectMessage -import io.ably.lib.objects.ObjectOperation -import io.ably.lib.objects.ObjectOperationAction -import io.ably.lib.objects.unit.* -import io.ably.lib.types.AblyException -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class DefaultLiveMapTest { - @Test - fun `(RTLM6, RTLM6a) DefaultLiveMap should override serials with state serials from sync`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - // Set initial data - liveMap.siteTimeserials["site1"] = "serial1" - liveMap.siteTimeserials["site2"] = "serial2" - - val objectState = ObjectState( - objectId = "map:testMap@1", - siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), - tombstone = false, - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - ) - ) - - val objectMessage = ObjectMessage( - id = "testId", - objectState = objectState, - serial = "serial1", - siteCode = "site1" - ) - - liveMap.applyObjectSync(objectMessage) - assertEquals(mapOf("site3" to "serial3", "site4" to "serial4"), liveMap.siteTimeserials) // RTLM6a - } - - @Test - fun `(RTLM15, RTLM15a) DefaultLiveMap should check objectId before applying operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@2", // Different objectId - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap() - ) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15a - Should throw error when objectId doesn't match - val exception = assertFailsWith { - liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - } - val errorInfo = exception.errorInfo - assertNotNull(errorInfo) - - // Assert on error codes - assertEquals(92000, exception.errorInfo?.code) // InvalidObject error code - assertEquals(500, exception.errorInfo?.statusCode) // InternalServerError status code - } - - @Test - fun `(RTLM15, RTLM15b) DefaultLiveMap should validate site serial before applying operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - // Set existing site serial that is newer than the incoming message - liveMap.siteTimeserials["site1"] = "serial2" // Newer than "serial1" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", // Matching objectId - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap() - ) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial1", // Older serial - siteCode = "site1" - ) - - // RTLM15b - Should skip operation when serial is not newer - liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - // Verify that the site serial was not updated (operation was skipped) - assertEquals("serial2", liveMap.siteTimeserials["site1"]) - } - - @Test - fun `(RTLM15, RTLM15c) DefaultLiveMap should update site serial if valid`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - // Set existing site serial that is older than the incoming message - liveMap.siteTimeserials["site1"] = "serial1" // Older than "serial2" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", // Matching objectId - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap() - ) - ) - - val message = ObjectMessage( - id = "testId", - operation = operation, - serial = "serial2", // Newer serial - siteCode = "site1" - ) - - // RTLM15c - Should update site serial when operation is valid - liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - // Verify that the site serial was updated - assertEquals("serial2", liveMap.siteTimeserials["site1"]) - } - - @Test - fun `(RTLM15c LOCAL) applyObject with LOCAL source updates data but does NOT update siteTimeserials`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - assertTrue(liveMap.siteTimeserials.isEmpty(), "siteTimeserials should start empty") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = io.ably.lib.objects.MapSet(key = "key1", value = io.ably.lib.objects.ObjectData(string = "value1")) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15c - LOCAL source: data IS updated (entry set), siteTimeserials is NOT updated - val result = liveMap.applyObject(message, ObjectsOperationSource.LOCAL) - - assertTrue(result, "applyObject should return true for successful MAP_SET") - assertEquals("value1", liveMap.data["key1"]?.data?.string, "map entry should be updated for LOCAL source") - assertFalse(liveMap.siteTimeserials.containsKey("site1"), - "siteTimeserials should NOT be updated for LOCAL source") - } - - @Test - fun `(RTLM15b return) applyObject returns false when incoming serial is not newer than existing`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - liveMap.siteTimeserials["site1"] = "serial5" // Newer than incoming "serial1" - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = io.ably.lib.objects.MapSet(key = "key1", value = io.ably.lib.objects.ObjectData(string = "value1")) - ), - serial = "serial1", // Older than "serial5" - siteCode = "site1" - ) - - // RTLM15b - Should return false when canApplyOperation fails - val result = liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertFalse(result, "applyObject should return false when serial is not newer") - assertEquals("serial5", liveMap.siteTimeserials["site1"], "siteTimeserials should not change") - } - - @Test - fun `(RTLM15e return) applyObject returns false when object is tombstoned`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - liveMap.tombstone(null) // Tombstone the object - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = io.ably.lib.objects.MapSet(key = "key1", value = io.ably.lib.objects.ObjectData(string = "value1")) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15e - Should return false when object is tombstoned - val result = liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertFalse(result, "applyObject should return false when object is tombstoned") - } - - @Test - fun `(RTLM15d2b) applyObject returns true for successful MAP_SET`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = io.ably.lib.objects.MapSet(key = "key1", value = io.ably.lib.objects.ObjectData(string = "value1")) - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15d2b - Should return true for successful MAP_SET - val result = liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for successful MAP_SET") - assertEquals("value1", liveMap.data["key1"]?.data?.string) - } - - @Test - fun `(RTLM15d3b) applyObject returns true for successful MAP_REMOVE`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = io.ably.lib.objects.MapRemove(key = "key1") - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15d3b - Should return true for successful MAP_REMOVE - val result = liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for successful MAP_REMOVE") - } - - @Test - fun `(RTLM15d5b) applyObject returns true for OBJECT_DELETE (tombstone)`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - - val message = ObjectMessage( - id = "testId", - operation = ObjectOperation( - action = ObjectOperationAction.ObjectDelete, - objectId = "map:testMap@1", - ), - serial = "serial1", - siteCode = "site1" - ) - - // RTLM15d5b - Should return true for OBJECT_DELETE (tombstone applied) - val result = liveMap.applyObject(message, ObjectsOperationSource.CHANNEL) - - assertTrue(result, "applyObject should return true for OBJECT_DELETE") - assertTrue(liveMap.isTombstoned, "object should be tombstoned") - } -} diff --git a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt b/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt deleted file mode 100644 index adaf4eb81..000000000 --- a/liveobjects/src/test/kotlin/io/ably/lib/objects/unit/type/livemap/LiveMapManagerTest.kt +++ /dev/null @@ -1,1388 +0,0 @@ -package io.ably.lib.objects.unit.type.livemap - -import io.ably.lib.objects.* -import io.ably.lib.objects.MapClear -import io.ably.lib.objects.MapCreate -import io.ably.lib.objects.MapRemove -import io.ably.lib.objects.MapSet -import io.ably.lib.objects.type.livemap.LiveMapEntry -import io.ably.lib.objects.type.livemap.LiveMapManager -import io.ably.lib.objects.type.map.LiveMapUpdate -import io.ably.lib.objects.unit.LiveMapManager -import io.ably.lib.objects.unit.TombstonedAt -import io.ably.lib.objects.unit.getDefaultLiveMapWithMockedDeps -import io.ably.lib.types.AblyException -import io.mockk.mockk -import org.junit.Test -import org.junit.Assert.* -import kotlin.test.* - -class LiveMapManagerTest { - - private val livemapManager = LiveMapManager(mockk(relaxed = true)) - - @Test - fun `(RTLM6, RTLM6b, RTLM6c) DefaultLiveMap should override map data with state from sync`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "newValue1"), - timeserial = "serial1" - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "value2"), - timeserial = "serial2" - ) - ) - ), - siteTimeserials = mapOf("site3" to "serial3", "site4" to "serial4"), - tombstone = false, - ) - - val update = liveMapManager.applyState(objectState, null) - - assertFalse(liveMap.createOperationIsMerged) // RTLM6b - assertEquals(2, liveMap.data.size) // RTLM6c - assertEquals("newValue1", liveMap.data["key1"]?.data?.string) // RTLM6c - assertEquals("value2", liveMap.data["key2"]?.data?.string) // RTLM6c - - // Assert on update field - should show changes from old to new state - val expectedUpdate = mapOf( - "key1" to LiveMapUpdate.Change.UPDATED, // key1 was updated from "oldValue" to "newValue1" - "key2" to LiveMapUpdate.Change.UPDATED // key2 was added - ) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, RTLM6c) DefaultLiveMap should handle empty map entries in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap() // Empty map entries - ), - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - val update = liveMapManager.applyState(objectState, null) - - assertFalse(liveMap.createOperationIsMerged) // RTLM6b - assertEquals(0, liveMap.data.size) // RTLM6c - should be empty map - - // Assert on update field - should show that key1 was removed - val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, RTLM6c) DefaultLiveMap should handle null map in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = null, // Null map - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - val update = liveMapManager.applyState(objectState, null) - - assertFalse(liveMap.createOperationIsMerged) // RTLM6b - assertEquals(0, liveMap.data.size) // RTLM6c - should be empty map when map is null - - // Assert on update field - should show that key1 was removed - val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, RTLM6d) DefaultLiveMap should merge initial data from create operation from state in sync`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "existingValue") - ) - - val createOp = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "createValue"), - timeserial = "serial1" - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "newValue"), - timeserial = "serial2" - ) - ) - ) - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "stateValue"), - timeserial = "serial3" - ) - ) - ), - createOp = createOp, - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - // RTLM6d - Merge initial data from create operation - val update = liveMapManager.applyState(objectState, null) - - assertEquals(2, liveMap.data.size) // Should have both state and create op entries - assertEquals("stateValue", liveMap.data["key1"]?.data?.string) // State value takes precedence - assertEquals("newValue", liveMap.data["key2"]?.data?.string) // Create op value - - // Assert on update field - should show changes from create operation - val expectedUpdate = mapOf( - "key1" to LiveMapUpdate.Change.UPDATED, // key1 was updated from "existingValue" to "stateValue" - "key2" to LiveMapUpdate.Change.UPDATED // key2 was added from create operation - ) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, RTLM6c, OME2d) DefaultLiveMap should handle tombstoned entries with serialTimestamp in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val expectedTimestamp = 1234567890L - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "newValue"), - timeserial = "serial1", - tombstone = true, - serialTimestamp = expectedTimestamp - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "value2"), - timeserial = "serial2" - ) - ) - ), - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - val update = liveMapManager.applyState(objectState, null) - - assertFalse(liveMap.createOperationIsMerged) // RTLM6b - assertEquals(2, liveMap.data.size) // RTLM6c - assertTrue(liveMap.data["key1"]?.isTombstoned == true) // Should be tombstoned - assertEquals(expectedTimestamp, liveMap.data["key1"]?.tombstonedAt) // Should use provided serialTimestamp - assertEquals("value2", liveMap.data["key2"]?.data?.string) // RTLM6c - - // Assert on update field - should show that key1 was removed (tombstoned) - val expectedUpdate = mapOf( - "key1" to LiveMapUpdate.Change.REMOVED, // key1 was tombstoned - "key2" to LiveMapUpdate.Change.UPDATED // key2 was added - ) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, RTLM6c, OME2d) DefaultLiveMap should handle tombstoned entries without serialTimestamp in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "newValue"), - timeserial = "serial1", - tombstone = true, - serialTimestamp = null // No timestamp provided - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "value2"), - timeserial = "serial2" - ) - ) - ), - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - val beforeOperation = System.currentTimeMillis() - val update = liveMapManager.applyState(objectState, null) - val afterOperation = System.currentTimeMillis() - - assertFalse(liveMap.createOperationIsMerged) // RTLM6b - assertEquals(2, liveMap.data.size) // RTLM6c - assertTrue(liveMap.data["key1"]?.isTombstoned == true) // Should be tombstoned - assertNotNull(liveMap.data["key1"]?.tombstonedAt) // Should have timestamp - assertTrue(liveMap.data["key1"]?.tombstonedAt!! >= beforeOperation) // Should be after operation start - assertTrue(liveMap.data["key1"]?.tombstonedAt!! <= afterOperation) // Should be before operation end - assertEquals("value2", liveMap.data["key2"]?.data?.string) // RTLM6c - - // Assert on update field - should show that key1 was removed (tombstoned) - val expectedUpdate = mapOf( - "key1" to LiveMapUpdate.Change.REMOVED, // key1 was tombstoned - "key2" to LiveMapUpdate.Change.UPDATED // key2 was added - ) - assertEquals(expectedUpdate, update.update) - } - - - @Test - fun `(RTLM15, RTLM15d1, RTLM16) LiveMapManager should apply map create operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "value1"), - timeserial = "serial1" - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "value2"), - timeserial = "serial2" - ) - ) - ) - ) - - // RTLM15d1 - Apply map create operation - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(2, liveMap.data.size) // Should have both entries - assertEquals("value1", liveMap.data["key1"]?.data?.string) // Should have value1 - assertEquals("value2", liveMap.data["key2"]?.data?.string) // Should have value2 - assertTrue(liveMap.createOperationIsMerged) // Should be marked as merged - } - - @Test - fun `(RTLM16, RTLM16d, RTLM23, OME2d) LiveMapManager should merge initial data from create operation with tombstoned entries`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "existingValue") - ) - - val expectedTimestamp = 1234567890L - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "createValue"), - timeserial = "serial2", - tombstone = true, - serialTimestamp = expectedTimestamp - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "newValue"), - timeserial = "serial3" - ), - "key3" to ObjectsMapEntry( - data = null, - timeserial = "serial4", - tombstone = true - ) - ) - ) - ) - - // RTLM16d - Merge initial data from create operation - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(3, liveMap.data.size) // Should have all entries - assertTrue(liveMap.data["key1"]?.isTombstoned == true) // RTLM23a2 - Should be tombstoned - assertEquals(expectedTimestamp, liveMap.data["key1"]?.tombstonedAt) // Should use provided serialTimestamp - assertEquals("newValue", liveMap.data["key2"]?.data?.string) // RTLM23a1 - Should be added - assertTrue(liveMap.data["key3"]?.isTombstoned == true) // RTLM23a2 - Should be tombstoned - assertTrue(liveMap.createOperationIsMerged) // RTLM23b - Should be marked as merged - } - - @Test - fun `(RTLM15, RTLM15d2, RTLM7) LiveMapManager should apply map set operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "oldValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM15d2 - Apply map set operation - liveMapManager.applyOperation(operation, "serial2", null) - - assertEquals("newValue", liveMap.data["key1"]?.data?.string) // RTLM7a2a - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // RTLM7a2b - assertFalse(liveMap.data["key1"]?.isTombstoned == true) // RTLM7a2c - } - - @Test - fun `(RTLM15, RTLM15d3, RTLM8) LiveMapManager should apply map remove operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "value1") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "key1") - ) - - val expectedTimestamp = 1234567890L - // RTLM15d3 - Apply map remove operation with provided timestamp - liveMapManager.applyOperation(operation, "serial2", expectedTimestamp) - - assertNull(liveMap.data["key1"]?.data) // RTLM8a2a - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // RTLM8a2b - assertTrue(liveMap.data["key1"]?.isTombstoned == true) // RTLM8a2c - assertEquals(expectedTimestamp, liveMap.data["key1"]?.tombstonedAt) // RTLM8c3 - Should use provided timestamp - } - - @Test - fun `(RTLM8, RTLM8c3, OME2d) LiveMapManager should use current time when no timestamp provided for map remove operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "value1") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "key1") - ) - - val beforeOperation = System.currentTimeMillis() - // RTLM8c3 - Apply map remove operation without timestamp (should use current time) - liveMapManager.applyOperation(operation, "serial2", null) - val afterOperation = System.currentTimeMillis() - - assertNull(liveMap.data["key1"]?.data) // RTLM8a2a - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // RTLM8a2b - assertTrue(liveMap.data["key1"]?.isTombstoned == true) // RTLM8a2c - assertNotNull(liveMap.data["key1"]?.tombstonedAt) // Should have timestamp - assertTrue(liveMap.data["key1"]?.tombstonedAt!! >= beforeOperation) // Should be after operation start - assertTrue(liveMap.data["key1"]?.tombstonedAt!! <= afterOperation) // Should be before operation end - } - - @Test - fun `(RTLM15d1b) LiveMapManager applyOperation returns true for MAP_CREATE`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate(semantics = ObjectsMapSemantics.LWW, entries = emptyMap()) - ) - - // RTLM15d1b - Should return true for successful MAP_CREATE - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertTrue(result, "applyOperation should return true for MAP_CREATE") - } - - @Test - fun `(RTLM15d2b) LiveMapManager applyOperation returns true for MAP_SET`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet(key = "key1", value = ObjectData(string = "value1")) - ) - - // RTLM15d2b - Should return true for successful MAP_SET - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertTrue(result, "applyOperation should return true for MAP_SET") - } - - @Test - fun `(RTLM15d3b) LiveMapManager applyOperation returns true for MAP_REMOVE`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "key1") - ) - - // RTLM15d3b - Should return true for successful MAP_REMOVE - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertTrue(result, "applyOperation should return true for MAP_REMOVE") - } - - @Test - fun `(RTLM15d5b) LiveMapManager applyOperation returns true for OBJECT_DELETE`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.ObjectDelete, - objectId = "map:testMap@1", - ) - - // RTLM15d5b - Should return true for OBJECT_DELETE (tombstone) - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertTrue(result, "applyOperation should return true for OBJECT_DELETE") - assertTrue(liveMap.isTombstoned, "map should be tombstoned after ObjectDelete") - } - - @Test - fun `(RTLM15, RTLM15d4) LiveMapManager should return false for unsupported action`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.CounterCreate, // Unsupported action for map - objectId = "map:testMap@1", - counterCreate = io.ably.lib.objects.CounterCreate(count = 20.0) - ) - - // RTLM15d4 - Should return false for unsupported action (no longer throws) - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertFalse(result, "Should return false for unsupported action") - } - - @Test - fun `(RTLM16, RTLM16b) LiveMapManager should skip map create operation if already merged`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set create operation as already merged - liveMap.createOperationIsMerged = true - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "value1"), - timeserial = "serial1" - ) - ) - ) - ) - - // RTLM16b - Should skip if already merged - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(0, liveMap.data.size) // Should not change (still empty) - assertTrue(liveMap.createOperationIsMerged) // Should remain merged - } - - - - @Test - fun `(RTLM16, RTLM16d, RTLM23) LiveMapManager should merge initial data from create operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key1" to ObjectsMapEntry( - data = ObjectData(string = "createValue"), - timeserial = "serial2" - ), - "key2" to ObjectsMapEntry( - data = ObjectData(string = "newValue"), - timeserial = "serial3" - ), - "key3" to ObjectsMapEntry( - data = null, - timeserial = "serial4", - tombstone = true - ) - ) - ) - ) - - // RTLM16d - Merge initial data from create operation - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(3, liveMap.data.size) // Should have all entries - assertEquals("createValue", liveMap.data["key1"]?.data?.string) // RTLM23a1 - Should be updated - assertEquals("newValue", liveMap.data["key2"]?.data?.string) // RTLM23a1 - Should be added - assertTrue(liveMap.data["key3"]?.isTombstoned == true) // RTLM23a2 - Should be tombstoned - assertTrue(liveMap.createOperationIsMerged) // RTLM23b - Should be marked as merged - } - - @Test - fun `(RTLM7, RTLM7b) LiveMapManager should create new entry for map set operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "newKey", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM7b - Create new entry - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(1, liveMap.data.size) // Should have one entry - assertEquals("newValue", liveMap.data["newKey"]?.data?.string) // RTLM7b1 - assertEquals("serial1", liveMap.data["newKey"]?.timeserial) // Should have serial - assertFalse(liveMap.data["newKey"]?.isTombstoned == true) // RTLM7b2 - } - - @Test - fun `(RTLM7, RTLM7a) LiveMapManager should skip map set operation with lower serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with higher serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial2", // Higher than "serial1" - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM7a - Should skip operation with lower serial - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals("existingValue", liveMap.data["key1"]?.data?.string) // Should not change - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should keep original serial - } - - @Test - fun `(RTLM8, RTLM8b) LiveMapManager should create tombstoned entry for map remove operation`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "nonExistingKey") - ) - - // RTLM8b - Create tombstoned entry for non-existing key - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals(1, liveMap.data.size) // Should have one entry - assertNull(liveMap.data["nonExistingKey"]?.data) // RTLM8b1 - assertEquals("serial1", liveMap.data["nonExistingKey"]?.timeserial) // Should have serial - assertTrue(liveMap.data["nonExistingKey"]?.isTombstoned == true) // RTLM8b2 - } - - @Test - fun `(RTLM8, RTLM8a) LiveMapManager should skip map remove operation with lower serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with higher serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial2", // Higher than "serial1" - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "key1") - ) - - // RTLM8a - Should skip operation with lower serial - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals("existingValue", liveMap.data["key1"]?.data?.string) // Should not change - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should keep original serial - assertFalse(liveMap.data["key1"]?.isTombstoned == true) // Should not be tombstoned - } - - @Test - fun `(RTLM9, RTLM9b) LiveMapManager should handle null serials correctly`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with null serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = null, - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM9b - Both null serials should be treated as equal - liveMapManager.applyOperation(operation, null, null) - - assertEquals("existingValue", liveMap.data["key1"]?.data?.string) // Should not change - } - - @Test - fun `(RTLM9, RTLM9d) LiveMapManager should apply operation with serial when entry has null serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with null serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = null, - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM9d - Operation serial is greater than missing entry serial - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals("newValue", liveMap.data["key1"]?.data?.string) // Should be updated - assertEquals("serial1", liveMap.data["key1"]?.timeserial) // Should have new serial - } - - @Test - fun `(RTLM9, RTLM9c) LiveMapManager should skip operation with null serial when entry has serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM9c - Missing operation serial is lower than existing entry serial - liveMapManager.applyOperation(operation, null, null) - - assertEquals("existingValue", liveMap.data["key1"]?.data?.string) // Should not change - assertEquals("serial1", liveMap.data["key1"]?.timeserial) // Should keep original serial - } - - @Test - fun `(RTLM9, RTLM9e) LiveMapManager should apply operation with higher serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with lower serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM9e - Higher serial should be applied - liveMapManager.applyOperation(operation, "serial2", null) - - assertEquals("newValue", liveMap.data["key1"]?.data?.string) // Should be updated - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should have new serial - } - - @Test - fun `(RTLM9, RTLM9e) LiveMapManager should skip operation with lower serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data with higher serial - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial2", - data = ObjectData(string = "existingValue") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet( - key = "key1", - value = ObjectData(string = "newValue") - ) - ) - - // RTLM9e - Lower serial should be skipped - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals("existingValue", liveMap.data["key1"]?.data?.string) // Should not change - assertEquals("serial2", liveMap.data["key1"]?.timeserial) // Should keep original serial - } - - @Test - fun `(RTLM16, RTLM16c) DefaultLiveMap should throw error for mismatched semantics`() { - val liveMap = getDefaultLiveMapWithMockedDeps("map:testMap@1") - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.Unknown, // This should match, but we'll test error case - entries = emptyMap() - ) - ) - - val exception = assertFailsWith { - liveMapManager.applyOperation(operation, "serial1", null) - } - - val errorInfo = exception.errorInfo - kotlin.test.assertNotNull(errorInfo, "Error info should not be null") // RTLM16c - - // Assert on error codes - kotlin.test.assertEquals(92000, exception.errorInfo?.code) // InvalidObject error code - kotlin.test.assertEquals(500, exception.errorInfo?.statusCode) // InternalServerError status code - } - - @Test - fun shouldCalculateMapDifferenceCorrectly() { - // Test case 1: No changes - val prevData1 = mapOf() - val newData1 = mapOf() - val result1 = livemapManager.calculateUpdateFromDataDiff(prevData1, newData1) - assertEquals(emptyMap(), result1.update, "Should return empty map for no changes") - - // Test case 2: Entry added - val prevData2 = mapOf() - val newData2 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ) - ) - val result2 = livemapManager.calculateUpdateFromDataDiff(prevData2, newData2) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result2.update, "Should detect added entry") - - // Test case 3: Entry removed - val prevData3 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ) - ) - val newData3 = mapOf() - val result3 = livemapManager.calculateUpdateFromDataDiff(prevData3, newData3) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.REMOVED), result3.update, "Should detect removed entry") - - // Test case 4: Entry updated - val prevData4 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ) - ) - val newData4 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "2", - data = ObjectData(string = "value2") - ) - ) - val result4 = livemapManager.calculateUpdateFromDataDiff(prevData4, newData4) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result4.update, "Should detect updated entry") - - // Test case 5: Entry tombstoned - val prevData5 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ) - ) - val newData5 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = true, - timeserial = "2", - data = null - ) - ) - val result5 = livemapManager.calculateUpdateFromDataDiff(prevData5, newData5) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.REMOVED), result5.update, "Should detect tombstoned entry") - - // Test case 6: Entry untombstoned - val prevData6 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = true, - timeserial = "1", - data = null - ) - ) - val newData6 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "2", - data = ObjectData(string = "value1") - ) - ) - val result6 = livemapManager.calculateUpdateFromDataDiff(prevData6, newData6) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result6.update, "Should detect untombstoned entry") - - // Test case 7: Both entries tombstoned (noop) - val prevData7 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = true, - timeserial = "1", - data = null - ) - ) - val newData7 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = true, - timeserial = "2", - data = ObjectData(string = "value1") - ) - ) - val result7 = livemapManager.calculateUpdateFromDataDiff(prevData7, newData7) - assertEquals(emptyMap(), result7.update, "Should not detect change for both tombstoned entries") - - // Test case 8: New tombstoned entry (noop) - val prevData8 = mapOf() - val newData8 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = true, - timeserial = "1", - data = null - ) - ) - val result8 = livemapManager.calculateUpdateFromDataDiff(prevData8, newData8) - assertEquals(emptyMap(), result8.update, "Should not detect change for new tombstoned entry") - - // Test case 9: Multiple changes - val prevData9 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ), - "key2" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value2") - ) - ) - val newData9 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "2", - data = ObjectData(string = "value1_updated") - ), - "key3" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value3") - ) - ) - val result9 = livemapManager.calculateUpdateFromDataDiff(prevData9, newData9) - val expected9 = mapOf( - "key1" to LiveMapUpdate.Change.UPDATED, - "key2" to LiveMapUpdate.Change.REMOVED, - "key3" to LiveMapUpdate.Change.UPDATED - ) - assertEquals(expected9, result9.update, "Should detect multiple changes correctly") - - // Test case 10: ObjectId references - val prevData10 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(objectId = "obj1") - ) - ) - val newData10 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(objectId = "obj2") - ) - ) - val result10 = livemapManager.calculateUpdateFromDataDiff(prevData10, newData10) - assertEquals(mapOf("key1" to LiveMapUpdate.Change.UPDATED), result10.update, "Should detect objectId change") - - // Test case 11: Same data, no change - val prevData11 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "value1") - ) - ) - val newData11 = mapOf( - "key1" to LiveMapEntry( - isTombstoned = false, - timeserial = "2", - data = ObjectData(string = "value1") - ) - ) - val result11 = livemapManager.calculateUpdateFromDataDiff(prevData11, newData11) - assertEquals(emptyMap(), result11.update, "Should not detect change for same data") - } - - @Test - fun `(RTLM6, OM2j) DefaultLiveMap should handle tombstone with serialTimestamp in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val expectedTimestamp = 1234567890L - val objectState = ObjectState( - objectId = "map:testMap@1", - map = null, // Null map for tombstone - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = true, // Object is tombstoned - ) - - val update = liveMapManager.applyState(objectState, expectedTimestamp) - - assertTrue(liveMap.isTombstoned) // Should be tombstoned - assertEquals(expectedTimestamp, liveMap.TombstonedAt) // Should use provided timestamp - assertEquals(0, liveMap.data.size) // Should be empty after tombstone - - // Assert on update field - should show that key1 was removed - val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM6, OM2j) DefaultLiveMap should handle tombstone without serialTimestamp in state`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // Set initial data - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "1", - data = ObjectData(string = "oldValue") - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = null, // Null map for tombstone - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = true, // Object is tombstoned - ) - - val beforeOperation = System.currentTimeMillis() - val update = liveMapManager.applyState(objectState, null) - val afterOperation = System.currentTimeMillis() - - assertTrue(liveMap.isTombstoned) // Should be tombstoned - assertNotNull(liveMap.TombstonedAt) // Should have timestamp - assertTrue(liveMap.TombstonedAt!! >= beforeOperation) // Should be after operation start - assertTrue(liveMap.TombstonedAt!! <= afterOperation) // Should be before operation end - assertEquals(0, liveMap.data.size) // Should be empty after tombstone - - // Assert on update field - should show that key1 was removed - val expectedUpdate = mapOf("key1" to LiveMapUpdate.Change.REMOVED) - assertEquals(expectedUpdate, update.update) - } - - @Test - fun `(RTLM24) applyMapClear removes entries older than clear serial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "value1") - ) - liveMap.data["key2"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial3", - data = ObjectData(string = "value2") - ) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapClear, - objectId = "map:testMap@1", - mapClear = MapClear - ) - - // Apply MAP_CLEAR with serial "serial2" — between serial1 and serial3 - liveMapManager.applyOperation(operation, "serial2", null) - - assertNull(liveMap.data["key1"], "Entry at serial1 should be removed") - assertNotNull(liveMap.data["key2"], "Entry at serial3 should be kept") - assertEquals("serial2", liveMap.clearTimeserial) - } - - @Test - fun `(RTLM24c) applyMapClear skips when existing clearTimeserial is newer`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial1", - data = ObjectData(string = "value1") - ) - liveMap.clearTimeserial = "serial3" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapClear, - objectId = "map:testMap@1", - mapClear = MapClear - ) - - liveMapManager.applyOperation(operation, "serial2", null) - - // clearTimeserial should remain unchanged and data should be untouched - assertEquals("serial3", liveMap.clearTimeserial) - assertNotNull(liveMap.data["key1"], "Entry should not be removed") - } - - @Test - fun `(RTLM25) clearTimeserial is set after MAP_CLEAR`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - assertNull(liveMap.clearTimeserial) - - val operation = ObjectOperation( - action = ObjectOperationAction.MapClear, - objectId = "map:testMap@1", - mapClear = MapClear - ) - - liveMapManager.applyOperation(operation, "serial1", null) - - assertEquals("serial1", liveMap.clearTimeserial) - } - - @Test - fun `(RTLM7h) applyMapSet skips when op serial is less than or equal to clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.clearTimeserial = "serial2" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet(key = "key1", value = ObjectData(string = "value1")) - ) - - liveMapManager.applyOperation(operation, "serial1", null) - - assertNull(liveMap.data["key1"], "Entry should NOT be added when op serial <= clearTimeserial") - } - - @Test - fun `(RTLM7h) applyMapSet applies when op serial is greater than clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.clearTimeserial = "serial1" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapSet, - objectId = "map:testMap@1", - mapSet = MapSet(key = "key1", value = ObjectData(string = "value1")) - ) - - liveMapManager.applyOperation(operation, "serial2", null) - - assertNotNull(liveMap.data["key1"], "Entry should be added when op serial > clearTimeserial") - assertEquals("value1", liveMap.data["key1"]?.data?.string) - } - - @Test - fun `(RTLM8g) applyMapRemove skips when op serial is less than or equal to clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.data["key1"] = LiveMapEntry( - isTombstoned = false, - timeserial = "serial3", - data = ObjectData(string = "value1") - ) - liveMap.clearTimeserial = "serial2" - - val operation = ObjectOperation( - action = ObjectOperationAction.MapRemove, - objectId = "map:testMap@1", - mapRemove = MapRemove(key = "key1") - ) - - liveMapManager.applyOperation(operation, "serial1", null) - - assertFalse(liveMap.data["key1"]?.isTombstoned == true, "Entry should NOT be tombstoned when op serial <= clearTimeserial") - } - - @Test - fun `(RTLM6i) applyState sets clearTimeserial from objectState`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap(), - clearTimeserial = "serial1" - ), - siteTimeserials = emptyMap(), - tombstone = false, - ) - - liveMapManager.applyState(objectState, null) - - assertEquals("serial1", liveMap.clearTimeserial) - } - - @Test - fun `(RTLM6i) applyState resets clearTimeserial to null when objectState has no clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - liveMap.clearTimeserial = "serial1" - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap(), - clearTimeserial = null - ), - siteTimeserials = emptyMap(), - tombstone = false, - ) - - liveMapManager.applyState(objectState, null) - - assertNull(liveMap.clearTimeserial) - } - - @Test - fun `(RTLM6i, RTLM6d, RTLM7h) applyState filters createOp entries older than or equal to clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - // createOp has three entries: - // key-null-serial — no timeserial (treated as pre-clear by RTLM7h) - // key-old-serial — serial1, strictly older than the clear serial (serial2) - // key-new-serial — serial3, strictly newer than the clear serial (serial2) - val createOp = ObjectOperation( - action = ObjectOperationAction.MapCreate, - objectId = "map:testMap@1", - mapCreate = MapCreate( - semantics = ObjectsMapSemantics.LWW, - entries = mapOf( - "key-null-serial" to ObjectsMapEntry( - data = ObjectData(string = "nullSerialValue"), - timeserial = null - ), - "key-old-serial" to ObjectsMapEntry( - data = ObjectData(string = "oldSerialValue"), - timeserial = "serial1" - ), - "key-new-serial" to ObjectsMapEntry( - data = ObjectData(string = "newSerialValue"), - timeserial = "serial3" - ) - ) - ) - ) - - val objectState = ObjectState( - objectId = "map:testMap@1", - map = ObjectsMap( - semantics = ObjectsMapSemantics.LWW, - entries = emptyMap(), - clearTimeserial = "serial2" // RTLM6i: set before createOp entries are merged - ), - createOp = createOp, - siteTimeserials = mapOf("site1" to "serial1"), - tombstone = false, - ) - - liveMapManager.applyState(objectState, null) - - // RTLM7h: entries with null or older-than-clear serials must be filtered out - assertNull(liveMap.data["key-null-serial"], "Entry with null serial should be filtered by RTLM7h") - assertNull(liveMap.data["key-old-serial"], "Entry with serial1 <= clearTimeserial serial2 should be filtered by RTLM7h") - // Entry whose serial is strictly newer than clearTimeserial must survive - assertNotNull(liveMap.data["key-new-serial"], "Entry with serial3 > clearTimeserial serial2 should be present") - assertEquals("newSerialValue", liveMap.data["key-new-serial"]?.data?.string) - } - - @Test - fun `(RTLM4) clearData resets clearTimeserial`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - - liveMap.clearTimeserial = "serial1" - liveMap.clearData() - - assertNull(liveMap.clearTimeserial) - } - - @Test - fun `(RTLM15d8) applyOperation returns true for MAP_CLEAR`() { - val liveMap = getDefaultLiveMapWithMockedDeps() - val liveMapManager = liveMap.LiveMapManager - - val operation = ObjectOperation( - action = ObjectOperationAction.MapClear, - objectId = "map:testMap@1", - mapClear = MapClear - ) - - val result = liveMapManager.applyOperation(operation, "serial1", null) - assertTrue(result, "applyOperation should return true for MAP_CLEAR") - } -}