From 0f25c180ed91111d0dd65e6a7b5a669c41f8d5d8 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Sun, 17 May 2026 03:15:49 +0200 Subject: [PATCH] feat(sync): Full-Inventory-Sync durch Delta-Sync ersetzen Server + Client: Timestamp-basierter Delta-Sync als Alternative zum Full-Sync. GET /api/inventory akzeptiert jetzt optionalen ?since= Query-Parameter und liefert nur Items mit lastUpdated > since. Shared: InventoryDto um deletedItemIds-Feld erweitert (Default: leer, backward-compatible mit bestehenden Clients). Server: - DeletedItems-Tabelle trackt geloeschte Item-IDs pro Inventory - InventoryRepository.loadInventorySince(): Delta-Query mit Items + deletedItemIds seit Timestamp - saveInventory()/deleteItem(): Loeschungen werden in DeletedItems protokolliert - DatabaseFactory: DeletedItems-Tabelle registriert Client (App): - SyncService.downloadInventory(since: Long?): optionaler since-Param - SyncServiceImpl: haengt ?since= an GET-Request - ItemDao.deleteByIds(): Batch-Loeschung fuer Delta-Sync - ImportExportRepositoryImpl: verarbeitet deletedItemIds aus DTO - SettingsViewModel.pullSync(fullSync): Delta-Sync mit letztem Sync-Timestamp; fullSyncRequired-Event loest weiterhin Full-Sync aus Entscheidungen: - Timestamp-basiert (nutzt bestehendes lastUpdated-Feld) - Full-Sync bleibt Fallback (fullSyncRequired, erster Sync) - Categories/Locations/Settings immer vollstaendig (klein) 6 neue DeltaSyncTests + 3 Repository-Tests + 2 SyncService-Tests Closes #74 --- .../krisenvorrat/app/data/db/dao/ItemDao.kt | 3 + .../data/export/ImportExportRepositoryImpl.kt | 3 + .../app/data/sync/SyncServiceImpl.kt | 8 +- .../app/domain/repository/SyncService.kt | 2 +- .../app/ui/settings/SettingsViewModel.kt | 9 +- .../krisenvorrat/app/data/export/TestFakes.kt | 5 + .../data/repository/ItemRepositoryImplTest.kt | 7 +- .../app/data/sync/SyncServiceImplTest.kt | 45 ++++ .../app/ui/settings/SettingsViewModelTest.kt | 2 +- .../krisenvorrat/server/db/DatabaseFactory.kt | 4 +- .../de/krisenvorrat/server/db/Tables.kt | 9 + .../server/repository/InventoryRepository.kt | 71 ++++++ .../server/routes/InventoryRoutes.kt | 7 +- .../de/krisenvorrat/server/DeltaSyncTest.kt | 220 ++++++++++++++++++ .../repository/InventoryRepositoryTest.kt | 58 +++++ .../krisenvorrat/shared/model/InventoryDto.kt | 3 +- 16 files changed, 443 insertions(+), 13 deletions(-) create mode 100644 server/src/test/kotlin/de/krisenvorrat/server/DeltaSyncTest.kt 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 index 9fd1165..9030753 100644 --- 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 @@ -40,6 +40,9 @@ internal interface ItemDao { @Upsert suspend fun upsertAll(items: List) + @Query("DELETE FROM items WHERE id IN (:ids)") + suspend fun deleteByIds(ids: List) + @Query("SELECT COUNT(*) FROM items WHERE category_id = :categoryId") suspend fun countByCategoryId(categoryId: Int): Int diff --git a/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt index 0d89dbb..0f82c69 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt @@ -100,6 +100,9 @@ internal class ImportExportRepositoryImpl @Inject constructor( transaction.execute { categoryDao.upsertAll(dto.categories.map { CategoryEntity(id = it.id, name = it.name) }) locationDao.upsertAll(dto.locations.map { LocationEntity(id = it.id, name = it.name) }) + if (dto.deletedItemIds.isNotEmpty()) { + itemDao.deleteByIds(dto.deletedItemIds) + } itemDao.upsertAll(itemsToApply.map { item -> ItemEntity( id = item.id, diff --git a/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt index 11c2cb3..2c685d1 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt @@ -30,9 +30,13 @@ internal class SyncServiceImpl @Inject constructor( private val settingsRepository: SettingsRepository ) : SyncService { - override suspend fun downloadInventory(): Result = + override suspend fun downloadInventory(since: Long?): Result = executeRequest { serverUrl, token -> - val response = httpClient.get("$serverUrl/api/inventory") { + val url = buildString { + append("$serverUrl/api/inventory") + if (since != null) append("?since=$since") + } + val response = httpClient.get(url) { header("Authorization", "Bearer $token") } handleResponse(response) diff --git a/app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt b/app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt index c35fe4e..b528b2f 100644 --- a/app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt +++ b/app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt @@ -4,7 +4,7 @@ import de.krisenvorrat.shared.model.InventoryDto import de.krisenvorrat.shared.model.ItemDto internal interface SyncService { - suspend fun downloadInventory(): Result + suspend fun downloadInventory(since: Long? = null): Result suspend fun uploadInventory(inventory: InventoryDto): Result suspend fun login(serverUrl: String, username: String, password: String): Result suspend fun logout() diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt index 9e129e3..ddf6e4b 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt @@ -51,8 +51,8 @@ internal class SettingsViewModel @Inject constructor( viewModelScope.launch { webSocketClient.events.collect { event -> when (event) { - is WebSocketEvent.FullSyncRequired -> pullSync() - is WebSocketEvent.InventoryUpdated -> pullSync() + is WebSocketEvent.FullSyncRequired -> pullSync(fullSync = true) + is WebSocketEvent.InventoryUpdated -> pullSync(fullSync = false) is WebSocketEvent.ConnectionFailed -> { _uiState.update { it.copy(syncStatus = SyncStatus.Error(event.message)) } } @@ -336,11 +336,12 @@ internal class SettingsViewModel @Inject constructor( } } - fun pullSync() { + fun pullSync(fullSync: Boolean = false) { viewModelScope.launch { _uiState.update { it.copy(syncStatus = SyncStatus.Running) } try { - val result = syncService.downloadInventory() + val since = if (fullSync) null else settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP)?.toLongOrNull() + val result = syncService.downloadInventory(since) result.fold( onSuccess = { inventoryDto -> importExportRepository.importFromInventoryDto(inventoryDto).fold( 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 index f57516f..0a0a0cd 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/export/TestFakes.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/export/TestFakes.kt @@ -87,6 +87,11 @@ internal class FakeItemDao : ItemDao { override suspend fun updateLocationId(fromId: Int, toId: Int) = throw UnsupportedOperationException() override suspend fun getLastUsedLocationId(): Int? = throw UnsupportedOperationException() + override suspend fun deleteByIds(ids: List) { + items.removeAll { it.id in ids } + emit() + } + fun getItems(): List = items.toList() } diff --git a/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt index 6f1476e..75eb61a 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt @@ -100,6 +100,11 @@ private class FakeItemDao : ItemDao { override suspend fun getLastUsedLocationId(): Int? = items.maxByOrNull { it.lastUpdated }?.locationId + + override suspend fun deleteByIds(ids: List) { + items.removeAll { it.id in ids } + emit() + } } private class FakePendingSyncOpDao : PendingSyncOpDao { @@ -137,7 +142,7 @@ private class FakeSyncService : SyncService { return deleteResult } - override suspend fun downloadInventory(): Result = Result.success( + override suspend fun downloadInventory(since: Long?): Result = Result.success( InventoryDto(categories = emptyList(), locations = emptyList(), items = emptyList(), settings = emptyList()) ) override suspend fun uploadInventory(inventory: InventoryDto): Result = diff --git a/app/src/test/java/de/krisenvorrat/app/data/sync/SyncServiceImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/sync/SyncServiceImplTest.kt index 21347e3..7a763a3 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/sync/SyncServiceImplTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/sync/SyncServiceImplTest.kt @@ -191,6 +191,51 @@ class SyncServiceImplTest { assertTrue(result.exceptionOrNull() is SyncError.NotConfigured) } + @Test + fun test_downloadInventory_withSince_appendsQueryParam() = runTest { + // Given + setupSettings() + val engine = MockEngine { request -> + assertEquals("/api/inventory", request.url.encodedPath) + assertEquals("1715000000", request.url.parameters["since"]) + respond( + content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + httpClient = createClient(engine) + syncService = SyncServiceImpl(httpClient, settingsRepository) + + // When + val result = syncService.downloadInventory(since = 1715000000L) + + // Then + assertTrue(result.isSuccess) + } + + @Test + fun test_downloadInventory_withoutSince_noQueryParam() = runTest { + // Given + setupSettings() + val engine = MockEngine { request -> + assertTrue(request.url.parameters.isEmpty()) + respond( + content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + httpClient = createClient(engine) + syncService = SyncServiceImpl(httpClient, settingsRepository) + + // When + val result = syncService.downloadInventory(since = null) + + // Then + assertTrue(result.isSuccess) + } + // --- uploadInventory --- @Test diff --git a/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt index 8ea1472..390e0b7 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt @@ -770,7 +770,7 @@ private class FakeSyncService : SyncService { settings = emptyList() ) - override suspend fun downloadInventory(): Result { + override suspend fun downloadInventory(since: Long?): Result { if (downloadShouldFail) return Result.failure(downloadError) return Result.success(downloadResult) } diff --git a/server/src/main/kotlin/de/krisenvorrat/server/db/DatabaseFactory.kt b/server/src/main/kotlin/de/krisenvorrat/server/db/DatabaseFactory.kt index 2c9562e..309fd1e 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/db/DatabaseFactory.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/db/DatabaseFactory.kt @@ -42,8 +42,8 @@ internal object DatabaseFactory { Database.connect(jdbcUrl, driver) } transaction { - SchemaUtils.create(Inventories, Users, Categories, Locations, Items, Settings, Messages) - SchemaUtils.createMissingTablesAndColumns(Inventories, Users, Categories, Locations, Items, Settings, Messages) + SchemaUtils.create(Inventories, Users, Categories, Locations, Items, Settings, Messages, DeletedItems) + SchemaUtils.createMissingTablesAndColumns(Inventories, Users, Categories, Locations, Items, Settings, Messages, DeletedItems) } migrateUserInventories() seedAdmin(adminPassword) diff --git a/server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt b/server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt index 546828c..e79a5b3 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt @@ -72,3 +72,12 @@ internal object Messages : Table("messages") { val deliveredAt = long("delivered_at").nullable() override val primaryKey = PrimaryKey(id) } + +internal object DeletedItems : Table("deleted_items") { + val id = integer("id").autoIncrement() + val itemId = varchar("item_id", 36) + val inventoryId = varchar("inventory_id", 36) + val deletedAt = long("deleted_at") + + override val primaryKey = PrimaryKey(id) +} diff --git a/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt b/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt index e4f417e..4cf32ae 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt @@ -1,6 +1,7 @@ package de.krisenvorrat.server.repository import de.krisenvorrat.server.db.Categories +import de.krisenvorrat.server.db.DeletedItems import de.krisenvorrat.server.db.Inventories import de.krisenvorrat.server.db.Items import de.krisenvorrat.server.db.Locations @@ -108,6 +109,22 @@ internal class InventoryRepository { fun saveInventory(inventoryId: String, inventory: InventoryDto) { transaction { + val existingItemIds = Items.selectAll() + .where { Items.inventoryId eq inventoryId } + .map { it[Items.id] } + .toSet() + val incomingItemIds = inventory.items.map { it.id }.toSet() + val removedItemIds = existingItemIds - incomingItemIds + if (removedItemIds.isNotEmpty()) { + val now = System.currentTimeMillis() + for (removedId in removedItemIds) { + DeletedItems.insert { + it[itemId] = removedId + it[DeletedItems.inventoryId] = inventoryId + it[deletedAt] = now + } + } + } Items.deleteWhere { Items.inventoryId eq inventoryId } Settings.deleteWhere { Settings.inventoryId eq inventoryId } Categories.deleteWhere { Categories.inventoryId eq inventoryId } @@ -269,10 +286,64 @@ internal class InventoryRepository { val deleted = Items.deleteWhere { (Items.id eq itemId) and (Items.inventoryId eq inventoryId) } + if (deleted > 0) { + DeletedItems.insert { + it[DeletedItems.itemId] = itemId + it[DeletedItems.inventoryId] = inventoryId + it[deletedAt] = System.currentTimeMillis() + } + } deleted > 0 } } + fun loadInventorySince(inventoryId: String, since: Long): InventoryDto { + return transaction { + val categories = Categories.selectAll() + .where { Categories.inventoryId eq inventoryId } + .map { CategoryDto(id = it[Categories.id], name = it[Categories.name]) } + + val locations = Locations.selectAll() + .where { Locations.inventoryId eq inventoryId } + .map { LocationDto(id = it[Locations.id], name = it[Locations.name]) } + + val items = Items.selectAll() + .where { (Items.inventoryId eq inventoryId) and (Items.lastUpdated greater since) } + .map { + ItemDto( + id = it[Items.id], + name = it[Items.name], + categoryId = it[Items.categoryId], + quantity = it[Items.quantity], + unit = it[Items.unit], + unitPrice = it[Items.unitPrice], + kcalPerKg = it[Items.kcalPerKg], + expiryDate = it[Items.expiryDate], + locationId = it[Items.locationId], + notes = it[Items.notes], + lastUpdated = it[Items.lastUpdated] + ) + } + + val settings = Settings.selectAll() + .where { Settings.inventoryId eq inventoryId } + .map { SettingDto(key = it[Settings.key], value = it[Settings.value]) } + + val deletedItemIds = DeletedItems.selectAll() + .where { (DeletedItems.inventoryId eq inventoryId) and (DeletedItems.deletedAt greater since) } + .map { it[DeletedItems.itemId] } + .distinct() + + InventoryDto( + categories = categories, + locations = locations, + items = items, + settings = settings, + deletedItemIds = deletedItemIds + ) + } + } + fun getAggregatedStats(): InventoryStatsDto { return transaction { val totalItems = Items.selectAll().count() diff --git a/server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt b/server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt index 42bef34..0056321 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt @@ -55,7 +55,12 @@ internal fun Route.inventoryRoutes( val userId = call.principal()?.userId ?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized")) val inventoryId = repository.getEffectiveInventoryId(userId) - val inventory = repository.loadInventory(inventoryId) + val since = call.request.queryParameters["since"]?.toLongOrNull() + val inventory = if (since != null) { + repository.loadInventorySince(inventoryId, since) + } else { + repository.loadInventory(inventoryId) + } call.respond(HttpStatusCode.OK, inventory) } diff --git a/server/src/test/kotlin/de/krisenvorrat/server/DeltaSyncTest.kt b/server/src/test/kotlin/de/krisenvorrat/server/DeltaSyncTest.kt new file mode 100644 index 0000000..a0dcd57 --- /dev/null +++ b/server/src/test/kotlin/de/krisenvorrat/server/DeltaSyncTest.kt @@ -0,0 +1,220 @@ +package de.krisenvorrat.server + +import de.krisenvorrat.server.db.DatabaseFactory +import de.krisenvorrat.shared.model.CategoryDto +import de.krisenvorrat.shared.model.InventoryDto +import de.krisenvorrat.shared.model.ItemDto +import de.krisenvorrat.shared.model.LocationDto +import de.krisenvorrat.shared.model.SettingDto +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.config.* +import io.ktor.server.testing.* +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DeltaSyncTest { + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val token = createTestAccessToken() + + private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + environment { + config = MapApplicationConfig(*testMapConfig().toTypedArray()) + } + application { + DatabaseFactory.init( + jdbcUrl = "jdbc:h2:mem:delta_test_${System.nanoTime()};DB_CLOSE_DELAY=-1", + driver = "org.h2.Driver", + adminPassword = "test-admin-pw" + ) + configurePlugins() + } + block() + } + + @Test + fun test_deltaPull_withoutSince_returnsFullInventory() = testApp { + // Given + val inventory = createInventory(lastUpdated = 1000L) + client.put("/api/inventory") { + bearerAuth(token) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(InventoryDto.serializer(), inventory)) + } + + // When – pull without since (full sync) + val response = client.get("/api/inventory") { bearerAuth(token) } + + // Then + assertEquals(HttpStatusCode.OK, response.status) + val result = json.decodeFromString(response.bodyAsText()) + assertEquals(2, result.items.size) + assertTrue(result.deletedItemIds.isEmpty()) + } + + @Test + fun test_deltaPull_withSince_returnsOnlyChangedItems() = testApp { + // Given – push inventory with two items at t=1000 + val inventory = createInventory(lastUpdated = 1000L) + client.put("/api/inventory") { + bearerAuth(token) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(InventoryDto.serializer(), inventory)) + } + + // When – patch item-1 at t=2000 + val patchBody = """{"name":"Dosenbrot aktualisiert","lastUpdated":2000}""" + client.patch("/api/inventory/items/item-1") { + bearerAuth(token) + contentType(ContentType.Application.Json) + setBody(patchBody) + } + + // When – delta pull since=1500 + val response = client.get("/api/inventory?since=1500") { bearerAuth(token) } + + // Then – only item-1 returned (updated after since) + assertEquals(HttpStatusCode.OK, response.status) + val result = json.decodeFromString(response.bodyAsText()) + assertEquals(1, result.items.size) + assertEquals("item-1", result.items[0].id) + assertEquals("Dosenbrot aktualisiert", result.items[0].name) + } + + @Test + fun test_deltaPull_withSince_returnsDeletedItemIds() = testApp { + // Given – push inventory + val inventory = createInventory(lastUpdated = 1000L) + client.put("/api/inventory") { + bearerAuth(token) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(InventoryDto.serializer(), inventory)) + } + + // When – delete item-2 + val deleteResponse = client.delete("/api/inventory/items/item-2") { bearerAuth(token) } + assertEquals(HttpStatusCode.NoContent, deleteResponse.status) + + // When – delta pull since=500 + val response = client.get("/api/inventory?since=500") { bearerAuth(token) } + + // Then – item-2 appears in deletedItemIds + assertEquals(HttpStatusCode.OK, response.status) + val result = json.decodeFromString(response.bodyAsText()) + assertTrue(result.deletedItemIds.contains("item-2")) + // item-1 should still be in items (lastUpdated=1000 > since=500) + assertEquals(1, result.items.size) + assertEquals("item-1", result.items[0].id) + } + + @Test + fun test_deltaPull_withRecentSince_returnsNoItems() = testApp { + // Given – push inventory with lastUpdated=1000 + val inventory = createInventory(lastUpdated = 1000L) + client.put("/api/inventory") { + bearerAuth(token) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(InventoryDto.serializer(), inventory)) + } + + // When – delta pull since=9999 (after all updates) + val response = client.get("/api/inventory?since=9999") { bearerAuth(token) } + + // Then – no items, no deletions + assertEquals(HttpStatusCode.OK, response.status) + val result = json.decodeFromString(response.bodyAsText()) + assertEquals(0, result.items.size) + assertTrue(result.deletedItemIds.isEmpty()) + // Categories and locations always returned + assertEquals(1, result.categories.size) + assertEquals(1, result.locations.size) + } + + @Test + fun test_deltaPull_saveInventoryTracksDeletedItems() = testApp { + // Given – push inventory with 2 items + val inventory = createInventory(lastUpdated = 1000L) + client.put("/api/inventory") { + bearerAuth(token) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(InventoryDto.serializer(), inventory)) + } + + // When – push again with only 1 item (item-2 removed) + val updatedInventory = InventoryDto( + categories = listOf(CategoryDto(id = 1, name = "Konserven")), + locations = listOf(LocationDto(id = 1, name = "Keller")), + items = listOf( + ItemDto( + id = "item-1", name = "Dosenbrot", categoryId = 1, + quantity = 5.0, unit = "Stück", unitPrice = 3.99, + kcalPerKg = 250, expiryDate = "2027-06-15", locationId = 1, + notes = "", lastUpdated = 3000L + ) + ), + settings = emptyList() + ) + client.put("/api/inventory") { + bearerAuth(token) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(InventoryDto.serializer(), updatedInventory)) + } + + // When – delta pull since=500 + val response = client.get("/api/inventory?since=500") { bearerAuth(token) } + + // Then – item-2 in deletedItemIds, item-1 in items + assertEquals(HttpStatusCode.OK, response.status) + val result = json.decodeFromString(response.bodyAsText()) + assertTrue(result.deletedItemIds.contains("item-2")) + assertEquals(1, result.items.size) + assertEquals("item-1", result.items[0].id) + } + + @Test + fun test_deltaPull_invalidSinceParam_ignoredAsFullSync() = testApp { + // Given + val inventory = createInventory(lastUpdated = 1000L) + client.put("/api/inventory") { + bearerAuth(token) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(InventoryDto.serializer(), inventory)) + } + + // When – since is not a valid number + val response = client.get("/api/inventory?since=abc") { bearerAuth(token) } + + // Then – treated as full sync + assertEquals(HttpStatusCode.OK, response.status) + val result = json.decodeFromString(response.bodyAsText()) + assertEquals(2, result.items.size) + } + + private fun createInventory(lastUpdated: Long): InventoryDto = InventoryDto( + categories = listOf(CategoryDto(id = 1, name = "Konserven")), + locations = listOf(LocationDto(id = 1, name = "Keller")), + items = listOf( + ItemDto( + id = "item-1", name = "Dosenbrot", categoryId = 1, + quantity = 5.0, unit = "Stück", unitPrice = 3.99, + kcalPerKg = 250, expiryDate = "2027-06-15", locationId = 1, + notes = "", lastUpdated = lastUpdated + ), + ItemDto( + id = "item-2", name = "Mineralwasser", categoryId = 1, + quantity = 24.0, unit = "Liter", unitPrice = 0.49, + kcalPerKg = 0, expiryDate = "2028-01-01", locationId = 1, + notes = "", lastUpdated = lastUpdated + ) + ), + settings = emptyList() + ) +} diff --git a/server/src/test/kotlin/de/krisenvorrat/server/repository/InventoryRepositoryTest.kt b/server/src/test/kotlin/de/krisenvorrat/server/repository/InventoryRepositoryTest.kt index 2b545c5..592f57e 100644 --- a/server/src/test/kotlin/de/krisenvorrat/server/repository/InventoryRepositoryTest.kt +++ b/server/src/test/kotlin/de/krisenvorrat/server/repository/InventoryRepositoryTest.kt @@ -228,4 +228,62 @@ class InventoryRepositoryTest { assertEquals(2L, stats.recentTransactions) assertTrue(stats.lastUpdated!! >= recentTimestamp) } + + @Test + fun test_loadInventorySince_returnsOnlyChangedItems() { + // Given + val inventory = InventoryDto( + categories = listOf(CategoryDto(id = 1, name = "Test")), + locations = listOf(LocationDto(id = 1, name = "Test")), + items = listOf( + ItemDto(id = "old", name = "Old", categoryId = 1, quantity = 1.0, unit = "Stk", + unitPrice = 0.0, kcalPerKg = null, expiryDate = null, locationId = 1, + notes = "", lastUpdated = 1000L), + ItemDto(id = "new", name = "New", categoryId = 1, quantity = 1.0, unit = "Stk", + unitPrice = 0.0, kcalPerKg = null, expiryDate = null, locationId = 1, + notes = "", lastUpdated = 3000L) + ), + settings = emptyList() + ) + repository.saveInventory(testUserId, inventory) + + // When + val result = repository.loadInventorySince(testUserId, 2000L) + + // Then – only the item with lastUpdated > 2000 is returned + assertEquals(1, result.items.size) + assertEquals("new", result.items[0].id) + // Note: deletedItemIds may contain entries from setUp clearing previous test data + } + + @Test + fun test_deleteItem_trackedInDeletedItems() { + // Given + repository.saveInventory(testUserId, createTestInventory()) + + // When + val deleted = repository.deleteItem(testUserId, "item-1") + + // Then + assertTrue(deleted) + val delta = repository.loadInventorySince(testUserId, 0L) + assertTrue(delta.deletedItemIds.contains("item-1")) + } + + @Test + fun test_saveInventory_tracksRemovedItemsAsDeleted() { + // Given + repository.saveInventory(testUserId, createTestInventory()) + + // When – save with no items (all removed) + val emptyInventory = InventoryDto( + categories = emptyList(), locations = emptyList(), + items = emptyList(), settings = emptyList() + ) + repository.saveInventory(testUserId, emptyInventory) + + // Then + val delta = repository.loadInventorySince(testUserId, 0L) + assertTrue(delta.deletedItemIds.contains("item-1")) + } } diff --git a/shared/src/main/kotlin/de/krisenvorrat/shared/model/InventoryDto.kt b/shared/src/main/kotlin/de/krisenvorrat/shared/model/InventoryDto.kt index 4663b40..d9ebd09 100644 --- a/shared/src/main/kotlin/de/krisenvorrat/shared/model/InventoryDto.kt +++ b/shared/src/main/kotlin/de/krisenvorrat/shared/model/InventoryDto.kt @@ -11,5 +11,6 @@ data class InventoryDto( val categories: List, val locations: List, val items: List, - val settings: List + val settings: List, + val deletedItemIds: List = emptyList() )