test(upgrade): Cover PENDING purchases granting no Pro and no ack

This commit is contained in:
Matthias Urhahn
2026-07-14 17:27:36 +02:00
committed by GitHub
parent f4facd3e86
commit bfc0e1cfaa
2 changed files with 61 additions and 0 deletions
@@ -6,17 +6,40 @@ import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import io.mockk.mockk 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 org.junit.jupiter.api.Test
import testhelpers.BaseTest import testhelpers.BaseTest
import testhelpers.coroutine.runTest2
class BillingClientConnectionTest : BaseTest() { class BillingClientConnectionTest : BaseTest() {
private fun mockPurchase( private fun mockPurchase(
productId: String = CapodSku.Iap.PRO_UPGRADE.id, productId: String = CapodSku.Iap.PRO_UPGRADE.id,
purchaseTime: Long = 1_000, purchaseTime: Long = 1_000,
state: Int = Purchase.PurchaseState.PURCHASED,
): Purchase = mockk { ): Purchase = mockk {
every { products } returns listOf(productId) every { products } returns listOf(productId)
every { this@mockk.purchaseTime } returns purchaseTime 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 @Test
@@ -2,6 +2,7 @@ package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.AppForegroundState import eu.darken.capod.common.AppForegroundState
import eu.darken.capod.common.upgrade.core.client.BillingClientConnection import eu.darken.capod.common.upgrade.core.client.BillingClientConnection
import eu.darken.capod.common.upgrade.core.client.BillingClientConnectionProvider import eu.darken.capod.common.upgrade.core.client.BillingClientConnectionProvider
@@ -212,6 +213,43 @@ class BillingDataRepoTest : BaseTest() {
mapped shouldBe original 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<BillingClientConnection> {
every { purchases } returns flowOf(listOf(pending, purchasedUnacked, purchasedAcked))
coEvery { acknowledgePurchase(any()) } returns Unit
}
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flowOf(clientConnection)
}
val foregroundState = mockk<AppForegroundState> {
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) { private class ForegroundRefreshHarness(testScope: TestScope) {
val clientConnection = mockk<BillingClientConnection> { val clientConnection = mockk<BillingClientConnection> {
every { purchases } returns emptyFlow() every { purchases } returns emptyFlow()