test: add unit tests for Room DAOs, LocalDateConverter, and JSON roundtrip

LocalDateConverterTest: added negative test for invalid string input
(DateTimeParseException).

CategoryDaoTest, LocationDaoTest: added getAll tests with multiple
entities to verify complete retrieval.

ItemDaoTest: fixed getExpiringSoon test (was calling non-existent
getExpiringSoon(Int) instead of getExpiringSoonByCutoff(LocalDate));
added getAll, getById positive, and getById negative tests.

JsonRoundtripTest (new): verifies lossless export-import roundtrip
with multiple items covering all fields, nullable fields (null
kcalPer100g, null expiryDate), and empty database edge case.

TestFakes (new): extracted shared Fake DAO implementations from
ImportExportRepositoryImplTest to avoid private class redeclaration
errors across test files in the same package.

Closes #22
This commit is contained in:
Jens Reinemann 2026-05-14 00:32:45 +02:00
parent 5825b0351c
commit 0c1e06afca
7 changed files with 408 additions and 119 deletions

View file

@ -84,4 +84,18 @@ internal class CategoryDaoTest {
// Then // Then
assertNull(result) assertNull(result)
} }
@Test
fun test_getAll_multipleCategories_returnsAll() = runBlocking {
// Given
dao.insert(CategoryEntity(name = "Lebensmittel"))
dao.insert(CategoryEntity(name = "Hygiene"))
dao.insert(CategoryEntity(name = "Medizin"))
// When
val result = dao.getAll().first()
// Then
assertEquals(3, result.size)
}
} }

View file

@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -133,16 +134,53 @@ internal class ItemDaoTest {
} }
@Test @Test
fun test_getExpiringSoon_itemExpiresInOneDayWithDaysUntil365_returnsItem() = runBlocking { fun test_getExpiringSoon_itemExpiresInOneDayWithCutoff365Days_returnsItem() = runBlocking {
// Given // Given
val tomorrow = LocalDate.now().plusDays(1) val tomorrow = LocalDate.now().plusDays(1)
dao.insert(buildItem(expiryDate = tomorrow)) dao.insert(buildItem(expiryDate = tomorrow))
// When // When
val result = dao.getExpiringSoon(365).first() val cutoff = LocalDate.now().plusDays(365)
val result = dao.getExpiringSoonByCutoff(cutoff).first()
// Then // Then
assertEquals(1, result.size) assertEquals(1, result.size)
assertEquals("item-1", result.first().id) assertEquals("item-1", result.first().id)
} }
@Test
fun test_getAll_multipleItems_returnsAll() = runBlocking {
// Given
dao.insert(buildItem(id = "item-1"))
dao.insert(buildItem(id = "item-2"))
dao.insert(buildItem(id = "item-3"))
// When
val result = dao.getAll().first()
// Then
assertEquals(3, result.size)
}
@Test
fun test_getById_existingItem_returnsItem() = runBlocking {
// Given
dao.insert(buildItem(id = "item-1"))
// When
val result = dao.getById("item-1")
// Then
assertEquals("item-1", result?.id)
assertEquals("Wasser", result?.name)
}
@Test
fun test_getById_nonExistentId_returnsNull() = runBlocking {
// Given / When
val result = dao.getById("does-not-exist")
// Then
assertNull(result)
}
} }

View file

@ -84,4 +84,18 @@ internal class LocationDaoTest {
// Then // Then
assertNull(result) assertNull(result)
} }
@Test
fun test_getAll_multipleLocations_returnsAll() = runBlocking {
// Given
dao.insert(LocationEntity(name = "Keller"))
dao.insert(LocationEntity(name = "Garage"))
dao.insert(LocationEntity(name = "Dachboden"))
// When
val result = dao.getAll().first()
// Then
assertEquals(3, result.size)
}
} }

View file

@ -2,8 +2,10 @@ package de.krisenvorrat.app.data.db
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Test import org.junit.Test
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeParseException
class LocalDateConverterTest { class LocalDateConverterTest {
@ -56,4 +58,15 @@ class LocalDateConverterTest {
// Then // Then
assertNull(result) assertNull(result)
} }
@Test
fun test_toLocalDate_withInvalidString_throwsDateTimeParseException() {
// Given
val invalidString = "not-a-date"
// When / Then
assertThrows(DateTimeParseException::class.java) {
converter.toLocalDate(invalidString)
}
}
} }

View file

