From ac4d8bb746594791ab9001d1ddf2bac52d56d7a8 Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Sun, 21 Jun 2026 21:07:49 +0400 Subject: [PATCH] test(inbox): visual notification inbox unit tests Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/listeners/QueueInboxTriggerTest.kt | 191 +++++++++ .../inbox/data/BrandingParseTest.kt | 137 +++++++ .../inbox/data/InboxFetchOutcomeTest.kt | 117 ++++++ .../inbox/data/InboxRepositoryTest.kt | 385 ++++++++++++++++++ .../inbox/data/InboxSelectionTest.kt | 83 ++++ .../inbox/data/RetryPolicyTest.kt | 74 ++++ .../inbox/jist/JistInboxAdapterTest.kt | 109 +++++ .../InAppMessageReducerInboxEnabledTest.kt | 58 +++ .../messaginginbox/InboxJistDecoderTest.kt | 149 +++++++ .../VisualInboxControllerTest.kt | 310 ++++++++++++++ 10 files changed, 1613 insertions(+) create mode 100644 messaginginapp/src/test/java/io/customer/messaginginapp/gist/data/listeners/QueueInboxTriggerTest.kt create mode 100644 messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/BrandingParseTest.kt create mode 100644 messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/InboxFetchOutcomeTest.kt create mode 100644 messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/InboxRepositoryTest.kt create mode 100644 messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/InboxSelectionTest.kt create mode 100644 messaginginapp/src/test/java/io/customer/messaginginapp/inbox/data/RetryPolicyTest.kt create mode 100644 messaginginapp/src/test/java/io/customer/messaginginapp/inbox/jist/JistInboxAdapterTest.kt create mode 100644 messaginginapp/src/test/java/io/customer/messaginginapp/state/InAppMessageReducerInboxEnabledTest.kt create mode 100644 messaginginbox/src/test/java/io/customer/messaginginbox/InboxJistDecoderTest.kt create mode 100644 messaginginbox/src/test/java/io/customer/messaginginbox/VisualInboxControllerTest.kt 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/src/test/java/io/customer/messaginginbox/InboxJistDecoderTest.kt b/messaginginbox/src/test/java/io/customer/messaginginbox/InboxJistDecoderTest.kt new file mode 100644 index 000000000..614b1cf1b --- /dev/null +++ b/messaginginbox/src/test/java/io/customer/messaginginbox/InboxJistDecoderTest.kt @@ -0,0 +1,149 @@ +package io.customer.messaginginbox + +import io.customer.messaginginapp.inbox.jist.JistInboxMessage +import java.util.Date +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.intOrNull +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeInstanceOf +import org.amshove.kluent.shouldBeTrue +import org.junit.Test + +/** + * Unit tests for [InboxJistDecoder] (raw data layer JSON/maps -> Jist render types) and the + * unread-count accessor. Pure logic — no Compose runtime. + */ +class InboxJistDecoderTest { + + private fun jistMessage( + queueId: String = "q", + type: String = "basic", + opened: Boolean = false, + properties: Map = emptyMap() + ): JistInboxMessage = JistInboxMessage( + queueId = queueId, + deliveryId = null, + type = type, + opened = opened, + priority = null, + sentAt = Date(0), + expiry = null, + topics = emptyList(), + properties = properties + ) + + // --- decodeTemplates --- + + private val validTemplatesJson = """ + { + "${'$'}schema": "https://example.com/schema", + "basic": [ { "version": "1", "root": { "type": "text", "name": "title" } } ], + "card": [ { "version": "1", "root": { "type": "layout", "direction": "vertical" } } ] + } + """.trimIndent() + + @Test + fun decodeTemplates_givenValidRegistry_expectKeysDecodedAndMetadataDropped() { + val templates = InboxJistDecoder.decodeTemplates(validTemplatesJson) + + templates.keys shouldBeEqualTo setOf("basic", "card") + templates["basic"]!!.size shouldBeEqualTo 1 + templates["basic"]!!.first().version shouldBeEqualTo "1" + } + + @Test + fun decodeTemplates_givenNullOrBlank_expectEmptyMap() { + InboxJistDecoder.decodeTemplates(null) shouldBeEqualTo emptyMap() + InboxJistDecoder.decodeTemplates(" ") shouldBeEqualTo emptyMap() + } + + @Test + fun decodeTemplates_givenMalformedJson_expectEmptyMap() { + InboxJistDecoder.decodeTemplates("not json {") shouldBeEqualTo emptyMap() + } + + @Test + fun decodeTemplates_givenNonObjectRoot_expectEmptyMap() { + InboxJistDecoder.decodeTemplates("[1,2,3]") shouldBeEqualTo emptyMap() + } + + // --- toJsonObject / decodeData --- + + @Test + fun toJsonObject_givenNullOrEmpty_expectEmptyObject() { + InboxJistDecoder.toJsonObject(null) shouldBeEqualTo JsonObject(emptyMap()) + InboxJistDecoder.toJsonObject(emptyMap()) shouldBeEqualTo JsonObject(emptyMap()) + } + + @Test + fun toJsonObject_givenTypedNestedProperties_expectTypesPreserved() { + val map = mapOf( + "title" to "Hello", + "count" to 3, + "flag" to true, + "nested" to mapOf("inner" to "v"), + "list" to listOf(1, 2) + ) + + val json = InboxJistDecoder.toJsonObject(map) + + (json["title"] as JsonPrimitive).content shouldBeEqualTo "Hello" + (json["count"] as JsonPrimitive).intOrNull shouldBeEqualTo 3 + (json["flag"] as JsonPrimitive).booleanOrNull shouldBeEqualTo true + json["nested"].shouldBeInstanceOf() + json["list"].shouldBeInstanceOf() + (json["list"] as JsonArray).size shouldBeEqualTo 2 + } + + @Test + fun decodeData_givenMessageProperties_expectMappedToJsonElements() { + val message = jistMessage(properties = mapOf("title" to "Hi")) + + val data = InboxJistDecoder.decodeData(message) + + data.containsKey("title").shouldBeTrue() + (data.getValue("title") as JsonPrimitive).content shouldBeEqualTo "Hi" + } + + @Test + fun decodeData_givenEmptyProperties_expectEmpty() { + InboxJistDecoder.decodeData(jistMessage(properties = emptyMap())).isEmpty().shouldBeTrue() + } + + @Test + fun toJsonObject_givenDateValue_expectStringFallback() { + val json = InboxJistDecoder.toJsonObject(mapOf("when" to Date(0))) + json.containsKey("when").shouldBeTrue() + // Rendered as a string (ISO instant), not dropped. + (json["when"] as JsonPrimitive).isString.shouldBeTrue() + json.containsKey("missing") shouldBeEqualTo false + } + + // --- unopenedInboxCount --- + + @Test + fun unopenedInboxCount_givenNoMessages_expectZero() { + unopenedInboxCount(emptyList()) shouldBeEqualTo 0 + } + + @Test + fun unopenedInboxCount_givenAllOpened_expectZero() { + unopenedInboxCount( + listOf(jistMessage(opened = true), jistMessage(opened = true)) + ) shouldBeEqualTo 0 + } + + @Test + fun unopenedInboxCount_givenMixed_expectOnlyUnopenedCounted() { + unopenedInboxCount( + listOf( + jistMessage(queueId = "a", opened = false), + jistMessage(queueId = "b", opened = true), + jistMessage(queueId = "c", opened = false) + ) + ) shouldBeEqualTo 2 + } +} diff --git a/messaginginbox/src/test/java/io/customer/messaginginbox/VisualInboxControllerTest.kt b/messaginginbox/src/test/java/io/customer/messaginginbox/VisualInboxControllerTest.kt new file mode 100644 index 000000000..609e854f8 --- /dev/null +++ b/messaginginbox/src/test/java/io/customer/messaginginbox/VisualInboxControllerTest.kt @@ -0,0 +1,310 @@ +package io.customer.messaginginbox + +import io.customer.messaginginapp.gist.data.model.InboxMessage +import io.customer.messaginginapp.inbox.VisualInbox +import io.customer.messaginginapp.inbox.data.Branding +import io.customer.messaginginapp.inbox.data.InboxVisibility +import io.customer.messaginginapp.inbox.jist.JistInboxAdapter +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.util.Date +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEmpty +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeInstanceOf +import org.junit.Test + +/** + * Unit tests for [VisualInboxController]: load snapshotting (item 9) and the auto-mark-opened + * dedupe / in-flight guard (item 8). [VisualInbox] is mocked (MockK can mock the final class). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class VisualInboxControllerTest { + + private fun message( + queueId: String, + opened: Boolean = false + ): InboxMessage = InboxMessage( + queueId = queueId, + deliveryId = null, + expiry = null, + sentAt = Date(0), + topics = emptyList(), + type = "basic", + opened = opened, + priority = null, + properties = emptyMap() + ) + + /** Collects [VisualInboxController.uiStateFlow] into [sink] on the given scope. */ + private fun CoroutineScope.launchCollect( + controller: VisualInboxController, + sink: MutableList + ) { + launch { controller.uiStateFlow().collect { sink.add(it) } } + } + + /** + * Builds a controller whose uiStateFlow upstream runs on the test scheduler instead of + * [kotlinx.coroutines.Dispatchers.IO]. uiStateFlow() applies `flowOn(loadDispatcher)`; pointing + * it at a [StandardTestDispatcher] backed by this test's scheduler keeps the load/snapshot work + * on virtual time, so `runCurrent()` deterministically drains it (production still uses IO). + */ + private fun TestScope.controllerOnTestDispatcher(visualInbox: VisualInbox): VisualInboxController = + VisualInboxController(visualInbox, loadDispatcher = StandardTestDispatcher(testScheduler)) + + private fun visible(messages: List): InboxVisibility.Visible = + InboxVisibility.Visible( + templatesJson = "{}", + branding = Branding(), + messages = messages, + fromCache = false + ) + + @Test + fun load_givenDisabled_expectHiddenWithoutFetch() = runTest { + val visualInbox = mockk(relaxed = true) + every { visualInbox.isEnabled } returns false + + val state = VisualInboxController(visualInbox).load() + + state.visibility.shouldBeInstanceOf() + verify(exactly = 0) { visualInbox.getSelectedMessages() } + } + + @Test + fun load_givenEnabledAndVisible_expectSnapshotWithUnopenedCount() = runTest { + val messages = listOf(message("a", opened = false), message("b", opened = true)) + val visualInbox = mockk(relaxed = true) + every { visualInbox.isEnabled } returns true + every { visualInbox.getVisibility() } returns visible(messages) + every { visualInbox.getSelectedMessages() } returns JistInboxAdapter.toJist(messages) + + val state = VisualInboxController(visualInbox).load() + + state.isVisible shouldBeEqualTo true + state.unopenedCount shouldBeEqualTo 1 + state.messages.size shouldBeEqualTo 2 + } + + @Test + fun snapshot_givenHiddenButSelectableMessagesExist_expectEmptyMessagesAndZeroUnopened() { + // Visibility gate: the data layer is Hidden (e.g. templates/branding missing) even though + // selectable messages exist. The snapshot must NOT carry those messages, so the panel shows + // empty (not a broken Jist render with null templates) and markOpenMessagesOpened no-ops + // consistently. unopenedCount must be 0 because messages is empty. + val messages = listOf(message("a", opened = false), message("b", opened = false)) + val visualInbox = mockk(relaxed = true) + every { visualInbox.getVisibility() } returns + InboxVisibility.Hidden("templates unavailable, branding unavailable") + every { visualInbox.getSelectedMessages() } returns JistInboxAdapter.toJist(messages) + + val state = VisualInboxController(visualInbox).snapshot() + + state.isVisible shouldBeEqualTo false + state.visibility.shouldBeInstanceOf() + state.messages.shouldBeEmpty() + state.unopenedCount shouldBeEqualTo 0 + // Selectable messages must not be read into the snapshot when Hidden. + verify(exactly = 0) { visualInbox.getSelectedMessages() } + } + + @Test + fun uiStateFlow_givenEnablementFlipsTrueAfterInit_expectHiddenThenVisible() = runTest { + // The store signal the overlay observes. We drive it manually to simulate the queue poll + // returning X-CIO-Inbox-Enabled AFTER the overlay has already started collecting. + val changes = MutableSharedFlow(replay = 0, extraBufferCapacity = 8) + val messages = listOf(message("a", opened = false)) + + // isEnabled flips: false on the seeded first emission, then true once the store change + // arrives. Backed by a mutable flag (not a fixed sequence) because load()/snapshot() read + // isEnabled more than once per emission when enabled. + var enabled = false + val visualInbox = mockk(relaxed = true) + every { visualInbox.observeInboxChanges() } returns changes + every { visualInbox.isEnabled } answers { enabled } + every { visualInbox.getVisibility() } returns visible(messages) + every { visualInbox.getSelectedMessages() } returns JistInboxAdapter.toJist(messages) + + val controller = controllerOnTestDispatcher(visualInbox) + val collected = mutableListOf() + + // Collect the seeded emission + one store change, then stop. + backgroundScope.launchCollect(controller, collected) + runCurrent() + // First (seeded) snapshot: disabled -> Hidden. + collected.last().isVisible shouldBeEqualTo false + collected.last().visibility.shouldBeInstanceOf() + + // Enablement flips true and the store emits a change. + enabled = true + changes.emit(Unit) + runCurrent() + + // Now the overlay reflects Visible WITHOUT any recomposition / re-mount. + collected.last().isVisible shouldBeEqualTo true + collected.last().unopenedCount shouldBeEqualTo 1 + } + + @Test + fun uiStateFlow_expectLoadWorkRunsOnInjectedDispatcherNotCollector() = runTest { + // Fix 1 guard: uiStateFlow applies flowOn(loadDispatcher) so the load/snapshot work (which + // in production runs loadTemplatesAndBranding's retry/backoff + parsing) never executes on + // the collector's thread — in the overlay that collector is the MAIN thread, so running it + // there risks jank/ANR. We assert confinement to the injected dispatcher: with a + // StandardTestDispatcher (which does NOT auto-resume), simply starting collection must not + // touch the data layer; the work only runs once the dispatcher is advanced via runCurrent(). + val changes = MutableSharedFlow(replay = 0, extraBufferCapacity = 8) + val messages = listOf(message("a", opened = false)) + val visualInbox = mockk(relaxed = true) + every { visualInbox.observeInboxChanges() } returns changes + every { visualInbox.isEnabled } returns true + every { visualInbox.getVisibility() } returns visible(messages) + every { visualInbox.getSelectedMessages() } returns JistInboxAdapter.toJist(messages) + + val controller = controllerOnTestDispatcher(visualInbox) + val collected = mutableListOf() + + backgroundScope.launchCollect(controller, collected) + // Not advanced yet: the upstream is parked on loadDispatcher, so no load/snapshot happened. + collected.shouldBeEmpty() + coVerify(exactly = 0) { visualInbox.loadTemplatesAndBranding() } + verify(exactly = 0) { visualInbox.getVisibility() } + + // Advancing the injected dispatcher drains the seeded load -> first snapshot is produced. + runCurrent() + collected.last().isVisible shouldBeEqualTo true + coVerify(exactly = 1) { visualInbox.loadTemplatesAndBranding() } + } + + @Test + fun uiStateFlow_givenFetchCompletesAfterEnablementFlip_expectHiddenThenVisibleWithoutStoreChange() = runTest { + // Reproduces the on-device bug: enablement flips true, but the queue layer's concurrent + // templates/branding fetch is still in flight, so the store-keyed emission computes + // visibility while templates/branding are momentarily UNAVAILABLE -> Hidden. The fetch + // then completes ~1s later, populating the cache WITHOUT changing isInboxEnabled or + // inboxMessages -> the STORE source never re-emits. The fetch-completion signal must drive + // the overlay Hidden -> Visible with NO further store (enabled/messages) emission. + val storeChanges = MutableSharedFlow(replay = 0, extraBufferCapacity = 8) + val contentChanges = MutableSharedFlow(replay = 0, extraBufferCapacity = 8) + val messages = listOf(message("a", opened = false)) + + // Templates/branding unavailable on the enablement flip (fetch in flight), available once + // the fetch completes. Backed by a mutable flag, not a fixed sequence, because the same + // visibility is read on every snapshot until the fetch flips it. + var templatesReady = false + val visualInbox = mockk(relaxed = true) + every { visualInbox.observeInboxChanges() } returns storeChanges + every { visualInbox.observeContentChanges() } returns contentChanges + every { visualInbox.isEnabled } returns true + every { visualInbox.getVisibility() } answers { + if (templatesReady) visible(messages) else InboxVisibility.Hidden("templates unavailable, branding unavailable") + } + every { visualInbox.getSelectedMessages() } returns JistInboxAdapter.toJist(messages) + + val controller = controllerOnTestDispatcher(visualInbox) + val collected = mutableListOf() + + backgroundScope.launchCollect(controller, collected) + runCurrent() + // Seeded emission (and any store emission) sees templates/branding unavailable -> Hidden. + collected.last().isVisible shouldBeEqualTo false + collected.last().visibility.shouldBeInstanceOf() + + // The concurrent queue-triggered fetch completes: cache is now warm and the completion + // signal fires. There is NO store (enabled/messages) change here. + templatesReady = true + contentChanges.emit(Unit) + runCurrent() + + // The overlay transitions Hidden -> Visible driven ONLY by the fetch-completion signal. + collected.last().isVisible shouldBeEqualTo true + collected.last().unopenedCount shouldBeEqualTo 1 + // And it did so WITHOUT re-running load(): only the single seeded store-style emission ever + // triggered a templates/branding fetch. The fetch-completion path snapshots cached state + // only, so it issues NO additional network fetch (no loop). + coVerify(exactly = 1) { visualInbox.loadTemplatesAndBranding() } + } + + @Test + fun uiStateFlow_givenOpenedStateChanges_expectUnreadCountUpdates() = runTest { + val changes = MutableSharedFlow(replay = 0, extraBufferCapacity = 8) + val unopened = listOf(message("a", opened = false), message("b", opened = false)) + val afterOpen = listOf(message("a", opened = true), message("b", opened = false)) + + val visualInbox = mockk(relaxed = true) + every { visualInbox.observeInboxChanges() } returns changes + every { visualInbox.isEnabled } returns true + // Seeded emission sees 2 unopened; after the opened-state change, message "a" is opened. + every { visualInbox.getVisibility() } returnsMany listOf(visible(unopened), visible(afterOpen)) + every { visualInbox.getSelectedMessages() } returnsMany listOf( + JistInboxAdapter.toJist(unopened), + JistInboxAdapter.toJist(afterOpen) + ) + + val controller = controllerOnTestDispatcher(visualInbox) + val collected = mutableListOf() + + backgroundScope.launchCollect(controller, collected) + runCurrent() + collected.last().unopenedCount shouldBeEqualTo 2 + + // markMessageOpened dispatches a store change; the flow re-emits with the new badge count. + changes.emit(Unit) + runCurrent() + collected.last().unopenedCount shouldBeEqualTo 1 + } + + @Test + fun markOpenMessagesOpened_givenUnopened_expectEachMarkedOnce() { + val messages = listOf(message("a", opened = false), message("b", opened = false)) + val visualInbox = mockk(relaxed = true) + val controller = VisualInboxController(visualInbox) + + controller.markOpenMessagesOpened(visible(messages)) + + verify(exactly = 1) { visualInbox.markMessageOpened(match { it.queueId == "a" }) } + verify(exactly = 1) { visualInbox.markMessageOpened(match { it.queueId == "b" }) } + } + + @Test + fun markOpenMessagesOpened_givenAlreadyOpened_expectNotMarked() { + val messages = listOf(message("a", opened = true)) + val visualInbox = mockk(relaxed = true) + + VisualInboxController(visualInbox).markOpenMessagesOpened(visible(messages)) + + verify(exactly = 0) { visualInbox.markMessageOpened(any()) } + } + + @Test + fun markOpenMessagesOpened_calledTwice_expectDedupedAcrossOpens() { + val messages = listOf(message("a", opened = false)) + val visualInbox = mockk(relaxed = true) + val controller = VisualInboxController(visualInbox) + + controller.markOpenMessagesOpened(visible(messages)) + // Second open with the SAME still-unopened message must not re-mark (dedupe guard). + controller.markOpenMessagesOpened(visible(messages)) + + verify(exactly = 1) { visualInbox.markMessageOpened(match { it.queueId == "a" }) } + } + + @Test + fun markOpenMessagesOpened_givenHidden_expectNoOp() { + val visualInbox = mockk(relaxed = true) + + VisualInboxController(visualInbox).markOpenMessagesOpened(InboxVisibility.Hidden("x")) + + verify(exactly = 0) { visualInbox.markMessageOpened(any()) } + } +}