@@ -8,6 +8,8 @@ import com.flipcash.app.core.internal.bill.BillController
88import com.flipcash.app.session.internal.toast.ToastController
99import com.flipcash.app.featureflags.FeatureFlagController
1010import com.flipcash.app.shareable.ShareSheetController
11+ import com.flipcash.app.shareable.ShareResult
12+ import com.flipcash.app.shareable.Shareable
1113import com.flipcash.app.shareable.ShareableConfirmationController
1214import com.flipcash.app.tokens.TokenCoordinator
1315import com.flipcash.app.tokens.TokenUpdater
@@ -22,16 +24,21 @@ import com.getcode.opencode.internal.manager.VerifiedState
2224import com.getcode.opencode.internal.transactors.ReceiveGiftTransactorError
2325import com.getcode.opencode.model.accounts.AccountCluster
2426import com.flipcash.app.core.bill.Bill
27+ import com.flipcash.app.core.bill.BillState
2528import com.getcode.opencode.model.financial.LocalFiat
2629import com.getcode.opencode.model.financial.Token
2730import com.getcode.util.resources.ResourceHelper
2831import com.flipcash.app.billing.BillingClient
2932import com.flipcash.app.core.MainCoroutineRule
3033import com.getcode.utils.network.NetworkConnectivityListener
3134import com.getcode.util.vibration.Vibrator
35+ import com.getcode.opencode.model.accounts.GiftCardAccount
3236import io.mockk.every
3337import io.mockk.mockk
38+ import io.mockk.mockkObject
3439import io.mockk.slot
40+ import io.mockk.unmockkObject
41+ import io.mockk.verify
3542import kotlinx.coroutines.ExperimentalCoroutinesApi
3643import kotlinx.coroutines.test.runTest
3744import org.junit.After
@@ -80,7 +87,9 @@ class SessionControllerGiftCardErrorTest {
8087 BottomBarManager .clear()
8188 }
8289
83- private fun createController (): RealSessionController {
90+ private fun createController (
91+ shareSheetController : ShareSheetController = mockk(relaxed = true),
92+ ): RealSessionController {
8493 return RealSessionController (
8594 billController = billController,
8695 userManager = userManager,
@@ -94,7 +103,7 @@ class SessionControllerGiftCardErrorTest {
94103 tokenUpdater = mockk(relaxed = true ),
95104 activityFeedUpdater = mockk(relaxed = true ),
96105 profileUpdater = mockk(relaxed = true ),
97- shareSheetController = mockk(relaxed = true ) ,
106+ shareSheetController = shareSheetController ,
98107 shareConfirmationController = mockk(relaxed = true ),
99108 toastController = mockk(relaxed = true ),
100109 billingClient = mockk(relaxed = true ),
@@ -195,6 +204,70 @@ class SessionControllerGiftCardErrorTest {
195204 assertEquals(" error_title_CashReturnedToWallet" , messages.first().title)
196205 }
197206
207+ @Test
208+ fun `duplicate onShared invocations only fund gift card once` () = runTest {
209+ // Mock GiftCardAccount.create to avoid MnemonicCache (requires Android context)
210+ mockkObject(GiftCardAccount .Companion )
211+ every { GiftCardAccount .create(any(), any()) } returns mockk(relaxed = true )
212+
213+ try {
214+ // Fake that fires onShared twice on present(), simulating the race between
215+ // shareResultReceiver and checkForShare().
216+ val racingShareSheet = object : ShareSheetController {
217+ override val isCheckingForShare: Boolean = false
218+ override var onShared: ((ShareResult ) -> Unit )? = null
219+ override fun checkForShare () {}
220+ override suspend fun present (shareable : Shareable ) {
221+ onShared?.invoke(ShareResult .SharedToApp (" com.example.app" ))
222+ onShared?.invoke(ShareResult .SharedToApp (" com.example.app" ))
223+ }
224+ override fun reset (setChecked : Boolean ) {}
225+ }
226+
227+ // Capture the update lambda so we can extract the SendAsLink action
228+ val updateSlot = slot< (BillState ) -> BillState > ()
229+ every { billController.update(capture(updateSlot)) } answers {}
230+
231+ val controller = createController(shareSheetController = racingShareSheet)
232+
233+ val amount = mockk<LocalFiat >(relaxed = true ) {
234+ every { nativeAmount.decimalValue } returns 5.0
235+ }
236+ val bill = Bill .Cash (
237+ token = mockk(relaxed = true ),
238+ amount = amount,
239+ didReceive = false ,
240+ kind = Bill .Kind .cash,
241+ verifiedState = mockk(relaxed = true ),
242+ )
243+
244+ controller.showBill(bill)
245+
246+ // Extract and invoke the "Send as Link" action (calls shareGiftCard)
247+ val updatedState = updateSlot.captured(BillState .Default )
248+ val sendAction = updatedState.primaryAction as BillState .Action .SendAsLink
249+ sendAction.action()
250+
251+ // Wait for IO-dispatched coroutines to execute
252+ Thread .sleep(1000 )
253+
254+ // The guard should ensure fundGiftCard is called exactly once, not twice
255+ verify(exactly = 1 ) {
256+ billController.fundGiftCard(
257+ giftCard = any(),
258+ amount = any(),
259+ token = any(),
260+ owner = any(),
261+ verifiedState = any(),
262+ onFunded = any(),
263+ onError = any(),
264+ )
265+ }
266+ } finally {
267+ unmockkObject(GiftCardAccount .Companion )
268+ }
269+ }
270+
198271 // Phase 3: fund gift card error
199272 @Test
200273 fun `fund gift card error shows failedToCreateGiftCard error` () = runTest {
0 commit comments