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 = createOpenFeatureInstance()
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