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:
darken
2026-08-16 17:44:02 +02:00
committed by Matthias Urhahn
parent abc82b0f09
commit a357d77b00
7 changed files with 916 additions and 178 deletions
@@ -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.
@@ -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
@@ -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()
}
}
@@ -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
@@ -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()