mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 10:48:47 +03:00
BIT-279, BIT-1201: Storage, retrieval, and clearing implementation for password history (#416)
This commit is contained in:
parent
39e285fff8
commit
27140bf02c
9 changed files with 357 additions and 71 deletions
|
@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn
|
|||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import java.time.Instant
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
|
@ -67,7 +68,11 @@ class GeneratorRepositoryImpl(
|
|||
}
|
||||
.onEach { encryptedPasswordHistoryListResult ->
|
||||
mutablePasswordHistoryStateFlow.value = encryptedPasswordHistoryListResult.fold(
|
||||
onSuccess = { LocalDataState.Loaded(it) },
|
||||
onSuccess = {
|
||||
LocalDataState.Loaded(
|
||||
it.sortedByDescending { history -> history.lastUsedDate },
|
||||
)
|
||||
},
|
||||
onFailure = { LocalDataState.Error(it) },
|
||||
)
|
||||
}
|
||||
|
@ -78,7 +83,14 @@ class GeneratorRepositoryImpl(
|
|||
generatorSdkSource
|
||||
.generatePassword(passwordGeneratorRequest)
|
||||
.fold(
|
||||
onSuccess = { GeneratedPasswordResult.Success(it) },
|
||||
onSuccess = { generatedPassword ->
|
||||
val passwordHistoryView = PasswordHistoryView(
|
||||
password = generatedPassword,
|
||||
lastUsedDate = Instant.now(),
|
||||
)
|
||||
storePasswordHistory(passwordHistoryView)
|
||||
GeneratedPasswordResult.Success(generatedPassword)
|
||||
},
|
||||
onFailure = { GeneratedPasswordResult.InvalidRequest },
|
||||
)
|
||||
|
||||
|
@ -88,7 +100,14 @@ class GeneratorRepositoryImpl(
|
|||
generatorSdkSource
|
||||
.generatePassphrase(passphraseGeneratorRequest)
|
||||
.fold(
|
||||
onSuccess = { GeneratedPassphraseResult.Success(it) },
|
||||
onSuccess = { generatedPassphrase ->
|
||||
val passwordHistoryView = PasswordHistoryView(
|
||||
password = generatedPassphrase,
|
||||
lastUsedDate = Instant.now(),
|
||||
)
|
||||
storePasswordHistory(passwordHistoryView)
|
||||
GeneratedPassphraseResult.Success(generatedPassphrase)
|
||||
},
|
||||
onFailure = { GeneratedPassphraseResult.InvalidRequest },
|
||||
)
|
||||
|
||||
|
|
|
@ -28,11 +28,14 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
|
||||
|
@ -48,6 +51,7 @@ import kotlinx.collections.immutable.persistentListOf
|
|||
fun PasswordHistoryScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: PasswordHistoryViewModel = hiltViewModel(),
|
||||
clipboardManager: ClipboardManager = LocalClipboardManager.current,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
@ -56,6 +60,11 @@ fun PasswordHistoryScreen(
|
|||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
PasswordHistoryEvent.NavigateBack -> onNavigateBack.invoke()
|
||||
|
||||
is PasswordHistoryEvent.CopyTextToClipboard -> {
|
||||
clipboardManager.setText(event.text.toAnnotatedString())
|
||||
}
|
||||
|
||||
is PasswordHistoryEvent.ShowToast -> {
|
||||
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
@ -189,7 +198,7 @@ private fun PasswordHistoryError(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = state.message,
|
||||
text = state.message.invoke(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
|
|
|
@ -1,9 +1,22 @@
|
|||
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.PasswordHistoryView
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState
|
||||
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.PasswordHistoryState.GeneratedPassword
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.util.toFormattedPattern
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -12,16 +25,61 @@ import javax.inject.Inject
|
|||
*/
|
||||
@HiltViewModel
|
||||
@Suppress("TooManyFunctions")
|
||||
class PasswordHistoryViewModel @Inject constructor() :
|
||||
BaseViewModel<PasswordHistoryState, PasswordHistoryEvent, PasswordHistoryAction>(
|
||||
initialState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading),
|
||||
) {
|
||||
class PasswordHistoryViewModel @Inject constructor(
|
||||
private val generatorRepository: GeneratorRepository,
|
||||
) : BaseViewModel<PasswordHistoryState, PasswordHistoryEvent, PasswordHistoryAction>(
|
||||
initialState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading),
|
||||
) {
|
||||
|
||||
init {
|
||||
generatorRepository
|
||||
.passwordHistoryStateFlow
|
||||
.map { PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: PasswordHistoryAction) {
|
||||
when (action) {
|
||||
PasswordHistoryAction.CloseClick -> handleCloseClick()
|
||||
is PasswordHistoryAction.PasswordCopyClick -> handleCopyClick(action.password)
|
||||
PasswordHistoryAction.PasswordClearClick -> handlePasswordHistoryClearClick()
|
||||
is PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive -> {
|
||||
handleUpdatePasswordHistoryReceive(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUpdatePasswordHistoryReceive(
|
||||
action: PasswordHistoryAction.Internal.UpdatePasswordHistoryReceive,
|
||||
) {
|
||||
val newState = when (val state = action.state) {
|
||||
is LocalDataState.Loading -> PasswordHistoryState.ViewState.Loading
|
||||
|
||||
is LocalDataState.Error -> {
|
||||
PasswordHistoryState.ViewState.Error(R.string.an_error_has_occurred.asText())
|
||||
}
|
||||
|
||||
is LocalDataState.Loaded -> {
|
||||
val passwords = state.data.map { passwordHistoryView ->
|
||||
GeneratedPassword(
|
||||
password = passwordHistoryView.password,
|
||||
date = passwordHistoryView.lastUsedDate.toFormattedPattern(
|
||||
pattern = "MM/dd/yy h:mm a",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (passwords.isEmpty()) {
|
||||
PasswordHistoryState.ViewState.Empty
|
||||
} else {
|
||||
PasswordHistoryState.ViewState.Content(passwords)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = newState)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -32,19 +90,13 @@ class PasswordHistoryViewModel @Inject constructor() :
|
|||
}
|
||||
|
||||
private fun handlePasswordHistoryClearClick() {
|
||||
sendEvent(
|
||||
event = PasswordHistoryEvent.ShowToast(
|
||||
message = "Not yet implemented.",
|
||||
),
|
||||
)
|
||||
viewModelScope.launch {
|
||||
generatorRepository.clearPasswordHistory()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCopyClick(password: GeneratedPassword) {
|
||||
sendEvent(
|
||||
event = PasswordHistoryEvent.ShowToast(
|
||||
message = "Not yet implemented.",
|
||||
),
|
||||
)
|
||||
sendEvent(PasswordHistoryEvent.CopyTextToClipboard(password.password))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -76,7 +128,7 @@ data class PasswordHistoryState(
|
|||
* @property message The error message to be displayed.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(val message: String) : ViewState()
|
||||
data class Error(val message: Text) : ViewState()
|
||||
|
||||
/**
|
||||
* Empty state for the password history screen.
|
||||
|
@ -122,6 +174,11 @@ sealed class PasswordHistoryEvent {
|
|||
* Event to navigate back to the previous screen.
|
||||
*/
|
||||
data object NavigateBack : PasswordHistoryEvent()
|
||||
|
||||
/**
|
||||
* Copies text to the clipboard.
|
||||
*/
|
||||
data class CopyTextToClipboard(val text: String) : PasswordHistoryEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -145,4 +202,17 @@ sealed class PasswordHistoryAction {
|
|||
* Action when the close button is clicked.
|
||||
*/
|
||||
data object CloseClick : PasswordHistoryAction()
|
||||
|
||||
/**
|
||||
* Models actions that the [PasswordHistoryViewModel] itself might send.
|
||||
*/
|
||||
sealed class Internal : PasswordHistoryAction() {
|
||||
|
||||
/**
|
||||
* Indicates a password history update is received.
|
||||
*/
|
||||
data class UpdatePasswordHistoryReceive(
|
||||
val state: LocalDataState<List<PasswordHistoryView>>,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
package com.x8bit.bitwarden.ui.tools.feature.generator.util
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* Converts the [Instant] to a formatted string based on the provided pattern and time zone.
|
||||
*/
|
||||
fun Instant.toFormattedPattern(
|
||||
pattern: String,
|
||||
zone: ZoneId = TimeZone.getDefault().toZoneId(),
|
||||
): String {
|
||||
val formatter = DateTimeFormatter.ofPattern(pattern).withZone(zone)
|
||||
return formatter.format(this)
|
||||
}
|
|
@ -25,20 +25,21 @@ import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassph
|
|||
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult
|
||||
import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.runs
|
||||
import io.mockk.unmockkStatic
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
|
||||
|
@ -50,6 +51,7 @@ class GeneratorRepositoryTest {
|
|||
private val generatorDiskSource: GeneratorDiskSource = mockk()
|
||||
private val authDiskSource: AuthDiskSource = mockk {
|
||||
every { userStateFlow } returns mutableUserStateFlow
|
||||
every { userState } returns null
|
||||
}
|
||||
private val passwordHistoryDiskSource: PasswordHistoryDiskSource = mockk()
|
||||
private val vaultSdkSource: VaultSdkSource = mockk()
|
||||
|
@ -64,13 +66,19 @@ class GeneratorRepositoryTest {
|
|||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
clearMocks(generatorSdkSource)
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(Instant::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generatePassword should emit Success result with the generated password`() = runTest {
|
||||
fun `generatePassword should emit Success result and store the generated password`() = runTest {
|
||||
val fixedInstant = Instant.parse("2021-01-01T00:00:00Z")
|
||||
|
||||
mockkStatic(Instant::class)
|
||||
every { Instant.now() } returns fixedInstant
|
||||
|
||||
val userId = "testUserId"
|
||||
val request = PasswordGeneratorRequest(
|
||||
lowercase = true,
|
||||
uppercase = true,
|
||||
|
@ -83,15 +91,30 @@ class GeneratorRepositoryTest {
|
|||
minNumber = null,
|
||||
minSpecial = null,
|
||||
)
|
||||
val expectedResult = "GeneratedPassword123!"
|
||||
coEvery {
|
||||
generatorSdkSource.generatePassword(request)
|
||||
} returns Result.success(expectedResult)
|
||||
val generatedPassword = "GeneratedPassword123!"
|
||||
val encryptedPasswordHistory =
|
||||
PasswordHistory(password = generatedPassword, lastUsedDate = Instant.now())
|
||||
|
||||
coEvery { authDiskSource.userState?.activeUserId } returns userId
|
||||
|
||||
coEvery { generatorSdkSource.generatePassword(request) } returns
|
||||
Result.success(generatedPassword)
|
||||
|
||||
coEvery { vaultSdkSource.encryptPasswordHistory(any()) } returns
|
||||
Result.success(encryptedPasswordHistory)
|
||||
|
||||
coEvery { passwordHistoryDiskSource.insertPasswordHistory(any()) } just runs
|
||||
|
||||
val result = repository.generatePassword(request)
|
||||
|
||||
assertEquals(expectedResult, (result as GeneratedPasswordResult.Success).generatedString)
|
||||
assertEquals(generatedPassword, (result as GeneratedPasswordResult.Success).generatedString)
|
||||
coVerify { generatorSdkSource.generatePassword(request) }
|
||||
|
||||
coVerify {
|
||||
passwordHistoryDiskSource.insertPasswordHistory(
|
||||
encryptedPasswordHistory.toPasswordHistoryEntity(userId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -118,23 +141,47 @@ class GeneratorRepositoryTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `generatePassphrase should emit Success result with the generated passphrase`() = runTest {
|
||||
val request = PassphraseGeneratorRequest(
|
||||
numWords = 5.toUByte(),
|
||||
capitalize = true,
|
||||
includeNumber = true,
|
||||
wordSeparator = '-'.toString(),
|
||||
)
|
||||
val expectedResult = "Generated-Passphrase-123!"
|
||||
coEvery {
|
||||
generatorSdkSource.generatePassphrase(request)
|
||||
} returns Result.success(expectedResult)
|
||||
fun `generatePassphrase should emit Success result and store the generated passphrase`() =
|
||||
runTest {
|
||||
val fixedInstant = Instant.parse("2021-01-01T00:00:00Z")
|
||||
mockkStatic(Instant::class)
|
||||
every { Instant.now() } returns fixedInstant
|
||||
|
||||
val result = repository.generatePassphrase(request)
|
||||
val userId = "testUserId"
|
||||
val request = PassphraseGeneratorRequest(
|
||||
numWords = 5.toUByte(),
|
||||
capitalize = true,
|
||||
includeNumber = true,
|
||||
wordSeparator = "-",
|
||||
)
|
||||
val generatedPassphrase = "Generated-Passphrase-123"
|
||||
val encryptedPasswordHistory =
|
||||
PasswordHistory(password = generatedPassphrase, lastUsedDate = Instant.now())
|
||||
|
||||
assertEquals(expectedResult, (result as GeneratedPassphraseResult.Success).generatedString)
|
||||
coVerify { generatorSdkSource.generatePassphrase(request) }
|
||||
}
|
||||
coEvery { authDiskSource.userState?.activeUserId } returns userId
|
||||
|
||||
coEvery { generatorSdkSource.generatePassphrase(request) } returns
|
||||
Result.success(generatedPassphrase)
|
||||
|
||||
coEvery { vaultSdkSource.encryptPasswordHistory(any()) } returns
|
||||
Result.success(encryptedPasswordHistory)
|
||||
|
||||
coEvery { passwordHistoryDiskSource.insertPasswordHistory(any()) } just runs
|
||||
|
||||
val result = repository.generatePassphrase(request)
|
||||
|
||||
assertEquals(
|
||||
generatedPassphrase,
|
||||
(result as GeneratedPassphraseResult.Success).generatedString,
|
||||
)
|
||||
coVerify { generatorSdkSource.generatePassphrase(request) }
|
||||
coVerify { vaultSdkSource.encryptPasswordHistory(any()) }
|
||||
coVerify {
|
||||
passwordHistoryDiskSource.insertPasswordHistory(
|
||||
encryptedPasswordHistory.toPasswordHistoryEntity(userId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
|
@ -278,25 +325,25 @@ class GeneratorRepositoryTest {
|
|||
val encryptedPasswordHistoryEntities = listOf(
|
||||
PasswordHistoryEntity(
|
||||
userId = USER_STATE.activeUserId,
|
||||
encryptedPassword = "encryptedPassword1",
|
||||
generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(),
|
||||
encryptedPassword = "encryptedPassword2",
|
||||
generatedDateTimeMs = Instant.parse("2021-01-02T00:00:00Z").toEpochMilli(),
|
||||
),
|
||||
PasswordHistoryEntity(
|
||||
userId = USER_STATE.activeUserId,
|
||||
encryptedPassword = "encryptedPassword2",
|
||||
generatedDateTimeMs = Instant.parse("2021-01-02T00:00:00Z").toEpochMilli(),
|
||||
encryptedPassword = "encryptedPassword1",
|
||||
generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(),
|
||||
),
|
||||
)
|
||||
|
||||
val decryptedPasswordHistoryList = listOf(
|
||||
PasswordHistoryView(
|
||||
password = "password1",
|
||||
lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"),
|
||||
),
|
||||
PasswordHistoryView(
|
||||
password = "password2",
|
||||
lastUsedDate = Instant.parse("2021-01-02T00:00:00Z"),
|
||||
),
|
||||
PasswordHistoryView(
|
||||
password = "password1",
|
||||
lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"),
|
||||
),
|
||||
)
|
||||
|
||||
coEvery {
|
||||
|
|
|
@ -74,4 +74,19 @@ class FakeGeneratorRepository : GeneratorRepository {
|
|||
fun setMockGeneratePassphraseResult(result: GeneratedPassphraseResult) {
|
||||
generatePassphraseResult = result
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific password is in the history.
|
||||
*/
|
||||
fun isPasswordStoredInHistory(password: String): Boolean {
|
||||
val passwordHistoryList = mutablePasswordHistoryStateFlow.value.data.orEmpty()
|
||||
return passwordHistoryList.any { it.password == password }
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits specified state to the passwordHistoryStateFlow.
|
||||
*/
|
||||
fun emitPasswordHistoryState(state: LocalDataState<List<PasswordHistoryView>>) {
|
||||
mutablePasswordHistoryStateFlow.value = state
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
|
|||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
|
@ -49,7 +50,9 @@ class PasswordHistoryScreenTest : BaseComposeTest() {
|
|||
@Test
|
||||
fun `Error state should display error message`() {
|
||||
val errorMessage = "Error occurred"
|
||||
updateState(PasswordHistoryState(PasswordHistoryState.ViewState.Error(errorMessage)))
|
||||
updateState(
|
||||
PasswordHistoryState(PasswordHistoryState.ViewState.Error(errorMessage.asText())),
|
||||
)
|
||||
composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed()
|
||||
}
|
||||
|
||||
|
|
|
@ -1,10 +1,18 @@
|
|||
package com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.PasswordHistoryView
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState
|
||||
import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRepository
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.tools.feature.generator.util.toFormattedPattern
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
|
||||
class PasswordHistoryViewModelTest : BaseViewModelTest() {
|
||||
|
||||
|
@ -18,6 +26,77 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when repository emits Loading state the state updates correctly`() = runTest {
|
||||
val fakeRepository = FakeGeneratorRepository().apply {
|
||||
emitPasswordHistoryState(LocalDataState.Loading)
|
||||
}
|
||||
val viewModel = PasswordHistoryViewModel(generatorRepository = fakeRepository)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
val expectedState = PasswordHistoryState(PasswordHistoryState.ViewState.Loading)
|
||||
val actualState = awaitItem()
|
||||
assertEquals(expectedState, actualState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when repository emits Error state the state updates correctly`() = runTest {
|
||||
val fakeRepository = FakeGeneratorRepository().apply {
|
||||
emitPasswordHistoryState(LocalDataState.Error(Exception("An error has occurred.")))
|
||||
}
|
||||
val viewModel = PasswordHistoryViewModel(generatorRepository = fakeRepository)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
val expectedState = PasswordHistoryState(
|
||||
PasswordHistoryState.ViewState.Error(R.string.an_error_has_occurred.asText()),
|
||||
)
|
||||
val actualState = awaitItem()
|
||||
assertEquals(expectedState, actualState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when repository emits Empty state the state updates correctly`() = runTest {
|
||||
val fakeRepository = FakeGeneratorRepository().apply {
|
||||
emitPasswordHistoryState(LocalDataState.Loaded(emptyList()))
|
||||
}
|
||||
val viewModel = PasswordHistoryViewModel(generatorRepository = fakeRepository)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
val expectedState = PasswordHistoryState(PasswordHistoryState.ViewState.Empty)
|
||||
val actualState = awaitItem()
|
||||
assertEquals(expectedState, actualState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when password history updates the state updates correctly`() = runTest {
|
||||
val fakeRepository = FakeGeneratorRepository()
|
||||
val viewModel = PasswordHistoryViewModel(generatorRepository = fakeRepository)
|
||||
|
||||
val passwordHistoryView = PasswordHistoryView("password", Instant.now())
|
||||
fakeRepository.storePasswordHistory(passwordHistoryView)
|
||||
|
||||
val expectedState = PasswordHistoryState(
|
||||
viewState = PasswordHistoryState.ViewState.Content(
|
||||
passwords = listOf(
|
||||
PasswordHistoryState.GeneratedPassword(
|
||||
password = "password",
|
||||
date = passwordHistoryView.lastUsedDate.toFormattedPattern(
|
||||
pattern = "MM/dd/yy h:mm a",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
val actualState = awaitItem()
|
||||
assertEquals(expectedState, actualState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CloseClick action should emit NavigateBack event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
@ -29,37 +108,44 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `PasswordCopyClick action should emit password copied ShowToast event`() = runTest {
|
||||
fun `PasswordCopyClick action should emit CopyTextToClipboard event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val generatedPassword = PasswordHistoryState.GeneratedPassword(
|
||||
password = "testPassword",
|
||||
date = "01/01/23",
|
||||
)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.actionChannel.trySend(
|
||||
PasswordHistoryAction.PasswordCopyClick(
|
||||
PasswordHistoryState.GeneratedPassword(password = "Password", date = "Date"),
|
||||
),
|
||||
PasswordHistoryAction.PasswordCopyClick(generatedPassword),
|
||||
)
|
||||
assertEquals(
|
||||
PasswordHistoryEvent.CopyTextToClipboard(generatedPassword.password),
|
||||
awaitItem(),
|
||||
)
|
||||
assertEquals(PasswordHistoryEvent.ShowToast("Not yet implemented."), awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PasswordClearClick action should emit password history cleared ShowToast event`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
fun `PasswordClearClick action should update to Empty ViewState`() = runTest {
|
||||
val fakeRepository = FakeGeneratorRepository()
|
||||
val viewModel = PasswordHistoryViewModel(generatorRepository = fakeRepository)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.actionChannel.trySend(PasswordHistoryAction.PasswordClearClick)
|
||||
assertEquals(
|
||||
PasswordHistoryEvent.ShowToast("Not yet implemented."),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
val passwordHistoryView = PasswordHistoryView("password", Instant.now())
|
||||
fakeRepository.storePasswordHistory(passwordHistoryView)
|
||||
|
||||
viewModel.actionChannel.trySend(PasswordHistoryAction.PasswordClearClick)
|
||||
|
||||
assertTrue(fakeRepository.passwordHistoryStateFlow.value is LocalDataState.Loaded)
|
||||
assertTrue(
|
||||
(fakeRepository.passwordHistoryStateFlow.value as LocalDataState.Loaded).data.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
//region Helper Functions
|
||||
|
||||
private fun createViewModel(): PasswordHistoryViewModel {
|
||||
return PasswordHistoryViewModel()
|
||||
return PasswordHistoryViewModel(generatorRepository = FakeGeneratorRepository())
|
||||
}
|
||||
|
||||
//endregion Helper Functions
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
package com.x8bit.bitwarden.ui.tools.feature.generator.util
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
|
||||
class InstantExtensionsTest {
|
||||
|
||||
@Test
|
||||
fun `toFormattedPattern should return correctly formatted string`() {
|
||||
val instant = Instant.parse("2023-12-10T15:30:00Z")
|
||||
val pattern = "MM/dd/yyyy hh:mm a"
|
||||
val zone = ZoneId.of("UTC")
|
||||
val expectedFormattedString = "12/10/2023 03:30 PM"
|
||||
val formattedString = instant.toFormattedPattern(pattern, zone)
|
||||
|
||||
assertEquals(expectedFormattedString, formattedString)
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue