From c9dbf7231fae5a90a0b611e1e98837ffa316b898 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Tue, 6 Oct 2026 11:59:17 +0100 Subject: [PATCH] [SDK-753] Stop flaky Android tests from depending on network timing The image unit test, the 400 instrumentation case, and the embedded BCIT membership check were failing when CI could not download a live asset, the emulator timed out, or a profile update had not propagated yet. --- .../tests/EmbeddedMessageIntegrationTest.kt | 101 ++++++++++++++---- .../iterableapi/IterableApiResponseTest.java | 57 +++++++--- .../iterableapi/IterableNotificationTest.java | 13 ++- .../src/test/resources/notification_image.png | Bin 0 -> 69 bytes 4 files changed, 138 insertions(+), 33 deletions(-) create mode 100644 iterableapi/src/test/resources/notification_image.png diff --git a/integration-tests/src/androidTest/java/com/iterable/integration/tests/EmbeddedMessageIntegrationTest.kt b/integration-tests/src/androidTest/java/com/iterable/integration/tests/EmbeddedMessageIntegrationTest.kt index cfc1468da..89b83f994 100644 --- a/integration-tests/src/androidTest/java/com/iterable/integration/tests/EmbeddedMessageIntegrationTest.kt +++ b/integration-tests/src/androidTest/java/com/iterable/integration/tests/EmbeddedMessageIntegrationTest.kt @@ -13,6 +13,7 @@ import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.By import com.iterable.iterableapi.IterableApi import com.iterable.iterableapi.IterableEmbeddedMessage +import com.iterable.iterableapi.IterableEmbeddedUpdateHandler import com.iterable.integration.tests.activities.EmbeddedMessageTestActivity import com.iterable.integration.tests.utils.maskEmail import com.iterable.iterableapi.ui.embedded.IterableEmbeddedView @@ -25,6 +26,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger @RunWith(AndroidJUnit4::class) class EmbeddedMessageIntegrationTest : BaseIntegrationTest() { @@ -36,6 +38,20 @@ class EmbeddedMessageIntegrationTest : BaseIntegrationTest() { private lateinit var uiDevice: UiDevice private lateinit var mainActivityScenario: ActivityScenario + private var syncHandlerRegistered = false + private val syncSuccesses = AtomicInteger(0) + private val syncFailures = AtomicInteger(0) + private val syncHandler = object : IterableEmbeddedUpdateHandler { + override fun onMessagesUpdated() {} + override fun onEmbeddedMessagingDisabled() {} + override fun onEmbeddedMessagingSyncSucceeded() { + syncSuccesses.incrementAndGet() + } + override fun onEmbeddedMessagingSyncFailed(reason: String?) { + Log.d(TAG, "Embedded sync failed: $reason") + syncFailures.incrementAndGet() + } + } @Before override fun setUp() { @@ -70,6 +86,10 @@ class EmbeddedMessageIntegrationTest : BaseIntegrationTest() { @After override fun tearDown() { + if (syncHandlerRegistered) { + IterableApi.getInstance().embeddedManager.removeUpdateListener(syncHandler) + syncHandlerRegistered = false + } super.tearDown() } @@ -138,15 +158,13 @@ class EmbeddedMessageIntegrationTest : BaseIntegrationTest() { // Drive a clean standard→premium membership transition. Mirrors the iOS BCIT // embedded test — the BCIT campaign's audience predicate is on // `membershipLevel == "premium"`. - setMembershipLevel("standard") - syncMessagesAndWait() + val standardPlacementIds = waitForMembershipLevel("standard", present = false) Assert.assertFalse( - "User should not be eligible for placement $TEST_PLACEMENT_ID with membershipLevel=standard", - IterableApi.getInstance().embeddedManager.getPlacementIds().contains(TEST_PLACEMENT_ID) + "User should not be eligible for placement $TEST_PLACEMENT_ID with membershipLevel=standard, but found: $standardPlacementIds", + standardPlacementIds.contains(TEST_PLACEMENT_ID) ) - setMembershipLevel("premium") - val placementIds = syncAndWaitForPlacement(TEST_PLACEMENT_ID, timeoutSeconds = 30) + val placementIds = waitForMembershipLevel("premium", present = true) Assert.assertTrue( "Placement ID $TEST_PLACEMENT_ID should exist, but found: $placementIds", placementIds.contains(TEST_PLACEMENT_ID) @@ -255,24 +273,71 @@ class EmbeddedMessageIntegrationTest : BaseIntegrationTest() { } private fun setMembershipLevel(level: String) { + Log.d(TAG, "Updating membershipLevel=$level") IterableApi.getInstance().updateUser(JSONObject().put("membershipLevel", level)) - Thread.sleep(3000) } - private fun syncMessagesAndWait() { - IterableApi.getInstance().embeddedManager.syncMessages() - Thread.sleep(2000) + private fun waitForMembershipLevel( + level: String, + present: Boolean, + timeoutSeconds: Long = 30 + ): List { + val deadline = System.currentTimeMillis() + timeoutSeconds * 1000 + var lastIds = IterableApi.getInstance().embeddedManager.getPlacementIds() + var updateSent = false + var consecutiveMatches = 0 + while (System.currentTimeMillis() < deadline) { + val synced = awaitSuccessfulSync() + if (synced == null) { + consecutiveMatches = 0 + Thread.sleep(1000) + continue + } + lastIds = synced + Log.d(TAG, "Placement IDs while waiting for membershipLevel=$level present=$present: $lastIds") + val matches = lastIds.contains(TEST_PLACEMENT_ID) == present + if (!matches && !updateSent) { + setMembershipLevel(level) + updateSent = true + consecutiveMatches = 0 + Thread.sleep(1000) + continue + } + if (matches) { + consecutiveMatches += 1 + if (consecutiveMatches >= 2) { + return lastIds + } + } else { + consecutiveMatches = 0 + } + Thread.sleep(1000) + } + return lastIds } - private fun syncAndWaitForPlacement(placementId: Long, timeoutSeconds: Long = 30): List { - val deadline = System.currentTimeMillis() + timeoutSeconds * 1000 - var placementIds = IterableApi.getInstance().embeddedManager.getPlacementIds() - while (System.currentTimeMillis() < deadline && !placementIds.contains(placementId)) { - IterableApi.getInstance().embeddedManager.syncMessages() - Thread.sleep(2000) - placementIds = IterableApi.getInstance().embeddedManager.getPlacementIds() + // removeUpdateListener() ends the embedded session, so the handler stays on until tearDown. + // A failed sync does not update placement ids, so only a successful sync counts. + private fun awaitSuccessfulSync(): List? { + if (!syncHandlerRegistered) { + IterableApi.getInstance().embeddedManager.addUpdateListener(syncHandler) + syncHandlerRegistered = true + } + val successesBefore = syncSuccesses.get() + val failuresBefore = syncFailures.get() + IterableApi.getInstance().embeddedManager.syncMessages() + val deadline = System.currentTimeMillis() + 10_000 + while (System.currentTimeMillis() < deadline) { + if (syncSuccesses.get() > successesBefore) { + return IterableApi.getInstance().embeddedManager.getPlacementIds() + } + if (syncFailures.get() > failuresBefore) { + return null + } + Thread.sleep(100) } - return placementIds + Log.d(TAG, "Embedded sync did not finish within 10s") + return null } } diff --git a/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableApiResponseTest.java b/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableApiResponseTest.java index afab7676b..4c7e2afe6 100644 --- a/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableApiResponseTest.java +++ b/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableApiResponseTest.java @@ -15,6 +15,7 @@ import org.junit.runner.RunWith; import java.io.IOException; +import java.util.Locale; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -28,6 +29,7 @@ import static junit.framework.Assert.assertNotNull; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; +import static junit.framework.Assert.fail; import static org.junit.Assert.assertThat; @RunWith(AndroidJUnit4.class) @@ -130,21 +132,52 @@ public void onFailure(@NonNull String reason, @Nullable JSONObject data) { @Test public void testResponseCode400WithoutMessage() throws Exception { - final CountDownLatch signal = new CountDownLatch(1); - - stubAnyRequestReturningStatusCode(400, "{}"); + String lastReason = null; + boolean serverReceivedRequest = false; + for (int attempt = 1; attempt <= 3; attempt++) { + final CountDownLatch signal = new CountDownLatch(1); + final String[] reason = new String[1]; + + stubAnyRequestReturningStatusCode(400, "{}"); + + IterableApiRequest request = new IterableApiRequest("fake_key", "", new JSONObject(), IterableApiRequest.POST, null, null, new IterableHelper.FailureHandler() { + @Override + public void onFailure(@NonNull String failureReason, @Nullable JSONObject data) { + reason[0] = failureReason; + signal.countDown(); + } + }); + new IterableRequestTask().execute(request); + + RecordedRequest recorded = server.takeRequest(5, TimeUnit.SECONDS); + boolean callbackFinished = signal.await(5, TimeUnit.SECONDS); + lastReason = reason[0]; + if (recorded != null) { + serverReceivedRequest = true; + } - IterableApiRequest request = new IterableApiRequest("fake_key", "", new JSONObject(), IterableApiRequest.POST, null, null, new IterableHelper.FailureHandler() { - @Override - public void onFailure(@NonNull String reason, @Nullable JSONObject data) { - assertEquals("Invalid Request", reason); - signal.countDown(); + if (callbackFinished && "Invalid Request".equals(lastReason)) { + return; } - }); - new IterableRequestTask().execute(request); + if (recorded != null && callbackFinished && !isEmulatorTransportTimeout(lastReason)) { + assertEquals("Invalid Request", lastReason); + } + } - server.takeRequest(5, TimeUnit.SECONDS); - assertTrue("onFailure is called", signal.await(5, TimeUnit.SECONDS)); + if (isEmulatorTransportTimeout(lastReason)) { + fail("Emulator transport timeout: stubbed HTTP 400 was not applied before the HTTP timeout. " + + "MockWebServer received the request: " + serverReceivedRequest + + ". Last failure reason: " + lastReason); + } + fail("MockWebServer received the request but the SDK did not report Invalid Request. Last failure reason: " + lastReason); + } + + private static boolean isEmulatorTransportTimeout(String reason) { + if (reason == null) { + return true; + } + String lower = reason.toLowerCase(Locale.US); + return lower.contains("timeout") || lower.contains("timed out") || lower.contains("failed to connect"); } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java index a8a65c9f5..74dcaceca 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java @@ -22,11 +22,13 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.net.URL; import static android.os.Looper.getMainLooper; import static androidx.test.core.app.ApplicationProvider.getApplicationContext; import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertNotNull; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; @@ -51,7 +53,6 @@ public class IterableNotificationTest { String itbl_ghost = "{\"templateId\":1,\"campaignId\":1,\"messageId\":\"11111111111111111111111111111111\",\"isGhostPush\":true}"; String itbl1 = "{\"templateId\":1,\"campaignId\":1,\"messageId\":\"11111111111111111111111111111111\",\"isGhostPush\":false}"; String itbl2 = "{\"templateId\":2,\"campaignId\":2,\"messageId\":\"22222222222222222222222222222222\",\"isGhostPush\":false}}"; - String itbl_image = "{\"templateId\":1,\"campaignId\":1,\"messageId\":\"11111111111111111111111111111111\",\"isGhostPush\":false,\"attachment-url\":\"https://assets.iterable.com/assets/images/logos/itbl-logo-full-gray-800x300.png\"}"; private Context getContext() { return getApplicationContext(); @@ -108,13 +109,19 @@ public void testGhostPush() throws Exception { } /** - * Tests loading a notification with an image. + * Loads the notification image from a test resource so the assertion does not hit the network. * @throws Exception */ @Test public void testNotificationImage() throws Exception { + URL imageFixture = getClass().getClassLoader().getResource("notification_image.png"); + assertNotNull("notification_image.png fixture missing from test resources", imageFixture); + + String itblImage = "{\"templateId\":1,\"campaignId\":1,\"messageId\":\"11111111111111111111111111111111\",\"isGhostPush\":false,\"attachment-url\":\"" + + imageFixture.toExternalForm() + "\"}"; + Bundle notif = new Bundle(); - notif.putString(IterableConstants.ITERABLE_DATA_KEY, itbl_image); + notif.putString(IterableConstants.ITERABLE_DATA_KEY, itblImage); notif.putString(IterableConstants.ITERABLE_DATA_BODY, body); IterableNotificationBuilder iterableNotification = postNotification(notif); diff --git a/iterableapi/src/test/resources/notification_image.png b/iterableapi/src/test/resources/notification_image.png new file mode 100644 index 0000000000000000000000000000000000000000..62a5f8f47fec02344e5bf9061888262f677cf5d6 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yf1E$Sz`)GN$Z+!C Rr1wB^22WQ%mvv4FO#seq5RCu; literal 0 HcmV?d00001