mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-19 20:58:11 +01:00
Increase regV5 test coverage.
This commit is contained in:
committed by
Alex Hart
parent
357fbfa8aa
commit
017b902c3c
@@ -525,7 +525,6 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
factory = RemoteBackupRestoreViewModel.Factory(
|
||||
aep = key.aep,
|
||||
repository = registrationRepository,
|
||||
parentState = registrationViewModel.state,
|
||||
parentEventEmitter = registrationViewModel::onEvent
|
||||
)
|
||||
)
|
||||
|
||||
+6
-7
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-251
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+563
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+96
@@ -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"
|
||||
}
|
||||
}
|
||||
+98
@@ -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"
|
||||
}
|
||||
}
|
||||
+258
@@ -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"
|
||||
}
|
||||
}
|
||||
+230
@@ -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)
|
||||
}
|
||||
}
|
||||
+128
@@ -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()
|
||||
}
|
||||
}
|
||||
+148
@@ -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)
|
||||
}
|
||||
}
|
||||
+197
@@ -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)
|
||||
}
|
||||
}
|
||||
+237
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user