Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions FlagsmithClient/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Comment thread
gazreese marked this conversation as resolved.
val userAgent = getUserAgent()
val request = chain.request().newBuilder()
.addHeader(USER_AGENT_HEADER, userAgent)
.build()
chain.proceed(request)
}
}

fun <T : FlagsmithRetrofitService> create(
baseUrl: String,
Expand Down Expand Up @@ -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 }
Expand Down
188 changes: 188 additions & 0 deletions FlagsmithClient/src/test/java/com/flagsmith/UserAgentTests.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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._-]+$"))
)
}
}
Comment on lines +71 to +102
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

Whitespace assertion doesn’t fully match the message; tighten the check

The intent of

assertTrue("Version should not contain whitespace", version.trim() == version)

is that the version “should not contain whitespace”, but this only rejects leading/trailing whitespace. Internal whitespace (e.g. "1.8.0 beta") would still pass.

You can align the assertion with the message by rejecting any whitespace character:

-            assertTrue("Version should not contain whitespace", version.trim() == version)
+            assertTrue(
+                "Version should not contain whitespace",
+                !version.any { it.isWhitespace() }
+            )

The rest of the format checks (semantic-version‑compatible or a simple identifier like "unknown") look reasonable.


I'll verify this review comment by inspecting the actual test file and confirming the whitespace assertion logic.


---

🏁 Script executed:

```shell
#!/bin/bash
# Find and inspect the test file
fd -t f "SdkVersionRetrievalTest.kt" --exec cat {} \;

Length of output: 5074


🏁 Script executed:

#!/bin/bash
# Also search for similar whitespace patterns in the codebase
rg -n "trim()" --type kotlin -C 3 | head -50

Length of output: 3772


🏁 Script executed:

#!/bin/bash
# Check for isWhitespace usage in Kotlin files
rg -n "isWhitespace()" --type kotlin

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Verify Kotlin String has 'any' method with isWhitespace
rg -n "\.any\s*{" --type kotlin -C 2 | head -30

Length of output: 81


I need to verify that the suggested Kotlin syntax is valid and standard. Let me check the Kotlin documentation.


---

🌐 Web query:

```web
Kotlin String any method isWhitespace character

💡 Result:

Use Char.isWhitespace() inside String.any { ... }.

Example:
val s = "hello world"
val hasWs = s.any { it.isWhitespace() } // true

Notes:

  • Char.isWhitespace() checks Unicode whitespace categories. [1]
  • CharSequence.any(predicate) returns true if any character matches the predicate. [2]
  • If you want to test whether the whole string is empty or only whitespace, use isBlank(). [3]

Sources:
[1] Kotlin Char.isWhitespace docs.
[2] Kotlin CharSequence.any docs.
[3] Kotlin CharSequence.isBlank docs.


Whitespace assertion doesn't fully match the message; tighten the check

The assertion version.trim() == version only rejects leading/trailing whitespace, but the message states "should not contain whitespace"—implying any whitespace. Internal whitespace like "1.8.0 beta" would incorrectly pass.

Align the assertion with the message by rejecting any whitespace character:

-            assertTrue("Version should not contain whitespace", version.trim() == version)
+            assertTrue(
+                "Version should not contain whitespace",
+                !version.any { it.isWhitespace() }
+            )

The rest of the format checks (semantic versioning or simple identifiers) are reasonable.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Test
fun testVersionFormatIsValid() {
// Given - Create a client with the user agent interceptor
val interceptor = FlagsmithRetrofitService.userAgentInterceptor(null)
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 testVersionFormatIsValid() {
// Given - Create a client with the user agent interceptor
val interceptor = FlagsmithRetrofitService.userAgentInterceptor(null)
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.any { it.isWhitespace() }
)
// 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._-]+$"))
)
}
}
🤖 Prompt for AI Agents
In
FlagsmithClient/src/test/java/com/flagsmith/internal/SdkVersionRetrievalTest.kt
around lines 71 to 102, the whitespace assertion currently only checks
leading/trailing spaces but the message says "should not contain whitespace";
update the assertion to reject any whitespace character within the version
(e.g., assertTrue("Version should not contain whitespace", !version.any {
it.isWhitespace() } ) or equivalent regex check), and ensure the assertion
message remains accurate.


@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
)
}
}
Loading