feat(db): Room-Datenbank & DAOs implementieren (#18)

- KrisenvorratDatabase mit allen 4 Entities und LocalDateConverter
- CategoryDao, LocationDao, ItemDao, SettingsDao mit CRUD und Flow-Queries
- ItemDao.getExpiringSoon(daysUntil) als Default-Interface-Methode
- SettingsDao mit @Upsert (Room 2.6.1)
- Instrumentierungstests für alle 4 DAOs (in-memory DB)
- androidx.room:room-testing zu Dependencies ergänzt
This commit is contained in:
Jens Reinemann 2026-05-13 23:18:48 +02:00
parent f52724ce0c
commit b719739451
11 changed files with 539 additions and 0 deletions

View file

@ -71,6 +71,7 @@ dependencies {
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)

View file

@ -0,0 +1,87 @@
package de.krisenvorrat.app.data.db.dao
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.krisenvorrat.app.data.db.KrisenvorratDatabase
import de.krisenvorrat.app.data.db.entity.CategoryEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
internal class CategoryDaoTest {
private lateinit var db: KrisenvorratDatabase
private lateinit var dao: CategoryDao
@Before
fun setup() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
KrisenvorratDatabase::class.java
).allowMainThreadQueries().build()
dao = db.categoryDao()
}
@After
fun teardown() {
db.close()
}
@Test
fun test_insert_emptyDb_getAllReturnsOneItem() = runBlocking {
// Given
val category = CategoryEntity(name = "Lebensmittel")
// When
dao.insert(category)
// Then
val result = dao.getAll().first()
assertEquals(1, result.size)
assertEquals("Lebensmittel", result.first().name)
}
@Test
fun test_update_existingCategory_nameIsChanged() = runBlocking {
// Given
dao.insert(CategoryEntity(name = "Alt"))
val inserted = dao.getAll().first().first()
// When
dao.update(inserted.copy(name = "Neu"))
// Then
val updated = dao.getById(inserted.id)
assertEquals("Neu", updated?.name)
}
@Test
fun test_delete_existingCategory_getAllReturnsEmpty() = runBlocking {
// Given
dao.insert(CategoryEntity(name = "Temp"))
val inserted = dao.getAll().first().first()
// When
dao.delete(inserted)
// Then
val result = dao.getAll().first()
assertEquals(0, result.size)
}
@Test
fun test_getById_nonExistentId_returnsNull() = runBlocking {
// Given / When
val result = dao.getById(999)
// Then
assertNull(result)
}
}

View file

