From b7197394514b2a7f498a69769671c9c3a5d704a4 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Wed, 13 May 2026 23:18:48 +0200 Subject: [PATCH] feat(db): Room-Datenbank & DAOs implementieren (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/build.gradle.kts | 1 + .../app/data/db/dao/CategoryDaoTest.kt | 87 ++++++++++ .../app/data/db/dao/ItemDaoTest.kt | 148 ++++++++++++++++++ .../app/data/db/dao/LocationDaoTest.kt | 87 ++++++++++ .../app/data/db/dao/SettingsDaoTest.kt | 72 +++++++++ .../app/data/db/KrisenvorratDatabase.kt | 26 +++ .../app/data/db/dao/CategoryDao.kt | 28 ++++ .../krisenvorrat/app/data/db/dao/ItemDao.kt | 41 +++++ .../app/data/db/dao/LocationDao.kt | 28 ++++ .../app/data/db/dao/SettingsDao.kt | 20 +++ gradle/libs.versions.toml | 1 + 11 files changed, 539 insertions(+) create mode 100644 app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/CategoryDaoTest.kt create mode 100644 app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/ItemDaoTest.kt create mode 100644 app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/LocationDaoTest.kt create mode 100644 app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/SettingsDaoTest.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/data/db/dao/CategoryDao.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/data/db/dao/ItemDao.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/data/db/dao/LocationDao.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/data/db/dao/SettingsDao.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 41ae68a..101e6a9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/CategoryDaoTest.kt b/app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/CategoryDaoTest.kt new file mode 100644 index 0000000..7806596 --- /dev/null +++ b/app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/CategoryDaoTest.kt @@ -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) + } +} diff --git a/app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/ItemDaoTest.kt b/app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/ItemDaoTest.kt new file mode 100644 index 0000000..a527888 --- /dev/null +++ b/app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/ItemDaoTest.kt @@ -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) + } +} diff --git a/app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/LocationDaoTest.kt b/app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/LocationDaoTest.kt new file mode 100644 index 0000000..c3cdbdb --- /dev/null +++ b/app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/LocationDaoTest.kt @@ -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) + } +} diff --git a/app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/SettingsDaoTest.kt b/app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/SettingsDaoTest.kt new file mode 100644 index 0000000..2e1b7f7 --- /dev/null +++ b/app/src/androidTest/java/de/krisenvorrat/app/data/db/dao/SettingsDaoTest.kt @@ -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) + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt b/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt new file mode 100644 index 0000000..c176c5e --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt @@ -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 +} diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/dao/CategoryDao.kt b/app/src/main/java/de/krisenvorrat/app/data/db/dao/CategoryDao.kt new file mode 100644 index 0000000..1a662d7 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/db/dao/CategoryDao.kt @@ -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> + + @Query("SELECT * FROM categories WHERE id = :id") + suspend fun getById(id: Int): CategoryEntity? +} diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/dao/ItemDao.kt b/app/src/main/java/de/krisenvorrat/app/data/db/dao/ItemDao.kt new file mode 100644 index 0000000..62ee8e6 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/db/dao/ItemDao.kt @@ -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> + + @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> + + @Query("SELECT * FROM items WHERE location_id = :locationId") + fun getByLocation(locationId: Int): Flow> + + @Query("SELECT * FROM items WHERE expiry_date IS NOT NULL AND expiry_date <= :cutoff") + fun getExpiringSoonByCutoff(cutoff: LocalDate): Flow> + + fun getExpiringSoon(daysUntil: Int = 30): Flow> = + getExpiringSoonByCutoff(LocalDate.now().plusDays(daysUntil.toLong())) +} diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/dao/LocationDao.kt b/app/src/main/java/de/krisenvorrat/app/data/db/dao/LocationDao.kt new file mode 100644 index 0000000..0a83f45 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/db/dao/LocationDao.kt @@ -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> + + @Query("SELECT * FROM locations WHERE id = :id") + suspend fun getById(id: Int): LocationEntity? +} diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/dao/SettingsDao.kt b/app/src/main/java/de/krisenvorrat/app/data/db/dao/SettingsDao.kt new file mode 100644 index 0000000..2186733 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/db/dao/SettingsDao.kt @@ -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> +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3f70110..e059a15 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }