diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/cache/AnalyticHyperskillCacheDataSourceImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/cache/AnalyticHyperskillCacheDataSourceImpl.kt index 364d069ef0..9383586c96 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/cache/AnalyticHyperskillCacheDataSourceImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/cache/AnalyticHyperskillCacheDataSourceImpl.kt @@ -10,6 +10,10 @@ class AnalyticHyperskillCacheDataSourceImpl : AnalyticHyperskillCacheDataSource events.add(event) } + override suspend fun logEvents(events: List) { + this.events.addAll(events) + } + override suspend fun getEvents(): List = events.toList() diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/data/repository/AnalyticHyperskillRepositoryImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/data/repository/AnalyticHyperskillRepositoryImpl.kt index 55e649ef86..5d361c799e 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/data/repository/AnalyticHyperskillRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/data/repository/AnalyticHyperskillRepositoryImpl.kt @@ -8,10 +8,12 @@ import org.hyperskill.app.analytic.domain.model.AnalyticEvent import org.hyperskill.app.analytic.domain.repository.AnalyticHyperskillRepository internal class AnalyticHyperskillRepositoryImpl( - private val mutex: Mutex, private val hyperskillRemoteDataSource: AnalyticHyperskillRemoteDataSource, private val hyperskillCacheDataSource: AnalyticHyperskillCacheDataSource ) : AnalyticHyperskillRepository { + + private val mutex = Mutex() + override suspend fun logEvent(event: AnalyticEvent) { mutex.withLock { hyperskillCacheDataSource.logEvent(event) @@ -24,6 +26,12 @@ internal class AnalyticHyperskillRepositoryImpl( eventsToFlush = hyperskillCacheDataSource.getEvents() hyperskillCacheDataSource.clearEvents() } - return hyperskillRemoteDataSource.flushEvents(eventsToFlush, isAuthorized) + return hyperskillRemoteDataSource + .flushEvents(eventsToFlush, isAuthorized) + .onFailure { + mutex.withLock { + hyperskillCacheDataSource.logEvents(eventsToFlush) + } + } } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/data/source/AnalyticHyperskillCacheDataSource.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/data/source/AnalyticHyperskillCacheDataSource.kt index 186a42564c..c4a740bb10 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/data/source/AnalyticHyperskillCacheDataSource.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/data/source/AnalyticHyperskillCacheDataSource.kt @@ -4,6 +4,7 @@ import org.hyperskill.app.analytic.domain.model.AnalyticEvent interface AnalyticHyperskillCacheDataSource { suspend fun logEvent(event: AnalyticEvent) + suspend fun logEvents(events: List) suspend fun getEvents(): List suspend fun clearEvents() } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticEngineImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticEngineImpl.kt index 6af20398cc..fa31fb94df 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticEngineImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticEngineImpl.kt @@ -75,6 +75,9 @@ internal class HyperskillAnalyticEngineImpl( analyticHyperskillRepository .flushEvents(isAuthorized) + .onSuccess { + logger.d { "Successfully flush events" } + } .onFailure { logger.e(it) { "Failed to flush events" } } } } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/injection/HyperskillAnalyticEngineComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/injection/HyperskillAnalyticEngineComponentImpl.kt index 4aa16b0bb3..1fd1d041f0 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/injection/HyperskillAnalyticEngineComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/injection/HyperskillAnalyticEngineComponentImpl.kt @@ -1,6 +1,5 @@ package org.hyperskill.app.analytic.injection -import kotlinx.coroutines.sync.Mutex import org.hyperskill.app.analytic.cache.AnalyticHyperskillCacheDataSourceImpl import org.hyperskill.app.analytic.data.repository.AnalyticHyperskillRepositoryImpl import org.hyperskill.app.analytic.data.source.AnalyticHyperskillCacheDataSource @@ -25,7 +24,6 @@ internal class HyperskillAnalyticEngineComponentImpl(appGraph: AppGraph) : Hyper AnalyticHyperskillCacheDataSourceImpl() private val hyperskillRepository: AnalyticHyperskillRepository = AnalyticHyperskillRepositoryImpl( - Mutex(), hyperskillRemoteDataSource, hyperskillCacheDataSource ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/presentation/AnalyticActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/presentation/AnalyticActionDispatcher.kt deleted file mode 100644 index 1394e20552..0000000000 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/presentation/AnalyticActionDispatcher.kt +++ /dev/null @@ -1,71 +0,0 @@ -package org.hyperskill.app.analytic.presentation - -import kotlinx.coroutines.CompletableJob -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor -import org.hyperskill.app.analytic.domain.model.AnalyticEvent -import ru.nobird.app.presentation.redux.dispatcher.ActionDispatcher - -/** - * Is responsible for dispatching analytic events. - * - * @property analyticInteractor the [AnalyticInteractor] used for logging analytic events - * @property logAnalyticScope the [CoroutineScope] used for logging analytic events - * @property getAnalyticEvent a function that takes an [Action] as input and returns a collection of [AnalyticEvent]. - * If the function returns null, no events will be logged. - */ -internal class AnalyticActionDispatcher( - private val analyticInteractor: AnalyticInteractor, - private val logAnalyticScope: CoroutineScope, - private val getAnalyticEvent: (Action) -> Collection? -) : ActionDispatcher { - - private var isCancelled: Boolean = false - - override fun handleAction(action: Action) { - if (isCancelled) return - - val analyticEvents = getAnalyticEvent(action) - if (!analyticEvents.isNullOrEmpty()) { - logAnalyticScope.launch { - analyticEvents.forEach { analyticEvent -> - analyticInteractor.logEvent(analyticEvent) - } - } - } - } - - override fun setListener(listener: (message: Message) -> Unit) { - // no op - } - - override fun cancel() { - isCancelled = true - (logAnalyticScope.coroutineContext[Job] as? CompletableJob)?.complete() - } - - /** - * Represents [CoroutineScope] config for logging [AnalyticEvent]. - * If [logAnalyticParentScope] is not presented uses a [Dispatchers.Main].immediate + [SupervisorJob]. - */ - interface ScopeConfigOptions { - val logAnalyticParentScope: CoroutineScope? - - val logAnalyticCoroutineExceptionHandler: CoroutineExceptionHandler - - fun createLogAnalyticScope(): CoroutineScope { - val parentScope = logAnalyticParentScope - return if (parentScope != null) { - parentScope + SupervisorJob(parentScope.coroutineContext[Job]) + logAnalyticCoroutineExceptionHandler - } else { - CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + logAnalyticCoroutineExceptionHandler) - } - } - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/presentation/WrapWithAnalyticLogger.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/presentation/WrapWithAnalyticLogger.kt index 90f576220c..3a073c3c95 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/presentation/WrapWithAnalyticLogger.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/presentation/WrapWithAnalyticLogger.kt @@ -3,21 +3,23 @@ package org.hyperskill.app.analytic.presentation import kotlinx.coroutines.CoroutineScope import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.analytic.domain.model.AnalyticEvent +import org.hyperskill.app.core.presentation.CompletableCoroutineActionDispatcher +import org.hyperskill.app.core.presentation.CompletableCoroutineActionDispatcherConfig import ru.nobird.app.presentation.redux.dispatcher.wrapWithActionDispatcher import ru.nobird.app.presentation.redux.feature.Feature /** - * Wraps the given [Feature] with an [AnalyticActionDispatcher]. + * Wraps the given [Feature] with an [CompletableCoroutineActionDispatcher]. * * @param analyticInteractor The [AnalyticInteractor] used for logging analytic events. * * @param parentScope The parent [CoroutineScope] to use for creating the logAnalyticScope. The Default value is null. - * @see [AnalyticActionDispatcher.ScopeConfigOptions] for more details. + * @see [CompletableCoroutineActionDispatcher.ScopeConfigOptions] for more details. * * @param getAnalyticEvent A function that takes an [Action] as input and returns an [AnalyticEvent]. * If the function returns null, no events will be logged. * - * @see [AnalyticActionDispatcher], [AnalyticActionDispatcherConfig], [AnalyticActionDispatcher.ScopeConfigOptions] + * @see [CompletableCoroutineActionDispatcher], [CompletableCoroutineActionDispatcherConfig], [CompletableCoroutineActionDispatcher.ScopeConfigOptions] */ internal inline fun Feature.wrapWithAnalyticLogger( analyticInteractor: AnalyticInteractor, @@ -33,17 +35,17 @@ internal inline fun Feature.wra ) /** - * Wraps the given [Feature] with an [AnalyticActionDispatcher]. + * Wraps the given [Feature] with an [CompletableCoroutineActionDispatcher]. * * @param analyticInteractor The [AnalyticInteractor] used for logging analytic events. * * @param parentScope The parent CoroutineScope to use for creating the logAnalyticScope. The Default value is null. - * @see [AnalyticActionDispatcher.ScopeConfigOptions] for more details. + * @see [CompletableCoroutineActionDispatcher.ScopeConfigOptions] for more details. * * @param getAnalyticEvent A function that takes an [Action] as input and returns a collection of [AnalyticEvent]. * If the function returns null, no events will be logged. * - * @see [AnalyticActionDispatcher], [AnalyticActionDispatcherConfig], [AnalyticActionDispatcher.ScopeConfigOptions] + * @see [CompletableCoroutineActionDispatcher], [CompletableCoroutineActionDispatcherConfig], [CompletableCoroutineActionDispatcher.ScopeConfigOptions] */ internal fun Feature.wrapWithBatchAnalyticLogger( analyticInteractor: AnalyticInteractor, @@ -63,13 +65,16 @@ internal inline fun SingleAnalyticEventActionDispatcher( analyticInteractor: AnalyticInteractor, parentScope: CoroutineScope? = null, crossinline getAnalyticEvent: (Action) -> AnalyticEvent? -): AnalyticActionDispatcher = - AnalyticActionDispatcher( - analyticInteractor = analyticInteractor, - logAnalyticScope = AnalyticActionDispatcherConfig(parentScope).createLogAnalyticScope() - ) { action -> - val event = getAnalyticEvent(action) - if (event != null) listOf(event) else null +): CompletableCoroutineActionDispatcher = + object : CompletableCoroutineActionDispatcher( + coroutineScope = CompletableCoroutineActionDispatcherConfig(parentScope).createScope() + ) { + override suspend fun handleNonCancellableAction(action: Action) { + val event = getAnalyticEvent(action) + if (event != null) { + analyticInteractor.logEvent(event) + } + } } @Suppress("FunctionName", "unused") @@ -77,9 +82,13 @@ internal inline fun BatchAnalyticEventActionDispatcher( analyticInteractor: AnalyticInteractor, parentScope: CoroutineScope? = null, noinline getAnalyticEvent: (Action) -> Collection? -): AnalyticActionDispatcher = - AnalyticActionDispatcher( - analyticInteractor = analyticInteractor, - logAnalyticScope = AnalyticActionDispatcherConfig(parentScope).createLogAnalyticScope(), - getAnalyticEvent = getAnalyticEvent - ) \ No newline at end of file +): CompletableCoroutineActionDispatcher = + object : CompletableCoroutineActionDispatcher( + coroutineScope = CompletableCoroutineActionDispatcherConfig(parentScope).createScope(), + ) { + override suspend fun handleNonCancellableAction(action: Action) { + getAnalyticEvent(action)?.forEach { analyticEvent -> + analyticInteractor.logEvent(analyticEvent) + } + } + } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/presentation/CompletableCoroutineActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/presentation/CompletableCoroutineActionDispatcher.kt new file mode 100644 index 0000000000..3f66724496 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/presentation/CompletableCoroutineActionDispatcher.kt @@ -0,0 +1,64 @@ +package org.hyperskill.app.core.presentation + +import kotlinx.coroutines.CompletableJob +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import org.hyperskill.app.analytic.domain.model.AnalyticEvent +import ru.nobird.app.presentation.redux.dispatcher.ActionDispatcher + +/** + * Base class for ActionDispatcher dispatching actions, that should not be cancelled with feature cancellation. + * E.g. analytic event logging. + * + * @param coroutineScope is not cancelled on [cancel]. + * Instead it is complete to wait for all launched coroutines to finish. + */ +internal abstract class CompletableCoroutineActionDispatcher( + private val coroutineScope: CoroutineScope +) : ActionDispatcher { + + private var isCancelled: Boolean = false + + abstract suspend fun handleNonCancellableAction(action: Action) + + override fun handleAction(action: Action) { + if (isCancelled) return + + coroutineScope.launch { + handleNonCancellableAction(action) + } + } + + override fun setListener(listener: (message: Message) -> Unit) { + // no op + } + + override fun cancel() { + isCancelled = true + (coroutineScope.coroutineContext[Job] as? CompletableJob)?.complete() + } + + /** + * Represents [CoroutineScope] config for logging [AnalyticEvent]. + * If [parentScope] is not presented uses a [Dispatchers.Main].immediate + [SupervisorJob]. + */ + interface ScopeConfigOptions { + val parentScope: CoroutineScope? + + val coroutineExceptionHandler: CoroutineExceptionHandler + + fun createScope(): CoroutineScope { + val parentScope = parentScope + return if (parentScope != null) { + parentScope + SupervisorJob(parentScope.coroutineContext[Job]) + coroutineExceptionHandler + } else { + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + coroutineExceptionHandler) + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/presentation/AnalyticActionDispatcherConfig.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/presentation/CompletableCoroutineActionDispatcherConfig.kt similarity index 57% rename from shared/src/commonMain/kotlin/org/hyperskill/app/analytic/presentation/AnalyticActionDispatcherConfig.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/core/presentation/CompletableCoroutineActionDispatcherConfig.kt index 1c6c554f11..e2de0731f2 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/presentation/AnalyticActionDispatcherConfig.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/presentation/CompletableCoroutineActionDispatcherConfig.kt @@ -1,14 +1,14 @@ -package org.hyperskill.app.analytic.presentation +package org.hyperskill.app.core.presentation import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import org.hyperskill.app.core.domain.throwError -internal class AnalyticActionDispatcherConfig( - override val logAnalyticParentScope: CoroutineScope? = null -) : AnalyticActionDispatcher.ScopeConfigOptions { - override val logAnalyticCoroutineExceptionHandler: CoroutineExceptionHandler = +internal class CompletableCoroutineActionDispatcherConfig( + override val parentScope: CoroutineScope? = null +) : CompletableCoroutineActionDispatcher.ScopeConfigOptions { + override val coroutineExceptionHandler: CoroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> if (throwable !is CancellationException) { throwError(throwable) // rethrow if not cancellation exception diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepFeatureBuilder.kt index 42fdefcd42..b550f4849e 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepFeatureBuilder.kt @@ -6,6 +6,7 @@ import org.hyperskill.app.analytic.presentation.wrapWithAnalyticLogger import org.hyperskill.app.core.domain.BuildVariant import org.hyperskill.app.core.domain.url.HyperskillUrlBuilder import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.core.presentation.CompletableCoroutineActionDispatcherConfig import org.hyperskill.app.core.presentation.transformState import org.hyperskill.app.learning_activities.domain.repository.NextLearningActivityStateRepository import org.hyperskill.app.logging.presentation.wrapWithLogger @@ -69,6 +70,11 @@ internal object StepFeatureBuilder { logger = logger.withTag(LOG_TAG) ) + val viewStepActionDispatcher = ViewStepActionDispatcher( + config = CompletableCoroutineActionDispatcherConfig(), + stepInteractor = stepInteractor + ) + val stepViewStateMapper = StepViewStateMapper(stepRoute) return ReduxFeature(StepFeature.initialState(stepRoute), stepReducer) @@ -89,6 +95,6 @@ internal object StepFeatureBuilder { .wrapWithAnalyticLogger(analyticInteractor) { (it as? InternalAction.LogAnalyticEvent)?.analyticEvent } - .wrapWithActionDispatcher(ViewStepActionDispatcher(stepInteractor)) + .wrapWithActionDispatcher(viewStepActionDispatcher) } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/ViewStepActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/ViewStepActionDispatcher.kt index 072a48ff25..6fc4a3e1b2 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/ViewStepActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/ViewStepActionDispatcher.kt @@ -1,37 +1,17 @@ package org.hyperskill.app.step.presentation -import kotlinx.coroutines.CompletableJob -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch +import org.hyperskill.app.core.presentation.CompletableCoroutineActionDispatcher +import org.hyperskill.app.core.presentation.CompletableCoroutineActionDispatcherConfig import org.hyperskill.app.step.domain.interactor.StepInteractor -import ru.nobird.app.presentation.redux.dispatcher.ActionDispatcher internal class ViewStepActionDispatcher( + config: CompletableCoroutineActionDispatcherConfig, private val stepInteractor: StepInteractor -) : ActionDispatcher { +) : CompletableCoroutineActionDispatcher(config.createScope()) { - private val coroutineScope: CoroutineScope = - CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - - private var isCancelled: Boolean = false - - override fun handleAction(action: StepFeature.Action) { - if (!isCancelled && action is StepFeature.InternalAction.ViewStep) { - coroutineScope.launch { - stepInteractor.viewStep(action.stepId, action.stepContext) - } + override suspend fun handleNonCancellableAction(action: StepFeature.Action) { + if (action is StepFeature.InternalAction.ViewStep) { + stepInteractor.viewStep(action.stepId, action.stepContext) } } - - override fun setListener(listener: (message: StepFeature.Message) -> Unit) { - // no op - } - - override fun cancel() { - isCancelled = true - (coroutineScope.coroutineContext[Job] as? CompletableJob)?.complete() - } } \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/analytic/data/repository/AnalyticHyperskillRepositoryTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/analytic/data/repository/AnalyticHyperskillRepositoryTest.kt new file mode 100644 index 0000000000..74f4dbe39a --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/analytic/data/repository/AnalyticHyperskillRepositoryTest.kt @@ -0,0 +1,99 @@ +package org.hyperskill.analytic.data.repository + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.hyperskill.app.analytic.cache.AnalyticHyperskillCacheDataSourceImpl +import org.hyperskill.app.analytic.data.repository.AnalyticHyperskillRepositoryImpl +import org.hyperskill.app.analytic.data.source.AnalyticHyperskillRemoteDataSource +import org.hyperskill.app.analytic.domain.model.AnalyticEvent +import org.hyperskill.app.analytic.domain.model.AnalyticSource + +class AnalyticHyperskillRepositoryTest { + + @Test + fun `Logged event should be written to the cache`() { + val testEvent = getAnalyticEventStub("Test event") + + val cacheDataSource = AnalyticHyperskillCacheDataSourceImpl() + + val remoteDataSource = object : AnalyticHyperskillRemoteDataSource { + override suspend fun flushEvents(events: List, isAuthorized: Boolean): Result { + TODO("Not yet implemented") + } + } + + val repository = AnalyticHyperskillRepositoryImpl(remoteDataSource, cacheDataSource) + + runBlocking { + repository.logEvent(testEvent) + val actualEvents = cacheDataSource.getEvents() + assertEquals(listOf(testEvent), actualEvents) + } + } + + @Test + fun `Cached events should be removed from the cache after flushing`() { + val initialEvent = getAnalyticEventStub("Initial") + + val cacheDataSource = AnalyticHyperskillCacheDataSourceImpl() + + val remoteDataSource = object : AnalyticHyperskillRemoteDataSource { + override suspend fun flushEvents(events: List, isAuthorized: Boolean): Result = + Result.success(Unit) + } + + val repository = AnalyticHyperskillRepositoryImpl(remoteDataSource, cacheDataSource) + + runBlocking { + repository.logEvent(initialEvent) + repository.flushEvents(isAuthorized = true) + assertTrue(cacheDataSource.getEvents().isEmpty()) + } + } + + @Test + fun `Cached events should be returned to cache after failed flushing`() { + val initialEvents = listOf( + getAnalyticEventStub("First"), + getAnalyticEventStub("Second"), + getAnalyticEventStub("Third") + ) + + val extraEvent = getAnalyticEventStub("Extra") + + val cacheDataSource = AnalyticHyperskillCacheDataSourceImpl() + val remoteDataSource = object : AnalyticHyperskillRemoteDataSource { + override suspend fun flushEvents(events: List, isAuthorized: Boolean): Result { + delay(1.seconds) + return Result.failure(Exception("Flush is failed")) + } + } + + val repository = AnalyticHyperskillRepositoryImpl(remoteDataSource, cacheDataSource) + + runBlocking { + cacheDataSource.logEvents(initialEvents) + repository.flushEvents(isAuthorized = true) + repository.logEvent(extraEvent) + assertEquals( + expected = cacheDataSource.getEvents(), + actual = initialEvents + extraEvent + ) + } + } + + private fun getAnalyticEventStub( + name: String + ): AnalyticEvent = + object : AnalyticEvent { + override val name: String = name + override val sources: Set + get() = setOf(AnalyticSource.HYPERSKILL_API) + + override fun toString(): String = name + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/core/presentation/CompletableCoroutineActionDispatcherTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/core/presentation/CompletableCoroutineActionDispatcherTest.kt new file mode 100644 index 0000000000..64cbaeaa15 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/core/presentation/CompletableCoroutineActionDispatcherTest.kt @@ -0,0 +1,151 @@ +package org.hyperskill.core.presentation + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.hyperskill.app.core.presentation.CompletableCoroutineActionDispatcher +import org.hyperskill.app.core.presentation.CompletableCoroutineActionDispatcherConfig +import ru.nobird.app.presentation.redux.dispatcher.wrapWithActionDispatcher +import ru.nobird.app.presentation.redux.feature.ReduxFeature +import ru.nobird.app.presentation.redux.reducer.StateReducer + +class CompletableCoroutineActionDispatcherTest { + + @Test + fun `Action handling should not be cancelled if the feature is cancelled`() { + runBlocking { + val actionDispatcherScope = + CompletableCoroutineActionDispatcherConfig(this).createScope() + + val actualHandledActions = mutableListOf() + + val actionDispatcher = + object : CompletableCoroutineActionDispatcher( + coroutineScope = actionDispatcherScope + ) { + override suspend fun handleNonCancellableAction(action: TestFeature.Action) { + delay(5) + actualHandledActions.add(action) + } + } + + val feature = ReduxFeature( + initialState = TestFeature.State, + reducer = TestReducer() + ).wrapWithActionDispatcher(actionDispatcher) + + feature.addActionListener { action -> + if (action is TestFeature.Action.CancelFeatureAction) { + feature.cancel() + } + } + + feature.onNewMessage(TestFeature.Message.ProduceThreeActionsAndCancel) + + // Wait for actionDispatcher tasks to be finished + actionDispatcherScope.coroutineContext[Job]?.join() + + val expectedHandledActions = listOf( + TestFeature.Action.Action1, + TestFeature.Action.Action2, + TestFeature.Action.Action3, + TestFeature.Action.CancelFeatureAction + ) + + assertContentEquals( + expected = expectedHandledActions, + actual = actualHandledActions + ) + } + } + + @Test + fun `Actions produced after feature cancellation should not be handled`() { + runBlocking { + val actionDispatcherScope = + CompletableCoroutineActionDispatcherConfig(this).createScope() + + val actualHandledActions = mutableListOf() + + val actionDispatcher = + object : CompletableCoroutineActionDispatcher( + coroutineScope = actionDispatcherScope + ) { + override suspend fun handleNonCancellableAction(action: TestFeature.Action) { + delay(5) + actualHandledActions.add(action) + } + } + + val feature = ReduxFeature( + initialState = TestFeature.State, + reducer = TestReducer() + ).wrapWithActionDispatcher(actionDispatcher) + + feature.addActionListener { action -> + if (action is TestFeature.Action.CancelFeatureAction) { + feature.cancel() + } + } + + feature.onNewMessage(TestFeature.Message.ProduceThreeActionsWithCancellationInTheMiddle) + + // Wait for actionDispatcher tasks to be finished + actionDispatcherScope.coroutineContext[Job]?.join() + + val expectedHandledActions = listOf( + TestFeature.Action.Action1, + TestFeature.Action.CancelFeatureAction + ) + + assertContentEquals( + expected = expectedHandledActions, + actual = actualHandledActions + ) + } + } +} + +private object TestFeature { + object State + + sealed interface Message { + data object ProduceThreeActionsAndCancel : Message + data object ProduceThreeActionsWithCancellationInTheMiddle : Message + } + + sealed interface Action { + data object Action1 : Action + data object Action2 : Action + data object Action3 : Action + + data object CancelFeatureAction : Action + } +} + +private class TestReducer : StateReducer { + override fun reduce( + state: TestFeature.State, + message: TestFeature.Message + ): Pair> = + when (message) { + TestFeature.Message.ProduceThreeActionsAndCancel -> { + state to setOf( + TestFeature.Action.Action1, + TestFeature.Action.Action2, + TestFeature.Action.Action3, + TestFeature.Action.CancelFeatureAction + ) + } + TestFeature.Message.ProduceThreeActionsWithCancellationInTheMiddle -> { + state to setOf( + TestFeature.Action.Action1, + TestFeature.Action.CancelFeatureAction, + TestFeature.Action.Action2, + TestFeature.Action.Action3 + ) + } + } +} \ No newline at end of file