Skip to content

Commit 565b450

Browse files
committed
test: add isolated API instance tests
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
1 parent 9e43d34 commit 565b450

2 files changed

Lines changed: 372 additions & 3 deletions

File tree

kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureInstance.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,11 @@ open class OpenFeatureInstance {
124124
_statusFlow.emit(OpenFeatureStatus.NotReady)
125125

126126
// Shutdown the previous provider outside the mutex
127-
tryWithStatusEmitErrorHandling {
128-
untrackProviderBinding(oldProvider)
129-
oldProvider.shutdown()
127+
if (oldProvider !== provider) {
128+
tryWithStatusEmitErrorHandling {
129+
untrackProviderBinding(oldProvider)
130+
oldProvider.shutdown()
131+
}
130132
}
131133

132134
// Initialize the new provider
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
package dev.openfeature.kotlin.sdk
2+
3+
import dev.openfeature.kotlin.sdk.helpers.DoSomethingProvider
4+
import dev.openfeature.kotlin.sdk.helpers.GenericSpyHookMock
5+
import dev.openfeature.kotlin.sdk.helpers.SpyProvider
6+
import kotlinx.coroutines.ExperimentalCoroutinesApi
7+
import kotlinx.coroutines.test.StandardTestDispatcher
8+
import kotlinx.coroutines.test.advanceUntilIdle
9+
import kotlinx.coroutines.test.runTest
10+
import kotlin.test.AfterTest
11+
import kotlin.test.Test
12+
import kotlin.test.assertEquals
13+
import kotlin.test.assertFalse
14+
import kotlin.test.assertNotSame
15+
import kotlin.test.assertNull
16+
import kotlin.test.assertTrue
17+
18+
class IsolatedAPIInstanceTests {
19+
20+
private val instances = mutableListOf<OpenFeatureInstance>()
21+
22+
private fun createInstance(): OpenFeatureInstance {
23+
val instance = OpenFeatureAPI.createInstance()
24+
instances.add(instance)
25+
return instance
26+
}
27+
28+
@AfterTest
29+
fun tearDown() = runTest {
30+
OpenFeatureAPI.shutdown()
31+
instances.forEach { it.shutdown() }
32+
}
33+
34+
@Test
35+
fun testCreateInstanceReturnsNewIndependentInstance() {
36+
val instance1 = createInstance()
37+
val instance2 = createInstance()
38+
assertNotSame(instance1, instance2)
39+
assertNotSame(instance1, OpenFeatureAPI)
40+
}
41+
42+
@Test
43+
fun testIsolatedInstanceHasOwnProvider() = runTest {
44+
val provider1 = DoSomethingProvider(
45+
metadata = object : ProviderMetadata {
46+
override val name: String = "Provider 1"
47+
}
48+
)
49+
val provider2 = DoSomethingProvider(
50+
metadata = object : ProviderMetadata {
51+
override val name: String = "Provider 2"
52+
}
53+
)
54+
55+
val instance = createInstance()
56+
OpenFeatureAPI.setProviderAndWait(provider1, ImmutableContext())
57+
instance.setProviderAndWait(provider2, ImmutableContext())
58+
59+
assertEquals("Provider 1", OpenFeatureAPI.getProvider().metadata.name)
60+
assertEquals("Provider 2", instance.getProvider().metadata.name)
61+
}
62+
63+
@Test
64+
fun testIsolatedInstanceHasOwnEvaluationContext() = runTest {
65+
val instance = createInstance()
66+
67+
OpenFeatureAPI.setProviderAndWait(NoOpProvider())
68+
instance.setProviderAndWait(NoOpProvider())
69+
70+
val ctx1 = ImmutableContext(targetingKey = "singleton-key")
71+
val ctx2 = ImmutableContext(targetingKey = "instance-key")
72+
73+
OpenFeatureAPI.setEvaluationContextAndWait(ctx1)
74+
instance.setEvaluationContextAndWait(ctx2)
75+
76+
assertEquals("singleton-key", OpenFeatureAPI.getEvaluationContext()?.getTargetingKey())
77+
assertEquals("instance-key", instance.getEvaluationContext()?.getTargetingKey())
78+
}
79+
80+
@Test
81+
fun testIsolatedInstanceHasOwnHooks() = runTest {
82+
val instance = createInstance()
83+
84+
val hook1 = GenericSpyHookMock()
85+
val hook2 = GenericSpyHookMock()
86+
87+
OpenFeatureAPI.addHooks(listOf(hook1))
88+
instance.addHooks(listOf(hook2))
89+
90+
assertEquals(1, OpenFeatureAPI.hooks.size)
91+
assertEquals(1, instance.hooks.size)
92+
assertTrue(OpenFeatureAPI.hooks.contains(hook1))
93+
assertFalse(OpenFeatureAPI.hooks.contains(hook2))
94+
assertTrue(instance.hooks.contains(hook2))
95+
assertFalse(instance.hooks.contains(hook1))
96+
}
97+
98+
@Test
99+
fun testIsolatedInstanceHasOwnStatus() = runTest {
100+
val instance = createInstance()
101+
102+
instance.setProviderAndWait(DoSomethingProvider(), ImmutableContext())
103+
104+
assertEquals(OpenFeatureStatus.NotReady, OpenFeatureAPI.getStatus())
105+
assertEquals(OpenFeatureStatus.Ready, instance.getStatus())
106+
}
107+
108+
@Test
109+
fun testIsolatedInstanceClientEvaluatesIndependently() = runTest {
110+
val instance = createInstance()
111+
instance.setProviderAndWait(DoSomethingProvider(), ImmutableContext())
112+
113+
val client = instance.getClient()
114+
// DoSomethingProvider returns !defaultValue for booleans
115+
val result = client.getBooleanValue("flag", false)
116+
assertTrue(result)
117+
}
118+
119+
@Test
120+
fun testSingletonStillWorksAfterCreatingInstances() = runTest {
121+
createInstance()
122+
123+
OpenFeatureAPI.setProviderAndWait(DoSomethingProvider(), ImmutableContext())
124+
val client = OpenFeatureAPI.getClient()
125+
val result = client.getBooleanValue("flag", false)
126+
assertTrue(result)
127+
}
128+
129+
@Test
130+
fun testShutdownInstanceDoesNotAffectSingleton() = runTest {
131+
val instance = createInstance()
132+
133+
OpenFeatureAPI.setProviderAndWait(DoSomethingProvider(), ImmutableContext())
134+
instance.setProviderAndWait(DoSomethingProvider(), ImmutableContext())
135+
136+
instance.shutdown()
137+
138+
assertEquals(OpenFeatureStatus.Ready, OpenFeatureAPI.getStatus())
139+
assertEquals(OpenFeatureStatus.NotReady, instance.getStatus())
140+
}
141+
142+
@Test
143+
fun testShutdownSingletonDoesNotAffectInstance() = runTest {
144+
val instance = createInstance()
145+
146+
OpenFeatureAPI.setProviderAndWait(DoSomethingProvider(), ImmutableContext())
147+
instance.setProviderAndWait(DoSomethingProvider(), ImmutableContext())
148+
149+
OpenFeatureAPI.shutdown()
150+
151+
assertEquals(OpenFeatureStatus.NotReady, OpenFeatureAPI.getStatus())
152+
assertEquals(OpenFeatureStatus.Ready, instance.getStatus())
153+
}
154+
155+
@Test
156+
fun testProviderCannotBeBoundToMultipleInstancesSync() = runTest {
157+
val instance = createInstance()
158+
val sharedProvider = DoSomethingProvider()
159+
160+
OpenFeatureAPI.setProviderAndWait(sharedProvider, ImmutableContext())
161+
instance.setProviderAndWait(sharedProvider, ImmutableContext())
162+
163+
assertTrue(instance.getStatus() is OpenFeatureStatus.Error)
164+
}
165+
166+
@OptIn(ExperimentalCoroutinesApi::class)
167+
@Test
168+
fun testProviderCannotBeBoundToMultipleInstancesAsync() = runTest {
169+
val testDispatcher = StandardTestDispatcher(testScheduler)
170+
val instance = createInstance()
171+
val sharedProvider = DoSomethingProvider()
172+
173+
OpenFeatureAPI.setProviderAndWait(sharedProvider, ImmutableContext())
174+
instance.setProvider(sharedProvider, dispatcher = testDispatcher)
175+
advanceUntilIdle()
176+
177+
assertTrue(instance.getStatus() is OpenFeatureStatus.Error)
178+
}
179+
180+
@Test
181+
fun testProviderCanBeReusedAfterShutdown() = runTest {
182+
val instance = createInstance()
183+
val provider = DoSomethingProvider()
184+
185+
instance.setProviderAndWait(provider, ImmutableContext())
186+
instance.shutdown()
187+
188+
// Provider is now unbound, can be used by another instance
189+
OpenFeatureAPI.setProviderAndWait(provider, ImmutableContext())
190+
assertEquals(OpenFeatureStatus.Ready, OpenFeatureAPI.getStatus())
191+
}
192+
193+
@Test
194+
fun testProviderCanBeReusedAfterClearProvider() = runTest {
195+
val instance = createInstance()
196+
val provider = DoSomethingProvider()
197+
198+
instance.setProviderAndWait(provider, ImmutableContext())
199+
instance.clearProvider()
200+
201+
OpenFeatureAPI.setProviderAndWait(provider, ImmutableContext())
202+
assertEquals(OpenFeatureStatus.Ready, OpenFeatureAPI.getStatus())
203+
}
204+
205+
@Test
206+
fun testMultipleIsolatedInstancesAreIndependent() = runTest {
207+
val instance1 = createInstance()
208+
val instance2 = createInstance()
209+
210+
val provider1 = DoSomethingProvider(
211+
metadata = object : ProviderMetadata {
212+
override val name: String = "Instance1 Provider"
213+
}
214+
)
215+
val provider2 = DoSomethingProvider(
216+
metadata = object : ProviderMetadata {
217+
override val name: String = "Instance2 Provider"
218+
}
219+
)
220+
221+
instance1.setProviderAndWait(provider1, ImmutableContext())
222+
instance2.setProviderAndWait(provider2, ImmutableContext())
223+
224+
assertEquals("Instance1 Provider", instance1.getProvider().metadata.name)
225+
assertEquals("Instance2 Provider", instance2.getProvider().metadata.name)
226+
227+
instance1.shutdown()
228+
assertEquals(OpenFeatureStatus.NotReady, instance1.getStatus())
229+
assertEquals(OpenFeatureStatus.Ready, instance2.getStatus())
230+
}
231+
232+
@Test
233+
fun testIsolatedInstanceClientHooks() = runTest {
234+
val instance = createInstance()
235+
instance.setProviderAndWait(NoOpProvider(), ImmutableContext())
236+
237+
val client = instance.getClient()
238+
val hook = GenericSpyHookMock()
239+
client.addHooks(listOf(hook))
240+
241+
client.getBooleanValue("test", false)
242+
assertEquals(1, hook.finallyCalledAfter)
243+
}
244+
245+
@Test
246+
fun testIsolatedInstanceContextIsNull() {
247+
val instance = createInstance()
248+
assertNull(instance.getEvaluationContext())
249+
}
250+
251+
@Test
252+
fun testSwappingProviderOnInstance() = runTest {
253+
val instance = createInstance()
254+
255+
val provider1 = DoSomethingProvider(
256+
metadata = object : ProviderMetadata {
257+
override val name: String = "First"
258+
}
259+
)
260+
val provider2 = DoSomethingProvider(
261+
metadata = object : ProviderMetadata {
262+
override val name: String = "Second"
263+
}
264+
)
265+
266+
instance.setProviderAndWait(provider1, ImmutableContext())
267+
assertEquals("First", instance.getProvider().metadata.name)
268+
269+
instance.setProviderAndWait(provider2, ImmutableContext())
270+
assertEquals("Second", instance.getProvider().metadata.name)
271+
}
272+
273+
@Test
274+
fun testDistinctProvidersWithSameEqualityAreNotConflated() = runTest {
275+
val instance1 = createInstance()
276+
val instance2 = createInstance()
277+
278+
// Two distinct provider objects that are structurally equal via equals/hashCode
279+
val provider1 = ValueEqualProvider("shared-name")
280+
val provider2 = ValueEqualProvider("shared-name")
281+
assertEquals(provider1, provider2, "Precondition: providers are structurally equal")
282+
283+
instance1.setProviderAndWait(provider1, ImmutableContext())
284+
instance2.setProviderAndWait(provider2, ImmutableContext())
285+
286+
// Both should succeed — distinct objects should not be conflated
287+
assertEquals(OpenFeatureStatus.Ready, instance1.getStatus())
288+
assertEquals(OpenFeatureStatus.Ready, instance2.getStatus())
289+
}
290+
291+
@Test
292+
fun testNoOpProviderSubclassIsGuarded() = runTest {
293+
val instance1 = createInstance()
294+
val instance2 = createInstance()
295+
296+
// A single provider that happens to extend NoOpProvider
297+
val sharedSubclass = object : NoOpProvider() {
298+
override val metadata = object : ProviderMetadata {
299+
override val name: String = "Custom NoOp Subclass"
300+
}
301+
}
302+
303+
instance1.setProviderAndWait(sharedSubclass, ImmutableContext())
304+
instance2.setProviderAndWait(sharedSubclass, ImmutableContext())
305+
306+
// The subclass is not the instance's private fallback, so the guard must fire
307+
assertEquals(OpenFeatureStatus.Ready, instance1.getStatus())
308+
assertTrue(instance2.getStatus() is OpenFeatureStatus.Error)
309+
}
310+
311+
@Test
312+
fun testClearProviderUnbindsEvenIfShutdownThrows() = runTest {
313+
val instance1 = createInstance()
314+
val instance2 = createInstance()
315+
316+
var shouldThrow = true
317+
val throwingProvider = object : NoOpProvider() {
318+
override val metadata = object : ProviderMetadata {
319+
override val name: String = "Throwing Provider"
320+
}
321+
override fun shutdown() {
322+
if (shouldThrow) throw RuntimeException("shutdown failed")
323+
}
324+
}
325+
326+
instance1.setProviderAndWait(throwingProvider, ImmutableContext())
327+
assertEquals(OpenFeatureStatus.Ready, instance1.getStatus())
328+
329+
// clearProvider should release the binding even though shutdown throws
330+
try { instance1.clearProvider() } catch (_: RuntimeException) {}
331+
332+
// Another instance should now be able to bind the same provider
333+
instance2.setProviderAndWait(throwingProvider, ImmutableContext())
334+
assertEquals(OpenFeatureStatus.Ready, instance2.getStatus())
335+
336+
// Disable throwing so tearDown can clean up
337+
shouldThrow = false
338+
}
339+
340+
@Test
341+
fun testReSettingSameProviderDoesNotShutItDown() = runTest {
342+
val instance = createInstance()
343+
val provider = SpyProvider()
344+
345+
instance.setProviderAndWait(provider)
346+
instance.setProviderAndWait(provider)
347+
348+
assertEquals(0, provider.shutdownCalls.value)
349+
assertEquals(OpenFeatureStatus.Ready, instance.getStatus())
350+
}
351+
}
352+
353+
/**
354+
* A NoOpProvider that implements value equality via [name], so two distinct instances
355+
* with the same name are structurally equal. Used to verify the binding registry
356+
* uses identity, not equality.
357+
*/
358+
private class ValueEqualProvider(val name: String) : NoOpProvider() {
359+
override val metadata = object : ProviderMetadata {
360+
override val name: String = this@ValueEqualProvider.name
361+
}
362+
363+
override fun equals(other: Any?): Boolean =
364+
other is ValueEqualProvider && name == other.name
365+
366+
override fun hashCode(): Int = name.hashCode()
367+
}

0 commit comments

Comments
 (0)