Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ScopeProvider>(scopeProviderStub)
overrideDependency<InboxRepository>(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() }
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading