diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 7ebcc312..056d235b 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -28,6 +28,8 @@ public final class org/dexpace/sdk/core/config/Configuration { public static final field LOG_LEVEL Ljava/lang/String; public static final field MAX_RETRY_ATTEMPTS Ljava/lang/String; public static final field NO_PROXY Ljava/lang/String; + public static final fun builder ()Lorg/dexpace/sdk/core/config/ConfigurationBuilder; + public final fun derive (Ljava/util/function/Consumer;)Lorg/dexpace/sdk/core/config/Configuration; public final fun get (Ljava/lang/String;)Ljava/lang/String; public final fun get (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; public static synthetic fun get$default (Lorg/dexpace/sdk/core/config/Configuration;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/String; @@ -36,20 +38,25 @@ public final class org/dexpace/sdk/core/config/Configuration { public static final fun getGlobalConfiguration ()Lorg/dexpace/sdk/core/config/Configuration; public final fun getInt (Ljava/lang/String;I)I public final fun getProperty (Ljava/lang/String;)Ljava/lang/String; + public final fun newBuilder ()Lorg/dexpace/sdk/core/config/ConfigurationBuilder; public static final fun setGlobalConfiguration (Lorg/dexpace/sdk/core/config/Configuration;)V } public final class org/dexpace/sdk/core/config/Configuration$Companion { + public final fun builder ()Lorg/dexpace/sdk/core/config/ConfigurationBuilder; public final fun getGlobalConfiguration ()Lorg/dexpace/sdk/core/config/Configuration; public final fun setGlobalConfiguration (Lorg/dexpace/sdk/core/config/Configuration;)V } -public final class org/dexpace/sdk/core/config/ConfigurationBuilder { +public final class org/dexpace/sdk/core/config/ConfigurationBuilder : org/dexpace/sdk/core/generics/Builder { public fun ()V - public final fun build ()Lorg/dexpace/sdk/core/config/Configuration; + public fun (Lorg/dexpace/sdk/core/config/Configuration;)V + public synthetic fun build ()Ljava/lang/Object; + public fun build ()Lorg/dexpace/sdk/core/config/Configuration; public final fun envSource (Ljava/util/function/Function;)Lorg/dexpace/sdk/core/config/ConfigurationBuilder; public final fun propsSource (Ljava/util/function/Function;)Lorg/dexpace/sdk/core/config/ConfigurationBuilder; public final fun put (Ljava/lang/String;Ljava/lang/String;)Lorg/dexpace/sdk/core/config/ConfigurationBuilder; + public final fun remove (Ljava/lang/String;)Lorg/dexpace/sdk/core/config/ConfigurationBuilder; } public abstract interface class org/dexpace/sdk/core/generics/Builder { diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Configuration.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Configuration.kt index 321de182..7b6598bc 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Configuration.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/Configuration.kt @@ -9,6 +9,7 @@ package org.dexpace.sdk.core.config import java.time.Duration import java.util.Locale +import java.util.function.Consumer import java.util.function.Function /** @@ -21,7 +22,25 @@ import java.util.function.Function * Typed accessors (`getInt`, `getBoolean`, `getDuration`) return the provided default on parse failures — * configuration issues never throw at the lookup site. * - * Constructed via [ConfigurationBuilder]. + * Constructed via [ConfigurationBuilder]; derive a reconfigured copy of an existing instance with + * [derive] or [newBuilder]. + * + * ## Deriving a reconfigured copy (copy-on-write) + * [derive] returns a **new** immutable [Configuration] with a mutator applied on top of this + * one, leaving the receiver untouched: + * + * ```java + * Configuration base = Configuration.builder().put("MAX_RETRY_ATTEMPTS", "3").build(); + * Configuration derived = base.derive(b -> b.put("LOG_LEVEL", "DEBUG")); + * // base is unchanged; derived carries both overrides. + * ``` + * + * The derivation is copy-on-write in the value sense: the override map is copied so the original and + * the derived instance never alias the same mutable state, while the [envSource]/[propsSource] + * lookup functions are shared by reference (they are pure read seams and are never mutated). A + * mutator that touches no override and replaces no source produces an independent instance equal in + * behaviour to the original. [newBuilder] exposes the same prefilled builder for callers that prefer + * to thread it through other builder-folding code before [ConfigurationBuilder.build]. * * ## Thread-safety * Instances are immutable once built (the override map is copied) and safe to share across threads. @@ -29,10 +48,38 @@ import java.util.function.Function * configuration under last-write-wins semantics. */ public class Configuration internal constructor( - private val overrides: Map, - private val envSource: Function = Function { name -> System.getenv(name) }, - private val propsSource: Function = Function { name -> System.getProperty(name) }, + @get:JvmSynthetic + internal val overrides: Map, + @get:JvmSynthetic + internal val envSource: Function = Function { name -> System.getenv(name) }, + @get:JvmSynthetic + internal val propsSource: Function = Function { name -> System.getProperty(name) }, ) { + /** + * Returns a fresh [ConfigurationBuilder] preloaded with this instance's overrides and lookup + * sources. Mutating the returned builder never affects this [Configuration]; the override map is + * copied up front. Use this when you want to thread the builder through other configuration code + * before calling [ConfigurationBuilder.build]; prefer [derive] for the common + * derive-in-one-call case. + */ + public fun newBuilder(): ConfigurationBuilder = ConfigurationBuilder(this) + + /** + * Derive a new immutable [Configuration] by applying [mutator] to a builder prefilled from this + * instance, then building it. This [Configuration] is left unchanged (copy-on-write): the + * override map is copied before [mutator] runs, so overrides added, replaced, or removed inside + * [mutator] never leak back into the receiver. The env/property lookup seams are inherited by + * reference. + * + * Kotlin's compiler-generated non-null parameter check raises `NullPointerException` when a Java + * caller passes `null` for [mutator], so no explicit guard is needed here. + */ + public fun derive(mutator: Consumer): Configuration { + val builder = newBuilder() + mutator.accept(builder) + return builder.build() + } + /** * Look up a configuration value by [name]. * @@ -95,6 +142,14 @@ public class Configuration internal constructor( ): Duration = get(name)?.let { parseDuration(it) } ?: default public companion object { + /** + * Returns a fresh empty [ConfigurationBuilder]. Java-friendly entry point matching the + * `builder()` idiom every other SDK model exposes; build from scratch with this, or derive a + * reconfigured copy of an existing instance with [newBuilder] / [derive]. + */ + @JvmStatic + public fun builder(): ConfigurationBuilder = ConfigurationBuilder() + // Well-known keys. `const val` so callers reference them as `Configuration.MAX_RETRY_ATTEMPTS` // from both Kotlin and Java without going through `Companion`. diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/ConfigurationBuilder.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/ConfigurationBuilder.kt index 7050ba18..32952df1 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/ConfigurationBuilder.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/config/ConfigurationBuilder.kt @@ -7,6 +7,7 @@ package org.dexpace.sdk.core.config +import org.dexpace.sdk.core.generics.Builder import java.util.function.Function /** @@ -14,15 +15,33 @@ import java.util.function.Function * system properties), and [envSource] / [propsSource] as test seams to substitute the env / property * lookups with hermetic functions. * + * Implements the generic [Builder] contract so it can be folded by builder-style configuration code. + * Construct empty for a configuration built from scratch, or via [Configuration.newBuilder] / + * [Configuration.derive] to derive a reconfigured copy of an existing [Configuration]. + * * ## Thread-safety * Builders are *not* thread-safe — construct, configure, and [build] from a single thread. The * resulting [Configuration] is immutable and safe to share. */ -public class ConfigurationBuilder { +public class ConfigurationBuilder : Builder { private val overrides = mutableMapOf() private var envSource: Function = Function { name -> System.getenv(name) } private var propsSource: Function = Function { name -> System.getProperty(name) } + /** Creates an empty builder with the default env / system-property lookup sources. */ + public constructor() + + /** + * Creates a builder preloaded with [source]'s overrides and lookup sources. The override map is + * copied, so mutating this builder never affects [source]. Used by [Configuration.newBuilder] and + * [Configuration.derive]. + */ + public constructor(source: Configuration) { + overrides.putAll(source.overrides) + envSource = source.envSource + propsSource = source.propsSource + } + /** * Register an explicit override. Overrides win over every other layer. Kotlin's * compiler-generated non-null parameter check raises `NullPointerException` when a Java @@ -36,6 +55,22 @@ public class ConfigurationBuilder { overrides[name] = value } + /** + * Remove the explicit override for [name], if one is set. This drops only the override layer: + * a later [Configuration.get] for [name] falls through to the environment-variable and + * system-property seams (and finally the caller's default) exactly as if the override had never + * been registered — it does **not** force the key to resolve to `null`. Removing a key that + * carries no override is a no-op. As the inverse of [put], this is what lets + * [Configuration.derive] un-pin an override inherited from the source instance. + * + * Kotlin's compiler-generated non-null parameter check raises `NullPointerException` when a + * Java caller passes `null` for [name], so no explicit guard is needed here. + */ + public fun remove(name: String): ConfigurationBuilder = + apply { + overrides.remove(name) + } + /** Test seam: override the environment-variable source. */ public fun envSource(source: Function): ConfigurationBuilder = apply { envSource = source } @@ -46,5 +81,5 @@ public class ConfigurationBuilder { * Materialize the immutable [Configuration]. The current override map is defensively copied so * subsequent [put] calls on this builder do not mutate the returned configuration. */ - public fun build(): Configuration = Configuration(overrides.toMap(), envSource, propsSource) + override fun build(): Configuration = Configuration(overrides.toMap(), envSource, propsSource) } diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/ConfigurationTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/ConfigurationTest.kt index a86efd5f..ccf0c0b6 100644 --- a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/ConfigurationTest.kt +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/config/ConfigurationTest.kt @@ -8,6 +8,7 @@ package org.dexpace.sdk.core.config import java.time.Duration +import java.util.function.Function import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -330,6 +331,17 @@ class ConfigurationTest { // ----- ConfigurationBuilder behaviors ----- + @Test + fun `builder factory returns a fresh empty builder each call`() { + // Java-friendly factory: equivalent to `new ConfigurationBuilder()`, distinct each call. + val first = Configuration.builder() + val second = Configuration.builder() + assertFalse(first === second) + // It starts empty: with the seams pinned absent, an unset key resolves to null. + val cfg = Configuration.builder().envSource { null }.propsSource { null }.build() + assertNull(cfg.get("MAX_RETRY_ATTEMPTS")) + } + @Test fun `builder put null name throws NullPointerException`() { val method = @@ -360,6 +372,82 @@ class ConfigurationTest { assertTrue(ex.targetException is NullPointerException, "Expected NPE, got ${ex.targetException}") } + @Test + fun `builder remove deletes an existing override`() { + // Seams pinned absent, so a removed override leaves nothing to fall back to: get -> null. + val cfg = + ConfigurationBuilder() + .put("MAX_RETRY_ATTEMPTS", "3") + .remove("MAX_RETRY_ATTEMPTS") + .envSource { null } + .propsSource { null } + .build() + assertNull(cfg.get("MAX_RETRY_ATTEMPTS")) + } + + @Test + fun `builder remove of an absent key is a no-op`() { + // Removing a key that was never overridden must not throw and must not disturb other keys. + val cfg = + ConfigurationBuilder() + .put("A", "1") + .remove("NEVER_SET") + .envSource { null } + .propsSource { null } + .build() + assertEquals("1", cfg.get("A")) + assertNull(cfg.get("NEVER_SET")) + } + + @Test + fun `builder remove drops only the override layer - env seam still resolves`() { + // remove un-pins the explicit override; it does NOT suppress the inherited env seam for + // that key. The key must still resolve via env after the override is gone. + val cfg = + ConfigurationBuilder() + .put("MAX_RETRY_ATTEMPTS", "3") + .envSource { name -> if (name == "MAX_RETRY_ATTEMPTS") "5" else null } + .propsSource { null } + .remove("MAX_RETRY_ATTEMPTS") + .build() + assertEquals("5", cfg.get("MAX_RETRY_ATTEMPTS")) + } + + @Test + fun `builder remove drops only the override layer - property seam still resolves`() { + // Mirror of the env fall-through case, exercising the distinct system-property branch of + // get(): env is pinned absent, so the removed override falls through to the envToProp- + // normalized property lookup (MAX_RETRY_ATTEMPTS -> max.retry.attempts). + val cfg = + ConfigurationBuilder() + .put("MAX_RETRY_ATTEMPTS", "3") + .envSource { null } + .propsSource { name -> if (name == "max.retry.attempts") "9" else null } + .remove("MAX_RETRY_ATTEMPTS") + .build() + assertEquals("9", cfg.get("MAX_RETRY_ATTEMPTS")) + } + + @Test + fun `builder remove returns same instance for chaining`() { + val b = ConfigurationBuilder() + assertSame(b, b.remove("anything")) + } + + @Test + fun `builder remove null name throws NullPointerException`() { + val method = + ConfigurationBuilder::class.java.getMethod( + "remove", + String::class.java, + ) + val ex = + assertFailsWith { + method.invoke(ConfigurationBuilder(), null as String?) + } + assertTrue(ex.targetException is NullPointerException, "Expected NPE, got ${ex.targetException}") + } + @Test fun `builder test seams isolate from process env and sysprops`() { // Hermetic: neither the real env nor real sysprops should be touched. @@ -529,4 +617,253 @@ class ConfigurationTest { method.invoke(null, custom) assertEquals("hit", Configuration.getGlobalConfiguration().get("BRIDGE")) } + + // ----- derive / newBuilder (copy-on-write derivation) ----- + + @Test + fun `derive adds a new override on the derived configuration`() { + val base = ConfigurationBuilder().put("MAX_RETRY_ATTEMPTS", "3").build() + val derived = base.derive { it.put("LOG_LEVEL", "DEBUG") } + assertEquals("3", derived.get("MAX_RETRY_ATTEMPTS")) + assertEquals("DEBUG", derived.get("LOG_LEVEL")) + } + + @Test + fun `derive leaves the original configuration unchanged`() { + val base = ConfigurationBuilder().put("MAX_RETRY_ATTEMPTS", "3").build() + val derived = base.derive { it.put("LOG_LEVEL", "DEBUG") } + // The override added to the derived copy must not leak back into the receiver. + assertNull(base.get("LOG_LEVEL")) + assertEquals("3", base.get("MAX_RETRY_ATTEMPTS")) + // The two instances are distinct objects. + assertFalse(base === derived) + } + + @Test + fun `derive can override an existing key without mutating the original`() { + val base = ConfigurationBuilder().put("MAX_RETRY_ATTEMPTS", "3").build() + val derived = base.derive { it.put("MAX_RETRY_ATTEMPTS", "9") } + assertEquals("9", derived.get("MAX_RETRY_ATTEMPTS")) + assertEquals("3", base.get("MAX_RETRY_ATTEMPTS")) + } + + @Test + fun `derive inherits the env and property lookup seams`() { + val base = + ConfigurationBuilder() + .envSource { name -> if (name == "MAX_RETRY_ATTEMPTS") "5" else null } + .propsSource { name -> if (name == "log.level") "INFO" else null } + .build() + val derived = base.derive { it.put("LOG_LEVEL", "DEBUG") } + // Inherited env seam still resolves on the derived copy. + assertEquals("5", derived.get("MAX_RETRY_ATTEMPTS")) + // Explicit override on the derived copy wins over the inherited property seam. + assertEquals("DEBUG", derived.get("LOG_LEVEL")) + // The base, queried for the same key, still falls through to the property seam. + assertEquals("INFO", base.get("LOG_LEVEL")) + } + + @Test + fun `derive override wins over the inherited env seam on the derived copy`() { + val base = + ConfigurationBuilder() + .envSource { name -> if (name == "MAX_RETRY_ATTEMPTS") "5" else null } + .propsSource { null } + .build() + // The inherited env seam supplies "5"; the derived copy adds an explicit override for the + // same key. Override -> env precedence must hold across derivation. + val derived = base.derive { it.put("MAX_RETRY_ATTEMPTS", "9") } + assertEquals("9", derived.get("MAX_RETRY_ATTEMPTS")) + // The base, queried for the same key, still falls through to the inherited env seam. + assertEquals("5", base.get("MAX_RETRY_ATTEMPTS")) + } + + @Test + fun `derive inherits the env and property seams by reference, not by copy`() { + val env = Function { null } + val props = Function { null } + val base = ConfigurationBuilder().envSource(env).propsSource(props).build() + val derived = base.derive { it.put("LOG_LEVEL", "DEBUG") } + // The pure read seams are inherited by reference; the override map is the only copied state. + assertSame(base.envSource, derived.envSource) + assertSame(base.propsSource, derived.propsSource) + } + + @Test + fun `derive replacing a source detaches only the derived copy from the shared seam`() { + val baseEnv = Function { null } + val newEnv = Function { name -> if (name == "MAX_RETRY_ATTEMPTS") "9" else null } + val base = ConfigurationBuilder().envSource(baseEnv).propsSource { null }.build() + val derived = base.derive { it.envSource(newEnv) } + // The derived copy swaps in the new seam... + assertSame(newEnv, derived.envSource) + // ...while the base keeps its original reference (copy-on-write, not aliased mutation). + assertSame(baseEnv, base.envSource) + // Behaviour follows the rebinding: derived resolves via newEnv, base does not. + assertEquals("9", derived.get("MAX_RETRY_ATTEMPTS")) + assertNull(base.get("MAX_RETRY_ATTEMPTS")) + } + + @Test + fun `derive preserves the empty-env skip-to-sysprop semantics on the derived copy`() { + val base = + ConfigurationBuilder() + .envSource { "" } // present-but-empty: must be treated as absent + .propsSource { name -> if (name == "max.retry.attempts") "7" else null } + .build() + val derived = base.derive { it.put("LOG_LEVEL", "DEBUG") } + // The empty-env skip is inherited: the derived copy still falls through to the sysprop seam. + assertEquals("7", derived.get("MAX_RETRY_ATTEMPTS")) + assertEquals("DEBUG", derived.get("LOG_LEVEL")) + } + + @Test + fun `derive on a configuration with no overrides yields an override-only copy`() { + // Empty override map + pinned-absent seams: exercises the prefilled constructor's + // putAll(emptyMap) path hermetically, without touching the real process environment. + val base = ConfigurationBuilder().envSource { null }.propsSource { null }.build() + val derived = base.derive { it.put("LOG_LEVEL", "DEBUG") } + assertEquals("DEBUG", derived.get("LOG_LEVEL")) + assertNull(base.get("LOG_LEVEL")) + assertFalse(base === derived) + } + + @Test + fun `derive chained twice accumulates overrides and leaves each level independent`() { + val base = + ConfigurationBuilder() + .put("A", "1") + .envSource { null } + .propsSource { null } + .build() + val d1 = base.derive { it.put("B", "2") } + val d2 = + d1.derive { + it.put("C", "3") + it.put("A", "9") + } + // d2 accumulates across both hops, with its own override shadowing the inherited A. + assertEquals("9", d2.get("A")) + assertEquals("2", d2.get("B")) + assertEquals("3", d2.get("C")) + // The intermediate d1 is unaffected by d2's later additions/overrides. + assertEquals("1", d1.get("A")) + assertEquals("2", d1.get("B")) + assertNull(d1.get("C")) + // The base never sees anything added downstream. + assertEquals("1", base.get("A")) + assertNull(base.get("B")) + assertNull(base.get("C")) + } + + @Test + fun `derive with an empty mutator yields an equivalent independent configuration`() { + val base = ConfigurationBuilder().put("MAX_RETRY_ATTEMPTS", "3").build() + val derived = base.derive { /* no-op */ } + assertFalse(base === derived) + assertEquals("3", derived.get("MAX_RETRY_ATTEMPTS")) + } + + @Test + fun `derive null mutator throws NullPointerException`() { + // Force `null` past the Kotlin non-null param via reflection — mirrors a Java caller + // who hands in null. The compiler-generated non-null check must trigger, as the KDoc claims. + val method = + Configuration::class.java.getMethod( + "derive", + java.util.function.Consumer::class.java, + ) + val base = ConfigurationBuilder().put("MAX_RETRY_ATTEMPTS", "3").build() + val ex = + assertFailsWith { + method.invoke(base, null as java.util.function.Consumer?) + } + assertTrue(ex.targetException is NullPointerException, "Expected NPE, got ${ex.targetException}") + } + + @Test + fun `newBuilder prefills overrides and sources and is independent of the source`() { + val base = ConfigurationBuilder().put("MAX_RETRY_ATTEMPTS", "3").build() + val builder = base.newBuilder() + builder.put("LOG_LEVEL", "DEBUG") + val derived = builder.build() + assertEquals("3", derived.get("MAX_RETRY_ATTEMPTS")) + assertEquals("DEBUG", derived.get("LOG_LEVEL")) + // Mutating the builder after the fact never affects the already-derived configuration + // nor the original. + builder.put("EXTRA", "x") + assertNull(derived.get("EXTRA")) + assertNull(base.get("EXTRA")) + assertNull(base.get("LOG_LEVEL")) + } + + @Test + fun `prefilled builder constructor copies the override map defensively`() { + val base = ConfigurationBuilder().put("MAX_RETRY_ATTEMPTS", "3").build() + val first = base.newBuilder().put("A", "1").build() + val second = base.newBuilder().put("B", "2").build() + // Two independent derivations from the same base do not see each other's overrides. + assertEquals("1", first.get("A")) + assertNull(first.get("B")) + assertEquals("2", second.get("B")) + assertNull(second.get("A")) + } + + @Test + fun `derive removing an inherited override un-pins it without mutating the original`() { + val base = + ConfigurationBuilder() + .put("LOG_LEVEL", "DEBUG") + .envSource { null } + .propsSource { null } + .build() + val derived = base.derive { it.remove("LOG_LEVEL") } + // The derived copy no longer carries the override and, with seams pinned absent, resolves null. + assertNull(derived.get("LOG_LEVEL")) + // The base keeps its override: removal on the derived copy must not leak back. + assertEquals("DEBUG", base.get("LOG_LEVEL")) + } + + @Test + fun `derive remove drops only the override and falls back to the inherited env seam`() { + // The crux of remove's contract across derivation: removing an inherited override un-pins + // the explicit value but does NOT suppress the inherited env seam for that key. The base + // forces "3"; the derived copy removes the override and so falls back to the env seam's "5". + val base = + ConfigurationBuilder() + .put("MAX_RETRY_ATTEMPTS", "3") + .envSource { name -> if (name == "MAX_RETRY_ATTEMPTS") "5" else null } + .propsSource { null } + .build() + val derived = base.derive { it.remove("MAX_RETRY_ATTEMPTS") } + assertEquals("5", derived.get("MAX_RETRY_ATTEMPTS")) + // The base still resolves the explicit override it was built with. + assertEquals("3", base.get("MAX_RETRY_ATTEMPTS")) + } + + @Test + fun `derive remove drops only the override and falls back to the inherited property seam`() { + // Property-seam mirror of the env fall-through case: env pinned absent, so removing the + // inherited override on the derived copy lets the key resolve via the inherited, + // envToProp-normalized system-property seam (MAX_RETRY_ATTEMPTS -> max.retry.attempts). + val base = + ConfigurationBuilder() + .put("MAX_RETRY_ATTEMPTS", "3") + .envSource { null } + .propsSource { name -> if (name == "max.retry.attempts") "9" else null } + .build() + val derived = base.derive { it.remove("MAX_RETRY_ATTEMPTS") } + assertEquals("9", derived.get("MAX_RETRY_ATTEMPTS")) + // The base still resolves the explicit override it was built with. + assertEquals("3", base.get("MAX_RETRY_ATTEMPTS")) + } + + @Test + fun `derive remove of an absent override is a harmless no-op`() { + val base = ConfigurationBuilder().put("A", "1").envSource { null }.propsSource { null }.build() + val derived = base.derive { it.remove("NEVER_SET") } + assertEquals("1", derived.get("A")) + assertNull(derived.get("NEVER_SET")) + assertFalse(base === derived) + } } diff --git a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/AsyncResponseBridge.kt b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/AsyncResponseBridge.kt index cb71bb37..4b7e40c2 100644 --- a/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/AsyncResponseBridge.kt +++ b/sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/AsyncResponseBridge.kt @@ -66,7 +66,7 @@ internal fun bridgeAsyncResponse( private fun closeQuietly(response: SdkResponse) { try { response.close() - } catch (ignored: Exception) { + } catch (_: Exception) { // Intentionally ignored: the response is already being discarded. } }