fix(upgrade): Harden purchase restore and billing error handling

This commit is contained in:
Matthias Urhahn
2026-07-10 22:28:30 +02:00
committed by GitHub
parent 3ddd5f3cfd
commit 8dcc4f8489
9 changed files with 430 additions and 67 deletions
@@ -13,6 +13,7 @@ import eu.darken.capod.common.upgrade.core.data.BillingDataRepo
import eu.darken.capod.common.upgrade.core.data.PurchasedSku
import eu.darken.capod.common.upgrade.core.data.Sku
import eu.darken.capod.common.upgrade.core.data.SkuDetails
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@@ -37,38 +39,13 @@ class UpgradeRepoGplay @Inject constructor(
set(value) { billingCache.lastProStateAt.valueBlocking = value }
override val upgradeInfo: Flow<UpgradeRepo.Info> = billingDataRepo.billingData
.map { data ->
val now = System.currentTimeMillis()
val proSku = data.getProSku()
log(TAG) { "now=$now, lastProStateAt=$lastProStateAt, data=${data}" }
when {
proSku != null -> {
lastProStateAt = now
Info(billingData = data, upgrades = data.getProSkus())
}
(now - lastProStateAt) < 7 * 24 * 60 * 60 * 1000L -> { // 7 days
log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" }
Info(gracePeriod = true, billingData = null)
}
else -> {
Info(billingData = data, upgrades = data.getProSkus())
}
}
}
.onStart {
val now = System.currentTimeMillis()
if ((now - lastProStateAt) < 7 * 24 * 60 * 60 * 1000L) {
emit(Info(gracePeriod = true, billingData = null))
} else {
emit(Info(billingData = null))
}
}
.map<BillingData, BillingData?> { it }
.onStart { emit(null) }
.map { data -> data.toUpgradeInfo() }
.catch { error ->
log(TAG, WARN) { "upgradeInfo error: ${error.asLog()}" }
val now = System.currentTimeMillis()
if ((now - lastProStateAt) < 7 * 24 * 60 * 60 * 1000L) {
if ((now - lastProStateAt) < GRACE_PERIOD_MS) {
emit(Info(gracePeriod = true, billingData = null))
} else {
emit(Info(billingData = null, error = error))
@@ -76,6 +53,49 @@ class UpgradeRepoGplay @Inject constructor(
}
.shareIn(scope, SharingStarted.WhileSubscribed(3000L, 0L), replay = 1)
// Explicit "Restore purchase": query Play now and evaluate Pro from the returned data in the
// same coroutine (real happens-before), so we never read a stale upgradeInfo replay. Billing
// errors propagate so the caller can distinguish "not owned" from "Play unavailable".
suspend fun restorePurchaseNow(): Info {
log(TAG) { "restorePurchaseNow()" }
return try {
billingDataRepo.refresh().toUpgradeInfo()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Mirror the reactive flow's catch: a transient Play error while we were Pro recently
// keeps us Pro via the grace period; otherwise surface the error so the caller can show
// the proper "Play unavailable" message instead of a generic restore failure.
if ((System.currentTimeMillis() - lastProStateAt) < GRACE_PERIOD_MS) {
log(TAG, VERBOSE) { "Restore hit a Play error but we were Pro recently -> grace" }
Info(gracePeriod = true, billingData = null)
} else {
throw e
}
}
}
// Shared Pro/grace mapping used by both the reactive upgradeInfo flow and restorePurchaseNow().
// Only relinquishes Pro if we haven't had it for a while (grace period).
private fun BillingData?.toUpgradeInfo(): Info {
val now = System.currentTimeMillis()
val proSku = this?.getProSku()
log(TAG) { "toUpgradeInfo(): now=$now, lastProStateAt=$lastProStateAt, data=$this" }
return when {
proSku != null -> {
lastProStateAt = now
Info(billingData = this, upgrades = this!!.getProSkus())
}
(now - lastProStateAt) < GRACE_PERIOD_MS -> {
log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" }
Info(gracePeriod = true, billingData = null)
}
else -> Info(billingData = this, upgrades = this?.getProSkus() ?: emptyList())
}
}
data class Info(
private val gracePeriod: Boolean = false,
private val billingData: BillingData?,
@@ -110,8 +130,6 @@ class UpgradeRepoGplay @Inject constructor(
offer: Sku.Subscription.Offer? = null,
) = billingDataRepo.startBillingFlow(activity, sku, offer)
suspend fun refresh(): BillingData = billingDataRepo.refresh()
companion object {
private fun BillingData.getProSku(): PurchasedSku? = purchasedSkus
.firstOrNull { it.sku in CapodSku.PRO_SKUS }
@@ -119,6 +137,9 @@ class UpgradeRepoGplay @Inject constructor(
private fun BillingData.getProSkus(): Collection<PurchasedSku> = purchasedSkus
.filter { it.sku in CapodSku.PRO_SKUS }
// Keep paying users Pro through transient empty/failed Play Billing responses.
val GRACE_PERIOD_MS = Duration.ofDays(7).toMillis()
val TAG: String = logTag("Upgrade", "Gplay", "Control")
}
}
@@ -17,30 +17,36 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.upgrade.core.CapodSku
import eu.darken.capod.common.upgrade.core.data.Sku
import eu.darken.capod.common.upgrade.core.data.SkuDetails
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
data class BillingClientConnection(
private val client: BillingClient,
private val purchasesGlobal: Flow<Collection<Purchase>>,
) {
private val queryCacheIaps = MutableStateFlow<Collection<Purchase>?>(null)
private val queryCacheSubs = MutableStateFlow<Collection<Purchase>?>(null)
private data class QueryCaches(
val iaps: Collection<Purchase>? = null,
val subs: Collection<Purchase>? = null,
)
private val queryCache = MutableStateFlow(QueryCaches())
val purchases: Flow<Collection<Purchase>> = combine(
purchasesGlobal,
queryCacheIaps,
queryCacheSubs,
) { global, iaps, subs ->
queryCache,
) { global, cached ->
val combined = mutableSetOf<Purchase>()
iaps?.let { combined.addAll(it) }
subs?.let { combined.addAll(it) }
cached.iaps?.let { combined.addAll(it) }
cached.subs?.let { combined.addAll(it) }
global
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
@@ -50,17 +56,48 @@ data class BillingClientConnection(
}
.setupCommonEventHandlers(TAG) { "purchases" }
// Returns the freshly queried PURCHASED purchases so callers get a guaranteed happens-before
// relation instead of racing the shared purchases/billingData replay caches after a refresh.
// Tolerant of a single product-type failure: a known Pro purchase found by either type is
// authoritative, and an error only surfaces otherwise — so callers can tell "not owned" apart
// from "couldn't verify".
suspend fun refreshPurchases(): Collection<Purchase> = coroutineScope {
val iapsDeferred = async { queryPurchasesByType(BillingClient.ProductType.INAPP) }
val subsDeferred = async { queryPurchasesByType(BillingClient.ProductType.SUBS) }
val iapsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.INAPP) }
val subsDeferred = async { queryPurchasedProducts(BillingClient.ProductType.SUBS) }
val iaps = iapsDeferred.await()
val subs = subsDeferred.await()
log(TAG) { "refreshPurchases(): iaps=${iaps.getOrNull()}, subs=${subs.getOrNull()}" }
queryCacheIaps.value = iaps
queryCacheSubs.value = subs
// Evaluate before publishing: an inconclusive refresh (query failed and nothing
// authoritative found) must not touch the caches at all, or a partially updated state
// could surface synthetic ownership to the hot purchases flow and wrongly refresh the
// grace anchor from stale data.
val combined = combinePurchaseResults(iaps, subs)
(iaps + subs).sortedByDescending { it.purchaseTime }
// Single atomic snapshot update; a failed type retains its previous value.
queryCache.update { previous ->
QueryCaches(
iaps = iaps.getOrNull() ?: previous.iaps,
subs = subs.getOrNull() ?: previous.subs,
)
}
combined
}
// Never throws except on cancellation, so a single failing product-type query doesn't cancel
// the sibling query (or the coroutineScope).
private suspend fun queryPurchasedProducts(
productType: String,
): Result<Collection<Purchase>> = try {
val purchased = queryPurchasesByType(productType)
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
Result.success(purchased)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
private suspend fun queryPurchasesByType(productType: String): Collection<Purchase> {
@@ -162,5 +199,24 @@ data class BillingClientConnection(
companion object {
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "ClientConnection")
// Combines the two product-type query results: with a partial failure, only a known Pro
// purchase found by the successful type may suppress the error — an unknown/legacy purchase
// must not mask that the other type couldn't be verified. Without failures, everything
// found is returned as-is. Pure and unit-tested.
internal fun combinePurchaseResults(
iaps: Result<Collection<Purchase>>,
subs: Result<Collection<Purchase>>,
isAuthoritative: (Purchase) -> Boolean = { purchase ->
purchase.products.any { productId -> CapodSku.PRO_SKUS.any { it.id == productId } }
},
): Collection<Purchase> {
val found = iaps.getOrNull().orEmpty() + subs.getOrNull().orEmpty()
val error = iaps.exceptionOrNull() ?: subs.exceptionOrNull()
return when {
error == null || found.any(isAuthoritative) -> found.sortedByDescending { it.purchaseTime }
else -> throw error
}
}
}
}
@@ -1,6 +1,7 @@
package eu.darken.capod.common.upgrade.core.data
import android.app.Activity
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.*
@@ -46,10 +47,12 @@ class BillingDataRepo @Inject constructor(
.onEach { (client, purchases) ->
purchases
.filter {
val needsAck = !it.isAcknowledged
// Only settled purchases can be acknowledged — acking a PENDING purchase
// fails and would spin the retry loop below.
val needsAck = !it.isAcknowledged && it.purchaseState == Purchase.PurchaseState.PURCHASED
if (needsAck) log(TAG, INFO) { "Needs ACK: $it" }
else log(TAG) { "Already ACK'ed: $it" }
else log(TAG) { "No ACK necessary: $it" }
needsAck
}
@@ -13,6 +13,7 @@ import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.common.upgrade.core.CapodSku
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
import eu.darken.capod.common.upgrade.core.data.SkuDetails
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
@@ -177,23 +178,38 @@ class UpgradeViewModel @Inject constructor(
fun restorePurchase() = launch {
log(TAG, INFO) { "restorePurchase()" }
try {
val data = upgradeRepo.refresh()
log(TAG, INFO) { "Restore check: $data" }
val info = upgradeRepo.upgradeInfo.first()
if (info.isPro) {
log(TAG) { "Pro purchase found" }
} else {
log(TAG) { "No pro purchase found" }
val restored = try {
withTimeoutOrNull(RESTORE_TIMEOUT_MS) { upgradeRepo.restorePurchaseNow() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Play/billing error (e.g. service unavailable): surface the proper error dialog
// instead of the generic "restore failed" toast, so the user can tell the cases apart.
log(TAG, WARN) { "Restore purchase errored: ${e.asLog()}" }
errorEvents.emitBlocking(e)
return@launch
}
when {
restored == null -> {
// Play never answered in time; the restore-failed message already suggests the
// purchase may take a while to sync, which fits a timeout too.
log(TAG, WARN) { "Restore purchase timed out" }
events.tryEmit(UpgradeEvent.RestoreFailed)
}
restored.isPro -> log(TAG, INFO) { "Restored purchase :))" }
else -> {
log(TAG, WARN) { "No pro purchase found" }
events.tryEmit(UpgradeEvent.RestoreFailed)
}
} catch (e: Exception) {
log(TAG) { "Restore failed: $e" }
errorEvents.emitBlocking(e)
}
}
companion object {
internal const val RESTORE_TIMEOUT_MS = 15_000L
private val TAG = logTag("Upgrade", "VM")
}
}
@@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@Composable
@@ -31,17 +32,15 @@ private fun ComposeErrorDialog(
throwable: Throwable,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
// Prefer the curated HasLocalizedError strings (e.g. billing errors) over raw exception
// messages like Play Billing's internal debugMessage.
val localizedError = remember(throwable, context) { throwable.localized(context) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(android.R.string.dialog_alert_title)) },
text = {
Text(
text = throwable.localizedMessage
?: throwable.message
?: throwable::class.simpleName
?: "Unknown error"
)
},
title = { Text(text = localizedError.label) },
text = { Text(text = localizedError.description) },
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(android.R.string.ok))
@@ -19,16 +19,19 @@ fun Throwable.localized(c: Context): LocalizedError = when {
this is HasLocalizedError -> this.getLocalizedError(c)
localizedMessage != null -> LocalizedError(
throwable = this,
label = "${c.getString(R.string.general_error_label)}: ${this::class.simpleName!!}",
label = "${c.getString(R.string.general_error_label)}: ${errorTypeName()}",
description = localizedMessage ?: getStackTracePeek()
)
else -> LocalizedError(
throwable = this,
label = "${c.getString(R.string.general_error_label)}: ${this::class.simpleName!!}",
label = "${c.getString(R.string.general_error_label)}: ${errorTypeName()}",
description = getStackTracePeek()
)
}
// Anonymous throwable classes have no simpleName — never crash while rendering an error.
private fun Throwable.errorTypeName(): String = this::class.simpleName ?: "Error"
private fun Throwable.getStackTracePeek() = this.stackTraceToString()
.lines()
.filterIndexed { index, _ -> index > 1 }
@@ -6,9 +6,12 @@ import eu.darken.capod.common.datastore.createValue
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.upgrade.core.data.BillingData
import eu.darken.capod.common.upgrade.core.data.BillingDataRepo
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.longs.shouldBeGreaterThan
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.cancel
@@ -207,4 +210,69 @@ class UpgradeRepoGplayTest : BaseTest() {
val info = UpgradeRepoGplay.Info(billingData = null)
info.type shouldBe eu.darken.capod.common.upgrade.UpgradeRepo.Type.GPLAY
}
@Test
fun `restore returns pro when a purchase is found`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
coEvery { billingDataRepo.refresh() } returns BillingData(
purchases = listOf(mockPurchase(CapodSku.Iap.PRO_UPGRADE.id))
)
val repo = createRepo(testScope)
repo.restorePurchaseNow().isPro shouldBe true
// A confirmed pro purchase must refresh the grace timestamp.
billingCache.lastProStateAt.valueBlocking shouldBeGreaterThan 0L
testScope.cancel()
}
@Test
fun `restore keeps pro within grace when the query comes back empty`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
coEvery { billingDataRepo.refresh() } returns BillingData(purchases = emptyList())
billingCache.lastProStateAt.valueBlocking = System.currentTimeMillis() - 1_000L
val repo = createRepo(testScope)
repo.restorePurchaseNow().isPro shouldBe true
testScope.cancel()
}
@Test
fun `restore is not pro when the query is empty and grace has expired`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
coEvery { billingDataRepo.refresh() } returns BillingData(purchases = emptyList())
billingCache.lastProStateAt.valueBlocking =
System.currentTimeMillis() - UpgradeRepoGplay.GRACE_PERIOD_MS - 1_000L
val repo = createRepo(testScope)
repo.restorePurchaseNow().isPro shouldBe false
testScope.cancel()
}
@Test
fun `restore keeps pro within grace when the query errors`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
coEvery { billingDataRepo.refresh() } throws RuntimeException("Play unavailable")
billingCache.lastProStateAt.valueBlocking = System.currentTimeMillis() - 1_000L
val repo = createRepo(testScope)
repo.restorePurchaseNow().isPro shouldBe true
testScope.cancel()
}
@Test
fun `restore rethrows the error when it happens outside grace`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
coEvery { billingDataRepo.refresh() } throws RuntimeException("Play unavailable")
val repo = createRepo(testScope)
shouldThrow<RuntimeException> {
repo.restorePurchaseNow()
}
testScope.cancel()
}
}
@@ -0,0 +1,97 @@
package eu.darken.capod.common.upgrade.core.client
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.upgrade.core.CapodSku
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class BillingClientConnectionTest : BaseTest() {
private fun mockPurchase(
productId: String = CapodSku.Iap.PRO_UPGRADE.id,
purchaseTime: Long = 1_000,
): Purchase = mockk {
every { products } returns listOf(productId)
every { this@mockk.purchaseTime } returns purchaseTime
}
@Test
fun `combines both product types, newest first`() {
val older = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id, purchaseTime = 1_000)
val newer = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, purchaseTime = 2_000)
BillingClientConnection.combinePurchaseResults(
iaps = Result.success(listOf(older)),
subs = Result.success(listOf(newer)),
) shouldBe listOf(newer, older)
}
@Test
fun `a single product-type failure does not mask a pro purchase found by the other`() {
val owned = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)
BillingClientConnection.combinePurchaseResults(
iaps = Result.success(listOf(owned)),
subs = Result.failure(RuntimeException("SUBS query failed")),
) shouldBe listOf(owned)
val ownedSub = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id)
BillingClientConnection.combinePurchaseResults(
iaps = Result.failure(RuntimeException("IAP query failed")),
subs = Result.success(listOf(ownedSub)),
) shouldBe listOf(ownedSub)
}
@Test
fun `an unknown purchase does not suppress the other product-type's failure`() {
// An unknown/legacy product is discarded by BillingData later — it must not hide that the
// other product type couldn't be verified, or a restore would wrongly report "not owned".
val unknown = mockPurchase("some.legacy.product")
shouldThrow<RuntimeException> {
BillingClientConnection.combinePurchaseResults(
iaps = Result.success(listOf(unknown)),
subs = Result.failure(RuntimeException("SUBS query failed")),
)
}
}
@Test
fun `unknown purchases are returned as-is when both queries succeed`() {
val unknown = mockPurchase("some.legacy.product")
BillingClientConnection.combinePurchaseResults(
iaps = Result.success(listOf(unknown)),
subs = Result.success(emptyList()),
) shouldBe listOf(unknown)
}
@Test
fun `both product types empty returns empty`() {
BillingClientConnection.combinePurchaseResults(
iaps = Result.success(emptyList()),
subs = Result.success(emptyList()),
) shouldBe emptyList()
}
@Test
fun `nothing found but a query failed rethrows the error`() {
shouldThrow<RuntimeException> {
BillingClientConnection.combinePurchaseResults(
iaps = Result.success(emptyList()),
subs = Result.failure(RuntimeException("SUBS query failed")),
)
}
shouldThrow<RuntimeException> {
BillingClientConnection.combinePurchaseResults(
iaps = Result.failure(RuntimeException("IAP query failed")),
subs = Result.success(emptyList()),
)
}
}
}
@@ -0,0 +1,100 @@
package eu.darken.capod.upgrade.ui
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.coroutine.TestDispatcherProvider
import testhelpers.coroutine.runTest2
class UpgradeViewModelTest : BaseTest() {
private fun mockRepo(): UpgradeRepoGplay = mockk<UpgradeRepoGplay>(relaxed = true).apply {
every { upgradeInfo } returns MutableStateFlow(UpgradeRepoGplay.Info(billingData = null))
}
private fun TestScope.createVm(repo: UpgradeRepoGplay) = UpgradeViewModel(
dispatcherProvider = TestDispatcherProvider(UnconfinedTestDispatcher(testScheduler)),
upgradeRepo = repo,
)
@Test
fun `restore with no purchase emits RestoreFailed`() = runTest2 {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } returns UpgradeRepoGplay.Info(billingData = null)
val vm = createVm(repo)
val event = async { vm.events.first() }
vm.restorePurchase()
advanceUntilIdle()
event.await() shouldBe UpgradeViewModel.UpgradeEvent.RestoreFailed
}
@Test
fun `restore that finds a purchase stays silent`() = runTest2 {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } returns UpgradeRepoGplay.Info(
gracePeriod = true,
billingData = null,
)
val vm = createVm(repo)
val events = mutableListOf<UpgradeViewModel.UpgradeEvent>()
val errors = mutableListOf<Throwable>()
val eventJob = launch(UnconfinedTestDispatcher(testScheduler)) { vm.events.collect { events.add(it) } }
val errorJob = launch(UnconfinedTestDispatcher(testScheduler)) { vm.errorEvents.collect { errors.add(it) } }
vm.restorePurchase()
advanceUntilIdle()
coVerify(exactly = 1) { repo.restorePurchaseNow() }
events shouldBe emptyList()
errors shouldBe emptyList()
eventJob.cancel()
errorJob.cancel()
}
@Test
fun `restore that times out emits RestoreFailed`() = runTest2 {
val repo = mockRepo()
coEvery { repo.restorePurchaseNow() } coAnswers {
delay(UpgradeViewModel.RESTORE_TIMEOUT_MS * 2)
UpgradeRepoGplay.Info(gracePeriod = true, billingData = null)
}
val vm = createVm(repo)
val event = async { vm.events.first() }
vm.restorePurchase()
advanceUntilIdle()
event.await() shouldBe UpgradeViewModel.UpgradeEvent.RestoreFailed
}
@Test
fun `restore that errors forwards the error instead of RestoreFailed`() = runTest2 {
val repo = mockRepo()
val boom = IllegalStateException("Play unavailable")
coEvery { repo.restorePurchaseNow() } throws boom
val vm = createVm(repo)
val forwardedError = async { vm.errorEvents.first() }
vm.restorePurchase()
advanceUntilIdle()
forwardedError.await() shouldBe boom
}
}