From 6e05278ee341b565b989bbeac746bfe5cf47e0dd Mon Sep 17 00:00:00 2001 From: Rodrigo Gomez Palacio Date: Tue, 24 Sep 2024 15:45:52 -0500 Subject: [PATCH 01/12] ConsistencyManager interface & implementation Manages read-your-write tokens. The manager works based on conditions & tokens. Tokens are stored in a nested map indexed by a unique id (e.g. `onesignalId`) and a token key (e.g. `USER`). This allows us to track tokens on a per-user basis (e.g. handle switching users). Conditions work by creating a blocking mechanism with customizable token retrieval until a pre-defined condition is met (e.g. at least two specific tokens are available). Also allows extensibility for future applications to control offset blocking mechanism in consistency use-cases. --- .../consistency/impl/ConsistencyManager.kt | 92 +++++++++++++++ .../common/consistency/models/ICondition.kt | 21 ++++ .../consistency/models/IConsistencyKeyEnum.kt | 3 + .../consistency/models/IConsistencyManager.kt | 31 +++++ .../consistency/ConsistencyManagerTests.kt | 111 ++++++++++++++++++ 5 files changed, 258 insertions(+) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/impl/ConsistencyManager.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/models/ICondition.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/models/IConsistencyKeyEnum.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/models/IConsistencyManager.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/consistency/ConsistencyManagerTests.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/impl/ConsistencyManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/impl/ConsistencyManager.kt new file mode 100644 index 000000000..139c06e3b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/impl/ConsistencyManager.kt @@ -0,0 +1,92 @@ +package com.onesignal.common.consistency.impl + +import com.onesignal.common.consistency.models.ICondition +import com.onesignal.common.consistency.models.IConsistencyKeyEnum +import com.onesignal.common.consistency.models.IConsistencyManager +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Manages read-your-write tokens for more accurate segment membership + * calculation. Uses customizable conditions that block retrieval of the newest token until met. + * + * Usage: + * val consistencyManager = ConsistencyManager() + * val updateConditionDeferred = consistencyManager.registerCondition(MyCustomCondition()) + * val rywToken = updateConditionDeferred.await() + */ +class ConsistencyManager : IConsistencyManager { + private val mutex = Mutex() + private val indexedTokens: MutableMap> = mutableMapOf() + private val conditions: MutableList>> = + mutableListOf() + + /** + * Set method to update the token based on the key. + * Params: + * id: String - the index of the token map (e.g. onesignalId) + * key: K - corresponds to the operation for which we have a read-your-write token + * value: String? - the token (read-your-write token) + */ + override suspend fun setRywToken( + id: String, + key: IConsistencyKeyEnum, + value: String, + ) { + mutex.withLock { + val rywTokens = indexedTokens.getOrPut(id) { mutableMapOf() } + rywTokens[key] = value + checkConditionsAndComplete() + } + } + + /** + * Register a condition with its corresponding deferred action. Returns a deferred condition. + */ + override suspend fun registerCondition(condition: ICondition): CompletableDeferred { + mutex.withLock { + val deferred = CompletableDeferred() + val pair = Pair(condition, deferred) + conditions.add(pair) + checkConditionsAndComplete() + return deferred + } + } + + override suspend fun resolveConditionsWithID(id: String) { + val completedConditions = mutableListOf>>() + + for ((condition, deferred) in conditions) { + if (condition.id == id) { + if (!deferred.isCompleted) { + deferred.complete(null) + } + } + completedConditions.add(Pair(condition, deferred)) + } + + // Remove completed conditions from the list + conditions.removeAll(completedConditions) + } + + /** + * IMPORTANT: calling code should be protected by mutex to avoid potential inconsistencies + */ + private fun checkConditionsAndComplete() { + val completedConditions = mutableListOf>>() + + for ((condition, deferred) in conditions) { + if (condition.isMet(indexedTokens)) { + val newestToken = condition.getNewestToken(indexedTokens) + if (!deferred.isCompleted) { + deferred.complete(newestToken) + } + completedConditions.add(Pair(condition, deferred)) + } + } + + // Remove completed conditions from the list + conditions.removeAll(completedConditions) + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/models/ICondition.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/models/ICondition.kt new file mode 100644 index 000000000..fbb97d23b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/models/ICondition.kt @@ -0,0 +1,21 @@ +package com.onesignal.common.consistency.models + +interface ICondition { + /** + * Every implementation should define a unique ID & make available via a companion object for + * ease of use + */ + val id: String + + /** + * Define a condition that "unblocks" execution + * e.g. we have token (A && B) || A + */ + fun isMet(indexedTokens: Map>): Boolean + + /** + * Used to process tokens according to their format & return the newest token. + * e.g. numeric strings would be compared differently from JWT tokens + */ + fun getNewestToken(indexedTokens: Map>): String? +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/models/IConsistencyKeyEnum.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/models/IConsistencyKeyEnum.kt new file mode 100644 index 000000000..058561232 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/models/IConsistencyKeyEnum.kt @@ -0,0 +1,3 @@ +package com.onesignal.common.consistency.models + +interface IConsistencyKeyEnum diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/models/IConsistencyManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/models/IConsistencyManager.kt new file mode 100644 index 000000000..d1bba5312 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/models/IConsistencyManager.kt @@ -0,0 +1,31 @@ +package com.onesignal.common.consistency.models + +import kotlinx.coroutines.CompletableDeferred + +interface IConsistencyManager { + /** + * Set method to update the RYW token based on the key. + * Params: + * id: String - the index of the RYW token map (e.g., onesignalId) + * key: IConsistencyKeyEnum - corresponds to the operation for which we have a read-your-write token + * value: String? - the read-your-write token + */ + suspend fun setRywToken( + id: String, + key: IConsistencyKeyEnum, + value: String, + ) + + /** + * Register a condition with its corresponding deferred action. Returns a deferred condition. + * Params: + * condition: ICondition - the condition to be registered + * Returns: CompletableDeferred - a deferred action that completes when the condition is met + */ + suspend fun registerCondition(condition: ICondition): CompletableDeferred + + /** + * Resolve all conditions with a specific ID + */ + suspend fun resolveConditionsWithID(id: String) +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/consistency/ConsistencyManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/consistency/ConsistencyManagerTests.kt new file mode 100644 index 000000000..f92174e0b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/consistency/ConsistencyManagerTests.kt @@ -0,0 +1,111 @@ +package com.onesignal.common.consistency.impl + +import com.onesignal.common.consistency.enums.IamFetchRywTokenKey +import com.onesignal.common.consistency.models.ICondition +import com.onesignal.common.consistency.models.IConsistencyKeyEnum +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.test.runTest + +class ConsistencyManagerTests : FunSpec({ + + lateinit var consistencyManager: ConsistencyManager + + beforeAny { + consistencyManager = ConsistencyManager() + } + + test("setRywToken updates the token correctly") { + runTest { + // Given + val id = "test_id" + val key = IamFetchRywTokenKey.USER + val value = "123" + + consistencyManager.setRywToken(id, key, value) + + val condition = TestMetCondition(mapOf(id to mapOf(key to value))) + val deferred = consistencyManager.registerCondition(condition) + val result = deferred.await() + + result shouldBe value + } + } + + test("registerCondition completes when condition is met") { + runTest { + // Given + val id = "test_id" + val key = IamFetchRywTokenKey.USER + val value = "123" + + // Set a token to meet the condition + consistencyManager.setRywToken(id, key, value) + + val condition = TestMetCondition(mapOf(id to mapOf(key to value))) + val deferred = consistencyManager.registerCondition(condition) + + deferred.await() + deferred.isCompleted shouldBe true + } + } + + test("registerCondition does not complete when condition is not met") { + runTest { + val condition = TestUnmetCondition() + val deferred = consistencyManager.registerCondition(condition) + + consistencyManager.setRywToken("id", IamFetchRywTokenKey.USER, "123") + deferred.isCompleted shouldBe false + } + } + + test("resolveConditionsWithID resolves conditions based on ID") { + runTest { + val condition = TestUnmetCondition() + val deferred = consistencyManager.registerCondition(condition) + consistencyManager.resolveConditionsWithID(TestUnmetCondition.ID) + deferred.await() + + deferred.isCompleted shouldBe true + } + } +}) { + // Mock implementation of ICondition that simulates a condition that isn't met + private class TestUnmetCondition : ICondition { + companion object { + const val ID = "TestUnmetCondition" + } + + override val id: String + get() = ID + + override fun isMet(indexedTokens: Map>): Boolean { + return false // Always returns false to simulate an unmet condition + } + + override fun getNewestToken(indexedTokens: Map>): String? { + return null + } + } + + // Mock implementation of ICondition for cases where the condition is met + private class TestMetCondition( + private val expectedRywTokens: Map>, + ) : ICondition { + companion object { + const val ID = "TestMetCondition" + } + + override val id: String + get() = ID + + override fun isMet(indexedTokens: Map>): Boolean { + return indexedTokens == expectedRywTokens + } + + override fun getNewestToken(indexedTokens: Map>): String? { + return expectedRywTokens.values.firstOrNull()?.values?.firstOrNull() + } + } +} From ca6fe5a61408c10d1c32d4db841a4253782f4245 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomez Palacio Date: Tue, 24 Sep 2024 15:46:14 -0500 Subject: [PATCH 02/12] DI: register ConsistencyManager in UserModule Inject ConsistencyManager where it will be used. --- .../core/src/main/java/com/onesignal/user/UserModule.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt index 7e2c5daac..b2df99ee4 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt @@ -1,5 +1,7 @@ package com.onesignal.user +import com.onesignal.common.consistency.impl.ConsistencyManager +import com.onesignal.common.consistency.models.IConsistencyManager import com.onesignal.common.modules.IModule import com.onesignal.common.services.ServiceBuilder import com.onesignal.core.internal.operations.IOperationExecutor @@ -34,6 +36,9 @@ import com.onesignal.user.internal.subscriptions.impl.SubscriptionManager internal class UserModule : IModule { override fun register(builder: ServiceBuilder) { + // Consistency + builder.register().provides() + // Properties builder.register().provides() builder.register().provides() From 32775a055fc63d0ab12832973477c15c3f2d0578 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomez Palacio Date: Tue, 24 Sep 2024 15:47:40 -0500 Subject: [PATCH 03/12] `IamFetchReadyCondition` Implementation Motivation: custom condition to block token retrieval until condition is met. We then return the newest token --- .../consistency/IamFetchReadyCondition.kt | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/IamFetchReadyCondition.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/IamFetchReadyCondition.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/IamFetchReadyCondition.kt new file mode 100644 index 000000000..329832aa5 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/IamFetchReadyCondition.kt @@ -0,0 +1,44 @@ +package com.onesignal.common.consistency + +import com.onesignal.common.consistency.enums.IamFetchRywTokenKey +import com.onesignal.common.consistency.models.ICondition +import com.onesignal.common.consistency.models.IConsistencyKeyEnum + +/** + * Used for read your write consistency when fetching In-App Messages. + * + * Params: + * key : String - the index of the RYW token map + */ +class IamFetchReadyCondition( + private val key: String, +) : ICondition { + companion object { + const val ID = "IamFetchReadyCondition" + } + + override val id: String + get() = ID + + override fun isMet(indexedTokens: Map>): Boolean { + val tokenMap = indexedTokens[key] ?: return false + val userUpdateTokenSet = tokenMap[IamFetchRywTokenKey.USER] != null + val subscriptionUpdateTokenSet = tokenMap[IamFetchRywTokenKey.SUBSCRIPTION] != null + + /** + * We always update the session count so we know we will have a userUpdateToken. We don't + * necessarily make a subscriptionUpdate call on every session. The following logic + * is written in a way so that if somehow the subscriptionUpdateToken is set *before* the + * userUpdateToken, we will wait for the userUpdateToken to also be set. This is because + * we know that a userUpdate call was made and both user & subscription properties are + * considered during segment calculations. + */ + return (userUpdateTokenSet && subscriptionUpdateTokenSet) || userUpdateTokenSet + } + + override fun getNewestToken(indexedTokens: Map>): String? { + val tokenMap = indexedTokens[key] ?: return null + // maxOrNull compares lexicographically + return listOfNotNull(tokenMap[IamFetchRywTokenKey.USER], tokenMap[IamFetchRywTokenKey.SUBSCRIPTION]).maxOrNull() + } +} From 1505d07f4fbe37e930266b67814b7bd8114b83e3 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomez Palacio Date: Tue, 24 Sep 2024 15:48:27 -0500 Subject: [PATCH 04/12] Add offset, retryCount, & sessionDuration to OptionalHeaders Motivation: support passing values as headers, add comments --- .../core/internal/http/impl/OptionalHeaders.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/OptionalHeaders.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/OptionalHeaders.kt index 78d870dff..f566fd04f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/OptionalHeaders.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/OptionalHeaders.kt @@ -1,5 +1,20 @@ package com.onesignal.core.internal.http.impl data class OptionalHeaders( + /** + * Used as an E-Tag + */ val cacheKey: String? = null, + /** + * Used for read your write consistency + */ + val rywToken: String? = null, + /** + * Current retry count + */ + val retryCount: Int? = null, + /** + * Used to track delay between session start and request + */ + val sessionDuration: Long? = null, ) From be10255aa89691738d386a07129c140d55a77338 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomez Palacio Date: Tue, 24 Sep 2024 15:49:16 -0500 Subject: [PATCH 05/12] Update Subscription & User backend services to return offsets Motivation: need the offsets to be available to set in the ConsistencyManager --- .../backend/ISubscriptionBackendService.kt | 4 ++-- .../internal/backend/IUserBackendService.kt | 2 +- .../backend/impl/SubscriptionBackendService.kt | 18 +++++++++++++++--- .../backend/impl/UserBackendService.kt | 9 ++++++++- .../backend/SubscriptionBackendServiceTests.kt | 2 +- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/ISubscriptionBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/ISubscriptionBackendService.kt index 8741b2df6..133f298e7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/ISubscriptionBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/ISubscriptionBackendService.kt @@ -21,7 +21,7 @@ interface ISubscriptionBackendService { aliasLabel: String, aliasValue: String, subscription: SubscriptionObject, - ): String? + ): Pair? /** * Update an existing subscription with the properties provided. @@ -34,7 +34,7 @@ interface ISubscriptionBackendService { appId: String, subscriptionId: String, subscription: SubscriptionObject, - ) + ): String? /** * Delete an existing subscription. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt index a47018641..62837fb26 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/IUserBackendService.kt @@ -47,7 +47,7 @@ interface IUserBackendService { properties: PropertiesObject, refreshDeviceMetadata: Boolean, propertyiesDelta: PropertiesDeltasObject, - ) + ): String? /** * Retrieve a user from the backend. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/SubscriptionBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/SubscriptionBackendService.kt index bcb01db44..25d0e0a3e 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/SubscriptionBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/SubscriptionBackendService.kt @@ -16,7 +16,7 @@ internal class SubscriptionBackendService( aliasLabel: String, aliasValue: String, subscription: SubscriptionObject, - ): String? { + ): Pair? { val jsonSubscription = JSONConverter.convertToJSON(subscription) jsonSubscription.remove("id") val requestJSON = JSONObject().put("subscription", jsonSubscription) @@ -33,14 +33,19 @@ internal class SubscriptionBackendService( return null } - return subscriptionJSON.getString("id") + var rywToken: String? = null + if (responseJSON.has("ryw_token")) { + rywToken = responseJSON.getString("ryw_token") + } + + return Pair(subscriptionJSON.getString("id"), rywToken) } override suspend fun updateSubscription( appId: String, subscriptionId: String, subscription: SubscriptionObject, - ) { + ): String? { val requestJSON = JSONObject() .put("subscription", JSONConverter.convertToJSON(subscription)) @@ -50,6 +55,13 @@ internal class SubscriptionBackendService( if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) } + + val responseBody = JSONObject(response.payload) + return if (responseBody.has("ryw_token")) { + responseBody.getString("ryw_token") + } else { + null + } } override suspend fun deleteSubscription( diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt index 45492aa50..996f637e7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/backend/impl/UserBackendService.kt @@ -52,7 +52,7 @@ internal class UserBackendService( properties: PropertiesObject, refreshDeviceMetadata: Boolean, propertyiesDelta: PropertiesDeltasObject, - ) { + ): String? { val jsonObject = JSONObject() .put("refresh_device_metadata", refreshDeviceMetadata) @@ -70,6 +70,13 @@ internal class UserBackendService( if (!response.isSuccess) { throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) } + + val responseBody = JSONObject(response.payload) + return if (responseBody.has("ryw_token")) { + responseBody.getString("ryw_token") + } else { + null + } } override suspend fun getUser( diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/SubscriptionBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/SubscriptionBackendServiceTests.kt index 92cf55947..c228a5c5c 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/SubscriptionBackendServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/SubscriptionBackendServiceTests.kt @@ -42,7 +42,7 @@ class SubscriptionBackendServiceTests : FunSpec({ val response = subscriptionBackendService.createSubscription("appId", aliasLabel, aliasValue, subscription) // Then - response shouldBe "subscriptionId" + response shouldBe Pair("subscriptionId", null) coVerify { spyHttpClient.post( "apps/appId/users/by/$aliasLabel/$aliasValue/subscriptions", From 1a4356782410aa37acace7383ee490e0b69879c6 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomez Palacio Date: Tue, 24 Sep 2024 15:50:13 -0500 Subject: [PATCH 06/12] Update IAM manager & backend service with retry logic, optional headers Motivation: the IAM fetch call (`listInAppMessages`) will include the rywToken, retryCount, & secondsSinceAppOpen (tracked on backend) We update the request & related code here. Handle retry logic --- .../internal/InAppMessagesManager.kt | 59 +- .../internal/backend/IInAppBackendService.kt | 4 + .../backend/impl/InAppBackendService.kt | 98 +- .../internal/InAppMessagesManagerTests.kt | 2 + .../backend/InAppBackendServiceTests.kt | 901 ++++++++++-------- 5 files changed, 612 insertions(+), 452 deletions(-) diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt index 4c3f1e5d8..80b4071da 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/InAppMessagesManager.kt @@ -4,6 +4,8 @@ import android.app.AlertDialog import com.onesignal.common.AndroidUtils import com.onesignal.common.IDManager import com.onesignal.common.JSONUtils +import com.onesignal.common.consistency.IamFetchReadyCondition +import com.onesignal.common.consistency.models.IConsistencyManager import com.onesignal.common.events.EventProducer import com.onesignal.common.exceptions.BackendException import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler @@ -66,6 +68,7 @@ internal class InAppMessagesManager( private val _lifecycle: IInAppLifecycleService, private val _languageContext: ILanguageContext, private val _time: ITime, + private val _consistencyManager: IConsistencyManager, ) : IInAppMessagesManager, IStartableService, ISubscriptionChangedHandler, @@ -149,7 +152,13 @@ internal class InAppMessagesManager( } // attempt to fetch messages from the backend (if we have the pre-requisite data already) - fetchMessages() + val onesignalId = _userManager.onesignalId + val updateConditionDeferred = + _consistencyManager.registerCondition(IamFetchReadyCondition(onesignalId)) + val rywToken = updateConditionDeferred.await() + if (rywToken != null) { + fetchMessages(rywToken) + } } } @@ -181,18 +190,14 @@ internal class InAppMessagesManager( return } - suspendifyOnThread { - fetchMessages() - } + fetchMessagesWhenConditionIsMet() } override fun onModelReplaced( model: ConfigModel, tag: String, ) { - suspendifyOnThread { - fetchMessages() - } + fetchMessagesWhenConditionIsMet() } override fun onSubscriptionAdded(subscription: ISubscription) { } @@ -207,9 +212,7 @@ internal class InAppMessagesManager( return } - suspendifyOnThread { - fetchMessages() - } + fetchMessagesWhenConditionIsMet() } override fun onSessionStarted() { @@ -217,17 +220,28 @@ internal class InAppMessagesManager( redisplayInAppMessage.isDisplayedInSession = false } - suspendifyOnThread { - fetchMessages() - } + fetchMessagesWhenConditionIsMet() } override fun onSessionActive() { } override fun onSessionEnded(duration: Long) { } + private fun fetchMessagesWhenConditionIsMet() { + suspendifyOnThread { + val onesignalId = _userManager.onesignalId + val iamFetchCondition = + _consistencyManager.registerCondition(IamFetchReadyCondition(onesignalId)) + val rywToken = iamFetchCondition.await() + + if (rywToken != null) { + fetchMessages(rywToken) + } + } + } + // called when a new push subscription is added, or the app id is updated, or a new session starts - private suspend fun fetchMessages() { + private suspend fun fetchMessages(rywToken: String?) { // We only want to fetch IAMs if we know the app is in the // foreground, as we don't want to do this for background // events (such as push received), wasting resources for @@ -252,7 +266,9 @@ internal class InAppMessagesManager( lastTimeFetchedIAMs = now } - val newMessages = _backend.listInAppMessages(appId, subscriptionId) + // lambda so that it is updated on each potential retry + val sessionDurationProvider = { _time.currentTimeMillis - _sessionService.startTime } + val newMessages = _backend.listInAppMessages(appId, subscriptionId, rywToken, sessionDurationProvider) if (newMessages != null) { this.messages = newMessages as MutableList @@ -517,7 +533,9 @@ internal class InAppMessagesManager( if (triggerModel != null) { triggerModel.value = value } else { - triggerModel = com.onesignal.inAppMessages.internal.triggers.TriggerModel() + triggerModel = + com.onesignal.inAppMessages.internal.triggers + .TriggerModel() triggerModel.id = key triggerModel.key = key triggerModel.value = value @@ -782,13 +800,15 @@ internal class InAppMessagesManager( private fun logInAppMessagePreviewActions(action: InAppMessageClickResult) { if (action.tags != null) { Logging.debug( - "InAppMessagesManager.logInAppMessagePreviewActions: Tags detected inside of the action click payload, ignoring because action came from IAM preview:: " + action.tags.toString(), + "InAppMessagesManager.logInAppMessagePreviewActions: Tags detected inside of the action click payload, ignoring because action came from IAM preview:: " + + action.tags.toString(), ) } if (action.outcomes.size > 0) { Logging.debug( - "InAppMessagesManager.logInAppMessagePreviewActions: Outcomes detected inside of the action click payload, ignoring because action came from IAM preview: " + action.outcomes.toString(), + "InAppMessagesManager.logInAppMessagePreviewActions: Outcomes detected inside of the action click payload, ignoring because action came from IAM preview: " + + action.outcomes.toString(), ) } @@ -890,7 +910,8 @@ internal class InAppMessagesManager( ) { val messageTitle = _applicationService.appContext.getString(R.string.location_permission_missing_title) val message = _applicationService.appContext.getString(R.string.location_permission_missing_message) - AlertDialog.Builder(_applicationService.current) + AlertDialog + .Builder(_applicationService.current) .setTitle(messageTitle) .setMessage(message) .setPositiveButton(android.R.string.ok) { _, _ -> suspendifyOnThread { showMultiplePrompts(inAppMessage, prompts) } } diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt index adafdce86..85db9f689 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/IInAppBackendService.kt @@ -13,12 +13,16 @@ internal interface IInAppBackendService { * * @param appId The ID of the application that the IAM will be retrieved from. * @param subscriptionId The specific subscription within the [appId] the IAM will be delivered to. + * @param rywToken Used for read your write consistency + * @param sessionDurationProvider Lambda to calculate the session duration at the time of the request * * @return The list of IAMs associated to the subscription, or null if the IAMs could not be retrieved. */ suspend fun listInAppMessages( appId: String, subscriptionId: String, + rywToken: String?, + sessionDurationProvider: () -> Long, ): List? /** diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt index c318f97f9..414dd8489 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt @@ -4,12 +4,14 @@ import com.onesignal.common.NetworkUtils import com.onesignal.common.exceptions.BackendException import com.onesignal.core.internal.device.IDeviceService import com.onesignal.core.internal.http.IHttpClient +import com.onesignal.core.internal.http.impl.OptionalHeaders import com.onesignal.debug.internal.logging.Logging import com.onesignal.inAppMessages.internal.InAppMessage import com.onesignal.inAppMessages.internal.InAppMessageContent import com.onesignal.inAppMessages.internal.backend.GetIAMDataResponse import com.onesignal.inAppMessages.internal.backend.IInAppBackendService import com.onesignal.inAppMessages.internal.hydrators.InAppHydrator +import kotlinx.coroutines.delay import org.json.JSONObject internal class InAppBackendService( @@ -22,24 +24,11 @@ internal class InAppBackendService( override suspend fun listInAppMessages( appId: String, subscriptionId: String, + rywToken: String?, + sessionDurationProvider: () -> Long, ): List? { - // Retrieve any in app messages that might exist - val response = _httpClient.get("apps/$appId/subscriptions/$subscriptionId/iams") - - if (response.isSuccess) { - val jsonResponse = JSONObject(response.payload) - - if (jsonResponse.has("in_app_messages")) { - val iamMessagesAsJSON = jsonResponse.getJSONArray("in_app_messages") - // TODO: Outstanding question on whether we still want to cache this. Only used when - // hard start of the app, but within 30 seconds of it being killed (i.e. same session startup). - // Cache copy for quick cold starts -// _prefs.savedIAMs = iamMessagesAsJSON.toString() - return _hydrator.hydrateIAMMessages(iamMessagesAsJSON) - } - } - - return null + val baseUrl = "apps/$appId/subscriptions/$subscriptionId/iams" + return attemptFetchWithRetries(baseUrl, rywToken, sessionDurationProvider) } override suspend fun getIAMData( @@ -51,7 +40,7 @@ internal class InAppBackendService( htmlPathForMessage(messageId, variantId, appId) ?: return GetIAMDataResponse(null, false) - val response = _httpClient.get(htmlPath, null) + val response = _httpClient.get(htmlPath) if (response.isSuccess) { // Successful request, reset count @@ -81,7 +70,7 @@ internal class InAppBackendService( ): InAppMessageContent? { val htmlPath = "in_app_messages/device_preview?preview_id=$previewUUID&app_id=$appId" - val response = _httpClient.get(htmlPath, null) + val response = _httpClient.get(htmlPath) return if (response.isSuccess) { val jsonResponse = JSONObject(response.payload!!) @@ -209,4 +198,75 @@ internal class InAppBackendService( ) { Logging.error("Encountered a $statusCode error while attempting in-app message $requestType request: $response") } + + private suspend fun attemptFetchWithRetries( + baseUrl: String, + rywToken: String?, + sessionDurationProvider: () -> Long, + ): List? { + var attempts = 1 + var retryLimit: Int? = null // Retry limit will be determined dynamically + + while (retryLimit == null || attempts <= retryLimit + 1) { + val retryCount = if (attempts > 1) attempts - 1 else null + val values = + OptionalHeaders( + rywToken = rywToken, + sessionDuration = sessionDurationProvider(), + retryCount = retryCount, + ) + val response = _httpClient.get(baseUrl, values) + + if (response.isSuccess) { + val jsonResponse = response.payload?.let { JSONObject(it) } + return jsonResponse?.let { hydrateInAppMessages(it) } + } else if (response.statusCode == 425 || response.statusCode == 429) { + // Dynamically update the retry limit from response + retryLimit = response.retryLimit ?: retryLimit + + // Apply the Retry-After delay if present, otherwise proceed without delay + val retryAfter = response.retryAfterSeconds + if (retryAfter != null) { + delay(retryAfter * 1_000L) + } + } else if (response.statusCode in 500..599) { + return null + } else { + return null + } + + attempts++ + } + + // Final attempt without the RYW token if retries fail + return fetchInAppMessagesWithoutRywToken(baseUrl, sessionDurationProvider) + } + + private suspend fun fetchInAppMessagesWithoutRywToken( + url: String, + sessionDurationProvider: () -> Long, + ): List? { + val response = + _httpClient.get( + url, + OptionalHeaders( + sessionDuration = sessionDurationProvider(), + ), + ) + + if (response.isSuccess) { + val jsonResponse = response.payload?.let { JSONObject(it) } + return jsonResponse?.let { hydrateInAppMessages(it) } + } else { + return null + } + } + + private fun hydrateInAppMessages(jsonResponse: JSONObject): List? = + if (jsonResponse.has("in_app_messages")) { + val iamMessagesAsJSON = jsonResponse.getJSONArray("in_app_messages") + _hydrator.hydrateIAMMessages(iamMessagesAsJSON) + } else { + null + } } diff --git a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt index fa5d4c16e..f2753bcff 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt @@ -1,5 +1,6 @@ package com.onesignal.inAppMessages.internal +import com.onesignal.common.consistency.models.IConsistencyManager import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.inAppMessages.internal.backend.IInAppBackendService import com.onesignal.inAppMessages.internal.display.IInAppDisplayer @@ -52,6 +53,7 @@ class InAppMessagesManagerTests : FunSpec({ mockk(), MockHelper.languageContext(), MockHelper.time(1000), + mockk(), ) // When diff --git a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/backend/InAppBackendServiceTests.kt b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/backend/InAppBackendServiceTests.kt index 8fa82a50c..379013d7c 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/backend/InAppBackendServiceTests.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/backend/InAppBackendServiceTests.kt @@ -22,435 +22,508 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk -class InAppBackendServiceTests : FunSpec({ - beforeAny { - Logging.logLevel = LogLevel.NONE - } - - test("listInAppMessages with no messages returns zero-lengthed array") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(200, "{ in_app_messages: [] }") - - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) - - // When - val response = inAppBackendService.listInAppMessages("appId", "subscriptionId") - - // Then - response shouldNotBe null - response!!.count() shouldBe 0 - coVerify(exactly = 1) { mockHttpClient.get("apps/appId/subscriptions/subscriptionId/iams", any()) } - } - - test("listInAppMessages with 1 message returns one-lengthed array") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { - mockHttpClient.get(any(), any()) - } returns HttpResponse(200, "{ in_app_messages: [{id: \"messageId1\", variants:{all: {en: \"content1\"}}, triggers:[[{id: \"triggerId1\", kind: \"custom\", property: \"property1\", operator: \"equal\", value: \"value1\"}]], end_time: \"2008-09-03T20:56:35.450686Z\", redisplay: { limit: 11111, delay: 22222}}] }") - - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) - - // When - val response = inAppBackendService.listInAppMessages("appId", "subscriptionId") - - // Then - response shouldNotBe null - response!!.count() shouldBe 1 - response[0].messageId shouldBe "messageId1" - response[0].variants.keys.count() shouldBe 1 - response[0].variants["all"] shouldNotBe null - response[0].variants["all"]!!.keys.count() shouldBe 1 - response[0].variants["all"]!!["en"] shouldBe "content1" - response[0].triggers.count() shouldBe 1 - response[0].triggers[0].count() shouldBe 1 - response[0].triggers[0][0].triggerId shouldBe "triggerId1" - response[0].triggers[0][0].kind shouldBe Trigger.OSTriggerKind.CUSTOM - response[0].triggers[0][0].property shouldBe "property1" - response[0].triggers[0][0].operatorType shouldBe Trigger.OSTriggerOperator.EQUAL_TO - response[0].triggers[0][0].value shouldBe "value1" - response[0].isFinished shouldBe true - response[0].redisplayStats.displayLimit shouldBe 11111 - response[0].redisplayStats.displayDelay shouldBe 22222 - - coVerify(exactly = 1) { mockHttpClient.get("apps/appId/subscriptions/subscriptionId/iams", any()) } - } - - test("listInAppMessages returns null when non-success response") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(404, null) - - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) - - // When - val response = inAppBackendService.listInAppMessages("appId", "subscriptionId") - - // Then - response shouldBe null - coVerify(exactly = 1) { mockHttpClient.get("apps/appId/subscriptions/subscriptionId/iams", any()) } - } - - test("getIAMData successfully hydrates successful response") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { - mockHttpClient.get(any(), any()) - } returns HttpResponse(200, "{html: \"html1\", display_duration: 123, styles: {remove_height_margin: true, remove_width_margin: true}}") - - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) - - // When - val response = inAppBackendService.getIAMData("appId", "messageId", "variantId") - - // Then - response shouldNotBe null - response.shouldRetry shouldBe false - response.content shouldNotBe null - response.content!!.contentHtml shouldStartWith "html1" - response.content!!.displayDuration shouldBe 123 - response.content!!.useHeightMargin shouldBe false - response.content!!.useWidthMargin shouldBe false - response.content!!.isFullBleed shouldBe true - - coVerify(exactly = 1) { mockHttpClient.get("in_app_messages/messageId/variants/variantId/html?app_id=appId", any()) } - } - - test("getIAMData successfully hydrates successful response with no content") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(200, "{}") - - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) - - // When - val response = inAppBackendService.getIAMData("appId", "messageId", "variantId") - - // Then - response shouldNotBe null - response.shouldRetry shouldBe false - response.content shouldBe null - - coVerify(exactly = 1) { mockHttpClient.get("in_app_messages/messageId/variants/variantId/html?app_id=appId", any()) } - } - - test("getIAMData successfully hydrates successful response with no style") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(200, "{html: \"html1\", display_duration: 123 }") - - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) - - // When - val response = inAppBackendService.getIAMData("appId", "messageId", "variantId") - - // Then - response shouldNotBe null - response.shouldRetry shouldBe false - response.content shouldNotBe null - response.content!!.contentHtml shouldStartWith "html1" - response.content!!.displayDuration shouldBe 123 - response.content!!.useHeightMargin shouldBe true - response.content!!.useWidthMargin shouldBe true - response.content!!.isFullBleed shouldBe false - - coVerify(exactly = 1) { mockHttpClient.get("in_app_messages/messageId/variants/variantId/html?app_id=appId", any()) } - } - - test("getIAMData indicates retry when retryable response provided") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(500, null) - - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) - - // When - val response = inAppBackendService.getIAMData("appId", "messageId", "variantId") - - // Then - response shouldNotBe null - response.shouldRetry shouldBe true - response.content shouldBe null - - coVerify(exactly = 1) { mockHttpClient.get("in_app_messages/messageId/variants/variantId/html?app_id=appId", any()) } - } - - test("getIAMData indicates no retry when non-retryable response provided") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(404, null) - - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) - - // When - val response = inAppBackendService.getIAMData("appId", "messageId", "variantId") - - // Then - response shouldNotBe null - response.shouldRetry shouldBe false - response.content shouldBe null - - coVerify(exactly = 1) { mockHttpClient.get("in_app_messages/messageId/variants/variantId/html?app_id=appId", any()) } - } - - test("getIAMData indicates no retry when retryable response provided more than 3 times") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(500, null) - - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) - - // When - val response1 = inAppBackendService.getIAMData("appId", "messageId", "variantId") - val response2 = inAppBackendService.getIAMData("appId", "messageId", "variantId") - val response3 = inAppBackendService.getIAMData("appId", "messageId", "variantId") - val response4 = inAppBackendService.getIAMData("appId", "messageId", "variantId") - - // Then - response1 shouldNotBe null - response1.shouldRetry shouldBe true - response1.content shouldBe null - response2 shouldNotBe null - response2.shouldRetry shouldBe true - response2.content shouldBe null - response3 shouldNotBe null - response3.shouldRetry shouldBe true - response3.content shouldBe null - response4 shouldNotBe null - response4.shouldRetry shouldBe false - response4.content shouldBe null - - coVerify(exactly = 4) { mockHttpClient.get("in_app_messages/messageId/variants/variantId/html?app_id=appId", any()) } - } - - test("getIAMPreviewData successfully hydrates successful response") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { - mockHttpClient.get(any(), any()) - } returns HttpResponse(200, "{html: \"html1\", display_duration: 123, styles: {remove_height_margin: true, remove_width_margin: true}}") - - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) - - // When - val response = inAppBackendService.getIAMPreviewData("appId", "previewUUID") - - // Then - response shouldNotBe null - response!!.contentHtml shouldStartWith "html1" - response!!.displayDuration shouldBe 123 - response!!.useHeightMargin shouldBe false - response!!.useWidthMargin shouldBe false - response!!.isFullBleed shouldBe true - - coVerify(exactly = 1) { mockHttpClient.get("in_app_messages/device_preview?preview_id=previewUUID&app_id=appId", any()) } - } - - test("getIAMPreviewData returns no data when response is unsuccessful") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(404, null) - - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) - - // When - val response = inAppBackendService.getIAMPreviewData("appId", "previewUUID") - - // Then - response shouldBe null - - coVerify(exactly = 1) { mockHttpClient.get("in_app_messages/device_preview?preview_id=previewUUID&app_id=appId", any()) } - } - - test("sendIAMClick is successful when there is a successful response") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { mockHttpClient.post(any(), any()) } returns HttpResponse(200, "{}") - - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) - - // When - inAppBackendService.sendIAMClick("appId", "subscriptionId", "variantId", "messageId", "clickId", isFirstClick = true) - - // Then - coVerify(exactly = 1) { - mockHttpClient.post( - "in_app_messages/messageId/click", - withArg { - it.safeString("app_id") shouldBe "appId" - it.safeInt("device_type") shouldBe IDeviceService.DeviceType.Android.value - it.safeString("player_id") shouldBe "subscriptionId" - it.safeString("click_id") shouldBe "clickId" - it.safeString("variant_id") shouldBe "variantId" - it.safeBool("first_click") shouldBe true - }, - ) +class InAppBackendServiceTests : + FunSpec({ + val mockSessionDurationProvider: () -> Long = { 456L } // Use any fixed value here + + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + test("listInAppMessages with no messages returns zero-lengthed array") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(200, "{ in_app_messages: [] }") + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + val response = inAppBackendService.listInAppMessages("appId", "subscriptionId", "123", mockSessionDurationProvider) + + // Then + response shouldNotBe null + response!!.count() shouldBe 0 + coVerify(exactly = 1) { mockHttpClient.get("apps/appId/subscriptions/subscriptionId/iams", any()) } } - } - - test("sendIAMClick throws exception when there is an unsuccessful response") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { mockHttpClient.post(any(), any()) } returns HttpResponse(409, "{}") - - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) - - // When - val exception = - shouldThrowUnit { - inAppBackendService.sendIAMClick( - "appId", - "subscriptionId", - "variantId", - "messageId", - "clickId", - isFirstClick = true, + + test("listInAppMessages with 1 message returns one-lengthed array") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { + mockHttpClient.get(any(), any()) + } returns + HttpResponse( + 200, + "{ in_app_messages: [{id: \"messageId1\", variants:{all: {en: \"content1\"}}, triggers:[[{id: \"triggerId1\", kind: \"custom\", property: \"property1\", operator: \"equal\", value: \"value1\"}]], end_time: \"2008-09-03T20:56:35.450686Z\", redisplay: { limit: 11111, delay: 22222}}] }", ) - } - // Then - exception.statusCode shouldBe 409 - exception.response shouldBe "{}" - coVerify(exactly = 1) { - mockHttpClient.post( - "in_app_messages/messageId/click", - withArg { - it.safeString("app_id") shouldBe "appId" - it.safeInt("device_type") shouldBe IDeviceService.DeviceType.Android.value - it.safeString("player_id") shouldBe "subscriptionId" - it.safeString("click_id") shouldBe "clickId" - it.safeString("variant_id") shouldBe "variantId" - it.safeBool("first_click") shouldBe true - }, - ) + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + val response = inAppBackendService.listInAppMessages("appId", "subscriptionId", "123", mockSessionDurationProvider) + + // Then + response shouldNotBe null + response!!.count() shouldBe 1 + response[0].messageId shouldBe "messageId1" + response[0].variants.keys.count() shouldBe 1 + response[0].variants["all"] shouldNotBe null + response[0].variants["all"]!!.keys.count() shouldBe 1 + response[0].variants["all"]!!["en"] shouldBe "content1" + response[0].triggers.count() shouldBe 1 + response[0].triggers[0].count() shouldBe 1 + response[0].triggers[0][0].triggerId shouldBe "triggerId1" + response[0].triggers[0][0].kind shouldBe Trigger.OSTriggerKind.CUSTOM + response[0].triggers[0][0].property shouldBe "property1" + response[0].triggers[0][0].operatorType shouldBe Trigger.OSTriggerOperator.EQUAL_TO + response[0].triggers[0][0].value shouldBe "value1" + response[0].isFinished shouldBe true + response[0].redisplayStats.displayLimit shouldBe 11111 + response[0].redisplayStats.displayDelay shouldBe 22222 + + coVerify(exactly = 1) { mockHttpClient.get("apps/appId/subscriptions/subscriptionId/iams", any()) } } - } - - test("sendIAMImpression is successful when there is a successful response") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { mockHttpClient.post(any(), any()) } returns HttpResponse(200, "{}") - - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) - - // When - inAppBackendService.sendIAMImpression("appId", "subscriptionId", "variantId", "messageId") - - // Then - coVerify(exactly = 1) { - mockHttpClient.post( - "in_app_messages/messageId/impression", - withArg { - it.safeString("app_id") shouldBe "appId" - it.safeInt("device_type") shouldBe IDeviceService.DeviceType.Android.value - it.safeString("player_id") shouldBe "subscriptionId" - it.safeString("variant_id") shouldBe "variantId" - it.safeBool("first_impression") shouldBe true - }, - ) + + test("listInAppMessages returns null when non-success response") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(404, null) + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + val response = inAppBackendService.listInAppMessages("appId", "subscriptionId", "123", mockSessionDurationProvider) + + // Then + response shouldBe null + coVerify(exactly = 1) { mockHttpClient.get("apps/appId/subscriptions/subscriptionId/iams", any()) } } - } - test("sendIAMImpression throws exception when there is an unsuccessful response") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { mockHttpClient.post(any(), any()) } returns HttpResponse(409, "{}") + test( + "retries according to retry limit and retryAfterSeconds and makes final request without RYW token after retries exhausted", + ) { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + + // Mock the first three attempts to return a 429 Too Many Requests response with retry limits + coEvery { + mockHttpClient.get(any(), any()) + } returnsMany + listOf( + HttpResponse(425, null, retryAfterSeconds = 1, retryLimit = 3), + HttpResponse(425, null, retryAfterSeconds = 1, retryLimit = 3), + HttpResponse(425, null, retryAfterSeconds = 1, retryLimit = 3), + HttpResponse(425, null, retryAfterSeconds = 1, retryLimit = 3), + HttpResponse(200, "{ in_app_messages: [] }"), + ) + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + val response = inAppBackendService.listInAppMessages("appId", "subscriptionId", "1234", mockSessionDurationProvider) + + // Then + response shouldNotBe null + response!!.count() shouldBe 0 - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + coVerify(exactly = 1) { + mockHttpClient.get( + "apps/appId/subscriptions/subscriptionId/iams", + match { + it.rywToken == "1234" && it.retryCount == null && it.sessionDuration == mockSessionDurationProvider() + }, + ) + } + + // Verify that the get method retried twice with the RYW token + coVerify(exactly = 3) { + mockHttpClient.get( + "apps/appId/subscriptions/subscriptionId/iams", + match { + it.rywToken == "1234" && it.sessionDuration == mockSessionDurationProvider() && it.retryCount != null + }, + ) + } - // When - val exception = - shouldThrowUnit { - inAppBackendService.sendIAMImpression("appId", "subscriptionId", "variantId", "messageId") + // Verify that the get method was retried the final time without the RYW token + coVerify(exactly = 1) { + mockHttpClient.get( + "apps/appId/subscriptions/subscriptionId/iams", + match { + it.rywToken == null && it.sessionDuration == mockSessionDurationProvider() && it.retryCount == null + }, + ) } + } + + test("getIAMData successfully hydrates successful response") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { + mockHttpClient.get(any(), any()) + } returns + HttpResponse( + 200, + "{html: \"html1\", display_duration: 123, styles: {remove_height_margin: true, remove_width_margin: true}}", + ) + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + val response = inAppBackendService.getIAMData("appId", "messageId", "variantId") + + // Then + response shouldNotBe null + response.shouldRetry shouldBe false + response.content shouldNotBe null + response.content!!.contentHtml shouldStartWith "html1" + response.content!!.displayDuration shouldBe 123 + response.content!!.useHeightMargin shouldBe false + response.content!!.useWidthMargin shouldBe false + response.content!!.isFullBleed shouldBe true + + coVerify(exactly = 1) { mockHttpClient.get("in_app_messages/messageId/variants/variantId/html?app_id=appId", any()) } + } + + test("getIAMData successfully hydrates successful response with no content") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(200, "{}") + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + val response = inAppBackendService.getIAMData("appId", "messageId", "variantId") + + // Then + response shouldNotBe null + response.shouldRetry shouldBe false + response.content shouldBe null + + coVerify(exactly = 1) { mockHttpClient.get("in_app_messages/messageId/variants/variantId/html?app_id=appId", any()) } + } + + test("getIAMData successfully hydrates successful response with no style") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(200, "{html: \"html1\", display_duration: 123 }") + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + val response = inAppBackendService.getIAMData("appId", "messageId", "variantId") + + // Then + response shouldNotBe null + response.shouldRetry shouldBe false + response.content shouldNotBe null + response.content!!.contentHtml shouldStartWith "html1" + response.content!!.displayDuration shouldBe 123 + response.content!!.useHeightMargin shouldBe true + response.content!!.useWidthMargin shouldBe true + response.content!!.isFullBleed shouldBe false + + coVerify(exactly = 1) { mockHttpClient.get("in_app_messages/messageId/variants/variantId/html?app_id=appId", any()) } + } - // Then - exception.statusCode shouldBe 409 - exception.response shouldBe "{}" - coVerify(exactly = 1) { - mockHttpClient.post( - "in_app_messages/messageId/impression", - withArg { - it.safeString("app_id") shouldBe "appId" - it.safeInt("device_type") shouldBe IDeviceService.DeviceType.Android.value - it.safeString("player_id") shouldBe "subscriptionId" - it.safeString("variant_id") shouldBe "variantId" - it.safeBool("first_impression") shouldBe true - }, - ) + test("getIAMData indicates retry when retryable response provided") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(500, null) + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + val response = inAppBackendService.getIAMData("appId", "messageId", "variantId") + + // Then + response shouldNotBe null + response.shouldRetry shouldBe true + response.content shouldBe null + + coVerify(exactly = 1) { mockHttpClient.get("in_app_messages/messageId/variants/variantId/html?app_id=appId", any()) } + } + + test("getIAMData indicates no retry when non-retryable response provided") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(404, null) + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + val response = inAppBackendService.getIAMData("appId", "messageId", "variantId") + + // Then + response shouldNotBe null + response.shouldRetry shouldBe false + response.content shouldBe null + + coVerify(exactly = 1) { mockHttpClient.get("in_app_messages/messageId/variants/variantId/html?app_id=appId", any()) } + } + + test("getIAMData indicates no retry when retryable response provided more than 3 times") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(500, null) + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + val response1 = inAppBackendService.getIAMData("appId", "messageId", "variantId") + val response2 = inAppBackendService.getIAMData("appId", "messageId", "variantId") + val response3 = inAppBackendService.getIAMData("appId", "messageId", "variantId") + val response4 = inAppBackendService.getIAMData("appId", "messageId", "variantId") + + // Then + response1 shouldNotBe null + response1.shouldRetry shouldBe true + response1.content shouldBe null + response2 shouldNotBe null + response2.shouldRetry shouldBe true + response2.content shouldBe null + response3 shouldNotBe null + response3.shouldRetry shouldBe true + response3.content shouldBe null + response4 shouldNotBe null + response4.shouldRetry shouldBe false + response4.content shouldBe null + + coVerify(exactly = 4) { mockHttpClient.get("in_app_messages/messageId/variants/variantId/html?app_id=appId", any()) } + } + + test("getIAMPreviewData successfully hydrates successful response") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { + mockHttpClient.get(any(), any()) + } returns + HttpResponse( + 200, + "{html: \"html1\", display_duration: 123, styles: {remove_height_margin: true, remove_width_margin: true}}", + ) + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + val response = inAppBackendService.getIAMPreviewData("appId", "previewUUID") + + // Then + response shouldNotBe null + response!!.contentHtml shouldStartWith "html1" + response!!.displayDuration shouldBe 123 + response!!.useHeightMargin shouldBe false + response!!.useWidthMargin shouldBe false + response!!.isFullBleed shouldBe true + + coVerify(exactly = 1) { mockHttpClient.get("in_app_messages/device_preview?preview_id=previewUUID&app_id=appId", any()) } + } + + test("getIAMPreviewData returns no data when response is unsuccessful") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { mockHttpClient.get(any(), any()) } returns HttpResponse(404, null) + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + val response = inAppBackendService.getIAMPreviewData("appId", "previewUUID") + + // Then + response shouldBe null + + coVerify(exactly = 1) { mockHttpClient.get("in_app_messages/device_preview?preview_id=previewUUID&app_id=appId", any()) } } - } - - test("sendIAMPageImpression is successful when there is a successful response") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { mockHttpClient.post(any(), any()) } returns HttpResponse(200, "{}") - - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) - - // When - inAppBackendService.sendIAMPageImpression("appId", "subscriptionId", "variantId", "messageId", "pageId") - - // Then - coVerify(exactly = 1) { - mockHttpClient.post( - "in_app_messages/messageId/pageImpression", - withArg { - it.safeString("app_id") shouldBe "appId" - it.safeInt("device_type") shouldBe IDeviceService.DeviceType.Android.value - it.safeString("player_id") shouldBe "subscriptionId" - it.safeString("variant_id") shouldBe "variantId" - it.safeString("page_id") shouldBe "pageId" - }, - ) + + test("sendIAMClick is successful when there is a successful response") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { mockHttpClient.post(any(), any()) } returns HttpResponse(200, "{}") + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + inAppBackendService.sendIAMClick("appId", "subscriptionId", "variantId", "messageId", "clickId", isFirstClick = true) + + // Then + coVerify(exactly = 1) { + mockHttpClient.post( + "in_app_messages/messageId/click", + withArg { + it.safeString("app_id") shouldBe "appId" + it.safeInt("device_type") shouldBe IDeviceService.DeviceType.Android.value + it.safeString("player_id") shouldBe "subscriptionId" + it.safeString("click_id") shouldBe "clickId" + it.safeString("variant_id") shouldBe "variantId" + it.safeBool("first_click") shouldBe true + }, + ) + } } - } - test("sendIAMPageImpression throws exception when there is an unsuccessful response") { - // Given - val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) - val mockHttpClient = mockk() - coEvery { mockHttpClient.post(any(), any()) } returns HttpResponse(409, "{}") + test("sendIAMClick throws exception when there is an unsuccessful response") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { mockHttpClient.post(any(), any()) } returns HttpResponse(409, "{}") + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + val exception = + shouldThrowUnit { + inAppBackendService.sendIAMClick( + "appId", + "subscriptionId", + "variantId", + "messageId", + "clickId", + isFirstClick = true, + ) + } + + // Then + exception.statusCode shouldBe 409 + exception.response shouldBe "{}" + coVerify(exactly = 1) { + mockHttpClient.post( + "in_app_messages/messageId/click", + withArg { + it.safeString("app_id") shouldBe "appId" + it.safeInt("device_type") shouldBe IDeviceService.DeviceType.Android.value + it.safeString("player_id") shouldBe "subscriptionId" + it.safeString("click_id") shouldBe "clickId" + it.safeString("variant_id") shouldBe "variantId" + it.safeBool("first_click") shouldBe true + }, + ) + } + } - val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + test("sendIAMImpression is successful when there is a successful response") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { mockHttpClient.post(any(), any()) } returns HttpResponse(200, "{}") + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + inAppBackendService.sendIAMImpression("appId", "subscriptionId", "variantId", "messageId") + + // Then + coVerify(exactly = 1) { + mockHttpClient.post( + "in_app_messages/messageId/impression", + withArg { + it.safeString("app_id") shouldBe "appId" + it.safeInt("device_type") shouldBe IDeviceService.DeviceType.Android.value + it.safeString("player_id") shouldBe "subscriptionId" + it.safeString("variant_id") shouldBe "variantId" + it.safeBool("first_impression") shouldBe true + }, + ) + } + } + + test("sendIAMImpression throws exception when there is an unsuccessful response") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { mockHttpClient.post(any(), any()) } returns HttpResponse(409, "{}") + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + val exception = + shouldThrowUnit { + inAppBackendService.sendIAMImpression("appId", "subscriptionId", "variantId", "messageId") + } + + // Then + exception.statusCode shouldBe 409 + exception.response shouldBe "{}" + coVerify(exactly = 1) { + mockHttpClient.post( + "in_app_messages/messageId/impression", + withArg { + it.safeString("app_id") shouldBe "appId" + it.safeInt("device_type") shouldBe IDeviceService.DeviceType.Android.value + it.safeString("player_id") shouldBe "subscriptionId" + it.safeString("variant_id") shouldBe "variantId" + it.safeBool("first_impression") shouldBe true + }, + ) + } + } - // When - val exception = - shouldThrowUnit { - inAppBackendService.sendIAMPageImpression("appId", "subscriptionId", "variantId", "messageId", "pageId") + test("sendIAMPageImpression is successful when there is a successful response") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { mockHttpClient.post(any(), any()) } returns HttpResponse(200, "{}") + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + inAppBackendService.sendIAMPageImpression("appId", "subscriptionId", "variantId", "messageId", "pageId") + + // Then + coVerify(exactly = 1) { + mockHttpClient.post( + "in_app_messages/messageId/pageImpression", + withArg { + it.safeString("app_id") shouldBe "appId" + it.safeInt("device_type") shouldBe IDeviceService.DeviceType.Android.value + it.safeString("player_id") shouldBe "subscriptionId" + it.safeString("variant_id") shouldBe "variantId" + it.safeString("page_id") shouldBe "pageId" + }, + ) } + } - // Then - exception.statusCode shouldBe 409 - exception.response shouldBe "{}" - coVerify(exactly = 1) { - mockHttpClient.post( - "in_app_messages/messageId/pageImpression", - withArg { - it.safeString("app_id") shouldBe "appId" - it.safeInt("device_type") shouldBe IDeviceService.DeviceType.Android.value - it.safeString("player_id") shouldBe "subscriptionId" - it.safeString("variant_id") shouldBe "variantId" - it.safeString("page_id") shouldBe "pageId" - }, - ) + test("sendIAMPageImpression throws exception when there is an unsuccessful response") { + // Given + val mockHydrator = InAppHydrator(MockHelper.time(1000), MockHelper.propertiesModelStore()) + val mockHttpClient = mockk() + coEvery { mockHttpClient.post(any(), any()) } returns HttpResponse(409, "{}") + + val inAppBackendService = InAppBackendService(mockHttpClient, MockHelper.deviceService(), mockHydrator) + + // When + val exception = + shouldThrowUnit { + inAppBackendService.sendIAMPageImpression("appId", "subscriptionId", "variantId", "messageId", "pageId") + } + + // Then + exception.statusCode shouldBe 409 + exception.response shouldBe "{}" + coVerify(exactly = 1) { + mockHttpClient.post( + "in_app_messages/messageId/pageImpression", + withArg { + it.safeString("app_id") shouldBe "appId" + it.safeInt("device_type") shouldBe IDeviceService.DeviceType.Android.value + it.safeString("player_id") shouldBe "subscriptionId" + it.safeString("variant_id") shouldBe "variantId" + it.safeString("page_id") shouldBe "pageId" + }, + ) + } } - } -}) + }) From 591bbe7b29f4e1d70f177f27ff4ad3f65ad62740 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomez Palacio Date: Tue, 24 Sep 2024 15:50:28 -0500 Subject: [PATCH 07/12] Flush track session start operation Motivation: we want to update the user as soon as possible in order to not delay IAM fetch --- .../onesignal/session/internal/session/impl/SessionListener.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt index 9669e05de..8d2161aa6 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/impl/SessionListener.kt @@ -40,7 +40,7 @@ internal class SessionListener( } override fun onSessionStarted() { - _operationRepo.enqueue(TrackSessionStartOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId)) + _operationRepo.enqueue(TrackSessionStartOperation(_configModelStore.model.appId, _identityModelStore.model.onesignalId), true) } override fun onSessionActive() { From c3f15f53949d277a6404b9c683b4af7e58af661d Mon Sep 17 00:00:00 2001 From: Rodrigo Gomez Palacio Date: Tue, 24 Sep 2024 15:51:02 -0500 Subject: [PATCH 08/12] IamFetchRywTokenKey Enum Motivation: to be used to index read your write tokens specific to the IamFetch consistency use case --- .../common/consistency/enums/IamFetchRywTokenKey.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/enums/IamFetchRywTokenKey.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/enums/IamFetchRywTokenKey.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/enums/IamFetchRywTokenKey.kt new file mode 100644 index 000000000..3f31c9a98 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/enums/IamFetchRywTokenKey.kt @@ -0,0 +1,12 @@ +package com.onesignal.common.consistency.enums + +import com.onesignal.common.consistency.models.IConsistencyKeyEnum + +/** + * Each enum is a key that we use to keep track of read-your-write tokens. + * Although the enums are named with "UPDATE", they serve as keys for tokens from both PATCH & POST + */ +enum class IamFetchRywTokenKey : IConsistencyKeyEnum { + USER, + SUBSCRIPTION, +} From 5b83545bb494ca4077771414b80eb38461b91303 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomez Palacio Date: Tue, 24 Sep 2024 15:52:10 -0500 Subject: [PATCH 09/12] Update User & Subscription operation executors to set the tokens Motivation: the executors call the respective backend services who's result will include the token value. We then hold in memory via `setRywToken` --- .../SubscriptionOperationExecutor.kt | 24 +- .../executors/UpdateUserOperationExecutor.kt | 55 +- .../SubscriptionOperationExecutorTests.kt | 1360 +++++++++-------- .../UpdateUserOperationExecutorTests.kt | 676 ++++---- 4 files changed, 1146 insertions(+), 969 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt index 73ee00074..1622d55f1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt @@ -6,6 +6,9 @@ import com.onesignal.common.DeviceUtils import com.onesignal.common.NetworkUtils import com.onesignal.common.OneSignalUtils import com.onesignal.common.RootToolsInternalMethods +import com.onesignal.common.consistency.IamFetchReadyCondition +import com.onesignal.common.consistency.enums.IamFetchRywTokenKey +import com.onesignal.common.consistency.models.IConsistencyManager import com.onesignal.common.exceptions.BackendException import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.core.internal.application.IApplicationService @@ -39,6 +42,7 @@ internal class SubscriptionOperationExecutor( private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, + private val _consistencyManager: IConsistencyManager, ) : IOperationExecutor { override val operations: List get() = listOf(CREATE_SUBSCRIPTION, UPDATE_SUBSCRIPTION, DELETE_SUBSCRIPTION, TRANSFER_SUBSCRIPTION) @@ -101,7 +105,7 @@ internal class SubscriptionOperationExecutor( AndroidUtils.getAppVersion(_applicationService.appContext), ) - val backendSubscriptionId = + val result = _subscriptionBackend.createSubscription( createOperation.appId, IdentityConstants.ONESIGNAL_ID, @@ -109,6 +113,15 @@ internal class SubscriptionOperationExecutor( subscription, ) ?: return ExecutionResponse(ExecutionResult.SUCCESS) + val backendSubscriptionId = result.first + val rywToken = result.second + + if (rywToken != null) { + _consistencyManager.setRywToken(createOperation.onesignalId, IamFetchRywTokenKey.SUBSCRIPTION, rywToken) + } else { + _consistencyManager.resolveConditionsWithID(IamFetchReadyCondition.ID) + } + // update the subscription model with the new ID, if it's still active. val subscriptionModel = _subscriptionModelStore.get(createOperation.subscriptionId) subscriptionModel?.setStringProperty( @@ -175,7 +188,13 @@ internal class SubscriptionOperationExecutor( AndroidUtils.getAppVersion(_applicationService.appContext), ) - _subscriptionBackend.updateSubscription(lastOperation.appId, lastOperation.subscriptionId, subscription) + val rywToken = _subscriptionBackend.updateSubscription(lastOperation.appId, lastOperation.subscriptionId, subscription) + + if (rywToken != null) { + _consistencyManager.setRywToken(startingOperation.onesignalId, IamFetchRywTokenKey.SUBSCRIPTION, rywToken) + } else { + _consistencyManager.resolveConditionsWithID(IamFetchReadyCondition.ID) + } } catch (ex: BackendException) { val responseType = NetworkUtils.getResponseStatusType(ex.statusCode) @@ -216,6 +235,7 @@ internal class SubscriptionOperationExecutor( return ExecutionResponse(ExecutionResult.SUCCESS) } + // TODO: whenever the end-user changes users, we need to add the read-your-write token here, currently no code to handle the re-fetch IAMs private suspend fun transferSubscription(startingOperation: TransferSubscriptionOperation): ExecutionResponse { try { _subscriptionBackend.transferSubscription( diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt index e3d2a7425..0f28ae61d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt @@ -1,6 +1,9 @@ package com.onesignal.user.internal.operations.impl.executors import com.onesignal.common.NetworkUtils +import com.onesignal.common.consistency.IamFetchReadyCondition +import com.onesignal.common.consistency.enums.IamFetchRywTokenKey +import com.onesignal.common.consistency.models.IConsistencyManager import com.onesignal.common.exceptions.BackendException import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.core.internal.operations.ExecutionResponse @@ -31,12 +34,13 @@ internal class UpdateUserOperationExecutor( private val _propertiesModelStore: PropertiesModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, + private val _consistencyManager: IConsistencyManager, ) : IOperationExecutor { override val operations: List get() = listOf(SET_TAG, DELETE_TAG, SET_PROPERTY, TRACK_SESSION_START, TRACK_SESSION_END, TRACK_PURCHASE) - override suspend fun execute(ops: List): ExecutionResponse { - Logging.log(LogLevel.DEBUG, "UpdateUserOperationExecutor(operation: $ops)") + override suspend fun execute(operations: List): ExecutionResponse { + Logging.log(LogLevel.DEBUG, "UpdateUserOperationExecutor(operation: $operations)") var appId: String? = null var onesignalId: String? = null @@ -45,7 +49,7 @@ internal class UpdateUserOperationExecutor( var deltasObject = PropertiesDeltasObject() var refreshDeviceMetadata = false - for (operation in ops) { + for (operation in operations) { when (operation) { is SetTagOperation -> { if (appId == null) { @@ -83,7 +87,8 @@ internal class UpdateUserOperationExecutor( // that exist in this group. val sessionCount = if (deltasObject.sessionCount != null) deltasObject.sessionCount!! + 1 else 1 - deltasObject = PropertiesDeltasObject(deltasObject.sessionTime, sessionCount, deltasObject.amountSpent, deltasObject.purchases) + deltasObject = + PropertiesDeltasObject(deltasObject.sessionTime, sessionCount, deltasObject.amountSpent, deltasObject.purchases) refreshDeviceMetadata = true } is TrackSessionEndOperation -> { @@ -94,9 +99,15 @@ internal class UpdateUserOperationExecutor( // The session time we pass up is the total session time across all `TrackSessionEndOperation` // operations that exist in this group. - val sessionTime = if (deltasObject.sessionTime != null) deltasObject.sessionTime!! + operation.sessionTime else operation.sessionTime + val sessionTime = + if (deltasObject.sessionTime != null) { + deltasObject.sessionTime!! + operation.sessionTime + } else { + operation.sessionTime + } - deltasObject = PropertiesDeltasObject(sessionTime, deltasObject.sessionCount, deltasObject.amountSpent, deltasObject.purchases) + deltasObject = + PropertiesDeltasObject(sessionTime, deltasObject.sessionCount, deltasObject.amountSpent, deltasObject.purchases) } is TrackPurchaseOperation -> { if (appId == null) { @@ -107,7 +118,12 @@ internal class UpdateUserOperationExecutor( // The amount spent we pass up is the total amount spent across all `TrackPurchaseOperation` // operations that exist in this group, while the purchases is the union of all // `TrackPurchaseOperation` operations that exist in this group. - val amountSpent = if (deltasObject.amountSpent != null) deltasObject.amountSpent!! + operation.amountSpent else operation.amountSpent + val amountSpent = + if (deltasObject.amountSpent != null) { + deltasObject.amountSpent!! + operation.amountSpent + } else { + operation.amountSpent + } val purchasesArray = if (deltasObject.purchases != null) deltasObject.purchases!!.toMutableList() else mutableListOf() for (purchase in operation.purchases) { @@ -122,18 +138,25 @@ internal class UpdateUserOperationExecutor( if (appId != null && onesignalId != null) { try { - _userBackend.updateUser( - appId, - IdentityConstants.ONESIGNAL_ID, - onesignalId, - propertiesObject, - refreshDeviceMetadata, - deltasObject, - ) + val rywToken = + _userBackend.updateUser( + appId, + IdentityConstants.ONESIGNAL_ID, + onesignalId, + propertiesObject, + refreshDeviceMetadata, + deltasObject, + ) + + if (rywToken != null) { + _consistencyManager.setRywToken(onesignalId, IamFetchRywTokenKey.USER, rywToken) + } else { + _consistencyManager.resolveConditionsWithID(IamFetchReadyCondition.ID) + } if (_identityModelStore.model.onesignalId == onesignalId) { // go through and make sure any properties are in the correct model state - for (operation in ops) { + for (operation in operations) { when (operation) { is SetTagOperation -> _propertiesModelStore.model.tags.setStringProperty( diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt index 37c290855..87d5f4a0b 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt @@ -1,6 +1,8 @@ package com.onesignal.user.internal.operations import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.consistency.enums.IamFetchRywTokenKey +import com.onesignal.common.consistency.models.IConsistencyManager import com.onesignal.common.exceptions.BackendException import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.Operation @@ -18,6 +20,7 @@ import com.onesignal.user.internal.subscriptions.SubscriptionStatus import com.onesignal.user.internal.subscriptions.SubscriptionType import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe +import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -27,664 +30,741 @@ import io.mockk.runs import io.mockk.verify @RobolectricTest -class SubscriptionOperationExecutorTests : FunSpec({ - val appId = "appId" - val remoteOneSignalId = "remote-onesignalId" - val localSubscriptionId = "local-subscriptionId1" - val remoteSubscriptionId = "remote-subscriptionId1" - - test("create subscription successfully creates subscription") { - // Given - val mockSubscriptionBackendService = mockk() - coEvery { mockSubscriptionBackendService.createSubscription(any(), any(), any(), any()) } returns remoteSubscriptionId - - val mockSubscriptionsModelStore = mockk() - val subscriptionModel1 = SubscriptionModel() - subscriptionModel1.id = localSubscriptionId - every { mockSubscriptionsModelStore.get(localSubscriptionId) } returns subscriptionModel1 - - val mockBuildUserService = mockk() - - val subscriptionOperationExecutor = - SubscriptionOperationExecutor( - mockSubscriptionBackendService, - MockHelper.deviceService(), - AndroidMockHelper.applicationService(), - mockSubscriptionsModelStore, - MockHelper.configModelStore(), - mockBuildUserService, - getNewRecordState(), - ) - - val operations = - listOf( - CreateSubscriptionOperation( - appId, - remoteOneSignalId, - localSubscriptionId, - SubscriptionType.PUSH, - true, - "pushToken", - SubscriptionStatus.SUBSCRIBED, - ), - ) - - // When - val response = subscriptionOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.SUCCESS - subscriptionModel1.id shouldBe remoteSubscriptionId - coVerify(exactly = 1) { - mockSubscriptionBackendService.createSubscription( - appId, - IdentityConstants.ONESIGNAL_ID, - remoteOneSignalId, - withArg { - it.type shouldBe SubscriptionObjectType.ANDROID_PUSH - it.enabled shouldBe true - it.token shouldBe "pushToken" - it.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value - }, - ) +class SubscriptionOperationExecutorTests : + FunSpec({ + val appId = "appId" + val remoteOneSignalId = "remote-onesignalId" + val localSubscriptionId = "local-subscriptionId1" + val remoteSubscriptionId = "remote-subscriptionId1" + val rywToken = "1" + val mockConsistencyManager = mockk() + + beforeTest { + clearMocks(mockConsistencyManager) + coEvery { mockConsistencyManager.setRywToken(any(), any(), any()) } just runs } - } - - test("create subscription fails with retry when there is a network condition") { - // Given - val mockSubscriptionBackendService = mockk() - coEvery { mockSubscriptionBackendService.createSubscription(any(), any(), any(), any()) } throws BackendException(408, retryAfterSeconds = 10) - - val mockSubscriptionsModelStore = mockk() - val mockBuildUserService = mockk() - - val subscriptionOperationExecutor = - SubscriptionOperationExecutor( - mockSubscriptionBackendService, - MockHelper.deviceService(), - AndroidMockHelper.applicationService(), - mockSubscriptionsModelStore, - MockHelper.configModelStore(), - mockBuildUserService, - getNewRecordState(), - ) - - val operations = - listOf( - CreateSubscriptionOperation( + + test("create subscription successfully creates subscription") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.createSubscription(any(), any(), any(), any()) } returns + Pair(remoteSubscriptionId, rywToken) + + val mockSubscriptionsModelStore = mockk() + val subscriptionModel1 = SubscriptionModel() + subscriptionModel1.id = localSubscriptionId + every { mockSubscriptionsModelStore.get(localSubscriptionId) } returns subscriptionModel1 + + val mockBuildUserService = mockk() + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val operations = + listOf( + CreateSubscriptionOperation( + appId, + remoteOneSignalId, + localSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken", + SubscriptionStatus.SUBSCRIBED, + ), + ) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + subscriptionModel1.id shouldBe remoteSubscriptionId + coVerify(exactly = 1) { + mockSubscriptionBackendService.createSubscription( appId, + IdentityConstants.ONESIGNAL_ID, remoteOneSignalId, - localSubscriptionId, - SubscriptionType.PUSH, - true, - "pushToken", - SubscriptionStatus.SUBSCRIBED, - ), - ) - - // When - val response = subscriptionOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.FAIL_RETRY - response.retryAfterSeconds shouldBe 10 - coVerify(exactly = 1) { - mockSubscriptionBackendService.createSubscription( - appId, - IdentityConstants.ONESIGNAL_ID, - remoteOneSignalId, - withArg { - it.type shouldBe SubscriptionObjectType.ANDROID_PUSH - it.enabled shouldBe true - it.token shouldBe "pushToken" - it.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value - }, - ) + withArg { + it.type shouldBe SubscriptionObjectType.ANDROID_PUSH + it.enabled shouldBe true + it.token shouldBe "pushToken" + it.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value + }, + ) + } } - } - - test("create subscription fails without retry when there is a backend error") { - // Given - val mockSubscriptionBackendService = mockk() - coEvery { mockSubscriptionBackendService.createSubscription(any(), any(), any(), any()) } throws BackendException(404) - - val mockSubscriptionsModelStore = mockk() - val mockBuildUserService = mockk() - every { mockBuildUserService.getRebuildOperationsIfCurrentUser(any(), any()) } answers { null } - - val subscriptionOperationExecutor = - SubscriptionOperationExecutor( - mockSubscriptionBackendService, - MockHelper.deviceService(), - AndroidMockHelper.applicationService(), - mockSubscriptionsModelStore, - MockHelper.configModelStore(), - mockBuildUserService, - getNewRecordState(), - ) - - val operations = - listOf( - CreateSubscriptionOperation( + + test("create subscription fails with retry when there is a network condition") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.createSubscription(any(), any(), any(), any()) } throws + BackendException(408, retryAfterSeconds = 10) + + val mockSubscriptionsModelStore = mockk() + val mockBuildUserService = mockk() + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val operations = + listOf( + CreateSubscriptionOperation( + appId, + remoteOneSignalId, + localSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken", + SubscriptionStatus.SUBSCRIBED, + ), + ) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.FAIL_RETRY + response.retryAfterSeconds shouldBe 10 + coVerify(exactly = 1) { + mockSubscriptionBackendService.createSubscription( appId, + IdentityConstants.ONESIGNAL_ID, remoteOneSignalId, - localSubscriptionId, - SubscriptionType.PUSH, - true, - "pushToken", - SubscriptionStatus.SUBSCRIBED, - ), - ) - - // When - val response = subscriptionOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.FAIL_NORETRY - coVerify(exactly = 1) { - mockSubscriptionBackendService.createSubscription( - appId, - IdentityConstants.ONESIGNAL_ID, - remoteOneSignalId, - withArg { - it.type shouldBe SubscriptionObjectType.ANDROID_PUSH - it.enabled shouldBe true - it.token shouldBe "pushToken" - it.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value - }, - ) + withArg { + it.type shouldBe SubscriptionObjectType.ANDROID_PUSH + it.enabled shouldBe true + it.token shouldBe "pushToken" + it.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value + }, + ) + } } - } - - test("create subscription fails with retry when the backend returns MISSING, when isInMissingRetryWindow") { - // Given - val mockSubscriptionBackendService = mockk() - coEvery { mockSubscriptionBackendService.createSubscription(any(), any(), any(), any()) } throws BackendException(404) - - val mockSubscriptionsModelStore = mockk() - val mockBuildUserService = mockk() - val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 } - val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add(remoteOneSignalId) } - - val subscriptionOperationExecutor = - SubscriptionOperationExecutor( - mockSubscriptionBackendService, - MockHelper.deviceService(), - AndroidMockHelper.applicationService(), - mockSubscriptionsModelStore, - MockHelper.configModelStore(), - mockBuildUserService, - newRecordState, - ) - - val operations = - listOf( - CreateSubscriptionOperation( - appId, - remoteOneSignalId, - localSubscriptionId, - SubscriptionType.PUSH, - true, - "pushToken", - SubscriptionStatus.SUBSCRIBED, - ), - ) - - // When - val response = subscriptionOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.FAIL_RETRY - } - - test("create subscription then delete subscription is a successful no-op") { - // Given - val mockSubscriptionBackendService = mockk() - - val mockSubscriptionsModelStore = mockk() - val subscriptionModel1 = SubscriptionModel() - subscriptionModel1.id = localSubscriptionId - every { mockSubscriptionsModelStore.get(localSubscriptionId) } returns subscriptionModel1 - - val mockBuildUserService = mockk() - - val subscriptionOperationExecutor = - SubscriptionOperationExecutor( - mockSubscriptionBackendService, - MockHelper.deviceService(), - AndroidMockHelper.applicationService(), - mockSubscriptionsModelStore, - MockHelper.configModelStore(), - mockBuildUserService, - getNewRecordState(), - ) - - val operations = - listOf( - CreateSubscriptionOperation( - appId, - remoteOneSignalId, - localSubscriptionId, - SubscriptionType.PUSH, - true, - "pushToken", - SubscriptionStatus.SUBSCRIBED, - ), - DeleteSubscriptionOperation(appId, remoteOneSignalId, localSubscriptionId), - ) - - // When - val response = subscriptionOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.SUCCESS - } - - test("create subscription then update subscription successfully creates subscription") { - // Given - val mockSubscriptionBackendService = mockk() - coEvery { mockSubscriptionBackendService.createSubscription(any(), any(), any(), any()) } returns remoteSubscriptionId - - val mockSubscriptionsModelStore = mockk() - val subscriptionModel1 = SubscriptionModel() - subscriptionModel1.id = localSubscriptionId - every { mockSubscriptionsModelStore.get(localSubscriptionId) } returns subscriptionModel1 - - val mockBuildUserService = mockk() - - val subscriptionOperationExecutor = - SubscriptionOperationExecutor( - mockSubscriptionBackendService, - MockHelper.deviceService(), - AndroidMockHelper.applicationService(), - mockSubscriptionsModelStore, - MockHelper.configModelStore(), - mockBuildUserService, - getNewRecordState(), - ) - - val operations = - listOf( - CreateSubscriptionOperation( - appId, - remoteOneSignalId, - localSubscriptionId, - SubscriptionType.PUSH, - true, - "pushToken1", - SubscriptionStatus.SUBSCRIBED, - ), - UpdateSubscriptionOperation( + + test("create subscription fails without retry when there is a backend error") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.createSubscription(any(), any(), any(), any()) } throws BackendException(404) + + val mockSubscriptionsModelStore = mockk() + val mockBuildUserService = mockk() + every { mockBuildUserService.getRebuildOperationsIfCurrentUser(any(), any()) } answers { null } + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val operations = + listOf( + CreateSubscriptionOperation( + appId, + remoteOneSignalId, + localSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken", + SubscriptionStatus.SUBSCRIBED, + ), + ) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.FAIL_NORETRY + coVerify(exactly = 1) { + mockSubscriptionBackendService.createSubscription( appId, + IdentityConstants.ONESIGNAL_ID, remoteOneSignalId, - localSubscriptionId, - SubscriptionType.PUSH, - true, - "pushToken2", - SubscriptionStatus.SUBSCRIBED, - ), - ) - - // When - val response = subscriptionOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.SUCCESS - subscriptionModel1.id shouldBe remoteSubscriptionId - coVerify(exactly = 1) { - mockSubscriptionBackendService.createSubscription( - appId, - IdentityConstants.ONESIGNAL_ID, - remoteOneSignalId, - withArg { - it.type shouldBe SubscriptionObjectType.ANDROID_PUSH - it.enabled shouldBe true - it.token shouldBe "pushToken2" - it.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value - }, - ) + withArg { + it.type shouldBe SubscriptionObjectType.ANDROID_PUSH + it.enabled shouldBe true + it.token shouldBe "pushToken" + it.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value + }, + ) + } } - } - - test("update subscription successfully updates subscription") { - // Given - val mockSubscriptionBackendService = mockk() - coEvery { mockSubscriptionBackendService.updateSubscription(any(), any(), any()) } just runs - - val mockSubscriptionsModelStore = mockk() - val subscriptionModel1 = SubscriptionModel() - subscriptionModel1.id = remoteSubscriptionId - subscriptionModel1.address = "pushToken1" - every { mockSubscriptionsModelStore.get(remoteSubscriptionId) } returns subscriptionModel1 - - val mockBuildUserService = mockk() - - val subscriptionOperationExecutor = - SubscriptionOperationExecutor( - mockSubscriptionBackendService, - MockHelper.deviceService(), - AndroidMockHelper.applicationService(), - mockSubscriptionsModelStore, - MockHelper.configModelStore(), - mockBuildUserService, - getNewRecordState(), - ) - - val operations = - listOf( - UpdateSubscriptionOperation( - appId, - remoteOneSignalId, - remoteSubscriptionId, - SubscriptionType.PUSH, - true, - "pushToken2", - SubscriptionStatus.SUBSCRIBED, - ), - UpdateSubscriptionOperation( + + test("create subscription fails with retry when the backend returns MISSING, when isInMissingRetryWindow") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.createSubscription(any(), any(), any(), any()) } throws BackendException(404) + + val mockSubscriptionsModelStore = mockk() + val mockBuildUserService = mockk() + val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 } + val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add(remoteOneSignalId) } + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + newRecordState, + mockConsistencyManager, + ) + + val operations = + listOf( + CreateSubscriptionOperation( + appId, + remoteOneSignalId, + localSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken", + SubscriptionStatus.SUBSCRIBED, + ), + ) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.FAIL_RETRY + } + + test("create subscription then delete subscription is a successful no-op") { + // Given + val mockSubscriptionBackendService = mockk() + + val mockSubscriptionsModelStore = mockk() + val subscriptionModel1 = SubscriptionModel() + subscriptionModel1.id = localSubscriptionId + every { mockSubscriptionsModelStore.get(localSubscriptionId) } returns subscriptionModel1 + + val mockBuildUserService = mockk() + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val operations = + listOf( + CreateSubscriptionOperation( + appId, + remoteOneSignalId, + localSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken", + SubscriptionStatus.SUBSCRIBED, + ), + DeleteSubscriptionOperation(appId, remoteOneSignalId, localSubscriptionId), + ) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + } + + test("create subscription then update subscription successfully creates subscription") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.createSubscription(any(), any(), any(), any()) } returns + Pair(remoteSubscriptionId, rywToken) + + val mockSubscriptionsModelStore = mockk() + val subscriptionModel1 = SubscriptionModel() + subscriptionModel1.id = localSubscriptionId + every { mockSubscriptionsModelStore.get(localSubscriptionId) } returns subscriptionModel1 + + val mockBuildUserService = mockk() + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val operations = + listOf( + CreateSubscriptionOperation( + appId, + remoteOneSignalId, + localSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken1", + SubscriptionStatus.SUBSCRIBED, + ), + UpdateSubscriptionOperation( + appId, + remoteOneSignalId, + localSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken2", + SubscriptionStatus.SUBSCRIBED, + ), + ) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + subscriptionModel1.id shouldBe remoteSubscriptionId + coVerify(exactly = 1) { + mockSubscriptionBackendService.createSubscription( appId, + IdentityConstants.ONESIGNAL_ID, remoteOneSignalId, - remoteSubscriptionId, - SubscriptionType.PUSH, - true, - "pushToken3", - SubscriptionStatus.SUBSCRIBED, - ), - ) - - // When - val response = subscriptionOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.SUCCESS - coVerify(exactly = 1) { - mockSubscriptionBackendService.updateSubscription( - appId, - remoteSubscriptionId, - withArg { - it.type shouldBe SubscriptionObjectType.ANDROID_PUSH - it.enabled shouldBe true - it.token shouldBe "pushToken3" - it.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value - }, - ) + withArg { + it.type shouldBe SubscriptionObjectType.ANDROID_PUSH + it.enabled shouldBe true + it.token shouldBe "pushToken2" + it.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value + }, + ) + } } - } - - test("update subscription fails with retry when there is a network condition") { - // Given - val mockSubscriptionBackendService = mockk() - coEvery { mockSubscriptionBackendService.updateSubscription(any(), any(), any()) } throws BackendException(408) - - val mockSubscriptionsModelStore = mockk() - val mockBuildUserService = mockk() - - val subscriptionOperationExecutor = - SubscriptionOperationExecutor( - mockSubscriptionBackendService, - MockHelper.deviceService(), - AndroidMockHelper.applicationService(), - mockSubscriptionsModelStore, - MockHelper.configModelStore(), - mockBuildUserService, - getNewRecordState(), - ) - - val operations = - listOf( - UpdateSubscriptionOperation( + + test("update subscription successfully updates subscription") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.updateSubscription(any(), any(), any()) } returns rywToken + + val mockSubscriptionsModelStore = mockk() + val subscriptionModel1 = + SubscriptionModel().apply { + id = remoteSubscriptionId + address = "pushToken1" + } + every { mockSubscriptionsModelStore.get(remoteSubscriptionId) } returns subscriptionModel1 + + val mockBuildUserService = mockk() + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val operations = + listOf( + UpdateSubscriptionOperation( + appId, + remoteOneSignalId, + remoteSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken2", + SubscriptionStatus.SUBSCRIBED, + ), + UpdateSubscriptionOperation( + appId, + remoteOneSignalId, + remoteSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken3", + SubscriptionStatus.SUBSCRIBED, + ), + ) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockSubscriptionBackendService.updateSubscription( appId, - remoteOneSignalId, remoteSubscriptionId, - SubscriptionType.PUSH, - true, - "pushToken2", - SubscriptionStatus.SUBSCRIBED, - ), - ) - - // When - val response = subscriptionOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.FAIL_RETRY - coVerify(exactly = 1) { - mockSubscriptionBackendService.updateSubscription( - appId, - remoteSubscriptionId, - withArg { - it.type shouldBe SubscriptionObjectType.ANDROID_PUSH - it.enabled shouldBe true - it.token shouldBe "pushToken2" - it.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value - }, - ) + withArg { + it.type shouldBe SubscriptionObjectType.ANDROID_PUSH + it.enabled shouldBe true + it.token shouldBe "pushToken3" + it.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value + }, + ) + } } - } - - test("update subscription fails without retry when there is a backend error") { - // Given - val mockSubscriptionBackendService = mockk() - coEvery { mockSubscriptionBackendService.updateSubscription(any(), any(), any()) } throws BackendException(404) - - val mockSubscriptionsModelStore = mockk() - val mockBuildUserService = mockk() - - val subscriptionOperationExecutor = - SubscriptionOperationExecutor( - mockSubscriptionBackendService, - MockHelper.deviceService(), - AndroidMockHelper.applicationService(), - mockSubscriptionsModelStore, - MockHelper.configModelStore(), - mockBuildUserService, - getNewRecordState(), - ) - - val operations = - listOf( - UpdateSubscriptionOperation( + + test("update subscription fails with retry when there is a network condition") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.updateSubscription(any(), any(), any()) } throws BackendException(408) + + val mockSubscriptionsModelStore = mockk() + val mockBuildUserService = mockk() + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val operations = + listOf( + UpdateSubscriptionOperation( + appId, + remoteOneSignalId, + remoteSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken2", + SubscriptionStatus.SUBSCRIBED, + ), + ) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.FAIL_RETRY + coVerify(exactly = 1) { + mockSubscriptionBackendService.updateSubscription( appId, - remoteOneSignalId, remoteSubscriptionId, - SubscriptionType.PUSH, - true, - "pushToken2", - SubscriptionStatus.SUBSCRIBED, - ), - ) - - // When - val response = subscriptionOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.FAIL_NORETRY - coVerify(exactly = 1) { - mockSubscriptionBackendService.updateSubscription( - appId, - remoteSubscriptionId, - withArg { - it.type shouldBe SubscriptionObjectType.ANDROID_PUSH - it.enabled shouldBe true - it.token shouldBe "pushToken2" - it.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value - }, - ) + withArg { + it.type shouldBe SubscriptionObjectType.ANDROID_PUSH + it.enabled shouldBe true + it.token shouldBe "pushToken2" + it.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value + }, + ) + } } - } - - test("update subscription fails with retry when the backend returns MISSING, when isInMissingRetryWindow") { - // Given - val mockSubscriptionBackendService = mockk() - coEvery { mockSubscriptionBackendService.updateSubscription(any(), any(), any()) } throws BackendException(404) - - val mockSubscriptionsModelStore = mockk() - val mockBuildUserService = mockk() - val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 } - val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add(remoteOneSignalId) } - - val subscriptionOperationExecutor = - SubscriptionOperationExecutor( - mockSubscriptionBackendService, - MockHelper.deviceService(), - AndroidMockHelper.applicationService(), - mockSubscriptionsModelStore, - MockHelper.configModelStore(), - mockBuildUserService, - newRecordState, - ) - - val operations = - listOf( - UpdateSubscriptionOperation( + + test("update subscription fails without retry when there is a backend error") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.updateSubscription(any(), any(), any()) } throws BackendException(404) + + val mockSubscriptionsModelStore = mockk() + val mockBuildUserService = mockk() + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val operations = + listOf( + UpdateSubscriptionOperation( + appId, + remoteOneSignalId, + remoteSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken2", + SubscriptionStatus.SUBSCRIBED, + ), + ) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.FAIL_NORETRY + coVerify(exactly = 1) { + mockSubscriptionBackendService.updateSubscription( appId, - remoteOneSignalId, remoteSubscriptionId, - SubscriptionType.PUSH, - true, - "pushToken2", - SubscriptionStatus.SUBSCRIBED, - ), - ) - - // When - val response = subscriptionOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.FAIL_RETRY - } - - test("delete subscription successfully deletes subscription") { - // Given - val mockSubscriptionBackendService = mockk() - coEvery { mockSubscriptionBackendService.deleteSubscription(any(), any()) } just runs - - val mockSubscriptionsModelStore = mockk() - every { mockSubscriptionsModelStore.remove(any(), any()) } just runs - - val mockBuildUserService = mockk() - - val subscriptionOperationExecutor = - SubscriptionOperationExecutor( - mockSubscriptionBackendService, - MockHelper.deviceService(), - AndroidMockHelper.applicationService(), - mockSubscriptionsModelStore, - MockHelper.configModelStore(), - mockBuildUserService, - getNewRecordState(), - ) - - val operations = - listOf( - DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId), - ) - - // When - val response = subscriptionOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.SUCCESS - coVerify(exactly = 1) { mockSubscriptionBackendService.deleteSubscription(appId, remoteSubscriptionId) } - verify(exactly = 1) { mockSubscriptionsModelStore.remove(remoteSubscriptionId, any()) } - } - - test("delete subscription fails with retry when there is a network condition") { - // Given - val mockSubscriptionBackendService = mockk() - coEvery { mockSubscriptionBackendService.deleteSubscription(any(), any()) } throws BackendException(408) - - val mockSubscriptionsModelStore = mockk() - val mockBuildUserService = mockk() - - val subscriptionOperationExecutor = - SubscriptionOperationExecutor( - mockSubscriptionBackendService, - MockHelper.deviceService(), - AndroidMockHelper.applicationService(), - mockSubscriptionsModelStore, - MockHelper.configModelStore(), - mockBuildUserService, - getNewRecordState(), - ) - - val operations = - listOf( - DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId), - ) - - // When - val response = subscriptionOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.FAIL_RETRY - coVerify(exactly = 1) { mockSubscriptionBackendService.deleteSubscription(appId, remoteSubscriptionId) } - } - - // If we get a 404 then the subscription has already been deleted, - // so we count it as successful - test("delete subscription is successful if there is a 404") { - // Given - val mockSubscriptionBackendService = mockk() - coEvery { mockSubscriptionBackendService.deleteSubscription(any(), any()) } throws BackendException(404) - - val mockSubscriptionsModelStore = mockk() - val mockBuildUserService = mockk() - - val subscriptionOperationExecutor = - SubscriptionOperationExecutor( - mockSubscriptionBackendService, - MockHelper.deviceService(), - AndroidMockHelper.applicationService(), - mockSubscriptionsModelStore, - MockHelper.configModelStore(), - mockBuildUserService, - getNewRecordState(), - ) - - val operations = - listOf( - DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId), - ) - - // When - val response = subscriptionOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.SUCCESS - coVerify(exactly = 1) { mockSubscriptionBackendService.deleteSubscription(appId, remoteSubscriptionId) } - } - - test("delete subscription fails with retry when the backend returns MISSING, when isInMissingRetryWindow") { - // Given - val mockSubscriptionBackendService = mockk() - coEvery { mockSubscriptionBackendService.deleteSubscription(any(), any()) } throws BackendException(404) - - val mockSubscriptionsModelStore = mockk() - val mockBuildUserService = mockk() - val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 } - val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add(remoteOneSignalId) } - - val subscriptionOperationExecutor = - SubscriptionOperationExecutor( - mockSubscriptionBackendService, - MockHelper.deviceService(), - AndroidMockHelper.applicationService(), - mockSubscriptionsModelStore, - MockHelper.configModelStore(), - mockBuildUserService, - newRecordState, - ) - - val operations = - listOf( - DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId), - ) - - // When - val response = subscriptionOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.FAIL_RETRY - } -}) + withArg { + it.type shouldBe SubscriptionObjectType.ANDROID_PUSH + it.enabled shouldBe true + it.token shouldBe "pushToken2" + it.notificationTypes shouldBe SubscriptionStatus.SUBSCRIBED.value + }, + ) + } + } + + test("update subscription fails with retry when the backend returns MISSING, when isInMissingRetryWindow") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.updateSubscription(any(), any(), any()) } throws BackendException(404) + + val mockSubscriptionsModelStore = mockk() + val mockBuildUserService = mockk() + val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 } + val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add(remoteOneSignalId) } + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + newRecordState, + mockConsistencyManager, + ) + + val operations = + listOf( + UpdateSubscriptionOperation( + appId, + remoteOneSignalId, + remoteSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken2", + SubscriptionStatus.SUBSCRIBED, + ), + ) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.FAIL_RETRY + } + + test("delete subscription successfully deletes subscription") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.deleteSubscription(any(), any()) } just runs + + val mockSubscriptionsModelStore = mockk() + every { mockSubscriptionsModelStore.remove(any(), any()) } just runs + + val mockBuildUserService = mockk() + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val operations = + listOf( + DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId), + ) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { mockSubscriptionBackendService.deleteSubscription(appId, remoteSubscriptionId) } + verify(exactly = 1) { mockSubscriptionsModelStore.remove(remoteSubscriptionId, any()) } + } + + test("delete subscription fails with retry when there is a network condition") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.deleteSubscription(any(), any()) } throws BackendException(408) + + val mockSubscriptionsModelStore = mockk() + val mockBuildUserService = mockk() + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val operations = + listOf( + DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId), + ) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.FAIL_RETRY + coVerify(exactly = 1) { mockSubscriptionBackendService.deleteSubscription(appId, remoteSubscriptionId) } + } + + // If we get a 404 then the subscription has already been deleted, + // so we count it as successful + test("delete subscription is successful if there is a 404") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.deleteSubscription(any(), any()) } throws BackendException(404) + + val mockSubscriptionsModelStore = mockk() + val mockBuildUserService = mockk() + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val operations = + listOf( + DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId), + ) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { mockSubscriptionBackendService.deleteSubscription(appId, remoteSubscriptionId) } + } + + test("delete subscription fails with retry when the backend returns MISSING, when isInMissingRetryWindow") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.deleteSubscription(any(), any()) } throws BackendException(404) + + val mockSubscriptionsModelStore = mockk() + val mockBuildUserService = mockk() + val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 } + val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add(remoteOneSignalId) } + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + newRecordState, + mockConsistencyManager, + ) + + val operations = + listOf( + DeleteSubscriptionOperation(appId, remoteOneSignalId, remoteSubscriptionId), + ) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.FAIL_RETRY + } + + test("setRywToken is called after successful subscription update") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { + mockSubscriptionBackendService.updateSubscription(any(), any(), any()) + } returns rywToken + + val mockSubscriptionsModelStore = mockk() + val subscriptionModel1 = + SubscriptionModel().apply { + id = remoteSubscriptionId + address = "pushToken1" + } + every { mockSubscriptionsModelStore.get(remoteSubscriptionId) } returns subscriptionModel1 + + val mockBuildUserService = mockk() + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val operations = + listOf( + UpdateSubscriptionOperation( + appId, + remoteOneSignalId, + remoteSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken2", + SubscriptionStatus.SUBSCRIBED, + ), + ) + + subscriptionOperationExecutor.execute(operations) + + // Then + coVerify(exactly = 1) { + mockConsistencyManager.setRywToken(remoteOneSignalId, IamFetchRywTokenKey.SUBSCRIPTION, rywToken) + } + } + }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt index 529bb22a6..4c511e375 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt @@ -1,5 +1,7 @@ package com.onesignal.user.internal.operations +import com.onesignal.common.consistency.enums.IamFetchRywTokenKey +import com.onesignal.common.consistency.models.IConsistencyManager import com.onesignal.common.exceptions.BackendException import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.Operation @@ -13,6 +15,7 @@ import com.onesignal.user.internal.properties.PropertiesModel import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe +import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -21,322 +24,373 @@ import io.mockk.mockk import io.mockk.runs import java.math.BigDecimal -class UpdateUserOperationExecutorTests : FunSpec({ - val appId = "appId" - val localOneSignalId = "local-onesignalId" - val remoteOneSignalId = "remote-onesignalId" - - test("update user single operation is successful") { - // Given - val mockUserBackendService = mockk() - coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } just runs - - // Given - val mockIdentityModelStore = MockHelper.identityModelStore() - val mockPropertiesModelStore = MockHelper.propertiesModelStore() - val mockBuildUserService = mockk() - - val loginUserOperationExecutor = - UpdateUserOperationExecutor( - mockUserBackendService, - mockIdentityModelStore, - mockPropertiesModelStore, - mockBuildUserService, - getNewRecordState(), - ) - val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) - - // When - val response = loginUserOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.SUCCESS - coVerify(exactly = 1) { - mockUserBackendService.updateUser( - appId, - IdentityConstants.ONESIGNAL_ID, - remoteOneSignalId, - withArg { - it.tags shouldBe mapOf("tagKey1" to "tagValue1") - }, - any(), - any(), - ) +class UpdateUserOperationExecutorTests : + FunSpec({ + val appId = "appId" + val localOneSignalId = "local-onesignalId" + val remoteOneSignalId = "remote-onesignalId" + val rywToken = "1" + val mockConsistencyManager = mockk() + + beforeTest { + clearMocks(mockConsistencyManager) + coEvery { mockConsistencyManager.setRywToken(any(), any(), any()) } just runs } - } - - test("update user multiple property operations are successful") { - // Given - val mockUserBackendService = mockk() - coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } just runs - - // Given - val mockIdentityModelStore = MockHelper.identityModelStore() - val mockPropertiesModelStore = MockHelper.propertiesModelStore() - val mockBuildUserService = mockk() - - val loginUserOperationExecutor = - UpdateUserOperationExecutor( - mockUserBackendService, - mockIdentityModelStore, - mockPropertiesModelStore, - mockBuildUserService, - getNewRecordState(), - ) - val operations = - listOf( - SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1-1"), - SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1-2"), - SetTagOperation(appId, remoteOneSignalId, "tagKey2", "tagValue2"), - SetTagOperation(appId, remoteOneSignalId, "tagKey3", "tagValue3"), - DeleteTagOperation(appId, remoteOneSignalId, "tagKey3"), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::language.name, "lang1"), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::language.name, "lang2"), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::timezone.name, "timezone"), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::country.name, "country"), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationLatitude.name, 123.45), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationLongitude.name, 678.90), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationType.name, 1), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationAccuracy.name, 0.15), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationBackground.name, true), - SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationTimestamp.name, 1111L), - ) - - // When - val response = loginUserOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.SUCCESS - coVerify(exactly = 1) { - mockUserBackendService.updateUser( - appId, - IdentityConstants.ONESIGNAL_ID, - remoteOneSignalId, - withArg { - it.tags shouldBe mapOf("tagKey1" to "tagValue1-2", "tagKey2" to "tagValue2", "tagKey3" to null) - it.country shouldBe "country" - it.language shouldBe "lang2" - it.timezoneId shouldBe "timezone" - it.latitude shouldBe 123.45 - it.longitude shouldBe 678.90 - }, - any(), - any(), - ) + + test("update user single operation is successful") { + // Given + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } returns rywToken + + // Given + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockBuildUserService = mockk() + + val loginUserOperationExecutor = + UpdateUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) + + // When + val response = loginUserOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.updateUser( + appId, + IdentityConstants.ONESIGNAL_ID, + remoteOneSignalId, + withArg { + it.tags shouldBe mapOf("tagKey1" to "tagValue1") + }, + any(), + any(), + ) + } } - } - - test("update user single property delta operations is successful") { - // Given - val mockUserBackendService = mockk() - coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } just runs - - // Given - val mockIdentityModelStore = MockHelper.identityModelStore() - val mockPropertiesModelStore = MockHelper.propertiesModelStore() - val mockBuildUserService = mockk() - - val loginUserOperationExecutor = - UpdateUserOperationExecutor( - mockUserBackendService, - mockIdentityModelStore, - mockPropertiesModelStore, - mockBuildUserService, - getNewRecordState(), - ) - val operations = - listOf( - TrackSessionEndOperation(appId, remoteOneSignalId, 1111), - ) - - // When - val response = loginUserOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.SUCCESS - coVerify(exactly = 1) { - mockUserBackendService.updateUser( - appId, - IdentityConstants.ONESIGNAL_ID, - remoteOneSignalId, - withArg { - it.tags shouldBe null - }, - any(), - withArg { - it.sessionTime shouldBe 1111 - }, - ) + + test("update user multiple property operations are successful") { + // Given + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } returns rywToken + + // Given + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockBuildUserService = mockk() + + val loginUserOperationExecutor = + UpdateUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + val operations = + listOf( + SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1-1"), + SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1-2"), + SetTagOperation(appId, remoteOneSignalId, "tagKey2", "tagValue2"), + SetTagOperation(appId, remoteOneSignalId, "tagKey3", "tagValue3"), + DeleteTagOperation(appId, remoteOneSignalId, "tagKey3"), + SetPropertyOperation(appId, localOneSignalId, PropertiesModel::language.name, "lang1"), + SetPropertyOperation(appId, localOneSignalId, PropertiesModel::language.name, "lang2"), + SetPropertyOperation(appId, localOneSignalId, PropertiesModel::timezone.name, "timezone"), + SetPropertyOperation(appId, localOneSignalId, PropertiesModel::country.name, "country"), + SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationLatitude.name, 123.45), + SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationLongitude.name, 678.90), + SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationType.name, 1), + SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationAccuracy.name, 0.15), + SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationBackground.name, true), + SetPropertyOperation(appId, localOneSignalId, PropertiesModel::locationTimestamp.name, 1111L), + ) + + // When + val response = loginUserOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.updateUser( + appId, + IdentityConstants.ONESIGNAL_ID, + remoteOneSignalId, + withArg { + it.tags shouldBe mapOf("tagKey1" to "tagValue1-2", "tagKey2" to "tagValue2", "tagKey3" to null) + it.country shouldBe "country" + it.language shouldBe "lang2" + it.timezoneId shouldBe "timezone" + it.latitude shouldBe 123.45 + it.longitude shouldBe 678.90 + }, + any(), + any(), + ) + } } - } - - test("update user multiple property delta operations are successful") { - // Given - val mockUserBackendService = mockk() - coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } just runs - - // Given - val mockIdentityModelStore = MockHelper.identityModelStore() - val mockPropertiesModelStore = MockHelper.propertiesModelStore() - val mockBuildUserService = mockk() - - val loginUserOperationExecutor = - UpdateUserOperationExecutor( - mockUserBackendService, - mockIdentityModelStore, - mockPropertiesModelStore, - mockBuildUserService, - getNewRecordState(), - ) - val operations = - listOf( - TrackSessionEndOperation(appId, remoteOneSignalId, 1111), - TrackPurchaseOperation( + + test("update user single property delta operations is successful") { + // Given + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } returns rywToken + + // Given + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockBuildUserService = mockk() + + val loginUserOperationExecutor = + UpdateUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + val operations = + listOf( + TrackSessionEndOperation(appId, remoteOneSignalId, 1111), + ) + + // When + val response = loginUserOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.updateUser( appId, + IdentityConstants.ONESIGNAL_ID, remoteOneSignalId, - false, - BigDecimal(2222), - listOf( - PurchaseInfo("sku1", "iso1", BigDecimal(1000)), - PurchaseInfo("sku2", "iso2", BigDecimal(1222)), + withArg { + it.tags shouldBe null + }, + any(), + withArg { + it.sessionTime shouldBe 1111 + }, + ) + } + } + + test("update user multiple property delta operations are successful") { + // Given + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } returns rywToken + + // Given + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockBuildUserService = mockk() + + val loginUserOperationExecutor = + UpdateUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + val operations = + listOf( + TrackSessionEndOperation(appId, remoteOneSignalId, 1111), + TrackPurchaseOperation( + appId, + remoteOneSignalId, + false, + BigDecimal(2222), + listOf( + PurchaseInfo("sku1", "iso1", BigDecimal(1000)), + PurchaseInfo("sku2", "iso2", BigDecimal(1222)), + ), ), - ), - TrackSessionEndOperation(appId, remoteOneSignalId, 3333), - ) - - // When - val response = loginUserOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.SUCCESS - coVerify(exactly = 1) { - mockUserBackendService.updateUser( - appId, - IdentityConstants.ONESIGNAL_ID, - remoteOneSignalId, - withArg { - it.tags shouldBe null - }, - any(), - withArg { - it.sessionTime shouldBe (1111 + 3333) - it.amountSpent shouldBe BigDecimal(2222) - it.purchases shouldNotBe null - it.purchases!!.count() shouldBe 2 - it.purchases!![0].sku shouldBe "sku1" - it.purchases!![0].iso shouldBe "iso1" - it.purchases!![0].amount shouldBe BigDecimal(1000) - it.purchases!![1].sku shouldBe "sku2" - it.purchases!![1].iso shouldBe "iso2" - it.purchases!![1].amount shouldBe BigDecimal(1222) - }, - ) + TrackSessionEndOperation(appId, remoteOneSignalId, 3333), + ) + + // When + val response = loginUserOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.updateUser( + appId, + IdentityConstants.ONESIGNAL_ID, + remoteOneSignalId, + withArg { + it.tags shouldBe null + }, + any(), + withArg { + it.sessionTime shouldBe (1111 + 3333) + it.amountSpent shouldBe BigDecimal(2222) + it.purchases shouldNotBe null + it.purchases!!.count() shouldBe 2 + it.purchases!![0].sku shouldBe "sku1" + it.purchases!![0].iso shouldBe "iso1" + it.purchases!![0].amount shouldBe BigDecimal(1000) + it.purchases!![1].sku shouldBe "sku2" + it.purchases!![1].iso shouldBe "iso2" + it.purchases!![1].amount shouldBe BigDecimal(1222) + }, + ) + } + } + + test("update user with both property and property delta operations are successful") { + // Given + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } returns rywToken + + // Given + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockBuildUserService = mockk() + + val loginUserOperationExecutor = + UpdateUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + val operations = + listOf( + TrackSessionEndOperation(appId, remoteOneSignalId, 1111), + SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1"), + TrackSessionEndOperation(appId, remoteOneSignalId, 3333), + ) + + // When + val response = loginUserOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.updateUser( + appId, + IdentityConstants.ONESIGNAL_ID, + remoteOneSignalId, + withArg { + it.tags shouldBe mapOf("tagKey1" to "tagValue1") + }, + any(), + withArg { + it.sessionTime shouldBe (1111 + 3333) + }, + ) + } } - } - - test("update user with both property and property delta operations are successful") { - // Given - val mockUserBackendService = mockk() - coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } just runs - - // Given - val mockIdentityModelStore = MockHelper.identityModelStore() - val mockPropertiesModelStore = MockHelper.propertiesModelStore() - val mockBuildUserService = mockk() - - val loginUserOperationExecutor = - UpdateUserOperationExecutor( - mockUserBackendService, - mockIdentityModelStore, - mockPropertiesModelStore, - mockBuildUserService, - getNewRecordState(), - ) - val operations = - listOf( - TrackSessionEndOperation(appId, remoteOneSignalId, 1111), - SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1"), - TrackSessionEndOperation(appId, remoteOneSignalId, 3333), - ) - - // When - val response = loginUserOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.SUCCESS - coVerify(exactly = 1) { - mockUserBackendService.updateUser( - appId, - IdentityConstants.ONESIGNAL_ID, - remoteOneSignalId, - withArg { - it.tags shouldBe mapOf("tagKey1" to "tagValue1") - }, - any(), - withArg { - it.sessionTime shouldBe (1111 + 3333) - }, - ) + + test("update user single operation fails with MISSING") { + // Given + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } throws BackendException(404) + + // Given + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockBuildUserService = mockk() + every { mockBuildUserService.getRebuildOperationsIfCurrentUser(any(), any()) } returns null + + val loginUserOperationExecutor = + UpdateUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) + + // When + val response = loginUserOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.FAIL_NORETRY + } + + test("update user single operation fails with MISSING, but isInMissingRetryWindow") { + // Given + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } throws + BackendException(404, retryAfterSeconds = 10) + + // Given + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockBuildUserService = mockk() + every { mockBuildUserService.getRebuildOperationsIfCurrentUser(any(), any()) } returns null + + val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 } + val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add(remoteOneSignalId) } + + val loginUserOperationExecutor = + UpdateUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + mockBuildUserService, + newRecordState, + mockConsistencyManager, + ) + val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) + + // When + val response = loginUserOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.FAIL_RETRY + response.retryAfterSeconds shouldBe 10 + } + + test("setRywToken is called after successful user update of session count") { + // Given + val mockUserBackendService = mockk() + coEvery { + mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) + } returns rywToken + + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockBuildUserService = mockk() + + val loginUserOperationExecutor = + UpdateUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val operations = + listOf( + TrackSessionStartOperation(appId, onesignalId = remoteOneSignalId), + ) + + // When + loginUserOperationExecutor.execute(operations) + + // Then + coVerify(exactly = 1) { + mockConsistencyManager.setRywToken(remoteOneSignalId, IamFetchRywTokenKey.USER, rywToken) + } } - } - - test("update user single operation fails with MISSING") { - // Given - val mockUserBackendService = mockk() - coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } throws BackendException(404) - - // Given - val mockIdentityModelStore = MockHelper.identityModelStore() - val mockPropertiesModelStore = MockHelper.propertiesModelStore() - val mockBuildUserService = mockk() - every { mockBuildUserService.getRebuildOperationsIfCurrentUser(any(), any()) } returns null - - val loginUserOperationExecutor = - UpdateUserOperationExecutor( - mockUserBackendService, - mockIdentityModelStore, - mockPropertiesModelStore, - mockBuildUserService, - getNewRecordState(), - ) - val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) - - // When - val response = loginUserOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.FAIL_NORETRY - } - - test("update user single operation fails with MISSING, but isInMissingRetryWindow") { - // Given - val mockUserBackendService = mockk() - coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } throws BackendException(404, retryAfterSeconds = 10) - - // Given - val mockIdentityModelStore = MockHelper.identityModelStore() - val mockPropertiesModelStore = MockHelper.propertiesModelStore() - val mockBuildUserService = mockk() - every { mockBuildUserService.getRebuildOperationsIfCurrentUser(any(), any()) } returns null - - val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 } - val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add(remoteOneSignalId) } - - val loginUserOperationExecutor = - UpdateUserOperationExecutor( - mockUserBackendService, - mockIdentityModelStore, - mockPropertiesModelStore, - mockBuildUserService, - newRecordState, - ) - val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) - - // When - val response = loginUserOperationExecutor.execute(operations) - - // Then - response.result shouldBe ExecutionResult.FAIL_RETRY - response.retryAfterSeconds shouldBe 10 - } -}) + }) From 2083bd094b30a4856fbb3e036ef2a81899a18f43 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomez Palacio Date: Tue, 24 Sep 2024 15:53:16 -0500 Subject: [PATCH 10/12] Update HttpClient to put new optional headers into request Motivation: offset, secondsSinceAppOpen, & retryCount will be sent as headers --- .../core/internal/http/impl/HttpClient.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt index 30b7e93ce..f74a76bc2 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt @@ -184,6 +184,18 @@ internal class HttpClient( } } + if (headers?.rywToken != null) { + con.setRequestProperty("OneSignal-RYW-Token", headers.rywToken.toString()) + } + + if (headers?.retryCount != null) { + con.setRequestProperty("Onesignal-Retry-Count", headers.retryCount.toString()) + } + + if (headers?.sessionDuration != null) { + con.setRequestProperty("OneSignal-Session-Duration", headers.sessionDuration.toString()) + } + // Network request is made from getResponseCode() httpResponse = con.responseCode @@ -299,9 +311,9 @@ internal class HttpClient( * Reads the HTTP Retry-Limit from the response. */ private fun retryLimitFromResponse(con: HttpURLConnection): Int? { - val retryLimitStr = con.getHeaderField("Retry-Limit") + val retryLimitStr = con.getHeaderField("OneSignal-Retry-Limit") return if (retryLimitStr != null) { - Logging.debug("HttpClient: Response Retry-After: $retryLimitStr") + Logging.debug("HttpClient: Response OneSignal-Retry-Limit: $retryLimitStr") retryLimitStr.toIntOrNull() } else { null From 55a3e25c03d945ba1aeb18a800475dfafc924a30 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomez Palacio Date: Fri, 27 Sep 2024 13:14:46 -0500 Subject: [PATCH 11/12] fixup! Update IAM manager & backend service with retry logic, optional headers --- .../backend/impl/InAppBackendService.kt | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt index 414dd8489..f541f9b32 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/backend/impl/InAppBackendService.kt @@ -204,11 +204,11 @@ internal class InAppBackendService( rywToken: String?, sessionDurationProvider: () -> Long, ): List? { - var attempts = 1 - var retryLimit: Int? = null // Retry limit will be determined dynamically + var attempts = 0 + var retryLimit: Int = 0 // retry limit is remote defined & set dynamically below - while (retryLimit == null || attempts <= retryLimit + 1) { - val retryCount = if (attempts > 1) attempts - 1 else null + do { + val retryCount = if (attempts > 0) attempts else null val values = OptionalHeaders( rywToken = rywToken, @@ -221,13 +221,12 @@ internal class InAppBackendService( val jsonResponse = response.payload?.let { JSONObject(it) } return jsonResponse?.let { hydrateInAppMessages(it) } } else if (response.statusCode == 425 || response.statusCode == 429) { - // Dynamically update the retry limit from response + // update the retry limit from response retryLimit = response.retryLimit ?: retryLimit - // Apply the Retry-After delay if present, otherwise proceed without delay - val retryAfter = response.retryAfterSeconds - if (retryAfter != null) { - delay(retryAfter * 1_000L) + // apply the Retry-After delay if present + response.retryAfterSeconds?.let { + delay(it * 1_000L) } } else if (response.statusCode in 500..599) { return null @@ -236,7 +235,7 @@ internal class InAppBackendService( } attempts++ - } + } while (attempts <= retryLimit) // Final attempt without the RYW token if retries fail return fetchInAppMessagesWithoutRywToken(baseUrl, sessionDurationProvider) From 5873b167a7cd0c124e14b1a144d85b6d1725b09d Mon Sep 17 00:00:00 2001 From: Rodrigo Gomez Palacio Date: Fri, 27 Sep 2024 14:24:18 -0500 Subject: [PATCH 12/12] fixup! `IamFetchReadyCondition` Implementation --- .../common/consistency/IamFetchReadyCondition.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/IamFetchReadyCondition.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/IamFetchReadyCondition.kt index 329832aa5..d43e3c989 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/IamFetchReadyCondition.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/consistency/IamFetchReadyCondition.kt @@ -23,17 +23,14 @@ class IamFetchReadyCondition( override fun isMet(indexedTokens: Map>): Boolean { val tokenMap = indexedTokens[key] ?: return false val userUpdateTokenSet = tokenMap[IamFetchRywTokenKey.USER] != null - val subscriptionUpdateTokenSet = tokenMap[IamFetchRywTokenKey.SUBSCRIPTION] != null /** * We always update the session count so we know we will have a userUpdateToken. We don't * necessarily make a subscriptionUpdate call on every session. The following logic - * is written in a way so that if somehow the subscriptionUpdateToken is set *before* the - * userUpdateToken, we will wait for the userUpdateToken to also be set. This is because - * we know that a userUpdate call was made and both user & subscription properties are - * considered during segment calculations. + * doesn't consider tokenMap[IamFetchRywTokenKey.SUBSCRIPTION] for this reason. This doesn't + * mean it isn't considered if present when doing the token comparison. */ - return (userUpdateTokenSet && subscriptionUpdateTokenSet) || userUpdateTokenSet + return userUpdateTokenSet } override fun getNewestToken(indexedTokens: Map>): String? {