@ -0,0 +1,148 @@
package de.krisenvorrat.app.data.db.dao
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.krisenvorrat.app.data.db.KrisenvorratDatabase
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 kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.LocalDate
@RunWith(AndroidJUnit4::class)
internal class ItemDaoTest {
private lateinit var db: KrisenvorratDatabase
private lateinit var dao: ItemDao
@Before
fun setup() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
KrisenvorratDatabase::class.java
).allowMainThreadQueries().build()
dao = db.itemDao()
runBlocking {
db.categoryDao().insert(CategoryEntity(id = 1, name = "Kategorie"))
db.locationDao().insert(LocationEntity(id = 1, name = "Ort"))
}
}
@After
fun teardown() {
db.close()
}
private fun buildItem(
id: String = "item-1",
categoryId: Int = 1,
locationId: Int = 1,
quantity: Double = 1.0,
expiryDate: LocalDate? = null
) = ItemEntity(
id = id,
name = "Wasser",
categoryId = categoryId,
quantity = quantity,
unit = "Liter",
unitPrice = 0.5,
kcalPer100g = null,
expiryDate = expiryDate,
locationId = locationId,
minStock = 0.0,
notes = "",
lastUpdated = System.currentTimeMillis()
)
@Test
fun test_insert_emptyDb_getAllReturnsOneItem() = runBlocking {
// Given
val item = buildItem()
// When
dao.insert(item)
// Then
val result = dao.getAll().first()
assertEquals(1, result.size)
assertEquals("item-1", result.first().id)
}
@Test
fun test_update_existingItem_quantityIsChanged() = runBlocking {
// Given
dao.insert(buildItem(quantity = 1.0))
// When
dao.update(buildItem(quantity = 5.0))
// Then
val updated = dao.getById("item-1")
assertEquals(5.0, updated?.quantity)
}
@Test
fun test_delete_existingItem_getAllReturnsEmpty() = runBlocking {
// Given
val item = buildItem()
dao.insert(item)
// When
dao.delete(item)
// Then
val result = dao.getAll().first()
assertEquals(0, result.size)
}
@Test
fun test_getByLocation_twoItemsDifferentLocations_returnsOnlyMatching() = runBlocking {
// Given
db.locationDao().insert(LocationEntity(id = 2, name = "Anderer Ort"))
dao.insert(buildItem(id = "item-1", locationId = 1))
dao.insert(buildItem(id = "item-2", locationId = 2))
// When
val result = dao.getByLocation(1).first()
// Then
assertEquals(1, result.size)
assertEquals("item-1", result.first().id)
}
@Test
fun test_getByCategory_twoItemsDifferentCategories_returnsOnlyMatching() = runBlocking {
// Given
db.categoryDao().insert(CategoryEntity(id = 2, name = "Andere"))
dao.insert(buildItem(id = "item-1", categoryId = 1))
dao.insert(buildItem(id = "item-2", categoryId = 2))
// When
val result = dao.getByCategory(1).first()
// Then
assertEquals(1, result.size)
assertEquals("item-1", result.first().id)
}
@Test
fun test_getExpiringSoon_itemExpiresInOneDayWithDaysUntil365_returnsItem() = runBlocking {
// Given
val tomorrow = LocalDate.now().plusDays(1)
dao.insert(buildItem(expiryDate = tomorrow))
// When
val result = dao.getExpiringSoon(365).first()
// Then
assertEquals(1, result.size)
assertEquals("item-1", result.first().id)
}
}

View file

@ -0,0 +1,87 @@
package de.krisenvorrat.app.data.db.dao
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.krisenvorrat.app.data.db.KrisenvorratDatabase
import de.krisenvorrat.app.data.db.entity.LocationEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
internal class LocationDaoTest {
private lateinit var db: KrisenvorratDatabase
private lateinit var dao: LocationDao
@Before
fun setup() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
KrisenvorratDatabase::class.java
).allowMainThreadQueries().build()
dao = db.locationDao()
}
@After
fun teardown() {
db.close()
}
@Test
fun test_insert_emptyDb_getAllReturnsOneItem() = runBlocking {
// Given
val location = LocationEntity(name = "Keller")
// When
dao.insert(location)
// Then
val result = dao.getAll().first()
assertEquals(1, result.size)
assertEquals("Keller", result.first().name)
}
@Test
fun test_update_existingLocation_nameIsChanged() = runBlocking {
// Given
dao.insert(LocationEntity(name = "Alt"))
val inserted = dao.getAll().first().first()
// When
dao.update(inserted.copy(name = "Neu"))
// Then
val updated = dao.getById(inserted.id)
assertEquals("Neu", updated?.name)
}
@Test
fun test_delete_existingLocation_getAllReturnsEmpty() = runBlocking {
// Given
dao.insert(LocationEntity(name = "Temp"))
val inserted = dao.getAll().first().first()
// When
dao.delete(inserted)
// Then
val result = dao.getAll().first()
assertEquals(0, result.size)
}
@Test
fun test_getById_nonExistentId_returnsNull() = runBlocking {
// Given / When
val result = dao.getById(999)
// Then
assertNull(result)
}
}

View file

@ -0,0 +1,72 @@
package de.krisenvorrat.app.data.db.dao
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.krisenvorrat.app.data.db.KrisenvorratDatabase
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
internal class SettingsDaoTest {
private lateinit var db: KrisenvorratDatabase
private lateinit var dao: SettingsDao
@Before
fun setup() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
KrisenvorratDatabase::class.java
).allowMainThreadQueries().build()
dao = db.settingsDao()
}
@After
fun teardown() {
db.close()
}
@Test
fun test_upsert_newKey_getValueReturnsValue() = runBlocking {
// Given
val setting = SettingsEntity(key = "theme", value = "dark")
// When
dao.upsert(setting)
// Then
val result = dao.getValue("theme")
assertEquals("dark", result)
}
@Test
fun test_getValue_unknownKey_returnsNull() = runBlocking {
// Given / When
val result = dao.getValue("nonexistent")
// Then
assertNull(result)
}
@Test
fun test_upsert_existingKey_getAllReturnsOnlyOneEntry() = runBlocking {
// Given
dao.upsert(SettingsEntity(key = "theme", value = "dark"))
// When
dao.upsert(SettingsEntity(key = "theme", value = "light"))
// Then
val result = dao.getAll().first()
assertEquals(1, result.size)
assertEquals("light", result.first().value)
}
}

