diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/EppoException.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/EppoException.kt new file mode 100644 index 0000000..fa1036b --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/EppoException.kt @@ -0,0 +1,54 @@ +package cloud.eppo.kotlin + +/** + * Base exception for Eppo SDK errors. + * + * These exceptions are only thrown when gracefulMode is disabled. + * When gracefulMode is enabled, errors are logged and default values are returned. + */ +open class EppoException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** + * Thrown when a flag is not found in precomputed values. + * + * @param flagKey The flag key that was not found + */ +class FlagNotFoundException(flagKey: String) : EppoException("Flag not found: $flagKey") + +/** + * Thrown when flag type doesn't match expected type. + * + * @param flagKey The flag key + * @param expected The expected type + * @param actual The actual type + */ +class TypeMismatchException( + flagKey: String, + expected: String, + actual: String +) : EppoException("Type mismatch for flag '$flagKey': expected $expected but got $actual") + +/** + * Thrown when flag value cannot be parsed. + * + * @param flagKey The flag key + * @param cause The underlying parse error + */ +class ParseException( + flagKey: String, + cause: Throwable +) : EppoException("Failed to parse flag value: $flagKey", cause) + +/** + * Thrown when client is not initialized. + */ +class NotInitializedException : EppoException("Client not initialized. Call fetchPrecomputedFlags() first.") + +/** + * Thrown when a required parameter is missing or invalid. + * + * @param paramName The parameter name + */ +class InvalidParameterException( + paramName: String +) : EppoException("Invalid parameter: $paramName") diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/EppoPrecomputedConfig.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/EppoPrecomputedConfig.kt new file mode 100644 index 0000000..f23b6f1 --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/EppoPrecomputedConfig.kt @@ -0,0 +1,224 @@ +package cloud.eppo.kotlin + +typealias BanditFlagKey = String +typealias BanditActionKey = String +typealias BanditActionAttributes = Map> + +/** + * Configuration for EppoPrecomputedClient. + * + * Use [Builder] to construct instances. + * + * Example: + * ``` + * val config = EppoPrecomputedConfig.Builder() + * .apiKey("your-api-key") + * .subject("user-123", mapOf("plan" to "premium")) + * .gracefulMode(true) + * .build() + * ``` + */ +data class EppoPrecomputedConfig internal constructor( + val apiKey: String, + val subject: Subject, + val baseUrl: String, + val requestTimeoutMs: Long, + val enablePolling: Boolean, + val pollingIntervalMs: Long, + val enablePersistence: Boolean, + val enableAssignmentCache: Boolean, + val assignmentCacheMaxSize: Int, + val banditActions: Map? = null, + val gracefulMode: Boolean +) { + /** + * Subject information for flag evaluation. + * + * @property subjectKey Unique identifier for the subject (e.g., user ID) + * @property subjectAttributes Additional attributes for targeting + */ + data class Subject( + val subjectKey: String, + val subjectAttributes: Map = emptyMap() + ) + + /** + * Builder for EppoPrecomputedConfig. + * + * Graceful Mode Behavior: + * - When enabled (default): get*Value() methods never throw exceptions, + * always return defaultValue on error. Errors logged internally. + * - When disabled: Exceptions can be thrown from get*Value() methods for + * critical errors (e.g., TypeMismatchException, InvalidFlagException). + */ + class Builder { + private var apiKey: String? = null + private var subject: Subject? = null + private var baseUrl: String = "https://fs-edge-assignment.eppo.cloud" + private var requestTimeoutMs: Long = 5_000 + private var enablePolling: Boolean = false + private var pollingIntervalMs: Long = 30_000 + private var enablePersistence: Boolean = true + private var enableAssignmentCache: Boolean = true + private var assignmentCacheMaxSize: Int = 1000 + private var banditActions: Map? = null + private var gracefulMode: Boolean = true + + /** + * Set the API key for authentication. + * + * @param key Eppo API key (required) + * @return This builder + */ + fun apiKey(key: String) = apply { + this.apiKey = key + } + + /** + * Set the subject for flag evaluation. + * + * @param key Subject identifier (required) + * @param attributes Subject attributes for targeting (optional) + * @return This builder + */ + fun subject(key: String, attributes: Map = emptyMap()) = apply { + this.subject = Subject(key, attributes) + } + + /** + * Set the base URL for the Eppo API. + * + * @param url Base URL (default: https://fs-edge-assignment.eppo.cloud) + * @return This builder + */ + fun baseUrl(url: String) = apply { + this.baseUrl = url + } + + /** + * Set the request timeout. + * + * @param ms Timeout in milliseconds (default: 5000) + * @return This builder + */ + fun requestTimeout(ms: Long) = apply { + this.requestTimeoutMs = ms + } + + /** + * Enable or disable automatic polling. + * + * @param enable Whether to enable polling (default: false) + * @param intervalMs Polling interval in milliseconds (default: 30000) + * @return This builder + */ + fun enablePolling(enable: Boolean, intervalMs: Long = 30_000) = apply { + this.enablePolling = enable + this.pollingIntervalMs = intervalMs + } + + /** + * Enable or disable persistence to disk. + * + * @param enable Whether to enable persistence (default: true) + * @return This builder + */ + fun enablePersistence(enable: Boolean) = apply { + this.enablePersistence = enable + } + + /** + * Enable or disable assignment caching. + * + * @param enable Whether to enable caching (default: true) + * @param maxSize Maximum cache size (default: 1000) + * @return This builder + */ + fun enableAssignmentCache(enable: Boolean, maxSize: Int = 1000) = apply { + this.enableAssignmentCache = enable + this.assignmentCacheMaxSize = maxSize + } + + /** + * Set bandit actions for precomputation. + * + * Format: Map>> + * + * Example: + * ``` + * mapOf( + * "product-recommendation" to mapOf( + * "action-1" to mapOf("price" to 9.99, "color" to "red"), + * "action-2" to mapOf("price" to 14.99, "color" to "blue") + * ) + * ) + * ``` + * + * @param actions Bandit actions configuration + * @return This builder + * @throws IllegalArgumentException if actions are invalid + */ + fun banditActions(actions: Map>>) = apply { + require(actions.isNotEmpty()) { "Bandit actions cannot be empty" } + actions.forEach { (flagKey, actionMap) -> + require(flagKey.isNotBlank()) { "Bandit flag key cannot be blank" } + require(actionMap.isNotEmpty()) { + "Bandit flag '$flagKey' must have at least one action" + } + actionMap.forEach { (actionKey, _) -> + require(actionKey.isNotBlank()) { + "Action key cannot be blank for flag '$flagKey'" + } + } + } + this.banditActions = actions + } + + /** + * Enable or disable graceful error handling. + * + * When enabled (default): + * - get*Value() methods never throw exceptions + * - Always return defaultValue on error + * - Errors logged internally + * + * When disabled: + * - Exceptions can be thrown for critical errors + * - More explicit error handling required + * + * @param enabled Whether to enable graceful mode (default: true) + * @return This builder + */ + fun gracefulMode(enabled: Boolean) = apply { + this.gracefulMode = enabled + } + + /** + * Build the configuration. + * + * @return EppoPrecomputedConfig instance + * @throws IllegalStateException if required fields are missing + */ + fun build(): EppoPrecomputedConfig { + require(apiKey != null) { "API key is required" } + require(subject != null) { "Subject is required" } + require(requestTimeoutMs > 0) { "Request timeout must be positive" } + require(pollingIntervalMs > 0) { "Polling interval must be positive" } + require(assignmentCacheMaxSize > 0) { "Assignment cache max size must be positive" } + + return EppoPrecomputedConfig( + apiKey = apiKey!!, + subject = subject!!, + baseUrl = baseUrl, + requestTimeoutMs = requestTimeoutMs, + enablePolling = enablePolling, + pollingIntervalMs = pollingIntervalMs, + enablePersistence = enablePersistence, + enableAssignmentCache = enableAssignmentCache, + assignmentCacheMaxSize = assignmentCacheMaxSize, + banditActions = banditActions, + gracefulMode = gracefulMode + ) + } + } +} diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/PrecomputedRequestor.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/PrecomputedRequestor.kt new file mode 100644 index 0000000..407449c --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/PrecomputedRequestor.kt @@ -0,0 +1,90 @@ +package cloud.eppo.kotlin.internal.network + +import cloud.eppo.kotlin.EppoPrecomputedConfig +import cloud.eppo.kotlin.internal.network.dto.PrecomputedFlagsResponse +import cloud.eppo.kotlin.internal.util.await +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Response +import org.slf4j.LoggerFactory +import java.io.IOException + +/** + * HTTP client for fetching precomputed flags from the Eppo API. + * + * @property httpClient OkHttp client instance + * @property requestFactory Factory for building requests + * @property json JSON serializer for parsing responses + */ +internal class PrecomputedRequestor( + private val httpClient: OkHttpClient, + private val requestFactory: RequestFactory, + private val json: Json +) { + private val logger = LoggerFactory.getLogger(PrecomputedRequestor::class.java) + + /** + * Fetch precomputed flags for the given subject. + * + * This is a suspending function that executes on the IO dispatcher. + * + * @param config Client configuration + * @param subject Subject to fetch flags for + * @return Result containing the response or an error + */ + suspend fun fetchPrecomputedFlags( + config: EppoPrecomputedConfig, + subject: EppoPrecomputedConfig.Subject + ): Result = withContext(Dispatchers.IO) { + runCatching { + logger.debug("Fetching precomputed flags for subject: ${subject.subjectKey}") + + val request = requestFactory.buildFetchRequest(config, subject) + + val response: Response = try { + httpClient.newCall(request).await() + } catch (e: IOException) { + logger.error("Network error fetching precomputed flags", e) + throw e + } + + if (!response.isSuccessful) { + val errorMessage = "HTTP ${response.code}: ${response.message}" + logger.error("Failed to fetch precomputed flags: $errorMessage") + throw HttpException(response.code, response.message) + } + + val body = response.body?.string() + ?: throw IllegalStateException("Empty response body") + + response.close() + + val parsedResponse = try { + json.decodeFromString(body) + } catch (e: Exception) { + logger.error("Failed to parse precomputed flags response", e) + throw e + } + + logger.debug( + "Successfully fetched ${parsedResponse.flags.size} flags " + + "and ${parsedResponse.bandits?.size ?: 0} bandits" + ) + + parsedResponse + } + } +} + +/** + * Exception thrown when HTTP request fails. + * + * @property code HTTP status code + * @property message HTTP status message + */ +internal class HttpException( + val code: Int, + message: String +) : IOException("HTTP $code: $message") diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/RequestFactory.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/RequestFactory.kt new file mode 100644 index 0000000..6cb63f4 --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/RequestFactory.kt @@ -0,0 +1,97 @@ +package cloud.eppo.kotlin.internal.network + +import cloud.eppo.kotlin.EppoPrecomputedConfig +import cloud.eppo.kotlin.internal.network.dto.PrecomputedFlagsRequest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * Factory for building HTTP requests to the precomputed flags API. + * + * @property json JSON serializer instance + */ +internal class RequestFactory( + private val json: Json +) { + companion object { + private const val HEADER_API_KEY = "x-eppo-token" + private const val CONTENT_TYPE = "application/json; charset=utf-8" + private const val ASSIGNMENTS_PATH = "/api/v1/precomputed-flags" + } + + /** + * Build a request for fetching precomputed flags. + * + * @param config Client configuration + * @param subject Subject information (may differ from config if updateSubject was called) + * @return OkHttp Request object + */ + fun buildFetchRequest( + config: EppoPrecomputedConfig, + subject: EppoPrecomputedConfig.Subject + ): Request { + val url = "${config.baseUrl}$ASSIGNMENTS_PATH" + + val requestBody = PrecomputedFlagsRequest( + subjectKey = subject.subjectKey, + subjectAttributes = subject.subjectAttributes.toJsonElementMap(), + banditActions = config.banditActions?.mapValues { (_, actionMap) -> + actionMap.mapValues { (_, attributeMap) -> + attributeMap.toJsonElementMap() + } + } + ) + + val bodyJson = json.encodeToString( + PrecomputedFlagsRequest.serializer(), + requestBody + ) + + return Request.Builder() + .url(url) + .addHeader(HEADER_API_KEY, config.apiKey) + .addHeader("Content-Type", CONTENT_TYPE) + .post(bodyJson.toRequestBody(CONTENT_TYPE.toMediaType())) + .build() + } + + /** + * Convert a Map to Map. + * + * Handles common primitive types (String, Number, Boolean, null). + */ + private fun Map.toJsonElementMap(): Map { + return mapValues { (_, value) -> + value.toJsonElement() + } + } + + /** + * Convert Any value to JsonElement. + * + * @throws IllegalArgumentException if value type is not supported + */ + private fun Any?.toJsonElement(): JsonElement { + return when (this) { + null -> JsonPrimitive(null as String?) + is String -> JsonPrimitive(this) + is Number -> JsonPrimitive(this) + is Boolean -> JsonPrimitive(this) + is Map<*, *> -> buildJsonObject { + @Suppress("UNCHECKED_CAST") + (this@toJsonElement as Map).forEach { (key, value) -> + put(key, value.toJsonElement()) + } + } + else -> throw IllegalArgumentException( + "Unsupported attribute type: ${this::class.simpleName}" + ) + } + } +} diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/dto/Environment.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/dto/Environment.kt new file mode 100644 index 0000000..1266cf7 --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/dto/Environment.kt @@ -0,0 +1,14 @@ +package cloud.eppo.kotlin.internal.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Environment information from the server response. + * + * @property name Environment name (e.g., "production", "staging") + */ +@Serializable +internal data class Environment( + @SerialName("name") val name: String +) diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/dto/PrecomputedBandit.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/dto/PrecomputedBandit.kt new file mode 100644 index 0000000..084866a --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/dto/PrecomputedBandit.kt @@ -0,0 +1,29 @@ +package cloud.eppo.kotlin.internal.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Precomputed bandit data from the server. + * + * Key string fields (banditKey, action, modelVersion) are Base64 encoded. + * Attribute maps have Base64 encoded keys and values. + * + * @property banditKey Base64 encoded bandit identifier + * @property action Base64 encoded recommended action + * @property modelVersion Base64 encoded model version + * @property actionProbability Probability of this action being selected + * @property optimalityGap Optimality gap metric + * @property actionNumericAttributes Numeric attributes (Base64 encoded keys/values) + * @property actionCategoricalAttributes Categorical attributes (Base64 encoded keys/values) + */ +@Serializable +internal data class PrecomputedBandit( + @SerialName("banditKey") val banditKey: String, + @SerialName("action") val action: String, + @SerialName("modelVersion") val modelVersion: String, + @SerialName("actionProbability") val actionProbability: Double, + @SerialName("optimalityGap") val optimalityGap: Double, + @SerialName("actionNumericAttributes") val actionNumericAttributes: Map = emptyMap(), + @SerialName("actionCategoricalAttributes") val actionCategoricalAttributes: Map = emptyMap() +) diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/dto/PrecomputedFlag.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/dto/PrecomputedFlag.kt new file mode 100644 index 0000000..abef4c8 --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/dto/PrecomputedFlag.kt @@ -0,0 +1,28 @@ +package cloud.eppo.kotlin.internal.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Precomputed flag data from the server. + * + * All string fields (except variationType and doLog) are Base64 encoded. + * + * @property flagKey MD5 hash of the flag key (optional, may be null) + * @property allocationKey Base64 encoded allocation key + * @property variationKey Base64 encoded variation key + * @property variationType Type of the variation value (BOOLEAN, INTEGER, NUMERIC, STRING, JSON) + * @property variationValue Base64 encoded variation value + * @property extraLogging Base64 encoded extra logging metadata (keys and values) + * @property doLog Whether to log this assignment + */ +@Serializable +internal data class PrecomputedFlag( + @SerialName("flagKey") val flagKey: String? = null, + @SerialName("allocationKey") val allocationKey: String? = null, + @SerialName("variationKey") val variationKey: String? = null, + @SerialName("variationType") val variationType: String, + @SerialName("variationValue") val variationValue: String, + @SerialName("extraLogging") val extraLogging: Map? = null, + @SerialName("doLog") val doLog: Boolean +) diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/dto/PrecomputedFlagsRequest.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/dto/PrecomputedFlagsRequest.kt new file mode 100644 index 0000000..522976d --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/dto/PrecomputedFlagsRequest.kt @@ -0,0 +1,23 @@ +package cloud.eppo.kotlin.internal.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * Request payload for fetching precomputed flags. + * + * POST to /assignments endpoint. + * + * Format of banditActions: Map>> + * + * @property subjectKey Subject identifier (e.g., user ID) + * @property subjectAttributes Subject attributes for targeting + * @property banditActions Optional bandit actions for precomputation + */ +@Serializable +internal data class PrecomputedFlagsRequest( + @SerialName("subject_key") val subjectKey: String, + @SerialName("subject_attributes") val subjectAttributes: Map, + @SerialName("bandit_actions") val banditActions: Map>>? = null +) diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/dto/PrecomputedFlagsResponse.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/dto/PrecomputedFlagsResponse.kt new file mode 100644 index 0000000..b2cac3d --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/network/dto/PrecomputedFlagsResponse.kt @@ -0,0 +1,28 @@ +package cloud.eppo.kotlin.internal.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Response from the precomputed flags API. + * + * Contains precomputed flag assignments for the requested subject. + * + * @property format Response format (should be "PRECOMPUTED") + * @property obfuscated Whether values are obfuscated (should be true) + * @property createdAt ISO 8601 timestamp when response was created + * @property environment Environment information + * @property salt Salt used for MD5 hashing flag keys + * @property flags Map of hashed flag keys to precomputed flag data + * @property bandits Map of hashed flag keys to precomputed bandit data + */ +@Serializable +internal data class PrecomputedFlagsResponse( + @SerialName("format") val format: String, + @SerialName("obfuscated") val obfuscated: Boolean, + @SerialName("createdAt") val createdAt: String, + @SerialName("environment") val environment: Environment? = null, + @SerialName("salt") val salt: String, + @SerialName("flags") val flags: Map, + @SerialName("bandits") val bandits: Map? = null +) diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/repository/DecodedPrecomputedBandit.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/repository/DecodedPrecomputedBandit.kt new file mode 100644 index 0000000..5c5b0d5 --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/repository/DecodedPrecomputedBandit.kt @@ -0,0 +1,24 @@ +package cloud.eppo.kotlin.internal.repository + +/** + * Decoded (unobfuscated) precomputed bandit. + * + * All Base64 encoded values have been decoded to their original form. + * + * @property banditKey Decoded bandit identifier + * @property action Decoded recommended action + * @property modelVersion Decoded model version + * @property actionProbability Probability of this action being selected + * @property optimalityGap Optimality gap metric + * @property actionNumericAttributes Decoded numeric attributes + * @property actionCategoricalAttributes Decoded categorical attributes + */ +internal data class DecodedPrecomputedBandit( + val banditKey: String, + val action: String, + val modelVersion: String, + val actionProbability: Double, + val optimalityGap: Double, + val actionNumericAttributes: Map, + val actionCategoricalAttributes: Map +) diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/repository/DecodedPrecomputedFlag.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/repository/DecodedPrecomputedFlag.kt new file mode 100644 index 0000000..6ab2600 --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/repository/DecodedPrecomputedFlag.kt @@ -0,0 +1,24 @@ +package cloud.eppo.kotlin.internal.repository + +import cloud.eppo.ufc.dto.VariationType + +/** + * Decoded (unobfuscated) precomputed flag. + * + * All Base64 encoded values have been decoded to their original form. + * + * @property allocationKey Decoded allocation key + * @property variationKey Decoded variation key + * @property variationType Type of the variation value + * @property variationValue Decoded and typed variation value + * @property extraLogging Decoded extra logging metadata + * @property doLog Whether to log this assignment + */ +internal data class DecodedPrecomputedFlag( + val allocationKey: String?, + val variationKey: String?, + val variationType: VariationType, + val variationValue: Any, + val extraLogging: Map?, + val doLog: Boolean +) diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/repository/FlagsRepository.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/repository/FlagsRepository.kt new file mode 100644 index 0000000..34ac0ae --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/repository/FlagsRepository.kt @@ -0,0 +1,175 @@ +package cloud.eppo.kotlin.internal.repository + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.slf4j.LoggerFactory +import java.time.Instant + +/** + * Repository for managing precomputed flags and bandits. + * + * Provides thread-safe access to flags with: + * - In-memory cache via StateFlow (fast, lock-free reads) + * - Persistent storage via DataStore (survives app restarts) + * - Atomic updates (readers see consistent state) + * + * Thread Safety: All methods are thread-safe. Multiple readers can access + * flags concurrently. Updates are serialized via Mutex. + * + * @property dataStore DataStore for persistent storage + * @property scope CoroutineScope for async persistence + */ +internal class FlagsRepository( + private val dataStore: DataStore, + private val scope: CoroutineScope +) { + private val logger = LoggerFactory.getLogger(FlagsRepository::class.java) + + // In-memory state (lock-free reads) + private val _stateFlow = MutableStateFlow(null) + val stateFlow: StateFlow = _stateFlow.asStateFlow() + + // Mutex for serializing write operations + private val mutex = Mutex() + + /** + * Load persisted state from DataStore. + * + * Should be called during initialization. If persistence is disabled + * or data doesn't exist, this is a no-op. + */ + suspend fun loadPersistedState() { + try { + val persisted = dataStore.data.first() + if (persisted != null) { + _stateFlow.value = persisted + logger.debug( + "Loaded ${persisted.flags.size} flags and " + + "${persisted.bandits.size} bandits from persistent storage" + ) + } + } catch (e: Exception) { + logger.error("Failed to load persisted state, starting fresh", e) + // Continue with null state - not a fatal error + } + } + + /** + * Update flags state atomically. + * + * This method: + * 1. Acquires mutex lock (serializes updates) + * 2. Updates StateFlow atomically (readers see old or new, never partial) + * 3. Persists to DataStore asynchronously (non-blocking) + * + * If persistence fails, in-memory state remains valid. + * + * @param flags Map of hashed keys to decoded flags + * @param bandits Map of hashed keys to decoded bandits + * @param salt Salt for MD5 hashing + * @param environment Environment name + * @param createdAt Timestamp + * @param format Response format + */ + suspend fun updateFlags( + flags: Map, + bandits: Map, + salt: String, + environment: String?, + createdAt: Instant, + format: String + ) = mutex.withLock { + val newState = FlagsState( + flags = flags, + bandits = bandits, + salt = salt, + environment = environment, + createdAt = createdAt, + format = format + ) + + // Atomic swap - readers immediately see new state + _stateFlow.value = newState + + logger.debug("Updated repository with ${flags.size} flags and ${bandits.size} bandits") + + // Persist asynchronously (don't block the update) + scope.launch(Dispatchers.IO) { + try { + dataStore.updateData { newState } + logger.debug("Successfully persisted flags to DataStore") + } catch (e: Exception) { + logger.error("Failed to persist flags, in-memory state remains valid", e) + // Don't throw - in-memory state is still valid + } + } + } + + /** + * Get a flag by its hashed key. + * + * Thread-safe, lock-free read. + * + * @param hashedKey MD5 hash of the flag key + * @return Decoded flag or null if not found + */ + fun getFlag(hashedKey: String): DecodedPrecomputedFlag? { + return _stateFlow.value?.getFlag(hashedKey) + } + + /** + * Get a bandit by its hashed key. + * + * Thread-safe, lock-free read. + * + * @param hashedKey MD5 hash of the flag key + * @return Decoded bandit or null if not found + */ + fun getBandit(hashedKey: String): DecodedPrecomputedBandit? { + return _stateFlow.value?.getBandit(hashedKey) + } + + /** + * Get the salt used for MD5 hashing. + * + * @return Salt string or null if not initialized + */ + fun getSalt(): String? { + return _stateFlow.value?.salt + } + + /** + * Check if repository has been initialized with data. + * + * @return true if flags have been loaded + */ + fun isInitialized(): Boolean { + return _stateFlow.value != null + } + + /** + * Clear all flags and bandits. + * + * This also clears the persisted state. + */ + suspend fun clear() = mutex.withLock { + _stateFlow.value = null + + scope.launch(Dispatchers.IO) { + try { + dataStore.updateData { null } + logger.debug("Cleared persisted flags") + } catch (e: Exception) { + logger.error("Failed to clear persisted flags", e) + } + } + } +} diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/repository/FlagsState.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/repository/FlagsState.kt new file mode 100644 index 0000000..ad312d9 --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/repository/FlagsState.kt @@ -0,0 +1,55 @@ +package cloud.eppo.kotlin.internal.repository + +import kotlinx.serialization.Serializable +import java.time.Instant + +/** + * Immutable state container for precomputed flags and bandits. + * + * This represents a complete snapshot of flags for a specific subject. + * All flags are stored by their MD5-hashed key. + * + * @property flags Map of hashed flag keys to decoded flags + * @property bandits Map of hashed flag keys to decoded bandits + * @property salt Salt used for MD5 hashing flag keys + * @property environment Environment name (e.g., "production") + * @property createdAt Timestamp when this state was created + * @property format Response format (should be "PRECOMPUTED") + */ +internal data class FlagsState( + val flags: Map, + val bandits: Map, + val salt: String, + val environment: String?, + val createdAt: Instant, + val format: String +) { + /** + * Get a flag by its hashed key. + * + * @param hashedKey MD5 hash of the flag key + * @return Decoded flag or null if not found + */ + fun getFlag(hashedKey: String): DecodedPrecomputedFlag? { + return flags[hashedKey] + } + + /** + * Get a bandit by its hashed key. + * + * @param hashedKey MD5 hash of the flag key + * @return Decoded bandit or null if not found + */ + fun getBandit(hashedKey: String): DecodedPrecomputedBandit? { + return bandits[hashedKey] + } + + /** + * Check if this state contains any flags. + * + * @return true if at least one flag exists + */ + fun hasFlags(): Boolean { + return flags.isNotEmpty() + } +} diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/repository/FlagsStateSerializer.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/repository/FlagsStateSerializer.kt new file mode 100644 index 0000000..933ae83 --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/repository/FlagsStateSerializer.kt @@ -0,0 +1,184 @@ +package cloud.eppo.kotlin.internal.repository + +import androidx.datastore.core.Serializer +import cloud.eppo.ufc.dto.VariationType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.slf4j.LoggerFactory +import java.io.InputStream +import java.io.OutputStream +import java.time.Instant + +/** + * Serializer for persisting FlagsState to DataStore. + * + * Converts FlagsState to/from JSON for disk storage. + */ +internal object FlagsStateSerializer : Serializer { + private val logger = LoggerFactory.getLogger(FlagsStateSerializer::class.java) + private val json = Json { ignoreUnknownKeys = true } + + override val defaultValue: FlagsState? = null + + override suspend fun readFrom(input: InputStream): FlagsState? { + return withContext(Dispatchers.IO) { + try { + val bytes = input.readBytes() + if (bytes.isEmpty()) { + return@withContext null + } + + val persistable = json.decodeFromString( + bytes.toString(Charsets.UTF_8) + ) + + persistable.toDomain() + } catch (e: Exception) { + logger.error("Failed to deserialize FlagsState", e) + null + } + } + } + + override suspend fun writeTo(t: FlagsState?, output: OutputStream) { + withContext(Dispatchers.IO) { + if (t == null) { + return@withContext + } + + try { + val persistable = PersistableFlagsState.fromDomain(t) + val jsonString = json.encodeToString( + PersistableFlagsState.serializer(), + persistable + ) + output.write(jsonString.toByteArray(Charsets.UTF_8)) + } catch (e: Exception) { + logger.error("Failed to serialize FlagsState", e) + throw e + } + } + } +} + +/** + * Serializable version of FlagsState for DataStore persistence. + * + * Uses primitive types that kotlinx.serialization can handle. + */ +@Serializable +private data class PersistableFlagsState( + val flags: Map, + val bandits: Map, + val salt: String, + val environment: String?, + val createdAtMillis: Long, + val format: String +) { + fun toDomain(): FlagsState { + return FlagsState( + flags = flags.mapValues { it.value.toDomain() }, + bandits = bandits.mapValues { it.value.toDomain() }, + salt = salt, + environment = environment, + createdAt = Instant.ofEpochMilli(createdAtMillis), + format = format + ) + } + + companion object { + fun fromDomain(state: FlagsState): PersistableFlagsState { + return PersistableFlagsState( + flags = state.flags.mapValues { PersistableFlag.fromDomain(it.value) }, + bandits = state.bandits.mapValues { PersistableBandit.fromDomain(it.value) }, + salt = state.salt, + environment = state.environment, + createdAtMillis = state.createdAt.toEpochMilli(), + format = state.format + ) + } + } +} + +@Serializable +private data class PersistableFlag( + val allocationKey: String?, + val variationKey: String?, + val variationType: String, + val variationValue: String, // Serialized as string + val extraLogging: Map?, + val doLog: Boolean +) { + fun toDomain(): DecodedPrecomputedFlag { + val type = VariationType.fromString(variationType) + ?: throw IllegalArgumentException("Unknown variation type: $variationType") + + val typedValue = when (type) { + VariationType.BOOLEAN -> variationValue.toBoolean() + VariationType.INTEGER -> variationValue.toInt() + VariationType.NUMERIC -> variationValue.toDouble() + VariationType.STRING, VariationType.JSON -> variationValue + } + + return DecodedPrecomputedFlag( + allocationKey = allocationKey, + variationKey = variationKey, + variationType = type, + variationValue = typedValue, + extraLogging = extraLogging, + doLog = doLog + ) + } + + companion object { + fun fromDomain(flag: DecodedPrecomputedFlag): PersistableFlag { + return PersistableFlag( + allocationKey = flag.allocationKey, + variationKey = flag.variationKey, + variationType = flag.variationType.value, + variationValue = flag.variationValue.toString(), + extraLogging = flag.extraLogging, + doLog = flag.doLog + ) + } + } +} + +@Serializable +private data class PersistableBandit( + val banditKey: String, + val action: String, + val modelVersion: String, + val actionProbability: Double, + val optimalityGap: Double, + val actionNumericAttributes: Map, + val actionCategoricalAttributes: Map +) { + fun toDomain(): DecodedPrecomputedBandit { + return DecodedPrecomputedBandit( + banditKey = banditKey, + action = action, + modelVersion = modelVersion, + actionProbability = actionProbability, + optimalityGap = optimalityGap, + actionNumericAttributes = actionNumericAttributes, + actionCategoricalAttributes = actionCategoricalAttributes + ) + } + + companion object { + fun fromDomain(bandit: DecodedPrecomputedBandit): PersistableBandit { + return PersistableBandit( + banditKey = bandit.banditKey, + action = bandit.action, + modelVersion = bandit.modelVersion, + actionProbability = bandit.actionProbability, + optimalityGap = bandit.optimalityGap, + actionNumericAttributes = bandit.actionNumericAttributes, + actionCategoricalAttributes = bandit.actionCategoricalAttributes + ) + } + } +} diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/util/Base64Extensions.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/util/Base64Extensions.kt new file mode 100644 index 0000000..266f365 --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/util/Base64Extensions.kt @@ -0,0 +1,30 @@ +package cloud.eppo.kotlin.internal.util + +import android.util.Base64 + +/** + * Decode a Base64 encoded string. + * + * Uses NO_WRAP flag to avoid newlines in the output. + * + * @return Decoded string + * @throws IllegalArgumentException if the input is not valid Base64 + */ +internal fun String.decodeBase64(): String { + return try { + Base64.decode(this, Base64.NO_WRAP).toString(Charsets.UTF_8) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid Base64 string: $this", e) + } +} + +/** + * Encode a string to Base64. + * + * Uses NO_WRAP flag to avoid newlines in the output. + * + * @return Base64 encoded string + */ +internal fun String.encodeBase64(): String { + return Base64.encodeToString(this.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) +} diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/util/Md5.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/util/Md5.kt new file mode 100644 index 0000000..2751ad8 --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/util/Md5.kt @@ -0,0 +1,26 @@ +package cloud.eppo.kotlin.internal.util + +import java.security.MessageDigest + +/** + * Compute MD5 hash of input string with salt. + * + * @param input The string to hash + * @param salt Salt to append to input before hashing + * @return Hex-encoded MD5 hash + */ +internal fun getMD5Hash(input: String, salt: String?): String { + val md5 = MessageDigest.getInstance("MD5") + val combined = input + (salt ?: "") + val digest = md5.digest(combined.toByteArray(Charsets.UTF_8)) + return digest.toHex() +} + +/** + * Convert byte array to hex string. + * + * @return Hex-encoded string + */ +private fun ByteArray.toHex(): String { + return joinToString("") { "%02x".format(it) } +} diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/util/OkHttpExtensions.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/util/OkHttpExtensions.kt new file mode 100644 index 0000000..e779cad --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/internal/util/OkHttpExtensions.kt @@ -0,0 +1,41 @@ +package cloud.eppo.kotlin.internal.util + +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Response +import java.io.IOException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Suspend extension for OkHttp Call to integrate with coroutines. + * + * This extension properly handles: + * - Coroutine cancellation (cancels the HTTP call) + * - Network errors + * - Response delivery + * + * Note: resume() and resumeWithException() are safe to call on cancelled + * continuations - they are idempotent and will be ignored if already resumed. + * + * @return The HTTP response + * @throws IOException for network errors + */ +internal suspend fun Call.await(): Response { + return suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { + this.cancel() + } + + enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) + } + }) + } +} diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/model/BanditResult.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/model/BanditResult.kt new file mode 100644 index 0000000..0e1b139 --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/model/BanditResult.kt @@ -0,0 +1,6 @@ +package cloud.eppo.kotlin.model + +data class BanditResult( + val variation: String, + val action: String? +) diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/model/ErrorCode.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/model/ErrorCode.kt new file mode 100644 index 0000000..d69e102 --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/model/ErrorCode.kt @@ -0,0 +1,8 @@ +package cloud.eppo.kotlin.model + +enum class ErrorCode { + FLAG_NOT_FOUND, + TYPE_MISMATCH, + PARSE_ERROR, + GENERAL +} diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/model/ResolutionDetails.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/model/ResolutionDetails.kt new file mode 100644 index 0000000..ba91d4d --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/model/ResolutionDetails.kt @@ -0,0 +1,10 @@ +package cloud.eppo.kotlin.model + +data class ResolutionDetails( + val value: T, + val variant: String? = null, + val reason: ResolutionReason? = null, + val errorCode: ErrorCode? = null, + val errorMessage: String? = null, + val flagMetadata: Map = emptyMap() +) diff --git a/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/model/ResolutionReason.kt b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/model/ResolutionReason.kt new file mode 100644 index 0000000..769880e --- /dev/null +++ b/eppo-kotlin/src/main/kotlin/cloud/eppo/kotlin/model/ResolutionReason.kt @@ -0,0 +1,8 @@ +package cloud.eppo.kotlin.model + +enum class ResolutionReason { + STATIC, + DEFAULT, + ERROR, + CACHED +} diff --git a/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/network/PrecomputedRequestorTest.kt b/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/network/PrecomputedRequestorTest.kt new file mode 100644 index 0000000..62e3970 --- /dev/null +++ b/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/network/PrecomputedRequestorTest.kt @@ -0,0 +1,300 @@ +package cloud.eppo.kotlin.internal.network + +import cloud.eppo.kotlin.EppoPrecomputedConfig +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.util.concurrent.TimeUnit + +class PrecomputedRequestorTest { + + private lateinit var mockWebServer: MockWebServer + private lateinit var requestor: PrecomputedRequestor + private lateinit var httpClient: OkHttpClient + private lateinit var json: Json + private lateinit var requestFactory: RequestFactory + + @Before + fun setup() { + mockWebServer = MockWebServer() + mockWebServer.start() + + json = Json { ignoreUnknownKeys = true } + requestFactory = RequestFactory(json) + httpClient = OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + + requestor = PrecomputedRequestor(httpClient, requestFactory, json) + } + + @After + fun tearDown() { + mockWebServer.shutdown() + } + + @Test + fun `fetchPrecomputedFlags returns success on valid response`() = runTest { + val responseBody = """ + { + "format": "PRECOMPUTED", + "obfuscated": true, + "createdAt": "2025-01-28T12:00:00Z", + "salt": "abc123", + "flags": { + "hash1": { + "variationType": "BOOLEAN", + "variationValue": "dHJ1ZQ==", + "doLog": true + } + } + } + """.trimIndent() + + mockWebServer.enqueue(MockResponse().setBody(responseBody).setResponseCode(200)) + + val config = createTestConfig(baseUrl = mockWebServer.url("/").toString()) + val subject = EppoPrecomputedConfig.Subject("user-123") + + val result = requestor.fetchPrecomputedFlags(config, subject) + + assertThat(result.isSuccess).isTrue() + val response = result.getOrThrow() + assertThat(response.format).isEqualTo("PRECOMPUTED") + assertThat(response.obfuscated).isTrue() + assertThat(response.salt).isEqualTo("abc123") + assertThat(response.flags).hasSize(1) + } + + @Test + fun `fetchPrecomputedFlags sends correct request`() = runTest { + mockWebServer.enqueue(MockResponse().setBody(createValidResponse()).setResponseCode(200)) + + val config = createTestConfig( + baseUrl = mockWebServer.url("/").toString(), + apiKey = "test-api-key-123" + ) + val subject = EppoPrecomputedConfig.Subject( + "user-456", + mapOf("country" to "UK") + ) + + requestor.fetchPrecomputedFlags(config, subject) + + val recordedRequest = mockWebServer.takeRequest() + assertThat(recordedRequest.method).isEqualTo("POST") + assertThat(recordedRequest.path).isEqualTo("/api/v1/precomputed-flags") + assertThat(recordedRequest.getHeader("x-eppo-token")).isEqualTo("test-api-key-123") + assertThat(recordedRequest.getHeader("Content-Type")).contains("application/json") + + val bodyJson = json.parseToJsonElement(recordedRequest.body.readUtf8()).jsonObject + assertThat(bodyJson["subject_key"]?.jsonPrimitive?.content).isEqualTo("user-456") + } + + @Test + fun `fetchPrecomputedFlags returns failure on network error`() = runTest { + mockWebServer.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)) + + val config = createTestConfig(baseUrl = mockWebServer.url("/").toString()) + val subject = EppoPrecomputedConfig.Subject("user-123") + + val result = requestor.fetchPrecomputedFlags(config, subject) + + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IOException::class.java) + } + + @Test + fun `fetchPrecomputedFlags returns failure on HTTP 404`() = runTest { + mockWebServer.enqueue(MockResponse().setResponseCode(404).setBody("Not Found")) + + val config = createTestConfig(baseUrl = mockWebServer.url("/").toString()) + val subject = EppoPrecomputedConfig.Subject("user-123") + + val result = requestor.fetchPrecomputedFlags(config, subject) + + assertThat(result.isFailure).isTrue() + val exception = result.exceptionOrNull() as? HttpException + assertThat(exception).isNotNull() + assertThat(exception?.code).isEqualTo(404) + } + + @Test + fun `fetchPrecomputedFlags returns failure on HTTP 500`() = runTest { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(500) + .setBody("Internal Server Error") + ) + + val config = createTestConfig(baseUrl = mockWebServer.url("/").toString()) + val subject = EppoPrecomputedConfig.Subject("user-123") + + val result = requestor.fetchPrecomputedFlags(config, subject) + + assertThat(result.isFailure).isTrue() + val exception = result.exceptionOrNull() as? HttpException + assertThat(exception?.code).isEqualTo(500) + } + + @Test + fun `fetchPrecomputedFlags returns failure on invalid JSON`() = runTest { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("{ invalid json }") + ) + + val config = createTestConfig(baseUrl = mockWebServer.url("/").toString()) + val subject = EppoPrecomputedConfig.Subject("user-123") + + val result = requestor.fetchPrecomputedFlags(config, subject) + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `fetchPrecomputedFlags returns failure on empty response body`() = runTest { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("")) + + val config = createTestConfig(baseUrl = mockWebServer.url("/").toString()) + val subject = EppoPrecomputedConfig.Subject("user-123") + + val result = requestor.fetchPrecomputedFlags(config, subject) + + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `fetchPrecomputedFlags parses bandits correctly`() = runTest { + val responseBody = """ + { + "format": "PRECOMPUTED", + "obfuscated": true, + "createdAt": "2025-01-28T12:00:00Z", + "salt": "abc123", + "flags": {}, + "bandits": { + "bandit-hash-1": { + "banditKey": "YmFuZGl0LWtleQ==", + "action": "YWN0aW9uLTE=", + "modelVersion": "djEuMA==", + "actionProbability": 0.75, + "optimalityGap": 0.1 + } + } + } + """.trimIndent() + + mockWebServer.enqueue(MockResponse().setBody(responseBody).setResponseCode(200)) + + val config = createTestConfig(baseUrl = mockWebServer.url("/").toString()) + val subject = EppoPrecomputedConfig.Subject("user-123") + + val result = requestor.fetchPrecomputedFlags(config, subject) + + assertThat(result.isSuccess).isTrue() + val response = result.getOrThrow() + assertThat(response.bandits).isNotNull() + assertThat(response.bandits).hasSize(1) + + val bandit = response.bandits?.get("bandit-hash-1") + assertThat(bandit?.banditKey).isEqualTo("YmFuZGl0LWtleQ==") + assertThat(bandit?.action).isEqualTo("YWN0aW9uLTE=") + assertThat(bandit?.actionProbability).isEqualTo(0.75) + } + + @Test + fun `fetchPrecomputedFlags includes bandit actions in request when configured`() = runTest { + mockWebServer.enqueue(MockResponse().setBody(createValidResponse()).setResponseCode(200)) + + val banditActions = mapOf( + "product-rec" to mapOf( + "action-1" to mapOf("price" to 10.0), + "action-2" to mapOf("price" to 20.0) + ) + ) + + val config = createTestConfig( + baseUrl = mockWebServer.url("/").toString(), + banditActions = banditActions + ) + val subject = EppoPrecomputedConfig.Subject("user-123") + + requestor.fetchPrecomputedFlags(config, subject) + + val recordedRequest = mockWebServer.takeRequest() + val bodyJson = json.parseToJsonElement(recordedRequest.body.readUtf8()).jsonObject + + val requestBanditActions = bodyJson["bandit_actions"]?.jsonObject + assertThat(requestBanditActions).isNotNull() + assertThat(requestBanditActions?.containsKey("product-rec")).isTrue() + } + + @Test + fun `fetchPrecomputedFlags handles response with environment`() = runTest { + val responseBody = """ + { + "format": "PRECOMPUTED", + "obfuscated": true, + "createdAt": "2025-01-28T12:00:00Z", + "environment": { + "name": "production" + }, + "salt": "abc123", + "flags": {} + } + """.trimIndent() + + mockWebServer.enqueue(MockResponse().setBody(responseBody).setResponseCode(200)) + + val config = createTestConfig(baseUrl = mockWebServer.url("/").toString()) + val subject = EppoPrecomputedConfig.Subject("user-123") + + val result = requestor.fetchPrecomputedFlags(config, subject) + + assertThat(result.isSuccess).isTrue() + val response = result.getOrThrow() + assertThat(response.environment).isNotNull() + assertThat(response.environment?.name).isEqualTo("production") + } + + // Helper functions + + private fun createTestConfig( + apiKey: String = "test-key", + baseUrl: String = "https://test.api.com", + banditActions: Map>>? = null + ): EppoPrecomputedConfig { + return EppoPrecomputedConfig.Builder() + .apiKey(apiKey) + .subject("default-subject") + .baseUrl(baseUrl) + .apply { banditActions?.let { banditActions(it) } } + .build() + } + + private fun createValidResponse(): String { + return """ + { + "format": "PRECOMPUTED", + "obfuscated": true, + "createdAt": "2025-01-28T12:00:00Z", + "salt": "test-salt", + "flags": {} + } + """.trimIndent() + } +} diff --git a/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/network/RequestFactoryTest.kt b/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/network/RequestFactoryTest.kt new file mode 100644 index 0000000..f4bd45e --- /dev/null +++ b/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/network/RequestFactoryTest.kt @@ -0,0 +1,180 @@ +package cloud.eppo.kotlin.internal.network + +import cloud.eppo.kotlin.EppoPrecomputedConfig +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Before +import org.junit.Test + +class RequestFactoryTest { + + private lateinit var requestFactory: RequestFactory + private lateinit var json: Json + + @Before + fun setup() { + json = Json { ignoreUnknownKeys = true } + requestFactory = RequestFactory(json) + } + + @Test + fun `buildFetchRequest creates correct URL`() { + val config = createTestConfig() + val subject = EppoPrecomputedConfig.Subject("user-123") + + val request = requestFactory.buildFetchRequest(config, subject) + + assertThat(request.url.toString()) + .isEqualTo("https://fs-edge-assignment.eppo.cloud/api/v1/precomputed-flags") + } + + @Test + fun `buildFetchRequest includes API key header`() { + val config = createTestConfig(apiKey = "test-api-key") + val subject = EppoPrecomputedConfig.Subject("user-123") + + val request = requestFactory.buildFetchRequest(config, subject) + + assertThat(request.header("x-eppo-token")).isEqualTo("test-api-key") + } + + @Test + fun `buildFetchRequest includes content type header`() { + val config = createTestConfig() + val subject = EppoPrecomputedConfig.Subject("user-123") + + val request = requestFactory.buildFetchRequest(config, subject) + + assertThat(request.header("Content-Type")) + .isEqualTo("application/json; charset=utf-8") + } + + @Test + fun `buildFetchRequest uses POST method`() { + val config = createTestConfig() + val subject = EppoPrecomputedConfig.Subject("user-123") + + val request = requestFactory.buildFetchRequest(config, subject) + + assertThat(request.method).isEqualTo("POST") + } + + @Test + fun `buildFetchRequest includes subject key in body`() { + val config = createTestConfig() + val subject = EppoPrecomputedConfig.Subject("user-456") + + val request = requestFactory.buildFetchRequest(config, subject) + val bodyJson = parseRequestBody(request) + + assertThat(bodyJson["subject_key"]?.jsonPrimitive?.content).isEqualTo("user-456") + } + + @Test + fun `buildFetchRequest includes subject attributes in body`() { + val config = createTestConfig() + val subject = EppoPrecomputedConfig.Subject( + "user-123", + mapOf( + "country" to "US", + "plan" to "premium", + "age" to 25 + ) + ) + + val request = requestFactory.buildFetchRequest(config, subject) + val bodyJson = parseRequestBody(request) + + val attributes = bodyJson["subject_attributes"]?.jsonObject + assertThat(attributes).isNotNull() + assertThat(attributes?.get("country")?.jsonPrimitive?.content).isEqualTo("US") + assertThat(attributes?.get("plan")?.jsonPrimitive?.content).isEqualTo("premium") + assertThat(attributes?.get("age")?.jsonPrimitive?.content).isEqualTo("25") + } + + @Test + fun `buildFetchRequest includes bandit actions when configured`() { + val banditActions = mapOf( + "product-recommendation" to mapOf( + "action-1" to mapOf("price" to 9.99, "color" to "red"), + "action-2" to mapOf("price" to 14.99, "color" to "blue") + ) + ) + + val config = createTestConfig(banditActions = banditActions) + val subject = EppoPrecomputedConfig.Subject("user-123") + + val request = requestFactory.buildFetchRequest(config, subject) + val bodyJson = parseRequestBody(request) + + val banditActionsJson = bodyJson["bandit_actions"]?.jsonObject + assertThat(banditActionsJson).isNotNull() + + val productRec = banditActionsJson?.get("product-recommendation")?.jsonObject + assertThat(productRec).isNotNull() + + val action1 = productRec?.get("action-1")?.jsonObject + assertThat(action1?.get("price")?.jsonPrimitive?.content).isEqualTo("9.99") + assertThat(action1?.get("color")?.jsonPrimitive?.content).isEqualTo("red") + } + + @Test + fun `buildFetchRequest excludes bandit actions when not configured`() { + val config = createTestConfig(banditActions = null) + val subject = EppoPrecomputedConfig.Subject("user-123") + + val request = requestFactory.buildFetchRequest(config, subject) + val bodyJson = parseRequestBody(request) + + assertThat(bodyJson.containsKey("bandit_actions")).isFalse() + } + + @Test + fun `buildFetchRequest uses custom base URL when provided`() { + val config = createTestConfig(baseUrl = "https://custom.api.com") + val subject = EppoPrecomputedConfig.Subject("user-123") + + val request = requestFactory.buildFetchRequest(config, subject) + + assertThat(request.url.toString()) + .startsWith("https://custom.api.com/api/v1/precomputed-flags") + } + + @Test + fun `buildFetchRequest handles empty subject attributes`() { + val config = createTestConfig() + val subject = EppoPrecomputedConfig.Subject("user-123", emptyMap()) + + val request = requestFactory.buildFetchRequest(config, subject) + val bodyJson = parseRequestBody(request) + + val attributes = bodyJson["subject_attributes"]?.jsonObject + assertThat(attributes).isNotNull() + assertThat(attributes?.size).isEqualTo(0) + } + + // Helper functions + + private fun createTestConfig( + apiKey: String = "test-key", + baseUrl: String = "https://fs-edge-assignment.eppo.cloud", + banditActions: Map>>? = null + ): EppoPrecomputedConfig { + return EppoPrecomputedConfig.Builder() + .apiKey(apiKey) + .subject("default-subject") + .baseUrl(baseUrl) + .apply { banditActions?.let { banditActions(it) } } + .build() + } + + private fun parseRequestBody(request: okhttp3.Request): JsonObject { + val buffer = okio.Buffer() + request.body?.writeTo(buffer) + val bodyString = buffer.readUtf8() + return json.parseToJsonElement(bodyString).jsonObject + } +} diff --git a/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/repository/FlagsRepositoryTest.kt b/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/repository/FlagsRepositoryTest.kt new file mode 100644 index 0000000..a6443e1 --- /dev/null +++ b/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/repository/FlagsRepositoryTest.kt @@ -0,0 +1,356 @@ +package cloud.eppo.kotlin.internal.repository + +import androidx.datastore.core.DataStore +import cloud.eppo.ufc.dto.VariationType +import com.google.common.truth.Truth.assertThat +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import java.time.Instant + +class FlagsRepositoryTest { + + private lateinit var mockDataStore: DataStore + private lateinit var repository: FlagsRepository + private lateinit var testScope: TestScope + + @Before + fun setup() { + mockDataStore = mockk(relaxed = true) + testScope = TestScope() + repository = FlagsRepository(mockDataStore, testScope) + } + + @Test + fun `initial state is null`() = runTest { + val state = repository.stateFlow.value + + assertThat(state).isNull() + } + + @Test + fun `isInitialized returns false when no data`() { + assertThat(repository.isInitialized()).isFalse() + } + + @Test + fun `isInitialized returns true after updateFlags`() = runTest { + val state = createTestState() + + repository.updateFlags( + flags = state.flags, + bandits = state.bandits, + salt = state.salt, + environment = state.environment, + createdAt = state.createdAt, + format = state.format + ) + + assertThat(repository.isInitialized()).isTrue() + } + + @Test + fun `updateFlags sets state atomically`() = runTest { + val testFlag = createTestFlag("test-value") + val flags = mapOf("hash1" to testFlag) + + repository.updateFlags( + flags = flags, + bandits = emptyMap(), + salt = "test-salt", + environment = "production", + createdAt = Instant.now(), + format = "PRECOMPUTED" + ) + + val state = repository.stateFlow.value + assertThat(state).isNotNull() + assertThat(state?.flags).hasSize(1) + assertThat(state?.salt).isEqualTo("test-salt") + } + + @Test + fun `getFlag returns null when not initialized`() { + val flag = repository.getFlag("any-hash") + + assertThat(flag).isNull() + } + + @Test + fun `getFlag returns flag when exists`() = runTest { + val testFlag = createTestFlag("test-value") + val flags = mapOf("hash-123" to testFlag) + + repository.updateFlags( + flags = flags, + bandits = emptyMap(), + salt = "salt", + environment = null, + createdAt = Instant.now(), + format = "PRECOMPUTED" + ) + + val flag = repository.getFlag("hash-123") + + assertThat(flag).isEqualTo(testFlag) + } + + @Test + fun `getFlag returns null when flag not found`() = runTest { + repository.updateFlags( + flags = emptyMap(), + bandits = emptyMap(), + salt = "salt", + environment = null, + createdAt = Instant.now(), + format = "PRECOMPUTED" + ) + + val flag = repository.getFlag("non-existent") + + assertThat(flag).isNull() + } + + @Test + fun `getBandit returns bandit when exists`() = runTest { + val testBandit = createTestBandit() + val bandits = mapOf("bandit-hash" to testBandit) + + repository.updateFlags( + flags = emptyMap(), + bandits = bandits, + salt = "salt", + environment = null, + createdAt = Instant.now(), + format = "PRECOMPUTED" + ) + + val bandit = repository.getBandit("bandit-hash") + + assertThat(bandit).isEqualTo(testBandit) + } + + @Test + fun `getSalt returns null when not initialized`() { + assertThat(repository.getSalt()).isNull() + } + + @Test + fun `getSalt returns salt after update`() = runTest { + repository.updateFlags( + flags = emptyMap(), + bandits = emptyMap(), + salt = "my-salt-123", + environment = null, + createdAt = Instant.now(), + format = "PRECOMPUTED" + ) + + assertThat(repository.getSalt()).isEqualTo("my-salt-123") + } + + @Test + fun `loadPersistedState loads data from DataStore`() = runTest { + val persistedState = createTestState() + coEvery { mockDataStore.data } returns flowOf(persistedState) + + repository.loadPersistedState() + + val state = repository.stateFlow.value + assertThat(state).isEqualTo(persistedState) + assertThat(repository.isInitialized()).isTrue() + } + + @Test + fun `loadPersistedState handles null data gracefully`() = runTest { + coEvery { mockDataStore.data } returns flowOf(null) + + repository.loadPersistedState() + + assertThat(repository.isInitialized()).isFalse() + } + + @Test + fun `loadPersistedState handles errors gracefully`() = runTest { + coEvery { mockDataStore.data } throws RuntimeException("Disk error") + + repository.loadPersistedState() + + // Should not throw, just log error + assertThat(repository.isInitialized()).isFalse() + } + + @Test + fun `updateFlags persists to DataStore`() = runTest { + val updateSlot = slot FlagsState?>() + coEvery { mockDataStore.updateData(capture(updateSlot)) } coAnswers { + updateSlot.captured(null) + } + + val testFlag = createTestFlag("value") + repository.updateFlags( + flags = mapOf("hash1" to testFlag), + bandits = emptyMap(), + salt = "salt", + environment = "production", + createdAt = Instant.now(), + format = "PRECOMPUTED" + ) + + // Give async persistence time to execute + testScope.testScheduler.advanceUntilIdle() + + coVerify { mockDataStore.updateData(any()) } + } + + @Test + fun `updateFlags continues even if persistence fails`() = runTest { + coEvery { mockDataStore.updateData(any()) } throws RuntimeException("Disk full") + + val testFlag = createTestFlag("value") + + // Should not throw + repository.updateFlags( + flags = mapOf("hash1" to testFlag), + bandits = emptyMap(), + salt = "salt", + environment = null, + createdAt = Instant.now(), + format = "PRECOMPUTED" + ) + + // In-memory state should still be updated + assertThat(repository.isInitialized()).isTrue() + assertThat(repository.getFlag("hash1")).isEqualTo(testFlag) + } + + @Test + fun `clear sets state to null`() = runTest { + repository.updateFlags( + flags = mapOf("hash1" to createTestFlag("value")), + bandits = emptyMap(), + salt = "salt", + environment = null, + createdAt = Instant.now(), + format = "PRECOMPUTED" + ) + + repository.clear() + + assertThat(repository.isInitialized()).isFalse() + assertThat(repository.stateFlow.value).isNull() + } + + @Test + fun `concurrent reads during update see consistent state`() = runTest { + val initialFlag = createTestFlag("initial") + repository.updateFlags( + flags = mapOf("hash1" to initialFlag), + bandits = emptyMap(), + salt = "salt1", + environment = null, + createdAt = Instant.now(), + format = "PRECOMPUTED" + ) + + val readResults = mutableListOf() + + // Launch multiple concurrent readers + repeat(100) { + launch { + readResults.add(repository.getFlag("hash1")) + } + } + + // Update while readers are active + val updatedFlag = createTestFlag("updated") + repository.updateFlags( + flags = mapOf("hash1" to updatedFlag), + bandits = emptyMap(), + salt = "salt2", + environment = null, + createdAt = Instant.now(), + format = "PRECOMPUTED" + ) + + // All reads should see either initial or updated, never partial/corrupt state + readResults.forEach { flag -> + assertThat(flag).isAnyOf(initialFlag, updatedFlag) + } + } + + @Test + fun `stateFlow emits new state on update`() = runTest { + val states = mutableListOf() + + // Collect states + val job = launch { + repository.stateFlow.collect { states.add(it) } + } + + // Initial state + assertThat(states).hasSize(1) + assertThat(states[0]).isNull() + + // Update + repository.updateFlags( + flags = mapOf("hash1" to createTestFlag("value")), + bandits = emptyMap(), + salt = "salt", + environment = null, + createdAt = Instant.now(), + format = "PRECOMPUTED" + ) + + // Should have emitted new state + assertThat(states).hasSize(2) + assertThat(states[1]).isNotNull() + assertThat(states[1]?.flags).hasSize(1) + + job.cancel() + } + + // Helper functions + + private fun createTestState(): FlagsState { + return FlagsState( + flags = mapOf("hash1" to createTestFlag("test")), + bandits = emptyMap(), + salt = "test-salt", + environment = "production", + createdAt = Instant.now(), + format = "PRECOMPUTED" + ) + } + + private fun createTestFlag(value: String): DecodedPrecomputedFlag { + return DecodedPrecomputedFlag( + allocationKey = "exp-1", + variationKey = "treatment", + variationType = VariationType.STRING, + variationValue = value, + extraLogging = null, + doLog = true + ) + } + + private fun createTestBandit(): DecodedPrecomputedBandit { + return DecodedPrecomputedBandit( + banditKey = "bandit-1", + action = "action-1", + modelVersion = "v1.0", + actionProbability = 0.75, + optimalityGap = 0.1, + actionNumericAttributes = mapOf("price" to 9.99), + actionCategoricalAttributes = mapOf("color" to "red") + ) + } +} diff --git a/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/util/Base64ExtensionsTest.kt b/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/util/Base64ExtensionsTest.kt new file mode 100644 index 0000000..5d1c08e --- /dev/null +++ b/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/util/Base64ExtensionsTest.kt @@ -0,0 +1,116 @@ +package cloud.eppo.kotlin.internal.util + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class Base64ExtensionsTest { + + @Test + fun `encodeBase64 encodes simple string correctly`() { + val input = "Hello, World!" + val encoded = input.encodeBase64() + + // Standard Base64 encoding of "Hello, World!" + assertThat(encoded.trim()).isEqualTo("SGVsbG8sIFdvcmxkIQ==") + } + + @Test + fun `decodeBase64 decodes simple string correctly`() { + val encoded = "SGVsbG8sIFdvcmxkIQ==" + val decoded = encoded.decodeBase64() + + assertThat(decoded).isEqualTo("Hello, World!") + } + + @Test + fun `encodeBase64 and decodeBase64 are inverse operations`() { + val original = "Test String with Special Characters: !@#$%^&*()" + val encoded = original.encodeBase64() + val decoded = encoded.decodeBase64() + + assertThat(decoded).isEqualTo(original) + } + + @Test + fun `decodeBase64 handles empty string`() { + val encoded = "" + val decoded = encoded.decodeBase64() + + assertThat(decoded).isEmpty() + } + + @Test + fun `encodeBase64 handles empty string`() { + val input = "" + val encoded = input.encodeBase64() + + assertThat(encoded.trim()).isEmpty() + } + + @Test + fun `decodeBase64 handles Unicode characters`() { + val original = "Hello δΈ–η•Œ 🌍" + val encoded = original.encodeBase64() + val decoded = encoded.decodeBase64() + + assertThat(decoded).isEqualTo(original) + } + + @Test + fun `decodeBase64 handles flag variation value from API`() { + // Real example: Base64 encoded "true" + val encoded = "dHJ1ZQ==" + val decoded = encoded.decodeBase64() + + assertThat(decoded).isEqualTo("true") + } + + @Test + fun `decodeBase64 handles numeric values`() { + // Base64 encoded "42" + val encoded = "NDI=" + val decoded = encoded.decodeBase64() + + assertThat(decoded).isEqualTo("42") + } + + @Test + fun `decodeBase64 handles invalid Base64 gracefully`() { + // Note: Android's Base64.decode is more lenient than Java's + // It may not throw on all invalid inputs, so we test it returns a result + val invalid = "Not valid Base64!!!" + + try { + val result = invalid.decodeBase64() + // If it doesn't throw, that's also acceptable behavior + assertThat(result).isNotNull() + } catch (e: IllegalArgumentException) { + // This is also acceptable - strict validation + assertThat(e).isInstanceOf(IllegalArgumentException::class.java) + } + } + + @Test + fun `roundtrip test with various data types`() { + val testCases = listOf( + "true", + "false", + "42", + "3.14159", + "control", + "treatment", + """{"key": "value"}""", + "exp-123-allocation", + "" + ) + + testCases.forEach { original -> + val encoded = original.encodeBase64() + val decoded = encoded.decodeBase64() + assertThat(decoded).isEqualTo(original) + } + } +} diff --git a/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/util/Md5Test.kt b/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/util/Md5Test.kt new file mode 100644 index 0000000..d77fe5c --- /dev/null +++ b/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/util/Md5Test.kt @@ -0,0 +1,138 @@ +package cloud.eppo.kotlin.internal.util + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class Md5Test { + + @Test + fun `getMD5Hash produces consistent hash`() { + val input = "test-flag-key" + val salt = "abc123" + + val hash1 = getMD5Hash(input, salt) + val hash2 = getMD5Hash(input, salt) + + assertThat(hash1).isEqualTo(hash2) + } + + @Test + fun `getMD5Hash produces different hash with different salt`() { + val input = "test-flag-key" + val salt1 = "salt1" + val salt2 = "salt2" + + val hash1 = getMD5Hash(input, salt1) + val hash2 = getMD5Hash(input, salt2) + + assertThat(hash1).isNotEqualTo(hash2) + } + + @Test + fun `getMD5Hash produces different hash with different input`() { + val input1 = "flag-key-1" + val input2 = "flag-key-2" + val salt = "abc123" + + val hash1 = getMD5Hash(input1, salt) + val hash2 = getMD5Hash(input2, salt) + + assertThat(hash1).isNotEqualTo(hash2) + } + + @Test + fun `getMD5Hash returns 32 character hex string`() { + val input = "test" + val salt = "salt" + + val hash = getMD5Hash(input, salt) + + assertThat(hash).hasLength(32) + assertThat(hash).matches("[0-9a-f]{32}") + } + + @Test + fun `getMD5Hash handles null salt`() { + val input = "test-flag-key" + val hash = getMD5Hash(input, null) + + // Should hash just the input without salt + assertThat(hash).hasLength(32) + assertThat(hash).isNotEmpty() + } + + @Test + fun `getMD5Hash handles empty string`() { + val input = "" + val salt = "salt" + + val hash = getMD5Hash(input, salt) + + assertThat(hash).hasLength(32) + assertThat(hash).matches("[0-9a-f]{32}") + // Should be deterministic + assertThat(getMD5Hash(input, salt)).isEqualTo(hash) + } + + @Test + fun `getMD5Hash handles empty salt`() { + val input = "test" + val salt = "" + + val hash = getMD5Hash(input, salt) + + assertThat(hash).hasLength(32) + assertThat(hash).matches("[0-9a-f]{32}") + // Verify it's the MD5 of just "test" (098f6bcd4621d373cade4e832627b4f6) + assertThat(hash).isEqualTo("098f6bcd4621d373cade4e832627b4f6") + } + + @Test + fun `getMD5Hash is deterministic for known input`() { + val input = "feature-flag" + val salt = "eppo-salt" + + val hash = getMD5Hash(input, salt) + + assertThat(hash).hasLength(32) + assertThat(hash).matches("[0-9a-f]{32}") + // Verify determinism + assertThat(getMD5Hash(input, salt)).isEqualTo(hash) + } + + @Test + fun `getMD5Hash handles special characters`() { + val input = "flag-key-with-special!@#$%^&*()" + val salt = "salt123" + + val hash = getMD5Hash(input, salt) + + assertThat(hash).hasLength(32) + assertThat(hash).matches("[0-9a-f]{32}") + } + + @Test + fun `getMD5Hash handles Unicode characters`() { + val input = "feature-δΈ–η•Œ" + val salt = "salt" + + val hash = getMD5Hash(input, salt) + + assertThat(hash).hasLength(32) + assertThat(hash).matches("[0-9a-f]{32}") + } + + @Test + fun `getMD5Hash is deterministic for flag key obfuscation`() { + // Simulate real usage: obfuscating flag keys + val flagKey = "show-new-checkout-flow" + val serverSalt = "abc123xyz" + + val hash1 = getMD5Hash(flagKey, serverSalt) + val hash2 = getMD5Hash(flagKey, serverSalt) + val hash3 = getMD5Hash(flagKey, serverSalt) + + assertThat(hash1).isEqualTo(hash2) + assertThat(hash2).isEqualTo(hash3) + } +} diff --git a/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/util/OkHttpExtensionsTest.kt b/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/util/OkHttpExtensionsTest.kt new file mode 100644 index 0000000..4eba95d --- /dev/null +++ b/eppo-kotlin/src/test/kotlin/cloud/eppo/kotlin/internal/util/OkHttpExtensionsTest.kt @@ -0,0 +1,162 @@ +package cloud.eppo.kotlin.internal.util + +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import org.junit.Test +import java.io.IOException + +class OkHttpExtensionsTest { + + @Test + fun `await returns response on successful call`() = runTest { + val mockCall = mockk(relaxed = true) + val callbackSlot = slot() + val expectedResponse = createMockResponse(code = 200, message = "OK") + + every { mockCall.enqueue(capture(callbackSlot)) } answers { + callbackSlot.captured.onResponse(mockCall, expectedResponse) + } + + val response = mockCall.await() + + assertThat(response).isEqualTo(expectedResponse) + assertThat(response.code).isEqualTo(200) + } + + @Test(expected = IOException::class) + fun `await throws IOException on network failure`() = runTest { + val mockCall = mockk(relaxed = true) + val callbackSlot = slot() + val networkError = IOException("Network unreachable") + + every { mockCall.enqueue(capture(callbackSlot)) } answers { + callbackSlot.captured.onFailure(mockCall, networkError) + } + + mockCall.await() + } + + @Test + fun `await cancels HTTP call when coroutine is cancelled`() = runTest { + val mockCall = mockk(relaxed = true) + val callbackSlot = slot() + var enqueued = false + + every { mockCall.enqueue(capture(callbackSlot)) } answers { + enqueued = true + // Don't call callback - simulate slow network + } + + val job = launch { + mockCall.await() + } + + // Wait for enqueue + while (!enqueued) { /* wait */ } + + // Cancel the coroutine before response arrives + job.cancel() + job.join() + + // Verify cancel was called on the HTTP call + verify { mockCall.cancel() } + } + + @Test + fun `await handles successful response with body`() = runTest { + val mockCall = mockk(relaxed = true) + val callbackSlot = slot() + val response = createMockResponse(code = 200, message = "OK") + + every { mockCall.enqueue(capture(callbackSlot)) } answers { + callbackSlot.captured.onResponse(mockCall, response) + } + + val result = mockCall.await() + + assertThat(result.isSuccessful).isTrue() + assertThat(result.code).isEqualTo(200) + } + + @Test + fun `await handles HTTP error responses`() = runTest { + val mockCall = mockk(relaxed = true) + val callbackSlot = slot() + val errorResponse = createMockResponse(code = 404, message = "Not Found") + + every { mockCall.enqueue(capture(callbackSlot)) } answers { + callbackSlot.captured.onResponse(mockCall, errorResponse) + } + + val result = mockCall.await() + + assertThat(result.code).isEqualTo(404) + assertThat(result.isSuccessful).isFalse() + } + + @Test(expected = IOException::class) + fun `await propagates timeout exception`() = runTest { + val mockCall = mockk(relaxed = true) + val callbackSlot = slot() + val timeoutError = IOException("timeout") + + every { mockCall.enqueue(capture(callbackSlot)) } answers { + callbackSlot.captured.onFailure(mockCall, timeoutError) + } + + mockCall.await() + } + + @Test + fun `await handles response after cancellation gracefully`() = runTest { + val mockCall = mockk(relaxed = true) + val callbackSlot = slot() + var enqueued = false + + every { mockCall.enqueue(capture(callbackSlot)) } answers { + enqueued = true + } + + val job = launch { + mockCall.await() + } + + // Wait for enqueue + while (!enqueued) { /* wait */ } + + // Cancel before callback fires + job.cancel() + job.join() + + // Fire callback after cancellation (simulating race condition) + // This should be handled gracefully by the continuation + val response = createMockResponse(code = 200, message = "OK") + callbackSlot.captured.onResponse(mockCall, response) + + // No assertion needed - just verify no crash + // The continuation will ignore the response since it's already cancelled + verify { mockCall.cancel() } + } + + // Helper function to create mock responses + private fun createMockResponse(code: Int, message: String): Response { + return Response.Builder() + .request(Request.Builder().url("https://example.com").build()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message(message) + .build() + } +}