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:
Jens Reinemann 2026-05-14 00:20:04 +02:00
parent b7cc8db80a
commit 5825b0351c
6 changed files with 282 additions and 25 deletions

View file

@ -0,0 +1,5 @@
package de.krisenvorrat.app.data.export
internal fun interface DatabaseTransaction {
suspend fun execute(block: suspend () -> Unit)
}

View file

@ -1,10 +1,13 @@
package de.krisenvorrat.app.data.export package de.krisenvorrat.app.data.export
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@OptIn(ExperimentalSerializationApi::class)
@Serializable @Serializable
internal data class ExportData( internal data class ExportData(
val version: Int = 1, @EncodeDefault(EncodeDefault.Mode.ALWAYS) val version: Int = 1,
val categories: List<CategoryExport>, val categories: List<CategoryExport>,
val locations: List<LocationExport>, val locations: List<LocationExport>,
val items: List<ItemExport>, val items: List<ItemExport>,

View file

@ -20,10 +20,14 @@ internal class ImportExportRepositoryImpl @Inject constructor(
private val categoryDao: CategoryDao, private val categoryDao: CategoryDao,
private val locationDao: LocationDao, private val locationDao: LocationDao,
private val itemDao: ItemDao, private val itemDao: ItemDao,
private val settingsDao: SettingsDao private val settingsDao: SettingsDao,
private val transaction: DatabaseTransaction
) : ImportExportRepository { ) : ImportExportRepository {
private val jsonSerializer = Json { prettyPrint = false } private val jsonSerializer = Json {
prettyPrint = false
ignoreUnknownKeys = true
}
override suspend fun exportToJson(): String = withContext(Dispatchers.IO) { override suspend fun exportToJson(): String = withContext(Dispatchers.IO) {
val categories = categoryDao.getAll().first() val categories = categoryDao.getAll().first()
@ -55,8 +59,11 @@ internal class ImportExportRepositoryImpl @Inject constructor(
jsonSerializer.encodeToString(ExportData.serializer(), exportData) jsonSerializer.encodeToString(ExportData.serializer(), exportData)
} }
override suspend fun importFromJson(json: String) = withContext(Dispatchers.IO) { override suspend fun importFromJson(json: String): Result<Unit> = withContext(Dispatchers.IO) {
val exportData = jsonSerializer.decodeFromString(ExportData.serializer(), json) 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) }) categoryDao.upsertAll(exportData.categories.map { CategoryEntity(id = it.id, name = it.name) })
locationDao.upsertAll(exportData.locations.map { LocationEntity(id = it.id, name = it.name) }) locationDao.upsertAll(exportData.locations.map { LocationEntity(id = it.id, name = it.name) })
itemDao.upsertAll(exportData.items.map { item -> itemDao.upsertAll(exportData.items.map { item ->
@ -77,4 +84,6 @@ internal class ImportExportRepositoryImpl @Inject constructor(
}) })
settingsDao.upsertAll(exportData.settings.map { SettingsEntity(key = it.key, value = it.value) }) settingsDao.upsertAll(exportData.settings.map { SettingsEntity(key = it.key, value = it.value) })
} }
}
}
} }

View file

@ -2,6 +2,7 @@ package de.krisenvorrat.app.di
import android.content.Context import android.content.Context
import androidx.room.Room import androidx.room.Room
import androidx.room.withTransaction
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn 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.ItemDao
import de.krisenvorrat.app.data.db.dao.LocationDao import de.krisenvorrat.app.data.db.dao.LocationDao
import de.krisenvorrat.app.data.db.dao.SettingsDao import de.krisenvorrat.app.data.db.dao.SettingsDao
import de.krisenvorrat.app.data.export.DatabaseTransaction
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@ -34,4 +36,9 @@ internal object DatabaseModule {
@Provides @Provides
fun provideSettingsDao(db: KrisenvorratDatabase): SettingsDao = db.settingsDao() fun provideSettingsDao(db: KrisenvorratDatabase): SettingsDao = db.settingsDao()
@Provides
@Singleton
fun provideDatabaseTransaction(db: KrisenvorratDatabase): DatabaseTransaction =
DatabaseTransaction { block -> db.withTransaction(block) }
} }

View file

@ -2,5 +2,5 @@ package de.krisenvorrat.app.domain.repository
internal interface ImportExportRepository { internal interface ImportExportRepository {
suspend fun exportToJson(): String suspend fun exportToJson(): String
suspend fun importFromJson(json: String) suspend fun importFromJson(json: String): Result<Unit>
} }

View file

@ -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)
}
}