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

grain.social/grain-android

1 collaborator
0
1
main
256 lines · 8.7 KB Raw History
package social.grain

import android.content.Context
import android.content.SharedPreferences
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
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.version.AndroidVersionPolicy
import social.grain.data.version.DEFAULT_STORE_URL
import social.grain.data.version.UpdateGate
import social.grain.data.version.UpdateRequired
import social.grain.data.version.isBelowMinimum
import social.grain.data.version.parseAndroidVersionPolicy
import social.grain.feature.update.UpdateRequiredScreen
import social.grain.feature.update.marketUriFor
import social.grain.support.STUB_BASE_URL
import social.grain.support.StubServer
import social.grain.support.appContext
import social.grain.support.testJson
import java.io.IOException

/**
 * The force-update gate.
 *
 * Its two failures are opposite and both bad: a gate that opens when it
 * shouldn't sends an old build into an API it can't speak, and one that closes
 * when it shouldn't locks everyone out over a missing file. So the tests are
 * mostly about what a bad or absent answer does to a good cached one.
 */
@RunWith(AndroidJUnit4::class)
class UpdateGateTest {

    @get:Rule
    val composeRule = createComposeRule()

    private lateinit var server: StubServer
    private lateinit var prefs: SharedPreferences

    @Before
    fun setUp() {
        server = StubServer()
        prefs = appContext.getSharedPreferences(UpdateGate.PREFS_NAME, Context.MODE_PRIVATE)
        prefs.edit().clear().commit()
    }

    @After
    fun tearDown() {
        prefs.edit().clear().commit()
        server.shutdown()
    }

    private fun gate(
        versionCode: Long = 13,
        http: OkHttpClient = server.client(),
    ) = UpdateGate(prefs, http, testJson, STUB_BASE_URL, versionCode)

    private fun versionFile(minimum: Int?, storeUrl: String? = PLAY_URL): String {
        val fields = listOfNotNull(
            minimum?.let { "\"minimumVersionCode\": $it" },
            storeUrl?.let { "\"storeUrl\": \"$it\"" },
        ).joinToString(", ")
        return """
            {
              "ios": { "minimumBuild": 0, "storeUrl": "https://apps.apple.com/app/id6747730230" },
              "android": { $fields }
            }
        """.trimIndent()
    }

    // MARK: - Decision

    @Test
    fun onlyABuildStrictlyBelowTheMinimumIsBlocked() {
        assertTrue(isBelowMinimum(versionCode = 12, minimum = 13))
        assertFalse("The minimum itself is supported", isBelowMinimum(versionCode = 13, minimum = 13))
        assertFalse(isBelowMinimum(versionCode = 14, minimum = 13))
        assertFalse("No minimum blocks nothing", isBelowMinimum(versionCode = 1, minimum = null))
    }

    // MARK: - Parsing

    @Test
    fun theContractDocumentParses() {
        assertEquals(
            AndroidVersionPolicy(minimumVersionCode = 0, storeUrl = PLAY_URL),
            parseAndroidVersionPolicy(versionFile(minimum = 0), testJson),
        )
    }

    @Test
    fun aMissingAndroidKeyOrMinimumMeansNoMinimum() {
        assertEquals(
            AndroidVersionPolicy(null, null),
            parseAndroidVersionPolicy("""{"ios":{"minimumBuild":40}}""", testJson),
        )
        assertEquals(
            AndroidVersionPolicy(null, PLAY_URL),
            parseAndroidVersionPolicy(versionFile(minimum = null), testJson),
        )
        assertNull(
            "A blank store URL is no store URL",
            parseAndroidVersionPolicy("""{"android":{"minimumVersionCode":3,"storeUrl":" "}}""", testJson)!!.storeUrl,
        )
    }

    @Test
    fun anythingButTheContractIsMalformed() {
        listOf(
            "",
            "not json",
            "<html>502 Bad Gateway</html>",
            "[]",
            """{"android":"14"}""",
            """{"android":{"minimumVersionCode":"14"}}""",
            """{"android":{"minimumVersionCode":14.5}}""",
            """{"android":{"minimumVersionCode":{"code":14}}}""",
        ).forEach { body ->
            assertNull("Expected <$body> to be malformed", parseAndroidVersionPolicy(body, testJson))
        }
    }

    // MARK: - Fetching

