Skip to content
This repository has been archived by the owner on Nov 5, 2024. It is now read-only.

Commit

Permalink
More integration tests (#2899)
Browse files Browse the repository at this point in the history
* WIP: Backups androidTest + extract the build logic for integration testing

* Make the test work

* Finish the e2e integration test

* WIP: Simplify the BackupDataUseCase

* Refactor `IvyFileReader`

* Improve the `BackupDataUseCaseAndroidTest`

* Fix Detekt errors
  • Loading branch information
ILIYANGERMANOV authored Jan 28, 2024
1 parent d41c3f5 commit afe9ce2
Show file tree
Hide file tree
Showing 12 changed files with 268 additions and 71 deletions.
19 changes: 19 additions & 0 deletions buildSrc/src/main/kotlin/ivy.integration.testing.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
plugins {
id("ivy.feature")
}

android {
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

packaging {
resources.pickFirsts.add("win32-x86-64/attach_hotspot_windows.dll")
resources.pickFirsts.add("win32-x86/attach_hotspot_windows.dll")
resources.pickFirsts.add("META-INF/**")
}
}

dependencies {
androidTestImplementation(libs.bundles.integration.testing)
}
5 changes: 2 additions & 3 deletions ivy-data/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
plugins {
id("ivy.feature")
id("ivy.room")
id("ivy.integration.testing")
}

android {
namespace = "com.ivy.data"
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}

dependencies {
Expand All @@ -17,5 +15,6 @@ dependencies {
implementation(libs.bundles.ktor)

androidTestImplementation(libs.bundles.integration.testing)
androidTestImplementation(projects.ivyTesting)
testImplementation(projects.ivyTesting)
}
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package com.ivy.data.backup

import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.ivy.base.di.KotlinxSerializationModule
import com.ivy.base.legacy.SharedPrefs
import com.ivy.data.db.IvyRoomDatabase
import com.ivy.data.file.IvyFileReader
import com.ivy.testing.TestDispatchersProvider
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.ints.shouldBeGreaterThan
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File

@RunWith(AndroidJUnit4::class)
class BackupDataUseCaseAndroidTest {

private lateinit var db: IvyRoomDatabase
private lateinit var useCase: BackupDataUseCase

@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, IvyRoomDatabase::class.java).build()
val appContext = InstrumentationRegistry.getInstrumentation().context
useCase = BackupDataUseCase(
accountDao = db.accountDao,
budgetDao = db.budgetDao,
categoryDao = db.categoryDao,
loanRecordDao = db.loanRecordDao,
loanDao = db.loanDao,
plannedPaymentRuleDao = db.plannedPaymentRuleDao,
settingsDao = db.settingsDao,
transactionDao = db.transactionDao,
sharedPrefs = SharedPrefs(appContext),
accountWriter = db.writeAccountDao,
categoryWriter = db.writeCategoryDao,
transactionWriter = db.writeTransactionDao,
settingsWriter = db.writeSettingsDao,
budgetWriter = db.writeBudgetDao,
loanWriter = db.writeLoanDao,
loanRecordWriter = db.writeLoanRecordDao,
plannedPaymentRuleWriter = db.writePlannedPaymentRuleDao,
context = appContext,
json = KotlinxSerializationModule.provideJson(),
dispatchersProvider = TestDispatchersProvider,
fileReader = IvyFileReader(appContext)
)
}

@After
fun closeDb() {
db.close()
}

@Test
fun backup450_150() = runBlocking {
backupTestCase("450-150")
}

private suspend fun backupTestCase(version: String) {
importBackupZipTestCase(version)
importBackupJsonTestCase(version)

// close and re-open the db to ensure fresh data
closeDb()
createDb()
exportsAndImportsTestCase(version)
}

private suspend fun importBackupZipTestCase(version: String) {
// given
val backupUri = copyTestResourceToInternalStorage("backups/$version.zip")

// when
val res = useCase.importBackupFile(backupUri, onProgress = {})

// then
res.shouldBeSuccessful()
}

private suspend fun importBackupJsonTestCase(version: String) {
// given
val backupUri = copyTestResourceToInternalStorage("backups/$version.json")

// when
val res = useCase.importBackupFile(backupUri, onProgress = {})

// then
res.shouldBeSuccessful()
}

private suspend fun exportsAndImportsTestCase(version: String) {
// given
val backupUri = copyTestResourceToInternalStorage("backups/$version.zip")
// preload data
useCase.importBackupFile(backupUri, onProgress = {}).shouldBeSuccessful()
val exportedFileUri = tempAndroidFile("exported", ".zip").toUri()

// then
useCase.exportToFile(exportedFileUri)
val reImportRes = useCase.importBackupFile(backupUri, onProgress = {})

// then
reImportRes.shouldBeSuccessful()
}

private fun copyTestResourceToInternalStorage(resPath: String): Uri {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val assetManager = context.assets
val inputStream = assetManager.open(resPath)
val outputFile = tempAndroidFile("temp-backup", resPath.split(".").last())
outputFile.outputStream().use { fileOut ->
fileOut.write(inputStream.readBytes())
}
return Uri.fromFile(outputFile)
}

private fun tempAndroidFile(prefix: String, suffix: String): File {
val context = InstrumentationRegistry.getInstrumentation().targetContext
return File.createTempFile(prefix, suffix, context.filesDir)
}

private fun ImportResult.shouldBeSuccessful() {
failedRows.shouldBeEmpty()
categoriesImported shouldBeGreaterThan 0
accountsImported shouldBeGreaterThan 0
transactionsImported shouldBeGreaterThan 0
}
}
62 changes: 35 additions & 27 deletions ivy-data/src/main/java/com/ivy/data/backup/BackupDataUseCase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import com.ivy.base.legacy.SharedPrefs
import com.ivy.base.legacy.readFile
import com.ivy.base.legacy.unzip
import com.ivy.base.legacy.zip
import com.ivy.base.threading.DispatchersProvider
Expand All @@ -24,6 +23,7 @@ import com.ivy.data.db.dao.write.WriteLoanRecordDao
import com.ivy.data.db.dao.write.WritePlannedPaymentRuleDao
import com.ivy.data.db.dao.write.WriteSettingsDao
import com.ivy.data.db.dao.write.WriteTransactionDao
import com.ivy.data.file.IvyFileReader
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.async
Expand Down Expand Up @@ -58,6 +58,7 @@ class BackupDataUseCase @Inject constructor(
private val context: Context,
private val json: Json,
private val dispatchersProvider: DispatchersProvider,
private val fileReader: IvyFileReader,
) {
suspend fun exportToFile(
zipFileUri: Uri
Expand Down Expand Up @@ -125,38 +126,15 @@ class BackupDataUseCase @Inject constructor(
return hashmap
}

suspend fun import(
suspend fun importBackupFile(
backupFileUri: Uri,
onProgress: suspend (progressPercent: Double) -> Unit
): ImportResult = withContext(dispatchersProvider.io) {
return@withContext try {
val jsonString = try {
val folderName = "backup" + System.currentTimeMillis()
val cacheFolderPath = File(context.cacheDir, folderName)

unzip(context, backupFileUri, cacheFolderPath)

val filesArray = cacheFolderPath.listFiles()

onProgress(0.05)

if (filesArray == null || filesArray.isEmpty()) {
error("Couldn't unzip")
}

val filesList = filesArray.toList().filter {
hasJsonExtension(it)
}

onProgress(0.1)

if (filesList.size != 1) {
error("Didn't unzip exactly one file.")
}

readFile(context, filesList[0].toUri(), Charsets.UTF_16)
extractAndReadBackupZip(backupFileUri, onProgress)
} catch (e: Exception) {
readFile(context, backupFileUri, Charsets.UTF_16)
fileReader.read(backupFileUri, Charsets.UTF_16).getOrNull()
} ?: ""

importJson(jsonString, onProgress, clearCacheDir = true)
Expand All @@ -172,6 +150,36 @@ class BackupDataUseCase @Inject constructor(
}
}

private suspend fun extractAndReadBackupZip(
backupFileUri: Uri,
onProgress: suspend (progressPercent: Double) -> Unit
): String? {
val folderName = "backup" + System.currentTimeMillis()
val cacheFolderPath = File(context.cacheDir, folderName)

unzip(context, backupFileUri, cacheFolderPath)

val filesArray = cacheFolderPath.listFiles()

onProgress(0.05)

if (filesArray == null || filesArray.isEmpty()) {
error("Couldn't unzip")
}

val filesList = filesArray.toList().filter {
hasJsonExtension(it)
}

onProgress(0.1)

if (filesList.size != 1) {
error("Didn't unzip exactly one file.")
}

return fileReader.read(filesList[0].toUri(), Charsets.UTF_16).getOrNull()
}

suspend fun importJson(
jsonString: String,
onProgress: suspend (Double) -> Unit = {},
Expand Down
66 changes: 66 additions & 0 deletions ivy-data/src/main/java/com/ivy/data/file/IvyFileReader.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.ivy.data.file

import android.content.Context
import android.net.Uri
import arrow.core.Either
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.BufferedReader
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStreamReader
import java.nio.charset.Charset
import javax.inject.Inject

class IvyFileReader @Inject constructor(
@ApplicationContext
private val appContext: Context
) {
fun read(
uri: Uri,
charset: Charset = Charsets.UTF_8
): Either<Failure, String> {
return try {
val contentResolver = appContext.contentResolver
var fileContent: String? = null

contentResolver.openFileDescriptor(uri, "r")?.use {
FileInputStream(it.fileDescriptor).use { fileInputStream ->
fileContent = readFileContent(
fileInputStream = fileInputStream,
charset = charset
)
}
}

Either.Right(fileContent!!)
} catch (e: FileNotFoundException) {
Either.Left(Failure.FileNotFound(e))
} catch (e: Exception) {
Either.Left(Failure.IO(e))
}
}

@Throws(IOException::class)
private fun readFileContent(
fileInputStream: FileInputStream,
charset: Charset
): String {
BufferedReader(InputStreamReader(fileInputStream, charset)).use { br ->
val sb = StringBuilder()
var line: String?
while (br.readLine().also { line = it } != null) {
sb.append(line)
sb.append('\n')
}
return sb.toString()
}
}

sealed interface Failure {
val e: Throwable

data class FileNotFound(override val e: Throwable) : Failure
data class IO(override val e: Throwable) : Failure
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class BackupDataUseCaseTest : FreeSpec({
sharedPrefs = mockk(relaxed = true),
json = KotlinxSerializationModule.provideJson(),
dispatchersProvider = TestDispatchersProvider,
fileReader = mockk(relaxed = true)
)

suspend fun backupTestCase(backupVersion: String) {
Expand Down
10 changes: 0 additions & 10 deletions ivy-testing/src/main/java/com/ivy/testing/TestResourceUtil.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,6 @@
package com.ivy.testing

import java.io.File
import java.io.FileInputStream

fun testResourceInputStream(resPath: String): FileInputStream {
try {
val file = testResource(resPath)
return FileInputStream(file)
} catch (e: Exception) {
throw TestResourceLoadException(resPath, e)
}
}

fun testResource(resPath: String): File {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import com.ivy.importdata.csv.domain.parseToAccount
import com.ivy.importdata.csv.domain.parseToAccountCurrency
import com.ivy.importdata.csv.domain.parseTransactionType
import com.ivy.navigation.Navigation
import com.ivy.wallet.domain.deprecated.logic.csv.IvyFileReader
import com.ivy.data.file.IvyFileReader
import com.opencsv.CSVReaderBuilder
import com.opencsv.validators.LineValidator
import com.opencsv.validators.RowValidator
Expand Down Expand Up @@ -463,7 +463,7 @@ class CSVViewModel @Inject constructor(
charset: Charset = Charsets.UTF_8
): List<CSVRow>? {
return try {
val fileContent = fileReader.read(uri, charset) ?: return null
val fileContent = fileReader.read(uri, charset).getOrNull() ?: return null
parseCSV(fileContent, normalizeCSV).takeIf { it.isNotEmpty() }
} catch (e: Exception) {
if (charset != Charsets.UTF_16) {
Expand Down
Loading

0 comments on commit afe9ce2

Please sign in to comment.