diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb6045b4..6b95448f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased +- Replaced the deprecated `AsyncTask`-based push notification handling with `WorkManager` for improved reliability and compatibility with modern Android versions. No action is required. + ## [3.6.5] ### Fixed - Fixed IterableEmbeddedView not having an empty constructor and causing crashes diff --git a/iterableapi/build.gradle b/iterableapi/build.gradle index b9924ce24..185f6fec6 100644 --- a/iterableapi/build.gradle +++ b/iterableapi/build.gradle @@ -63,6 +63,7 @@ dependencies { api 'com.google.firebase:firebase-messaging:20.3.0' implementation 'com.google.code.gson:gson:2.10.1' implementation "androidx.security:security-crypto:1.1.0-alpha06" + implementation 'androidx.work:work-runtime:2.9.0' testImplementation 'junit:junit:4.13.2' testImplementation 'androidx.test:runner:1.6.2' @@ -75,6 +76,7 @@ dependencies { testImplementation 'org.khronos:opengl-api:gl1.1-android-2.1_r1' testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.3' testImplementation 'org.skyscreamer:jsonassert:1.5.0' + testImplementation 'androidx.work:work-testing:2.9.0' testImplementation project(':iterableapi') androidTestImplementation 'androidx.test:runner:1.6.2' diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java index 72df83464..8ecb82dc5 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java @@ -1,8 +1,8 @@ package com.iterable.iterableapi; import android.content.Context; -import android.os.AsyncTask; import android.os.Bundle; + import androidx.annotation.NonNull; import com.google.android.gms.tasks.Tasks; @@ -11,6 +11,7 @@ import com.google.firebase.messaging.RemoteMessage; import java.util.Map; +import java.util.UUID; import java.util.concurrent.ExecutionException; public class IterableFirebaseMessagingService extends FirebaseMessagingService { @@ -56,12 +57,17 @@ public static boolean handleMessageReceived(@NonNull Context context, @NonNull R return false; } - if (!IterableNotificationHelper.isGhostPush(extras)) { + boolean isGhostPush = IterableNotificationHelper.isGhostPush(extras); + + if (!isGhostPush) { if (!IterableNotificationHelper.isEmptyBody(extras)) { IterableLogger.d(TAG, "Iterable push received " + messageData); - IterableNotificationBuilder notificationBuilder = IterableNotificationHelper.createNotification( - context.getApplicationContext(), extras); - new IterableNotificationManager().execute(notificationBuilder); + + if (IterableNotificationHelper.hasAttachmentUrl(extras)) { + enqueueNotificationWork(context, extras); + } else { + handleNow(context, extras); + } } else { IterableLogger.d(TAG, "Iterable OS notification push received"); } @@ -105,9 +111,7 @@ public static String getFirebaseToken() { String registrationToken = null; try { registrationToken = Tasks.await(FirebaseMessaging.getInstance().getToken()); - } catch (ExecutionException e) { - IterableLogger.e(TAG, e.getLocalizedMessage()); - } catch (InterruptedException e) { + } catch (ExecutionException | InterruptedException e) { IterableLogger.e(TAG, e.getLocalizedMessage()); } catch (Exception e) { IterableLogger.e(TAG, "Failed to fetch firebase token"); @@ -122,25 +126,60 @@ public static String getFirebaseToken() { * @return Boolean indicating whether the message is an Iterable ghost push or silent push */ public static boolean isGhostPush(RemoteMessage remoteMessage) { - Map messageData = remoteMessage.getData(); + try { + Map messageData = remoteMessage.getData(); + + if (messageData.isEmpty()) { + return false; + } - if (messageData == null || messageData.isEmpty()) { + Bundle extras = IterableNotificationHelper.mapToBundle(messageData); + return IterableNotificationHelper.isGhostPush(extras); + } catch (Exception e) { + IterableLogger.e(TAG, e.getMessage()); return false; } + } - Bundle extras = IterableNotificationHelper.mapToBundle(messageData); - return IterableNotificationHelper.isGhostPush(extras); + private static void enqueueNotificationWork(@NonNull final Context context, @NonNull final Bundle extras) { + IterableNotificationWorkScheduler scheduler = new IterableNotificationWorkScheduler(context); + + scheduler.scheduleNotificationWork( + extras, + new IterableNotificationWorkScheduler.SchedulerCallback() { + @Override + public void onScheduleSuccess(UUID workId) { + IterableLogger.d(TAG, "Notification work scheduled: " + workId); + } + + @Override + public void onScheduleFailure(Exception exception, Bundle notificationData) { + IterableLogger.e(TAG, "Failed to schedule notification work, falling back to immediate posting", exception); + handleNow(context, notificationData); + } + } + ); } -} -class IterableNotificationManager extends AsyncTask { + private static void handleNow(@NonNull Context context, @NonNull Bundle extras) { + Bundle safeExtras = extras; - @Override - protected Void doInBackground(IterableNotificationBuilder... params) { - if (params != null && params[0] != null) { - IterableNotificationBuilder notificationBuilder = params[0]; - IterableNotificationHelper.postNotificationOnDevice(notificationBuilder.context, notificationBuilder); + if (IterableNotificationHelper.hasAttachmentUrl(extras)) { + IterableLogger.w(TAG, "image found when handling on main thread, removing it for safe handling"); + safeExtras = IterableNotificationHelper.removePushImageFromBundle(extras); + } + + try { + IterableNotificationBuilder notificationBuilder = IterableNotificationHelper + .createNotification( + context.getApplicationContext(), + safeExtras + ); + if (notificationBuilder != null) { + IterableNotificationHelper.postNotificationOnDevice(context, notificationBuilder); + } + } catch (Exception e) { + IterableLogger.e(TAG, "Failed to post notification directly", e); } - return null; } -} \ No newline at end of file +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java index 2625b32bf..f4ddffbc5 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java @@ -87,6 +87,20 @@ static boolean isEmptyBody(Bundle extras) { return instance.isEmptyBody(extras); } + /** + * Returns whether the notification payload includes an image attachment URL, + * meaning display requires a network image download (long-running work). + * @param extras what is inside the bundle + * @return if it has an attachment url + */ + static boolean hasAttachmentUrl(Bundle extras) { + return instance.hasAttachmentUrl(extras); + } + + static Bundle removePushImageFromBundle(Bundle extras) { + return instance.removePushImageFromBundle(extras); + } + static Bundle mapToBundle(Map map) { Bundle bundle = new Bundle(); for (Map.Entry entry : map.entrySet()) { @@ -98,6 +112,11 @@ static Bundle mapToBundle(Map map) { static class IterableNotificationHelperImpl { public IterableNotificationBuilder createNotification(Context context, Bundle extras) { + if (extras == null) { + IterableLogger.w(IterableNotificationBuilder.TAG, "Notification extras is null. Skipping."); + return null; + } + String applicationName = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString(); String title = null; String notificationBody = null; @@ -436,7 +455,7 @@ boolean isIterablePush(Bundle extras) { boolean isGhostPush(Bundle extras) { boolean isGhostPush = false; - if (extras.containsKey(IterableConstants.ITERABLE_DATA_KEY)) { + if (extras != null && extras.containsKey(IterableConstants.ITERABLE_DATA_KEY)) { String iterableData = extras.getString(IterableConstants.ITERABLE_DATA_KEY); IterableNotificationData data = new IterableNotificationData(iterableData); isGhostPush = data.getIsGhostPush(); @@ -447,12 +466,50 @@ boolean isGhostPush(Bundle extras) { boolean isEmptyBody(Bundle extras) { String notificationBody = ""; - if (extras.containsKey(IterableConstants.ITERABLE_DATA_KEY)) { + if (extras != null && extras.containsKey(IterableConstants.ITERABLE_DATA_KEY)) { notificationBody = extras.getString(IterableConstants.ITERABLE_DATA_BODY, ""); } return notificationBody.isEmpty(); } + + @Nullable + private JSONObject getIterableJsonFromBundle(Bundle extras) { + if (extras == null || !extras.containsKey(IterableConstants.ITERABLE_DATA_KEY)) { + return null; + } + try { + String iterableData = extras.getString(IterableConstants.ITERABLE_DATA_KEY); + return new JSONObject(iterableData); + } catch (Exception e) { + return null; + } + } + + boolean hasAttachmentUrl(Bundle extras) { + JSONObject iterableJson = getIterableJsonFromBundle(extras); + if (iterableJson == null) { + return false; + } + String attachmentUrl = iterableJson.optString(IterableConstants.ITERABLE_DATA_PUSH_IMAGE, ""); + return !attachmentUrl.isEmpty(); + } + + Bundle removePushImageFromBundle(Bundle extras) { + JSONObject iterableJson = getIterableJsonFromBundle(extras); + if (iterableJson == null) { + return extras; + } + try { + Bundle newExtras = new Bundle(extras); + iterableJson.remove(IterableConstants.ITERABLE_DATA_PUSH_IMAGE); + newExtras.putString(IterableConstants.ITERABLE_DATA_KEY, iterableJson.toString()); + return newExtras; + } catch (Exception e) { + IterableLogger.e("IterableNotificationHelper", "Failed to remove push image from bundle", e); + return extras; + } + } } @Nullable diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java new file mode 100644 index 000000000..2137aad80 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java @@ -0,0 +1,80 @@ +package com.iterable.iterableapi; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.work.Data; +import androidx.work.OneTimeWorkRequest; +import androidx.work.OutOfQuotaPolicy; +import androidx.work.WorkManager; + +import java.util.UUID; + +class IterableNotificationWorkScheduler { + + private static final String TAG = "IterableNotificationWorkScheduler"; + + private final Context context; + private final WorkManager workManager; + + interface SchedulerCallback { + void onScheduleSuccess(UUID workId); + void onScheduleFailure(Exception exception, Bundle notificationData); + } + + IterableNotificationWorkScheduler(@NonNull Context context) { + this(context, WorkManager.getInstance(context)); + } + + @VisibleForTesting + IterableNotificationWorkScheduler(@NonNull Context context, @NonNull WorkManager workManager) { + this.context = context.getApplicationContext(); + this.workManager = workManager; + } + + void scheduleNotificationWork( + @NonNull Bundle notificationData, + @Nullable SchedulerCallback callback + ) { + + try { + Data inputData = IterableNotificationWorker.createInputData( + notificationData + ); + + OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(IterableNotificationWorker.class) + .setInputData(inputData) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build(); + + workManager.enqueue(workRequest); + + UUID workId = workRequest.getId(); + IterableLogger.d(TAG, "Notification work scheduled: " + workId); + + if (callback != null) { + callback.onScheduleSuccess(workId); + } + + } catch (Exception e) { + IterableLogger.e(TAG, "Failed to schedule notification work", e); + + if (callback != null) { + callback.onScheduleFailure(e, notificationData); + } + } + } + + @VisibleForTesting + Context getContext() { + return context; + } + + @VisibleForTesting + WorkManager getWorkManager() { + return workManager; + } +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt new file mode 100644 index 000000000..66c8771e2 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt @@ -0,0 +1,171 @@ +package com.iterable.iterableapi + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.annotation.WorkerThread +import androidx.core.app.NotificationCompat +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.Worker +import androidx.work.WorkerParameters +import org.json.JSONObject + +internal class IterableNotificationWorker( + context: Context, + params: WorkerParameters +) : Worker(context, params) { + + companion object { + private const val TAG = "IterableNotificationWorker" + private const val FOREGROUND_NOTIFICATION_ID = 10101 + + const val KEY_NOTIFICATION_DATA_JSON = "notification_data_json" + + @JvmStatic + fun createInputData(extras: Bundle): Data { + val jsonObject = JSONObject() + for (key in extras.keySet()) { + val value = extras.getString(key) + if (value != null) { + jsonObject.put(key, value) + } + } + + return Data.Builder() + .putString(KEY_NOTIFICATION_DATA_JSON, jsonObject.toString()) + .build() + } + } + + override fun getForegroundInfo(): ForegroundInfo { + val channelId = applicationContext.packageName + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager = applicationContext + .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (notificationManager.getNotificationChannel(channelId) == null) { + val channel = NotificationChannel( + channelId, + getChannelName(), + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(channel) + } + } + + val notification = NotificationCompat.Builder(applicationContext, channelId) + .setSmallIcon(getSmallIconId()) + .setContentTitle(getAppName()) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + return ForegroundInfo(FOREGROUND_NOTIFICATION_ID, notification) + } + + private fun getSmallIconId(): Int { + var iconId = 0 + + try { + val info = applicationContext.packageManager.getApplicationInfo( + applicationContext.packageName, PackageManager.GET_META_DATA + ) + iconId = info.metaData?.getInt(IterableConstants.NOTIFICATION_ICON_NAME, 0) ?: 0 + } catch (e: PackageManager.NameNotFoundException) { + IterableLogger.w(TAG, "Could not read application metadata for icon") + } + + if (iconId == 0) { + iconId = applicationContext.resources.getIdentifier( + IterableApi.getNotificationIcon(applicationContext), + IterableConstants.ICON_FOLDER_IDENTIFIER, + applicationContext.packageName + ) + } + + if (iconId == 0) { + iconId = applicationContext.applicationInfo.icon + } + + return iconId + } + + private fun getAppName(): String { + return applicationContext.applicationInfo + .loadLabel(applicationContext.packageManager).toString() + } + + private fun getChannelName(): String { + return try { + val info = applicationContext.packageManager.getApplicationInfo( + applicationContext.packageName, PackageManager.GET_META_DATA + ) + info.metaData?.getString("iterable_notification_channel_name") + ?: "Notifications" + } catch (e: PackageManager.NameNotFoundException) { + "Notifications" + } + } + + @WorkerThread + override fun doWork(): Result { + IterableLogger.d(TAG, "Starting notification processing in Worker") + + try { + val jsonString = inputData.getString(KEY_NOTIFICATION_DATA_JSON) + + if (jsonString == null || jsonString.isEmpty()) { + IterableLogger.e(TAG, "No notification data provided to Worker") + return Result.failure() + } + + val extras = jsonToBundle(jsonString) + + if (extras.keySet().size == 0) { + IterableLogger.e(TAG, "Deserialized bundle is empty") + return Result.failure() + } + + val notificationBuilder = IterableNotificationHelper.createNotification( + applicationContext, + extras + ) + + if (notificationBuilder == null) { + IterableLogger.w(TAG, "Notification builder is null, skipping") + return Result.success() + } + + IterableNotificationHelper.postNotificationOnDevice( + applicationContext, + notificationBuilder + ) + + IterableLogger.d(TAG, "Notification posted successfully") + return Result.success() + + } catch (e: Exception) { + IterableLogger.e(TAG, "Error processing notification in Worker", e) + return Result.retry() + } + } + + private fun jsonToBundle(jsonString: String): Bundle { + val bundle = Bundle() + try { + val jsonObject = JSONObject(jsonString) + val keys = jsonObject.keys() + while (keys.hasNext()) { + val key = keys.next() + val value = jsonObject.getString(key) + bundle.putString(key, value) + } + } catch (e: Exception) { + IterableLogger.e(TAG, "Error parsing notification JSON: ${e.message}", e) + } + return bundle + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableFirebaseMessagingServiceTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableFirebaseMessagingServiceTest.java index 9516505fb..478ac0c1f 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableFirebaseMessagingServiceTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableFirebaseMessagingServiceTest.java @@ -3,6 +3,12 @@ import android.content.Intent; import android.os.Bundle; +import androidx.work.Configuration; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; +import androidx.work.testing.SynchronousExecutor; +import androidx.work.testing.WorkManagerTestInitHelper; + import com.google.firebase.messaging.RemoteMessage; import org.junit.After; @@ -13,7 +19,9 @@ import org.robolectric.android.controller.ServiceController; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import okhttp3.mockwebserver.MockWebServer; @@ -46,6 +54,13 @@ public void setUp() throws Exception { server = new MockWebServer(); IterableApi.overrideURLEndpointPath(server.url("").toString()); + // Initialize WorkManager for testing with a synchronous executor + Configuration config = new Configuration.Builder() + .setMinimumLoggingLevel(android.util.Log.DEBUG) + .setExecutor(new SynchronousExecutor()) + .build(); + WorkManagerTestInitHelper.initializeTestWorkManager(getContext(), config); + controller = Robolectric.buildService(IterableFirebaseMessagingService.class); Intent intent = new Intent(getContext(), IterableFirebaseMessagingService.class); controller.withIntent(intent).startCommand(0, 0); @@ -139,4 +154,113 @@ public void testUpdateMessagesIsCalled() throws Exception { controller.get().onMessageReceived(builder.build()); verify(embeddedManagerSpy, atLeastOnce()).syncMessages(); } + + @Test + public void testPlainTextNotificationIsHandledDirectly() throws Exception { + when(notificationHelperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(notificationHelperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(notificationHelperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(notificationHelperSpy.hasAttachmentUrl(any(Bundle.class))).thenCallRealMethod(); + when(notificationHelperSpy.createNotification(any(), any(Bundle.class))).thenCallRealMethod(); + + // Plain text push — no attachment-url + RemoteMessage.Builder builder = new RemoteMessage.Builder("1234@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test notification"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, IterableTestUtils.getResourceString("push_payload_custom_action.json")); + controller.get().onMessageReceived(builder.build()); + + // Direct path: no WorkManager work should be enqueued + WorkManager workManager = WorkManager.getInstance(getContext()); + List workInfos = workManager.getWorkInfosByTag(IterableNotificationWorker.class.getName()).get(5, TimeUnit.SECONDS); + assertTrue("Plain text push should not enqueue WorkManager work", workInfos.isEmpty()); + + // Notification should still be created and posted directly + verify(notificationHelperSpy, atLeastOnce()).createNotification(any(), any(Bundle.class)); + verify(notificationHelperSpy, atLeastOnce()).postNotificationOnDevice(any(), any(IterableNotificationBuilder.class)); + } + + @Test + public void testImageNotificationUsesWorkManager() throws Exception { + when(notificationHelperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(notificationHelperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(notificationHelperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(notificationHelperSpy.hasAttachmentUrl(any(Bundle.class))).thenCallRealMethod(); + + // Push with attachment-url + RemoteMessage.Builder builder = new RemoteMessage.Builder("1234@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Image notification"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, IterableTestUtils.getResourceString("push_payload_image_push.json")); + controller.get().onMessageReceived(builder.build()); + + // WorkManager should have been used for the image download path + WorkManager workManager = WorkManager.getInstance(getContext()); + List workInfos = workManager.getWorkInfosByTag(IterableNotificationWorker.class.getName()).get(5, TimeUnit.SECONDS); + assertFalse("Image push should enqueue WorkManager work", workInfos.isEmpty()); + } + + @Test + public void testNotificationWorkerProcessesData() throws Exception { + when(notificationHelperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(notificationHelperSpy.createNotification(any(), any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("1234@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Worker test message"); + builder.addData(IterableConstants.ITERABLE_DATA_TITLE, "Worker Test"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, IterableTestUtils.getResourceString("push_payload_custom_action.json")); + + controller.get().onMessageReceived(builder.build()); + + // With SynchronousExecutor, work completes immediately + // Verify the notification was processed + verify(notificationHelperSpy, atLeastOnce()).createNotification(eq(getContext()), any(Bundle.class)); + } + + @Test + public void testRemovePushImageFromBundle() throws Exception { + when(notificationHelperSpy.hasAttachmentUrl(any(Bundle.class))).thenCallRealMethod(); + when(notificationHelperSpy.removePushImageFromBundle(any(Bundle.class))).thenCallRealMethod(); + + // Create a bundle with an attachment URL + Bundle bundleWithImage = new Bundle(); + bundleWithImage.putString(IterableConstants.ITERABLE_DATA_BODY, "Image notification"); + bundleWithImage.putString(IterableConstants.ITERABLE_DATA_KEY, IterableTestUtils.getResourceString("push_payload_image_push.json")); + + // Verify the bundle has an attachment URL + assertTrue("Bundle should have attachment URL", notificationHelperSpy.hasAttachmentUrl(bundleWithImage)); + + // Remove the image from the bundle + Bundle bundleWithoutImage = notificationHelperSpy.removePushImageFromBundle(bundleWithImage); + + // Verify the returned bundle no longer has an attachment URL + assertFalse("Bundle should not have attachment URL after removal", + notificationHelperSpy.hasAttachmentUrl(bundleWithoutImage)); + + // Verify the original bundle is not modified (immutable pattern) + assertTrue("Original bundle should still have attachment URL", + notificationHelperSpy.hasAttachmentUrl(bundleWithImage)); + } + + @Test + public void testRemovePushImageFromBundleWithNullBundle() throws Exception { + when(notificationHelperSpy.removePushImageFromBundle(any())).thenCallRealMethod(); + + // Test with null bundle + Bundle result = notificationHelperSpy.removePushImageFromBundle(null); + assertEquals("Should return null for null input", null, result); + } + + @Test + public void testRemovePushImageFromBundleWithoutIterableData() throws Exception { + when(notificationHelperSpy.removePushImageFromBundle(any(Bundle.class))).thenCallRealMethod(); + + // Create a bundle without Iterable data + Bundle bundleWithoutIterableData = new Bundle(); + bundleWithoutIterableData.putString("someKey", "someValue"); + + // Remove the image (should return the same bundle) + Bundle result = notificationHelperSpy.removePushImageFromBundle(bundleWithoutIterableData); + + // Should return the original bundle unchanged + assertEquals("Should return the same bundle when no Iterable data", bundleWithoutIterableData, result); + } } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationFlowTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationFlowTest.java new file mode 100644 index 000000000..2dcb045b5 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationFlowTest.java @@ -0,0 +1,439 @@ +package com.iterable.iterableapi; + +import android.os.Bundle; + +import androidx.work.Configuration; +import androidx.work.testing.SynchronousExecutor; +import androidx.work.testing.WorkManagerTestInitHelper; + +import com.google.firebase.messaging.RemoteMessage; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import okhttp3.mockwebserver.MockWebServer; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +public class IterableNotificationFlowTest extends BaseTest { + + private MockWebServer server; + private IterableNotificationHelper.IterableNotificationHelperImpl helperSpy; + private IterableNotificationHelper.IterableNotificationHelperImpl originalHelper; + + @Before + public void setUp() throws Exception { + IterableTestUtils.resetIterableApi(); + IterableTestUtils.createIterableApiNew(); + + server = new MockWebServer(); + IterableApi.overrideURLEndpointPath(server.url("").toString()); + + Configuration config = new Configuration.Builder() + .setMinimumLoggingLevel(android.util.Log.DEBUG) + .setExecutor(new SynchronousExecutor()) + .build(); + WorkManagerTestInitHelper.initializeTestWorkManager(getContext(), config); + + originalHelper = IterableNotificationHelper.instance; + helperSpy = spy(originalHelper); + IterableNotificationHelper.instance = helperSpy; + } + + @After + public void tearDown() throws Exception { + IterableNotificationHelper.instance = originalHelper; + if (server != null) { + server.shutdown(); + } + } + + // ======================================================================== + // MESSAGE VALIDATION TESTS + // ======================================================================== + + @Test + public void testIterablePushIsRecognized() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + + boolean isIterable = IterableFirebaseMessagingService.handleMessageReceived( + getContext(), builder.build()); + + assertTrue("Message with ITERABLE_DATA_KEY should be recognized", isIterable); + } + + @Test + public void testNonIterablePushIsIgnored() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData("some_other_key", "value"); + + boolean isIterable = IterableFirebaseMessagingService.handleMessageReceived( + getContext(), builder.build()); + + assertFalse("Message without ITERABLE_DATA_KEY should be ignored", isIterable); + } + + @Test + public void testEmptyMessageIsIgnored() { + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + + boolean isIterable = IterableFirebaseMessagingService.handleMessageReceived( + getContext(), builder.build()); + + assertFalse("Empty message should be ignored", isIterable); + } + + @Test + public void testGhostPushIsDetected() throws Exception { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_ghost_push.json")); + + boolean isGhost = IterableFirebaseMessagingService.isGhostPush(builder.build()); + + assertTrue("Ghost push should be detected", isGhost); + } + + @Test + public void testRegularPushIsNotGhost() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test"); + + boolean isGhost = IterableFirebaseMessagingService.isGhostPush(builder.build()); + + assertFalse("Regular push should not be ghost", isGhost); + } + + // ======================================================================== + // NOTIFICATION CREATION TESTS + // ======================================================================== + + @Test + public void testNotificationBuilderIsCreatedForValidPush() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test body"); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(helperSpy).createNotification(any(), any(Bundle.class)); + } + + @Test + public void testNotificationBuilderNotCreatedForGhostPush() throws Exception { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_ghost_push.json")); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(helperSpy, never()).createNotification(any(), any(Bundle.class)); + } + + @Test + public void testNotificationBuilderNotCreatedForEmptyBody() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + // No body + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(helperSpy, never()).createNotification(any(), any(Bundle.class)); + } + + // ======================================================================== + // NOTIFICATION POSTING TESTS + // ======================================================================== + + @Test + public void testNotificationIsPostedForValidPush() throws Exception { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, + IterableTestUtils.getResourceString("push_payload_custom_action.json")); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test"); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(helperSpy).postNotificationOnDevice(any(), any(IterableNotificationBuilder.class)); + } + + @Test + public void testNotificationNotPostedForGhostPush() throws Exception { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_ghost_push.json")); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(helperSpy, never()).postNotificationOnDevice(any(), any()); + } + + // ======================================================================== + // GHOST PUSH ACTION TESTS + // ======================================================================== + + @Test + public void testInAppUpdateActionIsTriggered() throws Exception { + IterableInAppManager inAppManager = org.mockito.Mockito.mock(IterableInAppManager.class); + IterableApi apiMock = spy(IterableApi.sharedInstance); + when(apiMock.getInAppManager()).thenReturn(inAppManager); + IterableApi.sharedInstance = apiMock; + + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_inapp_update.json")); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(inAppManager).syncInApp(); + } + + @Test + public void testInAppRemoveActionIsTriggered() throws Exception { + IterableInAppManager inAppManager = org.mockito.Mockito.mock(IterableInAppManager.class); + IterableApi apiMock = spy(IterableApi.sharedInstance); + when(apiMock.getInAppManager()).thenReturn(inAppManager); + IterableApi.sharedInstance = apiMock; + + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_inapp_remove.json")); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(inAppManager).removeMessage("1234567890abcdef"); + } + + @Test + public void testEmbeddedUpdateActionIsTriggered() throws Exception { + IterableEmbeddedManager embeddedManager = org.mockito.Mockito.mock(IterableEmbeddedManager.class); + IterableApi apiMock = spy(IterableApi.sharedInstance); + when(apiMock.getEmbeddedManager()).thenReturn(embeddedManager); + IterableApi.sharedInstance = apiMock; + + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_embedded_update.json")); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(embeddedManager).syncMessages(); + } + + // ======================================================================== + // DATA PRESERVATION TESTS + // ======================================================================== + + @Test + public void testNotificationTitleIsPreserved() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + String expectedTitle = "Test Title"; + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + builder.addData(IterableConstants.ITERABLE_DATA_TITLE, expectedTitle); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Body"); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + assertEquals(expectedTitle, bundleCaptor.getValue().getString(IterableConstants.ITERABLE_DATA_TITLE)); + } + + @Test + public void testNotificationBodyIsPreserved() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + String expectedBody = "Test Body Content"; + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, expectedBody); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + assertEquals(expectedBody, bundleCaptor.getValue().getString(IterableConstants.ITERABLE_DATA_BODY)); + } + + @Test + public void testNotificationDataKeyIsPreserved() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + String expectedData = "{\"campaignId\":123}"; + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, expectedData); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Body"); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + assertEquals(expectedData, bundleCaptor.getValue().getString(IterableConstants.ITERABLE_DATA_KEY)); + } + + @Test + public void testCustomFieldsArePreserved() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + String customValue = "customValue123"; + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Body"); + builder.addData("customField", customValue); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + assertEquals(customValue, bundleCaptor.getValue().getString("customField")); + } + + // ======================================================================== + // DIRECT vs WORKMANAGER ROUTING TESTS + // ======================================================================== + + @Test + public void testPlainTextNotificationIsHandledDirectly() throws Exception { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.hasAttachmentUrl(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, + IterableTestUtils.getResourceString("push_payload_custom_action.json")); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test"); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + // Direct path: createNotification and postNotificationOnDevice called without WorkManager + verify(helperSpy).createNotification(any(), any(Bundle.class)); + verify(helperSpy).postNotificationOnDevice(any(), any(IterableNotificationBuilder.class)); + } + + @Test + public void testImageNotificationUsesWorkManager() throws Exception { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.hasAttachmentUrl(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, + IterableTestUtils.getResourceString("push_payload_image_push.json")); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Image push"); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + // WorkManager path: Worker eventually calls createNotification (via SynchronousExecutor) + verify(helperSpy).createNotification(any(), any(Bundle.class)); + } + + @Test + public void testMultiplePlainTextNotificationsAreHandledDirectly() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.hasAttachmentUrl(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + for (int i = 0; i < 3; i++) { + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test " + i); + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + } + + verify(helperSpy, org.mockito.Mockito.times(3)).createNotification(any(), any(Bundle.class)); + } + + @Test + public void testNotificationDataIsPreservedOnDirectPath() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.hasAttachmentUrl(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + String testTitle = "Direct Path Title"; + String testBody = "Direct Path Body"; + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + builder.addData(IterableConstants.ITERABLE_DATA_TITLE, testTitle); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, testBody); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + Bundle capturedBundle = bundleCaptor.getValue(); + assertEquals(testTitle, capturedBundle.getString(IterableConstants.ITERABLE_DATA_TITLE)); + assertEquals(testBody, capturedBundle.getString(IterableConstants.ITERABLE_DATA_BODY)); + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkSchedulerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkSchedulerTest.java new file mode 100644 index 000000000..6f8f6aac4 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkSchedulerTest.java @@ -0,0 +1,520 @@ +package com.iterable.iterableapi; + +import android.os.Bundle; + +import androidx.work.Data; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.util.UUID; + +import okhttp3.mockwebserver.MockWebServer; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * TDD-style atomic tests for IterableNotificationWorkScheduler. + * Each test validates ONE specific behavior of the scheduler. + * + * Tests verify: + * - Work scheduling with WorkManager + * - Callback invocations + * - Error handling + * - Data preservation + * - WorkRequest configuration + */ +public class IterableNotificationWorkSchedulerTest extends BaseTest { + + private MockWebServer server; + private WorkManager mockWorkManager; + private IterableNotificationWorkScheduler scheduler; + + @Before + public void setUp() throws Exception { + IterableTestUtils.resetIterableApi(); + IterableTestUtils.createIterableApiNew(); + + server = new MockWebServer(); + IterableApi.overrideURLEndpointPath(server.url("").toString()); + + // Create mock WorkManager for testing + mockWorkManager = mock(WorkManager.class); + + // Create scheduler with mock WorkManager + scheduler = new IterableNotificationWorkScheduler(getContext(), mockWorkManager); + } + + @After + public void tearDown() throws Exception { + if (server != null) { + server.shutdown(); + } + } + + // ======================================================================== + // SCHEDULING SUCCESS TESTS + // ======================================================================== + + @Test + public void testScheduleNotificationWorkEnqueuesWithWorkManager() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, callback); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testScheduleNotificationWorkCallsSuccessCallback() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, callback); + + verify(callback).onScheduleSuccess(any(UUID.class)); + } + + @Test + public void testScheduleNotificationWorkPassesWorkIdToCallback() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, callback); + + ArgumentCaptor uuidCaptor = ArgumentCaptor.forClass(UUID.class); + verify(callback).onScheduleSuccess(uuidCaptor.capture()); + + UUID workId = uuidCaptor.getValue(); + assertNotNull("Work ID should not be null", workId); + } + + @Test + public void testScheduleNotificationWorkSucceedsWithNullCallback() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + // Should not throw exception with null callback + scheduler.scheduleNotificationWork(data, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testScheduleNotificationWorkEnqueuesOnlyOnce() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, null); + + // Verify enqueue called exactly once + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + // ======================================================================== + // SCHEDULING FAILURE TESTS + // ======================================================================== + + @Test + public void testScheduleNotificationWorkCallsFailureCallbackOnException() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + // Configure mock to throw exception + doThrow(new RuntimeException("WorkManager error")) + .when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, callback); + + verify(callback).onScheduleFailure(any(Exception.class), any(Bundle.class)); + } + + @Test + public void testScheduleNotificationWorkPassesExceptionToFailureCallback() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + RuntimeException testException = new RuntimeException("Test error"); + doThrow(testException).when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, callback); + + ArgumentCaptor exceptionCaptor = ArgumentCaptor.forClass(Exception.class); + verify(callback).onScheduleFailure(exceptionCaptor.capture(), any(Bundle.class)); + + assertEquals("Exception should match", testException, exceptionCaptor.getValue()); + } + + @Test + public void testScheduleNotificationWorkPassesOriginalDataToFailureCallback() { + Bundle data = new Bundle(); + data.putString("testKey", "testValue"); + + doThrow(new RuntimeException("Error")) + .when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, callback); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(callback).onScheduleFailure(any(Exception.class), bundleCaptor.capture()); + + Bundle capturedData = bundleCaptor.getValue(); + assertEquals("testValue", capturedData.getString("testKey")); + } + + @Test + public void testScheduleNotificationWorkHandlesFailureWithNullCallback() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + doThrow(new RuntimeException("Error")) + .when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + + // Should not throw exception with null callback + scheduler.scheduleNotificationWork(data, null); + } + + // ======================================================================== + // DATA HANDLING TESTS + // ======================================================================== + + @Test + public void testScheduleNotificationWorkPreservesNotificationData() { + Bundle data = new Bundle(); + data.putString(IterableConstants.ITERABLE_DATA_TITLE, "Test Title"); + data.putString(IterableConstants.ITERABLE_DATA_BODY, "Test Body"); + + scheduler.scheduleNotificationWork(data, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + OneTimeWorkRequest capturedRequest = requestCaptor.getValue(); + Data workData = capturedRequest.getWorkSpec().input; + + String jsonString = workData.getString(IterableNotificationWorker.KEY_NOTIFICATION_DATA_JSON); + assertNotNull("Notification data should be preserved", jsonString); + assertTrue("Should contain title", jsonString.contains("Test Title")); + assertTrue("Should contain body", jsonString.contains("Test Body")); + } + + @Test + public void testScheduleNotificationWorkHandlesEmptyBundle() { + Bundle emptyData = new Bundle(); + + scheduler.scheduleNotificationWork(emptyData, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testScheduleNotificationWorkPreservesMultipleFields() { + Bundle data = new Bundle(); + data.putString("field1", "value1"); + data.putString("field2", "value2"); + data.putString("field3", "value3"); + + scheduler.scheduleNotificationWork(data, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + OneTimeWorkRequest capturedRequest = requestCaptor.getValue(); + Data workData = capturedRequest.getWorkSpec().input; + + String jsonString = workData.getString(IterableNotificationWorker.KEY_NOTIFICATION_DATA_JSON); + assertTrue("Should contain field1", jsonString.contains("field1")); + assertTrue("Should contain field2", jsonString.contains("field2")); + assertTrue("Should contain field3", jsonString.contains("field3")); + } + + // ======================================================================== + // WORKMANAGER INTEGRATION TESTS + // ======================================================================== + + @Test + public void testScheduleNotificationWorkUsesCorrectWorkerClass() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + OneTimeWorkRequest capturedRequest = requestCaptor.getValue(); + assertEquals("Should use IterableNotificationWorker", + IterableNotificationWorker.class.getName(), + capturedRequest.getWorkSpec().workerClassName); + } + + @Test + public void testScheduleNotificationWorkCreatesOneTimeRequest() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, null); + + // Verify a OneTimeWorkRequest was enqueued + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testScheduleNotificationWorkSetsInputData() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + OneTimeWorkRequest capturedRequest = requestCaptor.getValue(); + Data workData = capturedRequest.getWorkSpec().input; + + assertNotNull("Input data should be set", workData); + assertNotNull("Should have notification JSON", + workData.getString(IterableNotificationWorker.KEY_NOTIFICATION_DATA_JSON)); + } + + // ======================================================================== + // CALLBACK BEHAVIOR TESTS + // ======================================================================== + + @Test + public void testSuccessCallbackIsCalledExactlyOnce() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, callback); + + verify(callback).onScheduleSuccess(any(UUID.class)); + verify(callback, never()).onScheduleFailure(any(Exception.class), any(Bundle.class)); + } + + @Test + public void testFailureCallbackIsCalledExactlyOnce() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + doThrow(new RuntimeException("Error")) + .when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, callback); + + verify(callback).onScheduleFailure(any(Exception.class), any(Bundle.class)); + verify(callback, never()).onScheduleSuccess(any(UUID.class)); + } + + @Test + public void testCallbacksAreOptional() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + // Should work without callbacks (null) + scheduler.scheduleNotificationWork(data, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testFailureCallbackReceivesCorrectException() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + IllegalStateException testException = new IllegalStateException("Test exception"); + doThrow(testException).when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, callback); + + ArgumentCaptor exceptionCaptor = ArgumentCaptor.forClass(Exception.class); + verify(callback).onScheduleFailure(exceptionCaptor.capture(), any(Bundle.class)); + + assertEquals("Should pass the same exception", testException, exceptionCaptor.getValue()); + } + + @Test + public void testFailureCallbackReceivesOriginalNotificationData() { + Bundle data = new Bundle(); + data.putString("originalKey", "originalValue"); + + doThrow(new RuntimeException("Error")) + .when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, callback); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(callback).onScheduleFailure(any(Exception.class), bundleCaptor.capture()); + + Bundle capturedData = bundleCaptor.getValue(); + assertEquals("originalValue", capturedData.getString("originalKey")); + } + + // ======================================================================== + // CONSTRUCTOR AND INITIALIZATION TESTS + // ======================================================================== + + @Test + public void testConstructorWithContext() { + // Create scheduler with just context (production constructor) + IterableNotificationWorkScheduler productionScheduler = + new IterableNotificationWorkScheduler(getContext()); + + assertNotNull("Scheduler should be created", productionScheduler); + assertNotNull("Context should be set", productionScheduler.getContext()); + assertNotNull("WorkManager should be initialized", productionScheduler.getWorkManager()); + } + + @Test + public void testConstructorWithContextAndWorkManager() { + WorkManager testWorkManager = mock(WorkManager.class); + + IterableNotificationWorkScheduler testScheduler = + new IterableNotificationWorkScheduler(getContext(), testWorkManager); + + assertNotNull("Scheduler should be created", testScheduler); + assertEquals("Should use injected WorkManager", testWorkManager, testScheduler.getWorkManager()); + } + + @Test + public void testConstructorUsesApplicationContext() { + IterableNotificationWorkScheduler testScheduler = + new IterableNotificationWorkScheduler(getContext()); + + assertEquals("Should use application context", + getContext().getApplicationContext(), + testScheduler.getContext()); + } + + // ======================================================================== + // DATA CREATION TESTS + // ======================================================================== + + @Test + public void testScheduleNotificationWorkCreatesValidInputData() { + Bundle data = new Bundle(); + data.putString(IterableConstants.ITERABLE_DATA_TITLE, "Title"); + + scheduler.scheduleNotificationWork(data, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + Data inputData = requestCaptor.getValue().getWorkSpec().input; + assertNotNull("Input data should not be null", inputData); + } + + @Test + public void testScheduleNotificationWorkIncludesAllRequiredKeys() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + Data inputData = requestCaptor.getValue().getWorkSpec().input; + + // Verify required keys are present + assertNotNull("Should have notification JSON", + inputData.getString(IterableNotificationWorker.KEY_NOTIFICATION_DATA_JSON)); + } + + @Test + public void testScheduleNotificationWorkWithComplexData() { + Bundle data = new Bundle(); + data.putString(IterableConstants.ITERABLE_DATA_KEY, "{\"campaignId\":123}"); + data.putString(IterableConstants.ITERABLE_DATA_TITLE, "Title"); + data.putString(IterableConstants.ITERABLE_DATA_BODY, "Body"); + data.putString("customField", "customValue"); + + scheduler.scheduleNotificationWork(data, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + // ======================================================================== + // EDGE CASE TESTS + // ======================================================================== + + @Test + public void testScheduleNotificationWorkHandlesSpecialCharactersInData() { + Bundle data = new Bundle(); + data.putString("special", "Value with symbols: !@#$% and \"quotes\""); + + scheduler.scheduleNotificationWork(data, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testScheduleNotificationWorkHandlesUnicodeInData() { + Bundle data = new Bundle(); + data.putString("unicode", "Unicode: 你好 👋 émojis 🎉"); + + scheduler.scheduleNotificationWork(data, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testScheduleNotificationWorkHandlesLargeBundle() { + Bundle data = new Bundle(); + for (int i = 0; i < 100; i++) { + data.putString("key" + i, "value" + i); + } + + scheduler.scheduleNotificationWork(data, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java new file mode 100644 index 000000000..9f55f4440 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java @@ -0,0 +1,435 @@ +package com.iterable.iterableapi; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.os.Bundle; + +import androidx.work.Data; +import androidx.work.ForegroundInfo; +import androidx.work.ListenableWorker; +import androidx.work.testing.TestListenableWorkerBuilder; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import okhttp3.mockwebserver.MockWebServer; + +/** + * TDD-style atomic tests for IterableNotificationWorker. + * Each test validates ONE specific behavior of the Worker. + */ +public class IterableNotificationWorkerUnitTest extends BaseTest { + + private MockWebServer server; + private IterableNotificationHelper.IterableNotificationHelperImpl helperSpy; + private IterableNotificationHelper.IterableNotificationHelperImpl originalHelper; + + @Before + public void setUp() throws Exception { + IterableTestUtils.resetIterableApi(); + IterableTestUtils.createIterableApiNew(); + + server = new MockWebServer(); + IterableApi.overrideURLEndpointPath(server.url("").toString()); + + originalHelper = IterableNotificationHelper.instance; + helperSpy = spy(originalHelper); + IterableNotificationHelper.instance = helperSpy; + } + + @After + public void tearDown() throws Exception { + IterableNotificationHelper.instance = originalHelper; + if (server != null) { + server.shutdown(); + } + } + + // ======================================================================== + // WORKER RESULT TESTS + // ======================================================================== + + @Test + public void testWorkerReturnsSuccessWithValidData() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + when(helperSpy.isIterablePush(any())).thenCallRealMethod(); + when(helperSpy.isGhostPush(any())).thenCallRealMethod(); + + Bundle bundle = new Bundle(); + bundle.putString(IterableConstants.ITERABLE_DATA_KEY, + IterableTestUtils.getResourceString("push_payload_custom_action.json")); + bundle.putString(IterableConstants.ITERABLE_DATA_BODY, "Test"); + + Data inputData = IterableNotificationWorker.createInputData(bundle); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + ListenableWorker.Result result = worker.doWork(); + + assertEquals(ListenableWorker.Result.success(), result); + } + + @Test + public void testWorkerReturnsFailureWithNullData() throws Exception { + Data inputData = new Data.Builder() + // No JSON data + .build(); + + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + ListenableWorker.Result result = worker.doWork(); + + assertEquals(ListenableWorker.Result.failure(), result); + } + + @Test + public void testWorkerReturnsFailureWithEmptyData() throws Exception { + Data inputData = new Data.Builder() + .putString(IterableNotificationWorker.KEY_NOTIFICATION_DATA_JSON, "") + .build(); + + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + ListenableWorker.Result result = worker.doWork(); + + assertEquals(ListenableWorker.Result.failure(), result); + } + + // ======================================================================== + // WORKER BEHAVIOR TESTS + // ======================================================================== + + @Test + public void testWorkerCallsCreateNotificationWithValidData() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + Bundle bundle = new Bundle(); + bundle.putString(IterableConstants.ITERABLE_DATA_KEY, "{}"); + bundle.putString(IterableConstants.ITERABLE_DATA_BODY, "Test"); + + Data inputData = IterableNotificationWorker.createInputData(bundle); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + verify(helperSpy).createNotification(any(), any(Bundle.class)); + } + + @Test + public void testWorkerCallsPostNotificationWithValidBuilder() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + when(helperSpy.isIterablePush(any())).thenCallRealMethod(); + when(helperSpy.isGhostPush(any())).thenCallRealMethod(); + + Bundle bundle = new Bundle(); + bundle.putString(IterableConstants.ITERABLE_DATA_KEY, + IterableTestUtils.getResourceString("push_payload_custom_action.json")); + bundle.putString(IterableConstants.ITERABLE_DATA_BODY, "Test"); + + Data inputData = IterableNotificationWorker.createInputData(bundle); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + verify(helperSpy).postNotificationOnDevice(any(), any(IterableNotificationBuilder.class)); + } + + @Test + public void testWorkerDoesNotCallPostNotificationWhenBuilderIsNull() throws Exception { + when(helperSpy.createNotification(any(), any())).thenReturn(null); + + Bundle bundle = new Bundle(); + bundle.putString(IterableConstants.ITERABLE_DATA_KEY, "{}"); + + Data inputData = IterableNotificationWorker.createInputData(bundle); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + verify(helperSpy, never()).postNotificationOnDevice(any(), any()); + } + + @Test + public void testWorkerSucceedsWhenBuilderIsNull() throws Exception { + when(helperSpy.createNotification(any(), any())).thenReturn(null); + + Bundle bundle = new Bundle(); + bundle.putString(IterableConstants.ITERABLE_DATA_KEY, "{}"); + + Data inputData = IterableNotificationWorker.createInputData(bundle); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + ListenableWorker.Result result = worker.doWork(); + + assertEquals("Worker should succeed even when builder is null", + ListenableWorker.Result.success(), result); + } + + // ======================================================================== + // DATA SERIALIZATION TESTS - Input Creation + // ======================================================================== + + @Test + public void testCreateInputDataReturnsNonNullData() { + Bundle bundle = new Bundle(); + bundle.putString("key", "value"); + + Data inputData = IterableNotificationWorker.createInputData(bundle); + + assertNotNull("Input data should not be null", inputData); + } + + @Test + public void testCreateInputDataIncludesJsonString() { + Bundle bundle = new Bundle(); + bundle.putString(IterableConstants.ITERABLE_DATA_TITLE, "Test"); + + Data inputData = IterableNotificationWorker.createInputData(bundle); + + String json = inputData.getString(IterableNotificationWorker.KEY_NOTIFICATION_DATA_JSON); + assertNotNull("JSON string should be present", json); + } + + @Test + public void testCreateInputDataHandlesEmptyBundle() { + Bundle bundle = new Bundle(); + + Data inputData = IterableNotificationWorker.createInputData(bundle); + + assertNotNull("Input data should not be null for empty bundle", inputData); + } + + // ======================================================================== + // DATA SERIALIZATION TESTS - Deserialization + // ======================================================================== + + @Test + public void testDeserializationPreservesSingleField() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + Bundle originalBundle = new Bundle(); + originalBundle.putString(IterableConstants.ITERABLE_DATA_TITLE, "Test Title"); + originalBundle.putString(IterableConstants.ITERABLE_DATA_KEY, "{}"); + + Data inputData = IterableNotificationWorker.createInputData(originalBundle); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + Bundle deserializedBundle = bundleCaptor.getValue(); + assertEquals("Test Title", deserializedBundle.getString(IterableConstants.ITERABLE_DATA_TITLE)); + } + + @Test + public void testDeserializationPreservesMultipleFields() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + Bundle originalBundle = new Bundle(); + originalBundle.putString(IterableConstants.ITERABLE_DATA_TITLE, "Title"); + originalBundle.putString(IterableConstants.ITERABLE_DATA_BODY, "Body"); + originalBundle.putString(IterableConstants.ITERABLE_DATA_KEY, "{}"); + originalBundle.putString("custom", "value"); + + Data inputData = IterableNotificationWorker.createInputData(originalBundle); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + Bundle deserializedBundle = bundleCaptor.getValue(); + assertEquals("Title", deserializedBundle.getString(IterableConstants.ITERABLE_DATA_TITLE)); + assertEquals("Body", deserializedBundle.getString(IterableConstants.ITERABLE_DATA_BODY)); + assertEquals("{}", deserializedBundle.getString(IterableConstants.ITERABLE_DATA_KEY)); + assertEquals("value", deserializedBundle.getString("custom")); + } + + @Test + public void testDeserializationPreservesSpecialCharacters() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + String specialValue = "Test with spaces, symbols: !@#$%, and \"quotes\""; + Bundle originalBundle = new Bundle(); + originalBundle.putString(IterableConstants.ITERABLE_DATA_KEY, "{}"); + originalBundle.putString("special", specialValue); + + Data inputData = IterableNotificationWorker.createInputData(originalBundle); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + Bundle deserializedBundle = bundleCaptor.getValue(); + assertEquals(specialValue, deserializedBundle.getString("special")); + } + + @Test + public void testDeserializationPreservesKeyCount() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + Bundle originalBundle = new Bundle(); + originalBundle.putString("key1", "value1"); + originalBundle.putString("key2", "value2"); + originalBundle.putString("key3", "value3"); + originalBundle.putString(IterableConstants.ITERABLE_DATA_KEY, "{}"); + + int originalCount = originalBundle.keySet().size(); + + Data inputData = IterableNotificationWorker.createInputData(originalBundle); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + Bundle deserializedBundle = bundleCaptor.getValue(); + assertEquals("Key count should match", originalCount, deserializedBundle.keySet().size()); + } + + @Test + public void testDeserializationHandlesJsonWithNestedObjects() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + String complexJson = "{\"campaignId\":123,\"metadata\":{\"key\":\"value\"}}"; + Bundle originalBundle = new Bundle(); + originalBundle.putString(IterableConstants.ITERABLE_DATA_KEY, complexJson); + + Data inputData = IterableNotificationWorker.createInputData(originalBundle); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + Bundle deserializedBundle = bundleCaptor.getValue(); + assertEquals(complexJson, deserializedBundle.getString(IterableConstants.ITERABLE_DATA_KEY)); + } + + // ======================================================================== + // FOREGROUND INFO TESTS (pre-Android 12 expedited work support) + // ======================================================================== + + @Test + public void testGetForegroundInfoReturnsNonNull() throws Exception { + Bundle bundle = new Bundle(); + bundle.putString("key", "value"); + + Data inputData = IterableNotificationWorker.createInputData(bundle); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + ForegroundInfo foregroundInfo = worker.getForegroundInfo(); + + assertNotNull("getForegroundInfo should return non-null ForegroundInfo", foregroundInfo); + } + + @Test + public void testGetForegroundInfoReturnsValidNotificationId() throws Exception { + Bundle bundle = new Bundle(); + bundle.putString("key", "value"); + + Data inputData = IterableNotificationWorker.createInputData(bundle); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + ForegroundInfo foregroundInfo = worker.getForegroundInfo(); + + assertNotNull("ForegroundInfo should contain a notification", foregroundInfo.getNotification()); + } + + @Test + public void testGetForegroundInfoDoesNotThrow() throws Exception { + Bundle bundle = new Bundle(); + bundle.putString("key", "value"); + + Data inputData = IterableNotificationWorker.createInputData(bundle); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + // Should not throw any exception - this is critical for pre-Android 12 expedited work + try { + ForegroundInfo foregroundInfo = worker.getForegroundInfo(); + assertNotNull(foregroundInfo); + } catch (Exception e) { + throw new AssertionError( + "getForegroundInfo() must not throw on pre-Android 12 devices. " + + "Without this, setExpedited() causes IllegalStateException. Error: " + e.getMessage(), e); + } + } + + @Test + public void testGetForegroundInfoCanBeCalledMultipleTimes() throws Exception { + Bundle bundle = new Bundle(); + bundle.putString("key", "value"); + + Data inputData = IterableNotificationWorker.createInputData(bundle); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + // Should be safe to call multiple times without issues + ForegroundInfo first = worker.getForegroundInfo(); + ForegroundInfo second = worker.getForegroundInfo(); + + assertNotNull(first); + assertNotNull(second); + } +} diff --git a/iterableapi/src/test/resources/push_payload_image_push.json b/iterableapi/src/test/resources/push_payload_image_push.json new file mode 100644 index 000000000..8eb06c8ed --- /dev/null +++ b/iterableapi/src/test/resources/push_payload_image_push.json @@ -0,0 +1,11 @@ +{ + "campaignId": 1234, + "templateId": 4321, + "messageId": "abc123imagepush", + "isGhostPush": false, + "attachment-url": "https://example.com/promo-image.jpg", + "defaultAction": { + "type": "openUrl", + "data": "https://example.com" + } +}