diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index bb5b45e8d..9a401d145 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches30.6% \ No newline at end of file +branches30.6% diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 6adbbd285..8790d333a 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage39.3% \ No newline at end of file +coverage42.5% diff --git a/scripts/superwall_timeline_diff_tool.html b/scripts/superwall_timeline_diff_tool.html new file mode 100644 index 000000000..758d8aef3 --- /dev/null +++ b/scripts/superwall_timeline_diff_tool.html @@ -0,0 +1,465 @@ + + + + + + + + + +
+
+

Timeline Diff

+

// paste two superwall event JSON arrays

+
+
+
+
Run A (left)
+ +
+
+
Run B (right)
+ +
+
+
+ +
+ +
+
+

Timeline Diff

+ // + + +
+
+
+
+
+
+
Run A
+
Run B
+
+
+
lifecycle
+
config
+
paywall
+
identity
+
redemption
+
attributes
+
+
+
+
+
+
+
+
+ + + + diff --git a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt index e53d0db21..442377a01 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt @@ -6,86 +6,78 @@ import Then import When import android.app.Application import android.content.Context -import android.util.Log import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.superwall.sdk.Superwall +import com.superwall.sdk.analytics.Tier import com.superwall.sdk.config.models.ConfigState -import com.superwall.sdk.config.models.getConfig import com.superwall.sdk.config.options.SuperwallOptions import com.superwall.sdk.dependencies.DependencyContainer import com.superwall.sdk.misc.Either import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.misc.primitives.SequentialActor import com.superwall.sdk.models.assignment.Assignment import com.superwall.sdk.models.assignment.ConfirmableAssignment import com.superwall.sdk.models.config.Config -import com.superwall.sdk.models.config.RawFeatureFlag import com.superwall.sdk.models.enrichment.Enrichment import com.superwall.sdk.models.entitlements.SubscriptionStatus import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.Trigger import com.superwall.sdk.models.triggers.TriggerRule import com.superwall.sdk.models.triggers.VariantOption -import com.superwall.sdk.network.Network -import com.superwall.sdk.network.NetworkError import com.superwall.sdk.network.NetworkMock import com.superwall.sdk.network.SuperwallAPI import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.paywall.manager.PaywallManager import com.superwall.sdk.storage.CONSTANT_API_KEY -import com.superwall.sdk.storage.LatestConfig -import com.superwall.sdk.storage.LatestEnrichment import com.superwall.sdk.storage.LatestRedemptionResponse -import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.StorageMock import com.superwall.sdk.storage.StoredEntitlementsByProductId import com.superwall.sdk.storage.StoredSubscriptionStatus -import com.superwall.sdk.storage.core_data.convertToJsonElement import com.superwall.sdk.store.Entitlements import com.superwall.sdk.store.StoreManager import com.superwall.sdk.web.WebPaywallRedeemer import io.mockk.Runs -import io.mockk.clearMocks import io.mockk.coEvery -import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk -import io.mockk.spyk -import io.mockk.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.JsonObject -import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds -class ConfigManagerUnderTest( - private val context: Context, - private val storage: Storage, - private val network: SuperwallAPI, - private val paywallManager: PaywallManager, - private val storeManager: StoreManager, - private val factory: Factory, - private val deviceHelper: DeviceHelper, - private val assignments: Assignments, - private val paywallPreload: PaywallPreload, - private val ioScope: CoroutineScope, +// Storage round-trip integration tests. Pure logic lives in src/test/. +open class ConfigManagerUnderTest( + context: Context, + storage: Storage, + network: SuperwallAPI, + paywallManager: PaywallManager, + storeManager: StoreManager, + factory: Factory, + deviceHelper: DeviceHelper, + assignments: Assignments, + paywallPreload: PaywallPreload, + ioScope: CoroutineScope, + private val testOptions: SuperwallOptions = SuperwallOptions(), + testEntitlements: Entitlements = + Entitlements( + mockk(relaxUnitFun = true) { + every { read(StoredSubscriptionStatus) } returns SubscriptionStatus.Unknown + every { read(StoredEntitlementsByProductId) } returns emptyMap() + every { read(LatestRedemptionResponse) } returns null + }, + ), + webRedeemer: WebPaywallRedeemer = mockk(relaxed = true), ) : ConfigManager( context = context, storage = storage, @@ -94,37 +86,31 @@ class ConfigManagerUnderTest( storeManager = storeManager, factory = factory, deviceHelper = deviceHelper, - options = SuperwallOptions(), + options = testOptions, assignments = assignments, paywallPreload = paywallPreload, ioScope = IOScope(ioScope.coroutineContext), - track = {}, - entitlements = - Entitlements( - mockk(relaxUnitFun = true) { - every { read(StoredSubscriptionStatus) } returns SubscriptionStatus.Unknown - every { read(StoredEntitlementsByProductId) } returns emptyMap() - every { read(LatestRedemptionResponse) } returns null - }, - ), - awaitUtilNetwork = {}, - webPaywallRedeemer = { mockk(relaxed = true) }, + tracker = {}, + entitlements = testEntitlements, + webPaywallRedeemer = { webRedeemer }, + actor = SequentialActor(ConfigState.None, IOScope(ioScope.coroutineContext)), ) { - suspend fun setConfig(config: Config) { - configState.emit(ConfigState.Retrieved(config)) + fun setConfig(config: Config) { + applyRetrievedConfigForTesting(config) } } @RunWith(AndroidJUnit4::class) class ConfigManagerTests { - val mockDeviceHelper = + private val mockDeviceHelper = mockk { every { appVersion } returns "1.0" every { locale } returns "en-US" + every { deviceTier } returns Tier.MID + every { bundleId } returns "com.test" + every { setEnrichment(any()) } just Runs coEvery { getTemplateDevice() } returns emptyMap() - coEvery { - getEnrichment(any(), any()) - } returns Either.Success(Enrichment.stub()) + coEvery { getEnrichment(any(), any()) } returns Either.Success(Enrichment.stub()) } @Before @@ -142,9 +128,7 @@ class ConfigManagerTests { fun test_confirmAssignment() = runTest(timeout = 5.minutes) { Given("we have a ConfigManager with a mock assignment") { - // get context val context = InstrumentationRegistry.getInstrumentation().targetContext - val experimentId = "abc" val variantId = "def" val variant = @@ -194,106 +178,11 @@ class ConfigManagerTests { } } - @Test - fun test_loadAssignments_noConfig() = - runTest(timeout = 5.minutes) { - Given("we have a ConfigManager with no config") { - // get context - val context = InstrumentationRegistry.getInstrumentation().targetContext - val dependencyContainer = - DependencyContainer(context, null, null, activityProvider = null, apiKey = "") - val network = NetworkMock() - val storage = StorageMock(context = context, coroutineScope = backgroundScope) - val assignments = Assignments(storage, network, backgroundScope) - val preload = - mockk { - coEvery { preloadAllPaywalls(any(), any()) } just Runs - coEvery { preloadPaywallsByNames(any(), any()) } just Runs - coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs - } - - val configManager = - ConfigManagerUnderTest( - context = context, - storage = storage, - network = network, - paywallManager = dependencyContainer.paywallManager, - storeManager = dependencyContainer.storeManager, - factory = dependencyContainer, - deviceHelper = mockDeviceHelper, - assignments = assignments, - paywallPreload = preload, - ioScope = backgroundScope, - ) - - Log.e("test", "test_loadAssignments_noConfig") - When("we try to get assignments") { - val job = - launch { - configManager.getAssignments() - ensureActive() - assert(false) // Make sure we never get here... - } - delay(1000) - job.cancel() - } - - Log.e("test", "test_loadAssignments_noConfig") - Then("no assignments should be stored") { - assertTrue(storage.getConfirmedAssignments().isEmpty()) - assertTrue(configManager.unconfirmedAssignments.isEmpty()) - } - } - return@runTest - } - - @Test - fun test_loadAssignments_noTriggers() = - runTest(timeout = 5.minutes) { - Given("we have a ConfigManager with a config that has no triggers") { - // get context - val context = InstrumentationRegistry.getInstrumentation().targetContext - - val dependencyContainer = - DependencyContainer(context, null, null, activityProvider = null, apiKey = "") - val network = NetworkMock() - val storage = StorageMock(context = context, coroutineScope = backgroundScope) - val assignments = Assignments(storage, network, backgroundScope) - val configManager = - ConfigManagerUnderTest( - context = context, - storage = storage, - network = network, - paywallManager = dependencyContainer.paywallManager, - storeManager = dependencyContainer.storeManager, - factory = dependencyContainer, - deviceHelper = mockDeviceHelper, - assignments = assignments, - paywallPreload = preload, - ioScope = backgroundScope, - ) - configManager.setConfig( - Config.stub().apply { this.triggers = emptySet() }, - ) - - When("we get assignments") { - configManager.getAssignments() - } - - Then("no assignments should be stored") { - assertTrue(storage.getConfirmedAssignments().isEmpty()) - assertTrue(configManager.unconfirmedAssignments.isEmpty()) - } - } - } - @Test fun test_loadAssignments_saveAssignmentsFromServer() = runTest(timeout = 30.seconds) { Given("we have a ConfigManager with assignments from the server") { - // get context val context = InstrumentationRegistry.getInstrumentation().targetContext - val network = NetworkMock() val storage = StorageMock(context = context, coroutineScope = backgroundScope) val assignmentStore = Assignments(storage, network, backgroundScope) @@ -319,11 +208,8 @@ class ConfigManagerTests { val variantId = "variantId" val experimentId = "experimentId" - val assignments: List = - listOf( - Assignment(experimentId = experimentId, variantId = variantId), - ) + listOf(Assignment(experimentId = experimentId, variantId = variantId)) network.assignments = assignments.toMutableList() val variantOption = VariantOption.stub().apply { id = variantId } @@ -357,547 +243,5 @@ class ConfigManagerTests { assertTrue(configManager.unconfirmedAssignments.isEmpty()) } } - return@runTest } - - @Test - fun should_refresh_config_successfully() = - runTest(timeout = Duration.INFINITE) { - Given("we have a ConfigManager with an old config") { - val mockNetwork = - mockk { - coEvery { getConfig(any()) } returns - Either.Success( - Config.stub(), - ) - coEvery { - getEnrichment( - any(), - any(), - any(), - ) - } returns Either.Success(mockk()) - } - val context = InstrumentationRegistry.getInstrumentation().targetContext - val dependencyContainer = - DependencyContainer(context, null, null, activityProvider = null, apiKey = "") - val storage = StorageMock(context = context, coroutineScope = backgroundScope) - val oldConfig = - Config.stub().copy( - rawFeatureFlags = - listOf( - RawFeatureFlag("enable_config_refresh_v2", true), - ), - ) - - val mockPaywallManager = - mockk { - every { resetPaywallRequestCache() } just Runs - every { currentView } returns null - } - - val mockContainer = - spyk(dependencyContainer) { - every { deviceHelper } returns mockDeviceHelper - every { paywallManager } returns mockPaywallManager - } - val assignments = Assignments(storage, mockNetwork, backgroundScope) - - val testId = "123" - val configManager = - spyk( - ConfigManagerUnderTest( - context, - storage, - mockNetwork, - mockPaywallManager, - dependencyContainer.storeManager, - mockContainer, - mockDeviceHelper, - assignments = assignments, - paywallPreload = preload, - ioScope = backgroundScope, - ), - ) { - every { config } returns oldConfig.copy(requestId = testId) - } - - When("we refresh the configuration") { - Superwall.configure( - context.applicationContext as Application, - "pk_test_1234", - null, - null, - null, - null, - ) - configManager.refreshConfiguration() - } - - Then("the config should be refreshed and the paywall cache reset") { - coVerify { mockNetwork.getConfig(any()) } - verify { mockPaywallManager.resetPaywallRequestCache() } - assertTrue(configManager.config?.requestId === testId) - } - } - } - - @Test - fun should_fail_refreshing_config_and_keep_old_config() = - runTest(timeout = Duration.INFINITE) { - Given("we have a ConfigManager with an old config and a network that fails") { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val mockNetwork = - mockk { - coEvery { getConfig(any()) } returns Either.Failure(NetworkError.Unknown()) - coEvery { getEnrichment(any(), any(), any()) } returns Either.Success(mockk()) - } - val dependencyContainer = - DependencyContainer(context, null, null, activityProvider = null, apiKey = "") - val storage = StorageMock(context = context, coroutineScope = backgroundScope) - val oldConfig = - Config.stub().copy( - rawFeatureFlags = - listOf( - RawFeatureFlag("enable_config_refresh_v2", true), - ), - ) - - val mockPaywallManager = - mockk { - every { resetPaywallRequestCache() } just Runs - every { currentView } returns null - } - - val mockContainer = - spyk(dependencyContainer) { - every { deviceHelper } returns mockDeviceHelper - every { paywallManager } returns mockPaywallManager - } - val assignments = Assignments(storage, mockNetwork, backgroundScope) - - val testId = "123" - val configManager = - spyk( - ConfigManagerUnderTest( - context, - storage, - mockNetwork, - mockPaywallManager, - dependencyContainer.storeManager, - mockContainer, - mockDeviceHelper, - assignments = assignments, - paywallPreload = preload, - ioScope = backgroundScope, - ), - ) { - every { config } returns oldConfig.copy(requestId = testId) - } - - When("we try to refresh the configuration") { - configManager.refreshConfiguration() - - Then("the old config should be kept") { - coVerify { mockNetwork.getConfig(any()) } - assertTrue(configManager.config?.requestId === testId) - } - } - } - } - - private val storage = - mockk { - coEvery { write(any(), any()) } just Runs - coEvery { read(LatestRedemptionResponse) } returns null - coEvery { read(StoredEntitlementsByProductId) } returns emptyMap() - } - private val dependencyContainer = - mockk { - coEvery { makeSessionDeviceAttributes() } returns hashMapOf() - coEvery { provideRuleEvaluator(any()) } returns mockk() - } - - private val manager = - mockk { - every { resetPaywallRequestCache() } just Runs - every { resetCache() } just Runs - } - private val storeKit = - mockk { - coEvery { products(any()) } returns emptySet() - coEvery { loadPurchasedProducts(any()) } just Runs - } - private val preload = - mockk { - coEvery { preloadAllPaywalls(any(), any()) } just Runs - coEvery { preloadPaywallsByNames(any(), any()) } just Runs - coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs - } - - private val localStorage = - mockk { - every { getConfirmedAssignments() } returns emptyMap() - every { saveConfirmedAssignments(any()) } just Runs - coEvery { read(LatestRedemptionResponse) } returns null - coEvery { read(StoredEntitlementsByProductId) } returns emptyMap() - } - private val mockNetwork = mockk() - - @Test - fun test_network_delay_with_cached_version() = - runTest(timeout = 5.minutes) { - Given("we have a cached config and a delayed network response") { - val cachedConfig = - Config.stub().copy( - buildId = "cached", - rawFeatureFlags = listOf(RawFeatureFlag("enable_config_refresh_v2", true)), - ) - val newConfig = Config.stub().copy(buildId = "not") - - coEvery { storage.read(LatestRedemptionResponse) } returns null - coEvery { localStorage.read(LatestRedemptionResponse) } returns null - coEvery { storage.read(LatestConfig) } returns cachedConfig - coEvery { storage.write(any(), any()) } just Runs - coEvery { storage.read(LatestEnrichment) } returns Enrichment.stub() - coEvery { mockNetwork.getConfig(any()) } coAnswers { - delay(1200) - Either.Success(newConfig) - } - coEvery { mockNetwork.getEnrichment(any(), any(), any()) } coAnswers { - delay(1200) - Either.Success(Enrichment.stub()) - } - - coEvery { - mockDeviceHelper.getEnrichment(any(), any()) - } coAnswers { - delay(1200) - Either.Success(Enrichment.stub()) - } - - val context = InstrumentationRegistry.getInstrumentation().targetContext - - val dependencyContainer = - DependencyContainer(context, null, null, activityProvider = null, apiKey = "") - val mockContainer = - spyk(dependencyContainer) { - every { deviceHelper } returns mockDeviceHelper - every { paywallManager } returns manager - } - - val assignmentStore = Assignments(localStorage, mockNetwork, backgroundScope) - val configManager = - ConfigManagerUnderTest( - context = context, - storage = storage, - network = mockNetwork, - paywallManager = mockContainer.paywallManager, - storeManager = mockContainer.storeManager, - factory = mockContainer, - deviceHelper = mockDeviceHelper, - assignments = assignmentStore, - paywallPreload = preload, - ioScope = backgroundScope, - ) - - When("we fetch the configuration") { - configManager.fetchConfiguration() - - Then("the cached config should be used initially") { - coVerify(exactly = 1) { storage.read(LatestConfig) } - configManager.configState.first { it is ConfigState.Retrieved } - assertEquals("cached", configManager.config?.buildId) - advanceUntilIdle() - } - } - } - } - - @Test - fun test_network_delay_without_cached_version() = - runTest(timeout = 5.minutes) { - Given("we have no cached config and a delayed network response") { - coEvery { storage.read(LatestRedemptionResponse) } returns null - coEvery { storage.read(LatestConfig) } returns null - coEvery { localStorage.read(LatestRedemptionResponse) } returns null - coEvery { localStorage.read(LatestEnrichment) } returns null - coEvery { storage.read(LatestEnrichment) } returns null - coEvery { - mockDeviceHelper.getEnrichment(any(), any()) - } returns Either.Failure(NetworkError.Unknown()) - coEvery { - mockNetwork.getEnrichment(any(), any(), any()) - } returns Either.Failure(NetworkError.Unknown()) - coEvery { mockNetwork.getConfig(any()) } coAnswers { - delay(1200) - Either.Success(Config.stub().copy(buildId = "not")) - } - coEvery { mockDeviceHelper.getTemplateDevice() } returns emptyMap() - - val context = InstrumentationRegistry.getInstrumentation().targetContext - - val assignmentStore = Assignments(localStorage, mockNetwork, backgroundScope) - val configManager = - ConfigManagerUnderTest( - context = context, - storage = storage, - network = mockNetwork, - paywallManager = manager, - storeManager = storeKit, - factory = dependencyContainer, - deviceHelper = mockDeviceHelper, - assignments = assignmentStore, - paywallPreload = preload, - ioScope = backgroundScope, - ) - - When("we fetch the configuration") { - configManager.fetchConfiguration() - - And("we wait for it to be retrieved") { - configManager.configState.first { it is ConfigState.Retrieved } - - Then("the new config should be fetched exactly once and used") { - coVerify(exactly = 1) { mockNetwork.getConfig(any()) } - assertEquals("not", configManager.config?.buildId) - } - } - } - } - } - - @Test - fun test_network_failure_with_cached_version() = - runTest(timeout = 5.minutes) { - Given("we have a cached config and a network failure") { - val cachedConfig = - Config.stub().copy( - buildId = "cached", - rawFeatureFlags = - listOf( - RawFeatureFlag("enable_config_refresh_v2", true), - ), - ) - coEvery { storage.read(LatestRedemptionResponse) } returns null - coEvery { localStorage.read(LatestRedemptionResponse) } returns null - coEvery { storage.read(LatestConfig) } returns cachedConfig - coEvery { mockNetwork.getConfig(any()) } returns Either.Failure(NetworkError.Unknown()) - coEvery { localStorage.read(LatestEnrichment) } returns null - coEvery { storage.read(LatestEnrichment) } returns null - coEvery { - mockNetwork.getEnrichment(any(), any(), any()) - } returns Either.Failure(NetworkError.Unknown()) - - coEvery { - mockDeviceHelper.getEnrichment(any(), any()) - } returns Either.Failure(NetworkError.Unknown()) - - coEvery { mockDeviceHelper.getTemplateDevice() } returns emptyMap() - - val context = InstrumentationRegistry.getInstrumentation().targetContext - - val assignmentStore = Assignments(localStorage, mockNetwork, backgroundScope) - val configManager = - ConfigManagerUnderTest( - context = context, - storage = storage, - network = mockNetwork, - paywallManager = manager, - storeManager = storeKit, - factory = dependencyContainer, - deviceHelper = mockDeviceHelper, - assignments = assignmentStore, - paywallPreload = preload, - ioScope = backgroundScope, - ) - - When("we fetch the configuration") { - configManager.fetchConfiguration() - - Then("the cached config should be used") { - configManager.configState.first { it is ConfigState.Retrieved } - coEvery { mockNetwork.getConfig(any()) } returns - Either.Success( - Config.stub().copy(buildId = "not"), - ) - assertEquals("cached", configManager.config?.buildId) - - And("the network becomes available and we fetch again") { - coEvery { mockNetwork.getConfig(any()) } returns - Either.Success( - Config.stub().copy(buildId = "not"), - ) - - Then("the new config should be set and used") { - configManager.configState - .onEach { - println("$it is ${it::class}") - }.drop(1) - .first { it is ConfigState.Retrieved } - assertEquals("not", configManager.config?.buildId) - } - } - } - } - } - } - - @Test - fun test_quick_network_success() = - runTest { - Given("we have a quick network response") { - val newConfig = Config.stub().copy(buildId = "not") - coEvery { storage.read(LatestRedemptionResponse) } returns null - coEvery { localStorage.read(LatestRedemptionResponse) } returns null - coEvery { storage.read(LatestConfig) } returns null - coEvery { mockNetwork.getConfig(any()) } coAnswers { - delay(200) - Either.Success(newConfig) - } - - coEvery { localStorage.read(LatestEnrichment) } returns null - coEvery { storage.read(LatestEnrichment) } returns null - coEvery { - mockNetwork.getEnrichment(any(), any(), any()) - } returns Either.Success(Enrichment.stub()) - - coEvery { - mockDeviceHelper.getEnrichment(any(), any()) - } returns Either.Success(Enrichment.stub()) - - val context = InstrumentationRegistry.getInstrumentation().targetContext - - val assignmentStore = Assignments(localStorage, mockNetwork, backgroundScope) - val preload = - mockk { - coEvery { preloadAllPaywalls(any(), any()) } just Runs - coEvery { preloadPaywallsByNames(any(), any()) } just Runs - coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs - } - - val configManager = - ConfigManagerUnderTest( - context = context, - storage = storage, - network = mockNetwork, - paywallManager = manager, - storeManager = storeKit, - factory = dependencyContainer, - deviceHelper = mockDeviceHelper, - assignments = assignmentStore, - paywallPreload = preload, - ioScope = backgroundScope, - ) - - When("we fetch the configuration") { - configManager.fetchConfiguration() - - Then("the new config should be used immediately") { - assertEquals("not", configManager.config?.buildId) - } - } - return@runTest - } - } - - @Test - fun test_config_and_geo_calls_both_cached() = - runTest(timeout = 500.seconds) { - Given("we have cached config and geo info, and delayed network responses") { - val cachedConfig = - Config.stub().copy( - buildId = "cached", - rawFeatureFlags = listOf(RawFeatureFlag("enable_config_refresh_v2", true)), - ) - val newConfig = Config.stub().copy(buildId = "not") - val cachedGeo = Enrichment.stub().copy() - val newGeo = Enrichment.stub().copy(_device = JsonObject(mapOf("demandTier" to "gold".convertToJsonElement()))) - - coEvery { preload.preloadAllPaywalls(any(), any()) } just Runs - coEvery { storage.read(LatestRedemptionResponse) } returns null - coEvery { localStorage.read(LatestRedemptionResponse) } returns null - coEvery { storage.read(LatestConfig) } returns cachedConfig - coEvery { storage.read(LatestEnrichment) } returns cachedGeo - coEvery { storage.write(any(), any()) } just Runs - coEvery { localStorage.read(LatestEnrichment) } returns cachedGeo - every { manager.resetPaywallRequestCache() } just Runs - coEvery { preload.removeUnusedPaywallVCsFromCache(any(), any()) } just Runs - var callCount = 0 - coEvery { mockNetwork.getConfig(any()) } coAnswers { - if (callCount == 0) { - callCount += 1 - delay(5000) - } - Either.Success(newConfig) - } - var enrichmentCallCount = 0 - every { mockDeviceHelper.setEnrichment(any()) } just Runs - coEvery { mockDeviceHelper.getEnrichment(any(), any()) } coAnswers { - enrichmentCallCount += 1 - if (enrichmentCallCount == 1) { - delay(5000) - Either.Failure(NetworkError.Timeout) - } else { - delay(100) - Either.Success(newGeo) - } - } - coEvery { mockDeviceHelper.getTemplateDevice() } returns emptyMap() - - val context = InstrumentationRegistry.getInstrumentation().targetContext - - val dependencyContainer = - DependencyContainer(context, null, null, activityProvider = null, apiKey = "") - - val mockContainer = - spyk(dependencyContainer) { - every { deviceHelper } returns mockDeviceHelper - every { paywallManager } returns manager - } - - val assignmentStore = Assignments(localStorage, mockNetwork, backgroundScope) - val configManager = - ConfigManagerUnderTest( - context = context, - storage = storage, - network = mockNetwork, - paywallManager = mockContainer.paywallManager, - storeManager = mockContainer.storeManager, - factory = mockContainer, - deviceHelper = mockDeviceHelper, - assignments = assignmentStore, - paywallPreload = preload, - ioScope = backgroundScope, - ) - - When("we fetch the configuration") { - configManager.fetchConfiguration() - - Then("the cached config and geo info should be used initially") { - configManager.configState.first { it is ConfigState.Retrieved }.also { - assertEquals("cached", it.getConfig()?.buildId) - } - coEvery { mockNetwork.getConfig(any()) } coAnswers { - delay(100) - Either.Success(newConfig) - } - - And("we wait until new config is available") { - configManager.configState.drop(1).first { it is ConfigState.Retrieved } - - Then("the new config and geo info should be fetched and used") { - assertEquals("not", configManager.config?.buildId) - advanceUntilIdle() - } - } - } - } - } - } - - @After - fun tearDown() { - clearMocks(dependencyContainer, manager, storage, preload, localStorage, mockNetwork) - } } diff --git a/superwall/src/main/java/com/superwall/sdk/SdkContext.kt b/superwall/src/main/java/com/superwall/sdk/SdkContext.kt index 5e52d3785..e40283266 100644 --- a/superwall/src/main/java/com/superwall/sdk/SdkContext.kt +++ b/superwall/src/main/java/com/superwall/sdk/SdkContext.kt @@ -10,7 +10,7 @@ import com.superwall.sdk.models.config.Config * Keeps the identity slice decoupled from concrete manager types. */ interface SdkContext { - fun reevaluateTestMode(appUserId: String?, aliasId: String?) + suspend fun reevaluateTestMode(appUserId: String?, aliasId: String?) suspend fun fetchAssignments() @@ -20,7 +20,7 @@ interface SdkContext { class SdkContextImpl( private val configManager: () -> ConfigManager, ) : SdkContext { - override fun reevaluateTestMode(appUserId: String?, aliasId: String?) { + override suspend fun reevaluateTestMode(appUserId: String?, aliasId: String?) { configManager().reevaluateTestMode(appUserId = appUserId, aliasId = aliasId) } diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 7ca44896f..251f1a9c5 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -295,7 +295,7 @@ class Superwall( * @param subscriptionStatus The entitlement status of the user. */ fun setSubscriptionStatus(subscriptionStatus: SubscriptionStatus) { - if (dependencyContainer.testModeManager.isTestMode) { + if (dependencyContainer.testMode.isTestMode) { Logger.debug( LogLevel.warn, LogScope.superwallCore, @@ -317,7 +317,7 @@ class Superwall( * @param entitlements A list of entitlements. * */ fun setSubscriptionStatus(vararg entitlements: String) { - if (dependencyContainer.testModeManager.isTestMode) { + if (dependencyContainer.testMode.isTestMode) { Logger.debug( LogLevel.warn, LogScope.superwallCore, @@ -345,7 +345,7 @@ class Superwall( if (dependencyContainer.makeHasExternalPurchaseController()) { return } - if (dependencyContainer.testModeManager.isTestMode) { + if (dependencyContainer.testMode.isTestMode) { return } val webEntitlements = dependencyContainer.entitlements.web @@ -467,7 +467,7 @@ class Superwall( val configurationStateListener: Flow get() = - dependencyContainer.configManager.configState.asSharedFlow().map { + dependencyContainer.configManager.configState.map { when (it) { is ConfigState.Retrieved -> ConfigurationStatus.Configured is ConfigState.Failed -> ConfigurationStatus.Failed diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigContext.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigContext.kt new file mode 100644 index 000000000..368d8a100 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigContext.kt @@ -0,0 +1,39 @@ +package com.superwall.sdk.config + +import android.content.Context +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.config.models.ConfigState +import com.superwall.sdk.config.options.SuperwallOptions +import com.superwall.sdk.identity.IdentityManager +import com.superwall.sdk.misc.primitives.BaseContext +import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.entitlements.SubscriptionStatus +import com.superwall.sdk.models.triggers.Trigger +import com.superwall.sdk.network.SuperwallAPI +import com.superwall.sdk.network.device.DeviceHelper +import com.superwall.sdk.paywall.manager.PaywallManager +import com.superwall.sdk.store.Entitlements +import com.superwall.sdk.store.StoreManager +import com.superwall.sdk.store.testmode.TestMode +import com.superwall.sdk.web.WebPaywallRedeemer + +interface ConfigContext : BaseContext { + val context: Context + val storeManager: StoreManager + val entitlements: Entitlements + val network: SuperwallAPI + val deviceHelper: DeviceHelper + val options: SuperwallOptions + val paywallManager: PaywallManager + val webPaywallRedeemer: () -> WebPaywallRedeemer + val factory: ConfigManager.Factory + val assignments: Assignments + val paywallPreload: PaywallPreload + val testMode: TestMode? + val identityManager: (() -> IdentityManager)? + val setSubscriptionStatus: ((SubscriptionStatus) -> Unit)? + val awaitUtilNetwork: suspend () -> Unit + val activateTestMode: suspend (config: Config, justActivated: Boolean) -> Unit + + fun setTriggers(triggers: Map) +} diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt index 6746fce0e..a454bd30d 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt @@ -2,11 +2,10 @@ package com.superwall.sdk.config import android.content.Context import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent -import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent.TestModeModal.* +import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent import com.superwall.sdk.config.models.ConfigState import com.superwall.sdk.config.models.getConfig import com.superwall.sdk.config.options.SuperwallOptions -import com.superwall.sdk.config.options.computedShouldPreload import com.superwall.sdk.dependencies.DeviceHelperFactory import com.superwall.sdk.dependencies.DeviceInfoFactory import com.superwall.sdk.dependencies.HasExternalPurchaseControllerFactory @@ -14,79 +13,54 @@ import com.superwall.sdk.dependencies.RequestFactory import com.superwall.sdk.dependencies.RuleAttributesFactory import com.superwall.sdk.dependencies.StoreTransactionFactory import com.superwall.sdk.identity.IdentityManager -import com.superwall.sdk.logger.LogLevel -import com.superwall.sdk.logger.LogScope -import com.superwall.sdk.logger.Logger -import com.superwall.sdk.misc.ActivityProvider -import com.superwall.sdk.misc.CurrentActivityTracker -import com.superwall.sdk.misc.Either import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.awaitFirstValidConfig -import com.superwall.sdk.misc.fold -import com.superwall.sdk.misc.into -import com.superwall.sdk.misc.onError -import com.superwall.sdk.misc.then +import com.superwall.sdk.misc.primitives.StateActor import com.superwall.sdk.models.config.Config -import com.superwall.sdk.models.enrichment.Enrichment import com.superwall.sdk.models.entitlements.SubscriptionStatus import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.ExperimentID import com.superwall.sdk.models.triggers.Trigger -import com.superwall.sdk.network.Network import com.superwall.sdk.network.SuperwallAPI import com.superwall.sdk.network.awaitUntilNetworkExists import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.paywall.manager.PaywallManager -import com.superwall.sdk.storage.DisableVerboseEvents -import com.superwall.sdk.storage.LatestConfig -import com.superwall.sdk.storage.LatestEnrichment import com.superwall.sdk.storage.Storage import com.superwall.sdk.store.Entitlements import com.superwall.sdk.store.StoreManager -import com.superwall.sdk.store.abstractions.product.StoreProduct -import com.superwall.sdk.store.testmode.TestModeManager -import com.superwall.sdk.store.testmode.TestStoreProduct -import com.superwall.sdk.store.testmode.models.SuperwallProductPlatform -import com.superwall.sdk.store.testmode.ui.TestModeModal +import com.superwall.sdk.store.testmode.TestMode import com.superwall.sdk.web.WebPaywallRedeemer -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import java.util.concurrent.atomic.AtomicInteger -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds open class ConfigManager( - private val context: Context, - private val storeManager: StoreManager, - private val entitlements: Entitlements, - private val storage: Storage, - private val network: SuperwallAPI, - private val fullNetwork: Network? = null, - private val deviceHelper: DeviceHelper, - var options: SuperwallOptions, - private val paywallManager: PaywallManager, - private val webPaywallRedeemer: () -> WebPaywallRedeemer, - private val factory: Factory, - private val assignments: Assignments, - private val paywallPreload: PaywallPreload, + override val context: Context, + override val storeManager: StoreManager, + override val entitlements: Entitlements, + override val storage: Storage, + override val network: SuperwallAPI, + override val deviceHelper: DeviceHelper, + override var options: SuperwallOptions, + override val paywallManager: PaywallManager, + override val webPaywallRedeemer: () -> WebPaywallRedeemer, + override val factory: Factory, + override val assignments: Assignments, + override val paywallPreload: PaywallPreload, private val ioScope: IOScope, - private val track: suspend (InternalSuperwallEvent) -> Unit, - private val testModeManager: TestModeManager? = null, - private val identityManager: (() -> IdentityManager)? = null, - private val activityProvider: ActivityProvider? = null, - private val activityTracker: CurrentActivityTracker? = null, - private val setSubscriptionStatus: ((SubscriptionStatus) -> Unit)? = null, - private val awaitUtilNetwork: suspend () -> Unit = { + override val tracker: suspend (TrackableSuperwallEvent) -> Unit, + override val testMode: TestMode? = null, + override val identityManager: (() -> IdentityManager)? = null, + override val setSubscriptionStatus: ((SubscriptionStatus) -> Unit)? = null, + override val awaitUtilNetwork: suspend () -> Unit = { context.awaitUntilNetworkExists() }, -) { + override val activateTestMode: suspend (Config, Boolean) -> Unit = { _, _ -> }, + override val actor: StateActor, +) : ConfigContext { interface Factory : RequestFactory, DeviceInfoFactory, @@ -95,28 +69,24 @@ open class ConfigManager( StoreTransactionFactory, HasExternalPurchaseControllerFactory - // The configuration of the Superwall dashboard - internal val configState = MutableStateFlow(ConfigState.None) + override val scope: CoroutineScope get() = ioScope + + internal val configState: StateFlow get() = actor.state - // Convenience variable to access config val config: Config? - get() = - configState.value - .also { - if (it is ConfigState.Failed) { - ioScope.launch { - fetchConfiguration() - } - } - }.getConfig() + get() { + val current = actor.state.value + if (current is ConfigState.Failed) { + effect(ConfigState.Actions.FetchConfig) + } + return current.getConfig() + } - // A flow that emits just once only when `config` is non-`nil`. val hasConfig: Flow = - configState + actor.state .mapNotNull { it.getConfig() } .take(1) - // A dictionary of triggers by their event name. private var _triggersByEventName = mutableMapOf() var triggersByEventName: Map get() = _triggersByEventName @@ -124,490 +94,69 @@ open class ConfigManager( _triggersByEventName = value.toMutableMap() } - // A memory store of assignments that are yet to be confirmed. + override fun setTriggers(triggers: Map) { + triggersByEventName = triggers + } val unconfirmedAssignments: Map get() = assignments.unconfirmedAssignments suspend fun fetchConfiguration() { - if (configState.value != ConfigState.Retrieving) { - fetchConfig() - } - } - - private suspend fun fetchConfig() { - configState.update { ConfigState.Retrieving } - val oldConfig = storage.read(LatestConfig) - val status = entitlements.status.value - val CACHE_LIMIT = if (status is SubscriptionStatus.Active) 500.milliseconds else 1.seconds - var isConfigFromCache = false - var isEnrichmentFromCache = false - - // If config is cached, get config from the network but timeout after 300ms - // and default to the cached version. Then, refresh in the background. - val configRetryCount: AtomicInteger = AtomicInteger(0) - var configDuration = 0L - val configDeferred = - ioScope.async { - val start = System.currentTimeMillis() - ( - if (oldConfig?.featureFlags?.enableConfigRefresh == true) { - try { - // If config refresh is enabled, try loading with a timeout - withTimeout(CACHE_LIMIT) { - network - .getConfig { - // Emit retrying state - configState.update { ConfigState.Retrying } - configRetryCount.incrementAndGet() - awaitUtilNetwork() - }.into { - if (it is Either.Failure) { - isConfigFromCache = true - Either.Success(oldConfig) - } else { - it - } - } - } - } catch (e: Throwable) { - e.printStackTrace() - // If fetching config fails, default to the cached version - // Note: Only a timeout exception is possible here - oldConfig?.let { - isConfigFromCache = true - Either.Success(it) - } ?: Either.Failure(e) - } - } else { - // If config refresh is disabled or there is no cache - // just fetch with a normal retry - network - .getConfig { - configState.update { ConfigState.Retrying } - configRetryCount.incrementAndGet() - context.awaitUntilNetworkExists() - } - } - ).also { - configDuration = System.currentTimeMillis() - start - } - } - - val enrichmentDeferred = - ioScope.async { - val cached = storage.read(LatestEnrichment) - if (oldConfig?.featureFlags?.enableConfigRefresh == true) { - // If we have a cached config and refresh was enabled, try loading with - // a timeout or load from cache - val res = - deviceHelper - .getEnrichment(0, CACHE_LIMIT) - .then { - storage.write(LatestEnrichment, it) - } - if (res.getSuccess() == null) { - // Loading timed out, we default to cached version - cached?.let { - deviceHelper.setEnrichment(cached) - isEnrichmentFromCache = true - Either.Success(it) - } ?: res - } else { - res - } - } else { - // If there's no cached enrichment and config refresh is disabled, - // try to fetch with 1 sec timeout or fail. - deviceHelper.getEnrichment(0, 1.seconds) - } - } - - val attributesDeferred = ioScope.async { factory.makeSessionDeviceAttributes() } - - // Await results from both operations - val (result, enriched) = - listOf( - configDeferred, - enrichmentDeferred, - ).awaitAll() - val attributes = attributesDeferred.await() - ioScope.launch { - @Suppress("UNCHECKED_CAST") - track(InternalSuperwallEvent.DeviceAttributes(attributes as HashMap)) - } - val configResult = result as Either - val enrichmentResult = enriched as Either - configResult - .then { - ioScope.launch { - track( - InternalSuperwallEvent.ConfigRefresh( - isCached = isConfigFromCache, - buildId = it.buildId, - fetchDuration = configDuration, - retryCount = configRetryCount.get(), - ), - ) - } - }.then(::processConfig) - .then { - if (testModeManager?.isTestMode != true) { - ioScope.launch { - checkForWebEntitlements() - } - } - }.then { - if (testModeManager?.isTestMode != true && options.computedShouldPreload(deviceHelper.deviceTier)) { - val productIds = it.paywalls.flatMap { it.productIds }.toSet() - try { - storeManager.products(productIds) - } catch (e: Throwable) { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.productsManager, - message = "Failed to preload products", - error = e, - ) - } - } - }.then { - configState.update { _ -> ConfigState.Retrieved(it) } - }.then { - if (isConfigFromCache) { - ioScope.launch { refreshConfiguration() } - } - if (isEnrichmentFromCache || enrichmentResult.getThrowable() != null) { - ioScope.launch { deviceHelper.getEnrichment(6, 1.seconds) } - } - }.fold( - onSuccess = - { - ioScope.launch { preloadPaywalls() } - }, - onFailure = - { e -> - e.printStackTrace() - configState.update { ConfigState.Failed(e) } - if (!isConfigFromCache) { - refreshConfiguration() - } - track(InternalSuperwallEvent.ConfigFail(e.message ?: "Unknown error")) - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.superwallCore, - message = "Failed to Fetch Configuration", - error = e, - ) - }, - ) + val current = actor.state.value + if (current is ConfigState.Retrieving || current is ConfigState.Retrying) return + immediate(ConfigState.Actions.FetchConfig) } + // Sync on caller for the mutation; preload follow-up goes through the actor. fun reset() { - val config = configState.value.getConfig() ?: return + val config = actor.state.value.getConfig() ?: return assignments.reset() assignments.choosePaywallVariants(config.triggers) - - ioScope.launch { preloadPaywalls() } + effect(ConfigState.Actions.PreloadIfEnabled) } - /** - * Re-evaluates test mode with the current identity and config. - * If test mode was active but the current user no longer qualifies, clears test mode - * and resets subscription status. If a new user qualifies, activates test mode and - * shows the modal. - */ - fun reevaluateTestMode( - config: Config? = configState.value.getConfig(), + suspend fun reevaluateTestMode( + config: Config? = null, appUserId: String? = null, aliasId: String? = null, ) { - config ?: return - val wasTestMode = testModeManager?.isTestMode == true - testModeManager?.evaluateTestMode( - config = config, - bundleId = deviceHelper.bundleId, - appUserId = appUserId ?: identityManager?.invoke()?.appUserId, - aliasId = aliasId ?: identityManager?.invoke()?.aliasId, - testModeBehavior = options.testModeBehavior, + // Resolved in body, not as default param — actor reads in defaults trip MockK. + val resolvedConfig = config ?: actor.state.value.getConfig() ?: return + if (testMode == null) return + immediate( + ConfigState.Actions.ReevaluateTestMode( + config = resolvedConfig, + appUserId = appUserId, + aliasId = aliasId, + ), ) - val isNowTestMode = testModeManager?.isTestMode == true - if (wasTestMode && !isNowTestMode) { - testModeManager?.clearTestModeState() - setSubscriptionStatus?.invoke(SubscriptionStatus.Inactive) - } else if (!wasTestMode && isNowTestMode) { - ioScope.launch { - fetchTestModeProducts() - presentTestModeModal(config) - } - } } suspend fun getAssignments() { - val config = configState.awaitFirstValidConfig() ?: return - - config.triggers.takeUnless { it.isEmpty() }?.let { triggers -> - try { - assignments - .getAssignments(triggers) - .then { - ioScope.launch { preloadPaywalls() } - }.onError { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.configManager, - message = "Error retrieving assignments.", - error = it, - ) - } - } catch (e: Throwable) { - e.printStackTrace() - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.configManager, - message = "Error retrieving assignments.", - error = e, - ) - } - } + actor.state.awaitFirstValidConfig() + immediate(ConfigState.Actions.GetAssignments) } - private fun processConfig(config: Config) { - storage.write(DisableVerboseEvents, config.featureFlags.disableVerboseEvents) - if (config.featureFlags.enableConfigRefresh) { - storage.write(LatestConfig, config) - } - triggersByEventName = ConfigLogic.getTriggersByEventName(config.triggers) - assignments.choosePaywallVariants(config.triggers) - // Extract entitlements from both products (ProductItem) and productsV3 (CrossplatformProduct) - ConfigLogic.extractEntitlementsByProductId(config.products).let { - entitlements.addEntitlementsByProductId(it) - } - config.productsV3?.let { productsV3 -> - ConfigLogic.extractEntitlementsByProductIdFromCrossplatform(productsV3).let { - entitlements.addEntitlementsByProductId(it) - } - } - - // Test mode evaluation - val wasTestMode = testModeManager?.isTestMode == true - testModeManager?.evaluateTestMode( - config = config, - bundleId = deviceHelper.bundleId, - appUserId = identityManager?.invoke()?.appUserId, - aliasId = identityManager?.invoke()?.aliasId, - testModeBehavior = options.testModeBehavior, - ) - val testModeJustActivated = !wasTestMode && testModeManager?.isTestMode == true - - if (testModeManager?.isTestMode == true) { - // Set a default subscription status immediately so the paywall pipeline - // doesn't timeout waiting for it while the test mode modal is shown. - if (testModeJustActivated) { - val defaultStatus = testModeManager.buildSubscriptionStatus() - testModeManager.setOverriddenSubscriptionStatus(defaultStatus) - entitlements.setSubscriptionStatus(defaultStatus) - } - ioScope.launch { - fetchTestModeProducts() - if (testModeJustActivated) { - presentTestModeModal(config) - } - } - } else { - if (wasTestMode) { - testModeManager?.clearTestModeState() - setSubscriptionStatus?.invoke(SubscriptionStatus.Inactive) - } - ioScope.launch { - storeManager.loadPurchasedProducts(entitlements.entitlementsByProductId) - } - } + suspend fun preloadAllPaywalls() { + actor.state.awaitFirstValidConfig() + immediate(ConfigState.Actions.PreloadAll) } -// Preloading Paywalls - - // Preloads paywalls. - private suspend fun preloadPaywalls() { - if (!options.computedShouldPreload(deviceHelper.deviceTier)) return - preloadAllPaywalls() + suspend fun preloadPaywallsByNames(eventNames: Set) { + actor.state.awaitFirstValidConfig() + immediate(ConfigState.Actions.PreloadByNames(eventNames)) } - // Preloads paywalls referenced by triggers. - suspend fun preloadAllPaywalls() = - paywallPreload.preloadAllPaywalls( - configState.awaitFirstValidConfig(), - context, - ) - - // Preloads paywalls referenced by the provided triggers. - suspend fun preloadPaywallsByNames(eventNames: Set) = - paywallPreload.preloadPaywallsByNames( - configState.awaitFirstValidConfig(), - eventNames, - ) - - private suspend fun Either.handleConfigUpdate( - fetchDuration: Long, - retryCount: Int, - ) = then { - paywallManager.resetPaywallRequestCache() - val oldConfig = config - if (oldConfig != null) { - paywallPreload.removeUnusedPaywallVCsFromCache(oldConfig, it) - } - }.then { config -> - processConfig(config) - configState.update { ConfigState.Retrieved(config) } - track( - InternalSuperwallEvent.ConfigRefresh( - isCached = false, - buildId = config.buildId, - fetchDuration = fetchDuration, - retryCount = retryCount, - ), - ) - }.fold( - onSuccess = { newConfig -> - ioScope.launch { preloadPaywalls() } - }, - onFailure = { - Logger.debug( - logLevel = LogLevel.warn, - scope = LogScope.superwallCore, - message = "Failed to refresh configuration.", - info = null, - error = it, - ) - }, - ) - internal suspend fun refreshConfiguration(force: Boolean = false) { - // Make sure config already exists - val oldConfig = config ?: return - - // Ensure the config refresh feature flag is enabled - if (!force && !oldConfig.featureFlags.enableConfigRefresh) { - return - } - - ioScope.launch { - deviceHelper.getEnrichment(0, 1.seconds) - } - - val retryCount: AtomicInteger = AtomicInteger(0) - val startTime = System.currentTimeMillis() - network - .getConfig { - retryCount.incrementAndGet() - context.awaitUntilNetworkExists() - }.handleConfigUpdate( - retryCount = retryCount.get(), - fetchDuration = System.currentTimeMillis() - startTime, - ) + if (actor.state.value.getConfig() == null) return + immediate(ConfigState.Actions.RefreshConfig(force = force)) } - suspend fun checkForWebEntitlements() { - ioScope.launch { - webPaywallRedeemer().redeem(WebPaywallRedeemer.RedeemType.Existing) - } + internal fun applyRetrievedConfigForTesting(config: Config) { + actor.update(ConfigState.Updates.SetRetrieved(config)) } - private suspend fun fetchTestModeProducts() { - val net = fullNetwork ?: return - val manager = testModeManager ?: return - - net.getSuperwallProducts().fold( - onSuccess = { response -> - val androidProducts = - response.data.filter { it.platform == SuperwallProductPlatform.ANDROID && it.price != null } - manager.setProducts(androidProducts) - - val productsByFullId = - androidProducts.associate { superwallProduct -> - val testProduct = TestStoreProduct(superwallProduct) - superwallProduct.identifier to StoreProduct(testProduct) - } - manager.setTestProducts(productsByFullId) - - Logger.debug( - LogLevel.info, - LogScope.superwallCore, - "Test mode: loaded ${androidProducts.size} products", - ) - }, - onFailure = { error -> - Logger.debug( - LogLevel.error, - LogScope.superwallCore, - "Test mode: failed to fetch products - ${error.message}", - ) - }, - ) - } - - private suspend fun presentTestModeModal(config: Config) { - val manager = testModeManager ?: return - // Prefer the lifecycle-tracked activity (sees the actual foreground activity, - // e.g. SuperwallPaywallActivity) over the user-provided ActivityProvider - // (which in Expo/RN may always return the root MainActivity). - val activity = - activityTracker?.getCurrentActivity() - ?: activityProvider?.getCurrentActivity() - ?: activityTracker?.awaitActivity(10.seconds) - if (activity == null) { - Logger.debug( - LogLevel.warn, - LogScope.superwallCore, - "Test mode modal could not be presented: no activity available. Setting default subscription status.", - ) - with(manager) { - val status = buildSubscriptionStatus() - setOverriddenSubscriptionStatus(status) - entitlements.setSubscriptionStatus(status) - } - return - } - - track(InternalSuperwallEvent.TestModeModal(State.Open)) - - val reason = manager.testModeReason?.description ?: "Test mode activated" - val allEntitlements = - config.productsV3 - ?.flatMap { it.entitlements.map { e -> e.id } } - ?.distinct() - ?.sorted() - ?: emptyList() - - val dashboardBaseUrl = - when (options.networkEnvironment) { - is SuperwallOptions.NetworkEnvironment.Developer -> "https://superwall.dev" - else -> "https://superwall.com" - } - - val apiKey = deviceHelper.storage.apiKey - val savedSettings = manager.loadSettings() - - val result = - TestModeModal.show( - activity = activity, - reason = reason, - hasPurchaseController = factory.makeHasExternalPurchaseController(), - availableEntitlements = allEntitlements, - apiKey = apiKey, - dashboardBaseUrl = dashboardBaseUrl, - savedSettings = savedSettings, - ) - - with(manager) { - setFreeTrialOverride(result.freeTrialOverride) - setEntitlements(result.entitlements) - saveSettings() - val status = buildSubscriptionStatus() - setOverriddenSubscriptionStatus(status) - entitlements.setSubscriptionStatus(status) - } - - track(InternalSuperwallEvent.TestModeModal(State.Close)) + internal fun setConfigStateForTesting(state: ConfigState) { + actor.update(ConfigState.Updates.Set(state)) } } diff --git a/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt b/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt index 4affe9578..a552819d0 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt @@ -1,8 +1,37 @@ package com.superwall.sdk.config.models +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.config.ConfigContext +import com.superwall.sdk.config.ConfigLogic +import com.superwall.sdk.config.options.computedShouldPreload +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger +import com.superwall.sdk.misc.Either +import com.superwall.sdk.misc.awaitFirstValidConfig +import com.superwall.sdk.misc.fold +import com.superwall.sdk.misc.into +import com.superwall.sdk.misc.onError +import com.superwall.sdk.misc.primitives.Reducer +import com.superwall.sdk.misc.primitives.TypedAction +import com.superwall.sdk.misc.then +import com.superwall.sdk.misc.thenIf import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.enrichment.Enrichment +import com.superwall.sdk.models.entitlements.SubscriptionStatus +import com.superwall.sdk.storage.DisableVerboseEvents +import com.superwall.sdk.storage.LatestConfig +import com.superwall.sdk.storage.LatestEnrichment +import com.superwall.sdk.web.WebPaywallRedeemer +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds -internal sealed class ConfigState { +sealed class ConfigState { object None : ConfigState() object Retrieving : ConfigState() @@ -15,7 +44,360 @@ internal sealed class ConfigState { data class Failed( val throwable: Throwable, + val retryCount: Int = 0, ) : ConfigState() + + internal sealed class Updates( + override val reduce: (ConfigState) -> ConfigState, + ) : Reducer { + object SetRetrieving : Updates({ Retrieving }) + + object SetRetrying : Updates({ Retrying }) + + data class SetRetrieved(val config: Config) : Updates({ Retrieved(config) }) + + data class SetFailed( + val error: Throwable, + val retryCount: Int = 0, + ) : Updates({ Failed(error, retryCount) }) + + data class Set(val state: ConfigState) : Updates({ state }) + } + + internal sealed class Actions( + override val execute: suspend ConfigContext.() -> Unit, + ) : TypedAction { + object FetchConfig : Actions(exec@{ + val current = state.value + if (current is Retrieving || current is Retrying) return@exec + + // Capture before transitioning out of Failed; Retrieved resets the lineage. + val priorRetries = (current as? Failed)?.retryCount ?: 0 + + update(Updates.SetRetrieving) + + val oldConfig = storage.read(LatestConfig) + val status = entitlements.status.value + val cacheLimit = + if (status is SubscriptionStatus.Active) 500.milliseconds else 1.seconds + + var isConfigFromCache = false + var isEnrichmentFromCache = false + val configRetryCount = AtomicInteger(0) + var configDuration = 0L + + val configDeferred = + scope.async { + val start = System.currentTimeMillis() + ( + if (oldConfig?.featureFlags?.enableConfigRefresh == true) { + try { + withTimeout(cacheLimit) { + network + .getConfig { + update(Updates.SetRetrying) + configRetryCount.incrementAndGet() + awaitUtilNetwork() + }.into { + if (it is Either.Failure) { + isConfigFromCache = true + Either.Success(oldConfig) + } else { + it + } + } + } + } catch (e: Throwable) { + e.printStackTrace() + oldConfig.let { + isConfigFromCache = true + Either.Success(it) + } + } + } else { + network.getConfig { + update(Updates.SetRetrying) + configRetryCount.incrementAndGet() + awaitUtilNetwork() + } + } + ).also { + configDuration = System.currentTimeMillis() - start + } + } + + val enrichmentDeferred = + scope.async { + val cached = storage.read(LatestEnrichment) + if (oldConfig?.featureFlags?.enableConfigRefresh == true) { + val res = + deviceHelper + .getEnrichment(0, cacheLimit) + .then { storage.write(LatestEnrichment, it) } + if (res.getSuccess() == null) { + cached?.let { + deviceHelper.setEnrichment(cached) + isEnrichmentFromCache = true + Either.Success(it) + } ?: res + } else { + res + } + } else { + deviceHelper.getEnrichment(0, 1.seconds) + } + } + + val attributesDeferred = scope.async { factory.makeSessionDeviceAttributes() } + + val (configResultAny, enrichmentResultAny) = + listOf(configDeferred, enrichmentDeferred).awaitAll() + val attributes = attributesDeferred.await() + scope.launch { + @Suppress("UNCHECKED_CAST") + track(InternalSuperwallEvent.DeviceAttributes(attributes as HashMap)) + } + + @Suppress("UNCHECKED_CAST") + val configResult = configResultAny as Either + + @Suppress("UNCHECKED_CAST") + val enrichmentResult = enrichmentResultAny as Either + + configResult + .then { config -> + track( + InternalSuperwallEvent.ConfigRefresh( + isCached = isConfigFromCache, + buildId = config.buildId, + fetchDuration = configDuration, + retryCount = configRetryCount.get(), + ), + ) + }.then { config -> immediate(ApplyConfig(config)) } + .thenIf(testMode?.isTestMode != true) { + sideEffect { + webPaywallRedeemer().redeem(WebPaywallRedeemer.RedeemType.Existing) + } + }.then { config -> + if (testMode?.isTestMode != true && + options.computedShouldPreload(deviceHelper.deviceTier) + ) { + val productIds = config.paywalls.flatMap { it.productIds }.toSet() + try { + storeManager.products(productIds) + } catch (e: Throwable) { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.productsManager, + message = "Failed to preload products", + error = e, + ) + } + } + config + }.then { config -> + update(Updates.SetRetrieved(config)) + }.then { + if (isEnrichmentFromCache || enrichmentResult.getThrowable() != null) { + scope.launch { deviceHelper.getEnrichment(6, 1.seconds) } + } + }.fold( + onSuccess = { + // Preload before refresh — cached boot serves cached paywalls fast. + effect(PreloadIfEnabled) + if (isConfigFromCache) { + effect(RefreshConfig()) + } + }, + onFailure = { e -> + immediate(HandleFetchFailure(e, priorRetries, isConfigFromCache)) + }, + ) + }) + + data class HandleFetchFailure( + val error: Throwable, + val priorRetries: Int, + val isConfigFromCache: Boolean, + ) : Actions({ + error.printStackTrace() + val newRetries = priorRetries + 1 + update(Updates.SetFailed(error, retryCount = newRetries)) + if (!isConfigFromCache && newRetries <= 1) { + effect(FetchConfig) + } + track(InternalSuperwallEvent.ConfigFail(error.message ?: "Unknown error")) + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.superwallCore, + message = "Failed to Fetch Configuration", + error = error, + ) + }) + + data class RefreshConfig(val force: Boolean = false) : Actions(exec@{ + val oldConfig = state.value.getConfig() ?: return@exec + if (!force && !oldConfig.featureFlags.enableConfigRefresh) return@exec + + scope.launch { deviceHelper.getEnrichment(0, 1.seconds) } + + val retryCount = AtomicInteger(0) + val startTime = System.currentTimeMillis() + val result = + network.getConfig { + retryCount.incrementAndGet() + awaitUtilNetwork() + } + + result + .then { newConfig -> + paywallManager.resetPaywallRequestCache() + val previous = state.value.getConfig() + if (previous != null) { + paywallPreload.removeUnusedPaywallVCsFromCache(previous, newConfig) + } + newConfig + }.then { newConfig -> + immediate(ApplyConfig(newConfig)) + update(Updates.SetRetrieved(newConfig)) + track( + InternalSuperwallEvent.ConfigRefresh( + isCached = false, + buildId = newConfig.buildId, + fetchDuration = System.currentTimeMillis() - startTime, + retryCount = retryCount.get(), + ), + ) + newConfig + }.fold( + onSuccess = { effect(PreloadIfEnabled) }, + onFailure = { e -> + Logger.debug( + logLevel = LogLevel.warn, + scope = LogScope.superwallCore, + message = "Failed to refresh configuration.", + info = null, + error = e, + ) + }, + ) + }) + + data class ApplyConfig(val config: Config) : Actions({ + storage.write(DisableVerboseEvents, config.featureFlags.disableVerboseEvents) + if (config.featureFlags.enableConfigRefresh) { + storage.write(LatestConfig, config) + } + setTriggers(ConfigLogic.getTriggersByEventName(config.triggers)) + assignments.choosePaywallVariants(config.triggers) + + ConfigLogic.extractEntitlementsByProductId(config.products).let { + entitlements.addEntitlementsByProductId(it) + } + config.productsV3?.let { productsV3 -> + ConfigLogic.extractEntitlementsByProductIdFromCrossplatform(productsV3).let { + entitlements.addEntitlementsByProductId(it) + } + } + + val manager = testMode + val wasTestMode = manager?.isTestMode == true + manager?.evaluateTestMode( + config = config, + bundleId = deviceHelper.bundleId, + appUserId = identityManager?.invoke()?.appUserId, + aliasId = identityManager?.invoke()?.aliasId, + testModeBehavior = options.testModeBehavior, + ) + val testModeJustActivated = !wasTestMode && manager?.isTestMode == true + + if (manager?.isTestMode == true) { + if (testModeJustActivated) { + val defaultStatus = manager.buildSubscriptionStatus() + manager.setOverriddenSubscriptionStatus(defaultStatus) + entitlements.setSubscriptionStatus(defaultStatus) + } + scope.launch { activateTestMode(config, testModeJustActivated) } + } else { + if (wasTestMode) { + manager?.clearTestModeState() + setSubscriptionStatus?.invoke(SubscriptionStatus.Inactive) + } + scope.launch { + storeManager.loadPurchasedProducts(entitlements.entitlementsByProductId) + } + } + }) + + data class ReevaluateTestMode( + val config: Config, + val appUserId: String?, + val aliasId: String?, + ) : Actions(exec@{ + val manager = testMode ?: return@exec + val wasTestMode = manager.isTestMode + manager.evaluateTestMode( + config = config, + bundleId = deviceHelper.bundleId, + appUserId = appUserId ?: identityManager?.invoke()?.appUserId, + aliasId = aliasId ?: identityManager?.invoke()?.aliasId, + testModeBehavior = options.testModeBehavior, + ) + val isNowTestMode = manager.isTestMode + if (wasTestMode && !isNowTestMode) { + manager.clearTestModeState() + setSubscriptionStatus?.invoke(SubscriptionStatus.Inactive) + } else if (!wasTestMode && isNowTestMode) { + scope.launch { activateTestMode(config, true) } + } + }) + + object PreloadIfEnabled : Actions(exec@{ + if (!options.computedShouldPreload(deviceHelper.deviceTier)) return@exec + val config = state.value.getConfig() ?: return@exec + paywallPreload.preloadAllPaywalls(config, context) + }) + + object PreloadAll : Actions(exec@{ + val config = state.value.getConfig() ?: return@exec + paywallPreload.preloadAllPaywalls(config, context) + }) + + data class PreloadByNames( + val eventNames: Set, + ) : Actions(exec@{ + val config = state.value.getConfig() ?: return@exec + paywallPreload.preloadPaywallsByNames(config, eventNames) + }) + + object GetAssignments : Actions(exec@{ + val config = state.value.getConfig() ?: return@exec + config.triggers.takeUnless { it.isEmpty() }?.let { triggers -> + try { + assignments + .getAssignments(triggers) + .then { effect(PreloadIfEnabled) } + .onError { err -> + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.configManager, + message = "Error retrieving assignments.", + error = err, + ) + } + } catch (e: Throwable) { + e.printStackTrace() + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.configManager, + message = "Error retrieving assignments.", + error = e, + ) + } + } + }) + } } internal fun ConfigState.getConfig(): Config? = diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt index 7b21f4508..15ba4c86c 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -123,7 +123,7 @@ import com.superwall.sdk.store.StoreManager import com.superwall.sdk.store.abstractions.product.receipt.ReceiptManager import com.superwall.sdk.store.abstractions.transactions.GoogleBillingPurchaseTransaction import com.superwall.sdk.store.abstractions.transactions.StoreTransaction -import com.superwall.sdk.store.testmode.TestModeManager +import com.superwall.sdk.store.testmode.TestMode import com.superwall.sdk.store.testmode.TestModeTransactionHandler import com.superwall.sdk.store.transactions.TransactionManager import com.superwall.sdk.utilities.DateUtils @@ -208,7 +208,7 @@ class DependencyContainer( internal val customCallbackRegistry: CustomCallbackRegistry var entitlements: Entitlements - internal val testModeManager: TestModeManager + internal val testMode: TestMode internal val testModeTransactionHandler: TestModeTransactionHandler internal lateinit var customerInfoManager: CustomerInfoManager lateinit var reedemer: WebPaywallRedeemer @@ -272,10 +272,27 @@ class DependencyContainer( _apiKey = apiKey ) entitlements = Entitlements(storage) - testModeManager = TestModeManager(storage) + val options = options ?: SuperwallOptions() + testMode = + TestMode( + storage = storage, + getSuperwallProducts = { network.getSuperwallProducts() }, + entitlements = entitlements, + activityProvider = { this.activityProvider }, + activityTracker = { currentActivityTracker }, + hasExternalPurchaseController = { makeHasExternalPurchaseController() }, + apiKey = { storage.apiKey }, + dashboardBaseUrl = { + when (options.networkEnvironment) { + is SuperwallOptions.NetworkEnvironment.Developer -> "https://superwall.dev" + else -> "https://superwall.com" + } + }, + track = { Superwall.instance.track(it) }, + ) testModeTransactionHandler = TestModeTransactionHandler( - testModeManager = testModeManager, + testMode = testMode, activityProvider = activityProvider, activityTracker = currentActivityTracker, ) @@ -309,7 +326,7 @@ class DependencyContainer( customerInfoManager = { customerInfoManager }, ) }, - testModeManager = testModeManager, + testMode = testMode, ) delegateAdapter = SuperwallDelegateAdapter() @@ -321,7 +338,6 @@ class DependencyContainer( makeHeaders(debugging, requestId) }, ) - val options = options ?: SuperwallOptions() api = Api(networkEnvironment = options.networkEnvironment) network = @@ -429,13 +445,22 @@ class DependencyContainer( configManager = { configManager }, ) + // Config actor setup — the SequentialActor serializes all state-mutating + // actions (fetch, refresh, reset, reevaluate test mode) through a single + // FIFO queue, so applying a new config can never race with a variant pick. + val configActor = + SequentialActor( + com.superwall.sdk.config.models.ConfigState.None, + ioScope, + ) + // DebugInterceptor.install(configActor, name = "Config") + configManager = ConfigManager( context = context, storeManager = storeManager, storage = storage, network = network, - fullNetwork = network, options = options, factory = this, paywallManager = paywallManager, @@ -443,18 +468,20 @@ class DependencyContainer( assignments = assignments, ioScope = ioScope, paywallPreload = paywallPreload, - track = { + tracker = { Superwall.instance.track(it) }, entitlements = entitlements, webPaywallRedeemer = { reedemer }, - testModeManager = testModeManager, + testMode = testMode, identityManager = { identityManager }, - activityProvider = activityProvider, - activityTracker = currentActivityTracker, setSubscriptionStatus = { status -> entitlements.setSubscriptionStatus(status) }, + activateTestMode = { config, justActivated -> + testMode.activate(config, justActivated) + }, + actor = configActor, ) identityManager = @@ -531,7 +558,7 @@ class DependencyContainer( storage = storage, activityProvider, factory = this, - testModeManager = testModeManager, + testMode = testMode, testModeTransactionHandler = testModeTransactionHandler, setSubscriptionStatus = { status -> entitlements.setSubscriptionStatus(status) @@ -851,7 +878,7 @@ class DependencyContainer( locale = deviceHelper.locale, ) - override fun makeIsSandbox(): Boolean = testModeManager.isTestMode || deviceHelper.isSandbox + override fun makeIsSandbox(): Boolean = testMode.isTestMode || deviceHelper.isSandbox override suspend fun makeSessionDeviceAttributes(): HashMap { val attributes = deviceHelper.getTemplateDevice().toMutableMap() diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt index ec4cd057e..f27880c24 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt @@ -45,7 +45,7 @@ import com.superwall.sdk.paywall.view.webview.templating.models.JsonVariables import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.storage.core_data.CoreDataManager import com.superwall.sdk.store.abstractions.transactions.StoreTransaction -import com.superwall.sdk.store.testmode.TestModeManager +import com.superwall.sdk.store.testmode.TestMode import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow @@ -258,6 +258,6 @@ interface CoreDataManagerFactory { fun makeCoreDataManager(): CoreDataManager } -internal interface TestModeManagerFactory { - fun makeTestModeManager(): TestModeManager? +internal interface TestModeFactory { + fun makeTestMode(): TestMode? } diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt index 5f0fa7a38..9ca63af5f 100644 --- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt @@ -15,6 +15,5 @@ interface IdentityContext : BaseContext { val sdkContext: SdkContext val webPaywallRedeemer: () -> WebPaywallRedeemer val completeReset: () -> Unit - val track: suspend (Trackable) -> Unit val notifyUserChange: ((Map) -> Unit)? } diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt index d5f55dd8a..8d9a2c402 100644 --- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt @@ -31,7 +31,7 @@ class IdentityManager( override val completeReset: () -> Unit = { Superwall.instance.reset(duringIdentify = true) }, - private val trackEvent: suspend (TrackableSuperwallEvent) -> Unit = { + override val tracker: suspend (TrackableSuperwallEvent) -> Unit = { Superwall.instance.track(it) }, private val options: () -> SuperwallOptions, @@ -41,7 +41,6 @@ class IdentityManager( override val sdkContext: SdkContext, ) : IdentityContext { override val scope: CoroutineScope get() = ioScope - override val track: suspend (Trackable) -> Unit = { trackEvent(it as TrackableSuperwallEvent) } private val identity get() = actor.state.value diff --git a/superwall/src/main/java/com/superwall/sdk/misc/Either.kt b/superwall/src/main/java/com/superwall/sdk/misc/Either.kt index 28e327b4c..e5753c563 100644 --- a/superwall/src/main/java/com/superwall/sdk/misc/Either.kt +++ b/superwall/src/main/java/com/superwall/sdk/misc/Either.kt @@ -39,6 +39,27 @@ suspend fun Either.then(then: suspend (In) -> Unit): is Either.Failure -> this } +suspend fun Either.thenIf( + boolean: Boolean, + then: suspend (In) -> Unit +): Either = + when (this) { + is Either.Success -> { + try { + if (boolean) { + then(this.value) + } + this + } catch (e: Throwable) { + (e as? E)?.let { Either.Failure(it) } + ?: Either.Failure(IllegalStateException("Error in then block", e) as E) + } + } + + is Either.Failure -> this + } + + fun Either.map(transform: (In) -> Out): Either = when (this) { is Either.Success -> Either.Success(transform(this.value)) @@ -124,4 +145,5 @@ inline fun Either.toResult() = is Either.Failure -> Result.failure(this.error) } -suspend inline fun Either.into(crossinline map: suspend (Either) -> Either): Either = map(this) +suspend inline fun Either.into(crossinline map: suspend (Either) -> Either): Either = + map(this) diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/BaseContext.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/BaseContext.kt index b9741a035..14c6c1918 100644 --- a/superwall/src/main/java/com/superwall/sdk/misc/primitives/BaseContext.kt +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/BaseContext.kt @@ -1,7 +1,9 @@ package com.superwall.sdk.misc.primitives +import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent import com.superwall.sdk.storage.Storable import com.superwall.sdk.storage.Storage +import kotlinx.coroutines.launch /** * SDK-level actor context — extends [StoreContext] with storage helpers. @@ -11,6 +13,8 @@ import com.superwall.sdk.storage.Storage interface BaseContext> : StoreContext { val storage: Storage + val tracker: suspend (TrackableSuperwallEvent) -> Unit + /** Persist a value to storage. */ fun persist( storable: Storable, @@ -29,4 +33,8 @@ interface BaseContext> : StoreContext { @Suppress("UNCHECKED_CAST") storage.delete(storable as Storable) } + + fun track(event: TrackableSuperwallEvent) { + scope.launch { tracker(event) } + } } diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/StoreContext.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/StoreContext.kt index f49bf08ef..b0c062700 100644 --- a/superwall/src/main/java/com/superwall/sdk/misc/primitives/StoreContext.kt +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/StoreContext.kt @@ -2,6 +2,7 @@ package com.superwall.sdk.misc.primitives import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch /** * Pure actor context — the minimal contract for action execution. @@ -46,4 +47,8 @@ interface StoreContext> : StateStore { ) { actor.immediateUntil(this as Self, action, until) } + + fun sideEffect(what: suspend () -> Unit){ + scope.launch { what() } + } } diff --git a/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt b/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt index fcbe5ef70..1b73fc7e0 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt @@ -23,12 +23,15 @@ class BaseHostService( requestId: String, ): Map = factory.makeHeaders(isForDebugging, requestId) - suspend fun config(requestId: String) = - get( - "static_config", - requestId = requestId, - queryItems = listOf(URLQueryItem("pk", factory.storage.apiKey)), - ) + suspend fun config( + requestId: String, + isRetryingCallback: (suspend () -> Unit)? = null, + ) = get( + "static_config", + requestId = requestId, + queryItems = listOf(URLQueryItem("pk", factory.storage.apiKey)), + isRetryingCallback = isRetryingCallback, + ) suspend fun assignments() = get("assignments") diff --git a/superwall/src/main/java/com/superwall/sdk/network/Network.kt b/superwall/src/main/java/com/superwall/sdk/network/Network.kt index bb1d55386..2379f5a26 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/Network.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/Network.kt @@ -60,7 +60,8 @@ open class Network( return baseHostService .config( - requestId, + requestId = requestId, + isRetryingCallback = isRetryingCallback, ).map { config -> config.requestId = requestId config diff --git a/superwall/src/main/java/com/superwall/sdk/network/NetworkService.kt b/superwall/src/main/java/com/superwall/sdk/network/NetworkService.kt index bfb3e54ea..44e47a8d1 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/NetworkService.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/NetworkService.kt @@ -24,6 +24,7 @@ abstract class NetworkService { isForDebugging: Boolean = false, requestId: String = UUID.randomUUID().toString(), retryCount: Int = NetworkConsts.retryCount(), + noinline isRetryingCallback: (suspend () -> Unit)? = null, timeout: Duration? = null ): Either where T : @Serializable Any = customHttpUrlConnection.request( @@ -43,6 +44,7 @@ abstract class NetworkService { ) }, retryCount = retryCount, + isRetryingCallback = isRetryingCallback, ) suspend inline fun post( diff --git a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt index 062623195..f8c0b852a 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt @@ -18,7 +18,7 @@ import com.superwall.sdk.paywall.request.PaywallRequest import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.abstractions.product.receipt.ReceiptManager import com.superwall.sdk.store.coordinator.ProductsFetcher -import com.superwall.sdk.store.testmode.TestModeManager +import com.superwall.sdk.store.testmode.TestMode import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.awaitAll import java.util.Date @@ -31,7 +31,7 @@ class StoreManager( private val track: suspend (InternalSuperwallEvent) -> Unit = { Superwall.instance.track(it) }, - var testModeManager: TestModeManager? = null, + var testMode: TestMode? = null, ) : ProductsFetcher, StoreKit { val receiptManager by lazy(receiptManagerFactory) @@ -319,7 +319,7 @@ class StoreManager( override fun getProductFromCache(productId: String): StoreProduct? { // Check test products first when in test mode - testModeManager?.let { manager -> + testMode?.let { manager -> if (manager.isTestMode) { manager.testProductsByFullId[productId]?.let { return it } } @@ -328,7 +328,7 @@ class StoreManager( } override fun hasCached(productId: String): Boolean { - testModeManager?.let { manager -> + testMode?.let { manager -> if (manager.isTestMode && manager.testProductsByFullId.containsKey(productId)) { return true } diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestModeManager.kt b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt similarity index 63% rename from superwall/src/main/java/com/superwall/sdk/store/testmode/TestModeManager.kt rename to superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt index 35706ebcb..e3a4ed261 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestModeManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt @@ -1,25 +1,59 @@ package com.superwall.sdk.store.testmode +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent.TestModeModal.State import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger +import com.superwall.sdk.misc.ActivityProvider +import com.superwall.sdk.misc.CurrentActivityTracker +import com.superwall.sdk.misc.Either +import com.superwall.sdk.misc.fold import com.superwall.sdk.models.config.Config import com.superwall.sdk.models.entitlements.Entitlement import com.superwall.sdk.models.entitlements.SubscriptionStatus +import com.superwall.sdk.network.NetworkError import com.superwall.sdk.storage.IsTestModeActiveSubscription import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.StoredTestModeSettings import com.superwall.sdk.storage.TestModeSettings +import com.superwall.sdk.store.Entitlements import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.testmode.models.SuperwallEntitlementRef import com.superwall.sdk.store.testmode.models.SuperwallProduct +import com.superwall.sdk.store.testmode.models.SuperwallProductPlatform +import com.superwall.sdk.store.testmode.models.SuperwallProductsResponse import com.superwall.sdk.store.testmode.models.TestStoreUserType import com.superwall.sdk.store.testmode.ui.EntitlementSelection import com.superwall.sdk.store.testmode.ui.EntitlementStateOption +import com.superwall.sdk.store.testmode.ui.TestModeModal +import kotlin.time.Duration.Companion.seconds -class TestModeManager( +/** + * The single test-mode surface: holds the activation state (products, + * entitlement selections, settings persistence) AND runs the activation UI + * flow (`activate` → refresh products → present modal). + * + * Not exactly a "manager" — the UI flow pieces (activity lookup, subscription + * products fetch, modal presentation) are injected as thin lambdas so this + * class stays testable and config-slice-free. + */ +class TestMode( private val storage: Storage, private val isTestEnvironment: Boolean = Companion.isTestEnvironment, + // Activation UI hooks — all default to no-ops so unit tests exercising + // state management can construct `TestMode(storage)` without wiring the + // whole UI/network surface. + private val getSuperwallProducts: suspend () -> Either = { + Either.Failure(NetworkError.Unknown()) + }, + private val entitlements: Entitlements? = null, + private val activityProvider: () -> ActivityProvider? = { null }, + private val activityTracker: () -> CurrentActivityTracker? = { null }, + private val hasExternalPurchaseController: () -> Boolean = { false }, + private val apiKey: () -> String = { "" }, + private val dashboardBaseUrl: () -> String = { "" }, + private val track: suspend (InternalSuperwallEvent) -> Unit = { }, ) { companion object { val isTestEnvironment: Boolean by lazy { @@ -248,4 +282,103 @@ class TestModeManager( fun clearSettings() { storage.delete(StoredTestModeSettings) } + + // ---- Activation UI flow ------------------------------------------------ + + /** + * Refresh the test product catalog and (when [justActivated] is true) + * present the test-mode modal. Must be called off the actor queue — + * [presentModal] blocks on user interaction. + */ + suspend fun activate( + config: Config, + justActivated: Boolean, + ) { + refreshProducts() + if (justActivated) { + presentModal(config) + } + } + + private suspend fun refreshProducts() { + getSuperwallProducts().fold( + onSuccess = { response -> + val androidProducts = + response.data.filter { + it.platform == SuperwallProductPlatform.ANDROID && it.price != null + } + setProducts(androidProducts) + + val productsByFullId = + androidProducts.associate { superwallProduct -> + val testProduct = TestStoreProduct(superwallProduct) + superwallProduct.identifier to StoreProduct(testProduct) + } + setTestProducts(productsByFullId) + + Logger.debug( + LogLevel.info, + LogScope.superwallCore, + "Test mode: loaded ${androidProducts.size} products", + ) + }, + onFailure = { error -> + Logger.debug( + LogLevel.error, + LogScope.superwallCore, + "Test mode: failed to fetch products - ${error.message}", + ) + }, + ) + } + + private suspend fun presentModal(config: Config) { + val activity = + activityTracker()?.getCurrentActivity() + ?: activityProvider()?.getCurrentActivity() + ?: activityTracker()?.awaitActivity(10.seconds) + if (activity == null) { + Logger.debug( + LogLevel.warn, + LogScope.superwallCore, + "Test mode modal could not be presented: no activity available. Setting default subscription status.", + ) + val status = buildSubscriptionStatus() + setOverriddenSubscriptionStatus(status) + entitlements?.setSubscriptionStatus(status) + return + } + + track(InternalSuperwallEvent.TestModeModal(State.Open)) + + val reason = testModeReason?.description ?: "Test mode activated" + val allEntitlements = + config.productsV3 + ?.flatMap { it.entitlements.map { e -> e.id } } + ?.distinct() + ?.sorted() + ?: emptyList() + + val savedSettings = loadSettings() + + val result = + TestModeModal.show( + activity = activity, + reason = reason, + hasPurchaseController = hasExternalPurchaseController(), + availableEntitlements = allEntitlements, + apiKey = apiKey(), + dashboardBaseUrl = dashboardBaseUrl(), + savedSettings = savedSettings, + ) + + setFreeTrialOverride(result.freeTrialOverride) + setEntitlements(result.entitlements) + saveSettings() + val status = buildSubscriptionStatus() + setOverriddenSubscriptionStatus(status) + entitlements?.setSubscriptionStatus(status) + + track(InternalSuperwallEvent.TestModeModal(State.Close)) + } } diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestModeTransactionHandler.kt b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestModeTransactionHandler.kt index c8e8fad17..9d75b9ab2 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestModeTransactionHandler.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestModeTransactionHandler.kt @@ -17,7 +17,7 @@ import com.superwall.sdk.store.testmode.ui.TestModeRestoreDrawer import com.superwall.sdk.store.transactions.TransactionManager.PurchaseSource class TestModeTransactionHandler( - private val testModeManager: TestModeManager, + private val testMode: TestMode, private val activityProvider: ActivityProvider, private val activityTracker: CurrentActivityTracker? = null, ) { @@ -37,10 +37,10 @@ class TestModeTransactionHandler( ?: return PurchaseResult.Failed("Activity not found - required for test mode purchase drawer") val superwallProduct = - testModeManager.products.find { it.identifier == product.fullIdentifier } + testMode.products.find { it.identifier == product.fullIdentifier } val entitlements = superwallProduct?.entitlements ?: emptyList() - val hasFreeTrial = testModeManager.shouldShowFreeTrial(product.hasFreeTrial) + val hasFreeTrial = testMode.shouldShowFreeTrial(product.hasFreeTrial) Logger.debug( LogLevel.debug, @@ -61,9 +61,9 @@ class TestModeTransactionHandler( return when (result) { is PurchaseSimulationResult.Purchased -> { - testModeManager.fakePurchase(entitlements) - val status = testModeManager.buildSubscriptionStatus() - testModeManager.setOverriddenSubscriptionStatus(status) + testMode.fakePurchase(entitlements) + val status = testMode.buildSubscriptionStatus() + testMode.setOverriddenSubscriptionStatus(status) PurchaseResult.Purchased() } is PurchaseSimulationResult.Abandoned -> { @@ -80,7 +80,7 @@ class TestModeTransactionHandler( getForegroundActivity() ?: return RestorationResult.Failed(Throwable("Activity not found")) - val allEntitlements = testModeManager.allEntitlements() + val allEntitlements = testMode.allEntitlements() Logger.debug( LogLevel.debug, @@ -92,14 +92,14 @@ class TestModeTransactionHandler( TestModeRestoreDrawer.show( activity = activity, availableEntitlements = allEntitlements.toList(), - currentSelections = testModeManager.testEntitlementSelections, + currentSelections = testMode.testEntitlementSelections, ) return when (result) { is RestoreSimulationResult.Restored -> { - testModeManager.setEntitlements(result.selectedEntitlements) - val status = testModeManager.buildSubscriptionStatus() - testModeManager.setOverriddenSubscriptionStatus(status) + testMode.setEntitlements(result.selectedEntitlements) + val status = testMode.buildSubscriptionStatus() + testMode.setOverriddenSubscriptionStatus(status) RestorationResult.Restored() } is RestoreSimulationResult.Cancelled -> { @@ -108,7 +108,7 @@ class TestModeTransactionHandler( } } - fun findSuperwallProductForId(productId: String): SuperwallProduct? = testModeManager.products.find { it.identifier == productId } + fun findSuperwallProductForId(productId: String): SuperwallProduct? = testMode.products.find { it.identifier == productId } fun entitlementsForProduct(productId: String): Set { val superwallProduct = findSuperwallProductForId(productId) ?: return emptySet() diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt index 44cc16a87..17fa423bf 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt @@ -47,7 +47,7 @@ import com.superwall.sdk.store.StoreManager import com.superwall.sdk.store.abstractions.product.RawStoreProduct import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.abstractions.transactions.StoreTransaction -import com.superwall.sdk.store.testmode.TestModeManager +import com.superwall.sdk.store.testmode.TestMode import com.superwall.sdk.store.testmode.TestModeTransactionHandler import com.superwall.sdk.web.openRestoreOnWeb import kotlinx.coroutines.flow.asSharedFlow @@ -79,7 +79,7 @@ class TransactionManager( private val refreshReceipt: () -> Unit, private val updateState: (cacheKey: String, update: PaywallViewState.Updates) -> Unit, private val notifyOfTransactionComplete: suspend (paywallCacheKey: String, trialEndDate: Long?, productId: String) -> Unit, - private val testModeManager: TestModeManager? = null, + private val testMode: TestMode? = null, private val testModeTransactionHandler: TestModeTransactionHandler? = null, private val setSubscriptionStatus: ((SubscriptionStatus) -> Unit)? = null, ) { @@ -299,13 +299,13 @@ class TransactionManager( } // Test mode intercept: simulate purchase without real billing - if (testModeManager?.isTestMode == true && testModeTransactionHandler != null) { + if (testMode?.isTestMode == true && testModeTransactionHandler != null) { prepareToPurchase(product, purchaseSource) val result = testModeTransactionHandler.handlePurchase(product, purchaseSource) when (result) { is PurchaseResult.Purchased -> { // In test mode, set subscription status directly (no real receipt to verify) - val status = testModeManager.buildSubscriptionStatus() + val status = testMode.buildSubscriptionStatus() setSubscriptionStatus?.invoke(status) trackTransactionDidSucceed(null, product, purchaseSource, product.hasFreeTrial) if (shouldDismiss && purchaseSource is PurchaseSource.Internal) { @@ -831,10 +831,10 @@ class TransactionManager( log(message = "Attempting Restore") // Test mode intercept: simulate restore without real billing - if (testModeManager?.isTestMode == true && testModeTransactionHandler != null) { + if (testMode?.isTestMode == true && testModeTransactionHandler != null) { val result = testModeTransactionHandler.handleRestore() if (result is RestorationResult.Restored) { - val status = testModeManager.buildSubscriptionStatus() + val status = testMode.buildSubscriptionStatus() setSubscriptionStatus?.invoke(status) } return result diff --git a/superwall/src/test/java/com/superwall/sdk/SdkContextImplTest.kt b/superwall/src/test/java/com/superwall/sdk/SdkContextImplTest.kt new file mode 100644 index 000000000..ded8598d6 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/SdkContextImplTest.kt @@ -0,0 +1,76 @@ +package com.superwall.sdk + +import com.superwall.sdk.config.ConfigManager +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * Smoke tests for [SdkContextImpl] — the cross-slice bridge used by the identity + * actor to reach into [ConfigManager]. Thin delegates, but since they're the only + * bridge between the two actors, a missing forward would silently break identity + * flows in production. + */ +class SdkContextImplTest { + @Test + fun `reevaluateTestMode forwards appUserId and aliasId to ConfigManager`() = + runTest { + val manager = + mockk(relaxed = true) { + coEvery { reevaluateTestMode(any(), any(), any()) } just Runs + } + val ctx = SdkContextImpl(configManager = { manager }) + + ctx.reevaluateTestMode(appUserId = "user-1", aliasId = "alias-1") + + coVerify(exactly = 1) { + manager.reevaluateTestMode( + config = any(), + appUserId = "user-1", + aliasId = "alias-1", + ) + } + } + + @Test + fun `fetchAssignments delegates to ConfigManager_getAssignments`() = + runTest { + val manager = + mockk { + coEvery { getAssignments() } just Runs + } + val ctx = SdkContextImpl(configManager = { manager }) + + ctx.fetchAssignments() + + coVerify(exactly = 1) { manager.getAssignments() } + } + + @Test + fun `configManager factory is invoked lazily so teardown-reconfigure swaps are observable`() = + runTest { + val first = + mockk(relaxed = true) { + coEvery { reevaluateTestMode(any(), any(), any()) } just Runs + } + val second = + mockk(relaxed = true) { + coEvery { reevaluateTestMode(any(), any(), any()) } just Runs + } + var current: ConfigManager = first + val ctx = SdkContextImpl(configManager = { current }) + + ctx.reevaluateTestMode(null, null) + coVerify(exactly = 1) { first.reevaluateTestMode(any(), any(), any()) } + + current = second + ctx.reevaluateTestMode(null, null) + coVerify(exactly = 1) { second.reevaluateTestMode(any(), any(), any()) } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt new file mode 100644 index 000000000..aa2bb4943 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/config/ConfigManagerTest.kt @@ -0,0 +1,1588 @@ +package com.superwall.sdk.config + +import android.content.Context +import com.superwall.sdk.analytics.Tier +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent +import com.superwall.sdk.config.models.ConfigState +import com.superwall.sdk.config.options.SuperwallOptions +import com.superwall.sdk.identity.IdentityManager +import com.superwall.sdk.misc.Either +import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.misc.primitives.SequentialActor +import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.config.RawFeatureFlag +import com.superwall.sdk.models.enrichment.Enrichment +import com.superwall.sdk.models.entitlements.SubscriptionStatus +import com.superwall.sdk.models.triggers.Trigger +import com.superwall.sdk.network.NetworkError +import com.superwall.sdk.network.SuperwallAPI +import com.superwall.sdk.network.device.DeviceHelper +import com.superwall.sdk.paywall.manager.PaywallManager +import com.superwall.sdk.storage.LatestConfig +import com.superwall.sdk.storage.LatestEnrichment +import com.superwall.sdk.storage.Storage +import com.superwall.sdk.store.Entitlements +import com.superwall.sdk.store.StoreManager +import com.superwall.sdk.store.testmode.TestMode +import com.superwall.sdk.store.testmode.TestModeBehavior +import com.superwall.sdk.web.WebPaywallRedeemer +import com.superwall.sdk.models.assignment.Assignment +import com.superwall.sdk.storage.DisableVerboseEvents +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration.Companion.seconds + +class ConfigManagerTest { + private fun config( + buildId: String = "stub", + triggers: Set = emptySet(), + enableRefresh: Boolean = false, + ): Config = + Config.stub().copy( + buildId = buildId, + triggers = triggers, + rawFeatureFlags = + if (enableRefresh) { + listOf(RawFeatureFlag("enable_config_refresh_v2", true)) + } else { + emptyList() + }, + ) + + private data class Setup( + val manager: ConfigManagerForTest, + val network: SuperwallAPI, + val storage: Storage, + val preload: PaywallPreload, + val storeManager: StoreManager, + val webRedeemer: WebPaywallRedeemer, + val deviceHelper: DeviceHelper, + val testMode: TestMode?, + val tracked: CopyOnWriteArrayList, + val statuses: MutableList, + val activateCalls: AtomicInteger, + ) + + @Suppress("LongParameterList") + private fun setup( + scope: CoroutineScope, + cachedConfig: Config? = null, + cachedEnrichment: Enrichment? = null, + networkConfig: Either = Either.Success(Config.stub()), + networkConfigAnswer: (suspend (suspend () -> Unit) -> Either)? = null, + deviceTier: Tier = Tier.MID, + shouldPreload: Boolean = false, + preloadDeviceOverrides: Map = emptyMap(), + testModeBehavior: TestModeBehavior = TestModeBehavior.AUTOMATIC, + injectedTestMode: TestMode? = null, + assignments: Assignments = mockk(relaxed = true), + identityManager: IdentityManager? = null, + storeManagerOverride: StoreManager? = null, + entitlementsOverride: Entitlements? = null, + ): Setup { + val context = mockk(relaxed = true) + val storage = + mockk(relaxed = true) { + every { read(LatestConfig) } returns cachedConfig + every { read(LatestEnrichment) } returns cachedEnrichment + every { write(any(), any()) } just Runs + } + val network = + mockk { + if (networkConfigAnswer != null) { + coEvery { getConfig(any()) } coAnswers { + networkConfigAnswer.invoke(firstArg()) + } + } else { + coEvery { getConfig(any()) } returns networkConfig + } + coEvery { getEnrichment(any(), any(), any()) } returns + Either.Success(Enrichment.stub()) + } + val deviceHelper = + mockk(relaxed = true) { + every { appVersion } returns "1.0" + every { locale } returns "en-US" + every { this@mockk.deviceTier } returns deviceTier + every { bundleId } returns "com.test" + every { setEnrichment(any()) } just Runs + coEvery { getTemplateDevice() } returns emptyMap() + coEvery { getEnrichment(any(), any()) } returns Either.Success(Enrichment.stub()) + } + val storeManager = + storeManagerOverride + ?: mockk(relaxed = true) { + coEvery { products(any()) } returns emptySet() + coEvery { loadPurchasedProducts(any()) } just Runs + } + val preload = + mockk { + coEvery { preloadAllPaywalls(any(), any()) } just Runs + coEvery { preloadPaywallsByNames(any(), any()) } just Runs + coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs + } + val paywallManager = mockk(relaxed = true) + val webRedeemer = mockk(relaxed = true) + val factory = + mockk(relaxed = true) { + coEvery { makeSessionDeviceAttributes() } returns HashMap() + every { makeHasExternalPurchaseController() } returns false + } + val entitlements = + entitlementsOverride ?: mockk(relaxed = true).also { + every { it.status } returns + kotlinx.coroutines.flow.MutableStateFlow(SubscriptionStatus.Unknown) + every { it.entitlementsByProductId } returns emptyMap() + } + + val tracked = CopyOnWriteArrayList() + val statuses = mutableListOf() + val activateCalls = AtomicInteger(0) + + val options = + SuperwallOptions().apply { + paywalls.shouldPreload = shouldPreload + paywalls.preloadDeviceOverrides = preloadDeviceOverrides + this.testModeBehavior = testModeBehavior + } + + val manager = + ConfigManagerForTest( + context = context, + storage = storage, + network = network, + deviceHelper = deviceHelper, + paywallManager = paywallManager, + storeManager = storeManager, + preload = preload, + webRedeemer = webRedeemer, + factory = factory, + entitlements = entitlements, + assignments = assignments, + options = options, + ioScope = scope, + testMode = injectedTestMode, + tracker = { tracked.add(it) }, + setSubscriptionStatus = { statuses.add(it) }, + activateTestMode = { _, justActivated -> + if (justActivated) activateCalls.incrementAndGet() + }, + identityManager = identityManager?.let { im -> { im } }, + ) + return Setup( + manager, + network, + storage, + preload, + storeManager, + webRedeemer, + deviceHelper, + injectedTestMode, + tracked, + statuses, + activateCalls, + ) + } + + @Test + fun `autoRetryCount resets after a successful apply`() = + runTest(timeout = 30.seconds) { + val calls = AtomicInteger(0) + val s = + setup( + backgroundScope, + networkConfigAnswer = { + when (calls.incrementAndGet()) { + 1, 2 -> Either.Failure(NetworkError.Unknown()) + 3 -> Either.Success(Config.stub()) + else -> Either.Failure(NetworkError.Unknown()) + } + }, + ) + + s.manager.fetchConfiguration() + advanceUntilIdle() + assertEquals("Cold-start = initial + 1 retry", 2, calls.get()) + assertTrue(s.manager.configState.value is ConfigState.Failed) + + s.manager.fetchConfiguration() + advanceUntilIdle() + assertEquals(3, calls.get()) + assertTrue(s.manager.configState.value is ConfigState.Retrieved) + + s.manager.fetchConfiguration() + advanceUntilIdle() + assertEquals("Counter must reset on Retrieved — fresh budget", 5, calls.get()) + } + + @Test + fun `reevaluateTestMode deactivates when user no longer qualifies`() = + runTest(timeout = 30.seconds) { + val storageForTm = mockk(relaxed = true) + val testMode = spyk(TestMode(storage = storageForTm, isTestEnvironment = false)) + testMode.evaluateTestMode( + Config.stub(), + "com.test", + null, + null, + testModeBehavior = TestModeBehavior.ALWAYS, + ) + assertTrue(testMode.isTestMode) + + val s = setup(backgroundScope, injectedTestMode = testMode) + s.manager.reevaluateTestMode(config = Config.stub(), appUserId = "no-match") + + assertFalse(testMode.isTestMode) + verify(atLeast = 1) { testMode.clearTestModeState() } + assertTrue( + "Expected SubscriptionStatus.Inactive on deactivation", + s.statuses.any { it is SubscriptionStatus.Inactive }, + ) + } + + @Test + fun `reevaluateTestMode activates when user now qualifies`() = + runTest(timeout = 30.seconds) { + val storageForTm = mockk(relaxed = true) + val testMode = TestMode(storage = storageForTm, isTestEnvironment = false) + assertFalse(testMode.isTestMode) + + val s = + setup( + backgroundScope, + testModeBehavior = TestModeBehavior.ALWAYS, + injectedTestMode = testMode, + ) + s.manager.reevaluateTestMode(config = Config.stub(), appUserId = "anyone") + advanceUntilIdle() + + assertTrue(testMode.isTestMode) + assertEquals("activateTestMode lambda must fire once", 1, s.activateCalls.get()) + } + + @Test + fun `reevaluateTestMode is noop when state unchanged`() = + runTest(timeout = 30.seconds) { + val storageForTm = mockk(relaxed = true) + val testMode = spyk(TestMode(storage = storageForTm, isTestEnvironment = false)) + assertFalse(testMode.isTestMode) + + val s = + setup( + backgroundScope, + testModeBehavior = TestModeBehavior.NEVER, + injectedTestMode = testMode, + ) + s.manager.reevaluateTestMode(config = Config.stub(), appUserId = "anyone") + advanceUntilIdle() + + assertFalse(testMode.isTestMode) + verify(exactly = 0) { testMode.clearTestModeState() } + assertTrue("No subscription status published on no-op", s.statuses.isEmpty()) + assertEquals("activateTestMode must not fire on no-op", 0, s.activateCalls.get()) + } + + // Both reevaluateTestMode and ApplyConfig mutate TestMode.state. They + // must be serialized through the config actor — never overlap. + @Test + fun `reevaluateTestMode and ApplyConfig do not overlap on TestMode state`() = + runTest(timeout = 30.seconds) { + val storage = mockk(relaxed = true) + val inFlight = AtomicInteger(0) + val maxOverlap = AtomicInteger(0) + val testMode = spyk(TestMode(storage = storage, isTestEnvironment = false)) + // Track in-flight evaluateTestMode calls. If serialization holds, + // maxOverlap stays at 1. + every { + testMode.evaluateTestMode(any(), any(), any(), any(), any()) + } answers { + val n = inFlight.incrementAndGet() + maxOverlap.updateAndGet { kotlin.math.max(it, n) } + Thread.sleep(20) // simulate non-trivial work + inFlight.decrementAndGet() + callOriginal() + } + + val s = setup( + backgroundScope, + injectedTestMode = testMode, + testModeBehavior = TestModeBehavior.ALWAYS, + ) + + // Race: drive ApplyConfig (via fetchConfiguration) and + // reevaluateTestMode in parallel. + val a = launch { s.manager.fetchConfiguration() } + val b = launch { s.manager.reevaluateTestMode(config = Config.stub(), appUserId = "u") } + val c = launch { s.manager.reevaluateTestMode(config = Config.stub(), appUserId = "v") } + a.join(); b.join(); c.join() + advanceUntilIdle() + + assertEquals( + "evaluateTestMode must never overlap with itself when serialized through the actor — saw ${maxOverlap.get()} concurrent", + 1, + maxOverlap.get(), + ) + } + + @Test + fun `fetchConfig in test mode skips web entitlements and product preload`() = + runTest(timeout = 30.seconds) { + val storageForTm = mockk(relaxed = true) + val testMode = TestMode(storage = storageForTm, isTestEnvironment = false) + val s = + setup( + backgroundScope, + shouldPreload = true, + testModeBehavior = TestModeBehavior.ALWAYS, + injectedTestMode = testMode, + ) + + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + assertTrue(testMode.isTestMode) + coVerify(exactly = 0) { s.storeManager.products(any()) } + coVerify(exactly = 0) { s.webRedeemer.redeem(any()) } + } + + @Test + fun `refreshConfig runs full applyConfig fanout`() = + runTest(timeout = 30.seconds) { + val initial = config(buildId = "initial", triggers = setOf(Trigger.stub().copy(eventName = "trigger_a")), enableRefresh = true) + val refreshed = config(buildId = "refreshed", triggers = setOf(Trigger.stub().copy(eventName = "trigger_b")), enableRefresh = true) + val getCalls = AtomicInteger(0) + val storageForTm = mockk(relaxed = true) + val testMode = spyk(TestMode(storage = storageForTm, isTestEnvironment = false)) + + val s = + setup( + backgroundScope, + networkConfigAnswer = { + Either.Success(if (getCalls.incrementAndGet() == 1) initial else refreshed) + }, + injectedTestMode = testMode, + ) + + s.manager.fetchConfiguration() + s.manager.configState.first { (it as? ConfigState.Retrieved)?.config?.buildId == "initial" } + advanceUntilIdle() + assertTrue(s.manager.triggersByEventName.containsKey("trigger_a")) + + s.manager.refreshConfiguration(force = true) + s.manager.configState.first { (it as? ConfigState.Retrieved)?.config?.buildId == "refreshed" } + advanceUntilIdle() + + assertTrue(s.manager.triggersByEventName.containsKey("trigger_b")) + assertFalse(s.manager.triggersByEventName.containsKey("trigger_a")) + verify(atLeast = 2) { testMode.evaluateTestMode(any(), any(), any(), any(), any()) } + verify { s.storage.write(LatestConfig, refreshed) } + } + + @Test + fun `preloadAllPaywalls bypasses shouldPreload gate`() = + runTest(timeout = 30.seconds) { + val s = setup(backgroundScope, shouldPreload = false) + s.manager.applyRetrievedConfigForTesting(Config.stub()) + + s.manager.preloadAllPaywalls() + advanceUntilIdle() + + coVerify(exactly = 1) { s.preload.preloadAllPaywalls(any(), any()) } + } + + @Test + fun `preloadPaywallsByNames bypasses shouldPreload gate`() = + runTest(timeout = 30.seconds) { + val s = setup(backgroundScope, shouldPreload = false) + s.manager.applyRetrievedConfigForTesting(Config.stub()) + + s.manager.preloadPaywallsByNames(setOf("evt")) + advanceUntilIdle() + + coVerify(exactly = 1) { s.preload.preloadPaywallsByNames(any(), eq(setOf("evt"))) } + } + + @Test + fun `tracking emits ConfigRefresh isCached false and DeviceAttributes on fresh fetch`() = + runTest(timeout = 30.seconds) { + val s = + setup( + backgroundScope, + networkConfig = Either.Success(Config.stub().copy(buildId = "fresh-id")), + ) + + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + val refresh = s.tracked.filterIsInstance() + assertTrue("Expected ConfigRefresh event, got ${s.tracked}", refresh.isNotEmpty()) + assertFalse("Fresh fetch must mark isCached=false", refresh.last().isCached) + assertEquals("fresh-id", refresh.last().buildId) + + val deviceAttrs = s.tracked.filterIsInstance() + assertTrue("Expected at least one DeviceAttributes event", deviceAttrs.isNotEmpty()) + } + + @Test + fun `tracking emits ConfigRefresh isCached true on cached path`() = + runTest(timeout = 30.seconds) { + val cached = config(buildId = "cached-id", enableRefresh = true) + val s = + setup( + backgroundScope, + cachedConfig = cached, + cachedEnrichment = Enrichment.stub(), + // network failure on cached-refresh path → FetchConfig's + // `.into { if (Failure) Success(oldConfig) }` falls back to cache. + networkConfig = Either.Failure(NetworkError.Unknown()), + ) + + s.manager.fetchConfiguration() + s.manager.configState.first { (it as? ConfigState.Retrieved)?.config?.buildId == "cached-id" } + advanceUntilIdle() + + val refresh = s.tracked.filterIsInstance() + assertTrue( + "Cached path must publish ConfigRefresh with isCached=true", + refresh.any { it.isCached && it.buildId == "cached-id" }, + ) + } + + @Test + fun `tracking emits ConfigFail on failure without cache`() = + runTest(timeout = 30.seconds) { + val s = + setup( + backgroundScope, + networkConfig = Either.Failure(NetworkError.Unknown()), + ) + + s.manager.fetchConfiguration() + advanceUntilIdle() + + val fails = s.tracked.filterIsInstance() + assertTrue("Expected at least one ConfigFail, got ${s.tracked}", fails.isNotEmpty()) + } + + @Test + fun `fetchConfig skips when already in Retrying state`() = + runTest(timeout = 30.seconds) { + val calls = AtomicInteger(0) + val s = + setup( + backgroundScope, + networkConfigAnswer = { retryCb -> + calls.incrementAndGet() + retryCb() // flip to Retrying + delay(800) + Either.Success(Config.stub()) + }, + ) + + val first = launch { s.manager.fetchConfiguration() } + s.manager.configState.first { it is ConfigState.Retrying } + s.manager.fetchConfiguration() // must early-return + first.join() + + assertEquals("Expected exactly one network.getConfig", 1, calls.get()) + } + + @Test + fun `enrichment success writes LatestEnrichment on cached boot`() = + runTest(timeout = 30.seconds) { + val freshEnrichment = Enrichment.stub() + val cached = config(enableRefresh = true) + val s = + setup( + backgroundScope, + cachedConfig = cached, + cachedEnrichment = null, + ) + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + verify(atLeast = 1) { s.storage.write(LatestEnrichment, freshEnrichment) } + } + + @Test + fun `applyConfig populates entitlements from products and productsV3`() = + runTest(timeout = 30.seconds) { + val productMap = mapOf("p1" to setOf()) + val crossplatformMap = mapOf("p2" to setOf()) + io.mockk.mockkObject(ConfigLogic) + try { + every { ConfigLogic.extractEntitlementsByProductId(any()) } returns productMap + every { ConfigLogic.extractEntitlementsByProductIdFromCrossplatform(any()) } returns crossplatformMap + every { ConfigLogic.getTriggersByEventName(any()) } returns emptyMap() + + val entitlements = mockk(relaxed = true).also { + every { it.status } returns kotlinx.coroutines.flow.MutableStateFlow(SubscriptionStatus.Unknown) + every { it.entitlementsByProductId } returns emptyMap() + } + val configWithV3 = + Config.stub().copy(productsV3 = listOf(mockk(relaxed = true))) + val s = + setup( + backgroundScope, + networkConfig = Either.Success(configWithV3), + entitlementsOverride = entitlements, + ) + + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + verify(exactly = 1) { entitlements.addEntitlementsByProductId(productMap) } + verify(exactly = 1) { entitlements.addEntitlementsByProductId(crossplatformMap) } + } finally { + io.mockk.unmockkObject(ConfigLogic) + } + } + + @Test + fun `applyConfig skips crossplatform entitlements when productsV3 is null`() = + runTest(timeout = 30.seconds) { + io.mockk.mockkObject(ConfigLogic) + try { + val productMap = mapOf("p1" to setOf()) + every { ConfigLogic.extractEntitlementsByProductId(any()) } returns productMap + every { ConfigLogic.extractEntitlementsByProductIdFromCrossplatform(any()) } returns emptyMap() + every { ConfigLogic.getTriggersByEventName(any()) } returns emptyMap() + + val entitlements = mockk(relaxed = true).also { + every { it.status } returns kotlinx.coroutines.flow.MutableStateFlow(SubscriptionStatus.Unknown) + every { it.entitlementsByProductId } returns emptyMap() + } + val s = + setup( + backgroundScope, + networkConfig = Either.Success(Config.stub().copy(productsV3 = null)), + entitlementsOverride = entitlements, + ) + + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + verify(exactly = 1) { entitlements.addEntitlementsByProductId(productMap) } + verify(exactly = 0) { + ConfigLogic.extractEntitlementsByProductIdFromCrossplatform(any()) + } + } finally { + io.mockk.unmockkObject(ConfigLogic) + } + } + + @Test + fun `applyConfig falls back to identityManager for appUserId and aliasId`() = + runTest(timeout = 30.seconds) { + val identity = + mockk(relaxed = true) { + every { appUserId } returns "from-identity" + every { aliasId } returns "alias-from-identity" + } + val storageForTm = mockk(relaxed = true) + val testMode = spyk(TestMode(storage = storageForTm, isTestEnvironment = false)) + val s = + setup( + backgroundScope, + injectedTestMode = testMode, + identityManager = identity, + ) + + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + verify(atLeast = 1) { + testMode.evaluateTestMode( + config = any(), + bundleId = "com.test", + appUserId = "from-identity", + aliasId = "alias-from-identity", + testModeBehavior = any(), + ) + } + } + + @Test + fun `reevaluateTestMode falls back to identityManager when ids omitted`() = + runTest(timeout = 30.seconds) { + val identity = + mockk(relaxed = true) { + every { appUserId } returns "from-identity" + every { aliasId } returns "alias-from-identity" + } + val storageForTm = mockk(relaxed = true) + val testMode = spyk(TestMode(storage = storageForTm, isTestEnvironment = false)) + val s = + setup( + backgroundScope, + testModeBehavior = TestModeBehavior.AUTOMATIC, + injectedTestMode = testMode, + identityManager = identity, + ) + + s.manager.reevaluateTestMode(config = Config.stub()) + advanceUntilIdle() + + verify(atLeast = 1) { + testMode.evaluateTestMode( + config = any(), + bundleId = "com.test", + appUserId = "from-identity", + aliasId = "alias-from-identity", + testModeBehavior = any(), + ) + } + } + + @Test + fun `fetchConfig completes when storeManager_products throws`() = + runTest(timeout = 30.seconds) { + val storeManager = + mockk(relaxed = true) { + coEvery { products(any()) } throws RuntimeException("billing exploded") + coEvery { loadPurchasedProducts(any()) } just Runs + } + val s = + setup( + backgroundScope, + networkConfig = Either.Success(Config.stub().copy(buildId = "ok")), + shouldPreload = true, // forces the products() call + storeManagerOverride = storeManager, + ) + + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + assertEquals( + "ok", + (s.manager.configState.value as ConfigState.Retrieved).config.buildId, + ) + coVerify(atLeast = 1) { storeManager.products(any()) } + } + + @Test + fun `tier override false suppresses preload even when shouldPreload is true`() = + runTest(timeout = 30.seconds) { + val s = + setup( + backgroundScope, + deviceTier = Tier.LOW, + shouldPreload = true, + preloadDeviceOverrides = mapOf(Tier.LOW to false), + ) + + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any()) } + } + + @Test + fun `tier override true forces preload even when shouldPreload is false`() = + runTest(timeout = 30.seconds) { + val s = + setup( + backgroundScope, + deviceTier = Tier.LOW, + shouldPreload = false, + preloadDeviceOverrides = mapOf(Tier.LOW to true), + ) + + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + coVerify(atLeast = 1) { s.preload.preloadAllPaywalls(any(), any()) } + } + + @Test + fun `getAssignments success triggers PreloadIfEnabled`() = + runTest(timeout = 30.seconds) { + val configWithTriggers = + Config.stub().copy(triggers = setOf(Trigger.stub().copy(eventName = "evt"))) + val assignments = + mockk(relaxed = true) { + coEvery { getAssignments(any()) } returns Either.Success(emptyList()) + } + val s = + setup( + backgroundScope, + shouldPreload = true, + assignments = assignments, + ) + s.manager.applyRetrievedConfigForTesting(configWithTriggers) + // Reset the mock so we only count post-assignment preloads. + io.mockk.clearMocks(s.preload, answers = false) + coEvery { s.preload.preloadAllPaywalls(any(), any()) } just Runs + coEvery { s.preload.preloadPaywallsByNames(any(), any()) } just Runs + coEvery { s.preload.removeUnusedPaywallVCsFromCache(any(), any()) } just Runs + + s.manager.getAssignments() + advanceUntilIdle() + + coVerify(atLeast = 1) { s.preload.preloadAllPaywalls(any(), any()) } + } + + @Test + fun `getAssignments suspends until config is Retrieved`() = + runTest(timeout = 30.seconds) { + val assignments = mockk(relaxed = true) { + coEvery { getAssignments(any()) } returns Either.Success(emptyList()) + } + val s = setup(backgroundScope, assignments = assignments) + + val job = launch { s.manager.getAssignments() } + delay(50) + assertTrue("getAssignments should still be suspended", job.isActive) + coVerify(exactly = 0) { assignments.getAssignments(any()) } + + s.manager.applyRetrievedConfigForTesting( + Config.stub().copy(triggers = setOf(Trigger.stub().copy(eventName = "e"))), + ) + job.join() + advanceUntilIdle() + + coVerify(atLeast = 1) { assignments.getAssignments(any()) } + } + + @Test + fun `getAssignments with no triggers does not hit the network`() = + runTest(timeout = 30.seconds) { + val assignments = mockk(relaxed = true) + val s = setup(backgroundScope, assignments = assignments) + s.manager.applyRetrievedConfigForTesting(Config.stub().copy(triggers = emptySet())) + + s.manager.getAssignments() + advanceUntilIdle() + + coVerify(exactly = 0) { assignments.getAssignments(any()) } + } + + @Test + fun `getAssignments network error is swallowed and state stays Retrieved`() = + runTest(timeout = 30.seconds) { + val assignments = mockk(relaxed = true) { + coEvery { getAssignments(any()) } returns Either.Failure(NetworkError.Unknown()) + } + val s = setup(backgroundScope, assignments = assignments) + s.manager.applyRetrievedConfigForTesting( + Config.stub().copy(triggers = setOf(Trigger.stub().copy(eventName = "e"))), + ) + + s.manager.getAssignments() + advanceUntilIdle() + + assertTrue(s.manager.configState.value is ConfigState.Retrieved) + } + + @Test + fun `refreshConfiguration without retrieved config does not hit network`() = + runTest(timeout = 30.seconds) { + val s = setup(backgroundScope) + s.manager.refreshConfiguration() + advanceUntilIdle() + coVerify(exactly = 0) { s.network.getConfig(any()) } + } + + @Test + fun `refreshConfiguration with flag disabled and force false does not hit network`() = + runTest(timeout = 30.seconds) { + val s = setup(backgroundScope) + s.manager.applyRetrievedConfigForTesting(Config.stub()) // no enableConfigRefresh flag + io.mockk.clearMocks(s.network, answers = false) + coEvery { s.network.getConfig(any()) } returns Either.Success(Config.stub()) + + s.manager.refreshConfiguration(force = false) + advanceUntilIdle() + + coVerify(exactly = 0) { s.network.getConfig(any()) } + } + + @Test + fun `refreshConfiguration force true ignores disabled flag`() = + runTest(timeout = 30.seconds) { + val s = setup( + backgroundScope, + networkConfig = Either.Success(Config.stub().copy(buildId = "forced")), + ) + s.manager.applyRetrievedConfigForTesting(Config.stub()) + io.mockk.clearMocks(s.network, answers = false) + coEvery { s.network.getConfig(any()) } returns Either.Success(Config.stub().copy(buildId = "forced")) + coEvery { s.network.getEnrichment(any(), any(), any()) } returns Either.Success(Enrichment.stub()) + + s.manager.refreshConfiguration(force = true) + advanceUntilIdle() + + coVerify(atLeast = 1) { s.network.getConfig(any()) } + } + + @Test + fun `refreshConfiguration is noop when state is Retrieving or None`() = + runTest(timeout = 30.seconds) { + val s = setup(backgroundScope) + + s.manager.setConfigStateForTesting(ConfigState.Retrieving) + s.manager.refreshConfiguration(force = false) + advanceUntilIdle() + coVerify(exactly = 0) { s.network.getConfig(any()) } + + s.manager.setConfigStateForTesting(ConfigState.None) + s.manager.refreshConfiguration(force = false) + advanceUntilIdle() + coVerify(exactly = 0) { s.network.getConfig(any()) } + } + + @Test + fun `refreshConfiguration success resets paywall request cache and removes unused`() = + runTest(timeout = 30.seconds) { + val oldConfig = config(buildId = "old", enableRefresh = true) + val newConfig = config(buildId = "new", enableRefresh = true) + val paywallManager = mockk(relaxed = true) + val preload = mockk(relaxed = true) { + coEvery { preloadAllPaywalls(any(), any()) } just Runs + coEvery { preloadPaywallsByNames(any(), any()) } just Runs + coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs + } + val s = setup(backgroundScope) + val mgr = ConfigManagerForTest( + context = mockk(relaxed = true), + storage = s.storage, + network = mockk { + coEvery { getConfig(any()) } returns Either.Success(newConfig) + coEvery { getEnrichment(any(), any(), any()) } returns Either.Success(Enrichment.stub()) + }, + deviceHelper = s.deviceHelper, + paywallManager = paywallManager, + storeManager = s.storeManager, + preload = preload, + webRedeemer = s.webRedeemer, + factory = mockk(relaxed = true) { + coEvery { makeSessionDeviceAttributes() } returns HashMap() + }, + entitlements = mockk(relaxed = true) { + every { status } returns kotlinx.coroutines.flow.MutableStateFlow(SubscriptionStatus.Unknown) + every { entitlementsByProductId } returns emptyMap() + }, + assignments = mockk(relaxed = true), + options = SuperwallOptions().apply { paywalls.shouldPreload = false }, + ioScope = backgroundScope, + testMode = null, + tracker = {}, + setSubscriptionStatus = null, + activateTestMode = { _, _ -> }, + ) + mgr.applyRetrievedConfigForTesting(oldConfig) + + mgr.refreshConfiguration() + advanceUntilIdle() + + verify(atLeast = 1) { paywallManager.resetPaywallRequestCache() } + coVerify(atLeast = 1) { preload.removeUnusedPaywallVCsFromCache(oldConfig, newConfig) } + } + + @Test + fun `refreshConfig failure preserves Retrieved state`() = + runTest(timeout = 30.seconds) { + val oldConfig = config(buildId = "old", enableRefresh = true) + val s = setup( + backgroundScope, + networkConfig = Either.Failure(NetworkError.Unknown()), + ) + s.manager.applyRetrievedConfigForTesting(oldConfig) + + s.manager.refreshConfiguration(force = true) + advanceUntilIdle() + + assertTrue(s.manager.configState.value is ConfigState.Retrieved) + assertEquals("old", s.manager.config?.buildId) + } + + @Test + fun `reset without config does not preload`() = + runTest(timeout = 30.seconds) { + val s = setup(backgroundScope) + s.manager.reset() + advanceUntilIdle() + coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any()) } + } + + @Test + fun `reset with config rebuilds assignments synchronously`() = + runTest(timeout = 30.seconds) { + val assignments = mockk(relaxed = true) + val s = setup(backgroundScope, assignments = assignments) + s.manager.applyRetrievedConfigForTesting(Config.stub()) + io.mockk.clearMocks(assignments, answers = false) + io.mockk.justRun { assignments.reset() } + io.mockk.justRun { assignments.choosePaywallVariants(any()) } + + s.manager.reset() + verify(exactly = 1) { assignments.reset() } + verify(exactly = 1) { assignments.choosePaywallVariants(any()) } + } + + @Test + fun `preloadAllPaywalls suspends until config is Retrieved`() = + runTest(timeout = 30.seconds) { + val s = setup(backgroundScope) + val job = launch { s.manager.preloadAllPaywalls() } + delay(50) + coVerify(exactly = 0) { s.preload.preloadAllPaywalls(any(), any()) } + + val cfg = Config.stub().copy(buildId = "preload-all") + s.manager.applyRetrievedConfigForTesting(cfg) + job.join() + advanceUntilIdle() + + coVerify(exactly = 1) { s.preload.preloadAllPaywalls(eq(cfg), any()) } + } + + @Test + fun `preloadPaywallsByNames suspends until config is Retrieved`() = + runTest(timeout = 30.seconds) { + val s = setup(backgroundScope) + val names = setOf("evt") + val job = launch { s.manager.preloadPaywallsByNames(names) } + delay(50) + coVerify(exactly = 0) { s.preload.preloadPaywallsByNames(any(), any()) } + + val cfg = Config.stub().copy(buildId = "preload-named") + s.manager.applyRetrievedConfigForTesting(cfg) + job.join() + advanceUntilIdle() + + coVerify(exactly = 1) { s.preload.preloadPaywallsByNames(eq(cfg), eq(names)) } + } + + @Test + fun `fetchConfiguration updates trigger cache and persists feature flags`() = + runTest(timeout = 30.seconds) { + val cfg = Config.stub().copy( + triggers = setOf(Trigger.stub().copy(eventName = "evt_a")), + rawFeatureFlags = listOf( + RawFeatureFlag("enable_config_refresh_v2", true), + RawFeatureFlag("disable_verbose_events", true), + ), + ) + val s = setup(backgroundScope, networkConfig = Either.Success(cfg)) + + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + assertEquals(setOf("evt_a"), s.manager.triggersByEventName.keys) + verify { s.storage.write(DisableVerboseEvents, true) } + verify { s.storage.write(LatestConfig, cfg) } + } + + @Test + fun `fetchConfiguration loads purchased products when not in test mode`() = + runTest(timeout = 30.seconds) { + val storeManager = mockk(relaxed = true) { + coEvery { products(any()) } returns emptySet() + coEvery { loadPurchasedProducts(any()) } just Runs + } + val s = setup(backgroundScope, storeManagerOverride = storeManager) + + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + coVerify(atLeast = 1) { storeManager.loadPurchasedProducts(any()) } + } + + @Test + fun `fetchConfiguration redeems existing web entitlements when not in test mode`() = + runTest(timeout = 30.seconds) { + val s = setup(backgroundScope) + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + coVerify(atLeast = 1) { s.webRedeemer.redeem(WebPaywallRedeemer.RedeemType.Existing) } + } + + @Test + fun `fetchConfiguration preloads products when preloading enabled`() = + runTest(timeout = 30.seconds) { + val cfg = Config.stub().copy( + paywalls = listOf( + com.superwall.sdk.models.paywall.Paywall.stub().copy(productIds = listOf("a", "b")), + com.superwall.sdk.models.paywall.Paywall.stub().copy(productIds = listOf("b", "c")), + ), + ) + val storeManager = mockk(relaxed = true) { + coEvery { products(any()) } returns emptySet() + coEvery { loadPurchasedProducts(any()) } just Runs + } + val s = setup( + backgroundScope, + networkConfig = Either.Success(cfg), + shouldPreload = true, + storeManagerOverride = storeManager, + ) + + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + coVerify(exactly = 1) { + storeManager.products(match { it == setOf("a", "b", "c") }) + } + } + + @Test + fun `fetchConfiguration emits Retrieving then Failed without cache`() = + runTest(timeout = 30.seconds) { + val s = setup( + backgroundScope, + networkConfig = Either.Failure(NetworkError.Unknown()), + ) + val states = CopyOnWriteArrayList() + val collector = CoroutineScope(Dispatchers.Unconfined).launch { + s.manager.configState.collect { states.add(it) } + } + s.manager.fetchConfiguration() + advanceUntilIdle() + collector.cancel() + + assertTrue("Expected Retrieving in lineage, got $states", states.any { it is ConfigState.Retrieving }) + assertTrue("Expected last state Failed, got ${states.last()}", states.last() is ConfigState.Failed) + } + + @Test + fun `cached config wins when network getConfig returns Failure`() = + runTest(timeout = 30.seconds) { + val cached = config(buildId = "cached", enableRefresh = true) + val s = setup( + backgroundScope, + cachedConfig = cached, + cachedEnrichment = Enrichment.stub(), + networkConfig = Either.Failure(NetworkError.Unknown()), + ) + + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + assertEquals("cached", s.manager.config?.buildId) + } + + @Test + fun `quick network success returns fresh config`() = + runTest(timeout = 30.seconds) { + val fresh = Config.stub().copy(buildId = "fresh") + val s = setup(backgroundScope, networkConfig = Either.Success(fresh)) + + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + assertEquals("fresh", s.manager.config?.buildId) + } + + @Test + fun `cached path with delayed network falls back to cache`() = + runTest(timeout = 30.seconds) { + val cached = config(buildId = "cached", enableRefresh = true) + val s = setup( + backgroundScope, + cachedConfig = cached, + cachedEnrichment = Enrichment.stub(), + networkConfig = Either.Failure(NetworkError.Unknown()), + ) + + s.manager.fetchConfiguration() + s.manager.configState.first { (it as? ConfigState.Retrieved)?.config?.buildId == "cached" } + advanceUntilIdle() + + assertEquals("cached", s.manager.config?.buildId) + } + + @Test + fun `network retry callback transitions state to Retrying`() = + runTest(timeout = 30.seconds) { + val retries = AtomicInteger(0) + val s = setup( + backgroundScope, + networkConfigAnswer = { cb -> + cb() + cb() + retries.set(2) + Either.Success(Config.stub()) + }, + ) + val seen = CopyOnWriteArrayList() + val collector = CoroutineScope(Dispatchers.Unconfined).launch { + s.manager.configState.collect { seen.add(it) } + } + s.manager.fetchConfiguration() + advanceUntilIdle() + collector.cancel() + + assertEquals(2, retries.get()) + assertTrue("Expected Retrying in $seen", seen.any { it is ConfigState.Retrying }) + } + + @Test + fun `cached config success preloads before refresh`() = + runTest(timeout = 30.seconds) { + val cached = config(buildId = "cached", enableRefresh = true) + val fresh = config(buildId = "fresh", enableRefresh = true) + val getCalls = AtomicInteger(0) + val s = setup( + backgroundScope, + cachedConfig = cached, + cachedEnrichment = Enrichment.stub(), + shouldPreload = true, + networkConfigAnswer = { + val n = getCalls.incrementAndGet() + if (n == 1) Either.Failure(NetworkError.Unknown()) // cached fallback wins + else Either.Success(fresh) + }, + ) + + s.manager.fetchConfiguration() + s.manager.configState.first { (it as? ConfigState.Retrieved)?.config?.buildId == "fresh" } + advanceUntilIdle() + + coVerifyOrder { + s.preload.preloadAllPaywalls(any(), any()) + s.network.getConfig(any()) + } + assertTrue(getCalls.get() >= 2) + } + + @Test + fun `concurrent fetchConfiguration calls dedup while Retrieving`() = + runTest(timeout = 30.seconds) { + val calls = AtomicInteger(0) + val s = setup( + backgroundScope, + networkConfigAnswer = { + calls.incrementAndGet() + delay(300) + Either.Success(Config.stub()) + }, + ) + + val first = launch { s.manager.fetchConfiguration() } + s.manager.configState.first { it is ConfigState.Retrieving } + s.manager.fetchConfiguration() // must early-return + first.join() + + assertEquals(1, calls.get()) + } + + @Test + fun `reevaluateTestMode flips state synchronously`() = + runTest(timeout = 30.seconds) { + val storage = mockk(relaxed = true) + val testMode = TestMode(storage = storage, isTestEnvironment = false) + val s = setup( + backgroundScope, + injectedTestMode = testMode, + testModeBehavior = TestModeBehavior.ALWAYS, + ) + assertFalse(testMode.isTestMode) + + s.manager.reevaluateTestMode(config = Config.stub(), appUserId = "u") + assertTrue(testMode.isTestMode) + } + + @Test + fun `applyConfig side effects happen before Retrieved`() = + runTest(timeout = 30.seconds) { + val cfg = Config.stub().copy( + triggers = setOf(Trigger.stub().copy(eventName = "evt")), + rawFeatureFlags = listOf(RawFeatureFlag("disable_verbose_events", true)), + ) + val s = setup(backgroundScope, networkConfig = Either.Success(cfg)) + + val triggersAtRetrieved = mutableListOf() + val collector = launch { + s.manager.configState.first { it is ConfigState.Retrieved } + triggersAtRetrieved.addAll(s.manager.triggersByEventName.keys) + } + s.manager.fetchConfiguration() + collector.join() + + assertTrue(triggersAtRetrieved.contains("evt")) + verify { s.storage.write(DisableVerboseEvents, true) } + } + + @Test + fun `applyConfig skips LatestConfig write when refresh flag off`() = + runTest(timeout = 30.seconds) { + val s = setup(backgroundScope, networkConfig = Either.Success(Config.stub())) + + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + verify(exactly = 0) { s.storage.write(LatestConfig, any()) } + verify { s.storage.write(DisableVerboseEvents, any()) } + } + + @Test + fun `applyConfig with null testMode loads purchased products`() = + runTest(timeout = 30.seconds) { + val storeManager = mockk(relaxed = true) { + coEvery { loadPurchasedProducts(any()) } just Runs + coEvery { products(any()) } returns emptySet() + } + val s = setup(backgroundScope, injectedTestMode = null, storeManagerOverride = storeManager) + + s.manager.fetchConfiguration() + s.manager.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + coVerify(atLeast = 1) { storeManager.loadPurchasedProducts(any()) } + } + + @Test + fun `applyConfig testMode just-activated publishes subscription status`() = + runTest(timeout = 30.seconds) { + val storage = mockk(relaxed = true) + val testMode = TestMode(storage = storage, isTestEnvironment = false) + val s = setup( + backgroundScope, + injectedTestMode = testMode, + testModeBehavior = TestModeBehavior.ALWAYS, + ) + assertFalse(testMode.isTestMode) + + s.manager.fetchConfiguration() + advanceUntilIdle() + + assertTrue(testMode.isTestMode) + assertTrue(testMode.overriddenSubscriptionStatus != null) + } + + @Test + fun `applyConfig deactivates testMode when user no longer qualifies`() = + runTest(timeout = 30.seconds) { + val storage = mockk(relaxed = true) + val testMode = spyk(TestMode(storage = storage, isTestEnvironment = false)) + testMode.evaluateTestMode( + Config.stub(), "com.app", null, null, + testModeBehavior = TestModeBehavior.ALWAYS, + ) + assertTrue(testMode.isTestMode) + + val s = setup( + backgroundScope, + injectedTestMode = testMode, + testModeBehavior = TestModeBehavior.AUTOMATIC, + ) + + s.manager.fetchConfiguration() + advanceUntilIdle() + + assertFalse(testMode.isTestMode) + verify(atLeast = 1) { testMode.clearTestModeState() } + } + + @Test + fun `enrichment failure with cached fallback uses cache and schedules retry`() = + runTest(timeout = 30.seconds) { + val cachedEnrichment = Enrichment.stub() + val cached = config(enableRefresh = true) + val helper = mockk(relaxed = true) { + every { appVersion } returns "1.0" + every { locale } returns "en-US" + every { deviceTier } returns Tier.MID + every { bundleId } returns "com.test" + every { setEnrichment(any()) } just Runs + coEvery { getTemplateDevice() } returns emptyMap() + coEvery { getEnrichment(any(), any()) } returns Either.Failure(NetworkError.Unknown()) + } + val s = setup( + backgroundScope, + cachedConfig = cached, + cachedEnrichment = cachedEnrichment, + ) + val mgr = ConfigManagerForTest( + context = mockk(relaxed = true), + storage = s.storage, + network = s.network, + deviceHelper = helper, + paywallManager = mockk(relaxed = true), + storeManager = s.storeManager, + preload = s.preload, + webRedeemer = s.webRedeemer, + factory = mockk(relaxed = true) { + coEvery { makeSessionDeviceAttributes() } returns HashMap() + }, + entitlements = mockk(relaxed = true) { + every { status } returns kotlinx.coroutines.flow.MutableStateFlow(SubscriptionStatus.Unknown) + every { entitlementsByProductId } returns emptyMap() + }, + assignments = mockk(relaxed = true), + options = SuperwallOptions(), + ioScope = backgroundScope, + testMode = null, + tracker = {}, + setSubscriptionStatus = null, + activateTestMode = { _, _ -> }, + ) + + mgr.fetchConfiguration() + mgr.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + verify { helper.setEnrichment(cachedEnrichment) } + coVerify(atLeast = 1) { helper.getEnrichment(6, 1.seconds) } + } + + @Test + fun `enrichment failure with no cache still reaches Retrieved`() = + runTest(timeout = 30.seconds) { + val helper = mockk(relaxed = true) { + every { appVersion } returns "1.0" + every { locale } returns "en-US" + every { deviceTier } returns Tier.MID + every { bundleId } returns "com.test" + every { setEnrichment(any()) } just Runs + coEvery { getTemplateDevice() } returns emptyMap() + coEvery { getEnrichment(any(), any()) } returns Either.Failure(NetworkError.Unknown()) + } + val s = setup(backgroundScope) + val mgr = ConfigManagerForTest( + context = mockk(relaxed = true), + storage = s.storage, + network = s.network, + deviceHelper = helper, + paywallManager = mockk(relaxed = true), + storeManager = s.storeManager, + preload = s.preload, + webRedeemer = s.webRedeemer, + factory = mockk(relaxed = true) { + coEvery { makeSessionDeviceAttributes() } returns HashMap() + }, + entitlements = mockk(relaxed = true) { + every { status } returns kotlinx.coroutines.flow.MutableStateFlow(SubscriptionStatus.Unknown) + every { entitlementsByProductId } returns emptyMap() + }, + assignments = mockk(relaxed = true), + options = SuperwallOptions(), + ioScope = backgroundScope, + testMode = null, + tracker = {}, + setSubscriptionStatus = null, + activateTestMode = { _, _ -> }, + ) + + mgr.fetchConfiguration() + mgr.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + coVerify(atLeast = 1) { helper.getEnrichment(6, 1.seconds) } + } + + @Test + fun `cached path retry callback invokes awaitUtilNetwork`() = + runTest(timeout = 30.seconds) { + val cached = config(enableRefresh = true) + val awaitCalls = AtomicInteger(0) + val network = mockk { + coEvery { getConfig(any()) } coAnswers { + val cb = firstArg Unit>() + cb() + Either.Success(cached) + } + coEvery { getEnrichment(any(), any(), any()) } returns Either.Success(Enrichment.stub()) + } + val storage = mockk(relaxed = true) { + every { read(LatestConfig) } returns cached + every { read(LatestEnrichment) } returns null + every { write(any(), any()) } just Runs + } + val mgr = ConfigManagerForTest( + context = mockk(relaxed = true), + storage = storage, + network = network, + deviceHelper = mockk(relaxed = true) { + every { appVersion } returns "1.0" + every { locale } returns "en-US" + every { deviceTier } returns Tier.MID + every { bundleId } returns "com.test" + every { setEnrichment(any()) } just Runs + coEvery { getTemplateDevice() } returns emptyMap() + coEvery { getEnrichment(any(), any()) } returns Either.Success(Enrichment.stub()) + }, + paywallManager = mockk(relaxed = true), + storeManager = mockk(relaxed = true) { + coEvery { products(any()) } returns emptySet() + coEvery { loadPurchasedProducts(any()) } just Runs + }, + preload = mockk(relaxed = true) { + coEvery { preloadAllPaywalls(any(), any()) } just Runs + coEvery { preloadPaywallsByNames(any(), any()) } just Runs + coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs + }, + webRedeemer = mockk(relaxed = true), + factory = mockk(relaxed = true) { + coEvery { makeSessionDeviceAttributes() } returns HashMap() + }, + entitlements = mockk(relaxed = true) { + every { status } returns kotlinx.coroutines.flow.MutableStateFlow(SubscriptionStatus.Unknown) + every { entitlementsByProductId } returns emptyMap() + }, + assignments = mockk(relaxed = true), + options = SuperwallOptions(), + ioScope = backgroundScope, + testMode = null, + tracker = {}, + setSubscriptionStatus = null, + activateTestMode = { _, _ -> }, + awaitUtilNetwork = { awaitCalls.incrementAndGet() }, + ) + + mgr.fetchConfiguration() + mgr.configState.first { it is ConfigState.Retrieved } + advanceUntilIdle() + + assertTrue("awaitUtilNetwork must fire on retry callback, got ${awaitCalls.get()}", awaitCalls.get() >= 1) + } + + @Test + fun `config getter on Failed returns null and dispatches a refetch`() = + runTest(timeout = 30.seconds) { + val calls = AtomicInteger(0) + val s = setup( + backgroundScope, + networkConfigAnswer = { + val n = calls.incrementAndGet() + if (n == 1) Either.Failure(NetworkError.Unknown()) + else Either.Success(Config.stub()) + }, + ) + s.manager.setConfigStateForTesting(ConfigState.Failed(Exception("boom"))) + assertEquals(null, s.manager.config) + advanceUntilIdle() + assertTrue("Expected getter to trigger a refetch, calls=${calls.get()}", calls.get() >= 1) + } + + @Test + fun `config getter on Retrieved does not dispatch fetch`() = + runTest(timeout = 30.seconds) { + val s = setup(backgroundScope) + s.manager.applyRetrievedConfigForTesting(Config.stub()) + io.mockk.clearMocks(s.network, answers = false) + coEvery { s.network.getConfig(any()) } returns Either.Success(Config.stub()) + + repeat(5) { s.manager.config } + advanceUntilIdle() + coVerify(exactly = 0) { s.network.getConfig(any()) } + } + + @Test + fun `hasConfig emits when config is set`() = + runTest(timeout = 30.seconds) { + val s = setup(backgroundScope) + val expected = Config.stub().copy(buildId = "has-config") + val emitted = launch { + assertEquals(expected.buildId, s.manager.hasConfig.first().buildId) + } + s.manager.applyRetrievedConfigForTesting(expected) + advanceUntilIdle() + emitted.join() + } + + @Test + fun `hasConfig emits exactly once`() = + runTest(timeout = 30.seconds) { + val s = setup(backgroundScope) + val emissions = mutableListOf() + val collector = launch { + s.manager.hasConfig.collect { emissions.add(it) } + } + s.manager.applyRetrievedConfigForTesting(Config.stub().copy(buildId = "first")) + advanceUntilIdle() + s.manager.setConfigStateForTesting(ConfigState.None) + advanceUntilIdle() + s.manager.applyRetrievedConfigForTesting(Config.stub().copy(buildId = "second")) + advanceUntilIdle() + collector.cancel() + + assertEquals(1, emissions.size) + assertEquals("first", emissions.single().buildId) + } +} + +/** + * Test-only ConfigManager subclass that hardwires the actor and exposes the + * test-only `applyRetrievedConfigForTesting` helper. + */ +internal class ConfigManagerForTest( + context: Context, + storage: Storage, + network: SuperwallAPI, + deviceHelper: DeviceHelper, + paywallManager: PaywallManager, + storeManager: StoreManager, + preload: PaywallPreload, + webRedeemer: WebPaywallRedeemer, + factory: ConfigManager.Factory, + entitlements: Entitlements, + assignments: Assignments, + options: SuperwallOptions, + ioScope: CoroutineScope, + testMode: TestMode?, + tracker: suspend (TrackableSuperwallEvent) -> Unit, + setSubscriptionStatus: ((SubscriptionStatus) -> Unit)?, + activateTestMode: suspend (Config, Boolean) -> Unit, + identityManager: (() -> IdentityManager)? = null, + awaitUtilNetwork: suspend () -> Unit = {}, +) : ConfigManager( + context = context, + storeManager = storeManager, + entitlements = entitlements, + storage = storage, + network = network, + deviceHelper = deviceHelper, + options = options, + paywallManager = paywallManager, + webPaywallRedeemer = { webRedeemer }, + factory = factory, + assignments = assignments, + paywallPreload = preload, + ioScope = IOScope(Dispatchers.Unconfined), + tracker = tracker, + testMode = testMode, + identityManager = identityManager, + setSubscriptionStatus = setSubscriptionStatus, + awaitUtilNetwork = awaitUtilNetwork, + activateTestMode = activateTestMode, + actor = SequentialActor(ConfigState.None, CoroutineScope(Dispatchers.Unconfined)), + ) diff --git a/superwall/src/test/java/com/superwall/sdk/config/ConfigStateReducerTest.kt b/superwall/src/test/java/com/superwall/sdk/config/ConfigStateReducerTest.kt new file mode 100644 index 000000000..8ca97c1a0 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/config/ConfigStateReducerTest.kt @@ -0,0 +1,93 @@ +package com.superwall.sdk.config + +import com.superwall.sdk.config.models.ConfigState +import com.superwall.sdk.models.config.Config +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure unit tests for [ConfigState.Updates]. Reducers are `(ConfigState) -> ConfigState` + * with no side effects, so they can be exercised without an actor, scope, or context. + * + * These guard the trivial state shape rather than behavior — if someone adds a field + * to ConfigState or subtly changes the phase model, they'll fail fast here instead of + * waiting for an integration test to catch it. + */ +class ConfigStateReducerTest { + private val stubConfig = Config.stub() + private val stubError = RuntimeException("boom") + + @Test + fun `SetRetrieving replaces any prior state with Retrieving`() { + val inputs: List = + listOf( + ConfigState.None, + ConfigState.Retrieving, + ConfigState.Retrying, + ConfigState.Retrieved(stubConfig), + ConfigState.Failed(stubError), + ) + inputs.forEach { input -> + val out = ConfigState.Updates.SetRetrieving.reduce(input) + assertSame("SetRetrieving from $input", ConfigState.Retrieving, out) + } + } + + @Test + fun `SetRetrying replaces any prior state with Retrying`() { + val inputs: List = + listOf( + ConfigState.None, + ConfigState.Retrieving, + ConfigState.Retrying, + ConfigState.Retrieved(stubConfig), + ConfigState.Failed(stubError), + ) + inputs.forEach { input -> + val out = ConfigState.Updates.SetRetrying.reduce(input) + assertSame("SetRetrying from $input", ConfigState.Retrying, out) + } + } + + @Test + fun `SetRetrieved carries the config payload and overwrites any prior state`() { + val next = ConfigState.Updates.SetRetrieved(stubConfig).reduce(ConfigState.Retrieving) + assertTrue(next is ConfigState.Retrieved) + assertEquals(stubConfig, (next as ConfigState.Retrieved).config) + + // Also from Failed (cold-start recovery path). + val next2 = ConfigState.Updates.SetRetrieved(stubConfig).reduce(ConfigState.Failed(stubError)) + assertTrue(next2 is ConfigState.Retrieved) + assertEquals(stubConfig, (next2 as ConfigState.Retrieved).config) + } + + @Test + fun `SetFailed carries the throwable payload`() { + val err = IllegalStateException("oops") + val next = ConfigState.Updates.SetFailed(err).reduce(ConfigState.Retrieving) + assertTrue(next is ConfigState.Failed) + assertEquals(err, (next as ConfigState.Failed).throwable) + } + + @Test + fun `Set forces any prior state to the supplied state — test-only escape hatch`() { + val target = ConfigState.Retrieved(stubConfig) + val out = ConfigState.Updates.Set(target).reduce(ConfigState.Failed(stubError)) + assertSame(target, out) + } + + @Test + fun `Updates are pure — invoking twice on the same input yields the same output`() { + val input = ConfigState.None + val a = ConfigState.Updates.SetRetrieving.reduce(input) + val b = ConfigState.Updates.SetRetrieving.reduce(input) + assertSame(a, b) + + val c = ConfigState.Updates.SetRetrieved(stubConfig).reduce(input) + val d = ConfigState.Updates.SetRetrieved(stubConfig).reduce(input) + // Retrieved uses data class equality, not identity — assertEquals is the right check. + assertEquals(c, d) + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt index 318ac5a53..bda46571b 100644 --- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt @@ -119,7 +119,7 @@ class IdentityActorIntegrationTest { ioScope = IOScope(scope.coroutineContext), notifyUserChange = {}, completeReset = { resetCalled = true }, - trackEvent = { trackedEvents.add(it) }, + tracker = { trackedEvents.add(it) }, webPaywallRedeemer = { mockk(relaxed = true) }, actor = actor, sdkContext = sdkContext, @@ -300,7 +300,7 @@ class IdentityActorIntegrationTest { ioScope = IOScope(testActorScope().coroutineContext), notifyUserChange = {}, completeReset = { resetCount++ }, - trackEvent = { trackedEvents.add(it) }, + tracker = { trackedEvents.add(it) }, webPaywallRedeemer = { mockk(relaxed = true) }, actor = actor, sdkContext = sdkContext, diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt index ee13acaa5..90c4cbec8 100644 --- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt @@ -115,7 +115,7 @@ class IdentityManagerTest { ioScope = scope, notifyUserChange = { notifiedChanges.add(it) }, completeReset = { resetCalled = true }, - trackEvent = { trackedEvents.add(it) }, + tracker = { trackedEvents.add(it) }, webPaywallRedeemer = { mockk(relaxed = true) }, actor = testIdentityActor(), sdkContext = mockk(relaxed = true), @@ -140,7 +140,7 @@ class IdentityManagerTest { ioScope = ioScope, notifyUserChange = { notifiedChanges.add(it) }, completeReset = { resetCalled = true }, - trackEvent = { trackedEvents.add(it) }, + tracker = { trackedEvents.add(it) }, webPaywallRedeemer = { mockk(relaxed = true) }, actor = testIdentityActor(), sdkContext = mockk(relaxed = true), @@ -340,7 +340,7 @@ class IdentityManagerTest { stringToSha = { "sha256-of-$it" }, notifyUserChange = {}, completeReset = {}, - trackEvent = {}, + tracker = {}, webPaywallRedeemer = { mockk(relaxed = true) }, actor = testIdentityActor(), sdkContext = mockk(relaxed = true), @@ -562,6 +562,7 @@ class IdentityManagerTest { shouldTrackMerge = true, ) manager.awaitLatestIdentity() + advanceUntilIdle() } Then("an Attributes event is tracked") { @@ -721,6 +722,7 @@ class IdentityManagerTest { When("identify is called with a new userId") { manager.identify("user-track-test") manager.awaitLatestIdentity() + advanceUntilIdle() } Then("an IdentityAlias event is tracked") { @@ -785,7 +787,7 @@ class IdentityManagerTest { ioScope = IOScope(this@runTest.coroutineContext), notifyUserChange = { notifiedChanges.add(it) }, completeReset = { resetCalled = true }, - trackEvent = { trackedEvents.add(it) }, + tracker = { trackedEvents.add(it) }, webPaywallRedeemer = { mockk(relaxed = true) }, actor = testIdentityActor(), sdkContext = sdkContext, diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt index 67507a2d8..3a46fbdc6 100644 --- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt @@ -92,7 +92,7 @@ class IdentityManagerUserAttributesTest { ioScope = IOScope(scope.coroutineContext), notifyUserChange = {}, completeReset = { resetCalled = true }, - trackEvent = { trackedEvents.add(it) }, + tracker = { trackedEvents.add(it) }, webPaywallRedeemer = { mockk(relaxed = true) }, actor = testActor(), sdkContext = mockk(relaxed = true), @@ -129,7 +129,7 @@ class IdentityManagerUserAttributesTest { ioScope = ioScope, notifyUserChange = {}, completeReset = { resetCalled = true }, - trackEvent = { trackedEvents.add(it) }, + tracker = { trackedEvents.add(it) }, webPaywallRedeemer = { mockk(relaxed = true) }, actor = testActor(), sdkContext = mockk(relaxed = true), diff --git a/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeTest.kt similarity index 86% rename from superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeManagerTest.kt rename to superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeTest.kt index 370d0b069..99ede9666 100644 --- a/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeTest.kt @@ -28,13 +28,13 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -class TestModeManagerTest { +class TestModeTest { private fun makeStorage(): Storage = mockk(relaxed = true) private fun makeManager( storage: Storage = makeStorage(), isTestEnvironment: Boolean = false, - ): TestModeManager = TestModeManager(storage, isTestEnvironment) + ): TestMode = TestMode(storage, isTestEnvironment) private fun makeConfig( bundleIdConfig: String? = null, @@ -64,7 +64,7 @@ class TestModeManagerTest { ) /** Activates test mode via ALWAYS behavior so session data can be written. */ - private fun activateTestMode(manager: TestModeManager) { + private fun activateTestMode(manager: TestMode) { manager.evaluateTestMode( makeConfig(), "com.app", @@ -78,7 +78,7 @@ class TestModeManagerTest { @Test fun `evaluateTestMode activates for applicationId mismatch`() { - Given("a TestModeManager with a config that has a different applicationId") { + Given("a TestMode with a config that has a different applicationId") { val manager = makeManager() val config = makeConfig(bundleIdConfig = "com.expected.app") @@ -98,7 +98,7 @@ class TestModeManagerTest { @Test fun `evaluateTestMode does not activate for matching bundleId`() { - Given("a TestModeManager with a config that has the same applicationId") { + Given("a TestMode with a config that has the same applicationId") { val manager = makeManager() val config = makeConfig(bundleIdConfig = "com.myapp") @@ -149,7 +149,7 @@ class TestModeManagerTest { @Test fun `evaluateTestMode activates for userId match`() { - Given("a TestModeManager with a config containing a matching userId") { + Given("a TestMode with a config containing a matching userId") { val manager = makeManager() val config = makeConfig( @@ -174,7 +174,7 @@ class TestModeManagerTest { @Test fun `evaluateTestMode activates for aliasId match`() { - Given("a TestModeManager with a config containing a matching aliasId") { + Given("a TestMode with a config containing a matching aliasId") { val manager = makeManager() val config = makeConfig( @@ -225,7 +225,7 @@ class TestModeManagerTest { @Test fun `evaluateTestMode does not activate when no conditions match`() { - Given("a TestModeManager with a config with non-matching users") { + Given("a TestMode with a config with non-matching users") { val manager = makeManager() val config = makeConfig( @@ -490,7 +490,7 @@ class TestModeManagerTest { @Test fun `shouldShowFreeTrial applies UseDefault correctly`() { - Given("a TestModeManager with UseDefault override") { + Given("a TestMode with UseDefault override") { val manager = makeManager() activateTestMode(manager) manager.setFreeTrialOverride(FreeTrialOverride.UseDefault) @@ -504,7 +504,7 @@ class TestModeManagerTest { @Test fun `shouldShowFreeTrial applies ForceAvailable correctly`() { - Given("a TestModeManager with ForceAvailable override") { + Given("a TestMode with ForceAvailable override") { val manager = makeManager() activateTestMode(manager) manager.setFreeTrialOverride(FreeTrialOverride.ForceAvailable) @@ -518,7 +518,7 @@ class TestModeManagerTest { @Test fun `shouldShowFreeTrial applies ForceUnavailable correctly`() { - Given("a TestModeManager with ForceUnavailable override") { + Given("a TestMode with ForceUnavailable override") { val manager = makeManager() activateTestMode(manager) manager.setFreeTrialOverride(FreeTrialOverride.ForceUnavailable) @@ -532,7 +532,7 @@ class TestModeManagerTest { @Test fun `shouldShowFreeTrial returns default when inactive`() { - Given("a TestModeManager that is inactive") { + Given("a TestMode that is inactive") { val manager = makeManager() Then("it returns the original value regardless") { @@ -548,7 +548,7 @@ class TestModeManagerTest { @Test fun `fakePurchase adds entitlement IDs`() { - Given("a TestModeManager with active test mode") { + Given("a TestMode with active test mode") { val storage = makeStorage() val manager = makeManager(storage) activateTestMode(manager) @@ -572,7 +572,7 @@ class TestModeManagerTest { @Test fun `fakePurchase accumulates entitlements across calls`() { - Given("a TestModeManager with existing entitlements") { + Given("a TestMode with existing entitlements") { val manager = makeManager() activateTestMode(manager) manager.fakePurchase(listOf(SuperwallEntitlementRef("premium", null))) @@ -593,7 +593,7 @@ class TestModeManagerTest { @Test fun `setEntitlements replaces existing entitlements`() { - Given("a TestModeManager with existing entitlements") { + Given("a TestMode with existing entitlements") { val storage = makeStorage() val manager = makeManager(storage) activateTestMode(manager) @@ -612,7 +612,7 @@ class TestModeManagerTest { @Test fun `setEntitlements with empty set writes false`() { - Given("a TestModeManager with active test mode") { + Given("a TestMode with active test mode") { val storage = makeStorage() val manager = makeManager(storage) activateTestMode(manager) @@ -630,7 +630,7 @@ class TestModeManagerTest { @Test fun `resetEntitlements clears all and writes false`() { - Given("a TestModeManager with entitlements") { + Given("a TestMode with entitlements") { val storage = makeStorage() val manager = makeManager(storage) activateTestMode(manager) @@ -653,7 +653,7 @@ class TestModeManagerTest { @Test fun `buildSubscriptionStatus returns Active with entitlements`() { - Given("a TestModeManager with entitlements set") { + Given("a TestMode with entitlements set") { val manager = makeManager() activateTestMode(manager) manager.setEntitlements(setOf("premium", "pro")) @@ -674,7 +674,7 @@ class TestModeManagerTest { @Test fun `buildSubscriptionStatus returns Inactive when empty`() { - Given("a TestModeManager with no entitlements") { + Given("a TestMode with no entitlements") { val manager = makeManager() When("buildSubscriptionStatus is called") { @@ -693,7 +693,7 @@ class TestModeManagerTest { @Test fun `setProducts stores products`() { - Given("a TestModeManager with active test mode") { + Given("a TestMode with active test mode") { val manager = makeManager() activateTestMode(manager) val products = @@ -716,7 +716,7 @@ class TestModeManagerTest { @Test fun `setTestProducts stores test product map`() { - Given("a TestModeManager with active test mode") { + Given("a TestMode with active test mode") { val manager = makeManager() activateTestMode(manager) val sp = mockk(relaxed = true) @@ -739,7 +739,7 @@ class TestModeManagerTest { @Test fun `allEntitlements aggregates from all products`() { - Given("a TestModeManager with products that have entitlements") { + Given("a TestMode with products that have entitlements") { val manager = makeManager() activateTestMode(manager) manager.setProducts( @@ -775,7 +775,7 @@ class TestModeManagerTest { @Test fun `allEntitlements returns empty when no products`() { - Given("a TestModeManager with no products") { + Given("a TestMode with no products") { val manager = makeManager() Then("allEntitlements returns empty set") { @@ -786,7 +786,7 @@ class TestModeManagerTest { @Test fun `entitlementsForProduct returns entitlements for given product`() { - Given("a TestModeManager with a product that has entitlements") { + Given("a TestMode with a product that has entitlements") { val manager = makeManager() val product = makeSuperwallProduct( @@ -816,7 +816,7 @@ class TestModeManagerTest { @Test fun `setOverriddenSubscriptionStatus stores and clears status`() { - Given("a TestModeManager with active test mode") { + Given("a TestMode with active test mode") { val manager = makeManager() activateTestMode(manager) @@ -845,7 +845,7 @@ class TestModeManagerTest { @Test fun `clearTestModeState resets all fields`() { - Given("a TestModeManager with active test mode") { + Given("a TestMode with active test mode") { val storage = makeStorage() val manager = makeManager(storage) val config = @@ -883,7 +883,7 @@ class TestModeManagerTest { @Test fun `state transitions from Inactive to Active and back`() { - Given("a fresh TestModeManager") { + Given("a fresh TestMode") { val manager = makeManager() Then("initial state is Inactive") { @@ -924,7 +924,7 @@ class TestModeManagerTest { @Test fun `session data is only accessible when active`() { - Given("a fresh TestModeManager") { + Given("a fresh TestMode") { val manager = makeManager() Then("session data returns defaults when inactive") { @@ -962,4 +962,93 @@ class TestModeManagerTest { } // endregion + + // region activate() — UI flow wiring + + @Test + fun `activate refreshes products by calling getSuperwallProducts and filters to Android`() = + kotlinx.coroutines.test.runTest { + val storage = makeStorage() + val androidProduct = makeSuperwallProduct("prod-android") + val iosProduct = + SuperwallProduct( + identifier = "prod-ios", + platform = SuperwallProductPlatform.IOS, + price = SuperwallProductPrice(amount = 999, currency = "USD"), + ) + val response = + com.superwall.sdk.store.testmode.models.SuperwallProductsResponse( + data = listOf(androidProduct, iosProduct), + ) + var getProductsCalls = 0 + val manager = + TestMode( + storage = storage, + isTestEnvironment = false, + getSuperwallProducts = { + getProductsCalls++ + com.superwall.sdk.misc.Either.Success(response) + }, + // No activity available → activate short-circuits the modal step. + activityProvider = { null }, + activityTracker = { null }, + ) + activateTestMode(manager) + + // justActivated = false → only refreshes products, never tries the modal. + manager.activate(makeConfig(), justActivated = false) + + assertEquals(1, getProductsCalls) + assertEquals( + "Only Android products should be registered", + listOf("prod-android"), + manager.products.map { it.identifier }, + ) + } + + @Test + fun `activate with empty product response leaves the catalog empty`() = + kotlinx.coroutines.test.runTest { + val storage = makeStorage() + val manager = + TestMode( + storage = storage, + isTestEnvironment = false, + getSuperwallProducts = { + com.superwall.sdk.misc.Either.Success( + com.superwall.sdk.store.testmode.models.SuperwallProductsResponse(data = emptyList()), + ) + }, + ) + activateTestMode(manager) + + manager.activate(makeConfig(), justActivated = false) + + assertTrue(manager.products.isEmpty()) + } + + @Test + fun `activate swallows network failure and leaves products unchanged`() = + kotlinx.coroutines.test.runTest { + val storage = makeStorage() + val manager = + TestMode( + storage = storage, + isTestEnvironment = false, + getSuperwallProducts = { + com.superwall.sdk.misc.Either.Failure( + com.superwall.sdk.network.NetworkError.Unknown(), + ) + }, + ) + activateTestMode(manager) + // Seed a product so we can verify the failure path leaves it untouched. + manager.setProducts(listOf(makeSuperwallProduct("prev"))) + + manager.activate(makeConfig(), justActivated = false) + + assertEquals(listOf("prev"), manager.products.map { it.identifier }) + } + + // endregion } diff --git a/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeTransactionHandlerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeTransactionHandlerTest.kt index fbfbe2713..600b476e2 100644 --- a/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeTransactionHandlerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/store/testmode/TestModeTransactionHandlerTest.kt @@ -20,8 +20,8 @@ import org.junit.Assert.assertTrue import org.junit.Test class TestModeTransactionHandlerTest { - private fun makeTestModeManager(): TestModeManager { - val manager = TestModeManager(mockk(relaxed = true)) + private fun makeTestMode(): TestMode { + val manager = TestMode(mockk(relaxed = true)) // Activate test mode so session data can be written val config = mockk(relaxed = true) manager.evaluateTestMode(config, "com.app", null, null, TestModeBehavior.ALWAYS) @@ -48,7 +48,7 @@ class TestModeTransactionHandlerTest { @Test fun `findSuperwallProductForId returns matching product`() { Given("a TestModeTransactionHandler with products") { - val manager = makeTestModeManager() + val manager = makeTestMode() manager.setProducts( listOf( makeProduct("com.test.monthly"), @@ -71,7 +71,7 @@ class TestModeTransactionHandlerTest { @Test fun `findSuperwallProductForId returns null for unknown id`() { Given("a TestModeTransactionHandler with products") { - val manager = makeTestModeManager() + val manager = makeTestMode() manager.setProducts(listOf(makeProduct("com.test.monthly"))) val handler = TestModeTransactionHandler(manager, mockk(relaxed = true)) @@ -88,7 +88,7 @@ class TestModeTransactionHandlerTest { @Test fun `entitlementsForProduct returns entitlement set`() { Given("a handler with products that have entitlements") { - val manager = makeTestModeManager() + val manager = makeTestMode() manager.setProducts( listOf( makeProduct( @@ -118,7 +118,7 @@ class TestModeTransactionHandlerTest { @Test fun `entitlementsForProduct returns empty set for unknown product`() { Given("a handler with products") { - val manager = makeTestModeManager() + val manager = makeTestMode() manager.setProducts(listOf(makeProduct("com.test.monthly"))) val handler = TestModeTransactionHandler(manager, mockk(relaxed = true)) @@ -135,7 +135,7 @@ class TestModeTransactionHandlerTest { @Test fun `entitlementsForProduct returns empty set for product without entitlements`() { Given("a handler with a product that has no entitlements") { - val manager = makeTestModeManager() + val manager = makeTestMode() manager.setProducts( listOf( makeProduct("com.test.basic", entitlements = emptyList()),