Skip to content

Commit 19e1964

Browse files
authored
fix(userflags): read the fee override from its own field (#1321)
`UserFlags.resolve()` passed `overrides.newCurrencyPurchaseAmount` to both `newCurrencyPurchaseAmount` and `newCurrencyFeeAmount`, so in the debug flag editor overriding the fee amount did nothing and overriding the purchase amount changed the fee too. `Overrides.newCurrencyFeeAmount` was already parsed from preferences and exposed as an editable field; it just had no consumer. Add ResolvedUserFlagsTest, which applies each of the ten overrides on its own against a server-flags fixture with distinct values and asserts it lands on its own resolved field and leaves the other nine untouched. A reflection check over `Overrides`' declared fields fails if a new override is added without a case, so the next mis-wiring of this kind shows up as a test failure.
1 parent 4305a17 commit 19e1964

2 files changed

Lines changed: 209 additions & 1 deletion

File tree

‎apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ internal fun UserFlags.resolve(overrides: Overrides): ResolvedUserFlags = Resolv
4747
minimumVersion = ResolvedFlag(minimumVersion, overrides.minimumVersion),
4848
billExchangeDataTimeout = ResolvedFlag(billExchangeDataTimeout, overrides.billExchangeDataTimeout),
4949
newCurrencyPurchaseAmount = ResolvedFlag(newCurrencyPurchaseAmount, overrides.newCurrencyPurchaseAmount),
50-
newCurrencyFeeAmount = ResolvedFlag(newCurrencyFeeAmount, overrides.newCurrencyPurchaseAmount),
50+
newCurrencyFeeAmount = ResolvedFlag(newCurrencyFeeAmount, overrides.newCurrencyFeeAmount),
5151
withdrawalFeeAmount = ResolvedFlag(withdrawalFeeAmount, overrides.withdrawalFeeAmount),
5252
usdcOnRampLiquidityPool = ResolvedFlag(preferredUsdcOnRampLiquidityPool, overrides.preferredUsdcOnRampLiquidityPool),
5353
enablePhoneNumberSend = ResolvedFlag(enablePhoneNumberSend, FieldOverride.None),
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package com.flipcash.app.userflags
2+
3+
import com.flipcash.app.userflags.UserFlagsCoordinator.Overrides
4+
import com.flipcash.services.internal.model.thirdparty.OnRampProvider
5+
import com.flipcash.services.internal.model.thirdparty.OnRampType
6+
import com.flipcash.services.internal.model.thirdparty.UsdcLiquidtyPool
7+
import com.flipcash.services.models.TipPresets
8+
import com.flipcash.services.models.UserFlags
9+
import com.getcode.opencode.model.financial.Fiat
10+
import java.lang.reflect.Modifier
11+
import kotlin.test.Test
12+
import kotlin.test.assertEquals
13+
import kotlin.test.assertFalse
14+
import kotlin.test.assertTrue
15+
import kotlin.time.Duration.Companion.seconds
16+
17+
class ResolvedUserFlagsTest {
18+
19+
@Test
20+
fun `resolve without overrides exposes the server value for every flag`() {
21+
val resolved = ServerFlags.resolve(Overrides.None)
22+
23+
assertEquals(true, resolved.isStaff.effectiveValue)
24+
assertEquals(true, resolved.isRegistered.effectiveValue)
25+
assertEquals(true, resolved.requiresIapForRegistration.effectiveValue)
26+
assertEquals(OnRampProvider.ManualDeposit, resolved.preferredOnRampProvider.effectiveValue)
27+
assertEquals(listOf(OnRampProvider.ManualDeposit), resolved.supportedOnRampProviders.effectiveValue)
28+
assertEquals(100, resolved.minimumVersion.effectiveValue)
29+
assertEquals(30.seconds, resolved.billExchangeDataTimeout.effectiveValue)
30+
assertEquals(Fiat(quarks = 1_000_000L), resolved.newCurrencyPurchaseAmount.effectiveValue)
31+
assertEquals(Fiat(quarks = 2_000_000L), resolved.newCurrencyFeeAmount.effectiveValue)
32+
assertEquals(Fiat(quarks = 3_000_000L), resolved.withdrawalFeeAmount.effectiveValue)
33+
assertEquals(UsdcLiquidtyPool.Flipcash, resolved.usdcOnRampLiquidityPool.effectiveValue)
34+
assertEquals(true, resolved.enablePhoneNumberSend.effectiveValue)
35+
assertEquals(Fiat(quarks = 4_000_000L), resolved.minimumHolderAmountForLeaderboard.effectiveValue)
36+
assertEquals(true, resolved.requireCoinbaseEmailVerification.effectiveValue)
37+
assertEquals(ServerFlags.tipPresets, resolved.tipPresets.effectiveValue)
38+
}
39+
40+
@Test
41+
fun `resolve without overrides marks nothing as overridden`() {
42+
val resolved = ServerFlags.resolve(Overrides.None)
43+
OverrideCases.forEach { case ->
44+
assertFalse(case.isOverridden(resolved), "${case.name} reports an override for Overrides.None")
45+
}
46+
}
47+
48+
@Test
49+
fun `each override lands on its own field and leaves the others alone`() {
50+
val baseline = ServerFlags.resolve(Overrides.None)
51+
52+
OverrideCases.forEach { case ->
53+
val resolved = ServerFlags.resolve(case.applyTo(Overrides.None))
54+
55+
assertTrue(case.isOverridden(resolved), "${case.name} override was dropped")
56+
case.assertOverrideApplied(resolved)
57+
58+
OverrideCases.filterNot { it === case }.forEach { other ->
59+
assertFalse(
60+
other.isOverridden(resolved),
61+
"overriding ${case.name} also overrode ${other.name}",
62+
)
63+
other.assertMatchesBaseline(
64+
resolved = resolved,
65+
baseline = baseline,
66+
message = "overriding ${case.name} changed the value of ${other.name}",
67+
)
68+
}
69+
}
70+
}
71+
72+
@Test
73+
fun `all overrides applied together each land on their own field`() {
74+
val overrides = OverrideCases.fold(Overrides.None) { acc, case -> case.applyTo(acc) }
75+
val resolved = ServerFlags.resolve(overrides)
76+
77+
OverrideCases.forEach { case ->
78+
assertTrue(case.isOverridden(resolved), "${case.name} override was dropped")
79+
case.assertOverrideApplied(resolved)
80+
}
81+
}
82+
83+
/**
84+
* Guards the coverage of the tests above: a new field on [Overrides] has to gain a case here,
85+
* which is what forces its wiring in `resolve()` to be exercised.
86+
*/
87+
@Test
88+
fun `every Overrides field has a case`() {
89+
val declared = Overrides::class.java.declaredFields
90+
.filterNot { it.isSynthetic || Modifier.isStatic(it.modifiers) }
91+
.map { it.name }
92+
.toSet()
93+
94+
assertEquals(declared, OverrideCases.map { it.name }.toSet())
95+
}
96+
}
97+
98+
/**
99+
* One overridable flag: how to set it on [Overrides], and where it should surface
100+
* in [ResolvedUserFlags]. [name] matches the [Overrides] property name.
101+
*/
102+
private class OverrideCase<T>(
103+
val name: String,
104+
private val overrideValue: T,
105+
private val set: Overrides.(FieldOverride<T>) -> Overrides,
106+
private val select: (ResolvedUserFlags) -> ResolvedFlag<T>,
107+
) {
108+
fun applyTo(overrides: Overrides): Overrides = overrides.set(FieldOverride.Value(overrideValue))
109+
110+
fun isOverridden(resolved: ResolvedUserFlags): Boolean = select(resolved).isOverridden
111+
112+
fun assertOverrideApplied(resolved: ResolvedUserFlags) {
113+
assertEquals(overrideValue, select(resolved).effectiveValue, "$name resolved to the wrong value")
114+
}
115+
116+
fun assertMatchesBaseline(
117+
resolved: ResolvedUserFlags,
118+
baseline: ResolvedUserFlags,
119+
message: String,
120+
) {
121+
assertEquals(select(baseline).effectiveValue, select(resolved).effectiveValue, message)
122+
}
123+
}
124+
125+
private val ServerFlags = UserFlags(
126+
isStaff = true,
127+
isRegistered = true,
128+
requiresIapForRegistration = true,
129+
preferredOnRampProvider = OnRampProvider.ManualDeposit,
130+
supportedOnRampProviders = listOf(OnRampProvider.ManualDeposit, OnRampProvider.Unknown),
131+
minimumVersion = 100,
132+
billExchangeDataTimeout = 30.seconds,
133+
newCurrencyPurchaseAmount = Fiat(quarks = 1_000_000L),
134+
newCurrencyFeeAmount = Fiat(quarks = 2_000_000L),
135+
withdrawalFeeAmount = Fiat(quarks = 3_000_000L),
136+
preferredUsdcOnRampLiquidityPool = UsdcLiquidtyPool.Flipcash,
137+
enablePhoneNumberSend = true,
138+
minimumHolderValue = Fiat(quarks = 4_000_000L),
139+
requireCoinbaseEmailVerification = true,
140+
tipPresets = listOf(
141+
TipPresets(region = "US", minimum = 1.0, low = 2.0, medium = 3.0, high = 4.0),
142+
),
143+
)
144+
145+
// Every value here differs from the matching server value above, so a flag that reads the
146+
// wrong override — or none at all — cannot pass by coincidence.
147+
private val OverrideCases: List<OverrideCase<*>> = listOf(
148+
OverrideCase<OnRampProvider.Defined?>(
149+
name = "preferredOnRampProvider",
150+
overrideValue = OnRampProvider.Phantom,
151+
set = { copy(preferredOnRampProvider = it) },
152+
select = { it.preferredOnRampProvider },
153+
),
154+
OverrideCase(
155+
name = "supportedOnRampProviders",
156+
overrideValue = listOf<OnRampProvider.Defined>(OnRampProvider.Coinbase(OnRampType.Virtual)),
157+
set = { copy(supportedOnRampProviders = it) },
158+
select = { it.supportedOnRampProviders },
159+
),
160+
OverrideCase<Int?>(
161+
name = "minimumVersion",
162+
overrideValue = 999,
163+
set = { copy(minimumVersion = it) },
164+
select = { it.minimumVersion },
165+
),
166+
OverrideCase(
167+
name = "billExchangeDataTimeout",
168+
overrideValue = 90.seconds,
169+
set = { copy(billExchangeDataTimeout = it) },
170+
select = { it.billExchangeDataTimeout },
171+
),
172+
OverrideCase(
173+
name = "newCurrencyPurchaseAmount",
174+
overrideValue = Fiat(quarks = 11_000_000L),
175+
set = { copy(newCurrencyPurchaseAmount = it) },
176+
select = { it.newCurrencyPurchaseAmount },
177+
),
178+
OverrideCase(
179+
name = "newCurrencyFeeAmount",
180+
overrideValue = Fiat(quarks = 12_000_000L),
181+
set = { copy(newCurrencyFeeAmount = it) },
182+
select = { it.newCurrencyFeeAmount },
183+
),
184+
OverrideCase(
185+
name = "withdrawalFeeAmount",
186+
overrideValue = Fiat(quarks = 13_000_000L),
187+
set = { copy(withdrawalFeeAmount = it) },
188+
select = { it.withdrawalFeeAmount },
189+
),
190+
OverrideCase(
191+
name = "preferredUsdcOnRampLiquidityPool",
192+
overrideValue = UsdcLiquidtyPool.CoinbaseStableSwapper,
193+
set = { copy(preferredUsdcOnRampLiquidityPool = it) },
194+
select = { it.usdcOnRampLiquidityPool },
195+
),
196+
OverrideCase(
197+
name = "minimumHolderAmountForLeaderboard",
198+
overrideValue = Fiat(quarks = 14_000_000L),
199+
set = { copy(minimumHolderAmountForLeaderboard = it) },
200+
select = { it.minimumHolderAmountForLeaderboard },
201+
),
202+
OverrideCase(
203+
name = "requireCoinbaseEmailVerification",
204+
overrideValue = false,
205+
set = { copy(requireCoinbaseEmailVerification = it) },
206+
select = { it.requireCoinbaseEmailVerification },
207+
),
208+
)

0 commit comments

Comments
 (0)