merge feature
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
# VaultDrop
|
||||||
|
|
||||||
|
VaultDrop est un coffre-fort de documents personnel, local-first. Le client Android镜映 le SAF dans une base Room et pousse les métadonnées de ses mutations via un outbox transactionnel vers le serveur Go/PostgreSQL — pas d'upload physique, pas de cloud-dépendance.
|
||||||
|
|
||||||
|
Disponible sur [thirdshop.fr](https://thirdshop.fr/en/applications/vault/)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend (Go + PostgreSQL)
|
||||||
|
mise up_backend
|
||||||
|
|
||||||
|
# Frontend (Android, build debug APK)
|
||||||
|
mise up_mobile # émulateur par défaut
|
||||||
|
mise up_mobile device # sur un appareil connecté
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
cd backend && go test ./...
|
||||||
|
cd mobile-kotlin && ./gradlew :app:testDebugUnitTest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Où regarder
|
||||||
|
|
||||||
|
| Zone | Fichiers clés |
|
||||||
|
|------|---------------|
|
||||||
|
| API contract | `docs/api-v1.md` — contrat HTTP autoritatif |
|
||||||
|
| Backend entry | `backend/cmd/server/main.go` — wiring gin + routes |
|
||||||
|
| Sync outbox | `mobile-kotlin/.../features/sync/OutboxSyncWorker.kt` — worker WorkManager → `POST /sync/ops` |
|
||||||
|
| SAF scan | `mobile-kotlin/.../features/sync/DeviceSync.kt` — two-pass walk SAF ↔ Room |
|
||||||
|
| Room DB | `mobile-kotlin/.../data/local/AppDatabase.kt` — version 7, migrations, DAO |
|
||||||
|
| API Retrofit | `mobile-kotlin/.../data/remote/ApiService.kt` — interface Retrofit (contrat client) |
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package com.vaultdrop.mobile.features.saf
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import com.vaultdrop.mobile.data.local.AppDatabase
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
||||||
|
import com.vaultdrop.mobile.data.repository.FileRepository
|
||||||
|
import com.vaultdrop.mobile.data.repository.FolderRepository
|
||||||
|
import com.vaultdrop.mobile.data.repository.OutboxRepository
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppression de dossiers en mode multi-sélection.
|
||||||
|
*
|
||||||
|
* Un dossier SAF ne peut être supprimé physiquement que vide : on supprime donc
|
||||||
|
* récursivement son contenu (fichiers puis sous-dossiers, du plus profond au
|
||||||
|
* plus proche) avant le dossier lui-même. Room sert de source de vérité pour
|
||||||
|
* l'énumération du contenu (issue du walk SAF).
|
||||||
|
*
|
||||||
|
* Trois modes (partagés avec `FileDeleter`) :
|
||||||
|
* - **LOCALLY** : suppression physique SAF de l'arbre + marquage `exists = 0`
|
||||||
|
* Room, sans outbox (le serveur n'est pas affecté).
|
||||||
|
* - **IN_CLOUD** : enqueue `delete_resource` pour les dossiers uniquement.
|
||||||
|
* - **FULL** : les deux — suppression physique + outbox, transaction atomique.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class FolderDeleter @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val folderRepository: FolderRepository,
|
||||||
|
private val fileRepository: FileRepository,
|
||||||
|
private val outboxRepository: OutboxRepository,
|
||||||
|
private val appDatabase: AppDatabase,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val resolver: ContentResolver get() = context.contentResolver
|
||||||
|
|
||||||
|
/** Descendants collectés d'un dossier — fichiers à tout niveau + sous-dossiers. */
|
||||||
|
private data class FolderTree(
|
||||||
|
val files: List<FileEntity>,
|
||||||
|
val folders: List<FolderEntity>,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun deleteFolders(
|
||||||
|
folders: List<FolderEntity>,
|
||||||
|
mode: FileDeleter.DeleteMode,
|
||||||
|
): FileDeleter.DeleteReport {
|
||||||
|
var succeeded = 0
|
||||||
|
var failed = 0
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
for (folder in folders) {
|
||||||
|
val ok = when (mode) {
|
||||||
|
FileDeleter.DeleteMode.LOCALLY -> deleteLocally(folder)
|
||||||
|
FileDeleter.DeleteMode.IN_CLOUD -> deleteInCloud(folder)
|
||||||
|
FileDeleter.DeleteMode.FULL -> deleteFull(folder)
|
||||||
|
}
|
||||||
|
if (ok) succeeded++ else failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return FileDeleter.DeleteReport(succeeded, failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suppression physique locale uniquement (SAF + Room), outbox absente. */
|
||||||
|
private suspend fun deleteLocally(folder: FolderEntity): Boolean {
|
||||||
|
val uri = folder.uri ?: return false
|
||||||
|
val tree = collectDescendants(folder.resourceId)
|
||||||
|
if (deleteTreePhysically(folder, tree)) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
tree.files.forEach { fileRepository.markMissing(it.resourceId, now) }
|
||||||
|
tree.folders.forEach { folderRepository.markMissing(it.resourceId, now) }
|
||||||
|
folderRepository.markMissing(folder.resourceId, now)
|
||||||
|
Timber.d("deleted folder locally %s", uri)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
Timber.w("local delete returned false for %s", uri)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suppression cloud uniquement (outbox `delete_resource`), physique intact. */
|
||||||
|
private suspend fun deleteInCloud(folder: FolderEntity): Boolean {
|
||||||
|
var ok = false
|
||||||
|
runCatching {
|
||||||
|
if (outboxRepository.hasCreateOperationAnyType(folder.resourceId)) {
|
||||||
|
outboxRepository.enqueueDeleteResource(folder.resourceId, "folder")
|
||||||
|
}
|
||||||
|
ok = true
|
||||||
|
Timber.d("enqueued cloud delete for folder %s", folder.resourceId)
|
||||||
|
}.onFailure { e ->
|
||||||
|
Timber.w(e, "enqueueDeleteResource failed for folder %s", folder.resourceId)
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suppression complète : physique + cloud. Transaction atomique Room. */
|
||||||
|
private suspend fun deleteFull(folder: FolderEntity): Boolean {
|
||||||
|
val uri = folder.uri
|
||||||
|
if (uri != null) {
|
||||||
|
val tree = collectDescendants(folder.resourceId)
|
||||||
|
if (deleteTreePhysically(folder, tree)) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
appDatabase.withTransaction {
|
||||||
|
treeMarkMissing(folder, tree, now)
|
||||||
|
}
|
||||||
|
Timber.d("deleted folder full %s", uri)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
Timber.w("deleteDocument returned false for %s", uri)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Cloud-only : outbox uniquement.
|
||||||
|
return deleteInCloud(folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marque missing + outbox `delete_resource` pour toute la descendance, atomique. */
|
||||||
|
private suspend fun treeMarkMissing(folder: FolderEntity, tree: FolderTree, now: Long) {
|
||||||
|
for (file in tree.files) {
|
||||||
|
fileRepository.markMissing(file.resourceId, now)
|
||||||
|
if (outboxRepository.hasCreateOperation(file.resourceId)) {
|
||||||
|
outboxRepository.enqueueDeleteResource(file.resourceId, "file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (desc in tree.folders) {
|
||||||
|
folderRepository.markMissing(desc.resourceId, now)
|
||||||
|
if (outboxRepository.hasCreateOperationAnyType(desc.resourceId)) {
|
||||||
|
outboxRepository.enqueueDeleteResource(desc.resourceId, "folder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
folderRepository.markMissing(folder.resourceId, now)
|
||||||
|
if (outboxRepository.hasCreateOperationAnyType(folder.resourceId)) {
|
||||||
|
outboxRepository.enqueueDeleteResource(folder.resourceId, "folder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Énumère le contenu d'un dossier (fichiers + sous-dossiers, tout niveau) via Room. */
|
||||||
|
private suspend fun collectDescendants(folderResourceId: String): FolderTree {
|
||||||
|
val files = fileRepository.getFilesInFolder(folderResourceId).toMutableList()
|
||||||
|
val folders = mutableListOf<FolderEntity>()
|
||||||
|
val children = folderRepository.getChildren(folderResourceId)
|
||||||
|
for (child in children) {
|
||||||
|
val sub = collectDescendants(child.resourceId)
|
||||||
|
files += sub.files
|
||||||
|
folders += child
|
||||||
|
folders += sub.folders
|
||||||
|
}
|
||||||
|
return FolderTree(files = files, folders = folders)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppression SAF physique d'un arbre : fichiers d'abord, puis sous-dossiers
|
||||||
|
* du plus profond au plus proche, puis le dossier racine (deleteDocument
|
||||||
|
* exige un conteneur vide). Retourne `true` si tout l'arbre est supprimé.
|
||||||
|
*/
|
||||||
|
private suspend fun deleteTreePhysically(folder: FolderEntity, tree: FolderTree): Boolean {
|
||||||
|
for (file in tree.files) {
|
||||||
|
if (!deleteDocument(file.uri)) return false
|
||||||
|
}
|
||||||
|
val orderedDirs = tree.folders
|
||||||
|
.map { it to depthOf(it.resourceId, folder.resourceId) }
|
||||||
|
.sortedByDescending { it.second }
|
||||||
|
.map { it.first }
|
||||||
|
for (dir in orderedDirs) {
|
||||||
|
if (!deleteDocument(dir.uri)) return false
|
||||||
|
}
|
||||||
|
return deleteDocument(folder.uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appelle `deleteDocument` SAF ; sans uri = rien à faire (= succès). */
|
||||||
|
private suspend fun deleteDocument(uri: String?): Boolean {
|
||||||
|
if (uri == null) return true
|
||||||
|
return runCatching {
|
||||||
|
DocumentsContract.deleteDocument(resolver, Uri.parse(uri))
|
||||||
|
}.onFailure { e ->
|
||||||
|
Timber.w(e, "deleteDocument failed for %s", uri)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Profondeur d'un descendant par rapport à la racine de l'arbre (itère Room). */
|
||||||
|
private suspend fun depthOf(resourceId: String, rootResourceId: String): Int {
|
||||||
|
var depth = 0
|
||||||
|
var current = folderRepository.getFolder(resourceId)
|
||||||
|
while (current != null && current.parentResourceId != null && current.parentResourceId != rootResourceId) {
|
||||||
|
depth++
|
||||||
|
current = folderRepository.getFolder(current.parentResourceId)
|
||||||
|
}
|
||||||
|
return depth
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.vaultdrop.mobile.features.saf
|
||||||
|
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
||||||
|
import com.vaultdrop.mobile.data.repository.FileRepository
|
||||||
|
import com.vaultdrop.mobile.data.repository.FolderRepository
|
||||||
|
import com.vaultdrop.mobile.data.repository.SaveFolderInput
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fusion de dossiers en mode multi-sélection.
|
||||||
|
*
|
||||||
|
* 1. Crée un nouveau dossier nommé `newName` au même niveau que les dossiers
|
||||||
|
* sélectionnés (même parent).
|
||||||
|
* 2. Déplace tout le contenu de chaque dossier source (fichiers + sous-dossiers)
|
||||||
|
* vers le dossier fusionné — relocation SAF quand les deux côtés sont
|
||||||
|
* physiques, repli métadonnée seule sinon (`move_resource` outbox quand même).
|
||||||
|
* 3. Supprime les dossiers sources désormais vides (physique + outbox).
|
||||||
|
*
|
||||||
|
* L'ordre outbox est garanti par l'insertion séquentielle : `create_resource`
|
||||||
|
* du dossier fusionné avant les `move_resource`, avant les `delete_resource`.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class FolderMerger @Inject constructor(
|
||||||
|
private val folderRepository: FolderRepository,
|
||||||
|
private val fileRepository: FileRepository,
|
||||||
|
private val fileMover: FileMover,
|
||||||
|
private val folderDeleter: FolderDeleter,
|
||||||
|
private val safFolderCreator: SafFolderCreator,
|
||||||
|
) {
|
||||||
|
|
||||||
|
data class MergeReport(
|
||||||
|
val movedFiles: Int,
|
||||||
|
val deletedFolders: Int,
|
||||||
|
val failed: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fusionne `folders` (frères, même parcours d'explorateur) sous un nouveau
|
||||||
|
* dossier `newName`. Retourne un rapport d'exécution.
|
||||||
|
*/
|
||||||
|
suspend fun mergeSelectedFolders(folders: List<FolderEntity>, newName: String): MergeReport {
|
||||||
|
require(folders.size >= 2) { "merge requires at least 2 folders" }
|
||||||
|
|
||||||
|
// 1. Dossier fusionné au même niveau que les sources.
|
||||||
|
val parentId = folders.first().parentResourceId
|
||||||
|
val merged = createMergedFolder(parentId, newName)
|
||||||
|
|
||||||
|
// 2. Déplacement du contenu de chaque dossier source.
|
||||||
|
var movedFiles = 0
|
||||||
|
var stepFailures = 0
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
for (src in folders) {
|
||||||
|
val ok = runCatching {
|
||||||
|
val files = fileRepository.getFilesInFolder(src.resourceId)
|
||||||
|
if (files.isNotEmpty()) {
|
||||||
|
fileMover.moveFiles(files.map { it.resourceId }, merged.resourceId)
|
||||||
|
movedFiles += files.size
|
||||||
|
}
|
||||||
|
val subFolders = folderRepository.getChildren(src.resourceId)
|
||||||
|
if (subFolders.isNotEmpty()) {
|
||||||
|
folderRepository.reparentSubFolders(subFolders.map { it.resourceId }, merged.resourceId)
|
||||||
|
}
|
||||||
|
}.isSuccess
|
||||||
|
if (!ok) stepFailures++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Suppression des dossiers sources (désormais sans contenu).
|
||||||
|
var deletedFolders = 0
|
||||||
|
var failed = 0
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
for (src in folders) {
|
||||||
|
val report = folderDeleter.deleteFolders(listOf(src), FileDeleter.DeleteMode.FULL)
|
||||||
|
if (report.failed == 0) deletedFolders++ else failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failed += stepFailures
|
||||||
|
Timber.d("merge '%s': %d fichiers déplacés, %d dossiers supprimés, %d échecs",
|
||||||
|
newName, movedFiles, deletedFolders, failed)
|
||||||
|
return MergeReport(movedFiles = movedFiles, deletedFolders = deletedFolders, failed = failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée (physique si le parent l'est, cloud-only sinon) et enregistre en Room
|
||||||
|
* le dossier fusionné. Retourne l'entité Room. La sauvegarde enqueue le
|
||||||
|
* `create_resource` dans la même transaction.
|
||||||
|
*/
|
||||||
|
private suspend fun createMergedFolder(parentId: String?, newName: String): FolderEntity {
|
||||||
|
val parent = parentId?.let { folderRepository.getFolder(it) }
|
||||||
|
val createdUri = parent?.uri?.let { safFolderCreator.createFolder(it, newName) }?.toString()
|
||||||
|
return folderRepository.saveFolder(
|
||||||
|
input = SaveFolderInput(
|
||||||
|
uri = createdUri,
|
||||||
|
name = newName,
|
||||||
|
exists = true,
|
||||||
|
createdInApp = true,
|
||||||
|
),
|
||||||
|
parentResourceId = parentId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
package com.vaultdrop.mobile.data.repository
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||||
|
import com.vaultdrop.mobile.data.local.AppDatabase
|
||||||
|
import com.vaultdrop.mobile.data.local.dao.FolderDao
|
||||||
|
import com.vaultdrop.mobile.data.local.dao.PendingOperationDao
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
||||||
|
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 com.vaultdrop.mobile.data.remote.ApiClient
|
||||||
|
import com.vaultdrop.mobile.data.remote.ApiService
|
||||||
|
import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope
|
||||||
|
import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto
|
||||||
|
import com.vaultdrop.mobile.data.remote.dto.FileDto
|
||||||
|
import com.vaultdrop.mobile.data.remote.dto.FolderDto
|
||||||
|
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
|
||||||
|
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
|
||||||
|
import com.vaultdrop.mobile.data.remote.dto.ResolvedUserDto
|
||||||
|
import com.vaultdrop.mobile.data.remote.dto.ResourcePermissionDto
|
||||||
|
import com.vaultdrop.mobile.data.remote.dto.SyncOpsRequest
|
||||||
|
import com.vaultdrop.mobile.data.remote.dto.SyncOpsResult
|
||||||
|
import com.vaultdrop.mobile.domain.DeviceIdentity
|
||||||
|
import com.vaultdrop.mobile.domain.GenerateId
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garde-fous data-layer de la fusion de dossiers : le re-parentage repositionne
|
||||||
|
* les sous-dossiers et journalise un `move_resource` par dossier (ordre outbox),
|
||||||
|
* et le garde `delete_resource` reconnaît les dossiers poussés (`resource_type =
|
||||||
|
* 'folder'`) via `hasCreateOperationAnyType`.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34])
|
||||||
|
class FolderMergeDataTest {
|
||||||
|
|
||||||
|
private lateinit var db: AppDatabase
|
||||||
|
private lateinit var folderDao: FolderDao
|
||||||
|
private lateinit var opsDao: PendingOperationDao
|
||||||
|
private lateinit var folderRepository: FolderRepository
|
||||||
|
private lateinit var outboxRepository: OutboxRepository
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
|
||||||
|
.allowMainThreadQueries()
|
||||||
|
.build()
|
||||||
|
folderDao = db.folderDao()
|
||||||
|
opsDao = db.pendingOperationDao()
|
||||||
|
|
||||||
|
val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
|
||||||
|
val generateId = GenerateId()
|
||||||
|
val deviceIdentity = DeviceIdentity(db.userPreferenceDao(), generateId)
|
||||||
|
outboxRepository = OutboxRepository(opsDao, generateId, moshi)
|
||||||
|
folderRepository = FolderRepository(
|
||||||
|
folderDao = folderDao,
|
||||||
|
apiClient = ApiClient(FolderMergeApiService(), moshi),
|
||||||
|
generateId = generateId,
|
||||||
|
deviceIdentity = deviceIdentity,
|
||||||
|
appDatabase = db,
|
||||||
|
outboxRepository = outboxRepository,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reparent_repositionne_les_sous_dossiers_et_enqueue_move_resource_par_dossier() = runTest {
|
||||||
|
folderDao.upsert(folder(ROOT, parent = null))
|
||||||
|
folderDao.upsert(folder(CHILD_A, parent = ROOT))
|
||||||
|
folderDao.upsert(folder(CHILD_B, parent = ROOT))
|
||||||
|
|
||||||
|
folderRepository.reparentSubFolders(listOf(CHILD_A, CHILD_B), MERGED)
|
||||||
|
|
||||||
|
assertEquals("parent_A repositionné", MERGED, folderDao.getByResourceId(CHILD_A)!!.parentResourceId)
|
||||||
|
assertEquals("parent_B repositionné", MERGED, folderDao.getByResourceId(CHILD_B)!!.parentResourceId)
|
||||||
|
|
||||||
|
val ops = opsDao.selectPending(10)
|
||||||
|
assertEquals("une op move par dossier (ordre outbox)", 2, ops.size)
|
||||||
|
assertEquals(PendingOperationType.MOVE_RESOURCE, ops[0].operation)
|
||||||
|
assertEquals("folder", ops[0].resourceType)
|
||||||
|
assertEquals(CHILD_A, ops[0].resourceId)
|
||||||
|
assertEquals(ROOT_WITH_MERGED, ops[0].payload)
|
||||||
|
assertEquals(PendingOperationType.MOVE_RESOURCE, ops[1].operation)
|
||||||
|
assertEquals(CHILD_B, ops[1].resourceId)
|
||||||
|
assertEquals(PendingOpStatus.PENDING, ops[1].status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reparent_expose_les_enfants_au_nouveau_parent() = runTest {
|
||||||
|
folderDao.upsert(folder(ROOT, parent = null))
|
||||||
|
folderDao.upsert(folder(CHILD_A, parent = ROOT))
|
||||||
|
folderDao.upsert(folder(CHILD_B, parent = ROOT))
|
||||||
|
// L'ancien parent n'a plus d'enfants visibles.
|
||||||
|
assertEquals(2, folderRepository.getChildren(ROOT).size)
|
||||||
|
|
||||||
|
folderRepository.reparentSubFolders(listOf(CHILD_A, CHILD_B), MERGED)
|
||||||
|
|
||||||
|
assertEquals(listOf(CHILD_A, CHILD_B), folderRepository.getChildren(MERGED).map { it.resourceId })
|
||||||
|
assertEquals("l'ancien parent est vide", 0, folderRepository.getChildren(ROOT).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reparent_vide_est_un_noop() = runTest {
|
||||||
|
folderRepository.reparentSubFolders(emptyList(), MERGED)
|
||||||
|
assertEquals(0, opsDao.selectPending(10).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hasCreateOperationAnyType_reconnait_les_dossiers_pousses() = runTest {
|
||||||
|
opsDao.insert(
|
||||||
|
PendingOperationEntity(
|
||||||
|
operationId = "1".repeat(32),
|
||||||
|
resourceId = FOLDER_ID,
|
||||||
|
resourceType = "folder",
|
||||||
|
operation = PendingOperationType.CREATE_RESOURCE,
|
||||||
|
payload = "{}",
|
||||||
|
status = PendingOpStatus.PENDING,
|
||||||
|
createdAt = NOW,
|
||||||
|
updatedAt = NOW,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(
|
||||||
|
"le gate « processed » (fichiers) reste scindé sur resource_type = 'file'",
|
||||||
|
outboxRepository.hasCreateOperation(FOLDER_ID),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"un delete_resource peut être émis pour un dossier déjà poussé",
|
||||||
|
outboxRepository.hasCreateOperationAnyType(FOLDER_ID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- fixtures ------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun folder(resourceId: String, parent: String?) = FolderEntity(
|
||||||
|
resourceId = resourceId,
|
||||||
|
uri = null,
|
||||||
|
name = resourceId,
|
||||||
|
exists = 1,
|
||||||
|
parentResourceId = parent,
|
||||||
|
ownerId = OWNER,
|
||||||
|
syncStatus = "cloud",
|
||||||
|
addedAt = NOW,
|
||||||
|
updatedAt = NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val NOW = 1_700_000_000_000L
|
||||||
|
const val ROOT = "11111111111111111111111111111111"
|
||||||
|
const val CHILD_A = "22222222222222222222222222222222"
|
||||||
|
const val CHILD_B = "33333333333333333333333333333333"
|
||||||
|
const val MERGED = "44444444444444444444444444444444"
|
||||||
|
const val OWNER = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
const val FOLDER_ID = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
val ROOT_WITH_MERGED = """{"toFolderResourceId":"$MERGED"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fake `ApiService` — aucun endpoint n'est consommé par `reparentSubFolders`. */
|
||||||
|
private class FolderMergeApiService : ApiService {
|
||||||
|
override suspend fun listFolders(): Response<ApiEnvelope<List<FolderDto>>> =
|
||||||
|
Response.success(ApiEnvelope(data = emptyList()))
|
||||||
|
|
||||||
|
override suspend fun listFiles(
|
||||||
|
folderId: String?,
|
||||||
|
page: Int?,
|
||||||
|
pageSize: Int?,
|
||||||
|
): Response<ApiEnvelope<List<FileDto>>> = Response.success(ApiEnvelope(data = emptyList()))
|
||||||
|
|
||||||
|
override suspend fun registerDevice(
|
||||||
|
body: DeviceRegistrationDto,
|
||||||
|
): Response<ApiEnvelope<DeviceRegistrationDto>> = Response.success(ApiEnvelope(data = body))
|
||||||
|
|
||||||
|
override suspend fun login(
|
||||||
|
body: LoginRequestDto,
|
||||||
|
): Response<ApiEnvelope<LoginResponseDto>> = Response.success(ApiEnvelope())
|
||||||
|
|
||||||
|
override suspend fun syncOps(
|
||||||
|
body: SyncOpsRequest,
|
||||||
|
): Response<ApiEnvelope<SyncOpsResult>> = Response.success(ApiEnvelope(data = SyncOpsResult(applied = 0)))
|
||||||
|
|
||||||
|
override suspend fun resolveUser(
|
||||||
|
username: String,
|
||||||
|
): Response<ApiEnvelope<ResolvedUserDto>> = Response.success(ApiEnvelope())
|
||||||
|
|
||||||
|
override suspend fun syncPermissions(
|
||||||
|
after: Long?,
|
||||||
|
): Response<ApiEnvelope<List<ResourcePermissionDto>>> =
|
||||||
|
Response.success(ApiEnvelope(data = emptyList()))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user