feat(repo): Repository-Schicht implementieren (#20)

- 4 Repository-Interfaces in domain/repository/ (Category, Location, Item, Settings)
- 4 Implementierungsklassen in data/repository/ mit Hilt @Inject
- RepositoryModule mit @Binds-Bindings fuer alle Repositories
- Datumslogik (getExpiringSoon) aus ItemDao in ItemRepositoryImpl verschoben
- 20 Unit-Tests mit Fake-DAOs (4 pro Repository)
This commit is contained in:
Jens Reinemann 2026-05-13 23:50:00 +02:00
parent 7380dbbdea
commit 95d8a10ed0
14 changed files with 665 additions and 3 deletions

View file

@ -35,7 +35,4 @@ internal interface ItemDao {
@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,25 @@
package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.CategoryDao
import de.krisenvorrat.app.data.db.entity.CategoryEntity
import de.krisenvorrat.app.domain.repository.CategoryRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class CategoryRepositoryImpl @Inject constructor(
private val dao: CategoryDao
) : CategoryRepository {
override fun getAll(): Flow<List<CategoryEntity>> = dao.getAll()
override suspend fun insert(category: CategoryEntity) =
withContext(Dispatchers.IO) { dao.insert(category) }
override suspend fun update(category: CategoryEntity) =
withContext(Dispatchers.IO) { dao.update(category) }
override suspend fun delete(category: CategoryEntity) =
withContext(Dispatchers.IO) { dao.delete(category) }
}

View file

@ -0,0 +1,38 @@
package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.ItemDao
import de.krisenvorrat.app.data.db.entity.ItemEntity
import de.krisenvorrat.app.domain.repository.ItemRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import java.time.LocalDate
import javax.inject.Inject
internal class ItemRepositoryImpl @Inject constructor(
private val dao: ItemDao
) : ItemRepository {
override fun getAll(): Flow<List<ItemEntity>> = dao.getAll()
override suspend fun getById(id: String): ItemEntity? =
withContext(Dispatchers.IO) { dao.getById(id) }
override suspend fun insert(item: ItemEntity) =
withContext(Dispatchers.IO) { dao.insert(item) }
override suspend fun update(item: ItemEntity) =
withContext(Dispatchers.IO) { dao.update(item) }
override suspend fun delete(item: ItemEntity) =
withContext(Dispatchers.IO) { dao.delete(item) }
override fun getByCategory(categoryId: Int): Flow<List<ItemEntity>> =
dao.getByCategory(categoryId)
override fun getByLocation(locationId: Int): Flow<List<ItemEntity>> =
dao.getByLocation(locationId)
override fun getExpiringSoon(daysUntil: Int): Flow<List<ItemEntity>> =
dao.getExpiringSoonByCutoff(LocalDate.now().plusDays(daysUntil.toLong()))
}

View file

@ -0,0 +1,25 @@
package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.LocationDao
import de.krisenvorrat.app.data.db.entity.LocationEntity
import de.krisenvorrat.app.domain.repository.LocationRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class LocationRepositoryImpl @Inject constructor(
private val dao: LocationDao
) : LocationRepository {
override fun getAll(): Flow<List<LocationEntity>> = dao.getAll()
override suspend fun insert(location: LocationEntity) =
withContext(Dispatchers.IO) { dao.insert(location) }
override suspend fun update(location: LocationEntity) =
withContext(Dispatchers.IO) { dao.update(location) }
override suspend fun delete(location: LocationEntity) =
withContext(Dispatchers.IO) { dao.delete(location) }
}

View file

@ -0,0 +1,22 @@
package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.SettingsDao
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import de.krisenvorrat.app.domain.repository.SettingsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class SettingsRepositoryImpl @Inject constructor(
private val dao: SettingsDao
) : SettingsRepository {
override suspend fun getValue(key: String): String? =
withContext(Dispatchers.IO) { dao.getValue(key) }
override suspend fun setValue(key: String, value: String) =
withContext(Dispatchers.IO) { dao.upsert(SettingsEntity(key = key, value = value)) }
override fun getAll(): Flow<List<SettingsEntity>> = dao.getAll()
}

View file

@ -0,0 +1,36 @@
package de.krisenvorrat.app.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.krisenvorrat.app.data.repository.CategoryRepositoryImpl
import de.krisenvorrat.app.data.repository.ItemRepositoryImpl
import de.krisenvorrat.app.data.repository.LocationRepositoryImpl
import de.krisenvorrat.app.data.repository.SettingsRepositoryImpl
import de.krisenvorrat.app.domain.repository.CategoryRepository
import de.krisenvorrat.app.domain.repository.ItemRepository
import de.krisenvorrat.app.domain.repository.LocationRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindCategoryRepository(impl: CategoryRepositoryImpl): CategoryRepository
@Binds
@Singleton
abstract fun bindLocationRepository(impl: LocationRepositoryImpl): LocationRepository
@Binds
@Singleton
abstract fun bindItemRepository(impl: ItemRepositoryImpl): ItemRepository
@Binds
@Singleton
abstract fun bindSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository
}

View file

@ -0,0 +1,11 @@
package de.krisenvorrat.app.domain.repository
import de.krisenvorrat.app.data.db.entity.CategoryEntity
import kotlinx.coroutines.flow.Flow
internal interface CategoryRepository {
fun getAll(): Flow<List<CategoryEntity>>
suspend fun insert(category: CategoryEntity)
suspend fun update(category: CategoryEntity)
suspend fun delete(category: CategoryEntity)
}

View file

@ -0,0 +1,15 @@
package de.krisenvorrat.app.domain.repository
import de.krisenvorrat.app.data.db.entity.ItemEntity
import kotlinx.coroutines.flow.Flow
internal interface ItemRepository {
fun getAll(): Flow<List<ItemEntity>>
suspend fun getById(id: String): ItemEntity?
suspend fun insert(item: ItemEntity)
suspend fun update(item: ItemEntity)
suspend fun delete(item: ItemEntity)
fun getByCategory(categoryId: Int): Flow<List<ItemEntity>>
fun getByLocation(locationId: Int): Flow<List<ItemEntity>>
fun getExpiringSoon(daysUntil: Int = 30): Flow<List<ItemEntity>>
}

View file

@ -0,0 +1,11 @@
package de.krisenvorrat.app.domain.repository
import de.krisenvorrat.app.data.db.entity.LocationEntity
import kotlinx.coroutines.flow.Flow
internal interface LocationRepository {
fun getAll(): Flow<List<LocationEntity>>
suspend fun insert(location: LocationEntity)
suspend fun update(location: LocationEntity)
suspend fun delete(location: LocationEntity)
}

View file

@ -0,0 +1,10 @@
package de.krisenvorrat.app.domain.repository
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import kotlinx.coroutines.flow.Flow
internal interface SettingsRepository {
suspend fun getValue(key: String): String?
suspend fun setValue(key: String, value: String)
fun getAll(): Flow<List<SettingsEntity>>
}

View file

@ -0,0 +1,102 @@
package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.CategoryDao
import de.krisenvorrat.app.data.db.entity.CategoryEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
private class FakeCategoryDao : CategoryDao {
private val items = mutableListOf<CategoryEntity>()
private val flow = MutableStateFlow<List<CategoryEntity>>(emptyList())
override suspend fun insert(category: CategoryEntity) {
items.add(category)
flow.value = items.toList()
}
override suspend fun update(category: CategoryEntity) {
val idx = items.indexOfFirst { it.id == category.id }
if (idx >= 0) {
items[idx] = category
flow.value = items.toList()
}
}
override suspend fun delete(category: CategoryEntity) {
items.remove(category)
flow.value = items.toList()
}
override fun getAll(): Flow<List<CategoryEntity>> = flow
override suspend fun getById(id: Int): CategoryEntity? = items.find { it.id == id }
}
class CategoryRepositoryImplTest {
private val fakeDao = FakeCategoryDao()
private val repository = CategoryRepositoryImpl(fakeDao)
@Test
fun test_insert_withNewCategory_categoryAppearsInGetAll() = runBlocking {
// Given
val category = CategoryEntity(id = 1, name = "Lebensmittel")
// When
repository.insert(category)
// Then
val result = repository.getAll().first()
assertTrue(result.contains(category))
}
@Test
fun test_update_withExistingCategory_categoryIsUpdated() = runBlocking {
// Given
val original = CategoryEntity(id = 1, name = "Alt")
repository.insert(original)
val updated = CategoryEntity(id = 1, name = "Neu")
// When
repository.update(updated)
// Then
val result = repository.getAll().first()
assertEquals("Neu", result.first { it.id == 1 }.name)
}
@Test
fun test_delete_withExistingCategory_categoryRemovedFromGetAll() = runBlocking {
// Given
val category = CategoryEntity(id = 1, name = "Lebensmittel")
repository.insert(category)
// When
repository.delete(category)
// Then
val result = repository.getAll().first()
assertFalse(result.contains(category))
}
@Test
fun test_getAll_withMultipleCategories_returnsAllCategories() = runBlocking {
// Given
val cat1 = CategoryEntity(id = 1, name = "Lebensmittel")
val cat2 = CategoryEntity(id = 2, name = "Hygiene")
repository.insert(cat1)
repository.insert(cat2)
// When
val result = repository.getAll().first()
// Then
assertEquals(2, result.size)
}
}

View file

@ -0,0 +1,186 @@
package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.ItemDao
import de.krisenvorrat.app.data.db.entity.ItemEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
private class FakeItemDao : ItemDao {
private val items = mutableListOf<ItemEntity>()
private val flow = MutableStateFlow<List<ItemEntity>>(emptyList())
private val expiringSoonFlow = MutableStateFlow<List<ItemEntity>>(emptyList())
fun setExpiringSoonItems(items: List<ItemEntity>) { expiringSoonFlow.value = items }
private fun emit() { flow.value = items.toList() }
override suspend fun insert(item: ItemEntity) {
items.add(item)
emit()
}
override suspend fun update(item: ItemEntity) {
val idx = items.indexOfFirst { it.id == item.id }
if (idx >= 0) {
items[idx] = item
emit()
}
}
override suspend fun delete(item: ItemEntity) {
items.remove(item)
emit()
}
override fun getAll(): Flow<List<ItemEntity>> = flow
override suspend fun getById(id: String): ItemEntity? = items.find { it.id == id }
override fun getByCategory(categoryId: Int): Flow<List<ItemEntity>> =
flow.map { list -> list.filter { it.categoryId == categoryId } }
override fun getByLocation(locationId: Int): Flow<List<ItemEntity>> =
flow.map { list -> list.filter { it.locationId == locationId } }
override fun getExpiringSoonByCutoff(cutoff: LocalDate): Flow<List<ItemEntity>> =
expiringSoonFlow
}
private fun buildItem(
id: String = "id1",
categoryId: Int = 1,
locationId: Int = 1
) = ItemEntity(
id = id,
name = "Konserve",
categoryId = categoryId,
quantity = 2.0,
unit = "Stk",
unitPrice = 1.5,
kcalPer100g = null,
expiryDate = null,
locationId = locationId,
minStock = 1.0,
notes = "",
lastUpdated = 0L
)
class ItemRepositoryImplTest {
private val fakeDao = FakeItemDao()
private val repository = ItemRepositoryImpl(fakeDao)
@Test
fun test_insert_withNewItem_itemAppearsInGetAll() = runBlocking {
// Given
val item = buildItem(id = "abc")
// When
repository.insert(item)
// Then
val result = repository.getAll().first()
assertTrue(result.any { it.id == "abc" })
}
@Test
fun test_getById_withExistingId_returnsItem() = runBlocking {
// Given
val item = buildItem(id = "abc")
repository.insert(item)
// When
val result = repository.getById("abc")
// Then
assertEquals(item, result)
}
@Test
fun test_getById_withUnknownId_returnsNull() = runBlocking {
// Given / When
val result = repository.getById("unknown")
// Then
assertNull(result)
}
@Test
fun test_update_withExistingItem_itemIsUpdated() = runBlocking {
// Given
val item = buildItem(id = "abc")
repository.insert(item)
val updated = item.copy(name = "Aktualisiert")
// When
repository.update(updated)
// Then
val result = repository.getById("abc")
assertEquals("Aktualisiert", result?.name)
}
@Test
fun test_delete_withExistingItem_itemRemovedFromGetAll() = runBlocking {
// Given
val item = buildItem(id = "abc")
repository.insert(item)
// When
repository.delete(item)
// Then
val result = repository.getAll().first()
assertFalse(result.any { it.id == "abc" })
}
@Test
fun test_getByCategory_withMatchingItems_returnsFilteredItems() = runBlocking {
// Given
repository.insert(buildItem(id = "a", categoryId = 1))
repository.insert(buildItem(id = "b", categoryId = 2))
// When
val result = repository.getByCategory(1).first()
// Then
assertEquals(1, result.size)
assertEquals("a", result.first().id)
}
@Test
fun test_getByLocation_withMatchingItems_returnsFilteredItems() = runBlocking {
// Given
repository.insert(buildItem(id = "a", locationId = 1))
repository.insert(buildItem(id = "b", locationId = 2))
// When
val result = repository.getByLocation(2).first()
// Then
assertEquals(1, result.size)
assertEquals("b", result.first().id)
}
@Test
fun test_getExpiringSoon_withPreconfiguredFlow_returnsExpectedItems() = runBlocking {
// Given
val expiring = buildItem(id = "exp")
fakeDao.setExpiringSoonItems(listOf(expiring))
// When
val result = repository.getExpiringSoon(30).first()
// Then
assertEquals(1, result.size)
assertEquals("exp", result.first().id)
}
}

View file

@ -0,0 +1,102 @@
package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.LocationDao
import de.krisenvorrat.app.data.db.entity.LocationEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
private class FakeLocationDao : LocationDao {
private val items = mutableListOf<LocationEntity>()
private val flow = MutableStateFlow<List<LocationEntity>>(emptyList())
override suspend fun insert(location: LocationEntity) {
items.add(location)
flow.value = items.toList()
}
override suspend fun update(location: LocationEntity) {
val idx = items.indexOfFirst { it.id == location.id }
if (idx >= 0) {
items[idx] = location
flow.value = items.toList()
}
}
override suspend fun delete(location: LocationEntity) {
items.remove(location)
flow.value = items.toList()
}
override fun getAll(): Flow<List<LocationEntity>> = flow
override suspend fun getById(id: Int): LocationEntity? = items.find { it.id == id }
}
class LocationRepositoryImplTest {
private val fakeDao = FakeLocationDao()
private val repository = LocationRepositoryImpl(fakeDao)
@Test
fun test_insert_withNewLocation_locationAppearsInGetAll() = runBlocking {
// Given
val location = LocationEntity(id = 1, name = "Keller")
// When
repository.insert(location)
// Then
val result = repository.getAll().first()
assertTrue(result.contains(location))
}
@Test
fun test_update_withExistingLocation_locationIsUpdated() = runBlocking {
// Given
val original = LocationEntity(id = 1, name = "Keller")
repository.insert(original)
val updated = LocationEntity(id = 1, name = "Dachboden")
// When
repository.update(updated)
// Then
val result = repository.getAll().first()
assertEquals("Dachboden", result.first { it.id == 1 }.name)
}
@Test
fun test_delete_withExistingLocation_locationRemovedFromGetAll() = runBlocking {
// Given
val location = LocationEntity(id = 1, name = "Keller")
repository.insert(location)
// When
repository.delete(location)
// Then
val result = repository.getAll().first()
assertFalse(result.contains(location))
}
@Test
fun test_getAll_withMultipleLocations_returnsAllLocations() = runBlocking {
// Given
val loc1 = LocationEntity(id = 1, name = "Keller")
val loc2 = LocationEntity(id = 2, name = "Küche")
repository.insert(loc1)
repository.insert(loc2)
// When
val result = repository.getAll().first()
// Then
assertEquals(2, result.size)
}
}

View file

@ -0,0 +1,82 @@
package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.SettingsDao
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
private class FakeSettingsDao : SettingsDao {
private val store = mutableMapOf<String, String>()
private val flow = MutableStateFlow<List<SettingsEntity>>(emptyList())
override suspend fun upsert(setting: SettingsEntity) {
store[setting.key] = setting.value
flow.value = store.map { SettingsEntity(key = it.key, value = it.value) }
}
override suspend fun getValue(key: String): String? = store[key]
override fun getAll(): Flow<List<SettingsEntity>> = flow
}
class SettingsRepositoryImplTest {
private val fakeDao = FakeSettingsDao()
private val repository = SettingsRepositoryImpl(fakeDao)
@Test
fun test_setValue_withNewKey_valueCanBeRetrieved() = runBlocking {
// Given
val key = "theme"
val value = "dark"
// When
repository.setValue(key, value)
// Then
val result = repository.getValue(key)
assertEquals(value, result)
}
@Test
fun test_setValue_withExistingKey_valueIsUpdated() = runBlocking {
// Given
repository.setValue("theme", "light")
// When
repository.setValue("theme", "dark")
// Then
assertEquals("dark", repository.getValue("theme"))
}
@Test
fun test_getValue_withUnknownKey_returnsNull() = runBlocking {
// Given / When
val result = repository.getValue("nonexistent")
// Then
assertNull(result)
}
@Test
fun test_getAll_afterInsertingTwoSettings_returnsBothSettings() = runBlocking {
// Given
repository.setValue("theme", "dark")
repository.setValue("language", "de")
// When
val result = repository.getAll().first()
// Then
assertEquals(2, result.size)
assertTrue(result.any { it.key == "theme" && it.value == "dark" })
assertTrue(result.any { it.key == "language" && it.value == "de" })
}
}