View file

@ -0,0 +1,26 @@
package de.krisenvorrat.app.data.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
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
@Database(
entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class],
version = 1,
exportSchema = false
)
@TypeConverters(LocalDateConverter::class)
internal abstract class KrisenvorratDatabase : RoomDatabase() {
abstract fun categoryDao(): CategoryDao
abstract fun locationDao(): LocationDao
abstract fun itemDao(): ItemDao
abstract fun settingsDao(): SettingsDao
}

View file

@ -0,0 +1,28 @@
package de.krisenvorrat.app.data.db.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import de.krisenvorrat.app.data.db.entity.CategoryEntity
import kotlinx.coroutines.flow.Flow
@Dao
internal interface CategoryDao {
@Insert
suspend fun insert(category: CategoryEntity)
@Update
suspend fun update(category: CategoryEntity)
@Delete
suspend fun delete(category: CategoryEntity)
@Query("SELECT * FROM categories")
fun getAll(): Flow<List<CategoryEntity>>
@Query("SELECT * FROM categories WHERE id = :id")
suspend fun getById(id: Int): CategoryEntity?
}

View file

@ -0,0 +1,41 @@
package de.krisenvorrat.app.data.db.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import de.krisenvorrat.app.data.db.entity.ItemEntity
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
@Dao
internal interface ItemDao {
@Insert
suspend fun insert(item: ItemEntity)
@Update
suspend fun update(item: ItemEntity)
@Delete
suspend fun delete(item: ItemEntity)
@Query("SELECT * FROM items")
fun getAll(): Flow<List<ItemEntity>>
@Query("SELECT * FROM items WHERE id = :id")
suspend fun getById(id: String): ItemEntity?
@Query("SELECT * FROM items WHERE category_id = :categoryId")
fun getByCategory(categoryId: Int): Flow<List<ItemEntity>>
@Query("SELECT * FROM items WHERE location_id = :locationId")
fun getByLocation(locationId: Int): Flow<List<ItemEntity>>
@Query("SELECT * FROM items WHERE expiry_date IS NOT NULL AND expiry_date <= :cutoff")
fun getExpiringSoonByCutoff(cutoff: LocalDate): Flow<List<ItemEntity>>
fun getExpiringSoon(daysUntil: Int = 30): Flow<List<ItemEntity>> =
getExpiringSoonByCutoff(LocalDate.now().plusDays(daysUntil.toLong()))
}

View file

@ -0,0 +1,28 @@
package de.krisenvorrat.app.data.db.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import de.krisenvorrat.app.data.db.entity.LocationEntity
import kotlinx.coroutines.flow.Flow
@Dao
internal interface LocationDao {
@Insert
suspend fun insert(location: LocationEntity)
@Update
suspend fun update(location: LocationEntity)
@Delete
suspend fun delete(location: LocationEntity)
@Query("SELECT * FROM locations")
fun getAll(): Flow<List<LocationEntity>>
@Query("SELECT * FROM locations WHERE id = :id")
suspend fun getById(id: Int): LocationEntity?
}

View file

@ -0,0 +1,20 @@
package de.krisenvorrat.app.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import kotlinx.coroutines.flow.Flow
@Dao
internal interface SettingsDao {
@Upsert
suspend fun upsert(setting: SettingsEntity)
@Query("SELECT value FROM settings WHERE `key` = :key")
suspend fun getValue(key: String): String?
@Query("SELECT * FROM settings")
fun getAll(): Flow<List<SettingsEntity>>
}

View file

@ -38,6 +38,7 @@ androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref =
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }