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
This commit is contained in:
Jens Reinemann 2026-05-16 21:40:10 +02:00
parent 4c2f5f08a4
commit 1d7a62448a
16 changed files with 821 additions and 13 deletions

View file

@ -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')"
]
}
}

View file

@ -107,7 +107,50 @@ internal class KrisenvorratDatabaseMigrationTest {
context, context,
KrisenvorratDatabase::class.java, KrisenvorratDatabase::class.java,
dbName 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 // Tests
@ -187,7 +230,7 @@ internal class KrisenvorratDatabaseMigrationTest {
fun freshInstall_worksWithoutMigration() { fun freshInstall_worksWithoutMigration() {
// Fresh-Install: Room.onCreate() läuft direkt, keine Migration nötig // Fresh-Install: Room.onCreate() läuft direkt, keine Migration nötig
val db = Room.inMemoryDatabaseBuilder(context, KrisenvorratDatabase::class.java) val db = Room.inMemoryDatabaseBuilder(context, KrisenvorratDatabase::class.java)
.addMigrations(Migrations.MIGRATION_1_2) .addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3)
.build() .build()
try { try {
// Tabellen anlegen und Basis-Operationen prüfen // Tabellen anlegen und Basis-Operationen prüfen
@ -201,4 +244,38 @@ internal class KrisenvorratDatabaseMigrationTest {
db.close() db.close()
} }
} }
@Test
fun migrate2To3_pendingSyncOpsTableExists() {
createV2Database()
val db = openMigratedDbV3()
try {
val tables = mutableListOf<String>()
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()
}
}
} }

View file

@ -6,15 +6,17 @@ import androidx.room.TypeConverters
import de.krisenvorrat.app.data.db.dao.CategoryDao import de.krisenvorrat.app.data.db.dao.CategoryDao
import de.krisenvorrat.app.data.db.dao.ItemDao import de.krisenvorrat.app.data.db.dao.ItemDao
import de.krisenvorrat.app.data.db.dao.LocationDao 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.dao.SettingsDao
import de.krisenvorrat.app.data.db.entity.CategoryEntity import de.krisenvorrat.app.data.db.entity.CategoryEntity
import de.krisenvorrat.app.data.db.entity.ItemEntity import de.krisenvorrat.app.data.db.entity.ItemEntity
import de.krisenvorrat.app.data.db.entity.LocationEntity import de.krisenvorrat.app.data.db.entity.LocationEntity
import de.krisenvorrat.app.data.db.entity.PendingSyncOpEntity
import de.krisenvorrat.app.data.db.entity.SettingsEntity import de.krisenvorrat.app.data.db.entity.SettingsEntity
@Database( @Database(
entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class], entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class],
version = 2, version = 3,
exportSchema = true exportSchema = true
) )
@TypeConverters(LocalDateConverter::class) @TypeConverters(LocalDateConverter::class)
@ -23,4 +25,5 @@ internal abstract class KrisenvorratDatabase : RoomDatabase() {
abstract fun locationDao(): LocationDao abstract fun locationDao(): LocationDao
abstract fun itemDao(): ItemDao abstract fun itemDao(): ItemDao
abstract fun settingsDao(): SettingsDao abstract fun settingsDao(): SettingsDao
abstract fun pendingSyncOpDao(): PendingSyncOpDao
} }

View file

@ -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()
)
}
}
} }

View file

@ -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<PendingSyncOpEntity>
@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)
}

View file

@ -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
)

View file