@ -1,131 +1,14 @@
package de.krisenvorrat.app.data.export 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.CategoryEntity
import de.krisenvorrat.app.data.db.entity.ItemEntity
import de.krisenvorrat.app.data.db.entity.LocationEntity import de.krisenvorrat.app.data.db.entity.LocationEntity
import de.krisenvorrat.app.data.db.entity.SettingsEntity import de.krisenvorrat.app.data.db.entity.SettingsEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.time.LocalDate 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 { class ImportExportRepositoryImplTest {
private fun buildRepository( private fun buildRepository(

View file

@ -0,0 +1,204 @@
package de.krisenvorrat.app.data.export
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.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
class JsonRoundtripTest {
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_roundtrip_multipleItemsWithAllFields_dataIsPreservedLosslessly() = runBlocking {
// Given
val categories = listOf(
CategoryEntity(id = 1, name = "Lebensmittel"),
CategoryEntity(id = 2, name = "Hygiene")
)
val locations = listOf(
LocationEntity(id = 1, name = "Keller"),
LocationEntity(id = 2, name = "Garage")
)
val items = listOf(
ItemEntity(
id = "item-1",
name = "Konserve",
categoryId = 1,
quantity = 10.0,
unit = "Stk",
unitPrice = 2.49,
kcalPer100g = 180,
expiryDate = LocalDate.of(2027, 6, 15),
locationId = 1,
minStock = 5.0,
notes = "Ravioli",
lastUpdated = 1700000000L
),
ItemEntity(
id = "item-2",
name = "Seife",
categoryId = 2,
quantity = 3.0,
unit = "Stk",
unitPrice = 0.99,
kcalPer100g = null,
expiryDate = null,
locationId = 2,
minStock = 1.0,
notes = "",
lastUpdated = 1700000001L
)
)
val settings = listOf(
SettingsEntity(key = "theme", value = "dark"),
SettingsEntity(key = "language", value = "de")
)
// Export-Repository mit vorhandenen Daten
val exportCategoryDao = FakeCategoryDao()
val exportLocationDao = FakeLocationDao()
val exportItemDao = FakeItemDao()
val exportSettingsDao = FakeSettingsDao()
exportCategoryDao.upsertAll(categories)
exportLocationDao.upsertAll(locations)
exportItemDao.upsertAll(items)
exportSettingsDao.upsertAll(settings)
val exportRepo = buildRepository(exportCategoryDao, exportLocationDao, exportItemDao, exportSettingsDao)
// When Export
val json = exportRepo.exportToJson()
// Then Import in leeres Repository
val importCategoryDao = FakeCategoryDao()
val importLocationDao = FakeLocationDao()
val importItemDao = FakeItemDao()
val importSettingsDao = FakeSettingsDao()
val importRepo = buildRepository(importCategoryDao, importLocationDao, importItemDao, importSettingsDao)
val result = importRepo.importFromJson(json)
// Then Roundtrip ist verlustfrei
assertTrue(result.isSuccess)
// Categories
assertEquals(categories.size, importCategoryDao.getItems().size)
categories.forEach { original ->
val imported = importCategoryDao.getItems().find { it.id == original.id }
assertEquals(original.name, imported?.name)
}
// Locations
assertEquals(locations.size, importLocationDao.getItems().size)
locations.forEach { original ->
val imported = importLocationDao.getItems().find { it.id == original.id }
assertEquals(original.name, imported?.name)
}
// Items alle Felder prüfen
assertEquals(items.size, importItemDao.getItems().size)
items.forEach { original ->
val imported = importItemDao.getItems().find { it.id == original.id }
assertEquals(original.name, imported?.name)
assertEquals(original.categoryId, imported?.categoryId)
assertEquals(original.quantity, imported?.quantity)
assertEquals(original.unit, imported?.unit)
assertEquals(original.unitPrice, imported?.unitPrice)
assertEquals(original.kcalPer100g, imported?.kcalPer100g)
assertEquals(original.expiryDate, imported?.expiryDate)
assertEquals(original.locationId, imported?.locationId)
assertEquals(original.minStock, imported?.minStock)
assertEquals(original.notes, imported?.notes)
assertEquals(original.lastUpdated, imported?.lastUpdated)
}
// Settings
assertEquals(settings.size, importSettingsDao.getItems().size)
settings.forEach { original ->
val imported = importSettingsDao.getItems().find { it.key == original.key }
assertEquals(original.value, imported?.value)
}
}
@Test
fun test_roundtrip_itemWithNullableFields_nullsArePreserved() = runBlocking {
// Given
val categoryDao = FakeCategoryDao()
val locationDao = FakeLocationDao()
val itemDao = FakeItemDao()
val settingsDao = FakeSettingsDao()
categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Test")))
locationDao.upsertAll(listOf(LocationEntity(id = 1, name = "Ort")))
itemDao.upsertAll(listOf(
ItemEntity(
id = "null-item",
name = "Ohne Extras",
categoryId = 1,
quantity = 1.0,
unit = "Stk",
unitPrice = 0.0,
kcalPer100g = null,
expiryDate = null,
locationId = 1,
minStock = 0.0,
notes = "",
lastUpdated = 0L
)
))
val exportRepo = buildRepository(categoryDao, locationDao, itemDao, settingsDao)
// When
val json = exportRepo.exportToJson()
val importItemDao = FakeItemDao()
val importCatDao = FakeCategoryDao()
val importLocDao = FakeLocationDao()
val importSetDao = FakeSettingsDao()
val importRepo = buildRepository(importCatDao, importLocDao, importItemDao, importSetDao)
val result = importRepo.importFromJson(json)
// Then
assertTrue(result.isSuccess)
val imported = importItemDao.getItems().first()
assertEquals(null, imported.kcalPer100g)
assertEquals(null, imported.expiryDate)
}
@Test
fun test_roundtrip_emptyDatabase_producesEmptyCollections() = runBlocking {
// Given
val exportRepo = buildRepository()
// When
val json = exportRepo.exportToJson()
val importCategoryDao = FakeCategoryDao()
val importLocationDao = FakeLocationDao()
val importItemDao = FakeItemDao()
val importSettingsDao = FakeSettingsDao()
val importRepo = buildRepository(importCategoryDao, importLocationDao, importItemDao, importSettingsDao)
val result = importRepo.importFromJson(json)
// Then
assertTrue(result.isSuccess)
assertEquals(0, importCategoryDao.getItems().size)
assertEquals(0, importLocationDao.getItems().size)
assertEquals(0, importItemDao.getItems().size)
assertEquals(0, importSettingsDao.getItems().size)
}
}

View file

@ -0,0 +1,123 @@
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 java.time.LocalDate
internal 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()
}
internal 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()
}
internal 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()
}
internal 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()
}
internal val passThroughTransaction = DatabaseTransaction { block -> block() }
internal 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
)