SyncEngineTest.kt 264 lignes · 9099 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 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 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_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)
    }
}