AgendaSyncEngineTest.kt
478 lignes · 19158 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.CrmDatabase import fr.ebii.card2vcf.data.RdvEntity import fr.ebii.card2vcf.data.ReservationEntity 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 import java.time.Instant /** Task M5 : LWW RDV, conflit 409 réservation, abandon, `ressourcesQuery`, pont Agenda↔Room. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [31]) class AgendaSyncEngineTest { 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() private fun epoch(iso: String) = Instant.parse(iso).toEpochMilli() // ---- LWW RDV ---- @Test fun syncNow_rdvPullNewerServerOverwritesCleanLocal() = runBlocking { db.rdvDao().upsert( RdvEntity(serverId = "rdv1", titre = "Ancien titre", updatedAt = epoch("2026-07-20T10:00:00Z")), ) api.pullResult = AilianceApiClient.ApiResult.Ok( SyncPullResponse( serverTime = "2026-07-22T12:00:00Z", rdv = listOf( RendezVousDto( id = "rdv1", titre = "Nouveau titre serveur", debut = "2026-07-22T09:00:00Z", fin = "2026-07-22T10:00:00Z", creeLe = "2026-07-20T10:00:00Z", misAJourLe = "2026-07-22T10:00:00Z", ), ), ), ) engine.syncNow() val updated = db.rdvDao().getByServerId("rdv1") assertEquals("Nouveau titre serveur", updated?.titre) } @Test fun syncNow_rdvPullDirtyLocalNewerKeepsLocal() = runBlocking { db.rdvDao().upsert( RdvEntity( serverId = "rdv2", titre = "Titre local en avance", updatedAt = epoch("2026-07-22T10:00:00Z"), dirtyLocal = true, ), ) api.pullResult = AilianceApiClient.ApiResult.Ok( SyncPullResponse( serverTime = "2026-07-22T12:00:00Z", rdv = listOf( RendezVousDto( id = "rdv2", titre = "Titre serveur périmé", debut = "2026-07-19T09:00:00Z", fin = "2026-07-19T10:00:00Z", creeLe = "2026-07-18T10:00:00Z", misAJourLe = "2026-07-20T10:00:00Z", ), ), ), ) engine.syncNow() val kept = db.rdvDao().getByServerId("rdv2") assertEquals("Titre local en avance", kept?.titre) assertTrue(kept?.dirtyLocal == true) } // ---- Conflit 409 réservation ---- @Test fun syncNow_createReservation409SetsConflictPendingAndKeepsSyncOp() = runBlocking { val localId = db.reservationDao().upsert( ReservationEntity(cibleType = "salle", cibleId = "salle-1", motif = "Réunion", dirtyLocal = true), ) db.syncOpDao().insert( SyncOpEntity( entityType = "reservation", op = "create", payloadJson = """{"debut":"2026-07-22T09:00:00Z","fin":"2026-07-22T10:00:00Z"}""", localId = localId, createdAt = 1L, ), ) api.createReservationResult = AilianceApiClient.ApiResult.Err(409, "Chevauchement avec une autre réservation") val result = engine.syncNow() assertTrue(result.conflicts.isNotEmpty()) assertEquals(localId, result.conflicts.single().localReservationId) assertTrue(db.reservationDao().getByLocalId(localId)?.conflictPending == true) assertEquals(1, db.syncOpDao().listAll().size) assertEquals("salle", api.createReservationCalls.single().first) assertEquals("salle-1", api.createReservationCalls.single().second) } // ---- Abandon ---- @Test fun abandonLocalReservation_removesEntitySyncOpsAndCalendarEvent() = runBlocking { val localId = db.reservationDao().upsert( ReservationEntity( cibleType = "salle", cibleId = "salle-1", motif = "Réunion", conflictPending = true, calendarEventId = 42L, ), ) db.syncOpDao().insert( SyncOpEntity(entityType = "reservation", op = "create", localId = localId, createdAt = 1L), ) val bridge = FakeCalendarBridge() engine.abandonLocalReservation(localId, bridge) assertNull(db.reservationDao().getByLocalId(localId)) assertTrue(db.syncOpDao().listAll().isEmpty()) assertTrue(bridge.deletedEventIds.contains(42L)) } // ---- ressourcesQuery ---- @Test fun ressourcesQuery_buildsFromNonRdvBindingsWithServerResourceId() { val coordinator = AgendaSyncCoordinator(db) val bindings = listOf( CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = 1L), CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = 2L), CalendarBinding(kind = "materiel", serverResourceId = "m1", displayName = "Matériel B", androidCalendarId = 3L), CalendarBinding(kind = "vehicule", serverResourceId = null, displayName = "Sans id serveur", androidCalendarId = 4L), ) assertEquals("salle:s1,materiel:m1", coordinator.ressourcesQuery(bindings)) } @Test fun ressourcesQuery_emptyBindingsReturnsNull() { val coordinator = AgendaSyncCoordinator(db) assertNull(coordinator.ressourcesQuery(emptyList())) } @Test fun syncNow_pullUsesRessourcesQueryDerivedFromBindings() = runBlocking { val bridge = FakeCalendarBridge() val rdvCalendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV") val salleCalendarId = bridge.ensureLocalCalendar("Card2vcf — Salle A") val bindings = listOf( CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = rdvCalendarId), CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = salleCalendarId), ) engine.syncNow(bindings, bridge) assertEquals("salle:s1", api.pullQueries.last()) } @Test fun checkStatus_forwardsRessourcesQueryToApi() = runBlocking { engine.checkStatus("salle:s1") assertEquals("salle:s1", api.statusQueries.last()) } @Test fun syncNow_withoutBindingsOrBridgeOmitsRessourcesQuery() = runBlocking { engine.syncNow() assertNull(api.pullQueries.last()) } // ---- Agenda -> Room / Room -> Agenda ---- @Test fun syncNow_agendaToRoomCreatesRdvFromNewCalendarEventAndPushesIt() = runBlocking { val bridge = FakeCalendarBridge() val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV") bridge.seedEvent( calendarId, CalendarEventSnapshot(title = "RDV client", debutMs = 1_000L, finMs = 2_000L, descriptionBody = "notes"), ) val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId)) engine.syncNow(bindings, bridge) val stored = db.rdvDao().listAll() assertEquals(1, stored.size) assertEquals("RDV client", stored.single().titre) assertEquals(1, api.createRdvCalls.size) assertTrue(db.syncOpDao().listAll().none { it.entityType == "rdv" }) } @Test fun syncNow_roomToAgendaUpsertsRdvEventAndStoresCalendarEventId() = runBlocking { val bridge = FakeCalendarBridge() val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV") val localId = db.rdvDao().upsert( RdvEntity(serverId = "rdv-9", titre = "RDV serveur", debutMs = 5_000L, finMs = 6_000L, updatedAt = 1L), ) val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId)) engine.syncNow(bindings, bridge) val updated = db.rdvDao().getByLocalId(localId) assertTrue(updated?.calendarEventId != null) assertEquals(1, bridge.listEvents(calendarId).size) assertEquals("RDV serveur", bridge.listEvents(calendarId).single().title) } // ---- Fix HIGH : roomToAgenda supprime les events orphelins ---- @Test fun syncNow_roomToAgendaDeletesOrphanEventAfterRdvTombstone() = runBlocking { val bridge = FakeCalendarBridge() val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV") val eventId = bridge.seedEvent( calendarId, CalendarEventSnapshot(title = "RDV serveur", debutMs = 1_000L, finMs = 2_000L, serverId = "rdv-old"), ) db.rdvDao().upsert(RdvEntity(serverId = "rdv-old", titre = "RDV serveur", calendarEventId = eventId, updatedAt = 1L)) val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId)) api.pullResult = AilianceApiClient.ApiResult.Ok( SyncPullResponse( serverTime = "2026-07-22T12:00:00Z", tombstones = listOf(TombstoneDto(entityType = "rdv", id = "rdv-old", supprimeLe = "2026-07-22T12:00:00Z")), ), ) engine.syncNow(bindings, bridge) assertNull(db.rdvDao().getByServerId("rdv-old")) assertTrue(bridge.deletedEventIds.contains(eventId)) assertTrue(bridge.listEvents(calendarId).isEmpty()) } @Test fun syncNow_roomToAgendaDeletesOrphanEventAfterReservationBecomesInactive() = runBlocking { val bridge = FakeCalendarBridge() val calendarId = bridge.ensureLocalCalendar("Card2vcf — Salle A") val eventId = bridge.seedEvent( calendarId, CalendarEventSnapshot(title = "Réunion", debutMs = 1_000L, finMs = 2_000L, serverId = "resa-1"), ) db.reservationDao().upsert( ReservationEntity( serverId = "resa-1", cibleType = "salle", cibleId = "s1", motif = "Réunion", calendarEventId = eventId, updatedAt = 1L, statut = "active", ), ) val bindings = listOf(CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = calendarId)) api.pullResult = AilianceApiClient.ApiResult.Ok( SyncPullResponse( serverTime = "2026-07-22T12:00:00Z", reservations = listOf( ReservationDto( id = "resa-1", cible = CibleRessourceDto(type = "salle", id = "s1"), debut = "2026-07-22T09:00:00Z", fin = "2026-07-22T10:00:00Z", statut = "annulee", creeLe = "2026-07-20T10:00:00Z", misAJourLe = "2026-07-22T11:00:00Z", ), ), ), ) engine.syncNow(bindings, bridge) assertNull(db.reservationDao().getByServerId("resa-1")) assertTrue(bridge.deletedEventIds.contains(eventId)) } @Test fun syncNow_roomToAgendaKeepsPendingPushEventEvenWithoutServerId() = runBlocking { val bridge = FakeCalendarBridge() val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV") val eventId = bridge.seedEvent( calendarId, CalendarEventSnapshot(title = "Nouveau RDV", debutMs = 1_000L, finMs = 2_000L), ) val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId)) api.createRdvResult = AilianceApiClient.ApiResult.Err(-1, "Réseau indisponible") engine.syncNow(bindings, bridge) val stored = db.rdvDao().listAll().single() assertNull(stored.serverId) assertTrue(bridge.deletedEventIds.isEmpty()) assertTrue(bridge.listEvents(calendarId).any { it.eventId == eventId }) } // ---- Fix MEDIUM : agendaToRoom détecte les suppressions côté Agenda ---- @Test fun agendaToRoom_detectsDeletedRdvEventWithServerIdEnqueuesDeleteOp() = runBlocking { val bridge = FakeCalendarBridge() val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV") db.rdvDao().upsert(RdvEntity(serverId = "rdv-5", titre = "RDV supprimé", calendarEventId = 99L, updatedAt = 1L)) val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId)) val coordinator = AgendaSyncCoordinator(db) coordinator.agendaToRoom(bindings, bridge) val op = db.syncOpDao().listAll().single { it.entityType == "rdv" } assertEquals("delete", op.op) assertEquals("rdv-5", op.serverId) assertNull(db.rdvDao().getByServerId("rdv-5")) } @Test fun agendaToRoom_detectsDeletedRdvEventWithoutServerIdRemovesLocalEntityDirectly() = runBlocking { val bridge = FakeCalendarBridge() val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV") val localId = db.rdvDao().upsert(RdvEntity(titre = "RDV jamais poussé", calendarEventId = 77L, updatedAt = 1L)) val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId)) val coordinator = AgendaSyncCoordinator(db) coordinator.agendaToRoom(bindings, bridge) assertNull(db.rdvDao().getByLocalId(localId)) assertTrue(db.syncOpDao().listAll().none { it.entityType == "rdv" }) } @Test fun agendaToRoom_detectsDeletedReservationEventWithServerIdEnqueuesDeleteOp() = runBlocking { val bridge = FakeCalendarBridge() val calendarId = bridge.ensureLocalCalendar("Card2vcf — Salle A") db.reservationDao().upsert( ReservationEntity( serverId = "resa-9", cibleType = "salle", cibleId = "s1", motif = "Réunion supprimée", calendarEventId = 55L, updatedAt = 1L, ), ) val bindings = listOf(CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = calendarId)) val coordinator = AgendaSyncCoordinator(db) coordinator.agendaToRoom(bindings, bridge) val op = db.syncOpDao().listAll().single { it.entityType == "reservation" } assertEquals("delete", op.op) assertEquals("resa-9", op.serverId) assertNull(db.reservationDao().getByServerId("resa-9")) } @Test fun syncNow_pushesDeleteOpFromDeletedReservationEventUsingPayloadCibleFallback() = runBlocking { val bridge = FakeCalendarBridge() val calendarId = bridge.ensureLocalCalendar("Card2vcf — Salle A") db.reservationDao().upsert( ReservationEntity( serverId = "resa-9", cibleType = "salle", cibleId = "s1", motif = "Réunion supprimée", calendarEventId = 55L, updatedAt = 1L, ), ) val bindings = listOf(CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = calendarId)) // agendaToRoom (via syncNow) supprime l'entité locale puis pushOps doit résoudre la cible via le payload de l'op. engine.syncNow(bindings, bridge) assertEquals(1, api.deleteReservationCalls.size) assertEquals(Triple("salle", "s1", "resa-9"), api.deleteReservationCalls.single()) } @Test fun agendaToRoom_conflictPendingReservationNotDeletedWhenEventMissing() = runBlocking { val bridge = FakeCalendarBridge() val calendarId = bridge.ensureLocalCalendar("Card2vcf — Salle A") db.reservationDao().upsert( ReservationEntity( serverId = "resa-conflict", cibleType = "salle", cibleId = "s1", motif = "En conflit", calendarEventId = 12L, updatedAt = 1L, conflictPending = true, ), ) val bindings = listOf(CalendarBinding(kind = "salle", serverResourceId = "s1", displayName = "Salle A", androidCalendarId = calendarId)) val coordinator = AgendaSyncCoordinator(db) coordinator.agendaToRoom(bindings, bridge) assertTrue(db.reservationDao().getByServerId("resa-conflict") != null) assertTrue(db.syncOpDao().listAll().none { it.entityType == "reservation" }) } // ---- Fix MEDIUM : localAheadCount avec events orphelins ---- @Test fun localAheadCount_withoutBridgeCountsDirtyAndPendingOpsOnly() = runBlocking { db.rdvDao().upsert(RdvEntity(titre = "Dirty", dirtyLocal = true)) db.syncOpDao().insert(SyncOpEntity(entityType = "reservation", op = "create", createdAt = 1L)) val count = engine.localAheadCount() assertEquals(2, count) } @Test fun localAheadCount_withBridgeIncludesOrphanTaggedEvents() = runBlocking { val bridge = FakeCalendarBridge() val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV") bridge.seedEvent( calendarId, CalendarEventSnapshot(title = "Orphelin", debutMs = 1_000L, finMs = 2_000L, serverId = "rdv-orphan"), ) val bindings = listOf(CalendarBinding(kind = "rdv", displayName = "Mes RDV", androidCalendarId = calendarId)) val count = engine.localAheadCount(bindings, bridge) assertEquals(1, count) } // ---- Fix LOW : delete réservation sans serverId ---- @Test fun pushReservationDeleteWithoutServerIdDoesNotDeleteSyncOp() = runBlocking { val localId = db.reservationDao().upsert(ReservationEntity(cibleType = "salle", cibleId = "s1", motif = "x")) db.syncOpDao().insert(SyncOpEntity(entityType = "reservation", op = "delete", localId = localId, createdAt = 1L)) engine.syncNow() assertEquals(1, db.syncOpDao().listAll().size) } }
GitRust