diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/CapodSkuTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/CapodSkuTest.kt new file mode 100644 index 00000000..ce9b3012 --- /dev/null +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/CapodSkuTest.kt @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + every { basePlanId } returns "upgrade-pro-baseplan" + every { offerId } returns null + } + CapodSku.Sub.PRO_UPGRADE.TRIAL_OFFER.matches(offerDetails) shouldBe false + } +} 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 new file mode 100644 index 00000000..f1c64de4 --- /dev/null +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplayTest.kt @@ -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 + 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() + 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() + 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() + 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() + 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() + 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() + 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 + } +} 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 new file mode 100644 index 00000000..af9b18b1 --- /dev/null +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt @@ -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() + } + + @Test + fun `service disconnected maps to GplayServiceUnavailableException`() { + val result = mockBillingResult(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED) + val mapped = BillingResultException(result).tryMapUserFriendly() + mapped.shouldBeInstanceOf() + } + + @Test + fun `service timeout maps to GplayServiceUnavailableException`() { + val result = mockBillingResult(BillingClient.BillingResponseCode.SERVICE_TIMEOUT) + val mapped = BillingResultException(result).tryMapUserFriendly() + mapped.shouldBeInstanceOf() + } + + @Test + fun `permanent unavailable maps to GplayServiceUnavailableException`() { + val result = mockBillingResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) + val mapped = BillingResultException(result).tryMapUserFriendly() + mapped.shouldBeInstanceOf() + } + + @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 + } +} diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataTest.kt new file mode 100644 index 00000000..c254549e --- /dev/null +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataTest.kt @@ -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) + } +}