diff --git a/chat-android/src/main/java/com/ably/chat/Messages.kt b/chat-android/src/main/java/com/ably/chat/Messages.kt index c30b072..5593457 100644 --- a/chat-android/src/main/java/com/ably/chat/Messages.kt +++ b/chat-android/src/main/java/com/ably/chat/Messages.kt @@ -239,6 +239,8 @@ internal class DefaultMessages( */ private val messagesChannelName = "$roomId::\$chat::\$chatMessages" + override val featureName: String = "messages" + override val channel = realtimeChannels.get(messagesChannelName, ChatChannelOptions()) override val contributor: ContributesToRoomLifecycle = this 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 83e9ec4..4a3f567 100644 --- a/chat-android/src/main/java/com/ably/chat/Occupancy.kt +++ b/chat-android/src/main/java/com/ably/chat/Occupancy.kt @@ -63,6 +63,8 @@ internal class DefaultOccupancy( private val messages: Messages, ) : Occupancy, ContributesToRoomLifecycleImpl(), ResolvedContributor { + override val featureName: String = "occupancy" + override val channel = messages.channel override val contributor: ContributesToRoomLifecycle = this 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 c095fc4..be9e4e7 100644 --- a/chat-android/src/main/java/com/ably/chat/Presence.kt +++ b/chat-android/src/main/java/com/ably/chat/Presence.kt @@ -134,6 +134,8 @@ internal class DefaultPresence( private val messages: Messages, ) : Presence, ContributesToRoomLifecycleImpl(), ResolvedContributor { + override val featureName = "presence" + override val channel = messages.channel override val contributor: ContributesToRoomLifecycle = this diff --git a/chat-android/src/main/java/com/ably/chat/RoomLifecycleManager.kt b/chat-android/src/main/java/com/ably/chat/RoomLifecycleManager.kt index 4d4a0d0..0591e87 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomLifecycleManager.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomLifecycleManager.kt @@ -18,6 +18,11 @@ import io.ably.lib.realtime.Channel as AblyRealtimeChannel * An interface for features that contribute to the room status. */ interface ContributesToRoomLifecycle : EmitsDiscontinuities, HandlesDiscontinuity { + /** + * Name of the feature + */ + val featureName: String + /** * Gets the channel on which the feature operates. This promise is never * rejected except in the case where room initialization is canceled. @@ -106,7 +111,7 @@ class DefaultRoomAttachmentResult : RoomAttachmentResult { override val exception: AblyException get() { val errorInfo = errorField - ?: ErrorInfo("unknown error in attach", ErrorCodes.RoomLifecycleError.errorCode, HttpStatusCodes.InternalServerError) + ?: ErrorInfo("unknown error in attach", HttpStatusCodes.InternalServerError, ErrorCodes.RoomLifecycleError.errorCode) throwable?.let { return AblyException.fromErrorInfo(throwable, errorInfo) } @@ -235,8 +240,8 @@ class RoomLifecycleManager AblyException.fromErrorInfo( ErrorInfo( "no failed feature in doRetry", - ErrorCodes.RoomLifecycleError.errorCode, HttpStatusCodes.InternalServerError, + ErrorCodes.RoomLifecycleError.errorCode, ), ) } @@ -271,7 +276,7 @@ class RoomLifecycleManager contributor.channel.once(ChannelState.failed) { val exception = AblyException.fromErrorInfo( it.reason - ?: ErrorInfo("unknown error in _doRetry", ErrorCodes.RoomLifecycleError.errorCode, HttpStatusCodes.InternalServerError), + ?: ErrorInfo("unknown error in _doRetry", HttpStatusCodes.InternalServerError, ErrorCodes.RoomLifecycleError.errorCode), ) continuation.resumeWithException(exception) } @@ -284,13 +289,14 @@ class RoomLifecycleManager * If a channel enters the suspended state, then we reject, but we will retry after a short delay as is the case * in the core SDK. * If a channel enters the failed state, we reject and then begin to wind down the other channels. + * Spec: CHA-RL1 */ @SuppressWarnings("ThrowsCount") internal suspend fun attach() { - val deferredAttach = atomicCoroutineScope.async(LifecycleOperationPrecedence.AttachOrDetach.priority) { + val deferredAttach = atomicCoroutineScope.async(LifecycleOperationPrecedence.AttachOrDetach.priority) { // CHA-RL1d when (_status.current) { - RoomLifecycle.Attached -> return@async - RoomLifecycle.Releasing -> + RoomLifecycle.Attached -> return@async // CHA-RL1a + RoomLifecycle.Releasing -> // CHA-RL1b throw AblyException.fromErrorInfo( ErrorInfo( "unable to attach room; room is releasing", @@ -298,7 +304,7 @@ class RoomLifecycleManager ErrorCodes.RoomIsReleasing.errorCode, ), ) - RoomLifecycle.Released -> + RoomLifecycle.Released -> // CHA-RL1c throw AblyException.fromErrorInfo( ErrorInfo( "unable to attach room; room is released", @@ -312,35 +318,37 @@ class RoomLifecycleManager // At this point, we force the room status to be attaching clearAllTransientDetachTimeouts() _operationInProgress = true - _status.setStatus(RoomLifecycle.Attaching) + _status.setStatus(RoomLifecycle.Attaching) // CHA-RL1e val attachResult = doAttach() - // If we're in a failed state, then we should wind down all the channels, eventually + // CHA-RL1h4 - If we're in a failed state, then we should wind down all the channels, eventually if (attachResult.status === RoomLifecycle.Failed) { + // CHA-RL1h5 - detach all remaining channels atomicCoroutineScope.async(LifecycleOperationPrecedence.Internal.priority) { runDownChannelsOnFailedAttach() } - throw attachResult.exception + throw attachResult.exception // CHA-RL1h1 } - // If we're in suspended, then this attach should fail, but we'll retry after a short delay async + // CHA-RL1h1, CHA-RL1h2 - If we're in suspended, then this attach should fail, but we'll retry after a short delay async if (attachResult.status === RoomLifecycle.Suspended) { if (attachResult.failedFeature == null) { AblyException.fromErrorInfo( ErrorInfo( "no failed feature in attach", - ErrorCodes.RoomLifecycleError.errorCode, HttpStatusCodes.InternalServerError, + ErrorCodes.RoomLifecycleError.errorCode, ), ) } attachResult.failedFeature?.let { + // CHA-RL1h3 - Enter recovery for failed room feature/contributor atomicCoroutineScope.async(LifecycleOperationPrecedence.Internal.priority) { doRetry(it) } } - throw attachResult.exception + throw attachResult.exception // CHA-RL1h1 } // We attached, finally! @@ -353,21 +361,21 @@ class RoomLifecycleManager * * Attaches each feature channel with rollback on channel attach failure. * This method is re-usable and can be called as a part of internal room operations. - * + * Spec: CHA-RL1f, CHA-RL1g, CHA-RL1h */ private suspend fun doAttach(): RoomAttachmentResult { val attachResult = DefaultRoomAttachmentResult() - for (feature in _contributors) { + for (feature in _contributors) { // CHA-RL1f - attach each feature sequentially try { feature.channel.attachCoroutine() _firstAttachesCompleted[feature] = true - } catch (ex: Throwable) { + } catch (ex: Throwable) { // CHA-RL1h - handle channel attach failure attachResult.throwable = ex attachResult.failedFeatureField = feature attachResult.errorField = ErrorInfo( - "failed to attach feature", - feature.contributor.attachmentErrorCode.errorCode, + "failed to attach ${feature.contributor.featureName} feature${feature.channel.errorMessage}", HttpStatusCodes.InternalServerError, + feature.contributor.attachmentErrorCode.errorCode, ) // The current feature should be in one of two states, it will be either suspended or failed @@ -379,9 +387,9 @@ class RoomLifecycleManager else -> { attachResult.statusField = RoomLifecycle.Failed attachResult.errorField = ErrorInfo( - "unexpected channel state in doAttach ${feature.channel.state}", - ErrorCodes.RoomLifecycleError.errorCode, + "unexpected channel state in doAttach ${feature.channel.state}${feature.channel.errorMessage}", HttpStatusCodes.InternalServerError, + ErrorCodes.RoomLifecycleError.errorCode, ) } } @@ -393,7 +401,7 @@ class RoomLifecycleManager } } - // We successfully attached all the channels - set our status to attached, start listening changes in channel status + // CHA-RL1g, We successfully attached all the channels - set our status to attached, start listening changes in channel status this._status.setStatus(attachResult) this._operationInProgress = false @@ -407,14 +415,14 @@ class RoomLifecycleManager /** * If we've failed to attach, then we're in the failed state and all that is left to do is to detach all the channels. - * + * Spec: CHA-RL1h5, CHA-RL1h6 * @returns Returns only when all channels are detached. Doesn't throw exception. */ private suspend fun runDownChannelsOnFailedAttach() { // At this point, we have control over the channel lifecycle, so we can hold onto it until things are resolved // Keep trying to detach the channels until they're all detached. var channelWindDown = kotlin.runCatching { doChannelWindDown() } - while (channelWindDown.isFailure) { + while (channelWindDown.isFailure) { // CHA-RL1h6 - repeat until all channels are detached // Something went wrong during the wind down. After a short delay, to give others a turn, we should run down // again until we reach a suitable conclusion. delay(_retryDurationInMs) @@ -425,9 +433,10 @@ class RoomLifecycleManager /** * Detach all features except the one exception provided. * If the room is in a failed state, then all channels should either reach the failed state or be detached. - * + * Spec: CHA-RL1h5 * @param except The contributor to exclude from the detachment. * @returns Success/Failure when all channels are detached or at least one of them fails. + * */ @SuppressWarnings("CognitiveComplexMethod", "ComplexCondition") private suspend fun doChannelWindDown(except: ResolvedContributor? = null) = coroutineScope { @@ -461,8 +470,8 @@ class RoomLifecycleManager ) { val contributorError = ErrorInfo( "failed to detach feature", - contributor.contributor.detachmentErrorCode.errorCode, HttpStatusCodes.InternalServerError, + contributor.contributor.detachmentErrorCode.errorCode, ) _status.setStatus(RoomLifecycle.Failed, contributorError) throw AblyException.fromErrorInfo(throwable, contributorError) @@ -474,4 +483,26 @@ class RoomLifecycleManager } }.awaitAll() } + + /** + * Detaches the room. If the room is already detached, this is a no-op. + * If one of the channels fails to detach, the room status will be set to failed. + * If the room is in the process of detaching, this will wait for the detachment to complete. + * @return when the room is detached. + */ + internal suspend fun detach() { + // TODO("Need to impl. room detach") + } + + /** + * Releases the room. If the room is already released, this is a no-op. + * Any channel that detaches into the failed state is ok. But any channel that fails to detach + * will cause the room status to be set to failed. + * + * @returns Returns when the room is released. If a channel detaches into a non-terminated + * state (e.g. attached), release will throw exception. + */ + internal suspend fun release() { + // TODO("Need to impl. room release") + } } 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 59a10d9..12cca2b 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomReactions.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomReactions.kt @@ -112,6 +112,7 @@ internal class DefaultRoomReactions( private val roomReactionsChannelName = "$roomId::\$chat::\$reactions" override val channel: AblyRealtimeChannel = realtimeChannels.get(roomReactionsChannelName, ChatChannelOptions()) + override val featureName = "reactions" override val contributor: ContributesToRoomLifecycle = this 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 25823cd..1f40e5b 100644 --- a/chat-android/src/main/java/com/ably/chat/RoomStatus.kt +++ b/chat-android/src/main/java/com/ably/chat/RoomStatus.kt @@ -174,7 +174,7 @@ class RoomStatusEventEmitter : EventEmitter( } } -class DefaultStatus(private val logger: LogHandler?) : InternalRoomStatus { +class DefaultStatus(private val logger: LogHandler? = null) : InternalRoomStatus { private val _logger = logger diff --git a/chat-android/src/main/java/com/ably/chat/Typing.kt b/chat-android/src/main/java/com/ably/chat/Typing.kt index 9950adc..2f6fe85 100644 --- a/chat-android/src/main/java/com/ably/chat/Typing.kt +++ b/chat-android/src/main/java/com/ably/chat/Typing.kt @@ -82,6 +82,8 @@ internal class DefaultTyping( private val typingIndicatorsChannelName = "$roomId::\$chat::\$typingIndicators" + override val featureName = "typing" + override val channel = realtimeClient.channels.get(typingIndicatorsChannelName, ChatChannelOptions()) override val contributor: ContributesToRoomLifecycle = this 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 8f57352..41fa260 100644 --- a/chat-android/src/main/java/com/ably/chat/Utils.kt +++ b/chat-android/src/main/java/com/ably/chat/Utils.kt @@ -50,6 +50,13 @@ suspend fun Channel.publishCoroutine(message: PubSubMessage) = suspendCoroutine ) } +val Channel.errorMessage: String + get() = if (reason == null) { + "" + } else { + ", ${reason.message}" + } + @Suppress("FunctionName") fun ChatChannelOptions(init: (ChannelOptions.() -> Unit)? = null): ChannelOptions { val options = ChannelOptions() diff --git a/chat-android/src/test/java/com/ably/chat/RoomLifecycleManagerTest.kt b/chat-android/src/test/java/com/ably/chat/RoomLifecycleManagerTest.kt new file mode 100644 index 0000000..ec6053c --- /dev/null +++ b/chat-android/src/test/java/com/ably/chat/RoomLifecycleManagerTest.kt @@ -0,0 +1,419 @@ +package com.ably.chat + +import com.ably.utils.atomicCoroutineScope +import com.ably.utils.createRoomFeatureMocks +import com.ably.utils.setState +import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.AblyException +import io.ably.lib.types.ErrorInfo +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Test + +class RoomLifecycleManagerTest { + + private val roomScope = CoroutineScope( + Dispatchers.Default.limitedParallelism(1) + CoroutineName("roomId"), + ) + + @Test + fun `(CHA-RL1a) Attach success when room is already in attached state`() = runTest { + val status = spyk().apply { + setStatus(RoomLifecycle.Attached) + } + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, status, createRoomFeatureMocks())) + val result = kotlin.runCatching { roomLifecycle.attach() } + Assert.assertTrue(result.isSuccess) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Test + fun `(CHA-RL1b) Attach throws exception when room in releasing state`() = runTest { + val status = spyk().apply { + setStatus(RoomLifecycle.Releasing) + } + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, status, createRoomFeatureMocks())) + val exception = Assert.assertThrows(AblyException::class.java) { + runBlocking { + roomLifecycle.attach() + } + } + Assert.assertEquals("unable to attach room; room is releasing", exception.errorInfo.message) + Assert.assertEquals(ErrorCodes.RoomIsReleasing.errorCode, exception.errorInfo.code) + Assert.assertEquals(HttpStatusCodes.InternalServerError, exception.errorInfo.statusCode) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Test + fun `(CHA-RL1c) Attach throws exception when room in released state`() = runTest { + val status = spyk().apply { + setStatus(RoomLifecycle.Released) + } + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, status, listOf())) + val exception = Assert.assertThrows(AblyException::class.java) { + runBlocking { + roomLifecycle.attach() + } + } + Assert.assertEquals("unable to attach room; room is released", exception.errorInfo.message) + Assert.assertEquals(ErrorCodes.RoomIsReleased.errorCode, exception.errorInfo.code) + Assert.assertEquals(HttpStatusCodes.InternalServerError, exception.errorInfo.statusCode) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Test + fun `(CHA-RL1d) Attach op should wait for existing operation as per (CHA-RL7)`() = runTest { + val status = spyk() + Assert.assertEquals(RoomLifecycle.Initializing, status.current) + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, status, createRoomFeatureMocks())) + + val roomReleased = Channel() + coEvery { + roomLifecycle.release() + } coAnswers { + roomLifecycle.atomicCoroutineScope().async { + status.setStatus(RoomLifecycle.Releasing) + roomReleased.receive() + status.setStatus(RoomLifecycle.Released) + } + } + + // Release op started from separate coroutine + launch { roomLifecycle.release() } + assertWaiter { !roomLifecycle.atomicCoroutineScope().finishedProcessing } + Assert.assertEquals(0, roomLifecycle.atomicCoroutineScope().pendingJobCount) // no queued jobs, one job running + assertWaiter { status.current == RoomLifecycle.Releasing } + + // Attach op started from separate coroutine + val roomAttachOpDeferred = async(SupervisorJob()) { roomLifecycle.attach() } + assertWaiter { roomLifecycle.atomicCoroutineScope().pendingJobCount == 1 } // attach op queued + Assert.assertEquals(RoomLifecycle.Releasing, status.current) + + // Finish release op, so ATTACH op can start + roomReleased.send(true) + assertWaiter { status.current == RoomLifecycle.Released } + + val result = kotlin.runCatching { roomAttachOpDeferred.await() } + Assert.assertTrue(roomLifecycle.atomicCoroutineScope().finishedProcessing) + + Assert.assertTrue(result.isFailure) + val exception = result.exceptionOrNull() as AblyException + + Assert.assertEquals("unable to attach room; room is released", exception.errorInfo.message) + Assert.assertEquals(ErrorCodes.RoomIsReleased.errorCode, exception.errorInfo.code) + Assert.assertEquals(HttpStatusCodes.InternalServerError, exception.errorInfo.statusCode) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + coVerify { roomLifecycle.release() } + } + + @Test + fun `(CHA-RL1e) Attach op should transition room into ATTACHING state`() = runTest { + val status = spyk() + val roomStatusChanges = mutableListOf() + status.onChange { + roomStatusChanges.add(it) + } + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, status, emptyList())) + roomLifecycle.attach() + + Assert.assertEquals(RoomLifecycle.Attaching, roomStatusChanges[0].current) + Assert.assertEquals(RoomLifecycle.Attached, roomStatusChanges[1].current) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + } + + @Test + fun `(CHA-RL1f) Attach op should attach each contributor channel sequentially`() = runTest { + val status = spyk() + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + val capturedChannels = mutableListOf() + coEvery { any().attachCoroutine() } coAnswers { + capturedChannels.add(firstArg()) + } + + val contributors = createRoomFeatureMocks() + Assert.assertEquals(5, contributors.size) + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, status, contributors)) + + val result = kotlin.runCatching { roomLifecycle.attach() } + Assert.assertTrue(result.isSuccess) + + Assert.assertEquals(5, capturedChannels.size) + repeat(5) { + Assert.assertEquals(contributors[it].channel.name, capturedChannels[it].name) + } + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[0].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[1].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", capturedChannels[2].name) + Assert.assertEquals("1234::\$chat::\$typingIndicators", capturedChannels[3].name) + Assert.assertEquals("1234::\$chat::\$reactions", capturedChannels[4].name) + } + + @Test + fun `(CHA-RL1g) When all contributor channels ATTACH, op is complete and room should be considered ATTACHED`() = runTest { + val status = spyk() + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coEvery { any().attachCoroutine() } returns Unit + + val contributors = createRoomFeatureMocks("1234") + val contributorErrors = mutableListOf() + for (contributor in contributors) { + every { + contributor.contributor.discontinuityDetected(capture(contributorErrors)) + } returns Unit + } + Assert.assertEquals(5, contributors.size) + + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, status, contributors), recordPrivateCalls = true) { + val pendingDiscontinuityEvents = mutableMapOf().apply { + for (contributor in contributors) { + put(contributor, ErrorInfo("${contributor.channel.name} error", 500)) + } + } + this.setPrivateField("_pendingDiscontinuityEvents", pendingDiscontinuityEvents) + } + justRun { roomLifecycle invokeNoArgs "clearAllTransientDetachTimeouts" } + + val result = kotlin.runCatching { roomLifecycle.attach() } + + // CHA-RL1g1 + Assert.assertTrue(result.isSuccess) + Assert.assertEquals(RoomLifecycle.Attached, status.current) + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } + + // CHA-RL1g2 + verify(exactly = 1) { + for (contributor in contributors) { + contributor.contributor.discontinuityDetected(any()) + } + } + Assert.assertEquals(5, contributorErrors.size) + + // CHA-RL1g3 + verify(exactly = 1) { + roomLifecycle invokeNoArgs "clearAllTransientDetachTimeouts" + } + } + + // All of the following tests cover sub-spec points under CHA-RL1h ( channel attach failure ) + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL1h1, CHA-RL1h2) If a one of the contributors fails to attach (enters suspended state), attach throws related error and room enters suspended state`() = runTest { + val status = spyk() + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coEvery { any().attachCoroutine() } coAnswers { + val channel = firstArg() + if ("reactions" in channel.name) { + // Throw error for typing contributor, likely to throw because it uses different channel + channel.setState(ChannelState.suspended) + throw AblyException.fromErrorInfo(ErrorInfo("error attaching channel ${channel.name}", 500)) + } + } + + val contributors = createRoomFeatureMocks("1234") + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, status, contributors), recordPrivateCalls = true) + + val result = kotlin.runCatching { roomLifecycle.attach() } + + Assert.assertTrue(result.isFailure) + Assert.assertEquals(RoomLifecycle.Suspended, status.current) + + val exception = result.exceptionOrNull() as AblyException + + Assert.assertEquals("failed to attach reactions feature", exception.errorInfo.message) + Assert.assertEquals(ErrorCodes.ReactionsAttachmentFailed.errorCode, exception.errorInfo.code) + Assert.assertEquals(500, exception.errorInfo.statusCode) + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL1h1, CHA-RL1h4) If a one of the contributors fails to attach (enters failed state), attach throws related error and room enters failed state`() = runTest { + val status = spyk() + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coEvery { any().attachCoroutine() } coAnswers { + val channel = firstArg() + if ("typing" in channel.name) { + // Throw error for typing contributor, likely to throw because it uses different channel + val error = ErrorInfo("error attaching channel ${channel.name}", 500) + channel.setState(ChannelState.failed, error) + throw AblyException.fromErrorInfo(error) + } + } + + val contributors = createRoomFeatureMocks("1234") + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, status, contributors), recordPrivateCalls = true) + + val result = kotlin.runCatching { roomLifecycle.attach() } + + Assert.assertTrue(result.isFailure) + Assert.assertEquals(RoomLifecycle.Failed, status.current) + + val exception = result.exceptionOrNull() as AblyException + + Assert.assertEquals( + "failed to attach typing feature, error attaching channel 1234::\$chat::\$typingIndicators", + exception.errorInfo.message, + ) + Assert.assertEquals(ErrorCodes.TypingAttachmentFailed.errorCode, exception.errorInfo.code) + Assert.assertEquals(500, exception.errorInfo.statusCode) + } + + @Test + fun `(CHA-RL1h3) When room enters suspended state (CHA-RL1h2), it should enter recovery loop as per (CHA-RL5)`() = runTest { + val status = spyk() + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coEvery { any().attachCoroutine() } coAnswers { + val channel = firstArg() + if ("reactions" in channel.name) { + // Throw error for typing contributor, likely to throw because it uses different channel + channel.setState(ChannelState.suspended) + throw AblyException.fromErrorInfo(ErrorInfo("error attaching channel ${channel.name}", 500)) + } + } + + val contributors = createRoomFeatureMocks("1234") + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, status, contributors), recordPrivateCalls = true) + + val resolvedContributor = slot() + + // Behaviour for CHA-RL5 will be tested as a part of sub spec for the same + coEvery { roomLifecycle["doRetry"](capture(resolvedContributor)) } coAnswers { + delay(1000) + } + + val result = kotlin.runCatching { roomLifecycle.attach() } + assertWaiter { !roomLifecycle.atomicCoroutineScope().finishedProcessing } // internal attach retry in progress + + Assert.assertTrue(result.isFailure) + Assert.assertEquals(RoomLifecycle.Suspended, status.current) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } // Wait for doRetry to finish + + coVerify(exactly = 1) { + roomLifecycle["doRetry"](resolvedContributor.captured) + } + Assert.assertEquals("reactions", resolvedContributor.captured.contributor.featureName) + } + + @Test + fun `(CHA-RL1h5) When room enters failed state (CHA-RL1h4), room detach all channels not in failed state`() = runTest { + val status = spyk() + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coEvery { any().attachCoroutine() } coAnswers { + val channel = firstArg() + if ("typing" in channel.name) { + // Throw error for typing contributor, likely to throw because it uses different channel + val error = ErrorInfo("error attaching channel ${channel.name}", 500) + channel.setState(ChannelState.failed, error) + throw AblyException.fromErrorInfo(error) + } + } + + val detachedChannels = mutableListOf() + coEvery { any().detachCoroutine() } coAnswers { + delay(200) + detachedChannels.add(firstArg()) + } + + val contributors = createRoomFeatureMocks("1234") + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, status, contributors), recordPrivateCalls = true) + + val result = kotlin.runCatching { roomLifecycle.attach() } + Assert.assertFalse(roomLifecycle.atomicCoroutineScope().finishedProcessing) // Internal channels detach in progress + + Assert.assertTrue(result.isFailure) + Assert.assertEquals(RoomLifecycle.Failed, status.current) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } // Wait for channels detach + + coVerify { + roomLifecycle invokeNoArgs "runDownChannelsOnFailedAttach" + } + + coVerify(exactly = 1) { + roomLifecycle["doChannelWindDown"](any()) + } + + Assert.assertEquals("1234::\$chat::\$chatMessages", detachedChannels[0].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", detachedChannels[1].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", detachedChannels[2].name) + Assert.assertEquals("1234::\$chat::\$reactions", detachedChannels[3].name) + } + + @Suppress("MaximumLineLength") + @Test + fun `(CHA-RL1h6) When room enters failed state, when CHA-RL1h5 fails to detach, op will be repeated till all channels are detached`() = runTest { + val status = spyk() + + mockkStatic(io.ably.lib.realtime.Channel::attachCoroutine) + coEvery { any().attachCoroutine() } coAnswers { + val channel = firstArg() + if ("typing" in channel.name) { + // Throw error for typing contributor, likely to throw because it uses different channel + val error = ErrorInfo("error attaching channel ${channel.name}", 500) + channel.setState(ChannelState.failed, error) + throw AblyException.fromErrorInfo(error) + } + } + + var failDetachTimes = 5 + val detachedChannels = mutableListOf() + coEvery { any().detachCoroutine() } coAnswers { + delay(200) + if (--failDetachTimes >= 0) { + error("failed to detach channel") + } + detachedChannels.add(firstArg()) + } + + val contributors = createRoomFeatureMocks("1234") + val roomLifecycle = spyk(RoomLifecycleManager(roomScope, status, contributors), recordPrivateCalls = true) + + val result = kotlin.runCatching { roomLifecycle.attach() } + Assert.assertFalse(roomLifecycle.atomicCoroutineScope().finishedProcessing) // Internal channels detach in progress + + Assert.assertTrue(result.isFailure) + Assert.assertEquals(RoomLifecycle.Failed, status.current) + + assertWaiter { roomLifecycle.atomicCoroutineScope().finishedProcessing } // Wait for channels detach + + coVerify { + roomLifecycle invokeNoArgs "runDownChannelsOnFailedAttach" + } + + // Channel detach success on 6th call + coVerify(exactly = 6) { + roomLifecycle["doChannelWindDown"](any()) + } + + Assert.assertEquals("1234::\$chat::\$chatMessages", detachedChannels[0].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", detachedChannels[1].name) + Assert.assertEquals("1234::\$chat::\$chatMessages", detachedChannels[2].name) + Assert.assertEquals("1234::\$chat::\$reactions", detachedChannels[3].name) + } +} 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 1e202e0..afebf95 100644 --- a/chat-android/src/test/java/com/ably/chat/TestUtils.kt +++ b/chat-android/src/test/java/com/ably/chat/TestUtils.kt @@ -64,3 +64,16 @@ suspend fun assertWaiter(timeoutInMs: Long = 10_000, block: () -> Boolean) { } } } + +fun Any.setPrivateField(name: String, value: Any?) { + val valueField = javaClass.getDeclaredField(name) + valueField.isAccessible = true + return valueField.set(this, value) +} + +fun Any.getPrivateField(name: String): T { + val valueField = javaClass.getDeclaredField(name) + valueField.isAccessible = true + @Suppress("UNCHECKED_CAST") + return valueField.get(this) as T +} diff --git a/chat-android/src/test/java/com/ably/utils/RoomLifecycleManagerTestHelpers.kt b/chat-android/src/test/java/com/ably/utils/RoomLifecycleManagerTestHelpers.kt new file mode 100644 index 0000000..8c0a2b0 --- /dev/null +++ b/chat-android/src/test/java/com/ably/utils/RoomLifecycleManagerTestHelpers.kt @@ -0,0 +1,38 @@ +package com.ably.utils + +import com.ably.chat.AtomicCoroutineScope +import com.ably.chat.ChatApi +import com.ably.chat.DefaultMessages +import com.ably.chat.DefaultOccupancy +import com.ably.chat.DefaultPresence +import com.ably.chat.DefaultRoomReactions +import com.ably.chat.DefaultTyping +import com.ably.chat.ResolvedContributor +import com.ably.chat.RoomLifecycleManager +import com.ably.chat.getPrivateField +import io.ably.lib.realtime.AblyRealtime +import io.ably.lib.realtime.ChannelState +import io.ably.lib.types.ClientOptions +import io.ably.lib.types.ErrorInfo +import io.mockk.mockk +import io.mockk.spyk +import io.ably.lib.realtime.Channel as AblyRealtimeChannel + +fun RoomLifecycleManager.atomicCoroutineScope(): AtomicCoroutineScope = getPrivateField("atomicCoroutineScope") + +fun AblyRealtimeChannel.setState(state: ChannelState, errorInfo: ErrorInfo? = null) { + this.state = state + this.reason = errorInfo +} + +fun createRoomFeatureMocks(roomId: String = "1234"): List { + val realtimeClient = spyk(AblyRealtime(ClientOptions("id:key").apply { autoConnect = false })) + val chatApi = mockk(relaxed = true) + + val messagesContributor = spyk(DefaultMessages(roomId, realtimeClient.channels, chatApi), recordPrivateCalls = true) + val presenceContributor = spyk(DefaultPresence(messagesContributor), recordPrivateCalls = true) + val occupancyContributor = spyk(DefaultOccupancy(messagesContributor), recordPrivateCalls = true) + val typingContributor = spyk(DefaultTyping(roomId, realtimeClient), recordPrivateCalls = true) + val reactionsContributor = spyk(DefaultRoomReactions(roomId, "client1", realtimeClient.channels), recordPrivateCalls = true) + return listOf(messagesContributor, presenceContributor, occupancyContributor, typingContributor, reactionsContributor) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0c36112..83ab262 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ lifecycle-runtime-ktx = "2.8.4" activity-compose = "1.9.1" compose-bom = "2024.06.00" gson = "2.11.0" -mockk = "1.13.12" +mockk = "1.13.13" coroutine = "1.9.0" build-config = "5.4.0"