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,
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue