diff --git a/analytics-kotlin-live/build.gradle b/analytics-kotlin-live/build.gradle index 21f8c10..117665f 100644 --- a/analytics-kotlin-live/build.gradle +++ b/analytics-kotlin-live/build.gradle @@ -41,9 +41,24 @@ dependencies { implementation 'androidx.core:core-ktx:1.13.0' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.11.0' - testImplementation 'junit:junit:4.13.2' + // TESTING + testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2' + androidTestImplementation 'io.mockk:mockk-android:1.12.2' + androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + + // Add JUnit5 dependencies. + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2' + + // Add JUnit4 legacy dependencies. + testImplementation 'junit:junit:4.13.2' + testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.8.2' + + // Roboelectric dependencies for JVM unit tests only + testImplementation 'org.robolectric:robolectric:4.16' + testImplementation 'androidx.test:core:1.5.0' } task sourcesJar(type: Jar) { archiveClassifier.set('sources') diff --git a/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/JSAnalyticsTest.kt b/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/JSAnalyticsTest.kt new file mode 100644 index 0000000..a5e1e0e --- /dev/null +++ b/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/JSAnalyticsTest.kt @@ -0,0 +1,503 @@ +package com.segment.analytics.liveplugins.kotlin + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.segment.analytics.kotlin.android.AndroidStorageProvider +import com.segment.analytics.kotlin.core.AliasEvent +import com.segment.analytics.kotlin.core.Analytics +import com.segment.analytics.kotlin.core.BaseEvent +import com.segment.analytics.kotlin.core.Configuration +import com.segment.analytics.kotlin.core.GroupEvent +import com.segment.analytics.kotlin.core.IdentifyEvent +import com.segment.analytics.kotlin.core.ScreenEvent +import com.segment.analytics.kotlin.core.TrackEvent +import com.segment.analytics.liveplugins.kotlin.utils.StubPlugin +import com.segment.analytics.liveplugins.kotlin.utils.testAnalytics +import com.segment.analytics.substrata.kotlin.JSScope +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlinx.coroutines.ExperimentalCoroutinesApi + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class JSAnalyticsTest { + + private lateinit var engine: JSScope + private lateinit var jsAnalytics: JSAnalytics + private var exceptionThrown: Throwable? = null + + // Global tracking variables for captured method calls + private val capturedTrackCalls = mutableListOf>() + private val capturedIdentifyCalls = mutableListOf>() + private val capturedScreenCalls = mutableListOf>() + private val capturedGroupCalls = mutableListOf>() + private val capturedAliasCalls = mutableListOf() + private var capturedFlushCalls = 0 + private var capturedResetCalls = 0 + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + @Before + fun setUp() { + exceptionThrown = null + + // Clear tracking variables + capturedTrackCalls.clear() + capturedIdentifyCalls.clear() + capturedScreenCalls.clear() + capturedGroupCalls.clear() + capturedAliasCalls.clear() + capturedFlushCalls = 0 + capturedResetCalls = 0 + + engine = JSScope{ exception -> + exceptionThrown = exception + } + + // Create a mock Analytics instance for testing using MockK + val mockAnalytics = createMockAnalytics() + jsAnalytics = JSAnalytics(mockAnalytics, engine) + + // Setup the engine similar to LivePlugins.configureEngine + engine.sync { + + // Export JSAnalytics to the engine + export(jsAnalytics, "Analytics", "analytics") + + // Evaluate the embedded JS scripts + evaluate(EmbeddedJS.ENUM_SETUP_SCRIPT) + evaluate(EmbeddedJS.LIVE_PLUGINS_BASE_SETUP_SCRIPT) + } + } + + private fun createMockAnalytics(): Analytics { + val analytics = testAnalytics( + Configuration( + writeKey = "123", + application = InstrumentationRegistry.getInstrumentation().targetContext, + storageProvider = AndroidStorageProvider + ), + testScope, testDispatcher + ) + // Create a custom capture plugin that doesn't require mocking + val plugin = object : StubPlugin() { + override fun track(payload: TrackEvent): TrackEvent { + capturedTrackCalls.add(payload.event to payload.properties) + return payload + } + + override fun reset() { + capturedResetCalls++ + } + + override fun screen(payload: ScreenEvent): BaseEvent? { + capturedScreenCalls.add(Triple(payload.name, payload.category, payload.properties)) + return payload + } + + override fun identify(payload: IdentifyEvent): IdentifyEvent { + capturedIdentifyCalls.add(payload.userId to payload.traits) + return payload + } + + override fun group(payload: GroupEvent): GroupEvent { + capturedGroupCalls.add(payload.groupId to payload.traits) + return payload + } + + override fun alias(payload: AliasEvent): AliasEvent { + capturedAliasCalls.add(payload.userId) + return payload + } + + override fun flush() { + capturedFlushCalls++ + super.flush() + } + } + + analytics.add(plugin) + + return analytics + } + + @Test + fun testJSAnalyticsBasicFunctionality() { + // First set up state through JavaScript calls + engine.sync { + evaluate("""analytics.identify("test-user-id");""") + evaluate("""analytics.track("test-event");""") + evaluate("""analytics.flush();""") + } + + // Now verify the properties reflect the JavaScript calls + assertNotNull("Anonymous ID should not be null", jsAnalytics.anonymousId) + assertTrue("Anonymous ID should not be empty", jsAnalytics.anonymousId.isNotEmpty()) + assertEquals("test-user-id", jsAnalytics.userId) + + // Verify the calls were captured by our plugin + assertEquals(1, capturedTrackCalls.size) + assertEquals("test-event", capturedTrackCalls[0].first) + + assertEquals(1, capturedIdentifyCalls.size) + assertEquals("test-user-id", capturedIdentifyCalls[0].first) + + assertEquals(1, capturedFlushCalls) + + assertNull("No exception should be thrown", exceptionThrown) + } + + @After + fun tearDown() { + if (exceptionThrown == null && ::engine.isInitialized) { + engine.release() + } + } + + @Test + fun testJSAnalyticsProperties() { + // First identify a user through JavaScript + engine.sync { + evaluate("""analytics.identify("test-user-id");""") + } + + val anonymousId = jsAnalytics.anonymousId + val userId = jsAnalytics.userId + + assertNotNull("Anonymous ID should not be null", anonymousId) + assertTrue("Anonymous ID should not be empty", anonymousId.isNotEmpty()) + assertEquals("test-user-id", userId) + + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testTrackFromJavaScript() { + engine.sync { + evaluate("""analytics.track("Test Event");""") + } + + // Verify that the track method was called with correct parameters from JavaScript + assertEquals(1, capturedTrackCalls.size) + assertEquals("Test Event", capturedTrackCalls[0].first) + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testTrackWithPropertiesFromJavaScript() { + engine.sync { + evaluate(""" + analytics.track("Test Event", { + property1: "value1", + property2: 42 + }); + """) + } + + // Verify that the track method was called with correct parameters from JavaScript + assertEquals(1, capturedTrackCalls.size) + assertEquals("Test Event", capturedTrackCalls[0].first) + + // Verify the properties JsonElement content + val properties = capturedTrackCalls[0].second as JsonElement + assertNotNull("Properties should be passed from JavaScript", properties) + assertTrue("Properties should be a JsonObject", properties is JsonObject) + + val propsObj = properties.jsonObject + assertEquals("value1", propsObj["property1"]?.jsonPrimitive?.content) + assertEquals(42, propsObj["property2"]?.jsonPrimitive?.content?.toInt()) + + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testIdentifyFromJavaScript() { + engine.sync { + evaluate("""analytics.identify("new-user-id");""") + } + + // Verify that the identify method was called with correct parameters from JavaScript + assertEquals(1, capturedIdentifyCalls.size) + assertEquals("new-user-id", capturedIdentifyCalls[0].first) + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testIdentifyWithTraitsFromJavaScript() { + engine.sync { + evaluate(""" + analytics.identify("new-user-id", { + name: "John Doe", + email: "john@example.com" + }); + """) + } + + // Verify that the identify method was called with traits from JavaScript + assertEquals(1, capturedIdentifyCalls.size) + assertEquals("new-user-id", capturedIdentifyCalls[0].first) + + // Verify the traits JsonElement content + val traits = capturedIdentifyCalls[0].second as JsonElement + assertNotNull("Traits should be passed from JavaScript", traits) + assertTrue("Traits should be a JsonObject", traits is JsonObject) + + val traitsObj = traits.jsonObject + assertEquals("John Doe", traitsObj["name"]?.jsonPrimitive?.content) + assertEquals("john@example.com", traitsObj["email"]?.jsonPrimitive?.content) + + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testScreenWithPropertiesFromJavaScript() { + engine.sync { + evaluate(""" + analytics.screen("Home Screen", "Navigation", { + loaded_time: 1.2, + user_type: "premium" + }); + """) + } + + // Verify that the screen method was called with properties from JavaScript + assertEquals(1, capturedScreenCalls.size) + assertEquals("Home Screen", capturedScreenCalls[0].first) + assertEquals("Navigation", capturedScreenCalls[0].second) + + // Since screen methods are handled by relaxed mock, we'll skip detailed property verification here + // but verify that the method was called correctly + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testGroupFromJavaScript() { + engine.sync { + evaluate("""analytics.group("group-123");""") + } + + // Verify that the group method was called with correct parameters from JavaScript + assertEquals(1, capturedGroupCalls.size) + assertEquals("group-123", capturedGroupCalls[0].first) + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testGroupWithTraitsFromJavaScript() { + engine.sync { + evaluate(""" + analytics.group("group-123", { + name: "Acme Inc", + plan: "enterprise" + }); + """) + } + + // Verify that the group method was called with traits from JavaScript + assertEquals(1, capturedGroupCalls.size) + assertEquals("group-123", capturedGroupCalls[0].first) + + // Verify the traits JsonElement content + val traits = capturedGroupCalls[0].second as JsonElement + assertNotNull("Traits should be passed from JavaScript", traits) + assertTrue("Traits should be a JsonObject", traits is JsonObject) + + val traitsObj = traits.jsonObject + assertEquals("Acme Inc", traitsObj["name"]?.jsonPrimitive?.content) + assertEquals("enterprise", traitsObj["plan"]?.jsonPrimitive?.content) + + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testAliasFromJavaScript() { + engine.sync { + evaluate("""analytics.alias("new-identity");""") + } + + // Verify that the alias method was called with correct parameters from JavaScript + assertEquals(1, capturedAliasCalls.size) + assertEquals("new-identity", capturedAliasCalls[0]) + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testFlushFromJavaScript() { + engine.sync { + evaluate("""analytics.flush();""") + } + + // Verify that the flush method was called from JavaScript + assertEquals(1, capturedFlushCalls) + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testResetFromJavaScript() { + engine.sync { + evaluate("""analytics.reset();""") + } + + // Verify that the reset method was called from JavaScript + assertEquals(1, capturedResetCalls) + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testAnonymousIdPropertyFromJavaScript() { + engine.sync { + val result = evaluate("""analytics.anonymousId;""") + // Verify that JavaScript returns the same anonymous ID as Kotlin + assertEquals(jsAnalytics.anonymousId, result) + } + + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testUserIdPropertyFromJavaScript() { + engine.sync { + // First set the user ID through JavaScript + evaluate("""analytics.identify("test-user-id");""") + // Then verify it's accessible from JavaScript + val result = evaluate("""analytics.userId;""") + assertEquals("test-user-id", result) + } + + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testAddPluginFromJavaScript() { + engine.sync { + val result = evaluate(""" + class AnonymizeIPs extends LivePlugin { + execute(event) { + event.context.ip = "xxx.xxx.xxx.xxx"; + return super.execute(event) + } + } + let plugin = new AnonymizeIPs(); + analytics.add(plugin); + """) + // The result should be true since our real analytics supports plugin addition + assertEquals(true, result) + } + + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testLivePluginTypeEnumFromJavaScript() { + engine.sync { + val before = evaluate("LivePluginType.before;") + val enrichment = evaluate("LivePluginType.enrichment;") + val after = evaluate("LivePluginType.after;") + + assertEquals(0, before) + assertEquals(1, enrichment) + assertEquals(2, after) + } + + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testLivePluginClassFromJavaScript() { + engine.sync { + val result = evaluate(""" + var plugin = new LivePlugin(LivePluginType.enrichment, null); + typeof plugin; + """) + assertEquals("object", result) + } + + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testComplexJavaScriptInteraction() { + engine.sync { + evaluate(""" + // Test a complex interaction with multiple method calls + analytics.identify("complex-user", { + name: "Complex User", + age: 30 + }); + + analytics.track("Complex Event", { + category: "test", + value: 100 + }); + + analytics.screen("Complex Screen", "Category", { + loaded_time: 2.5 + }); + + analytics.group("complex-group", { + plan: "premium" + }); + + analytics.alias("complex-alias"); + analytics.flush(); + """) + } + + // Verify all method calls were captured correctly from the JavaScript interaction + + // Verify identify call with traits + assertEquals(1, capturedIdentifyCalls.size) + assertEquals("complex-user", capturedIdentifyCalls[0].first) + val identifyTraits = capturedIdentifyCalls[0].second as JsonElement + assertNotNull("Traits should be passed from JavaScript", identifyTraits) + assertTrue("Traits should be a JsonObject", identifyTraits is JsonObject) + val identifyTraitsObj = identifyTraits.jsonObject + assertEquals("Complex User", identifyTraitsObj["name"]?.jsonPrimitive?.content) + assertEquals(30, identifyTraitsObj["age"]?.jsonPrimitive?.content?.toInt()) + + // Verify track call with properties + assertEquals(1, capturedTrackCalls.size) + assertEquals("Complex Event", capturedTrackCalls[0].first) + val trackProperties = capturedTrackCalls[0].second as JsonElement + assertNotNull("Properties should be passed from JavaScript", trackProperties) + assertTrue("Properties should be a JsonObject", trackProperties is JsonObject) + val trackPropsObj = trackProperties.jsonObject + assertEquals("test", trackPropsObj["category"]?.jsonPrimitive?.content) + assertEquals(100, trackPropsObj["value"]?.jsonPrimitive?.content?.toInt()) + + // Verify screen call (relaxed mock handling) + assertEquals(1, capturedScreenCalls.size) + assertEquals("Complex Screen", capturedScreenCalls[0].first) + assertEquals("Category", capturedScreenCalls[0].second) + + // Verify group call with traits + assertEquals(1, capturedGroupCalls.size) + assertEquals("complex-group", capturedGroupCalls[0].first) + val groupTraits = capturedGroupCalls[0].second as JsonElement + assertNotNull("Traits should be passed from JavaScript", groupTraits) + assertTrue("Traits should be a JsonObject", groupTraits is JsonObject) + val groupTraitsObj = groupTraits.jsonObject + assertEquals("premium", groupTraitsObj["plan"]?.jsonPrimitive?.content) + + // Verify alias call + assertEquals(1, capturedAliasCalls.size) + assertEquals("complex-alias", capturedAliasCalls[0]) + + // Verify flush call + assertEquals(1, capturedFlushCalls) + + assertNull("No exception should be thrown", exceptionThrown) + } +} \ No newline at end of file diff --git a/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/LivePluginTest.kt b/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/LivePluginTest.kt new file mode 100644 index 0000000..82a2f8e --- /dev/null +++ b/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/LivePluginTest.kt @@ -0,0 +1,126 @@ +package com.segment.analytics.liveplugins.kotlin + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.segment.analytics.kotlin.android.AndroidStorageProvider +import com.segment.analytics.kotlin.core.Analytics +import com.segment.analytics.kotlin.core.Configuration +import com.segment.analytics.liveplugins.kotlin.utils.testAnalytics +import com.segment.analytics.substrata.kotlin.JSScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class LivePluginTest { + + private lateinit var engine: JSScope + private lateinit var jsAnalytics: JSAnalytics + private var exceptionThrown: Throwable? = null + private val logger = Logger() + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + @Before + fun setUp() { + exceptionThrown = null + + engine = JSScope{ exception -> + exceptionThrown = exception + } + + // Create a mock Analytics instance for testing using MockK + val mockAnalytics = createMockAnalytics() + jsAnalytics = JSAnalytics(mockAnalytics, engine) + + // Setup the engine similar to LivePlugins.configureEngine + engine.sync { + + // Export JSAnalytics to the engine + export(jsAnalytics, "Analytics", "analytics") + export(logger, "Logger", "logger") + + // Evaluate the embedded JS scripts + evaluate(EmbeddedJS.ENUM_SETUP_SCRIPT) + evaluate(EmbeddedJS.LIVE_PLUGINS_BASE_SETUP_SCRIPT) + } + } + + private fun createMockAnalytics(): Analytics { + val analytics = testAnalytics( + Configuration( + writeKey = "123", + application = InstrumentationRegistry.getInstrumentation().targetContext, + storageProvider = AndroidStorageProvider + ), + testScope, testDispatcher + ) + + return analytics + } + + @After + fun tearDown() { + if (exceptionThrown == null && ::engine.isInitialized) { + engine.release() + } + } + + @Test + fun testLivePlugin() { + engine.sync { + evaluate(""" + class AnonymizeIPs extends LivePlugin { + update(settings, type) { + logger.updateCalled++ + } + + execute(event) { + logger.executeCalled++ + return super.execute(event) + } + } + let plugin = new AnonymizeIPs(); + analytics.add(plugin); + + + // Test a complex interaction with multiple method calls + analytics.identify("complex-user", { + name: "Complex User", + age: 30 + }); + + analytics.track("Complex Event", { + category: "test", + value: 100 + }); + + analytics.screen("Complex Screen", "Category", { + loaded_time: 2.5 + }); + + analytics.group("complex-group", { + plan: "premium" + }); + + analytics.alias("complex-alias"); + """) + } + + assertNull("No exception should be thrown", exceptionThrown) + assertEquals(1, logger.updateCalled) + assertEquals(5, logger.executeCalled) + } + + class Logger { + var updateCalled = 0 + var executeCalled = 0 + } +} \ No newline at end of file diff --git a/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/LivePluginsTest.kt b/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/LivePluginsTest.kt new file mode 100644 index 0000000..66165bd --- /dev/null +++ b/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/LivePluginsTest.kt @@ -0,0 +1,575 @@ +package com.segment.analytics.liveplugins.kotlin + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.segment.analytics.kotlin.android.AndroidStorageProvider +import com.segment.analytics.kotlin.core.Analytics +import com.segment.analytics.kotlin.core.Configuration +import com.segment.analytics.kotlin.core.Settings +import com.segment.analytics.kotlin.core.emptyJsonObject +import com.segment.analytics.kotlin.core.platform.Plugin +import com.segment.analytics.liveplugins.kotlin.utils.TestCoroutineConfiguration +import com.segment.analytics.liveplugins.kotlin.utils.testAnalytics +import com.segment.analytics.substrata.kotlin.JSExceptionHandler +import com.segment.analytics.substrata.kotlin.JSScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.ByteArrayInputStream +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class LivePluginsTest { + + private lateinit var livePlugins: LivePlugins + private lateinit var analytics: Analytics + private lateinit var context: Context + private lateinit var sharedPreferences: SharedPreferences + private var exceptionThrown: Throwable? = null + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val capturedDependents = mutableListOf() + + @Before + fun setUp() { + exceptionThrown = null + capturedDependents.clear() + + context = InstrumentationRegistry.getInstrumentation().targetContext + analytics = testAnalytics( + Configuration( + writeKey = "test-write-key", + application = context, + storageProvider = AndroidStorageProvider + ), + testScope, testDispatcher + ) + + livePlugins = LivePlugins(exceptionHandler = { exception -> + exceptionThrown = exception + }) + + // Clear the loaded state for each test + LivePlugins.loaded = false + + // Clear SharedPreferences cache to ensure clean state for each test + val sharedPrefs = context.getSharedPreferences( + "analytics-liveplugins-test-write-key", + Context.MODE_PRIVATE + ) + sharedPrefs.edit().clear().apply() + } + + @After + fun tearDown() { + if (::livePlugins.isInitialized) { + try { + livePlugins.release() + } catch (_: Exception) { + // Ignore exceptions during tearDown release - the test might have already released it + } + } + LivePlugins.loaded = false + } + + @Test + fun testLivePluginsCreation() { + assertNotNull("LivePlugins should be created", livePlugins) + assertEquals("Plugin type should be Utility", Plugin.Type.Utility, livePlugins.type) + assertNotNull("Engine should be initialized", livePlugins.engine) + assertFalse("Loaded should be false initially", LivePlugins.loaded) + } + + @Test + fun testLivePluginsWithFallbackFile() { + val fallbackContent = "console.log('fallback script');" + val fallbackStream = ByteArrayInputStream(fallbackContent.toByteArray()) + + val livePluginsWithFallback = LivePlugins( + fallbackFile = fallbackStream, + forceFallbackFile = true + ) + + assertNotNull("LivePlugins with fallback should be created", livePluginsWithFallback) + livePluginsWithFallback.release() + } + + @Test + fun testLivePluginsWithLocalJS() { + val localJSContent = "console.log('local js script');" + val localJSStream = ByteArrayInputStream(localJSContent.toByteArray()) + + val livePluginsWithLocalJS = LivePlugins( + localJS = listOf(localJSStream) + ) + + assertNotNull("LivePlugins with local JS should be created", livePluginsWithLocalJS) + livePluginsWithLocalJS.release() + } + + @Test + fun testSetupWithValidAnalytics() { + livePlugins.setup(analytics) + + assertEquals("Analytics should be assigned", analytics, livePlugins.analytics) + assertNotNull("LivePluginsHolder should contain weak reference", LivePluginsHolder.plugin) + assertEquals("LivePluginsHolder should reference our instance", livePlugins, LivePluginsHolder.plugin?.get()) + + assertNull("No exception should be thrown during setup", exceptionThrown) + } + + @Test + fun testSetupSkipsWhenExistingLivePluginsFound() { + val existingLivePlugins = LivePlugins() + analytics.add(existingLivePlugins) + + livePlugins.setup(analytics) + + // Should not crash and should skip setup + assertNull("No exception should be thrown", exceptionThrown) + + existingLivePlugins.release() + } + + @Test + fun testEngineConfiguration() { + livePlugins.setup(analytics) + + // Verify that the engine is configured with JSAnalytics + livePlugins.engine.sync { + val result = evaluate("typeof analytics") + assertEquals("analytics should be available in JS", "object", result) + } + + // Verify LivePluginType enum is available + livePlugins.engine.sync { + val beforeType = evaluate("LivePluginType.before") + val enrichmentType = evaluate("LivePluginType.enrichment") + val afterType = evaluate("LivePluginType.after") + + assertEquals("Before type should be 0", 0, beforeType) + assertEquals("Enrichment type should be 1", 1, enrichmentType) + assertEquals("After type should be 2", 2, afterType) + } + + // Verify LivePlugin class is available + livePlugins.engine.sync { + val result = evaluate("typeof LivePlugin") + assertEquals("LivePlugin class should be available", "function", result) + } + + assertNull("No exception should be thrown during engine configuration", exceptionThrown) + } + + @Test + fun testUpdateWithoutSettings() { + livePlugins.setup(analytics) + + val emptySettings = Settings(emptyJsonObject) + livePlugins.update(emptySettings, Plugin.UpdateType.Initial) + + assertTrue("Loaded should be true after update", LivePlugins.loaded) + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testUpdateWithEdgeFunctionSettings() { + livePlugins.setup(analytics) + + val edgeFunctionSettings = buildJsonObject { + put("version", 2) + put("downloadURL", "https://example.com/plugins.js") + } + + val settings = Settings(integrations = emptyJsonObject, edgeFunction = edgeFunctionSettings) + livePlugins.update(settings, Plugin.UpdateType.Initial) + + assertTrue("Loaded should be true after update", LivePlugins.loaded) + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testUpdateRemovesDuplicateInstance() { + val existingLivePlugins = LivePlugins() + analytics.add(existingLivePlugins) + + livePlugins.setup(analytics) + livePlugins.update(Settings(emptyJsonObject), Plugin.UpdateType.Initial) + + // The duplicate instance should be removed + assertFalse("Analytics should not contain our instance", analytics.findAll(LivePlugins::class).contains(livePlugins)) + + existingLivePlugins.release() + } + + @Test + fun testUpdateSkipsWhenAlreadyLoaded() { + livePlugins.setup(analytics) + + // First update + livePlugins.update(Settings(emptyJsonObject), Plugin.UpdateType.Initial) + assertTrue("Should be loaded after first update", LivePlugins.loaded) + + // Second update should be skipped + val settingsWithEdgeFunction = Settings( + integrations = emptyJsonObject, + edgeFunction = buildJsonObject { + put("version", 5) + put("downloadURL", "https://example.com/new-plugins.js") + } + ) + + livePlugins.update(settingsWithEdgeFunction, Plugin.UpdateType.Refresh) + + // Should still be loaded but not process the update again + assertTrue("Should still be loaded", LivePlugins.loaded) + assertNull("No exception should be thrown", exceptionThrown) + } + + @Test + fun testAddDependentWhenNotLoaded() { + livePlugins.setup(analytics) + + val mockDependent = object : LivePluginsDependent { + override fun prepare(engine: JSScope) { + capturedDependents.add(this) + } + + override fun readyToStart() { + // Not called when not loaded + } + } + + livePlugins.addDependent(mockDependent) + + // Dependent should be added but not notified yet + assertEquals("Should have 1 dependent", 1, livePlugins.dependents.size) + assertEquals("Should not call prepare yet", 0, capturedDependents.size) + } + + @Test + fun testAddDependentWhenLoaded() { + livePlugins.setup(analytics) + livePlugins.update(Settings(emptyJsonObject), Plugin.UpdateType.Initial) + + var prepareCallCount = 0 + var readyToStartCallCount = 0 + + val mockDependent = object : LivePluginsDependent { + override fun prepare(engine: JSScope) { + prepareCallCount++ + } + + override fun readyToStart() { + readyToStartCallCount++ + } + } + + livePlugins.addDependent(mockDependent) + + // Dependent should be added and notified immediately + assertEquals("Should have 1 dependent", 1, livePlugins.dependents.size) + assertEquals("Should call prepare once", 1, prepareCallCount) + assertEquals("Should call readyToStart once", 1, readyToStartCallCount) + } + + @Test + fun testShouldUpdateLivePluginWithNoCache() { + livePlugins.setup(analytics) + + val newSettings = LivePluginsSettings(version = 1, downloadURL = "https://example.com/plugins.js") + + // Use reflection to test private method + val method = LivePlugins::class.java.getDeclaredMethod("shouldUpdateLivePlugin", LivePluginsSettings::class.java) + method.isAccessible = true + val result = method.invoke(livePlugins, newSettings) as Boolean + + assertTrue("Should update when no cache exists", result) + } + + @Test + fun testShouldUpdateLivePluginWithOlderCache() { + livePlugins.setup(analytics) + + // Simulate cached settings with older version + val context = analytics.configuration.application as Context + val sharedPrefs = context.getSharedPreferences( + "analytics-liveplugins-test-write-key", + Context.MODE_PRIVATE + ) + sharedPrefs.edit().putString( + LivePlugins.SHARED_PREFS_KEY, + """{"version":1,"downloadURL":"https://old.com/plugins.js"}""" + ).apply() + + val newSettings = LivePluginsSettings(version = 2, downloadURL = "https://new.com/plugins.js") + + // Use reflection to test private method + val method = LivePlugins::class.java.getDeclaredMethod("shouldUpdateLivePlugin", LivePluginsSettings::class.java) + method.isAccessible = true + val result = method.invoke(livePlugins, newSettings) as Boolean + + assertTrue("Should update when new version is higher", result) + + // Clean up + sharedPrefs.edit().clear().apply() + } + + @Test + fun testShouldNotUpdateLivePluginWithSameOrNewerCache() { + livePlugins.setup(analytics) + + // Simulate cached settings with same version + val context = analytics.configuration.application as Context + val sharedPrefs = context.getSharedPreferences( + "analytics-liveplugins-test-write-key", + Context.MODE_PRIVATE + ) + sharedPrefs.edit().putString( + LivePlugins.SHARED_PREFS_KEY, + """{"version":2,"downloadURL":"https://current.com/plugins.js"}""" + ).apply() + + val newSettings = LivePluginsSettings(version = 2, downloadURL = "https://same.com/plugins.js") + + // Use reflection to test private method + val method = LivePlugins::class.java.getDeclaredMethod("shouldUpdateLivePlugin", LivePluginsSettings::class.java) + method.isAccessible = true + val result = method.invoke(livePlugins, newSettings) as Boolean + + assertFalse("Should not update when version is same or older", result) + + // Clean up + sharedPrefs.edit().clear().apply() + } + + @Test + fun testLivePluginsSettingsDataClass() { + val defaultSettings = LivePluginsSettings() + assertEquals("Default version should be -1", -1, defaultSettings.version) + assertEquals("Default download URL should be empty", "", defaultSettings.downloadURL) + + val customSettings = LivePluginsSettings(version = 5, downloadURL = "https://example.com/plugins.js") + assertEquals("Custom version should be 5", 5, customSettings.version) + assertEquals("Custom download URL should match", "https://example.com/plugins.js", customSettings.downloadURL) + } + + @Test + fun testCompanionObjectConstants() { + assertEquals("File name constant should match", "livePlugins.js", LivePlugins.LIVE_PLUGINS_FILE_NAME) + assertEquals("Shared prefs key should match", "LivePlugins", LivePlugins.SHARED_PREFS_KEY) + } + + @Test + fun testRelease() { + livePlugins.setup(analytics) + + // Engine should be functional before release + livePlugins.engine.sync { + val result = evaluate("1 + 1") + assertEquals("Engine should work before release", 2, result) + } + + livePlugins.release() + + // After release, engine operations should not work (or should be handled gracefully) + assertNull("No exception should be thrown during release", exceptionThrown) + } + + @Test + fun testFallbackFileUsage() { + val fallbackContent = "console.log('fallback executed');" + val fallbackStream = ByteArrayInputStream(fallbackContent.toByteArray()) + + val livePluginsWithFallback = LivePlugins( + fallbackFile = fallbackStream, + forceFallbackFile = true + ) + + livePluginsWithFallback.setup(analytics) + livePluginsWithFallback.update(Settings(emptyJsonObject), Plugin.UpdateType.Initial) + + // Verify fallback file was used by checking if file exists + val context = analytics.configuration.application as Context + val storageDirectory = context.getDir("segment-data", Context.MODE_PRIVATE) + val livePluginFile = File(storageDirectory, LivePlugins.LIVE_PLUGINS_FILE_NAME) + + assertTrue("LivePlugin file should exist after fallback", livePluginFile.exists()) + + livePluginsWithFallback.release() + + // Clean up + livePluginFile.delete() + } + + @Test + fun testLocalJSExecution() { + val localJSContent = "console.log('local JS executed');" + val localJSStream = ByteArrayInputStream(localJSContent.toByteArray()) + + val livePluginsWithLocalJS = LivePlugins( + localJS = listOf(localJSStream) + ) + + livePluginsWithLocalJS.setup(analytics) + livePluginsWithLocalJS.update(Settings(emptyJsonObject), Plugin.UpdateType.Initial) + + assertTrue("Should be loaded successfully", LivePlugins.loaded) + + livePluginsWithLocalJS.release() + } + + @Test + fun testMultipleDependents() { + // Create a valid JavaScript content that will load successfully + val fallbackContent = """ + // Valid JavaScript that creates a simple plugin + class TestPlugin extends LivePlugin { + constructor() { + super(LivePluginType.enrichment, null); + } + + track(event) { + console.log('TestPlugin track called'); + return event; + } + } + + // Register the plugin + analytics.add(new TestPlugin()); + """.trimIndent() + + val fallbackStream = ByteArrayInputStream(fallbackContent.toByteArray()) + + val livePluginsWithFallback = LivePlugins( + fallbackFile = fallbackStream, + forceFallbackFile = true + ) + + livePluginsWithFallback.setup(analytics) + + var dependent1PrepareCount = 0 + var dependent1ReadyCount = 0 + var dependent2PrepareCount = 0 + var dependent2ReadyCount = 0 + + val dependent1 = object : LivePluginsDependent { + override fun prepare(engine: JSScope) { dependent1PrepareCount++ } + override fun readyToStart() { dependent1ReadyCount++ } + } + + val dependent2 = object : LivePluginsDependent { + override fun prepare(engine: JSScope) { dependent2PrepareCount++ } + override fun readyToStart() { dependent2ReadyCount++ } + } + + livePluginsWithFallback.addDependent(dependent1) + livePluginsWithFallback.addDependent(dependent2) + + assertEquals("Should have 2 dependents", 2, livePluginsWithFallback.dependents.size) + + // Update to trigger loading - this should complete successfully and call readyToStart + livePluginsWithFallback.update(Settings(emptyJsonObject), Plugin.UpdateType.Initial) + + // Give some time for async loading to complete + Thread.sleep(2000) + + assertEquals("Dependent 1 prepare should be called once", 1, dependent1PrepareCount) + assertEquals("Dependent 1 ready should be called once", 1, dependent1ReadyCount) + assertEquals("Dependent 2 prepare should be called once", 1, dependent2PrepareCount) + assertEquals("Dependent 2 ready should be called once", 1, dependent2ReadyCount) + + livePluginsWithFallback.release() + + // Clean up the created file + val context = analytics.configuration.application as Context + val storageDirectory = context.getDir("segment-data", Context.MODE_PRIVATE) + val livePluginFile = File(storageDirectory, LivePlugins.LIVE_PLUGINS_FILE_NAME) + livePluginFile.delete() + } + + @Test + fun testAddDependentsAfterLoading() { + // Create a valid JavaScript content that will load successfully + val fallbackContent = """ + // Valid JavaScript that creates a simple plugin + class TestPlugin extends LivePlugin { + constructor() { + super(LivePluginType.enrichment, null); + } + + track(event) { + console.log('TestPlugin track called'); + return event; + } + } + + // Register the plugin + analytics.add(new TestPlugin()); + """.trimIndent() + + val fallbackStream = ByteArrayInputStream(fallbackContent.toByteArray()) + + val livePluginsWithFallback = LivePlugins( + fallbackFile = fallbackStream, + forceFallbackFile = true + ) + + livePluginsWithFallback.setup(analytics) + + // First, trigger loading WITHOUT any dependents + livePluginsWithFallback.update(Settings(emptyJsonObject), Plugin.UpdateType.Initial) + + // Give some time for async loading to complete + Thread.sleep(2000) + + assertTrue("Should be loaded after update", LivePlugins.loaded) + + // Now add dependents AFTER loading is complete + var dependent1PrepareCount = 0 + var dependent1ReadyCount = 0 + var dependent2PrepareCount = 0 + var dependent2ReadyCount = 0 + + val dependent1 = object : LivePluginsDependent { + override fun prepare(engine: JSScope) { dependent1PrepareCount++ } + override fun readyToStart() { dependent1ReadyCount++ } + } + + val dependent2 = object : LivePluginsDependent { + override fun prepare(engine: JSScope) { dependent2PrepareCount++ } + override fun readyToStart() { dependent2ReadyCount++ } + } + + // Add dependents after loading - they should be notified immediately + livePluginsWithFallback.addDependent(dependent1) + livePluginsWithFallback.addDependent(dependent2) + + assertEquals("Should have 2 dependents", 2, livePluginsWithFallback.dependents.size) + + // Since loading is already complete, both prepare and readyToStart should be called immediately + assertEquals("Dependent 1 prepare should be called immediately", 1, dependent1PrepareCount) + assertEquals("Dependent 1 ready should be called immediately", 1, dependent1ReadyCount) + assertEquals("Dependent 2 prepare should be called immediately", 1, dependent2PrepareCount) + assertEquals("Dependent 2 ready should be called immediately", 1, dependent2ReadyCount) + + livePluginsWithFallback.release() + + // Clean up the created file + val context = analytics.configuration.application as Context + val storageDirectory = context.getDir("segment-data", Context.MODE_PRIVATE) + val livePluginFile = File(storageDirectory, LivePlugins.LIVE_PLUGINS_FILE_NAME) + livePluginFile.delete() + } +} \ No newline at end of file diff --git a/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/utils/MockPreferences.kt b/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/utils/MockPreferences.kt new file mode 100644 index 0000000..938f44b --- /dev/null +++ b/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/utils/MockPreferences.kt @@ -0,0 +1,134 @@ +package com.segment.analytics.liveplugins.kotlin.utils + +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import androidx.annotation.Nullable + +/** + * Mock implementation of shared preference, which just saves data in memory using map. + */ +class MemorySharedPreferences : SharedPreferences { + internal val preferenceMap: HashMap = HashMap() + private val preferenceEditor: MockSharedPreferenceEditor + override fun getAll(): Map { + return preferenceMap + } + + @Nullable + override fun getString(s: String, @Nullable s1: String?): String? { + return try { + preferenceMap[s] as String? + } catch(ex: Exception) { + s1 + } + } + + @Nullable + override fun getStringSet(s: String, @Nullable set: Set?): Set? { + return try { + preferenceMap[s] as Set? + } catch(ex: Exception) { + set + } + } + + override fun getInt(s: String, i: Int): Int { + return try { + preferenceMap[s] as Int + } catch(ex: Exception) { + i + } + } + + override fun getLong(s: String, l: Long): Long { + return try { + preferenceMap[s] as Long + } catch(ex: Exception) { + l + } + } + + override fun getFloat(s: String, v: Float): Float { + return try { + preferenceMap[s] as Float + } catch(ex: Exception) { + v + } + } + + override fun getBoolean(s: String, b: Boolean): Boolean { + return try { + preferenceMap[s] as Boolean + } catch(ex: Exception) { + b + } + } + + override fun contains(s: String): Boolean { + return preferenceMap.containsKey(s) + } + + override fun edit(): SharedPreferences.Editor { + return preferenceEditor + } + + override fun registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener: OnSharedPreferenceChangeListener) {} + override fun unregisterOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener: OnSharedPreferenceChangeListener) {} + class MockSharedPreferenceEditor(private val preferenceMap: HashMap) : + SharedPreferences.Editor { + override fun putString(s: String, @Nullable s1: String?): SharedPreferences.Editor { + preferenceMap[s] = s1 + return this + } + + override fun putStringSet( + s: String, + @Nullable set: Set? + ): SharedPreferences.Editor { + preferenceMap[s] = set + return this + } + + override fun putInt(s: String, i: Int): SharedPreferences.Editor { + preferenceMap[s] = i + return this + } + + override fun putLong(s: String, l: Long): SharedPreferences.Editor { + preferenceMap[s] = l + return this + } + + override fun putFloat(s: String, v: Float): SharedPreferences.Editor { + preferenceMap[s] = v + return this + } + + override fun putBoolean(s: String, b: Boolean): SharedPreferences.Editor { + preferenceMap[s] = b + return this + } + + override fun remove(s: String): SharedPreferences.Editor { + preferenceMap.remove(s) + return this + } + + override fun clear(): SharedPreferences.Editor { + preferenceMap.clear() + return this + } + + override fun commit(): Boolean { + return true + } + + override fun apply() { + // Nothing to do, everything is saved in memory. + } + } + + init { + preferenceEditor = MockSharedPreferenceEditor(preferenceMap) + } +} \ No newline at end of file diff --git a/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/utils/Mocks.kt b/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/utils/Mocks.kt new file mode 100644 index 0000000..120ca20 --- /dev/null +++ b/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/utils/Mocks.kt @@ -0,0 +1,96 @@ +package com.segment.analytics.liveplugins.kotlin.utils + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import com.segment.analytics.kotlin.core.* +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.spyk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import sovran.kotlin.Store +import java.io.ByteArrayInputStream +import java.io.File +import java.net.HttpURLConnection +import kotlin.coroutines.CoroutineContext + +fun mockAnalytics(testScope: TestScope, testDispatcher: TestDispatcher): Analytics { + val mock = mockk(relaxed = true) + val mockStore = spyStore(testScope, testDispatcher) + every { mock.store } returns mockStore + every { mock.analyticsScope } returns testScope + every { mock.fileIODispatcher } returns testDispatcher + every { mock.networkIODispatcher } returns testDispatcher + every { mock.analyticsDispatcher } returns testDispatcher + return mock +} + +fun testAnalytics(configuration: Configuration, testScope: TestScope, testDispatcher: TestDispatcher): Analytics { + return object : Analytics(configuration, TestCoroutineConfiguration(testScope, testDispatcher)) {} +} + +fun mockContext(): Context { + val mockPrefs = MemorySharedPreferences() + val packageInfo = PackageInfo() + packageInfo.versionCode = 100 + packageInfo.versionName = "1.0.0" + + val mockPkgMgr = mockk() + every { mockPkgMgr.getPackageInfo("com.foo", 0) } returns packageInfo + val mock = mockk { + every { getSharedPreferences(any(), any()) } returns mockPrefs + every { getDir(any(), any()) } returns File("/tmp/analytics-android-test/") + every { packageName } returns "com.foo" + every { packageManager } returns mockPkgMgr + } + return mock +} + +fun clearPersistentStorage() { + File("/tmp/analytics-android-test/").deleteRecursively() +} + +fun spyStore(scope: CoroutineScope, dispatcher: CoroutineDispatcher): Store { + val store = spyk(Store()) + every { store getProperty "sovranScope" } propertyType CoroutineScope::class returns scope + every { store getProperty "syncQueue" } propertyType CoroutineContext::class returns dispatcher + every { store getProperty "updateQueue" } propertyType CoroutineContext::class returns dispatcher + return store +} + +fun mockHTTPClient() { + mockkConstructor(HTTPClient::class) + val settingsStream = ByteArrayInputStream( + """ + {"integrations":{"Segment.io":{"apiKey":"1vNgUqwJeCHmqgI9S1sOm9UHCyfYqbaQ"}},"plan":{},"edgeFunction":{}} + """.trimIndent().toByteArray() + ) + val httpConnection: HttpURLConnection = mockk() + val connection = object : Connection(httpConnection, settingsStream, null) {} + every { anyConstructed().settings("cdn-settings.segment.com/v1") } returns connection +} + +class TestCoroutineConfiguration( + val testScope: TestScope, + val testDispatcher: TestDispatcher +) : CoroutineConfiguration { + + override val store: Store = + spyStore(testScope, testDispatcher) + + override val analyticsScope: CoroutineScope + get() = testScope + + override val analyticsDispatcher: CoroutineDispatcher + get() = testDispatcher + + override val networkIODispatcher: CoroutineDispatcher + get() = testDispatcher + + override val fileIODispatcher: CoroutineDispatcher + get() = testDispatcher +} \ No newline at end of file diff --git a/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/utils/Plugins.kt b/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/utils/Plugins.kt new file mode 100644 index 0000000..976df12 --- /dev/null +++ b/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/utils/Plugins.kt @@ -0,0 +1,81 @@ +package com.segment.analytics.liveplugins.kotlin.utils + +import com.segment.analytics.kotlin.core.* +import com.segment.analytics.kotlin.core.platform.* + +/** + * An analytics plugin that can be used to test features + * Current capabilities + * - is a `Before` plugin so is guaranteed to be run + * - can add a closure that will be run if any hook is executed + * - has a boolean state `ran` that can be used to verify if a particular hook was executed + * - has a `reset()` function to reset state between tests + */ +class TestRunPlugin(var closure: (BaseEvent?) -> Unit): EventPlugin { + override val type: Plugin.Type = Plugin.Type.Before + override lateinit var analytics: Analytics + var ran = false + + override fun reset() { + ran = false + } + + fun updateState(ran: Boolean) { + this.ran = ran + } + + override fun execute(event: BaseEvent): BaseEvent { + super.execute(event) + updateState(true) + return event + } + + override fun track(payload: TrackEvent): BaseEvent { + closure(payload) + updateState(true) + return payload + } + + override fun identify(payload: IdentifyEvent): BaseEvent { + closure(payload) + updateState(true) + return payload + } + + override fun screen(payload: ScreenEvent): BaseEvent { + closure(payload) + updateState(true) + return payload + } + + override fun group(payload: GroupEvent): BaseEvent { + closure(payload) + updateState(true) + return payload + } + + override fun alias(payload: AliasEvent): BaseEvent { + closure(payload) + updateState(true) + return payload + } +} + +/** + * An analytics plugin that is a simple pass-through plugin. Ideally to be used to verify + * if particular hooks are run via mockk's `verify` + */ +open class StubPlugin : EventPlugin { + override val type: Plugin.Type = Plugin.Type.Before + override lateinit var analytics: Analytics +} + +open class StubAfterPlugin : EventPlugin { + override val type: Plugin.Type = Plugin.Type.After + override lateinit var analytics: Analytics +} + +open class StubDestinationPlugin : DestinationPlugin() { + override val key: String = "StubDestination" + override fun isDestinationEnabled(event: BaseEvent?) = true +} \ No newline at end of file diff --git a/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/LivePlugins.kt b/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/LivePlugins.kt index 227f032..4157bd9 100644 --- a/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/LivePlugins.kt +++ b/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/LivePlugins.kt @@ -57,7 +57,7 @@ class LivePlugins( private lateinit var livePluginFile: File - private val dependents = CopyOnWriteArrayList() + internal val dependents = CopyOnWriteArrayList() // Call this function when app is destroyed, to prevent memory leaks fun release() { @@ -93,10 +93,12 @@ class LivePlugins( override fun update(settings: Settings, type: Plugin.UpdateType) { // if we find an existing LivePlugins instance that is not ourselves... - if (analytics.find(LivePlugins::class) != this) { - // remove ourselves. we can't do this in configure. - analytics.remove(this) - return + analytics.find(LivePlugins::class)?.let { + if (it != this@LivePlugins) { + // remove ourselves. we can't do this in configure. + analytics.remove(this@LivePlugins) + return + } } if (loaded) { diff --git a/analytics-kotlin-live/src/test/java/com/segment/analytics/liveplugins/kotlin/BundlerTest.kt b/analytics-kotlin-live/src/test/java/com/segment/analytics/liveplugins/kotlin/BundlerTest.kt new file mode 100644 index 0000000..ff1c9e3 --- /dev/null +++ b/analytics-kotlin-live/src/test/java/com/segment/analytics/liveplugins/kotlin/BundlerTest.kt @@ -0,0 +1,113 @@ +package com.segment.analytics.liveplugins.kotlin + +import org.junit.Test +import org.junit.Assert.* +import org.junit.Before +import org.junit.After +import java.io.File +import java.io.IOException +import java.net.MalformedURLException + +class BundlerTest { + + private lateinit var testFile: File + + @Before + fun setUp() { + testFile = File.createTempFile("test_bundle", ".js") + testFile.deleteOnExit() + } + + @After + fun tearDown() { + if (testFile.exists()) { + testFile.delete() + } + } + + @Test + fun disableBundleURL_createsFileWithCorrectContent() { + disableBundleURL(testFile) + + assertTrue("File should exist after disableBundleURL", testFile.exists()) + val content = testFile.readText() + assertEquals("// live plugins are disabled.", content) + } + + @Test + fun disableBundleURL_overwritesExistingFile() { + testFile.writeText("existing content") + + disableBundleURL(testFile) + + val content = testFile.readText() + assertEquals("// live plugins are disabled.", content) + } + + @Test + fun disableBundleURL_createsNewFileIfNotExists() { + testFile.delete() + assertFalse("File should not exist before test", testFile.exists()) + + disableBundleURL(testFile) + + assertTrue("File should be created", testFile.exists()) + val content = testFile.readText() + assertEquals("// live plugins are disabled.", content) + } + + @Test + fun disableBundleURL_handlesFilePermissions() { + disableBundleURL(testFile) + + assertTrue("File should be readable", testFile.canRead()) + val content = testFile.readText() + assertEquals("// live plugins are disabled.", content) + } + + @Test + fun disableBundleURL_writesToCorrectFile() { + val anotherFile = File.createTempFile("another_test", ".js") + anotherFile.deleteOnExit() + + disableBundleURL(testFile) + disableBundleURL(anotherFile) + + assertTrue("Both files should exist", testFile.exists() && anotherFile.exists()) + assertEquals("// live plugins are disabled.", testFile.readText()) + assertEquals("// live plugins are disabled.", anotherFile.readText()) + + anotherFile.delete() + } + + @Test(expected = IOException::class) + fun download_throwsExceptionForInvalidURL() { + download("invalid-url", testFile) + } + + @Test(expected = MalformedURLException::class) + fun download_throwsExceptionForMalformedURL() { + download("not-a-url", testFile) + } + + @Test(expected = IOException::class) + fun download_throwsExceptionForNonExistentServer() { + download("http://localhost:54321/bundle.js", testFile) + } + + @Test(expected = IOException::class) + fun download_throwsExceptionForNonExistentHost() { + download("http://invalid-host-that-does-not-exist-12345.com/bundle.js", testFile) + } + + @Test + fun download_createsFileEvenIfDownloadFails() { + try { + download("http://localhost:54321/bundle.js", testFile) + fail("Should have thrown IOException") + } catch (e: IOException) { + // Expected - but check that file structure is handled correctly + assertNotNull("Exception should be thrown", e) + } + } +} \ No newline at end of file diff --git a/analytics-kotlin-live/src/test/java/com/segment/analytics/liveplugins/kotlin/EmbeddedJSTest.kt b/analytics-kotlin-live/src/test/java/com/segment/analytics/liveplugins/kotlin/EmbeddedJSTest.kt new file mode 100644 index 0000000..ffa6efc --- /dev/null +++ b/analytics-kotlin-live/src/test/java/com/segment/analytics/liveplugins/kotlin/EmbeddedJSTest.kt @@ -0,0 +1,53 @@ +package com.segment.analytics.liveplugins.kotlin + +import com.segment.analytics.kotlin.core.platform.Plugin +import org.junit.Test +import org.junit.Assert.* + +class EmbeddedJSTest { + + @Test + fun pluginTypeToInt_mapsCorrectValues() { + assertEquals("Before type should map to 0", 0, Plugin.Type.Before.toInt()) + assertEquals("Enrichment type should map to 1", 1, Plugin.Type.Enrichment.toInt()) + assertEquals("After type should map to 2", 2, Plugin.Type.After.toInt()) + } + + @Test + fun pluginTypeToInt_handlesUnknownType() { + val unknownType = Plugin.Type.Destination + assertEquals("Unknown type should map to -1", -1, unknownType.toInt()) + } + + @Test + fun pluginTypeFromInt_mapsCorrectValues() { + assertEquals("0 should map to Before type", Plugin.Type.Before, pluginTypeFromInt(0)) + assertEquals("1 should map to Enrichment type", Plugin.Type.Enrichment, pluginTypeFromInt(1)) + assertEquals("2 should map to After type", Plugin.Type.After, pluginTypeFromInt(2)) + } + + @Test + fun pluginTypeFromInt_handlesInvalidValues() { + assertNull("Invalid positive value should return null", pluginTypeFromInt(99)) + assertNull("Invalid negative value should return null", pluginTypeFromInt(-1)) + assertNull("Invalid large value should return null", pluginTypeFromInt(Integer.MAX_VALUE)) + } + + @Test + fun pluginTypeFromInt_handlesEdgeCases() { + assertNull("3 should return null (utility is commented out)", pluginTypeFromInt(3)) + assertNull("4 should return null", pluginTypeFromInt(4)) + } + + @Test + fun pluginTypeConversion_isReversible() { + val types = listOf(Plugin.Type.Before, Plugin.Type.Enrichment, Plugin.Type.After) + + for (type in types) { + val intValue = type.toInt() + val convertedBack = pluginTypeFromInt(intValue) + assertEquals("Conversion should be reversible for $type", type, convertedBack) + } + } + +} \ No newline at end of file diff --git a/analytics-kotlin-live/src/test/java/com/segment/analytics/liveplugins/kotlin/ExampleUnitTest.kt b/analytics-kotlin-live/src/test/java/com/segment/analytics/liveplugins/kotlin/ExampleUnitTest.kt deleted file mode 100644 index 0302ea1..0000000 --- a/analytics-kotlin-live/src/test/java/com/segment/analytics/liveplugins/kotlin/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.segment.analytics.liveplugins.kotlin - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file