Fix small race condition with username reservation.

This commit is contained in:
Greyson Parrelli
2026-09-10 17:10:15 -04:00
committed by Cody Henthorne
parent 914917c7e9
commit 28c69c8a0a
2 changed files with 40 additions and 1 deletions
@@ -126,6 +126,9 @@ class AddUsernameViewModel(
* Non-digits are dropped as they're typed, since a discriminator can only ever be digits.
*
* Emptying the field hands control back to the service once the field loses focus (see [applyDiscriminatorFocusLost]).
* Until then we hold onto a service-assigned reservation, since that pairing is still the one the entry is showing.
* A user-typed reservation, on the other hand, is dropped as soon as its digits are erased -- the user asked for that
* exact number and no longer wants it, so there is nothing left to submit until the field settles.
*/
private fun applyDiscriminatorChanged(state: AddUsernameState, discriminator: String, stateEmitter: (AddUsernameState) -> Unit) {
val digitsOnly = discriminator.filter { it in '0'..'9' }
@@ -141,7 +144,7 @@ class AddUsernameViewModel(
discriminator = digitsOnly,
isDiscriminatorUserSet = isUserSet,
validationError = null,
reservation = if (isUserSet) null else state.reservation,
reservation = state.reservation.takeIf { !isUserSet && !state.isDiscriminatorUserSet },
isReserving = false
)
@@ -205,6 +205,42 @@ class AddUsernameViewModelTest {
assertThat(viewModel.state.value.isSubmittable).isTrue()
}
@Test
fun `emptying a user-typed discriminator drops its reservation`() = runTest(testDispatcher) {
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45"))
coEvery { mockRepository.reserveUsername("maya", "77") } returns RequestResult.Success(Username("maya.77"))
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
advanceUntilIdle()
viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("77"))
advanceUntilIdle()
viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged(""))
advanceUntilIdle()
assertThat(viewModel.state.value.discriminator).isEmpty()
assertThat(viewModel.state.value.isDiscriminatorUserSet).isFalse()
assertThat(viewModel.state.value.reservation).isNull()
assertThat(viewModel.state.value.isSubmittable).isFalse()
}
@Test
fun `NextClicked cannot confirm a discriminator the user just erased`() = runTest(testDispatcher) {
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45"))
coEvery { mockRepository.reserveUsername("maya", "77") } returns RequestResult.Success(Username("maya.77"))
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
advanceUntilIdle()
viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("77"))
advanceUntilIdle()
viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged(""))
advanceUntilIdle()
viewModel.onEvent(AddUsernameScreenEvents.NextClicked)
advanceUntilIdle()
coVerify(exactly = 0) { mockRepository.confirmUsername(any()) }
assertThat(parentEvents).isEmpty()
}
@Test
fun `losing focus with an empty discriminator hands it back to the service`() = runTest(testDispatcher) {
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45")) andThen RequestResult.Success(Username("maya.99"))