mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
test(upgrade): Add unit tests for Google Play billing and upgrade logic
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
+72
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user