package social.grain import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import social.grain.data.api.ActorSuggestion import social.grain.feature.login.LoginViewModel import social.grain.support.STUB_ISSUER import social.grain.support.stubSignIn import social.grain.support.Harness import social.grain.support.MainDispatcherRule import social.grain.support.applySnapshot import social.grain.support.awaitCall import social.grain.support.awaitValue import social.grain.support.settle import social.grain.support.type /** * The sign-in screen: a handle field with a typeahead, and the PAR call behind * the button. * * The subtle one is that choosing a suggestion writes it into the field, which * the collector watching the field sees as a fresh query — without the settled * handle it would reopen the suggestion list under the reader's finger. */ @RunWith(AndroidJUnit4::class) class LoginViewModelTest { @get:Rule val mainDispatcher = MainDispatcherRule() private lateinit var harness: Harness private val typeahead = "social.grain.actor.searchActorsTypeahead" @Before fun setUp() { harness = Harness(signedIn = false) harness.server .on( typeahead, """{"actors":[{"did":"did:plc:alice","handle":"alice.grain.social"}]}""", ) harness.server.stubSignIn(did = "did:plc:alice", handle = "alice.grain.social") } @After fun tearDown() = harness.tearDown() private fun viewModel() = LoginViewModel(harness.auth, harness.client) @Test fun typingAtLeastTwoCharactersFetchesSuggestions() { val model = viewModel() model.handleState.type("al") val state = model.state.awaitValue("the suggestions") { it.suggestions.isNotEmpty() } assertEquals("alice.grain.social", state.suggestions.single().handle) assertEquals("al", harness.server.lastCall(typeahead).params["q"]) } @Test fun theLeadingAtIsStrippedBeforeAsking() { val model = viewModel() model.handleState.type("@alice") harness.server.awaitCall(typeahead) assertEquals("alice", harness.server.lastCall(typeahead).params["q"]) } @Test fun oneCharacterIsNotEnoughToSuggestAnything() { val model = viewModel() model.handleState.type("a") settle() Thread.sleep(500) assertEquals(0, harness.server.countOf(typeahead)) assertTrue(model.state.value.suggestions.isEmpty()) } @Test fun choosingASuggestionFillsTheFieldWithoutReopeningTheList() { val model = viewModel() model.handleState.type("al") model.state.awaitValue("the suggestions") { it.suggestions.isNotEmpty() } val before = harness.server.countOf(typeahead) model.chooseSuggestion(ActorSuggestion(did = "did:plc:alice", handle = "alice.grain.social")) applySnapshot() Thread.sleep(500) settle() assertEquals("alice.grain.social", model.handleState.text.toString()) assertTrue(model.state.value.suggestions.isEmpty()) assertEquals("The chosen handle isn't looked up again", before, harness.server.countOf(typeahead)) } @Test fun dismissingTheListKeepsItClosedForWhatIsAlreadyTyped() { val model = viewModel() model.handleState.type("alice.grain.social") model.state.awaitValue("the suggestions") { it.suggestions.isNotEmpty() } model.clearSuggestions() applySnapshot() Thread.sleep(500) assertTrue(model.state.value.suggestions.isEmpty()) } @Test fun signingInRunsParAndHandsBackTheUrlToOpen() { val model = viewModel() model.handleState.type("alice.grain.social") model.signIn() val state = model.state.awaitValue("the authorize url") { it.authorizeUrl != null } assertTrue(state.authorizeUrl!!.startsWith("$STUB_ISSUER/oauth/authorize")) assertTrue(state.authorizeUrl!!.contains("request_uri=urn")) assertFalse(state.isLoading) val par = harness.server.lastCall("par") assertTrue(par.body.contains("code_challenge_method=S256")) assertTrue(par.body.contains("login_hint=alice.grain.social")) assertEquals(3, par.dpopProof!!.split('.').size) model.consumeAuthorizeUrl() assertNull("The url is consumed so a recomposition can't reopen the browser", model.state.value.authorizeUrl) } @Test fun creatingAnAccountAsksForTheSignUpPageInstead() { val model = viewModel() model.signIn(createAccount = true) model.state.awaitValue("the authorize url") { it.authorizeUrl != null } val par = harness.server.lastCall("par") assertTrue(par.body.contains("prompt=create")) } @Test fun aParThatFailsReportsItAndLeavesTheButtonUsable() { harness.server.on("par", """{"error":"no"}""", code = 500) val model = viewModel() model.handleState.type("alice.grain.social") model.signIn() val state = model.state.awaitValue("the error") { it.errorMessage != null } assertFalse(state.isLoading) assertNull(state.authorizeUrl) model.dismissError() assertNull(model.state.value.errorMessage) } @Test fun aSecondTapWhileTheFirstIsInFlightIsIgnored() { harness.server.on("par", """{"request_uri":"urn:a"}""") val model = viewModel() model.handleState.type("alice.grain.social") model.signIn() model.signIn() model.state.awaitValue("the authorize url") { it.authorizeUrl != null } assertEquals(1, harness.server.countOf("par")) } @Test fun signingInWithNoHandleAsksForOne() { val model = viewModel() model.signIn() val state = model.state.awaitValue("the error") { it.errorMessage != null } assertEquals("Enter your handle to sign in.", state.errorMessage) assertEquals(0, harness.server.countOf("par")) } @Test fun thereIsNoReauthReasonOnAPlainSignIn() { assertNull(viewModel().reauthReason) } }