diff --git a/iterableapi/build.gradle b/iterableapi/build.gradle index e29737da3..cbbbac757 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..c8d17b729 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java @@ -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,13 @@ 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); + + enqueueNotificationWork(context, extras, false); } else { IterableLogger.d(TAG, "Iterable OS notification push received"); } @@ -105,9 +107,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,17 +122,65 @@ 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 == null || messageData.isEmpty()) { + if (messageData.isEmpty()) { + return false; + } + + 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, boolean isGhostPush) { + IterableNotificationWorkScheduler scheduler = new IterableNotificationWorkScheduler(context); + + scheduler.scheduleNotificationWork( + extras, + isGhostPush, + new IterableNotificationWorkScheduler.SchedulerCallback() { + @Override + public void onScheduleSuccess(UUID workId) { + IterableLogger.d(TAG, "Notification work scheduled successfully: " + workId); + } + + @Override + public void onScheduleFailure(Exception exception, Bundle notificationData) { + IterableLogger.e(TAG, "Failed to schedule notification work", exception); + IterableLogger.e(TAG, "Attempting FALLBACK to direct processing..."); + handleFallbackNotification(context, notificationData); + } + } + ); + } + + private static void handleFallbackNotification(@NonNull Context context, @NonNull Bundle extras) { + try { + IterableNotificationBuilder notificationBuilder = IterableNotificationHelper.createNotification( + context.getApplicationContext(), extras); + if (notificationBuilder != null) { + IterableNotificationHelper.postNotificationOnDevice(context, notificationBuilder); + IterableLogger.d(TAG, "✓ FALLBACK succeeded - notification posted directly"); + } else { + IterableLogger.w(TAG, "✗ FALLBACK: Notification builder is null"); + } + } catch (Exception fallbackException) { + IterableLogger.e(TAG, "✗ CRITICAL: FALLBACK also failed!", fallbackException); + IterableLogger.e(TAG, "NOTIFICATION WILL NOT BE DISPLAYED"); + } } } +/** + * @deprecated This class is no longer used. Notification processing now uses WorkManager + * to comply with Firebase best practices. This class is kept for backwards compatibility only. + */ +@Deprecated class IterableNotificationManager extends AsyncTask { @Override diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java index 2625b32bf..51e21704f 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java @@ -98,6 +98,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 +441,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,7 +452,7 @@ 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, ""); } 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..03fa68658 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java @@ -0,0 +1,146 @@ +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.OneTimeWorkRequest; +import androidx.work.OutOfQuotaPolicy; +import androidx.work.WorkManager; + +import java.util.UUID; + +/** + * Manages scheduling of notification processing work using WorkManager. + * This class is responsible for: + * - Creating WorkManager requests for notification processing + * - Enqueueing work with expedited execution for high-priority notifications + * - Providing callback interface for success/failure handling + * - Comprehensive logging of scheduling operations + */ +class IterableNotificationWorkScheduler { + + private static final String TAG = "IterableNotificationWorkScheduler"; + + private final Context context; + private final WorkManager workManager; + + /** + * Callback interface for work scheduling results. + * Allows caller to handle success/failure appropriately. + */ + public interface SchedulerCallback { + /** + * Called when work is successfully scheduled. + * @param workId UUID of the scheduled work + */ + void onScheduleSuccess(UUID workId); + + /** + * Called when work scheduling fails. + * @param exception The exception that caused the failure + * @param notificationData The original notification data (for fallback) + */ + void onScheduleFailure(Exception exception, Bundle notificationData); + } + + /** + * Constructor for production use. + * Initializes with application context and default WorkManager instance. + * + * @param context Application or service context + */ + public IterableNotificationWorkScheduler(@NonNull Context context) { + this(context, WorkManager.getInstance(context)); + } + + /** + * Constructor for testing. + * Allows injection of mock WorkManager for unit testing. + * + * @param context Application or service context + * @param workManager WorkManager instance (can be mocked for tests) + */ + @VisibleForTesting + IterableNotificationWorkScheduler(@NonNull Context context, @NonNull WorkManager workManager) { + this.context = context.getApplicationContext(); + this.workManager = workManager; + } + + /** + * Schedules notification processing work using WorkManager. + * + * Creates an expedited OneTimeWorkRequest and enqueues it with WorkManager. + * Expedited execution ensures high-priority notifications are processed promptly, + * with quota exemption when called from FCM onMessageReceived. + * + * @param notificationData Bundle containing notification data + * @param isGhostPush Whether this is a ghost/silent push + * @param callback Optional callback for success/failure (can be null) + */ + public void scheduleNotificationWork( + @NonNull Bundle notificationData, + boolean isGhostPush, + @Nullable SchedulerCallback callback) { + + IterableLogger.d(TAG, "========================================"); + IterableLogger.d(TAG, "Scheduling notification work"); + IterableLogger.d(TAG, "Bundle keys: " + notificationData.keySet().size()); + IterableLogger.d(TAG, "Is ghost push: " + isGhostPush); + + try { + IterableLogger.d(TAG, "Step 1: Creating Worker input data"); + androidx.work.Data inputData = IterableNotificationWorker.createInputData( + notificationData, + isGhostPush + ); + IterableLogger.d(TAG, " ✓ Worker input data created successfully"); + + IterableLogger.d(TAG, "Step 2: Building expedited WorkRequest"); + OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(IterableNotificationWorker.class) + .setInputData(inputData) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build(); + IterableLogger.d(TAG, " ✓ WorkRequest built with expedited execution"); + + IterableLogger.d(TAG, "Step 3: Enqueueing work with WorkManager"); + workManager.enqueue(workRequest); + + UUID workId = workRequest.getId(); + IterableLogger.d(TAG, " ✓ Work enqueued successfully"); + IterableLogger.d(TAG, ""); + IterableLogger.d(TAG, "✓ NOTIFICATION WORK SCHEDULED"); + IterableLogger.d(TAG, " Work ID: " + workId); + IterableLogger.d(TAG, " Priority: EXPEDITED (high-priority notification)"); + IterableLogger.d(TAG, " Worker: " + IterableNotificationWorker.class.getSimpleName()); + IterableLogger.d(TAG, "========================================"); + + if (callback != null) { + callback.onScheduleSuccess(workId); + } + + } catch (Exception e) { + IterableLogger.e(TAG, "========================================"); + IterableLogger.e(TAG, "✗ FAILED TO SCHEDULE NOTIFICATION WORK"); + IterableLogger.e(TAG, "Error type: " + e.getClass().getSimpleName()); + IterableLogger.e(TAG, "Error message: " + e.getMessage()); + IterableLogger.e(TAG, "========================================"); + + 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..9fd93983b --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt @@ -0,0 +1,145 @@ +package com.iterable.iterableapi + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Bundle +import androidx.annotation.WorkerThread +import androidx.work.Data +import androidx.work.Worker +import androidx.work.WorkerParameters +import org.json.JSONObject +import java.io.IOException +import java.net.URL + +/** + * WorkManager Worker to handle push notification processing. + * This replaces the deprecated AsyncTask approach to comply with Firebase best practices. + * + * The Worker handles: + * - Downloading notification images from remote URLs + * - Building notifications with proper styling + * - Posting notifications to the system + */ +internal class IterableNotificationWorker( + context: Context, + params: WorkerParameters +) : Worker(context, params) { + + companion object { + private const val TAG = "IterableNotificationWorker" + + const val KEY_NOTIFICATION_DATA_JSON = "notification_data_json" + const val KEY_IS_GHOST_PUSH = "is_ghost_push" + + /** + * Creates input data for the Worker from a Bundle. + * Converts the Bundle to JSON for reliable serialization. + */ + @JvmStatic + fun createInputData(extras: Bundle, isGhostPush: Boolean): 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()) + .putBoolean(KEY_IS_GHOST_PUSH, isGhostPush) + .build() + } + } + + @WorkerThread + override fun doWork(): Result { + IterableLogger.d(TAG, "========================================") + IterableLogger.d(TAG, "Starting notification processing in Worker") + IterableLogger.d(TAG, "Worker ID: $id") + IterableLogger.d(TAG, "Run attempt: $runAttemptCount") + + try { + val isGhostPush = inputData.getBoolean(KEY_IS_GHOST_PUSH, false) + IterableLogger.d(TAG, "Step 1: Ghost push check - isGhostPush=$isGhostPush") + + if (isGhostPush) { + IterableLogger.d(TAG, "Ghost push detected - no user-visible notification to display") + return Result.success() + } + + val jsonString = inputData.getString(KEY_NOTIFICATION_DATA_JSON) + IterableLogger.d(TAG, "Step 2: Retrieved notification JSON data (length=${jsonString?.length ?: 0})") + + if (jsonString == null || jsonString.isEmpty()) { + IterableLogger.e(TAG, "CRITICAL ERROR: No notification data provided to Worker") + return Result.failure() + } + + IterableLogger.d(TAG, "Step 3: Deserializing notification data from JSON") + val extras = jsonToBundle(jsonString) + val keyCount = extras.keySet().size + IterableLogger.d(TAG, "Step 3: Deserialized $keyCount keys from notification data") + + if (keyCount == 0) { + IterableLogger.e(TAG, "CRITICAL ERROR: Deserialized bundle is empty") + return Result.failure() + } + + IterableLogger.d(TAG, "Step 4: Creating notification builder") + val notificationBuilder = IterableNotificationHelper.createNotification( + applicationContext, + extras + ) + + if (notificationBuilder == null) { + IterableLogger.w(TAG, "Step 4: Notification builder is null (likely ghost push or invalid data)") + return Result.success() + } + + IterableLogger.d(TAG, "Step 4: Notification builder created successfully") + val hasImage = extras.getString(IterableConstants.ITERABLE_DATA_PUSH_IMAGE) != null + if (hasImage) { + IterableLogger.d(TAG, "Step 4: Notification contains image URL: ${extras.getString(IterableConstants.ITERABLE_DATA_PUSH_IMAGE)}") + } + + IterableLogger.d(TAG, "Step 5: Posting notification to device (this may download images)") + IterableNotificationHelper.postNotificationOnDevice( + applicationContext, + notificationBuilder + ) + + IterableLogger.d(TAG, "Step 5: Notification posted successfully to NotificationManager") + IterableLogger.d(TAG, "Notification processing COMPLETED successfully") + IterableLogger.d(TAG, "========================================") + return Result.success() + + } catch (e: Exception) { + IterableLogger.e(TAG, "========================================") + IterableLogger.e(TAG, "CRITICAL ERROR processing notification in Worker", e) + IterableLogger.e(TAG, "Error type: ${e.javaClass.simpleName}") + IterableLogger.e(TAG, "Error message: ${e.message}") + IterableLogger.e(TAG, "Stack trace:", e) + IterableLogger.e(TAG, "========================================") + + 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..21021861e 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; @@ -21,6 +29,7 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.TestCase.assertFalse; import static junit.framework.TestCase.assertTrue; +import static junit.framework.TestCase.assertNotNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; @@ -46,6 +55,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 +155,42 @@ public void testUpdateMessagesIsCalled() throws Exception { controller.get().onMessageReceived(builder.build()); verify(embeddedManagerSpy, atLeastOnce()).syncMessages(); } + + @Test + public void testWorkManagerIsUsedForNotifications() throws Exception { + when(notificationHelperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(notificationHelperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(notificationHelperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + + // Send a regular push notification (not ghost push) + 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()); + + // Verify WorkManager has enqueued work + WorkManager workManager = WorkManager.getInstance(getContext()); + List workInfos = workManager.getWorkInfosByTag(IterableNotificationWorker.class.getName()).get(5, TimeUnit.SECONDS); + + // Note: With SynchronousExecutor, work completes immediately + // Verify that notification helper methods were called (indicating Worker ran) + verify(notificationHelperSpy, atLeastOnce()).createNotification(any(), any(Bundle.class)); + } + + @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)); + } } 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..01832544d --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationFlowTest.java @@ -0,0 +1,429 @@ +package com.iterable.iterableapi; + +import android.os.Bundle; + +import androidx.work.Configuration; +import androidx.work.Data; +import androidx.work.WorkManager; +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 java.util.HashMap; +import java.util.Map; + +import okhttp3.mockwebserver.MockWebServer; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertNotNull; +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")); + } + + // ======================================================================== + // SCHEDULER INTEGRATION TESTS + // ======================================================================== + + @Test + public void testNotificationUsesWorkManagerScheduling() 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 notification was posted (via WorkManager with SynchronousExecutor) + verify(helperSpy).postNotificationOnDevice(any(), any(IterableNotificationBuilder.class)); + } + + @Test + public void testSchedulerHandlesMultipleNotifications() { + 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(); + + // Send three notifications + 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 all three notifications were created + verify(helperSpy, org.mockito.Mockito.times(3)) + .createNotification(any(), any(Bundle.class)); + } + + @Test + public void testSchedulerPreservesNotificationDataThroughWorkManager() { + 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 testTitle = "Scheduler Test Title"; + String testBody = "Scheduler Test 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()); + + // Verify data was preserved through the scheduler -> worker -> notification flow + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + Bundle capturedBundle = bundleCaptor.getValue(); + assertEquals("Title should be preserved through scheduler", + testTitle, capturedBundle.getString(IterableConstants.ITERABLE_DATA_TITLE)); + assertEquals("Body should be preserved through scheduler", + 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..b6de3f4e7 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkSchedulerTest.java @@ -0,0 +1,558 @@ +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"); + + scheduler.scheduleNotificationWork(data, false, null); + + 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, false, 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, false, 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, false, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testScheduleNotificationWorkEnqueuesOnlyOnce() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, false, 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, false, 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, false, 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, false, 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, false, 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, false, 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 testScheduleNotificationWorkHandlesGhostPushFlagTrue() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, true, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + OneTimeWorkRequest capturedRequest = requestCaptor.getValue(); + Data workData = capturedRequest.getWorkSpec().input; + + boolean isGhostPush = workData.getBoolean(IterableNotificationWorker.KEY_IS_GHOST_PUSH, false); + assertEquals("Ghost push flag should be true", true, isGhostPush); + } + + @Test + public void testScheduleNotificationWorkHandlesGhostPushFlagFalse() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, false, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + OneTimeWorkRequest capturedRequest = requestCaptor.getValue(); + Data workData = capturedRequest.getWorkSpec().input; + + boolean isGhostPush = workData.getBoolean(IterableNotificationWorker.KEY_IS_GHOST_PUSH, true); + assertEquals("Ghost push flag should be false", false, isGhostPush); + } + + @Test + public void testScheduleNotificationWorkHandlesEmptyBundle() { + Bundle emptyData = new Bundle(); + + scheduler.scheduleNotificationWork(emptyData, false, 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, false, 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, false, 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, false, 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, false, 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, false, 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, false, 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, false, 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, false, 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, false, 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, false, 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, false, 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)); + + // Ghost push flag should be present (default false) + boolean hasFlag = inputData.getKeyValueMap() + .containsKey(IterableNotificationWorker.KEY_IS_GHOST_PUSH); + assertTrue("Should have ghost push flag", hasFlag); + } + + @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, false, 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, false, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testScheduleNotificationWorkHandlesUnicodeInData() { + Bundle data = new Bundle(); + data.putString("unicode", "Unicode: 你好 👋 émojis 🎉"); + + scheduler.scheduleNotificationWork(data, false, 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, false, 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..3d216cc9a --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java @@ -0,0 +1,405 @@ +package com.iterable.iterableapi; + +import android.os.Bundle; + +import androidx.work.Data; +import androidx.work.ListenableWorker; +import androidx.work.testing.TestListenableWorkerBuilder; + +import org.json.JSONObject; +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.assertNotNull; +import static junit.framework.Assert.assertNull; +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; + +/** + * 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, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + ListenableWorker.Result result = worker.doWork(); + + assertEquals(ListenableWorker.Result.success(), result); + } + + @Test + public void testWorkerReturnsSuccessForGhostPush() throws Exception { + Bundle bundle = new Bundle(); + bundle.putString("someKey", "someValue"); + + Data inputData = IterableNotificationWorker.createInputData(bundle, true); // isGhostPush=true + 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() + .putBoolean(IterableNotificationWorker.KEY_IS_GHOST_PUSH, false) + // 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, "") + .putBoolean(IterableNotificationWorker.KEY_IS_GHOST_PUSH, false) + .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, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + verify(helperSpy).createNotification(any(), any(Bundle.class)); + } + + @Test + public void testWorkerDoesNotCallCreateNotificationForGhostPush() throws Exception { + Bundle bundle = new Bundle(); + bundle.putString("key", "value"); + + Data inputData = IterableNotificationWorker.createInputData(bundle, true); // isGhostPush=true + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + verify(helperSpy, never()).createNotification(any(), any()); + } + + @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, false); + 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, false); + 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, false); + 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, false); + + 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, false); + + String json = inputData.getString(IterableNotificationWorker.KEY_NOTIFICATION_DATA_JSON); + assertNotNull("JSON string should be present", json); + } + + @Test + public void testCreateInputDataIncludesGhostPushFlag() { + Bundle bundle = new Bundle(); + bundle.putString("key", "value"); + + Data inputDataTrue = IterableNotificationWorker.createInputData(bundle, true); + Data inputDataFalse = IterableNotificationWorker.createInputData(bundle, false); + + assertEquals(true, inputDataTrue.getBoolean(IterableNotificationWorker.KEY_IS_GHOST_PUSH, false)); + assertEquals(false, inputDataFalse.getBoolean(IterableNotificationWorker.KEY_IS_GHOST_PUSH, true)); + } + + @Test + public void testCreateInputDataHandlesEmptyBundle() { + Bundle bundle = new Bundle(); + + Data inputData = IterableNotificationWorker.createInputData(bundle, false); + + 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, false); + 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, false); + 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, false); + 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, false); + 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, false); + 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)); + } +}