~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1
main
194 lines · 6.3 KB Raw History
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)
    }
}