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.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.unspecced.searchActorsTypeahead" @Before fun setUp() { harness = Harness(signedIn = false) harness.server .on( typeahead, """{"actors":[{"did":"did:plc:alice","handle":"alice.grain.social"}]}""", ) .on("par", """{"request_uri":"urn:ietf:params:oauth:request_uri:abc"}""") } @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("https://grain.social/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.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.signIn() model.signIn() model.state.awaitValue("the authorize url") { it.authorizeUrl != null } assertEquals(1, harness.server.countOf("par")) } @Test fun thereIsNoReauthReasonOnAPlainSignIn() { assertNull(viewModel().reauthReason) } }