sync platecement
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
package com.vaultdrop.mobile.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Règle de placement d'une ressource (`sync_status`), partagée entre files et
|
||||||
|
* folders. Placement = présence device / serveur, pas un marqueur de push.
|
||||||
|
*
|
||||||
|
* - `local` : copie physique SAF uniquement, pas encore connue du serveur.
|
||||||
|
* - `cloud` : connue du serveur uniquement (pas de copie physique, uri NULL).
|
||||||
|
* - `local-cloud` : copie physique **et** connue du serveur.
|
||||||
|
*/
|
||||||
|
object SyncPlacement {
|
||||||
|
|
||||||
|
const val LOCAL = "local"
|
||||||
|
const val CLOUD = "cloud"
|
||||||
|
const val LOCAL_CLOUD = "local-cloud"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placement d'une ressource après confirmation serveur (snapshot `GET /files`
|
||||||
|
* ou op outbox `create_resource`/`move_resource` appliquée) : `local-cloud`
|
||||||
|
* si une copie physique existe encore, sinon `cloud`.
|
||||||
|
*/
|
||||||
|
fun confirmed(hasLocalUri: Boolean): String =
|
||||||
|
if (hasLocalUri) LOCAL_CLOUD else CLOUD
|
||||||
|
}
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
package com.vaultdrop.mobile.data.local.dao
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.vaultdrop.mobile.data.local.AppDatabase
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileStatus
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FolderStatus
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.PendingOperationType
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.PendingOpStatus
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garantit le contrat placement `sync_status` du client :
|
||||||
|
* - `promoteSyncStatus` confirme une ressource après `POST /sync/ops` appliqué
|
||||||
|
* (`local-cloud` si copie physique présente, `cloud` sinon) ;
|
||||||
|
* - `backfillSyncedStatus` rattrape les ressources déjà confirmées avant ce
|
||||||
|
* mécanisme, sans toucher aux `local` non confirmées ni aux `failed`.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34])
|
||||||
|
class SyncPlacementDaoTest {
|
||||||
|
|
||||||
|
private lateinit var db: AppDatabase
|
||||||
|
private lateinit var fileDao: FileDao
|
||||||
|
private lateinit var folderDao: FolderDao
|
||||||
|
private lateinit var opsDao: PendingOperationDao
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
|
||||||
|
.allowMainThreadQueries()
|
||||||
|
.build()
|
||||||
|
fileDao = db.fileDao()
|
||||||
|
folderDao = db.folderDao()
|
||||||
|
opsDao = db.pendingOperationDao()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- promoteSyncStatus -------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun promote_file_avec_uri_local_cloud_sinon_cloud() = runTest {
|
||||||
|
val physical = file("aaaa00000000000000000000000000aa", uri = "content://tree/doc")
|
||||||
|
val onlyCloud = file("bbbb00000000000000000000000000bb", uri = null)
|
||||||
|
fileDao.upsert(physical)
|
||||||
|
fileDao.upsert(onlyCloud)
|
||||||
|
|
||||||
|
fileDao.promoteSyncStatus(physical.resourceId, now())
|
||||||
|
fileDao.promoteSyncStatus(onlyCloud.resourceId, now())
|
||||||
|
|
||||||
|
assertEquals(FileStatus.LOCAL_CLOUD, fileDao.getByResourceId(physical.resourceId)!!.syncStatus)
|
||||||
|
assertEquals(FileStatus.CLOUD, fileDao.getByResourceId(onlyCloud.resourceId)!!.syncStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun promote_folder_avec_uri_local_cloud_sinon_cloud() = runTest {
|
||||||
|
val physical = folder("cccc00000000000000000000000000cc", uri = "content://tree/root")
|
||||||
|
val onlyCloud = folder("dddd00000000000000000000000000dd", uri = null)
|
||||||
|
folderDao.upsert(physical)
|
||||||
|
folderDao.upsert(onlyCloud)
|
||||||
|
|
||||||
|
folderDao.promoteSyncStatus(physical.resourceId, now())
|
||||||
|
folderDao.promoteSyncStatus(onlyCloud.resourceId, now())
|
||||||
|
|
||||||
|
assertEquals(FolderStatus.LOCAL_CLOUD, folderDao.getByResourceId(physical.resourceId)!!.syncStatus)
|
||||||
|
assertEquals(FolderStatus.CLOUD, folderDao.getByResourceId(onlyCloud.resourceId)!!.syncStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun promote_idempotent() = runTest {
|
||||||
|
val f = file("eeee00000000000000000000000000ee")
|
||||||
|
fileDao.upsert(f)
|
||||||
|
fileDao.promoteSyncStatus(f.resourceId, now())
|
||||||
|
fileDao.promoteSyncStatus(f.resourceId, now())
|
||||||
|
assertEquals(FileStatus.LOCAL_CLOUD, fileDao.getByResourceId(f.resourceId)!!.syncStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- backfillSyncedStatus -----------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun backfill_promeut_les_fichiers_confirmees_par_un_create_synced() = runTest {
|
||||||
|
val confirmed = file("1111")
|
||||||
|
val notConfirmed = file("2222")
|
||||||
|
val failed = file("3333")
|
||||||
|
val deleted = file("4444")
|
||||||
|
fileDao.upsert(confirmed)
|
||||||
|
fileDao.upsert(notConfirmed)
|
||||||
|
fileDao.upsert(failed)
|
||||||
|
fileDao.upsert(deleted)
|
||||||
|
|
||||||
|
opsDao.insert(op(resourceId = confirmed.resourceId, type = "file", operation = PendingOperationType.CREATE_RESOURCE, status = PendingOpStatus.SYNCED))
|
||||||
|
opsDao.insert(op(resourceId = notConfirmed.resourceId, type = "file", operation = PendingOperationType.CREATE_RESOURCE, status = PendingOpStatus.PENDING))
|
||||||
|
opsDao.insert(op(resourceId = failed.resourceId, type = "file", operation = PendingOperationType.CREATE_RESOURCE, status = PendingOpStatus.FAILED))
|
||||||
|
opsDao.insert(op(resourceId = deleted.resourceId, type = "file", operation = PendingOperationType.DELETE_RESOURCE, status = PendingOpStatus.SYNCED))
|
||||||
|
|
||||||
|
fileDao.backfillSyncedStatus(now())
|
||||||
|
|
||||||
|
assertEquals(FileStatus.LOCAL_CLOUD, fileDao.getByResourceId(confirmed.resourceId)!!.syncStatus)
|
||||||
|
assertEquals(FileStatus.LOCAL, fileDao.getByResourceId(notConfirmed.resourceId)!!.syncStatus)
|
||||||
|
assertEquals(FileStatus.LOCAL, fileDao.getByResourceId(failed.resourceId)!!.syncStatus)
|
||||||
|
assertEquals(FileStatus.LOCAL, fileDao.getByResourceId(deleted.resourceId)!!.syncStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun backfill_respecte_le_type_folder_vs_file() = runTest {
|
||||||
|
val folder = folder("5555")
|
||||||
|
val file = file("6666")
|
||||||
|
folderDao.upsert(folder)
|
||||||
|
fileDao.upsert(file)
|
||||||
|
|
||||||
|
// Une op synced sur le folder ne promeut pas le file de même resource_id.
|
||||||
|
opsDao.insert(op(resourceId = folder.resourceId, type = "folder", operation = PendingOperationType.CREATE_RESOURCE, status = PendingOpStatus.SYNCED))
|
||||||
|
opsDao.insert(op(resourceId = file.resourceId, type = "file", operation = PendingOperationType.CREATE_RESOURCE, status = PendingOpStatus.SYNCED))
|
||||||
|
|
||||||
|
folderDao.backfillSyncedStatus(now())
|
||||||
|
fileDao.backfillSyncedStatus(now())
|
||||||
|
|
||||||
|
assertEquals(FolderStatus.LOCAL_CLOUD, folderDao.getByResourceId(folder.resourceId)!!.syncStatus)
|
||||||
|
assertEquals(FileStatus.LOCAL_CLOUD, fileDao.getByResourceId(file.resourceId)!!.syncStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun backfill_ne_touche_pas_les_lignes_deja_confirmees() = runTest {
|
||||||
|
val alreadyCloud = file("7777", syncStatus = FileStatus.CLOUD)
|
||||||
|
val alreadyBoth = file("8888", syncStatus = FileStatus.LOCAL_CLOUD)
|
||||||
|
fileDao.upsert(alreadyCloud)
|
||||||
|
fileDao.upsert(alreadyBoth)
|
||||||
|
opsDao.insert(op(resourceId = alreadyCloud.resourceId, type = "file", operation = PendingOperationType.CREATE_RESOURCE, status = PendingOpStatus.SYNCED))
|
||||||
|
opsDao.insert(op(resourceId = alreadyBoth.resourceId, type = "file", operation = PendingOperationType.CREATE_RESOURCE, status = PendingOpStatus.SYNCED))
|
||||||
|
|
||||||
|
fileDao.backfillSyncedStatus(now())
|
||||||
|
|
||||||
|
assertEquals(FileStatus.CLOUD, fileDao.getByResourceId(alreadyCloud.resourceId)!!.syncStatus)
|
||||||
|
assertEquals(FileStatus.LOCAL_CLOUD, fileDao.getByResourceId(alreadyBoth.resourceId)!!.syncStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- fixtures ------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun now(): Long = 1_700_000_000_000L
|
||||||
|
|
||||||
|
private var opSeq = 0
|
||||||
|
|
||||||
|
private fun op(
|
||||||
|
resourceId: String,
|
||||||
|
type: String,
|
||||||
|
operation: String,
|
||||||
|
status: String,
|
||||||
|
) = PendingOperationEntity(
|
||||||
|
operationId = String.format("%032x", opSeq++),
|
||||||
|
resourceId = resourceId,
|
||||||
|
resourceType = type,
|
||||||
|
operation = operation,
|
||||||
|
payload = "{}",
|
||||||
|
status = status,
|
||||||
|
createdAt = now(),
|
||||||
|
updatedAt = now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun file(
|
||||||
|
id: String,
|
||||||
|
uri: String? = "content://tree/$id",
|
||||||
|
syncStatus: String = FileStatus.LOCAL,
|
||||||
|
) = FileEntity(
|
||||||
|
resourceId = id.padEnd(32, '0'),
|
||||||
|
uri = uri,
|
||||||
|
name = "$id.txt",
|
||||||
|
folderResourceId = "00000000000000000000000000000000",
|
||||||
|
extension = "txt",
|
||||||
|
size = 1,
|
||||||
|
mimeType = "text/plain",
|
||||||
|
exists = 1,
|
||||||
|
syncStatus = syncStatus,
|
||||||
|
addedAt = now(),
|
||||||
|
updatedAt = now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun folder(
|
||||||
|
id: String,
|
||||||
|
uri: String? = "content://tree/$id",
|
||||||
|
syncStatus: String = FolderStatus.LOCAL,
|
||||||
|
) = FolderEntity(
|
||||||
|
resourceId = id.padEnd(32, '0'),
|
||||||
|
uri = uri,
|
||||||
|
name = id,
|
||||||
|
exists = 1,
|
||||||
|
parentResourceId = null,
|
||||||
|
syncStatus = syncStatus,
|
||||||
|
addedAt = now(),
|
||||||
|
updatedAt = now(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.vaultdrop.mobile.domain
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SyncPlacementTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun confirmation_avec_copie_physique_promote_local_cloud() {
|
||||||
|
assertEquals(SyncPlacement.LOCAL_CLOUD, SyncPlacement.confirmed(hasLocalUri = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun confirmation_sans_copie_physique_passe_a_cloud() {
|
||||||
|
assertEquals(SyncPlacement.CLOUD, SyncPlacement.confirmed(hasLocalUri = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun confirmation_ne_revient_jamais_a_local() {
|
||||||
|
for (hasUri in listOf(true, false)) {
|
||||||
|
assertEquals(false, SyncPlacement.confirmed(hasUri) == SyncPlacement.LOCAL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user