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", "502 Bad Gateway", "[]", """{"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 { 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 { server.on("app-version.json", versionFile(minimum = 13)) val gate = gate(versionCode = 13) gate.check() assertNull(gate.required.value) } @Test fun aMissingStoreUrlFallsBackToThePlayListing() = runBlocking { 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 { server.on("app-version.json", "oops", code = 503) gate().apply { check() }.also { assertNull(it.required.value) } server.on("app-version.json", "not the file") gate().apply { check() }.also { assertNull(it.required.value) } gate(http = unreachable()).apply { check() }.also { assertNull(it.required.value) } } @Test fun aBlockedBuildIsStillBlockedOnAnOfflineLaunch() = runBlocking { 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 { 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 { 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" } }