@@ -5,6 +5,8 @@ import com.onesignal.common.exceptions.BackendException
55import com.onesignal.common.modeling.ModelChangeTags
66import com.onesignal.core.internal.operations.ExecutionResult
77import com.onesignal.core.internal.operations.Operation
8+ import com.onesignal.debug.LogLevel
9+ import com.onesignal.debug.internal.logging.Logging
810import com.onesignal.mocks.MockHelper
911import com.onesignal.user.internal.backend.CreateUserResponse
1012import com.onesignal.user.internal.backend.IUserBackendService
@@ -18,6 +20,7 @@ import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getIdentit
1820import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getJwtTokenStore
1921import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getNewRecordState
2022import com.onesignal.user.internal.operations.impl.executors.RefreshUserOperationExecutor
23+ import com.onesignal.user.internal.operations.impl.executors.SubscriptionOperationExecutor
2124import com.onesignal.user.internal.properties.PropertiesModel
2225import com.onesignal.user.internal.subscriptions.SubscriptionModel
2326import com.onesignal.user.internal.subscriptions.SubscriptionModelStore
@@ -61,8 +64,10 @@ class RefreshUserOperationExecutorTests : FunSpec({
6164 mapOf(IdentityConstants .ONESIGNAL_ID to remoteOneSignalId, "aliasLabel1" to "aliasValue1"),
6265 PropertiesObject (country = remoteCountry, language = remoteLanguage, timezoneId = remoteTimeZone, tags = remoteTags),
6366 listOf(
64- SubscriptionObject (existingSubscriptionId1, SubscriptionObjectType .ANDROID_PUSH , enabled = true, token = "on-backend-push-token"),
65- SubscriptionObject (remoteSubscriptionId1, SubscriptionObjectType .ANDROID_PUSH , enabled = true, token = "pushToken2"),
67+ // notificationTypes = 1 keeps server-side view healthy so the push
68+ // self-heal divergence check is a no-op for this happy-path test.
69+ SubscriptionObject (existingSubscriptionId1, SubscriptionObjectType .ANDROID_PUSH , enabled = true, notificationTypes = 1, token = "on-backend-push-token"),
70+ SubscriptionObject (remoteSubscriptionId1, SubscriptionObjectType .ANDROID_PUSH , enabled = true, notificationTypes = 1, token = "pushToken2"),
6671 SubscriptionObject (remoteSubscriptionId2, SubscriptionObjectType .EMAIL , token = "name@company.com"),
6772 ),
6873 )
@@ -321,6 +326,172 @@ class RefreshUserOperationExecutorTests : FunSpec({
321326 }
322327 }
323328
329+ // Push self-heal divergence detection. Verifies that when the device-cached push
330+ // subscription resolves to enabled-and-opted-in but the GET /users response returns the
331+ // same subscription as disabled (the "Never Subscribed" stuck state),
332+ // RefreshUserOperationExecutor emits a follow-up UpdateSubscriptionOperation to re-assert
333+ // local truth via PATCH.
334+ fun buildSelfHealHarness(
335+ serverPushEnabled: Boolean ,
336+ serverNotificationTypes: Int? ,
337+ localOptedIn: Boolean ,
338+ localStatus: SubscriptionStatus ,
339+ localAddress: String ,
340+ ): Triple <RefreshUserOperationExecutor , SubscriptionModel , IUserBackendService > {
341+ val mockUserBackendService = mockk<IUserBackendService >()
342+ coEvery { mockUserBackendService.getUser(appId, IdentityConstants .ONESIGNAL_ID , remoteOneSignalId) } returns
343+ CreateUserResponse (
344+ mapOf(IdentityConstants .ONESIGNAL_ID to remoteOneSignalId),
345+ PropertiesObject (),
346+ listOf(
347+ SubscriptionObject (
348+ existingSubscriptionId1,
349+ SubscriptionObjectType .ANDROID_PUSH ,
350+ enabled = serverPushEnabled,
351+ notificationTypes = serverNotificationTypes,
352+ token = "on-backend-push-token",
353+ ),
354+ ),
355+ )
356+
357+ val mockIdentityModelStore = MockHelper .identityModelStore()
358+ val mockIdentityModel = IdentityModel ()
359+ mockIdentityModel.onesignalId = remoteOneSignalId
360+ every { mockIdentityModelStore.model } returns mockIdentityModel
361+ every { mockIdentityModelStore.replace(any(), any()) } just runs
362+
363+ val mockPropertiesModelStore = MockHelper .propertiesModelStore()
364+ val mockPropertiesModel = PropertiesModel ()
365+ mockPropertiesModel.onesignalId = remoteOneSignalId
366+ every { mockPropertiesModelStore.model } returns mockPropertiesModel
367+ every { mockPropertiesModelStore.replace(any(), any()) } just runs
368+
369+ val mockSubscriptionsModelStore = mockk<SubscriptionModelStore >()
370+ every { mockSubscriptionsModelStore.replaceAll(any(), any()) } just runs
371+
372+ val cachedPushSubscriptionModel = SubscriptionModel ()
373+ cachedPushSubscriptionModel.id = existingSubscriptionId1
374+ cachedPushSubscriptionModel.type = SubscriptionType .PUSH
375+ cachedPushSubscriptionModel.address = localAddress
376+ cachedPushSubscriptionModel.status = localStatus
377+ cachedPushSubscriptionModel.optedIn = localOptedIn
378+ every { mockSubscriptionsModelStore.get(existingSubscriptionId1) } returns cachedPushSubscriptionModel
379+
380+ val mockConfigModelStore =
381+ MockHelper .configModelStore {
382+ it.pushSubscriptionId = existingSubscriptionId1
383+ }
384+
385+ val executor =
386+ RefreshUserOperationExecutor (
387+ mockUserBackendService,
388+ mockIdentityModelStore,
389+ mockPropertiesModelStore,
390+ mockSubscriptionsModelStore,
391+ mockConfigModelStore,
392+ mockk<IRebuildUserService >(),
393+ getNewRecordState(),
394+ getJwtTokenStore(), getIdentityVerificationService(),
395+ )
396+
397+ return Triple (executor, cachedPushSubscriptionModel, mockUserBackendService)
398+ }
399+
400+ test("push self-heal: enqueues follow-up update-subscription op when server is stuck-disabled but local is enabled") {
401+ // Given: server view says push is disabled (the stuck state), local view says enabled
402+ val (executor, _ , _ ) =
403+ buildSelfHealHarness(
404+ serverPushEnabled = false,
405+ serverNotificationTypes = 0,
406+ localOptedIn = true,
407+ localStatus = SubscriptionStatus .SUBSCRIBED ,
408+ localAddress = onDevicePushToken,
409+ )
410+
411+ // Suppress logcat output for the self-heal WARN line so the unmocked android.util.Log
412+ // in robolectric-free unit tests doesn't blow up. Restored in finally.
413+ val originalLogLevel = Logging .logLevel
414+ Logging .logLevel = LogLevel .NONE
415+ try {
416+ // When
417+ val response = executor.execute(listOf(RefreshUserOperation (appId, remoteOneSignalId, null)))
418+
419+ // Then a single UpdateSubscriptionOperation is returned to the op repo
420+ response.result shouldBe ExecutionResult .SUCCESS
421+ response.operations?.count() shouldBe 1
422+ val followup = response.operations!![0 ]
423+ (followup is UpdateSubscriptionOperation ) shouldBe true
424+ followup as UpdateSubscriptionOperation
425+ followup.name shouldBe SubscriptionOperationExecutor .UPDATE_SUBSCRIPTION
426+ followup.appId shouldBe appId
427+ followup.onesignalId shouldBe remoteOneSignalId
428+ followup.subscriptionId shouldBe existingSubscriptionId1
429+ followup.type shouldBe SubscriptionType .PUSH
430+ followup.enabled shouldBe true
431+ followup.address shouldBe onDevicePushToken
432+ followup.status shouldBe SubscriptionStatus .SUBSCRIBED
433+ } finally {
434+ Logging .logLevel = originalLogLevel
435+ }
436+ }
437+
438+ test("push self-heal: does NOT enqueue follow-up op when server matches local (healthy push subscription)") {
439+ // Given: server already enabled and notificationTypes=1, local also enabled
440+ val (executor, _ , _ ) =
441+ buildSelfHealHarness(
442+ serverPushEnabled = true,
443+ serverNotificationTypes = 1,
444+ localOptedIn = true,
445+ localStatus = SubscriptionStatus .SUBSCRIBED ,
446+ localAddress = onDevicePushToken,
447+ )
448+
449+ // When
450+ val response = executor.execute(listOf(RefreshUserOperation (appId, remoteOneSignalId, null)))
451+
452+ // Then no follow-up ops emitted
453+ response.result shouldBe ExecutionResult .SUCCESS
454+ response.operations shouldBe null
455+ }
456+
457+ test("push self-heal: does NOT enqueue follow-up op when local is opted out (UNSUBSCRIBE is intentional)") {
458+ // Given: server is disabled, but so is local (user explicitly opted out)
459+ val (executor, _ , _ ) =
460+ buildSelfHealHarness(
461+ serverPushEnabled = false,
462+ serverNotificationTypes = -2,
463+ localOptedIn = false,
464+ localStatus = SubscriptionStatus .SUBSCRIBED ,
465+ localAddress = onDevicePushToken,
466+ )
467+
468+ // When
469+ val response = executor.execute(listOf(RefreshUserOperation (appId, remoteOneSignalId, null)))
470+
471+ // Then no follow-up — opt-out is the user's intent, not divergence
472+ response.result shouldBe ExecutionResult .SUCCESS
473+ response.operations shouldBe null
474+ }
475+
476+ test("push self-heal: does NOT enqueue follow-up op when local has NO_PERMISSION (OS -level disable is real)") {
477+ // Given: server disabled, local also has no notification permission
478+ val (executor, _ , _ ) =
479+ buildSelfHealHarness(
480+ serverPushEnabled = false,
481+ serverNotificationTypes = 0,
482+ localOptedIn = true,
483+ localStatus = SubscriptionStatus .NO_PERMISSION ,
484+ localAddress = onDevicePushToken,
485+ )
486+
487+ // When
488+ val response = executor.execute(listOf(RefreshUserOperation (appId, remoteOneSignalId, null)))
489+
490+ // Then no follow-up — OS denies notifications, server's view is correct
491+ response.result shouldBe ExecutionResult .SUCCESS
492+ response.operations shouldBe null
493+ }
494+
324495 test("refresh user is retried when backend returns MISSING , but isInMissingRetryWindow") {
325496 // Given
326497 val mockUserBackendService = mockk<IUserBackendService >()
0 commit comments