    @Test
    fun aBuildBelowTheFetchedMinimumIsSentToTheStore() = runBlocking<Unit> {
        server.on("app-version.json", versionFile(minimum = 14))
        val gate = gate(versionCode = 13)
        assertNull("Nothing cached, so nothing blocks before the fetch", gate.required.value)

        gate.check()

        assertEquals(UpdateRequired(PLAY_URL), gate.required.value)
        val call = server.callsTo("app-version.json").single()
        assertEquals("$STUB_BASE_URL/app-version.json", call.url.toString())
        assertEquals("GET", call.method)
    }

    @Test
    fun aBuildAtOrAboveTheMinimumCarriesOn() = runBlocking<Unit> {
        server.on("app-version.json", versionFile(minimum = 13))
        val gate = gate(versionCode = 13)

        gate.check()

        assertNull(gate.required.value)
    }

    @Test
    fun aMissingStoreUrlFallsBackToThePlayListing() = runBlocking<Unit> {
        server.on("app-version.json", versionFile(minimum = 14, storeUrl = null))
        val gate = gate(versionCode = 13)

        gate.check()

        assertEquals(UpdateRequired(DEFAULT_STORE_URL), gate.required.value)
    }

    @Test
    fun nothingCachedAndNoAnswerMeansNoBlock() = runBlocking<Unit> {
        server.on("app-version.json", "oops", code = 503)
        gate().apply { check() }.also { assertNull(it.required.value) }

        server.on("app-version.json", "<html>not the file</html>")
        gate().apply { check() }.also { assertNull(it.required.value) }

        gate(http = unreachable()).apply { check() }.also { assertNull(it.required.value) }
    }

    @Test
    fun aBlockedBuildIsStillBlockedOnAnOfflineLaunch() = runBlocking<Unit> {
        server.on("app-version.json", versionFile(minimum = 14))
        gate().check()

        // The next launch: a new gate over the same preferences, with no network.
        val offline = gate(http = unreachable())
        assertEquals(
            "Blocked from the cache before any request goes out",
            UpdateRequired(PLAY_URL),
            offline.required.value,
        )

        offline.check()
        assertEquals(UpdateRequired(PLAY_URL), offline.required.value)
    }

    @Test
    fun aFailedOrMalformedFetchLeavesTheCachedMinimumAlone() = runBlocking<Unit> {
        server.on("app-version.json", versionFile(minimum = 14))
        gate().check()

        server.on("app-version.json", "oops", code = 500)
        gate().apply { check() }.also { assertEquals(UpdateRequired(PLAY_URL), it.required.value) }

        server.on("app-version.json", """{"android":{"minimumVersionCode":"0"}}""")
        gate().apply { check() }.also { assertEquals(UpdateRequired(PLAY_URL), it.required.value) }
    }

    @Test
    fun aLoweredMinimumLiftsTheBlockAndForgetsIt() = runBlocking<Unit> {
        server.on("app-version.json", versionFile(minimum = 14))
        val gate = gate()
        gate.check()
        assertEquals(UpdateRequired(PLAY_URL), gate.required.value)

        server.on("app-version.json", """{"ios":{"minimumBuild":0}}""")
        gate.check()

        assertNull(gate.required.value)
        assertNull("The next launch starts open too", gate(http = unreachable()).required.value)
    }

    // MARK: - Screen

    @Test
    fun theScreenSaysWhyAndItsButtonOpensTheStore() {
        var opened: String? = null
        composeRule.setContent {
            UpdateRequiredScreen(storeUrl = PLAY_URL, onUpdate = { opened = it })
        }

        composeRule.onNodeWithText("Update Grain").assertExists()
        composeRule
            .onNodeWithText("This version of Grain is no longer supported. Update to keep using it.")
            .assertExists()
        composeRule.onNodeWithText("Update").performClick()

        assertEquals(PLAY_URL, opened)
    }

    @Test
    fun aPlayListingOpensInThePlayApp() {
        assertEquals("market://details?id=social.grain", marketUriFor(PLAY_URL).toString())
        assertNull(marketUriFor("https://example.com/grain.apk"))
        assertNull(marketUriFor("https://play.google.com/store/apps/details"))
    }

    private fun unreachable(): OkHttpClient = OkHttpClient.Builder()
        .addInterceptor { throw IOException("offline") }
        .build()

    private companion object {
        const val PLAY_URL = "https://play.google.com/store/apps/details?id=social.grain"
    }
}