@ -90,10 +90,15 @@ internal class ImportExportRepositoryImpl @Inject constructor(
} }
private suspend fun applyInventoryDto(dto: InventoryDto) { 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 { transaction.execute {
categoryDao.upsertAll(dto.categories.map { CategoryEntity(id = it.id, name = it.name) }) categoryDao.upsertAll(dto.categories.map { CategoryEntity(id = it.id, name = it.name) })
locationDao.upsertAll(dto.locations.map { LocationEntity(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( ItemEntity(
id = item.id, id = item.id,
name = item.name, name = item.name,

View file

@ -1,31 +1,69 @@
package de.krisenvorrat.app.data.repository package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.ItemDao 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.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.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.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.time.LocalDate import java.time.LocalDate
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
internal class ItemRepositoryImpl @Inject constructor( 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 { ) : ItemRepository {
init {
scope.launch {
webSocketClient.events.collect { event ->
if (event is WebSocketEvent.Connected) {
drainQueue()
}
}
}
}
override fun getAll(): Flow<List<ItemEntity>> = dao.getAll() override fun getAll(): Flow<List<ItemEntity>> = dao.getAll()
override suspend fun getById(id: String): ItemEntity? = override suspend fun getById(id: String): ItemEntity? =
withContext(Dispatchers.IO) { dao.getById(id) } withContext(Dispatchers.IO) { dao.getById(id) }
override suspend fun insert(item: ItemEntity) = override suspend fun insert(item: ItemEntity) {
withContext(Dispatchers.IO) { dao.insert(item) } 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) } withContext(Dispatchers.IO) { dao.update(item) }
scope.launch { attemptPatch(item.id, item.toDto()) }
}
override suspend fun delete(item: ItemEntity) = override suspend fun delete(item: ItemEntity) {
withContext(Dispatchers.IO) { dao.delete(item) } withContext(Dispatchers.IO) {
pendingSyncOpDao.deleteByItemId(item.id)
dao.delete(item)
}
scope.launch { attemptDelete(item.id) }
}
override fun getByCategory(categoryId: Int): Flow<List<ItemEntity>> = override fun getByCategory(categoryId: Int): Flow<List<ItemEntity>> =
dao.getByCategory(categoryId) dao.getByCategory(categoryId)
@ -50,4 +88,78 @@ internal class ItemRepositoryImpl @Inject constructor(
override suspend fun getLastUsedLocationId(): Int? = override suspend fun getLastUsedLocationId(): Int? =
withContext(Dispatchers.IO) { dao.getLastUsedLocationId() } 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
)
} }

View file

@ -5,10 +5,13 @@ import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.SettingsRepository import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.repository.SyncService import de.krisenvorrat.app.domain.repository.SyncService
import de.krisenvorrat.shared.model.InventoryDto import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.ItemDto
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.header import io.ktor.client.request.header
import io.ktor.client.request.patch
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.put import io.ktor.client.request.put
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
@ -45,6 +48,24 @@ internal class SyncServiceImpl @Inject constructor(
handleResponse(response) handleResponse(response)
} }
override suspend fun patchItem(itemId: String, item: ItemDto): Result<Unit> =
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<Unit> =
executeItemRequest { serverUrl, token ->
val response = httpClient.delete("$serverUrl/api/inventory/items/$itemId") {
header("Authorization", "Bearer $token")
}
handleUnitResponse(response)
}
override suspend fun login( override suspend fun login(
serverUrl: String, serverUrl: String,
username: String, username: String,
@ -119,6 +140,28 @@ internal class SyncServiceImpl @Inject constructor(
} }
} }
private suspend fun executeItemRequest(
block: suspend (serverUrl: String, token: String) -> Result<Unit>
): Result<Unit> = 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 { private suspend fun refreshToken(serverUrl: String): Boolean {
val refreshToken = settingsRepository.getValue(KEY_REFRESH_TOKEN) val refreshToken = settingsRepository.getValue(KEY_REFRESH_TOKEN)
if (refreshToken.isNullOrBlank()) return false if (refreshToken.isNullOrBlank()) return false
@ -152,6 +195,18 @@ internal class SyncServiceImpl @Inject constructor(
) )
} }
private fun handleUnitResponse(response: HttpResponse): Result<Unit> =
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 { private companion object {
val KEY_SERVER_URL = SettingsKeys.SERVER_URL val KEY_SERVER_URL = SettingsKeys.SERVER_URL
val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN

View file

@ -0,0 +1,7 @@
package de.krisenvorrat.app.di
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class ApplicationScope

View file

@ -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.CategoryDao
import de.krisenvorrat.app.data.db.dao.ItemDao import de.krisenvorrat.app.data.db.dao.ItemDao
import de.krisenvorrat.app.data.db.dao.LocationDao 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.dao.SettingsDao
import de.krisenvorrat.app.data.export.DatabaseTransaction import de.krisenvorrat.app.data.export.DatabaseTransaction
import javax.inject.Singleton import javax.inject.Singleton
@ -28,7 +29,7 @@ internal object DatabaseModule {
fun provideDatabase(@ApplicationContext context: Context): KrisenvorratDatabase = fun provideDatabase(@ApplicationContext context: Context): KrisenvorratDatabase =
Room.databaseBuilder(context, KrisenvorratDatabase::class.java, "krisenvorrat.db") Room.databaseBuilder(context, KrisenvorratDatabase::class.java, "krisenvorrat.db")
.addCallback(DefaultDataCallback) .addCallback(DefaultDataCallback)
.addMigrations(Migrations.MIGRATION_1_2) .addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3)
.build() .build()
private object DefaultDataCallback : RoomDatabase.Callback() { private object DefaultDataCallback : RoomDatabase.Callback() {
@ -58,6 +59,9 @@ internal object DatabaseModule {
@Provides @Provides
fun provideLocationDao(db: KrisenvorratDatabase): LocationDao = db.locationDao() fun provideLocationDao(db: KrisenvorratDatabase): LocationDao = db.locationDao()
@Provides
fun providePendingSyncOpDao(db: KrisenvorratDatabase): PendingSyncOpDao = db.pendingSyncOpDao()
@Provides @Provides
fun provideSettingsDao(db: KrisenvorratDatabase): SettingsDao = db.settingsDao() fun provideSettingsDao(db: KrisenvorratDatabase): SettingsDao = db.settingsDao()

View file

@ -16,7 +16,9 @@ import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Singleton import javax.inject.Singleton
@ -56,6 +58,12 @@ internal abstract class NetworkModule {
} }
} }
@Provides
@Singleton
@ApplicationScope
fun provideApplicationScope(): CoroutineScope =
CoroutineScope(SupervisorJob() + Dispatchers.Default)
@Provides @Provides
@IoDispatcher @IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO

View file

@ -1,10 +1,13 @@
package de.krisenvorrat.app.domain.repository package de.krisenvorrat.app.domain.repository
import de.krisenvorrat.shared.model.InventoryDto import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.ItemDto
internal interface SyncService { internal interface SyncService {
suspend fun downloadInventory(): Result<InventoryDto> suspend fun downloadInventory(): Result<InventoryDto>
suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto> suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto>
suspend fun login(serverUrl: String, username: String, password: String): Result<Unit> suspend fun login(serverUrl: String, username: String, password: String): Result<Unit>
suspend fun logout() suspend fun logout()
suspend fun patchItem(itemId: String, item: ItemDto): Result<Unit>
suspend fun deleteItem(itemId: String): Result<Unit>
} }

View file

@ -242,4 +242,34 @@ class ImportExportRepositoryImplTest {
// Then // Then
assertTrue(!markdown.contains("## Einstellungen")) 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)
}
} }

View file

@ -1,12 +1,33 @@
package de.krisenvorrat.app.data.repository package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.ItemDao 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.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.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking 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.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@ -81,6 +102,59 @@ private class FakeItemDao : ItemDao {
items.maxByOrNull { it.lastUpdated }?.locationId items.maxByOrNull { it.lastUpdated }?.locationId
} }
private class FakePendingSyncOpDao : PendingSyncOpDao {
val ops = mutableListOf<PendingSyncOpEntity>()
override suspend fun insert(op: PendingSyncOpEntity) { ops.add(op) }
override suspend fun getAll(): List<PendingSyncOpEntity> = 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<String, String>()
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<String?> = MutableStateFlow(store[key])
override fun getAll(): Flow<List<SettingsEntity>> = MutableStateFlow(emptyList())
}
private class FakeSyncService : SyncService {
var patchResult: Result<Unit> = Result.success(Unit)
var deleteResult: Result<Unit> = Result.success(Unit)
val patchedItems = mutableListOf<Pair<String, ItemDto>>()
val deletedItemIds = mutableListOf<String>()
override suspend fun patchItem(itemId: String, item: ItemDto): Result<Unit> {
patchedItems.add(itemId to item)
return patchResult
}
override suspend fun deleteItem(itemId: String): Result<Unit> {
deletedItemIds.add(itemId)
return deleteResult
}
override suspend fun downloadInventory(): Result<InventoryDto> = Result.success(
InventoryDto(categories = emptyList(), locations = emptyList(), items = emptyList(), settings = emptyList())
)
override suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto> =
Result.success(inventory)
override suspend fun login(serverUrl: String, username: String, password: String): Result<Unit> =
Result.success(Unit)
override suspend fun logout() {}
}
private class FakeWebSocketClient : WebSocketClient {
private val _events = MutableSharedFlow<WebSocketEvent>(extraBufferCapacity = 10)
override val events: SharedFlow<WebSocketEvent> = _events
suspend fun emit(event: WebSocketEvent) { _events.emit(event) }
override fun connect(serverUrl: String, accessToken: String) {}
override fun disconnect() {}
}
private fun buildItem( private fun buildItem(
id: String = "id1", id: String = "id1",
categoryId: Int = 1, categoryId: Int = 1,
@ -99,14 +173,29 @@ private fun buildItem(
lastUpdated = 0L lastUpdated = 0L
) )
@OptIn(ExperimentalCoroutinesApi::class)
class ItemRepositoryImplTest { class ItemRepositoryImplTest {
private val fakeDao = FakeItemDao() 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 @Test
fun test_insert_withNewItem_itemAppearsInGetAll() = runBlocking { fun test_insert_withNewItem_itemAppearsInGetAll() = runBlocking {
// Given // Given
val repository = buildRepository()
val item = buildItem(id = "abc") val item = buildItem(id = "abc")
// When // When
@ -120,6 +209,7 @@ class ItemRepositoryImplTest {
@Test @Test
fun test_getById_withExistingId_returnsItem() = runBlocking { fun test_getById_withExistingId_returnsItem() = runBlocking {
// Given // Given
val repository = buildRepository()
val item = buildItem(id = "abc") val item = buildItem(id = "abc")
repository.insert(item) repository.insert(item)
@ -133,7 +223,7 @@ class ItemRepositoryImplTest {
@Test @Test
fun test_getById_withUnknownId_returnsNull() = runBlocking { fun test_getById_withUnknownId_returnsNull() = runBlocking {
// Given / When // Given / When
val result = repository.getById("unknown") val result = buildRepository().getById("unknown")
// Then // Then
assertNull(result) assertNull(result)
@ -142,6 +232,7 @@ class ItemRepositoryImplTest {
@Test @Test
fun test_update_withExistingItem_itemIsUpdated() = runBlocking { fun test_update_withExistingItem_itemIsUpdated() = runBlocking {
// Given // Given
val repository = buildRepository()
val item = buildItem(id = "abc") val item = buildItem(id = "abc")
repository.insert(item) repository.insert(item)
val updated = item.copy(name = "Aktualisiert") val updated = item.copy(name = "Aktualisiert")
@ -157,6 +248,7 @@ class ItemRepositoryImplTest {
@Test @Test
fun test_delete_withExistingItem_itemRemovedFromGetAll() = runBlocking { fun test_delete_withExistingItem_itemRemovedFromGetAll() = runBlocking {
// Given // Given
val repository = buildRepository()
val item = buildItem(id = "abc") val item = buildItem(id = "abc")
repository.insert(item) repository.insert(item)
@ -171,6 +263,7 @@ class ItemRepositoryImplTest {
@Test @Test
fun test_getByCategory_withMatchingItems_returnsFilteredItems() = runBlocking { fun test_getByCategory_withMatchingItems_returnsFilteredItems() = runBlocking {
// Given // Given
val repository = buildRepository()
repository.insert(buildItem(id = "a", categoryId = 1)) repository.insert(buildItem(id = "a", categoryId = 1))
repository.insert(buildItem(id = "b", categoryId = 2)) repository.insert(buildItem(id = "b", categoryId = 2))
@ -185,6 +278,7 @@ class ItemRepositoryImplTest {
@Test @Test
fun test_getByLocation_withMatchingItems_returnsFilteredItems() = runBlocking { fun test_getByLocation_withMatchingItems_returnsFilteredItems() = runBlocking {
// Given // Given
val repository = buildRepository()
repository.insert(buildItem(id = "a", locationId = 1)) repository.insert(buildItem(id = "a", locationId = 1))
repository.insert(buildItem(id = "b", locationId = 2)) repository.insert(buildItem(id = "b", locationId = 2))
@ -199,6 +293,7 @@ class ItemRepositoryImplTest {
@Test @Test
fun test_getExpiringSoon_withPreconfiguredFlow_returnsExpectedItems() = runBlocking { fun test_getExpiringSoon_withPreconfiguredFlow_returnsExpectedItems() = runBlocking {
// Given // Given
val repository = buildRepository()
val expiring = buildItem(id = "exp") val expiring = buildItem(id = "exp")
fakeDao.setExpiringSoonItems(listOf(expiring)) fakeDao.setExpiringSoonItems(listOf(expiring))
@ -209,4 +304,99 @@ class ItemRepositoryImplTest {
assertEquals(1, result.size) assertEquals(1, result.size)
assertEquals("exp", result.first().id) 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()
}
} }

View file

@ -786,6 +786,8 @@ private class FakeSyncService : SyncService {
} }
override suspend fun logout() {} override suspend fun logout() {}
override suspend fun patchItem(itemId: String, item: de.krisenvorrat.shared.model.ItemDto): Result<Unit> = Result.success(Unit)
override suspend fun deleteItem(itemId: String): Result<Unit> = Result.success(Unit)
} }
private class FakeWebSocketClient : WebSocketClient { private class FakeWebSocketClient : WebSocketClient {