diff --git a/integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt b/integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt index 68fac4f3c..c584f503c 100644 --- a/integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt +++ b/integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt @@ -5,14 +5,18 @@ import android.util.Log import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import com.google.firebase.messaging.RemoteMessage import com.iterable.iterableapi.IterableApi import com.iterable.iterableapi.IterableConfig +import com.iterable.iterableapi.IterableFirebaseMessagingService +import com.iterable.integration.tests.services.IntegrationFirebaseMessagingService import com.iterable.integration.tests.utils.IntegrationTestUtils import com.iterable.integration.tests.utils.TestUserEmailGenerator import com.iterable.integration.tests.utils.TestUserEmailOverride import com.iterable.integration.tests.TestConstants import org.awaitility.Awaitility import org.awaitility.core.ConditionTimeoutException +import org.json.JSONObject import org.junit.After import org.junit.Before import org.junit.runner.RunWith @@ -196,6 +200,31 @@ abstract class BaseIntegrationTest { } + // Local-mode gate: avoids racing IterableApi.registerForPush(). In CI [injectPushMessage] + // bypasses FCM, so this is unused there. + protected fun waitForDeviceTokenRegistered(timeoutSeconds: Long = 20): Boolean { + return waitForCondition({ + IntegrationFirebaseMessagingService.tokenRegistered.get() + }, timeoutSeconds) + } + + // CI-mode push injection. Builds a RemoteMessage locally and feeds it to the SDK's + // normal handler — same shape as iOS's `xcrun simctl push booted`. itblPayload goes + // under the `itbl` data key; title/body/extraData are top-level data fields. + protected fun injectPushMessage( + itblPayload: JSONObject, + title: String? = null, + body: String? = null, + extraData: Map = emptyMap() + ): Boolean { + val builder = RemoteMessage.Builder("test@gcm.googleapis.com") + .addData("itbl", itblPayload.toString()) + title?.let { builder.addData("title", it) } + body?.let { builder.addData("body", it) } + extraData.forEach { (k, v) -> builder.addData(k, v) } + return IterableFirebaseMessagingService.handleMessageReceived(context, builder.build()) + } + /** * Trigger a campaign via Iterable API */ diff --git a/integration-tests/src/androidTest/java/com/iterable/integration/tests/PushNotificationIntegrationTest.kt b/integration-tests/src/androidTest/java/com/iterable/integration/tests/PushNotificationIntegrationTest.kt index 99d3c587e..9c5adeadb 100644 --- a/integration-tests/src/androidTest/java/com/iterable/integration/tests/PushNotificationIntegrationTest.kt +++ b/integration-tests/src/androidTest/java/com/iterable/integration/tests/PushNotificationIntegrationTest.kt @@ -13,6 +13,7 @@ import androidx.test.uiautomator.By import com.iterable.iterableapi.IterableApi import com.iterable.integration.tests.activities.PushNotificationTestActivity import org.awaitility.Awaitility +import org.json.JSONObject import org.junit.After import org.junit.Assert import org.junit.Before @@ -23,10 +24,16 @@ import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class PushNotificationIntegrationTest : BaseIntegrationTest() { - + companion object { private const val TAG = "PushNotificationIntegrationTest" private const val TEST_PUSH_CAMPAIGN_ID = TestConstants.TEST_PUSH_CAMPAIGN_ID + // Captured from a real BCIT delivery; only used in CI to populate `itbl.templateId`. + private const val BCIT_TEMPLATE_ID = 20392358 + private const val APP_PACKAGE = "com.iterable.integration.tests" + // Title substring; avoids the emoji prefix which `By.text` matches inconsistently. + private const val EXPECTED_TITLE_SUBSTRING = "BCIT Push Notification Test" + private const val NOTIFICATION_TIMEOUT_SECONDS = 30L } private lateinit var uiDevice: UiDevice @@ -36,12 +43,12 @@ class PushNotificationIntegrationTest : BaseIntegrationTest() { override fun setUp() { uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) super.setUp() - + IterableApi.getInstance().inAppManager.setAutoDisplayPaused(true) IterableApi.getInstance().inAppManager.messages.forEach { IterableApi.getInstance().inAppManager.removeMessage(it) } - + launchAppAndNavigateToPushNotificationTesting() } @@ -62,8 +69,9 @@ class PushNotificationIntegrationTest : BaseIntegrationTest() { mainActivityScenario.state == Lifecycle.State.RESUMED } + // RESUMED fires before view inflation completes; waitForExists handles the race. val pushButton = uiDevice.findObject(UiSelector().resourceId("com.iterable.integration.tests:id/btnPushNotifications")) - if (!pushButton.exists()) { + if (!pushButton.waitForExists(5000)) { Assert.fail("Push Notifications button not found in MainActivity") } pushButton.click() @@ -71,88 +79,142 @@ class PushNotificationIntegrationTest : BaseIntegrationTest() { } @Test - @Ignore("SDK-115 follow-up: foreground assertion after openNotification() is unreliable on the BCIT CI emulator (notification taps don't always resume the test app). Re-enable once the open path is stable.") fun testPushNotificationMVP() { Assert.assertTrue("User should be signed in", testUtils.ensureUserSignedIn(TestConstants.TEST_USER_EMAIL)) Assert.assertTrue("Notification permission should be granted", hasNotificationPermission()) - - // Test 1: Trigger campaign, minimize app, open notification, verify app opens - Log.d(TAG, "Test 1: Push notification open action") + + if (!isRunningInCI) { + // Local-mode only: wait for token registration + a backend cool-down before + // triggering the real campaign. CI uses [injectPushMessage] and skips both. + Assert.assertTrue( + "Device token should be registered with Iterable SDK before triggering a campaign", + waitForDeviceTokenRegistered(timeoutSeconds = 20) + ) + Thread.sleep(5_000) + } + + Log.d(TAG, "MVP: Push notification open action") triggerCampaignAndWait() uiDevice.pressHome() Thread.sleep(1000) - + uiDevice.openNotification() Thread.sleep(1000) - val notification1 = findNotification() - Assert.assertNotNull("Notification should be found", notification1) - - notification1?.click() - Thread.sleep(2000) // Wait for app to open - - // Verify app is in foreground by checking current package name + val notification = findNotification() + Assert.assertNotNull("Notification should be found", notification) + + notification?.click() + Thread.sleep(2000) + val isAppInForeground = waitForCondition({ - val currentPackage = uiDevice.currentPackageName - currentPackage == "com.iterable.integration.tests" + uiDevice.currentPackageName == APP_PACKAGE }, timeoutSeconds = 5) Assert.assertTrue("App should be in foreground after opening notification", isAppInForeground) - navigateToPushNotificationTestActivity() - - // Test 2: Trigger campaign again, tap first action button (Google), verify URL handler - Log.d(TAG, "Test 2: Action button with URL handler") + } + + @Test + @Ignore("SDK-115 follow-up: action buttons aren't laid out in the collapsed notification shade; UiAutomator can't reliably expand it. Re-enable with an expand-on-find helper.") + fun testPushNotificationActionButtons() { + Assert.assertTrue("User should be signed in", testUtils.ensureUserSignedIn(TestConstants.TEST_USER_EMAIL)) + Assert.assertTrue("Notification permission should be granted", hasNotificationPermission()) + if (!isRunningInCI) { + Assert.assertTrue( + "Device token should be registered with Iterable SDK before triggering a campaign", + waitForDeviceTokenRegistered(timeoutSeconds = 20) + ) + Thread.sleep(5_000) + } + + // URL handler via the "Google" action button + Log.d(TAG, "Action button with URL handler") triggerCampaignAndWait() uiDevice.pressHome() Thread.sleep(1000) - uiDevice.openNotification() Thread.sleep(2000) - val notification2 = findNotification() - Assert.assertNotNull("Notification should be found", notification2) - + Assert.assertNotNull("Notification should be found", findNotification()) + resetUrlHandlerTracking() val googleButton = uiDevice.findObject(By.text("Google")) Assert.assertNotNull("Google button should be found", googleButton) googleButton?.click() Thread.sleep(2000) - Assert.assertTrue("URL handler should be called", waitForUrlHandler(timeoutSeconds = 5)) Assert.assertNotNull("Handled URL should not be null", getLastHandledUrl()) - - // Navigate back to PushNotificationTestActivity for next test (in case action button opened app) + Thread.sleep(1000) navigateToPushNotificationTestActivity() - - // Test 3: Trigger campaign again, tap second action button (Deeplink), verify custom action handler - Log.d(TAG, "Test 3: Action button with custom action handler") + + // Custom action handler via the "Deeplink" action button + Log.d(TAG, "Action button with custom action handler") triggerCampaignAndWait() uiDevice.pressHome() Thread.sleep(1000) - uiDevice.openNotification() Thread.sleep(2000) - val notification3 = findNotification() - Assert.assertNotNull("Notification should be found", notification3) - + Assert.assertNotNull("Notification should be found", findNotification()) + resetCustomActionHandlerTracking() val deeplinkButton = uiDevice.findObject(By.text("Deeplink")) Assert.assertNotNull("Deeplink button should be found", deeplinkButton) deeplinkButton?.click() Thread.sleep(2000) - Assert.assertTrue("Custom action handler should be called", waitForCustomActionHandler(timeoutSeconds = 5)) Assert.assertNotNull("Action type should not be null", getLastHandledActionType()) - - // Navigate back to PushNotificationTestActivity (in case action button opened app) - Thread.sleep(1000) - navigateToPushNotificationTestActivity() - - // Note: trackPushOpen() is called internally by the SDK when notifications are opened - // It's automatically invoked by IterablePushNotificationUtil.executeAction() which is called - // by the trampoline activity when handling push notification clicks - Log.d(TAG, "Test completed successfully") } private fun triggerCampaignAndWait() { + if (isRunningInCI) { + injectSimulatedBcitPush() + } else { + triggerCampaignViaBackendAndWait() + } + } + + // CI path: locally constructed payload mirroring the BCIT push template, injected + // via IterableFirebaseMessagingService.handleMessageReceived. Bypasses FCM. + private fun injectSimulatedBcitPush() { + val itbl = JSONObject().apply { + put("templateId", BCIT_TEMPLATE_ID) + put("campaignId", TEST_PUSH_CAMPAIGN_ID) + put("messageId", "ci-${System.currentTimeMillis()}") + put("isGhostPush", false) + put("defaultAction", JSONObject().apply { + put("type", "openApp") + put("data", "") + }) + put("actionButtons", org.json.JSONArray().apply { + put(JSONObject().apply { + put("identifier", "google") + put("title", "Google") + put("buttonType", "default") + put("action", JSONObject().apply { + put("type", "openUrl") + put("data", "https://www.google.com") + }) + put("openApp", true) + }) + put(JSONObject().apply { + put("identifier", "deeplink") + put("title", "Deeplink") + put("buttonType", "default") + put("action", JSONObject().apply { + put("type", "cart-page") + put("data", "") + }) + put("openApp", true) + }) + }) + } + val handled = injectPushMessage( + itblPayload = itbl, + title = "🔔 BCIT Push Notification Test", + body = "🚀 BCIT Update: Here's what you need to know! Don't miss out." + ) + Assert.assertTrue("Iterable SDK should accept the simulated BCIT push payload", handled) + } + + private fun triggerCampaignViaBackendAndWait() { var campaignTriggered = false val latch = java.util.concurrent.CountDownLatch(1) triggerPushCampaignViaAPI(TEST_PUSH_CAMPAIGN_ID, TestConstants.TEST_USER_EMAIL, null) { success -> @@ -161,22 +223,18 @@ class PushNotificationIntegrationTest : BaseIntegrationTest() { } Assert.assertTrue("Campaign trigger should complete", latch.await(10, java.util.concurrent.TimeUnit.SECONDS)) Assert.assertTrue("Campaign should be triggered successfully", campaignTriggered) - Thread.sleep(5000) // Wait for FCM delivery } - - private fun findNotification(): UiObject2? { - val searchTexts = listOf("BCIT", "iterable", "Test", TestConstants.TEST_USER_EMAIL) - for (searchText in searchTexts) { - val notification = uiDevice.findObject(By.textContains(searchText)) - if (notification != null) return notification - } - - val allNotifications = uiDevice.findObjects(By.res("com.android.systemui:id/notification_text")) - for (notif in allNotifications) { - val text = notif.text ?: "" - if (text.contains("Iterable", ignoreCase = true) || text.contains("iterable", ignoreCase = true)) { - return notif.parent - } + + // Poll the systemui notification shade for the BCIT push by title; walk up to the + // row so a click hits the whole notification, not just the title text view. + private fun findNotification(timeoutSeconds: Long = NOTIFICATION_TIMEOUT_SECONDS): UiObject2? { + val deadline = System.currentTimeMillis() + timeoutSeconds * 1000 + while (System.currentTimeMillis() < deadline) { + val match = uiDevice.findObject( + By.pkg("com.android.systemui").textContains(EXPECTED_TITLE_SUBSTRING) + ) + if (match != null) return match.parent ?: match + Thread.sleep(500) } return null } diff --git a/integration-tests/src/main/java/com/iterable/integration/tests/services/IntegrationFirebaseMessagingService.kt b/integration-tests/src/main/java/com/iterable/integration/tests/services/IntegrationFirebaseMessagingService.kt index 8cc573085..7618d9456 100644 --- a/integration-tests/src/main/java/com/iterable/integration/tests/services/IntegrationFirebaseMessagingService.kt +++ b/integration-tests/src/main/java/com/iterable/integration/tests/services/IntegrationFirebaseMessagingService.kt @@ -5,21 +5,28 @@ import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.iterable.integration.tests.utils.IntegrationTestUtils import com.iterable.iterableapi.IterableFirebaseMessagingService +import java.util.concurrent.atomic.AtomicBoolean class IntegrationFirebaseMessagingService : FirebaseMessagingService() { - + companion object { private const val TAG = "IntegrationFCMService" + + // Set after onNewToken hands the token to the Iterable SDK; the local-mode push + // test polls this before triggering a campaign to avoid the registerDeviceToken + // race. + val tokenRegistered: AtomicBoolean = AtomicBoolean(false) } - + override fun onNewToken(token: String) { super.onNewToken(token) Log.d(TAG, "New FCM token: $token") - + // Register the token with Iterable SDK try { com.iterable.iterableapi.IterableApi.getInstance().registerForPush() Log.d(TAG, "FCM token registered with Iterable SDK") + tokenRegistered.set(true) } catch (e: Exception) { Log.e(TAG, "Failed to register FCM token with Iterable SDK", e) }