From 08bec27ef56a995c88888f19992d615018ddae10 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 29 Jul 2026 17:01:04 -0700 Subject: [PATCH] fix: repair passkey rename flow --- .../com/clerk/api/network/api/UserApi.kt | 10 ++- .../com/clerk/api/network/api/UserApiTest.kt | 38 ++++++++++++ .../rename/UserProfilePasskeyRenameView.kt | 25 ++++++-- .../UserProfilePasskeyRenameViewModel.kt | 4 ++ .../UserProfilePasskeyRenameViewModelTest.kt | 61 +++++++++++++++++++ .../UserProfilePasskeyRenameViewTest.kt | 36 +++++++++++ 6 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 source/api/src/test/java/com/clerk/api/network/api/UserApiTest.kt create mode 100644 source/ui/src/test/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameViewModelTest.kt create mode 100644 source/ui/src/test/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameViewTest.kt diff --git a/source/api/src/main/kotlin/com/clerk/api/network/api/UserApi.kt b/source/api/src/main/kotlin/com/clerk/api/network/api/UserApi.kt index 8e2151343..b6220e2c6 100644 --- a/source/api/src/main/kotlin/com/clerk/api/network/api/UserApi.kt +++ b/source/api/src/main/kotlin/com/clerk/api/network/api/UserApi.kt @@ -402,22 +402,26 @@ internal interface UserApi { * Retrieves a specific passkey by its ID. * * @param passkeyId The ID of the passkey to retrieve + * @param sessionId Optional session ID. Defaults to current session ID from [Clerk.session] * @return [ClerkResult] containing the [Passkey] on success or [ClerkErrorResponse] on failure */ @GET(ApiPaths.User.Passkey.WITH_ID) suspend fun getPasskey( - @Path(ApiParams.PASSKEY_ID) passkeyId: String + @Path(ApiParams.PASSKEY_ID) passkeyId: String, + @Query(ApiParams.CLERK_SESSION_ID) sessionId: String? = Clerk.session?.id, ): ClerkResult /** * Deletes a specific passkey. * * @param passkeyId The ID of the passkey to delete + * @param sessionId Optional session ID. Defaults to current session ID from [Clerk.session] * @return [ClerkResult] containing [DeletedObject] on success or [ClerkErrorResponse] on failure */ @DELETE(ApiPaths.User.Passkey.WITH_ID) suspend fun deletePasskey( - @Path(ApiParams.PASSKEY_ID) passkeyId: String + @Path(ApiParams.PASSKEY_ID) passkeyId: String, + @Query(ApiParams.CLERK_SESSION_ID) sessionId: String? = Clerk.session?.id, ): ClerkResult /** @@ -425,6 +429,7 @@ internal interface UserApi { * * @param passkeyId The ID of the passkey to update * @param name Optional: New name for the passkey + * @param sessionId Optional session ID. Defaults to current session ID from [Clerk.session] * @return [ClerkResult] containing the updated [Passkey] on success or [ClerkErrorResponse] on * failure */ @@ -433,6 +438,7 @@ internal interface UserApi { suspend fun updatePasskey( @Path(ApiParams.PASSKEY_ID) passkeyId: String, @Field("name") name: String? = null, + @Query(ApiParams.CLERK_SESSION_ID) sessionId: String? = Clerk.session?.id, ): ClerkResult /** diff --git a/source/api/src/test/java/com/clerk/api/network/api/UserApiTest.kt b/source/api/src/test/java/com/clerk/api/network/api/UserApiTest.kt new file mode 100644 index 000000000..d7a942578 --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/network/api/UserApiTest.kt @@ -0,0 +1,38 @@ +package com.clerk.api.network.api + +import com.clerk.api.network.ApiParams +import kotlin.test.Test +import kotlin.test.assertTrue +import retrofit2.http.Query + +class UserApiTest { + + @Test + fun `passkey endpoints include clerk session id query`() { + val passkeyMethods = + listOf( + "createPasskey", + "getPasskey", + "deletePasskey", + "updatePasskey", + "attemptPasskeyVerification", + ) + + passkeyMethods.forEach { methodName -> + assertTrue( + method(methodName).hasQuery(ApiParams.CLERK_SESSION_ID), + "$methodName should include _clerk_session_id", + ) + } + } + + private fun method(name: String): java.lang.reflect.Method { + return UserApi::class.java.methods.single { it.name == name } + } + + private fun java.lang.reflect.Method.hasQuery(value: String): Boolean { + return parameterAnnotations.any { annotations -> + annotations.filterIsInstance().any { it.value == value } + } + } +} diff --git a/source/ui/src/main/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameView.kt b/source/ui/src/main/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameView.kt index 27575e4a9..19b73403b 100644 --- a/source/ui/src/main/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameView.kt +++ b/source/ui/src/main/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameView.kt @@ -44,14 +44,20 @@ private fun UserProfilePasskeyRenameViewImpl( viewModel: UserProfilePasskeyRenameViewModel = viewModel(), ) { val userProfileState = LocalUserProfileState.current - var passkeyNameInput by rememberSaveable { mutableStateOf(passkeyName) } - var originalPasskeyName by rememberSaveable { mutableStateOf(passkeyName) } + var passkeyNameInput by rememberSaveable(passkeyId) { mutableStateOf(passkeyName) } + val originalPasskeyName = rememberSaveable(passkeyId) { passkeyName } + val updatedPasskeyName = + updatedPasskeyNameOrNull( + passkeyNameInput = passkeyNameInput, + originalPasskeyName = originalPasskeyName, + ) val state by viewModel.state.collectAsStateWithLifecycle() val errorMessage = (state as? UserProfilePasskeyRenameViewModel.State.Error)?.message LaunchedEffect(state) { if (state is UserProfilePasskeyRenameViewModel.State.Success) { userProfileState.navigateBack() + viewModel.resetState() } } @@ -77,13 +83,24 @@ private fun UserProfilePasskeyRenameViewImpl( ClerkButton( modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.save), - isEnabled = passkeyName.isNotBlank() && passkeyName != originalPasskeyName, + isEnabled = updatedPasskeyName != null, isLoading = state is UserProfilePasskeyRenameViewModel.State.Loading, - onClick = { viewModel.renamePasskey(passkeyId = passkeyId, newName = passkeyName) }, + onClick = { + updatedPasskeyName?.let { viewModel.renamePasskey(passkeyId = passkeyId, newName = it) } + }, ) } } +internal fun updatedPasskeyNameOrNull( + passkeyNameInput: String, + originalPasskeyName: String, +): String? { + return passkeyNameInput.takeIf { + passkeyNameInput.isNotBlank() && passkeyNameInput != originalPasskeyName + } +} + @PreviewLightDark @Composable private fun Preview() { diff --git a/source/ui/src/main/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameViewModel.kt b/source/ui/src/main/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameViewModel.kt index 2f82bee1a..a92d5528a 100644 --- a/source/ui/src/main/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameViewModel.kt +++ b/source/ui/src/main/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameViewModel.kt @@ -28,6 +28,10 @@ internal class UserProfilePasskeyRenameViewModel : ViewModel() { } } + fun resetState() { + _state.value = State.Idle + } + sealed interface State { data object Idle : State diff --git a/source/ui/src/test/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameViewModelTest.kt b/source/ui/src/test/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameViewModelTest.kt new file mode 100644 index 000000000..00d436fa2 --- /dev/null +++ b/source/ui/src/test/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameViewModelTest.kt @@ -0,0 +1,61 @@ +package com.clerk.ui.userprofile.security.passkey.rename + +import app.cash.turbine.test +import com.clerk.api.Clerk +import com.clerk.api.network.serialization.ClerkResult +import com.clerk.api.passkeys.Passkey +import com.clerk.api.passkeys.update +import com.clerk.api.user.User +import com.clerk.ui.userprofile.MainDispatcherRule +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.unmockkStatic +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class UserProfilePasskeyRenameViewModelTest { + + @get:org.junit.Rule val dispatcherRule = MainDispatcherRule() + + @BeforeTest + fun setUp() { + mockkObject(Clerk) + mockkStatic("com.clerk.api.passkeys.PasskeyKt") + } + + @AfterTest + fun tearDown() { + unmockkStatic("com.clerk.api.passkeys.PasskeyKt") + unmockkAll() + } + + @Test + fun `successful rename state can be reset before reopening`() = runTest { + val passkey = mockk() + val user = mockk() + every { passkey.id } returns "passkey_123" + every { user.passkeys } returns listOf(passkey) + every { Clerk.user } returns user + coEvery { passkey.update(name = "Work laptop") } returns ClerkResult.success(passkey) + + val viewModel = UserProfilePasskeyRenameViewModel() + viewModel.state.test { + assertEquals(UserProfilePasskeyRenameViewModel.State.Idle, awaitItem()) + + viewModel.renamePasskey(passkeyId = "passkey_123", newName = "Work laptop") + assertEquals(UserProfilePasskeyRenameViewModel.State.Success, awaitItem()) + + viewModel.resetState() + assertEquals(UserProfilePasskeyRenameViewModel.State.Idle, awaitItem()) + } + } +} diff --git a/source/ui/src/test/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameViewTest.kt b/source/ui/src/test/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameViewTest.kt new file mode 100644 index 000000000..fe612f2ca --- /dev/null +++ b/source/ui/src/test/java/com/clerk/ui/userprofile/security/passkey/rename/UserProfilePasskeyRenameViewTest.kt @@ -0,0 +1,36 @@ +package com.clerk.ui.userprofile.security.passkey.rename + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class UserProfilePasskeyRenameViewTest { + + @Test + fun `updated passkey name is null when input is unchanged`() { + assertNull( + updatedPasskeyNameOrNull( + passkeyNameInput = "Personal phone", + originalPasskeyName = "Personal phone", + ) + ) + } + + @Test + fun `updated passkey name is null when input is blank`() { + assertNull( + updatedPasskeyNameOrNull(passkeyNameInput = " ", originalPasskeyName = "Personal phone") + ) + } + + @Test + fun `updated passkey name returns changed input`() { + assertEquals( + "Work laptop", + updatedPasskeyNameOrNull( + passkeyNameInput = "Work laptop", + originalPasskeyName = "Personal phone", + ), + ) + } +}