Skip to content

Commit 5d5bab8

Browse files
committed
test: add isolated API instance tests
1 parent 1f04248 commit 5d5bab8

1 file changed

Lines changed: 354 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)