From bfc0e1cfaa31343f7275b7c829e50ab5cc6a798d Mon Sep 17 00:00:00 2001 From: Matthias Urhahn Date: Tue, 14 Jul 2026 17:27:36 +0200 Subject: [PATCH] test(upgrade): Cover PENDING purchases granting no Pro and no ack --- .../client/BillingClientConnectionTest.kt | 23 +++++++++++ .../upgrade/core/data/BillingDataRepoTest.kt | 38 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionTest.kt index 2f50dd19..0c7fd043 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionTest.kt @@ -6,17 +6,40 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import org.junit.jupiter.api.Test import testhelpers.BaseTest +import testhelpers.coroutine.runTest2 class BillingClientConnectionTest : BaseTest() { private fun mockPurchase( productId: String = CapodSku.Iap.PRO_UPGRADE.id, purchaseTime: Long = 1_000, + state: Int = Purchase.PurchaseState.PURCHASED, ): Purchase = mockk { every { products } returns listOf(productId) every { this@mockk.purchaseTime } returns purchaseTime + every { purchaseState } returns state + } + + @Test + fun `pending purchases are filtered out of the push-based purchases flow`() = runTest2 { + // A PENDING purchase (e.g. a slow cash/deferred payment) must never reach the entitlement + // layer — only PURCHASED grants Pro. This guards the #628 fix at the connection boundary. + val pending = mockPurchase(purchaseTime = 2_000, state = Purchase.PurchaseState.PENDING) + val purchased = mockPurchase(purchaseTime = 1_000, state = Purchase.PurchaseState.PURCHASED) + + val connection = BillingClientConnection( + client = mockk(relaxed = true), + purchasesGlobal = MutableStateFlow(listOf(pending, purchased)), + freshObservations = MutableSharedFlow(), + purchaseFailuresGlobal = MutableSharedFlow(), + ) + + connection.purchases.first() shouldBe listOf(purchased) } @Test diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt index e36e85a8..2aa6d6bf 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt @@ -2,6 +2,7 @@ package eu.darken.capod.common.upgrade.core.data import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.Purchase import eu.darken.capod.common.AppForegroundState import eu.darken.capod.common.upgrade.core.client.BillingClientConnection import eu.darken.capod.common.upgrade.core.client.BillingClientConnectionProvider @@ -212,6 +213,43 @@ class BillingDataRepoTest : BaseTest() { mapped shouldBe original } + private fun mockPurchase(state: Int, acknowledged: Boolean): Purchase = mockk { + every { purchaseState } returns state + every { isAcknowledged } returns acknowledged + } + + @Test + fun `ack pipeline acknowledges only unacknowledged PURCHASED purchases`() = runTest2 { + val testScope = TestScope(UnconfinedTestDispatcher(testScheduler)) + // A PENDING purchase must not be acknowledged (acking it fails and spins the retry loop); + // an already-acknowledged one must not be re-acked; only a settled, unacked one is acked. + val pending = mockPurchase(Purchase.PurchaseState.PENDING, acknowledged = false) + val purchasedUnacked = mockPurchase(Purchase.PurchaseState.PURCHASED, acknowledged = false) + val purchasedAcked = mockPurchase(Purchase.PurchaseState.PURCHASED, acknowledged = true) + + val clientConnection = mockk { + every { purchases } returns flowOf(listOf(pending, purchasedUnacked, purchasedAcked)) + coEvery { acknowledgePurchase(any()) } returns Unit + } + val provider = mockk { + every { connection } returns flowOf(clientConnection) + } + val foregroundState = mockk { + every { isForeground } returns MutableStateFlow(false) + } + + try { + BillingDataRepo(provider, testScope, foregroundState, TestTimeSource()) + testScope.testScheduler.runCurrent() + + coVerify(exactly = 1) { clientConnection.acknowledgePurchase(purchasedUnacked) } + coVerify(exactly = 0) { clientConnection.acknowledgePurchase(pending) } + coVerify(exactly = 0) { clientConnection.acknowledgePurchase(purchasedAcked) } + } finally { + testScope.cancel() + } + } + private class ForegroundRefreshHarness(testScope: TestScope) { val clientConnection = mockk { every { purchases } returns emptyFlow()