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 f1e77a7c5..b51e79c9e 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,7 +21,9 @@ okhttp = "4.12.0" test-retry = "1.6.0" kotlin = "2.1.10" coroutine = "1.9.0" +mockk = "1.14.2" turbine = "1.2.0" +ktor = "3.1.0" jetbrains-annoations = "26.0.2" [libraries] @@ -47,12 +49,16 @@ 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" } +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", "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 745a9a47c..6aa0d641d 100644 --- a/live-objects/build.gradle.kts +++ b/live-objects/build.gradle.kts @@ -1,3 +1,5 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + plugins { `java-library` alias(libs.plugins.kotlin.jvm) @@ -9,14 +11,38 @@ repositories { dependencies { implementation(project(":java")) - testImplementation(kotlin("test")) implementation(libs.coroutine.core) - testImplementation(libs.coroutine.test) + 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.*") + exclude("**/UnitTest.class") + } } -tasks.test { - useJUnitPlatform() +tasks.register("runLiveObjectIntegrationTests") { + filter { + includeTestsMatching("io.ably.lib.objects.integration.*") + exclude("**/IntegrationTest.class") // Exclude the base integration test class + } } 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/TestUtils.kt b/live-objects/src/test/kotlin/io/ably/lib/objects/TestUtils.kt new file mode 100644 index 000000000..16dc44495 --- /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?): 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 + @Suppress("UNCHECKED_CAST") + return method?.invoke(this, *args) as T +} 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..46a831b78 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/LiveObjectTest.kt @@ -0,0 +1,17 @@ +package io.ably.lib.objects.integration + +import io.ably.lib.objects.integration.setup.IntegrationTest +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertNotNull + +open class LiveObjectTest : IntegrationTest() { + + @Test + 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..57ca28476 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/integration/setup/IntegrationTest.kt @@ -0,0 +1,93 @@ +package io.ably.lib.objects.integration.setup + +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.Channel +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. + */ + 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() } + } + 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. + */ + 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 + + @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..0e1827ec2 --- /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") + } + + 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 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() +} 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..9d6ed9ad4 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/LiveObjectTest.kt @@ -0,0 +1,15 @@ +package io.ably.lib.objects.unit + +import io.ably.lib.objects.unit.setup.UnitTest +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertNotNull + +class LiveObjectTest : UnitTest() { + @Test + 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..e831978e2 --- /dev/null +++ b/live-objects/src/test/kotlin/io/ably/lib/objects/unit/setup/UnitTest.kt @@ -0,0 +1,48 @@ +package io.ably.lib.objects.unit.setup + +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() + } +}