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:
parent
4c2f5f08a4
commit
1d7a62448a
16 changed files with 821 additions and 13 deletions
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<List<ItemEntity>> = 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<List<ItemEntity>> =
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
serverUrl: 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 {
|
||||
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<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 {
|
||||
val KEY_SERVER_URL = SettingsKeys.SERVER_URL
|
||||
val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package de.krisenvorrat.app.di
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
internal annotation class ApplicationScope
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<InventoryDto>
|
||||
suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto>
|
||||
suspend fun login(serverUrl: String, username: String, password: String): Result<Unit>
|
||||
suspend fun logout()
|
||||
suspend fun patchItem(itemId: String, item: ItemDto): Result<Unit>
|
||||
suspend fun deleteItem(itemId: String): Result<Unit>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Unit> = Result.success(Unit)
|
||||
override suspend fun deleteItem(itemId: String): Result<Unit> = Result.success(Unit)
|
||||
}
|
||||
|
||||
private class FakeWebSocketClient : WebSocketClient {
|
||||
|
|
|
|||
Loading…
Reference in a new issue