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 index 7806596..061a401 100644 --- 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 @@ -84,4 +84,18 @@ internal class CategoryDaoTest { // Then 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) + } } 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 index a527888..ecc3b50 100644 --- 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 @@ -11,6 +11,7 @@ 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 @@ -133,16 +134,53 @@ internal class ItemDaoTest { } @Test - fun test_getExpiringSoon_itemExpiresInOneDayWithDaysUntil365_returnsItem() = runBlocking { + fun test_getExpiringSoon_itemExpiresInOneDayWithCutoff365Days_returnsItem() = runBlocking { // Given val tomorrow = LocalDate.now().plusDays(1) dao.insert(buildItem(expiryDate = tomorrow)) // When - val result = dao.getExpiringSoon(365).first() + val cutoff = LocalDate.now().plusDays(365) + val result = dao.getExpiringSoonByCutoff(cutoff).first() // Then assertEquals(1, result.size) 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) + } } 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 index c3cdbdb..f130ca9 100644 --- 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 @@ -84,4 +84,18 @@ internal class LocationDaoTest { // Then 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) + } } diff --git a/app/src/test/java/de/krisenvorrat/app/data/db/LocalDateConverterTest.kt b/app/src/test/java/de/krisenvorrat/app/data/db/LocalDateConverterTest.kt index cc87cf6..fd7e302 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/db/LocalDateConverterTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/db/LocalDateConverterTest.kt @@ -2,8 +2,10 @@ package de.krisenvorrat.app.data.db import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows import org.junit.Test import java.time.LocalDate +import java.time.format.DateTimeParseException class LocalDateConverterTest { @@ -56,4 +58,15 @@ class LocalDateConverterTest { // Then assertNull(result) } + + @Test + fun test_toLocalDate_withInvalidString_throwsDateTimeParseException() { + // Given + val invalidString = "not-a-date" + + // When / Then + assertThrows(DateTimeParseException::class.java) { + converter.toLocalDate(invalidString) + } + } } diff --git a/app/src/test/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImplTest.kt index c3324c0..4cae161 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImplTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImplTest.kt @@ -1,131 +1,14 @@ 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() - private val flow = MutableStateFlow>(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> = flow - override suspend fun getById(id: Int): CategoryEntity? = throw UnsupportedOperationException() - - override suspend fun upsertAll(categories: List) { - 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 = items.toList() -} - -private class FakeLocationDao : LocationDao { - private val items = mutableListOf() - private val flow = MutableStateFlow>(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> = flow - override suspend fun getById(id: Int): LocationEntity? = throw UnsupportedOperationException() - - override suspend fun upsertAll(locations: List) { - 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 = items.toList() -} - -private class FakeItemDao : ItemDao { - private val items = mutableListOf() - private val flow = MutableStateFlow>(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> = flow - override suspend fun getById(id: String): ItemEntity? = throw UnsupportedOperationException() - override fun getByCategory(categoryId: Int): Flow> = throw UnsupportedOperationException() - override fun getByLocation(locationId: Int): Flow> = throw UnsupportedOperationException() - override fun getExpiringSoonByCutoff(cutoff: LocalDate): Flow> = throw UnsupportedOperationException() - - override suspend fun upsertAll(items: List) { - 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 = items.toList() -} - -private class FakeSettingsDao : SettingsDao { - private val items = mutableListOf() - private val flow = MutableStateFlow>(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> = flow - - override suspend fun upsertAll(settings: List) { - 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 = 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( diff --git a/app/src/test/java/de/krisenvorrat/app/data/export/JsonRoundtripTest.kt b/app/src/test/java/de/krisenvorrat/app/data/export/JsonRoundtripTest.kt new file mode 100644 index 0000000..a9ead2f --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/data/export/JsonRoundtripTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/krisenvorrat/app/data/export/TestFakes.kt b/app/src/test/java/de/krisenvorrat/app/data/export/TestFakes.kt new file mode 100644 index 0000000..95804cc --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/data/export/TestFakes.kt @@ -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() + private val flow = MutableStateFlow>(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> = flow + override suspend fun getById(id: Int): CategoryEntity? = throw UnsupportedOperationException() + + override suspend fun upsertAll(categories: List) { + 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 = items.toList() +} + +internal class FakeLocationDao : LocationDao { + private val items = mutableListOf() + private val flow = MutableStateFlow>(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> = flow + override suspend fun getById(id: Int): LocationEntity? = throw UnsupportedOperationException() + + override suspend fun upsertAll(locations: List) { + 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 = items.toList() +} + +internal class FakeItemDao : ItemDao { + private val items = mutableListOf() + private val flow = MutableStateFlow>(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> = flow + override suspend fun getById(id: String): ItemEntity? = throw UnsupportedOperationException() + override fun getByCategory(categoryId: Int): Flow> = throw UnsupportedOperationException() + override fun getByLocation(locationId: Int): Flow> = throw UnsupportedOperationException() + override fun getExpiringSoonByCutoff(cutoff: LocalDate): Flow> = throw UnsupportedOperationException() + + override suspend fun upsertAll(items: List) { + 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 = items.toList() +} + +internal class FakeSettingsDao : SettingsDao { + private val items = mutableListOf() + private val flow = MutableStateFlow>(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> = flow + + override suspend fun upsertAll(settings: List) { + 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 = 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 +)