From 1d7a62448ae8990a779feb3315b132dd24b9b216 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Sat, 16 May 2026 21:40:10 +0200 Subject: [PATCH] feat: Offline-Queue, Sofort-Sync & Last-Write-Wins (#61) - PendingSyncOpEntity + PendingSyncOpDao: Room-Queue fuer ausstehende PATCH/DELETE-Ops - MIGRATION_2_3: neue Tabelle pending_sync_ops (Version 2 -> 3) - SyncService.patchItem() + deleteItem(): PATCH/DELETE /api/inventory/items/{id} - ItemRepositoryImpl: nach insert/update/delete sofortiger PATCH-Versuch (fire-and-forget), bei Netzwerkfehler (Timeout/Connection/Unknown) -> Queue, AuthError/NotConfigured -> silent - drainQueue() bei WebSocketEvent.Connected: Queue abarbeiten, korrupte Ops loeschen - ImportExportRepositoryImpl.applyInventoryDto(): Last-Write-Wins per lastUpdated-Timestamp - KrisenvorratDatabaseMigrationTest: V2->V3-Test ergaenzt - 223 Unit Tests gruen --- .../3.json | 258 ++++++++++++++++++ .../db/KrisenvorratDatabaseMigrationTest.kt | 81 +++++- .../app/data/db/KrisenvorratDatabase.kt | 7 +- .../de/krisenvorrat/app/data/db/Migrations.kt | 17 ++ .../app/data/db/dao/PendingSyncOpDao.kt | 23 ++ .../app/data/db/entity/PendingSyncOpEntity.kt | 14 + .../data/export/ImportExportRepositoryImpl.kt | 7 +- .../app/data/repository/ItemRepositoryImpl.kt | 122 ++++++++- .../app/data/sync/SyncServiceImpl.kt | 55 ++++ .../krisenvorrat/app/di/ApplicationScope.kt | 7 + .../de/krisenvorrat/app/di/DatabaseModule.kt | 6 +- .../de/krisenvorrat/app/di/NetworkModule.kt | 8 + .../app/domain/repository/SyncService.kt | 3 + .../export/ImportExportRepositoryImplTest.kt | 30 ++ .../data/repository/ItemRepositoryImplTest.kt | 194 ++++++++++++- .../app/ui/settings/SettingsViewModelTest.kt | 2 + 16 files changed, 821 insertions(+), 13 deletions(-) create mode 100644 app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/3.json create mode 100644 app/src/main/java/de/krisenvorrat/app/data/db/dao/PendingSyncOpDao.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/data/db/entity/PendingSyncOpEntity.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/di/ApplicationScope.kt diff --git a/app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/3.json b/app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/3.json new file mode 100644 index 0000000..170ba2d --- /dev/null +++ b/app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/3.json @@ -0,0 +1,258 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "5a36dbd23349eee49325038a617b7f60", + "entities": [ + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `category_id` INTEGER NOT NULL, `quantity` REAL NOT NULL, `unit` TEXT NOT NULL, `unit_price` REAL NOT NULL, `kcal_per_kg` INTEGER, `expiry_date` TEXT, `location_id` INTEGER NOT NULL, `notes` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`category_id`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "quantity", + "columnName": "quantity", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unitPrice", + "columnName": "unit_price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "kcalPerKg", + "columnName": "kcal_per_kg", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expiryDate", + "columnName": "expiry_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locationId", + "columnName": "location_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_items_category_id", + "unique": false, + "columnNames": [ + "category_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_items_category_id` ON `${TABLE_NAME}` (`category_id`)" + }, + { + "name": "index_items_location_id", + "unique": false, + "columnNames": [ + "location_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_items_location_id` ON `${TABLE_NAME}` (`location_id`)" + } + ], + "foreignKeys": [ + { + "table": "categories", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "locations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "location_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "pending_sync_ops", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `item_id` TEXT NOT NULL, `operation` TEXT NOT NULL, `payload` TEXT NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "item_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "operation", + "columnName": "operation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5a36dbd23349eee49325038a617b7f60')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/krisenvorrat/app/data/db/KrisenvorratDatabaseMigrationTest.kt b/app/src/androidTest/java/de/krisenvorrat/app/data/db/KrisenvorratDatabaseMigrationTest.kt index 1e98a75..8e5aa76 100644 --- a/app/src/androidTest/java/de/krisenvorrat/app/data/db/KrisenvorratDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/de/krisenvorrat/app/data/db/KrisenvorratDatabaseMigrationTest.kt @@ -107,7 +107,50 @@ internal class KrisenvorratDatabaseMigrationTest { context, KrisenvorratDatabase::class.java, dbName - ).addMigrations(Migrations.MIGRATION_1_2).build() + ).addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3).build() + + private fun createV2Database() { + val dbFile = context.getDatabasePath(dbName).also { it.parentFile?.mkdirs() } + SQLiteDatabase.openOrCreateDatabase(dbFile, null).use { db -> + db.execSQL( + "CREATE TABLE `categories` " + + "(`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)" + ) + db.execSQL( + "CREATE TABLE `locations` " + + "(`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)" + ) + db.execSQL( + """ + CREATE TABLE `items` ( + `id` TEXT NOT NULL, + `name` TEXT NOT NULL, + `category_id` INTEGER NOT NULL, + `quantity` REAL NOT NULL, + `unit` TEXT NOT NULL, + `unit_price` REAL NOT NULL, + `kcal_per_kg` INTEGER, + `expiry_date` TEXT, + `location_id` INTEGER NOT NULL, + `notes` TEXT NOT NULL, + `last_updated` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + db.execSQL( + "CREATE TABLE `settings` " + + "(`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))" + ) + db.version = 2 + } + } + + private fun openMigratedDbV3() = Room.databaseBuilder( + context, + KrisenvorratDatabase::class.java, + dbName + ).addMigrations(Migrations.MIGRATION_2_3).build() // ------------------------------------------------------------------------- // Tests @@ -187,7 +230,7 @@ internal class KrisenvorratDatabaseMigrationTest { fun freshInstall_worksWithoutMigration() { // Fresh-Install: Room.onCreate() läuft direkt, keine Migration nötig val db = Room.inMemoryDatabaseBuilder(context, KrisenvorratDatabase::class.java) - .addMigrations(Migrations.MIGRATION_1_2) + .addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3) .build() try { // Tabellen anlegen und Basis-Operationen prüfen @@ -201,4 +244,38 @@ internal class KrisenvorratDatabaseMigrationTest { db.close() } } + + @Test + fun migrate2To3_pendingSyncOpsTableExists() { + createV2Database() + + val db = openMigratedDbV3() + try { + val tables = mutableListOf() + db.openHelper.writableDatabase.query( + "SELECT name FROM sqlite_master WHERE type='table'" + ).use { cursor -> + while (cursor.moveToNext()) { + tables.add(cursor.getString(0)) + } + } + + assertTrue("pending_sync_ops Tabelle muss existieren", tables.contains("pending_sync_ops")) + + // Eine Row kann eingetragen und gelesen werden + db.openHelper.writableDatabase.execSQL( + "INSERT INTO pending_sync_ops (id, item_id, operation, payload, created_at) " + + "VALUES ('op1', 'item1', 'PATCH', '{\"key\":\"val\"}', 1000)" + ) + var rowCount = 0 + db.openHelper.writableDatabase.query( + "SELECT COUNT(*) FROM pending_sync_ops" + ).use { cursor -> + if (cursor.moveToNext()) rowCount = cursor.getInt(0) + } + assertEquals("Genau eine Row muss in pending_sync_ops stehen", 1, rowCount) + } finally { + db.close() + } + } } diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt b/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt index 5226ecb..180a8a6 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt @@ -6,15 +6,17 @@ import androidx.room.TypeConverters import de.krisenvorrat.app.data.db.dao.CategoryDao import de.krisenvorrat.app.data.db.dao.ItemDao import de.krisenvorrat.app.data.db.dao.LocationDao +import de.krisenvorrat.app.data.db.dao.PendingSyncOpDao 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.PendingSyncOpEntity import de.krisenvorrat.app.data.db.entity.SettingsEntity @Database( - entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class], - version = 2, + entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class], + version = 3, exportSchema = true ) @TypeConverters(LocalDateConverter::class) @@ -23,4 +25,5 @@ internal abstract class KrisenvorratDatabase : RoomDatabase() { abstract fun locationDao(): LocationDao abstract fun itemDao(): ItemDao abstract fun settingsDao(): SettingsDao + abstract fun pendingSyncOpDao(): PendingSyncOpDao } diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt b/app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt index 0add0e3..4527948 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt @@ -68,4 +68,21 @@ internal object Migrations { ) } } + + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `pending_sync_ops` ( + `id` TEXT NOT NULL, + `item_id` TEXT NOT NULL, + `operation` TEXT NOT NULL, + `payload` TEXT NOT NULL, + `created_at` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + } + } } diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/dao/PendingSyncOpDao.kt b/app/src/main/java/de/krisenvorrat/app/data/db/dao/PendingSyncOpDao.kt new file mode 100644 index 0000000..7efab59 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/db/dao/PendingSyncOpDao.kt @@ -0,0 +1,23 @@ +package de.krisenvorrat.app.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import de.krisenvorrat.app.data.db.entity.PendingSyncOpEntity + +@Dao +internal interface PendingSyncOpDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(op: PendingSyncOpEntity) + + @Query("SELECT * FROM pending_sync_ops ORDER BY created_at ASC") + suspend fun getAll(): List + + @Query("DELETE FROM pending_sync_ops WHERE id = :id") + suspend fun deleteById(id: String) + + @Query("DELETE FROM pending_sync_ops WHERE item_id = :itemId") + suspend fun deleteByItemId(itemId: String) +} diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/entity/PendingSyncOpEntity.kt b/app/src/main/java/de/krisenvorrat/app/data/db/entity/PendingSyncOpEntity.kt new file mode 100644 index 0000000..8d0fcdf --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/db/entity/PendingSyncOpEntity.kt @@ -0,0 +1,14 @@ +package de.krisenvorrat.app.data.db.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "pending_sync_ops") +internal data class PendingSyncOpEntity( + @PrimaryKey val id: String, + @ColumnInfo(name = "item_id") val itemId: String, + @ColumnInfo(name = "operation") val operation: String, + @ColumnInfo(name = "payload") val payload: String, + @ColumnInfo(name = "created_at") val createdAt: Long +) 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 6c1aa01..6874838 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 @@ -90,10 +90,15 @@ internal class ImportExportRepositoryImpl @Inject constructor( } private suspend fun applyInventoryDto(dto: InventoryDto) { + val localItems = itemDao.getAll().first() + val localMap = localItems.associateBy { it.id } + val itemsToApply = dto.items.filter { item -> + item.lastUpdated > (localMap[item.id]?.lastUpdated ?: -1L) + } 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) }) - itemDao.upsertAll(dto.items.map { item -> + itemDao.upsertAll(itemsToApply.map { item -> ItemEntity( id = item.id, name = item.name, diff --git a/app/src/main/java/de/krisenvorrat/app/data/repository/ItemRepositoryImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/repository/ItemRepositoryImpl.kt index 13a0646..eb753fc 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/repository/ItemRepositoryImpl.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/repository/ItemRepositoryImpl.kt @@ -1,31 +1,69 @@ package de.krisenvorrat.app.data.repository import de.krisenvorrat.app.data.db.dao.ItemDao +import de.krisenvorrat.app.data.db.dao.PendingSyncOpDao import de.krisenvorrat.app.data.db.entity.ItemEntity +import de.krisenvorrat.app.data.db.entity.PendingSyncOpEntity +import de.krisenvorrat.app.data.sync.WebSocketClient +import de.krisenvorrat.app.data.sync.WebSocketEvent +import de.krisenvorrat.app.di.ApplicationScope +import de.krisenvorrat.app.domain.model.SettingsKeys +import de.krisenvorrat.app.domain.model.SyncError import de.krisenvorrat.app.domain.repository.ItemRepository +import de.krisenvorrat.app.domain.repository.SettingsRepository +import de.krisenvorrat.app.domain.repository.SyncService +import de.krisenvorrat.shared.model.ItemDto +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json import java.time.LocalDate +import java.util.UUID import javax.inject.Inject internal class ItemRepositoryImpl @Inject constructor( - private val dao: ItemDao + private val dao: ItemDao, + private val syncService: SyncService, + private val pendingSyncOpDao: PendingSyncOpDao, + private val settingsRepository: SettingsRepository, + private val webSocketClient: WebSocketClient, + @ApplicationScope private val scope: CoroutineScope ) : ItemRepository { + init { + scope.launch { + webSocketClient.events.collect { event -> + if (event is WebSocketEvent.Connected) { + drainQueue() + } + } + } + } + override fun getAll(): Flow> = dao.getAll() override suspend fun getById(id: String): ItemEntity? = withContext(Dispatchers.IO) { dao.getById(id) } - override suspend fun insert(item: ItemEntity) = + override suspend fun insert(item: ItemEntity) { withContext(Dispatchers.IO) { dao.insert(item) } + scope.launch { attemptPatch(item.id, item.toDto()) } + } - override suspend fun update(item: ItemEntity) = + override suspend fun update(item: ItemEntity) { withContext(Dispatchers.IO) { dao.update(item) } + scope.launch { attemptPatch(item.id, item.toDto()) } + } - override suspend fun delete(item: ItemEntity) = - withContext(Dispatchers.IO) { dao.delete(item) } + override suspend fun delete(item: ItemEntity) { + withContext(Dispatchers.IO) { + pendingSyncOpDao.deleteByItemId(item.id) + dao.delete(item) + } + scope.launch { attemptDelete(item.id) } + } override fun getByCategory(categoryId: Int): Flow> = dao.getByCategory(categoryId) @@ -50,4 +88,78 @@ internal class ItemRepositoryImpl @Inject constructor( override suspend fun getLastUsedLocationId(): Int? = withContext(Dispatchers.IO) { dao.getLastUsedLocationId() } + + private suspend fun attemptPatch(itemId: String, item: ItemDto) { + val token = settingsRepository.getValue(SettingsKeys.AUTH_ACCESS_TOKEN) + if (token.isNullOrBlank()) return + val result = syncService.patchItem(itemId, item) + val error = result.exceptionOrNull() + if (error is SyncError.Timeout || error is SyncError.ConnectionError || error is SyncError.Unknown) { + pendingSyncOpDao.insert( + PendingSyncOpEntity( + id = UUID.randomUUID().toString(), + itemId = itemId, + operation = "PATCH", + payload = Json.encodeToString(ItemDto.serializer(), item), + createdAt = System.currentTimeMillis() + ) + ) + } + } + + private suspend fun attemptDelete(itemId: String) { + val token = settingsRepository.getValue(SettingsKeys.AUTH_ACCESS_TOKEN) + if (token.isNullOrBlank()) return + val result = syncService.deleteItem(itemId) + val error = result.exceptionOrNull() + if (error is SyncError.Timeout || error is SyncError.ConnectionError || error is SyncError.Unknown) { + pendingSyncOpDao.insert( + PendingSyncOpEntity( + id = UUID.randomUUID().toString(), + itemId = itemId, + operation = "DELETE", + payload = "", + createdAt = System.currentTimeMillis() + ) + ) + } + } + + private suspend fun drainQueue() { + val ops = pendingSyncOpDao.getAll() + for (op in ops) { + val result = when (op.operation) { + "PATCH" -> { + val item = runCatching { + Json.decodeFromString(ItemDto.serializer(), op.payload) + }.getOrNull() + if (item == null) { + pendingSyncOpDao.deleteById(op.id) + continue + } + syncService.patchItem(op.itemId, item) + } + "DELETE" -> syncService.deleteItem(op.itemId) + else -> Result.failure(IllegalStateException("Unknown operation: ${op.operation}")) + } + if (result.isSuccess) { + pendingSyncOpDao.deleteById(op.id) + } + } + } + + private fun ItemEntity.toDto() = ItemDto( + id = id, + name = name, + categoryId = categoryId, + quantity = quantity, + unit = unit, + unitPrice = unitPrice, + kcalPerKg = kcalPerKg, + expiryDate = expiryDate?.toString(), + locationId = locationId, + notes = notes, + lastUpdated = lastUpdated + ) } + 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 98903df..9ca3b97 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 @@ -5,10 +5,13 @@ import de.krisenvorrat.app.domain.model.SyncError import de.krisenvorrat.app.domain.repository.SettingsRepository import de.krisenvorrat.app.domain.repository.SyncService import de.krisenvorrat.shared.model.InventoryDto +import de.krisenvorrat.shared.model.ItemDto import io.ktor.client.HttpClient import io.ktor.client.call.body +import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.header +import io.ktor.client.request.patch import io.ktor.client.request.post import io.ktor.client.request.put import io.ktor.client.request.setBody @@ -45,6 +48,24 @@ internal class SyncServiceImpl @Inject constructor( handleResponse(response) } + override suspend fun patchItem(itemId: String, item: ItemDto): Result = + executeItemRequest { serverUrl, token -> + val response = httpClient.patch("$serverUrl/api/inventory/items/$itemId") { + header("Authorization", "Bearer $token") + contentType(ContentType.Application.Json) + setBody(item) + } + handleUnitResponse(response) + } + + override suspend fun deleteItem(itemId: String): Result = + executeItemRequest { serverUrl, token -> + val response = httpClient.delete("$serverUrl/api/inventory/items/$itemId") { + header("Authorization", "Bearer $token") + } + handleUnitResponse(response) + } + override suspend fun login( serverUrl: String, username: String, @@ -119,6 +140,28 @@ internal class SyncServiceImpl @Inject constructor( } } + private suspend fun executeItemRequest( + block: suspend (serverUrl: String, token: String) -> Result + ): Result = withContext(Dispatchers.IO) { + val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) + if (serverUrl.isNullOrBlank()) { + return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt")) + } + val token = settingsRepository.getValue(KEY_ACCESS_TOKEN) + if (token.isNullOrBlank()) { + return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet")) + } + try { + block(serverUrl.trimEnd('/'), token) + } catch (e: SocketTimeoutException) { + Result.failure(SyncError.Timeout(e)) + } catch (e: ConnectException) { + Result.failure(SyncError.ConnectionError(e)) + } catch (e: Exception) { + Result.failure(SyncError.Unknown(e)) + } + } + private suspend fun refreshToken(serverUrl: String): Boolean { val refreshToken = settingsRepository.getValue(KEY_REFRESH_TOKEN) if (refreshToken.isNullOrBlank()) return false @@ -152,6 +195,18 @@ internal class SyncServiceImpl @Inject constructor( ) } + private fun handleUnitResponse(response: HttpResponse): Result = + when (response.status) { + HttpStatusCode.OK, HttpStatusCode.NoContent -> Result.success(Unit) + HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError()) + else -> Result.failure( + SyncError.ServerError( + statusCode = response.status.value, + message = response.status.description + ) + ) + } + private companion object { val KEY_SERVER_URL = SettingsKeys.SERVER_URL val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN diff --git a/app/src/main/java/de/krisenvorrat/app/di/ApplicationScope.kt b/app/src/main/java/de/krisenvorrat/app/di/ApplicationScope.kt new file mode 100644 index 0000000..ed63ffd --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/di/ApplicationScope.kt @@ -0,0 +1,7 @@ +package de.krisenvorrat.app.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +internal annotation class ApplicationScope diff --git a/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt b/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt index 0659660..9d209a9 100644 --- a/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt +++ b/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt @@ -15,6 +15,7 @@ import de.krisenvorrat.app.data.db.Migrations 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.PendingSyncOpDao import de.krisenvorrat.app.data.db.dao.SettingsDao import de.krisenvorrat.app.data.export.DatabaseTransaction import javax.inject.Singleton @@ -28,7 +29,7 @@ internal object DatabaseModule { fun provideDatabase(@ApplicationContext context: Context): KrisenvorratDatabase = Room.databaseBuilder(context, KrisenvorratDatabase::class.java, "krisenvorrat.db") .addCallback(DefaultDataCallback) - .addMigrations(Migrations.MIGRATION_1_2) + .addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3) .build() private object DefaultDataCallback : RoomDatabase.Callback() { @@ -58,6 +59,9 @@ internal object DatabaseModule { @Provides fun provideLocationDao(db: KrisenvorratDatabase): LocationDao = db.locationDao() + @Provides + fun providePendingSyncOpDao(db: KrisenvorratDatabase): PendingSyncOpDao = db.pendingSyncOpDao() + @Provides fun provideSettingsDao(db: KrisenvorratDatabase): SettingsDao = db.settingsDao() diff --git a/app/src/main/java/de/krisenvorrat/app/di/NetworkModule.kt b/app/src/main/java/de/krisenvorrat/app/di/NetworkModule.kt index 989345c..9f4bd4a 100644 --- a/app/src/main/java/de/krisenvorrat/app/di/NetworkModule.kt +++ b/app/src/main/java/de/krisenvorrat/app/di/NetworkModule.kt @@ -16,7 +16,9 @@ import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.serialization.json.Json import java.util.concurrent.TimeUnit import javax.inject.Singleton @@ -56,6 +58,12 @@ internal abstract class NetworkModule { } } + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.Default) + @Provides @IoDispatcher fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO 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 869a840..c35fe4e 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 @@ -1,10 +1,13 @@ package de.krisenvorrat.app.domain.repository import de.krisenvorrat.shared.model.InventoryDto +import de.krisenvorrat.shared.model.ItemDto internal interface SyncService { suspend fun downloadInventory(): Result suspend fun uploadInventory(inventory: InventoryDto): Result suspend fun login(serverUrl: String, username: String, password: String): Result suspend fun logout() + suspend fun patchItem(itemId: String, item: ItemDto): Result + suspend fun deleteItem(itemId: String): Result } 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 cd6ccd5..9602bb9 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 @@ -242,4 +242,34 @@ class ImportExportRepositoryImplTest { // Then assertTrue(!markdown.contains("## Einstellungen")) } + + @Test + fun test_applyInventoryDto_lastWriteWins_localNewerNotOverwritten() = runBlocking { + // Given – lokales Item hat neueren Timestamp + val itemDao = FakeItemDao() + itemDao.upsertAll(listOf(buildItemEntity("item1").copy(name = "Lokal", lastUpdated = 1000L))) + val repository = buildRepository(itemDao = itemDao) + val json = """{"version":1,"categories":[],"locations":[],"items":[{"id":"item1","name":"VomServer","categoryId":1,"quantity":2.0,"unit":"Stk","unitPrice":1.5,"kcalPerKg":null,"expiryDate":null,"locationId":1,"notes":"","lastUpdated":500}],"settings":[]}""" + + // When + repository.importFromJson(json) + + // Then – lokaler Name bleibt erhalten, da lokaler Timestamp neuer ist + assertEquals("Lokal", itemDao.getItems().first { it.id == "item1" }.name) + } + + @Test + fun test_applyInventoryDto_lastWriteWins_serverNewerIsApplied() = runBlocking { + // Given – Server-Item hat neueren Timestamp + val itemDao = FakeItemDao() + itemDao.upsertAll(listOf(buildItemEntity("item2").copy(name = "Lokal", lastUpdated = 100L))) + val repository = buildRepository(itemDao = itemDao) + val json = """{"version":1,"categories":[],"locations":[],"items":[{"id":"item2","name":"VomServer","categoryId":1,"quantity":2.0,"unit":"Stk","unitPrice":1.5,"kcalPerKg":null,"expiryDate":null,"locationId":1,"notes":"","lastUpdated":2000}],"settings":[]}""" + + // When + repository.importFromJson(json) + + // Then – Server-Name wird übernommen, da Server-Timestamp neuer ist + assertEquals("VomServer", itemDao.getItems().first { it.id == "item2" }.name) + } } 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 940d9bb..6f1476e 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 @@ -1,12 +1,33 @@ package de.krisenvorrat.app.data.repository import de.krisenvorrat.app.data.db.dao.ItemDao +import de.krisenvorrat.app.data.db.dao.PendingSyncOpDao import de.krisenvorrat.app.data.db.entity.ItemEntity +import de.krisenvorrat.app.data.db.entity.PendingSyncOpEntity +import de.krisenvorrat.app.data.db.entity.SettingsEntity +import de.krisenvorrat.app.data.sync.WebSocketClient +import de.krisenvorrat.app.data.sync.WebSocketEvent +import de.krisenvorrat.app.domain.model.SettingsKeys +import de.krisenvorrat.app.domain.model.SyncError +import de.krisenvorrat.app.domain.repository.SettingsRepository +import de.krisenvorrat.app.domain.repository.SyncService +import de.krisenvorrat.shared.model.InventoryDto +import de.krisenvorrat.shared.model.ItemDto +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -81,6 +102,59 @@ private class FakeItemDao : ItemDao { items.maxByOrNull { it.lastUpdated }?.locationId } +private class FakePendingSyncOpDao : PendingSyncOpDao { + val ops = mutableListOf() + + override suspend fun insert(op: PendingSyncOpEntity) { ops.add(op) } + override suspend fun getAll(): List = ops.toList() + override suspend fun deleteById(id: String) { ops.removeAll { it.id == id } } + override suspend fun deleteByItemId(itemId: String) { ops.removeAll { it.itemId == itemId } } +} + +private class FakeSettingsRepository : SettingsRepository { + private val store = mutableMapOf() + fun set(key: String, value: String) { store[key] = value } + + override suspend fun getValue(key: String): String? = store[key] + override suspend fun setValue(key: String, value: String) { store[key] = value } + override fun observeValue(key: String): Flow = MutableStateFlow(store[key]) + override fun getAll(): Flow> = MutableStateFlow(emptyList()) +} + +private class FakeSyncService : SyncService { + var patchResult: Result = Result.success(Unit) + var deleteResult: Result = Result.success(Unit) + val patchedItems = mutableListOf>() + val deletedItemIds = mutableListOf() + + override suspend fun patchItem(itemId: String, item: ItemDto): Result { + patchedItems.add(itemId to item) + return patchResult + } + + override suspend fun deleteItem(itemId: String): Result { + deletedItemIds.add(itemId) + return deleteResult + } + + override suspend fun downloadInventory(): Result = Result.success( + InventoryDto(categories = emptyList(), locations = emptyList(), items = emptyList(), settings = emptyList()) + ) + override suspend fun uploadInventory(inventory: InventoryDto): Result = + Result.success(inventory) + override suspend fun login(serverUrl: String, username: String, password: String): Result = + Result.success(Unit) + override suspend fun logout() {} +} + +private class FakeWebSocketClient : WebSocketClient { + private val _events = MutableSharedFlow(extraBufferCapacity = 10) + override val events: SharedFlow = _events + suspend fun emit(event: WebSocketEvent) { _events.emit(event) } + override fun connect(serverUrl: String, accessToken: String) {} + override fun disconnect() {} +} + private fun buildItem( id: String = "id1", categoryId: Int = 1, @@ -99,14 +173,29 @@ private fun buildItem( lastUpdated = 0L ) +@OptIn(ExperimentalCoroutinesApi::class) class ItemRepositoryImplTest { private val fakeDao = FakeItemDao() - private val repository = ItemRepositoryImpl(fakeDao) + private val fakePendingSyncOpDao = FakePendingSyncOpDao() + private val fakeSettingsRepository = FakeSettingsRepository() + private val fakeSyncService = FakeSyncService() + private val fakeWebSocketClient = FakeWebSocketClient() + private val testScope = TestScope() + + private fun buildRepository(scope: CoroutineScope = testScope) = ItemRepositoryImpl( + dao = fakeDao, + syncService = fakeSyncService, + pendingSyncOpDao = fakePendingSyncOpDao, + settingsRepository = fakeSettingsRepository, + webSocketClient = fakeWebSocketClient, + scope = scope + ) @Test fun test_insert_withNewItem_itemAppearsInGetAll() = runBlocking { // Given + val repository = buildRepository() val item = buildItem(id = "abc") // When @@ -120,6 +209,7 @@ class ItemRepositoryImplTest { @Test fun test_getById_withExistingId_returnsItem() = runBlocking { // Given + val repository = buildRepository() val item = buildItem(id = "abc") repository.insert(item) @@ -133,7 +223,7 @@ class ItemRepositoryImplTest { @Test fun test_getById_withUnknownId_returnsNull() = runBlocking { // Given / When - val result = repository.getById("unknown") + val result = buildRepository().getById("unknown") // Then assertNull(result) @@ -142,6 +232,7 @@ class ItemRepositoryImplTest { @Test fun test_update_withExistingItem_itemIsUpdated() = runBlocking { // Given + val repository = buildRepository() val item = buildItem(id = "abc") repository.insert(item) val updated = item.copy(name = "Aktualisiert") @@ -157,6 +248,7 @@ class ItemRepositoryImplTest { @Test fun test_delete_withExistingItem_itemRemovedFromGetAll() = runBlocking { // Given + val repository = buildRepository() val item = buildItem(id = "abc") repository.insert(item) @@ -171,6 +263,7 @@ class ItemRepositoryImplTest { @Test fun test_getByCategory_withMatchingItems_returnsFilteredItems() = runBlocking { // Given + val repository = buildRepository() repository.insert(buildItem(id = "a", categoryId = 1)) repository.insert(buildItem(id = "b", categoryId = 2)) @@ -185,6 +278,7 @@ class ItemRepositoryImplTest { @Test fun test_getByLocation_withMatchingItems_returnsFilteredItems() = runBlocking { // Given + val repository = buildRepository() repository.insert(buildItem(id = "a", locationId = 1)) repository.insert(buildItem(id = "b", locationId = 2)) @@ -199,6 +293,7 @@ class ItemRepositoryImplTest { @Test fun test_getExpiringSoon_withPreconfiguredFlow_returnsExpectedItems() = runBlocking { // Given + val repository = buildRepository() val expiring = buildItem(id = "exp") fakeDao.setExpiringSoonItems(listOf(expiring)) @@ -209,4 +304,99 @@ class ItemRepositoryImplTest { assertEquals(1, result.size) assertEquals("exp", result.first().id) } + + @Test + fun test_insert_whenServerConfigured_callsPatchItem() = runBlocking { + // Given + fakeSettingsRepository.set(SettingsKeys.AUTH_ACCESS_TOKEN, "token123") + val syncScope = CoroutineScope(Dispatchers.Unconfined) + val repository = buildRepository(scope = syncScope) + val item = buildItem(id = "sync1") + + // When + repository.insert(item) + + // Then + assertTrue(fakeSyncService.patchedItems.any { it.first == "sync1" }) + syncScope.cancel() + } + + @Test + fun test_insert_whenPatchFails_queuesOp() = runBlocking { + // Given + fakeSettingsRepository.set(SettingsKeys.AUTH_ACCESS_TOKEN, "token123") + fakeSyncService.patchResult = Result.failure(SyncError.ConnectionError()) + val syncScope = CoroutineScope(Dispatchers.Unconfined) + val repository = buildRepository(scope = syncScope) + val item = buildItem(id = "queue1") + + // When + repository.insert(item) + + // Then + val ops = fakePendingSyncOpDao.getAll() + assertTrue(ops.any { it.itemId == "queue1" && it.operation == "PATCH" }) + syncScope.cancel() + } + + @Test + fun test_insert_whenNotConfigured_doesNotQueue() = runBlocking { + // Given – kein Token gesetzt + val repository = buildRepository() + val item = buildItem(id = "noconf") + + // When + repository.insert(item) + + // Then + assertTrue(fakePendingSyncOpDao.ops.isEmpty()) + assertTrue(fakeSyncService.patchedItems.isEmpty()) + } + + @Test + fun test_delete_queuesDeleteOp_onNetworkError() = runBlocking { + // Given + fakeSettingsRepository.set(SettingsKeys.AUTH_ACCESS_TOKEN, "token123") + fakeSyncService.deleteResult = Result.failure(SyncError.ConnectionError()) + val syncScope = CoroutineScope(Dispatchers.Unconfined) + val repository = buildRepository(scope = syncScope) + val item = buildItem(id = "del1") + fakeDao.insert(item) + + // When + repository.delete(item) + + // Then + val ops = fakePendingSyncOpDao.getAll() + assertTrue(ops.any { it.itemId == "del1" && it.operation == "DELETE" }) + syncScope.cancel() + } + + @Test + fun test_drainQueue_onConnectedEvent_executesAndClearsOps() = + runTest(UnconfinedTestDispatcher()) { + // Given + fakeSettingsRepository.set(SettingsKeys.AUTH_ACCESS_TOKEN, "token123") + fakeSyncService.patchResult = Result.success(Unit) + val drainScope = CoroutineScope(UnconfinedTestDispatcher()) + val repository = buildRepository(scope = drainScope) + fakePendingSyncOpDao.insert( + PendingSyncOpEntity( + id = "op1", + itemId = "drain1", + operation = "PATCH", + payload = """{"id":"drain1","name":"Konserve","categoryId":1,"quantity":2.0,"unit":"Stk","unitPrice":1.5,"kcalPerKg":null,"expiryDate":null,"locationId":1,"notes":"","lastUpdated":0}""", + createdAt = 1000L + ) + ) + + // When + fakeWebSocketClient.emit(WebSocketEvent.Connected) + + // Then + assertTrue(fakePendingSyncOpDao.ops.isEmpty()) + assertTrue(fakeSyncService.patchedItems.any { it.first == "drain1" }) + drainScope.cancel() + } } + 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 d3833cb..8ea1472 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 @@ -786,6 +786,8 @@ private class FakeSyncService : SyncService { } override suspend fun logout() {} + override suspend fun patchItem(itemId: String, item: de.krisenvorrat.shared.model.ItemDto): Result = Result.success(Unit) + override suspend fun deleteItem(itemId: String): Result = Result.success(Unit) } private class FakeWebSocketClient : WebSocketClient {