SyncEngineTest.kt
398 lignes · 14521 octets
package fr.ebii.card2vcf.sync import android.content.Context import androidx.room.Room import androidx.test.core.app.ApplicationProvider import fr.ebii.card2vcf.data.CrmContactEntity import fr.ebii.card2vcf.data.CrmDatabase import fr.ebii.card2vcf.data.ProjetEntity import fr.ebii.card2vcf.data.RdvEntity import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull 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 @RunWith(RobolectricTestRunner::class) @Config(sdk = [31]) class SyncEngineTest { private lateinit var db: CrmDatabase private lateinit var api: FakeAilianceApi private lateinit var engine: SyncEngine @Before fun setUp() { val ctx = ApplicationProvider.getApplicationContext<Context>() db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java) .allowMainThreadQueries() .build() api = FakeAilianceApi() engine = SyncEngine(api, db) } @After fun tearDown() = db.close() @Test fun watermark_ignoreLorsqueLeServeurOuLeCompteChange() = runBlocking { // Synchro réussie avec l'identité A : watermark posé à serverTime. api.pullResult = AilianceApiClient.ApiResult.Ok( SyncPullResponse(serverTime = "2026-09-11T22:00:00Z"), ) val engineA = SyncEngine(api, db, serverIdentity = "https://prod.example|alice") engineA.syncNow() val engineA2 = SyncEngine(api, db, serverIdentity = "https://prod.example|alice") engineA2.syncNow() assertEquals("2026-09-11T22:00:00Z", api.pullSince.last()) // Même base locale, mais serveur différent : le pull doit repartir de l'époque, // sinon les données plus anciennes que le watermark hérité ne redescendent jamais. val engineB = SyncEngine(api, db, serverIdentity = "https://demo.example|alice") engineB.syncNow() assertEquals("1970-01-01T00:00:00Z", api.pullSince.last()) } @Test fun checkStatus_returnsTotalFromFake() = runBlocking { api.statusResult = AilianceApiClient.ApiResult.Ok( SyncStatusResponse( serverTime = "2026-07-22T12:00:00Z", changes = SyncChanges(contacts = 2, tombstones = 1), total = 3, ), ) val result = engine.checkStatus() assertEquals(3, result.pendingRemoteChanges) assertEquals(3, result.response?.total) } @Test fun syncNow_pullAppliesNewerContact() = runBlocking { db.crmContactDao().insert( CrmContactEntity( serverId = "c1", fullName = "Old Name", updatedAt = 1_000L, ), ) api.pullResult = AilianceApiClient.ApiResult.Ok( SyncPullResponse( serverTime = "2026-07-22T12:05:00Z", contacts = listOf( ContactDto( id = "c1", prenom = "Jean", nom = "Dupont", creeLe = "2026-07-20T10:00:00Z", misAJourLe = "2026-07-22T11:00:00Z", ), ), ), ) engine.syncNow() val updated = db.crmContactDao().getByServerId("c1") assertEquals("Jean Dupont", updated?.fullName) } @Test fun syncNow_pullKeepsBothAppendOnlyInteractions() = runBlocking { api.pullResult = AilianceApiClient.ApiResult.Ok( SyncPullResponse( serverTime = "2026-07-22T12:05:00Z", interactions = listOf( InteractionDto(id = "i1", contactId = "c1", sujet = "Appel", creeLe = "2026-07-22T09:00:00Z"), InteractionDto(id = "i2", contactId = "c1", sujet = "Relance", creeLe = "2026-07-22T10:00:00Z"), ), ), ) engine.syncNow() val stored = db.interactionDao().listByContactServerId("c1") assertEquals(2, stored.size) assertTrue(stored.any { it.serverId == "i1" }) assertTrue(stored.any { it.serverId == "i2" }) } @Test fun syncNow_tombstoneDeletesContact() = runBlocking { db.crmContactDao().insert( CrmContactEntity(serverId = "c-old", fullName = "À supprimer", updatedAt = 500L), ) api.pullResult = AilianceApiClient.ApiResult.Ok( SyncPullResponse( serverTime = "2026-07-22T12:05:00Z", tombstones = listOf( TombstoneDto(entityType = "contact", id = "c-old", supprimeLe = "2026-07-22T12:00:00Z"), ), ), ) engine.syncNow() assertNull(db.crmContactDao().getByServerId("c-old")) } @Test fun syncNow_updatesWatermarkFromServerTime() = runBlocking { api.pullResult = AilianceApiClient.ApiResult.Ok( SyncPullResponse(serverTime = "2026-07-22T12:05:00Z"), ) val result = engine.syncNow() assertTrue(result.success) assertEquals("2026-07-22T12:05:00Z", result.serverTime) assertEquals("2026-07-22T12:05:00Z", db.syncMetaDao().get("watermark")?.value) } @Test fun syncNow_pushesContactCreateOpAndMapsServerId() = runBlocking { val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Nouveau")) db.syncOpDao().insert( SyncOpEntity( entityType = "contact", op = "create", payloadJson = """{"fullName":"Nouveau"}""", localId = localId, createdAt = 1L, ), ) engine.syncNow() assertEquals(1, api.createContactCalls.size) assertEquals(0, db.syncOpDao().listAll().size) assertEquals("srv-generated", db.crmContactDao().getById(localId)?.serverId) } @Test fun syncNow_pushesProjetCreateOpAndMapsServerId() = runBlocking { val localId = db.projetDao().upsert(ProjetEntity(nom = "Salon", workflowServerId = "wf-1")) db.syncOpDao().insert( SyncOpEntity( entityType = "projet", op = "create", payloadJson = """{"nom":"Salon","workflow_id":"wf-1"}""", localId = localId, createdAt = 1L, ), ) engine.syncNow() assertEquals(1, api.createProjetCalls.size) assertEquals(0, db.syncOpDao().listAll().size) assertEquals("projet-generated", db.projetDao().getByLocalId(localId)?.serverId) } @Test fun syncNow_pushesCardImageAfterContactCreate() = runBlocking { val ctx = ApplicationProvider.getApplicationContext<Context>() val images = fr.ebii.card2vcf.data.ContactImageStore(ctx) engine = SyncEngine(api, db, images) val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Avec carte")) val cardPath = images.saveBytes(localId, "card.jpg", byteArrayOf(1, 2, 3, 4)) db.crmContactDao().update( db.crmContactDao().getById(localId)!!.copy(cardImagePath = cardPath), ) db.syncOpDao().insert( SyncOpEntity( entityType = "contact", op = "create", payloadJson = """{"prenom":"A","nom":"B"}""", localId = localId, createdAt = 1L, ), ) engine.syncNow() assertEquals(1, api.createContactCalls.size) assertEquals(1, api.uploadCarteCalls.size) assertEquals("srv-generated", api.uploadCarteCalls[0].first) assertEquals(4, api.uploadCarteCalls[0].second) assertEquals(0, db.syncOpDao().listAll().size) } @Test fun syncNow_enqueuesMediaRetryWhenUploadFails() = runBlocking { val ctx = ApplicationProvider.getApplicationContext<Context>() val images = fr.ebii.card2vcf.data.ContactImageStore(ctx) engine = SyncEngine(api, db, images) api.uploadCarteResult = AilianceApiClient.ApiResult.Err(500, "boom") val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Retry")) val cardPath = images.saveBytes(localId, "card.jpg", byteArrayOf(9)) db.crmContactDao().update( db.crmContactDao().getById(localId)!!.copy(cardImagePath = cardPath), ) db.syncOpDao().insert( SyncOpEntity( entityType = "contact", op = "create", payloadJson = """{"prenom":"A","nom":"B"}""", localId = localId, createdAt = 1L, ), ) engine.syncNow() val pending = db.syncOpDao().listAll() assertEquals(1, pending.size) assertEquals("contact_media", pending[0].entityType) assertEquals("srv-generated", pending[0].serverId) } @Test fun syncNow_pullDownloadsMissingCardImage() = runBlocking { val ctx = ApplicationProvider.getApplicationContext<Context>() val images = fr.ebii.card2vcf.data.ContactImageStore(ctx) engine = SyncEngine(api, db, images) api.downloadCarteResult = AilianceApiClient.ApiResult.Ok(byteArrayOf(7, 8, 9)) api.pullResult = AilianceApiClient.ApiResult.Ok( SyncPullResponse( serverTime = "2026-07-22T12:05:00Z", contacts = listOf( ContactDto( id = "c-img", prenom = "Marie", nom = "Curie", creeLe = "2026-07-20T10:00:00Z", carteVisite = "carte.jpg", ), ), ), ) engine.syncNow() assertEquals(listOf("c-img"), api.downloadCarteCalls) val stored = db.crmContactDao().getByServerId("c-img") assertTrue(!stored?.cardImagePath.isNullOrBlank()) assertTrue(java.io.File(stored!!.cardImagePath!!).isFile) } @Test fun syncNow_skipsImageUploadWhenNoLocalFile() = runBlocking { val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Sans image")) db.syncOpDao().insert( SyncOpEntity( entityType = "contact", op = "create", payloadJson = """{"prenom":"A","nom":"B"}""", localId = localId, createdAt = 1L, ), ) engine.syncNow() assertEquals(0, api.uploadCarteCalls.size) assertEquals(0, api.uploadPhotoCalls.size) } // ---- Retour utilisateur : compteurs et échecs de push ---- @Test fun syncNow_pushRefusedKeepsOpAndReportsServerMessage() = runBlocking { val localId = db.rdvDao().upsert(RdvEntity(titre = "Point client", dirtyLocal = true)) db.syncOpDao().insert( SyncOpEntity( entityType = AgendaSyncCoordinator.KIND_RDV, op = "create", payloadJson = """{"titre":"Point client"}""", localId = localId, createdAt = 1L, ), ) api.createRdvResult = AilianceApiClient.ApiResult.Err( 409, "Réservation refusée : période en conflit avec Entretien", ) val result = engine.syncNow() assertTrue(result.success) assertEquals(1, db.syncOpDao().listAll().size) assertEquals(0, result.pushed) val failure = result.pushFailures.single() assertEquals(AgendaSyncCoordinator.KIND_RDV, failure.entityType) assertEquals("create", failure.op) assertEquals(409, failure.code) assertEquals("Réservation refusée : période en conflit avec Entretien", failure.message) } @Test fun syncNow_pushFailureMarksOpWithAttemptsAndLastError() = runBlocking { val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Hors ligne")) db.syncOpDao().insert( SyncOpEntity( entityType = "contact", op = "create", payloadJson = """{"fullName":"Hors ligne"}""", localId = localId, createdAt = 1L, ), ) api.createContactResult = AilianceApiClient.ApiResult.Err(500, "Erreur interne.") engine.syncNow() val op = db.syncOpDao().listAll().single() assertEquals(1, op.attempts) assertEquals("Erreur interne.", op.lastError) // Le push suivant réussit : l'op est dépilée, le badge disparaît. api.createContactResult = AilianceApiClient.ApiResult.Ok("""{"id":"srv-generated"}""") engine.syncNow() assertEquals(0, db.syncOpDao().listAll().size) } @Test fun syncNow_reportsPushedAndReceivedCounts() = runBlocking { val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Nouveau")) db.syncOpDao().insert( SyncOpEntity( entityType = "contact", op = "create", payloadJson = """{"prenom":"A","nom":"B"}""", localId = localId, createdAt = 1L, ), ) api.pullResult = AilianceApiClient.ApiResult.Ok( SyncPullResponse( serverTime = "2026-07-22T12:05:00Z", contacts = listOf( ContactDto(id = "c1", prenom = "Jean", nom = "Dupont", creeLe = "2026-07-20T10:00:00Z"), ), interactions = listOf( InteractionDto(id = "i1", contactId = "c1", sujet = "Appel", creeLe = "2026-07-22T09:00:00Z"), ), // Exclus du décompte utilisateur : référentiel et suppressions. workflows = listOf(WorkflowDto(id = "w1", nom = "Défaut", creeLe = "2026-07-20T10:00:00Z")), tombstones = listOf( TombstoneDto(entityType = "contact", id = "c-old", supprimeLe = "2026-07-22T12:00:00Z"), ), ), ) val result = engine.syncNow() assertEquals(1, result.pushed) assertEquals(2, result.received) assertTrue(result.pushFailures.isEmpty()) } }
GitRust