diff --git a/chat-android/src/main/java/com/ably/chat/ErrorCodes.kt b/chat-android/src/main/java/com/ably/chat/ErrorCodes.kt index b0a00bdf..556e2729 100644 --- a/chat-android/src/main/java/com/ably/chat/ErrorCodes.kt +++ b/chat-android/src/main/java/com/ably/chat/ErrorCodes.kt @@ -84,6 +84,11 @@ enum class ErrorCode(val code: Int) { */ RoomReleasedBeforeOperationCompleted(102_106), + /** + * Room is not in valid state to perform any realtime operation. + */ + RoomInInvalidState(102_107), + /** * Cannot perform operation because the previous operation failed. */ diff --git a/chat-android/src/main/java/com/ably/chat/Occupancy.kt b/chat-android/src/main/java/com/ably/chat/Occupancy.kt index f8c68876..c2afa229 100644 --- a/chat-android/src/main/java/com/ably/chat/Occupancy.kt +++ b/chat-android/src/main/java/com/ably/chat/Occupancy.kt @@ -4,7 +4,6 @@ package com.ably.chat import com.google.gson.JsonObject import com.google.gson.JsonPrimitive -import io.ably.lib.realtime.AblyRealtime import io.ably.lib.realtime.Channel import java.util.concurrent.CopyOnWriteArrayList import kotlinx.coroutines.CoroutineScope @@ -73,11 +72,8 @@ data class OccupancyEvent( ) internal class DefaultOccupancy( - realtimeChannels: AblyRealtime.Channels, - private val chatApi: ChatApi, - private val roomId: String, - private val logger: Logger, -) : Occupancy, ContributesToRoomLifecycleImpl(logger) { + private val room: DefaultRoom, +) : Occupancy, ContributesToRoomLifecycleImpl(room.roomLogger) { override val featureName: String = "occupancy" @@ -85,8 +81,12 @@ internal class DefaultOccupancy( override val detachmentErrorCode: ErrorCode = ErrorCode.OccupancyDetachmentFailed + private val realtimeChannels = room.realtimeClient.channels + + private val logger = room.roomLogger.withContext(tag = "Occupancy") + // (CHA-O1) - private val messagesChannelName = "$roomId::\$chat::\$chatMessages" + private val messagesChannelName = "${room.roomId}::\$chat::\$chatMessages" override val channel: Channel = realtimeChannels.get( messagesChannelName, @@ -142,7 +142,7 @@ internal class DefaultOccupancy( // (CHA-O3) override suspend fun get(): OccupancyEvent { logger.trace("Occupancy.get()") - return chatApi.getOccupancy(roomId) + return room.chatApi.getOccupancy(room.roomId) } override fun release() { diff --git a/chat-android/src/main/java/com/ably/chat/Presence.kt b/chat-android/src/main/java/com/ably/chat/Presence.kt index 278a8a37..c7b8ed1c 100644 --- a/chat-android/src/main/java/com/ably/chat/Presence.kt +++ b/chat-android/src/main/java/com/ably/chat/Presence.kt @@ -6,7 +6,6 @@ import com.google.gson.JsonElement import com.google.gson.JsonObject import io.ably.lib.realtime.Channel import io.ably.lib.types.PresenceMessage -import io.ably.lib.realtime.Presence as PubSubPresence import io.ably.lib.realtime.Presence.PresenceListener as PubSubPresenceListener typealias PresenceData = JsonElement @@ -134,11 +133,8 @@ data class PresenceEvent( ) internal class DefaultPresence( - private val clientId: String, - override val channel: Channel, - private val presence: PubSubPresence, - private val logger: Logger, -) : Presence, ContributesToRoomLifecycleImpl(logger) { + private val room: DefaultRoom, +) : Presence, ContributesToRoomLifecycleImpl(room.roomLogger) { override val featureName = "presence" @@ -146,7 +142,14 @@ internal class DefaultPresence( override val detachmentErrorCode: ErrorCode = ErrorCode.PresenceDetachmentFailed + override val channel: Channel = room.messages.channel + + private val logger = room.roomLogger.withContext(tag = "Presence") + + private val presence = channel.presence + override suspend fun get(waitForSync: Boolean, clientId: String?, connectionId: String?): List { + room.ensureAttached() // CHA-PR6c, CHA-PR6h return presence.getCoroutine(waitForSync, clientId, connectionId).map { user -> PresenceMember( clientId = user.clientId, @@ -160,15 +163,18 @@ internal class DefaultPresence( override suspend fun isUserPresent(clientId: String): Boolean = presence.getCoroutine(clientId = clientId).isNotEmpty() override suspend fun enter(data: PresenceData?) { - presence.enterClientCoroutine(clientId, wrapInUserCustomData(data)) + room.ensureAttached() // CHA-PR3d, CHA-PR3h + presence.enterClientCoroutine(room.clientId, wrapInUserCustomData(data)) } override suspend fun update(data: PresenceData?) { - presence.updateClientCoroutine(clientId, wrapInUserCustomData(data)) + room.ensureAttached() // CHA-PR10d, CHA-PR10h + presence.updateClientCoroutine(room.clientId, wrapInUserCustomData(data)) } override suspend fun leave(data: PresenceData?) { - presence.leaveClientCoroutine(clientId, wrapInUserCustomData(data)) + room.ensureAttached() + presence.leaveClientCoroutine(room.clientId, wrapInUserCustomData(data)) } override fun subscribe(listener: Presence.Listener): Subscription { diff --git a/chat-android/src/main/java/com/ably/chat/Room.kt b/chat-android/src/main/java/com/ably/chat/Room.kt index ab70ed22..92405710 100644 --- a/chat-android/src/main/java/com/ably/chat/Room.kt +++ b/chat-android/src/main/java/com/ably/chat/Room.kt @@ -3,10 +3,12 @@ package com.ably.chat import io.ably.lib.types.ErrorInfo +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch /** * Represents a chat room. @@ -110,12 +112,12 @@ interface Room { internal class DefaultRoom( override val roomId: String, override val options: RoomOptions, - private val realtimeClient: RealtimeClient, - chatApi: ChatApi, - clientId: String, + internal val realtimeClient: RealtimeClient, + internal val chatApi: ChatApi, + internal val clientId: String, logger: Logger, ) : Room { - private val roomLogger = logger.withContext("Room", mapOf("roomId" to roomId)) + internal val roomLogger = logger.withContext("Room", mapOf("roomId" to roomId)) /** * RoomScope is a crucial part of the Room lifecycle. It manages sequential and atomic operations. @@ -184,46 +186,25 @@ internal class DefaultRoom( val roomFeatures = mutableListOf(messages) options.presence?.let { - val presenceContributor = DefaultPresence( - clientId = clientId, - channel = messages.channel, - presence = messages.channel.presence, - logger = roomLogger.withContext(tag = "Presence"), - ) + val presenceContributor = DefaultPresence(room = this) roomFeatures.add(presenceContributor) _presence = presenceContributor } options.typing?.let { - val typingContributor = DefaultTyping( - roomId = roomId, - realtimeClient = realtimeClient, - clientId = clientId, - options = options.typing, - logger = roomLogger.withContext(tag = "Typing"), - ) + val typingContributor = DefaultTyping(room = this) roomFeatures.add(typingContributor) _typing = typingContributor } options.reactions?.let { - val reactionsContributor = DefaultRoomReactions( - roomId = roomId, - clientId = clientId, - realtimeChannels = realtimeClient.channels, - logger = roomLogger.withContext(tag = "Reactions"), - ) + val reactionsContributor = DefaultRoomReactions(room = this) roomFeatures.add(reactionsContributor) _reactions = reactionsContributor } options.occupancy?.let { - val occupancyContributor = DefaultOccupancy( - roomId = roomId, - realtimeChannels = realtimeClient.channels, - chatApi = chatApi, - logger = roomLogger.withContext(tag = "Occupancy"), - ) + val occupancyContributor = DefaultOccupancy(room = this) roomFeatures.add(occupancyContributor) _occupancy = occupancyContributor } @@ -253,4 +234,40 @@ internal class DefaultRoom( internal suspend fun release() { lifecycleManager.release() } + + /** + * Ensures that the room is attached before performing any realtime room operation. + * @throws roomInvalidStateException if room is not in ATTACHING/ATTACHED state. + * Spec: CHA-RL9 + */ + internal suspend fun ensureAttached() { + // CHA-PR3d, CHA-PR10d, CHA-PR6c, CHA-PR6c + if (statusLifecycle.status == RoomStatus.Attached) { + return + } + if (statusLifecycle.status == RoomStatus.Attaching) { // CHA-RL9 + val attachDeferred = CompletableDeferred() + roomScope.launch { + when (statusLifecycle.status) { + RoomStatus.Attached -> attachDeferred.complete(Unit) + RoomStatus.Attaching -> statusLifecycle.onChangeOnce { + if (it.current == RoomStatus.Attached) { + attachDeferred.complete(Unit) + } else { + val exception = roomInvalidStateException(roomId, statusLifecycle.status, HttpStatusCode.InternalServerError) + attachDeferred.completeExceptionally(exception) + } + } + else -> { + val exception = roomInvalidStateException(roomId, statusLifecycle.status, HttpStatusCode.InternalServerError) + attachDeferred.completeExceptionally(exception) + } + } + } + attachDeferred.await() + return + } + // CHA-PR3h, CHA-PR10h, CHA-PR6h, CHA-T2g + throw roomInvalidStateException(roomId, statusLifecycle.status, HttpStatusCode.BadRequest) + } } diff --git a/chat-android/src/main/java/com/ably/chat/RoomReactions.kt b/chat-android/src/main/java/com/ably/chat/RoomReactions.kt index b3385224..a5ce191b 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomReactions.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomReactions.kt @@ -3,7 +3,6 @@ package com.ably.chat import com.google.gson.JsonObject -import io.ably.lib.realtime.AblyRealtime import io.ably.lib.types.AblyException import io.ably.lib.types.ErrorInfo import io.ably.lib.types.MessageExtras @@ -104,22 +103,21 @@ data class SendReactionParams( ) internal class DefaultRoomReactions( - roomId: String, - private val clientId: String, - private val realtimeChannels: AblyRealtime.Channels, - private val logger: Logger, -) : RoomReactions, ContributesToRoomLifecycleImpl(logger) { + private val room: DefaultRoom, +) : RoomReactions, ContributesToRoomLifecycleImpl(room.roomLogger) { override val featureName = "reactions" - private val roomReactionsChannelName = "$roomId::\$chat::\$reactions" + private val roomReactionsChannelName = "${room.roomId}::\$chat::\$reactions" - override val channel: AblyRealtimeChannel = realtimeChannels.get(roomReactionsChannelName, ChatChannelOptions()) + override val channel: AblyRealtimeChannel = room.realtimeClient.channels.get(roomReactionsChannelName, ChatChannelOptions()) override val attachmentErrorCode: ErrorCode = ErrorCode.ReactionsAttachmentFailed override val detachmentErrorCode: ErrorCode = ErrorCode.ReactionsDetachmentFailed + private val logger = room.roomLogger.withContext(tag = "Reactions") + // (CHA-ER3) Ephemeral room reactions are sent to Ably via the Realtime connection via a send method. // (CHA-ER3a) Reactions are sent on the channel using a message in a particular format - see spec for format. override suspend fun send(params: SendReactionParams) { @@ -137,6 +135,7 @@ internal class DefaultRoomReactions( ) } } + room.ensureAttached() // TODO - This check might be removed in the future due to core spec change channel.publishCoroutine(pubSubMessage) } @@ -154,7 +153,7 @@ internal class DefaultRoomReactions( clientId = pubSubMessage.clientId, metadata = data.get("metadata")?.toMap() ?: mapOf(), headers = pubSubMessage.extras?.asJsonObject()?.get("headers")?.toMap() ?: mapOf(), - isSelf = pubSubMessage.clientId == clientId, + isSelf = pubSubMessage.clientId == room.clientId, ) listener.onReaction(reaction) } @@ -163,6 +162,6 @@ internal class DefaultRoomReactions( } override fun release() { - realtimeChannels.release(channel.name) + room.realtimeClient.channels.release(channel.name) } } diff --git a/chat-android/src/main/java/com/ably/chat/RoomStatus.kt b/chat-android/src/main/java/com/ably/chat/RoomStatus.kt index 119c3e79..6fd3820a 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomStatus.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomStatus.kt @@ -179,10 +179,12 @@ internal class RoomStatusEventEmitter(logger: Logger) : EventEmitter) internal class DefaultTyping( - roomId: String, - private val realtimeClient: RealtimeClient, - private val clientId: String, - private val options: TypingOptions?, - private val logger: Logger, -) : Typing, ContributesToRoomLifecycleImpl(logger) { - private val typingIndicatorsChannelName = "$roomId::\$chat::\$typingIndicators" + private val room: DefaultRoom, +) : Typing, ContributesToRoomLifecycleImpl(room.roomLogger) { + private val typingIndicatorsChannelName = "${room.roomId}::\$chat::\$typingIndicators" override val featureName = "typing" @@ -105,6 +101,8 @@ internal class DefaultTyping( override val detachmentErrorCode: ErrorCode = ErrorCode.TypingDetachmentFailed + private val logger = room.roomLogger.withContext(tag = "Typing") + private val typingScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + SupervisorJob()) private val eventBus = MutableSharedFlow( @@ -112,7 +110,7 @@ internal class DefaultTyping( onBufferOverflow = BufferOverflow.DROP_OLDEST, ) - override val channel: Channel = realtimeClient.channels.get(typingIndicatorsChannelName, ChatChannelOptions()) + override val channel: Channel = room.realtimeClient.channels.get(typingIndicatorsChannelName, ChatChannelOptions()) private var typingJob: Job? = null @@ -155,6 +153,7 @@ internal class DefaultTyping( override suspend fun get(): Set { logger.trace("DefaultTyping.get()") + room.ensureAttached() // CHA-T2c, CHA-T2g return channel.presence.getCoroutine().map { it.clientId }.toSet() } @@ -169,7 +168,8 @@ internal class DefaultTyping( startTypingTimer() } else { startTypingTimer() - channel.presence.enterClientCoroutine(clientId) + room.ensureAttached() + channel.presence.enterClientCoroutine(room.clientId) } }.join() } @@ -178,18 +178,19 @@ internal class DefaultTyping( logger.trace("DefaultTyping.stop()") typingScope.launch { typingJob?.cancel() - channel.presence.leaveClientCoroutine(clientId) + room.ensureAttached() + channel.presence.leaveClientCoroutine(room.clientId) }.join() } override fun release() { presenceSubscription.unsubscribe() typingScope.cancel() - realtimeClient.channels.release(channel.name) + room.realtimeClient.channels.release(channel.name) } private fun startTypingTimer() { - val timeout = options?.timeoutMs ?: throw AblyException.fromErrorInfo( + val timeout = room.options.typing?.timeoutMs ?: throw AblyException.fromErrorInfo( ErrorInfo( "Typing options hasn't been initialized", ErrorCode.BadRequest.code, diff --git a/chat-android/src/main/java/com/ably/chat/Utils.kt b/chat-android/src/main/java/com/ably/chat/Utils.kt index 6b361bbb..3936e5d3 100644 --- a/chat-android/src/main/java/com/ably/chat/Utils.kt +++ b/chat-android/src/main/java/com/ably/chat/Utils.kt @@ -221,6 +221,13 @@ fun lifeCycleException( cause: Throwable? = null, ): AblyException = createAblyException(errorInfo, cause) +fun roomInvalidStateException(roomId: String, roomStatus: RoomStatus, statusCode: Int) = + ablyException( + "Can't perform operation; the room '$roomId' is in an invalid state: $roomStatus", + ErrorCode.RoomInInvalidState, + statusCode, + ) + fun ablyException( errorMessage: String, errorCode: ErrorCode, diff --git a/chat-android/src/test/java/com/ably/chat/OccupancyTest.kt b/chat-android/src/test/java/com/ably/chat/OccupancyTest.kt index bb64cb2c..39a67991 100644 --- a/chat-android/src/test/java/com/ably/chat/OccupancyTest.kt +++ b/chat-android/src/test/java/com/ably/chat/OccupancyTest.kt @@ -1,13 +1,12 @@ package com.ably.chat +import com.ably.chat.room.createMockChannel +import com.ably.chat.room.createMockChatApi +import com.ably.chat.room.createMockRealtimeClient +import com.ably.chat.room.createMockRoom import com.google.gson.JsonObject -import io.ably.lib.realtime.AblyRealtime.Channels -import io.ably.lib.realtime.Channel -import io.ably.lib.realtime.buildRealtimeChannel import io.mockk.every -import io.mockk.mockk import io.mockk.slot -import io.mockk.spyk import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -16,24 +15,20 @@ import org.junit.Test class OccupancyTest { - private val realtimeClient = mockk(relaxed = true) - private val realtimeChannels = mockk(relaxed = true) - private val realtimeChannel = spyk(buildRealtimeChannel()) - private val chatApi = spyk(ChatApi(realtimeClient, "clientId", EmptyLogger(LogContext(tag = "TEST")))) private lateinit var occupancy: Occupancy private val pubSubMessageListenerSlot = slot() + private val realtimeClient = createMockRealtimeClient() @Before fun setUp() { - every { realtimeChannels.get(any(), any()) } returns realtimeChannel - every { realtimeChannel.subscribe(capture(pubSubMessageListenerSlot)) } returns Unit + val mockRealtimeChannel = realtimeClient.createMockChannel() + every { mockRealtimeChannel.subscribe(capture(pubSubMessageListenerSlot)) } returns Unit - occupancy = DefaultOccupancy( - roomId = "room1", - realtimeChannels = realtimeChannels, - chatApi = chatApi, - logger = EmptyLogger(LogContext(tag = "TEST")), - ) + every { realtimeClient.channels.get(any(), any()) } returns mockRealtimeChannel + + val mockChatApi = createMockChatApi(realtimeClient) + val room = createMockRoom("room1", realtimeClient = realtimeClient, chatApi = mockChatApi) + occupancy = DefaultOccupancy(room) } /** diff --git a/chat-android/src/test/java/com/ably/chat/PresenceTest.kt b/chat-android/src/test/java/com/ably/chat/PresenceTest.kt index 3a302edf..718bbe22 100644 --- a/chat-android/src/test/java/com/ably/chat/PresenceTest.kt +++ b/chat-android/src/test/java/com/ably/chat/PresenceTest.kt @@ -1,16 +1,16 @@ package com.ably.chat -import com.ably.chat.room.createMockLogger +import com.ably.chat.room.createMockChannel +import com.ably.chat.room.createMockRealtimeClient +import com.ably.chat.room.createMockRoom import com.google.gson.JsonObject import com.google.gson.JsonPrimitive -import io.ably.lib.realtime.Channel import io.ably.lib.realtime.Presence.PresenceListener -import io.ably.lib.realtime.buildRealtimeChannel +import io.ably.lib.types.ChannelOptions import io.ably.lib.types.PresenceMessage import io.mockk.every import io.mockk.mockk import io.mockk.slot -import io.mockk.spyk import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Before @@ -18,19 +18,19 @@ import org.junit.Test import io.ably.lib.realtime.Presence as PubSubPresence class PresenceTest { - private val pubSubChannel = spyk(buildRealtimeChannel("room1::\$chat::\$messages")) + private val pubSubPresence = mockk(relaxed = true) private lateinit var presence: DefaultPresence - private val logger = createMockLogger() @Before fun setUp() { - presence = DefaultPresence( - clientId = "client1", - channel = pubSubChannel, - presence = pubSubPresence, - logger, - ) + val realtimeClient = createMockRealtimeClient() + val mockRealtimeChannel = realtimeClient.createMockChannel("room1::\$chat::\$messages") + mockRealtimeChannel.setPrivateField("presence", pubSubPresence) + + every { realtimeClient.channels.get(any(), any()) } returns mockRealtimeChannel + + presence = DefaultPresence(createMockRoom(realtimeClient = realtimeClient)) } /** diff --git a/chat-android/src/test/java/com/ably/chat/RoomReactionsTest.kt b/chat-android/src/test/java/com/ably/chat/RoomReactionsTest.kt index 2d6bf327..d8745d58 100644 --- a/chat-android/src/test/java/com/ably/chat/RoomReactionsTest.kt +++ b/chat-android/src/test/java/com/ably/chat/RoomReactionsTest.kt @@ -1,15 +1,14 @@ package com.ably.chat -import com.ably.chat.room.createMockLogger +import com.ably.chat.room.createMockChannel +import com.ably.chat.room.createMockRealtimeClient +import com.ably.chat.room.createMockRoom import com.google.gson.JsonObject -import io.ably.lib.realtime.AblyRealtime.Channels import io.ably.lib.realtime.Channel import io.ably.lib.realtime.buildRealtimeChannel import io.ably.lib.types.MessageExtras import io.mockk.every -import io.mockk.mockk import io.mockk.slot -import io.mockk.spyk import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -17,14 +16,16 @@ import org.junit.Before import org.junit.Test class RoomReactionsTest { - private val realtimeChannels = mockk(relaxed = true) - private val realtimeChannel = spyk(buildRealtimeChannel("room1::\$chat::\$reactions")) + private lateinit var realtimeChannel: Channel private lateinit var roomReactions: DefaultRoomReactions - private val logger = createMockLogger() + private lateinit var room: DefaultRoom @Before fun setUp() { - every { realtimeChannels.get(any(), any()) } answers { + val realtimeClient = createMockRealtimeClient() + realtimeChannel = realtimeClient.createMockChannel("room1::\$chat::\$reactions") + + every { realtimeClient.channels.get(any(), any()) } answers { val channelName = firstArg() if (channelName == "room1::\$chat::\$reactions") { realtimeChannel @@ -32,13 +33,8 @@ class RoomReactionsTest { buildRealtimeChannel(channelName) } } - - roomReactions = DefaultRoomReactions( - roomId = "room1", - clientId = "client1", - realtimeChannels = realtimeChannels, - logger, - ) + room = createMockRoom("room1", "client1", realtimeClient = realtimeClient) + roomReactions = DefaultRoomReactions(room) } /** @@ -46,15 +42,10 @@ class RoomReactionsTest { */ @Test fun `channel name is set according to the spec`() = runTest { - val roomReactions = DefaultRoomReactions( - roomId = "foo", - clientId = "client1", - realtimeChannels = realtimeChannels, - logger, - ) + val roomReactions = DefaultRoomReactions(room) assertEquals( - "foo::\$chat::\$reactions", + "room1::\$chat::\$reactions", roomReactions.channel.name, ) } diff --git a/chat-android/src/test/java/com/ably/chat/Sandbox.kt b/chat-android/src/test/java/com/ably/chat/Sandbox.kt index fd74e3bd..2a65d68e 100644 --- a/chat-android/src/test/java/com/ably/chat/Sandbox.kt +++ b/chat-android/src/test/java/com/ably/chat/Sandbox.kt @@ -7,6 +7,8 @@ import io.ably.lib.realtime.ConnectionEvent import io.ably.lib.realtime.ConnectionState 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.get @@ -26,7 +28,9 @@ private val client = HttpClient(CIO) { !response.status.isSuccess() } retryOnExceptionIf { _, cause -> - cause is HttpRequestTimeoutException + cause is ConnectTimeoutException || + cause is HttpRequestTimeoutException || + cause is SocketTimeoutException } exponentialDelay() } diff --git a/chat-android/src/test/java/com/ably/chat/TestUtils.kt b/chat-android/src/test/java/com/ably/chat/TestUtils.kt index a7f6d301..e7394b83 100644 --- a/chat-android/src/test/java/com/ably/chat/TestUtils.kt +++ b/chat-android/src/test/java/com/ably/chat/TestUtils.kt @@ -4,6 +4,7 @@ import com.google.gson.JsonElement import io.ably.lib.types.AsyncHttpPaginatedResponse import io.mockk.every import io.mockk.mockk +import java.lang.reflect.Field import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine @@ -80,18 +81,32 @@ suspend fun assertWaiter(timeoutInMs: Long = 10_000, block: () -> Boolean) { } fun Any.setPrivateField(name: String, value: Any?) { - val valueField = javaClass.getDeclaredField(name) + val valueField = javaClass.findField(name) valueField.isAccessible = true - return valueField.set(this, value) + valueField.set(this, value) } fun Any.getPrivateField(name: String): T { - val valueField = javaClass.getDeclaredField(name) + 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 { diff --git a/chat-android/src/test/java/com/ably/chat/room/RoomEnsureAttachedTest.kt b/chat-android/src/test/java/com/ably/chat/room/RoomEnsureAttachedTest.kt new file mode 100644 index 00000000..e4aca297 --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/room/RoomEnsureAttachedTest.kt @@ -0,0 +1,172 @@ +package com.ably.chat.room + +import com.ably.chat.ChatApi +import com.ably.chat.DefaultRoom +import com.ably.chat.DefaultRoomLifecycle +import com.ably.chat.ErrorCode +import com.ably.chat.HttpStatusCode +import com.ably.chat.RoomLifecycle +import com.ably.chat.RoomOptions +import com.ably.chat.RoomStatus +import com.ably.chat.RoomStatusChange +import com.ably.chat.assertWaiter +import com.ably.chat.setPrivateField +import io.ably.lib.types.AblyException +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Test + +/** + * Spec: CHA-RL9 + * Common spec: CHA-PR3d, CHA-PR3h, CHA-PR10d, CHA-PR10h, CHA-PR6c, CHA-PR6h, CHA-PR6c, CHA-T2g + */ +class RoomEnsureAttachedTest { + + private val clientId = "clientId" + private val logger = createMockLogger() + private val roomId = "1234" + private val mockRealtimeClient = createMockRealtimeClient() + private val chatApi = mockk(relaxed = true) + + @Test + fun `(CHA-PR3d, CHA-PR10d, CHA-PR6c, CHA-PR6c) When room is already ATTACHED, ensureAttached is a success`() = runTest { + val room = DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, clientId, logger) + Assert.assertEquals(RoomStatus.Initialized, room.status) + + // Set room status to ATTACHED + room.StatusLifecycle.setStatus(RoomStatus.Attached) + Assert.assertEquals(RoomStatus.Attached, room.status) + + val result = kotlin.runCatching { room.ensureAttached() } + Assert.assertTrue(result.isSuccess) + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-PR3h, CHA-PR10h, CHA-PR6h, CHA-T2g) When room is not ATTACHED or ATTACHING, ensureAttached throws error with code RoomInInvalidState`() = runTest { + val room = DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, clientId, logger) + Assert.assertEquals(RoomStatus.Initialized, room.status) + + // List of room status other than ATTACHED/ATTACHING + val invalidStatuses = listOf( + RoomStatus.Initialized, + RoomStatus.Detaching, + RoomStatus.Detached, + RoomStatus.Suspended, + RoomStatus.Failed, + RoomStatus.Releasing, + RoomStatus.Released, + ) + + for (invalidStatus in invalidStatuses) { + room.StatusLifecycle.setStatus(invalidStatus) + Assert.assertEquals(invalidStatus, room.status) + + // Check for exception when ensuring room ATTACHED + val result = kotlin.runCatching { room.ensureAttached() } + Assert.assertTrue(result.isFailure) + val exception = result.exceptionOrNull() as AblyException + Assert.assertEquals(ErrorCode.RoomInInvalidState.code, exception.errorInfo.code) + Assert.assertEquals(HttpStatusCode.BadRequest, exception.errorInfo.statusCode) + val errMsg = "Can't perform operation; the room '$roomId' is in an invalid state: $invalidStatus" + Assert.assertEquals(errMsg, exception.errorInfo.message) + } + } + + @Test + fun `(CHA-RL9a) When room is ATTACHING, subscribe once for next room status`() = runTest { + val room = DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, clientId, logger) + Assert.assertEquals(RoomStatus.Initialized, room.status) + + val roomLifecycleMock = spyk(DefaultRoomLifecycle(logger)) + every { + roomLifecycleMock.onChangeOnce(any()) + } answers { + val listener = firstArg() + listener.roomStatusChanged(RoomStatusChange(RoomStatus.Attached, RoomStatus.Attaching)) + } + room.setPrivateField("statusLifecycle", roomLifecycleMock) + + // Set room status to ATTACHING + room.StatusLifecycle.setStatus(RoomStatus.Attaching) + Assert.assertEquals(RoomStatus.Attaching, room.status) + + room.ensureAttached() + + verify(exactly = 1) { + roomLifecycleMock.onChangeOnce(any()) + } + } + + @Test + fun `(CHA-RL9b) When room is ATTACHING, subscription is registered, ensureAttached is a success`() = runTest { + val room = DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, clientId, logger) + Assert.assertEquals(RoomStatus.Initialized, room.status) + + // Set room status to ATTACHING + room.StatusLifecycle.setStatus(RoomStatus.Attaching) + Assert.assertEquals(RoomStatus.Attaching, room.status) + + val ensureAttachJob = async { room.ensureAttached() } + + // Wait for listener to be registered + assertWaiter { room.StatusLifecycle.InternalEmitter.Filters.size == 1 } + + // Set ATTACHED status + room.StatusLifecycle.setStatus(RoomStatus.Attached) + + val result = kotlin.runCatching { ensureAttachJob.await() } + Assert.assertTrue(result.isSuccess) + + Assert.assertEquals(0, room.StatusLifecycle.InternalEmitter.Filters.size) // Emitted event processed + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL9c) When room is ATTACHING and subscription is registered and fails, ensureAttached throws error with code RoomInInvalidState`() = runTest { + val room = DefaultRoom(roomId, RoomOptions.default, mockRealtimeClient, chatApi, clientId, logger) + Assert.assertEquals(RoomStatus.Initialized, room.status) + + // List of room status other than ATTACHED/ATTACHING + val invalidStatuses = listOf( + RoomStatus.Initialized, + RoomStatus.Detaching, + RoomStatus.Detached, + RoomStatus.Suspended, + RoomStatus.Failed, + RoomStatus.Releasing, + RoomStatus.Released, + ) + + for (invalidStatus in invalidStatuses) { + // Set room status to ATTACHING + room.StatusLifecycle.setStatus(RoomStatus.Attaching) + Assert.assertEquals(RoomStatus.Attaching, room.status) + + val ensureAttachJob = async(SupervisorJob()) { room.ensureAttached() } + + // Wait for listener to be registered + assertWaiter { room.StatusLifecycle.InternalEmitter.Filters.size == 1 } + + // set invalid room status + room.StatusLifecycle.setStatus(invalidStatus) + + // Check for exception when ensuring room ATTACHED + val result = kotlin.runCatching { ensureAttachJob.await() } + Assert.assertTrue(result.isFailure) + val exception = result.exceptionOrNull() as AblyException + Assert.assertEquals(ErrorCode.RoomInInvalidState.code, exception.errorInfo.code) + Assert.assertEquals(HttpStatusCode.InternalServerError, exception.errorInfo.statusCode) + val errMsg = "Can't perform operation; the room '$roomId' is in an invalid state: $invalidStatus" + Assert.assertEquals(errMsg, exception.errorInfo.message) + + Assert.assertEquals(0, room.StatusLifecycle.InternalEmitter.Filters.size) // Emitted event processed + } + } +} diff --git a/chat-android/src/test/java/com/ably/chat/room/RoomTestHelpers.kt b/chat-android/src/test/java/com/ably/chat/room/RoomTestHelpers.kt index 48035dee..cebff0b7 100644 --- a/chat-android/src/test/java/com/ably/chat/room/RoomTestHelpers.kt +++ b/chat-android/src/test/java/com/ably/chat/room/RoomTestHelpers.kt @@ -7,29 +7,63 @@ import com.ably.chat.ContributesToRoomLifecycle import com.ably.chat.DefaultMessages import com.ably.chat.DefaultOccupancy import com.ably.chat.DefaultPresence +import com.ably.chat.DefaultRoom import com.ably.chat.DefaultRoomLifecycle import com.ably.chat.DefaultRoomReactions import com.ably.chat.DefaultTyping import com.ably.chat.LifecycleOperationPrecedence import com.ably.chat.Logger +import com.ably.chat.RealtimeClient import com.ably.chat.Room import com.ably.chat.RoomLifecycleManager import com.ably.chat.RoomOptions +import com.ably.chat.RoomStatusEventEmitter import com.ably.chat.Rooms import com.ably.chat.getPrivateField import com.ably.chat.invokePrivateSuspendMethod +import com.ably.chat.setPrivateField 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.ably.lib.types.ErrorInfo +import io.ably.lib.util.EventEmitter import io.mockk.mockk import io.mockk.spyk import kotlinx.coroutines.CompletableDeferred import io.ably.lib.realtime.Channel as AblyRealtimeChannel -fun createMockRealtimeClient(): AblyRealtime = spyk(AblyRealtime(ClientOptions("id:key").apply { autoConnect = false })) +const val DEFAULT_ROOM_ID = "1234" +const val DEFAULT_CLIENT_ID = "clientId" +const val DEFAULT_CHANNEL_NAME = "channel" + +fun createMockRealtimeClient(): AblyRealtime { + val realtimeClient = spyk(AblyRealtime(ClientOptions("id:key").apply { autoConnect = false }), recordPrivateCalls = true) + val mockChannels = spyk(realtimeClient.channels, recordPrivateCalls = true) + realtimeClient.setPrivateField("channels", mockChannels) + return realtimeClient +} + +fun AblyRealtime.createMockChannel(channelName: String = DEFAULT_CHANNEL_NAME): Channel = + spyk(channels.get(channelName), recordPrivateCalls = true) + +internal fun createMockChatApi( + realtimeClient: RealtimeClient = createMockRealtimeClient(), + clientId: String = DEFAULT_CLIENT_ID, + logger: Logger = createMockLogger(), +) = spyk(ChatApi(realtimeClient, clientId, logger), recordPrivateCalls = true) + internal fun createMockLogger(): Logger = mockk(relaxed = true) +internal fun createMockRoom( + roomId: String = DEFAULT_ROOM_ID, + clientId: String = DEFAULT_CLIENT_ID, + realtimeClient: RealtimeClient = createMockRealtimeClient(), + chatApi: ChatApi = mockk(relaxed = true), + logger: Logger = createMockLogger(), +): DefaultRoom = + DefaultRoom(roomId, RoomOptions.default, realtimeClient, chatApi, clientId, logger) + // Rooms mocks val Rooms.RoomIdToRoom get() = getPrivateField>("roomIdToRoom") val Rooms.RoomGetDeferred get() = getPrivateField>>("roomGetDeferred") @@ -39,6 +73,13 @@ val Rooms.RoomReleaseDeferred get() = getPrivateField("statusLifecycle") internal val Room.LifecycleManager get() = getPrivateField("lifecycleManager") +// DefaultRoomLifecycle mocks +internal val DefaultRoomLifecycle.InternalEmitter get() = getPrivateField("internalEmitter") + +// EventEmitter mocks +internal val EventEmitter<*, *>.Listeners get() = getPrivateField>("listeners") +internal val EventEmitter<*, *>.Filters get() = getPrivateField>("filters") + // RoomLifeCycleManager Mocks internal fun RoomLifecycleManager.atomicCoroutineScope(): AtomicCoroutineScope = getPrivateField("atomicCoroutineScope") @@ -51,24 +92,17 @@ internal suspend fun RoomLifecycleManager.atomicRetry(exceptContributor: Contrib }.await() } -fun createRoomFeatureMocks(roomId: String = "1234"): List { - val clientId = "clientId" - +fun createRoomFeatureMocks(roomId: String = DEFAULT_ROOM_ID, clientId: String = DEFAULT_CLIENT_ID): List { val realtimeClient = createMockRealtimeClient() - val chatApi = mockk(relaxed = true) + val chatApi = createMockChatApi() val logger = createMockLogger() + val room = createMockRoom(roomId, clientId, realtimeClient, chatApi, logger) val messagesContributor = spyk(DefaultMessages(roomId, realtimeClient.channels, chatApi, logger), recordPrivateCalls = true) - val presenceContributor = spyk( - DefaultPresence(clientId, messagesContributor.channel, messagesContributor.channel.presence, logger), - recordPrivateCalls = true, - ) - val occupancyContributor = spyk(DefaultOccupancy(realtimeClient.channels, chatApi, roomId, logger), recordPrivateCalls = true) - val typingContributor = spyk( - DefaultTyping(roomId, realtimeClient, clientId, RoomOptions.default.typing, logger), - recordPrivateCalls = true, - ) - val reactionsContributor = spyk(DefaultRoomReactions(roomId, clientId, realtimeClient.channels, logger), recordPrivateCalls = true) + val presenceContributor = spyk(DefaultPresence(room), recordPrivateCalls = true) + val occupancyContributor = spyk(DefaultOccupancy(room), recordPrivateCalls = true) + val typingContributor = spyk(DefaultTyping(room), recordPrivateCalls = true) + val reactionsContributor = spyk(DefaultRoomReactions(room), recordPrivateCalls = true) return listOf(messagesContributor, presenceContributor, occupancyContributor, typingContributor, reactionsContributor) }