|
1 | 1 | package com.flipcash.shared.tipping |
2 | 2 |
|
3 | 3 | import com.flipcash.app.analytics.FlipcashAnalyticsService |
| 4 | +import com.flipcash.app.core.tipping.TipAmount |
4 | 5 | import com.flipcash.app.funding.PurchaseMethodController |
5 | 6 | import com.flipcash.app.tokens.TokenCoordinator |
6 | 7 | import com.flipcash.services.controllers.ProfileController |
| 8 | +import com.flipcash.services.models.TipAction |
| 9 | +import com.flipcash.services.models.TipOrigin |
7 | 10 | import com.flipcash.services.models.UserProfile |
| 11 | +import com.flipcash.services.models.chat.ChatId |
8 | 12 | import com.flipcash.services.user.UserManager |
9 | 13 | import com.flipcash.shared.payments.TipPaymentDelegate |
10 | 14 | import com.getcode.opencode.exchange.Exchange |
| 15 | +import com.getcode.opencode.exchange.VerifiedFiat |
11 | 16 | import com.getcode.opencode.exchange.VerifiedFiatCalculator |
| 17 | +import com.getcode.opencode.model.accounts.AccountCluster |
| 18 | +import com.getcode.opencode.model.financial.CurrencyCode |
| 19 | +import com.getcode.opencode.model.financial.Fiat |
| 20 | +import com.getcode.opencode.model.financial.LocalFiat |
| 21 | +import com.getcode.opencode.model.financial.Rate |
| 22 | +import com.getcode.opencode.model.core.ID |
| 23 | +import com.getcode.opencode.model.financial.Token |
| 24 | +import com.getcode.solana.keys.Mint |
12 | 25 | import com.getcode.util.resources.ResourceHelper |
13 | 26 | import com.getcode.util.vibration.Vibrator |
14 | 27 | import io.mockk.coEvery |
| 28 | +import io.mockk.coVerify |
15 | 29 | import io.mockk.every |
16 | 30 | import io.mockk.mockk |
| 31 | +import kotlinx.coroutines.flow.MutableStateFlow |
| 32 | +import kotlinx.coroutines.flow.flowOf |
17 | 33 | import kotlinx.coroutines.test.runTest |
18 | 34 | import kotlin.test.Test |
19 | 35 | import kotlin.test.assertEquals |
@@ -93,4 +109,77 @@ class TippingCoordinatorTest { |
93 | 109 |
|
94 | 110 | assertEquals(id, coordinator.currentUserId) |
95 | 111 | } |
| 112 | + |
| 113 | + /** |
| 114 | + * Reaches into [TippingCoordinator]'s private `_userId`/`_recipient`/`_canTip` state to select |
| 115 | + * a tip-card recipient without going through [TippingCoordinator.resolveTipCard] — that method |
| 116 | + * builds a real scannable [com.getcode.opencode.model.core.OpenCodePayload], which loads the |
| 117 | + * native `kikCodes`/`codeScanner` libraries a plain JVM unit test has no access to |
| 118 | + * (`UnsatisfiedLinkError`). Only [TippingCoordinator.confirmTip]'s wiring is under test here. |
| 119 | + */ |
| 120 | + @Suppress("UNCHECKED_CAST") |
| 121 | + private fun <T> TippingCoordinator.privateStateFlow(name: String): MutableStateFlow<T> { |
| 122 | + val field = TippingCoordinator::class.java.getDeclaredField(name) |
| 123 | + field.isAccessible = true |
| 124 | + return field.get(this) as MutableStateFlow<T> |
| 125 | + } |
| 126 | + |
| 127 | + @Test |
| 128 | + fun `confirmTip sends the tip card as a TIP action`() = runTest { |
| 129 | + // A tip card has no "send cash" path — every payment through it is a genuine tip, so |
| 130 | + // confirmTip must always report TipAction.TIP on the wire, never SEND. |
| 131 | + val userId = listOf<Byte>(4, 5, 6) |
| 132 | + |
| 133 | + val mint = Mint.usdf |
| 134 | + val token = mockk<Token>(relaxed = true) { |
| 135 | + every { address } returns mint |
| 136 | + } |
| 137 | + val rate = Rate.oneToOne |
| 138 | + val amount = Fiat(5.0, CurrencyCode.USD) |
| 139 | + val owner = mockk<AccountCluster>(relaxed = true) { |
| 140 | + every { withTimelockForToken(token) } returns this |
| 141 | + } |
| 142 | + val verifiedFiat = VerifiedFiat(LocalFiat.Zero, null) |
| 143 | + |
| 144 | + every { exchange.preferredRate } returns rate |
| 145 | + // selectedToken is a plain `val` built once at construction from these two calls, not a |
| 146 | + // lazily-recomputed property — stubbing them has to happen before TippingCoordinator is |
| 147 | + // built, or the coordinator captures whatever the mock's default (unstubbed) answer was. |
| 148 | + every { tokenCoordinator.observeSelectedTokenMint() } returns flowOf(mint) |
| 149 | + every { tokenCoordinator.tokens } returns flowOf(listOf(token)) |
| 150 | + every { tokenCoordinator.balanceForToken(token) } returns amount |
| 151 | + every { userManager.accountCluster } returns owner |
| 152 | + every { tipPaymentDelegate.minimumTipFor(userId, any()) } returns flowOf(null) |
| 153 | + coEvery { |
| 154 | + verifiedFiatCalculator.compute(any(), any(), any(), any(), any()) |
| 155 | + } returns Result.success(verifiedFiat) |
| 156 | + coEvery { |
| 157 | + tipPaymentDelegate.send(any(), any(), any(), any(), any(), any()) |
| 158 | + } returns Result.success<ChatId?>(null) |
| 159 | + |
| 160 | + val coordinator = buildCoordinator() |
| 161 | + |
| 162 | + // Selects the tip card the same way onCardResolved would, minus the native-dependent |
| 163 | + // OpenCodePayload construction resolveTipCard performs. |
| 164 | + coordinator.privateStateFlow<ID?>("_userId").value = userId |
| 165 | + coordinator.privateStateFlow<UserProfile?>("_recipient").value = profile("Bob") |
| 166 | + coordinator.privateStateFlow<Boolean>("_canTip").value = true |
| 167 | + |
| 168 | + coordinator.selectAmount(TipAmount.Custom(amount)) |
| 169 | + coordinator.confirmTip() |
| 170 | + |
| 171 | + // confirmTip fires into the coordinator's own background scope rather than this test's, |
| 172 | + // so the send lands asynchronously — coVerify's timeout polls for it instead of asserting |
| 173 | + // immediately after the (non-suspend) call above returns. |
| 174 | + coVerify(timeout = 5_000) { |
| 175 | + tipPaymentDelegate.send( |
| 176 | + userId = userId, |
| 177 | + verifiedFiat = verifiedFiat, |
| 178 | + token = token, |
| 179 | + source = owner, |
| 180 | + origin = TipOrigin.TIPCARD, |
| 181 | + action = TipAction.TIP, |
| 182 | + ) |
| 183 | + } |
| 184 | + } |
96 | 185 | } |
0 commit comments