feat: opt-in release candidate update channel (#1199)
This commit is contained in:
17 files changed
+1071
-60
No files matched your search
@@ -93,4 +93,18 @@ class ServerStoreTest {
|
||||
val decoded = json.decodeFromString<ServerStoreState>("{}")
|
||||
assertFalse(decoded.keepConnectedInBackground)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCheckReleaseCandidateUpdatesDefaultsToStableOnly() {
|
||||
val state = ServerStoreState()
|
||||
assertFalse(state.checkReleaseCandidateUpdates)
|
||||
assertTrue(state.copy(checkReleaseCandidateUpdates = true).checkReleaseCandidateUpdates)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCheckReleaseCandidateUpdates_deserializationDefault() {
|
||||
val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
|
||||
val decoded = json.decodeFromString<ServerStoreState>("{}")
|
||||
assertFalse(decoded.checkReleaseCandidateUpdates)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.m57.hermescontrol.data.update
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Endpoint contract for the opt-in release-candidate channel: the stable path
|
||||
* must keep using `releases/latest` (GitHub excludes pre-releases there), and
|
||||
* only the opt-in path may scan the releases list.
|
||||
*/
|
||||
class AppUpdateCheckerNetworkTest {
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun checker(): AppUpdateChecker =
|
||||
AppUpdateChecker(
|
||||
client = OkHttpClient(),
|
||||
apiBaseUrl = server.url("/repos/Hy4ri/hermes-mobile").toString(),
|
||||
)
|
||||
|
||||
private fun enqueue(
|
||||
body: String,
|
||||
code: Int = 200,
|
||||
) {
|
||||
server.enqueue(MockResponse().setResponseCode(code).setBody(body))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchLatestRelease_defaultUsesStableLatestEndpoint() =
|
||||
runTest {
|
||||
enqueue(STABLE_BODY)
|
||||
|
||||
val info = checker().fetchLatestRelease()
|
||||
|
||||
assertEquals("v1.24.2", info?.tagName)
|
||||
assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchLatestRelease_releaseCandidatesScanTheReleasesList() =
|
||||
runTest {
|
||||
enqueue(RELEASES_BODY)
|
||||
|
||||
val info = checker().fetchLatestRelease(includeReleaseCandidates = true)
|
||||
|
||||
assertEquals("v1.25.0-rc.3", info?.tagName)
|
||||
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=20", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchLatestRelease_releaseCandidatesStillIgnoreAlphaAndBeta() =
|
||||
runTest {
|
||||
enqueue(ALPHA_BETA_BODY)
|
||||
|
||||
assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchLatestRelease_nonSuccessYieldsNull() =
|
||||
runTest {
|
||||
// One queued response per request: stable path, then RC path.
|
||||
enqueue("", code = 404)
|
||||
enqueue("", code = 404)
|
||||
|
||||
assertNull(checker().fetchLatestRelease())
|
||||
assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STABLE_BODY = """
|
||||
{
|
||||
"tag_name": "v1.24.2",
|
||||
"prerelease": false,
|
||||
"draft": false,
|
||||
"assets": [
|
||||
{
|
||||
"name": "hermes-mobile-v1.24.2.apk",
|
||||
"size": 12345,
|
||||
"browser_download_url": "https://example.com/stable.apk"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
const val RELEASES_BODY = """
|
||||
[
|
||||
{
|
||||
"tag_name": "v1.25.0-rc.3",
|
||||
"prerelease": true,
|
||||
"draft": false,
|
||||
"assets": [
|
||||
{
|
||||
"name": "hermes-mobile-v1.25.0-rc.3.apk",
|
||||
"size": 999,
|
||||
"browser_download_url": "https://example.com/rc.apk"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag_name": "v1.24.2",
|
||||
"prerelease": false,
|
||||
"draft": false,
|
||||
"assets": [
|
||||
{
|
||||
"name": "hermes-mobile-v1.24.2.apk",
|
||||
"size": 12345,
|
||||
"browser_download_url": "https://example.com/stable.apk"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"""
|
||||
|
||||
const val ALPHA_BETA_BODY = """
|
||||
[
|
||||
{
|
||||
"tag_name": "v1.30.0-alpha.1",
|
||||
"prerelease": true,
|
||||
"assets": [
|
||||
{
|
||||
"name": "hermes-mobile-v1.30.0-alpha.1.apk",
|
||||
"size": 1,
|
||||
"browser_download_url": "https://example.com/alpha.apk"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag_name": "v1.29.0-beta.4",
|
||||
"prerelease": true,
|
||||
"assets": [
|
||||
{
|
||||
"name": "hermes-mobile-v1.29.0-beta.4.apk",
|
||||
"size": 1,
|
||||
"browser_download_url": "https://example.com/beta.apk"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -175,4 +175,159 @@ class AppUpdateCheckerTest {
|
||||
assertNull(parseUpdateInfo("not json at all"))
|
||||
assertNull(parseUpdateInfo(""))
|
||||
}
|
||||
|
||||
// ── Release candidate channel (opt-in) ──────────────────────────────
|
||||
|
||||
private fun release(
|
||||
tag: String,
|
||||
draft: Boolean = false,
|
||||
prerelease: Boolean = false,
|
||||
withApk: Boolean = true,
|
||||
): UpdateInfo =
|
||||
UpdateInfo(
|
||||
tagName = tag,
|
||||
draft = draft,
|
||||
prerelease = prerelease,
|
||||
assets =
|
||||
if (withApk) {
|
||||
listOf(
|
||||
UpdateInfo.Asset(
|
||||
name = "hermes-mobile-$tag.apk",
|
||||
size = 1L,
|
||||
browserDownloadUrl = "https://example.com/$tag.apk",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
UpdateInfo.Asset(
|
||||
name = "version.txt",
|
||||
size = 1L,
|
||||
browserDownloadUrl = "https://example.com/version.txt",
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@Test
|
||||
fun isReleaseCandidateVersion_acceptsRcTagForms() {
|
||||
listOf("v1.25.0-rc.1", "1.25-rc.1", "1.25.rc.1", "v1.25.0-rc", "1.25-rc1", "v1.25.0-RC.2").forEach {
|
||||
assertTrue(it, isReleaseCandidateVersion(it))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isReleaseCandidateVersion_rejectsStableAndNonRcPrereleases() {
|
||||
listOf("1.25", "v1.25.0", "1.25-alpha", "1.25-alpha.1", "1.25-beta.2", "1.25-dev", "garbage", "").forEach {
|
||||
assertFalse(it, isReleaseCandidateVersion(it))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectLatestUpdate_skipsDraftsAndReleasesWithoutApk() {
|
||||
val releases =
|
||||
listOf(
|
||||
release("v1.30.0", draft = true),
|
||||
release("v1.29.0", withApk = false),
|
||||
release("v1.28.0"),
|
||||
)
|
||||
|
||||
assertEquals("v1.28.0", selectLatestUpdate(releases)?.tagName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectLatestUpdate_excludesRcUnlessOptedIn() {
|
||||
val releases =
|
||||
listOf(
|
||||
release("v1.25.0-rc.3", prerelease = true),
|
||||
release("v1.24.2"),
|
||||
)
|
||||
|
||||
assertEquals("v1.24.2", selectLatestUpdate(releases)?.tagName)
|
||||
assertEquals("v1.25.0-rc.3", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectLatestUpdate_neverOffersAlphaOrBetaEvenWhenOptedIn() {
|
||||
val releases =
|
||||
listOf(
|
||||
release("v1.30.0-alpha.1", prerelease = true),
|
||||
release("v1.29.0-beta.4", prerelease = true),
|
||||
release("v1.28.0-rc.1", prerelease = true),
|
||||
)
|
||||
|
||||
assertEquals("v1.28.0-rc.1", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName)
|
||||
assertNull(selectLatestUpdate(releases))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectLatestUpdate_prefersStableWhenItIsNewerThanTheRc() {
|
||||
val releases =
|
||||
listOf(
|
||||
release("v1.26.0"),
|
||||
release("v1.25.0-rc.3", prerelease = true),
|
||||
)
|
||||
|
||||
// List order is publication order, not version order — the stable
|
||||
// release must win on semantics, not on position.
|
||||
assertEquals("v1.26.0", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectLatestUpdate_prefersNewerRcOverOlderStable() {
|
||||
val releases =
|
||||
listOf(
|
||||
release("v1.24.2"),
|
||||
release("v1.25.0-rc.3", prerelease = true),
|
||||
)
|
||||
|
||||
assertEquals("v1.25.0-rc.3", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectLatestUpdate_nullWhenNothingInstallable() {
|
||||
assertNull(selectLatestUpdate(emptyList()))
|
||||
assertNull(selectLatestUpdate(listOf(release("v1.25.0", draft = true), release("v1.24.0", withApk = false))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseReleaseList_readsDraftAndPrereleaseFlags() {
|
||||
val json =
|
||||
"""
|
||||
[
|
||||
{
|
||||
"tag_name": "v1.25.0-rc.3",
|
||||
"prerelease": true,
|
||||
"draft": false,
|
||||
"assets": [
|
||||
{
|
||||
"name": "hermes-mobile-v1.25.0-rc.3.apk",
|
||||
"size": 999,
|
||||
"browser_download_url": "https://example.com/rc.apk"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag_name": "v1.24.2",
|
||||
"assets": []
|
||||
}
|
||||
]
|
||||
""".trimIndent()
|
||||
|
||||
val releases = parseReleaseList(json)
|
||||
assertNotNull(releases)
|
||||
assertEquals(2, releases!!.size)
|
||||
assertEquals("v1.25.0-rc.3", releases[0].tagName)
|
||||
assertTrue(releases[0].prerelease)
|
||||
assertFalse(releases[0].draft)
|
||||
assertNotNull(releases[0].apkAsset)
|
||||
// Absent flags default to a plain stable release with no APK.
|
||||
assertFalse(releases[1].prerelease)
|
||||
assertNull(releases[1].apkAsset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseReleaseList_malformedJson_yieldsNull() {
|
||||
assertNull(parseReleaseList("not json at all"))
|
||||
assertNull(parseReleaseList("{}"))
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,8 @@ class UpdateNoticeManagerTest {
|
||||
every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit
|
||||
every { AuthManager.getDismissedUpdateTag() } returns null
|
||||
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
|
||||
every { AuthManager.setCheckReleaseCandidateUpdates(any()) } returns Unit
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -259,4 +261,85 @@ class UpdateNoticeManagerTest {
|
||||
|
||||
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
|
||||
}
|
||||
|
||||
// ── Release candidate channel (opt-in) ──────────────────────────────
|
||||
|
||||
@Test
|
||||
fun checkOnLaunch_staysOnStableChannelByDefault() =
|
||||
runTest {
|
||||
val checker = mockk<AppUpdateChecker>()
|
||||
coEvery { checker.fetchLatestRelease(false) } returns updateInfo()
|
||||
|
||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
|
||||
coVerify(exactly = 0) { checker.fetchLatestRelease(true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkOnLaunch_scansReleaseCandidatesWhenOptedIn() =
|
||||
runTest {
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
||||
val checker = mockk<AppUpdateChecker>()
|
||||
coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3")
|
||||
|
||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { checker.fetchLatestRelease(true) }
|
||||
assertEquals(
|
||||
"v1.25.0-rc.3",
|
||||
(AppUpdateCache.state.value as AppUpdateState.UpdateAvailable).latestTag,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noticeTag_suppressesPersistedRcTagOnStableChannel() {
|
||||
every { AuthManager.getLastKnownLatestTag() } returns "v1.25.0-rc.3"
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
|
||||
|
||||
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noticeTag_advertisesPersistedRcTagWhenOptedIn() {
|
||||
every { AuthManager.getLastKnownLatestTag() } returns "v1.25.0-rc.3"
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
||||
|
||||
assertEquals("v1.25.0-rc.3", UpdateNoticeManager.noticeTag(currentVersion))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noticeTag_suppressesCachedRcOfferOnStableChannel() {
|
||||
AppUpdateCache.update(
|
||||
AppUpdateState.UpdateAvailable(
|
||||
latestTag = "v1.25.0-rc.3",
|
||||
apkUrl = "https://example.com/apk",
|
||||
sizeBytes = 1L,
|
||||
),
|
||||
)
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
|
||||
|
||||
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkOnLaunch_discardsRcResultWhenChannelFlippedMidRequest() =
|
||||
runTest {
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
||||
val checker = mockk<AppUpdateChecker>()
|
||||
coEvery { checker.fetchLatestRelease(true) } coAnswers {
|
||||
// The user turns the RC channel off while the request is in flight.
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
|
||||
updateInfo(tag = "v1.25.0-rc.3")
|
||||
}
|
||||
|
||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||
advanceUntilIdle()
|
||||
|
||||
// The late RC result must not reach the cache or the persisted tag.
|
||||
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
|
||||
verify(exactly = 0) { AuthManager.setLastKnownLatestTag(any()) }
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,17 @@ import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -75,6 +79,8 @@ class AppUpdateViewModelTest {
|
||||
every { AuthManager.setLastUpdateCheckTimestamp(any()) } returns Unit
|
||||
every { AuthManager.getDismissedUpdateTag() } returns null
|
||||
every { AuthManager.setDismissedUpdateTag(any()) } returns Unit
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
|
||||
every { AuthManager.setCheckReleaseCandidateUpdates(any()) } returns Unit
|
||||
|
||||
app = mockk(relaxed = true)
|
||||
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
|
||||
@@ -348,4 +354,244 @@ class AppUpdateViewModelTest {
|
||||
assertTrue(AppUpdateCache.dismissed)
|
||||
assertFalse(AppUpdateCache.isDialogVisible)
|
||||
}
|
||||
|
||||
// ── Release candidate channel (opt-in) ──────────────────────────────
|
||||
|
||||
@Test
|
||||
fun init_checkUsesStableChannelByDefault() =
|
||||
runTest {
|
||||
val vm = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
|
||||
coVerify(exactly = 0) { checker.fetchLatestRelease(true) }
|
||||
assertFalse(vm.checkReleaseCandidateUpdates.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun init_doesNotAdoptCachedRcOfferOnStableChannel() =
|
||||
runTest {
|
||||
AppUpdateCache.update(
|
||||
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
|
||||
)
|
||||
|
||||
val vm = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
|
||||
assertEquals(
|
||||
AppUpdateState.UpdateAvailable(
|
||||
"v1.22.0",
|
||||
"https://example.com/hermes-mobile-v1.22.0.apk",
|
||||
12345678L,
|
||||
),
|
||||
vm.state.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun init_adoptsCachedRcOfferWhenOptedIn() =
|
||||
runTest {
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
||||
AppUpdateCache.update(
|
||||
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
|
||||
)
|
||||
|
||||
val vm = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { checker.fetchLatestRelease(any()) }
|
||||
assertEquals(
|
||||
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
|
||||
vm.state.value,
|
||||
)
|
||||
assertTrue(vm.checkReleaseCandidateUpdates.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setCheckReleaseCandidateUpdates_persistsAndRechecksOnRcChannel() =
|
||||
runTest {
|
||||
val vm = createViewModel()
|
||||
advanceUntilIdle()
|
||||
coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3")
|
||||
|
||||
vm.setCheckReleaseCandidateUpdates(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(vm.checkReleaseCandidateUpdates.value)
|
||||
verify(exactly = 1) { AuthManager.setCheckReleaseCandidateUpdates(true) }
|
||||
coVerify(exactly = 1) { checker.fetchLatestRelease(true) }
|
||||
assertEquals("v1.25.0-rc.3", (vm.state.value as AppUpdateState.UpdateAvailable).latestTag)
|
||||
assertEquals(vm.state.value, AppUpdateCache.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setCheckReleaseCandidateUpdates_sameValueDoesNotRecheckOrPersist() =
|
||||
runTest {
|
||||
val vm = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.setCheckReleaseCandidateUpdates(false)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(exactly = 0) { AuthManager.setCheckReleaseCandidateUpdates(any()) }
|
||||
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setCheckReleaseCandidateUpdates_offDropsStaleRcBeforeCheckingStable() =
|
||||
runTest {
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
||||
coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3")
|
||||
|
||||
val vm = createViewModel()
|
||||
advanceUntilIdle()
|
||||
assertEquals("v1.25.0-rc.3", (vm.state.value as AppUpdateState.UpdateAvailable).latestTag)
|
||||
|
||||
// Back on the stable channel the installed version is current.
|
||||
coEvery { checker.fetchLatestRelease(false) } returns updateInfo(tag = "1.21.0")
|
||||
vm.setCheckReleaseCandidateUpdates(false)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(vm.checkReleaseCandidateUpdates.value)
|
||||
assertEquals(AppUpdateState.UpToDate("1.21.0"), vm.state.value)
|
||||
assertEquals(AppUpdateState.UpToDate("1.21.0"), AppUpdateCache.state.value)
|
||||
// The RC offer must be gone everywhere, so startUpdate() is a no-op.
|
||||
vm.startUpdate()
|
||||
advanceUntilIdle()
|
||||
coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkForUpdate_afterOptInUsesRcChannel() =
|
||||
runTest {
|
||||
val vm = createViewModel()
|
||||
advanceUntilIdle()
|
||||
coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3")
|
||||
|
||||
vm.setCheckReleaseCandidateUpdates(true)
|
||||
advanceUntilIdle()
|
||||
vm.checkForUpdate()
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 2) { checker.fetchLatestRelease(true) }
|
||||
}
|
||||
|
||||
// ── Channel-switch races ────────────────────────────────────────────
|
||||
//
|
||||
// The real fetch bottoms out in a blocking OkHttp `execute()`, which cannot
|
||||
// be interrupted by coroutine cancellation. `withContext(NonCancellable)`
|
||||
// models that faithfully: the job is cancelled, the call still finishes, and
|
||||
// its result must not be published.
|
||||
|
||||
@Test
|
||||
fun lateRcResultAfterOptOut_neverPublishesRcOrInstalls() =
|
||||
runTest {
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
||||
val rcGate = CompletableDeferred<Unit>()
|
||||
coEvery { checker.fetchLatestRelease(true) } coAnswers {
|
||||
withContext(NonCancellable) { rcGate.await() }
|
||||
updateInfo(tag = "v1.25.0-rc.3")
|
||||
}
|
||||
coEvery { checker.fetchLatestRelease(false) } returns updateInfo(tag = "1.21.0")
|
||||
|
||||
val vm = createViewModel()
|
||||
advanceUntilIdle()
|
||||
assertTrue("RC check must start in flight", vm.state.value is AppUpdateState.Checking)
|
||||
|
||||
// User opts out while the RC check is still mid-request.
|
||||
vm.setCheckReleaseCandidateUpdates(false)
|
||||
// Let the un-interruptible RC request finish AFTER the channel switch.
|
||||
rcGate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(vm.checkReleaseCandidateUpdates.value)
|
||||
assertEquals(AppUpdateState.UpToDate("1.21.0"), vm.state.value)
|
||||
assertEquals(AppUpdateState.UpToDate("1.21.0"), AppUpdateCache.state.value)
|
||||
verify(exactly = 1) { AuthManager.setLastKnownLatestTag("1.21.0") }
|
||||
verify(exactly = 0) { AuthManager.setLastKnownLatestTag("v1.25.0-rc.3") }
|
||||
|
||||
// And the stale RC offer must not be installable.
|
||||
vm.startUpdate()
|
||||
advanceUntilIdle()
|
||||
coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancelledCheck_neverSurfacesAnErrorState() =
|
||||
runTest {
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
coEvery { checker.fetchLatestRelease(false) } coAnswers {
|
||||
gate.await()
|
||||
updateInfo()
|
||||
}
|
||||
coEvery { checker.fetchLatestRelease(true) } returns updateInfo()
|
||||
|
||||
val vm = createViewModel()
|
||||
val seen = mutableListOf<AppUpdateState>()
|
||||
backgroundScope.launch { vm.state.collect { seen += it } }
|
||||
advanceUntilIdle()
|
||||
assertTrue(vm.state.value is AppUpdateState.Checking)
|
||||
|
||||
// Switching channels cancels the in-flight stable check.
|
||||
vm.setCheckReleaseCandidateUpdates(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
// A cancelled check must never be reported as a failed one — not even
|
||||
// momentarily, because the next legitimate result would mask it.
|
||||
assertTrue(
|
||||
"cancellation must not emit an error state: $seen",
|
||||
seen.none { it is AppUpdateState.Error },
|
||||
)
|
||||
assertEquals(
|
||||
AppUpdateState.UpdateAvailable(
|
||||
"v1.22.0",
|
||||
"https://example.com/hermes-mobile-v1.22.0.apk",
|
||||
12345678L,
|
||||
),
|
||||
vm.state.value,
|
||||
)
|
||||
coVerify(exactly = 1) { checker.fetchLatestRelease(true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startUpdate_refusesCachedRcOfferOnStableChannel() =
|
||||
runTest {
|
||||
coEvery { checker.fetchLatestRelease(false) } returns updateInfo(tag = "1.21.0")
|
||||
val vm = createViewModel()
|
||||
advanceUntilIdle()
|
||||
assertEquals(AppUpdateState.UpToDate("1.21.0"), vm.state.value)
|
||||
|
||||
// A late RC result lands in the shared cache behind this ViewModel's
|
||||
// back while the user is on the stable channel.
|
||||
AppUpdateCache.update(
|
||||
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
|
||||
)
|
||||
|
||||
vm.startUpdate()
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) }
|
||||
// Refusal clears the stale offer and re-checks instead of dead-ending.
|
||||
assertEquals(AppUpdateState.UpToDate("1.21.0"), vm.state.value)
|
||||
assertEquals(AppUpdateState.UpToDate("1.21.0"), AppUpdateCache.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumeInstallAfterPermission_refusesStaleRcOffer() =
|
||||
runTest {
|
||||
coEvery { checker.fetchLatestRelease(false) } returns updateInfo(tag = "1.21.0")
|
||||
val vm = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
AppUpdateCache.update(
|
||||
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
|
||||
)
|
||||
|
||||
vm.resumeInstallAfterPermission()
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { checker.downloadApk(any(), any(), any()) }
|
||||
verify(exactly = 0) { app.startActivity(any()) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user