From a357d77b005424db7f8d5d9adc5d950034292bc8 Mon Sep 17 00:00:00 2001 From: darken Date: Sun, 16 Aug 2026 15:50:40 +0200 Subject: [PATCH] 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. --- .../upgrade/core/UpgradeRepoGplayTest.kt | 110 ++++++ .../core/billing/BillingManagerTest.kt | 249 +++++++++++- .../billing/client/BillingConnectionTest.kt | 358 ++++++++++-------- .../upgrade/ui/GplayUpgradeOwnershipTest.kt | 34 ++ .../upgrade/ui/GplayUpgradeScreenHostTest.kt | 30 ++ .../upgrade/ui/GplayUpgradeScreenTest.kt | 83 ++++ .../upgrade/ui/GplayUpgradeViewModelTest.kt | 230 ++++++++++- 7 files changed, 916 insertions(+), 178 deletions(-) diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt index ebaa2be2..ab47ba70 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt @@ -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().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() + 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(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 { + 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() + repo(lastProAt = 0L).startLaunch { errors.add(it) } + + errors.single().shouldBeInstanceOf() + } + + // 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. diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/BillingManagerTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/BillingManagerTest.kt index 88af24f8..97b0bbf9 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/BillingManagerTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/BillingManagerTest.kt @@ -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().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> = 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? = null, purchasesFlow: Flow> = flowOf(emptyList()), freshUpdatesFlow: Flow = emptyFlow(), failures: Flow = emptyFlow(), ) = mockk().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 = emptyList(), + confirmed: Collection = 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 = 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 = mutableListOf().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 { manager.querySubscriptions() } - advanceUntilIdle() + shouldThrow { 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().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 { 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 diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/client/BillingConnectionTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/client/BillingConnectionTest.kt index 061c95b6..932dce28 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/client/BillingConnectionTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/billing/client/BillingConnectionTest.kt @@ -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 = listOf(OurSku.Iap.PRO_UPGRADE.id), acknowledged: Boolean = false, + state: Int = PurchaseState.PURCHASED, ) = mockk().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 = 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 { + 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().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() + 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() + val client = mockk().apply { + every { queryPurchasesAsync(any(), 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 { 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() - 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 { 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() - val client = mockk().apply { - every { queryPurchasesAsync(any(), 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().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 { diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeOwnershipTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeOwnershipTest.kt index d3e9a0bf..3fffbea7 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeOwnershipTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeOwnershipTest.kt @@ -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() + } } diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenHostTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenHostTest.kt index 88e4d74e..72b8fc0a 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenHostTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenHostTest.kt @@ -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(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() diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt index e7a63a4f..642f1571 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeScreenTest.kt @@ -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 diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeViewModelTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeViewModelTest.kt index fdba817f..48523b3c 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeViewModelTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/ui/GplayUpgradeViewModelTest.kt @@ -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(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(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(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(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(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(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(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(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(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(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(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(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() + 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()