From c9c316885d141418613f54bff0ddaa30d311b89f Mon Sep 17 00:00:00 2001 From: Mohit Mandalia Date: Wed, 26 Nov 2025 23:24:12 +0530 Subject: [PATCH 1/2] Test ProfileSettingsViewModel --- feature-ui-settings/build.gradle | 10 + .../account/ProfileSettingsViewModelTest.kt | 261 ++++++++++++++++++ .../util/DefaultCoroutineTestRule.kt | 31 +++ 3 files changed, 302 insertions(+) create mode 100644 feature-ui-settings/src/test/java/com/anytypeio/anytype/ui_settings/account/ProfileSettingsViewModelTest.kt create mode 100644 feature-ui-settings/src/test/java/com/anytypeio/anytype/ui_settings/util/DefaultCoroutineTestRule.kt diff --git a/feature-ui-settings/build.gradle b/feature-ui-settings/build.gradle index eac608f655..710efaa146 100644 --- a/feature-ui-settings/build.gradle +++ b/feature-ui-settings/build.gradle @@ -46,4 +46,14 @@ dependencies { testImplementation libs.junit testImplementation libs.kotlinTest + + testImplementation project(':test:android-utils') + testImplementation project(':test:utils') + testImplementation project(":test:core-models-stub") + testImplementation libs.robolectric + testImplementation libs.androidXTestCore + testImplementation libs.mockitoKotlin + testImplementation libs.coroutineTesting + testImplementation libs.timberJUnit + testImplementation libs.turbine } \ No newline at end of file diff --git a/feature-ui-settings/src/test/java/com/anytypeio/anytype/ui_settings/account/ProfileSettingsViewModelTest.kt b/feature-ui-settings/src/test/java/com/anytypeio/anytype/ui_settings/account/ProfileSettingsViewModelTest.kt new file mode 100644 index 0000000000..f5c603fefc --- /dev/null +++ b/feature-ui-settings/src/test/java/com/anytypeio/anytype/ui_settings/account/ProfileSettingsViewModelTest.kt @@ -0,0 +1,261 @@ +package com.anytypeio.anytype.ui_settings.account + +import com.anytypeio.anytype.analytics.base.Analytics +import com.anytypeio.anytype.core_models.Hash +import com.anytypeio.anytype.core_models.NetworkMode +import com.anytypeio.anytype.core_models.NetworkModeConfig +import com.anytypeio.anytype.core_models.Payload +import com.anytypeio.anytype.core_models.Relations +import com.anytypeio.anytype.core_models.StubConfig +import com.anytypeio.anytype.core_models.membership.Membership +import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod +import com.anytypeio.anytype.core_models.membership.MembershipStatus +import com.anytypeio.anytype.core_models.membership.TierId +import com.anytypeio.anytype.core_models.primitives.SpaceId +import com.anytypeio.anytype.domain.base.Either +import com.anytypeio.anytype.domain.base.Resultat +import com.anytypeio.anytype.domain.config.ConfigStorage +import com.anytypeio.anytype.domain.icon.RemoveObjectIcon +import com.anytypeio.anytype.domain.icon.SetDocumentImageIcon +import com.anytypeio.anytype.domain.icon.SetImageIcon +import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer +import com.anytypeio.anytype.domain.misc.UrlBuilder +import com.anytypeio.anytype.domain.networkmode.GetNetworkMode +import com.anytypeio.anytype.domain.`object`.SetObjectDetails +import com.anytypeio.anytype.domain.search.ProfileSubscriptionManager +import com.anytypeio.anytype.presentation.membership.provider.MembershipProvider +import com.anytypeio.anytype.presentation.notifications.NotificationPermissionManager +import com.anytypeio.anytype.ui_settings.util.DefaultCoroutineTestRule +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever + +class ProfileSettingsViewModelTest { + + @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val coroutineTestRule = DefaultCoroutineTestRule() + + @Mock private lateinit var analytics: Analytics + @Mock private lateinit var container: StorelessSubscriptionContainer + @Mock private lateinit var setObjectDetails: SetObjectDetails + @Mock private lateinit var configStorage: ConfigStorage + @Mock private lateinit var urlBuilder: UrlBuilder + @Mock private lateinit var setImageIcon: SetDocumentImageIcon + @Mock private lateinit var membershipProvider: MembershipProvider + @Mock private lateinit var getNetworkMode: GetNetworkMode + @Mock private lateinit var profileContainer: ProfileSubscriptionManager + @Mock private lateinit var removeObjectIcon: RemoveObjectIcon + @Mock private lateinit var notificationPermissionManager: NotificationPermissionManager + + private val defaultNetworkMode = NetworkMode.CUSTOM + private val defaultMembershipStatus = MembershipStatus( + activeTier = TierId(1), + status = Membership.Status.STATUS_ACTIVE, + paymentMethod = MembershipPaymentMethod.METHOD_INAPP_GOOGLE, + anyName = "testName", + tiers = emptyList(), + dateEnds = 123, + formattedDateEnds = "123" + ) + + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + setupDefaultMocks() + } + + + private fun createViewModel(): ProfileSettingsViewModel { + return ProfileSettingsViewModel( + analytics = analytics, + container = container, + setObjectDetails = setObjectDetails, + configStorage = configStorage, + urlBuilder = urlBuilder, + setImageIcon = setImageIcon, + membershipProvider = membershipProvider, + getNetworkMode = getNetworkMode, + profileContainer = profileContainer, + removeObjectIcon = removeObjectIcon, + notificationPermissionManager = notificationPermissionManager + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `should update membership status on init based on network mode`() = runTest { + + val vm = createViewModel() + advanceUntilIdle() + + verifyBlocking(getNetworkMode, times(1)) { async(Unit) } + + assertEquals( + expected = ShowMembership(true), + actual = vm.showMembershipState.value + ) + + assertEquals( + expected = defaultMembershipStatus, + actual = vm.membershipStatusState.value + ) + + } + + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `object details get changed when name changes`() = runTest { + val name = "test name" + val stubConfig = StubConfig() + val setObjectDetailsParams = SetObjectDetails.Params( + ctx = stubConfig.profile, + details = mapOf(Relations.NAME to name) + ) + val payload = Payload(context = "Test ctx", events = emptyList()) + whenever(configStorage.getOrNull()).thenReturn(stubConfig) + whenever(setObjectDetails.execute(any())).thenReturn(Resultat.Success(payload)) + + val vm = createViewModel() + + vm.onNameChange(name) + + advanceUntilIdle() + + verifyBlocking(setObjectDetails, times(1)) { execute(setObjectDetailsParams) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `object details does not get changed when profile is null`() = runTest { + val name = "test name" + val stubConfig = StubConfig() + val setObjectDetailsParams = SetObjectDetails.Params( + ctx = stubConfig.profile, + details = mapOf(Relations.NAME to name) + ) + whenever(configStorage.getOrNull()).thenReturn(null) + + val vm = createViewModel() + + vm.onNameChange(name) + + advanceUntilIdle() + + verifyBlocking(setObjectDetails, times(0)) { execute(setObjectDetailsParams) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `image icon gets set when image picked from device`() = runTest { + val path = "test path" + val stubConfig = StubConfig() + val params = SetImageIcon.Params( + target = stubConfig.profile, + path = path, + spaceId = SpaceId(stubConfig.techSpace) + ) + val testId: Hash = "uploaded-file-id" + + whenever(configStorage.getOrNull()).thenReturn(stubConfig) + whenever(setImageIcon(params)).thenReturn( + Either.Right( + Pair( + Payload(context = "Test ctx", events = emptyList()), + testId + ) + ) + ) + + val vm = createViewModel() + vm.onPickedImageFromDevice(path) + + advanceUntilIdle() + + verifyBlocking(setImageIcon, times(1)) { invoke(params) } + + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `image icon gets does not set when config is null`() = runTest { + val path = "test path" + whenever(configStorage.getOrNull()).thenReturn(null) + + val vm = createViewModel() + vm.onPickedImageFromDevice(path) + + advanceUntilIdle() + + verifyBlocking(setImageIcon, never()) { invoke(any()) } + + } + + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `remove object called when profile image is cleared`() = runTest { + val stubConfig = StubConfig() + val removeObjectIconParams = RemoveObjectIcon.Params( + objectId = stubConfig.profile + ) + + whenever(configStorage.getOrNull()).thenReturn(stubConfig) + whenever(removeObjectIcon.async(removeObjectIconParams)).thenReturn(Resultat.Success(Unit)) + + val vm = createViewModel() + vm.onClearProfileImage() + + advanceUntilIdle() + + verifyBlocking(removeObjectIcon, times(1)) { async(removeObjectIconParams) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `remove object not called when profile image is cleared as config is null`() = runTest { + whenever(configStorage.getOrNull()).thenReturn(null) + + val vm = createViewModel() + vm.onClearProfileImage() + + advanceUntilIdle() + + verifyBlocking(removeObjectIcon, never()) { async(any()) } + } + + @Test + fun `refresh permission state`() { + val vm = createViewModel() + vm.refreshPermissionState() + verify(notificationPermissionManager).refreshPermissionState() + } + + + private fun setupDefaultMocks() { + + runBlocking { + whenever(getNetworkMode.async(any())).thenReturn(Resultat.Success(NetworkModeConfig(defaultNetworkMode))) + } + whenever(membershipProvider.status()).thenReturn( + flowOf(defaultMembershipStatus) + ) + } + +} \ No newline at end of file diff --git a/feature-ui-settings/src/test/java/com/anytypeio/anytype/ui_settings/util/DefaultCoroutineTestRule.kt b/feature-ui-settings/src/test/java/com/anytypeio/anytype/ui_settings/util/DefaultCoroutineTestRule.kt new file mode 100644 index 0000000000..41e7593eb5 --- /dev/null +++ b/feature-ui-settings/src/test/java/com/anytypeio/anytype/ui_settings/util/DefaultCoroutineTestRule.kt @@ -0,0 +1,31 @@ +package com.anytypeio.anytype.ui_settings.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@ExperimentalCoroutinesApi +class DefaultCoroutineTestRule( + val dispatcher: TestDispatcher = StandardTestDispatcher() +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } + + fun advanceTime(millis: Long = 100L) { + dispatcher.scheduler.advanceTimeBy(millis) + } + + fun advanceUntilIdle() = dispatcher.scheduler.advanceUntilIdle() + +} \ No newline at end of file From 105ae1465c17db0a9a660783aa540b3506d8831b Mon Sep 17 00:00:00 2001 From: Mohit Mandalia Date: Thu, 27 Nov 2025 20:11:46 +0530 Subject: [PATCH 2/2] Test LogoutWarningViewModel --- .../account/LogoutWarningViewModelTest.kt | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 feature-ui-settings/src/test/java/com/anytypeio/anytype/ui_settings/account/LogoutWarningViewModelTest.kt diff --git a/feature-ui-settings/src/test/java/com/anytypeio/anytype/ui_settings/account/LogoutWarningViewModelTest.kt b/feature-ui-settings/src/test/java/com/anytypeio/anytype/ui_settings/account/LogoutWarningViewModelTest.kt new file mode 100644 index 0000000000..d87a083874 --- /dev/null +++ b/feature-ui-settings/src/test/java/com/anytypeio/anytype/ui_settings/account/LogoutWarningViewModelTest.kt @@ -0,0 +1,132 @@ +package com.anytypeio.anytype.ui_settings.account + +import app.cash.turbine.test +import com.anytypeio.anytype.analytics.base.Analytics +import com.anytypeio.anytype.analytics.base.EventsDictionary +import com.anytypeio.anytype.analytics.base.EventsPropertiesKey +import com.anytypeio.anytype.analytics.event.EventAnalytics +import com.anytypeio.anytype.analytics.props.Props +import com.anytypeio.anytype.domain.auth.interactor.Logout +import com.anytypeio.anytype.domain.base.Interactor +import com.anytypeio.anytype.domain.misc.AppActionManager +import com.anytypeio.anytype.domain.subscriptions.GlobalSubscriptionManager +import com.anytypeio.anytype.ui_settings.account.LogoutWarningViewModel.Command +import com.anytypeio.anytype.ui_settings.util.DefaultCoroutineTestRule +import junit.framework.TestCase.assertTrue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever + + +@OptIn(ExperimentalCoroutinesApi::class) +class LogoutWarningViewModelTest { + + @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val coroutineTestRule = DefaultCoroutineTestRule() + + @Mock lateinit var logout: Logout + @Mock lateinit var analytics: Analytics + @Mock lateinit var appActionManager: AppActionManager + @Mock lateinit var globalSubscriptionManager: GlobalSubscriptionManager + + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + setupDefaultMocks() + } + + private fun setupDefaultMocks() { + + } + + @Test + fun `should cleanup and emit correct command when logout success`() = runTest { + + whenever(logout.invoke(any())).thenReturn( + flowOf(Interactor.Status.Success) + ) + + val vm = createViewModel() + + vm.onLogoutClicked() + + vm.commands.test { + assertEquals(Command.Logout,awaitItem()) + } + + advanceUntilIdle() + + + + verifyBlocking(logout, times(1)) { invoke(any()) } + verifyBlocking(analytics, times(1)) { + registerEvent(any()) + } + verify(appActionManager).setup(any()) + verify(globalSubscriptionManager).onStop() + } + + @Test + fun `loggingOut should be true when interactor status is started`() = runTest { + whenever(logout.invoke(any())).thenReturn( + flowOf(Interactor.Status.Started) + ) + + val vm = createViewModel() + + vm.onLogoutClicked() + + vm.isLoggingOut.test { + val first = awaitItem() + assertFalse(first) + + val second = awaitItem() + assertTrue(second) + + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun `command emits show error when logout fails`() = runTest { + val error = Exception("Test Error") + whenever(logout.invoke(any())).thenReturn( + flowOf(Interactor.Status.Error(error)) + ) + + val vm = createViewModel() + + vm.onLogoutClicked() + + vm.commands.test { + assertEquals(Command.ShowError(error.message ?: ""),awaitItem()) + } + } + + + private fun createViewModel(): LogoutWarningViewModel { + return LogoutWarningViewModel( + logout = logout, + analytics = analytics, + appActionManager = appActionManager, + globalSubscriptionManager = globalSubscriptionManager + ) + } + +} \ No newline at end of file