diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce81b273bb..a6afd5c4cb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,8 +19,8 @@ paparazzi = "1.3.3" # Android min-sdk = "28" compile-sdk = "34" -version-name = "4.6.2" -version-code = "162" +version-name = "4.6.3" +version-code = "163" jvm-target = "17" diff --git a/screen/home/src/main/java/com/ivy/home/customerjourney/CustomerJourneyCardsProvider.kt b/screen/home/src/main/java/com/ivy/home/customerjourney/CustomerJourneyCardsProvider.kt index a7dec7e364..cb40fe7c05 100644 --- a/screen/home/src/main/java/com/ivy/home/customerjourney/CustomerJourneyCardsProvider.kt +++ b/screen/home/src/main/java/com/ivy/home/customerjourney/CustomerJourneyCardsProvider.kt @@ -1,5 +1,6 @@ package com.ivy.home.customerjourney +import com.ivy.base.legacy.SharedPrefs import com.ivy.base.legacy.stringRes import com.ivy.base.model.TransactionType import com.ivy.data.db.dao.read.PlannedPaymentRuleDao @@ -15,7 +16,6 @@ import com.ivy.design.l0_system.Red import com.ivy.design.l0_system.Red3 import com.ivy.legacy.Constants import com.ivy.legacy.IvyWalletCtx -import com.ivy.base.legacy.SharedPrefs import com.ivy.legacy.data.model.MainTab import com.ivy.navigation.EditPlannedScreen import com.ivy.navigation.PieChartStatisticScreen @@ -65,6 +65,7 @@ class CustomerJourneyCardsProvider @Inject constructor( rateUsCard_2(), joinTelegram2(), ivyWalletIsOpenSource(), + bugsApology(), ) fun adjustBalanceCard() = CustomerJourneyCardModel( @@ -231,5 +232,27 @@ class CustomerJourneyCardsProvider @Inject constructor( ivyActivity.openUrlInBrowser(Constants.URL_IVY_TELEGRAM_INVITE) } ) + + fun bugsApology(): CustomerJourneyCardModel = CustomerJourneyCardModel( + id = "bugs_apology_1", + condition = { trnCount, _, _ -> + trnCount > 10 + }, + title = "Apologies for the bugs!", + description = "Ivy Wallet v4.6.2 had some annoying bugs... " + + "We're sorry for that and we hope that we have fixed them.\n\n" + + "Ivy Wallet is an open-source and community-driven project " + + "that is maintained and develop solely by voluntary contributors. " + + "So to help us and make your experience better, " + + "please report any bugs as a GitHub issue. You can also" + + " join our community and become a contributor!", + cta = "Report a bug", + ctaIcon = R.drawable.github_logo, + background = Gradient.solid(Blue), + hasDismiss = true, + onAction = { _, _, ivyActivity -> + ivyActivity.openUrlInBrowser(Constants.URL_GITHUB_NEW_ISSUE) + } + ) } } diff --git a/shared/data/core/src/test/java/com/ivy/data/ArbAccountEntity.kt b/shared/data/core/src/test/java/com/ivy/data/ArbAccountEntity.kt new file mode 100644 index 0000000000..69dca59ab7 --- /dev/null +++ b/shared/data/core/src/test/java/com/ivy/data/ArbAccountEntity.kt @@ -0,0 +1,36 @@ +package com.ivy.data + +import com.ivy.data.db.entity.AccountEntity +import com.ivy.data.model.testing.colorInt +import com.ivy.data.model.testing.iconAsset +import com.ivy.data.model.testing.maybe +import com.ivy.data.model.testing.notBlankTrimmedString +import io.kotest.property.Arb +import io.kotest.property.arbitrary.arbitrary +import io.kotest.property.arbitrary.boolean +import io.kotest.property.arbitrary.double +import io.kotest.property.arbitrary.of +import io.kotest.property.arbitrary.removeEdgecases +import io.kotest.property.arbitrary.string +import io.kotest.property.arbitrary.uuid + +fun Arb.Companion.invalidAccountEntity(): Arb = arbitrary { + val validEntity = validAccountEntity().bind() + validEntity.copy( + name = Arb.of("", " ", " ").bind() + ) +} + +fun Arb.Companion.validAccountEntity(): Arb = arbitrary { + AccountEntity( + name = Arb.notBlankTrimmedString().bind().value, + currency = Arb.maybe(Arb.string()).bind(), + color = Arb.colorInt().bind().value, + icon = Arb.iconAsset().bind().id, + orderNum = Arb.double().removeEdgecases().bind(), + includeInBalance = Arb.boolean().bind(), + isSynced = Arb.boolean().bind(), + isDeleted = Arb.boolean().bind(), + id = Arb.uuid().bind() + ) +} \ No newline at end of file diff --git a/shared/data/core/src/test/java/com/ivy/data/repository/mapper/AccountMapperPropertyTest.kt b/shared/data/core/src/test/java/com/ivy/data/repository/mapper/AccountMapperPropertyTest.kt new file mode 100644 index 0000000000..76b768b453 --- /dev/null +++ b/shared/data/core/src/test/java/com/ivy/data/repository/mapper/AccountMapperPropertyTest.kt @@ -0,0 +1,80 @@ +package com.ivy.data.repository.mapper + +import com.ivy.data.invalidAccountEntity +import com.ivy.data.model.primitive.AssetCode +import com.ivy.data.model.testing.account +import com.ivy.data.repository.CurrencyRepository +import com.ivy.data.validAccountEntity +import io.kotest.assertions.arrow.core.shouldBeLeft +import io.kotest.assertions.arrow.core.shouldBeRight +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.checkAll +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class AccountMapperPropertyTest { + + private val currencyRepository = mockk() + + private lateinit var mapper: AccountMapper + + @Before + fun mapper() { + mapper = AccountMapper( + currencyRepository = currencyRepository, + ) + } + + @Test + fun `property - domain-entity isomorphism`() = runTest { + checkAll(Arb.account()) { accOrig -> + with(mapper) { + // when: domain -> entity -> domain + val entityOne = accOrig.toEntity() + val accTwo = entityOne.toDomain().getOrNull() + + // then: the recovered domain trn must be the same + accTwo.shouldNotBeNull() shouldBe accOrig + + // and when again: domain -> entity + val entityTwo = accTwo.toEntity() + + // then: the recovered entity must be the same + entityTwo shouldBe entityOne + } + } + } + + @Test + fun `maps invalid accounts - always fails`() = runTest { + checkAll(Arb.invalidAccountEntity()) { entity -> + // given + coEvery { currencyRepository.getBaseCurrency() } returns AssetCode.EUR + + // when + val res = with(mapper) { entity.toDomain() } + + // then + res.shouldBeLeft() + } + } + + @Test + fun `maps valid accounts - always succeeds`() = runTest { + checkAll(Arb.validAccountEntity()) { entity -> + // given + coEvery { currencyRepository.getBaseCurrency() } returns AssetCode.EUR + + // when + val res = with(mapper) { entity.toDomain() } + + // then + res.shouldBeRight() + } + } +} \ No newline at end of file diff --git a/shared/data/core/src/test/java/com/ivy/data/repository/mapper/AccountMapperTest.kt b/shared/data/core/src/test/java/com/ivy/data/repository/mapper/AccountMapperTest.kt index b3a91847cf..94a5290573 100644 --- a/shared/data/core/src/test/java/com/ivy/data/repository/mapper/AccountMapperTest.kt +++ b/shared/data/core/src/test/java/com/ivy/data/repository/mapper/AccountMapperTest.kt @@ -1,5 +1,7 @@ package com.ivy.data.repository.mapper +import com.google.testing.junit.testparameterinjector.TestParameter +import com.google.testing.junit.testparameterinjector.TestParameterInjector import com.ivy.data.db.entity.AccountEntity import com.ivy.data.model.Account import com.ivy.data.model.AccountId @@ -7,6 +9,7 @@ import com.ivy.data.model.primitive.AssetCode import com.ivy.data.model.primitive.ColorInt import com.ivy.data.model.primitive.IconAsset import com.ivy.data.model.primitive.NotBlankTrimmedString +import com.ivy.data.model.testing.ModelFixtures import com.ivy.data.repository.CurrencyRepository import io.kotest.assertions.arrow.core.shouldBeLeft import io.kotest.assertions.arrow.core.shouldBeRight @@ -16,9 +19,11 @@ import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith import java.time.Instant import java.util.UUID +@RunWith(TestParameterInjector::class) class AccountMapperTest { private val currencyRepository = mockk(relaxed = true) @@ -31,19 +36,21 @@ class AccountMapperTest { } @Test - fun `maps domain to entity`() { + fun `maps domain to entity`( + @TestParameter includeInBalance: Boolean, + @TestParameter removed: Boolean, + ) { // given - val id = UUID.randomUUID() val account = Account( - id = AccountId(id), + id = ModelFixtures.AccountId, name = NotBlankTrimmedString.unsafe("Test"), asset = AssetCode.unsafe("USD"), color = ColorInt(value = 42), icon = IconAsset.unsafe("icon"), - includeInBalance = true, + includeInBalance = includeInBalance, orderNum = 3.14, lastUpdated = Instant.EPOCH, - removed = false + removed = removed ) // when @@ -55,20 +62,25 @@ class AccountMapperTest { currency = "USD", color = 42, icon = "icon", - includeInBalance = true, + includeInBalance = includeInBalance, orderNum = 3.14, isSynced = true, - isDeleted = false, - id = id, + isDeleted = removed, + id = ModelFixtures.AccountId.value, ) } // region entity to domain @Test - fun `maps entity to domain - valid entity`() = runTest { + fun `maps entity to domain - valid entity`( + @TestParameter includeInBalance: Boolean, + @TestParameter removed: Boolean, + ) = runTest { // given val entity = ValidEntity.copy( - orderNum = 42.0 + orderNum = 42.0, + includeInBalance = includeInBalance, + isDeleted = removed, ) // when @@ -81,10 +93,10 @@ class AccountMapperTest { asset = AssetCode.unsafe("USD"), color = ColorInt(value = 42), icon = IconAsset.unsafe("icon"), - includeInBalance = true, + includeInBalance = includeInBalance, orderNum = 42.0, lastUpdated = Instant.EPOCH, - removed = false + removed = removed ) } diff --git a/shared/data/model-testing/src/main/java/com/ivy/data/model/testing/ArbCategory.kt b/shared/data/model-testing/src/main/java/com/ivy/data/model/testing/ArbCategory.kt index a2f76e1a6a..285fbc97ed 100644 --- a/shared/data/model-testing/src/main/java/com/ivy/data/model/testing/ArbCategory.kt +++ b/shared/data/model-testing/src/main/java/com/ivy/data/model/testing/ArbCategory.kt @@ -1,8 +1,31 @@ package com.ivy.data.model.testing +import arrow.core.None +import arrow.core.Option +import arrow.core.getOrElse +import com.ivy.data.model.Category import com.ivy.data.model.CategoryId import io.kotest.property.Arb +import io.kotest.property.arbitrary.arbitrary +import io.kotest.property.arbitrary.boolean +import io.kotest.property.arbitrary.double +import io.kotest.property.arbitrary.instant import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.removeEdgecases import io.kotest.property.arbitrary.uuid +fun Arb.Companion.category( + categoryId: Option = None, +): Arb = arbitrary { + Category( + id = categoryId.getOrElse { Arb.categoryId().bind() }, + name = Arb.notBlankTrimmedString().bind(), + color = Arb.colorInt().bind(), + icon = Arb.maybe(Arb.iconAsset()).bind(), + orderNum = Arb.double().removeEdgecases().bind(), + lastUpdated = Arb.instant().bind(), + removed = Arb.boolean().bind() + ) +} + fun Arb.Companion.categoryId(): Arb = Arb.uuid().map(::CategoryId) \ No newline at end of file diff --git a/shared/data/model-testing/src/test/java/com/ivy/data/model/testing/ArbCategoryTest.kt b/shared/data/model-testing/src/test/java/com/ivy/data/model/testing/ArbCategoryTest.kt new file mode 100644 index 0000000000..0df50f9e28 --- /dev/null +++ b/shared/data/model-testing/src/test/java/com/ivy/data/model/testing/ArbCategoryTest.kt @@ -0,0 +1,28 @@ +package com.ivy.data.model.testing + +import arrow.core.Some +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.checkAll +import io.kotest.property.forAll +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ArbCategoryTest { + + @Test + fun `generates arb category`() = runTest { + forAll(Arb.category()) { + true + } + } + + @Test + fun `arb category respects passed param`() = runTest { + val categoryId = ModelFixtures.CategoryId + + checkAll(Arb.category(categoryId = Some(categoryId))) { category -> + category.id shouldBe categoryId + } + } +} \ No newline at end of file diff --git a/shared/data/model-testing/src/test/java/com/ivy/data/model/testing/ArbTransactionTest.kt b/shared/data/model-testing/src/test/java/com/ivy/data/model/testing/ArbTransactionTest.kt index dc5ff36d33..5c29c6781a 100644 --- a/shared/data/model-testing/src/test/java/com/ivy/data/model/testing/ArbTransactionTest.kt +++ b/shared/data/model-testing/src/test/java/com/ivy/data/model/testing/ArbTransactionTest.kt @@ -109,8 +109,8 @@ class ArbTransactionTest { @Test fun `generates arb transfer`() = runTest { - forAll(Arb.transfer()) { - true + forAll(Arb.transfer()) { transfer -> + transfer.fromAccount != transfer.toAccount } } diff --git a/shared/domain/build.gradle.kts b/shared/domain/build.gradle.kts index c1f945ce96..daf93820b8 100644 --- a/shared/domain/build.gradle.kts +++ b/shared/domain/build.gradle.kts @@ -16,6 +16,9 @@ dependencies { implementation(libs.bundles.ktor) implementation(libs.bundles.opencsv) + testImplementation(projects.shared.data.modelTesting) + testImplementation(projects.shared.data.coreTesting) + androidTestImplementation(libs.bundles.integration.testing) androidTestImplementation(libs.mockk.android) } \ No newline at end of file diff --git a/shared/domain/src/main/java/com/ivy/domain/usecase/csv/ExportCsvUseCase.kt b/shared/domain/src/main/java/com/ivy/domain/usecase/csv/ExportCsvUseCase.kt index a29380c39b..27b59c1db1 100644 --- a/shared/domain/src/main/java/com/ivy/domain/usecase/csv/ExportCsvUseCase.kt +++ b/shared/domain/src/main/java/com/ivy/domain/usecase/csv/ExportCsvUseCase.kt @@ -118,12 +118,13 @@ class ExportCsvUseCase @Inject constructor( } private fun String.escapeCsvString(): String = try { - StringEscapeUtils.escapeCsv(this) + StringEscapeUtils.escapeCsv(this).escapeSpecialChars() } catch (e: Exception) { - replace(CSV_SEPARATOR, " ") - .replace(NEWLINE, " ") + escapeSpecialChars() } + private fun String.escapeSpecialChars(): String = replace("\\", "") + private fun Transaction.toIvyCsvRow(): IvyCsvRow = when (this) { is Expense -> expenseCsvRow() is Income -> incomeCsvRow() diff --git a/shared/domain/src/main/java/com/ivy/domain/usecase/csv/ReadCsvUseCase.kt b/shared/domain/src/main/java/com/ivy/domain/usecase/csv/ReadCsvUseCase.kt new file mode 100644 index 0000000000..07a6bea811 --- /dev/null +++ b/shared/domain/src/main/java/com/ivy/domain/usecase/csv/ReadCsvUseCase.kt @@ -0,0 +1,36 @@ +package com.ivy.domain.usecase.csv + +import com.opencsv.CSVReaderBuilder +import com.opencsv.validators.LineValidator +import com.opencsv.validators.RowValidator +import java.io.StringReader +import javax.inject.Inject + +class ReadCsvUseCase @Inject constructor() { + + fun readCsv(csv: String): List> { + val csvReader = CSVReaderBuilder(StringReader(csv)) + .withLineValidator(object : LineValidator { + override fun isValid(line: String?): Boolean { + return true + } + + override fun validate(line: String?) { + // do nothing + } + }) + .withRowValidator(object : RowValidator { + override fun isValid(row: Array?): Boolean { + return true + } + + override fun validate(row: Array?) { + // do nothing + } + }) + .build() + + return csvReader.readAll() + .map { it.toList() } + } +} \ No newline at end of file diff --git a/shared/domain/src/test/java/com/ivy/domain/usecase/csv/ExportCsvUseCasePropertyTest.kt b/shared/domain/src/test/java/com/ivy/domain/usecase/csv/ExportCsvUseCasePropertyTest.kt new file mode 100644 index 0000000000..b3e810e6e7 --- /dev/null +++ b/shared/domain/src/test/java/com/ivy/domain/usecase/csv/ExportCsvUseCasePropertyTest.kt @@ -0,0 +1,86 @@ +package com.ivy.domain.usecase.csv + +import arrow.core.Some +import com.ivy.base.TestDispatchersProvider +import com.ivy.data.file.FileSystem +import com.ivy.data.model.Transaction +import com.ivy.data.model.getFromAccount +import com.ivy.data.model.getToAccount +import com.ivy.data.model.testing.account +import com.ivy.data.model.testing.category +import com.ivy.data.model.testing.transaction +import com.ivy.data.repository.AccountRepository +import com.ivy.data.repository.CategoryRepository +import com.ivy.data.repository.TransactionRepository +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.list +import io.kotest.property.arbitrary.next +import io.kotest.property.checkAll +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class ExportCsvUseCasePropertyTest { + + private val accountRepository = mockk() + private val categoryRepository = mockk(relaxed = true) + private val transactionRepository = mockk() + private val fileSystem = mockk() + + private lateinit var useCase: ExportCsvUseCase + + @Before + fun setup() { + useCase = ExportCsvUseCase( + accountRepository = accountRepository, + categoryRepository = categoryRepository, + transactionRepository = transactionRepository, + dispatchers = TestDispatchersProvider, + fileSystem = fileSystem, + ) + } + + @Test + fun `property - num of row and columns matches the format`() = runTest { + checkAll(Arb.list(Arb.transaction())) { trns -> + // given + val accounts = trns.flatMap { + listOfNotNull(it.getFromAccount(), it.getToAccount()) + }.map { + Arb.account(accountId = Some(it)).next() + } + coEvery { accountRepository.findAll(any()) } returns accounts + val categories = trns + .mapNotNull(Transaction::category) + .map { + Arb.category(categoryId = Some(it)).next() + }.run { + if (isNotEmpty()) { + drop(Arb.int(indices).bind()).shuffled() + } else { + this + } + } + coEvery { categoryRepository.findAll(any()) } returns categories + + // when + val csv = useCase.exportCsv { trns } + + // then + val rows = ReadCsvUseCase().readCsv(csv) + rows.size shouldBe trns.size + 1 // +1 for the header + rows.forEach { row -> + // Matches the expected # of columns + val hasExpectedNumOfColumns = row.size == IvyCsvRow.Columns.size + if (!hasExpectedNumOfColumns) { + println("(${row.size} cols) $row") + } + hasExpectedNumOfColumns shouldBe true + } + } + } +} \ No newline at end of file