Skip to content
This repository was archived by the owner on Oct 17, 2025. It is now read-only.

Commit 687b9d0

Browse files
committed
Add unit tests for OpenIapStore
Introduce testable constructor by injecting OpenIapProtocol into OpenIapStore. Add FakeModule to simulate billing flows and listeners. Cover idempotent finishTransaction, listener wiring on requestPurchase, deep link delegation, and active subscription pass-through. Keep app usage unchanged via existing Context constructor.
1 parent 824f546 commit 687b9d0

File tree

2 files changed

+180
-3
lines changed

2 files changed

+180
-3
lines changed

openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.app.Activity
44
import android.content.Context
55
import dev.hyo.openiap.OpenIapModule
66
import dev.hyo.openiap.OpenIapError
7+
import dev.hyo.openiap.OpenIapProtocol
78
import dev.hyo.openiap.models.*
89
import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener
910
import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener
@@ -16,8 +17,8 @@ import kotlinx.coroutines.flow.asStateFlow
1617
* Convenience store that wraps OpenIapModule and provides spec-aligned, suspend APIs
1718
* with observable StateFlows for UI layers (Compose/XML) to consume.
1819
*/
19-
class OpenIapStore(context: Context) {
20-
private val module = OpenIapModule(context)
20+
class OpenIapStore(private val module: OpenIapProtocol) {
21+
constructor(context: Context) : this(OpenIapModule(context))
2122

2223
// Public state
2324
private val _isConnected = MutableStateFlow(false)
@@ -62,7 +63,7 @@ class OpenIapStore(context: Context) {
6263

6364
// Expose a way to set the current Activity for purchase flows
6465
fun setActivity(activity: Activity?) {
65-
module.setActivity(activity)
66+
(module as? OpenIapModule)?.setActivity(activity)
6667
}
6768

6869
init {
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package dev.hyo.openiap
2+
3+
import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener
4+
import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener
5+
import dev.hyo.openiap.models.*
6+
import dev.hyo.openiap.store.OpenIapStore
7+
import org.junit.Assert.*
8+
import org.junit.Test
9+
10+
class OpenIapStoreTest {
11+
12+
// Fake implementation of OpenIapProtocol for unit testing
13+
class FakeModule : OpenIapProtocol {
14+
var initCalled = 0
15+
var endCalled = 0
16+
var finishCalled = 0
17+
var lastDeepLinkOptions: DeepLinkOptions? = null
18+
private val updateListeners = mutableSetOf<OpenIapPurchaseUpdateListener>()
19+
private val errorListeners = mutableSetOf<OpenIapPurchaseErrorListener>()
20+
21+
// Configurable responses
22+
var productsToReturn: List<OpenIapProduct> = emptyList()
23+
var purchasesToReturn: List<OpenIapPurchase> = emptyList()
24+
var activeSubsToReturn: List<OpenIapActiveSubscription> = emptyList()
25+
var requestEmitsPurchases: List<OpenIapPurchase> = emptyList()
26+
27+
override suspend fun initConnection(): Boolean {
28+
initCalled++
29+
return true
30+
}
31+
32+
override suspend fun endConnection(): Boolean {
33+
endCalled++
34+
return true
35+
}
36+
37+
override suspend fun fetchProducts(params: ProductRequest): List<OpenIapProduct> = productsToReturn
38+
39+
override suspend fun getAvailablePurchases(options: PurchaseOptions?): List<OpenIapPurchase> = purchasesToReturn
40+
41+
override suspend fun getActiveSubscriptions(subscriptionIds: List<String>?): List<OpenIapActiveSubscription> = activeSubsToReturn
42+
43+
override suspend fun hasActiveSubscriptions(subscriptionIds: List<String>?): Boolean = activeSubsToReturn.isNotEmpty()
44+
45+
override suspend fun requestPurchase(request: RequestPurchaseAndroidProps, type: ProductRequest.ProductRequestType): List<OpenIapPurchase> {
46+
// Broadcast to listeners
47+
requestEmitsPurchases.forEach { p ->
48+
updateListeners.forEach { it.onPurchaseUpdated(p) }
49+
}
50+
return requestEmitsPurchases
51+
}
52+
53+
override suspend fun finishTransaction(params: FinishTransactionParams): PurchaseResult {
54+
finishCalled++
55+
return PurchaseResult(responseCode = 0)
56+
}
57+
58+
override suspend fun validateReceipt(sku: String, androidOptions: ReceiptValidationProps.AndroidValidationOptions?): ReceiptValidationResultAndroid? = null
59+
60+
override suspend fun validateReceipt(options: ReceiptValidationProps): ReceiptValidationResultAndroid? = null
61+
62+
override suspend fun acknowledgePurchaseAndroid(purchaseToken: String) {}
63+
64+
override suspend fun consumePurchaseAndroid(purchaseToken: String) {}
65+
66+
override suspend fun flushFailedPurchaseCachedAsPendingAndroid() {}
67+
68+
override suspend fun deepLinkToSubscriptions(options: DeepLinkOptions) {
69+
lastDeepLinkOptions = options
70+
}
71+
72+
override fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) {
73+
updateListeners.add(listener)
74+
}
75+
76+
override fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) {
77+
updateListeners.remove(listener)
78+
}
79+
80+
override fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) {
81+
errorListeners.add(listener)
82+
}
83+
84+
override fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) {
85+
errorListeners.remove(listener)
86+
}
87+
}
88+
89+
private fun samplePurchase(token: String = "token-1", productId: String = "sku1"): OpenIapPurchase {
90+
return OpenIapPurchase(
91+
id = token,
92+
productId = productId,
93+
ids = listOf(productId),
94+
transactionId = "order-1",
95+
transactionDate = System.currentTimeMillis(),
96+
transactionReceipt = "{}",
97+
purchaseToken = token,
98+
platform = "android",
99+
quantity = 1,
100+
purchaseState = OpenIapPurchase.PurchaseState.PURCHASED,
101+
isAutoRenewing = false,
102+
purchaseTokenAndroid = token,
103+
dataAndroid = null,
104+
signatureAndroid = null,
105+
autoRenewingAndroid = false,
106+
isAcknowledgedAndroid = false,
107+
packageNameAndroid = "dev.hyo.martie",
108+
developerPayloadAndroid = "",
109+
obfuscatedAccountIdAndroid = "",
110+
obfuscatedProfileIdAndroid = "",
111+
)
112+
}
113+
114+
@Test
115+
fun finishTransaction_isIdempotentByToken() = kotlinx.coroutines.test.runTest {
116+
val fake = FakeModule()
117+
val store = OpenIapStore(fake)
118+
val purchase = samplePurchase(token = "t-123", productId = "sku.test")
119+
120+
val first = store.finishTransaction(purchase, isConsumable = false)
121+
val second = store.finishTransaction(purchase, isConsumable = false)
122+
123+
assertTrue(first)
124+
assertTrue(second)
125+
assertEquals(1, fake.finishCalled)
126+
}
127+
128+
@Test
129+
fun requestPurchase_emitsListenerUpdates_andReturnsList() = kotlinx.coroutines.test.runTest {
130+
val fake = FakeModule()
131+
val store = OpenIapStore(fake)
132+
val emitted = samplePurchase(token = "t-1", productId = "sku1")
133+
fake.requestEmitsPurchases = listOf(emitted)
134+
135+
val result = store.requestPurchase(
136+
RequestPurchaseAndroidProps(skus = listOf("sku1")),
137+
ProductRequest.ProductRequestType.INAPP
138+
)
139+
140+
assertEquals(1, result.size)
141+
assertEquals("sku1", result.first().productId)
142+
assertEquals("sku1", store.currentPurchase.value?.productId)
143+
}
144+
145+
@Test
146+
fun deepLink_isDelegatedToModule() = kotlinx.coroutines.test.runTest {
147+
val fake = FakeModule()
148+
val store = OpenIapStore(fake)
149+
150+
val opts = DeepLinkOptions(skuAndroid = "skuX", packageNameAndroid = "dev.hyo.martie")
151+
store.deepLinkToSubscriptions(opts)
152+
assertEquals("skuX", fake.lastDeepLinkOptions?.skuAndroid)
153+
assertEquals("dev.hyo.martie", fake.lastDeepLinkOptions?.packageNameAndroid)
154+
}
155+
156+
@Test
157+
fun getActiveSubscriptions_passThrough() = kotlinx.coroutines.test.runTest {
158+
val fake = FakeModule()
159+
val store = OpenIapStore(fake)
160+
val sub = OpenIapActiveSubscription(
161+
productId = "sku.sub",
162+
isActive = true,
163+
transactionId = "t",
164+
purchaseToken = "pt",
165+
transactionDate = System.currentTimeMillis(),
166+
platform = "android",
167+
autoRenewingAndroid = true
168+
)
169+
fake.activeSubsToReturn = listOf(sub)
170+
171+
val result = store.getActiveSubscriptions(listOf("sku.sub"))
172+
assertEquals(1, result.size)
173+
assertEquals("sku.sub", result.first().productId)
174+
}
175+
}
176+

0 commit comments

Comments
 (0)