Increase regV5 test coverage.

This commit is contained in:
Greyson Parrelli
2026-04-22 15:12:47 -03:00
committed by Alex Hart
parent 357fbfa8aa
commit 017b902c3c
12 changed files with 1961 additions and 259 deletions
@@ -525,7 +525,6 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
factory = RemoteBackupRestoreViewModel.Factory(
aep = key.aep,
repository = registrationRepository,
parentState = registrationViewModel.state,
parentEventEmitter = registrationViewModel::onEvent
)
)
@@ -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<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val ioDispatcher: CoroutineContext = Dispatchers.IO
) : EventDrivenViewModel<RemoteBackupRestoreScreenEvents>(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<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return RemoteBackupRestoreViewModel(aep, repository, parentState, parentEventEmitter) as T
return RemoteBackupRestoreViewModel(aep, repository, parentEventEmitter) as T
}
}
}
@@ -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
)
}
}
@@ -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<PreExistingRegistrationData>(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
)
}
}
@@ -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<RegistrationFlowEvent>
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"
}
}
@@ -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<RegistrationFlowEvent>
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<RegistrationFlowEvent.UserSuppliedAepSubmitted>()
.prop(RegistrationFlowEvent.UserSuppliedAepSubmitted::aep)
.prop(AccountEntropyPool::value)
.isEqualTo(VALID_AEP)
assertThat(emittedParentEvents[1])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.RemoteRestore>()
}
@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"
}
}
@@ -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<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var emittedStates: MutableList<EnterAepState>
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<KeyMaterial>(relaxed = true) {
io.mockk.every { accountEntropyPool } returns aep
}
val mockResponse = mockk<NetworkController.RegisterAccountResponse>(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<RegistrationFlowEvent.UserSuppliedAepSubmitted>()
assertThat(emittedParentEvents[1]).isInstanceOf<RegistrationFlowEvent.Registered>()
assertThat(emittedParentEvents[2])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.RemoteRestore>()
}
@Test
fun `Submit sets isRegistering true before registration call`() = runTest {
val aep = AccountEntropyPool(VALID_AEP)
val mockKeyMaterial = mockk<KeyMaterial>(relaxed = true) {
io.mockk.every { accountEntropyPool } returns aep
}
val mockResponse = mockk<NetworkController.RegisterAccountResponse>(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<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.PinEntryForRegistrationLock>()
}
@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"
}
}
@@ -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<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var emittedStates: MutableList<LocalBackupRestoreState>
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<Uri>()
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<RegistrationFlowEvent.NavigateToScreen>()
.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<RegistrationFlowEvent.NavigateToScreen>()
.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)
}
}
@@ -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<RegistrationFlowState>
private lateinit var emittedParentEvents: MutableList<RegistrationFlowEvent>
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<MasterKey>()) } 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<MasterKey>()) } 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()
}
}
@@ -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<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var emittedStates: MutableList<QuickRestoreQrState>
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<NetworkController.ProvisioningEvent>(replay = 1)
every { mockRepository.startProvisioning() } returns flow
val viewModel = createViewModel()
flow.emit(NetworkController.ProvisioningEvent.QrCodeReady("sgnl://example"))
assertThat(viewModel.state.value.qrState).isInstanceOf<QrState.Loaded>()
}
@Test
fun `Error provisioning event updates state to Failed`() = runTest(testDispatcher) {
val flow = MutableSharedFlow<NetworkController.ProvisioningEvent>(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)
}
}
@@ -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<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var emittedStates: MutableList<RemoteBackupRestoreState>
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<RegistrationFlowEvent.UserSuppliedAepVerified>()
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)
}
}
@@ -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<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var emittedStates: MutableList<ArchiveRestoreSelectionState>
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<ArchiveRestoreOption> = 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<RegistrationFlowEvent.PendingRestoreOptionSelected>()
.prop(RegistrationFlowEvent.PendingRestoreOptionSelected::option)
.isEqualTo(PendingRestoreOption.RemoteBackup)
assertThat(emittedParentEvents[1])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.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<RegistrationFlowEvent.NavigateToScreen>()
.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<RegistrationFlowEvent.PendingRestoreOptionSelected>()
.prop(RegistrationFlowEvent.PendingRestoreOptionSelected::option)
.isEqualTo(PendingRestoreOption.LocalBackup)
assertThat(emittedParentEvents[1])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.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<RegistrationFlowEvent.NavigateToScreen>()
.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<RegistrationFlowEvent.NavigateToScreen>()
.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()
}
}