From 017b902c3c3762365eba93dc8b187de445763015 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 17 Apr 2026 16:41:14 -0400 Subject: [PATCH] Increase regV5 test coverage. --- .../registration/RegistrationNavigation.kt | 1 - .../RemoteBackupRestoreViewModel.kt | 13 +- .../RegistrationViewModelRestoreTest.kt | 251 -------- .../registration/RegistrationViewModelTest.kt | 563 ++++++++++++++++++ .../EnterAepForLocalBackupViewModelTest.kt | 96 +++ ...moteBackupPostRegistrationViewModelTest.kt | 98 +++ ...emoteBackupPreRegistrationViewModelTest.kt | 258 ++++++++ .../LocalBackupRestoreViewModelTest.kt | 230 +++++++ .../pincreation/PinCreationViewModelTest.kt | 128 ++++ .../QuickRestoreQrViewModelTest.kt | 148 +++++ .../RemoteBackupRestoreViewModelTest.kt | 197 ++++++ .../ArchiveRestoreSelectionViewModelTest.kt | 237 ++++++++ 12 files changed, 1961 insertions(+), 259 deletions(-) delete mode 100644 feature/registration/src/test/java/org/signal/registration/RegistrationViewModelRestoreTest.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/RegistrationViewModelTest.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModelTest.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModelTest.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModelTest.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModelTest.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModelTest.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModelTest.kt diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index c3ce0c84cc..7da631bc01 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -525,7 +525,6 @@ private fun EntryProviderScope.navigationEntries( factory = RemoteBackupRestoreViewModel.Factory( aep = key.aep, repository = registrationRepository, - parentState = registrationViewModel.state, parentEventEmitter = registrationViewModel::onEvent ) ) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModel.kt index 910044d66d..29be78f105 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModel.kt @@ -22,16 +22,16 @@ import org.signal.core.util.logging.Log import org.signal.libsignal.net.RequestResult import org.signal.registration.NetworkController import org.signal.registration.RegistrationFlowEvent -import org.signal.registration.RegistrationFlowState import org.signal.registration.RegistrationRepository import org.signal.registration.screens.EventDrivenViewModel import org.signal.registration.screens.util.navigateBack +import kotlin.coroutines.CoroutineContext class RemoteBackupRestoreViewModel( private val aep: AccountEntropyPool, private val repository: RegistrationRepository, - private val parentState: StateFlow, - private val parentEventEmitter: (RegistrationFlowEvent) -> Unit + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit, + private val ioDispatcher: CoroutineContext = Dispatchers.IO ) : EventDrivenViewModel(TAG) { companion object { @@ -162,7 +162,7 @@ class RemoteBackupRestoreViewModel( viewModelScope.launch { _state.value = _state.value.copy(loadState = RemoteBackupRestoreState.LoadState.Loading, loadAttempts = _state.value.loadAttempts + 1) - val result = withContext(Dispatchers.IO) { + val result = withContext(ioDispatcher) { repository.getRemoteBackupInfo(_state.value.aep) } @@ -172,7 +172,7 @@ class RemoteBackupRestoreViewModel( // parentEventEmitter(RegistrationFlowEvent) - val lastModifiedResult = withContext(Dispatchers.IO) { + val lastModifiedResult = withContext(ioDispatcher) { repository.getBackupFileLastModified(_state.value.aep, info) } @@ -209,11 +209,10 @@ class RemoteBackupRestoreViewModel( class Factory( private val aep: AccountEntropyPool, private val repository: RegistrationRepository, - private val parentState: StateFlow, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return RemoteBackupRestoreViewModel(aep, repository, parentState, parentEventEmitter) as T + return RemoteBackupRestoreViewModel(aep, repository, parentEventEmitter) as T } } } diff --git a/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelRestoreTest.kt b/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelRestoreTest.kt deleted file mode 100644 index 4d1de1b345..0000000000 --- a/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelRestoreTest.kt +++ /dev/null @@ -1,251 +0,0 @@ -/* - * Copyright 2025 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.registration - -import androidx.lifecycle.SavedStateHandle -import assertk.assertThat -import assertk.assertions.isEqualTo -import assertk.assertions.isNull -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class RegistrationViewModelRestoreTest { - - private val testDispatcher = StandardTestDispatcher() - private lateinit var mockRepository: RegistrationRepository - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - mockRepository = mockk(relaxed = true) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `no saved state starts fresh and loads preExistingRegistrationData`() = runTest(testDispatcher) { - coEvery { mockRepository.restoreFlowState() } returns null - coEvery { mockRepository.getPreExistingRegistrationData() } returns null - - val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) - advanceUntilIdle() - - val state = viewModel.state.value - assertThat(state.backStack).isEqualTo(listOf(RegistrationRoute.Welcome)) - assertThat(state.sessionMetadata).isNull() - } - - @Test - fun `restore with valid session proceeds normally with updated session metadata`() = runTest(testDispatcher) { - val savedSession = createSessionMetadata("session-1") - val freshSession = createSessionMetadata("session-1").copy(nextSms = 9999L) - - val savedState = RegistrationFlowState( - backStack = listOf( - RegistrationRoute.Welcome, - RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry), - RegistrationRoute.PhoneNumberEntry, - RegistrationRoute.VerificationCodeEntry - ), - sessionMetadata = savedSession, - sessionE164 = "+15551234567" - ) - - coEvery { mockRepository.restoreFlowState() } returns savedState - coEvery { mockRepository.validateSession("session-1") } returns freshSession - - val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) - advanceUntilIdle() - - val state = viewModel.state.value - assertThat(state.backStack).isEqualTo(savedState.backStack) - assertThat(state.sessionMetadata).isEqualTo(freshSession) - assertThat(state.sessionE164).isEqualTo("+15551234567") - } - - @Test - fun `restore with expired session and not registered resets to PhoneNumberEntry with e164 preserved`() = runTest(testDispatcher) { - val savedSession = createSessionMetadata("session-expired") - - val savedState = RegistrationFlowState( - backStack = listOf( - RegistrationRoute.Welcome, - RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry), - RegistrationRoute.PhoneNumberEntry, - RegistrationRoute.VerificationCodeEntry - ), - sessionMetadata = savedSession, - sessionE164 = "+15559876543" - ) - - coEvery { mockRepository.restoreFlowState() } returns savedState - coEvery { mockRepository.validateSession("session-expired") } returns null - coEvery { mockRepository.isRegistered() } returns false - - val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) - advanceUntilIdle() - - val state = viewModel.state.value - assertThat(state.backStack).isEqualTo( - listOf( - RegistrationRoute.Welcome, - RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry), - RegistrationRoute.PhoneNumberEntry - ) - ) - assertThat(state.sessionMetadata).isNull() - assertThat(state.sessionE164).isEqualTo("+15559876543") - } - - @Test - fun `restore with expired session and already registered proceeds with null session`() = runTest(testDispatcher) { - val savedSession = createSessionMetadata("session-expired-2") - - val savedState = RegistrationFlowState( - backStack = listOf( - RegistrationRoute.Welcome, - RegistrationRoute.PinCreate - ), - sessionMetadata = savedSession, - sessionE164 = "+15551234567" - ) - - coEvery { mockRepository.restoreFlowState() } returns savedState - coEvery { mockRepository.validateSession("session-expired-2") } returns null - coEvery { mockRepository.isRegistered() } returns true - - val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) - advanceUntilIdle() - - val state = viewModel.state.value - assertThat(state.backStack).isEqualTo(listOf(RegistrationRoute.Welcome, RegistrationRoute.PinCreate)) - assertThat(state.sessionMetadata).isNull() - assertThat(state.sessionE164).isEqualTo("+15551234567") - } - - @Test - fun `restore with no session skips validation`() = runTest(testDispatcher) { - val savedState = RegistrationFlowState( - backStack = listOf( - RegistrationRoute.Welcome, - RegistrationRoute.PinCreate, - RegistrationRoute.ArchiveRestoreSelection.forManualRestore() - ), - sessionMetadata = null, - sessionE164 = "+15551234567", - doNotAttemptRecoveryPassword = true - ) - - coEvery { mockRepository.restoreFlowState() } returns savedState - - val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) - advanceUntilIdle() - - val state = viewModel.state.value - assertThat(state.backStack).isEqualTo(savedState.backStack) - assertThat(state.sessionMetadata).isNull() - assertThat(state.doNotAttemptRecoveryPassword).isEqualTo(true) - - coVerify(exactly = 0) { mockRepository.validateSession(any()) } - } - - @Test - fun `onEvent ResetState clears flow state`() = runTest(testDispatcher) { - coEvery { mockRepository.restoreFlowState() } returns null - coEvery { mockRepository.getPreExistingRegistrationData() } returns null - - val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) - advanceUntilIdle() - - viewModel.onEvent(RegistrationFlowEvent.ResetState) - advanceUntilIdle() - - coVerify { mockRepository.clearFlowState() } - } - - @Test - fun `onEvent NavigateToScreen FullyComplete clears flow state`() = runTest(testDispatcher) { - coEvery { mockRepository.restoreFlowState() } returns null - coEvery { mockRepository.getPreExistingRegistrationData() } returns null - - val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) - advanceUntilIdle() - - viewModel.onEvent(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete)) - advanceUntilIdle() - - coVerify { mockRepository.clearFlowState() } - } - - @Test - fun `onEvent NavigateToScreen saves flow state`() = runTest(testDispatcher) { - coEvery { mockRepository.restoreFlowState() } returns null - coEvery { mockRepository.getPreExistingRegistrationData() } returns null - - val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) - advanceUntilIdle() - - viewModel.onEvent(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.PhoneNumberEntry)) - advanceUntilIdle() - - coVerify { mockRepository.saveFlowState(any()) } - } - - @Test - fun `onEvent Registered does not save flow state`() = runTest(testDispatcher) { - coEvery { mockRepository.restoreFlowState() } returns null - coEvery { mockRepository.getPreExistingRegistrationData() } returns null - - val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) - advanceUntilIdle() - - viewModel.onEvent(RegistrationFlowEvent.Registered(org.signal.core.models.AccountEntropyPool.generate())) - advanceUntilIdle() - - coVerify(exactly = 0) { mockRepository.saveFlowState(any()) } - } - - @Test - fun `onEvent MasterKeyRestoredFromSvr does not save flow state`() = runTest(testDispatcher) { - coEvery { mockRepository.restoreFlowState() } returns null - coEvery { mockRepository.getPreExistingRegistrationData() } returns null - - val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) - advanceUntilIdle() - - viewModel.onEvent(RegistrationFlowEvent.MasterKeyRestoredFromSvr(org.signal.core.models.MasterKey(ByteArray(32)))) - advanceUntilIdle() - - coVerify(exactly = 0) { mockRepository.saveFlowState(any()) } - } - - private fun createSessionMetadata(id: String = "test-session"): NetworkController.SessionMetadata { - return NetworkController.SessionMetadata( - id = id, - nextSms = 1000L, - nextCall = 2000L, - nextVerificationAttempt = 3000L, - allowedToRequestCode = true, - requestedInformation = emptyList(), - verified = false - ) - } -} diff --git a/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelTest.kt new file mode 100644 index 0000000000..0ba5e104ca --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/RegistrationViewModelTest.kt @@ -0,0 +1,563 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration + +import androidx.lifecycle.SavedStateHandle +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.signal.core.models.AccountEntropyPool +import org.signal.core.models.MasterKey + +@OptIn(ExperimentalCoroutinesApi::class) +class RegistrationViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockRepository: RegistrationRepository + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + mockRepository = mockk(relaxed = true) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + // ==================== Restore Flow State Tests ==================== + + @Test + fun `no saved state starts fresh and loads preExistingRegistrationData`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val state = viewModel.state.value + assertThat(state.backStack).isEqualTo(listOf(RegistrationRoute.Welcome)) + assertThat(state.sessionMetadata).isNull() + } + + @Test + fun `restore with valid session proceeds normally with updated session metadata`() = runTest(testDispatcher) { + val savedSession = createSessionMetadata("session-1") + val freshSession = createSessionMetadata("session-1").copy(nextSms = 9999L) + + val savedState = RegistrationFlowState( + backStack = listOf( + RegistrationRoute.Welcome, + RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry), + RegistrationRoute.PhoneNumberEntry, + RegistrationRoute.VerificationCodeEntry + ), + sessionMetadata = savedSession, + sessionE164 = "+15551234567" + ) + + coEvery { mockRepository.restoreFlowState() } returns savedState + coEvery { mockRepository.validateSession("session-1") } returns freshSession + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val state = viewModel.state.value + assertThat(state.backStack).isEqualTo(savedState.backStack) + assertThat(state.sessionMetadata).isEqualTo(freshSession) + assertThat(state.sessionE164).isEqualTo("+15551234567") + } + + @Test + fun `restore with expired session and not registered resets to PhoneNumberEntry with e164 preserved`() = runTest(testDispatcher) { + val savedSession = createSessionMetadata("session-expired") + + val savedState = RegistrationFlowState( + backStack = listOf( + RegistrationRoute.Welcome, + RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry), + RegistrationRoute.PhoneNumberEntry, + RegistrationRoute.VerificationCodeEntry + ), + sessionMetadata = savedSession, + sessionE164 = "+15559876543" + ) + + coEvery { mockRepository.restoreFlowState() } returns savedState + coEvery { mockRepository.validateSession("session-expired") } returns null + coEvery { mockRepository.isRegistered() } returns false + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val state = viewModel.state.value + assertThat(state.backStack).isEqualTo( + listOf( + RegistrationRoute.Welcome, + RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry), + RegistrationRoute.PhoneNumberEntry + ) + ) + assertThat(state.sessionMetadata).isNull() + assertThat(state.sessionE164).isEqualTo("+15559876543") + } + + @Test + fun `restore with expired session and already registered proceeds with null session`() = runTest(testDispatcher) { + val savedSession = createSessionMetadata("session-expired-2") + + val savedState = RegistrationFlowState( + backStack = listOf( + RegistrationRoute.Welcome, + RegistrationRoute.PinCreate + ), + sessionMetadata = savedSession, + sessionE164 = "+15551234567" + ) + + coEvery { mockRepository.restoreFlowState() } returns savedState + coEvery { mockRepository.validateSession("session-expired-2") } returns null + coEvery { mockRepository.isRegistered() } returns true + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val state = viewModel.state.value + assertThat(state.backStack).isEqualTo(listOf(RegistrationRoute.Welcome, RegistrationRoute.PinCreate)) + assertThat(state.sessionMetadata).isNull() + assertThat(state.sessionE164).isEqualTo("+15551234567") + } + + @Test + fun `restore with no session skips validation`() = runTest(testDispatcher) { + val savedState = RegistrationFlowState( + backStack = listOf( + RegistrationRoute.Welcome, + RegistrationRoute.PinCreate, + RegistrationRoute.ArchiveRestoreSelection.forManualRestore() + ), + sessionMetadata = null, + sessionE164 = "+15551234567", + doNotAttemptRecoveryPassword = true + ) + + coEvery { mockRepository.restoreFlowState() } returns savedState + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val state = viewModel.state.value + assertThat(state.backStack).isEqualTo(savedState.backStack) + assertThat(state.sessionMetadata).isNull() + assertThat(state.doNotAttemptRecoveryPassword).isEqualTo(true) + + coVerify(exactly = 0) { mockRepository.validateSession(any()) } + } + + @Test + fun `init clears isRestoringNavigationState after restore completes`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + assertThat(viewModel.state.value.isRestoringNavigationState).isEqualTo(false) + } + + // ==================== Persistence Side-Effect Tests ==================== + + @Test + fun `onEvent ResetState clears flow state`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + viewModel.onEvent(RegistrationFlowEvent.ResetState) + advanceUntilIdle() + + coVerify { mockRepository.clearFlowState() } + } + + @Test + fun `onEvent NavigateToScreen FullyComplete clears flow state`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + viewModel.onEvent(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete)) + advanceUntilIdle() + + coVerify { mockRepository.clearFlowState() } + } + + @Test + fun `onEvent NavigateToScreen saves flow state`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + viewModel.onEvent(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.PhoneNumberEntry)) + advanceUntilIdle() + + coVerify { mockRepository.saveFlowState(any()) } + } + + @Test + fun `onEvent Registered does not save flow state`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + viewModel.onEvent(RegistrationFlowEvent.Registered(AccountEntropyPool.generate())) + advanceUntilIdle() + + coVerify(exactly = 0) { mockRepository.saveFlowState(any()) } + } + + @Test + fun `onEvent MasterKeyRestoredFromSvr does not save flow state`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + viewModel.onEvent(RegistrationFlowEvent.MasterKeyRestoredFromSvr(MasterKey(ByteArray(32)))) + advanceUntilIdle() + + coVerify(exactly = 0) { mockRepository.saveFlowState(any()) } + } + + @Test + fun `onEvent RegistrationComplete commits final data and clears flow state`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + viewModel.onEvent(RegistrationFlowEvent.RegistrationComplete) + advanceUntilIdle() + + coVerify { mockRepository.commitFinalRegistrationData() } + coVerify { mockRepository.clearFlowState() } + } + + // ==================== applyEvent Tests (Navigation & State Reducers) ==================== + + @Test + fun `applyEvent NavigateToScreen appends to backStack`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val initialState = RegistrationFlowState(backStack = listOf(RegistrationRoute.Welcome)) + + val result = viewModel.applyEvent( + initialState, + RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.PhoneNumberEntry) + ) + + assertThat(result.backStack).isEqualTo( + listOf(RegistrationRoute.Welcome, RegistrationRoute.PhoneNumberEntry) + ) + } + + @Test + fun `applyEvent NavigateBack pops last from backStack`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val initialState = RegistrationFlowState( + backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.PhoneNumberEntry) + ) + + val result = viewModel.applyEvent(initialState, RegistrationFlowEvent.NavigateBack) + + assertThat(result.backStack).isEqualTo(listOf(RegistrationRoute.Welcome)) + } + + @Test + fun `applyEvent ResetState returns default state`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val populatedState = RegistrationFlowState( + backStack = listOf(RegistrationRoute.Welcome, RegistrationRoute.PhoneNumberEntry), + sessionMetadata = createSessionMetadata(), + sessionE164 = "+15551234567", + doNotAttemptRecoveryPassword = true + ) + + val result = viewModel.applyEvent(populatedState, RegistrationFlowEvent.ResetState) + + assertThat(result.backStack).isEqualTo(listOf(RegistrationRoute.Welcome)) + assertThat(result.sessionMetadata).isNull() + assertThat(result.sessionE164).isNull() + assertThat(result.doNotAttemptRecoveryPassword).isEqualTo(false) + assertThat(result.isRestoringNavigationState).isEqualTo(false) + } + + @Test + fun `applyEvent SessionUpdated updates sessionMetadata`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val newSession = createSessionMetadata("new-session") + + val result = viewModel.applyEvent( + RegistrationFlowState(), + RegistrationFlowEvent.SessionUpdated(newSession) + ) + + assertThat(result.sessionMetadata).isEqualTo(newSession) + } + + @Test + fun `applyEvent E164Chosen updates sessionE164`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val result = viewModel.applyEvent( + RegistrationFlowState(), + RegistrationFlowEvent.E164Chosen("+15551234567") + ) + + assertThat(result.sessionE164).isEqualTo("+15551234567") + } + + @Test + fun `applyEvent Registered updates accountEntropyPool`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val aep = AccountEntropyPool.generate() + + val result = viewModel.applyEvent( + RegistrationFlowState(), + RegistrationFlowEvent.Registered(aep) + ) + + assertThat(result.accountEntropyPool).isEqualTo(aep) + } + + @Test + fun `applyEvent MasterKeyRestoredFromSvr updates temporaryMasterKey`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val masterKey = MasterKey(ByteArray(32)) + + val result = viewModel.applyEvent( + RegistrationFlowState(), + RegistrationFlowEvent.MasterKeyRestoredFromSvr(masterKey) + ) + + assertThat(result.temporaryMasterKey).isEqualTo(masterKey) + } + + @Test + fun `applyEvent RecoveryPasswordInvalid sets doNotAttemptRecoveryPassword to true`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val result = viewModel.applyEvent( + RegistrationFlowState(), + RegistrationFlowEvent.RecoveryPasswordInvalid + ) + + assertThat(result.doNotAttemptRecoveryPassword).isTrue() + } + + @Test + fun `applyEvent PendingRestoreOptionSelected updates pendingRestoreOption`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val result = viewModel.applyEvent( + RegistrationFlowState(), + RegistrationFlowEvent.PendingRestoreOptionSelected(PendingRestoreOption.RemoteBackup) + ) + + assertThat(result.pendingRestoreOption).isEqualTo(PendingRestoreOption.RemoteBackup) + } + + @Test + fun `applyEvent UserSuppliedAepSubmitted updates unverifiedRestoredAep`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val aep = AccountEntropyPool.generate() + + val result = viewModel.applyEvent( + RegistrationFlowState(), + RegistrationFlowEvent.UserSuppliedAepSubmitted(aep) + ) + + assertThat(result.unverifiedRestoredAep).isEqualTo(aep) + } + + @Test + fun `applyEvent UserSuppliedAepVerified saves and updates accountEntropyPool`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val aep = AccountEntropyPool.generate() + + val result = viewModel.applyEvent( + RegistrationFlowState(), + RegistrationFlowEvent.UserSuppliedAepVerified(aep) + ) + + assertThat(result.accountEntropyPool).isEqualTo(aep) + coVerify { mockRepository.saveVerifiedUserSuppliedAep(aep) } + } + + @Test + fun `applyEvent RegistrationComplete commits data and navigates to FullyComplete`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val initialState = RegistrationFlowState(backStack = listOf(RegistrationRoute.Welcome)) + + val result = viewModel.applyEvent(initialState, RegistrationFlowEvent.RegistrationComplete) + + assertThat(result.backStack.last()).isEqualTo(RegistrationRoute.FullyComplete) + coVerify { mockRepository.commitFinalRegistrationData() } + } + + // ==================== getRequiredPermissions Tests ==================== + + @Test + fun `getRequiredPermissions always includes contacts and phone state`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + val permissions = viewModel.getRequiredPermissions() + + assertThat(permissions.contains(android.Manifest.permission.READ_CONTACTS)).isTrue() + assertThat(permissions.contains(android.Manifest.permission.WRITE_CONTACTS)).isTrue() + assertThat(permissions.contains(android.Manifest.permission.READ_PHONE_STATE)).isTrue() + } + + // ==================== preExistingRegistrationData Load Test ==================== + + @Test + fun `no saved state loads preExistingRegistrationData when present`() = runTest(testDispatcher) { + val preExisting = mockk(relaxed = true) + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns preExisting + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + assertThat(viewModel.state.value.preExistingRegistrationData).isEqualTo(preExisting) + } + + // ==================== resultBus Tests ==================== + + @Test + fun `resultBus is initialized`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + advanceUntilIdle() + + assertThat(viewModel.resultBus).isNotNull() + } + + // ==================== Initial State Tests ==================== + + @Test + fun `initial state has isRestoringNavigationState true before init completes`() = runTest(testDispatcher) { + coEvery { mockRepository.restoreFlowState() } returns null + coEvery { mockRepository.getPreExistingRegistrationData() } returns null + + val viewModel = RegistrationViewModel(mockRepository, SavedStateHandle()) + + assertThat(viewModel.state.value.isRestoringNavigationState).isTrue() + + advanceUntilIdle() + } + + // ==================== Helpers ==================== + + private fun createSessionMetadata(id: String = "test-session"): NetworkController.SessionMetadata { + return NetworkController.SessionMetadata( + id = id, + nextSms = 1000L, + nextCall = 2000L, + nextVerificationAttempt = 3000L, + allowedToRequestCode = true, + requestedInformation = emptyList(), + verified = false + ) + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModelTest.kt new file mode 100644 index 0000000000..8b0b57d75a --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModelTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.aepentry + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import org.junit.Before +import org.junit.Test +import org.signal.core.ui.navigation.ResultEventBus +import org.signal.registration.RegistrationFlowEvent + +class EnterAepForLocalBackupViewModelTest { + + private lateinit var viewModel: EnterAepForLocalBackupViewModel + private lateinit var resultBus: ResultEventBus + private lateinit var emittedParentEvents: MutableList + private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit + + private val resultKey = "test-result-key" + + @Before + fun setup() { + resultBus = ResultEventBus() + emittedParentEvents = mutableListOf() + parentEventEmitter = { event -> emittedParentEvents.add(event) } + viewModel = EnterAepForLocalBackupViewModel( + parentEventEmitter = parentEventEmitter, + resultBus = resultBus, + resultKey = resultKey + ) + } + + // ==================== BackupKeyChanged Tests ==================== + + @Test + fun `BackupKeyChanged updates backup key in state`() { + val testKey = VALID_AEP + + viewModel.onEvent(EnterAepEvents.BackupKeyChanged(testKey)) + + assertThat(viewModel.state.value.backupKey).isEqualTo(testKey) + } + + // ==================== Submit Tests ==================== + + @Test + fun `Submit with valid key sends result via resultBus and emits NavigateBack`() { + viewModel.onEvent(EnterAepEvents.BackupKeyChanged(VALID_AEP)) + viewModel.onEvent(EnterAepEvents.Submit) + + val result = resultBus.channelMap[resultKey]?.tryReceive()?.getOrNull() + assertThat(result).isEqualTo(VALID_AEP) + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack) + } + + @Test + fun `Submit with invalid key does not send result or navigate`() { + viewModel.onEvent(EnterAepEvents.BackupKeyChanged("too-short")) + viewModel.onEvent(EnterAepEvents.Submit) + + assertThat(resultBus.channelMap[resultKey]).isNull() + assertThat(emittedParentEvents).isEmpty() + } + + // ==================== Cancel Tests ==================== + + @Test + fun `Cancel emits NavigateBack`() { + viewModel.onEvent(EnterAepEvents.Cancel) + + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack) + } + + // ==================== DismissError Tests ==================== + + @Test + fun `DismissError clears registrationError from state`() { + viewModel.onEvent(EnterAepEvents.DismissError) + + assertThat(viewModel.state.value.registrationError).isNull() + } + + // ==================== Constants ==================== + + companion object { + private const val VALID_AEP = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t" + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModelTest.kt new file mode 100644 index 0000000000..403a3fecb4 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPostRegistrationViewModelTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.aepentry + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNull +import assertk.assertions.prop +import org.junit.Before +import org.junit.Test +import org.signal.core.models.AccountEntropyPool +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationRoute + +class EnterAepForRemoteBackupPostRegistrationViewModelTest { + + private lateinit var viewModel: EnterAepForRemoteBackupPostRegistrationViewModel + private lateinit var emittedParentEvents: MutableList + private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit + + @Before + fun setup() { + emittedParentEvents = mutableListOf() + parentEventEmitter = { event -> emittedParentEvents.add(event) } + viewModel = EnterAepForRemoteBackupPostRegistrationViewModel( + parentEventEmitter = parentEventEmitter + ) + } + + // ==================== BackupKeyChanged Tests ==================== + + @Test + fun `BackupKeyChanged updates backup key in state`() { + val testKey = VALID_AEP + + viewModel.onEvent(EnterAepEvents.BackupKeyChanged(testKey)) + + assertThat(viewModel.state.value.backupKey).isEqualTo(testKey) + } + + // ==================== Submit Tests ==================== + + @Test + fun `Submit with valid key emits UserSuppliedAepSubmitted then NavigateToScreen with RemoteRestore`() { + viewModel.onEvent(EnterAepEvents.BackupKeyChanged(VALID_AEP)) + viewModel.onEvent(EnterAepEvents.Submit) + + assertThat(emittedParentEvents).hasSize(2) + assertThat(emittedParentEvents[0]) + .isInstanceOf() + .prop(RegistrationFlowEvent.UserSuppliedAepSubmitted::aep) + .prop(AccountEntropyPool::value) + .isEqualTo(VALID_AEP) + assertThat(emittedParentEvents[1]) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + } + + @Test + fun `Submit with invalid key emits nothing`() { + viewModel.onEvent(EnterAepEvents.BackupKeyChanged("too-short")) + viewModel.onEvent(EnterAepEvents.Submit) + + assertThat(emittedParentEvents).isEmpty() + } + + // ==================== Cancel Tests ==================== + + @Test + fun `Cancel emits NavigateBack`() { + viewModel.onEvent(EnterAepEvents.Cancel) + + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack) + } + + // ==================== DismissError Tests ==================== + + @Test + fun `DismissError clears registrationError from state`() { + viewModel.onEvent(EnterAepEvents.DismissError) + + assertThat(viewModel.state.value.registrationError).isNull() + } + + // ==================== Constants ==================== + + companion object { + private const val VALID_AEP = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t" + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModelTest.kt new file mode 100644 index 0000000000..f71ffffe76 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModelTest.kt @@ -0,0 +1,258 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.aepentry + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNull +import assertk.assertions.prop +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.signal.core.models.AccountEntropyPool +import org.signal.libsignal.net.RequestResult +import org.signal.registration.KeyMaterial +import org.signal.registration.NetworkController +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationRepository +import org.signal.registration.RegistrationRoute + +class EnterAepForRemoteBackupPreRegistrationViewModelTest { + + private lateinit var viewModel: EnterAepForRemoteBackupPreRegistrationViewModel + private lateinit var mockRepository: RegistrationRepository + private lateinit var emittedParentEvents: MutableList + private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit + private lateinit var emittedStates: MutableList + private lateinit var stateEmitter: (EnterAepState) -> Unit + + @Before + fun setup() { + mockRepository = mockk(relaxed = true) + emittedParentEvents = mutableListOf() + parentEventEmitter = { event -> emittedParentEvents.add(event) } + emittedStates = mutableListOf() + stateEmitter = { state -> emittedStates.add(state) } + viewModel = EnterAepForRemoteBackupPreRegistrationViewModel( + e164 = E164, + repository = mockRepository, + parentEventEmitter = parentEventEmitter + ) + } + + // ==================== BackupKeyChanged Tests ==================== + + @Test + fun `BackupKeyChanged updates state with new key`() = runTest { + val initialState = EnterAepState() + + viewModel.applyEvent(initialState, EnterAepEvents.BackupKeyChanged(VALID_AEP), stateEmitter) + + assertThat(emittedStates).hasSize(1) + assertThat(emittedStates.last().backupKey).isEqualTo(VALID_AEP) + } + + // ==================== Submit Success Tests ==================== + + @Test + fun `Submit with valid key and successful registration emits UserSuppliedAepSubmitted, Registered, and NavigateToScreen`() = runTest { + val aep = AccountEntropyPool(VALID_AEP) + val mockKeyMaterial = mockk(relaxed = true) { + io.mockk.every { accountEntropyPool } returns aep + } + val mockResponse = mockk(relaxed = true) + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + + coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns + RequestResult.Success(mockResponse to mockKeyMaterial) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) + + assertThat(emittedParentEvents).hasSize(3) + assertThat(emittedParentEvents[0]).isInstanceOf() + assertThat(emittedParentEvents[1]).isInstanceOf() + assertThat(emittedParentEvents[2]) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + } + + @Test + fun `Submit sets isRegistering true before registration call`() = runTest { + val aep = AccountEntropyPool(VALID_AEP) + val mockKeyMaterial = mockk(relaxed = true) { + io.mockk.every { accountEntropyPool } returns aep + } + val mockResponse = mockk(relaxed = true) + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + + coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns + RequestResult.Success(mockResponse to mockKeyMaterial) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) + + assertThat(emittedStates).hasSize(2) + assertThat(emittedStates[0].isRegistering).isEqualTo(true) + assertThat(emittedStates[1].isRegistering).isEqualTo(false) + } + + // ==================== Submit Error Tests ==================== + + @Test + fun `Submit with RegistrationRecoveryPasswordIncorrect sets registrationError and aepValidationError`() = runTest { + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + + coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns + RequestResult.NonSuccess( + NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect("Incorrect") + ) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) + + assertThat(emittedStates.last().registrationError).isEqualTo(RegistrationError.IncorrectRecoveryPassword) + assertThat(emittedStates.last().aepValidationError).isEqualTo(AepValidationError.Incorrect) + assertThat(emittedStates.last().isRegistering).isEqualTo(false) + } + + @Test + fun `Submit with InvalidRequest sets registrationError to UnknownError`() = runTest { + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + + coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns + RequestResult.NonSuccess( + NetworkController.RegisterAccountError.InvalidRequest("Bad request") + ) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) + + assertThat(emittedStates.last().registrationError).isEqualTo(RegistrationError.UnknownError) + assertThat(emittedStates.last().isRegistering).isEqualTo(false) + } + + @Test + fun `Submit with RegistrationLock navigates to PinEntryForRegistrationLock`() = runTest { + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + val svrCredentials = NetworkController.SvrCredentials(username = "test-username", password = "test-password") + val registrationLockData = NetworkController.RegistrationLockResponse( + timeRemaining = 86400000L, + svr2Credentials = svrCredentials + ) + + coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns + RequestResult.NonSuccess( + NetworkController.RegisterAccountError.RegistrationLock(registrationLockData) + ) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) + + assertThat(emittedStates.last().isRegistering).isEqualTo(false) + assertThat(emittedParentEvents).hasSize(2) + assertThat(emittedParentEvents[1]) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isInstanceOf() + } + + @Test + fun `Submit with RateLimited sets registrationError to RateLimited`() = runTest { + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + + coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns + RequestResult.NonSuccess( + NetworkController.RegisterAccountError.RateLimited(kotlin.time.Duration.parse("1m")) + ) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) + + assertThat(emittedStates.last().registrationError).isEqualTo(RegistrationError.RateLimited) + assertThat(emittedStates.last().isRegistering).isEqualTo(false) + } + + @Test(expected = IllegalStateException::class) + fun `Submit with SessionNotFoundOrNotVerified throws IllegalStateException`() = runTest { + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + + coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns + RequestResult.NonSuccess( + NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified("Not found") + ) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) + } + + @Test(expected = IllegalStateException::class) + fun `Submit with DeviceTransferPossible throws IllegalStateException`() = runTest { + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + + coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns + RequestResult.NonSuccess( + NetworkController.RegisterAccountError.DeviceTransferPossible + ) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) + } + + @Test + fun `Submit with RetryableNetworkError sets registrationError to NetworkError`() = runTest { + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + + coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns + RequestResult.RetryableNetworkError(java.io.IOException("Network error")) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) + + assertThat(emittedStates.last().registrationError).isEqualTo(RegistrationError.NetworkError) + assertThat(emittedStates.last().isRegistering).isEqualTo(false) + } + + @Test + fun `Submit with ApplicationError sets registrationError to UnknownError`() = runTest { + val initialState = EnterAepState(backupKey = VALID_AEP, isBackupKeyValid = true) + + coEvery { mockRepository.registerAccountWithRecoveryPassword(any(), any(), any(), any(), any(), any()) } returns + RequestResult.ApplicationError(RuntimeException("Unexpected")) + + viewModel.applyEvent(initialState, EnterAepEvents.Submit, stateEmitter) + + assertThat(emittedStates.last().registrationError).isEqualTo(RegistrationError.UnknownError) + assertThat(emittedStates.last().isRegistering).isEqualTo(false) + } + + // ==================== Cancel Tests ==================== + + @Test + fun `Cancel emits NavigateBack`() = runTest { + val initialState = EnterAepState() + + viewModel.applyEvent(initialState, EnterAepEvents.Cancel, stateEmitter) + + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack) + } + + // ==================== DismissError Tests ==================== + + @Test + fun `DismissError clears registrationError`() = runTest { + val initialState = EnterAepState(registrationError = RegistrationError.NetworkError) + + viewModel.applyEvent(initialState, EnterAepEvents.DismissError, stateEmitter) + + assertThat(emittedStates).hasSize(1) + assertThat(emittedStates.last().registrationError).isNull() + } + + // ==================== Constants ==================== + + companion object { + private const val VALID_AEP = "uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t" + private const val E164 = "+15551234567" + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt new file mode 100644 index 0000000000..0baff0dad4 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModelTest.kt @@ -0,0 +1,230 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.localbackuprestore + +import android.net.Uri +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import assertk.assertions.prop +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.signal.core.ui.navigation.ResultEventBus +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationRepository +import org.signal.registration.RegistrationRoute +import java.time.LocalDateTime + +class LocalBackupRestoreViewModelTest { + + private lateinit var mockRepository: RegistrationRepository + private lateinit var resultBus: ResultEventBus + private lateinit var emittedParentEvents: MutableList + private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit + private lateinit var emittedStates: MutableList + private lateinit var stateEmitter: (LocalBackupRestoreState) -> Unit + + private val resultKey = "test-result-key" + + @Before + fun setup() { + mockRepository = mockk(relaxed = true) + resultBus = ResultEventBus() + emittedParentEvents = mutableListOf() + parentEventEmitter = { event -> emittedParentEvents.add(event) } + emittedStates = mutableListOf() + stateEmitter = { state -> emittedStates.add(state) } + } + + private fun createViewModel(isPreRegistration: Boolean): LocalBackupRestoreViewModel { + return LocalBackupRestoreViewModel( + repository = mockRepository, + parentEventEmitter = parentEventEmitter, + isPreRegistration = isPreRegistration, + resultBus = resultBus, + resultKey = resultKey + ) + } + + // ==================== PickBackupFolder Tests ==================== + + @Test + fun `PickBackupFolder sets launchFolderPicker to true`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val initialState = LocalBackupRestoreState() + + viewModel.applyEvent(initialState, LocalBackupRestoreEvents.PickBackupFolder, stateEmitter) + + assertThat(emittedStates).hasSize(1) + assertThat(emittedStates.last().launchFolderPicker).isTrue() + } + + // ==================== BackupFolderSelected Tests ==================== + + @Test + fun `BackupFolderSelected sets restorePhase to Scanning and selectedFolderUri`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val initialState = LocalBackupRestoreState() + val folderUri = mockk() + + viewModel.applyEvent(initialState, LocalBackupRestoreEvents.BackupFolderSelected(folderUri), stateEmitter) + + assertThat(emittedStates).hasSize(1) + assertThat(emittedStates.last().restorePhase).isEqualTo(LocalBackupRestoreState.RestorePhase.Scanning) + assertThat(emittedStates.last().selectedFolderUri).isEqualTo(folderUri) + } + + // ==================== RestoreBackup with V1 Tests ==================== + + @Test + fun `RestoreBackup with V1 backup navigates to EnterLocalBackupV1Passphrase`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val backupInfo = LocalBackupInfo( + type = LocalBackupInfo.BackupType.V1, + date = LocalDateTime.now(), + name = "backup.backup", + uri = mockk() + ) + val initialState = LocalBackupRestoreState(backupInfo = backupInfo) + + viewModel.applyEvent(initialState, LocalBackupRestoreEvents.RestoreBackup, stateEmitter) + + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isEqualTo(RegistrationRoute.EnterLocalBackupV1Passphrase) + } + + // ==================== RestoreBackup with V2 Tests ==================== + + @Test + fun `RestoreBackup with V2 backup navigates to EnterAepForLocalBackup`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val backupInfo = LocalBackupInfo( + type = LocalBackupInfo.BackupType.V2, + date = LocalDateTime.now(), + name = "backup.bin", + uri = mockk() + ) + val initialState = LocalBackupRestoreState(backupInfo = backupInfo) + + viewModel.applyEvent(initialState, LocalBackupRestoreEvents.RestoreBackup, stateEmitter) + + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isEqualTo(RegistrationRoute.EnterAepForLocalBackup) + } + + // ==================== RestoreBackup with no backup Tests ==================== + + @Test + fun `RestoreBackup with null backupInfo does nothing`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val initialState = LocalBackupRestoreState(backupInfo = null) + + viewModel.applyEvent(initialState, LocalBackupRestoreEvents.RestoreBackup, stateEmitter) + + assertThat(emittedParentEvents).isEmpty() + assertThat(emittedStates).isEmpty() + } + + // ==================== ChooseDifferentFolder Tests ==================== + + @Test + fun `ChooseDifferentFolder resets state and sets launchFolderPicker to true`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val backupInfo = LocalBackupInfo( + type = LocalBackupInfo.BackupType.V2, + date = LocalDateTime.now(), + name = "backup.bin", + uri = mockk() + ) + val initialState = LocalBackupRestoreState( + restorePhase = LocalBackupRestoreState.RestorePhase.BackupFound, + backupInfo = backupInfo, + selectedFolderUri = mockk() + ) + + viewModel.applyEvent(initialState, LocalBackupRestoreEvents.ChooseDifferentFolder, stateEmitter) + + assertThat(emittedStates).hasSize(1) + assertThat(emittedStates.last().launchFolderPicker).isTrue() + assertThat(emittedStates.last().restorePhase).isEqualTo(LocalBackupRestoreState.RestorePhase.SelectFolder) + assertThat(emittedStates.last().backupInfo).isNull() + assertThat(emittedStates.last().selectedFolderUri).isNull() + } + + // ==================== BackupSelected Tests ==================== + + @Test + fun `BackupSelected updates backupInfo in state`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val backupInfo = LocalBackupInfo( + type = LocalBackupInfo.BackupType.V2, + date = LocalDateTime.now(), + name = "backup.bin", + uri = mockk() + ) + val initialState = LocalBackupRestoreState() + + viewModel.applyEvent(initialState, LocalBackupRestoreEvents.BackupSelected(backupInfo), stateEmitter) + + assertThat(emittedStates).hasSize(1) + assertThat(emittedStates.last().backupInfo).isEqualTo(backupInfo) + } + + // ==================== FolderPickerDismissed Tests ==================== + + @Test + fun `FolderPickerDismissed sets launchFolderPicker to false`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val initialState = LocalBackupRestoreState(launchFolderPicker = true) + + viewModel.applyEvent(initialState, LocalBackupRestoreEvents.FolderPickerDismissed, stateEmitter) + + assertThat(emittedStates).hasSize(1) + assertThat(emittedStates.last().launchFolderPicker).isEqualTo(false) + } + + // ==================== Cancel (pre-registration) Tests ==================== + + @Test + fun `Cancel when pre-registration sends Canceled result and emits NavigateBack`() = runTest { + val viewModel = createViewModel(isPreRegistration = true) + val initialState = LocalBackupRestoreState() + + viewModel.applyEvent(initialState, LocalBackupRestoreEvents.Cancel, stateEmitter) + + val result = resultBus.channelMap[resultKey]?.tryReceive()?.getOrNull() + assertThat(result).isNotNull().isEqualTo(LocalBackupRestoreResult.Canceled) + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack) + } + + // ==================== Cancel (post-registration) Tests ==================== + + @Test + fun `Cancel when NOT pre-registration emits NavigateBack without sending result`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val initialState = LocalBackupRestoreState() + + viewModel.applyEvent(initialState, LocalBackupRestoreEvents.Cancel, stateEmitter) + + assertThat(resultBus.channelMap[resultKey]).isNull() + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack) + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt new file mode 100644 index 0000000000..6220494e63 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.pincreation + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.signal.core.models.AccountEntropyPool +import org.signal.core.models.MasterKey +import org.signal.libsignal.net.RequestResult +import org.signal.registration.NetworkController +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationFlowState +import org.signal.registration.RegistrationRepository + +@OptIn(ExperimentalCoroutinesApi::class) +class PinCreationViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var viewModel: PinCreationViewModel + private lateinit var mockRepository: RegistrationRepository + private lateinit var parentState: MutableStateFlow + private lateinit var emittedParentEvents: MutableList + private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + mockRepository = mockk(relaxed = true) + parentState = MutableStateFlow(RegistrationFlowState()) + emittedParentEvents = mutableListOf() + parentEventEmitter = { event -> emittedParentEvents.add(event) } + viewModel = PinCreationViewModel( + repository = mockRepository, + parentState = parentState, + parentEventEmitter = parentEventEmitter + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + // ==================== PinSubmitted Success Tests ==================== + + @Test + fun `PinSubmitted with valid AEP and successful SVR backup emits RegistrationComplete`() = runTest(testDispatcher) { + val aep = AccountEntropyPool.generate() + val initialState = PinCreationState(accountEntropyPool = aep) + + coEvery { mockRepository.setNewlyCreatedPin(any(), any(), any()) } returns + RequestResult.Success(null) + + viewModel.applyEvent(initialState, PinCreationScreenEvents.PinSubmitted("123456")) + + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.RegistrationComplete) + } + + // ==================== PinSubmitted Missing AEP Test ==================== + + @Test + fun `PinSubmitted with null AEP emits ResetState`() = runTest(testDispatcher) { + val initialState = PinCreationState(accountEntropyPool = null) + + viewModel.applyEvent(initialState, PinCreationScreenEvents.PinSubmitted("123456")) + + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState) + } + + // ==================== PinSubmitted Error Tests ==================== + + @Test + fun `PinSubmitted with NotRegistered error emits ResetState`() = runTest(testDispatcher) { + val aep = AccountEntropyPool.generate() + val initialState = PinCreationState(accountEntropyPool = aep) + + coEvery { mockRepository.setNewlyCreatedPin(any(), any(), any()) } returns + RequestResult.NonSuccess(NetworkController.BackupMasterKeyError.NotRegistered) + + viewModel.applyEvent(initialState, PinCreationScreenEvents.PinSubmitted("123456")) + + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState) + } + + // ==================== applyParentState Tests ==================== + + @Test + fun `applyParentState copies accountEntropyPool from parent`() { + val aep = AccountEntropyPool.generate() + val parentFlowState = RegistrationFlowState(accountEntropyPool = aep) + val initialState = PinCreationState() + + val result = viewModel.applyParentState(initialState, parentFlowState) + + assertThat(result.accountEntropyPool).isEqualTo(aep) + } + + @Test + fun `applyParentState with null accountEntropyPool keeps null`() { + val parentFlowState = RegistrationFlowState(accountEntropyPool = null) + val initialState = PinCreationState() + + val result = viewModel.applyParentState(initialState, parentFlowState) + + assertThat(result.accountEntropyPool).isNull() + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModelTest.kt new file mode 100644 index 0000000000..2b3d1e4d60 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModelTest.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.quickrestore + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isNull +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.signal.registration.NetworkController +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationRepository + +@OptIn(ExperimentalCoroutinesApi::class) +class QuickRestoreQrViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var mockRepository: RegistrationRepository + private lateinit var emittedParentEvents: MutableList + private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit + private lateinit var emittedStates: MutableList + private lateinit var stateEmitter: (QuickRestoreQrState) -> Unit + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + mockRepository = mockk(relaxed = true) + every { mockRepository.startProvisioning() } returns emptyFlow() + emittedParentEvents = mutableListOf() + parentEventEmitter = { event -> emittedParentEvents.add(event) } + emittedStates = mutableListOf() + stateEmitter = { state -> emittedStates.add(state) } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel(): QuickRestoreQrViewModel { + return QuickRestoreQrViewModel( + repository = mockRepository, + parentEventEmitter = parentEventEmitter + ) + } + + // ==================== Cancel Tests ==================== + + @Test + fun `Cancel emits NavigateBack`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val initialState = QuickRestoreQrState() + + viewModel.applyEvent(initialState, QuickRestoreQrEvents.Cancel, stateEmitter) + + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack) + } + + // ==================== RetryQrCode Tests ==================== + + @Test + fun `RetryQrCode resets qrState to Loading and clears errors`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val initialState = QuickRestoreQrState( + qrState = QrState.Failed, + showRegistrationError = true, + errorMessage = "some error" + ) + + viewModel.applyEvent(initialState, QuickRestoreQrEvents.RetryQrCode, stateEmitter) + + assertThat(emittedStates).hasSize(1) + assertThat(emittedStates.last().qrState).isEqualTo(QrState.Loading) + assertThat(emittedStates.last().showRegistrationError).isFalse() + assertThat(emittedStates.last().errorMessage).isNull() + } + + // ==================== DismissError Tests ==================== + + @Test + fun `DismissError clears registration error`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val initialState = QuickRestoreQrState( + showRegistrationError = true, + errorMessage = "rate limited" + ) + + viewModel.applyEvent(initialState, QuickRestoreQrEvents.DismissError, stateEmitter) + + assertThat(emittedStates).hasSize(1) + assertThat(emittedStates.last().showRegistrationError).isFalse() + assertThat(emittedStates.last().errorMessage).isNull() + } + + // ==================== Initial State Tests ==================== + + @Test + fun `initial state has qrState Loading`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + assertThat(viewModel.state.value.qrState).isEqualTo(QrState.Loading) + assertThat(viewModel.state.value.isRegistering).isFalse() + assertThat(viewModel.state.value.showRegistrationError).isFalse() + } + + // ==================== Provisioning Flow Tests ==================== + + @Test + fun `QrCodeReady provisioning event updates state to Loaded`() = runTest(testDispatcher) { + val flow = MutableSharedFlow(replay = 1) + every { mockRepository.startProvisioning() } returns flow + + val viewModel = createViewModel() + flow.emit(NetworkController.ProvisioningEvent.QrCodeReady("sgnl://example")) + + assertThat(viewModel.state.value.qrState).isInstanceOf() + } + + @Test + fun `Error provisioning event updates state to Failed`() = runTest(testDispatcher) { + val flow = MutableSharedFlow(replay = 1) + every { mockRepository.startProvisioning() } returns flow + + val viewModel = createViewModel() + flow.emit(NetworkController.ProvisioningEvent.Error(RuntimeException("boom"))) + + assertThat(viewModel.state.value.qrState).isEqualTo(QrState.Failed) + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModelTest.kt new file mode 100644 index 0000000000..bc67bbd46a --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModelTest.kt @@ -0,0 +1,197 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.remotebackuprestore + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNull +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.signal.core.models.AccountEntropyPool +import org.signal.libsignal.net.RequestResult +import org.signal.registration.NetworkController +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationRepository + +@OptIn(ExperimentalCoroutinesApi::class) +class RemoteBackupRestoreViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var mockRepository: RegistrationRepository + private lateinit var emittedParentEvents: MutableList + private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit + private lateinit var emittedStates: MutableList + private lateinit var stateEmitter: (RemoteBackupRestoreState) -> Unit + private lateinit var aep: AccountEntropyPool + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + aep = AccountEntropyPool.generate() + mockRepository = mockk(relaxed = true) + every { mockRepository.restoreRemoteBackup(any()) } returns emptyFlow() + coEvery { mockRepository.getRemoteBackupInfo(any()) } returns + RequestResult.NonSuccess(NetworkController.GetBackupInfoError.NoBackup) + emittedParentEvents = mutableListOf() + parentEventEmitter = { event -> emittedParentEvents.add(event) } + emittedStates = mutableListOf() + stateEmitter = { state -> emittedStates.add(state) } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel(): RemoteBackupRestoreViewModel { + return RemoteBackupRestoreViewModel( + aep = aep, + repository = mockRepository, + parentEventEmitter = parentEventEmitter, + ioDispatcher = testDispatcher + ) + } + + // ==================== BackupRestoreBackup Tests ==================== + + @Test + fun `BackupRestoreBackup emits InProgress state and triggers restore`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val initialState = RemoteBackupRestoreState(aep = aep) + + viewModel.applyEvent( + initialState, + RemoteBackupRestoreScreenEvents.BackupRestoreBackup, + stateEmitter + ) + + assertThat(emittedStates).hasSize(1) + assertThat(emittedStates.last().restoreState).isEqualTo(RemoteBackupRestoreState.RestoreState.InProgress) + coVerify { mockRepository.restoreRemoteBackup(aep) } + } + + // ==================== Cancel Tests ==================== + + @Test + fun `Cancel emits NavigateBack`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val initialState = RemoteBackupRestoreState(aep = aep) + + viewModel.applyEvent(initialState, RemoteBackupRestoreScreenEvents.Cancel, stateEmitter) + + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack) + } + + // ==================== DismissError Tests ==================== + + @Test + fun `DismissError resets restoreState to None and clears progress`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val initialState = RemoteBackupRestoreState( + aep = aep, + restoreState = RemoteBackupRestoreState.RestoreState.Failed, + restoreProgress = RemoteBackupRestoreState.RestoreProgress( + phase = RemoteBackupRestoreState.RestoreProgress.Phase.Downloading, + bytesCompleted = 50, + totalBytes = 100 + ) + ) + + viewModel.applyEvent(initialState, RemoteBackupRestoreScreenEvents.DismissError, stateEmitter) + + assertThat(emittedStates).hasSize(1) + assertThat(emittedStates.last().restoreState).isEqualTo(RemoteBackupRestoreState.RestoreState.None) + assertThat(emittedStates.last().restoreProgress).isNull() + } + + // ==================== loadBackupInfo Tests ==================== + + @Test + fun `init with successful backup info invokes getRemoteBackupInfo and getBackupFileLastModified`() = runTest(testDispatcher) { + val info = NetworkController.GetBackupInfoResponse( + cdn = 3, + backupDir = "dir", + mediaDir = "media", + backupName = "backup", + usedSpace = 1024L + ) + coEvery { mockRepository.getRemoteBackupInfo(any()) } returns RequestResult.Success(info) + coEvery { mockRepository.getBackupFileLastModified(any(), any()) } returns RequestResult.Success(1234L) + + createViewModel() + + coVerify { mockRepository.getRemoteBackupInfo(aep) } + coVerify { mockRepository.getBackupFileLastModified(aep, info) } + } + + @Test + fun `init with NoBackup error invokes getRemoteBackupInfo`() = runTest(testDispatcher) { + coEvery { mockRepository.getRemoteBackupInfo(any()) } returns + RequestResult.NonSuccess(NetworkController.GetBackupInfoError.NoBackup) + + createViewModel() + + coVerify { mockRepository.getRemoteBackupInfo(aep) } + } + + // ==================== Restore Progress Tests ==================== + + @Test + fun `BackupRestoreBackup Complete progress emits RegistrationComplete and UserSuppliedAepVerified`() = runTest(testDispatcher) { + every { mockRepository.restoreRemoteBackup(any()) } returns flowOf( + RemoteBackupRestoreProgress.Complete + ) + + val viewModel = createViewModel() + val initialState = RemoteBackupRestoreState(aep = aep) + + viewModel.applyEvent( + initialState, + RemoteBackupRestoreScreenEvents.BackupRestoreBackup, + stateEmitter + ) + + assertThat(emittedParentEvents).hasSize(2) + assertThat(emittedParentEvents[0]).isInstanceOf() + assertThat(emittedParentEvents[1]).isEqualTo(RegistrationFlowEvent.RegistrationComplete) + } + + @Test + fun `BackupRestoreBackup NetworkError progress triggers restore and emits no parent events`() = runTest(testDispatcher) { + every { mockRepository.restoreRemoteBackup(any()) } returns flowOf( + RemoteBackupRestoreProgress.NetworkError() + ) + + val viewModel = createViewModel() + val initialState = RemoteBackupRestoreState(aep = aep) + + viewModel.applyEvent( + initialState, + RemoteBackupRestoreScreenEvents.BackupRestoreBackup, + stateEmitter + ) + + coVerify { mockRepository.restoreRemoteBackup(aep) } + assertThat(emittedParentEvents).hasSize(0) + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModelTest.kt new file mode 100644 index 0000000000..6cd4a8fbf1 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModelTest.kt @@ -0,0 +1,237 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.restoreselection + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import assertk.assertions.prop +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.signal.registration.PendingRestoreOption +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationRoute + +class ArchiveRestoreSelectionViewModelTest { + + private lateinit var emittedParentEvents: MutableList + private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit + private lateinit var emittedStates: MutableList + private lateinit var stateEmitter: (ArchiveRestoreSelectionState) -> Unit + + @Before + fun setup() { + emittedParentEvents = mutableListOf() + parentEventEmitter = { event -> emittedParentEvents.add(event) } + emittedStates = mutableListOf() + stateEmitter = { state -> emittedStates.add(state) } + } + + private fun createViewModel( + restoreOptions: List = listOf( + ArchiveRestoreOption.SignalSecureBackup, + ArchiveRestoreOption.LocalBackup, + ArchiveRestoreOption.DeviceTransfer + ), + isPreRegistration: Boolean = false + ): ArchiveRestoreSelectionViewModel { + return ArchiveRestoreSelectionViewModel( + restoreOptions = restoreOptions, + isPreRegistration = isPreRegistration, + parentEventEmitter = parentEventEmitter + ) + } + + // ==================== RestoreOptionSelected Tests ==================== + + @Test + fun `SignalSecureBackup pre-registration emits PendingRestoreOptionSelected and navigates to PhoneNumberEntry`() = runTest { + val viewModel = createViewModel(isPreRegistration = true) + val initialState = ArchiveRestoreSelectionState() + + viewModel.applyEvent( + initialState, + ArchiveRestoreSelectionScreenEvents.RestoreOptionSelected(ArchiveRestoreOption.SignalSecureBackup), + stateEmitter + ) + + assertThat(emittedParentEvents).hasSize(2) + assertThat(emittedParentEvents[0]) + .isInstanceOf() + .prop(RegistrationFlowEvent.PendingRestoreOptionSelected::option) + .isEqualTo(PendingRestoreOption.RemoteBackup) + assertThat(emittedParentEvents[1]) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isEqualTo(RegistrationRoute.PhoneNumberEntry) + } + + @Test + fun `SignalSecureBackup post-registration navigates to EnterAepForRemoteBackupPostRegistration`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val initialState = ArchiveRestoreSelectionState() + + viewModel.applyEvent( + initialState, + ArchiveRestoreSelectionScreenEvents.RestoreOptionSelected(ArchiveRestoreOption.SignalSecureBackup), + stateEmitter + ) + + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isEqualTo(RegistrationRoute.EnterAepForRemoteBackupPostRegistration) + } + + @Test + fun `LocalBackup pre-registration emits PendingRestoreOptionSelected and navigates to PhoneNumberEntry`() = runTest { + val viewModel = createViewModel(isPreRegistration = true) + val initialState = ArchiveRestoreSelectionState() + + viewModel.applyEvent( + initialState, + ArchiveRestoreSelectionScreenEvents.RestoreOptionSelected(ArchiveRestoreOption.LocalBackup), + stateEmitter + ) + + assertThat(emittedParentEvents).hasSize(2) + assertThat(emittedParentEvents[0]) + .isInstanceOf() + .prop(RegistrationFlowEvent.PendingRestoreOptionSelected::option) + .isEqualTo(PendingRestoreOption.LocalBackup) + assertThat(emittedParentEvents[1]) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isEqualTo(RegistrationRoute.PhoneNumberEntry) + } + + @Test + fun `LocalBackup post-registration navigates to LocalBackupRestore`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val initialState = ArchiveRestoreSelectionState() + + viewModel.applyEvent( + initialState, + ArchiveRestoreSelectionScreenEvents.RestoreOptionSelected(ArchiveRestoreOption.LocalBackup), + stateEmitter + ) + + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isEqualTo(RegistrationRoute.LocalBackupRestore(isPreRegistration = false)) + } + + @Test + fun `DeviceTransfer is not implemented and emits no events`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val initialState = ArchiveRestoreSelectionState() + + viewModel.applyEvent( + initialState, + ArchiveRestoreSelectionScreenEvents.RestoreOptionSelected(ArchiveRestoreOption.DeviceTransfer), + stateEmitter + ) + + assertThat(emittedParentEvents).hasSize(0) + } + + @Test + fun `None option sets showSkipWarningDialog to true`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val initialState = ArchiveRestoreSelectionState() + + viewModel.applyEvent( + initialState, + ArchiveRestoreSelectionScreenEvents.RestoreOptionSelected(ArchiveRestoreOption.None), + stateEmitter + ) + + assertThat(emittedStates).hasSize(1) + assertThat(emittedStates.last().showSkipWarningDialog).isTrue() + } + + // ==================== Skip Tests ==================== + + @Test + fun `Skip sets showSkipWarningDialog to true`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val initialState = ArchiveRestoreSelectionState() + + viewModel.applyEvent(initialState, ArchiveRestoreSelectionScreenEvents.Skip, stateEmitter) + + assertThat(emittedStates).hasSize(1) + assertThat(emittedStates.last().showSkipWarningDialog).isTrue() + } + + // ==================== ConfirmSkip Tests ==================== + + @Test + fun `ConfirmSkip navigates to PinCreate and clears dialog`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val initialState = ArchiveRestoreSelectionState(showSkipWarningDialog = true) + + viewModel.applyEvent(initialState, ArchiveRestoreSelectionScreenEvents.ConfirmSkip, stateEmitter) + + assertThat(emittedParentEvents).hasSize(1) + assertThat(emittedParentEvents.first()) + .isInstanceOf() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isEqualTo(RegistrationRoute.PinCreate) + assertThat(emittedStates.last().showSkipWarningDialog).isFalse() + } + + // ==================== DismissSkipWarning Tests ==================== + + @Test + fun `DismissSkipWarning sets showSkipWarningDialog to false`() = runTest { + val viewModel = createViewModel(isPreRegistration = false) + val initialState = ArchiveRestoreSelectionState(showSkipWarningDialog = true) + + viewModel.applyEvent( + initialState, + ArchiveRestoreSelectionScreenEvents.DismissSkipWarning, + stateEmitter + ) + + assertThat(emittedStates).hasSize(1) + assertThat(emittedStates.last().showSkipWarningDialog).isFalse() + } + + // ==================== Initial State Tests ==================== + + @Test + fun `initial state contains provided restore options`() = runTest { + val options = listOf(ArchiveRestoreOption.SignalSecureBackup, ArchiveRestoreOption.None) + val viewModel = createViewModel(restoreOptions = options) + + assertThat(viewModel.state.value.restoreOptions).isEqualTo(options) + } + + @Test + fun `showSkipButton is false when None is in options`() = runTest { + val viewModel = createViewModel( + restoreOptions = listOf(ArchiveRestoreOption.SignalSecureBackup, ArchiveRestoreOption.None) + ) + + assertThat(viewModel.state.value.showSkipButton).isFalse() + } + + @Test + fun `showSkipButton is true when None is not in options`() = runTest { + val viewModel = createViewModel( + restoreOptions = listOf(ArchiveRestoreOption.SignalSecureBackup, ArchiveRestoreOption.LocalBackup) + ) + + assertThat(viewModel.state.value.showSkipButton).isTrue() + } +}