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=<ts> 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
This commit is contained in:
parent
75cfc41924
commit
0f25c180ed
16 changed files with 443 additions and 13 deletions
|
|
@ -40,6 +40,9 @@ internal interface ItemDao {
|
|||
@Upsert
|
||||
suspend fun upsertAll(items: List<ItemEntity>)
|
||||
|
||||
@Query("DELETE FROM items WHERE id IN (:ids)")
|
||||
suspend fun deleteByIds(ids: List<String>)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM items WHERE category_id = :categoryId")
|
||||
suspend fun countByCategoryId(categoryId: Int): Int
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -30,9 +30,13 @@ internal class SyncServiceImpl @Inject constructor(
|
|||
private val settingsRepository: SettingsRepository
|
||||
) : SyncService {
|
||||
|
||||
override suspend fun downloadInventory(): Result<InventoryDto> =
|
||||
override suspend fun downloadInventory(since: Long?): Result<InventoryDto> =
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import de.krisenvorrat.shared.model.InventoryDto
|
|||
import de.krisenvorrat.shared.model.ItemDto
|
||||
|
||||
internal interface SyncService {
|
||||
suspend fun downloadInventory(): Result<InventoryDto>
|
||||
suspend fun downloadInventory(since: Long? = null): Result<InventoryDto>
|
||||
suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto>
|
||||
suspend fun login(serverUrl: String, username: String, password: String): Result<Unit>
|
||||
suspend fun logout()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<String>) {
|
||||
items.removeAll { it.id in ids }
|
||||
emit()
|
||||
}
|
||||
|
||||
fun getItems(): List<ItemEntity> = items.toList()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,11 @@ private class FakeItemDao : ItemDao {
|
|||
|
||||
override suspend fun getLastUsedLocationId(): Int? =
|
||||
items.maxByOrNull { it.lastUpdated }?.locationId
|
||||
|
||||
override suspend fun deleteByIds(ids: List<String>) {
|
||||
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<InventoryDto> = Result.success(
|
||||
override suspend fun downloadInventory(since: Long?): Result<InventoryDto> = Result.success(
|
||||
InventoryDto(categories = emptyList(), locations = emptyList(), items = emptyList(), settings = emptyList())
|
||||
)
|
||||
override suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto> =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -770,7 +770,7 @@ private class FakeSyncService : SyncService {
|
|||
settings = emptyList()
|
||||
)
|
||||
|
||||
override suspend fun downloadInventory(): Result<InventoryDto> {
|
||||
override suspend fun downloadInventory(since: Long?): Result<InventoryDto> {
|
||||
if (downloadShouldFail) return Result.failure(downloadError)
|
||||
return Result.success(downloadResult)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -55,7 +55,12 @@ internal fun Route.inventoryRoutes(
|
|||
val userId = call.principal<UserPrincipal>()?.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)
|
||||
}
|
||||
|
||||
|
|
|
|||
220
server/src/test/kotlin/de/krisenvorrat/server/DeltaSyncTest.kt
Normal file
220
server/src/test/kotlin/de/krisenvorrat/server/DeltaSyncTest.kt
Normal file
|
|
@ -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<InventoryDto>(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<InventoryDto>(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<InventoryDto>(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<InventoryDto>(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<InventoryDto>(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<InventoryDto>(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()
|
||||
)
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@ data class InventoryDto(
|
|||
val categories: List<CategoryDto>,
|
||||
val locations: List<LocationDto>,
|
||||
val items: List<ItemDto>,
|
||||
val settings: List<SettingDto>
|
||||
val settings: List<SettingDto>,
|
||||
val deletedItemIds: List<String> = emptyList()
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue