Skip to content

Commit a6d024d

Browse files
committed
Use ConcurrentHashMap for the PaymentMethodProvider to support thread safety
COSDK-550
1 parent d3317e5 commit a6d024d

File tree

3 files changed

+194
-1
lines changed

3 files changed

+194
-1
lines changed

core/src/main/java/com/adyen/checkout/core/components/internal/PaymentMethodProvider.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,17 @@
99
package com.adyen.checkout.core.components.internal
1010

1111
import androidx.annotation.RestrictTo
12+
import androidx.annotation.VisibleForTesting
1213
import com.adyen.checkout.core.components.CheckoutConfiguration
1314
import com.adyen.checkout.core.components.internal.ui.PaymentComponent
1415
import com.adyen.checkout.core.sessions.internal.model.SessionParams
1516
import kotlinx.coroutines.CoroutineScope
17+
import java.util.concurrent.ConcurrentHashMap
1618

1719
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
1820
object PaymentMethodProvider {
1921

20-
private val factories = mutableMapOf<String, PaymentMethodFactory<*, *>>()
22+
private val factories = ConcurrentHashMap<String, PaymentMethodFactory<*, *>>()
2123

2224
fun register(
2325
txVariant: String,
@@ -52,4 +54,20 @@ object PaymentMethodProvider {
5254
error("Factory for payment method type: $txVariant is not registered.")
5355
}
5456
}
57+
58+
/**
59+
* Clears all registered factories. Should only be used in tests.
60+
*/
61+
@VisibleForTesting
62+
internal fun clear() {
63+
factories.clear()
64+
}
65+
66+
/**
67+
* Returns the number of registered factories. Should only be used in tests.
68+
*/
69+
@VisibleForTesting
70+
internal fun getFactoriesCount(): Int {
71+
return factories.size
72+
}
5573
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* Copyright (c) 2025 Adyen N.V.
3+
*
4+
* This file is open source and available under the MIT license. See the LICENSE file for more info.
5+
*
6+
* Created by ararat on 17/7/2025.
7+
*/
8+
9+
package com.adyen.checkout.core.components.internal
10+
11+
import com.adyen.checkout.core.common.Environment
12+
import com.adyen.checkout.core.components.CheckoutConfiguration
13+
import com.adyen.checkout.core.components.internal.ui.PaymentComponent
14+
import com.adyen.checkout.core.components.internal.ui.TestPaymentComponent
15+
import com.adyen.checkout.core.sessions.internal.model.SessionParams
16+
import kotlinx.coroutines.CoroutineScope
17+
import kotlinx.coroutines.test.runTest
18+
import org.junit.Assert
19+
import org.junit.Assert.assertEquals
20+
import org.junit.Assert.assertNotSame
21+
import org.junit.Before
22+
import org.junit.Test
23+
import org.junit.jupiter.api.assertThrows
24+
import java.util.Locale
25+
import java.util.concurrent.Executors
26+
import java.util.concurrent.TimeUnit
27+
28+
internal class PaymentMethodProviderTest {
29+
30+
private val component = TestPaymentComponent()
31+
private val factory = generateFactory(
32+
paymentComponent = component,
33+
)
34+
35+
@Before
36+
fun setUp() {
37+
PaymentMethodProvider.clear()
38+
}
39+
40+
@Test
41+
fun `when register is called concurrently from multiple threads, then provider should not lose any data`() {
42+
val threadCount = 10
43+
val registrationsPerThread = 100
44+
val totalRegistrations = threadCount * registrationsPerThread
45+
val executor = Executors.newFixedThreadPool(threadCount)
46+
47+
// Submit all registration tasks to the thread pool
48+
for (i in 0 until totalRegistrations) {
49+
executor.submit {
50+
PaymentMethodProvider.register("test_method_$i", factory)
51+
}
52+
}
53+
54+
// Wait for all tasks to complete
55+
executor.shutdown()
56+
// Giving it a generous timeout to finish all tasks
57+
val completed = executor.awaitTermination(5, TimeUnit.SECONDS)
58+
assertEquals("Executor tasks did not complete in time.", true, completed)
59+
assertEquals(totalRegistrations, PaymentMethodProvider.getFactoriesCount())
60+
}
61+
62+
@Test
63+
fun `when register is called with an existing txVariant, then the factory is overwritten`() = runTest {
64+
val secondaryComponent = TestPaymentComponent()
65+
val secondaryFactory = generateFactory(
66+
paymentComponent = secondaryComponent,
67+
)
68+
69+
PaymentMethodProvider.register("txVariant", factory)
70+
PaymentMethodProvider.register("txVariant", secondaryFactory)
71+
72+
val actualComponent = PaymentMethodProvider.get(
73+
txVariant = "txVariant",
74+
coroutineScope = this,
75+
checkoutConfiguration = generateCheckoutConfiguration(),
76+
componentSessionParams = null,
77+
)
78+
assertEquals(1, PaymentMethodProvider.getFactoriesCount())
79+
assertEquals(secondaryComponent, actualComponent)
80+
assertNotSame(component, actualComponent)
81+
}
82+
83+
@Test
84+
fun `when register is called for different txVariants, then all factories are stored`() {
85+
PaymentMethodProvider.register("txVariant_one", factory)
86+
PaymentMethodProvider.register("txVariant_two", factory)
87+
88+
assertEquals(2, PaymentMethodProvider.getFactoriesCount())
89+
}
90+
91+
@Test
92+
fun `when get is called for a registered factory, then the correct component is returned`() = runTest {
93+
PaymentMethodProvider.register("txVariant", factory)
94+
95+
val actualComponent = PaymentMethodProvider.get(
96+
txVariant = "txVariant",
97+
coroutineScope = this,
98+
checkoutConfiguration = generateCheckoutConfiguration(),
99+
componentSessionParams = null,
100+
)
101+
assertEquals(1, PaymentMethodProvider.getFactoriesCount())
102+
Assert.assertSame(component, actualComponent)
103+
}
104+
105+
@Test
106+
fun `when get is called for an unregistered factory, then an error is thrown`() = runTest {
107+
assertThrows<IllegalStateException> {
108+
PaymentMethodProvider.get(
109+
txVariant = "unregistered_txVariant",
110+
coroutineScope = this,
111+
checkoutConfiguration = generateCheckoutConfiguration(),
112+
componentSessionParams = null,
113+
)
114+
}
115+
}
116+
117+
@Test
118+
fun `when clear is called, then all factories are removed`() {
119+
PaymentMethodProvider.register("txVariant_one", factory)
120+
PaymentMethodProvider.register("txVariant_two", factory)
121+
assertEquals(2, PaymentMethodProvider.getFactoriesCount())
122+
123+
PaymentMethodProvider.clear()
124+
125+
assertEquals(0, PaymentMethodProvider.getFactoriesCount())
126+
}
127+
128+
private fun generateFactory(paymentComponent: PaymentComponent<BaseComponentState>) =
129+
object : PaymentMethodFactory<BaseComponentState, PaymentComponent<BaseComponentState>> {
130+
override fun create(
131+
coroutineScope: CoroutineScope,
132+
checkoutConfiguration: CheckoutConfiguration,
133+
componentSessionParams: SessionParams?
134+
) = paymentComponent
135+
}
136+
137+
private fun generateCheckoutConfiguration() = CheckoutConfiguration(
138+
shopperLocale = Locale.US,
139+
environment = Environment.TEST,
140+
clientKey = "test_key_12345",
141+
)
142+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright (c) 2025 Adyen N.V.
3+
*
4+
* This file is open source and available under the MIT license. See the LICENSE file for more info.
5+
*
6+
* Created by ararat on 17/7/2025.
7+
*/
8+
9+
package com.adyen.checkout.core.components.internal.ui
10+
11+
import androidx.compose.runtime.Composable
12+
import androidx.compose.ui.Modifier
13+
import com.adyen.checkout.core.components.internal.BaseComponentState
14+
import com.adyen.checkout.core.components.internal.PaymentComponentEvent
15+
import kotlinx.coroutines.flow.Flow
16+
import kotlinx.coroutines.flow.flowOf
17+
import java.util.UUID
18+
19+
internal data class TestPaymentComponent(
20+
private val id: String = UUID.randomUUID().toString(),
21+
) : PaymentComponent<BaseComponentState> {
22+
override fun submit() {
23+
// No-op
24+
}
25+
26+
@Composable
27+
override fun ViewFactory(modifier: Modifier) {
28+
// No-op
29+
}
30+
31+
override val eventFlow: Flow<PaymentComponentEvent<BaseComponentState>>
32+
get() = flowOf()
33+
}

0 commit comments

Comments
 (0)