test(upgrade): Add unit tests for Google Play billing and upgrade logic

This commit is contained in:
darken
2026-03-05 13:41:34 +00:00
committed by Matthias Urhahn
parent e35bce4d19
commit 5cd97805b4
4 changed files with 442 additions and 0 deletions
@@ -0,0 +1,90 @@
package eu.darken.capod.common.upgrade.core
import com.android.billingclient.api.ProductDetails
import eu.darken.capod.common.upgrade.core.data.Sku
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class CapodSkuTest : BaseTest() {
@Test
fun `PRO_SKUS contains both IAP and subscription`() {
CapodSku.PRO_SKUS.size shouldBe 2
CapodSku.PRO_SKUS.any { it is Sku.Iap } shouldBe true
CapodSku.PRO_SKUS.any { it is Sku.Subscription } shouldBe true
}
@Test
fun `IAP SKU has correct type`() {
CapodSku.Iap.PRO_UPGRADE.type shouldBe Sku.Type.IAP
}
@Test
fun `Sub SKU has correct id and type`() {
CapodSku.Sub.PRO_UPGRADE.id shouldBe "upgrade.pro"
CapodSku.Sub.PRO_UPGRADE.type shouldBe Sku.Type.SUBSCRIPTION
}
@Test
fun `Sub has both base and trial offers`() {
CapodSku.Sub.PRO_UPGRADE.offers.size shouldBe 2
}
@Test
fun `BASE_OFFER matches correct offer details`() {
val offerDetails = mockk<ProductDetails.SubscriptionOfferDetails> {
every { basePlanId } returns "upgrade-pro-baseplan"
every { offerId } returns null
}
CapodSku.Sub.PRO_UPGRADE.BASE_OFFER.matches(offerDetails) shouldBe true
}
@Test
fun `BASE_OFFER does not match wrong basePlanId`() {
val offerDetails = mockk<ProductDetails.SubscriptionOfferDetails> {
every { basePlanId } returns "wrong-plan"
every { offerId } returns null
}
CapodSku.Sub.PRO_UPGRADE.BASE_OFFER.matches(offerDetails) shouldBe false
}
@Test
fun `BASE_OFFER does not match offer with offerId`() {
val offerDetails = mockk<ProductDetails.SubscriptionOfferDetails> {
every { basePlanId } returns "upgrade-pro-baseplan"
every { offerId } returns "some-offer"
}
CapodSku.Sub.PRO_UPGRADE.BASE_OFFER.matches(offerDetails) shouldBe false
}
@Test
fun `TRIAL_OFFER matches correct offer details`() {
val offerDetails = mockk<ProductDetails.SubscriptionOfferDetails> {
every { basePlanId } returns "upgrade-pro-baseplan"
every { offerId } returns "upgrade-pro-baseplan-trial"
}
CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER.matches(offerDetails) shouldBe true
}
@Test
fun `TRIAL_OFFER does not match wrong offerId`() {
val offerDetails = mockk<ProductDetails.SubscriptionOfferDetails> {
every { basePlanId } returns "upgrade-pro-baseplan"
every { offerId } returns "wrong-offer"
}
CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER.matches(offerDetails) shouldBe false
}
@Test
fun `TRIAL_OFFER does not match null offerId`() {
val offerDetails = mockk<ProductDetails.SubscriptionOfferDetails> {
every { basePlanId } returns "upgrade-pro-baseplan"
every { offerId } returns null
}
CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER.matches(offerDetails) shouldBe false
}
}
@@ -0,0 +1,210 @@
package eu.darken.capod.common.upgrade.core
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.datastore.createValue
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.upgrade.core.data.BillingData
import eu.darken.capod.common.upgrade.core.data.BillingDataRepo
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import testhelpers.BaseTest
import testhelpers.coroutine.runTest2
import java.io.File
class UpgradeRepoGplayTest : BaseTest() {
@TempDir
lateinit var tempDir: File
private lateinit var billingDataFlow: MutableSharedFlow<BillingData>
private lateinit var billingDataRepo: BillingDataRepo
private lateinit var billingCache: BillingCache
private var dsCounter = 0
@BeforeEach
fun setup() {
billingDataFlow = MutableSharedFlow()
billingDataRepo = mockk {
every { billingData } returns billingDataFlow
}
val dataStore = PreferenceDataStoreFactory.create(
produceFile = { File(tempDir, "test_billing_cache_${dsCounter++}.preferences_pb") }
)
billingCache = mockk {
every { lastProStateAt } returns dataStore.createValue("gplay.cache.lastProAt", 0L)
}
}
private fun createRepo(scope: TestScope): UpgradeRepoGplay {
return UpgradeRepoGplay(
scope = scope,
billingDataRepo = billingDataRepo,
billingCache = billingCache,
)
}
private fun mockPurchase(productId: String, purchaseTime: Long = 1000L): Purchase = mockk {
every { products } returns listOf(productId)
every { this@mockk.purchaseTime } returns purchaseTime
}
@Test
fun `no purchases and no grace period - not pro`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
val repo = createRepo(testScope)
// Collect emissions in background before emitting data
val emissions = mutableListOf<eu.darken.capod.common.upgrade.UpgradeRepo.Info>()
val job = testScope.launch { repo.upgradeInfo.toList(emissions) }
billingDataFlow.emit(BillingData(purchases = emptyList()))
// First emission is onStart, second is billing data
emissions.size shouldBe 2
val info = emissions.last()
info.isPro shouldBe false
info.error.shouldBeNull()
job.cancel()
testScope.cancel()
}
@Test
fun `IAP purchase - is pro with hasIap`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
val repo = createRepo(testScope)
val emissions = mutableListOf<eu.darken.capod.common.upgrade.UpgradeRepo.Info>()
val job = testScope.launch { repo.upgradeInfo.toList(emissions) }
val purchase = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)
billingDataFlow.emit(BillingData(purchases = listOf(purchase)))
val info = emissions.last() as UpgradeRepoGplay.Info
info.isPro shouldBe true
info.hasIap shouldBe true
info.hasSub shouldBe false
job.cancel()
testScope.cancel()
}
@Test
fun `subscription purchase - is pro with hasSub`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
val repo = createRepo(testScope)
val emissions = mutableListOf<eu.darken.capod.common.upgrade.UpgradeRepo.Info>()
val job = testScope.launch { repo.upgradeInfo.toList(emissions) }
val purchase = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id)
billingDataFlow.emit(BillingData(purchases = listOf(purchase)))
val info = emissions.last() as UpgradeRepoGplay.Info
info.isPro shouldBe true
info.hasSub shouldBe true
info.hasIap shouldBe false
job.cancel()
testScope.cancel()
}
@Test
fun `within grace period - onStart emits pro`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// Set last pro state to 1 hour ago (within 7-day window)
billingCache.lastProStateAt.valueBlocking = System.currentTimeMillis() - 60 * 60 * 1000L
val repo = createRepo(testScope)
// The very first emission (onStart) should already be pro due to grace period
val info = repo.upgradeInfo.first()
info.isPro shouldBe true
testScope.cancel()
}
@Test
fun `within grace period - billing data confirms grace`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// Set last pro state to 1 hour ago (within 7-day window)
billingCache.lastProStateAt.valueBlocking = System.currentTimeMillis() - 60 * 60 * 1000L
val repo = createRepo(testScope)
val emissions = mutableListOf<eu.darken.capod.common.upgrade.UpgradeRepo.Info>()
val job = testScope.launch { repo.upgradeInfo.toList(emissions) }
billingDataFlow.emit(BillingData(purchases = emptyList()))
val info = emissions.last()
info.isPro shouldBe true
job.cancel()
testScope.cancel()
}
@Test
fun `grace period expired - not pro`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
// Set last pro state to 8 days ago (beyond 7-day window)
billingCache.lastProStateAt.valueBlocking = System.currentTimeMillis() - 8 * 24 * 60 * 60 * 1000L
val repo = createRepo(testScope)
val emissions = mutableListOf<eu.darken.capod.common.upgrade.UpgradeRepo.Info>()
val job = testScope.launch { repo.upgradeInfo.toList(emissions) }
billingDataFlow.emit(BillingData(purchases = emptyList()))
val info = emissions.last()
info.isPro shouldBe false
job.cancel()
testScope.cancel()
}
@Test
fun `upgradedAt returns correct instant from purchase`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
val repo = createRepo(testScope)
val emissions = mutableListOf<eu.darken.capod.common.upgrade.UpgradeRepo.Info>()
val job = testScope.launch { repo.upgradeInfo.toList(emissions) }
val purchaseTime = 1709553600000L
val purchase = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id, purchaseTime)
billingDataFlow.emit(BillingData(purchases = listOf(purchase)))
val info = emissions.last() as UpgradeRepoGplay.Info
info.upgradedAt.shouldNotBeNull()
info.upgradedAt!!.toEpochMilli() shouldBe purchaseTime
job.cancel()
testScope.cancel()
}
@Test
fun `Info type is GPLAY`() {
val info = UpgradeRepoGplay.Info(billingData = null)
info.type shouldBe eu.darken.capod.common.upgrade.UpgradeRepo.Type.GPLAY
}
}
@@ -0,0 +1,72 @@
package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
import eu.darken.capod.common.upgrade.core.client.BillingException
import eu.darken.capod.common.upgrade.core.client.BillingResultException
import eu.darken.capod.common.upgrade.core.client.GplayServiceUnavailableException
import eu.darken.capod.common.upgrade.core.data.BillingDataRepo.Companion.tryMapUserFriendly
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class BillingDataRepoTest : BaseTest() {
private fun mockBillingResult(responseCode: Int): BillingResult = mockk {
every { this@mockk.responseCode } returns responseCode
every { debugMessage } returns "mock"
}
@Test
fun `temporary unavailable maps to GplayServiceUnavailableException`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE)
val mapped = BillingResultException(result).tryMapUserFriendly()
mapped.shouldBeInstanceOf<GplayServiceUnavailableException>()
}
@Test
fun `service disconnected maps to GplayServiceUnavailableException`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED)
val mapped = BillingResultException(result).tryMapUserFriendly()
mapped.shouldBeInstanceOf<GplayServiceUnavailableException>()
}
@Test
fun `service timeout maps to GplayServiceUnavailableException`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.SERVICE_TIMEOUT)
val mapped = BillingResultException(result).tryMapUserFriendly()
mapped.shouldBeInstanceOf<GplayServiceUnavailableException>()
}
@Test
fun `permanent unavailable maps to GplayServiceUnavailableException`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE)
val mapped = BillingResultException(result).tryMapUserFriendly()
mapped.shouldBeInstanceOf<GplayServiceUnavailableException>()
}
@Test
fun `other billing result passes through unchanged`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED)
val original = BillingResultException(result)
val mapped = original.tryMapUserFriendly()
mapped shouldBe original
}
@Test
fun `non-billing exception passes through unchanged`() {
val original = IllegalStateException("something else")
val mapped = original.tryMapUserFriendly()
mapped shouldBe original
}
@Test
fun `generic BillingException passes through unchanged`() {
val original = BillingException("generic billing error")
val mapped = original.tryMapUserFriendly()
mapped shouldBe original
}
}
@@ -0,0 +1,70 @@
package eu.darken.capod.common.upgrade.core.data
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.upgrade.core.CapodSku
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class BillingDataTest : BaseTest() {
private fun mockPurchase(vararg productIds: String): Purchase = mockk {
every { products } returns productIds.toList()
}
@Test
fun `empty purchases yields empty purchasedSkus`() {
val data = BillingData(purchases = emptyList())
data.purchasedSkus.shouldBeEmpty()
}
@Test
fun `purchase with IAP product ID maps to IAP SKU`() {
val purchase = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)
val data = BillingData(purchases = listOf(purchase))
data.purchasedSkus.size shouldBe 1
data.purchasedSkus.first().sku shouldBe CapodSku.Iap.PRO_UPGRADE
data.purchasedSkus.first().purchase shouldBe purchase
}
@Test
fun `purchase with subscription product ID maps to Sub SKU`() {
val purchase = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id)
val data = BillingData(purchases = listOf(purchase))
data.purchasedSkus.size shouldBe 1
data.purchasedSkus.first().sku shouldBe CapodSku.Sub.PRO_UPGRADE
data.purchasedSkus.first().purchase shouldBe purchase
}
@Test
fun `purchase with unknown product ID is filtered out`() {
val purchase = mockPurchase("com.unknown.product")
val data = BillingData(purchases = listOf(purchase))
data.purchasedSkus.shouldBeEmpty()
}
@Test
fun `purchase with mixed products returns only matching SKU`() {
val purchase = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id, "com.unknown.product")
val data = BillingData(purchases = listOf(purchase))
data.purchasedSkus.size shouldBe 1
data.purchasedSkus.first().sku shouldBe CapodSku.Sub.PRO_UPGRADE
}
@Test
fun `multiple purchases with different pro SKUs`() {
val iapPurchase = mockPurchase(CapodSku.Iap.PRO_UPGRADE.id)
val subPurchase = mockPurchase(CapodSku.Sub.PRO_UPGRADE.id)
val data = BillingData(purchases = listOf(iapPurchase, subPurchase))
data.purchasedSkus.size shouldBe 2
data.purchasedSkus.map { it.sku }.toSet() shouldBe setOf(CapodSku.Iap.PRO_UPGRADE, CapodSku.Sub.PRO_UPGRADE)
}
}