diff --git a/FlagsmithClient/build.gradle.kts b/FlagsmithClient/build.gradle.kts index 265d3d9..788f534 100644 --- a/FlagsmithClient/build.gradle.kts +++ b/FlagsmithClient/build.gradle.kts @@ -82,6 +82,9 @@ dependencies { implementation("com.squareup.okhttp3:okhttp-sse:4.11.0") testImplementation("com.squareup.okhttp3:okhttp-sse:4.11.0") + // MockWebServer for testing HTTP interactions + testImplementation("com.squareup.okhttp3:mockwebserver:4.11.0") + testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") testImplementation("org.mock-server:mockserver-netty-no-dependencies:5.14.0") diff --git a/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithEventService.kt b/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithEventService.kt index e10888d..b37254c 100644 --- a/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithEventService.kt +++ b/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithEventService.kt @@ -19,6 +19,7 @@ internal class FlagsmithEventService constructor( ) { private val sseClient = OkHttpClient.Builder() .addInterceptor(FlagsmithRetrofitService.envKeyInterceptor(environmentKey)) + .addInterceptor(FlagsmithRetrofitService.userAgentInterceptor()) .connectTimeout(6, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.MINUTES) .writeTimeout(10, TimeUnit.MINUTES) diff --git a/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithRetrofitService.kt b/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithRetrofitService.kt index 60e9eab..7078eb2 100644 --- a/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithRetrofitService.kt +++ b/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithRetrofitService.kt @@ -39,6 +39,29 @@ interface FlagsmithRetrofitService { private const val UPDATED_AT_HEADER = "x-flagsmith-document-updated-at" private const val ACCEPT_HEADER_VALUE = "application/json" private const val CONTENT_TYPE_HEADER_VALUE = "application/json; charset=utf-8" + private const val USER_AGENT_HEADER = "User-Agent" + private const val USER_AGENT_PREFIX = "flagsmith-kotlin-android-sdk" + + private fun getUserAgent(): String { + val sdkVersion = getSdkVersion() + return "$USER_AGENT_PREFIX/$sdkVersion" + } + + private fun getSdkVersion(): String { + // x-release-please-start-version + return "1.8.0" + // x-release-please-end + } + + fun userAgentInterceptor(): Interceptor { + return Interceptor { chain -> + val userAgent = getUserAgent() + val request = chain.request().newBuilder() + .addHeader(USER_AGENT_HEADER, userAgent) + .build() + chain.proceed(request) + } + } fun create( baseUrl: String, @@ -92,6 +115,7 @@ interface FlagsmithRetrofitService { val client = OkHttpClient.Builder() .addInterceptor(envKeyInterceptor(environmentKey)) + .addInterceptor(userAgentInterceptor()) .addInterceptor(updatedAtInterceptor(timeTracker)) .addInterceptor(jsonContentTypeInterceptor()) .let { if (cacheConfig.enableCache) it.addNetworkInterceptor(cacheControlInterceptor()) else it } diff --git a/FlagsmithClient/src/test/java/com/flagsmith/UserAgentTests.kt b/FlagsmithClient/src/test/java/com/flagsmith/UserAgentTests.kt new file mode 100644 index 0000000..2ca3ef9 --- /dev/null +++ b/FlagsmithClient/src/test/java/com/flagsmith/UserAgentTests.kt @@ -0,0 +1,188 @@ +package com.flagsmith + +import com.flagsmith.entities.Trait +import com.flagsmith.mockResponses.MockEndpoint +import com.flagsmith.mockResponses.mockResponseFor +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockserver.integration.ClientAndServer +import org.mockserver.model.HttpRequest.request + +class UserAgentTests { + + private lateinit var mockServer: ClientAndServer + private lateinit var flagsmith: Flagsmith + + companion object { + // Expected version set by release-please in FlagsmithRetrofitService.getSdkVersion() + // x-release-please-start-version + private const val EXPECTED_SDK_VERSION = "1.8.0" + // x-release-please-end + private const val EXPECTED_USER_AGENT = "flagsmith-kotlin-android-sdk/$EXPECTED_SDK_VERSION" + } + + @Before + fun setup() { + mockServer = ClientAndServer.startClientAndServer() + } + + @After + fun tearDown() { + mockServer.stop() + } + + @Test + fun testUserAgentHeaderSentWithGetFlags() { + // Given - SDK version is set by release-please + flagsmith = Flagsmith( + environmentKey = "test-key", + baseUrl = "http://localhost:${mockServer.localPort}", + context = null, + enableAnalytics = false, + cacheConfig = FlagsmithCacheConfig(enableCache = false) + ) + + mockServer.mockResponseFor(MockEndpoint.GET_FLAGS) + + // When + runBlocking { + val result = flagsmith.getFeatureFlagsSync() + assertTrue(result.isSuccess) + } + + // Then - Verify User-Agent contains the SDK version from release-please + mockServer.verify( + request() + .withPath("/flags/") + .withMethod("GET") + .withHeader("User-Agent", EXPECTED_USER_AGENT) + ) + } + + @Test + fun testUserAgentHeaderSentWithNullContext() { + // Given - Context being null doesn't affect SDK version retrieval + flagsmith = Flagsmith( + environmentKey = "test-key", + baseUrl = "http://localhost:${mockServer.localPort}", + context = null, + enableAnalytics = false, + cacheConfig = FlagsmithCacheConfig(enableCache = false) + ) + + mockServer.mockResponseFor(MockEndpoint.GET_FLAGS) + + // When + runBlocking { + val result = flagsmith.getFeatureFlagsSync() + assertTrue(result.isSuccess) + } + + // Then - Should get the SDK version from release-please + mockServer.verify( + request() + .withPath("/flags/") + .withMethod("GET") + .withHeader("User-Agent", EXPECTED_USER_AGENT) + ) + } + + @Test + fun testUserAgentHeaderSentWithIdentityRequest() { + // Given - Testing that User-Agent header is sent consistently across all API endpoints + flagsmith = Flagsmith( + environmentKey = "test-key", + baseUrl = "http://localhost:${mockServer.localPort}", + context = null, + enableAnalytics = false, + cacheConfig = FlagsmithCacheConfig(enableCache = false) + ) + + mockServer.mockResponseFor(MockEndpoint.GET_IDENTITIES) + + // When + runBlocking { + val result = flagsmith.getIdentitySync("test-user") + assertTrue(result.isSuccess) + } + + // Then - Verify User-Agent header is sent with GET /identities/ + mockServer.verify( + request() + .withPath("/identities/") + .withMethod("GET") + .withQueryStringParameter("identifier", "test-user") + .withHeader("User-Agent", EXPECTED_USER_AGENT) + ) + } + + @Test + fun testUserAgentHeaderSentWithTraitRequest() { + // Given - Testing that User-Agent header is sent with POST requests + flagsmith = Flagsmith( + environmentKey = "test-key", + baseUrl = "http://localhost:${mockServer.localPort}", + context = null, + enableAnalytics = false, + cacheConfig = FlagsmithCacheConfig(enableCache = false) + ) + + mockServer.mockResponseFor(MockEndpoint.SET_TRAIT) + + // When + runBlocking { + val result = flagsmith.setTraitSync(Trait(key = "test-key", traitValue = "test-value"), "test-user") + assertTrue(result.isSuccess) + } + + // Then - Verify User-Agent header is sent with POST /identities/ + mockServer.verify( + request() + .withPath("/identities/") + .withMethod("POST") + .withHeader("User-Agent", EXPECTED_USER_AGENT) + ) + } + + @Test + fun testUserAgentFormat() { + // Given + flagsmith = Flagsmith( + environmentKey = "test-key", + baseUrl = "http://localhost:${mockServer.localPort}", + context = null, + enableAnalytics = false, + cacheConfig = FlagsmithCacheConfig(enableCache = false) + ) + + mockServer.mockResponseFor(MockEndpoint.GET_FLAGS) + + // When + runBlocking { + flagsmith.getFeatureFlagsSync() + } + + // Then - Verify User-Agent follows the format: flagsmith-kotlin-android-sdk/{version} + val requests = mockServer.retrieveRecordedRequests( + request().withPath("/flags/") + ) + + assertEquals(1, requests.size) + val userAgentHeader = requests[0].getFirstHeader("User-Agent") + + // Verify format + assertTrue("User-Agent should start with 'flagsmith-kotlin-android-sdk/'", + userAgentHeader.startsWith("flagsmith-kotlin-android-sdk/")) + + // Verify version part exists and is not empty + val version = userAgentHeader.substringAfter("flagsmith-kotlin-android-sdk/") + assertTrue("Version should not be empty", version.isNotEmpty()) + + // Should be the version set by release-please + assertEquals(EXPECTED_SDK_VERSION, version) + } +} diff --git a/FlagsmithClient/src/test/java/com/flagsmith/internal/SdkVersionRetrievalTest.kt b/FlagsmithClient/src/test/java/com/flagsmith/internal/SdkVersionRetrievalTest.kt new file mode 100644 index 0000000..9848266 --- /dev/null +++ b/FlagsmithClient/src/test/java/com/flagsmith/internal/SdkVersionRetrievalTest.kt @@ -0,0 +1,141 @@ +package com.flagsmith.internal + +import com.flagsmith.FlagsmithCacheConfig +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for SDK version retrieval functionality in FlagsmithRetrofitService. + * + * These tests verify that getSdkVersion() correctly returns the version set by release-please. + */ +class SdkVersionRetrievalTest { + + private lateinit var mockServer: MockWebServer + + companion object { + // This should match the version in getSdkVersion() and in .release-please-manifest.json + // x-release-please-start-version + private const val EXPECTED_SDK_VERSION = "1.8.0" + // x-release-please-end + private const val USER_AGENT_PREFIX = "flagsmith-kotlin-android-sdk" + } + + @Before + fun setup() { + mockServer = MockWebServer() + mockServer.start() + } + + @After + fun tearDown() { + mockServer.shutdown() + } + + @Test + fun testUserAgentInterceptorReturnsValidFormat() { + // Given - Create a client with the user agent interceptor + val interceptor = FlagsmithRetrofitService.userAgentInterceptor() + val client = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + mockServer.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + // When - Make a request + val request = Request.Builder() + .url(mockServer.url("/")) + .build() + + client.newCall(request).execute().use { response -> + // Then - Verify the request was made with the correct User-Agent header + val recordedRequest = mockServer.takeRequest() + val userAgent = recordedRequest.getHeader("User-Agent") + + assertNotNull("User-Agent header should be present", userAgent) + assertTrue( + "User-Agent should start with correct prefix: $userAgent", + userAgent!!.startsWith("$USER_AGENT_PREFIX/") + ) + } + } + + @Test + fun testVersionFormatIsValid() { + // Given - Create a client with the user agent interceptor + val interceptor = FlagsmithRetrofitService.userAgentInterceptor() + val client = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + mockServer.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + // When - Make a request + val request = Request.Builder() + .url(mockServer.url("/")) + .build() + + client.newCall(request).execute().use { response -> + // Then - Verify version format is semantic versioning compatible + val recordedRequest = mockServer.takeRequest() + val userAgent = recordedRequest.getHeader("User-Agent")!! + val version = userAgent.substringAfter("$USER_AGENT_PREFIX/") + + assertTrue("Version should not be empty", version.isNotEmpty()) + assertTrue("Version should not contain whitespace", version.trim() == version) + + // Version should match semantic versioning pattern (X.Y.Z) or be a valid identifier + val semverPattern = Regex("^\\d+\\.\\d+\\.\\d+.*$") + assertTrue( + "Version should follow semantic versioning or be a valid identifier: $version", + semverPattern.matches(version) || version.matches(Regex("^[a-zA-Z0-9._-]+$")) + ) + } + } + + @Test + fun testUserAgentHeaderIsPersistentAcrossRequests() { + // Given - Create a client with the user agent interceptor + val interceptor = FlagsmithRetrofitService.userAgentInterceptor() + val client = OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + + mockServer.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + mockServer.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + // When - Make multiple requests + val request1 = Request.Builder().url(mockServer.url("/first")).build() + val request2 = Request.Builder().url(mockServer.url("/second")).build() + + client.newCall(request1).execute().close() + client.newCall(request2).execute().close() + + // Then - Both requests should have the same User-Agent + val recordedRequest1 = mockServer.takeRequest() + val recordedRequest2 = mockServer.takeRequest() + + val userAgent1 = recordedRequest1.getHeader("User-Agent") + val userAgent2 = recordedRequest2.getHeader("User-Agent") + + assertEquals( + "User-Agent should be consistent across requests", + userAgent1, + userAgent2 + ) + + assertEquals( + "User-Agent should be the expected value", + "$USER_AGENT_PREFIX/$EXPECTED_SDK_VERSION", + userAgent1 + ) + } +} diff --git a/release-please-config.json b/release-please-config.json index 81d97a3..04ccdde 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -8,7 +8,12 @@ "bump-patch-for-minor-pre-major": false, "draft": false, "prerelease": false, - "include-component-in-tag": false + "include-component-in-tag": false, + "extra-files": [ + "FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithRetrofitService.kt", + "FlagsmithClient/src/test/java/com/flagsmith/UserAgentTests.kt", + "FlagsmithClient/src/test/java/com/flagsmith/internal/SdkVersionRetrievalTest.kt" + ] } }, "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",