From 81cbec883e266e9a1d3d1348c5cc1928ecf41d69 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 23 Mar 2026 16:36:30 +0100 Subject: [PATCH] Replace the old dispatcher+queue IdentityManager with a SequentialActor that serializes all identity mutations through a single mutex. - IdentityState: immutable data class with pure reducers (Updates) and async side-effecting actions (Actions) via IdentityContext - IdentityPersistenceInterceptor: diff-based storage writes on state change - SdkContext: cross-slice bridge so identity can call ConfigManager without a direct dependency - Reset flow gates identity readiness during cleanup, matching iOS - mergeAttributes aligned with iOS (no null filtering in nested collections) - Startup repair widened to handle empty stored attributes - Tracking/notify paths use enrichedAttributes consistently Includes pure reducer unit tests and SequentialActor integration tests covering concurrency, rapid identify/reset interleaving, and persistence. --- .../main/java/com/superwall/sdk/SdkContext.kt | 33 + .../main/java/com/superwall/sdk/Superwall.kt | 29 +- .../sdk/dependencies/DependencyContainer.kt | 84 +- .../superwall/sdk/identity/IdentityContext.kt | 23 + .../superwall/sdk/identity/IdentityLogic.kt | 13 +- .../superwall/sdk/identity/IdentityManager.kt | 356 ++------- .../sdk/identity/IdentityManagerActor.kt | 401 ++++++++++ .../IdentityPersistenceInterceptor.kt | 39 + .../superwall/sdk/misc/primitives/Actor.kt | 22 + .../sdk/misc/primitives/ActorTypes.kt | 45 ++ .../sdk/misc/primitives/BaseContext.kt | 32 + .../sdk/misc/primitives/DebugInterceptor.kt | 90 +++ .../superwall/sdk/misc/primitives/Reduce.kt | 11 + .../sdk/misc/primitives/StateActor.kt | 194 +++++ .../sdk/misc/primitives/StateStore.kt | 16 + .../sdk/misc/primitives/StoreContext.kt | 49 ++ .../sdk/misc/primitives/TypedAction.kt | 13 + .../identity/IdentityActorIntegrationTest.kt | 404 ++++++++++ .../sdk/identity/IdentityLogicEnhancedTest.kt | 8 +- .../sdk/identity/IdentityManagerTest.kt | 622 +++++++++++++-- .../IdentityManagerUserAttributesTest.kt | 118 ++- .../sdk/identity/IdentityStateReducerTest.kt | 724 ++++++++++++++++++ 22 files changed, 2854 insertions(+), 472 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/SdkContext.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/identity/IdentityPersistenceInterceptor.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/primitives/Actor.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/primitives/ActorTypes.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/primitives/BaseContext.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/primitives/DebugInterceptor.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/primitives/Reduce.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/primitives/StateActor.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/primitives/StateStore.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/primitives/StoreContext.kt create mode 100644 superwall/src/main/java/com/superwall/sdk/misc/primitives/TypedAction.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/identity/IdentityStateReducerTest.kt diff --git a/superwall/src/main/java/com/superwall/sdk/SdkContext.kt b/superwall/src/main/java/com/superwall/sdk/SdkContext.kt new file mode 100644 index 000000000..5e52d3785 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/SdkContext.kt @@ -0,0 +1,33 @@ +package com.superwall.sdk + +import com.superwall.sdk.config.ConfigManager +import com.superwall.sdk.misc.awaitFirstValidConfig +import com.superwall.sdk.models.config.Config + +/** + * Cross-slice bridge used by the identity actor to call into other managers. + * + * Keeps the identity slice decoupled from concrete manager types. + */ +interface SdkContext { + fun reevaluateTestMode(appUserId: String?, aliasId: String?) + + suspend fun fetchAssignments() + + suspend fun awaitConfig(): Config? +} + +class SdkContextImpl( + private val configManager: () -> ConfigManager, +) : SdkContext { + override fun reevaluateTestMode(appUserId: String?, aliasId: String?) { + configManager().reevaluateTestMode(appUserId = appUserId, aliasId = aliasId) + } + + override suspend fun fetchAssignments() { + configManager().getAssignments() + } + + override suspend fun awaitConfig(): Config? = + configManager().configState.awaitFirstValidConfig() +} diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 5b2034bde..eb0c553b1 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -682,7 +682,9 @@ class Superwall( } // Implicitly wait dependencyContainer.configManager.fetchConfiguration() - dependencyContainer.identityManager.configure() + dependencyContainer.identityManager.configure( + neverCalledStaticConfig = dependencyContainer.storage.neverCalledStaticConfig, + ) }.toResult().fold({ CoroutineScope(Dispatchers.Main).launch { completion?.invoke(Result.success(Unit)) @@ -829,16 +831,23 @@ class Superwall( /** * Asynchronously resets. Presentation of paywalls is suspended until reset completes. */ - internal fun reset(duringIdentify: Boolean) { + internal fun reset(duringIdentify: Boolean = false) { withErrorTracking { - dependencyContainer.identityManager.reset(duringIdentify) - dependencyContainer.storage.reset() - dependencyContainer.paywallManager.resetCache() - presentationItems.reset() - dependencyContainer.configManager.reset() - dependencyContainer.reedemer.clear(RedemptionOwnershipType.AppUser) - ioScope.launch { - track(InternalSuperwallEvent.Reset) + if (!duringIdentify) { + // Public reset — delegate to identity actor which coordinates + // dropping readiness, running cleanup, and restoring readiness. + dependencyContainer.identityManager.reset() + } else { + // Called from identity actor's completeReset during identify + // or full reset — just do cleanup without touching identity. + dependencyContainer.storage.reset() + dependencyContainer.paywallManager.resetCache() + presentationItems.reset() + dependencyContainer.configManager.reset() + dependencyContainer.reedemer.clear(RedemptionOwnershipType.AppUser) + ioScope.launch { + track(InternalSuperwallEvent.Reset) + } } } } 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 202150f27..bf82f1994 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -8,6 +8,8 @@ import android.webkit.WebSettings import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ViewModelProvider import com.android.billingclient.api.Purchase +import com.superwall.sdk.SdkContextImpl +import com.superwall.sdk.SdkContext import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.AttributionManager import com.superwall.sdk.analytics.ClassifierDataFactory @@ -34,8 +36,12 @@ import com.superwall.sdk.debug.DebugView import com.superwall.sdk.deeplinks.DeepLinkRouter import com.superwall.sdk.delegate.SuperwallDelegateAdapter import com.superwall.sdk.delegate.subscription_controller.PurchaseController +import com.superwall.sdk.identity.IdentityContext import com.superwall.sdk.identity.IdentityInfo import com.superwall.sdk.identity.IdentityManager +import com.superwall.sdk.identity.IdentityPersistenceInterceptor +import com.superwall.sdk.identity.IdentityState +import com.superwall.sdk.identity.createInitialIdentityState import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger @@ -44,6 +50,8 @@ import com.superwall.sdk.misc.AppLifecycleObserver import com.superwall.sdk.misc.CurrentActivityTracker import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.MainScope +import com.superwall.sdk.misc.primitives.DebugInterceptor +import com.superwall.sdk.misc.primitives.SequentialActor import com.superwall.sdk.models.config.ComputedPropertyRequest import com.superwall.sdk.models.config.FeatureFlags import com.superwall.sdk.models.entitlements.SubscriptionStatus @@ -125,7 +133,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.ClassDiscriminatorMode import kotlinx.serialization.json.Json import java.lang.ref.WeakReference @@ -254,7 +261,13 @@ class DependencyContainer( this, ) storage = - LocalStorage(context = context, ioScope = ioScope(), factory = this, json = json(), _apiKey = apiKey) + LocalStorage( + context = context, + ioScope = ioScope(), + factory = this, + json = json(), + _apiKey = apiKey + ) entitlements = Entitlements(storage) testModeManager = TestModeManager(storage) testModeTransactionHandler = @@ -400,6 +413,18 @@ class DependencyContainer( }, ) + // Identity actor setup + val initialIdentity = createInitialIdentityState(storage, deviceHelper.appInstalledAtString) + val identityActor = SequentialActor(initialIdentity) + + DebugInterceptor.install(identityActor, name = "Identity") + IdentityPersistenceInterceptor.install(identityActor, storage) + + val sdkContext: SdkContext = + SdkContextImpl( + configManager = { configManager }, + ) + configManager = ConfigManager( context = context, @@ -427,14 +452,12 @@ class DependencyContainer( entitlements.setSubscriptionStatus(status) }, ) + identityManager = IdentityManager( storage = storage, deviceHelper = deviceHelper, - configManager = configManager, - neverCalledStaticConfig = { - storage.neverCalledStaticConfig - }, + options = { options }, ioScope = ioScope, stringToSha = { val bytes = this.toString().toByteArray() @@ -445,6 +468,8 @@ class DependencyContainer( notifyUserChange = { delegate().userAttributesDidChange(it) }, + actor = identityActor, + sdkContext = sdkContext, ) reedemer = @@ -591,9 +616,9 @@ class DependencyContainer( val paywallActivity = ( - paywallView.encapsulatingActivity?.get() - ?: activityProvider?.getCurrentActivity() - ) as? SuperwallPaywallActivity + paywallView.encapsulatingActivity?.get() + ?: activityProvider?.getCurrentActivity() + ) as? SuperwallPaywallActivity if (paywallActivity != null) { ioScope.launch { @@ -610,7 +635,12 @@ class DependencyContainer( ) } // Await message delivery to ensure webview has time to process before dismiss - paywallView.webView.messageHandler.handle(PaywallMessage.TrialStarted(trialEndDate, id)) + paywallView.webView.messageHandler.handle( + PaywallMessage.TrialStarted( + trialEndDate, + id + ) + ) } }, ) @@ -702,8 +732,8 @@ class DependencyContainer( "X-Low-Power-Mode" to deviceHelper.isLowPowerModeEnabled.toString(), "X-Is-Sandbox" to deviceHelper.isSandbox.toString(), "X-Entitlement-Status" to - Superwall.instance.entitlements.status.value - .toString(), + Superwall.instance.entitlements.status.value + .toString(), "Content-Type" to "application/json", "X-Current-Time" to dateFormat(DateUtils.ISO_MILLIS).format(Date()), "X-Static-Config-Build-Id" to (configManager.config?.buildId ?: ""), @@ -800,7 +830,8 @@ class DependencyContainer( return view } - override fun makeCache(): PaywallViewCache = PaywallViewCache(context, makeViewStore(), activityProvider!!, deviceHelper) + override fun makeCache(): PaywallViewCache = + PaywallViewCache(context, makeViewStore(), activityProvider!!, deviceHelper) override fun activePaywallId(): String? = paywallManager.currentView @@ -835,9 +866,11 @@ class DependencyContainer( audienceFilterParams = HashMap(identityManager.userAttributes), ) - override fun makeHasExternalPurchaseController(): Boolean = storeManager.purchaseController.hasExternalPurchaseController + override fun makeHasExternalPurchaseController(): Boolean = + storeManager.purchaseController.hasExternalPurchaseController - override fun makeHasInternalPurchaseController(): Boolean = storeManager.purchaseController.hasInternalPurchaseController + override fun makeHasInternalPurchaseController(): Boolean = + storeManager.purchaseController.hasInternalPurchaseController override fun isWebToAppEnabled(): Boolean = configManager.config?.featureFlags?.web2App ?: false @@ -927,7 +960,8 @@ class DependencyContainer( override fun makeFeatureFlags(): FeatureFlags? = configManager.config?.featureFlags - override fun makeComputedPropertyRequests(): List = configManager.config?.allComputedProperties ?: emptyList() + override fun makeComputedPropertyRequests(): List = + configManager.config?.allComputedProperties ?: emptyList() override suspend fun makeIdentityInfo(): IdentityInfo = IdentityInfo( @@ -965,7 +999,8 @@ class DependencyContainer( appSessionId = appSessionManager.appSession.id, ) - override suspend fun activeProductIds(): List = storeManager.receiptManager.purchases.toList() + override suspend fun activeProductIds(): List = + storeManager.receiptManager.purchases.toList() override suspend fun makeIdentityManager(): IdentityManager = identityManager @@ -992,7 +1027,8 @@ class DependencyContainer( get() = ViewModelFactory() private val vmProvider = ViewModelProvider(storeOwner, vmFactory) - override fun makeViewStore(): ViewStorageViewModel = vmProvider[ViewStorageViewModel::class.java] + override fun makeViewStore(): ViewStorageViewModel = + vmProvider[ViewStorageViewModel::class.java] private var _mainScope: MainScope? = null private var _ioScope: IOScope? = null @@ -1056,7 +1092,8 @@ class DependencyContainer( override fun context(): Context = context - override fun experimentalProperties(): Map = storeManager.receiptManager.experimentalProperties() + override fun experimentalProperties(): Map = + storeManager.receiptManager.experimentalProperties() override fun getCurrentUserAttributes(): Map = identityManager.userAttributes @@ -1064,7 +1101,8 @@ class DependencyContainer( override fun demandScore(): Int? = deviceHelper.demandScore - override suspend fun track(event: TrackableSuperwallEvent): Result = Superwall.instance.track(event) + override suspend fun track(event: TrackableSuperwallEvent): Result = + Superwall.instance.track(event) override fun delegate(): SuperwallDelegateAdapter = delegateAdapter @@ -1098,7 +1136,8 @@ class DependencyContainer( delegateAdapter.didRedeemLink(redemptionResult) } - override fun maxAge(): Long = configManager.config?.webToAppConfig?.entitlementsMaxAgeMs ?: 86400000L + override fun maxAge(): Long = + configManager.config?.webToAppConfig?.entitlementsMaxAgeMs ?: 86400000L override fun getActiveDeviceEntitlements(): Set = entitlements.activeDeviceEntitlements @@ -1140,7 +1179,8 @@ class DependencyContainer( ?.flatMap { entitlements.byProductId(it) } ?.toSet() ?: emptySet() - override fun getPaywallInfo(): PaywallInfo = Superwall.instance.paywallView?.info ?: PaywallInfo.empty() + override fun getPaywallInfo(): PaywallInfo = + Superwall.instance.paywallView?.info ?: PaywallInfo.empty() override fun trackRestorationFailed(message: String) { trackRestorationFailure(message) diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt new file mode 100644 index 000000000..e2b33673f --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityContext.kt @@ -0,0 +1,23 @@ +package com.superwall.sdk.identity + +import com.superwall.sdk.SdkContext +import com.superwall.sdk.analytics.internal.trackable.Trackable +import com.superwall.sdk.misc.primitives.BaseContext +import com.superwall.sdk.network.device.DeviceHelper +import com.superwall.sdk.store.testmode.TestModeManager +import com.superwall.sdk.web.WebPaywallRedeemer + +/** + * All dependencies available to identity [IdentityState.Actions]. + * + * Cross-slice dispatch goes through [sdkContext]. Config reads use [configManager]. + */ +interface IdentityContext : BaseContext { + val sdkContext: SdkContext + val webPaywallRedeemer: (() -> WebPaywallRedeemer)? + val testModeManager: TestModeManager? + val deviceHelper: DeviceHelper + val completeReset: () -> Unit + val track: suspend (Trackable) -> Unit + val notifyUserChange: ((Map) -> Unit)? +} diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityLogic.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityLogic.kt index d19e2f3eb..a9187eb5d 100644 --- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityLogic.kt @@ -36,14 +36,11 @@ object IdentityLogic { transformedKey = transformedKey.replace("\$", "") } - when (val value = entry.value) { - is List<*> -> mergedAttributes[transformedKey] = value.filterNotNull() - is Map<*, *> -> { - val nonNullMap = value.filterValues { it != null } - mergedAttributes[transformedKey] = nonNullMap.filterKeys { it != null } - } - null -> mergedAttributes.remove(transformedKey) - else -> mergedAttributes[transformedKey] = value + val value = entry.value + if (value != null) { + mergedAttributes[transformedKey] = value + } else { + mergedAttributes.remove(transformedKey) } } 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 0e5949c18..a318d3a9e 100644 --- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt @@ -1,342 +1,120 @@ package com.superwall.sdk.identity +import com.superwall.sdk.SdkContext import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.track -import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.analytics.internal.trackable.Trackable import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent -import com.superwall.sdk.config.ConfigManager -import com.superwall.sdk.logger.LogLevel -import com.superwall.sdk.logger.LogScope -import com.superwall.sdk.logger.Logger +import com.superwall.sdk.config.options.SuperwallOptions import com.superwall.sdk.misc.IOScope -import com.superwall.sdk.misc.awaitFirstValidConfig -import com.superwall.sdk.misc.launchWithTracking -import com.superwall.sdk.misc.sha256MappedToRange +import com.superwall.sdk.misc.primitives.StateActor import com.superwall.sdk.network.device.DeviceHelper -import com.superwall.sdk.storage.AliasId -import com.superwall.sdk.storage.AppUserId -import com.superwall.sdk.storage.DidTrackFirstSeen -import com.superwall.sdk.storage.Seed import com.superwall.sdk.storage.Storage -import com.superwall.sdk.storage.UserAttributes -import com.superwall.sdk.utilities.withErrorTracking +import com.superwall.sdk.store.testmode.TestModeManager +import com.superwall.sdk.web.WebPaywallRedeemer import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.Executors - +import kotlinx.coroutines.flow.map + +/** + * Facade over the identity state of the shared SDK actor. + * + * Implements [IdentityContext] directly — actions receive `this` as + * their context, eliminating the intermediate object. + */ class IdentityManager( - private val deviceHelper: DeviceHelper, - private val storage: Storage, - private val configManager: ConfigManager, + override val deviceHelper: DeviceHelper, + override val storage: Storage, private val ioScope: IOScope, - private val neverCalledStaticConfig: () -> Boolean, private val stringToSha: (String) -> String = { it }, - private val notifyUserChange: (change: Map) -> Unit, - private val completeReset: () -> Unit = { + override val notifyUserChange: (change: Map) -> Unit, + override val completeReset: () -> Unit = { Superwall.instance.reset(duringIdentify = true) }, - private val track: suspend (TrackableSuperwallEvent) -> Unit = { + private val trackEvent: suspend (TrackableSuperwallEvent) -> Unit = { Superwall.instance.track(it) }, -) { - private companion object Keys { - val appUserId = "appUserId" - val aliasId = "aliasId" - - val seed = "seed" - } - - private var _appUserId: String? = storage.read(AppUserId) - - val appUserId: String? - get() = - runBlocking(queue) { - _appUserId - } - - private var _aliasId: String = - storage.read(AliasId) ?: IdentityLogic.generateAlias() + private val options: () -> SuperwallOptions, + override val webPaywallRedeemer: (() -> WebPaywallRedeemer)? = null, + override val testModeManager: TestModeManager? = null, + override val actor: StateActor, + @Suppress("EXPOSED_PARAMETER_TYPE") + override val sdkContext: SdkContext, +) : IdentityContext { + override val scope: CoroutineScope get() = ioScope + override val track: suspend (Trackable) -> Unit = { trackEvent(it as TrackableSuperwallEvent) } + + // ----------------------------------------------------------------------- + // State reads + // ----------------------------------------------------------------------- + + private val identity get() = actor.state.value + + val appUserId: String? get() = identity.appUserId + val aliasId: String get() = identity.aliasId + val seed: Int get() = identity.seed + val userId: String get() = identity.userId + val userAttributes: Map get() = identity.enrichedAttributes + val isLoggedIn: Boolean get() = identity.isLoggedIn val externalAccountId: String get() = - if (configManager.options.passIdentifiersToPlayStore) { + if (options().passIdentifiersToPlayStore) { userId } else { stringToSha(userId) } - val aliasId: String - get() = - runBlocking(queue) { - _aliasId - } + val hasIdentity: Flow + get() = actor.state.map { it.isReady }.filter { it } - private var _seed: Int = - storage.read(Seed) ?: IdentityLogic.generateSeed() + // ----------------------------------------------------------------------- + // Actions — dispatch with self as context + // ----------------------------------------------------------------------- - val seed: Int - get() = - runBlocking(queue) { - _seed - } - - val userId: String - get() = - runBlocking(queue) { - _appUserId ?: _aliasId - } - - private var _userAttributes: Map = storage.read(UserAttributes) ?: emptyMap() - - val userAttributes: Map - get() = - runBlocking(queue) { - _userAttributes.toMutableMap().apply { - // Ensure we always have user identifiers - put(Keys.appUserId, _appUserId ?: _aliasId) - put(Keys.aliasId, _aliasId) - } - } - - val isLoggedIn: Boolean get() = _appUserId != null - - private val identityFlow = MutableStateFlow(false) - val hasIdentity: Flow get() = identityFlow.asStateFlow().filter { it } - - private val queue = Executors.newSingleThreadExecutor().asCoroutineDispatcher() - private val scope = CoroutineScope(queue) - private val identityJobs = CopyOnWriteArrayList() - - init { - val extraAttributes = mutableMapOf() - - val aliasId = storage.read(AliasId) - if (aliasId == null) { - storage.write(AliasId, _aliasId) - extraAttributes[Keys.aliasId] = _aliasId - } - - val seed = storage.read(Seed) - if (seed == null) { - storage.write(Seed, _seed) - extraAttributes[Keys.seed] = _seed - } - - if (extraAttributes.isNotEmpty()) { - mergeUserAttributes( - newUserAttributes = extraAttributes, - shouldTrackMerge = false, - ) - } - } - - fun configure() { - ioScope.launchWithTracking { - val neverCalledStaticConfig = neverCalledStaticConfig() - val isFirstAppOpen = - !(storage.read(DidTrackFirstSeen) ?: false) - - if (IdentityLogic.shouldGetAssignments( - isLoggedIn, - neverCalledStaticConfig, - isFirstAppOpen, - ) - ) { - configManager.getAssignments() - } - didSetIdentity() - } + fun configure(neverCalledStaticConfig: Boolean) { + effect( + IdentityState.Actions.Configure( + neverCalledStaticConfig = neverCalledStaticConfig, + ), + ) } fun identify( userId: String, options: IdentityOptions? = null, ) { - scope.launch { - withErrorTracking { - IdentityLogic.sanitize(userId)?.let { sanitizedUserId -> - if (_appUserId == sanitizedUserId || sanitizedUserId == "") { - if (sanitizedUserId == "") { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.identityManager, - message = "The provided userId was empty.", - ) - } - return@withErrorTracking - } - - identityFlow.emit(false) - - val oldUserId = _appUserId - if (oldUserId != null && sanitizedUserId != oldUserId) { - completeReset() - } - - _appUserId = sanitizedUserId - - // If we haven't gotten config yet, we need - // to leave this open to grab the appUserId for headers - identityJobs += - ioScope.launch { - val config = configManager.configState.awaitFirstValidConfig() - - if (config?.featureFlags?.enableUserIdSeed == true) { - sanitizedUserId.sha256MappedToRange()?.let { seed -> - _seed = seed - saveIds() - } - } - } - - saveIds() - - ioScope.launch { - val trackableEvent = InternalSuperwallEvent.IdentityAlias() - track(trackableEvent) - } - - configManager.checkForWebEntitlements() - configManager.reevaluateTestMode( - appUserId = _appUserId, - aliasId = _aliasId, - ) - - if (options?.restorePaywallAssignments == true) { - identityJobs += - ioScope.launch { - configManager.getAssignments() - didSetIdentity() - } - } else { - ioScope.launch { - configManager.getAssignments() - } - didSetIdentity() - } - } - } - } - } - - private fun didSetIdentity() { - scope.launch { - identityJobs.forEach { it.join() } - identityFlow.emit(true) - } - } - - /** - * Saves the `aliasId`, `seed` and `appUserId` to storage and user attributes. - */ - private fun saveIds() { - withErrorTracking { - // This is not wrapped in a scope/mutex because is - // called from the didSet of vars, who are already - // being set within the queue. - _appUserId?.let { - storage.write(AppUserId, it) - } ?: kotlin.run { storage.delete(AppUserId) } - storage.write(AliasId, _aliasId) - storage.write(Seed, _seed) - - val newUserAttributes = - mutableMapOf( - Keys.aliasId to _aliasId, - Keys.seed to _seed, - ) - _appUserId?.let { newUserAttributes[Keys.appUserId] = it } - - _mergeUserAttributes( - newUserAttributes = newUserAttributes, - ) - } + effect(IdentityState.Actions.Identify(userId, options)) } - fun reset(duringIdentify: Boolean) { - ioScope.launch { - identityFlow.emit(false) - } - - if (duringIdentify) { - _reset() - } else { - _reset() - didSetIdentity() - } - } - - @Suppress("ktlint:standard:function-naming") - private fun _reset() { - _appUserId = null - _aliasId = IdentityLogic.generateAlias() - _seed = IdentityLogic.generateSeed() - _userAttributes = emptyMap() - saveIds() + fun reset() { + effect(IdentityState.Actions.FullReset) } fun mergeUserAttributes( newUserAttributes: Map, shouldTrackMerge: Boolean = true, ) { - scope.launch { - _mergeUserAttributes( - newUserAttributes = newUserAttributes, + effect( + IdentityState.Actions.MergeAttributes( + attrs = newUserAttributes, shouldTrackMerge = shouldTrackMerge, - ) - } + shouldNotify = false, + ), + ) } internal fun mergeAndNotify( newUserAttributes: Map, shouldTrackMerge: Boolean = true, ) { - scope.launch { - _mergeUserAttributes( - newUserAttributes = newUserAttributes, + effect( + IdentityState.Actions.MergeAttributes( + attrs = newUserAttributes, shouldTrackMerge = shouldTrackMerge, shouldNotify = true, - ) - } - } - - @Suppress("ktlint:standard:function-naming") - private fun _mergeUserAttributes( - newUserAttributes: Map, - shouldTrackMerge: Boolean = true, - shouldNotify: Boolean = false, - ) { - withErrorTracking { - val mergedAttributes = - IdentityLogic.mergeAttributes( - newAttributes = newUserAttributes, - oldAttributes = _userAttributes, - appInstalledAtString = deviceHelper.appInstalledAtString, - ) - - if (shouldTrackMerge) { - ioScope.launch { - val trackableEvent = - InternalSuperwallEvent.Attributes( - deviceHelper.appInstalledAtString, - HashMap(mergedAttributes), - ) - track(trackableEvent) - } - } - storage.write(UserAttributes, mergedAttributes) - _userAttributes = mergedAttributes - if (shouldNotify) { - notifyUserChange(mergedAttributes) - } - } + ), + ) } } diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt new file mode 100644 index 000000000..98a1e787c --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManagerActor.kt @@ -0,0 +1,401 @@ +package com.superwall.sdk.identity + +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger +import com.superwall.sdk.misc.primitives.Reducer +import com.superwall.sdk.misc.primitives.TypedAction +import com.superwall.sdk.misc.sha256MappedToRange +import com.superwall.sdk.models.config.Config +import com.superwall.sdk.storage.AliasId +import com.superwall.sdk.storage.AppUserId +import com.superwall.sdk.storage.DidTrackFirstSeen +import com.superwall.sdk.storage.Seed +import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.UserAttributes +import com.superwall.sdk.web.WebPaywallRedeemer + +internal object Keys { + const val APP_USER_ID = "appUserId" + const val ALIAS_ID = "aliasId" + const val SEED = "seed" +} + +data class IdentityState( + val appUserId: String? = null, + val aliasId: String = IdentityLogic.generateAlias(), + val seed: Int = IdentityLogic.generateSeed(), + val userAttributes: Map = emptyMap(), + val phase: Phase = Phase.Pending(setOf(Pending.Configuration)), + val appInstalledAtString: String = "", +) { + enum class Pending { Configuration, Seed, Assignments } + + sealed class Phase { + data class Pending(val items: Set) : Phase() + object Ready : Phase() + } + + val isReady: Boolean get() = phase is Phase.Ready + + val userId: String get() = appUserId ?: aliasId + + val isLoggedIn: Boolean get() = appUserId != null + + val enrichedAttributes: Map + get() = + userAttributes.toMutableMap().apply { + put(Keys.APP_USER_ID, userId) + put(Keys.ALIAS_ID, aliasId) + } + + val pending: Set + get() = (phase as? Phase.Pending)?.items ?: emptySet() + + fun resolve(item: Pending): IdentityState { + val current = (phase as? Phase.Pending)?.items ?: return this + val next = current - item + return copy(phase = if (next.isEmpty()) Phase.Ready else Phase.Pending(next)) + } + + // ----------------------------------------------------------------------- + // Pure state mutations — (IdentityState) -> IdentityState, nothing else + // ----------------------------------------------------------------------- + + internal sealed class Updates( + override val reduce: (IdentityState) -> IdentityState, + ) : Reducer { + data class Identify( + val userId: String, + val restoreAssignments: Boolean, + ) : Updates({ state -> + // userId is already sanitized by Actions.Identify before dispatch. + if (userId == state.appUserId) { + state + } else { + val base = + if (state.appUserId != null) { + // Switching users — start fresh identity, no phase + // since we set it explicitly below. + IdentityState( + appInstalledAtString = state.appInstalledAtString, + phase = Phase.Ready, + ) + } else { + state + } + + val merged = + IdentityLogic.mergeAttributes( + newAttributes = + mapOf( + Keys.APP_USER_ID to userId, + Keys.ALIAS_ID to base.aliasId, + Keys.SEED to base.seed, + ), + oldAttributes = base.userAttributes, + appInstalledAtString = state.appInstalledAtString, + ) + + base.copy( + appUserId = userId, + userAttributes = merged, + phase = Phase.Pending(buildSet { + add(Pending.Seed) + if (restoreAssignments) add(Pending.Assignments) + }), + ) + } + }) + + data class SeedResolved( + val seed: Int, + ) : Updates({ state -> + val merged = + IdentityLogic.mergeAttributes( + newAttributes = + mapOf( + Keys.APP_USER_ID to state.userId, + Keys.ALIAS_ID to state.aliasId, + Keys.SEED to seed, + ), + oldAttributes = state.userAttributes, + appInstalledAtString = state.appInstalledAtString, + ) + + state + .copy(seed = seed, userAttributes = merged) + .resolve(Pending.Seed) + }) + + object SeedSkipped : Updates({ state -> + state.resolve(Pending.Seed) + }) + + data class AttributesMerged( + val attrs: Map, + ) : Updates({ state -> + val merged = + IdentityLogic.mergeAttributes( + newAttributes = attrs, + oldAttributes = state.userAttributes, + appInstalledAtString = state.appInstalledAtString, + ) + state.copy(userAttributes = merged) + }) + + object AssignmentsCompleted : Updates({ state -> + state.resolve(Pending.Assignments) + }) + + data class Configure( + val needsAssignments: Boolean, + ) : Updates({ state -> + // Resolve the Configuration item, optionally add Assignments, + // but preserve any existing pending items (e.g. Seed from a + // concurrent identify() that started before config was fetched). + val existing = (state.phase as? Phase.Pending)?.items ?: emptySet() + val next = + (existing - Pending.Configuration) + + (if (needsAssignments) setOf(Pending.Assignments) else emptySet()) + if (next.isEmpty()) { + state.copy(phase = Phase.Ready) + } else { + state.copy(phase = Phase.Pending(next)) + } + }) + + object Reset : Updates({ state -> + val fresh = IdentityState(appInstalledAtString = state.appInstalledAtString) + val merged = + IdentityLogic.mergeAttributes( + newAttributes = + mapOf( + Keys.ALIAS_ID to fresh.aliasId, + Keys.SEED to fresh.seed, + ), + oldAttributes = emptyMap(), + appInstalledAtString = state.appInstalledAtString, + ) + // Default phase is Pending(Configuration) — identity is NOT ready. + // This gates paywall presentation during the reset window. + // The calling action is responsible for restoring readiness. + fresh.copy(userAttributes = merged) + }) + + object ResetComplete : Updates({ state -> + state.copy(phase = Phase.Ready) + }) + } + + // ----------------------------------------------------------------------- + // Async work — actions have full access to IdentityContext + // ----------------------------------------------------------------------- + + internal sealed class Actions( + override val execute: suspend IdentityContext.() -> Unit, + ) : TypedAction { + /** + * Called after config has been fetched. Evaluates whether assignments + * are needed and resolves the Configuration pending item. + */ + data class Configure( + val neverCalledStaticConfig: Boolean, + ) : Actions({ + val isFirstAppOpen = !(storage.read(DidTrackFirstSeen) ?: false) + val needsAssignments = + IdentityLogic.shouldGetAssignments( + isLoggedIn = actor.state.value.isLoggedIn, + neverCalledStaticConfig = neverCalledStaticConfig, + isFirstAppOpen = isFirstAppOpen, + ) + update(Updates.Configure(needsAssignments = needsAssignments)) + if (needsAssignments) { + effect(FetchAssignments) + } + }) + + data class Identify( + val userId: String, + val options: IdentityOptions?, + ) : Actions({ + val sanitized = IdentityLogic.sanitize(userId) + if (sanitized.isNullOrEmpty()) { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.identityManager, + message = "The provided userId was null or empty.", + ) + } else if (sanitized != state.value.appUserId) { + val wasLoggedIn = state.value.appUserId != null + + // If switching users, reset other managers BEFORE updating state + // so storage.reset() doesn't wipe the new IDs + if (wasLoggedIn) { + completeReset() + immediate(Reset) + } + + // Update state (pure) — persistence handled by interceptor + update(Updates.Identify(sanitized, options?.restorePaywallAssignments == true)) + + val newState = state.value + immediate( + IdentityChanged( + sanitized, + newState.aliasId, + options?.restorePaywallAssignments, + ), + ) + } + }) + + data class IdentityChanged( + val id: String, + val alias: String, + val restoreAssignments: Boolean?, + ) : Actions({ + track(InternalSuperwallEvent.IdentityAlias()) + + // Fire-and-forget sub-actions + effect(ResolveSeed(id)) + effect(CheckWebEntitlements) + sdkContext.reevaluateTestMode(id, alias) + + // Fetch assignments — inline if restoring, fire-and-forget otherwise + if (restoreAssignments == true) { + immediate(FetchAssignments) + } else { + effect(FetchAssignments) + } + }) + + data class ResolveSeed( + val userId: String, + ) : Actions({ + try { + val config = sdkContext.awaitConfig() + if (config != null && config.featureFlags.enableUserIdSeed) { + userId.sha256MappedToRange()?.let { mapped -> + update(Updates.SeedResolved(mapped)) + } ?: update(Updates.SeedSkipped) + } else { + update(Updates.SeedSkipped) + } + } catch (_: Exception) { + update(Updates.SeedSkipped) + } + }) + + object FetchAssignments : Actions({ + try { + sdkContext.fetchAssignments() + } finally { + update(Updates.AssignmentsCompleted) + } + }) + + object CheckWebEntitlements : Actions({ + webPaywallRedeemer?.invoke()?.redeem(WebPaywallRedeemer.RedeemType.Existing) + }) + + data class MergeAttributes( + val attrs: Map, + val shouldTrackMerge: Boolean = true, + val shouldNotify: Boolean = false, + ) : Actions({ + update(Updates.AttributesMerged(attrs)) + if (shouldTrackMerge) { + val current = actor.state.value + track( + InternalSuperwallEvent.Attributes( + appInstalledAtString = current.appInstalledAtString, + audienceFilterParams = HashMap(current.enrichedAttributes), + ), + ) + } + if (shouldNotify) { + effect(NotifyUserChange(actor.state.value.enrichedAttributes)) + } + }) + + data class NotifyUserChange( + val attributes: Map, + ) : Actions({ + notifyUserChange?.invoke(attributes) + }) + + /** Resets identity state only. Used during identify when switching users. */ + object Reset : Actions({ + update(Updates.Reset) + }) + + /** + * Full reset from public API. Drops identity readiness so paywall + * presentation is gated, performs Superwall cleanup, then restores + * readiness. Matches iOS behavior where identitySubject is set to + * false during the reset window. + */ + object FullReset : Actions({ + update(Updates.Reset) // identity not ready + completeReset() // storage, config, paywall cache cleanup + update(Updates.ResetComplete) // identity ready + }) + } +} + +/** + * Builds initial IdentityState from storage BEFORE the actor starts. + * This is synchronous — same as the old IdentityManager constructor. + */ +internal fun createInitialIdentityState( + storage: Storage, + appInstalledAtString: String, +): IdentityState { + val storedAliasId = storage.read(AliasId) + val storedSeed = storage.read(Seed) + + val aliasId = + storedAliasId ?: IdentityLogic.generateAlias().also { + storage.write(AliasId, it) + } + val seed = + storedSeed ?: IdentityLogic.generateSeed().also { + storage.write(Seed, it) + } + val appUserId = storage.read(AppUserId) + val userAttributes = storage.read(UserAttributes) ?: emptyMap() + + // Merge when alias/seed are new OR when stored attributes are empty/missing + // (e.g. deserialization failed but individual fields were intact). + val needsMerge = storedAliasId == null || storedSeed == null || userAttributes.isEmpty() + val finalAttributes = + if (needsMerge) { + val enriched = + IdentityLogic.mergeAttributes( + newAttributes = + buildMap { + put(Keys.ALIAS_ID, aliasId) + put(Keys.SEED, seed) + appUserId?.let { put(Keys.APP_USER_ID, it) } + }, + oldAttributes = userAttributes, + appInstalledAtString = appInstalledAtString, + ) + if (enriched != userAttributes) { + storage.write(UserAttributes, enriched) + } + enriched + } else { + userAttributes + } + + return IdentityState( + appUserId = appUserId, + aliasId = aliasId, + seed = seed, + userAttributes = finalAttributes, + appInstalledAtString = appInstalledAtString, + ) +} diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityPersistenceInterceptor.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityPersistenceInterceptor.kt new file mode 100644 index 000000000..b1c3d8aa6 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityPersistenceInterceptor.kt @@ -0,0 +1,39 @@ +package com.superwall.sdk.identity + +import com.superwall.sdk.misc.primitives.StateActor +import com.superwall.sdk.storage.AliasId +import com.superwall.sdk.storage.AppUserId +import com.superwall.sdk.storage.Seed +import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.UserAttributes + +/** + * Auto-persists identity fields to storage whenever state changes. + * + * Only writes fields that actually changed, so reducers that only + * touch `pending`/`isReady` (e.g. Configure, AssignmentsCompleted) + * produce zero storage writes. + */ +internal object IdentityPersistenceInterceptor { + fun install( + actor: StateActor, + storage: Storage, + ) { + actor.onUpdate { reducer, next -> + val before = actor.state.value + next(reducer) + val after = actor.state.value + + if (after.aliasId != before.aliasId) storage.write(AliasId, after.aliasId) + if (after.seed != before.seed) storage.write(Seed, after.seed) + if (after.userAttributes != before.userAttributes) storage.write(UserAttributes, after.userAttributes) + if (after.appUserId != before.appUserId) { + if (after.appUserId != null) { + storage.write(AppUserId, after.appUserId) + } else { + storage.delete(AppUserId) + } + } + } + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/Actor.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/Actor.kt new file mode 100644 index 000000000..c9e8fc26b --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/Actor.kt @@ -0,0 +1,22 @@ +package com.superwall.sdk.misc.primitives + +interface Actor { + /** Fire-and-forget action dispatch. */ + fun effect( + ctx: Ctx, + action: TypedAction, + ) + + /** Dispatch action inline, suspending until it completes. */ + suspend fun immediate( + ctx: Ctx, + action: TypedAction, + ) + + /** Dispatch action, suspending until state matches [until]. */ + suspend fun immediateUntil( + ctx: Ctx, + action: TypedAction, + until: (S) -> Boolean, + ): S +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/ActorTypes.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/ActorTypes.kt new file mode 100644 index 000000000..2cf3fcf18 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/ActorTypes.kt @@ -0,0 +1,45 @@ +package com.superwall.sdk.misc.primitives + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext + +/** + * A [StateActor] that serializes all action execution via a [Mutex]. + * + * Actions dispatched with [effect] or [immediate] will never run concurrently — + * if an action suspends (e.g. waiting on a network call), the next action waits + * until the first fully completes. + * + * Re-entrant: actions that call [immediate] on the same actor (sub-actions) + * skip the mutex since the parent already holds it. + */ +class SequentialActor(initial: S) : StateActor( + initial, CoroutineScope(Dispatchers.IO), +) { + private val mutex = Mutex() + + private class OwnerElement(val actor: Any) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key + } + + override suspend fun executeAction( + ctx: Ctx, + action: TypedAction, + ) { + if (coroutineContext[OwnerElement]?.actor === this) { + super.executeAction(ctx, action) + } else { + mutex.withLock { + withContext(OwnerElement(this@SequentialActor)) { + super.executeAction(ctx, action) + } + } + } + } +} 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 new file mode 100644 index 000000000..b9741a035 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/BaseContext.kt @@ -0,0 +1,32 @@ +package com.superwall.sdk.misc.primitives + +import com.superwall.sdk.storage.Storable +import com.superwall.sdk.storage.Storage + +/** + * SDK-level actor context — extends [StoreContext] with storage helpers. + * + * All Superwall domain contexts (IdentityContext, ConfigContext) extend this. + */ +interface BaseContext> : StoreContext { + val storage: Storage + + /** Persist a value to storage. */ + fun persist( + storable: Storable, + value: T, + ) { + storage.write(storable, value) + } + + fun read(storable: Storable): Result = + storage.read(storable)?.let { + Result.success(it) + } ?: Result.failure(IllegalArgumentException("Not found")) + + /** Delete a value from storage. */ + fun delete(storable: Storable<*>) { + @Suppress("UNCHECKED_CAST") + storage.delete(storable as Storable) + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/DebugInterceptor.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/DebugInterceptor.kt new file mode 100644 index 000000000..3a58b3927 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/DebugInterceptor.kt @@ -0,0 +1,90 @@ +package com.superwall.sdk.misc.primitives + +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger + +/** + * Installs debug interceptors on an [StateActor] that log every action dispatch + * and state update, building a traceable timeline of what happened and why. + * + * Usage: + * ```kotlin + * val actor = Actor(initialState, scope) + * DebugInterceptor.install(actor, name = "Identity") + * ``` + * + * Output example: + * ``` + * [Identity] action → Identify(userId=user_123) + * [Identity] update → Identify | 2ms + * [Identity] update → AttributesMerged | 0ms + * [Identity] action → ResolveSeed(userId=user_123) + * [Identity] update → SeedResolved | 1ms + * ``` + */ +object DebugInterceptor { + /** + * Install debug logging on an [StateActor]. + * + * @param actor The actor to instrument. + * @param name A human-readable label for log output (e.g. "Identity", "Config"). + * @param scope The [LogScope] to log under. Defaults to [LogScope.superwallCore]. + * @param level The [LogLevel] to log at. Defaults to [LogLevel.debug]. + */ + fun install( + actor: StateActor, + name: String, + scope: LogScope = LogScope.superwallCore, + level: LogLevel = LogLevel.debug, + ) { + actor.onUpdate { reducer, next -> + val reducerName = reducer.labelOf() + val start = System.nanoTime() + next(reducer) + val elapsedMs = (System.nanoTime() - start) / 1_000_000 + Logger.debug( + logLevel = level, + scope = scope, + message = "Interceptor: [$name] update → $reducerName | ${elapsedMs}ms", + ) + } + + actor.onAction { action, next -> + val actionName = action.labelOf() + Logger.debug( + logLevel = level, + scope = scope, + message = "Interceptor: [$name] action → $actionName", + ) + next() + } + + actor.onActionExecution { action, next -> + val actionName = action.labelOf() + val start = System.nanoTime() + next() + val elapsedMs = (System.nanoTime() - start) / 1_000_000 + Logger.debug( + logLevel = level, + scope = scope, + message = "Interceptor: [$name] action ✓ $actionName | ${elapsedMs}ms", + ) + } + } + + /** + * Derive a readable label from an action or reducer instance. + * + * For sealed-class members like `IdentityState.Updates.Identify(userId=foo)`, + * this returns `"Identify(userId=foo)"` — the simple class name plus toString + * for data classes, or just the simple name for objects. + */ + private fun Any.labelOf(): String { + val cls = this::class + val simple = cls.simpleName ?: cls.qualifiedName ?: "anonymous" + // Data classes have a useful toString; objects don't — just use the name. + val str = toString() + return if (str.startsWith(simple)) str else simple + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/Reduce.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/Reduce.kt new file mode 100644 index 000000000..e2323482f --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/Reduce.kt @@ -0,0 +1,11 @@ +package com.superwall.sdk.misc.primitives + +/** + * A pure state transform — no side effects, no dispatch. + * + * Reducers are `(S) -> S`. They describe HOW state changes. + * All side effects (storage, network, tracking) belong in [TypedAction]s. + */ +interface Reducer { + val reduce: (S) -> S +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/StateActor.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/StateActor.kt new file mode 100644 index 000000000..55c0a0cf2 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/StateActor.kt @@ -0,0 +1,194 @@ +package com.superwall.sdk.misc.primitives + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +fun stateActor(initial: State, scope: CoroutineScope) = StateActor(initial, scope) + +/** + * Holds state and provides synchronous updates + async action dispatching. + * + * [update] uses [MutableStateFlow.update] internally (CAS retry) — + * concurrent updates from multiple actions are safe. + * + * Dispatch modes: + * - [effect]: fire-and-forget — launches in the actor's scope. + * - [immediateUntil]: dispatch + suspend until state matches a condition. + * + * ## Interceptors + * + * ```kotlin + * actor.onUpdate { reducer, next -> + * next(reducer) // call to proceed, skip to suppress + * } + * + * actor.onAction { action, next -> + * next() // call to proceed, skip to suppress + * } + * ``` + */ + +open class StateActor( + initial: S, + internal val scope: CoroutineScope, +) : StateStore, + Actor { + private val _state = MutableStateFlow(initial) + override val state: StateFlow = _state.asStateFlow() + + // -- Interceptor chains -------------------------------------------------- + + private var updateChain: (Reducer) -> Unit = { reducer -> + _state.update { reducer.reduce(it) } + } + + private var actionInterceptors: List<(action: Any, next: () -> Unit) -> Unit> = emptyList() + + /** + * Async interceptors that wrap the suspend execution of each action. + * Unlike [onAction] (which wraps the dispatch/launch), these run + * _inside_ the coroutine and can measure wall-clock execution time. + */ + private var asyncActionInterceptors: List Unit) -> Unit> = emptyList() + + /** + * Add an update interceptor. Call `next(reducer)` to proceed, + * or skip to suppress the update. + */ + fun onUpdate(interceptor: (reducer: Reducer, next: (Reducer) -> Unit) -> Unit) { + val previous = updateChain + updateChain = { reducer -> interceptor(reducer, previous) } + } + + /** + * Add an action interceptor. Call `next()` to proceed, + * or skip to suppress the action. Action is [Any] — cast to inspect. + * + * Note: `next()` launches a coroutine and returns immediately. + * To measure action execution time, use [onActionExecution] instead. + */ + fun onAction(interceptor: (action: Any, next: () -> Unit) -> Unit) { + actionInterceptors = actionInterceptors + interceptor + } + + /** + * Add an async interceptor that wraps the action's suspend execution. + * Runs inside the coroutine — `next()` suspends until the action completes. + * + * ```kotlin + * actor.onActionExecution { action, next -> + * val start = System.nanoTime() + * next() // suspends until the action finishes + * val ms = (System.nanoTime() - start) / 1_000_000 + * println("${action::class.simpleName} took ${ms}ms") + * } + * ``` + */ + fun onActionExecution(interceptor: suspend (action: Any, next: suspend () -> Unit) -> Unit) { + asyncActionInterceptors = asyncActionInterceptors + interceptor + } + + /** Atomic state mutation using CAS retry, routed through update interceptors. */ + override fun update(reducer: Reducer) { + updateChain(reducer) + } + + /** Fire-and-forget: launch action in actor's scope, routed through interceptors. */ + override fun effect( + ctx: Ctx, + action: TypedAction, + ) { + val execute = { + scope.launch { executeAction(ctx, action) } + Unit + } + runInterceptorChain(action, execute) + } + + /** + * Dispatch action and suspend until state matches [until]. + * + * Actor-native awaiting: fire the action, observe the state transition. + */ + override suspend fun immediateUntil( + ctx: Ctx, + action: TypedAction, + until: (S) -> Boolean, + ): S { + effect(ctx, action) + return state.first { until(it) } + } + + /** + * Dispatch action inline and suspend until it completes. + * Goes through action interceptors. Use for cross-slice coordination + * where the caller needs to await the action finishing. + */ + override suspend fun immediate( + ctx: Ctx, + action: TypedAction, + ) { + var shouldExecute = true + if (actionInterceptors.isNotEmpty()) { + shouldExecute = false + var chain: () -> Unit = { shouldExecute = true } + for (i in actionInterceptors.indices.reversed()) { + val interceptor = actionInterceptors[i] + val next = chain + chain = { interceptor(action, next) } + } + chain() + } + if (shouldExecute) { + executeAction(ctx, action) + } + } + + /** Runs the action through async interceptors. Override to add serialization. */ + protected open suspend fun executeAction( + ctx: Ctx, + action: TypedAction, + ) { + runAsyncInterceptorChain(action) { action.execute.invoke(ctx) } + } + + protected suspend fun runAsyncInterceptorChain( + action: Any, + terminal: suspend () -> Unit, + ) { + if (asyncActionInterceptors.isEmpty()) { + terminal() + } else { + var chain: suspend () -> Unit = terminal + for (i in asyncActionInterceptors.indices.reversed()) { + val interceptor = asyncActionInterceptors[i] + val next = chain + chain = { interceptor(action, next) } + } + chain() + } + } + + protected fun runInterceptorChain( + action: Any, + terminal: () -> Unit, + ) { + if (actionInterceptors.isEmpty()) { + terminal() + } else { + var chain: () -> Unit = terminal + for (i in actionInterceptors.indices.reversed()) { + val interceptor = actionInterceptors[i] + val next = chain + chain = { interceptor(action, next) } + } + chain() + } + } +} + diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/StateStore.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/StateStore.kt new file mode 100644 index 000000000..1432dd26a --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/StateStore.kt @@ -0,0 +1,16 @@ +package com.superwall.sdk.misc.primitives + +import kotlinx.coroutines.flow.StateFlow + +/** + * Common interface for reading, updating, and dispatching on state. + * + * Both [StateActor] (root) and [ScopedState] (projection) implement this. + * Contexts depend on [StateStore] — they never see the concrete type. + */ +interface StateStore { + val state: StateFlow + + /** Atomic state mutation. */ + fun update(reducer: Reducer) +} 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 new file mode 100644 index 000000000..f49bf08ef --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/StoreContext.kt @@ -0,0 +1,49 @@ +package com.superwall.sdk.misc.primitives + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +/** + * Pure actor context — the minimal contract for action execution. + * + * Provides a [StateStore] for state reads/updates, a [CoroutineScope], + * and a type-safe [effect] for fire-and-forget sub-action dispatch. + * + * SDK-specific concerns (storage, persistence) live in [BaseContext]. + */ +interface StoreContext> : StateStore { + val actor: StateActor + val scope: CoroutineScope + + /** Delegate state reads to the actor. */ + override val state: StateFlow get() = actor.state + + /** Apply a state reducer inline. */ + override fun update(reducer: Reducer) { + actor.update(reducer) + } + + /** + * Fire-and-forget dispatch of a sub-action on this context's actor. + * + * Type-safe: [Self] is the implementing context, matching the action's + * receiver type. The cast is guaranteed correct by the F-bounded constraint. + */ + @Suppress("UNCHECKED_CAST") + fun effect(action: TypedAction) { + actor.effect(this as Self, action) + } + + @Suppress("UNCHECKED_CAST") + suspend fun immediate(action: TypedAction) { + actor.immediate(this as Self, action) + } + + @Suppress("UNCHECKED_CAST") + suspend fun immediateUntil( + action: TypedAction, + until: (S) -> Boolean, + ) { + actor.immediateUntil(this as Self, action, until) + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/primitives/TypedAction.kt b/superwall/src/main/java/com/superwall/sdk/misc/primitives/TypedAction.kt new file mode 100644 index 000000000..baaa99142 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/primitives/TypedAction.kt @@ -0,0 +1,13 @@ +package com.superwall.sdk.misc.primitives + +/** + * An async operation scoped to a [Ctx] that provides all dependencies. + * + * Actions do the real work: network calls, storage writes, tracking. + * They call [StateActor.update] with pure [Reducer]s to mutate state. + * + * Actions are launched via [StateActor.action] and run concurrently. + */ +interface TypedAction { + val execute: suspend Ctx.() -> Unit +} diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt new file mode 100644 index 000000000..4f6af2056 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityActorIntegrationTest.kt @@ -0,0 +1,404 @@ +package com.superwall.sdk.identity + +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.And +import com.superwall.sdk.SdkContext +import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent +import com.superwall.sdk.config.options.SuperwallOptions +import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.misc.primitives.SequentialActor +import com.superwall.sdk.models.config.Config +import com.superwall.sdk.network.device.DeviceHelper +import com.superwall.sdk.storage.AliasId +import com.superwall.sdk.storage.AppUserId +import com.superwall.sdk.storage.DidTrackFirstSeen +import com.superwall.sdk.storage.Seed +import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.UserAttributes +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Integration tests using the production [SequentialActor] (mutex-serialized) + * to verify ordering assumptions that plain StateActor tests miss. + */ +class IdentityActorIntegrationTest { + private lateinit var storage: Storage + private lateinit var deviceHelper: DeviceHelper + private lateinit var sdkContext: SdkContext + private var resetCalled = false + private var trackedEvents: MutableList = mutableListOf() + + @Before + fun setup() { + storage = mockk(relaxed = true) + deviceHelper = mockk(relaxed = true) + sdkContext = mockk(relaxed = true) + resetCalled = false + trackedEvents = mutableListOf() + + every { storage.read(AppUserId) } returns null + every { storage.read(AliasId) } returns null + every { storage.read(Seed) } returns null + every { storage.read(UserAttributes) } returns null + every { storage.read(DidTrackFirstSeen) } returns null + every { deviceHelper.appInstalledAtString } returns "2024-01-01" + + // SdkContext mocks — fetchAssignments and awaitConfig return quickly + coEvery { sdkContext.fetchAssignments() } returns Unit + coEvery { sdkContext.awaitConfig() } returns null + } + + private fun createSequentialManager( + existingAppUserId: String? = null, + existingAliasId: String? = null, + existingSeed: Int? = null, + ): IdentityManager { + existingAppUserId?.let { every { storage.read(AppUserId) } returns it } + existingAliasId?.let { every { storage.read(AliasId) } returns it } + existingSeed?.let { every { storage.read(Seed) } returns it } + + val initial = createInitialIdentityState(storage, "2024-01-01") + val actor = SequentialActor(initial) + IdentityPersistenceInterceptor.install(actor, storage) + + return IdentityManager( + deviceHelper = deviceHelper, + storage = storage, + options = { SuperwallOptions() }, + ioScope = IOScope(kotlinx.coroutines.Dispatchers.IO), + notifyUserChange = {}, + completeReset = { resetCalled = true }, + trackEvent = { trackedEvents.add(it) }, + actor = actor, + sdkContext = sdkContext, + ) + } + + // ----------------------------------------------------------------------- + // Serialization: actions don't interleave + // ----------------------------------------------------------------------- + + @Test + fun `identify followed by mergeAttributes are serialized`() = runTest { + Given("a fresh manager with SequentialActor") { + val manager = createSequentialManager() + + When("identify and mergeAttributes are dispatched back-to-back") { + manager.identify("user-1") + manager.mergeUserAttributes(mapOf("key" to "value")) + + // Wait for identity to become ready + withTimeout(5000) { + manager.hasIdentity.first() + } + } + + Then("both operations completed — userId is set") { + assertEquals("user-1", manager.appUserId) + } + And("custom attribute was merged") { + assertTrue(manager.userAttributes.containsKey("key")) + assertEquals("value", manager.userAttributes["key"]) + } + } + } + + @Test + fun `configure resolves initial Configuration pending item`() = runTest { + Given("a fresh manager (phase = Pending Configuration)") { + val manager = createSequentialManager() + every { storage.read(DidTrackFirstSeen) } returns true + + assertFalse("should start not ready", manager.actor.state.value.isReady) + + When("configure is dispatched") { + manager.configure(neverCalledStaticConfig = false) + + withTimeout(5000) { + manager.hasIdentity.first() + } + } + + Then("identity is ready") { + assertTrue(manager.actor.state.value.isReady) + } + } + } + + @Test + fun `reset gates identity readiness then restores it`() = runTest { + Given("a configured ready manager") { + val manager = createSequentialManager() + every { storage.read(DidTrackFirstSeen) } returns true + + // Make it ready first + manager.configure(neverCalledStaticConfig = false) + withTimeout(5000) { manager.hasIdentity.first() } + assertTrue("should be ready after configure", manager.actor.state.value.isReady) + + When("FullReset is dispatched") { + manager.reset() + // Wait for it to become ready again + withTimeout(5000) { manager.hasIdentity.first() } + } + + Then("completeReset was called") { + assertTrue(resetCalled) + } + And("identity is ready again with fresh state") { + assertTrue(manager.actor.state.value.isReady) + assertNull(manager.appUserId) + } + } + } + + @Test + fun `identify then reset produces clean anonymous state`() = runTest { + Given("a manager identified as user-1") { + val manager = createSequentialManager() + every { storage.read(DidTrackFirstSeen) } returns true + + manager.configure(neverCalledStaticConfig = false) + withTimeout(5000) { manager.hasIdentity.first() } + manager.identify("user-1") + // Wait for identify to complete + Thread.sleep(200) + + assertEquals("user-1", manager.appUserId) + + When("reset is called") { + manager.reset() + withTimeout(5000) { manager.hasIdentity.first() } + } + + Then("appUserId is cleared") { + assertNull(manager.appUserId) + } + And("a new aliasId was generated") { + assertNotNull(manager.aliasId) + } + } + } + + // ----------------------------------------------------------------------- + // Concurrency stress: rapid-fire mutations + // ----------------------------------------------------------------------- + + @Test + fun `rapid concurrent identifies - last one wins`() = runTest { + Given("a configured ready manager") { + val manager = createSequentialManager() + every { storage.read(DidTrackFirstSeen) } returns true + + manager.configure(neverCalledStaticConfig = false) + withTimeout(5000) { manager.hasIdentity.first() } + + When("multiple identifies are fired concurrently") { + manager.identify("user-1") + manager.identify("user-2") + manager.identify("user-3") + manager.identify("user-4") + manager.identify("user-5") + + // Wait for all to settle — hasIdentity will emit once the + // last action's pending items resolve. + Thread.sleep(500) + withTimeout(5000) { manager.hasIdentity.first() } + } + + Then("the final userId wins") { + assertEquals("user-5", manager.appUserId) + } + And("identity is ready") { + assertTrue(manager.actor.state.value.isReady) + } + And("completeReset was called for user switches") { + // Each switch from one logged-in user to another triggers completeReset + assertTrue(resetCalled) + } + } + } + + @Test + fun `concurrent identifies from different coroutines`() = runTest { + Given("a configured ready manager") { + val manager = createSequentialManager() + every { storage.read(DidTrackFirstSeen) } returns true + + manager.configure(neverCalledStaticConfig = false) + withTimeout(5000) { manager.hasIdentity.first() } + + When("identifies are launched from multiple coroutines simultaneously") { + val jobs = (1..10).map { i -> + kotlinx.coroutines.launch(kotlinx.coroutines.Dispatchers.Default) { + manager.identify("user-$i") + } + } + jobs.forEach { it.join() } + + // Wait for all actions to process through the sequential actor + Thread.sleep(500) + withTimeout(5000) { manager.hasIdentity.first() } + } + + Then("exactly one userId survives") { + assertNotNull(manager.appUserId) + assertTrue(manager.appUserId!!.startsWith("user-")) + } + And("identity is ready and consistent") { + assertTrue(manager.actor.state.value.isReady) + assertEquals(manager.appUserId, manager.userAttributes[Keys.APP_USER_ID]) + } + } + } + + @Test + fun `reset-identify-reset-identify sequence`() = runTest { + Given("a configured ready manager identified as user-1") { + var resetCount = 0 + val manager = createSequentialManager() + // Override completeReset to count calls + val actor = manager.actor + val managerWithCounter = IdentityManager( + deviceHelper = deviceHelper, + storage = storage, + options = { SuperwallOptions() }, + ioScope = IOScope(kotlinx.coroutines.Dispatchers.IO), + notifyUserChange = {}, + completeReset = { resetCount++ }, + trackEvent = { trackedEvents.add(it) }, + actor = actor, + sdkContext = sdkContext, + ) + + every { storage.read(DidTrackFirstSeen) } returns true + + managerWithCounter.configure(neverCalledStaticConfig = false) + withTimeout(5000) { managerWithCounter.hasIdentity.first() } + managerWithCounter.identify("user-1") + Thread.sleep(200) + assertEquals("user-1", managerWithCounter.appUserId) + + When("reset/identify/reset/identify is called in sequence") { + managerWithCounter.reset() + Thread.sleep(200) + withTimeout(5000) { managerWithCounter.hasIdentity.first() } + + managerWithCounter.identify("user-2") + Thread.sleep(200) + withTimeout(5000) { managerWithCounter.hasIdentity.first() } + + managerWithCounter.reset() + Thread.sleep(200) + withTimeout(5000) { managerWithCounter.hasIdentity.first() } + + managerWithCounter.identify("user-3") + Thread.sleep(200) + withTimeout(5000) { managerWithCounter.hasIdentity.first() } + } + + Then("final state is user-3") { + assertEquals("user-3", managerWithCounter.appUserId) + } + And("identity is ready") { + assertTrue(managerWithCounter.actor.state.value.isReady) + } + And("userAttributes are consistent with final identity") { + assertEquals("user-3", managerWithCounter.userAttributes[Keys.APP_USER_ID]) + } + And("completeReset was called for each reset") { + // 2 explicit resets + user switches during identify + assertTrue("resetCount should be >= 2, was $resetCount", resetCount >= 2) + } + } + } + + @Test + fun `rapid reset-identify interleaving from multiple coroutines`() = runTest { + Given("a configured ready manager") { + val manager = createSequentialManager() + every { storage.read(DidTrackFirstSeen) } returns true + + manager.configure(neverCalledStaticConfig = false) + withTimeout(5000) { manager.hasIdentity.first() } + + When("resets and identifies are interleaved from concurrent coroutines") { + val jobs = (1..5).flatMap { i -> + listOf( + kotlinx.coroutines.launch(kotlinx.coroutines.Dispatchers.Default) { + manager.identify("user-$i") + }, + kotlinx.coroutines.launch(kotlinx.coroutines.Dispatchers.Default) { + manager.reset() + }, + ) + } + jobs.forEach { it.join() } + + // Final identify to ensure we end in a known state + manager.identify("final-user") + Thread.sleep(500) + withTimeout(5000) { manager.hasIdentity.first() } + } + + Then("state is consistent — either final-user or anonymous") { + // Due to serialization, the last processed action wins. + // If reset ran last, appUserId is null. If identify ran last, it's "final-user". + val state = manager.actor.state.value + assertTrue("state must be ready", state.isReady) + + if (state.appUserId != null) { + // If logged in, attributes must match + assertEquals(state.appUserId, state.enrichedAttributes[Keys.APP_USER_ID]) + } else { + // If anonymous, aliasId must be in attributes + assertEquals(state.aliasId, state.enrichedAttributes[Keys.ALIAS_ID]) + } + } + And("no crash or deadlock occurred") { + // If we got here, the mutex serialization worked correctly + assertTrue(true) + } + } + } + + // ----------------------------------------------------------------------- + // Persistence interceptor under serialization + // ----------------------------------------------------------------------- + + @Test + fun `persistence interceptor writes only changed fields`() = runTest { + Given("a fresh manager with SequentialActor") { + val manager = createSequentialManager() + every { storage.read(DidTrackFirstSeen) } returns true + + When("configure is dispatched (only phase changes, no identity fields)") { + manager.configure(neverCalledStaticConfig = false) + withTimeout(5000) { manager.hasIdentity.first() } + } + + Then("no identity field writes occurred (only phase changed)") { + // The interceptor should NOT have written AliasId, Seed, etc. + // because those didn't change — only phase did. + // (Initial writes happen in createInitialIdentityState, not the interceptor) + verify(exactly = 0) { storage.write(AppUserId, any()) } + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityLogicEnhancedTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityLogicEnhancedTest.kt index 07f611a95..0153bb4a0 100644 --- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityLogicEnhancedTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityLogicEnhancedTest.kt @@ -108,7 +108,7 @@ class IdentityLogicEnhancedTest { } @Test - fun `mergeAttributes filters null values from lists`() { + fun `mergeAttributes preserves null values in lists`() { val newAttributes = mapOf("items" to listOf("a", null, "b", null, "c")) val result = @@ -119,11 +119,11 @@ class IdentityLogicEnhancedTest { ) val items = result["items"] as List<*> - assertEquals(listOf("a", "b", "c"), items) + assertEquals(listOf("a", null, "b", null, "c"), items) } @Test - fun `mergeAttributes filters null values from maps`() { + fun `mergeAttributes preserves null values in maps`() { val newAttributes = mapOf( "metadata" to @@ -144,7 +144,7 @@ class IdentityLogicEnhancedTest { @Suppress("UNCHECKED_CAST") val metadata = result["metadata"] as Map<*, *> assertTrue(metadata.containsKey("key1")) - assertFalse(metadata.containsKey("key2")) + assertTrue(metadata.containsKey("key2")) assertTrue(metadata.containsKey("key3")) } 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 e8d6be0a5..0f39ee1a4 100644 --- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerTest.kt @@ -4,11 +4,12 @@ import com.superwall.sdk.And import com.superwall.sdk.Given import com.superwall.sdk.Then import com.superwall.sdk.When -import com.superwall.sdk.config.ConfigManager -import com.superwall.sdk.config.models.ConfigState +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.config.options.SuperwallOptions import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.misc.primitives.StateActor import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.config.RawFeatureFlag import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.storage.AliasId import com.superwall.sdk.storage.AppUserId @@ -16,15 +17,18 @@ import com.superwall.sdk.storage.DidTrackFirstSeen import com.superwall.sdk.storage.Seed import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.UserAttributes -import io.mockk.coVerify +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals @@ -35,16 +39,25 @@ import org.junit.Test class IdentityManagerTest { private lateinit var storage: Storage - private lateinit var configManager: ConfigManager private lateinit var deviceHelper: DeviceHelper private var notifiedChanges: MutableList> = mutableListOf() private var resetCalled = false private var trackedEvents: MutableList = mutableListOf() + /** Create a test identity actor using Unconfined dispatcher. */ + private fun testIdentityActor(): StateActor { + val actor = + StateActor( + createInitialIdentityState(storage, "2024-01-01"), + CoroutineScope(Dispatchers.Unconfined), + ) + IdentityPersistenceInterceptor.install(actor, storage) + return actor + } + @Before fun setup() { storage = mockk(relaxed = true) - configManager = mockk(relaxed = true) deviceHelper = mockk(relaxed = true) notifiedChanges = mutableListOf() resetCalled = false @@ -56,8 +69,6 @@ class IdentityManagerTest { every { storage.read(UserAttributes) } returns null every { storage.read(DidTrackFirstSeen) } returns null every { deviceHelper.appInstalledAtString } returns "2024-01-01" - every { configManager.options } returns SuperwallOptions() - every { configManager.configState } returns MutableStateFlow(ConfigState.None) } /** @@ -70,22 +81,24 @@ class IdentityManagerTest { existingAliasId: String? = null, existingSeed: Int? = null, existingAttributes: Map? = null, - neverCalledStaticConfig: Boolean = false, + superwallOptions: SuperwallOptions = SuperwallOptions(), ): IdentityManager { existingAppUserId?.let { every { storage.read(AppUserId) } returns it } existingAliasId?.let { every { storage.read(AliasId) } returns it } existingSeed?.let { every { storage.read(Seed) } returns it } existingAttributes?.let { every { storage.read(UserAttributes) } returns it } + val scope = IOScope(dispatcher.coroutineContext) return IdentityManager( deviceHelper = deviceHelper, storage = storage, - configManager = configManager, - ioScope = IOScope(dispatcher.coroutineContext), - neverCalledStaticConfig = { neverCalledStaticConfig }, + options = { superwallOptions }, + ioScope = scope, notifyUserChange = { notifiedChanges.add(it) }, completeReset = { resetCalled = true }, - track = { trackedEvents.add(it) }, + trackEvent = { trackedEvents.add(it) }, + actor = testIdentityActor(), + sdkContext = mockk(relaxed = true), ) } @@ -97,7 +110,6 @@ class IdentityManagerTest { ioScope: IOScope, existingAppUserId: String? = null, existingAliasId: String? = null, - neverCalledStaticConfig: Boolean = false, ): IdentityManager { existingAppUserId?.let { every { storage.read(AppUserId) } returns it } existingAliasId?.let { every { storage.read(AliasId) } returns it } @@ -105,12 +117,13 @@ class IdentityManagerTest { return IdentityManager( deviceHelper = deviceHelper, storage = storage, - configManager = configManager, + options = { SuperwallOptions() }, ioScope = ioScope, - neverCalledStaticConfig = { neverCalledStaticConfig }, notifyUserChange = { notifiedChanges.add(it) }, completeReset = { resetCalled = true }, - track = { trackedEvents.add(it) }, + trackEvent = { trackedEvents.add(it) }, + actor = testIdentityActor(), + sdkContext = mockk(relaxed = true), ) } @@ -271,10 +284,12 @@ class IdentityManagerTest { fun `externalAccountId returns userId directly when passIdentifiersToPlayStore is true`() = runTest { Given("passIdentifiersToPlayStore is enabled") { - val options = SuperwallOptions().apply { passIdentifiersToPlayStore = true } - every { configManager.options } returns options - - val manager = createManager(this@runTest, existingAppUserId = "user-123") + val manager = + createManager( + this@runTest, + existingAppUserId = "user-123", + superwallOptions = SuperwallOptions().apply { passIdentifiersToPlayStore = true }, + ) val externalId = When("externalAccountId is accessed") { @@ -291,20 +306,20 @@ class IdentityManagerTest { fun `externalAccountId returns sha of userId when passIdentifiersToPlayStore is false`() = runTest { Given("passIdentifiersToPlayStore is disabled") { - val options = SuperwallOptions().apply { passIdentifiersToPlayStore = false } - every { configManager.options } returns options + val testOptions = SuperwallOptions().apply { passIdentifiersToPlayStore = false } val manager = IdentityManager( deviceHelper = deviceHelper, storage = storage, - configManager = configManager, + options = { testOptions }, ioScope = IOScope(Dispatchers.Unconfined), - neverCalledStaticConfig = { false }, stringToSha = { "sha256-of-$it" }, notifyUserChange = {}, completeReset = {}, - track = {}, + trackEvent = {}, + actor = testIdentityActor(), + sdkContext = mockk(relaxed = true), ) val externalId = @@ -336,7 +351,8 @@ class IdentityManagerTest { val oldAlias = manager.aliasId When("reset is called not during identify") { - manager.reset(duringIdentify = false) + manager.reset() + Thread.sleep(100) } Then("appUserId is cleared") { @@ -352,30 +368,6 @@ class IdentityManagerTest { } } } - - @Test - fun `reset during identify does not emit identity`() = - runTest { - Given("a logged in user") { - val manager = createManager(this@runTest, existingAppUserId = "user-123") - - When("reset is called during identify") { - manager.reset(duringIdentify = true) - } - - Then("appUserId is cleared") { - assertNull(manager.appUserId) - } - - And("new alias and seed are persisted") { - verify(atLeast = 2) { storage.write(AliasId, any()) } - verify(atLeast = 2) { storage.write(Seed, any()) } - } - } - } - - // endregion - // region identify @Test @@ -383,15 +375,13 @@ class IdentityManagerTest { runTest { Given("a fresh manager with no logged in user") { val testScope = IOScope(this@runTest.coroutineContext) - val configState = MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState val manager = createManagerWithScope(testScope) When("identify is called with a new userId") { manager.identify("new-user-456") // Internal queue dispatches asynchronously - Thread.sleep(200) + Thread.sleep(100) } Then("appUserId is set") { @@ -413,19 +403,16 @@ class IdentityManagerTest { runTest { Given("a manager with an existing userId") { val testScope = IOScope(this@runTest.coroutineContext) - val configState = MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState val manager = createManagerWithScope(testScope) // First identify manager.identify("user-123") Thread.sleep(200) - advanceUntilIdle() When("identify is called again with the same userId") { manager.identify("user-123") - Thread.sleep(200) + Thread.sleep(100) } Then("completeReset is not called") { @@ -444,7 +431,7 @@ class IdentityManagerTest { When("identify is called with an empty string") { manager.identify("") - Thread.sleep(200) + Thread.sleep(100) } Then("appUserId remains null") { @@ -462,15 +449,14 @@ class IdentityManagerTest { runTest { Given("a manager already identified with user-A") { val testScope = IOScope(this@runTest.coroutineContext) - val configState = MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState + every { storage.read(AppUserId) } returns "user-A" val manager = createManagerWithScope(testScope, existingAppUserId = "user-A") When("identify is called with a different userId") { manager.identify("user-B") - Thread.sleep(200) + Thread.sleep(100) } Then("completeReset is called") { @@ -497,16 +483,20 @@ class IdentityManagerTest { val manager = createManagerWithScope( ioScope = testScope, - neverCalledStaticConfig = true, ) - When("configure is called") { - manager.configure() - advanceUntilIdle() + When("configure is dispatched") { + manager.effect(IdentityState.Actions.Configure(neverCalledStaticConfig = true)) + Thread.sleep(100) } - Then("getAssignments is not called") { - coVerify(exactly = 0) { configManager.getAssignments() } + Then("identity is ready immediately without pending assignments") { + assertTrue("Identity should be ready", manager.actor.state.value.isReady) + assertFalse( + "Should not have pending assignments", + manager.actor.state.value.pending + .contains(IdentityState.Pending.Assignments), + ) } } } @@ -525,7 +515,7 @@ class IdentityManagerTest { When("mergeUserAttributes is called with new attributes") { manager.mergeUserAttributes(mapOf("name" to "Test User")) - Thread.sleep(200) + Thread.sleep(100) } Then("merged attributes are written to storage") { @@ -549,8 +539,7 @@ class IdentityManagerTest { mapOf("key" to "value"), shouldTrackMerge = true, ) - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) } Then("an Attributes event is tracked") { @@ -572,7 +561,7 @@ class IdentityManagerTest { mapOf("key" to "value"), shouldTrackMerge = false, ) - Thread.sleep(200) + Thread.sleep(100) } Then("no event is tracked") { @@ -591,7 +580,7 @@ class IdentityManagerTest { When("mergeAndNotify is called") { manager.mergeAndNotify(mapOf("key" to "value")) - Thread.sleep(200) + Thread.sleep(100) } Then("notifyUserChange callback is invoked") { @@ -600,5 +589,492 @@ class IdentityManagerTest { } } + @Test + fun `mergeUserAttributes does not call notifyUserChange`() = + runTest { + Given("a manager") { + val testScope = IOScope(this@runTest.coroutineContext) + + val manager = createManagerWithScope(testScope) + + When("mergeUserAttributes is called (not mergeAndNotify)") { + manager.mergeUserAttributes(mapOf("key" to "value")) + Thread.sleep(100) + } + + Then("notifyUserChange callback is NOT invoked") { + assertTrue(notifiedChanges.isEmpty()) + } + } + } + + // endregion + + // region identify - restorePaywallAssignments + + @Test + fun `identify with restorePaywallAssignments true sets appUserId`() = + runTest { + Given("a manager with config available") { + val testScope = IOScope(this@runTest.coroutineContext) + + val manager = createManagerWithScope(testScope) + + When("identify is called with restorePaywallAssignments = true") { + manager.identify( + "user-restore", + options = IdentityOptions(restorePaywallAssignments = true), + ) + Thread.sleep(100) + } + + Then("appUserId is set") { + assertEquals("user-restore", manager.appUserId) + } + + And("userId is persisted") { + verify { storage.write(AppUserId, "user-restore") } + } + } + } + + @Test + fun `identify with restorePaywallAssignments false sets appUserId`() = + runTest { + Given("a manager with config available") { + val testScope = IOScope(this@runTest.coroutineContext) + + val manager = createManagerWithScope(testScope) + + When("identify is called with restorePaywallAssignments = false (default)") { + manager.identify("user-no-restore") + Thread.sleep(100) + } + + Then("appUserId is set") { + assertEquals("user-no-restore", manager.appUserId) + } + + And("userId is persisted") { + verify { storage.write(AppUserId, "user-no-restore") } + } + } + } + + // endregion + + // region identify - side effects + + @Test + fun `identify with whitespace-only userId is a no-op`() = + runTest { + Given("a fresh manager") { + val testScope = IOScope(this@runTest.coroutineContext) + + val manager = createManagerWithScope(testScope) + + When("identify is called with whitespace-only string") { + manager.identify(" \n\t ") + Thread.sleep(100) + } + + Then("appUserId remains null") { + assertNull(manager.appUserId) + } + + And("completeReset is not called") { + assertFalse(resetCalled) + } + } + } + + @Test + fun `identify tracks IdentityAlias event`() = + runTest { + Given("a manager with config available") { + val testScope = IOScope(this@runTest.coroutineContext) + + val manager = createManagerWithScope(testScope) + + When("identify is called with a new userId") { + manager.identify("user-track-test") + Thread.sleep(100) + } + + Then("an IdentityAlias event is tracked") { + assertTrue( + "Expected IdentityAlias event in tracked events, got: $trackedEvents", + trackedEvents.any { it is InternalSuperwallEvent.IdentityAlias }, + ) + } + } + } + + @Test + fun `identify persists aliasId along with appUserId`() = + runTest { + Given("a manager with config available") { + val testScope = IOScope(this@runTest.coroutineContext) + + val manager = createManagerWithScope(testScope) + + When("identify is called") { + manager.identify("user-side-effects") + Thread.sleep(100) + } + + Then("appUserId is persisted") { + verify { storage.write(AppUserId, "user-side-effects") } + } + + And("aliasId is persisted alongside it") { + verify { storage.write(AliasId, any()) } + } + + And("seed is persisted alongside it") { + verify { storage.write(Seed, any()) } + } + } + } + + // endregion + + // region identify - seed re-computation with enableUserIdSeed + + @Test + fun `identify re-seeds from userId SHA when enableUserIdSeed flag is true`() = + runTest { + Given("a config with enableUserIdSeed enabled") { + val configWithFlag = + Config.stub().copy( + rawFeatureFlags = + listOf( + RawFeatureFlag("enable_userid_seed", true), + ), + ) + // Set up sdkContext mock so ResolveSeed can read the config + val sdkContext = mockk(relaxed = true) + coEvery { sdkContext.awaitConfig() } returns configWithFlag + + val manager = + IdentityManager( + deviceHelper = deviceHelper, + storage = storage, + options = { SuperwallOptions() }, + ioScope = IOScope(this@runTest.coroutineContext), + notifyUserChange = { notifiedChanges.add(it) }, + completeReset = { resetCalled = true }, + trackEvent = { trackedEvents.add(it) }, + actor = testIdentityActor(), + sdkContext = sdkContext, + ) + + val seedBefore = manager.seed + + When("identify is called with a userId") { + manager.identify("deterministic-user") + Thread.sleep(100) + } + + Then("seed is updated based on the userId hash") { + val seedAfter = manager.seed + // The seed should be deterministically derived from the userId + assertTrue("Seed should be in range 0-99, got: $seedAfter", seedAfter in 0..99) + // Verify seed was written to storage + verify(atLeast = 1) { storage.write(Seed, any()) } + } + } + } + + // endregion + + // region hasIdentity flow + + @Test + fun `hasIdentity emits true after configure`() = + runTest { + Given("a fresh manager") { + val testScope = IOScope(this@runTest.coroutineContext) + every { storage.read(DidTrackFirstSeen) } returns true + + val manager = + createManagerWithScope( + ioScope = testScope, + ) + + When("configure is dispatched") { + manager.effect(IdentityState.Actions.Configure(neverCalledStaticConfig = false)) + Thread.sleep(100) + } + + Then("hasIdentity emits true") { + val result = withTimeout(2000) { manager.hasIdentity.first() } + assertTrue(result) + } + } + } + + @Test + fun `hasIdentity emits true after configure for returning user`() = + runTest { + Given("a returning anonymous user") { + val testScope = IOScope(this@runTest.coroutineContext) + every { storage.read(DidTrackFirstSeen) } returns true + + val manager = + createManagerWithScope( + ioScope = testScope, + existingAliasId = "returning-alias", + ) + + var identityReceived = false + val collectJob = + launch { + manager.hasIdentity.first() + identityReceived = true + } + + When("configure is dispatched") { + manager.effect(IdentityState.Actions.Configure(neverCalledStaticConfig = false)) + Thread.sleep(100) + advanceUntilIdle() + } + + Then("hasIdentity emitted true") { + collectJob.cancel() + assertTrue( + "hasIdentity should have emitted true after configure", + identityReceived, + ) + } + } + } + + // endregion + + // region configure - additional cases + + @Test + fun `configure triggers assignment fetching when logged in and neverCalledStaticConfig`() = + runTest { + Given("a logged-in returning user with neverCalledStaticConfig = true") { + val testScope = IOScope(this@runTest.coroutineContext) + every { storage.read(DidTrackFirstSeen) } returns true + + val manager = + createManagerWithScope( + ioScope = testScope, + existingAppUserId = "user-123", + ) + + When("configure is dispatched") { + manager.effect(IdentityState.Actions.Configure(neverCalledStaticConfig = true)) + Thread.sleep(100) + } + + Then("identity state reflects that assignments were requested") { + assertTrue("Identity should be ready after configure", manager.actor.state.value.isReady) + } + } + } + + @Test + fun `configure triggers assignment fetching for anonymous returning user with neverCalledStaticConfig`() = + runTest { + Given("an anonymous returning user with neverCalledStaticConfig = true") { + val testScope = IOScope(this@runTest.coroutineContext) + every { storage.read(DidTrackFirstSeen) } returns true // not first open + + val manager = + createManagerWithScope( + ioScope = testScope, + ) + + When("configure is dispatched") { + manager.effect(IdentityState.Actions.Configure(neverCalledStaticConfig = true)) + Thread.sleep(100) + } + + Then("identity state reflects that assignments were requested") { + assertTrue("Identity should be ready after configure", manager.actor.state.value.isReady) + } + } + } + + @Test + fun `configure does not trigger assignments when neverCalledStaticConfig is false`() = + runTest { + Given("a logged-in user but static config has been called") { + val testScope = IOScope(this@runTest.coroutineContext) + every { storage.read(DidTrackFirstSeen) } returns true + + val manager = + createManagerWithScope( + ioScope = testScope, + existingAppUserId = "user-123", + ) + + When("configure is dispatched") { + manager.effect(IdentityState.Actions.Configure(neverCalledStaticConfig = false)) + Thread.sleep(100) + } + + Then("identity is ready without pending assignments") { + assertTrue("Identity should be ready", manager.actor.state.value.isReady) + assertFalse( + "Should not have pending assignments", + manager.actor.state.value.pending + .contains(IdentityState.Pending.Assignments), + ) + } + } + } + + // endregion + + // region reset - custom attributes cleared + + @Test + fun `reset clears custom attributes but repopulates identity fields`() = + runTest { + Given("an identified user with custom attributes") { + val manager = + createManager( + this@runTest, + existingAppUserId = "user-123", + existingAliasId = "old-alias", + existingSeed = 42, + existingAttributes = + mapOf( + "aliasId" to "old-alias", + "seed" to 42, + "appUserId" to "user-123", + "customName" to "John", + "customEmail" to "john@test.com", + "applicationInstalledAt" to "2024-01-01", + ), + ) + + When("reset is called") { + manager.reset() + } + + Thread.sleep(100) + + Then("custom attributes are gone") { + val attrs = manager.userAttributes + assertFalse( + "customName should not survive reset, got: $attrs", + attrs.containsKey("customName"), + ) + assertFalse( + "customEmail should not survive reset, got: $attrs", + attrs.containsKey("customEmail"), + ) + } + + And("identity fields are repopulated with new values") { + val attrs = manager.userAttributes + assertTrue(attrs.containsKey("aliasId")) + assertTrue(attrs.containsKey("seed")) + assertNotEquals("old-alias", attrs["aliasId"]) + } + } + } + + // endregion + + // region userAttributes getter invariant + + @Test + fun `userAttributes getter always injects identity fields even when internal map is empty`() = + runTest { + Given("a manager with no stored attributes") { + val manager = createManager(this@runTest, existingAliasId = "test-alias", existingSeed = 55) + + Then("userAttributes always contains aliasId") { + val attrs = manager.userAttributes + assertTrue( + "userAttributes must always contain aliasId, got: $attrs", + attrs.containsKey("aliasId"), + ) + assertEquals("test-alias", attrs["aliasId"]) + } + + And("userAttributes always contains appUserId (falls back to aliasId when anonymous)") { + val attrs = manager.userAttributes + assertTrue(attrs.containsKey("appUserId")) + assertEquals("test-alias", attrs["appUserId"]) + } + } + } + + @Test + fun `userAttributes getter reflects appUserId after identify`() = + runTest { + Given("a fresh manager") { + val testScope = IOScope(this@runTest.coroutineContext) + + val manager = createManagerWithScope(testScope) + val aliasBeforeIdentify = manager.aliasId + + When("identify is called") { + manager.identify("real-user") + Thread.sleep(100) + } + + Then("userAttributes appUserId reflects the identified user") { + assertEquals("real-user", manager.userAttributes["appUserId"]) + } + + And("userAttributes aliasId is still present") { + assertEquals(aliasBeforeIdentify, manager.userAttributes["aliasId"]) + } + } + } + + // endregion + + // region concurrent operations + + @Test + fun `concurrent identify and mergeUserAttributes do not lose data`() = + runTest { + Given("a manager with config available") { + val testScope = IOScope(this@runTest.coroutineContext) + + val manager = createManagerWithScope(testScope) + + When("identify and mergeUserAttributes are called concurrently") { + val job1 = launch { manager.identify("concurrent-user") } + val job2 = + launch { + manager.mergeUserAttributes( + mapOf("name" to "Test", "plan" to "premium"), + ) + } + job1.join() + job2.join() + Thread.sleep(100) + } + + Then("appUserId is set correctly") { + assertEquals("concurrent-user", manager.appUserId) + } + + And("identity fields are always present in userAttributes") { + val attrs = manager.userAttributes + assertTrue( + "aliasId must be present, got: $attrs", + attrs.containsKey("aliasId"), + ) + assertTrue( + "appUserId must be present, got: $attrs", + attrs.containsKey("appUserId"), + ) + } + } + } + // endregion } 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 ba6935c32..cf6a78b73 100644 --- a/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityManagerUserAttributesTest.kt @@ -4,11 +4,9 @@ import com.superwall.sdk.And import com.superwall.sdk.Given import com.superwall.sdk.Then import com.superwall.sdk.When -import com.superwall.sdk.config.ConfigManager -import com.superwall.sdk.config.models.ConfigState import com.superwall.sdk.config.options.SuperwallOptions import com.superwall.sdk.misc.IOScope -import com.superwall.sdk.models.config.Config +import com.superwall.sdk.misc.primitives.StateActor import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.storage.AliasId import com.superwall.sdk.storage.AppUserId @@ -18,9 +16,9 @@ import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.UserAttributes import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -38,7 +36,6 @@ import org.junit.Test */ class IdentityManagerUserAttributesTest { private lateinit var storage: Storage - private lateinit var configManager: ConfigManager private lateinit var deviceHelper: DeviceHelper private var resetCalled = false private var trackedEvents: MutableList = mutableListOf() @@ -47,7 +44,6 @@ class IdentityManagerUserAttributesTest { fun setup() = runTest { storage = mockk(relaxed = true) - configManager = mockk(relaxed = true) deviceHelper = mockk(relaxed = true) resetCalled = false trackedEvents = mutableListOf() @@ -58,8 +54,6 @@ class IdentityManagerUserAttributesTest { every { storage.read(UserAttributes) } returns null every { storage.read(DidTrackFirstSeen) } returns null every { deviceHelper.appInstalledAtString } returns "2024-01-01" - every { configManager.options } returns SuperwallOptions() - every { configManager.configState } returns MutableStateFlow(ConfigState.None) } private fun createManager( @@ -77,15 +71,26 @@ class IdentityManagerUserAttributesTest { return IdentityManager( deviceHelper = deviceHelper, storage = storage, - configManager = configManager, + options = { SuperwallOptions() }, ioScope = IOScope(scope.coroutineContext), - neverCalledStaticConfig = { false }, notifyUserChange = {}, completeReset = { resetCalled = true }, - track = { trackedEvents.add(it) }, + trackEvent = { trackedEvents.add(it) }, + actor = testActor(), + sdkContext = mockk(relaxed = true), ) } + private fun testActor(): StateActor { + val actor = + StateActor( + createInitialIdentityState(storage, "2024-01-01"), + CoroutineScope(Dispatchers.Unconfined), + ) + IdentityPersistenceInterceptor.install(actor, storage) + return actor + } + private fun createManagerWithScope( ioScope: IOScope, existingAppUserId: String? = null, @@ -101,12 +106,13 @@ class IdentityManagerUserAttributesTest { return IdentityManager( deviceHelper = deviceHelper, storage = storage, - configManager = configManager, + options = { SuperwallOptions() }, ioScope = ioScope, - neverCalledStaticConfig = { false }, notifyUserChange = {}, completeReset = { resetCalled = true }, - track = { trackedEvents.add(it) }, + trackEvent = { trackedEvents.add(it) }, + actor = testActor(), + sdkContext = mockk(relaxed = true), ) } @@ -122,7 +128,7 @@ class IdentityManagerUserAttributesTest { } // Allow scope.launch from init's mergeUserAttributes to complete - Thread.sleep(200) + Thread.sleep(100) Then("userAttributes contains aliasId") { val attrs = manager.userAttributes @@ -148,17 +154,14 @@ class IdentityManagerUserAttributesTest { fun `fresh install - identify adds appUserId to userAttributes`() = runTest { Given("a fresh install") { - val configState = - MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState val testScope = IOScope(this@runTest.coroutineContext) val manager = createManagerWithScope(testScope) When("identify is called with a new userId") { manager.identify("user-123") - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } Then("userAttributes contains appUserId") { @@ -238,9 +241,7 @@ class IdentityManagerUserAttributesTest { "appUserId" to "user-123", "applicationInstalledAt" to "2024-01-01", ) - val configState = - MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState + val testScope = IOScope(this@runTest.coroutineContext) val manager = @@ -254,8 +255,8 @@ class IdentityManagerUserAttributesTest { When("identify is called with the SAME userId") { manager.identify("user-123") - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } Then("userAttributes still contains aliasId") { @@ -288,7 +289,7 @@ class IdentityManagerUserAttributesTest { } // Allow any async merges to complete - Thread.sleep(200) + Thread.sleep(100) Then("aliasId individual field is correct") { assertEquals("stored-alias", manager.aliasId) @@ -322,9 +323,6 @@ class IdentityManagerUserAttributesTest { fun `BUG - returning user with empty storage, same identify, then setUserAttributes`() = runTest { Given("UserAttributes failed to load, individual IDs exist") { - val configState = - MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState val testScope = IOScope(this@runTest.coroutineContext) val manager = @@ -338,14 +336,14 @@ class IdentityManagerUserAttributesTest { When("identify is called with the SAME userId (early return, no saveIds)") { manager.identify("user-123") - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } And("setUserAttributes is called with custom data") { manager.mergeUserAttributes(mapOf("name" to "John")) - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } Then("userAttributes should contain the custom attribute") { @@ -388,8 +386,8 @@ class IdentityManagerUserAttributesTest { When("setUserAttributes is called without any identify") { manager.mergeUserAttributes(mapOf("name" to "John")) - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } Then("userAttributes contains custom attribute") { @@ -431,11 +429,11 @@ class IdentityManagerUserAttributesTest { ) When("reset is called") { - manager.reset(duringIdentify = false) + manager.reset() } // Allow async operations - Thread.sleep(200) + Thread.sleep(100) Then("userAttributes contains the NEW aliasId") { val attrs = manager.userAttributes @@ -467,9 +465,6 @@ class IdentityManagerUserAttributesTest { fun `reset during identify followed by new identify populates userAttributes`() = runTest { Given("a user identified as user-A") { - val configState = - MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState val testScope = IOScope(this@runTest.coroutineContext) val manager = @@ -489,8 +484,8 @@ class IdentityManagerUserAttributesTest { When("identify is called with a DIFFERENT userId (triggers reset)") { manager.identify("user-B") - Thread.sleep(300) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } Then("appUserId is user-B") { @@ -521,17 +516,14 @@ class IdentityManagerUserAttributesTest { fun `setUserAttributes does not remove identity fields`() = runTest { Given("a fresh install where init merge has completed") { - val configState = - MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState val testScope = IOScope(this@runTest.coroutineContext) val manager = createManagerWithScope(testScope) // First identify to get appUserId into attributes manager.identify("user-123") - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) val attrsBefore = manager.userAttributes assertNotNull( @@ -544,8 +536,8 @@ class IdentityManagerUserAttributesTest { manager.mergeUserAttributes( mapOf("name" to "John", "email" to "john@example.com"), ) - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } Then("custom attributes are added") { @@ -572,19 +564,16 @@ class IdentityManagerUserAttributesTest { runTest { Given("a manager with identity fields in userAttributes") { val testScope = IOScope(this@runTest.coroutineContext) - val configState = - MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState val manager = createManagerWithScope(testScope) manager.identify("user-123") - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) When("setUserAttributes is called with aliasId = null") { manager.mergeUserAttributes(mapOf("aliasId" to null)) - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) } Then("aliasId is removed from userAttributes") { @@ -608,15 +597,12 @@ class IdentityManagerUserAttributesTest { fun `after identify - aliasId field and userAttributes aliasId are consistent`() = runTest { Given("a fresh install") { - val configState = - MutableStateFlow(ConfigState.Retrieved(Config.stub())) - every { configManager.configState } returns configState val testScope = IOScope(this@runTest.coroutineContext) val manager = createManagerWithScope(testScope) manager.identify("user-123") - Thread.sleep(200) - advanceUntilIdle() + Thread.sleep(100) + Thread.sleep(100) Then("aliasId field matches userAttributes aliasId") { assertEquals( @@ -661,10 +647,10 @@ class IdentityManagerUserAttributesTest { ) When("reset is called") { - manager.reset(duringIdentify = false) + manager.reset() } - Thread.sleep(200) + Thread.sleep(100) Then("aliasId field matches userAttributes aliasId") { assertEquals( @@ -707,7 +693,7 @@ class IdentityManagerUserAttributesTest { } // Allow init merge to complete - Thread.sleep(200) + Thread.sleep(100) Then("userAttributes contains the newly generated aliasId") { val attrs = manager.userAttributes @@ -740,7 +726,7 @@ class IdentityManagerUserAttributesTest { } // Allow any async operations to complete - Thread.sleep(200) + Thread.sleep(100) Then("the individual fields are correct") { assertEquals("stored-alias", manager.aliasId) diff --git a/superwall/src/test/java/com/superwall/sdk/identity/IdentityStateReducerTest.kt b/superwall/src/test/java/com/superwall/sdk/identity/IdentityStateReducerTest.kt new file mode 100644 index 000000000..a0fdc6fd5 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/identity/IdentityStateReducerTest.kt @@ -0,0 +1,724 @@ +package com.superwall.sdk.identity + +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.And +import com.superwall.sdk.identity.IdentityState.Pending +import com.superwall.sdk.identity.IdentityState.Phase +import com.superwall.sdk.identity.IdentityState.Updates +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure reducer tests — no coroutines, no mocks, no actor pipeline. + * Each test applies a single Updates reducer to a known state and + * asserts the output. + */ +class IdentityStateReducerTest { + + private fun readyState( + appUserId: String? = null, + aliasId: String = "alias-1", + seed: Int = 42, + userAttributes: Map = emptyMap(), + appInstalledAtString: String = "2024-01-01", + ) = IdentityState( + appUserId = appUserId, + aliasId = aliasId, + seed = seed, + userAttributes = userAttributes, + phase = Phase.Ready, + appInstalledAtString = appInstalledAtString, + ) + + private fun pendingState( + vararg items: Pending, + appUserId: String? = null, + aliasId: String = "alias-1", + seed: Int = 42, + userAttributes: Map = emptyMap(), + ) = IdentityState( + appUserId = appUserId, + aliasId = aliasId, + seed = seed, + userAttributes = userAttributes, + phase = Phase.Pending(items.toSet()), + appInstalledAtString = "2024-01-01", + ) + + // ----------------------------------------------------------------------- + // Updates.Identify + // ----------------------------------------------------------------------- + + @Test + fun `Identify - first login sets appUserId and Pending Seed`() = + Given("an anonymous ready state") { + val state = readyState() + + val result = When("Identify is applied with a new userId") { + Updates.Identify("user-1", restoreAssignments = false).reduce(state) + } + + Then("appUserId is set") { + assertEquals("user-1", result.appUserId) + } + And("phase is Pending with Seed") { + assertEquals(Phase.Pending(setOf(Pending.Seed)), result.phase) + } + And("userAttributes contain the userId") { + assertEquals("user-1", result.userAttributes[Keys.APP_USER_ID]) + } + } + + @Test + fun `Identify - with restoreAssignments adds Assignments to pending`() = + Given("an anonymous ready state") { + val state = readyState() + + val result = When("Identify is applied with restoreAssignments = true") { + Updates.Identify("user-1", restoreAssignments = true).reduce(state) + } + + Then("phase has both Seed and Assignments pending") { + assertEquals( + Phase.Pending(setOf(Pending.Seed, Pending.Assignments)), + result.phase, + ) + } + } + + @Test + fun `Identify - same userId is a no-op`() = + Given("a logged-in state") { + val state = readyState(appUserId = "user-1") + + val result = When("Identify is applied with the same userId") { + Updates.Identify("user-1", restoreAssignments = false).reduce(state) + } + + Then("state is unchanged") { + assertEquals(state, result) + } + } + + @Test + fun `Identify - switching users resets to fresh identity`() = + Given("a logged-in state with attributes") { + val state = readyState( + appUserId = "user-1", + aliasId = "old-alias", + seed = 42, + userAttributes = mapOf("custom" to "value"), + ) + + val result = When("Identify is applied with a different userId") { + Updates.Identify("user-2", restoreAssignments = false).reduce(state) + } + + Then("appUserId is updated") { + assertEquals("user-2", result.appUserId) + } + And("aliasId is regenerated (different from old)") { + assertNotEquals("old-alias", result.aliasId) + } + And("old custom attributes are dropped") { + assertNull(result.userAttributes["custom"]) + } + And("new identity attributes are present") { + assertEquals("user-2", result.userAttributes[Keys.APP_USER_ID]) + } + } + + @Test + fun `Identify - preserves appInstalledAtString across user switch`() = + Given("a state with a specific install date") { + val state = readyState( + appUserId = "old-user", + appInstalledAtString = "2023-06-15", + ) + + val result = When("switching users") { + Updates.Identify("new-user", restoreAssignments = false).reduce(state) + } + + Then("appInstalledAtString is preserved") { + assertEquals("2023-06-15", result.appInstalledAtString) + } + } + + // ----------------------------------------------------------------------- + // Updates.SeedResolved + // ----------------------------------------------------------------------- + + @Test + fun `SeedResolved - updates seed and resolves Pending Seed`() = + Given("a state with Seed pending") { + val state = pendingState(Pending.Seed, appUserId = "user-1", seed = 42) + + val result = When("SeedResolved is applied") { + Updates.SeedResolved(seed = 77).reduce(state) + } + + Then("seed is updated") { + assertEquals(77, result.seed) + } + And("state is Ready (no other pending items)") { + assertTrue(result.isReady) + } + And("seed is in userAttributes") { + assertEquals(77, result.userAttributes[Keys.SEED]) + } + } + + @Test + fun `SeedResolved - preserves other pending items`() = + Given("a state with Seed and Assignments pending") { + val state = pendingState(Pending.Seed, Pending.Assignments, appUserId = "user-1") + + val result = When("SeedResolved is applied") { + Updates.SeedResolved(seed = 55).reduce(state) + } + + Then("Seed is resolved but Assignments remains") { + assertEquals(Phase.Pending(setOf(Pending.Assignments)), result.phase) + } + And("state is not ready") { + assertFalse(result.isReady) + } + } + + // ----------------------------------------------------------------------- + // Updates.SeedSkipped + // ----------------------------------------------------------------------- + + @Test + fun `SeedSkipped - resolves Pending Seed without changing seed value`() = + Given("a state with Seed pending") { + val state = pendingState(Pending.Seed, seed = 42) + + val result = When("SeedSkipped is applied") { + Updates.SeedSkipped.reduce(state) + } + + Then("seed is unchanged") { + assertEquals(42, result.seed) + } + And("state is Ready") { + assertTrue(result.isReady) + } + } + + @Test + fun `SeedSkipped - preserves other pending items`() = + Given("a state with Seed and Assignments pending") { + val state = pendingState(Pending.Seed, Pending.Assignments) + + val result = When("SeedSkipped is applied") { + Updates.SeedSkipped.reduce(state) + } + + Then("only Assignments remains pending") { + assertEquals(Phase.Pending(setOf(Pending.Assignments)), result.phase) + } + } + + // ----------------------------------------------------------------------- + // Updates.AttributesMerged + // ----------------------------------------------------------------------- + + @Test + fun `AttributesMerged - adds new attributes`() = + Given("a state with existing attributes") { + val state = readyState(userAttributes = mapOf("existing" to "value")) + + val result = When("new attributes are merged") { + Updates.AttributesMerged(mapOf("new_key" to "new_value")).reduce(state) + } + + Then("both old and new attributes are present") { + assertEquals("value", result.userAttributes["existing"]) + assertEquals("new_value", result.userAttributes["new_key"]) + } + } + + @Test + fun `AttributesMerged - null removes attribute`() = + Given("a state with an attribute") { + val state = readyState(userAttributes = mapOf("name" to "John", "age" to 30)) + + val result = When("attribute is set to null") { + Updates.AttributesMerged(mapOf("name" to null)).reduce(state) + } + + Then("attribute is removed") { + assertFalse(result.userAttributes.containsKey("name")) + } + And("other attributes remain") { + assertEquals(30, result.userAttributes["age"]) + } + } + + @Test + fun `AttributesMerged - does not change phase`() = + Given("a pending state") { + val state = pendingState(Pending.Configuration) + + val result = When("attributes are merged") { + Updates.AttributesMerged(mapOf("key" to "val")).reduce(state) + } + + Then("phase is unchanged") { + assertEquals(Phase.Pending(setOf(Pending.Configuration)), result.phase) + } + } + + // ----------------------------------------------------------------------- + // Updates.AssignmentsCompleted + // ----------------------------------------------------------------------- + + @Test + fun `AssignmentsCompleted - resolves Pending Assignments`() = + Given("a state with only Assignments pending") { + val state = pendingState(Pending.Assignments) + + val result = When("AssignmentsCompleted is applied") { + Updates.AssignmentsCompleted.reduce(state) + } + + Then("state is Ready") { + assertTrue(result.isReady) + } + } + + @Test + fun `AssignmentsCompleted - no-op when Assignments not pending`() = + Given("a Ready state") { + val state = readyState() + + val result = When("AssignmentsCompleted is applied") { + Updates.AssignmentsCompleted.reduce(state) + } + + Then("state is unchanged") { + assertEquals(state, result) + } + } + + @Test + fun `AssignmentsCompleted - preserves other pending items`() = + Given("a state with Seed and Assignments pending") { + val state = pendingState(Pending.Seed, Pending.Assignments) + + val result = When("AssignmentsCompleted is applied") { + Updates.AssignmentsCompleted.reduce(state) + } + + Then("only Seed remains pending") { + assertEquals(Phase.Pending(setOf(Pending.Seed)), result.phase) + } + } + + // ----------------------------------------------------------------------- + // Updates.Configure + // ----------------------------------------------------------------------- + + @Test + fun `Configure - resolves Configuration when no assignments needed`() = + Given("initial state with Configuration pending") { + val state = pendingState(Pending.Configuration) + + val result = When("Configure is applied with needsAssignments = false") { + Updates.Configure(needsAssignments = false).reduce(state) + } + + Then("state is Ready") { + assertTrue(result.isReady) + } + } + + @Test + fun `Configure - adds Assignments when needed`() = + Given("initial state with Configuration pending") { + val state = pendingState(Pending.Configuration) + + val result = When("Configure is applied with needsAssignments = true") { + Updates.Configure(needsAssignments = true).reduce(state) + } + + Then("Configuration is resolved and Assignments is added") { + assertEquals(Phase.Pending(setOf(Pending.Assignments)), result.phase) + } + } + + @Test + fun `Configure - preserves existing Seed pending from concurrent identify`() = + Given("a state with both Configuration and Seed pending (identify ran before config)") { + val state = pendingState(Pending.Configuration, Pending.Seed) + + val result = When("Configure is applied with needsAssignments = false") { + Updates.Configure(needsAssignments = false).reduce(state) + } + + Then("Configuration is resolved but Seed remains") { + assertEquals(Phase.Pending(setOf(Pending.Seed)), result.phase) + } + And("state is not ready") { + assertFalse(result.isReady) + } + } + + @Test + fun `Configure - preserves Seed and adds Assignments`() = + Given("a state with Configuration and Seed pending") { + val state = pendingState(Pending.Configuration, Pending.Seed) + + val result = When("Configure is applied with needsAssignments = true") { + Updates.Configure(needsAssignments = true).reduce(state) + } + + Then("both Seed and Assignments are pending") { + assertEquals( + Phase.Pending(setOf(Pending.Seed, Pending.Assignments)), + result.phase, + ) + } + } + + @Test + fun `Configure - on already Ready state with needsAssignments adds Assignments`() = + Given("a Ready state (Configure already ran or not applicable)") { + val state = readyState() + + val result = When("Configure is applied with needsAssignments = true") { + Updates.Configure(needsAssignments = true).reduce(state) + } + + Then("Assignments is pending") { + assertEquals(Phase.Pending(setOf(Pending.Assignments)), result.phase) + } + } + + @Test + fun `Configure - on already Ready state without assignments is no-op`() = + Given("a Ready state") { + val state = readyState() + + val result = When("Configure is applied with needsAssignments = false") { + Updates.Configure(needsAssignments = false).reduce(state) + } + + Then("state remains Ready") { + assertTrue(result.isReady) + } + } + + // ----------------------------------------------------------------------- + // Updates.Reset + // ----------------------------------------------------------------------- + + @Test + fun `Reset - creates fresh identity with Pending Configuration`() = + Given("a logged-in state with attributes") { + val state = readyState( + appUserId = "user-1", + aliasId = "alias-1", + seed = 42, + userAttributes = mapOf("custom" to "value"), + ) + + val result = When("Reset is applied") { + Updates.Reset.reduce(state) + } + + Then("appUserId is cleared") { + assertNull(result.appUserId) + } + And("aliasId is regenerated") { + assertNotEquals("alias-1", result.aliasId) + } + And("seed is regenerated") { + // Can't assert exact value since it's random, but it exists + assertTrue(result.seed in 0..99) + } + And("custom attributes are cleared") { + assertNull(result.userAttributes["custom"]) + } + And("phase is Pending Configuration (not Ready)") { + assertEquals(Phase.Pending(setOf(Pending.Configuration)), result.phase) + assertFalse(result.isReady) + } + And("appInstalledAtString is preserved") { + assertEquals("2024-01-01", result.appInstalledAtString) + } + And("aliasId is in userAttributes") { + assertEquals(result.aliasId, result.userAttributes[Keys.ALIAS_ID]) + } + } + + @Test + fun `Reset - from anonymous state also generates fresh identity`() = + Given("an anonymous state") { + val state = readyState(aliasId = "old-alias") + + val result = When("Reset is applied") { + Updates.Reset.reduce(state) + } + + Then("aliasId is regenerated") { + assertNotEquals("old-alias", result.aliasId) + } + And("state is not ready") { + assertFalse(result.isReady) + } + } + + // ----------------------------------------------------------------------- + // Updates.ResetComplete + // ----------------------------------------------------------------------- + + @Test + fun `ResetComplete - sets phase to Ready`() = + Given("a pending state (after Reset)") { + val state = pendingState(Pending.Configuration) + + val result = When("ResetComplete is applied") { + Updates.ResetComplete.reduce(state) + } + + Then("state is Ready") { + assertTrue(result.isReady) + } + } + + @Test + fun `ResetComplete - on already Ready state is no-op`() = + Given("a Ready state") { + val state = readyState() + + val result = When("ResetComplete is applied") { + Updates.ResetComplete.reduce(state) + } + + Then("state remains Ready") { + assertTrue(result.isReady) + } + } + + // ----------------------------------------------------------------------- + // IdentityState helpers + // ----------------------------------------------------------------------- + + @Test + fun `resolve - removes item and transitions to Ready when last item`() = + Given("a state with a single pending item") { + val state = pendingState(Pending.Seed) + + val result = When("that item is resolved") { + state.resolve(Pending.Seed) + } + + Then("state is Ready") { + assertTrue(result.isReady) + } + } + + @Test + fun `resolve - removes item but stays Pending when others remain`() = + Given("a state with multiple pending items") { + val state = pendingState(Pending.Seed, Pending.Assignments, Pending.Configuration) + + val result = When("one item is resolved") { + state.resolve(Pending.Seed) + } + + Then("remaining items are still pending") { + assertEquals( + Phase.Pending(setOf(Pending.Assignments, Pending.Configuration)), + result.phase, + ) + } + } + + @Test + fun `resolve - no-op when item not in pending set`() = + Given("a state without Assignments pending") { + val state = pendingState(Pending.Seed) + + val result = When("Assignments is resolved") { + state.resolve(Pending.Assignments) + } + + Then("state is unchanged (Seed still pending)") { + assertEquals(Phase.Pending(setOf(Pending.Seed)), result.phase) + } + } + + @Test + fun `resolve - no-op on Ready state`() = + Given("a Ready state") { + val state = readyState() + + val result = When("any item is resolved") { + state.resolve(Pending.Seed) + } + + Then("state is unchanged") { + assertEquals(state, result) + } + } + + @Test + fun `enrichedAttributes - always includes userId and aliasId`() = + Given("a state with minimal attributes") { + val state = readyState( + appUserId = "user-1", + aliasId = "alias-1", + userAttributes = mapOf("custom" to "value"), + ) + + val enriched = When("enrichedAttributes is read") { + state.enrichedAttributes + } + + Then("it contains custom attributes") { + assertEquals("value", enriched["custom"]) + } + And("it contains appUserId") { + assertEquals("user-1", enriched[Keys.APP_USER_ID]) + } + And("it contains aliasId") { + assertEquals("alias-1", enriched[Keys.ALIAS_ID]) + } + } + + @Test + fun `enrichedAttributes - uses aliasId as userId when no appUserId`() = + Given("an anonymous state") { + val state = readyState(appUserId = null, aliasId = "alias-1") + + val enriched = When("enrichedAttributes is read") { + state.enrichedAttributes + } + + Then("appUserId key contains aliasId") { + assertEquals("alias-1", enriched[Keys.APP_USER_ID]) + } + } + + // ----------------------------------------------------------------------- + // Composition: multi-step reducer sequences + // ----------------------------------------------------------------------- + + @Test + fun `full identify flow - Identify then SeedResolved reaches Ready`() = + Given("an anonymous ready state") { + val initial = readyState() + + val afterIdentify = When("Identify is applied") { + Updates.Identify("user-1", restoreAssignments = false).reduce(initial) + } + + Then("state is Pending Seed") { + assertEquals(Phase.Pending(setOf(Pending.Seed)), afterIdentify.phase) + } + + val afterSeed = When("SeedResolved is applied") { + Updates.SeedResolved(seed = 77).reduce(afterIdentify) + } + + Then("state is Ready") { + assertTrue(afterSeed.isReady) + } + And("seed is updated") { + assertEquals(77, afterSeed.seed) + } + } + + @Test + fun `full identify flow with restore - needs both Seed and Assignments`() = + Given("an anonymous ready state") { + val initial = readyState() + + val afterIdentify = When("Identify with restoreAssignments is applied") { + Updates.Identify("user-1", restoreAssignments = true).reduce(initial) + } + + Then("both Seed and Assignments are pending") { + assertEquals( + Phase.Pending(setOf(Pending.Seed, Pending.Assignments)), + afterIdentify.phase, + ) + } + + val afterSeed = When("SeedResolved is applied") { + Updates.SeedResolved(seed = 50).reduce(afterIdentify) + } + + Then("still not ready — Assignments pending") { + assertFalse(afterSeed.isReady) + assertEquals(Phase.Pending(setOf(Pending.Assignments)), afterSeed.phase) + } + + val afterAssignments = When("AssignmentsCompleted is applied") { + Updates.AssignmentsCompleted.reduce(afterSeed) + } + + Then("now Ready") { + assertTrue(afterAssignments.isReady) + } + } + + @Test + fun `configure then identify race - Configure preserves Seed from concurrent identify`() = + Given("initial Pending Configuration state") { + val initial = pendingState(Pending.Configuration) + + // Simulate: identify runs before configure + val afterIdentify = When("Identify runs (adding Seed) while Configuration still pending") { + // This simulates the state after identify runs but before configure + initial.copy( + phase = Phase.Pending(setOf(Pending.Configuration, Pending.Seed)), + appUserId = "user-1", + ) + } + + val afterConfigure = When("Configure runs") { + Updates.Configure(needsAssignments = false).reduce(afterIdentify) + } + + Then("Seed is preserved, Configuration is resolved") { + assertEquals(Phase.Pending(setOf(Pending.Seed)), afterConfigure.phase) + assertFalse(afterConfigure.isReady) + } + } + + @Test + fun `reset then complete flow`() = + Given("a logged-in ready state") { + val initial = readyState(appUserId = "user-1") + + val afterReset = When("Reset is applied") { + Updates.Reset.reduce(initial) + } + + Then("identity is not ready") { + assertFalse(afterReset.isReady) + } + And("user is cleared") { + assertNull(afterReset.appUserId) + } + + val afterComplete = When("ResetComplete is applied") { + Updates.ResetComplete.reduce(afterReset) + } + + Then("identity is ready again") { + assertTrue(afterComplete.isReady) + } + } +}