@@ -6,6 +6,7 @@ import com.onesignal.common.consistency.IamFetchReadyCondition
66import com.onesignal.common.consistency.RywData
77import com.onesignal.common.consistency.models.IConsistencyManager
88import com.onesignal.common.exceptions.BackendException
9+ import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler
910import com.onesignal.common.modeling.ModelChangedArgs
1011import com.onesignal.core.internal.config.ConfigModel
1112import com.onesignal.debug.LogLevel
@@ -31,6 +32,7 @@ import com.onesignal.session.internal.influence.IInfluenceManager
3132import com.onesignal.session.internal.outcomes.IOutcomeEventsController
3233import com.onesignal.session.internal.session.ISessionService
3334import com.onesignal.user.IUserManager
35+ import com.onesignal.user.internal.identity.IdentityModel
3436import com.onesignal.user.internal.identity.JwtTokenStore
3537import com.onesignal.user.internal.subscriptions.ISubscriptionManager
3638import com.onesignal.user.internal.subscriptions.SubscriptionModel
@@ -45,6 +47,7 @@ import io.mockk.just
4547import io.mockk.mockk
4648import io.mockk.mockkObject
4749import io.mockk.runs
50+ import io.mockk.slot
4851import io.mockk.spyk
4952import io.mockk.unmockkObject
5053import io.mockk.verify
@@ -169,6 +172,20 @@ private class Mocks {
169172 return property.get(manager) as Boolean
170173 }
171174
175+ fun getPendingJwtRetryExternalId (manager : InAppMessagesManager ): String? {
176+ val property = InAppMessagesManager ::class .memberProperties
177+ .first { it.name == " pendingJwtRetryExternalId" }
178+ property.isAccessible = true
179+ return property.get(manager) as String?
180+ }
181+
182+ fun getPendingJwtRetryRywData (manager : InAppMessagesManager ): RywData ? {
183+ val property = InAppMessagesManager ::class .memberProperties
184+ .first { it.name == " pendingJwtRetryRywData" }
185+ property.isAccessible = true
186+ return property.get(manager) as RywData ?
187+ }
188+
172189 // Helper function to create InAppMessagesManager with all dependencies
173190 val inAppMessagesManager = InAppMessagesManager (
174191 applicationService,
@@ -1418,4 +1435,123 @@ class InAppMessagesManagerTests : FunSpec({
14181435 messageAfterClear.isTriggerChanged shouldBe false
14191436 }
14201437 }
1438+
1439+ context("JWT 401 Retry ") {
1440+ test("fetchMessages stores pending retry state on 401 BackendException ") {
1441+ // Given
1442+ every { mocks.userManager.onesignalId } returns " onesignal-id"
1443+ every { mocks.applicationService.isInForeground } returns true
1444+ every { mocks.pushSubscription.id } returns " subscription-id"
1445+ mocks.identityModelStore.model.externalId = " test-external-id"
1446+ coEvery {
1447+ mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any())
1448+ } throws BackendException (401, "Unauthorized ")
1449+
1450+ // When
1451+ mocks.inAppMessagesManager.onSessionStarted()
1452+ awaitIO()
1453+
1454+ // Then
1455+ mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe " test-external-id"
1456+ mocks.getPendingJwtRetryRywData(mocks.inAppMessagesManager) shouldBe mocks.rywData
1457+ }
1458+
1459+ test("onJwtUpdated retries fetch when externalId matches pending retry") {
1460+ // Given
1461+ every { mocks.userManager.onesignalId } returns " onesignal-id"
1462+ every { mocks.applicationService.isInForeground } returns true
1463+ every { mocks.pushSubscription.id } returns " subscription-id"
1464+ mocks.identityModelStore.model.externalId = " test-external-id"
1465+ mocks.configModelStore.model.fetchIAMMinInterval = 0L
1466+
1467+ // First call throws 401, second call succeeds
1468+ coEvery {
1469+ mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any())
1470+ } throws BackendException (401, "Unauthorized ") andThen listOf(mocks.createInAppMessage())
1471+
1472+ // Trigger the initial fetch that will 401
1473+ mocks.inAppMessagesManager.onSessionStarted()
1474+ awaitIO()
1475+
1476+ // Verify pending state was set
1477+ mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe " test-external-id"
1478+
1479+ // When - JWT is updated for the same external ID
1480+ mocks.inAppMessagesManager.onJwtUpdated("test-external-id")
1481+ awaitIO()
1482+
1483+ // Then - should have retried and cleared the pending state
1484+ coVerify(exactly = 2) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) }
1485+ mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe null
1486+ mocks.getPendingJwtRetryRywData(mocks.inAppMessagesManager) shouldBe null
1487+ }
1488+
1489+ test("onJwtUpdated does not retry when externalId does not match pending retry") {
1490+ // Given
1491+ every { mocks.userManager.onesignalId } returns " onesignal-id"
1492+ every { mocks.applicationService.isInForeground } returns true
1493+ every { mocks.pushSubscription.id } returns " subscription-id"
1494+ mocks.identityModelStore.model.externalId = " test-external-id"
1495+ coEvery {
1496+ mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any())
1497+ } throws BackendException (401, "Unauthorized ")
1498+
1499+ // Trigger the initial fetch that will 401
1500+ mocks.inAppMessagesManager.onSessionStarted()
1501+ awaitIO()
1502+
1503+ // When - JWT is updated for a DIFFERENT external ID
1504+ mocks.inAppMessagesManager.onJwtUpdated("different-external-id")
1505+ awaitIO()
1506+
1507+ // Then - should NOT have retried, pending state remains
1508+ coVerify(exactly = 1) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) }
1509+ mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe " test-external-id"
1510+ }
1511+
1512+ test("onJwtUpdated does nothing when no pending retry") {
1513+ // Given - no 401 has happened, so no pending retry
1514+
1515+ // When
1516+ mocks.inAppMessagesManager.onJwtUpdated("any-external-id")
1517+ awaitIO()
1518+
1519+ // Then
1520+ coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any()) }
1521+ }
1522+
1523+ test("pending retry state is cleared on user switch (identity model replaced)") {
1524+ // Given
1525+ every { mocks.userManager.onesignalId } returns " onesignal-id"
1526+ every { mocks.applicationService.isInForeground } returns true
1527+ every { mocks.pushSubscription.id } returns " subscription-id"
1528+ mocks.identityModelStore.model.externalId = " test-external-id"
1529+ coEvery {
1530+ mocks.backend.listInAppMessages(any(), any(), any(), any(), any(), any(), any())
1531+ } throws BackendException (401, "Unauthorized ")
1532+
1533+ // Capture the handler passed to identityModelStore.subscribe
1534+ val handlerSlot = slot<ISingletonModelStoreChangeHandler <IdentityModel >>()
1535+ every { mocks.identityModelStore.subscribe(capture(handlerSlot)) } just runs
1536+
1537+ // Start the manager to subscribe
1538+ val mockRepository = mocks.repository
1539+ coEvery { mockRepository.cleanCachedInAppMessages() } just runs
1540+ coEvery { mockRepository.listInAppMessages() } returns emptyList()
1541+ mocks.inAppMessagesManager.start()
1542+ awaitIO()
1543+
1544+ // Trigger 401
1545+ mocks.inAppMessagesManager.onSessionStarted()
1546+ awaitIO()
1547+ mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe " test-external-id"
1548+
1549+ // When - simulate user switch via the captured handler
1550+ handlerSlot.captured.onModelReplaced(IdentityModel (), "test")
1551+
1552+ // Then - pending retry state should be cleared
1553+ mocks.getPendingJwtRetryExternalId(mocks.inAppMessagesManager) shouldBe null
1554+ mocks.getPendingJwtRetryRywData(mocks.inAppMessagesManager) shouldBe null
1555+ }
1556+ }
14211557})
0 commit comments