Improve remote restore test coverage in regV5.

This commit is contained in:
Greyson Parrelli
2026-07-01 15:10:22 -04:00
committed by Michelle Tang
parent bd42444e00
commit a4a6117788
2 changed files with 325 additions and 35 deletions
@@ -81,6 +81,7 @@ class RemoteBackupRestoreViewModel(
repository.restoreRemoteBackup(_state.value.aep).collect { progress ->
when (progress) {
is RemoteBackupRestoreProgress.Downloading -> {
Log.i(TAG, "[restoreBackup] Restoring...")
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.InProgress,
restoreProgress = RemoteBackupRestoreState.RestoreProgress(
@@ -91,6 +92,7 @@ class RemoteBackupRestoreViewModel(
)
}
is RemoteBackupRestoreProgress.Restoring -> {
Log.i(TAG, "[restoreBackup] Restoring...")
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.InProgress,
restoreProgress = RemoteBackupRestoreState.RestoreProgress(
@@ -101,6 +103,7 @@ class RemoteBackupRestoreViewModel(
)
}
is RemoteBackupRestoreProgress.Finalizing -> {
Log.i(TAG, "[restoreBackup] Finalizing...")
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.InProgress,
restoreProgress = RemoteBackupRestoreState.RestoreProgress(
@@ -111,7 +114,7 @@ class RemoteBackupRestoreViewModel(
)
}
is RemoteBackupRestoreProgress.Complete -> {
Log.i(TAG, "Remote restore completed successfully")
Log.i(TAG, "[restoreBackup] Remote restore completed successfully.")
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.Restored,
restoreProgress = null
@@ -121,35 +124,35 @@ class RemoteBackupRestoreViewModel(
repository.finishRegistrationOrCreateProfile(parentEventEmitter)
}
is RemoteBackupRestoreProgress.NetworkError -> {
Log.w(TAG, "Remote restore failed with network error", progress.cause)
Log.w(TAG, "[restoreBackup] Remote restore failed with network error.", progress.cause)
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.NetworkFailure,
restoreProgress = null
)
}
is RemoteBackupRestoreProgress.InvalidBackupVersion -> {
Log.w(TAG, "Remote restore failed: invalid backup version")
Log.w(TAG, "[restoreBackup] Remote restore failed: invalid backup version.")
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.InvalidBackupVersion,
restoreProgress = null
)
}
is RemoteBackupRestoreProgress.PermanentSvrBFailure -> {
Log.w(TAG, "Remote restore failed: permanent SVR-B failure")
Log.w(TAG, "[restoreBackup] Remote restore failed: permanent SVRB failure.")
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.PermanentSvrBFailure,
restoreProgress = null
)
}
is RemoteBackupRestoreProgress.Canceled -> {
Log.w(TAG, "Remote restore was canceled")
Log.w(TAG, "[restoreBackup] Remote restore was canceled.")
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.Failed,
restoreProgress = null
)
}
is RemoteBackupRestoreProgress.GenericError -> {
Log.w(TAG, "Remote restore failed", progress.cause)
Log.w(TAG, "[restoreBackup] Remote restore failed.", progress.cause)
_state.value = _state.value.copy(
restoreState = RemoteBackupRestoreState.RestoreState.Failed,
restoreProgress = null
@@ -170,10 +173,9 @@ class RemoteBackupRestoreViewModel(
when (result) {
is RequestResult.Success -> {
Log.i(TAG, "[loadBackupInfo] Successfully fetched backup info.")
val info = result.result
// parentEventEmitter(RegistrationFlowEvent)
val lastModifiedResult = withContext(ioDispatcher) {
repository.getBackupFileLastModified(_state.value.aep, info)
}
@@ -193,15 +195,35 @@ class RemoteBackupRestoreViewModel(
)
}
is RequestResult.NonSuccess -> {
_state.value = when (result.error) {
is NetworkController.GetBackupInfoError.NoBackup -> _state.value.copy(loadState = RemoteBackupRestoreState.LoadState.NotFound)
else -> _state.value.copy(loadState = RemoteBackupRestoreState.LoadState.Failure)
_state.value = when (val error = result.error) {
is NetworkController.GetBackupInfoError.NoBackup -> {
Log.w(TAG, "[loadBackupInfo] No backup found.")
_state.value.copy(loadState = RemoteBackupRestoreState.LoadState.NotFound)
}
is NetworkController.GetBackupInfoError.BadArguments -> {
Log.w(TAG, "[loadBackupInfo] Failed with bad arguments. Body: ${error.body}")
_state.value.copy(loadState = RemoteBackupRestoreState.LoadState.Failure)
}
is NetworkController.GetBackupInfoError.BadAuthCredential -> {
Log.w(TAG, "[loadBackupInfo] Bad auth credential. Body: ${error.body}")
_state.value.copy(loadState = RemoteBackupRestoreState.LoadState.Failure)
}
is NetworkController.GetBackupInfoError.Forbidden -> {
Log.w(TAG, "[loadBackupInfo] Forbidden. Body: ${error.body}")
_state.value.copy(loadState = RemoteBackupRestoreState.LoadState.Failure)
}
is NetworkController.GetBackupInfoError.RateLimited -> {
Log.w(TAG, "[loadBackupInfo] Rate limited. Try again in: ${error.retryAfter}")
_state.value.copy(loadState = RemoteBackupRestoreState.LoadState.Failure)
}
}
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[loadBackupInfo] Hit network error.", result.networkError)
_state.value = _state.value.copy(loadState = RemoteBackupRestoreState.LoadState.Failure)
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[loadBackupInfo] Hit unexpected error.", result.cause)
_state.value = _state.value.copy(loadState = RemoteBackupRestoreState.LoadState.Failure)
}
}
@@ -18,6 +18,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
@@ -31,6 +33,8 @@ import org.signal.registration.NetworkController
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRepository
import org.signal.registration.RestoreDecision
import java.io.IOException
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
class RemoteBackupRestoreViewModelTest {
@@ -72,7 +76,22 @@ class RemoteBackupRestoreViewModelTest {
)
}
// ==================== BackupRestoreBackup Tests ====================
/** Keeps the [WhileSubscribed] `state` flow hot and records every emission for assertions. */
private fun TestScope.collectStatesOf(viewModel: RemoteBackupRestoreViewModel): List<RemoteBackupRestoreState> {
val states = mutableListOf<RemoteBackupRestoreState>()
backgroundScope.launch { viewModel.state.collect { states.add(it) } }
return states
}
private fun backupInfo(usedSpace: Long? = 1024L) = NetworkController.GetBackupInfoResponse(
cdn = 3,
backupDir = "dir",
mediaDir = "media",
backupName = "backup",
usedSpace = usedSpace
)
// ==================== BackupRestoreBackup ====================
@Test
fun `BackupRestoreBackup emits InProgress state and triggers restore`() = runTest(testDispatcher) {
@@ -90,7 +109,7 @@ class RemoteBackupRestoreViewModelTest {
coVerify { mockRepository.restoreRemoteBackup(aep) }
}
// ==================== Cancel Tests ====================
// ==================== Cancel ====================
@Test
fun `Cancel emits NavigateBack`() = runTest(testDispatcher) {
@@ -103,7 +122,30 @@ class RemoteBackupRestoreViewModelTest {
assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack)
}
// ==================== DismissError Tests ====================
// ==================== Retry ====================
@Test
fun `Retry reloads backup info and re-emits the current state`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
val currentState = RemoteBackupRestoreState(aep = aep)
viewModel.applyEvent(currentState, RemoteBackupRestoreScreenEvents.Retry, stateEmitter)
coVerify(exactly = 2) { mockRepository.getRemoteBackupInfo(aep) }
assertThat(emittedStates).hasSize(1)
assertThat(states.last().loadAttempts).isEqualTo(2)
}
@Test
fun `init records a single load attempt`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
assertThat(states.last().loadAttempts).isEqualTo(1)
}
// ==================== DismissError ====================
@Test
fun `DismissError resets restoreState to None and clears progress`() = runTest(testDispatcher) {
@@ -125,17 +167,11 @@ class RemoteBackupRestoreViewModelTest {
assertThat(emittedStates.last().restoreProgress).isNull()
}
// ==================== loadBackupInfo Tests ====================
// ==================== loadBackupInfo ====================
@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
)
val info = backupInfo()
coEvery { mockRepository.getRemoteBackupInfo(any()) } returns RequestResult.Success(info)
coEvery { mockRepository.getBackupFileLastModified(any(), any()) } returns RequestResult.Success(1234L)
@@ -146,19 +182,172 @@ class RemoteBackupRestoreViewModelTest {
}
@Test
fun `init with NoBackup error invokes getRemoteBackupInfo`() = runTest(testDispatcher) {
fun `init with successful backup info moves to Loaded with size and time`() = runTest(testDispatcher) {
coEvery { mockRepository.getRemoteBackupInfo(any()) } returns RequestResult.Success(backupInfo(usedSpace = 2048L))
coEvery { mockRepository.getBackupFileLastModified(any(), any()) } returns RequestResult.Success(99999L)
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
assertThat(states.last().loadState).isEqualTo(RemoteBackupRestoreState.LoadState.Loaded)
assertThat(states.last().backupSize).isEqualTo(2048L)
assertThat(states.last().backupTime).isEqualTo(99999L)
}
@Test
fun `init with null usedSpace defaults backup size to zero`() = runTest(testDispatcher) {
coEvery { mockRepository.getRemoteBackupInfo(any()) } returns RequestResult.Success(backupInfo(usedSpace = null))
coEvery { mockRepository.getBackupFileLastModified(any(), any()) } returns RequestResult.Success(1L)
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
assertThat(states.last().loadState).isEqualTo(RemoteBackupRestoreState.LoadState.Loaded)
assertThat(states.last().backupSize).isEqualTo(0L)
}
@Test
fun `init with successful info but failed last-modified lookup uses sentinel backup time`() = runTest(testDispatcher) {
coEvery { mockRepository.getRemoteBackupInfo(any()) } returns RequestResult.Success(backupInfo())
coEvery { mockRepository.getBackupFileLastModified(any(), any()) } returns
RequestResult.NonSuccess(NetworkController.GetBackupInfoError.NoBackup)
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
assertThat(states.last().loadState).isEqualTo(RemoteBackupRestoreState.LoadState.Loaded)
assertThat(states.last().backupTime).isEqualTo(-1L)
}
@Test
fun `init with NoBackup moves to NotFound`() = runTest(testDispatcher) {
coEvery { mockRepository.getRemoteBackupInfo(any()) } returns
RequestResult.NonSuccess(NetworkController.GetBackupInfoError.NoBackup)
createViewModel()
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
coVerify { mockRepository.getRemoteBackupInfo(aep) }
assertThat(states.last().loadState).isEqualTo(RemoteBackupRestoreState.LoadState.NotFound)
}
// ==================== Restore Progress Tests ====================
@Test
fun `init with BadArguments moves to Failure`() = runTest(testDispatcher) {
coEvery { mockRepository.getRemoteBackupInfo(any()) } returns
RequestResult.NonSuccess(NetworkController.GetBackupInfoError.BadArguments())
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
assertThat(states.last().loadState).isEqualTo(RemoteBackupRestoreState.LoadState.Failure)
}
@Test
fun `BackupRestoreBackup Complete progress emits UserSuppliedAepVerified and hands off to finishRegistrationOrCreateProfile`() = runTest(testDispatcher) {
fun `init with BadAuthCredential moves to Failure`() = runTest(testDispatcher) {
coEvery { mockRepository.getRemoteBackupInfo(any()) } returns
RequestResult.NonSuccess(NetworkController.GetBackupInfoError.BadAuthCredential())
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
assertThat(states.last().loadState).isEqualTo(RemoteBackupRestoreState.LoadState.Failure)
}
@Test
fun `init with Forbidden moves to Failure`() = runTest(testDispatcher) {
coEvery { mockRepository.getRemoteBackupInfo(any()) } returns
RequestResult.NonSuccess(NetworkController.GetBackupInfoError.Forbidden())
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
assertThat(states.last().loadState).isEqualTo(RemoteBackupRestoreState.LoadState.Failure)
}
@Test
fun `init with RateLimited moves to Failure`() = runTest(testDispatcher) {
coEvery { mockRepository.getRemoteBackupInfo(any()) } returns
RequestResult.NonSuccess(NetworkController.GetBackupInfoError.RateLimited(30.seconds))
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
assertThat(states.last().loadState).isEqualTo(RemoteBackupRestoreState.LoadState.Failure)
}
@Test
fun `init with retryable network error moves to Failure`() = runTest(testDispatcher) {
coEvery { mockRepository.getRemoteBackupInfo(any()) } returns
RequestResult.RetryableNetworkError(IOException("Network error"))
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
assertThat(states.last().loadState).isEqualTo(RemoteBackupRestoreState.LoadState.Failure)
}
@Test
fun `init with application error moves to Failure`() = runTest(testDispatcher) {
coEvery { mockRepository.getRemoteBackupInfo(any()) } returns
RequestResult.ApplicationError(RuntimeException("Unexpected"))
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
assertThat(states.last().loadState).isEqualTo(RemoteBackupRestoreState.LoadState.Failure)
}
// ==================== Restore progress ====================
@Test
fun `Downloading progress maps to InProgress with download bytes`() = runTest(testDispatcher) {
every { mockRepository.restoreRemoteBackup(any()) } returns flowOf(
RemoteBackupRestoreProgress.Downloading(bytesDownloaded = 30, totalBytes = 100)
)
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.BackupRestoreBackup, stateEmitter)
val last = states.last()
assertThat(last.restoreState).isEqualTo(RemoteBackupRestoreState.RestoreState.InProgress)
assertThat(last.restoreProgress?.phase).isEqualTo(RemoteBackupRestoreState.RestoreProgress.Phase.Downloading)
assertThat(last.restoreProgress?.bytesCompleted).isEqualTo(30L)
assertThat(last.restoreProgress?.totalBytes).isEqualTo(100L)
}
@Test
fun `Restoring progress maps to InProgress with restore bytes`() = runTest(testDispatcher) {
every { mockRepository.restoreRemoteBackup(any()) } returns flowOf(
RemoteBackupRestoreProgress.Restoring(bytesRead = 75, totalBytes = 100)
)
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.BackupRestoreBackup, stateEmitter)
val last = states.last()
assertThat(last.restoreState).isEqualTo(RemoteBackupRestoreState.RestoreState.InProgress)
assertThat(last.restoreProgress?.phase).isEqualTo(RemoteBackupRestoreState.RestoreProgress.Phase.Restoring)
assertThat(last.restoreProgress?.bytesCompleted).isEqualTo(75L)
}
@Test
fun `Finalizing progress maps to InProgress with finalizing phase`() = runTest(testDispatcher) {
every { mockRepository.restoreRemoteBackup(any()) } returns flowOf(
RemoteBackupRestoreProgress.Finalizing
)
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.BackupRestoreBackup, stateEmitter)
val last = states.last()
assertThat(last.restoreState).isEqualTo(RemoteBackupRestoreState.RestoreState.InProgress)
assertThat(last.restoreProgress?.phase).isEqualTo(RemoteBackupRestoreState.RestoreProgress.Phase.Finalizing)
}
@Test
fun `Complete progress emits UserSuppliedAepVerified and hands off to finishRegistrationOrCreateProfile`() = runTest(testDispatcher) {
every { mockRepository.restoreRemoteBackup(any()) } returns flowOf(
RemoteBackupRestoreProgress.Complete
)
@@ -179,21 +368,100 @@ class RemoteBackupRestoreViewModelTest {
}
@Test
fun `BackupRestoreBackup NetworkError progress triggers restore and emits no parent events`() = runTest(testDispatcher) {
fun `Complete progress moves restore state to Restored and clears progress`() = runTest(testDispatcher) {
every { mockRepository.restoreRemoteBackup(any()) } returns flowOf(
RemoteBackupRestoreProgress.Complete
)
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.BackupRestoreBackup, stateEmitter)
assertThat(states.last().restoreState).isEqualTo(RemoteBackupRestoreState.RestoreState.Restored)
assertThat(states.last().restoreProgress).isNull()
}
@Test
fun `NetworkError progress moves restore state to NetworkFailure and emits no parent events`() = runTest(testDispatcher) {
every { mockRepository.restoreRemoteBackup(any()) } returns flowOf(
RemoteBackupRestoreProgress.NetworkError()
)
val viewModel = createViewModel()
val initialState = RemoteBackupRestoreState(aep = aep)
val states = collectStatesOf(viewModel)
viewModel.applyEvent(
initialState,
RemoteBackupRestoreScreenEvents.BackupRestoreBackup,
stateEmitter
)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.BackupRestoreBackup, stateEmitter)
coVerify { mockRepository.restoreRemoteBackup(aep) }
assertThat(states.last().restoreState).isEqualTo(RemoteBackupRestoreState.RestoreState.NetworkFailure)
assertThat(emittedParentEvents).hasSize(0)
}
@Test
fun `InvalidBackupVersion progress moves restore state to InvalidBackupVersion`() = runTest(testDispatcher) {
every { mockRepository.restoreRemoteBackup(any()) } returns flowOf(
RemoteBackupRestoreProgress.InvalidBackupVersion
)
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.BackupRestoreBackup, stateEmitter)
assertThat(states.last().restoreState).isEqualTo(RemoteBackupRestoreState.RestoreState.InvalidBackupVersion)
}
@Test
fun `PermanentSvrBFailure progress moves restore state to PermanentSvrBFailure`() = runTest(testDispatcher) {
every { mockRepository.restoreRemoteBackup(any()) } returns flowOf(
RemoteBackupRestoreProgress.PermanentSvrBFailure
)
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.BackupRestoreBackup, stateEmitter)
assertThat(states.last().restoreState).isEqualTo(RemoteBackupRestoreState.RestoreState.PermanentSvrBFailure)
}
@Test
fun `Canceled progress moves restore state to Failed`() = runTest(testDispatcher) {
every { mockRepository.restoreRemoteBackup(any()) } returns flowOf(
RemoteBackupRestoreProgress.Canceled
)
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.BackupRestoreBackup, stateEmitter)
assertThat(states.last().restoreState).isEqualTo(RemoteBackupRestoreState.RestoreState.Failed)
}
@Test
fun `GenericError progress moves restore state to Failed`() = runTest(testDispatcher) {
every { mockRepository.restoreRemoteBackup(any()) } returns flowOf(
RemoteBackupRestoreProgress.GenericError()
)
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.BackupRestoreBackup, stateEmitter)
assertThat(states.last().restoreState).isEqualTo(RemoteBackupRestoreState.RestoreState.Failed)
}
@Test
fun `a multi-phase progress sequence ends in Restored`() = runTest(testDispatcher) {
every { mockRepository.restoreRemoteBackup(any()) } returns flowOf(
RemoteBackupRestoreProgress.Downloading(bytesDownloaded = 10, totalBytes = 100),
RemoteBackupRestoreProgress.Restoring(bytesRead = 60, totalBytes = 100),
RemoteBackupRestoreProgress.Finalizing,
RemoteBackupRestoreProgress.Complete
)
val viewModel = createViewModel()
val states = collectStatesOf(viewModel)
viewModel.applyEvent(RemoteBackupRestoreState(aep = aep), RemoteBackupRestoreScreenEvents.BackupRestoreBackup, stateEmitter)
assertThat(states.last().restoreState).isEqualTo(RemoteBackupRestoreState.RestoreState.Restored)
coVerify { mockRepository.finishRegistrationOrCreateProfile(parentEventEmitter, any()) }
}
}