Skip to content

Commit 4cac4d6

Browse files
SaintPatrckclaude
andauthored
Add comprehensive tests for Unlock feature (#6426)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent a2ec99f commit 4cac4d6

File tree

2 files changed

+774
-0
lines changed

2 files changed

+774
-0
lines changed
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
package com.bitwarden.authenticator.ui.auth.unlock
2+
3+
import androidx.compose.ui.test.assert
4+
import androidx.compose.ui.test.assertIsDisplayed
5+
import androidx.compose.ui.test.hasAnyAncestor
6+
import androidx.compose.ui.test.isDialog
7+
import androidx.compose.ui.test.onNodeWithContentDescription
8+
import androidx.compose.ui.test.onNodeWithTag
9+
import androidx.compose.ui.test.onNodeWithText
10+
import androidx.compose.ui.test.performClick
11+
import com.bitwarden.authenticator.ui.platform.base.AuthenticatorComposeTest
12+
import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager
13+
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
14+
import com.bitwarden.ui.platform.resource.BitwardenString
15+
import com.bitwarden.ui.util.asText
16+
import io.mockk.every
17+
import io.mockk.just
18+
import io.mockk.mockk
19+
import io.mockk.runs
20+
import io.mockk.slot
21+
import io.mockk.verify
22+
import kotlinx.coroutines.flow.MutableStateFlow
23+
import org.junit.Assert.assertTrue
24+
import org.junit.Before
25+
import org.junit.Test
26+
import javax.crypto.Cipher
27+
28+
class UnlockScreenTest : AuthenticatorComposeTest() {
29+
30+
private var onUnlockedCalled = false
31+
32+
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
33+
private val mutableEventFlow = bufferedMutableSharedFlow<UnlockEvent>()
34+
35+
private val mockViewModel: UnlockViewModel = mockk(relaxed = true) {
36+
every { stateFlow } returns mutableStateFlow
37+
every { eventFlow } returns mutableEventFlow
38+
every { trySendAction(any()) } just runs
39+
}
40+
41+
private val mockBiometricsManager: BiometricsManager = mockk(relaxed = true)
42+
private val mockCipher: Cipher = mockk()
43+
44+
@Before
45+
fun setUp() {
46+
setContent(
47+
biometricsManager = mockBiometricsManager,
48+
) {
49+
UnlockScreen(
50+
viewModel = mockViewModel,
51+
onUnlocked = { onUnlockedCalled = true },
52+
)
53+
}
54+
}
55+
56+
@Test
57+
fun `unlock button should be displayed`() {
58+
composeTestRule
59+
.onNodeWithText("Unlock")
60+
.assertIsDisplayed()
61+
}
62+
63+
@Test
64+
fun `logo should be displayed`() {
65+
composeTestRule
66+
.onNodeWithContentDescription("Bitwarden Authenticator")
67+
.assertIsDisplayed()
68+
}
69+
70+
@Test
71+
fun `unlock button click should send BiometricsUnlockClick action`() {
72+
composeTestRule
73+
.onNodeWithText("Unlock")
74+
.performClick()
75+
76+
verify {
77+
mockViewModel.trySendAction(UnlockAction.BiometricsUnlockClick)
78+
}
79+
}
80+
81+
@Test
82+
fun `NavigateToItemListing event should call onUnlocked callback`() {
83+
mutableEventFlow.tryEmit(UnlockEvent.NavigateToItemListing)
84+
85+
assertTrue(onUnlockedCalled)
86+
}
87+
88+
@Test
89+
fun `PromptForBiometrics event should call biometricsManager`() {
90+
val onSuccessSlot = slot<(Cipher) -> Unit>()
91+
val onCancelSlot = slot<() -> Unit>()
92+
val onErrorSlot = slot<() -> Unit>()
93+
val onLockOutSlot = slot<() -> Unit>()
94+
95+
every {
96+
mockBiometricsManager.promptBiometrics(
97+
onSuccess = capture(onSuccessSlot),
98+
onCancel = capture(onCancelSlot),
99+
onError = capture(onErrorSlot),
100+
onLockOut = capture(onLockOutSlot),
101+
cipher = mockCipher,
102+
)
103+
} just runs
104+
105+
mutableEventFlow.tryEmit(UnlockEvent.PromptForBiometrics(mockCipher))
106+
107+
verify {
108+
mockBiometricsManager.promptBiometrics(
109+
onSuccess = any(),
110+
onCancel = any(),
111+
onError = any(),
112+
onLockOut = any(),
113+
cipher = mockCipher,
114+
)
115+
}
116+
}
117+
118+
@Test
119+
fun `biometric success callback should send BiometricsUnlockSuccess action`() {
120+
val onSuccessSlot = slot<(Cipher) -> Unit>()
121+
122+
every {
123+
mockBiometricsManager.promptBiometrics(
124+
onSuccess = capture(onSuccessSlot),
125+
onCancel = any(),
126+
onError = any(),
127+
onLockOut = any(),
128+
cipher = mockCipher,
129+
)
130+
} just runs
131+
132+
mutableEventFlow.tryEmit(UnlockEvent.PromptForBiometrics(mockCipher))
133+
134+
// Invoke the captured success callback
135+
onSuccessSlot.captured.invoke(mockCipher)
136+
137+
verify {
138+
mockViewModel.trySendAction(UnlockAction.BiometricsUnlockSuccess(mockCipher))
139+
}
140+
}
141+
142+
@Test
143+
fun `biometric lockout callback should send BiometricsLockout action`() {
144+
val onLockOutSlot = slot<() -> Unit>()
145+
146+
every {
147+
mockBiometricsManager.promptBiometrics(
148+
onSuccess = any(),
149+
onCancel = any(),
150+
onError = any(),
151+
onLockOut = capture(onLockOutSlot),
152+
cipher = mockCipher,
153+
)
154+
} just runs
155+
156+
mutableEventFlow.tryEmit(UnlockEvent.PromptForBiometrics(mockCipher))
157+
158+
// Invoke the captured lockout callback
159+
onLockOutSlot.captured.invoke()
160+
161+
verify {
162+
mockViewModel.trySendAction(UnlockAction.BiometricsLockout)
163+
}
164+
}
165+
166+
@Test
167+
fun `error dialog should display when state has Error dialog`() {
168+
mutableStateFlow.value = DEFAULT_STATE.copy(
169+
dialog = UnlockState.Dialog.Error(
170+
title = "Error Title".asText(),
171+
message = "Error Message".asText(),
172+
),
173+
)
174+
175+
composeTestRule
176+
.onNodeWithText("Error Title")
177+
.assert(hasAnyAncestor(isDialog()))
178+
.assertIsDisplayed()
179+
180+
composeTestRule
181+
.onNodeWithText("Error Message")
182+
.assert(hasAnyAncestor(isDialog()))
183+
.assertIsDisplayed()
184+
}
185+
186+
@Test
187+
fun `error dialog dismiss should send DismissDialog action`() {
188+
mutableStateFlow.value = DEFAULT_STATE.copy(
189+
dialog = UnlockState.Dialog.Error(
190+
title = BitwardenString.an_error_has_occurred.asText(),
191+
message = BitwardenString.generic_error_message.asText(),
192+
),
193+
)
194+
195+
composeTestRule
196+
.onNodeWithTag("AcceptAlertButton")
197+
.assert(hasAnyAncestor(isDialog()))
198+
.performClick()
199+
200+
verify {
201+
mockViewModel.trySendAction(UnlockAction.DismissDialog)
202+
}
203+
}
204+
205+
@Test
206+
fun `loading dialog should display when state has Loading dialog`() {
207+
mutableStateFlow.value = DEFAULT_STATE.copy(
208+
dialog = UnlockState.Dialog.Loading,
209+
)
210+
211+
composeTestRule
212+
.onNodeWithText("Loading")
213+
.assert(hasAnyAncestor(isDialog()))
214+
.assertIsDisplayed()
215+
}
216+
217+
@Test
218+
fun `no dialog should be displayed when state dialog is null`() {
219+
mutableStateFlow.value = DEFAULT_STATE.copy(dialog = null)
220+
221+
composeTestRule
222+
.onNodeWithText("Loading")
223+
.assertDoesNotExist()
224+
225+
composeTestRule
226+
.onNodeWithText("Ok")
227+
.assertDoesNotExist()
228+
}
229+
230+
@Test
231+
fun `biometric cancel callback should not crash`() {
232+
val onCancelSlot = slot<() -> Unit>()
233+
234+
every {
235+
mockBiometricsManager.promptBiometrics(
236+
onSuccess = any(),
237+
onCancel = capture(onCancelSlot),
238+
onError = any(),
239+
onLockOut = any(),
240+
cipher = mockCipher,
241+
)
242+
} just runs
243+
244+
mutableEventFlow.tryEmit(UnlockEvent.PromptForBiometrics(mockCipher))
245+
246+
// Invoke the captured cancel callback - should not crash
247+
onCancelSlot.captured.invoke()
248+
249+
// Verify no action was sent (it's a no-op)
250+
verify(exactly = 0) {
251+
mockViewModel.trySendAction(any())
252+
}
253+
}
254+
255+
@Test
256+
fun `biometric error callback should not crash`() {
257+
val onErrorSlot = slot<() -> Unit>()
258+
259+
every {
260+
mockBiometricsManager.promptBiometrics(
261+
onSuccess = any(),
262+
onCancel = any(),
263+
onError = capture(onErrorSlot),
264+
onLockOut = any(),
265+
cipher = mockCipher,
266+
)
267+
} just runs
268+
269+
mutableEventFlow.tryEmit(UnlockEvent.PromptForBiometrics(mockCipher))
270+
271+
// Invoke the captured error callback - should not crash
272+
onErrorSlot.captured.invoke()
273+
274+
// Verify no action was sent (it's a no-op)
275+
verify(exactly = 0) {
276+
mockViewModel.trySendAction(any())
277+
}
278+
}
279+
}
280+
281+
private val DEFAULT_STATE = UnlockState(
282+
isBiometricsEnabled = true,
283+
isBiometricsValid = true,
284+
showBiometricInvalidatedMessage = false,
285+
dialog = null,
286+
)

0 commit comments

Comments
 (0)