feat: JSON-Export & Import (Roundtrip-Serialisierung) #21
- ExportData/CategoryExport/LocationExport/ItemExport/SettingExport mit @Serializable - ImportExportRepository-Interface (exportToJson/importFromJson: Result<Unit>) - ImportExportRepositoryImpl mit atomarer Transaktion via DatabaseTransaction - ignoreUnknownKeys=true + Versions-Check (version==1) - @Upsert upsertAll() in CategoryDao, LocationDao, ItemDao, SettingsDao - DI-Binding in RepositoryModule + DatabaseTransaction in DatabaseModule - 5 Unit-Tests (29 passed total)
This commit is contained in:
parent
b7cc8db80a
commit
5825b0351c
6 changed files with 282 additions and 25 deletions
|
|
@ -0,0 +1,5 @@
|
|||
package de.krisenvorrat.app.data.export
|
||||
|
||||
internal fun interface DatabaseTransaction {
|
||||
suspend fun execute(block: suspend () -> Unit)
|
||||
}
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
package de.krisenvorrat.app.data.export
|
||||
|
||||
import kotlinx.serialization.EncodeDefault
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
internal data class ExportData(
|
||||
val version: Int = 1,
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val version: Int = 1,
|
||||
val categories: List<CategoryExport>,
|
||||
val locations: List<LocationExport>,
|
||||
val items: List<ItemExport>,
|
||||
|
|
|
|||
|
|
@ -20,10 +20,14 @@ internal class ImportExportRepositoryImpl @Inject constructor(
|
|||
private val categoryDao: CategoryDao,
|
||||
private val locationDao: LocationDao,
|
||||
private val itemDao: ItemDao,
|
||||
private val settingsDao: SettingsDao
|
||||
private val settingsDao: SettingsDao,
|
||||
private val transaction: DatabaseTransaction
|
||||
) : ImportExportRepository {
|
||||
|
||||
private val jsonSerializer = Json { prettyPrint = false }
|
||||
private val jsonSerializer = Json {
|
||||
prettyPrint = false
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
override suspend fun exportToJson(): String = withContext(Dispatchers.IO) {
|
||||
val categories = categoryDao.getAll().first()
|
||||
|
|
@ -55,8 +59,11 @@ internal class ImportExportRepositoryImpl @Inject constructor(
|
|||
jsonSerializer.encodeToString(ExportData.serializer(), exportData)
|
||||
}
|
||||
|
||||
override suspend fun importFromJson(json: String) = withContext(Dispatchers.IO) {
|
||||
val exportData = jsonSerializer.decodeFromString(ExportData.serializer(), json)
|
||||
override suspend fun importFromJson(json: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val exportData = jsonSerializer.decodeFromString<ExportData>(json)
|
||||
check(exportData.version == 1) { "Unsupported export format version: ${exportData.version}" }
|
||||
transaction.execute {
|
||||
categoryDao.upsertAll(exportData.categories.map { CategoryEntity(id = it.id, name = it.name) })
|
||||
locationDao.upsertAll(exportData.locations.map { LocationEntity(id = it.id, name = it.name) })
|
||||
itemDao.upsertAll(exportData.items.map { item ->
|
||||
|
|
@ -78,3 +85,5 @@ internal class ImportExportRepositoryImpl @Inject constructor(
|
|||
settingsDao.upsertAll(exportData.settings.map { SettingsEntity(key = it.key, value = it.value) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package de.krisenvorrat.app.di
|
|||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.withTransaction
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -12,6 +13,7 @@ import de.krisenvorrat.app.data.db.dao.CategoryDao
|
|||
import de.krisenvorrat.app.data.db.dao.ItemDao
|
||||
import de.krisenvorrat.app.data.db.dao.LocationDao
|
||||
import de.krisenvorrat.app.data.db.dao.SettingsDao
|
||||
import de.krisenvorrat.app.data.export.DatabaseTransaction
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
|
|
@ -34,4 +36,9 @@ internal object DatabaseModule {
|
|||
|
||||
@Provides
|
||||
fun provideSettingsDao(db: KrisenvorratDatabase): SettingsDao = db.settingsDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabaseTransaction(db: KrisenvorratDatabase): DatabaseTransaction =
|
||||
DatabaseTransaction { block -> db.withTransaction(block) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@ package de.krisenvorrat.app.domain.repository
|
|||
|
||||
internal interface ImportExportRepository {
|
||||
suspend fun exportToJson(): String
|
||||
suspend fun importFromJson(json: String)
|
||||
suspend fun importFromJson(json: String): Result<Unit>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
package de.krisenvorrat.app.data.export
|
||||
|
||||
import de.krisenvorrat.app.data.db.dao.CategoryDao
|
||||
import de.krisenvorrat.app.data.db.dao.ItemDao
|
||||
import de.krisenvorrat.app.data.db.dao.LocationDao
|
||||
import de.krisenvorrat.app.data.db.dao.SettingsDao
|
||||
import de.krisenvorrat.app.data.db.entity.CategoryEntity
|
||||
import de.krisenvorrat.app.data.db.entity.ItemEntity
|
||||
import de.krisenvorrat.app.data.db.entity.LocationEntity
|
||||
import de.krisenvorrat.app.data.db.entity.SettingsEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.LocalDate
|
||||
|
||||
private class FakeCategoryDao : CategoryDao {
|
||||
private val items = mutableListOf<CategoryEntity>()
|
||||
private val flow = MutableStateFlow<List<CategoryEntity>>(emptyList())
|
||||
|
||||
private fun emit() { flow.value = items.toList() }
|
||||
|
||||
override suspend fun insert(category: CategoryEntity) = throw UnsupportedOperationException()
|
||||
override suspend fun update(category: CategoryEntity) = throw UnsupportedOperationException()
|
||||
override suspend fun delete(category: CategoryEntity) = throw UnsupportedOperationException()
|
||||
override fun getAll(): Flow<List<CategoryEntity>> = flow
|
||||
override suspend fun getById(id: Int): CategoryEntity? = throw UnsupportedOperationException()
|
||||
|
||||
override suspend fun upsertAll(categories: List<CategoryEntity>) {
|
||||
categories.forEach { cat ->
|
||||
val idx = items.indexOfFirst { it.id == cat.id }
|
||||
if (idx >= 0) items[idx] = cat else items.add(cat)
|
||||
}
|
||||
emit()
|
||||
}
|
||||
|
||||
fun getItems(): List<CategoryEntity> = items.toList()
|
||||
}
|
||||
|
||||
private class FakeLocationDao : LocationDao {
|
||||
private val items = mutableListOf<LocationEntity>()
|
||||
private val flow = MutableStateFlow<List<LocationEntity>>(emptyList())
|
||||
|
||||
private fun emit() { flow.value = items.toList() }
|
||||
|
||||
override suspend fun insert(location: LocationEntity) = throw UnsupportedOperationException()
|
||||
override suspend fun update(location: LocationEntity) = throw UnsupportedOperationException()
|
||||
override suspend fun delete(location: LocationEntity) = throw UnsupportedOperationException()
|
||||
override fun getAll(): Flow<List<LocationEntity>> = flow
|
||||
override suspend fun getById(id: Int): LocationEntity? = throw UnsupportedOperationException()
|
||||
|
||||
override suspend fun upsertAll(locations: List<LocationEntity>) {
|
||||
locations.forEach { loc ->
|
||||
val idx = items.indexOfFirst { it.id == loc.id }
|
||||
if (idx >= 0) items[idx] = loc else items.add(loc)
|
||||
}
|
||||
emit()
|
||||
}
|
||||
|
||||
fun getItems(): List<LocationEntity> = items.toList()
|
||||
}
|
||||
|
||||
private class FakeItemDao : ItemDao {
|
||||
private val items = mutableListOf<ItemEntity>()
|
||||
private val flow = MutableStateFlow<List<ItemEntity>>(emptyList())
|
||||
|
||||
private fun emit() { flow.value = items.toList() }
|
||||
|
||||
override suspend fun insert(item: ItemEntity) = throw UnsupportedOperationException()
|
||||
override suspend fun update(item: ItemEntity) = throw UnsupportedOperationException()
|
||||
override suspend fun delete(item: ItemEntity) = throw UnsupportedOperationException()
|
||||
override fun getAll(): Flow<List<ItemEntity>> = flow
|
||||
override suspend fun getById(id: String): ItemEntity? = throw UnsupportedOperationException()
|
||||
override fun getByCategory(categoryId: Int): Flow<List<ItemEntity>> = throw UnsupportedOperationException()
|
||||
override fun getByLocation(locationId: Int): Flow<List<ItemEntity>> = throw UnsupportedOperationException()
|
||||
override fun getExpiringSoonByCutoff(cutoff: LocalDate): Flow<List<ItemEntity>> = throw UnsupportedOperationException()
|
||||
|
||||
override suspend fun upsertAll(items: List<ItemEntity>) {
|
||||
items.forEach { item ->
|
||||
val idx = this.items.indexOfFirst { it.id == item.id }
|
||||
if (idx >= 0) this.items[idx] = item else this.items.add(item)
|
||||
}
|
||||
emit()
|
||||
}
|
||||
|
||||
fun getItems(): List<ItemEntity> = items.toList()
|
||||
}
|
||||
|
||||
private class FakeSettingsDao : SettingsDao {
|
||||
private val items = mutableListOf<SettingsEntity>()
|
||||
private val flow = MutableStateFlow<List<SettingsEntity>>(emptyList())
|
||||
|
||||
private fun emit() { flow.value = items.toList() }
|
||||
|
||||
override suspend fun upsert(setting: SettingsEntity) = throw UnsupportedOperationException()
|
||||
override suspend fun getValue(key: String): String? = throw UnsupportedOperationException()
|
||||
override fun getAll(): Flow<List<SettingsEntity>> = flow
|
||||
|
||||
override suspend fun upsertAll(settings: List<SettingsEntity>) {
|
||||
settings.forEach { setting ->
|
||||
val idx = items.indexOfFirst { it.key == setting.key }
|
||||
if (idx >= 0) items[idx] = setting else items.add(setting)
|
||||
}
|
||||
emit()
|
||||
}
|
||||
|
||||
fun getItems(): List<SettingsEntity> = items.toList()
|
||||
}
|
||||
|
||||
private val passThroughTransaction = DatabaseTransaction { block -> block() }
|
||||
|
||||
private fun buildItemEntity(id: String = "item1") = ItemEntity(
|
||||
id = id,
|
||||
name = "Konserve",
|
||||
categoryId = 1,
|
||||
quantity = 2.0,
|
||||
unit = "Stk",
|
||||
unitPrice = 1.5,
|
||||
kcalPer100g = null,
|
||||
expiryDate = null,
|
||||
locationId = 1,
|
||||
minStock = 1.0,
|
||||
notes = "",
|
||||
lastUpdated = 0L
|
||||
)
|
||||
|
||||
class ImportExportRepositoryImplTest {
|
||||
|
||||
private fun buildRepository(
|
||||
categoryDao: FakeCategoryDao = FakeCategoryDao(),
|
||||
locationDao: FakeLocationDao = FakeLocationDao(),
|
||||
itemDao: FakeItemDao = FakeItemDao(),
|
||||
settingsDao: FakeSettingsDao = FakeSettingsDao()
|
||||
) = ImportExportRepositoryImpl(
|
||||
categoryDao = categoryDao,
|
||||
locationDao = locationDao,
|
||||
itemDao = itemDao,
|
||||
settingsDao = settingsDao,
|
||||
transaction = passThroughTransaction
|
||||
)
|
||||
|
||||
@Test
|
||||
fun test_exportToJson_withAllEntities_producesValidJson() = runBlocking {
|
||||
// Given
|
||||
val categoryDao = FakeCategoryDao()
|
||||
val locationDao = FakeLocationDao()
|
||||
val itemDao = FakeItemDao()
|
||||
val settingsDao = FakeSettingsDao()
|
||||
categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
|
||||
locationDao.upsertAll(listOf(LocationEntity(id = 1, name = "Keller")))
|
||||
itemDao.upsertAll(listOf(buildItemEntity("item1")))
|
||||
settingsDao.upsertAll(listOf(SettingsEntity(key = "theme", value = "dark")))
|
||||
val repository = buildRepository(categoryDao, locationDao, itemDao, settingsDao)
|
||||
|
||||
// When
|
||||
val json = repository.exportToJson()
|
||||
|
||||
// Then
|
||||
assertTrue(json.contains("\"version\":1"))
|
||||
assertTrue(json.contains("Lebensmittel"))
|
||||
assertTrue(json.contains("Keller"))
|
||||
assertTrue(json.contains("item1"))
|
||||
assertTrue(json.contains("dark"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_exportToJson_withExpiryDate_storesIso8601String() = runBlocking {
|
||||
// Given
|
||||
val itemDao = FakeItemDao()
|
||||
itemDao.upsertAll(listOf(buildItemEntity("exp1").copy(expiryDate = LocalDate.of(2026, 12, 31))))
|
||||
val repository = buildRepository(itemDao = itemDao)
|
||||
|
||||
// When
|
||||
val json = repository.exportToJson()
|
||||
|
||||
// Then
|
||||
assertTrue(json.contains("\"2026-12-31\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_importFromJson_withValidJson_upsertAllEntities() = runBlocking {
|
||||
// Given
|
||||
val validJson = """{"version":1,"categories":[{"id":1,"name":"Lebensmittel"}],"locations":[{"id":1,"name":"Keller"}],"items":[{"id":"item1","name":"Konserve","categoryId":1,"quantity":2.0,"unit":"Stk","unitPrice":1.5,"kcalPer100g":null,"expiryDate":null,"locationId":1,"minStock":1.0,"notes":"","lastUpdated":0}],"settings":[{"key":"theme","value":"dark"}]}"""
|
||||
val categoryDao = FakeCategoryDao()
|
||||
val locationDao = FakeLocationDao()
|
||||
val itemDao = FakeItemDao()
|
||||
val settingsDao = FakeSettingsDao()
|
||||
val repository = buildRepository(categoryDao, locationDao, itemDao, settingsDao)
|
||||
|
||||
// When
|
||||
val result = repository.importFromJson(validJson)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(1, categoryDao.getItems().size)
|
||||
assertEquals("Lebensmittel", categoryDao.getItems().first().name)
|
||||
assertEquals(1, locationDao.getItems().size)
|
||||
assertEquals("Keller", locationDao.getItems().first().name)
|
||||
assertEquals(1, itemDao.getItems().size)
|
||||
assertEquals("item1", itemDao.getItems().first().id)
|
||||
assertEquals(1, settingsDao.getItems().size)
|
||||
assertEquals("dark", settingsDao.getItems().first().value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_importFromJson_withInvalidJson_returnsFailure() = runBlocking {
|
||||
// Given
|
||||
val invalidJson = "{ not valid json !!!"
|
||||
val repository = buildRepository()
|
||||
|
||||
// When
|
||||
val result = repository.importFromJson(invalidJson)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_importFromJson_withUnsupportedVersion_returnsFailure() = runBlocking {
|
||||
// Given
|
||||
val json = """{"version":99,"categories":[],"locations":[],"items":[],"settings":[]}"""
|
||||
val repository = buildRepository()
|
||||
|
||||
// When
|
||||
val result = repository.importFromJson(json)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull()?.message?.contains("99") == true)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue