fix(update): make app update check, discovery, and installer flow reliable
Automate background update checks on launch/foreground and share results across chat and settings. Disregard release candidates by default unless opted in, and handle installer cancellation and download failures cleanly.
This commit is contained in:
1 parent
68a17d5499
commit
19eacf260f
14 files changed
+723
-258
No files matched your search
@@ -1,14 +1,21 @@
|
||||
package com.m57.hermescontrol.data.update
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.SocketPolicy
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Endpoint contract for the opt-in release-candidate channel: the stable path
|
||||
@@ -38,8 +45,11 @@ class AppUpdateCheckerNetworkTest {
|
||||
private fun enqueue(
|
||||
body: String,
|
||||
code: Int = 200,
|
||||
headers: Map<String, String> = emptyMap(),
|
||||
) {
|
||||
server.enqueue(MockResponse().setResponseCode(code).setBody(body))
|
||||
val response = MockResponse().setResponseCode(code).setBody(body)
|
||||
headers.forEach(response::addHeader)
|
||||
server.enqueue(response)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -56,26 +66,62 @@ class AppUpdateCheckerNetworkTest {
|
||||
@Test
|
||||
fun fetchLatestRelease_releaseCandidatesScanTheReleasesList() =
|
||||
runTest {
|
||||
enqueue(STABLE_BODY)
|
||||
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)
|
||||
assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path)
|
||||
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=1", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchLatestRelease_releaseCandidatesStillIgnoreAlphaAndBeta() =
|
||||
fun fetchLatestRelease_stableFallsBackToReleasesWhenLatestIsNotUsable() =
|
||||
runTest {
|
||||
enqueue("{}")
|
||||
enqueue(RELEASES_BODY)
|
||||
|
||||
val info = checker().fetchLatestRelease()
|
||||
|
||||
assertEquals("v1.24.2", info?.tagName)
|
||||
assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path)
|
||||
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=1", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchLatestRelease_rcScanFollowsGithubPagination() =
|
||||
runTest {
|
||||
enqueue(STABLE_BODY)
|
||||
val nextPage = server.url("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=2")
|
||||
enqueue(RELEASES_BODY, headers = mapOf("Link" to "<$nextPage>; rel=\"next\""))
|
||||
enqueue(
|
||||
"""[{"tag_name":"v1.26.0-rc.1","prerelease":true,"assets":[{"name":"release.apk","size":5,"browser_download_url":"https://example.com/new.apk"}]}]""",
|
||||
)
|
||||
|
||||
val info = checker().fetchLatestRelease(includeReleaseCandidates = true)
|
||||
|
||||
assertEquals("v1.26.0-rc.1", info?.tagName)
|
||||
assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path)
|
||||
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=1", server.takeRequest().path)
|
||||
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=2", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchLatestRelease_releaseCandidatesStillIgnoreAlphaAndBetaButKeepStable() =
|
||||
runTest {
|
||||
enqueue(STABLE_BODY)
|
||||
enqueue(ALPHA_BETA_BODY)
|
||||
|
||||
assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true))
|
||||
assertEquals("v1.24.2", checker().fetchLatestRelease(includeReleaseCandidates = true)?.tagName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchLatestRelease_nonSuccessYieldsNull() =
|
||||
runTest {
|
||||
// One queued response per request: stable path, then RC path.
|
||||
// Each channel checks the stable endpoint and falls back to the list on 404.
|
||||
enqueue("", code = 404)
|
||||
enqueue("", code = 404)
|
||||
enqueue("", code = 404)
|
||||
enqueue("", code = 404)
|
||||
|
||||
@@ -83,6 +129,35 @@ class AppUpdateCheckerNetworkTest {
|
||||
assertNull(checker().fetchLatestRelease(includeReleaseCandidates = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadApk_cancellingCoroutineCancelsNetworkAndDeletesPartialFile() =
|
||||
runTest {
|
||||
server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE))
|
||||
val destination = File.createTempFile("hermes-update", ".apk")
|
||||
destination.writeText("partial")
|
||||
val download =
|
||||
launch(Dispatchers.IO) {
|
||||
checker().downloadApk(server.url("/slow.apk").toString(), destination) {}
|
||||
}
|
||||
|
||||
assertEquals("/slow.apk", server.takeRequest(5, TimeUnit.SECONDS)?.path)
|
||||
download.cancelAndJoin()
|
||||
|
||||
assertFalse(destination.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadApk_invalidUrlReturnsFailureAndDeletesDestination() =
|
||||
runTest {
|
||||
val destination = File.createTempFile("hermes-update", ".apk")
|
||||
destination.writeText("partial")
|
||||
|
||||
val downloaded = checker().downloadApk("file:///tmp/release.apk", destination) {}
|
||||
|
||||
assertFalse(downloaded)
|
||||
assertFalse(destination.exists())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STABLE_BODY = """
|
||||
{
|
||||
|
||||
@@ -183,6 +183,7 @@ class AppUpdateCheckerTest {
|
||||
draft: Boolean = false,
|
||||
prerelease: Boolean = false,
|
||||
withApk: Boolean = true,
|
||||
apkUrl: String = "https://example.com/$tag.apk",
|
||||
): UpdateInfo =
|
||||
UpdateInfo(
|
||||
tagName = tag,
|
||||
@@ -194,7 +195,7 @@ class AppUpdateCheckerTest {
|
||||
UpdateInfo.Asset(
|
||||
name = "hermes-mobile-$tag.apk",
|
||||
size = 1L,
|
||||
browserDownloadUrl = "https://example.com/$tag.apk",
|
||||
browserDownloadUrl = apkUrl,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
@@ -289,6 +290,55 @@ class AppUpdateCheckerTest {
|
||||
assertNull(selectLatestUpdate(listOf(release("v1.25.0", draft = true), release("v1.24.0", withApk = false))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectLatestUpdate_rejectsPrereleaseTagsMislabelledAsStable() {
|
||||
val releases =
|
||||
listOf(
|
||||
release("v1.30.0-rc.1"),
|
||||
release("v1.29.0-alpha.1"),
|
||||
release("v1.28.0-beta.2"),
|
||||
release("v1.27.0"),
|
||||
)
|
||||
|
||||
assertEquals("v1.27.0", selectLatestUpdate(releases)?.tagName)
|
||||
assertEquals("v1.30.0-rc.1", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectLatestUpdate_ignoresInvalidTagsAndUnusableApkUrls() {
|
||||
val releases =
|
||||
listOf(
|
||||
release("not-a-version"),
|
||||
release("v1.30.0", apkUrl = " "),
|
||||
release("v1.29.0"),
|
||||
)
|
||||
|
||||
assertEquals("v1.29.0", selectLatestUpdate(releases)?.tagName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectLatestUpdate_rejectsMalformedAndUnsupportedApkUrlsButAllowsHttp() {
|
||||
val releases =
|
||||
listOf(
|
||||
release("v1.32.0", apkUrl = "https://bad host/release.apk"),
|
||||
release("v1.31.0", apkUrl = "file:///tmp/release.apk"),
|
||||
release("v1.30.0", apkUrl = "http://localhost/release.apk"),
|
||||
)
|
||||
|
||||
assertEquals("v1.30.0", selectLatestUpdate(releases)?.tagName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectLatestUpdate_stableOutranksRcForSameVersion() {
|
||||
val releases =
|
||||
listOf(
|
||||
release("v1.25.0-rc.10", prerelease = true),
|
||||
release("v1.25.0"),
|
||||
)
|
||||
|
||||
assertEquals("v1.25.0", selectLatestUpdate(releases, includeReleaseCandidates = true)?.tagName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseReleaseList_readsDraftAndPrereleaseFlags() {
|
||||
val json =
|
||||
|
||||
@@ -49,6 +49,7 @@ class UpdateNoticeManagerTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
UpdateNoticeManager.resetForTests()
|
||||
AppUpdateCache.reset()
|
||||
// Unit tests run against the debug variant where the feature is
|
||||
// disabled by default — flip the knob on so the behavior is testable.
|
||||
@@ -68,6 +69,7 @@ class UpdateNoticeManagerTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
UpdateNoticeManager.resetForTests()
|
||||
UpdateNoticeManager.enabled = false
|
||||
unmockkAll()
|
||||
Dispatchers.resetMain()
|
||||
@@ -92,7 +94,7 @@ class UpdateNoticeManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkOnLaunch_skipsWhenAlreadyCheckedWithin24Hours() =
|
||||
fun checkOnLaunch_ignoresPersistedDailyCheckTimestampAfterColdStart() =
|
||||
runTest {
|
||||
val now = 1000000000000L
|
||||
every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion
|
||||
@@ -103,9 +105,24 @@ class UpdateNoticeManagerTest {
|
||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now = now)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { checker.fetchLatestRelease() }
|
||||
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
|
||||
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
||||
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
|
||||
assertTrue(AppUpdateCache.state.value is AppUpdateState.UpdateAvailable)
|
||||
verify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkOnLaunch_debouncesRepeatedForegroundChecksAfterSuccess() =
|
||||
runTest {
|
||||
val checker = mockk<AppUpdateChecker>()
|
||||
coEvery { checker.fetchLatestRelease(false) } returns updateInfo()
|
||||
val now = 1_000_000L
|
||||
|
||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now)
|
||||
advanceUntilIdle()
|
||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now + 1_000L)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -148,9 +165,9 @@ class UpdateNoticeManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noticeTag_stableReleasePromptsDotRcFromPersistedCache() {
|
||||
fun noticeTag_doesNotUsePersistedTagForStableInstall() {
|
||||
every { AuthManager.getLastKnownLatestTag() } returns "v1.25"
|
||||
assertEquals("v1.25", UpdateNoticeManager.noticeTag("1.25.rc.1"))
|
||||
assertNull(UpdateNoticeManager.noticeTag("1.25.rc.1"))
|
||||
assertNull(UpdateNoticeManager.noticeTag("1.25"))
|
||||
}
|
||||
|
||||
@@ -176,9 +193,11 @@ class UpdateNoticeManagerTest {
|
||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
|
||||
// Still marked done so a dead network can't spam the API per launch.
|
||||
verify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
||||
assertEquals(
|
||||
AppUpdateState.Error("Network error — check your connection", isCheckError = true),
|
||||
AppUpdateCache.state.value,
|
||||
)
|
||||
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -190,8 +209,41 @@ class UpdateNoticeManagerTest {
|
||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(AppUpdateState.Error("No release found yet", isCheckError = true), AppUpdateCache.state.value)
|
||||
assertFalse(AppUpdateCache.isDismissed("v1.22.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkOnLaunch_noReleaseDoesNotPersistSuccessfulCheck() =
|
||||
runTest {
|
||||
val checker = mockk<AppUpdateChecker>()
|
||||
coEvery { checker.fetchLatestRelease(false) } returns null
|
||||
|
||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(any()) }
|
||||
verify(exactly = 0) { AuthManager.setLastUpdateCheckTimestamp(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkOnLaunch_channelInvalidationDuringFetchPreservesNewChannelState() =
|
||||
runTest {
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
||||
val checker = mockk<AppUpdateChecker>()
|
||||
coEvery { checker.fetchLatestRelease(true) } coAnswers {
|
||||
// A channel change invalidates the captured request while it is completing.
|
||||
UpdateNoticeManager.channelChanged(enabled = false)
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
|
||||
updateInfo(tag = "v1.25.0-rc.3")
|
||||
}
|
||||
|
||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
|
||||
assertFalse(AppUpdateCache.dismissed)
|
||||
verify(exactly = 0) { AuthManager.setLastKnownLatestTag(any()) }
|
||||
verify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(any()) }
|
||||
}
|
||||
|
||||
// ── noticeTag (dismissed/restart banner return) ─────────────────────
|
||||
@@ -211,10 +263,10 @@ class UpdateNoticeManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noticeTag_returnsPersistedNewerTagWhenCacheIdle() {
|
||||
fun noticeTag_doesNotAdvertisePersistedTagWithoutFreshApkMetadata() {
|
||||
every { AuthManager.getLastKnownLatestTag() } returns "v1.22.0"
|
||||
|
||||
assertEquals("v1.22.0", UpdateNoticeManager.noticeTag(currentVersion))
|
||||
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -303,11 +355,11 @@ class UpdateNoticeManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noticeTag_advertisesPersistedRcTagWhenOptedIn() {
|
||||
fun noticeTag_doesNotUsePersistedRcTagWithoutApkMetadata() {
|
||||
every { AuthManager.getLastKnownLatestTag() } returns "v1.25.0-rc.3"
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
||||
|
||||
assertEquals("v1.25.0-rc.3", UpdateNoticeManager.noticeTag(currentVersion))
|
||||
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -336,10 +388,50 @@ class UpdateNoticeManagerTest {
|
||||
}
|
||||
|
||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher)
|
||||
UpdateNoticeManager.channelChanged(enabled = false)
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns false
|
||||
advanceUntilIdle()
|
||||
|
||||
// The late RC result must not reach the cache or the persisted tag.
|
||||
// A genuine manager-mediated opt-out invalidates the in-flight RC result.
|
||||
assertEquals(AppUpdateState.Idle, AppUpdateCache.state.value)
|
||||
verify(exactly = 0) { AuthManager.setLastKnownLatestTag(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkOnLaunch_retriesAfterFailureBackoffInsteadOfPersistingFailureAsDone() =
|
||||
runTest {
|
||||
val checker = mockk<AppUpdateChecker>()
|
||||
coEvery { checker.fetchLatestRelease(false) } throws IOException("offline") andThen updateInfo()
|
||||
val now = 100_000L
|
||||
|
||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now)
|
||||
advanceUntilIdle()
|
||||
UpdateNoticeManager.checkOnLaunch(checker, currentVersion, testDispatcher, now + 1_000L)
|
||||
advanceUntilIdle()
|
||||
UpdateNoticeManager.checkOnLaunch(
|
||||
checker,
|
||||
currentVersion,
|
||||
testDispatcher,
|
||||
now + UpdateNoticeManager.FAILURE_BACKOFF_MS + 1L,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 2) { checker.fetchLatestRelease(false) }
|
||||
assertTrue(AppUpdateCache.state.value is AppUpdateState.UpdateAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noticeTag_dismissalAppliesOnlyToThatRelease() {
|
||||
AppUpdateCache.update(
|
||||
AppUpdateState.UpdateAvailable("v1.22.0", "https://example.com/1.apk", 1L),
|
||||
)
|
||||
AppUpdateCache.dismiss("v1.22.0")
|
||||
|
||||
assertNull(UpdateNoticeManager.noticeTag(currentVersion))
|
||||
|
||||
AppUpdateCache.update(
|
||||
AppUpdateState.UpdateAvailable("v1.23.0", "https://example.com/2.apk", 1L),
|
||||
)
|
||||
assertEquals("v1.23.0", UpdateNoticeManager.noticeTag(currentVersion))
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,14 @@ import com.m57.hermescontrol.data.update.AppUpdateCache
|
||||
import com.m57.hermescontrol.data.update.AppUpdateChecker
|
||||
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||
import com.m57.hermescontrol.data.update.UpdateInfo
|
||||
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -48,6 +50,7 @@ class AppUpdateViewModelTest {
|
||||
private lateinit var app: Application
|
||||
private lateinit var packageManager: PackageManager
|
||||
private lateinit var checker: AppUpdateChecker
|
||||
private var rcUpdatesEnabled = false
|
||||
|
||||
private val currentVersion = "1.21.0"
|
||||
|
||||
@@ -70,6 +73,8 @@ class AppUpdateViewModelTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
UpdateNoticeManager.resetForTests()
|
||||
UpdateNoticeManager.enabled = true
|
||||
AppUpdateCache.reset()
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.getUpdateCheckDoneForVersion() } returns null
|
||||
@@ -79,8 +84,12 @@ 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
|
||||
rcUpdatesEnabled = false
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } answers { rcUpdatesEnabled }
|
||||
every { AuthManager.setCheckReleaseCandidateUpdates(any()) } answers {
|
||||
rcUpdatesEnabled = firstArg()
|
||||
Unit
|
||||
}
|
||||
|
||||
app = mockk(relaxed = true)
|
||||
every { app.cacheDir } returns File(System.getProperty("java.io.tmpdir"))
|
||||
@@ -91,11 +100,17 @@ class AppUpdateViewModelTest {
|
||||
|
||||
checker = mockk()
|
||||
coEvery { checker.fetchLatestRelease() } returns updateInfo()
|
||||
coEvery { checker.downloadApk(any(), any(), any()) } returns true
|
||||
val downloadDestination = slot<File>()
|
||||
coEvery { checker.downloadApk(any(), capture(downloadDestination), any()) } coAnswers {
|
||||
downloadDestination.captured.writeBytes(byteArrayOf(1))
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
UpdateNoticeManager.resetForTests()
|
||||
UpdateNoticeManager.enabled = false
|
||||
unmockkAll()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
@@ -237,14 +252,14 @@ class AppUpdateViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkForUpdate_marksDoneEvenWhenCheckFails() =
|
||||
fun checkForUpdate_marksDoneOnlyAfterSuccessfulDiscovery() =
|
||||
runTest {
|
||||
coEvery { checker.fetchLatestRelease() } throws IOException("boom")
|
||||
|
||||
val vm = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
||||
coVerify(exactly = 0) { AuthManager.setUpdateCheckDoneForVersion(currentVersion) }
|
||||
}
|
||||
|
||||
// ── Update flow ─────────────────────────────────────────────────────
|
||||
@@ -351,7 +366,7 @@ class AppUpdateViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(exactly = 1) { AuthManager.setDismissedUpdateTag("v1.22.0") }
|
||||
assertTrue(AppUpdateCache.dismissed)
|
||||
assertFalse(AppUpdateCache.isDismissed("v1.22.0"))
|
||||
assertFalse(AppUpdateCache.isDialogVisible)
|
||||
}
|
||||
|
||||
@@ -392,7 +407,7 @@ class AppUpdateViewModelTest {
|
||||
@Test
|
||||
fun init_adoptsCachedRcOfferWhenOptedIn() =
|
||||
runTest {
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
||||
rcUpdatesEnabled = true
|
||||
AppUpdateCache.update(
|
||||
AppUpdateState.UpdateAvailable("v1.25.0-rc.3", "https://example.com/rc.apk", 1L),
|
||||
)
|
||||
@@ -419,6 +434,7 @@ class AppUpdateViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(vm.checkReleaseCandidateUpdates.value)
|
||||
verify(exactly = 1) { UpdateNoticeManager.channelChanged(true) }
|
||||
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)
|
||||
@@ -435,13 +451,14 @@ class AppUpdateViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(exactly = 0) { AuthManager.setCheckReleaseCandidateUpdates(any()) }
|
||||
verify(exactly = 0) { UpdateNoticeManager.channelChanged(any()) }
|
||||
coVerify(exactly = 1) { checker.fetchLatestRelease(false) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setCheckReleaseCandidateUpdates_offDropsStaleRcBeforeCheckingStable() =
|
||||
runTest {
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
||||
rcUpdatesEnabled = true
|
||||
coEvery { checker.fetchLatestRelease(true) } returns updateInfo(tag = "v1.25.0-rc.3")
|
||||
|
||||
val vm = createViewModel()
|
||||
@@ -487,7 +504,7 @@ class AppUpdateViewModelTest {
|
||||
@Test
|
||||
fun lateRcResultAfterOptOut_neverPublishesRcOrInstalls() =
|
||||
runTest {
|
||||
every { AuthManager.isCheckingReleaseCandidateUpdates() } returns true
|
||||
rcUpdatesEnabled = true
|
||||
val rcGate = CompletableDeferred<Unit>()
|
||||
coEvery { checker.fetchLatestRelease(true) } coAnswers {
|
||||
withContext(NonCancellable) { rcGate.await() }
|
||||
|
||||
Reference in new issue
Block a user