diff --git a/buildSrc/src/main/kotlin/io.customer/android/Dependencies.kt b/buildSrc/src/main/kotlin/io.customer/android/Dependencies.kt index 7836ec00f..74b4cb898 100644 --- a/buildSrc/src/main/kotlin/io.customer/android/Dependencies.kt +++ b/buildSrc/src/main/kotlin/io.customer/android/Dependencies.kt @@ -7,6 +7,13 @@ object Dependencies { const val androidxTestRunner = "androidx.test:runner:${Versions.ANDROIDX_TEST_RUNNER}" const val androidxTestRules = "androidx.test:rules:${Versions.ANDROIDX_TEST_RULES}" + // Core library desugaring: required because Jist (io.customer.android:jist) uses java.time. + const val coreLibraryDesugaring = + "com.android.tools:desugar_jdk_libs:${Versions.DESUGAR_JDK_LIBS}" + + // Jist rendering engine (published Maven Central artifact). + const val jist = "io.customer.android:jist:${Versions.JIST}" + const val coroutinesAndroid = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.COROUTINES}" const val androidxCoreKtx = "androidx.core:core-ktx:${Versions.ANDROIDX_KTX}" diff --git a/buildSrc/src/main/kotlin/io.customer/android/Versions.kt b/buildSrc/src/main/kotlin/io.customer/android/Versions.kt index ed7d56ef7..1ad9d36ce 100644 --- a/buildSrc/src/main/kotlin/io.customer/android/Versions.kt +++ b/buildSrc/src/main/kotlin/io.customer/android/Versions.kt @@ -9,6 +9,12 @@ object Versions { // When updating AGP version, make sure to also update workflow: gradle-compatibility-builds // and script: update-gradle-compatibility as needed. internal const val ANDROID_GRADLE_PLUGIN = "8.9.3" + + // Desugaring version for java.time (Jist requires it). + internal const val DESUGAR_JDK_LIBS = "2.1.4" + + // Jist rendering engine — published Maven Central artifact (io.customer.android:jist). + internal const val JIST = "0.1.0-alpha01" internal const val ANDROIDX_TEST_JUNIT = "1.3.0" internal const val ANDROIDX_TEST_RUNNER = "1.7.0" internal const val ANDROIDX_TEST_RULES = "1.7.0" diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/inbox/VisualInbox.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/inbox/VisualInbox.kt index 3ea7059c3..1bd968a64 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/inbox/VisualInbox.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/inbox/VisualInbox.kt @@ -9,6 +9,9 @@ import io.customer.messaginginapp.inbox.data.InboxVisibility import io.customer.messaginginapp.inbox.jist.JistInboxAdapter import io.customer.messaginginapp.inbox.jist.JistInboxMessage import io.customer.messaginginapp.state.InAppMessagingManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map /** * Data-layer entry point that the visual notification inbox overlay reads from. @@ -37,6 +40,29 @@ class VisualInbox internal constructor( val isEnabled: Boolean get() = inAppMessagingManager.getCurrentState().isInboxEnabled + /** + * Reactive signal that fires on each distinct change to a store input that can alter the + * inbox's visibility or unread count: the enablement gate or the inbox message set + * (including opened-state changes). Backed by the store's StateFlow, so it emits the + * current value immediately on collection (a late-mounting overlay still gets latest state). + * The overlay maps each emission to a fresh read of CACHED state via [getVisibility] / + * [getSelectedMessages]; this never triggers a network fetch. + */ + fun observeInboxChanges(): Flow = + inAppMessagingManager.state + .map { state -> InboxStateKey(state.isInboxEnabled, state.inboxMessages) } + .distinctUntilChanged() + .map { } + + /** + * Companion to [observeInboxChanges] that fires when a templates/branding fetch cycle + * completes. It closes a reactive gap: a fetch populates the cache WITHOUT changing + * `isInboxEnabled` or `inboxMessages`, so [observeInboxChanges] would not re-emit and an + * overlay that computed Hidden on the enablement flip would stay Hidden. Re-reading cached + * state on this emission lets it transition Hidden -> Visible. Does NOT trigger a fetch. + */ + fun observeContentChanges(): Flow = repository.observeContentChanges() + val isInboxVisible: Boolean get() = repository.isInboxVisible @@ -61,3 +87,13 @@ class VisualInbox internal constructor( fun trackMessageClicked(message: InboxMessage, actionName: String? = null) = notificationInbox.trackMessageClicked(message, actionName) } + +/** + * Equality key for [VisualInbox.observeInboxChanges]: the store inputs whose change can alter + * the visual inbox's visibility or unread count. Used with `distinctUntilChanged` so the overlay + * only re-reads cached state when something it cares about actually changed. + */ +private data class InboxStateKey( + val isInboxEnabled: Boolean, + val inboxMessages: Set +) diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/inbox/data/InboxRepository.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/inbox/data/InboxRepository.kt index 30bcb9ea0..cf1e9f542 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/inbox/data/InboxRepository.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/inbox/data/InboxRepository.kt @@ -14,6 +14,9 @@ import io.customer.sdk.core.util.Logger import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow /** * Orchestrates the visual-inbox data layer: a thin layer over the headless inbox. @@ -81,6 +84,25 @@ internal class InboxRepository( val isFetchInFlight: Boolean get() = fetchInFlight.get() + /** + * Fetch-completion signal. Emits [Unit] once whenever a [loadTemplatesAndBranding] cycle that + * actually ran the fetch path FINISHES (any terminal outcome). This is the reactive edge the + * overlay needs: a fetch triggered by an enablement flip populates the templates/branding + * cache WITHOUT changing `isInboxEnabled` or `inboxMessages`, so the store-keyed + * `observeInboxChanges` never re-emits. Observers re-read the now-warm cached visibility and + * transition Hidden -> Visible. Mirrors iOS's repository `loadStateChanges()` edge. + * + * `replay = 0` because it is a pure edge (not state); `extraBufferCapacity` lets the + * non-suspending `tryEmit` from the fetch coroutine never block or drop. + */ + private val contentChanges = MutableSharedFlow(replay = 0, extraBufferCapacity = 8) + + /** + * Observe [contentChanges]: fires when a templates/branding fetch cycle completes. + * Carries no payload — observers re-read cached visibility via [computeVisibility]. + */ + fun observeContentChanges(): Flow = contentChanges.asSharedFlow() + /** * Whether a templates/branding fetch is warranted right now: true when either * required render input is missing from the HTTP-cache-backed store. Used by the @@ -119,6 +141,10 @@ internal class InboxRepository( return doLoadTemplatesAndBranding() } finally { fetchInFlight.set(false) + // Signal completion after the cache has been populated. Only the path that actually + // ran the fetch reaches here (the in-flight short-circuit above returns early without + // emitting). tryEmit is non-suspending and cannot block/deadlock here. + contentChanges.tryEmit(Unit) } } diff --git a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingManager.kt b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingManager.kt index c043adaf9..414626ec7 100644 --- a/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingManager.kt +++ b/messaginginapp/src/main/java/io/customer/messaginginapp/state/InAppMessagingManager.kt @@ -5,6 +5,8 @@ import io.customer.sdk.core.di.SDKComponent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -69,6 +71,17 @@ internal data class InAppMessagingManager(val listener: GistListener? = null) { */ fun getCurrentState() = store.state + /** + * Reactive, read-only view of the store state. + * + * Emits the current state immediately on collection and every subsequent change. + * Consumers (e.g. the visual inbox overlay) collect this to react to store changes + * — such as `isInboxEnabled` flipping true after the first queue poll — without + * relying on recomposition or manual re-querying. Use [getCurrentState] for a + * one-shot read instead. + */ + val state: StateFlow = storeStateFlow.asStateFlow() + /** * Subscribes to a specific attribute of the state. * @param selector A function that selects a specific attribute of the state. diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/listeners/QueueInboxTriggerTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/listeners/QueueInboxTriggerTest.kt new file mode 100644 index 000000000..308d5c25e --- /dev/null +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/listeners/QueueInboxTriggerTest.kt @@ -0,0 +1,191 @@ +package io.customer.messaginginapp.gist.data.listeners + +import io.customer.commontest.config.TestConfig +import io.customer.commontest.config.testConfigurationDefault +import io.customer.commontest.core.TestConstants +import io.customer.commontest.extensions.attachToSDKComponent +import io.customer.commontest.extensions.flushCoroutines +import io.customer.commontest.util.ScopeProviderStub +import io.customer.messaginginapp.MessagingInAppModuleConfig +import io.customer.messaginginapp.ModuleMessagingInApp +import io.customer.messaginginapp.di.inAppMessagingManager +import io.customer.messaginginapp.gist.GistEnvironment +import io.customer.messaginginapp.gist.data.model.response.InboxMessageResponse +import io.customer.messaginginapp.gist.data.model.response.QueueMessagesResponse +import io.customer.messaginginapp.inbox.data.InboxFetchOutcome +import io.customer.messaginginapp.inbox.data.InboxRepository +import io.customer.messaginginapp.state.InAppMessagingAction +import io.customer.messaginginapp.state.InAppMessagingManager +import io.customer.messaginginapp.testutils.core.IntegrationTest +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.util.ScopeProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import okhttp3.Headers +import okhttp3.Headers.Companion.toHeaders +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Verifies the Queue->InboxRepository wiring added to make the dormant visual-inbox + * data layer LIVE: + * - the X-CIO-Inbox-Enabled header flipping true triggers the templates/branding fetch + * on the existing in-app lifecycle scope (the queue poll scope), and + * - an enabled poll with templates/branding missing triggers a fetch-if-missing, and + * - the live poll publishes inbox messages into the headless store (read on demand). + * + * The trigger path ([Queue.updateInboxFlag]) reads state, calls the repository, and + * dispatches actions only -- it touches no network -- so it is invoked directly via + * reflection here, avoiding the fixed-URL [GistEnvironment] HTTP path. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class QueueInboxTriggerTest : IntegrationTest() { + + private val scopeProviderStub = ScopeProviderStub.Unconfined() + private val mockRepository: InboxRepository = mockk(relaxed = true) + + private lateinit var manager: InAppMessagingManager + private lateinit var queue: Queue + + override fun setup(testConfig: TestConfig) { + super.setup( + testConfigurationDefault { + diGraph { + sdk { + overrideDependency(scopeProviderStub) + overrideDependency(mockRepository) + } + } + } + testConfig + ) + + coEvery { mockRepository.loadTemplatesAndBranding() } returns InboxFetchOutcome.Hidden("test") + + ModuleMessagingInApp( + config = MessagingInAppModuleConfig.Builder( + siteId = TestConstants.Keys.SITE_ID, + region = io.customer.sdk.data.model.Region.US + ).build() + ).attachToSDKComponent() + + manager = SDKComponent.inAppMessagingManager + manager.dispatch( + InAppMessagingAction.Initialize( + siteId = "site", + dataCenter = "us", + environment = GistEnvironment.PROD + ) + ) + manager.dispatch(InAppMessagingAction.SetUserIdentifier("user-1")) + flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + queue = Queue() + } + + override fun teardown() { + manager.dispatch(InAppMessagingAction.Reset) + super.teardown() + } + + private fun invokeUpdateInboxFlag(headers: Headers) { + val method = Queue::class.java.getDeclaredMethod("updateInboxFlag", Headers::class.java) + method.isAccessible = true + method.invoke(queue, headers) + flushCoroutines(scopeProviderStub.inAppLifecycleScope) + } + + // --- (a) enablement transition (false -> true) triggers the fetch --- + + @Test + fun updateInboxFlag_givenEnabledHeaderTransition_expectFetchTriggered() { + // State starts disabled; cache miss too, so transition is the trigger reason. + every { mockRepository.isFetchInFlight } returns false + every { mockRepository.needsTemplatesOrBrandingFetch() } returns true + + invokeUpdateInboxFlag(mapOf("X-CIO-Inbox-Enabled" to "true").toHeaders()) + + // Enablement flipped on in state, and the fetch was launched on the queue scope. + assert(manager.getCurrentState().isInboxEnabled) { "expected isInboxEnabled=true after transition" } + coVerify(exactly = 1) { mockRepository.loadTemplatesAndBranding() } + } + + // --- enabled + cache miss (no transition) still triggers fetch-if-missing --- + + @Test + fun updateInboxFlag_givenAlreadyEnabledAndCacheMiss_expectFetchTriggered() { + // Put state into enabled first (no transition on the call under test). + manager.dispatch(InAppMessagingAction.SetInboxEnabled(true)) + flushCoroutines(scopeProviderStub.inAppLifecycleScope) + every { mockRepository.isFetchInFlight } returns false + every { mockRepository.needsTemplatesOrBrandingFetch() } returns true + + invokeUpdateInboxFlag(mapOf("X-CIO-Inbox-Enabled" to "true").toHeaders()) + + coVerify(exactly = 1) { mockRepository.loadTemplatesAndBranding() } + } + + // --- enabled + fresh cache (no transition) does NOT trigger a fetch --- + + @Test + fun updateInboxFlag_givenAlreadyEnabledAndFreshCache_expectNoFetch() { + manager.dispatch(InAppMessagingAction.SetInboxEnabled(true)) + flushCoroutines(scopeProviderStub.inAppLifecycleScope) + every { mockRepository.isFetchInFlight } returns false + every { mockRepository.needsTemplatesOrBrandingFetch() } returns false + + invokeUpdateInboxFlag(mapOf("X-CIO-Inbox-Enabled" to "true").toHeaders()) + + coVerify(exactly = 0) { mockRepository.loadTemplatesAndBranding() } + } + + // --- (b) the live poll publishes inbox messages into the headless store --- + + @Test + fun handleSuccessfulFetch_givenInboxMessages_expectMessagesInState() { + val response = QueueMessagesResponse( + inAppMessages = emptyList(), + inboxMessages = listOf( + InboxMessageResponse( + queueId = "q1", + deliveryId = "d1", + sentAt = java.util.Date(), + topics = listOf("cio_inbox") + ) + ) + ) + + val method = Queue::class.java.getDeclaredMethod( + "handleSuccessfulFetch", + QueueMessagesResponse::class.java, + Boolean::class.javaPrimitiveType + ) + method.isAccessible = true + method.invoke(queue, response, false) + flushCoroutines(scopeProviderStub.inAppLifecycleScope) + + // The poll published the mapped inbox messages into the headless store; the visual + // inbox reads them on demand from state.inboxMessages (no separate message cache). + val inboxMessages = manager.getCurrentState().inboxMessages + assert(inboxMessages.size == 1 && inboxMessages.first().queueId == "q1") { + "expected one inbox message (q1) in state, got $inboxMessages" + } + } + + // --- disabled header never triggers a fetch --- + + @Test + fun updateInboxFlag_givenDisabledHeader_expectNoFetch() { + every { mockRepository.isFetchInFlight } returns false + every { mockRepository.needsTemplatesOrBrandingFetch() } returns true + + invokeUpdateInboxFlag(mapOf("X-CIO-Inbox-Enabled" to "false").toHeaders()) + + assert(!manager.getCurrentState().isInboxEnabled) { "expected isInboxEnabled=false" } + coVerify(exactly = 0) { mockRepository.loadTemplatesAndBranding() } + } +} diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/BrandingParseTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/BrandingParseTest.kt new file mode 100644 index 000000000..381c10d20 --- /dev/null +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/BrandingParseTest.kt @@ -0,0 +1,137 @@ +package io.customer.messaginginapp.inbox.data + +import com.google.gson.Gson +import com.google.gson.JsonObject +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldNotBeNull +import org.junit.Test + +/** + * Parse coverage for [parseBrandingJson] using a trimmed but representative copy of + * a real captured branding response. Asserts the floating bell + inbox chrome parse, + * and that an ABSENT `patterns.modes.dark` (which is how this workspace's response + * actually looks) yields null — never an assumed-present object. + */ +class BrandingParseTest { + + private val gson = Gson() + + private fun parse(json: String): Branding = + parseBrandingJson(gson.fromJson(json, JsonObject::class.java), gson) + + // Trimmed copy of a real branding response: real chrome values, and NO + // patterns.modes block at all (mirrors the captured response). + private val representativeJson = """ + { + "theme": { "text": { "color": "#000000" } }, + "patterns": { + "inbox": { + "floatingIcon": { + "background": "#000000", + "color": "#ffffff" + }, + "background": "#ffffff", + "cornerRadius": 8, + "borderColor": "#d9d9d9", + "dividerColor": "#d9d9d9", + "shadow": { "color": "#00000026", "offsetX": 0, "offsetY": 2, "blur": 8 }, + "position": "bottom-right", + "hoverBackground": "#f5f5f5", + "unreadIndicator": { + "showAlert": true, + "text": { "fontSize": 8, "color": "#ffffff" }, + "background": "#e00000" + } + } + } + } + """.trimIndent() + + @Test + fun parse_givenRepresentativeBranding_expectFloatingIconBellParsed() { + val branding = parse(representativeJson) + + val icon = branding.floatingIcon + icon.shouldNotBeNull() + icon.background shouldBeEqualTo "#000000" + icon.color shouldBeEqualTo "#ffffff" + } + + @Test + fun parse_givenRepresentativeBranding_expectInboxChromeParsed() { + val chrome = parse(representativeJson).inboxChrome + chrome.shouldNotBeNull() + + chrome.background shouldBeEqualTo "#ffffff" + chrome.cornerRadius shouldBeEqualTo 8.0 + chrome.borderColor shouldBeEqualTo "#d9d9d9" + chrome.dividerColor shouldBeEqualTo "#d9d9d9" + chrome.position shouldBeEqualTo "bottom-right" + chrome.hoverBackground shouldBeEqualTo "#f5f5f5" + } + + @Test + fun parse_givenRepresentativeBranding_expectShadowParsed() { + val shadow = parse(representativeJson).inboxChrome?.shadow + shadow.shouldNotBeNull() + + shadow.color shouldBeEqualTo "#00000026" + shadow.offsetX shouldBeEqualTo 0.0 + shadow.offsetY shouldBeEqualTo 2.0 + shadow.blur shouldBeEqualTo 8.0 + } + + @Test + fun parse_givenRepresentativeBranding_expectUnreadIndicatorParsed() { + val unread = parse(representativeJson).inboxChrome?.unreadIndicator + unread.shouldNotBeNull() + + unread.showAlert shouldBeEqualTo true + unread.background shouldBeEqualTo "#e00000" + // Text tokens are preserved as a raw, nested map. + unread.text.shouldNotBeNull() + unread.text["color"] shouldBeEqualTo "#ffffff" + } + + // --- The crux: patterns.modes.dark is ABSENT in this workspace -> null --- + + @Test + fun parse_givenNoModesBlock_expectModesNull() { + // representativeJson has no patterns.modes at all. + parse(representativeJson).patterns.modes.shouldBeNull() + } + + @Test + fun parse_givenModesPresentButNoDark_expectDarkNull() { + val json = """ + { "patterns": { "inbox": {}, "modes": { "light": { "x": 1 } } } } + """.trimIndent() + + val modes = parse(json).patterns.modes + modes.shouldNotBeNull() + modes.dark.shouldBeNull() + } + + @Test + fun parse_givenModesWithDark_expectDarkParsedAsRawMap() { + val json = """ + { "patterns": { "modes": { "dark": { "background": "#111111" } } } } + """.trimIndent() + + val dark = parse(json).patterns.modes?.dark + dark.shouldNotBeNull() + dark["background"] shouldBeEqualTo "#111111" + } + + @Test + fun parse_givenMissingKeysThroughout_expectTolerantNulls() { + // Entirely empty object: nothing assumed present. + val branding = parse("{}") + + branding.theme shouldBeEqualTo emptyMap() + branding.inboxChrome.shouldBeNull() + branding.floatingIcon.shouldBeNull() + branding.patterns.modes.shouldBeNull() + } +} diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/InboxFetchOutcomeTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/InboxFetchOutcomeTest.kt new file mode 100644 index 000000000..22dc273be --- /dev/null +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/InboxFetchOutcomeTest.kt @@ -0,0 +1,117 @@ +package io.customer.messaginginapp.inbox.data + +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeInstanceOf +import org.junit.Test + +/** + * Coverage of the interim HIDDEN-vs-VISIBLE policy in [decideOutcome] (the + * #26/#27 seam). The inbox shows ONLY when renderable; "no data" is HIDDEN, not + * an error. Both templates AND branding are required-to-render and each resolves + * fresh -> stale -> missing: + * - templates + branding both available (fresh or stale) -> Visible + * - either input missing (no fresh, no stale) -> Hidden (never error) + */ +class InboxFetchOutcomeTest { + + private val branding = Branding(theme = mapOf("color" to "blue")) + private val staleBranding = Branding(theme = mapOf("color" to "green")) + + // --- Fresh templates + fresh branding -> Visible, not from cache --- + + @Test + fun decideOutcome_givenFreshTemplatesAndFreshBranding_expectVisibleNotFromCache() { + val outcome = decideOutcome( + freshTemplatesJson = "{\"fresh\":true}", + staleTemplatesJson = null, + freshBranding = branding, + staleBranding = null + ) + + outcome.shouldBeInstanceOf() + val visible = outcome as InboxFetchOutcome.Visible + visible.templatesJson shouldBeEqualTo "{\"fresh\":true}" + visible.branding shouldBeEqualTo branding + visible.fromCache shouldBeEqualTo false + } + + // --- Both inputs serve from stale -> still Visible, fromCache=true --- + + @Test + fun decideOutcome_givenBothServeFromStale_expectVisibleFromCache() { + val outcome = decideOutcome( + freshTemplatesJson = null, + staleTemplatesJson = "{\"stale\":true}", + freshBranding = null, + staleBranding = staleBranding + ) + + outcome.shouldBeInstanceOf() + val visible = outcome as InboxFetchOutcome.Visible + visible.templatesJson shouldBeEqualTo "{\"stale\":true}" + visible.branding shouldBeEqualTo staleBranding + visible.fromCache shouldBeEqualTo true + } + + @Test + fun decideOutcome_givenFreshTemplatesButStaleBranding_expectVisibleFromCache() { + val outcome = decideOutcome( + freshTemplatesJson = "{\"fresh\":true}", + staleTemplatesJson = null, + freshBranding = null, + staleBranding = staleBranding + ) + + outcome.shouldBeInstanceOf() + val visible = outcome as InboxFetchOutcome.Visible + visible.branding shouldBeEqualTo staleBranding + // A stale input means the overall result is served (partly) from cache. + visible.fromCache shouldBeEqualTo true + } + + // --- Templates missing (no fresh, no stale) -> Hidden, not error --- + + @Test + fun decideOutcome_givenNoTemplatesAtAll_expectHiddenNotError() { + val outcome = decideOutcome( + freshTemplatesJson = null, + staleTemplatesJson = null, + freshBranding = branding, + staleBranding = null + ) + + outcome.shouldBeInstanceOf() + // Diagnostic reason is aligned BYTE-FOR-BYTE with iOS. + (outcome as InboxFetchOutcome.Hidden).reason shouldBeEqualTo "templates unavailable" + } + + // --- Branding now REQUIRED-to-render: missing branding hides the inbox --- + + @Test + fun decideOutcome_givenTemplatesButNoBrandingAtAll_expectHiddenNotError() { + val outcome = decideOutcome( + freshTemplatesJson = "{\"fresh\":true}", + staleTemplatesJson = null, + freshBranding = null, + staleBranding = null + ) + + outcome.shouldBeInstanceOf() + (outcome as InboxFetchOutcome.Hidden).reason shouldBeEqualTo "branding unavailable" + } + + @Test + fun decideOutcome_givenNothingAvailable_expectHidden() { + val outcome = decideOutcome( + freshTemplatesJson = null, + staleTemplatesJson = null, + freshBranding = null, + staleBranding = null + ) + + outcome.shouldBeInstanceOf() + // Both missing -> applicable reasons joined with ", " in order (templates, branding), + // mirroring the iOS combined diagnostic reason. + (outcome as InboxFetchOutcome.Hidden).reason shouldBeEqualTo "templates unavailable, branding unavailable" + } +} diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/InboxRepositoryTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/InboxRepositoryTest.kt new file mode 100644 index 000000000..2d3936c64 --- /dev/null +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/InboxRepositoryTest.kt @@ -0,0 +1,385 @@ +package io.customer.messaginginapp.inbox.data + +import io.customer.messaginginapp.gist.data.listeners.GistQueue +import io.customer.messaginginapp.gist.data.model.InboxMessage +import io.customer.messaginginapp.state.InAppMessagingManager +import io.customer.messaginginapp.state.InAppMessagingState +import io.customer.messaginginapp.store.InAppPreferenceStore +import io.customer.messaginginapp.testutils.extension.createInboxMessage +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeInstanceOf +import org.junit.Test + +/** + * Visibility-policy coverage for [InboxRepository]: the inbox is VISIBLE iff + * enabled + >=1 selected message + templates + branding (each fresh OR stale), + * else HIDDEN — never an error. Also covers branding/templates serve-stale (from + * the last-persisted HTTP-cache-backed value) and messages-from-headless-state. + * + * Freshness coverage (once-per-session revalidation gate): the first load of a + * session revalidates (conditional GET) even when both assets are persisted; a + * second load in the same session serves the persisted value with no network + * call; and a failed revalidation serves the last-persisted (stale) value. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class InboxRepositoryTest { + + private val baseUrl = "https://test.inapp.customer.io" + private val templatesUrl = "$baseUrl/api/v1/templates" + private val brandingUrl = "$baseUrl/api/v1/branding" + + private val branding = Branding(theme = mapOf("color" to "blue")) + private val brandingJson = "{\"theme\":{\"color\":\"blue\"}}" + private val templatesJson = "{\"basic\":[]}" + + private fun message(): InboxMessage = + createInboxMessage(deliveryId = "m1", topics = listOf("cio_inbox")) + + private fun managerWith( + enabled: Boolean, + messages: Set = emptySet() + ): InAppMessagingManager { + val manager = mockk() + val state = InAppMessagingState( + userId = "user-1", + isInboxEnabled = enabled, + inboxMessages = messages + ) + every { manager.getCurrentState() } returns state + return manager + } + + /** + * Mutable fake of the HTTP-cache-backed network-response store, keyed by URL. + * Templates/branding are read back through [InAppPreferenceStore.getNetworkResponse]. + */ + private fun preferenceStoreWith( + templates: String? = null, + branding: String? = null + ): InAppPreferenceStore { + val store = mockk(relaxed = true) + every { store.getNetworkResponse(templatesUrl) } returns templates + every { store.getNetworkResponse(brandingUrl) } returns branding + return store + } + + private fun gistQueue(): GistQueue { + val queue = mockk(relaxed = true) + every { queue.baseUrl } returns baseUrl + return queue + } + + private fun repository( + api: InboxApi, + manager: InAppMessagingManager, + preferenceStore: InAppPreferenceStore = preferenceStoreWith() + ): InboxRepository = InboxRepository( + api = api, + inAppMessagingManager = manager, + preferenceStore = preferenceStore, + gistQueue = gistQueue(), + retryPolicy = RetryPolicy(maxAttempts = 1, baseDelayMillis = 0L), + logger = mockk(relaxed = true) + ) + + // --- (a) enabled + messages + templates + branding present -> VISIBLE --- + + @Test + fun computeVisibility_givenAllPresentAndFresh_expectVisible() = runTest { + val api = mockk() + coEvery { api.fetchTemplatesRaw() } returns templatesJson + coEvery { api.fetchBranding() } returns branding + // After a successful fetch the gist interceptor persists both responses; model that + // by having the store return them on the post-fetch read used by computeVisibility. + val store = preferenceStoreWith(templates = templatesJson, branding = brandingJson) + val manager = managerWith(enabled = true, messages = setOf(message())) + val repo = repository(api, manager, store) + + repo.loadTemplatesAndBranding().shouldBeInstanceOf() + + val visibility = repo.computeVisibility() + visibility.shouldBeInstanceOf() + val visible = visibility as InboxVisibility.Visible + visible.templatesJson shouldBeEqualTo templatesJson + visible.branding shouldBeEqualTo branding + visible.messages.map { it.deliveryId } shouldBeEqualTo listOf("m1") + } + + // --- (b) any one missing and uncached -> HIDDEN (not error) --- + + @Test + fun computeVisibility_givenDisabled_expectHidden() = runTest { + val api = mockk(relaxed = true) + val manager = managerWith(enabled = false, messages = setOf(message())) + val repo = repository(api, manager, preferenceStoreWith(templatesJson, brandingJson)) + + val visibility = repo.computeVisibility() + visibility.shouldBeInstanceOf() + // Diagnostic reason aligned BYTE-FOR-BYTE with iOS. + (visibility as InboxVisibility.Hidden).reason shouldBeEqualTo "inbox disabled" + repo.isInboxVisible shouldBeEqualTo false + } + + @Test + fun computeVisibility_givenNoMessages_expectHidden() = runTest { + val api = mockk(relaxed = true) + val manager = managerWith(enabled = true, messages = emptySet()) + val repo = repository(api, manager, preferenceStoreWith(templatesJson, brandingJson)) + + val visibility = repo.computeVisibility() + visibility.shouldBeInstanceOf() + (visibility as InboxVisibility.Hidden).reason shouldBeEqualTo "no selected messages" + } + + @Test + fun loadTemplatesAndBranding_givenBrandingFailsAndUncached_expectHiddenNotError() = runTest { + val api = mockk() + coEvery { api.fetchTemplatesRaw() } returns templatesJson + coEvery { api.fetchBranding() } throws InboxFetchException("branding down") + // Nothing persisted -> no stale fallback for branding. + val manager = managerWith(enabled = true, messages = setOf(message())) + val repo = repository(api, manager) + + // Branding is required-to-render: a failed, uncached branding -> Hidden. + val outcome = repo.loadTemplatesAndBranding() + outcome.shouldBeInstanceOf() + (outcome as InboxFetchOutcome.Hidden).reason shouldBeEqualTo "branding unavailable" + } + + @Test + fun loadTemplatesAndBranding_givenTemplatesFailsAndUncached_expectHidden() = runTest { + val api = mockk() + coEvery { api.fetchTemplatesRaw() } throws InboxFetchException("templates down") + coEvery { api.fetchBranding() } returns branding + val manager = managerWith(enabled = true, messages = setOf(message())) + val repo = repository(api, manager) + + val outcome = repo.loadTemplatesAndBranding() + outcome.shouldBeInstanceOf() + (outcome as InboxFetchOutcome.Hidden).reason shouldBeEqualTo "templates unavailable" + } + + // --- (c) all serve from stale (last-persisted) -> still VISIBLE --- + + @Test + fun computeVisibility_givenAllServeFromStale_expectVisible() = runTest { + // Templates + branding are already persisted (last-known); the network fails for both, + // so serve-stale (the persisted values) must keep the inbox visible. + val store = preferenceStoreWith(templates = templatesJson, branding = brandingJson) + val api = mockk() + coEvery { api.fetchTemplatesRaw() } throws InboxFetchException("offline") + coEvery { api.fetchBranding() } throws InboxFetchException("offline") + // Messages still come from the headless store (retained across a failed poll). + val manager = managerWith(enabled = true, messages = setOf(message())) + val repo = repository(api, manager, store) + + val outcome = repo.loadTemplatesAndBranding() + // First load of the session revalidates (both fetches throw); serve-stale from the + // persisted values keeps the inbox Visible(fromCache=true). + outcome.shouldBeInstanceOf() + (outcome as InboxFetchOutcome.Visible).fromCache shouldBeEqualTo true + + val visibility = repo.computeVisibility() + visibility.shouldBeInstanceOf() + (visibility as InboxVisibility.Visible).messages.map { it.deliveryId } shouldBeEqualTo listOf("m1") + } + + @Test + fun loadTemplatesAndBranding_givenFetchFailsButPersistedExists_expectServedStaleVisible() = runTest { + // Templates persisted, branding NOT -> the fetch path runs (branding missing). Branding + // fetch fails; templates fetch also fails but the persisted templates serve stale, and + // here we persist branding too so the whole thing resolves Visible(fromCache=true). + val store = preferenceStoreWith(templates = templatesJson, branding = brandingJson) + val api = mockk() + coEvery { api.fetchTemplatesRaw() } throws InboxFetchException("offline") + coEvery { api.fetchBranding() } throws InboxFetchException("offline") + val manager = managerWith(enabled = true, messages = setOf(message())) + val repo = repository(api, manager, store) + + val outcome = repo.loadTemplatesAndBranding() + outcome.shouldBeInstanceOf() + (outcome as InboxFetchOutcome.Visible).fromCache shouldBeEqualTo true + } + + // --- needsTemplatesOrBrandingFetch drives the poll-time fetch-if-missing trigger --- + + @Test + fun needsTemplatesOrBrandingFetch_givenEmptyCache_expectTrue() = runTest { + val manager = managerWith(enabled = true, messages = setOf(message())) + val repo = repository(mockk(relaxed = true), manager) + + repo.needsTemplatesOrBrandingFetch() shouldBeEqualTo true + } + + @Test + fun needsTemplatesOrBrandingFetch_givenBothPersisted_expectFalse() = runTest { + val store = preferenceStoreWith(templates = templatesJson, branding = brandingJson) + val manager = managerWith(enabled = true, messages = setOf(message())) + val repo = repository(mockk(relaxed = true), manager, store) + + repo.needsTemplatesOrBrandingFetch() shouldBeEqualTo false + } + + // --- in-flight guard prevents duplicate concurrent fetches --- + + @Test + fun loadTemplatesAndBranding_givenConcurrentCalls_expectSingleNetworkFetch() = runTest { + // Gate the templates fetch so the first call is still "in-flight" when the + // second concurrent call starts; the guard must short-circuit the second. + val templatesGate = CompletableDeferred() + val templatesCalls = AtomicInteger(0) + val brandingCalls = AtomicInteger(0) + + val api = mockk() + coEvery { api.fetchTemplatesRaw() } coAnswers { + templatesCalls.incrementAndGet() + templatesGate.await() + templatesJson + } + coEvery { api.fetchBranding() } coAnswers { + brandingCalls.incrementAndGet() + branding + } + val manager = managerWith(enabled = true, messages = setOf(message())) + val repo = repository(api, manager) + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val first = async(dispatcher) { repo.loadTemplatesAndBranding() } + // Second call begins while the first is parked on templatesGate. + val second = async(dispatcher) { repo.loadTemplatesAndBranding() } + + // The guard should have short-circuited the second call (it returned the + // current outcome) without launching a second network fetch. + templatesGate.complete(Unit) + first.await().shouldBeInstanceOf() + second.await() + + // Exactly one network fetch per endpoint despite two concurrent callers. + templatesCalls.get() shouldBeEqualTo 1 + brandingCalls.get() shouldBeEqualTo 1 + repo.isFetchInFlight shouldBeEqualTo false + } + + // --- messages are read from the headless store (serve-stale = store retention) --- + + @Test + fun selectVisualInboxMessages_givenHeadlessStateMessages_expectSelected() = runTest { + val api = mockk(relaxed = true) + val manager = managerWith(enabled = true, messages = setOf(message())) + val repo = repository(api, manager) + + // No separate message cache: selection reads straight from state.inboxMessages. + repo.selectVisualInboxMessages().map { it.deliveryId } shouldBeEqualTo listOf("m1") + } + + @Test + fun selectVisualInboxMessages_givenEmptyHeadlessState_expectEmpty() = runTest { + val api = mockk(relaxed = true) + val manager = managerWith(enabled = true, messages = emptySet()) + val repo = repository(api, manager) + + repo.selectVisualInboxMessages() shouldBeEqualTo emptyList() + } + + // --- once-per-session revalidation gate --- + + /** + * Builds an [InboxApi] mock that counts calls and always returns the persisted + * fixtures. The conditional GET (304/200) is modeled as a successful fetch. + */ + private fun countingApi( + templatesCalls: AtomicInteger, + brandingCalls: AtomicInteger + ): InboxApi { + val api = mockk() + coEvery { api.fetchTemplatesRaw() } coAnswers { + templatesCalls.incrementAndGet() + templatesJson + } + coEvery { api.fetchBranding() } coAnswers { + brandingCalls.incrementAndGet() + branding + } + return api + } + + // (a) First load of a session revalidates even when both assets are already persisted + // (conditional GET -> 304/200), i.e. it hits the network rather than serving cache blindly. + @Test + fun loadTemplatesAndBranding_givenFirstLoadWithBothPersisted_expectRevalidatesOverNetwork() = runTest { + val templatesCalls = AtomicInteger(0) + val brandingCalls = AtomicInteger(0) + val store = preferenceStoreWith(templates = templatesJson, branding = brandingJson) + val manager = managerWith(enabled = true, messages = setOf(message())) + val repo = repository(countingApi(templatesCalls, brandingCalls), manager, store) + + val outcome = repo.loadTemplatesAndBranding() + + outcome.shouldBeInstanceOf() + // Network WAS hit despite both assets being persisted: this is the revalidation. + templatesCalls.get() shouldBeEqualTo 1 + brandingCalls.get() shouldBeEqualTo 1 + } + + // (b) A second load in the same session, with both assets persisted, serves from cache + // WITHOUT a second network call (the gate is closed after the first revalidation). + @Test + fun loadTemplatesAndBranding_givenSecondLoadSameSession_expectServesCachedNoNetwork() = runTest { + val templatesCalls = AtomicInteger(0) + val brandingCalls = AtomicInteger(0) + val store = preferenceStoreWith(templates = templatesJson, branding = brandingJson) + val manager = managerWith(enabled = true, messages = setOf(message())) + val repo = repository(countingApi(templatesCalls, brandingCalls), manager, store) + + repo.loadTemplatesAndBranding() // first load: revalidates (1 call each) + val second = repo.loadTemplatesAndBranding() // same session: must serve cache + + second.shouldBeInstanceOf() + (second as InboxFetchOutcome.Visible).fromCache shouldBeEqualTo true + // No SECOND network call: still exactly one fetch per endpoint across both loads. + templatesCalls.get() shouldBeEqualTo 1 + brandingCalls.get() shouldBeEqualTo 1 + } + + // (c) A failed revalidation serves the last-persisted (stale) value and closes the gate, + // so the next same-session load serves cache without retrying the network. + @Test + fun loadTemplatesAndBranding_givenRevalidationFails_expectServeStaleAndGateClosed() = runTest { + val templatesCalls = AtomicInteger(0) + val brandingCalls = AtomicInteger(0) + val store = preferenceStoreWith(templates = templatesJson, branding = brandingJson) + val api = mockk() + coEvery { api.fetchTemplatesRaw() } coAnswers { + templatesCalls.incrementAndGet() + throw InboxFetchException("offline") + } + coEvery { api.fetchBranding() } coAnswers { + brandingCalls.incrementAndGet() + throw InboxFetchException("offline") + } + val manager = managerWith(enabled = true, messages = setOf(message())) + val repo = repository(api, manager, store) + + val first = repo.loadTemplatesAndBranding() + // Revalidation attempted (1 call each) but failed -> serve stale persisted -> Visible. + first.shouldBeInstanceOf() + (first as InboxFetchOutcome.Visible).fromCache shouldBeEqualTo true + templatesCalls.get() shouldBeEqualTo 1 + brandingCalls.get() shouldBeEqualTo 1 + + val second = repo.loadTemplatesAndBranding() + // Gate is closed after the attempt: no retry on the next same-session load. + second.shouldBeInstanceOf() + templatesCalls.get() shouldBeEqualTo 1 + brandingCalls.get() shouldBeEqualTo 1 + } +} diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/InboxSelectionTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/InboxSelectionTest.kt new file mode 100644 index 000000000..aa8f9122d --- /dev/null +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/InboxSelectionTest.kt @@ -0,0 +1,83 @@ +package io.customer.messaginginapp.inbox.data + +import io.customer.messaginginapp.testutils.extension.createInboxMessage +import io.customer.messaginginapp.testutils.extension.dateDaysAgo +import io.customer.messaginginapp.testutils.extension.dateHoursAgo +import io.customer.messaginginapp.testutils.extension.dateNow +import java.util.Date +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +class InboxSelectionTest { + + @Test + fun select_givenTopicsWithPrefix_expectOnlyPrefixedKept() { + val matching1 = createInboxMessage(deliveryId = "m1", topics = listOf("cio_inbox")) + val matching2 = createInboxMessage(deliveryId = "m2", topics = listOf("cio_inbox_promos")) + val nonMatching = createInboxMessage(deliveryId = "m3", topics = listOf("promotions")) + + val result = InboxSelection.select(listOf(matching1, matching2, nonMatching)) + + result.map { it.deliveryId }.toSet() shouldBeEqualTo setOf("m1", "m2") + } + + @Test + fun select_givenPrefixCaseInsensitive_expectMatched() { + val message = createInboxMessage(deliveryId = "m1", topics = listOf("CIO_INBOX_News")) + + val result = InboxSelection.select(listOf(message)) + + result.map { it.deliveryId } shouldBeEqualTo listOf("m1") + } + + @Test + fun select_givenNullTopicFilter_expectAllNonExpiredKept() { + val message = createInboxMessage(deliveryId = "m1", topics = listOf("anything")) + + val result = InboxSelection.select(listOf(message), topicPrefix = null) + + result.map { it.deliveryId } shouldBeEqualTo listOf("m1") + } + + @Test + fun select_givenExpiredMessage_expectDropped() { + val now = Date() + val expired = createInboxMessage( + deliveryId = "expired", + topics = listOf("cio_inbox"), + expiry = dateHoursAgo(1) + ) + val live = createInboxMessage( + deliveryId = "live", + topics = listOf("cio_inbox"), + expiry = Date(now.time + 3_600_000L) + ) + val noExpiry = createInboxMessage(deliveryId = "noexp", topics = listOf("cio_inbox"), expiry = null) + + val result = InboxSelection.select(listOf(expired, live, noExpiry), now = now) + + result.map { it.deliveryId }.toSet() shouldBeEqualTo setOf("live", "noexp") + } + + @Test + fun select_givenPriorities_expectAscendingThenSentAtDescending() { + // priority asc (nulls last), then sentAt desc within equal priority. + val p1Newer = createInboxMessage(deliveryId = "p1-new", topics = listOf("cio_inbox"), priority = 1, sentAt = dateNow()) + val p1Older = createInboxMessage(deliveryId = "p1-old", topics = listOf("cio_inbox"), priority = 1, sentAt = dateDaysAgo(2)) + val p5 = createInboxMessage(deliveryId = "p5", topics = listOf("cio_inbox"), priority = 5, sentAt = dateNow()) + val pNull = createInboxMessage(deliveryId = "pnull", topics = listOf("cio_inbox"), priority = null, sentAt = dateNow()) + + val result = InboxSelection.select(listOf(p5, pNull, p1Older, p1Newer)) + + result.map { it.deliveryId } shouldBeEqualTo listOf("p1-new", "p1-old", "p5", "pnull") + } + + @Test + fun select_givenEmptyTopics_expectExcludedWhenPrefixSet() { + val noTopics = createInboxMessage(deliveryId = "none", topics = emptyList()) + + val result = InboxSelection.select(listOf(noTopics)) + + result shouldBeEqualTo emptyList() + } +} diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/RetryPolicyTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/RetryPolicyTest.kt new file mode 100644 index 000000000..4805669e9 --- /dev/null +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/RetryPolicyTest.kt @@ -0,0 +1,74 @@ +package io.customer.messaginginapp.inbox.data + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeInstanceOf +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RetryPolicyTest { + + @Test + fun delayForAttempt_givenExponentialPolicy_expectBaseTimesMultiplier() { + val policy = RetryPolicy(maxAttempts = 4, baseDelayMillis = 100L, multiplier = 2.0) + + policy.delayForAttempt(0) shouldBeEqualTo 100L + policy.delayForAttempt(1) shouldBeEqualTo 200L + policy.delayForAttempt(2) shouldBeEqualTo 400L + policy.delayForAttempt(3) shouldBeEqualTo 800L + } + + @Test + fun retryWithBackoff_givenSuccessFirstTry_expectNoDelaysAndResult() = runTest { + val policy = RetryPolicy(maxAttempts = 3, baseDelayMillis = 100L, multiplier = 2.0) + val recordedDelays = mutableListOf() + var calls = 0 + + val result = retryWithBackoff(policy, delayFn = { recordedDelays.add(it) }) { + calls++ + "ok" + } + + result shouldBeEqualTo "ok" + calls shouldBeEqualTo 1 + recordedDelays shouldBeEqualTo emptyList() + } + + @Test + fun retryWithBackoff_givenFailuresThenSuccess_expectBackoffBetweenAttempts() = runTest { + val policy = RetryPolicy(maxAttempts = 3, baseDelayMillis = 100L, multiplier = 2.0) + val recordedDelays = mutableListOf() + var calls = 0 + + val result = retryWithBackoff(policy, delayFn = { recordedDelays.add(it) }) { + calls++ + if (calls < 3) throw InboxFetchException("transient $calls") + "recovered" + } + + result shouldBeEqualTo "recovered" + calls shouldBeEqualTo 3 + // Delays only between attempts (after attempt 0 and attempt 1), not after the success. + recordedDelays shouldBeEqualTo listOf(100L, 200L) + } + + @Test + fun retryWithBackoff_givenAlwaysFails_expectExhaustedAndRethrows() = runTest { + val policy = RetryPolicy(maxAttempts = 3, baseDelayMillis = 10L, multiplier = 2.0) + val recordedDelays = mutableListOf() + var calls = 0 + + val thrown = runCatching { + retryWithBackoff(policy, delayFn = { recordedDelays.add(it) }) { + calls++ + throw InboxFetchException("always fails $calls") + } + }.exceptionOrNull() + + calls shouldBeEqualTo 3 + // No delay after the final (failing) attempt. + recordedDelays shouldBeEqualTo listOf(10L, 20L) + thrown shouldBeInstanceOf InboxFetchException::class + } +} diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/jist/JistInboxAdapterTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/jist/JistInboxAdapterTest.kt new file mode 100644 index 000000000..0cd1faffe --- /dev/null +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/inbox/jist/JistInboxAdapterTest.kt @@ -0,0 +1,109 @@ +package io.customer.messaginginapp.inbox.jist + +import io.customer.messaginginapp.testutils.extension.createInboxMessage +import java.util.Date +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeInstanceOf +import org.junit.Test + +class JistInboxAdapterTest { + + @Test + fun toJist_givenScalarFields_expectMappedFaithfully() { + val sentAt = Date() + val message = createInboxMessage( + queueId = "q1", + deliveryId = "d1", + type = "banner", + opened = true, + priority = 3, + sentAt = sentAt, + topics = listOf("cio_inbox") + ) + + val jist = JistInboxAdapter.toJist(message) + + jist.queueId shouldBeEqualTo "q1" + jist.deliveryId shouldBeEqualTo "d1" + jist.type shouldBeEqualTo "banner" + jist.opened shouldBeEqualTo true + jist.priority shouldBeEqualTo 3 + jist.sentAt shouldBeEqualTo sentAt + jist.topics shouldBeEqualTo listOf("cio_inbox") + } + + @Test + fun toJist_givenNestedProperties_expectPreservedWithoutFlattening() { + val nestedDate = Date(1_700_000_000_000L) + val properties: Map = mapOf( + "title" to "Hello", + "count" to 42, + "ratio" to 3.14, + "active" to true, + "tags" to listOf("a", "b", true, 7), + "nested" to mapOf( + "level2" to mapOf( + "flag" to false, + "values" to listOf(1, 2, 3) + ) + ), + "publishedAt" to nestedDate, + "nullable" to null + ) + val message = createInboxMessage(properties = properties) + + val jist = JistInboxAdapter.toJist(message) + + // Scalars keep their types. + jist.properties["title"] shouldBeEqualTo "Hello" + jist.properties["count"] shouldBeEqualTo 42 + jist.properties["count"]!! shouldBeInstanceOf Int::class + jist.properties["ratio"] shouldBeEqualTo 3.14 + jist.properties["active"] shouldBeEqualTo true + jist.properties["active"]!! shouldBeInstanceOf Boolean::class + + // Arrays preserved with mixed element types. + jist.properties["tags"] shouldBeEqualTo listOf("a", "b", true, 7) + + // Nested objects preserved as Maps (not stringified). + val nested = jist.properties["nested"] + nested shouldBeInstanceOf Map::class + + @Suppress("UNCHECKED_CAST") + val level2 = (nested as Map)["level2"] as Map + level2["flag"] shouldBeEqualTo false + level2["values"] shouldBeEqualTo listOf(1, 2, 3) + + // Date preserved as Date. + jist.properties["publishedAt"] shouldBeEqualTo nestedDate + jist.properties["publishedAt"]!! shouldBeInstanceOf Date::class + + // Null preserved. + jist.properties.containsKey("nullable") shouldBeEqualTo true + jist.properties["nullable"] shouldBeEqualTo null + } + + @Test + fun toJist_givenNestedProperties_expectDeepCopyIndependentOfSource() { + val mutableInner = mutableListOf(1, 2) + val message = createInboxMessage(properties = mapOf("list" to mutableInner)) + + val jist = JistInboxAdapter.toJist(message) + mutableInner.add(3) + + // The Jist copy should not reflect mutation of the source list. + jist.properties["list"] shouldBeEqualTo listOf(1, 2) + } + + @Test + fun toJist_givenList_expectAllMessagesMapped() { + val messages = listOf( + createInboxMessage(deliveryId = "a"), + createInboxMessage(deliveryId = "b") + ) + + val result = JistInboxAdapter.toJist(messages) + + result.map { it.deliveryId } shouldBeEqualTo listOf("a", "b") + } +} diff --git a/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessageReducerInboxEnabledTest.kt b/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessageReducerInboxEnabledTest.kt new file mode 100644 index 000000000..003316d1d --- /dev/null +++ b/messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessageReducerInboxEnabledTest.kt @@ -0,0 +1,58 @@ +package io.customer.messaginginapp.state + +import io.customer.messaginginapp.testutils.core.JUnitTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class InAppMessageReducerInboxEnabledTest : JUnitTest() { + + @Test + fun testSetInboxEnabled_givenFalseState_thenSetsInboxEnabledToTrue() { + val initialState = InAppMessagingState(isInboxEnabled = false) + val action = InAppMessagingAction.SetInboxEnabled(true) + + val newState = inAppMessagingReducer(initialState, action) + + assertTrue(newState.isInboxEnabled) + } + + @Test + fun testSetInboxEnabled_givenTrueState_thenSetsInboxEnabledToFalse() { + val initialState = InAppMessagingState(isInboxEnabled = true) + val action = InAppMessagingAction.SetInboxEnabled(false) + + val newState = inAppMessagingReducer(initialState, action) + + assertFalse(newState.isInboxEnabled) + } + + @Test + fun testSetInboxEnabled_givenStateWithOtherProperties_thenPreservesOtherStateProperties() { + val initialState = InAppMessagingState( + siteId = "test-site", + userId = "test-user", + sseEnabled = true, + isInboxEnabled = false + ) + val action = InAppMessagingAction.SetInboxEnabled(true) + + val newState = inAppMessagingReducer(initialState, action) + + assertEquals("test-site", newState.siteId) + assertEquals("test-user", newState.userId) + assertTrue(newState.sseEnabled) + assertTrue(newState.isInboxEnabled) + } + + @Test + fun testReset_givenInboxEnabledTrue_thenResetsInboxEnabledToFalse() { + val initialState = InAppMessagingState(isInboxEnabled = true) + val action = InAppMessagingAction.Reset + + val newState = inAppMessagingReducer(initialState, action) + + assertFalse(newState.isInboxEnabled) + } +} diff --git a/messaginginbox/api/messaginginbox.api b/messaginginbox/api/messaginginbox.api index e69de29bb..6b7f45472 100644 --- a/messaginginbox/api/messaginginbox.api +++ b/messaginginbox/api/messaginginbox.api @@ -0,0 +1,6 @@ +public final class io/customer/messaginginbox/ComposableSingletons$NotificationInboxOverlayKt { + public static final field INSTANCE Lio/customer/messaginginbox/ComposableSingletons$NotificationInboxOverlayKt; + public fun ()V + public final fun getLambda$-952362317$messaginginbox_release ()Lkotlin/jvm/functions/Function2; +} + diff --git a/messaginginbox/build.gradle b/messaginginbox/build.gradle index 5dfe590d3..4f295f686 100644 --- a/messaginginbox/build.gradle +++ b/messaginginbox/build.gradle @@ -24,6 +24,11 @@ android { consumerProguardFiles "consumer-rules.pro" } + // Core library desugaring: required because Jist (io.customer.android:jist) uses java.time. + compileOptions { + coreLibraryDesugaringEnabled true + } + buildTypes { release { minifyEnabled false @@ -39,10 +44,25 @@ dependencies { // Main dependencies api project(":messaginginapp") // This module builds on top of the messaginginapp module - // Compose: only the runtime is required for this skeleton (the Compose compiler needs it on - // the classpath). The UI artifacts (ui, foundation, material, …) land with the visual inbox UI. + // Required because Jist (io.customer.android:jist) uses java.time (see compileOptions above). + coreLibraryDesugaring Dependencies.coreLibraryDesugaring + + // Jist rendering engine (published Maven Central artifact). The overlay decodes the data + // layer's raw JSON into Jist's serialization types and renders messages with `JistView`. + implementation Dependencies.jist + + // kotlinx.serialization JSON: the overlay decodes the data layer's raw templates JSON, + // message properties, and branding theme into Jist's kotlinx.serialization types. + implementation Dependencies.kotlinxSerializationJson + + // Compose dependencies implementation platform(Dependencies.composeBom) + implementation Dependencies.composeUi + implementation Dependencies.composeUiGraphics + implementation Dependencies.composeFoundation + implementation Dependencies.composeMaterial implementation Dependencies.composeRuntime + implementation Dependencies.coroutinesAndroid // Testing testImplementation project(":common-test") diff --git a/messaginginbox/src/main/AndroidManifest.xml b/messaginginbox/src/main/AndroidManifest.xml index 11bed8604..8c38be1e1 100644 --- a/messaginginbox/src/main/AndroidManifest.xml +++ b/messaginginbox/src/main/AndroidManifest.xml @@ -1,4 +1,15 @@ - + + + + + diff --git a/messaginginbox/src/main/java/io/customer/messaginginbox/InboxJistDecoder.kt b/messaginginbox/src/main/java/io/customer/messaginginbox/InboxJistDecoder.kt new file mode 100644 index 000000000..4d6e7553c --- /dev/null +++ b/messaginginbox/src/main/java/io/customer/messaginginbox/InboxJistDecoder.kt @@ -0,0 +1,93 @@ +package io.customer.messaginginbox + +import io.customer.jist.JistJson +import io.customer.jist.JistTemplate +import io.customer.messaginginapp.inbox.jist.JistInboxMessage +import java.util.Date +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Pure (non-Compose) decoders that bridge the visual-inbox DATA LAYER + * ([io.customer.messaginginapp.inbox.VisualInbox]) to Jist's kotlinx.serialization render + * types. Kept as plain functions so they can be unit-tested without a Compose runtime. + * + * The data layer hands the overlay three raw render inputs: + * - templates: a raw JSON string (`VisualInbox.getTemplatesJson()`) + * - branding theme: a `Map` (`VisualInbox.getBranding()?.theme`) + * - per-message properties: a typed/nested `Map` (`JistInboxMessage.properties`) + * + * `JistView` consumes them as `Map>`, `JsonObject`, and + * `Map` respectively. These functions perform that conversion. + */ +internal object InboxJistDecoder { + + /** + * Decodes the data layer's raw templates registry JSON into the template map Jist renders + * from: `{ "": [