From 86aaaef328d5667f3eb4a4a27457745e0daffb24 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Wed, 25 Feb 2026 16:43:44 -0800 Subject: [PATCH 1/8] more tests --- .../ab/android/sdk/cmab/DefaultCmabClient.kt | 11 +- .../sdk/cmab/DefaultCmabClientTest.java | 138 +++++++++++++++++- 2 files changed, 139 insertions(+), 10 deletions(-) diff --git a/android-sdk/src/main/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClient.kt b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClient.kt index d76ef733..bcf83ae1 100644 --- a/android-sdk/src/main/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClient.kt +++ b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClient.kt @@ -71,8 +71,9 @@ open class DefaultCmabClient : CmabClient { val url = URL(apiEndpoint) urlConnection = client.openConnection(url) if (urlConnection == null) { - logger.error("Error opening connection to $apiEndpoint") - return@Request null + val errorMessage = String.format(cmabClientHelper.cmabFetchFailed, "Failed to open connection") + logger.error(errorMessage) + throw CmabFetchException(errorMessage) } // set timeouts for releasing failed connections (default is 0 = no timeout). @@ -107,6 +108,12 @@ open class DefaultCmabClient : CmabClient { logger.error(errorMessage) throw CmabFetchException(errorMessage) } + } catch (e: CmabInvalidResponseException) { + // Propagate validation exceptions as-is + throw e + } catch (e: CmabFetchException) { + // Propagate fetch exceptions as-is + throw e } catch (e: Exception) { logger.debug("Failed to fetch CMAB decision for ruleId={} and userId={}", ruleId, userId); val errorMessage: String = diff --git a/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java index 8ca06d6f..0e1d8eaa 100644 --- a/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java +++ b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java @@ -15,6 +15,8 @@ package com.optimizely.ab.android.sdk.cmab; import com.optimizely.ab.android.shared.Client; +import com.optimizely.ab.cmab.client.CmabFetchException; +import com.optimizely.ab.cmab.client.CmabInvalidResponseException; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -125,8 +127,9 @@ public void testFetchDecisionSuccess() throws Exception { verify(mockUrlConnection).setDoOutput(true); } - @Test + @Test(expected = CmabFetchException.class) public void testFetchDecisionConnectionFailure() throws Exception { + // When openConnection returns null, should throw CmabFetchException when(mockClient.openConnection(any(URL.class))).thenReturn(null); when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { Client.Request request = invocation.getArgument(0); @@ -135,20 +138,139 @@ public void testFetchDecisionConnectionFailure() throws Exception { mockCmabClient = new DefaultCmabClient(mockClient, mockCmabClientHelper); - String result = mockCmabClient.fetchDecision(testRuleId, testUserId, testAttributes, testCmabUuid); - assertNull(result); + // Should throw CmabFetchException when connection fails to open + mockCmabClient.fetchDecision(testRuleId, testUserId, testAttributes, testCmabUuid); + } + + @Test(expected = CmabFetchException.class) + public void testFetchDecisionThrowsExceptionOn500Error() throws Exception { + HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); + ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); + + when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); + when(mockUrlConnection.getResponseCode()).thenReturn(500); + when(mockUrlConnection.getResponseMessage()).thenReturn("Internal Server Error"); + when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { + Client.Request request = invocation.getArgument(0); + return request.execute(); + }); + + doReturn("{\"user_id\":\"test-user-456\"}") + .when(mockCmabClientHelper) + .buildRequestJson(any(), any(), any(), any()); + + mockCmabClient = new DefaultCmabClient(mockClient, mockCmabClientHelper); + + // Should throw CmabFetchException for 500 error + mockCmabClient.fetchDecision(testRuleId, testUserId, testAttributes, testCmabUuid); + } + + @Test(expected = CmabFetchException.class) + public void testFetchDecisionThrowsExceptionOn400Error() throws Exception { + HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); + ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); + + when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); + when(mockUrlConnection.getResponseCode()).thenReturn(400); + when(mockUrlConnection.getResponseMessage()).thenReturn("Bad Request"); + when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { + Client.Request request = invocation.getArgument(0); + return request.execute(); + }); + + doReturn("{\"user_id\":\"test-user-456\"}") + .when(mockCmabClientHelper) + .buildRequestJson(any(), any(), any(), any()); + + mockCmabClient = new DefaultCmabClient(mockClient, mockCmabClientHelper); + + // Should throw CmabFetchException for 400 error + mockCmabClient.fetchDecision(testRuleId, testUserId, testAttributes, testCmabUuid); + } + + @Test(expected = CmabFetchException.class) + public void testFetchDecisionThrowsExceptionOnNetworkError() throws Exception { + HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); + ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); + + when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); + when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + when(mockUrlConnection.getResponseCode()).thenThrow(new IOException("Network error")); + when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { + Client.Request request = invocation.getArgument(0); + return request.execute(); + }); + + doReturn("{\"user_id\":\"test-user-456\"}") + .when(mockCmabClientHelper) + .buildRequestJson(any(), any(), any(), any()); + + mockCmabClient = new DefaultCmabClient(mockClient, mockCmabClientHelper); + + // Should throw CmabFetchException when network IOException occurs + mockCmabClient.fetchDecision(testRuleId, testUserId, testAttributes, testCmabUuid); + } + + @Test(expected = CmabInvalidResponseException.class) + public void testFetchDecisionThrowsExceptionOnInvalidJson() throws Exception { + HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); + ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); + + String invalidResponseJson = "{\"invalid\":\"response\"}"; + when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); + when(mockUrlConnection.getResponseCode()).thenReturn(200); + when(mockClient.readStream(mockUrlConnection)).thenReturn(invalidResponseJson); + when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { + Client.Request request = invocation.getArgument(0); + return request.execute(); + }); + + doReturn("{\"user_id\":\"test-user-456\"}") + .when(mockCmabClientHelper) + .buildRequestJson(any(), any(), any(), any()); + doReturn(false) // Invalid response + .when(mockCmabClientHelper) + .validateResponse(any()); + + mockCmabClient = new DefaultCmabClient(mockClient, mockCmabClientHelper); + + // Should throw CmabInvalidResponseException when response validation fails + mockCmabClient.fetchDecision(testRuleId, testUserId, testAttributes, testCmabUuid); } @Test - public void testRetryOnFailureWithRetryBackoff() throws Exception { - when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenReturn(null); + public void testRetryConfigurationPassedToClient() throws Exception { + HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); + ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); + + String mockResponseJson = "{\"variation_id\":\"variation_1\",\"status\":\"success\"}"; + when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); + when(mockUrlConnection.getResponseCode()).thenReturn(200); + when(mockClient.readStream(mockUrlConnection)).thenReturn(mockResponseJson); + when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { + Client.Request request = invocation.getArgument(0); + return request.execute(); + }); + + doReturn("{\"user_id\":\"test-user-456\"}") + .when(mockCmabClientHelper) + .buildRequestJson(any(), any(), any(), any()); + doReturn(true) + .when(mockCmabClientHelper) + .validateResponse(any()); + doReturn("variation_1") + .when(mockCmabClientHelper) + .parseVariationId(any()); mockCmabClient = new DefaultCmabClient(mockClient, mockCmabClientHelper); - String result = mockCmabClient.fetchDecision(testRuleId, testUserId, testAttributes, testCmabUuid); - assertNull(result); + mockCmabClient.fetchDecision(testRuleId, testUserId, testAttributes, testCmabUuid); - // Verify the retry configuration matches our constants + // Verify the retry configuration is passed to client.execute() verify(mockClient).execute(any(Client.Request.class), eq(DefaultCmabClient.REQUEST_BACKOFF_TIMEOUT), eq(DefaultCmabClient.REQUEST_RETRIES_POWER)); assertEquals("REQUEST_BACKOFF_TIMEOUT should be 1", 1, DefaultCmabClient.REQUEST_BACKOFF_TIMEOUT); assertEquals("REQUEST_RETRIES_POWER should be 1", 1, DefaultCmabClient.REQUEST_RETRIES_POWER); From 1e68dbf98177613da9faa73516f0b8beec5743c3 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 26 Feb 2026 10:32:58 -0800 Subject: [PATCH 2/8] fix for fsc local testing --- .gitignore | 1 + android-sdk/build.gradle | 4 ++-- .../ab/android/sdk/cmab/DefaultCmabClient.kt | 17 +++++++++++++---- .../android/sdk/cmab/DefaultCmabClientTest.java | 10 +++++----- shared/build.gradle | 4 ++-- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index a3c8f936..7b43d077 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ /build /captures libs/ +fsc-test-libs/ values.gradle jacoco.exec .vscode/ diff --git a/android-sdk/build.gradle b/android-sdk/build.gradle index c049276f..2cbb1dfd 100644 --- a/android-sdk/build.gradle +++ b/android-sdk/build.gradle @@ -73,8 +73,8 @@ dependencies { api project(':user-profile') api project(':odp') // Enable using a local core-api jar for testing when the 'useLocalJars' property is specified - if (project.hasProperty('useLocalJars') && file('../libs/core-api.jar').exists()) { - api files('../libs/core-api.jar') + if (project.hasProperty('useLocalJars') && file('../fsc-test-libs/core-api.jar').exists()) { + api files('../fsc-test-libs/core-api.jar') } else { api ("com.optimizely.ab:core-api:$java_core_ver") { exclude group: 'com.google.code.findbugs' diff --git a/android-sdk/src/main/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClient.kt b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClient.kt index bcf83ae1..c094dc6e 100644 --- a/android-sdk/src/main/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClient.kt +++ b/android-sdk/src/main/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClient.kt @@ -71,9 +71,8 @@ open class DefaultCmabClient : CmabClient { val url = URL(apiEndpoint) urlConnection = client.openConnection(url) if (urlConnection == null) { - val errorMessage = String.format(cmabClientHelper.cmabFetchFailed, "Failed to open connection") - logger.error(errorMessage) - throw CmabFetchException(errorMessage) + logger.error("Error opening connection to $apiEndpoint") + return@Request null } // set timeouts for releasing failed connections (default is 0 = no timeout). @@ -130,7 +129,17 @@ open class DefaultCmabClient : CmabClient { } } } - return client.execute(request, REQUEST_BACKOFF_TIMEOUT, REQUEST_RETRIES_POWER) + val result = client.execute(request, REQUEST_BACKOFF_TIMEOUT, REQUEST_RETRIES_POWER) + + // Required to throw exception on any error so CmabService can return cmab error decision. + // Null result means CMAB fetch failed - throw exception. + if (result == null) { + val errorMessage = String.format(cmabClientHelper.cmabFetchFailed, "Client returned null - request failed") + logger.error(errorMessage) + throw CmabFetchException(errorMessage) + } + + return result } companion object { diff --git a/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java index 0e1d8eaa..2e8cc4fe 100644 --- a/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java +++ b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java @@ -128,7 +128,7 @@ public void testFetchDecisionSuccess() throws Exception { } @Test(expected = CmabFetchException.class) - public void testFetchDecisionConnectionFailure() throws Exception { + public void testFetchDecisionConnectionFailure() throws CmabFetchException { // When openConnection returns null, should throw CmabFetchException when(mockClient.openConnection(any(URL.class))).thenReturn(null); when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { @@ -143,7 +143,7 @@ public void testFetchDecisionConnectionFailure() throws Exception { } @Test(expected = CmabFetchException.class) - public void testFetchDecisionThrowsExceptionOn500Error() throws Exception { + public void testFetchDecisionThrowsExceptionOn500Error() throws CmabFetchException { HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); @@ -167,7 +167,7 @@ public void testFetchDecisionThrowsExceptionOn500Error() throws Exception { } @Test(expected = CmabFetchException.class) - public void testFetchDecisionThrowsExceptionOn400Error() throws Exception { + public void testFetchDecisionThrowsExceptionOn400Error() throws CmabFetchException { HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); @@ -191,7 +191,7 @@ public void testFetchDecisionThrowsExceptionOn400Error() throws Exception { } @Test(expected = CmabFetchException.class) - public void testFetchDecisionThrowsExceptionOnNetworkError() throws Exception { + public void testFetchDecisionThrowsExceptionOnNetworkError() throws CmabFetchException { HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); @@ -214,7 +214,7 @@ public void testFetchDecisionThrowsExceptionOnNetworkError() throws Exception { } @Test(expected = CmabInvalidResponseException.class) - public void testFetchDecisionThrowsExceptionOnInvalidJson() throws Exception { + public void testFetchDecisionThrowsExceptionOnInvalidJson() throws CmabInvalidResponseException { HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); diff --git a/shared/build.gradle b/shared/build.gradle index 50103bea..fb48d149 100644 --- a/shared/build.gradle +++ b/shared/build.gradle @@ -46,8 +46,8 @@ android { dependencies { // Enable using a local core-api jar for testing when the 'useLocalJars' property is specified - if (project.hasProperty('useLocalJars') && file('../libs/core-api.jar').exists()) { - api files('../libs/core-api.jar') + if (project.hasProperty('useLocalJars') && file('../fsc-test-libs/core-api.jar').exists()) { + api files('../fsc-test-libs/core-api.jar') } else { api ("com.optimizely.ab:core-api:$java_core_ver") { exclude group: 'com.google.code.findbugs' From 442dec89390d99da6c0e07c53e0e122aa447f2d2 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Thu, 26 Feb 2026 11:36:02 -0800 Subject: [PATCH 3/8] fix cmab test --- .../sdk/cmab/DefaultCmabClientTest.java | 102 ++++++++++++------ 1 file changed, 68 insertions(+), 34 deletions(-) diff --git a/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java index 2e8cc4fe..c29f3773 100644 --- a/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java +++ b/android-sdk/src/test/java/com/optimizely/ab/android/sdk/cmab/DefaultCmabClientTest.java @@ -90,15 +90,20 @@ public void testConstructorWithContextAndHelper() { } @Test - public void testFetchDecisionSuccess() throws Exception { + public void testFetchDecisionSuccess() { HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); - String mockResponseJson = "{\"variation_id\":\"variation_1\",\"status\":\"success\"}"; - when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); - when(mockUrlConnection.getResponseCode()).thenReturn(200); - when(mockClient.readStream(mockUrlConnection)).thenReturn(mockResponseJson); - when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + try { + String mockResponseJson = "{\"variation_id\":\"variation_1\",\"status\":\"success\"}"; + when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); + when(mockUrlConnection.getResponseCode()).thenReturn(200); + when(mockClient.readStream(mockUrlConnection)).thenReturn(mockResponseJson); + when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + } catch (IOException e) { + // Never happens with mocked connection + } + when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { Client.Request request = invocation.getArgument(0); return request.execute(); @@ -122,13 +127,17 @@ public void testFetchDecisionSuccess() throws Exception { verify(mockUrlConnection).setConnectTimeout(10*1000); verify(mockUrlConnection).setReadTimeout(60*1000); - verify(mockUrlConnection).setRequestMethod("POST"); + try { + verify(mockUrlConnection).setRequestMethod("POST"); + } catch (Exception e) { + // Never happens - verify() doesn't actually invoke the method + } verify(mockUrlConnection).setRequestProperty("content-type", "application/json"); verify(mockUrlConnection).setDoOutput(true); } @Test(expected = CmabFetchException.class) - public void testFetchDecisionConnectionFailure() throws CmabFetchException { + public void testFetchDecisionConnectionFailure() { // When openConnection returns null, should throw CmabFetchException when(mockClient.openConnection(any(URL.class))).thenReturn(null); when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { @@ -143,14 +152,19 @@ public void testFetchDecisionConnectionFailure() throws CmabFetchException { } @Test(expected = CmabFetchException.class) - public void testFetchDecisionThrowsExceptionOn500Error() throws CmabFetchException { + public void testFetchDecisionThrowsExceptionOn500Error() { HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); - when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); - when(mockUrlConnection.getResponseCode()).thenReturn(500); - when(mockUrlConnection.getResponseMessage()).thenReturn("Internal Server Error"); - when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + try { + when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); + when(mockUrlConnection.getResponseCode()).thenReturn(500); + when(mockUrlConnection.getResponseMessage()).thenReturn("Internal Server Error"); + when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + } catch (IOException e) { + // Never happens with mocked connection + } + when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { Client.Request request = invocation.getArgument(0); return request.execute(); @@ -167,14 +181,19 @@ public void testFetchDecisionThrowsExceptionOn500Error() throws CmabFetchExcepti } @Test(expected = CmabFetchException.class) - public void testFetchDecisionThrowsExceptionOn400Error() throws CmabFetchException { + public void testFetchDecisionThrowsExceptionOn400Error() { HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); - when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); - when(mockUrlConnection.getResponseCode()).thenReturn(400); - when(mockUrlConnection.getResponseMessage()).thenReturn("Bad Request"); - when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + try { + when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); + when(mockUrlConnection.getResponseCode()).thenReturn(400); + when(mockUrlConnection.getResponseMessage()).thenReturn("Bad Request"); + when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + } catch (IOException e) { + // Never happens with mocked connection + } + when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { Client.Request request = invocation.getArgument(0); return request.execute(); @@ -191,13 +210,18 @@ public void testFetchDecisionThrowsExceptionOn400Error() throws CmabFetchExcepti } @Test(expected = CmabFetchException.class) - public void testFetchDecisionThrowsExceptionOnNetworkError() throws CmabFetchException { + public void testFetchDecisionThrowsExceptionOnNetworkError() { HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); - when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); - when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); - when(mockUrlConnection.getResponseCode()).thenThrow(new IOException("Network error")); + try { + when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); + when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + doThrow(new IOException("Network error")).when(mockUrlConnection).getResponseCode(); + } catch (IOException e) { + // Never happens with mocked connection + } + when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { Client.Request request = invocation.getArgument(0); return request.execute(); @@ -214,15 +238,20 @@ public void testFetchDecisionThrowsExceptionOnNetworkError() throws CmabFetchExc } @Test(expected = CmabInvalidResponseException.class) - public void testFetchDecisionThrowsExceptionOnInvalidJson() throws CmabInvalidResponseException { + public void testFetchDecisionThrowsExceptionOnInvalidJson() { HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); - String invalidResponseJson = "{\"invalid\":\"response\"}"; - when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); - when(mockUrlConnection.getResponseCode()).thenReturn(200); - when(mockClient.readStream(mockUrlConnection)).thenReturn(invalidResponseJson); - when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + try { + String invalidResponseJson = "{\"invalid\":\"response\"}"; + when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); + when(mockUrlConnection.getResponseCode()).thenReturn(200); + when(mockClient.readStream(mockUrlConnection)).thenReturn(invalidResponseJson); + when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + } catch (IOException e) { + // Never happens with mocked connection + } + when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { Client.Request request = invocation.getArgument(0); return request.execute(); @@ -242,15 +271,20 @@ public void testFetchDecisionThrowsExceptionOnInvalidJson() throws CmabInvalidRe } @Test - public void testRetryConfigurationPassedToClient() throws Exception { + public void testRetryConfigurationPassedToClient() { HttpURLConnection mockUrlConnection = mock(HttpURLConnection.class); ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class); - String mockResponseJson = "{\"variation_id\":\"variation_1\",\"status\":\"success\"}"; - when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); - when(mockUrlConnection.getResponseCode()).thenReturn(200); - when(mockClient.readStream(mockUrlConnection)).thenReturn(mockResponseJson); - when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + try { + String mockResponseJson = "{\"variation_id\":\"variation_1\",\"status\":\"success\"}"; + when(mockClient.openConnection(any(URL.class))).thenReturn(mockUrlConnection); + when(mockUrlConnection.getResponseCode()).thenReturn(200); + when(mockClient.readStream(mockUrlConnection)).thenReturn(mockResponseJson); + when(mockUrlConnection.getOutputStream()).thenReturn(mockOutputStream); + } catch (IOException e) { + // Never happens with mocked connection + } + when(mockClient.execute(any(Client.Request.class), anyInt(), anyInt())).thenAnswer(invocation -> { Client.Request request = invocation.getArgument(0); return request.execute(); From 53908be8ec91e41ec8366f53449b4b9a9cbbb000 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Fri, 27 Feb 2026 15:40:10 -0800 Subject: [PATCH 4/8] change AVD versions --- .github/workflows/android.yml | 2 +- .gitignore | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index cf84f84a..61e9df8f 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -81,7 +81,7 @@ jobs: strategy: fail-fast: false matrix: - api-level: [21, 25, 26, 29] + api-level: [25, 29, 35] steps: - name: checkout uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 7b43d077..b251bf76 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ fsc-test-libs/ values.gradle jacoco.exec .vscode/ +.settings/ From 2c419defcbb7b19ec4444abf5d9d05131afdddcd Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Fri, 27 Feb 2026 15:44:41 -0800 Subject: [PATCH 5/8] clean up avd --- .github/workflows/android.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 61e9df8f..a6a6b1de 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -120,7 +120,7 @@ jobs: uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} - # arch: arm64-v8a # Specify ARM architecture + arch: x86_64 force-avd-creation: false emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: false @@ -130,6 +130,7 @@ jobs: uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} + arch: x86_64 force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true From 6bfcc421608a20f5c90943f9843fcf17788498d9 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Fri, 27 Feb 2026 16:08:27 -0800 Subject: [PATCH 6/8] fix avds --- .github/workflows/android.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index a6a6b1de..d39faa30 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -81,7 +81,9 @@ jobs: strategy: fail-fast: false matrix: - api-level: [25, 29, 35] + # 21 is too old (not properly supported by github) + # mokito version is not working ing 30+ + api-level: [25, 27, 29] steps: - name: checkout uses: actions/checkout@v4 From 3f43fb7b579ad0300d62730708af19e823d5b2e3 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Fri, 27 Feb 2026 16:23:24 -0800 Subject: [PATCH 7/8] clean up --- .github/workflows/android.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index d39faa30..08b4a449 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -123,6 +123,7 @@ jobs: with: api-level: ${{ matrix.api-level }} arch: x86_64 + target: google_apis force-avd-creation: false emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: false @@ -133,6 +134,7 @@ jobs: with: api-level: ${{ matrix.api-level }} arch: x86_64 + target: google_apis force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true From 7ff7556b847f7777e147c1a770bc7ab084a59f4d Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Fri, 27 Feb 2026 16:27:47 -0800 Subject: [PATCH 8/8] avds --- .github/workflows/android.yml | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 08b4a449..90669fe5 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -82,8 +82,17 @@ jobs: fail-fast: false matrix: # 21 is too old (not properly supported by github) - # mokito version is not working ing 30+ - api-level: [25, 27, 29] + # mockito version is not working in 30+ + include: + - api-level: 25 + arch: x86 + target: google_apis + - api-level: 27 + arch: x86 + target: google_apis + - api-level: 29 + arch: x86_64 + target: default steps: - name: checkout uses: actions/checkout@v4 @@ -115,15 +124,15 @@ jobs: ~/.android/avd/* ~/.android/adb* ~/.android/debug.keystore - key: avd-${{ matrix.api-level }} + key: avd-${{ matrix.api-level }}-${{ matrix.arch }}-${{ matrix.target }} - name: create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} - arch: x86_64 - target: google_apis + arch: ${{ matrix.arch }} + target: ${{ matrix.target }} force-avd-creation: false emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: false @@ -133,8 +142,8 @@ jobs: uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} - arch: x86_64 - target: google_apis + arch: ${{ matrix.arch }} + target: ${{ matrix.target }} force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true