diff --git a/examples/folio/app/shared/build.gradle.kts b/examples/folio/app/shared/build.gradle.kts index 9dd07b2..e6353c1 100644 --- a/examples/folio/app/shared/build.gradle.kts +++ b/examples/folio/app/shared/build.gradle.kts @@ -49,6 +49,9 @@ kotlin { implementation(libs.lifecycle.viewmodel.compose) implementation(libs.navigation.compose) } + commonTest.dependencies { + implementation(kotlin("test")) + } androidMain.dependencies { implementation(libs.androidx.activity.compose) } diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionViewModel.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionViewModel.kt index fbb160d..20337c2 100644 --- a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionViewModel.kt +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionViewModel.kt @@ -3,6 +3,7 @@ package app.folio.feature.ledger import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.folio.core.data.Account +import app.folio.core.data.MAX_TRANSACTION_AMOUNT_CENTS import app.folio.core.data.Repository import app.folio.core.data.TxnType import app.folio.navigation.Navigator @@ -87,6 +88,10 @@ class AddTransactionViewModel( form.update { it.copy(error = "Amount must be greater than zero") } return } + if (cents > MAX_TRANSACTION_AMOUNT_CENTS) { + form.update { it.copy(error = "Amount is too large (max \$1,000,000.00)") } + return + } viewModelScope.launch { try { repository.createTransaction(accountId, s.type, cents, s.note) diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/util/Format.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/util/Format.kt index d40c9d4..bb0cb29 100644 --- a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/util/Format.kt +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/util/Format.kt @@ -54,9 +54,8 @@ fun parseCents(input: String): Long? { val fracPadded = (frac + "00").substring(0, 2) val wholeLong = whole.toLongOrNull() ?: return null val fracLong = fracPadded.toLongOrNull() ?: return null - val total = wholeLong * 100 + fracLong - if (total < 0) return null - return total + if (wholeLong > (Long.MAX_VALUE - fracLong) / 100) return null + return wholeLong * 100 + fracLong } fun signedAmount(t: Transaction): Long = if (t.type == TxnType.credit) t.amount else -t.amount diff --git a/examples/folio/app/shared/src/commonTest/kotlin/app/folio/util/ParseCentsTest.kt b/examples/folio/app/shared/src/commonTest/kotlin/app/folio/util/ParseCentsTest.kt new file mode 100644 index 0000000..1580a34 --- /dev/null +++ b/examples/folio/app/shared/src/commonTest/kotlin/app/folio/util/ParseCentsTest.kt @@ -0,0 +1,38 @@ +package app.folio.util + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ParseCentsTest { + @Test + fun parsesEverydayAmountsExactly() { + assertEquals(1L, parseCents("0.01")) + assertEquals(1234L, parseCents("12.34")) + assertEquals(1250L, parseCents("12.5")) + assertEquals(1200L, parseCents("12.0")) + assertEquals(100000L, parseCents("1000")) + assertEquals(123456L, parseCents("1,234.56")) + } + + @Test + fun rejectsEighteenDigitWholeThatWrapsToAPositiveLong() { + assertNull(parseCents("999999999999999999")) + } + + @Test + fun rejectsSeventeenDigitWholeThatWrapsToANegativeLong() { + assertNull(parseCents("99999999999999999")) + } + + @Test + fun rejectsNineteenDigitWholeThatNoLongerFitsALong() { + assertNull(parseCents("9999999999999999999")) + } + + @Test + fun acceptsTheLargestRepresentableAmountAndRejectsOneCentMore() { + assertEquals(Long.MAX_VALUE, parseCents("92233720368547758.07")) + assertNull(parseCents("92233720368547758.08")) + } +} diff --git a/examples/folio/core/build.gradle.kts b/examples/folio/core/build.gradle.kts index 375fba2..db2e18b 100644 --- a/examples/folio/core/build.gradle.kts +++ b/examples/folio/core/build.gradle.kts @@ -35,6 +35,10 @@ kotlin { api(libs.sqldelight.coroutines.extensions) api(libs.sqldelight.async.extensions) } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } androidMain.dependencies { implementation(libs.sqldelight.android.driver) } diff --git a/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Repository.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Repository.kt index 020407e..ea8d8e0 100644 --- a/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Repository.kt +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Repository.kt @@ -6,6 +6,8 @@ import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.StateFlow +const val MAX_TRANSACTION_AMOUNT_CENTS = 100_000_000L + @SingleIn(AppScope::class) @Inject class Repository(private val store: LedgerStore) { @@ -27,6 +29,7 @@ class Repository(private val store: LedgerStore) { suspend fun createTransaction(accountId: String, type: TxnType, amount: Long, note: String): Transaction { require(amount > 0) { "Amount must be greater than zero" } + require(amount <= MAX_TRANSACTION_AMOUNT_CENTS) { "Amount is too large (max \$1,000,000.00)" } requireNotNull(getAccount(accountId)) { "Account not found" } val txn = Transaction( id = Platform.makeId(), diff --git a/examples/folio/core/src/commonTest/kotlin/app/folio/core/data/RepositoryTest.kt b/examples/folio/core/src/commonTest/kotlin/app/folio/core/data/RepositoryTest.kt new file mode 100644 index 0000000..65f9d81 --- /dev/null +++ b/examples/folio/core/src/commonTest/kotlin/app/folio/core/data/RepositoryTest.kt @@ -0,0 +1,66 @@ +package app.folio.core.data + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +private class FakeLedgerStore : LedgerStore { + override val accounts = MutableStateFlow>(emptyList()) + override val transactions = MutableStateFlow>(emptyList()) + override val session = MutableStateFlow(null) + + override suspend fun accountExistsByName(name: String): Boolean = + accounts.value.any { it.name == name } + + override suspend fun insertAccount(id: String, name: String, createdAt: Long) { + accounts.value = accounts.value + Account(id, name, createdAt) + } + + override suspend fun insertTxn( + id: String, + accountId: String, + type: TxnType, + amount: Long, + note: String, + createdAt: Long, + ) { + transactions.value = transactions.value + Transaction(id, accountId, type, amount, note, createdAt) + } + + override suspend fun upsertSession(user: String, loggedInAt: Long) { + session.value = Session(user, loggedInAt) + } + + override suspend fun clearSession() { + session.value = null + } +} + +class RepositoryTest { + @Test + fun rejectsAmountAboveOneMillionDollars() = runTest { + val repository = Repository(FakeLedgerStore()) + val account = repository.createAccount("Checking") + + assertFailsWith { + repository.createTransaction(account.id, TxnType.credit, 100_000_001L, "") + } + assertFailsWith { + repository.createTransaction(account.id, TxnType.credit, Long.MAX_VALUE, "") + } + assertTrue(repository.transactions.value.isEmpty()) + } + + @Test + fun acceptsAmountAtOneMillionDollars() = runTest { + val repository = Repository(FakeLedgerStore()) + val account = repository.createAccount("Checking") + + repository.createTransaction(account.id, TxnType.credit, 100_000_000L, "rent") + + assertEquals(listOf(100_000_000L), repository.transactions.value.map { it.amount }) + } +} diff --git a/examples/folio/gradle/libs.versions.toml b/examples/folio/gradle/libs.versions.toml index 5e4f40b..4561bdb 100644 --- a/examples/folio/gradle/libs.versions.toml +++ b/examples/folio/gradle/libs.versions.toml @@ -14,6 +14,7 @@ sqlite-wasm = "3.53.0-build1" [libraries] kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }