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..e5e4cc715 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,66 @@ 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 { + // Sent before polling so a placement state that already matches cannot pass against an + // unset or stale profile. + setMembershipLevel(level) + val deadline = System.currentTimeMillis() + timeoutSeconds * 1000 + var lastIds = IterableApi.getInstance().embeddedManager.getPlacementIds() + 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) { + 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 c835cf41e..d183db4b7 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,54 @@ 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(); + } + }); + dispatchRequest(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; } - }); - dispatchRequest(request); + // Not gated on takeRequest(): the request can reach MockWebServer after its wait, and a + // later attempt passing would then mask an unexpected SDK result. + if (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"); }