From f61cc6ad2aa546dbb083b4e6a285511e136b73b6 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 28 May 2025 17:38:36 +0530 Subject: [PATCH 1/5] [ECO-5338] Created tests package for liveobjects 1. Added test specific dependencies, updated junit, added mockk 2. Added test utils for mocking private classes, fields etc --- gradle/libs.versions.toml | 4 +- live-objects/build.gradle.kts | 4 ++ .../kotlin/io/ably/lib/objects/TestUtils.kt | 61 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f1e77a7c5..5c4e4e8f3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "8.5.2" -junit = "4.12" +junit = "4.13.2" gson = "2.9.0" msgpack = "0.8.11" java-websocket = "1.5.3" @@ -21,6 +21,7 @@ okhttp = "4.12.0" test-retry = "1.6.0" kotlin = "2.1.10" coroutine = "1.9.0" +mockk = "1.13.13" turbine = "1.2.0" jetbrains-annoations = "26.0.2" @@ -47,6 +48,7 @@ android-retrostreams = { group = "net.sourceforge.streamsupport", name = "androi okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } coroutine-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutine" } coroutine-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutine" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } jetbrains = { group = "org.jetbrains", name = "annotations", version.ref = "jetbrains-annoations" } diff --git a/live-objects/build.gradle.kts b/live-objects/build.gradle.kts index 745a9a47c..ba27c6481 100644 --- a/live-objects/build.gradle.kts +++ b/live-objects/build.gradle.kts @@ -12,7 +12,11 @@ dependencies { testImplementation(kotlin("test")) implementation(libs.coroutine.core) + testImplementation(libs.junit) + testImplementation(libs.mockk) testImplementation(libs.coroutine.test) + testImplementation(libs.nanohttpd) + testImplementation(libs.turbine) } tasks.test { diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt new file mode 100644 index 000000000..ec38a491a --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt @@ -0,0 +1,61 @@ +package io.ably.lib.objects + +import java.lang.reflect.Field +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout + +suspend fun assertWaiter(timeoutInMs: Long = 10_000, block: suspend () -> Boolean) { + withContext(Dispatchers.Default) { + withTimeout(timeoutInMs) { + do { + val success = block() + delay(100) + } while (!success) + } + } +} + +fun Any.setPrivateField(name: String, value: Any?) { + val valueField = javaClass.findField(name) + valueField.isAccessible = true + valueField.set(this, value) +} + +fun Any.getPrivateField(name: String): T { + val valueField = javaClass.findField(name) + valueField.isAccessible = true + @Suppress("UNCHECKED_CAST") + return valueField.get(this) as T +} + +private fun Class<*>.findField(name: String): Field { + var result = kotlin.runCatching { getDeclaredField(name) } + var currentClass = this + while (result.isFailure && currentClass.superclass != null) // stop when we got field or reached top of class hierarchy + { + currentClass = currentClass.superclass!! + result = kotlin.runCatching { currentClass.getDeclaredField(name) } + } + if (result.isFailure) { + throw result.exceptionOrNull() as Exception + } + return result.getOrNull() as Field +} + +suspend fun Any.invokePrivateSuspendMethod(methodName: String, vararg args: Any?) = suspendCancellableCoroutine { cont -> + val suspendMethod = javaClass.declaredMethods.find { it.name == methodName } + suspendMethod?.let { + it.isAccessible = true + it.invoke(this, *args, cont) + } +} + +fun Any.invokePrivateMethod(methodName: String, vararg args: Any?): T { + val method = javaClass.declaredMethods.find { it.name == methodName } + method?.isAccessible = true + @Suppress("UNCHECKED_CAST") + return method?.invoke(this, *args) as T +} From 0b44a36fa07a3cd35820cc2ffadcbc0eb23ecadb Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 29 May 2025 17:54:02 +0530 Subject: [PATCH 2/5] [ECO-5338] Created unit/integration test package for liveobjects 1. Updated separate gradlew task for both unit and integration tests 2. Updated check.yml and integration-test.yml file to run respective tests --- .github/workflows/check.yml | 2 +- .github/workflows/integration-test.yml | 18 +++++++++ gradle/libs.versions.toml | 1 + live-objects/build.gradle.kts | 38 +++++++++++++++---- .../lib/objects/integration/LiveObjectTest.kt | 14 +++++++ .../ably/lib/objects/unit/LiveObjectTest.kt | 14 +++++++ 6 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/unit/LiveObjectTest.kt diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 74a90dbfd..0ae15c491 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -19,4 +19,4 @@ jobs: distribution: 'temurin' - name: Set up Gradle uses: gradle/actions/setup-gradle@v3 - - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests + - run: ./gradlew checkWithCodenarc checkstyleMain checkstyleTest runUnitTests runLiveObjectUnitTests diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 8ec98e980..89368a8a8 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -90,3 +90,21 @@ jobs: uses: gradle/actions/setup-gradle@v3 - run: ./gradlew :java:testRealtimeSuite -Pokhttp + + check-liveobjects: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up the JDK + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - run: ./gradlew runLiveObjectIntegrationTests diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5c4e4e8f3..6eead9116 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,7 @@ jetbrains = { group = "org.jetbrains", name = "annotations", version.ref = "jetb [bundles] common = ["msgpack", "vcdiff-core"] tests = ["junit", "hamcrest-all", "nanohttpd", "nanohttpd-nanolets", "nanohttpd-websocket", "mockito-core", "concurrentunit", "slf4j-simple"] +kotlin-tests = ["junit", "mockk", "coroutine-test", "nanohttpd", "turbine"] instrumental-android = ["android-test-runner", "android-test-rules", "dexmaker", "dexmaker-dx", "dexmaker-mockito", "android-retrostreams"] [plugins] diff --git a/live-objects/build.gradle.kts b/live-objects/build.gradle.kts index ba27c6481..14d1d744f 100644 --- a/live-objects/build.gradle.kts +++ b/live-objects/build.gradle.kts @@ -1,6 +1,9 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + plugins { `java-library` alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.test.retry) } repositories { @@ -9,18 +12,37 @@ repositories { dependencies { implementation(project(":java")) - testImplementation(kotlin("test")) implementation(libs.coroutine.core) - testImplementation(libs.junit) - testImplementation(libs.mockk) - testImplementation(libs.coroutine.test) - testImplementation(libs.nanohttpd) - testImplementation(libs.turbine) + testImplementation(kotlin("test")) + testImplementation(libs.bundles.kotlin.tests) +} + +tasks.withType().configureEach { + testLogging { + exceptionFormat = TestExceptionFormat.FULL + } + jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED") + jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") + beforeTest(closureOf { logger.lifecycle("-> $this") }) + outputs.upToDateWhen { false } + // Skip tests for the "release" build type so we don't run tests twice + if (name.lowercase().contains("release")) { + enabled = false + } +} + +tasks.register("runLiveObjectUnitTests") { + filter { + includeTestsMatching("io.ably.lib.objects.unit.*") + } } -tasks.test { - useJUnitPlatform() +tasks.register("runLiveObjectIntegrationTests") { + filter { + includeTestsMatching("io.ably.lib.objects.integration.*") + } + // TODO - check if we need retry mechanism for integration tests in the future } kotlin { diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt new file mode 100644 index 000000000..312e4bba3 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt @@ -0,0 +1,14 @@ +package io.ably.lib.objects.integration + +import org.junit.Assert.assertTrue +import org.junit.Test + +class LiveObjectTest { + + @Test + fun testLiveObjectCreationIntegrationTest() { + // This is a placeholder for the actual test implementation. + // You would typically create instances of LiveObjects and perform assertions here. + assertTrue(true) + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/LiveObjectTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/LiveObjectTest.kt new file mode 100644 index 000000000..5560b0d19 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/LiveObjectTest.kt @@ -0,0 +1,14 @@ +package io.ably.lib.objects.unit + +import org.junit.Assert.assertTrue +import org.junit.Test + +class LiveObjectTest { + + @Test + fun testLiveObjectCreationUnitTest() { + // This is a placeholder for the actual test implementation. + // You would typically create instances of LiveObjects and perform assertions here. + assertTrue(true) + } +} From af9deda03e07e11f4dc5fa2611a623bcc311896c Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 30 May 2025 18:32:38 +0530 Subject: [PATCH 3/5] [ECO-5338[Integration test] Added ably sandbox script 1. Added ktor http dependencies and updated kotlin tests bundle 2. Added IntegrationTest parameterized class to run tests for both msgpack and json 3. Updated LiveObjectTest extending IntegrationTest class --- gradle/libs.versions.toml | 5 +- .../io/ably/lib/realtime/AblyRealtime.java | 8 +- .../java/io/ably/lib/realtime/Connection.java | 5 +- .../ably/lib/transport/ConnectionManager.java | 15 +-- .../test/realtime/ConnectionManagerTest.java | 2 +- live-objects/build.gradle.kts | 3 +- .../kotlin/io/ably/lib/objects/ErrorCodes.kt | 11 ++ .../main/kotlin/io/ably/lib/objects/Utils.kt | 36 ++++++ .../lib/objects/integration/LiveObjectTest.kt | 15 ++- .../integration/setup/IntegrationTest.kt | 94 +++++++++++++++ .../lib/objects/integration/setup/Sandbox.kt | 107 ++++++++++++++++++ 11 files changed, 272 insertions(+), 29 deletions(-) create mode 100644 live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt create mode 100644 live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6eead9116..a2608172b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ kotlin = "2.1.10" coroutine = "1.9.0" mockk = "1.13.13" turbine = "1.2.0" +ktor = "3.0.1" jetbrains-annoations = "26.0.2" [libraries] @@ -50,12 +51,14 @@ coroutine-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-c coroutine-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutine" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } jetbrains = { group = "org.jetbrains", name = "annotations", version.ref = "jetbrains-annoations" } [bundles] common = ["msgpack", "vcdiff-core"] tests = ["junit", "hamcrest-all", "nanohttpd", "nanohttpd-nanolets", "nanohttpd-websocket", "mockito-core", "concurrentunit", "slf4j-simple"] -kotlin-tests = ["junit", "mockk", "coroutine-test", "nanohttpd", "turbine"] +kotlin-tests = ["junit", "mockk", "coroutine-test", "nanohttpd", "turbine", "ktor-client-cio", "ktor-client-core"] instrumental-android = ["android-test-runner", "android-test-rules", "dexmaker", "dexmaker-dx", "dexmaker-mockito", "android-retrostreams"] [plugins] 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 92e0fdbd8..9a317bdc3 100644 --- a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java +++ b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java @@ -48,7 +48,7 @@ public class AblyRealtime extends AblyRest { *

* This field is initialized only if the LiveObjects plugin is present in the classpath. */ - private final LiveObjectsPlugin liveObjectsPlugin; + public final LiveObjectsPlugin liveObjectsPlugin; /** * Constructs a Realtime client object using an Ably API key or token string. @@ -73,9 +73,7 @@ public AblyRealtime(ClientOptions options) throws AblyException { final InternalChannels channels = new InternalChannels(); this.channels = channels; - liveObjectsPlugin = tryInitializeLiveObjectsPlugin(); - - connection = new Connection(this, channels, platformAgentProvider, liveObjectsPlugin); + connection = new Connection(this, channels, platformAgentProvider); if (!StringUtils.isNullOrEmpty(options.recover)) { RecoveryKeyContext recoveryKeyContext = RecoveryKeyContext.decode(options.recover); @@ -85,6 +83,8 @@ public AblyRealtime(ClientOptions options) throws AblyException { } } + liveObjectsPlugin = tryInitializeLiveObjectsPlugin(); + if(options.autoConnect) connection.connect(); } 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..c1ca65c70 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,5 @@ package io.ably.lib.realtime; -import io.ably.lib.objects.LiveObjectsPlugin; import io.ably.lib.realtime.ConnectionStateListener.ConnectionStateChange; import io.ably.lib.transport.ConnectionManager; import io.ably.lib.types.AblyException; @@ -123,10 +122,10 @@ public void close() { * internal *****************/ - Connection(AblyRealtime ably, ConnectionManager.Channels channels, PlatformAgentProvider platformAgentProvider, LiveObjectsPlugin liveObjectsPlugin) throws AblyException { + Connection(AblyRealtime ably, ConnectionManager.Channels channels, PlatformAgentProvider platformAgentProvider) throws AblyException { this.ably = ably; this.state = ConnectionState.initialized; - this.connectionManager = new ConnectionManager(ably, this, channels, platformAgentProvider, liveObjectsPlugin); + this.connectionManager = new ConnectionManager(ably, this, channels, platformAgentProvider); } public void onConnectionStateChange(ConnectionStateChange stateChange) { 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 f6f20e9eb..3750c6027 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,6 @@ 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.plugins.PluginConnectionAdapter; import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.Channel; @@ -81,13 +80,6 @@ public class ConnectionManager implements ConnectListener, PluginConnectionAdapt */ private boolean cleaningUpAfterEnteringTerminalState = false; - /** - * A nullable reference to the LiveObjects plugin. - *

- * This field is initialized only if the LiveObjects plugin is present in the classpath. - */ - private final LiveObjectsPlugin liveObjectsPlugin; - /** * Methods on the channels map owned by the {@link AblyRealtime} instance * which the {@link ConnectionManager} needs access to. @@ -773,12 +765,11 @@ public void run() { * ConnectionManager ***********************/ - public ConnectionManager(final AblyRealtime ably, final Connection connection, final Channels channels, final PlatformAgentProvider platformAgentProvider, LiveObjectsPlugin liveObjectsPlugin) throws AblyException { + public ConnectionManager(final AblyRealtime ably, final Connection connection, final Channels channels, final PlatformAgentProvider platformAgentProvider) throws AblyException { this.ably = ably; this.connection = connection; this.channels = channels; this.platformAgentProvider = platformAgentProvider; - this.liveObjectsPlugin = liveObjectsPlugin; ClientOptions options = ably.options; this.hosts = new Hosts(options.realtimeHost, Defaults.HOST_REALTIME, options); @@ -1232,9 +1223,9 @@ public void onMessage(ITransport transport, ProtocolMessage message) throws Ably break; case object: case object_sync: - if (liveObjectsPlugin != null) { + if (ably.liveObjectsPlugin != null) { try { - liveObjectsPlugin.handle(message); + ably.liveObjectsPlugin.handle(message); } catch (Throwable t) { Log.e(TAG, "LiveObjectsPlugin threw while handling message", t); } diff --git a/lib/src/test/java/io/ably/lib/test/realtime/ConnectionManagerTest.java b/lib/src/test/java/io/ably/lib/test/realtime/ConnectionManagerTest.java index eaaaead97..383270153 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/ConnectionManagerTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/ConnectionManagerTest.java @@ -137,7 +137,7 @@ public void connectionmanager_fallback_none_withoutconnection() throws AblyExcep Connection connection = Mockito.mock(Connection.class); final ConnectionManager.Channels channels = Mockito.mock(ConnectionManager.Channels.class); - ConnectionManager connectionManager = new ConnectionManager(ably, connection, channels, new EmptyPlatformAgentProvider(), null) { + ConnectionManager connectionManager = new ConnectionManager(ably, connection, channels, new EmptyPlatformAgentProvider()) { @Override protected boolean checkConnectivity() { return false; diff --git a/live-objects/build.gradle.kts b/live-objects/build.gradle.kts index 14d1d744f..15b408c9c 100644 --- a/live-objects/build.gradle.kts +++ b/live-objects/build.gradle.kts @@ -3,7 +3,6 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat plugins { `java-library` alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.test.retry) } repositories { @@ -41,8 +40,8 @@ tasks.register("runLiveObjectUnitTests") { tasks.register("runLiveObjectIntegrationTests") { filter { includeTestsMatching("io.ably.lib.objects.integration.*") + exclude("**/IntegrationTest.class") // Exclude the base integration test class } - // TODO - check if we need retry mechanism for integration tests in the future } kotlin { diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt new file mode 100644 index 000000000..148b8abf4 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/ErrorCodes.kt @@ -0,0 +1,11 @@ +package io.ably.lib.objects + +internal enum class ErrorCode(public val code: Int) { + BadRequest(40_000), + InternalError(50_000), +} + +internal enum class HttpStatusCode(public val code: Int) { + BadRequest(400), + InternalServerError(500), +} diff --git a/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt b/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt new file mode 100644 index 000000000..930a35f84 --- /dev/null +++ b/live-objects/src/main/kotlin/io/ably/lib/objects/Utils.kt @@ -0,0 +1,36 @@ +package io.ably.lib.objects + +import io.ably.lib.types.AblyException +import io.ably.lib.types.ErrorInfo + +internal fun ablyException( + errorMessage: String, + errorCode: ErrorCode, + statusCode: HttpStatusCode = HttpStatusCode.BadRequest, + cause: Throwable? = null, +): AblyException { + val errorInfo = createErrorInfo(errorMessage, errorCode, statusCode) + return createAblyException(errorInfo, cause) +} + +internal fun ablyException( + errorInfo: ErrorInfo, + cause: Throwable? = null, +): AblyException = createAblyException(errorInfo, cause) + +private fun createErrorInfo( + errorMessage: String, + errorCode: ErrorCode, + statusCode: HttpStatusCode, +) = ErrorInfo(errorMessage, statusCode.code, errorCode.code) + +private fun createAblyException( + errorInfo: ErrorInfo, + cause: Throwable?, +) = cause?.let { AblyException.fromErrorInfo(it, errorInfo) } + ?: AblyException.fromErrorInfo(errorInfo) + +internal fun clientError(errorMessage: String) = ablyException(errorMessage, ErrorCode.BadRequest, HttpStatusCode.BadRequest) + +internal fun serverError(errorMessage: String) = ablyException(errorMessage, ErrorCode.InternalError, HttpStatusCode.InternalServerError) + diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt index 312e4bba3..46a831b78 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt @@ -1,14 +1,17 @@ package io.ably.lib.objects.integration -import org.junit.Assert.assertTrue +import io.ably.lib.objects.integration.setup.IntegrationTest +import kotlinx.coroutines.test.runTest import org.junit.Test +import kotlin.test.assertNotNull -class LiveObjectTest { +open class LiveObjectTest : IntegrationTest() { @Test - fun testLiveObjectCreationIntegrationTest() { - // This is a placeholder for the actual test implementation. - // You would typically create instances of LiveObjects and perform assertions here. - assertTrue(true) + fun testChannelObjectGetterTest() = runTest { + val channelName = generateChannelName() + val channel = getRealtimeChannel(channelName) + val objects = channel.objects + assertNotNull(objects) } } diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt new file mode 100644 index 000000000..39a9fd542 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt @@ -0,0 +1,94 @@ +package io.ably.lib.objects.integration.setup + +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.types.ClientOptions +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) +open class IntegrationTest { + @Parameterized.Parameter + lateinit var testParams: String + + @JvmField + @Rule + val timeout: Timeout = Timeout.seconds(10) + + 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. + * The channel is attached and ensured to be in the attached state before returning. + * + * @param channelName Name of the channel + * @param clientId The ID of the client to use or create. Defaults to "client1". + * @return The attached realtime channel. + * @throws Exception If the channel fails to attach or the client fails to connect. + */ + 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() } + } + return client.channels.get(channelName).apply { + attach() + ensureAttached() + } + } + + /** + * Generates a unique channel name for testing purposes. + * This is mainly to avoid channel name/state/history collisions across tests in same file. + */ + 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 + + @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() + } + } + + @JvmStatic + @AfterClass + @Throws(Exception::class) + fun tearDownAfterClass() { + } + } +} diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt new file mode 100644 index 000000000..4aab69e47 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt @@ -0,0 +1,107 @@ +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.realtime.* +import io.ably.lib.types.ClientOptions +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.network.sockets.ConnectTimeoutException +import io.ktor.client.network.sockets.SocketTimeoutException +import io.ktor.client.plugins.HttpRequestRetry +import io.ktor.client.plugins.HttpRequestTimeoutException +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import kotlinx.coroutines.CompletableDeferred +import java.nio.file.Files +import java.nio.file.Paths + +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 fun loadAppCreationJson(): JsonElement { + val filePath = Paths.get("../lib/src/test/resources/ably-common/test-resources/test-app-setup.json") + val fileContent = Files.readString(filePath) + return JsonParser.parseString(fileContent).asJsonObject.get("post_apps") + } + + 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 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() +} + +internal suspend fun Channel.ensureAttached() { + if (this.state == ChannelState.attached) { + return + } + val attachedDeferred = CompletableDeferred() + this.on { + if (it.event == ChannelEvent.attached) { + attachedDeferred.complete(Unit) + this.off() + } else if (it.event != ChannelEvent.attaching) { + attachedDeferred.completeExceptionally(ablyException(it.reason)) + this.off() + } + } + attachedDeferred.await() +} From 1adf275d1fb6292e58969e6e324ea3fb83f69614 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Mon, 2 Jun 2025 17:53:39 +0530 Subject: [PATCH 4/5] [ECO-5338][Unit test] Created unittest setup class for initial setup 1. Updated LiveObjectTest to extend UnitTest class 2. Marked test utility methods as internal --- live-objects/build.gradle.kts | 1 + .../integration/setup/IntegrationTest.kt | 5 +- .../lib/objects/integration/setup/Sandbox.kt | 2 +- .../ably/lib/objects/unit/LiveObjectTest.kt | 15 +++--- .../ably/lib/objects/unit/setup/UnitTest.kt | 49 +++++++++++++++++++ 5 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 live-objects/src/test/kotlin/io/ably/lib/objects/unit/setup/UnitTest.kt diff --git a/live-objects/build.gradle.kts b/live-objects/build.gradle.kts index 15b408c9c..6aa0d641d 100644 --- a/live-objects/build.gradle.kts +++ b/live-objects/build.gradle.kts @@ -34,6 +34,7 @@ tasks.withType().configureEach { tasks.register("runLiveObjectUnitTests") { filter { includeTestsMatching("io.ably.lib.objects.unit.*") + exclude("**/UnitTest.class") } } diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt index 39a9fd542..57ca28476 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt @@ -2,7 +2,6 @@ package io.ably.lib.objects.integration.setup import io.ably.lib.realtime.AblyRealtime import io.ably.lib.realtime.Channel -import io.ably.lib.types.ClientOptions import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.AfterClass @@ -34,7 +33,7 @@ open class IntegrationTest { * @return The attached realtime channel. * @throws Exception If the channel fails to attach or the client fails to connect. */ - suspend fun getRealtimeChannel(channelName: String, clientId: String = "client1"): Channel { + internal suspend fun getRealtimeChannel(channelName: String, clientId: String = "client1"): Channel { val client = realtimeClients.getOrPut(clientId) { sandbox.createRealtimeClient { this.clientId = clientId @@ -51,7 +50,7 @@ open class IntegrationTest { * Generates a unique channel name for testing purposes. * This is mainly to avoid channel name/state/history collisions across tests in same file. */ - fun generateChannelName(): String { + internal fun generateChannelName(): String { return "test-channel-${UUID.randomUUID()}" } diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt index 4aab69e47..0e1827ec2 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/Sandbox.kt @@ -45,7 +45,7 @@ class Sandbox private constructor(val appId: String, val apiKey: String) { return JsonParser.parseString(fileContent).asJsonObject.get("post_apps") } - suspend fun createInstance(): Sandbox { + internal suspend fun createInstance(): Sandbox { val response: HttpResponse = client.post("https://sandbox.realtime.ably-nonprod.net/apps") { contentType(ContentType.Application.Json) setBody(loadAppCreationJson().toString()) diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/LiveObjectTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/LiveObjectTest.kt index 5560b0d19..9d6ed9ad4 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/LiveObjectTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/LiveObjectTest.kt @@ -1,14 +1,15 @@ package io.ably.lib.objects.unit -import org.junit.Assert.assertTrue +import io.ably.lib.objects.unit.setup.UnitTest +import kotlinx.coroutines.test.runTest import org.junit.Test +import kotlin.test.assertNotNull -class LiveObjectTest { - +class LiveObjectTest : UnitTest() { @Test - fun testLiveObjectCreationUnitTest() { - // This is a placeholder for the actual test implementation. - // You would typically create instances of LiveObjects and perform assertions here. - assertTrue(true) + fun testChannelObjectGetterTest() = runTest { + val channel = getMockRealtimeChannel("test-channel") + val objects = channel.objects + assertNotNull(objects) } } diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/setup/UnitTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/setup/UnitTest.kt new file mode 100644 index 000000000..4666fb842 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/setup/UnitTest.kt @@ -0,0 +1,49 @@ +package io.ably.lib.objects.unit.setup + +import io.ably.lib.objects.integration.setup.ensureAttached +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.ClientOptions +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import org.junit.After +import org.junit.runner.RunWith +import org.junit.runners.BlockJUnit4ClassRunner + +// This class serves as a base for unit tests related to Live Objects. +// It can be extended to include common setup or utility methods for unit tests. +@RunWith(BlockJUnit4ClassRunner::class) +open class UnitTest { + + private val realtimeClients = mutableMapOf() + internal fun getMockRealtimeChannel(channelName: String, clientId: String = "client1"): Channel { + val client = realtimeClients.getOrPut(clientId) { + AblyRealtime(ClientOptions().apply { + autoConnect = false + key = "keyName:Value" + this.clientId = clientId + }) + } + val channel = client.channels.get(channelName) + 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 + } + } + + @After + fun afterEach() { + realtimeClients.clear() + } +} From 5d3316faa6f1f8d221e97b90c4d7814b0e8067fc Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Tue, 3 Jun 2025 14:46:46 +0530 Subject: [PATCH 5/5] [ECO-5338][Unit test] Upgraded dependencies as per review comments, optimized imports --- gradle/libs.versions.toml | 4 ++-- .../src/test/kotlin/io/ably/lib/objects/TestUtils.kt | 12 ++++++------ .../io/ably/lib/objects/unit/setup/UnitTest.kt | 1 - 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a2608172b..b51e79c9e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,9 +21,9 @@ okhttp = "4.12.0" test-retry = "1.6.0" kotlin = "2.1.10" coroutine = "1.9.0" -mockk = "1.13.13" +mockk = "1.14.2" turbine = "1.2.0" -ktor = "3.0.1" +ktor = "3.1.0" jetbrains-annoations = "26.0.2" [libraries] diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt index ec38a491a..16dc44495 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt @@ -45,14 +45,14 @@ private fun Class<*>.findField(name: String): Field { return result.getOrNull() as Field } -suspend fun Any.invokePrivateSuspendMethod(methodName: String, vararg args: Any?) = suspendCancellableCoroutine { cont -> - val suspendMethod = javaClass.declaredMethods.find { it.name == methodName } - suspendMethod?.let { - it.isAccessible = true - it.invoke(this, *args, cont) - } +suspend fun Any.invokePrivateSuspendMethod(methodName: String, vararg args: Any?): T = suspendCancellableCoroutine { cont -> + val suspendMethod = javaClass.declaredMethods.find { it.name == methodName } + ?: error("Method '$methodName' not found") + suspendMethod.isAccessible = true + suspendMethod.invoke(this, *args, cont) } + fun Any.invokePrivateMethod(methodName: String, vararg args: Any?): T { val method = javaClass.declaredMethods.find { it.name == methodName } method?.isAccessible = true diff --git a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/setup/UnitTest.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/setup/UnitTest.kt index 4666fb842..e831978e2 100644 --- a/live-objects/src/test/kotlin/io/ably/lib/objects/unit/setup/UnitTest.kt +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/setup/UnitTest.kt @@ -1,6 +1,5 @@ package io.ably.lib.objects.unit.setup -import io.ably.lib.objects.integration.setup.ensureAttached import io.ably.lib.realtime.AblyRealtime import io.ably.lib.realtime.Channel import io.ably.lib.realtime.ChannelState