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