Skip to content

Commit e8666f1

Browse files
[SDK-115] BCIT push test on CI (#1062)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f2b2afd commit e8666f1

3 files changed

Lines changed: 158 additions & 64 deletions

File tree

integration-tests/src/androidTest/java/com/iterable/integration/tests/BaseIntegrationTest.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ import android.util.Log
55
import androidx.test.core.app.ApplicationProvider
66
import androidx.test.ext.junit.runners.AndroidJUnit4
77
import androidx.test.platform.app.InstrumentationRegistry
8+
import com.google.firebase.messaging.RemoteMessage
89
import com.iterable.iterableapi.IterableApi
910
import com.iterable.iterableapi.IterableConfig
11+
import com.iterable.iterableapi.IterableFirebaseMessagingService
12+
import com.iterable.integration.tests.services.IntegrationFirebaseMessagingService
1013
import com.iterable.integration.tests.utils.IntegrationTestUtils
1114
import com.iterable.integration.tests.utils.TestUserEmailGenerator
1215
import com.iterable.integration.tests.utils.TestUserEmailOverride
1316
import com.iterable.integration.tests.TestConstants
1417
import org.awaitility.Awaitility
1518
import org.awaitility.core.ConditionTimeoutException
19+
import org.json.JSONObject
1620
import org.junit.After
1721
import org.junit.Before
1822
import org.junit.runner.RunWith
@@ -196,6 +200,31 @@ abstract class BaseIntegrationTest {
196200
}
197201

198202

203+
// Local-mode gate: avoids racing IterableApi.registerForPush(). In CI [injectPushMessage]
204+
// bypasses FCM, so this is unused there.
205+
protected fun waitForDeviceTokenRegistered(timeoutSeconds: Long = 20): Boolean {
206+
return waitForCondition({
207+
IntegrationFirebaseMessagingService.tokenRegistered.get()
208+
}, timeoutSeconds)
209+
}
210+
211+
// CI-mode push injection. Builds a RemoteMessage locally and feeds it to the SDK's
212+
// normal handler — same shape as iOS's `xcrun simctl push booted`. itblPayload goes
213+
// under the `itbl` data key; title/body/extraData are top-level data fields.
214+
protected fun injectPushMessage(
215+
itblPayload: JSONObject,
216+
title: String? = null,
217+
body: String? = null,
218+
extraData: Map<String, String> = emptyMap()
219+
): Boolean {
220+
val builder = RemoteMessage.Builder("test@gcm.googleapis.com")
221+
.addData("itbl", itblPayload.toString())
222+
title?.let { builder.addData("title", it) }
223+
body?.let { builder.addData("body", it) }
224+
extraData.forEach { (k, v) -> builder.addData(k, v) }
225+
return IterableFirebaseMessagingService.handleMessageReceived(context, builder.build())
226+
}
227+
199228
/**
200229
* Trigger a campaign via Iterable API
201230
*/

integration-tests/src/androidTest/java/com/iterable/integration/tests/PushNotificationIntegrationTest.kt

Lines changed: 119 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import androidx.test.uiautomator.By
1313
import com.iterable.iterableapi.IterableApi
1414
import com.iterable.integration.tests.activities.PushNotificationTestActivity
1515
import org.awaitility.Awaitility
16+
import org.json.JSONObject
1617
import org.junit.After
1718
import org.junit.Assert
1819
import org.junit.Before
@@ -23,10 +24,16 @@ import java.util.concurrent.TimeUnit
2324

2425
@RunWith(AndroidJUnit4::class)
2526
class PushNotificationIntegrationTest : BaseIntegrationTest() {
26-
27+
2728
companion object {
2829
private const val TAG = "PushNotificationIntegrationTest"
2930
private const val TEST_PUSH_CAMPAIGN_ID = TestConstants.TEST_PUSH_CAMPAIGN_ID
31+
// Captured from a real BCIT delivery; only used in CI to populate `itbl.templateId`.
32+
private const val BCIT_TEMPLATE_ID = 20392358
33+
private const val APP_PACKAGE = "com.iterable.integration.tests"
34+
// Title substring; avoids the emoji prefix which `By.text` matches inconsistently.
35+
private const val EXPECTED_TITLE_SUBSTRING = "BCIT Push Notification Test"
36+
private const val NOTIFICATION_TIMEOUT_SECONDS = 30L
3037
}
3138

3239
private lateinit var uiDevice: UiDevice
@@ -36,12 +43,12 @@ class PushNotificationIntegrationTest : BaseIntegrationTest() {
3643
override fun setUp() {
3744
uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
3845
super.setUp()
39-
46+
4047
IterableApi.getInstance().inAppManager.setAutoDisplayPaused(true)
4148
IterableApi.getInstance().inAppManager.messages.forEach {
4249
IterableApi.getInstance().inAppManager.removeMessage(it)
4350
}
44-
51+
4552
launchAppAndNavigateToPushNotificationTesting()
4653
}
4754

@@ -62,97 +69,152 @@ class PushNotificationIntegrationTest : BaseIntegrationTest() {
6269
mainActivityScenario.state == Lifecycle.State.RESUMED
6370
}
6471

72+
// RESUMED fires before view inflation completes; waitForExists handles the race.
6573
val pushButton = uiDevice.findObject(UiSelector().resourceId("com.iterable.integration.tests:id/btnPushNotifications"))
66-
if (!pushButton.exists()) {
74+
if (!pushButton.waitForExists(5000)) {
6775
Assert.fail("Push Notifications button not found in MainActivity")
6876
}
6977
pushButton.click()
7078
Thread.sleep(2000)
7179
}
7280

7381
@Test
74-
@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.")
7582
fun testPushNotificationMVP() {
7683
Assert.assertTrue("User should be signed in", testUtils.ensureUserSignedIn(TestConstants.TEST_USER_EMAIL))
7784
Assert.assertTrue("Notification permission should be granted", hasNotificationPermission())
78-
79-
// Test 1: Trigger campaign, minimize app, open notification, verify app opens
80-
Log.d(TAG, "Test 1: Push notification open action")
85+
86+
if (!isRunningInCI) {
87+
// Local-mode only: wait for token registration + a backend cool-down before
88+
// triggering the real campaign. CI uses [injectPushMessage] and skips both.
89+
Assert.assertTrue(
90+
"Device token should be registered with Iterable SDK before triggering a campaign",
91+
waitForDeviceTokenRegistered(timeoutSeconds = 20)
92+
)
93+
Thread.sleep(5_000)
94+
}
95+
96+
Log.d(TAG, "MVP: Push notification open action")
8197
triggerCampaignAndWait()
8298
uiDevice.pressHome()
8399
Thread.sleep(1000)
84-
100+
85101
uiDevice.openNotification()
86102
Thread.sleep(1000)
87-
val notification1 = findNotification()
88-
Assert.assertNotNull("Notification should be found", notification1)
89-
90-
notification1?.click()
91-
Thread.sleep(2000) // Wait for app to open
92-
93-
// Verify app is in foreground by checking current package name
103+
val notification = findNotification()
104+
Assert.assertNotNull("Notification should be found", notification)
105+
106+
notification?.click()
107+
Thread.sleep(2000)
108+
94109
val isAppInForeground = waitForCondition({
95-
val currentPackage = uiDevice.currentPackageName
96-
currentPackage == "com.iterable.integration.tests"
110+
uiDevice.currentPackageName == APP_PACKAGE
97111
}, timeoutSeconds = 5)
98112
Assert.assertTrue("App should be in foreground after opening notification", isAppInForeground)
99-
navigateToPushNotificationTestActivity()
100-
101-
// Test 2: Trigger campaign again, tap first action button (Google), verify URL handler
102-
Log.d(TAG, "Test 2: Action button with URL handler")
113+
}
114+
115+
@Test
116+
@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.")
117+
fun testPushNotificationActionButtons() {
118+
Assert.assertTrue("User should be signed in", testUtils.ensureUserSignedIn(TestConstants.TEST_USER_EMAIL))
119+
Assert.assertTrue("Notification permission should be granted", hasNotificationPermission())
120+
if (!isRunningInCI) {
121+
Assert.assertTrue(
122+
"Device token should be registered with Iterable SDK before triggering a campaign",
123+
waitForDeviceTokenRegistered(timeoutSeconds = 20)
124+
)
125+
Thread.sleep(5_000)
126+
}
127+
128+
// URL handler via the "Google" action button
129+
Log.d(TAG, "Action button with URL handler")
103130
triggerCampaignAndWait()
104131
uiDevice.pressHome()
105132
Thread.sleep(1000)
106-
107133
uiDevice.openNotification()
108134
Thread.sleep(2000)
109-
val notification2 = findNotification()
110-
Assert.assertNotNull("Notification should be found", notification2)
111-
135+
Assert.assertNotNull("Notification should be found", findNotification())
136+
112137
resetUrlHandlerTracking()
113138
val googleButton = uiDevice.findObject(By.text("Google"))
114139
Assert.assertNotNull("Google button should be found", googleButton)
115140
googleButton?.click()
116141
Thread.sleep(2000)
117-
118142
Assert.assertTrue("URL handler should be called", waitForUrlHandler(timeoutSeconds = 5))
119143
Assert.assertNotNull("Handled URL should not be null", getLastHandledUrl())
120-
121-
// Navigate back to PushNotificationTestActivity for next test (in case action button opened app)
144+
122145
Thread.sleep(1000)
123146
navigateToPushNotificationTestActivity()
124-
125-
// Test 3: Trigger campaign again, tap second action button (Deeplink), verify custom action handler
126-
Log.d(TAG, "Test 3: Action button with custom action handler")
147+
148+
// Custom action handler via the "Deeplink" action button
149+
Log.d(TAG, "Action button with custom action handler")
127150
triggerCampaignAndWait()
128151
uiDevice.pressHome()
129152
Thread.sleep(1000)
130-
131153
uiDevice.openNotification()
132154
Thread.sleep(2000)
133-
val notification3 = findNotification()
134-
Assert.assertNotNull("Notification should be found", notification3)
135-
155+
Assert.assertNotNull("Notification should be found", findNotification())
156+
136157
resetCustomActionHandlerTracking()
137158
val deeplinkButton = uiDevice.findObject(By.text("Deeplink"))
138159
Assert.assertNotNull("Deeplink button should be found", deeplinkButton)
139160
deeplinkButton?.click()
140161
Thread.sleep(2000)
141-
142162
Assert.assertTrue("Custom action handler should be called", waitForCustomActionHandler(timeoutSeconds = 5))
143163
Assert.assertNotNull("Action type should not be null", getLastHandledActionType())
144-
145-
// Navigate back to PushNotificationTestActivity (in case action button opened app)
146-
Thread.sleep(1000)
147-
navigateToPushNotificationTestActivity()
148-
149-
// Note: trackPushOpen() is called internally by the SDK when notifications are opened
150-
// It's automatically invoked by IterablePushNotificationUtil.executeAction() which is called
151-
// by the trampoline activity when handling push notification clicks
152-
Log.d(TAG, "Test completed successfully")
153164
}
154165

155166
private fun triggerCampaignAndWait() {
167+
if (isRunningInCI) {
168+
injectSimulatedBcitPush()
169+
} else {
170+
triggerCampaignViaBackendAndWait()
171+
}
172+
}
173+
174+
// CI path: locally constructed payload mirroring the BCIT push template, injected
175+
// via IterableFirebaseMessagingService.handleMessageReceived. Bypasses FCM.
176+
private fun injectSimulatedBcitPush() {
177+
val itbl = JSONObject().apply {
178+
put("templateId", BCIT_TEMPLATE_ID)
179+
put("campaignId", TEST_PUSH_CAMPAIGN_ID)
180+
put("messageId", "ci-${System.currentTimeMillis()}")
181+
put("isGhostPush", false)
182+
put("defaultAction", JSONObject().apply {
183+
put("type", "openApp")
184+
put("data", "")
185+
})
186+
put("actionButtons", org.json.JSONArray().apply {
187+
put(JSONObject().apply {
188+
put("identifier", "google")
189+
put("title", "Google")
190+
put("buttonType", "default")
191+
put("action", JSONObject().apply {
192+
put("type", "openUrl")
193+
put("data", "https://www.google.com")
194+
})
195+
put("openApp", true)
196+
})
197+
put(JSONObject().apply {
198+
put("identifier", "deeplink")
199+
put("title", "Deeplink")
200+
put("buttonType", "default")
201+
put("action", JSONObject().apply {
202+
put("type", "cart-page")
203+
put("data", "")
204+
})
205+
put("openApp", true)
206+
})
207+
})
208+
}
209+
val handled = injectPushMessage(
210+
itblPayload = itbl,
211+
title = "🔔 BCIT Push Notification Test",
212+
body = "🚀 BCIT Update: Here's what you need to know! Don't miss out."
213+
)
214+
Assert.assertTrue("Iterable SDK should accept the simulated BCIT push payload", handled)
215+
}
216+
217+
private fun triggerCampaignViaBackendAndWait() {
156218
var campaignTriggered = false
157219
val latch = java.util.concurrent.CountDownLatch(1)
158220
triggerPushCampaignViaAPI(TEST_PUSH_CAMPAIGN_ID, TestConstants.TEST_USER_EMAIL, null) { success ->
@@ -161,22 +223,18 @@ class PushNotificationIntegrationTest : BaseIntegrationTest() {
161223
}
162224
Assert.assertTrue("Campaign trigger should complete", latch.await(10, java.util.concurrent.TimeUnit.SECONDS))
163225
Assert.assertTrue("Campaign should be triggered successfully", campaignTriggered)
164-
Thread.sleep(5000) // Wait for FCM delivery
165226
}
166-
167-
private fun findNotification(): UiObject2? {
168-
val searchTexts = listOf("BCIT", "iterable", "Test", TestConstants.TEST_USER_EMAIL)
169-
for (searchText in searchTexts) {
170-
val notification = uiDevice.findObject(By.textContains(searchText))
171-
if (notification != null) return notification
172-
}
173-
174-
val allNotifications = uiDevice.findObjects(By.res("com.android.systemui:id/notification_text"))
175-
for (notif in allNotifications) {
176-
val text = notif.text ?: ""
177-
if (text.contains("Iterable", ignoreCase = true) || text.contains("iterable", ignoreCase = true)) {
178-
return notif.parent
179-
}
227+
228+
// Poll the systemui notification shade for the BCIT push by title; walk up to the
229+
// row so a click hits the whole notification, not just the title text view.
230+
private fun findNotification(timeoutSeconds: Long = NOTIFICATION_TIMEOUT_SECONDS): UiObject2? {
231+
val deadline = System.currentTimeMillis() + timeoutSeconds * 1000
232+
while (System.currentTimeMillis() < deadline) {
233+
val match = uiDevice.findObject(
234+
By.pkg("com.android.systemui").textContains(EXPECTED_TITLE_SUBSTRING)
235+
)
236+
if (match != null) return match.parent ?: match
237+
Thread.sleep(500)
180238
}
181239
return null
182240
}

integration-tests/src/main/java/com/iterable/integration/tests/services/IntegrationFirebaseMessagingService.kt

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,28 @@ import com.google.firebase.messaging.FirebaseMessagingService
55
import com.google.firebase.messaging.RemoteMessage
66
import com.iterable.integration.tests.utils.IntegrationTestUtils
77
import com.iterable.iterableapi.IterableFirebaseMessagingService
8+
import java.util.concurrent.atomic.AtomicBoolean
89

910
class IntegrationFirebaseMessagingService : FirebaseMessagingService() {
10-
11+
1112
companion object {
1213
private const val TAG = "IntegrationFCMService"
14+
15+
// Set after onNewToken hands the token to the Iterable SDK; the local-mode push
16+
// test polls this before triggering a campaign to avoid the registerDeviceToken
17+
// race.
18+
val tokenRegistered: AtomicBoolean = AtomicBoolean(false)
1319
}
14-
20+
1521
override fun onNewToken(token: String) {
1622
super.onNewToken(token)
1723
Log.d(TAG, "New FCM token: $token")
18-
24+
1925
// Register the token with Iterable SDK
2026
try {
2127
com.iterable.iterableapi.IterableApi.getInstance().registerForPush()
2228
Log.d(TAG, "FCM token registered with Iterable SDK")
29+
tokenRegistered.set(true)
2330
} catch (e: Exception) {
2431
Log.e(TAG, "Failed to register FCM token with Iterable SDK", e)
2532
}

0 commit comments

Comments
 (0)