From 1d612cfd1385ed6f8722d0e83c4fae20a9cd975a Mon Sep 17 00:00:00 2001 From: Brian Smith Date: Fri, 14 Oct 2022 08:34:44 -0400 Subject: [PATCH] [User Model] Unit Test Initial * Initial setup of unit tests using kotest, mockk and robolectric * Created unit tests for OSDatabase, InfluenceManager, OutcomeEventsController, and SessionService. * Updated OSDatabase to allow specific db version testing * Updated HttpClient to allow for testing without making request * Updated HttpClient to properly handle timeouts --- OneSignalSDK/build.gradle | 3 +- OneSignalSDK/onesignal/build.gradle | 38 +- .../com/onesignal/core/internal/CoreModule.kt | 3 + .../core/internal/database/impl/OSDatabase.kt | 25 +- .../core/internal/http/impl/HttpClient.kt | 35 +- .../http/impl/HttpConnectionFactory.kt | 16 + .../http/impl/IHttpConnectionFactory.kt | 9 + .../core/internal/models/ModelStoreDefs.kt | 12 +- .../extensions/ContainedRobolectricRunner.kt | 65 +++ .../tests/extensions/RobolectricExtension.kt | 87 ++++ .../internal/database/InitialOSDatabase.kt | 45 ++ .../internal/database/OSDatabaseTests.kt | 63 +++ .../tests/internal/http/HttpClientTests.kt | 147 +++++++ .../http/MockHttpConnectionFactory.kt | 86 ++++ .../influence/InfluenceManagerTests.kt | 250 +++++++++++ .../OutcomeEventsBackendServiceTests.kt | 177 ++++++++ .../outcomes/OutcomeEventsControllerTests.kt | 413 ++++++++++++++++++ .../internal/session/SessionServiceTests.kt | 116 +++++ .../onesignal/core/tests/mocks/MockHelper.kt | 72 +++ .../tests/mocks/MockPreferencesService.kt | 23 + 20 files changed, 1645 insertions(+), 40 deletions(-) create mode 100644 OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/http/impl/HttpConnectionFactory.kt create mode 100644 OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/http/impl/IHttpConnectionFactory.kt create mode 100644 OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/extensions/ContainedRobolectricRunner.kt create mode 100644 OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/extensions/RobolectricExtension.kt create mode 100644 OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/database/InitialOSDatabase.kt create mode 100644 OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/database/OSDatabaseTests.kt create mode 100644 OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/http/HttpClientTests.kt create mode 100644 OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/http/MockHttpConnectionFactory.kt create mode 100644 OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/influence/InfluenceManagerTests.kt create mode 100644 OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/outcomes/OutcomeEventsBackendServiceTests.kt create mode 100644 OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/outcomes/OutcomeEventsControllerTests.kt create mode 100644 OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/session/SessionServiceTests.kt create mode 100644 OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/mocks/MockHelper.kt create mode 100644 OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/mocks/MockPreferencesService.kt diff --git a/OneSignalSDK/build.gradle b/OneSignalSDK/build.gradle index 4c65e735cd..9b265b0c51 100644 --- a/OneSignalSDK/build.gradle +++ b/OneSignalSDK/build.gradle @@ -12,7 +12,8 @@ buildscript { huaweiAgconnectVersion = '1.6.2.300' huaweiHMSPushVersion = '6.3.0.304' huaweiHMSLocationVersion = '4.0.0.300' - kotlinVersion = '1.6.20' + kotlinVersion = '1.6.21' + kotestVersion = '5.5.0' ktlintVersion = '11.0.0' detektVersion = '1.21.0' onesignalGradlePluginVersion = '[0.14.0, 0.99.99]' diff --git a/OneSignalSDK/onesignal/build.gradle b/OneSignalSDK/onesignal/build.gradle index bf6d3bc4e3..ce9c395a64 100644 --- a/OneSignalSDK/onesignal/build.gradle +++ b/OneSignalSDK/onesignal/build.gradle @@ -6,6 +6,7 @@ ext { apply plugin: 'com.android.library' apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' apply plugin: 'org.jlleitschuh.gradle.ktlint' apply plugin: 'io.gitlab.arturbosch.detekt' @@ -14,6 +15,8 @@ android { defaultConfig { minSdkVersion buildVersions.minSdkVersion consumerProguardFiles 'consumer-proguard-rules.pro' + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + testOptions.unitTests.includeAndroidResources = true } buildTypes { @@ -28,7 +31,15 @@ android { minifyEnabled false } } - + testOptions { + unitTests.all { + maxParallelForks 1 + maxHeapSize '2048m' + } + unitTests { + includeAndroidResources = true + } + } // Forced downgrade to Java 8 so SDK is backwards compatible in consuming projects compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -36,6 +47,14 @@ android { } } +tasks.withType(Test) { + testLogging { + exceptionFormat "full" + events "started", "skipped", "passed", "failed" + showStandardStreams false // Enable to have logging print + } +} + // api || implementation = compile and runtime // KEEP: version ranges, these get used in the released POM file for maven central @@ -47,8 +66,8 @@ dependencies { compileOnly('com.amazon.device:amazon-appstore-sdk:[3.0.1, 3.0.99]') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.2" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.2" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" implementation 'androidx.work:work-runtime-ktx:2.7.1' // play-services-location:16.0.0 is the last version before going to AndroidX @@ -114,6 +133,19 @@ dependencies { prefer '2.7.1' } } + + testImplementation("junit:junit:4.13.2") + testImplementation("io.kotest:kotest-runner-junit4:$kotestVersion") + testImplementation("io.kotest:kotest-runner-junit4-jvm:$kotestVersion") + testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") + testImplementation("io.kotest:kotest-property:$kotestVersion") + testImplementation("org.robolectric:robolectric:4.8.1") + testImplementation("androidx.test:core-ktx:1.4.0") + testImplementation("androidx.test:core:1.4.0") + testImplementation("io.mockk:mockk:1.13.2") + testImplementation("org.json:json:20180813") + testImplementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" } apply from: 'maven-push.gradle' + diff --git a/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/CoreModule.kt b/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/CoreModule.kt index 647715a6ce..9f9de2f839 100644 --- a/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/CoreModule.kt +++ b/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/CoreModule.kt @@ -19,6 +19,8 @@ import com.onesignal.core.internal.device.IDeviceService import com.onesignal.core.internal.device.impl.DeviceService import com.onesignal.core.internal.http.IHttpClient import com.onesignal.core.internal.http.impl.HttpClient +import com.onesignal.core.internal.http.impl.HttpConnectionFactory +import com.onesignal.core.internal.http.impl.IHttpConnectionFactory import com.onesignal.core.internal.influence.IInfluenceManager import com.onesignal.core.internal.influence.impl.InfluenceManager import com.onesignal.core.internal.language.ILanguageContext @@ -74,6 +76,7 @@ internal object CoreModule { builder.register() .provides() .provides() + builder.register().provides() builder.register().provides() builder.register().provides() builder.register().provides() diff --git a/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/database/impl/OSDatabase.kt b/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/database/impl/OSDatabase.kt index 4957ab03f2..9bbd51fb3b 100644 --- a/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/database/impl/OSDatabase.kt +++ b/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/database/impl/OSDatabase.kt @@ -23,10 +23,11 @@ import com.onesignal.notification.internal.common.NotificationConstants import java.lang.IllegalStateException import java.util.ArrayList -internal class OSDatabase( +internal open class OSDatabase( private val _outcomeTableProvider: OutcomeTableProvider, - context: Context? -) : SQLiteOpenHelper(context, DATABASE_NAME, null, dbVersion), IDatabase { + context: Context?, + version: Int = dbVersion +) : SQLiteOpenHelper(context, DATABASE_NAME, null, version), IDatabase { /** * Should be used in the event that we don't want to retry getting the a [SQLiteDatabase] instance @@ -260,7 +261,7 @@ internal class OSDatabase( Logging.debug("OneSignal Database onUpgrade from: $oldVersion to: $newVersion") try { - internalOnUpgrade(db, oldVersion) + internalOnUpgrade(db, oldVersion, newVersion) } catch (e: SQLiteException) { // This could throw if rolling back then forward again. // However this shouldn't happen as we clearing the database on onDowngrade @@ -269,16 +270,16 @@ internal class OSDatabase( } @Synchronized - private fun internalOnUpgrade(db: SQLiteDatabase, oldVersion: Int) { - if (oldVersion < 2) upgradeToV2(db) - if (oldVersion < 3) upgradeToV3(db) - if (oldVersion < 4) upgradeToV4(db) - if (oldVersion < 5) upgradeToV5(db) + private fun internalOnUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (oldVersion < 2 && newVersion >= 2) upgradeToV2(db) + if (oldVersion < 3 && newVersion >= 3) upgradeToV3(db) + if (oldVersion < 4 && newVersion >= 4) upgradeToV4(db) + if (oldVersion < 5 && newVersion >= 5) upgradeToV5(db) // Specifically running only when going from 5 to 6+ is intentional - if (oldVersion == 5) upgradeFromV5ToV6(db) - if (oldVersion < 7) upgradeToV7(db) - if (oldVersion < 8) upgradeToV8(db) + if (oldVersion == 5 && newVersion >= 6) upgradeFromV5ToV6(db) + if (oldVersion < 7 && newVersion >= 7) upgradeToV7(db) + if (oldVersion < 8 && newVersion >= 8) upgradeToV8(db) } // Add collapse_id field and index diff --git a/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt b/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt index 027a82a77c..f2886d7f39 100644 --- a/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt +++ b/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt @@ -12,19 +12,19 @@ import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import org.json.JSONObject -import java.io.IOException import java.net.ConnectException import java.net.HttpURLConnection -import java.net.URL import java.net.UnknownHostException import java.util.Scanner import javax.net.ssl.HttpsURLConnection internal class HttpClient( + private val _connectionFactory: IHttpConnectionFactory, private val _prefs: IPreferencesService, private val _configModelStore: ConfigModelStore ) : IHttpClient { @@ -80,7 +80,9 @@ internal class HttpClient( timeout: Int, cacheKey: String? ): HttpResponse { - return withContext(Dispatchers.IO) { + var retVal: HttpResponse? = null + + val job = GlobalScope.launch(Dispatchers.IO) { var httpResponse = -1 var con: HttpURLConnection? = null @@ -89,8 +91,8 @@ internal class HttpClient( } try { - Logging.debug("HttpClient: Making request to: $BASE_URL$url") - con = newHttpURLConnection(url) + Logging.debug("HttpClient: Making request to: $url") + con = _connectionFactory.newHttpURLConnection(url) // https://github.com/OneSignal/OneSignal-Android-SDK/issues/1465 // Android 4.4 and older devices fail to register to onesignal.com to due it's TLS1.2+ requirement @@ -139,7 +141,7 @@ internal class HttpClient( // Network request is made from getResponseCode() httpResponse = con.responseCode - Logging.verbose("HttpClient: After con.getResponseCode to: " + BASE_URL + url) + Logging.verbose("HttpClient: After con.getResponseCode to: $url") when (httpResponse) { HttpURLConnection.HTTP_NOT_MODIFIED -> { @@ -147,10 +149,10 @@ internal class HttpClient( Logging.debug("HttpClient: " + (method ?: "GET") + " - Using Cached response due to 304: " + cachedResponse) // TODO: SHOULD RETURN OK INSTEAD OF NOT_MODIFIED TO MAKE TRANSPARENT? - return@withContext HttpResponse(httpResponse, cachedResponse) + retVal = HttpResponse(httpResponse, cachedResponse) } HttpURLConnection.HTTP_ACCEPTED, HttpURLConnection.HTTP_OK -> { - Logging.debug("HttpClient: Successfully finished request to: $BASE_URL$url") + Logging.debug("HttpClient: Successfully finished request to: $url") val inputStream = con.inputStream val scanner = Scanner(inputStream, "UTF-8") @@ -168,10 +170,10 @@ internal class HttpClient( } } - return@withContext HttpResponse(httpResponse, json) + retVal = HttpResponse(httpResponse, json) } else -> { - Logging.debug("HttpClient: Failed request to: $BASE_URL$url") + Logging.debug("HttpClient: Failed request to: $url") var inputStream = con.errorStream if (inputStream == null) { @@ -189,7 +191,7 @@ internal class HttpClient( Logging.warn("HttpClient: $method HTTP Code: $httpResponse No response body!") } - return@withContext HttpResponse(httpResponse, jsonResponse) + retVal = HttpResponse(httpResponse, jsonResponse) } } } catch (t: Throwable) { @@ -199,16 +201,14 @@ internal class HttpClient( Logging.warn("HttpClient: $method Error thrown from network stack. ", t) } - return@withContext HttpResponse(httpResponse, null, t) + retVal = HttpResponse(httpResponse, null, t) } finally { con?.disconnect() } } - } - @Throws(IOException::class) - private fun newHttpURLConnection(url: String): HttpURLConnection { - return URL(BASE_URL + url).openConnection() as HttpURLConnection + job.join() + return retVal!! } private fun getThreadTimeout(timeout: Int): Int { @@ -218,7 +218,6 @@ internal class HttpClient( companion object { private const val OS_API_VERSION = "1" private const val OS_ACCEPT_HEADER = "application/vnd.onesignal.v$OS_API_VERSION+json" - private const val BASE_URL = "https://api.onesignal.com/" private const val GET_TIMEOUT = 60000 private const val TIMEOUT = 120000 private const val THREAD_ID = 10000 diff --git a/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/http/impl/HttpConnectionFactory.kt b/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/http/impl/HttpConnectionFactory.kt new file mode 100644 index 0000000000..b5bc24a462 --- /dev/null +++ b/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/http/impl/HttpConnectionFactory.kt @@ -0,0 +1,16 @@ +package com.onesignal.core.internal.http.impl + +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +internal class HttpConnectionFactory : IHttpConnectionFactory { + @Throws(IOException::class) + override fun newHttpURLConnection(url: String): HttpURLConnection { + return URL(BASE_URL + url).openConnection() as HttpURLConnection + } + + companion object { + private const val BASE_URL = "https://api.onesignal.com/" + } +} diff --git a/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/http/impl/IHttpConnectionFactory.kt b/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/http/impl/IHttpConnectionFactory.kt new file mode 100644 index 0000000000..8fff48bb99 --- /dev/null +++ b/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/http/impl/IHttpConnectionFactory.kt @@ -0,0 +1,9 @@ +package com.onesignal.core.internal.http.impl + +import java.io.IOException +import java.net.HttpURLConnection + +internal interface IHttpConnectionFactory { + @Throws(IOException::class) + fun newHttpURLConnection(url: String): HttpURLConnection +} diff --git a/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/models/ModelStoreDefs.kt b/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/models/ModelStoreDefs.kt index 32dbf90b79..2c39bac0c1 100644 --- a/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/models/ModelStoreDefs.kt +++ b/OneSignalSDK/onesignal/src/main/java/com/onesignal/core/internal/models/ModelStoreDefs.kt @@ -4,9 +4,9 @@ import com.onesignal.core.internal.modeling.SimpleModelStore import com.onesignal.core.internal.modeling.SingletonModelStore import com.onesignal.core.internal.preferences.IPreferencesService -internal class ConfigModelStore(prefs: IPreferencesService) : SingletonModelStore(SimpleModelStore({ ConfigModel() }, "config", prefs)) -internal class SessionModelStore(prefs: IPreferencesService) : SingletonModelStore(SimpleModelStore({ SessionModel() }, "session", prefs)) -internal class IdentityModelStore(prefs: IPreferencesService) : SingletonModelStore(SimpleModelStore({ IdentityModel() }, "identity", prefs)) -internal class PropertiesModelStore(prefs: IPreferencesService) : SingletonModelStore(SimpleModelStore({ PropertiesModel() }, "properties", prefs)) -internal class SubscriptionModelStore(prefs: IPreferencesService) : SimpleModelStore({ SubscriptionModel() }, "subscriptions", prefs) -internal class TriggerModelStore : SimpleModelStore({ TriggerModel() }) +internal open class ConfigModelStore(prefs: IPreferencesService) : SingletonModelStore(SimpleModelStore({ ConfigModel() }, "config", prefs)) +internal open class SessionModelStore(prefs: IPreferencesService) : SingletonModelStore(SimpleModelStore({ SessionModel() }, "session", prefs)) +internal open class IdentityModelStore(prefs: IPreferencesService) : SingletonModelStore(SimpleModelStore({ IdentityModel() }, "identity", prefs)) +internal open class PropertiesModelStore(prefs: IPreferencesService) : SingletonModelStore(SimpleModelStore({ PropertiesModel() }, "properties", prefs)) +internal open class SubscriptionModelStore(prefs: IPreferencesService) : SimpleModelStore({ SubscriptionModel() }, "subscriptions", prefs) +internal open class TriggerModelStore : SimpleModelStore({ TriggerModel() }) diff --git a/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/extensions/ContainedRobolectricRunner.kt b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/extensions/ContainedRobolectricRunner.kt new file mode 100644 index 0000000000..e0f384a9f1 --- /dev/null +++ b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/extensions/ContainedRobolectricRunner.kt @@ -0,0 +1,65 @@ +/** + * Code taken from https://github.com/kotest/kotest-extensions-robolectric with no changes. + * + * LICENSE: https://github.com/kotest/kotest-extensions-robolectric/blob/master/LICENSE + */ +package com.onesignal.core.tests.extensions + +import org.junit.runners.model.FrameworkMethod +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.internal.bytecode.InstrumentationConfiguration +import org.robolectric.pluginapi.config.ConfigurationStrategy +import org.robolectric.plugins.ConfigConfigurer +import java.lang.reflect.Method + +internal class ContainedRobolectricRunner( + private val config: Config? +) : RobolectricTestRunner(PlaceholderTest::class.java, injector) { + private val placeHolderMethod: FrameworkMethod = children[0] + val sdkEnvironment = getSandbox(placeHolderMethod).also { + configureSandbox(it, placeHolderMethod) + } + private val bootStrapMethod = sdkEnvironment.bootstrappedClass(testClass.javaClass) + .getMethod(PlaceholderTest::bootStrapMethod.name) + + fun containedBefore() { + super.beforeTest(sdkEnvironment, placeHolderMethod, bootStrapMethod) + } + + fun containedAfter() { + super.afterTest(placeHolderMethod, bootStrapMethod) + super.finallyAfterTest(placeHolderMethod) + } + + override fun createClassLoaderConfig(method: FrameworkMethod?): InstrumentationConfiguration { + return InstrumentationConfiguration.Builder(super.createClassLoaderConfig(method)) + .doNotAcquirePackage("io.kotest") + .build() + } + + override fun getConfig(method: Method?): Config { + val defaultConfiguration = injector.getInstance(ConfigurationStrategy::class.java) + .getConfig(testClass.javaClass, method) + + if (config != null) { + val configConfigurer = injector.getInstance(ConfigConfigurer::class.java) + return configConfigurer.merge(defaultConfiguration[Config::class.java], config) + } + + return super.getConfig(method) + } + + class PlaceholderTest { + @org.junit.Test + fun testPlaceholder() { + } + + fun bootStrapMethod() { + } + } + + companion object { + private val injector = defaultInjector().build() + } +} diff --git a/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/extensions/RobolectricExtension.kt b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/extensions/RobolectricExtension.kt new file mode 100644 index 0000000000..b58c9ce454 --- /dev/null +++ b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/extensions/RobolectricExtension.kt @@ -0,0 +1,87 @@ +/** + * Code taken from https://github.com/kotest/kotest-extensions-robolectric with a + * fix in the intercept method. + * + * LICENSE: https://github.com/kotest/kotest-extensions-robolectric/blob/master/LICENSE + */ +package com.onesignal.core.tests.extensions + +import android.app.Application +import io.kotest.core.extensions.ConstructorExtension +import io.kotest.core.extensions.SpecExtension +import io.kotest.core.spec.AutoScan +import io.kotest.core.spec.Spec +import org.robolectric.annotation.Config +import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation + +@AutoScan +internal class RobolectricExtension : ConstructorExtension, SpecExtension { + private fun Class<*>.getParentClass(): List> { + if (superclass == null) return listOf() + return listOf(superclass) + superclass.getParentClass() + } + + private fun KClass<*>.getConfig(): Config { + val annotations = listOf(this.java).plus(this.java.getParentClass()) + .mapNotNull { it.kotlin.findAnnotation() } + .asSequence() + + val application: KClass? = annotations + .firstOrNull { it.application != KotestDefaultApplication::class }?.application + val sdk: Int? = annotations.firstOrNull { it.sdk != -1 }?.takeUnless { it.sdk == -1 }?.sdk + + return Config.Builder() + .also { builder -> + if (application != null) { + builder.setApplication(application.java) + } + + if (sdk != null) { + builder.setSdk(sdk) + } + }.build() + } + + override fun instantiate(clazz: KClass): Spec? { + clazz.findAnnotation() ?: return null + + return ContainedRobolectricRunner(clazz.getConfig()) + .sdkEnvironment.bootstrappedClass(clazz.java).newInstance() + } + + override suspend fun intercept(spec: Spec, execute: suspend (Spec) -> Unit) { + // FIXED: Updated code based on https://github.com/kotest/kotest/issues/2717 + val hasRobolectricAnnotation = spec::class.annotations.any { annotation -> + annotation.annotationClass.qualifiedName == RobolectricTest::class.qualifiedName + } + + if (!hasRobolectricAnnotation) { + return execute(spec) + } + + val containedRobolectricRunner = ContainedRobolectricRunner(spec::class.getConfig()) + + beforeSpec(containedRobolectricRunner) + execute(spec) + afterSpec(containedRobolectricRunner) + } + + private fun beforeSpec(containedRobolectricRunner: ContainedRobolectricRunner) { + Thread.currentThread().contextClassLoader = + containedRobolectricRunner.sdkEnvironment.robolectricClassLoader + containedRobolectricRunner.containedBefore() + } + + private fun afterSpec(containedRobolectricRunner: ContainedRobolectricRunner) { + containedRobolectricRunner.containedAfter() + Thread.currentThread().contextClassLoader = RobolectricExtension::class.java.classLoader + } +} + +internal class KotestDefaultApplication : Application() + +annotation class RobolectricTest( + val application: KClass = KotestDefaultApplication::class, + val sdk: Int = -1 +) diff --git a/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/database/InitialOSDatabase.kt b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/database/InitialOSDatabase.kt new file mode 100644 index 0000000000..aac3f64c0e --- /dev/null +++ b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/database/InitialOSDatabase.kt @@ -0,0 +1,45 @@ +package com.onesignal.core.tests.internal.database + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import android.provider.BaseColumns +import com.onesignal.core.internal.database.impl.OneSignalDbContract + +/** + * This class setups up the database in it's initial form to test database upgrade paths. + */ +internal class InitialOSDatabase(context: Context?) : SQLiteOpenHelper(context, "OneSignal.db", null, 1) { + + private val TEXT_TYPE = " TEXT" + private val INT_TYPE = " INTEGER" + private val COMMA_SEP = "," + + private val SQL_CREATE_ENTRIES = + "CREATE TABLE " + OneSignalDbContract.NotificationTable.TABLE_NAME.toString() + " (" + + BaseColumns._ID.toString() + " INTEGER PRIMARY KEY," + + OneSignalDbContract.NotificationTable.COLUMN_NAME_NOTIFICATION_ID + TEXT_TYPE + COMMA_SEP + + OneSignalDbContract.NotificationTable.COLUMN_NAME_ANDROID_NOTIFICATION_ID + INT_TYPE + COMMA_SEP + + OneSignalDbContract.NotificationTable.COLUMN_NAME_GROUP_ID + TEXT_TYPE + COMMA_SEP + + OneSignalDbContract.NotificationTable.COLUMN_NAME_IS_SUMMARY + INT_TYPE.toString() + " DEFAULT 0" + COMMA_SEP + + OneSignalDbContract.NotificationTable.COLUMN_NAME_OPENED + INT_TYPE.toString() + " DEFAULT 0" + COMMA_SEP + + OneSignalDbContract.NotificationTable.COLUMN_NAME_DISMISSED + INT_TYPE.toString() + " DEFAULT 0" + COMMA_SEP + + OneSignalDbContract.NotificationTable.COLUMN_NAME_TITLE + TEXT_TYPE + COMMA_SEP + + OneSignalDbContract.NotificationTable.COLUMN_NAME_MESSAGE + TEXT_TYPE + COMMA_SEP + + OneSignalDbContract.NotificationTable.COLUMN_NAME_FULL_DATA + TEXT_TYPE + COMMA_SEP + + OneSignalDbContract.NotificationTable.COLUMN_NAME_CREATED_TIME.toString() + " TIMESTAMP DEFAULT (strftime('%s', 'now'))" + + ");" + + private val SQL_INDEX_ENTRIES: String = OneSignalDbContract.NotificationTable.INDEX_CREATE_NOTIFICATION_ID + + OneSignalDbContract.NotificationTable.INDEX_CREATE_ANDROID_NOTIFICATION_ID + + OneSignalDbContract.NotificationTable.INDEX_CREATE_GROUP_ID + + OneSignalDbContract.NotificationTable.INDEX_CREATE_CREATED_TIME + + override fun onCreate(db: SQLiteDatabase) { + db.execSQL(SQL_CREATE_ENTRIES) + db.execSQL(SQL_INDEX_ENTRIES) + } + + override fun onUpgrade(p0: SQLiteDatabase?, p1: Int, p2: Int) { + } +} diff --git a/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/database/OSDatabaseTests.kt b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/database/OSDatabaseTests.kt new file mode 100644 index 0000000000..dc1ccbe1ab --- /dev/null +++ b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/database/OSDatabaseTests.kt @@ -0,0 +1,63 @@ +package com.onesignal.core.tests.internal.database + +import android.content.ContentValues +import androidx.test.core.app.ApplicationProvider +import com.onesignal.core.debug.LogLevel +import com.onesignal.core.internal.database.impl.OSDatabase +import com.onesignal.core.internal.database.impl.OneSignalDbContract +import com.onesignal.core.internal.logging.Logging +import com.onesignal.core.internal.outcomes.impl.OutcomeTableProvider +import com.onesignal.core.tests.extensions.RobolectricTest +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.runner.junit4.KotestTestRunner +import io.mockk.mockk +import org.junit.runner.RunWith + +@RobolectricTest +@RunWith(KotestTestRunner::class) +class OSDatabaseTests : FunSpec({ + + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + beforeTest { + var initialDb = InitialOSDatabase(ApplicationProvider.getApplicationContext()) + + initialDb.writableDatabase.use { + it.beginTransaction() + val values = ContentValues() + values.put( + OneSignalDbContract.NotificationTable.COLUMN_NAME_ANDROID_NOTIFICATION_ID, + 1 + ) + it.insertOrThrow( + OneSignalDbContract.NotificationTable.TABLE_NAME, + null, + values + ) + it.setTransactionSuccessful() + it.endTransaction() + } + initialDb.close() + } + + test("upgrade database from v1 To v3") { + /* Given */ + val outcomeTableProvider = mockk() + val db = OSDatabase(outcomeTableProvider, ApplicationProvider.getApplicationContext(), 3) + + /* When */ + var createdTime: Long = 0 + var expireTime: Long = 0 + db.query(OneSignalDbContract.NotificationTable.TABLE_NAME) { + it.moveToFirst() + createdTime = it.getLong(OneSignalDbContract.NotificationTable.COLUMN_NAME_CREATED_TIME) + expireTime = it.getLong(OneSignalDbContract.NotificationTable.COLUMN_NAME_EXPIRE_TIME) + } + + /* Then */ + expireTime shouldBe createdTime + (72L * (60 * 60)) + } +}) diff --git a/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/http/HttpClientTests.kt b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/http/HttpClientTests.kt new file mode 100644 index 0000000000..9e4a6ed0a5 --- /dev/null +++ b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/http/HttpClientTests.kt @@ -0,0 +1,147 @@ +package com.onesignal.core.tests.internal.http + +import com.onesignal.core.debug.LogLevel +import com.onesignal.core.internal.common.OneSignalUtils +import com.onesignal.core.internal.http.impl.HttpClient +import com.onesignal.core.internal.logging.Logging +import com.onesignal.core.tests.mocks.MockHelper +import com.onesignal.core.tests.mocks.MockPreferencesService +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.beInstanceOf +import io.kotest.runner.junit4.KotestTestRunner +import kotlinx.coroutines.TimeoutCancellationException +import org.json.JSONObject +import org.junit.runner.RunWith + +@RunWith(KotestTestRunner::class) +class HttpClientTests : FunSpec({ + + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + test("timeout request will give a bad response") { + /* Given */ + val mockResponse = MockHttpConnectionFactory.MockResponse() + mockResponse.mockRequestTime = 240000 + + val factory = MockHttpConnectionFactory(mockResponse) + val httpClient = HttpClient(factory, MockPreferencesService(), MockHelper.configModelStore()) + + /* When */ + val response = httpClient.get("URL") + + /* Then */ + response.statusCode shouldBe 0 + response.throwable shouldNotBe null + response.throwable should beInstanceOf() + } + + test("SDKHeader is included in all requests") { + /* Given */ + val mockResponse = MockHttpConnectionFactory.MockResponse() + val factory = MockHttpConnectionFactory(mockResponse) + val httpClient = HttpClient(factory, MockPreferencesService(), MockHelper.configModelStore()) + + /* When */ + httpClient.get("URL") + httpClient.delete("URL") + httpClient.patch("URL", JSONObject()) + httpClient.post("URL", JSONObject()) + httpClient.put("URL", JSONObject()) + + /* Then */ + for (connection in factory.connections) { + connection.getRequestProperty("SDK-Version") shouldBe "onesignal/android/${OneSignalUtils.sdkVersion}" + } + } + + test("GET with cache key uses cache when unchanged") { + /* Given */ + val payload = "RESPONSE IS THIS" + val mockResponse1 = MockHttpConnectionFactory.MockResponse() + mockResponse1.status = 200 + mockResponse1.responseBody = payload + mockResponse1.mockProps.put("etag", "MOCK_ETAG") + + val mockResponse2 = MockHttpConnectionFactory.MockResponse() + mockResponse2.status = 304 + + val factory = MockHttpConnectionFactory(mockResponse1) + val httpClient = HttpClient(factory, MockPreferencesService(), MockHelper.configModelStore()) + + /* When */ + var response1 = httpClient.get("URL", "CACHE_KEY") + + factory.mockResponse = mockResponse2 + var response2 = httpClient.get("URL", "CACHE_KEY") + + /* Then */ + response1.statusCode shouldBe 200 + response1.payload shouldBe payload + response2.statusCode shouldBe 304 + response2.payload shouldBe payload + factory.lastConnection!!.getRequestProperty("if-none-match") shouldBe "MOCK_ETAG" + } + + test("GET with cache key replaces cache when changed") { + /* Given */ + val payload1 = "RESPONSE IS THIS" + val payload2 = "A DIFFERENT RESPONSE" + val mockResponse1 = MockHttpConnectionFactory.MockResponse() + mockResponse1.status = 200 + mockResponse1.responseBody = payload1 + mockResponse1.mockProps.put("etag", "MOCK_ETAG1") + + val mockResponse2 = MockHttpConnectionFactory.MockResponse() + mockResponse2.status = 200 + mockResponse2.responseBody = payload2 + mockResponse2.mockProps.put("etag", "MOCK_ETAG2") + + val mockResponse3 = MockHttpConnectionFactory.MockResponse() + mockResponse3.status = 304 + + val factory = MockHttpConnectionFactory(mockResponse1) + val httpClient = HttpClient(factory, MockPreferencesService(), MockHelper.configModelStore()) + + /* When */ + var response1 = httpClient.get("URL", "CACHE_KEY") + + factory.mockResponse = mockResponse2 + var response2 = httpClient.get("URL", "CACHE_KEY") + + factory.mockResponse = mockResponse3 + var response3 = httpClient.get("URL", "CACHE_KEY") + + /* Then */ + response1.statusCode shouldBe 200 + response1.payload shouldBe payload1 + response2.statusCode shouldBe 200 + response2.payload shouldBe payload2 + response3.statusCode shouldBe 304 + response3.payload shouldBe payload2 + + factory.lastConnection!!.getRequestProperty("if-none-match") shouldBe "MOCK_ETAG2" + } + + test("Error response") { + /* Given */ + val payload = "ERROR RESPONSE" + val mockResponse = MockHttpConnectionFactory.MockResponse() + mockResponse.status = 400 + mockResponse.errorResponseBody = payload + + val factory = MockHttpConnectionFactory(mockResponse) + val httpClient = HttpClient(factory, MockPreferencesService(), MockHelper.configModelStore()) + + /* When */ + var response = httpClient.post("URL", JSONObject()) + + /* Then */ + response.statusCode shouldBe 400 + response.payload shouldBe payload + } +}) diff --git a/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/http/MockHttpConnectionFactory.kt b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/http/MockHttpConnectionFactory.kt new file mode 100644 index 0000000000..bac0b8a1dd --- /dev/null +++ b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/http/MockHttpConnectionFactory.kt @@ -0,0 +1,86 @@ +package com.onesignal.core.tests.internal.http + +import com.onesignal.core.internal.http.impl.IHttpConnectionFactory +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.HttpURLConnection +import java.net.URL + +internal class MockHttpConnectionFactory( + var mockResponse: MockResponse +) : IHttpConnectionFactory { + + val connections: MutableList = mutableListOf() + var lastConnection: MockHttpURLConnection? = null + + override fun newHttpURLConnection(url: String): HttpURLConnection { + lastConnection = MockHttpURLConnection(URL("https://onesignal.com/api/v1/$url"), mockResponse) + connections.add(lastConnection!!) + return lastConnection as HttpURLConnection + } + + class MockResponse { + var responseBody: String? = null + var errorResponseBody: String? = null + var mockRequestTime: Long? = null + var status = 0 + var mockProps: MutableMap = mutableMapOf() + } + + class MockHttpURLConnection( + url: URL?, + private val mockResponse: MockResponse + ) : HttpURLConnection(url) { + override fun disconnect() {} + override fun usingProxy(): Boolean { + return false + } + + @Throws(IOException::class) + override fun connect() { + } + + override fun getHeaderField(name: String): String { + return mockResponse.mockProps[name]!! + } + + @Throws(IOException::class) + override fun getResponseCode(): Int { + if (mockResponse.mockRequestTime != null) { + try { + Thread.sleep(mockResponse.mockRequestTime!!) + } catch (e: InterruptedException) { + throw IOException("Successfully interrupted stuck thread!") + } + } + + return mockResponse.status + } + + override fun getOutputStream(): OutputStream { + return NullOutputStream() + } + + @Throws(IOException::class) + override fun getInputStream(): InputStream { + val bytes = mockResponse.responseBody!!.toByteArray() + return ByteArrayInputStream(bytes) + } + + override fun getErrorStream(): InputStream { + if (mockResponse.errorResponseBody == null) { + throw Exception("No error response body") + } + + val bytes = mockResponse.errorResponseBody!!.toByteArray() + return ByteArrayInputStream(bytes) + } + } + + private class NullOutputStream : OutputStream() { + override fun write(p0: Int) { + } + } +} diff --git a/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/influence/InfluenceManagerTests.kt b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/influence/InfluenceManagerTests.kt new file mode 100644 index 0000000000..c4dde8fc34 --- /dev/null +++ b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/influence/InfluenceManagerTests.kt @@ -0,0 +1,250 @@ +package com.onesignal.core.tests.internal.influence + +import com.onesignal.core.internal.application.AppEntryAction +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.influence.InfluenceChannel +import com.onesignal.core.internal.influence.impl.InfluenceManager +import com.onesignal.core.internal.session.ISessionService +import com.onesignal.core.tests.mocks.MockHelper +import com.onesignal.core.tests.mocks.MockPreferencesService +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.runner.junit4.KotestTestRunner +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.junit.runner.RunWith + +@RunWith(KotestTestRunner::class) +class InfluenceManagerTests : FunSpec({ + + test("default are disabled influences") { + /* Given */ + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockApplicationService = mockk() + val mockConfigModelStore = MockHelper.configModelStore { + it.influenceParams.isDirectEnabled = false + it.influenceParams.isIndirectEnabled = false + it.influenceParams.isUnattributedEnabled = false + } + val mockPreferences = MockPreferencesService() + val influenceManager = InfluenceManager(mockSessionService, mockApplicationService, mockConfigModelStore, mockPreferences, MockHelper.time(1111)) + + /* When */ + val influences = influenceManager.influences + + /* Then */ + influences.count() shouldBe 2 + val notificationInfluence = influences.first { it.influenceChannel == InfluenceChannel.NOTIFICATION } + notificationInfluence.influenceType.isDisabled() shouldBe true + notificationInfluence.directId shouldBe null + notificationInfluence.ids shouldBe null + + val iamInfluence = influences.first { it.influenceChannel == InfluenceChannel.IAM } + iamInfluence.influenceType.isDisabled() shouldBe true + iamInfluence.directId shouldBe null + iamInfluence.ids shouldBe null + } + + test("session begin are unattributed influences") { + /* Given */ + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockApplicationService = mockk() + every { mockApplicationService.entryState } returns AppEntryAction.APP_OPEN + + val mockConfigModelStore = MockHelper.configModelStore { + it.influenceParams.isDirectEnabled = true + it.influenceParams.isIndirectEnabled = true + it.influenceParams.isUnattributedEnabled = true + } + val mockPreferences = MockPreferencesService() + val influenceManager = InfluenceManager(mockSessionService, mockApplicationService, mockConfigModelStore, mockPreferences, MockHelper.time(1111)) + + /* When */ + influenceManager.onSessionStarted() + val influences = influenceManager.influences + + /* Then */ + influences.count() shouldBe 2 + val notificationInfluence = influences.first { it.influenceChannel == InfluenceChannel.NOTIFICATION } + notificationInfluence.influenceType.isUnattributed() shouldBe true + notificationInfluence.directId shouldBe null + notificationInfluence.ids shouldBe null + + val iamInfluence = influences.first { it.influenceChannel == InfluenceChannel.IAM } + iamInfluence.influenceType.isUnattributed() shouldBe true + iamInfluence.directId shouldBe null + iamInfluence.ids shouldBe null + } + + test("notification received creates notification indirect influence") { + /* Given */ + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockApplicationService = mockk() + every { mockApplicationService.entryState } returns AppEntryAction.APP_OPEN + + val mockConfigModelStore = MockHelper.configModelStore { + it.influenceParams.isDirectEnabled = true + it.influenceParams.isIndirectEnabled = true + it.influenceParams.isUnattributedEnabled = true + } + val mockPreferences = MockPreferencesService() + val influenceManager = InfluenceManager(mockSessionService, mockApplicationService, mockConfigModelStore, mockPreferences, MockHelper.time(1111)) + + /* When */ + influenceManager.onNotificationReceived("notificationId") + influenceManager.onSessionActive() + val influences = influenceManager.influences + + /* Then */ + influences.count() shouldBe 2 + val notificationInfluence = influences.first { it.influenceChannel == InfluenceChannel.NOTIFICATION } + notificationInfluence.influenceType.isIndirect() shouldBe true + notificationInfluence.ids shouldNotBe null + notificationInfluence.ids!!.length() shouldBe 1 + notificationInfluence.ids!![0].toString() shouldBe "notificationId" + + val iamInfluence = influences.first { it.influenceChannel == InfluenceChannel.IAM } + iamInfluence.influenceType.isUnattributed() shouldBe true + iamInfluence.ids shouldBe null + } + + test("IAM received creates IAM indirect influence") { + /* Given */ + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockApplicationService = mockk() + val mockConfigModelStore = MockHelper.configModelStore { + it.influenceParams.isDirectEnabled = true + it.influenceParams.isIndirectEnabled = true + it.influenceParams.isUnattributedEnabled = true + } + val mockPreferences = MockPreferencesService() + val influenceManager = InfluenceManager(mockSessionService, mockApplicationService, mockConfigModelStore, mockPreferences, MockHelper.time(1111)) + + /* When */ + influenceManager.onInAppMessageDisplayed("inAppMessageId") + influenceManager.onInAppMessageDismissed() + val influences = influenceManager.influences + + /* Then */ + influences.count() shouldBe 2 + val notificationInfluence = influences.first { it.influenceChannel == InfluenceChannel.NOTIFICATION } + notificationInfluence.influenceType.isUnattributed() shouldBe true + notificationInfluence.directId shouldBe null + notificationInfluence.ids shouldBe null + + val iamInfluence = influences.first { it.influenceChannel == InfluenceChannel.IAM } + iamInfluence.influenceType.isIndirect() shouldBe true + iamInfluence.influenceChannel shouldBe InfluenceChannel.IAM + iamInfluence.ids shouldNotBe null + iamInfluence.ids!!.length() shouldBe 1 + iamInfluence.ids!![0].toString() shouldBe "inAppMessageId" + } + + test("notification opened creates notification direct influence") { + /* Given */ + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockApplicationService = mockk() + val mockConfigModelStore = MockHelper.configModelStore { + it.influenceParams.isDirectEnabled = true + it.influenceParams.isIndirectEnabled = true + it.influenceParams.isUnattributedEnabled = true + } + val mockPreferences = MockPreferencesService() + val influenceManager = InfluenceManager(mockSessionService, mockApplicationService, mockConfigModelStore, mockPreferences, MockHelper.time(1111)) + + /* When */ + influenceManager.onNotificationReceived("notificationId") + influenceManager.onDirectInfluenceFromNotification("notificationId") + val influences = influenceManager.influences + + /* Then */ + influences.count() shouldBe 2 + val notificationInfluence = influences.first { it.influenceChannel == InfluenceChannel.NOTIFICATION } + notificationInfluence.influenceType.isDirect() shouldBe true + notificationInfluence.influenceChannel shouldBe InfluenceChannel.NOTIFICATION + notificationInfluence.directId shouldBe "notificationId" + + val iamInfluence = influences.first { it.influenceChannel == InfluenceChannel.IAM } + iamInfluence.influenceType.isUnattributed() shouldBe true + iamInfluence.directId shouldBe null + iamInfluence.ids shouldBe null + } + + test("IAM clicked while open creates IAM direct influence") { + /* Given */ + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockApplicationService = mockk() + val mockConfigModelStore = MockHelper.configModelStore { + it.influenceParams.isDirectEnabled = true + it.influenceParams.isIndirectEnabled = true + it.influenceParams.isUnattributedEnabled = true + } + val mockPreferences = MockPreferencesService() + val influenceManager = InfluenceManager(mockSessionService, mockApplicationService, mockConfigModelStore, mockPreferences, MockHelper.time(1111)) + + /* When */ + influenceManager.onInAppMessageDisplayed("inAppMessageId") + influenceManager.onDirectInfluenceFromIAM("inAppMessageId") + val influences = influenceManager.influences + + /* Then */ + influences.count() shouldBe 2 + val notificationInfluence = influences.first { it.influenceChannel == InfluenceChannel.NOTIFICATION } + notificationInfluence.influenceType.isUnattributed() shouldBe true + notificationInfluence.directId shouldBe null + notificationInfluence.ids shouldBe null + + val iamInfluence = influences.first { it.influenceChannel == InfluenceChannel.IAM } + iamInfluence.influenceType.isDirect() shouldBe true + iamInfluence.influenceChannel shouldBe InfluenceChannel.IAM + iamInfluence.directId shouldBe "inAppMessageId" + } + + test("IAM clicked then dismissed creates IAM indirect influence") { + /* Given */ + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockApplicationService = mockk() + val mockConfigModelStore = MockHelper.configModelStore { + it.influenceParams.isDirectEnabled = true + it.influenceParams.isIndirectEnabled = true + it.influenceParams.isUnattributedEnabled = true + } + val mockPreferences = MockPreferencesService() + val influenceManager = InfluenceManager(mockSessionService, mockApplicationService, mockConfigModelStore, mockPreferences, MockHelper.time(1111)) + + /* When */ + influenceManager.onInAppMessageDisplayed("inAppMessageId") + influenceManager.onDirectInfluenceFromIAM("inAppMessageId") + influenceManager.onInAppMessageDismissed() + val influences = influenceManager.influences + + /* Then */ + influences.count() shouldBe 2 + val notificationInfluence = influences.first { it.influenceChannel == InfluenceChannel.NOTIFICATION } + notificationInfluence.influenceType.isUnattributed() shouldBe true + notificationInfluence.directId shouldBe null + notificationInfluence.ids shouldBe null + + val iamInfluence = influences.first { it.influenceChannel == InfluenceChannel.IAM } + iamInfluence.influenceType.isIndirect() shouldBe true + iamInfluence.influenceChannel shouldBe InfluenceChannel.IAM + iamInfluence.directId shouldBe "inAppMessageId" + } +}) diff --git a/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/outcomes/OutcomeEventsBackendServiceTests.kt b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/outcomes/OutcomeEventsBackendServiceTests.kt new file mode 100644 index 0000000000..0c4c208b87 --- /dev/null +++ b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/outcomes/OutcomeEventsBackendServiceTests.kt @@ -0,0 +1,177 @@ +package com.onesignal.core.tests.internal.outcomes + +import com.onesignal.core.debug.LogLevel +import com.onesignal.core.internal.backend.BackendException +import com.onesignal.core.internal.http.HttpResponse +import com.onesignal.core.internal.http.IHttpClient +import com.onesignal.core.internal.influence.InfluenceType +import com.onesignal.core.internal.logging.Logging +import com.onesignal.core.internal.outcomes.impl.OutcomeEvent +import com.onesignal.core.internal.outcomes.impl.OutcomeEventsBackendService +import io.kotest.assertions.throwables.shouldThrowUnit +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.runner.junit4.KotestTestRunner +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import org.junit.runner.RunWith + +@RunWith(KotestTestRunner::class) +class OutcomeEventsBackendServiceTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + test("send outcome event") { + /* Given */ + val evnt = OutcomeEvent(InfluenceType.DIRECT, null, "EVENT_NAME", 0, 0F) + val spyHttpClient = mockk() + coEvery { spyHttpClient.post(any(), any()) } returns HttpResponse(200, null) + val outcomeEventsController = OutcomeEventsBackendService(spyHttpClient) + + /* When */ + outcomeEventsController.sendOutcomeEvent("appId", 1, null, evnt) + + /* Then */ + coVerify { + spyHttpClient.post( + "outcomes/measure", + withArg { + it.getString("app_id") shouldBe "appId" + it.getInt("device_type") shouldBe 1 + it.getString("id") shouldBe "EVENT_NAME" + it.has("direct") shouldBe false + it.has("notification_ids") shouldBe false + it.has("timestamp") shouldBe false + it.has("weight") shouldBe false + } + ) + } + } + + test("send outcome event with weight") { + /* Given */ + val evnt = OutcomeEvent(InfluenceType.DIRECT, null, "EVENT_NAME", 0, 1F) + val spyHttpClient = mockk() + coEvery { spyHttpClient.post(any(), any()) } returns HttpResponse(200, null) + val outcomeEventsController = OutcomeEventsBackendService(spyHttpClient) + + /* When */ + outcomeEventsController.sendOutcomeEvent("appId", 1, null, evnt) + + /* Then */ + coVerify { + spyHttpClient.post( + "outcomes/measure", + withArg { + it.getString("app_id") shouldBe "appId" + it.getInt("device_type") shouldBe 1 + it.getString("id") shouldBe "EVENT_NAME" + it.getInt("weight") shouldBe 1 + it.has("direct") shouldBe false + it.has("notification_ids") shouldBe false + it.has("timestamp") shouldBe false + } + ) + } + } + + test("send outcome event with indirect") { + /* Given */ + val evnt = OutcomeEvent(InfluenceType.DIRECT, null, "EVENT_NAME", 0, 0F) + val spyHttpClient = mockk() + coEvery { spyHttpClient.post(any(), any()) } returns HttpResponse(200, null) + val outcomeEventsController = OutcomeEventsBackendService(spyHttpClient) + + /* When */ + outcomeEventsController.sendOutcomeEvent("appId", 1, false, evnt) + + /* Then */ + coVerify { + spyHttpClient.post( + "outcomes/measure", + withArg { + it.getString("app_id") shouldBe "appId" + it.getInt("device_type") shouldBe 1 + it.getString("id") shouldBe "EVENT_NAME" + it.getBoolean("direct") shouldBe false + it.has("notification_ids") shouldBe false + it.has("timestamp") shouldBe false + it.has("weight") shouldBe false + } + ) + } + } + + test("send outcome event with direct") { + /* Given */ + val evnt = OutcomeEvent(InfluenceType.DIRECT, null, "EVENT_NAME", 0, 0F) + val spyHttpClient = mockk() + coEvery { spyHttpClient.post(any(), any()) } returns HttpResponse(200, null) + val outcomeEventsController = OutcomeEventsBackendService(spyHttpClient) + + /* When */ + outcomeEventsController.sendOutcomeEvent("appId", 1, true, evnt) + + /* Then */ + coVerify { + spyHttpClient.post( + "outcomes/measure", + withArg { + it.getString("app_id") shouldBe "appId" + it.getInt("device_type") shouldBe 1 + it.getString("id") shouldBe "EVENT_NAME" + it.getBoolean("direct") shouldBe true + it.has("notification_ids") shouldBe false + it.has("timestamp") shouldBe false + it.has("weight") shouldBe false + } + ) + } + } + + test("send outcome event with timestamp") { + /* Given */ + val evnt = OutcomeEvent(InfluenceType.DIRECT, null, "EVENT_NAME", 1111L, 0F) + val spyHttpClient = mockk() + coEvery { spyHttpClient.post(any(), any()) } returns HttpResponse(200, null) + val outcomeEventsController = OutcomeEventsBackendService(spyHttpClient) + + /* When */ + outcomeEventsController.sendOutcomeEvent("appId", 1, null, evnt) + + /* Then */ + coVerify { + spyHttpClient.post( + "outcomes/measure", + withArg { + it.getString("app_id") shouldBe "appId" + it.getInt("device_type") shouldBe 1 + it.getString("id") shouldBe "EVENT_NAME" + it.getInt("timestamp") shouldBe 1111 + it.has("notification_ids") shouldBe false + it.has("weight") shouldBe false + it.has("direct") shouldBe false + } + ) + } + } + + test("send outcome event with unsuccessful response") { + /* Given */ + val evnt = OutcomeEvent(InfluenceType.DIRECT, null, "EVENT_NAME", 1111L, 0F) + val spyHttpClient = mockk() + coEvery { spyHttpClient.post(any(), any()) } returns HttpResponse(503, "SERVICE UNAVAILABLE") + val outcomeEventsController = OutcomeEventsBackendService(spyHttpClient) + + /* When */ + val exception = shouldThrowUnit { + outcomeEventsController.sendOutcomeEvent("appId", 1, null, evnt) + } + + /* Then */ + exception.statusCode shouldBe 503 + exception.response shouldBe "SERVICE UNAVAILABLE" + } +}) diff --git a/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/outcomes/OutcomeEventsControllerTests.kt b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/outcomes/OutcomeEventsControllerTests.kt new file mode 100644 index 0000000000..8f1b9a7613 --- /dev/null +++ b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/outcomes/OutcomeEventsControllerTests.kt @@ -0,0 +1,413 @@ +package com.onesignal.core.tests.internal.outcomes + +import com.onesignal.core.debug.LogLevel +import com.onesignal.core.internal.backend.BackendException +import com.onesignal.core.internal.influence.IInfluenceManager +import com.onesignal.core.internal.influence.Influence +import com.onesignal.core.internal.influence.InfluenceChannel +import com.onesignal.core.internal.influence.InfluenceType +import com.onesignal.core.internal.logging.Logging +import com.onesignal.core.internal.outcomes.impl.IOutcomeEventsBackendService +import com.onesignal.core.internal.outcomes.impl.IOutcomeEventsPreferences +import com.onesignal.core.internal.outcomes.impl.IOutcomeEventsRepository +import com.onesignal.core.internal.outcomes.impl.OutcomeEventsController +import com.onesignal.core.internal.session.ISessionService +import com.onesignal.core.tests.mocks.MockHelper +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.runner.junit4.KotestTestRunner +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifySequence +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.spyk +import org.json.JSONArray +import org.junit.runner.RunWith + +@RunWith(KotestTestRunner::class) +class OutcomeEventsControllerTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + test("send outcome with disabled influences") { + /* Given */ + val now = 111L + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockInfluenceManager = mockk() + every { mockInfluenceManager.influences } returns listOf(Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DISABLED, null)) + + val mockOutcomeEventsRepository = spyk() + val mockOutcomeEventsPreferences = spyk() + val mockOutcomeEventsBackend = spyk() + + val outcomeEventsController = OutcomeEventsController( + mockSessionService, + mockInfluenceManager, + mockOutcomeEventsRepository, + mockOutcomeEventsPreferences, + mockOutcomeEventsBackend, + MockHelper.configModelStore(), + MockHelper.time(now), + MockHelper.deviceService() + ) + + /* When */ + val evnt = outcomeEventsController.sendOutcomeEvent("OUTCOME_1") + + /* Then */ + evnt shouldBe null + coVerify(exactly = 0) { mockOutcomeEventsBackend.sendOutcomeEvent(any(), any(), any(), any()) } + } + + test("send outcome with unattributed influences") { + /* Given */ + val now = 111L + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockInfluenceManager = mockk() + every { mockInfluenceManager.influences } returns listOf(Influence(InfluenceChannel.NOTIFICATION, InfluenceType.UNATTRIBUTED, null)) + + val mockOutcomeEventsRepository = spyk() + val mockOutcomeEventsPreferences = spyk() + val mockOutcomeEventsBackend = spyk() + + val outcomeEventsController = OutcomeEventsController( + mockSessionService, + mockInfluenceManager, + mockOutcomeEventsRepository, + mockOutcomeEventsPreferences, + mockOutcomeEventsBackend, + MockHelper.configModelStore(), + MockHelper.time(now), + MockHelper.deviceService() + ) + + /* When */ + val evnt = outcomeEventsController.sendOutcomeEvent("OUTCOME_1") + + /* Then */ + evnt shouldNotBe null + evnt!!.name shouldBe "OUTCOME_1" + evnt.notificationIds shouldBe null + evnt.weight shouldBe 0 + evnt.session shouldBe InfluenceType.UNATTRIBUTED + evnt.timestamp shouldBe 0 // timestamp only set when it had to be saved. + + coVerify(exactly = 1) { mockOutcomeEventsBackend.sendOutcomeEvent(MockHelper.DEFAULT_APP_ID, MockHelper.DEFAULT_DEVICE_TYPE, null, evnt) } + } + + test("send outcome with indirect influences") { + /* Given */ + val now = 111L + val notificationIds = "[\"id1\",\"id2\"]" + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockInfluenceManager = mockk() + every { mockInfluenceManager.influences } returns listOf(Influence(InfluenceChannel.NOTIFICATION, InfluenceType.INDIRECT, JSONArray(notificationIds))) + + val mockOutcomeEventsRepository = spyk() + val mockOutcomeEventsPreferences = spyk() + val mockOutcomeEventsBackend = spyk() + + val outcomeEventsController = OutcomeEventsController( + mockSessionService, + mockInfluenceManager, + mockOutcomeEventsRepository, + mockOutcomeEventsPreferences, + mockOutcomeEventsBackend, + MockHelper.configModelStore(), + MockHelper.time(now), + MockHelper.deviceService() + ) + + /* When */ + val evnt = outcomeEventsController.sendOutcomeEvent("OUTCOME_1") + + /* Then */ + evnt shouldNotBe null + evnt!!.name shouldBe "OUTCOME_1" + evnt.notificationIds shouldNotBe null + evnt.notificationIds!!.toString() shouldBe notificationIds + evnt.weight shouldBe 0 + evnt.session shouldBe InfluenceType.INDIRECT + evnt.timestamp shouldBe 0 // timestamp only set when it had to be saved. + + coVerify(exactly = 1) { mockOutcomeEventsBackend.sendOutcomeEvent(MockHelper.DEFAULT_APP_ID, MockHelper.DEFAULT_DEVICE_TYPE, false, evnt) } + } + + test("send outcome with direct influence") { + /* Given */ + val now = 111L + val notificationIds = "[\"id1\",\"id2\"]" + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockInfluenceManager = mockk() + every { mockInfluenceManager.influences } returns listOf(Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray(notificationIds))) + + val mockOutcomeEventsRepository = spyk() + val mockOutcomeEventsPreferences = spyk() + val mockOutcomeEventsBackend = spyk() + + val outcomeEventsController = OutcomeEventsController( + mockSessionService, + mockInfluenceManager, + mockOutcomeEventsRepository, + mockOutcomeEventsPreferences, + mockOutcomeEventsBackend, + MockHelper.configModelStore(), + MockHelper.time(now), + MockHelper.deviceService() + ) + + /* When */ + val evnt = outcomeEventsController.sendOutcomeEvent("OUTCOME_1") + + /* Then */ + evnt shouldNotBe null + evnt!!.name shouldBe "OUTCOME_1" + evnt.notificationIds shouldNotBe null + evnt.notificationIds!!.toString() shouldBe notificationIds + evnt.weight shouldBe 0 + evnt.session shouldBe InfluenceType.DIRECT + evnt.timestamp shouldBe 0 // timestamp only set when it had to be saved. + + coVerify(exactly = 1) { mockOutcomeEventsBackend.sendOutcomeEvent(MockHelper.DEFAULT_APP_ID, MockHelper.DEFAULT_DEVICE_TYPE, true, evnt) } + } + + test("send outcome with weight") { + /* Given */ + val now = 111L + val weight = 999F + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockInfluenceManager = mockk() + every { mockInfluenceManager.influences } returns listOf(Influence(InfluenceChannel.NOTIFICATION, InfluenceType.UNATTRIBUTED, null)) + + val mockOutcomeEventsRepository = spyk() + val mockOutcomeEventsPreferences = spyk() + val mockOutcomeEventsBackend = spyk() + + val outcomeEventsController = OutcomeEventsController( + mockSessionService, + mockInfluenceManager, + mockOutcomeEventsRepository, + mockOutcomeEventsPreferences, + mockOutcomeEventsBackend, + MockHelper.configModelStore(), + MockHelper.time(now), + MockHelper.deviceService() + ) + + /* When */ + val evnt = outcomeEventsController.sendOutcomeEventWithValue("OUTCOME_1", weight) + + /* Then */ + evnt shouldNotBe null + evnt!!.name shouldBe "OUTCOME_1" + evnt.notificationIds shouldBe null + evnt.weight shouldBe weight + evnt.session shouldBe InfluenceType.UNATTRIBUTED + evnt.timestamp shouldBe 0 // timestamp only set when it had to be saved. + + coVerify(exactly = 1) { mockOutcomeEventsBackend.sendOutcomeEvent(MockHelper.DEFAULT_APP_ID, MockHelper.DEFAULT_DEVICE_TYPE, null, evnt) } + } + + test("send unique outcome with unattributed influences") { + /* Given */ + val now = 111L + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockInfluenceManager = mockk() + every { mockInfluenceManager.influences } returns listOf(Influence(InfluenceChannel.NOTIFICATION, InfluenceType.UNATTRIBUTED, null)) + + val mockOutcomeEventsRepository = spyk() + val mockOutcomeEventsPreferences = spyk() + val mockOutcomeEventsBackend = spyk() + + val outcomeEventsController = OutcomeEventsController( + mockSessionService, + mockInfluenceManager, + mockOutcomeEventsRepository, + mockOutcomeEventsPreferences, + mockOutcomeEventsBackend, + MockHelper.configModelStore(), + MockHelper.time(now), + MockHelper.deviceService() + ) + + /* When */ + val evnt1 = outcomeEventsController.sendUniqueOutcomeEvent("OUTCOME_1") + val evnt2 = outcomeEventsController.sendUniqueOutcomeEvent("OUTCOME_1") + + /* Then */ + evnt1 shouldNotBe null + evnt1!!.name shouldBe "OUTCOME_1" + evnt1.notificationIds shouldBe null + evnt1.weight shouldBe 0 + evnt1.session shouldBe InfluenceType.UNATTRIBUTED + evnt1.timestamp shouldBe 0 // timestamp only set when it had to be saved. + + evnt2 shouldBe null + + coVerify(exactly = 1) { mockOutcomeEventsBackend.sendOutcomeEvent(MockHelper.DEFAULT_APP_ID, MockHelper.DEFAULT_DEVICE_TYPE, any(), any()) } + } + + test("send unique outcome with same indirect influences") { + /* Given */ + val now = 111L + val notificationIds = "[\"id1\",\"id2\"]" + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockInfluenceManager = mockk() + val notificationInfluence = Influence(InfluenceChannel.NOTIFICATION, InfluenceType.INDIRECT, JSONArray(notificationIds)) + every { mockInfluenceManager.influences } returns listOf(notificationInfluence) + + val mockOutcomeEventsRepository = mockk() + coEvery { mockOutcomeEventsRepository.getNotCachedUniqueInfluencesForOutcome("OUTCOME_1", any()) } returns listOf(notificationInfluence) andThen listOf() + coEvery { mockOutcomeEventsRepository.saveUniqueOutcomeEventParams(any()) } + + val mockOutcomeEventsPreferences = spyk() + val mockOutcomeEventsBackend = spyk() + + val outcomeEventsController = OutcomeEventsController( + mockSessionService, + mockInfluenceManager, + mockOutcomeEventsRepository, + mockOutcomeEventsPreferences, + mockOutcomeEventsBackend, + MockHelper.configModelStore(), + MockHelper.time(now), + MockHelper.deviceService() + ) + + /* When */ + val evnt1 = outcomeEventsController.sendUniqueOutcomeEvent("OUTCOME_1") + val evnt2 = outcomeEventsController.sendUniqueOutcomeEvent("OUTCOME_1") + + /* Then */ + evnt1 shouldNotBe null + evnt1!!.name shouldBe "OUTCOME_1" + evnt1.notificationIds shouldNotBe null + evnt1.notificationIds!!.toString() shouldBe notificationIds + evnt1.weight shouldBe 0 + evnt1.session shouldBe InfluenceType.INDIRECT + evnt1.timestamp shouldBe 0 // timestamp only set when it had to be saved. + + evnt2 shouldBe null + + coVerify(exactly = 1) { mockOutcomeEventsBackend.sendOutcomeEvent(MockHelper.DEFAULT_APP_ID, MockHelper.DEFAULT_DEVICE_TYPE, any(), any()) } + } + + test("send unique outcome with different indirect influences") { + /* Given */ + val now = 111L + val notificationIds1 = "[\"id1\",\"id2\"]" + val notificationIds2 = "[\"id3\",\"id4\"]" + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockInfluenceManager = mockk() + val notificationInfluence1 = Influence(InfluenceChannel.NOTIFICATION, InfluenceType.INDIRECT, JSONArray(notificationIds1)) + val notificationInfluence2 = Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray(notificationIds2)) + every { mockInfluenceManager.influences } returns listOf(notificationInfluence1) andThen listOf(notificationInfluence2) + + val mockOutcomeEventsRepository = mockk() + coEvery { mockOutcomeEventsRepository.getNotCachedUniqueInfluencesForOutcome("OUTCOME_1", any()) } returns listOf(notificationInfluence1) andThen listOf(notificationInfluence2) + coEvery { mockOutcomeEventsRepository.saveUniqueOutcomeEventParams(any()) } + + val mockOutcomeEventsPreferences = spyk() + val mockOutcomeEventsBackend = spyk() + + val outcomeEventsController = OutcomeEventsController( + mockSessionService, + mockInfluenceManager, + mockOutcomeEventsRepository, + mockOutcomeEventsPreferences, + mockOutcomeEventsBackend, + MockHelper.configModelStore(), + MockHelper.time(now), + MockHelper.deviceService() + ) + + /* When */ + val evnt1 = outcomeEventsController.sendUniqueOutcomeEvent("OUTCOME_1") + val evnt2 = outcomeEventsController.sendUniqueOutcomeEvent("OUTCOME_1") + + /* Then */ + evnt1 shouldNotBe null + evnt1!!.name shouldBe "OUTCOME_1" + evnt1.notificationIds shouldNotBe null + evnt1.notificationIds!!.toString() shouldBe notificationIds1 + evnt1.weight shouldBe 0 + evnt1.session shouldBe InfluenceType.INDIRECT + evnt1.timestamp shouldBe 0 // timestamp only set when it had to be saved. + + evnt2 shouldNotBe null + evnt2!!.name shouldBe "OUTCOME_1" + evnt2.notificationIds shouldNotBe null + evnt2.notificationIds!!.toString() shouldBe notificationIds2 + evnt2.weight shouldBe 0 + evnt2.session shouldBe InfluenceType.DIRECT + evnt2.timestamp shouldBe 0 // timestamp only set when it had to be saved. + + coVerifySequence { + mockOutcomeEventsBackend.sendOutcomeEvent(MockHelper.DEFAULT_APP_ID, MockHelper.DEFAULT_DEVICE_TYPE, false, evnt1) + mockOutcomeEventsBackend.sendOutcomeEvent(MockHelper.DEFAULT_APP_ID, MockHelper.DEFAULT_DEVICE_TYPE, true, evnt2) + } + } + + test("send outcome in offline mode") { + /* Given */ + val now = 111111L + val mockSessionService = mockk() + every { mockSessionService.subscribe(any()) } just Runs + + val mockInfluenceManager = mockk() + every { mockInfluenceManager.influences } returns listOf(Influence(InfluenceChannel.NOTIFICATION, InfluenceType.UNATTRIBUTED, null)) + + val mockOutcomeEventsRepository = spyk() + val mockOutcomeEventsPreferences = spyk() + val mockOutcomeEventsBackend = mockk() + coEvery { mockOutcomeEventsBackend.sendOutcomeEvent(any(), any(), any(), any()) } throws BackendException(408, null) + + val outcomeEventsController = OutcomeEventsController( + mockSessionService, + mockInfluenceManager, + mockOutcomeEventsRepository, + mockOutcomeEventsPreferences, + mockOutcomeEventsBackend, + MockHelper.configModelStore(), + MockHelper.time(now), + MockHelper.deviceService() + ) + + /* When */ + val evnt = outcomeEventsController.sendOutcomeEvent("OUTCOME_1") + + /* Then */ + evnt shouldBe null + + coVerify(exactly = 1) { + mockOutcomeEventsRepository.saveOutcomeEvent( + withArg { + it.outcomeId shouldBe "OUTCOME_1" + it.weight shouldBe 0 + it.timestamp shouldBe now / 1000 + } + ) + } + } +}) diff --git a/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/session/SessionServiceTests.kt b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/session/SessionServiceTests.kt new file mode 100644 index 0000000000..e56319c29e --- /dev/null +++ b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/internal/session/SessionServiceTests.kt @@ -0,0 +1,116 @@ +package com.onesignal.core.tests.internal.session + +import com.onesignal.core.internal.session.ISessionLifecycleHandler +import com.onesignal.core.internal.session.impl.SessionService +import com.onesignal.core.tests.mocks.MockHelper +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.runner.junit4.KotestTestRunner +import io.mockk.spyk +import io.mockk.verify +import org.junit.runner.RunWith + +@RunWith(KotestTestRunner::class) +class SessionServiceTests : FunSpec({ + + test("session created on focus when current session invalid") { + /* Given */ + val currentTime = 1111L + + val configModelStoreMock = MockHelper.configModelStore() + val sessionModelStoreMock = MockHelper.sessionModelStore { + it.isValid = false + } + val spyCallback = spyk() + + val sessionService = SessionService(MockHelper.applicationService(), configModelStoreMock, sessionModelStoreMock, MockHelper.time(currentTime)) + sessionService.start() + sessionService.subscribe(spyCallback) + + /* When */ + sessionService.onFocus() + + /* Then */ + sessionModelStoreMock.get().isValid shouldBe true + sessionModelStoreMock.get().startTime shouldBe currentTime + sessionModelStoreMock.get().focusTime shouldBe currentTime + verify(exactly = 1) { spyCallback.onSessionStarted() } + } + + test("session focus time updated when current session valid") { + /* Given */ + val currentTime = 1111L + val startTime = 555L + + val configModelStoreMock = MockHelper.configModelStore() + val sessionModelStoreMock = MockHelper.sessionModelStore { + it.isValid = true + it.startTime = startTime + } + val spyCallback = spyk() + + val sessionService = SessionService(MockHelper.applicationService(), configModelStoreMock, sessionModelStoreMock, MockHelper.time(currentTime)) + sessionService.start() + sessionService.subscribe(spyCallback) + + /* When */ + sessionService.onFocus() + + /* Then */ + sessionModelStoreMock.get().isValid shouldBe true + sessionModelStoreMock.get().startTime shouldBe startTime + sessionModelStoreMock.get().focusTime shouldBe currentTime + verify(exactly = 1) { spyCallback.onSessionActive() } + } + + test("session active duration updated when unfocused") { + /* Given */ + val startTime = 555L + val focusTime = 666L + val startingDuration = 1000L + val currentTime = 1111L + + val configModelStoreMock = MockHelper.configModelStore() + val sessionModelStoreMock = MockHelper.sessionModelStore { + it.isValid = true + it.startTime = startTime + it.focusTime = focusTime + it.activeDuration = startingDuration + } + + val sessionService = SessionService(MockHelper.applicationService(), configModelStoreMock, sessionModelStoreMock, MockHelper.time(currentTime)) + sessionService.start() + + /* When */ + sessionService.onUnfocused() + + /* Then */ + sessionModelStoreMock.get().isValid shouldBe true + sessionModelStoreMock.get().startTime shouldBe startTime + sessionModelStoreMock.get().activeDuration shouldBe startingDuration + (currentTime - focusTime) + } + + test("session ended when background run") { + /* Given */ + val activeDuration = 555L + val currentTime = 1111L + + val configModelStoreMock = MockHelper.configModelStore() + val sessionModelStoreMock = MockHelper.sessionModelStore { + it.isValid = true + it.activeDuration = activeDuration + } + val spyCallback = spyk() + + val sessionService = SessionService(MockHelper.applicationService(), configModelStoreMock, sessionModelStoreMock, MockHelper.time(currentTime)) + sessionService.start() + sessionService.subscribe(spyCallback) + + /* When */ + sessionService.backgroundRun() + + /* Then */ + sessionModelStoreMock.get().isValid shouldBe false + verify(exactly = 1) { spyCallback.onSessionEnded(activeDuration) } + } +}) diff --git a/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/mocks/MockHelper.kt b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/mocks/MockHelper.kt new file mode 100644 index 0000000000..f1748cc72c --- /dev/null +++ b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/mocks/MockHelper.kt @@ -0,0 +1,72 @@ +package com.onesignal.core.tests.mocks + +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.device.IDeviceService +import com.onesignal.core.internal.models.ConfigModel +import com.onesignal.core.internal.models.ConfigModelStore +import com.onesignal.core.internal.models.SessionModel +import com.onesignal.core.internal.models.SessionModelStore +import com.onesignal.core.internal.time.ITime +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk + +/** + * Singleton which provides common mock services. + */ +internal object MockHelper { + fun time(time: Long): ITime { + val mockTime = mockk() + every { mockTime.currentTimeMillis } returns time + + return mockTime + } + + fun applicationService(): IApplicationService { + val mockAppService = mockk() + + every { mockAppService.addApplicationLifecycleHandler(any()) } just Runs + + return mockAppService + } + + const val DEFAULT_APP_ID = "appId" + fun configModelStore(action: ((ConfigModel) -> Unit)? = null): ConfigModelStore { + val configModel = ConfigModel() + + configModel.appId = DEFAULT_APP_ID + + if (action != null) { + action(configModel) + } + + val mockConfigStore = mockk() + + every { mockConfigStore.get() } returns configModel + + return mockConfigStore + } + + fun sessionModelStore(action: ((SessionModel) -> Unit)? = null): SessionModelStore { + val sessionModel = SessionModel() + + if (action != null) { + action(sessionModel) + } + + val mockSessionStore = mockk() + + every { mockSessionStore.get() } returns sessionModel + + return mockSessionStore + } + + const val DEFAULT_DEVICE_TYPE = 1 + + fun deviceService(): IDeviceService { + val deviceService = mockk() + every { deviceService.deviceType } returns DEFAULT_DEVICE_TYPE + return deviceService + } +} diff --git a/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/mocks/MockPreferencesService.kt b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/mocks/MockPreferencesService.kt new file mode 100644 index 0000000000..73f532c361 --- /dev/null +++ b/OneSignalSDK/onesignal/src/test/java/com/onesignal/core/tests/mocks/MockPreferencesService.kt @@ -0,0 +1,23 @@ +package com.onesignal.core.tests.mocks + +import com.onesignal.core.internal.preferences.IPreferencesService + +/** + * The mock preferences store allows the initialization of the preferences store values as needed, + * and will return any saved value in memory. + */ +internal class MockPreferencesService(map: Map? = null) : IPreferencesService { + private val _map: MutableMap = map?.toMutableMap() ?: mutableMapOf() + + override fun getString(store: String, key: String, defValue: String?): String? = (_map[key] as String?) ?: defValue + override fun getBool(store: String, key: String, defValue: Boolean?): Boolean? = (_map[key] as Boolean?) ?: defValue + override fun getInt(store: String, key: String, defValue: Int?): Int? = (_map[key] as Int?) ?: defValue + override fun getLong(store: String, key: String, defValue: Long?): Long? = (_map[key] as Long?) ?: defValue + override fun getStringSet(store: String, key: String, defValue: Set?): Set? = (_map[key] as Set?) ?: defValue + + override fun saveString(store: String, key: String, value: String?) { _map[key] = value } + override fun saveBool(store: String, key: String, value: Boolean?) { _map[key] = value } + override fun saveInt(store: String, key: String, value: Int?) { _map[key] = value } + override fun saveLong(store: String, key: String, value: Long?) { _map[key] = value } + override fun saveStringSet(store: String, key: String, value: Set?) { _map[key] = value } +}