mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
test(upgrade): Cover pending purchases across the billing stack and screen
Ports the pending-purchase coverage alongside the production change. - BillingConnectionTest / BillingManagerTest: PENDING ingestion, the PURCHASED-only entitlement exits, provesAbsence ignoring a surviving pending overlay, the reconciliation pass and the ack pass skipping pending purchases. - UpgradeRepoGplayTest: pendingSkus never feeding isPro, the strict verify path and PendingPurchaseBillingException on an already-owned recovery. - GplayUpgradeViewModelTest: the shared pre-purchase gate on both paths (pending, timeout, error, owned upgrade, renewing subscription with an unknown product), the pending-payment launch failure, and the pending card rendering while prices are still loading or have failed. - GplayUpgradeScreenTest / GplayUpgradeOwnershipTest / GplayUpgradeScreenHostTest: the card for all three audiences, the locked offers and switch button, restore staying enabled, and the informational dialog reaching the composition.
This commit is contained in:
@@ -10,6 +10,7 @@ import eu.darken.capod.common.upgrade.core.billing.BillingData
|
||||
import eu.darken.capod.common.upgrade.core.billing.BillingManager
|
||||
import eu.darken.capod.common.upgrade.core.billing.GplayServiceUnavailableException
|
||||
import eu.darken.capod.common.upgrade.core.billing.ItemAlreadyOwnedBillingException
|
||||
import eu.darken.capod.common.upgrade.core.billing.PendingPurchaseBillingException
|
||||
import eu.darken.capod.common.upgrade.core.billing.PurchasedSku
|
||||
import eu.darken.capod.common.upgrade.core.billing.UserCanceledBillingException
|
||||
import eu.darken.capod.main.core.CurriculumVitae
|
||||
@@ -118,6 +119,13 @@ class UpgradeRepoGplayTest : BaseTest() {
|
||||
every { purchaseTime } returns Instant.parse("2024-01-01T00:00:00Z").toEpochMilli()
|
||||
}
|
||||
|
||||
// A payment Play is still processing. Lands in BillingData.pendingPurchases, never in
|
||||
// purchases — the split happens at the billing layer, this is what the repo receives.
|
||||
private fun pendingPurchase(productId: String = OurSku.Iap.PRO_UPGRADE.id) = mockk<Purchase>().apply {
|
||||
every { products } returns listOf(productId)
|
||||
every { purchaseTime } returns 1_000L
|
||||
}
|
||||
|
||||
@Test fun `test upgrade info pro status mapping`() {
|
||||
UpgradeRepoGplay.Info(
|
||||
gracePeriod = false,
|
||||
@@ -148,6 +156,108 @@ class UpgradeRepoGplayTest : BaseTest() {
|
||||
info.type
|
||||
}
|
||||
|
||||
// region pending payments
|
||||
|
||||
@Test fun `a pending payment is mapped but grants nothing`() {
|
||||
val info = UpgradeRepoGplay.Info(
|
||||
gracePeriod = false,
|
||||
billingData = BillingData(purchases = emptySet(), pendingPurchases = setOf(pendingPurchase())),
|
||||
)
|
||||
|
||||
info.pendingSkus shouldBe listOf(OurSku.Iap.PRO_UPGRADE)
|
||||
// The whole point of the split: visible, never an entitlement.
|
||||
info.upgrades shouldBe emptyList()
|
||||
info.isPro shouldBe false
|
||||
info.upgradedAt shouldBe null
|
||||
}
|
||||
|
||||
@Test fun `a pending payment for an unknown product is dropped`() {
|
||||
UpgradeRepoGplay.Info(
|
||||
gracePeriod = false,
|
||||
billingData = BillingData(
|
||||
purchases = emptySet(),
|
||||
pendingPurchases = setOf(pendingPurchase("some.unknown.product")),
|
||||
),
|
||||
).pendingSkus shouldBe emptyList()
|
||||
}
|
||||
|
||||
@Test fun `the grace-substituted info keeps the pending payment visible`() = runTest2 {
|
||||
// The audience that needs the explanation most: Pro is running on grace while Play is still
|
||||
// processing the payment that will renew it. Dropping the data here (as the grace branch
|
||||
// used to) left them with a silent screen.
|
||||
coEvery { billingManager.refresh() } returns BillingData(emptySet(), setOf(pendingPurchase()))
|
||||
|
||||
val outcome = repo(lastProAt = System.currentTimeMillis() - 1_000).restorePurchaseNow()
|
||||
|
||||
outcome.info.isPro shouldBe true
|
||||
outcome.info.pendingSkus shouldBe listOf(OurSku.Iap.PRO_UPGRADE)
|
||||
}
|
||||
|
||||
@Test fun `a restore that only finds a pending payment reports it without pro`() = runTest2 {
|
||||
coEvery { billingManager.refresh() } returns BillingData(emptySet(), setOf(pendingPurchase()))
|
||||
|
||||
val outcome = repo(lastProAt = 0L).restorePurchaseNow()
|
||||
|
||||
outcome.shouldBeInstanceOf<UpgradeRepoGplay.RestoreOutcome.Checked>()
|
||||
outcome.info.isPro shouldBe false
|
||||
outcome.info.pendingSkus shouldBe listOf(OurSku.Iap.PRO_UPGRADE)
|
||||
}
|
||||
|
||||
@Test fun `a manual restore over a partial refresh still advances the unconfirmed episode`() = runTest2 {
|
||||
// The restore itself succeeds (a pending payment IS an answer), so nothing on this path
|
||||
// throws — the episode clock is fed by the manager's reconciliation signal instead, which
|
||||
// carries the refresh's commit time.
|
||||
val failures = MutableSharedFlow<Long>(extraBufferCapacity = 1)
|
||||
val confirmedAt = System.currentTimeMillis() - 1_000
|
||||
coEvery { billingManager.refresh() } returns BillingData(emptySet(), setOf(pendingPurchase()))
|
||||
val repo = repo(lastProAt = confirmedAt, connectionFailures = failures)
|
||||
|
||||
repo.restorePurchaseNow().info.isPro shouldBe true
|
||||
failures.emit(confirmedAt + 500)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify { proUnconfirmedMock.update(any()) }
|
||||
}
|
||||
|
||||
@Test fun `verifyPurchaseStateNow fails closed instead of substituting grace`() = runTest2 {
|
||||
val boom = GplayServiceUnavailableException(RuntimeException("one product type failed"))
|
||||
coEvery { billingManager.refreshStrict() } throws boom
|
||||
|
||||
// Even a recent owner gets the error: a gate that can't verify must not let a purchase
|
||||
// through on the strength of a grace window.
|
||||
shouldThrow<GplayServiceUnavailableException> {
|
||||
repo(lastProAt = System.currentTimeMillis() - 1_000).verifyPurchaseStateNow()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `verifyPurchaseStateNow reports the fresh split state`() = runTest2 {
|
||||
coEvery { billingManager.refreshStrict() } returns BillingData(
|
||||
purchases = setOf(proPurchase()),
|
||||
pendingPurchases = setOf(pendingPurchase(OurSku.Sub.PRO_UPGRADE.id)),
|
||||
)
|
||||
|
||||
val info = repo(lastProAt = 0L).verifyPurchaseStateNow()
|
||||
|
||||
info.upgrades.map { it.sku } shouldBe OurSku.PRO_SKUS.toList()
|
||||
info.pendingSkus shouldBe listOf(OurSku.Sub.PRO_UPGRADE)
|
||||
info.isSettled shouldBe true
|
||||
}
|
||||
|
||||
@Test fun `already-owned recovery reports a pending payment instead of restore tips`() = runTest2 {
|
||||
// Play refuses to re-sell a product whose payment it is still processing. The already-owned
|
||||
// dialog would tell the user to restore, which cannot help.
|
||||
coEvery { billingManager.startIapFlow(any(), any(), null) } throws
|
||||
ItemAlreadyOwnedBillingException(RuntimeException("launch result"))
|
||||
coEvery { billingManager.refresh() } returns BillingData(emptySet(), setOf(pendingPurchase()))
|
||||
|
||||
val errors = mutableListOf<Throwable>()
|
||||
repo(lastProAt = 0L).startLaunch { errors.add(it) }
|
||||
|
||||
errors.single().shouldBeInstanceOf<PendingPurchaseBillingException>()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
@Test fun `grace period is 7 days`() {
|
||||
// Guards against the unit error where 7 * 24 * 60 * 1000 (2.8h) was used instead of 7 days,
|
||||
// which dropped paying users to non-Pro within hours of a transient empty/failed billing response.
|
||||
|
||||
+239
-10
@@ -71,6 +71,15 @@ class BillingManagerTest : BaseTest() {
|
||||
every { isAcknowledged } returns true
|
||||
}
|
||||
|
||||
// A payment Play is still processing: carried by the state, but never owned and never ackable.
|
||||
private fun pendingPurchase(token: String = "pending-token") = mockk<Purchase>().apply {
|
||||
every { purchaseState } returns PurchaseState.PENDING
|
||||
every { purchaseTime } returns 2_000L
|
||||
every { purchaseToken } returns token
|
||||
every { isAcknowledged } returns false
|
||||
every { products } returns listOf(OurSku.Iap.PRO_UPGRADE.id)
|
||||
}
|
||||
|
||||
// An unacknowledged purchase with its own token: the ack bookkeeping (log level, permanent
|
||||
// failure reports) is keyed per token, so tests need distinguishable ones.
|
||||
private fun unackedPurchase(
|
||||
@@ -89,17 +98,54 @@ class BillingManagerTest : BaseTest() {
|
||||
private fun connection(
|
||||
refreshResults: List<Collection<Purchase>> = listOf(emptyList()),
|
||||
refreshComplete: Boolean = true,
|
||||
// Full refresh outcomes for the tests that care about provenance (confirmed set, commit
|
||||
// time, partial error); overrides the plain refreshResults shorthand.
|
||||
refreshes: List<BillingConnection.PurchaseRefresh>? = null,
|
||||
purchasesFlow: Flow<Collection<Purchase>> = flowOf(emptyList()),
|
||||
freshUpdatesFlow: Flow<BillingConnection.FreshUpdate> = emptyFlow(),
|
||||
failures: Flow<BillingResult> = emptyFlow(),
|
||||
) = mockk<BillingConnection>().apply {
|
||||
coEvery { refreshPurchases() } returnsMany
|
||||
refreshResults.map { BillingConnection.PurchaseRefresh(it, isComplete = refreshComplete) }
|
||||
coEvery { refreshPurchases() } returnsMany (
|
||||
refreshes ?: refreshResults.map {
|
||||
BillingConnection.PurchaseRefresh(
|
||||
purchases = it,
|
||||
confirmed = it,
|
||||
hasConfirmedProPurchase = it.isNotEmpty(),
|
||||
isComplete = refreshComplete,
|
||||
)
|
||||
}
|
||||
)
|
||||
every { purchases } returns purchasesFlow
|
||||
every { freshUpdates } returns freshUpdatesFlow
|
||||
every { purchaseFailures } returns failures
|
||||
}
|
||||
|
||||
// An incomplete reconciliation: it committed what it found, but one product type couldn't be
|
||||
// checked. The default cause is NON-invalidating — the dead-binder codes are opted into.
|
||||
private fun partialRefresh(
|
||||
purchases: Collection<Purchase> = emptyList(),
|
||||
confirmed: Collection<Purchase> = emptyList(),
|
||||
hasConfirmedPro: Boolean = false,
|
||||
occurredAt: Long = 4242L,
|
||||
error: Throwable = GplayServiceUnavailableException(
|
||||
BillingClientException(result(BillingResponseCode.ERROR))
|
||||
),
|
||||
) = BillingConnection.PurchaseRefresh(
|
||||
purchases = purchases,
|
||||
confirmed = confirmed,
|
||||
hasConfirmedProPurchase = hasConfirmedPro,
|
||||
isComplete = false,
|
||||
occurredAt = occurredAt,
|
||||
partialError = error,
|
||||
)
|
||||
|
||||
private fun completeRefresh(purchases: Collection<Purchase> = emptyList()) = BillingConnection.PurchaseRefresh(
|
||||
purchases = purchases,
|
||||
confirmed = purchases,
|
||||
hasConfirmedProPurchase = purchases.isNotEmpty(),
|
||||
isComplete = true,
|
||||
)
|
||||
|
||||
// The real provider flow emits one connection and stays open for its lifetime -- flowOf() would
|
||||
// complete immediately, which the connect loop rightly treats as a connection failure.
|
||||
private fun providerOf(connection: BillingConnection): BillingConnectionProvider =
|
||||
@@ -540,6 +586,10 @@ class BillingManagerTest : BaseTest() {
|
||||
|
||||
// Drains the manager's connectionFailures (occurrence timestamps) into a list. Launched on
|
||||
// backgroundScope so it lives for the whole test.
|
||||
//
|
||||
// Drive it with runCurrent() (or a suspension of the test body), NEVER with advanceUntilIdle():
|
||||
// that one stops as soon as no FOREGROUND event is left and never runs backgroundScope work, so
|
||||
// a signal sent from the test body would sit unconsumed and the list would read empty.
|
||||
private fun TestScope.collectFailures(manager: BillingManager): List<Long> = mutableListOf<Long>().also { out ->
|
||||
backgroundScope.launch { manager.connectionFailures.collect { out.add(it) } }
|
||||
}
|
||||
@@ -665,22 +715,201 @@ class BillingManagerTest : BaseTest() {
|
||||
failures.isNotEmpty() shouldBe true
|
||||
}
|
||||
|
||||
@Test fun `a non-invalidating action error does not emit`() = runTest2 {
|
||||
// A strict querySubscriptions failure whose code is NOT invalidating leaves the connection
|
||||
// installed, so no connect-loop iteration fails and nothing feeds the episode clock.
|
||||
val conn = connection().apply {
|
||||
coEvery { querySubscriptions() } throws BillingClientException(result(BillingResponseCode.ERROR))
|
||||
}
|
||||
@Test fun `a strict gate failure does not feed the episode clock`() = runTest2 {
|
||||
// The gate surfaces its own failure to the user (fail-closed) and leaves the connection
|
||||
// installed. The episode clock is fed by the connect loop and by refresh() — a gate that
|
||||
// the user aborted mid-purchase is not a reconciliation outcome.
|
||||
val conn = connection(refreshes = listOf(completeRefresh(), partialRefresh()))
|
||||
val manager = manager(conn)
|
||||
val failures = collectFailures(manager)
|
||||
runCurrent() // connection established
|
||||
|
||||
shouldThrow<Exception> { manager.querySubscriptions() }
|
||||
advanceUntilIdle()
|
||||
shouldThrow<Exception> { manager.refreshStrict() }
|
||||
runCurrent()
|
||||
|
||||
failures shouldBe emptyList()
|
||||
}
|
||||
|
||||
@Test fun `a partial reconciliation without a confirmed pro purchase signals its commit time`() = runTest2 {
|
||||
// The pending-only cold start: Play answered for one type with a payment in progress, the
|
||||
// other failed. Nothing confirms Pro, so the grace episode clock must advance — stamped
|
||||
// with the refresh's COMMIT time, so a confirmation landing in between stays newer.
|
||||
val pending = pendingPurchase()
|
||||
val conn = connection(
|
||||
refreshes = listOf(partialRefresh(purchases = listOf(pending), occurredAt = 4242L)),
|
||||
purchasesFlow = flowOf(listOf(pending)),
|
||||
)
|
||||
val manager = manager(conn)
|
||||
val failures = collectFailures(manager)
|
||||
|
||||
runCurrent()
|
||||
|
||||
// Still published: a partial refresh is a usable connection, and starving billingData would
|
||||
// leave the screen at Loading forever.
|
||||
manager.billingData.first() shouldBe BillingData(
|
||||
purchases = emptyList(),
|
||||
pendingPurchases = listOf(pending),
|
||||
)
|
||||
failures shouldBe listOf(4242L)
|
||||
}
|
||||
|
||||
@Test fun `a partial manual refresh signals too`() = runTest2 {
|
||||
// Manual Restore runs the same reconciliation as the connect loop — before this it was the
|
||||
// only path whose partial outcome silently vanished.
|
||||
val conn = connection(refreshes = listOf(completeRefresh(), partialRefresh(occurredAt = 7_777L)))
|
||||
val manager = manager(conn)
|
||||
val failures = collectFailures(manager)
|
||||
runCurrent()
|
||||
|
||||
manager.refresh()
|
||||
runCurrent() // the collector lives on backgroundScope, which advanceUntilIdle would skip
|
||||
|
||||
failures shouldBe listOf(7_777L)
|
||||
}
|
||||
|
||||
@Test fun `a partial refresh that confirmed pro does not signal`() = runTest2 {
|
||||
val owned = purchase()
|
||||
val conn = connection(
|
||||
refreshes = listOf(
|
||||
completeRefresh(),
|
||||
partialRefresh(purchases = listOf(owned), confirmed = listOf(owned), hasConfirmedPro = true),
|
||||
),
|
||||
)
|
||||
val manager = manager(conn)
|
||||
val failures = collectFailures(manager)
|
||||
runCurrent()
|
||||
|
||||
manager.refresh()
|
||||
runCurrent()
|
||||
|
||||
// Pro WAS confirmed by this round-trip; the failed sibling type proves nothing against it.
|
||||
failures shouldBe emptyList()
|
||||
}
|
||||
|
||||
@Test fun `an invalidating partial refresh tears the connection down`() = runTest2 {
|
||||
// The dead-binder teardown used to ride refreshPurchases' throw path through useConnection.
|
||||
// A partial refresh returns instead of throwing, so without the explicit invalidation the
|
||||
// dead connection would stay installed for every later caller.
|
||||
val owned = purchase()
|
||||
val dead = connection(
|
||||
refreshes = listOf(
|
||||
partialRefresh(
|
||||
purchases = listOf(owned),
|
||||
confirmed = listOf(owned),
|
||||
hasConfirmedPro = true,
|
||||
error = GplayServiceUnavailableException(
|
||||
BillingClientException(result(BillingResponseCode.SERVICE_DISCONNECTED))
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val good = connection(refreshResults = listOf(emptyList(), listOf(owned)))
|
||||
var attempts = 0
|
||||
val provider = mockk<BillingConnectionProvider>().apply {
|
||||
every { this@apply.connection } returns flow {
|
||||
attempts++
|
||||
emit(if (attempts == 1) dead else good)
|
||||
awaitCancellation()
|
||||
}
|
||||
}
|
||||
val manager = manager(provider)
|
||||
runCurrent() // connection 1 established; its partial refresh signals the invalidation
|
||||
|
||||
// The next action is fresh demand: it skips the reconnect backoff and lands on connection 2.
|
||||
// await() is what drives the connect loop here — it runs on backgroundScope, which
|
||||
// advanceUntilIdle() alone would never touch.
|
||||
val refreshed = async { manager.refresh() }
|
||||
advanceUntilIdle()
|
||||
|
||||
refreshed.await() shouldBe BillingData(listOf(owned))
|
||||
attempts shouldBe 2
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region pending purchases
|
||||
|
||||
@Test fun `billing data splits owned purchases from pending payments`() = runTest2 {
|
||||
val owned = purchase()
|
||||
val pending = pendingPurchase()
|
||||
val manager = manager(connection(purchasesFlow = flowOf(listOf(owned, pending))))
|
||||
|
||||
// The split is what keeps a payment in progress out of every entitlement decision while
|
||||
// still letting the UI show it.
|
||||
manager.billingData.first() shouldBe BillingData(
|
||||
purchases = listOf(owned),
|
||||
pendingPurchases = listOf(pending),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `a pending purchase is never acknowledged`() = runTest2 {
|
||||
val pending = pendingPurchase()
|
||||
val purchases = purchasesFlow()
|
||||
val conn = connection(purchasesFlow = purchases)
|
||||
val acks = conn.scriptAck { _, _ -> result(BillingResponseCode.OK) }
|
||||
manager(conn)
|
||||
runCurrent()
|
||||
|
||||
purchases.tryEmit(listOf(pending))
|
||||
advanceTimeBy(400_000)
|
||||
runCurrent()
|
||||
|
||||
// Play rejects acking a pending purchase permanently: an unfiltered pass would fire a bug
|
||||
// report on every pass, forever, for a purchase that has nothing to acknowledge yet.
|
||||
acks shouldBe emptyList()
|
||||
verify(exactly = 0) { Bugs.report(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test fun `a follow-up refresh after a partial publish recovers the full state`() = runTest2 {
|
||||
val pending = pendingPurchase()
|
||||
val owned = purchase()
|
||||
val purchases = purchasesFlow()
|
||||
val conn = connection(
|
||||
refreshes = listOf(
|
||||
partialRefresh(purchases = listOf(pending)),
|
||||
completeRefresh(listOf(owned)),
|
||||
),
|
||||
purchasesFlow = purchases,
|
||||
)
|
||||
val manager = manager(conn)
|
||||
runCurrent()
|
||||
|
||||
purchases.tryEmit(listOf(pending))
|
||||
manager.billingData.first().pendingPurchases shouldBe listOf(pending)
|
||||
|
||||
// The payment completed: the next reconciliation returns the owned purchase, no reconnect
|
||||
// needed (the partial one left a working connection installed).
|
||||
manager.refresh() shouldBe BillingData(listOf(owned))
|
||||
}
|
||||
|
||||
@Test fun `refreshStrict fails closed on an incomplete refresh`() = runTest2 {
|
||||
val conn = connection(
|
||||
refreshes = listOf(
|
||||
completeRefresh(),
|
||||
partialRefresh(purchases = listOf(purchase()), confirmed = listOf(purchase())),
|
||||
),
|
||||
)
|
||||
val manager = manager(conn)
|
||||
runCurrent()
|
||||
|
||||
// A gate must not treat "one product type couldn't be checked" as "nothing else is owned":
|
||||
// that is exactly how a double purchase gets through.
|
||||
shouldThrow<GplayServiceUnavailableException> { manager.refreshStrict() }
|
||||
}
|
||||
|
||||
@Test fun `refreshStrict returns the split data on a complete refresh`() = runTest2 {
|
||||
val owned = purchase()
|
||||
val pending = pendingPurchase()
|
||||
val conn = connection(refreshes = listOf(completeRefresh(), completeRefresh(listOf(owned, pending))))
|
||||
val manager = manager(conn)
|
||||
runCurrent()
|
||||
|
||||
manager.refreshStrict() shouldBe BillingData(
|
||||
purchases = listOf(owned),
|
||||
pendingPurchases = listOf(pending),
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region purchase acknowledgement
|
||||
|
||||
+205
-153
@@ -25,7 +25,6 @@ import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -69,14 +68,21 @@ class BillingConnectionTest : BaseTest() {
|
||||
token: String = "token-$time",
|
||||
products: List<String> = listOf(OurSku.Iap.PRO_UPGRADE.id),
|
||||
acknowledged: Boolean = false,
|
||||
state: Int = PurchaseState.PURCHASED,
|
||||
) = mockk<Purchase>().apply {
|
||||
every { purchaseTime } returns time
|
||||
every { purchaseToken } returns token
|
||||
every { this@apply.products } returns products
|
||||
every { purchaseState } returns PurchaseState.PURCHASED
|
||||
every { purchaseState } returns state
|
||||
every { isAcknowledged } returns acknowledged
|
||||
}
|
||||
|
||||
private fun pendingPurchase(
|
||||
time: Long,
|
||||
token: String = "pending-$time",
|
||||
products: List<String> = listOf(OurSku.Iap.PRO_UPGRADE.id),
|
||||
) = purchase(time = time, token = token, products = products, state = PurchaseState.PENDING)
|
||||
|
||||
private fun result(code: Int): BillingResult = BillingResult.newBuilder().setResponseCode(code).build()
|
||||
|
||||
private val typeOf: (String) -> Sku.Type? = { id -> OurSku.PRO_SKUS.singleOrNull { it.id == id }?.type }
|
||||
@@ -123,6 +129,42 @@ class BillingConnectionTest : BaseTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `a known pending purchase suppresses the couldn't-verify error`() {
|
||||
// The user's payment is being processed: that IS a usable answer about this account, so a
|
||||
// failing sibling query must not turn the screen into "can't reach Play".
|
||||
val pending = pendingPurchase(1_000)
|
||||
|
||||
BillingConnection.combinePurchaseResults(
|
||||
iap = Result.success(listOf(pending)),
|
||||
sub = Result.failure(RuntimeException("SUBS query failed")),
|
||||
typeOf = typeOf,
|
||||
) shouldBe listOf(pending)
|
||||
}
|
||||
|
||||
@Test fun `an unknown pending purchase does not suppress the couldn't-verify error`() {
|
||||
// A pending payment for a product this app doesn't sell says nothing about the type whose
|
||||
// query failed — counting it as a find would swallow a real "couldn't verify".
|
||||
shouldThrow<RuntimeException> {
|
||||
BillingConnection.combinePurchaseResults(
|
||||
iap = Result.success(listOf(pendingPurchase(1_000, products = listOf("some.unknown.product")))),
|
||||
sub = Result.failure(RuntimeException("SUBS query failed")),
|
||||
typeOf = typeOf,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `an unknown PURCHASED product still suppresses the error`() {
|
||||
// Ownership of anything is authoritative: every product this app sells is a Pro SKU, so an
|
||||
// unrecognized owned product means our own SKU list is stale, not that Play failed.
|
||||
val owned = purchase(1_000, products = listOf("some.unknown.product"))
|
||||
|
||||
BillingConnection.combinePurchaseResults(
|
||||
iap = Result.success(listOf(owned)),
|
||||
sub = Result.failure(RuntimeException("SUBS query failed")),
|
||||
typeOf = typeOf,
|
||||
) shouldBe listOf(owned)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region ReducerState (pure)
|
||||
@@ -423,13 +465,8 @@ class BillingConnectionTest : BaseTest() {
|
||||
connection.purchaseFailures.first().responseCode shouldBe BillingResponseCode.USER_CANCELED
|
||||
}
|
||||
|
||||
@Test fun `a pending purchase never surfaces as owned or as fresh data`() = runTest2 {
|
||||
val pending = mockk<Purchase>().apply {
|
||||
every { purchaseState } returns PurchaseState.PENDING
|
||||
every { purchaseTime } returns 1_000L
|
||||
every { purchaseToken } returns "pending"
|
||||
every { this@apply.products } returns listOf(OurSku.Iap.PRO_UPGRADE.id)
|
||||
}
|
||||
@Test fun `a pending purchase event enters the state but never the fresh stream`() = runTest2 {
|
||||
val pending = pendingPurchase(1_000, token = "pending")
|
||||
val connection = BillingConnection(
|
||||
client = clientReturning(
|
||||
result(BillingResponseCode.OK) to emptyList(),
|
||||
@@ -437,16 +474,171 @@ class BillingConnectionTest : BaseTest() {
|
||||
),
|
||||
)
|
||||
|
||||
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(pending))
|
||||
connection.refreshPurchases()
|
||||
val freshUpdates = mutableListOf<BillingConnection.FreshUpdate>()
|
||||
backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
connection.freshUpdates.collect { freshUpdates.add(it) }
|
||||
}
|
||||
connection.refreshPurchases() // settles the state; predates the event
|
||||
|
||||
connection.purchases.first() shouldBe emptyList()
|
||||
// Only the refresh's own emission — the PENDING event never produced one.
|
||||
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(pending))
|
||||
runCurrent()
|
||||
|
||||
// Visible as state (the UI must be able to show a payment in progress)...
|
||||
connection.purchases.first().map { it.purchaseToken } shouldBe listOf("pending")
|
||||
// ...but the fresh stream feeds the entitlement and grace bookkeeping, so only the
|
||||
// refresh's own emission is on it — a pending payment confirms nothing.
|
||||
freshUpdates.size shouldBe 1
|
||||
freshUpdates.single().purchases shouldBe emptyList()
|
||||
}
|
||||
|
||||
@Test fun `a pending query result enters the state but never the fresh stream`() = runTest2 {
|
||||
val pending = pendingPurchase(1_000, token = "pending")
|
||||
val connection = BillingConnection(
|
||||
client = clientReturning(
|
||||
result(BillingResponseCode.OK) to listOf(pending),
|
||||
result(BillingResponseCode.OK) to emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
val refresh = connection.refreshPurchases()
|
||||
|
||||
refresh.purchases.map { it.purchaseToken } shouldBe listOf("pending")
|
||||
refresh.confirmed shouldBe emptyList()
|
||||
refresh.hasConfirmedProPurchase shouldBe false
|
||||
connection.purchases.first().map { it.purchaseToken } shouldBe listOf("pending")
|
||||
val update = connection.freshUpdates.first()
|
||||
update.purchases shouldBe emptyList()
|
||||
update.isFullSnapshot shouldBe true
|
||||
}
|
||||
|
||||
@Test fun `a completing payment supersedes its own pending entry`() = runTest2 {
|
||||
// Play delivers the same purchaseToken again once the payment cleared: the dedup must let
|
||||
// the PURCHASED instance win instead of leaving the user with two records.
|
||||
val pending = pendingPurchase(1_000, token = "same")
|
||||
val purchased = purchase(1_000, token = "same")
|
||||
val connection = BillingConnection(
|
||||
client = clientReturning(
|
||||
result(BillingResponseCode.OK) to emptyList(),
|
||||
result(BillingResponseCode.OK) to emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
connection.refreshPurchases() // settles the state; predates both events
|
||||
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(pending))
|
||||
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(purchased))
|
||||
|
||||
val merged = connection.purchases.first()
|
||||
merged.size shouldBe 1
|
||||
merged.single().purchaseState shouldBe PurchaseState.PURCHASED
|
||||
// The completion is fresh Play data: it must reach the grace bookkeeping, unlike the
|
||||
// pending event before it.
|
||||
val updates = connection.freshUpdates.take(2).toList()
|
||||
updates[0].purchases shouldBe emptyList()
|
||||
updates[1].purchases shouldBe listOf(purchased)
|
||||
}
|
||||
|
||||
@Test fun `an unspecified-state purchase is dropped everywhere`() = runTest2 {
|
||||
val unspecified = purchase(1_000, token = "unspecified", state = PurchaseState.UNSPECIFIED_STATE)
|
||||
val connection = BillingConnection(
|
||||
client = clientReturning(
|
||||
result(BillingResponseCode.OK) to listOf(unspecified),
|
||||
result(BillingResponseCode.OK) to emptyList(),
|
||||
),
|
||||
)
|
||||
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(unspecified))
|
||||
|
||||
val refresh = connection.refreshPurchases()
|
||||
|
||||
// Neither owned nor a payment in progress: it must not reach state, refresh or UI.
|
||||
refresh.purchases shouldBe emptyList()
|
||||
connection.purchases.first() shouldBe emptyList()
|
||||
}
|
||||
|
||||
@Test fun `a pending-only overlay survivor does not suppress absence bookkeeping`() = runTest2 {
|
||||
// A pending payment arrives while a refresh is in flight: the queries verified empty, and
|
||||
// the surviving PENDING overlay proves no ownership — the snapshot still proves absence,
|
||||
// unlike the PURCHASED case (which must not start a false unconfirmed-grace episode).
|
||||
val pendingListeners = mutableListOf<PurchasesResponseListener>()
|
||||
val client = mockk<BillingClient>().apply {
|
||||
every { queryPurchasesAsync(any<QueryPurchasesParams>(), any()) } answers {
|
||||
pendingListeners.add(secondArg())
|
||||
}
|
||||
}
|
||||
val connection = BillingConnection(client = client)
|
||||
|
||||
val refresh = async(start = CoroutineStart.UNDISPATCHED) { connection.refreshPurchases() }
|
||||
runCurrent()
|
||||
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(pendingPurchase(1_000)))
|
||||
pendingListeners.forEach { it.onQueryPurchasesResponse(result(BillingResponseCode.OK), mutableListOf()) }
|
||||
runCurrent()
|
||||
refresh.await()
|
||||
|
||||
val update = connection.freshUpdates.first()
|
||||
update.purchases shouldBe emptyList()
|
||||
update.isFullSnapshot shouldBe true
|
||||
}
|
||||
|
||||
@Test fun `a refresh reports only what its own queries confirmed`() = runTest2 {
|
||||
val iapOwned = purchase(1_000, token = "iap")
|
||||
val connection = BillingConnection(
|
||||
client = clientReturning(
|
||||
// Refresh 1: both succeed, IAP owned.
|
||||
result(BillingResponseCode.OK) to listOf(iapOwned),
|
||||
result(BillingResponseCode.OK) to emptyList(),
|
||||
// Refresh 2: IAP fails (stale snapshot retained), SUB confirms a pending payment.
|
||||
result(BillingResponseCode.ERROR) to emptyList(),
|
||||
result(BillingResponseCode.OK) to listOf(
|
||||
pendingPurchase(2_000, token = "pending-sub", products = listOf(OurSku.Sub.PRO_UPGRADE.id))
|
||||
),
|
||||
),
|
||||
)
|
||||
connection.refreshPurchases()
|
||||
|
||||
val second = connection.refreshPurchases()
|
||||
|
||||
// The retained IAP purchase is in the committed view, but it is NOT something these
|
||||
// queries confirmed — reporting it as confirmed Pro would keep the grace clock frozen
|
||||
// through an indefinite outage.
|
||||
second.purchases.map { it.purchaseToken } shouldContainExactly listOf("pending-sub", "iap")
|
||||
second.confirmed shouldBe emptyList()
|
||||
second.hasConfirmedProPurchase shouldBe false
|
||||
second.isComplete shouldBe false
|
||||
second.partialError.shouldNotBeNull()
|
||||
}
|
||||
|
||||
@Test fun `a confirmed known purchase is reported as confirmed pro`() = runTest2 {
|
||||
val connection = BillingConnection(
|
||||
client = clientReturning(
|
||||
result(BillingResponseCode.OK) to listOf(purchase(1_000, token = "iap")),
|
||||
result(BillingResponseCode.ERROR) to emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
val refresh = connection.refreshPurchases()
|
||||
|
||||
refresh.confirmed.map { it.purchaseToken } shouldBe listOf("iap")
|
||||
refresh.hasConfirmedProPurchase shouldBe true
|
||||
refresh.isComplete shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `a refresh is stamped with its commit time`() = runTest2 {
|
||||
val before = System.currentTimeMillis()
|
||||
val connection = BillingConnection(
|
||||
client = clientReturning(
|
||||
result(BillingResponseCode.OK) to emptyList(),
|
||||
result(BillingResponseCode.OK) to emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
val refresh = connection.refreshPurchases()
|
||||
val after = System.currentTimeMillis()
|
||||
|
||||
(refresh.occurredAt in before..after) shouldBe true
|
||||
// The same instant travels on the fresh update, so a confirmation and a later failure
|
||||
// signal can be ordered against each other.
|
||||
refresh.occurredAt shouldBe connection.freshUpdates.first().occurredAt
|
||||
}
|
||||
|
||||
@Test fun `fresh updates arrive in commit order`() = runTest2 {
|
||||
// The event's emission precedes the covering refresh's: a consumer stamping the Pro grace
|
||||
// period can never process a superseded event AFTER the query that cleared it.
|
||||
@@ -807,146 +999,6 @@ class BillingConnectionTest : BaseTest() {
|
||||
|
||||
// endregion
|
||||
|
||||
// region querySubscriptions (pre-purchase gate)
|
||||
|
||||
@Test fun `querySubscriptions returns fresh subs and keeps the iap snapshot intact`() = runTest2 {
|
||||
val iapOwned = purchase(1_000, token = "iap")
|
||||
val subOwned = purchase(2_000, token = "sub", products = listOf(OurSku.Sub.PRO_UPGRADE.id))
|
||||
val client = clientReturning(
|
||||
// Refresh: IAP owned, no subs yet.
|
||||
result(BillingResponseCode.OK) to listOf(iapOwned),
|
||||
result(BillingResponseCode.OK) to emptyList(),
|
||||
// SUBS-only gate query: sub found.
|
||||
result(BillingResponseCode.OK) to listOf(subOwned),
|
||||
)
|
||||
val connection = BillingConnection(client = client)
|
||||
connection.refreshPurchases()
|
||||
|
||||
val gateView = connection.querySubscriptions()
|
||||
|
||||
gateView shouldBe listOf(subOwned)
|
||||
// The gate must have queried SUBS, not INAPP (clientReturning ignores the params, so this
|
||||
// would otherwise go unnoticed). zza() is the params' only product-type accessor — a
|
||||
// billing library upgrade renaming it breaks this line loudly at compile time.
|
||||
verify(exactly = 2) {
|
||||
client.queryPurchasesAsync(match<QueryPurchasesParams> { it.zza() == BillingClient.ProductType.SUBS }, any())
|
||||
}
|
||||
// The SUBS-only commit updates the reactive view WITHOUT disturbing the IAP snapshot —
|
||||
// wiping it would briefly un-Pro a one-time-purchase owner.
|
||||
connection.purchases.first().map { it.purchaseToken } shouldContainExactly listOf("sub", "iap")
|
||||
val updates = connection.freshUpdates.take(2).toList()
|
||||
// Partial by definition: it proves what the SUBS query found, never absence of the rest.
|
||||
updates[1].purchases shouldBe listOf(subOwned)
|
||||
updates[1].isFullSnapshot shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `a failed querySubscriptions propagates and commits nothing`() = runTest2 {
|
||||
val iapOwned = purchase(1_000, token = "iap")
|
||||
val subOwned = purchase(2_000, token = "sub", products = listOf(OurSku.Sub.PRO_UPGRADE.id))
|
||||
val connection = BillingConnection(
|
||||
client = clientReturning(
|
||||
result(BillingResponseCode.OK) to listOf(iapOwned),
|
||||
result(BillingResponseCode.OK) to listOf(subOwned),
|
||||
result(BillingResponseCode.ERROR) to emptyList(),
|
||||
),
|
||||
)
|
||||
val freshUpdates = mutableListOf<BillingConnection.FreshUpdate>()
|
||||
backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
connection.freshUpdates.collect { freshUpdates.add(it) }
|
||||
}
|
||||
connection.refreshPurchases()
|
||||
|
||||
// Fail-closed contract: the gate must see the error, not an empty "no subscriptions".
|
||||
shouldThrow<BillingClientException> { connection.querySubscriptions() }
|
||||
runCurrent()
|
||||
|
||||
// No commit and no fresh emission from the failed query — only the refresh's own. Both
|
||||
// snapshots survive, including the SUB one the failed query was about.
|
||||
connection.purchases.first().map { it.purchaseToken } shouldContainExactly listOf("sub", "iap")
|
||||
freshUpdates.size shouldBe 1
|
||||
}
|
||||
|
||||
@Test fun `querySubscriptions clears an older sub overlay it could have seen`() = runTest2 {
|
||||
// The sub arrived via purchase event, then the user refunded/cancelled it away: the gate
|
||||
// query verifies empty and must supersede the stale event — otherwise the ghost sub keeps
|
||||
// blocking the one-time purchase.
|
||||
val subEvent = purchase(1_000, token = "sub-event", products = listOf(OurSku.Sub.PRO_UPGRADE.id))
|
||||
val connection = BillingConnection(
|
||||
client = clientReturning(result(BillingResponseCode.OK) to emptyList()),
|
||||
)
|
||||
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(subEvent))
|
||||
|
||||
connection.querySubscriptions() shouldBe emptyList()
|
||||
connection.purchases.first() shouldBe emptyList()
|
||||
}
|
||||
|
||||
@Test fun `querySubscriptions prefers a racing event's renewal state for the same token`() = runTest2 {
|
||||
// The stale query result says the sub no longer renews, but a purchase event that landed
|
||||
// while the query was in flight says it does (user just re-subscribed): the gate must see
|
||||
// the overlay version, or the fail-closed double-billing check lets the buy through.
|
||||
val queried = purchase(1_000, token = "same", products = listOf(OurSku.Sub.PRO_UPGRADE.id)).apply {
|
||||
every { isAutoRenewing } returns false
|
||||
}
|
||||
val raced = purchase(1_000, token = "same", products = listOf(OurSku.Sub.PRO_UPGRADE.id)).apply {
|
||||
every { isAutoRenewing } returns true
|
||||
}
|
||||
val pendingListeners = mutableListOf<PurchasesResponseListener>()
|
||||
val client = mockk<BillingClient>().apply {
|
||||
every { queryPurchasesAsync(any<QueryPurchasesParams>(), any()) } answers {
|
||||
pendingListeners.add(secondArg())
|
||||
}
|
||||
}
|
||||
val connection = BillingConnection(client = client)
|
||||
|
||||
val gate = async(start = CoroutineStart.UNDISPATCHED) { connection.querySubscriptions() }
|
||||
runCurrent()
|
||||
pendingListeners.size shouldBe 1
|
||||
|
||||
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(raced))
|
||||
pendingListeners.single().onQueryPurchasesResponse(result(BillingResponseCode.OK), mutableListOf(queried))
|
||||
runCurrent()
|
||||
|
||||
val gateView = gate.await()
|
||||
gateView.single().isAutoRenewing shouldBe true
|
||||
}
|
||||
|
||||
@Test fun `querySubscriptions excludes iap overlay entries but keeps untyped ones`() = runTest2 {
|
||||
val iapEvent = purchase(1_000, token = "iap-event")
|
||||
val unknownEvent = purchase(2_000, token = "unknown", products = listOf("some.unknown.product"))
|
||||
val connection = BillingConnection(
|
||||
client = clientReturning(result(BillingResponseCode.OK) to emptyList()),
|
||||
)
|
||||
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(iapEvent))
|
||||
connection.onPurchasesUpdated(result(BillingResponseCode.OK), listOf(unknownEvent))
|
||||
|
||||
val gateView = connection.querySubscriptions()
|
||||
|
||||
// An IAP can't be the blocking subscription; an unknown product might be, so it stays in
|
||||
// on the safe side.
|
||||
gateView.map { it.purchaseToken } shouldBe listOf("unknown")
|
||||
// Excluded from the gate view only — the reducer still owns both entries.
|
||||
connection.purchases.first().map { it.purchaseToken } shouldContainExactly listOf("unknown", "iap-event")
|
||||
}
|
||||
|
||||
@Test fun `querySubscriptions filters pending subscription results`() = runTest2 {
|
||||
val pendingSub = mockk<Purchase>().apply {
|
||||
every { purchaseState } returns PurchaseState.PENDING
|
||||
every { purchaseTime } returns 1_000L
|
||||
every { purchaseToken } returns "pending-sub"
|
||||
every { this@apply.products } returns listOf(OurSku.Sub.PRO_UPGRADE.id)
|
||||
}
|
||||
val connection = BillingConnection(
|
||||
client = clientReturning(result(BillingResponseCode.OK) to listOf(pendingSub)),
|
||||
)
|
||||
|
||||
// A PENDING subscription is not an active one — it must neither block the gate nor
|
||||
// surface as owned.
|
||||
connection.querySubscriptions() shouldBe emptyList()
|
||||
connection.purchases.first() shouldBe emptyList()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region acknowledgement
|
||||
|
||||
@Test fun `a late ack callback after the caller gave up is ignored`() = runTest2 {
|
||||
|
||||
@@ -89,4 +89,38 @@ class GplayUpgradeOwnershipTest : BaseTest() {
|
||||
|
||||
ownership.ownsAnything shouldBe false
|
||||
}
|
||||
|
||||
private fun pendingInfo(vararg pending: Purchase) = UpgradeRepoGplay.Info(
|
||||
false,
|
||||
BillingData(purchases = emptyList(), pendingPurchases = pending.toList()),
|
||||
null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a pending payment sets the pending flag`() {
|
||||
pendingInfo(mockPurchase("eu.darken.capod.iap.upgrade.pro")).toPendingFlag().shouldBeTrue()
|
||||
pendingInfo(mockPurchase("upgrade.pro")).toPendingFlag().shouldBeTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no pending payment leaves the flag off`() {
|
||||
info(mockPurchase("eu.darken.capod.iap.upgrade.pro")).toPendingFlag().shouldBeFalse()
|
||||
pendingInfo().toPendingFlag().shouldBeFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown pending product does not set the flag`() {
|
||||
// Nothing to explain and nothing to lock: a product this app doesn't sell isn't the Pro
|
||||
// upgrade the user is waiting for.
|
||||
pendingInfo(mockPurchase("some.unknown.sku")).toPendingFlag().shouldBeFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pending payment is not ownership`() {
|
||||
val ownership = pendingInfo(mockPurchase("eu.darken.capod.iap.upgrade.pro")).toOwnership()
|
||||
|
||||
ownership.hasIap.shouldBeFalse()
|
||||
ownership.subscription.shouldBeNull()
|
||||
ownership.ownsAnything.shouldBeFalse()
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -2,8 +2,10 @@ package eu.darken.capod.common.upgrade.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
|
||||
import eu.darken.capod.common.upgrade.core.billing.GplayServiceUnavailableException
|
||||
@@ -41,6 +43,34 @@ class GplayUpgradeScreenHostTest : BaseTest() {
|
||||
every { purchaseLaunchSku } returns MutableStateFlow<Sku?>(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pending-purchase event puts the informational dialog on screen`() {
|
||||
// Host-level wiring: the ViewModel tests prove the event is emitted, only a real
|
||||
// composition proves it reaches a dialog (and survives as a rememberSaveable flag).
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.querySkus(any()) } returns emptyList()
|
||||
val vm = UpgradeViewModel(
|
||||
handle = SavedStateHandle(mapOf("forced" to false)),
|
||||
dispatcherProvider = TestDispatcherProvider(),
|
||||
upgradeRepo = repo,
|
||||
webpageTool = mockk(relaxed = true),
|
||||
)
|
||||
|
||||
composeRule.setContent {
|
||||
PreviewWrapper {
|
||||
UpgradeScreenHost(vm = vm)
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
vm.events.tryEmit(UpgradeEvents.PurchasePending)
|
||||
|
||||
val message = composeRule.activity.getString(R.string.upgrade_screen_pending_dialog_message)
|
||||
composeRule.waitUntil {
|
||||
composeRule.onAllNodesWithText(message, substring = true).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returning to the screen re-runs a failed offers query`() {
|
||||
val repo = mockRepo()
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
@@ -356,6 +357,88 @@ class GplayUpgradeScreenTest : BaseComposeRobolectricTest() {
|
||||
)
|
||||
check(idle.iapEnabled) { "IAP buy should be enabled when idle" }
|
||||
check(idle.subscriptionEnabled) { "Subscription buy should be enabled when idle" }
|
||||
|
||||
// A pending payment locks BOTH, whichever product it belongs to: Play rejects a second
|
||||
// purchase of the pending product, and the alternative would charge twice for Pro.
|
||||
val pending = toLoadedState(
|
||||
iap = SkuDetails(OurSku.Iap.PRO_UPGRADE, iapDetails),
|
||||
sub = SkuDetails(OurSku.Sub.PRO_UPGRADE, subDetails),
|
||||
ownership = Ownership(),
|
||||
hasPendingPurchase = true,
|
||||
)
|
||||
check(!pending.iapEnabled) { "IAP buy must be disabled while a payment is pending" }
|
||||
check(!pending.subscriptionEnabled) { "Subscription buy must be disabled while a payment is pending" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pending payment shows the card and locks the offers for acquisition`() {
|
||||
composeRule.setUpgradeContent {
|
||||
UpgradeScreen(
|
||||
uiState = GplayUpgradeUiState.Loaded(
|
||||
subscriptionAction = SubscriptionAction.STANDARD,
|
||||
subscriptionEnabled = false,
|
||||
subscriptionPrice = "$12.99",
|
||||
iapEnabled = false,
|
||||
iapPrice = "$24.99",
|
||||
hasPendingPurchase = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_PENDING).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_pending_card_body))
|
||||
.assertCountEquals(1)
|
||||
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_SUBSCRIPTION).assertIsNotEnabled()
|
||||
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_IAP).assertIsNotEnabled()
|
||||
// Restore stays available: re-checking with Play is exactly the right move for someone who
|
||||
// believes the payment already went through.
|
||||
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_RESTORE).assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pending payment shows the card on the ownership screen and locks the switch`() {
|
||||
composeRule.setUpgradeContent {
|
||||
UpgradeScreen(
|
||||
uiState = ownedState(Ownership(subscription = SubscriptionOwnership(isAutoRenewing = false)))
|
||||
.copy(hasPendingPurchase = true),
|
||||
)
|
||||
}
|
||||
|
||||
// The subscriber switching to the one-time purchase: the switch offer is unlocked by the
|
||||
// non-renewing subscription, but a payment in progress must still hold it.
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_PENDING).assertCountEquals(1)
|
||||
composeRule.onNodeWithTag(UpgradeScreenTags.GPLAY_IAP).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pending payment shows the card during a young grace episode`() {
|
||||
composeRule.setUpgradeContent {
|
||||
UpgradeScreen(uiState = graceState(showDiagnostics = false).copy(hasPendingPurchase = true))
|
||||
}
|
||||
|
||||
// The young grace stage hides the offers box entirely, so a card rendered inside it would
|
||||
// never reach this audience — which is precisely the one waiting for a renewal payment.
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_PENDING).assertCountEquals(1)
|
||||
composeRule.onAllNodesWithTag(UpgradeScreenTags.GPLAY_GRACE).assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the pending dialog is informational only`() {
|
||||
var dismissals = 0
|
||||
composeRule.setUpgradeContent { PurchasePendingDialog(onDismiss = { dismissals++ }) }
|
||||
|
||||
composeRule.onNodeWithText(
|
||||
context.getString(R.string.upgrade_screen_pending_dialog_message),
|
||||
substring = true,
|
||||
).assertExists()
|
||||
// No support escalation and no restore tips: there is nothing for the user to do.
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_contact_support_action))
|
||||
.assertCountEquals(0)
|
||||
composeRule.onAllNodesWithText(context.getString(R.string.upgrade_screen_restore_multiaccount_hint))
|
||||
.assertCountEquals(0)
|
||||
|
||||
composeRule.onNodeWithText(context.getString(R.string.general_close_action)).performClick()
|
||||
composeRule.runOnIdle { dismissals shouldBe 1 }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+215
-15
@@ -11,6 +11,7 @@ import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
|
||||
import eu.darken.capod.common.upgrade.core.billing.BillingData
|
||||
import eu.darken.capod.common.upgrade.core.billing.GplayServiceUnavailableException
|
||||
import eu.darken.capod.common.upgrade.core.billing.OfferUnavailableBillingException
|
||||
import eu.darken.capod.common.upgrade.core.billing.PendingPurchaseBillingException
|
||||
import eu.darken.capod.common.upgrade.core.billing.Sku
|
||||
import io.kotest.matchers.booleans.shouldBeTrue
|
||||
import io.kotest.matchers.collections.shouldBeEmpty
|
||||
@@ -22,6 +23,7 @@ import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -296,6 +298,9 @@ class GplayUpgradeViewModelTest : BaseTest() {
|
||||
// Relaxed mocks return a no-op Flow that never emits -- the state combine would starve.
|
||||
every { autoRestoreBusy } returns MutableStateFlow(false)
|
||||
every { purchaseLaunchSku } returns MutableStateFlow<Sku?>(null)
|
||||
// Both purchase paths run the pre-purchase gate: the default is a clean account (nothing
|
||||
// owned, nothing pending), so tests only stub it when the gate IS the subject.
|
||||
coEvery { verifyPurchaseStateNow() } returns UpgradeRepoGplay.Info(false, null, null, isSettled = true)
|
||||
}
|
||||
|
||||
private fun buildVm(
|
||||
@@ -322,6 +327,15 @@ class GplayUpgradeViewModelTest : BaseTest() {
|
||||
isSettled = true,
|
||||
)
|
||||
|
||||
// Play is still processing a payment: nothing owned, nothing granted, but the purchase paths
|
||||
// must treat it as a blocking answer.
|
||||
private fun pendingInfo(skuId: String = OurSku.Iap.PRO_UPGRADE.id) = UpgradeRepoGplay.Info(
|
||||
false,
|
||||
BillingData(purchases = emptyList(), pendingPurchases = listOf(mockPurchase(skuId))),
|
||||
null,
|
||||
isSettled = true,
|
||||
)
|
||||
|
||||
/** Play answered. The default for restore mocks; use Inconclusive only to model a non-answer. */
|
||||
private fun checked(info: UpgradeRepoGplay.Info) = UpgradeRepoGplay.RestoreOutcome.Checked(info)
|
||||
|
||||
@@ -373,6 +387,22 @@ class GplayUpgradeViewModelTest : BaseTest() {
|
||||
event.await() shouldBe UpgradeEvents.RestoreFailed
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore that finds a pending payment emits PurchasePending`() = runTest2(context = testDispatcher) {
|
||||
// Play answered and DID find the purchase — it just isn't paid for yet. RestoreFailed would
|
||||
// send this user through account troubleshooting and support for something that resolves
|
||||
// itself.
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.restorePurchaseNow() } returns checked(pendingInfo())
|
||||
val vm = buildVm(repo)
|
||||
|
||||
val event = async { vm.events.first() }
|
||||
vm.restorePurchase()
|
||||
advanceUntilIdle()
|
||||
|
||||
event.await() shouldBe UpgradeEvents.PurchasePending
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore that times out emits RestoreInconclusive not RestoreFailed`() = runTest2(context = testDispatcher) {
|
||||
// A timeout proves nothing about ownership: the budget also covers connecting and the
|
||||
@@ -526,7 +556,28 @@ class GplayUpgradeViewModelTest : BaseTest() {
|
||||
@Test
|
||||
fun `iap purchase is blocked while the subscription is still set to renew`() = runTest2(context = testDispatcher) {
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.queryCurrentSubscriptions() } returns listOf(mockPurchase("upgrade.pro", autoRenewing = true))
|
||||
coEvery { repo.verifyPurchaseStateNow() } returns
|
||||
proInfo(mockPurchase(OurSku.Sub.PRO_UPGRADE.id, autoRenewing = true))
|
||||
val vm = buildVm(repo)
|
||||
|
||||
val event = async { vm.events.first() }
|
||||
vm.onGoIap(mockk<Activity>(relaxed = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
event.await() shouldBe UpgradeEvents.SubscriptionStillRenewing
|
||||
coVerify(exactly = 0) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `iap purchase is blocked by a renewing subscription with an unknown product`() = runTest2(
|
||||
context = testDispatcher,
|
||||
) {
|
||||
// The gate reads the RAW purchases, never the mapped upgrades: a subscription whose product
|
||||
// ID this build doesn't know (legacy SKU, renamed product) still renews and still bills, so
|
||||
// letting the one-time purchase through here charges the user for Pro twice.
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.verifyPurchaseStateNow() } returns
|
||||
proInfo(mockPurchase("some.unknown.subscription", autoRenewing = true))
|
||||
val vm = buildVm(repo)
|
||||
|
||||
val event = async { vm.events.first() }
|
||||
@@ -540,7 +591,8 @@ class GplayUpgradeViewModelTest : BaseTest() {
|
||||
@Test
|
||||
fun `iap purchase proceeds when the subscription is not set to renew`() = runTest2(context = testDispatcher) {
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.queryCurrentSubscriptions() } returns listOf(mockPurchase("upgrade.pro", autoRenewing = false))
|
||||
coEvery { repo.verifyPurchaseStateNow() } returns
|
||||
proInfo(mockPurchase(OurSku.Sub.PRO_UPGRADE.id, autoRenewing = false))
|
||||
val vm = buildVm(repo)
|
||||
|
||||
vm.onGoIap(mockk<Activity>(relaxed = true))
|
||||
@@ -552,7 +604,7 @@ class GplayUpgradeViewModelTest : BaseTest() {
|
||||
@Test
|
||||
fun `iap purchase proceeds without any subscription`() = runTest2(context = testDispatcher) {
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.queryCurrentSubscriptions() } returns emptyList()
|
||||
coEvery { repo.verifyPurchaseStateNow() } returns UpgradeRepoGplay.Info(false, null, null, isSettled = true)
|
||||
val vm = buildVm(repo)
|
||||
|
||||
vm.onGoIap(mockk<Activity>(relaxed = true))
|
||||
@@ -562,12 +614,12 @@ class GplayUpgradeViewModelTest : BaseTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failing subscription verification blocks the purchase and forwards the error`() = runTest2(
|
||||
fun `failing purchase verification blocks the purchase and forwards the error`() = runTest2(
|
||||
context = testDispatcher,
|
||||
) {
|
||||
val repo = mockRepo()
|
||||
val boom = IllegalStateException("Play unavailable")
|
||||
coEvery { repo.queryCurrentSubscriptions() } throws boom
|
||||
coEvery { repo.verifyPurchaseStateNow() } throws boom
|
||||
val vm = buildVm(repo)
|
||||
|
||||
val forwardedError = async { vm.errorEvents.first() }
|
||||
@@ -579,13 +631,13 @@ class GplayUpgradeViewModelTest : BaseTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subscription verification timeout blocks the purchase with a check-failed event`() = runTest2(
|
||||
fun `purchase verification timeout blocks the purchase with a check-failed event`() = runTest2(
|
||||
context = testDispatcher,
|
||||
) {
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.queryCurrentSubscriptions() } coAnswers {
|
||||
coEvery { repo.verifyPurchaseStateNow() } coAnswers {
|
||||
delay(30_000) // longer than the 10s verification timeout
|
||||
emptyList()
|
||||
UpgradeRepoGplay.Info(false, null, null, isSettled = true)
|
||||
}
|
||||
val vm = buildVm(repo)
|
||||
|
||||
@@ -593,16 +645,126 @@ class GplayUpgradeViewModelTest : BaseTest() {
|
||||
vm.onGoIap(mockk<Activity>(relaxed = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
event.await() shouldBe UpgradeEvents.SubscriptionCheckFailed
|
||||
event.await() shouldBe UpgradeEvents.PurchaseCheckFailed
|
||||
coVerify(exactly = 0) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a subscription gate timeout blocks that purchase too`() = runTest2(context = testDispatcher) {
|
||||
// The subscription path used to launch unverified: a slow Play means we don't know whether
|
||||
// a payment is already pending, so it must fail closed like the one-time path.
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.verifyPurchaseStateNow() } coAnswers {
|
||||
delay(30_000)
|
||||
UpgradeRepoGplay.Info(false, null, null, isSettled = true)
|
||||
}
|
||||
val vm = buildVm(repo)
|
||||
|
||||
val event = async { vm.events.first() }
|
||||
vm.onGoSubscription(mockk<Activity>(relaxed = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
event.await() shouldBe UpgradeEvents.PurchaseCheckFailed
|
||||
coVerify(exactly = 0) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pending payment blocks the one-time purchase`() = runTest2(context = testDispatcher) {
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.verifyPurchaseStateNow() } returns pendingInfo()
|
||||
val vm = buildVm(repo)
|
||||
|
||||
val event = async { vm.events.first() }
|
||||
vm.onGoIap(mockk<Activity>(relaxed = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
event.await() shouldBe UpgradeEvents.PurchasePending
|
||||
coVerify(exactly = 0) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pending payment blocks the subscription purchase`() = runTest2(context = testDispatcher) {
|
||||
// SKU-agnostic on purpose: the two products are alternatives, so a pending payment for
|
||||
// either one must block both — completing both charges the user twice.
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.verifyPurchaseStateNow() } returns pendingInfo()
|
||||
val vm = buildVm(repo)
|
||||
|
||||
val event = async { vm.events.first() }
|
||||
vm.onGoSubscriptionTrial(mockk<Activity>(relaxed = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
event.await() shouldBe UpgradeEvents.PurchasePending
|
||||
coVerify(exactly = 0) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a subscription purchase is blocked when the fresh check finds an owned upgrade`() = runTest2(
|
||||
context = testDispatcher,
|
||||
) {
|
||||
// The screen can be stale (the one-time purchase was made on another device) and Play sells
|
||||
// the subscription right next to an owned IAP — launching here charges the user for Pro a
|
||||
// second time.
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.verifyPurchaseStateNow() } returns proInfo(mockPurchase(OurSku.Iap.PRO_UPGRADE.id))
|
||||
val vm = buildVm(repo)
|
||||
|
||||
val event = async { vm.events.first() }
|
||||
vm.onGoSubscription(mockk<Activity>(relaxed = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
event.await() shouldBe UpgradeEvents.RestoreSucceeded
|
||||
coVerify(exactly = 0) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a subscription purchase is blocked when an unknown renewing subscription exists`() = runTest2(
|
||||
context = testDispatcher,
|
||||
) {
|
||||
// Unknown product ID => zero mapped upgrades, so the ownership block above lets it through.
|
||||
// It still renews and still bills, and a second subscription for the same features is the
|
||||
// same double charge.
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.verifyPurchaseStateNow() } returns
|
||||
proInfo(mockPurchase("some.unknown.subscription", autoRenewing = true))
|
||||
val vm = buildVm(repo)
|
||||
|
||||
val event = async { vm.events.first() }
|
||||
vm.onGoSubscription(mockk<Activity>(relaxed = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
event.await() shouldBe UpgradeEvents.SubscriptionStillRenewing
|
||||
coVerify(exactly = 0) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pending-payment launch failure surfaces as the pending dialog`() = runTest2(context = testDispatcher) {
|
||||
// Play can only report this at launch time (the gate saw a clean state moments earlier):
|
||||
// the already-owned error dialog with its restore tips would be the wrong advice.
|
||||
// The callback is captured and invoked from the test body rather than from inside the
|
||||
// answer: on a suspend function the argument list carries the continuation, so grabbing the
|
||||
// callback positionally there is a coin flip — and a wrong cast would surface as a hang.
|
||||
val repo = mockRepo()
|
||||
val onError = slot<(Throwable) -> Unit>()
|
||||
coEvery { repo.launchBillingFlowNow(any(), any(), any(), capture(onError)) } returns Unit
|
||||
val vm = buildVm(repo)
|
||||
|
||||
val event = async { vm.events.first() }
|
||||
vm.onGoIap(mockk<Activity>(relaxed = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
onError.captured(PendingPurchaseBillingException())
|
||||
advanceUntilIdle()
|
||||
|
||||
event.await() shouldBe UpgradeEvents.PurchasePending
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `iap taps are single-flight while a verification is running`() = runTest2(context = testDispatcher) {
|
||||
val repo = mockRepo()
|
||||
coEvery { repo.queryCurrentSubscriptions() } coAnswers {
|
||||
coEvery { repo.verifyPurchaseStateNow() } coAnswers {
|
||||
delay(5_000)
|
||||
emptyList()
|
||||
UpgradeRepoGplay.Info(false, null, null, isSettled = true)
|
||||
}
|
||||
val vm = buildVm(repo)
|
||||
|
||||
@@ -611,7 +773,7 @@ class GplayUpgradeViewModelTest : BaseTest() {
|
||||
vm.onGoIap(mockk<Activity>(relaxed = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { repo.queryCurrentSubscriptions() }
|
||||
coVerify(exactly = 1) { repo.verifyPurchaseStateNow() }
|
||||
coVerify(exactly = 1) { repo.launchBillingFlowNow(any(), eq(OurSku.Iap.PRO_UPGRADE), isNull(), any()) }
|
||||
}
|
||||
|
||||
@@ -648,8 +810,9 @@ class GplayUpgradeViewModelTest : BaseTest() {
|
||||
advanceUntilIdle()
|
||||
|
||||
// One arbiter for all three: the purchase and the restore would otherwise run concurrent
|
||||
// Play operations against the same account state.
|
||||
coVerify(exactly = 0) { repo.queryCurrentSubscriptions() }
|
||||
// Play operations against the same account state. Exactly one gate ran — the subscription's
|
||||
// own; the blocked IAP tap never got to verify anything.
|
||||
coVerify(exactly = 1) { repo.verifyPurchaseStateNow() }
|
||||
coVerify(exactly = 0) { repo.restorePurchaseNow() }
|
||||
coVerify(exactly = 1) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
|
||||
}
|
||||
@@ -672,7 +835,7 @@ class GplayUpgradeViewModelTest : BaseTest() {
|
||||
|
||||
coVerify(exactly = 1) { repo.restorePurchaseNow() }
|
||||
coVerify(exactly = 0) { repo.launchBillingFlowNow(any(), any(), any(), any()) }
|
||||
coVerify(exactly = 0) { repo.queryCurrentSubscriptions() }
|
||||
coVerify(exactly = 0) { repo.verifyPurchaseStateNow() }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -970,6 +1133,43 @@ class GplayUpgradeViewModelTest : BaseTest() {
|
||||
event.await() shouldBe UpgradeEvents.RestoreFailed
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pending payment renders while the price queries are still running`() = runTest2(
|
||||
context = testDispatcher,
|
||||
) {
|
||||
// Price-independent like owners and grace users: the pending card is this user's answer and
|
||||
// both offers are locked anyway, so waiting on prices would hide it behind Loading.
|
||||
val repo = mockRepo()
|
||||
every { repo.upgradeInfo } returns MutableStateFlow(pendingInfo())
|
||||
coEvery { repo.querySkus(any()) } coAnswers {
|
||||
delay(60_000) // effectively never within this test
|
||||
emptyList()
|
||||
}
|
||||
val vm = buildVm(repo)
|
||||
|
||||
val collector = launch(start = CoroutineStart.UNDISPATCHED) { vm.state.collect { } }
|
||||
testScheduler.advanceTimeBy(1_000)
|
||||
|
||||
val loaded = vm.state.value.shouldBeInstanceOf<GplayUpgradeUiState.Loaded>()
|
||||
loaded.hasPendingPurchase shouldBe true
|
||||
collector.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pending payment keeps its card when both price queries fail`() = runTest2(context = testDispatcher) {
|
||||
val repo = mockRepo()
|
||||
every { repo.upgradeInfo } returns MutableStateFlow(pendingInfo())
|
||||
coEvery { repo.querySkus(any()) } throws IllegalStateException("Play unavailable")
|
||||
val vm = buildVm(repo)
|
||||
|
||||
val loaded = async { awaitLoaded(vm) }
|
||||
advanceUntilIdle()
|
||||
|
||||
// Not the acquisition-style Unavailable card: the pending explanation must survive a price
|
||||
// outage, exactly like the grace card does.
|
||||
loaded.await().hasPendingPurchase shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `owner with failed detail queries gets no detail error dialog`() = runTest2(context = testDispatcher) {
|
||||
val repo = mockRepo()
|
||||
|
||||
Reference in New Issue
Block a user