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)
    }
}