NoteProjetSyncEngineTest.kt 213 lignes · 7376 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.NoteProjetEntity
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
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 NoteProjetSyncEngineTest {

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

    // ---- Tâche 1 : pull avec notes → insérées ----

    @Test
    fun pull_notesInserees() = runBlocking {
        api.pullResult = AilianceApiClient.ApiResult.Ok(
            SyncPullResponse(
                serverTime = "2026-09-15T10:00:00Z",
                notes = listOf(
                    NoteProjetDto(
                        id = "n1",
                        projetId = "p1",
                        titre = "Réunion kick-off",
                        contenu = "**Objectif** : lancer le projet.",
                        auteur = "alice",
                        creeLe = "2026-09-14T08:00:00Z",
                        majLe = null,
                    ),
                    NoteProjetDto(
                        id = "n2",
                        projetId = "p1",
                        titre = "Suivi hebdo",
                        contenu = "Point de la semaine.",
                        auteur = "bob",
                        creeLe = "2026-09-15T09:00:00Z",
                    ),
                ),
            ),
        )

        val result = engine.syncNow()

        assertEquals(2, db.noteProjetDao().listAll().size)
        val n1 = db.noteProjetDao().getByServerId("n1")
        assertNotNull(n1)
        assertEquals("Réunion kick-off", n1?.titre)
        assertEquals("**Objectif** : lancer le projet.", n1?.texte)
        assertEquals("alice", n1?.auteur)
        assertEquals("p1", n1?.projetServerId)
        // Notes comptées dans received
        assertEquals(2, result.received)
    }

    // ---- Tâche 2 : majLe plus récent → mise à jour, audioPath local préservé ----

    @Test
    fun pull_noteExistante_majLeRecent_miseAJourEtAudioPathPreserve() = runBlocking {
        val localAudioPath = "/data/app/audio/n1.wav"
        db.noteProjetDao().upsert(
            NoteProjetEntity(
                serverId = "n1",
                projetServerId = "p1",
                titre = "Ancien titre",
                texte = "Ancien contenu",
                auteur = "alice",
                audioPath = localAudioPath,
                createdAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"),
                updatedAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"),
            ),
        )

        api.pullResult = AilianceApiClient.ApiResult.Ok(
            SyncPullResponse(
                serverTime = "2026-09-15T10:00:00Z",
                notes = listOf(
                    NoteProjetDto(
                        id = "n1",
                        projetId = "p1",
                        titre = "Nouveau titre",
                        contenu = "Contenu mis à jour.",
                        auteur = "alice",
                        creeLe = "2026-09-14T08:00:00Z",
                        majLe = "2026-09-15T09:00:00Z", // plus récent que updatedAt local
                    ),
                ),
            ),
        )

        engine.syncNow()

        val updated = db.noteProjetDao().getByServerId("n1")
        assertNotNull(updated)
        assertEquals("Nouveau titre", updated?.titre)
        assertEquals("Contenu mis à jour.", updated?.texte)
        // audioPath local préservé
        assertEquals(localAudioPath, updated?.audioPath)
    }

    // ---- Tâche 3 : majLe plus ancien → pas d'écrasement ----

    @Test
    fun pull_noteExistante_majLeAncien_pasDecrasement() = runBlocking {
        val titreOriginal = "Titre original"
        db.noteProjetDao().upsert(
            NoteProjetEntity(
                serverId = "n1",
                projetServerId = "p1",
                titre = titreOriginal,
                texte = "Contenu original",
                auteur = "alice",
                createdAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"),
                updatedAt = parseIsoToEpochMs("2026-09-15T09:00:00Z"), // local plus récent
            ),
        )

        api.pullResult = AilianceApiClient.ApiResult.Ok(
            SyncPullResponse(
                serverTime = "2026-09-15T10:00:00Z",
                notes = listOf(
                    NoteProjetDto(
                        id = "n1",
                        projetId = "p1",
                        titre = "Titre distant obsolète",
                        contenu = "Contenu distant obsolète",
                        auteur = "alice",
                        creeLe = "2026-09-14T08:00:00Z",
                        majLe = "2026-09-14T10:00:00Z", // plus ancien que updatedAt local
                    ),
                ),
            ),
        )

        engine.syncNow()

        val unchanged = db.noteProjetDao().getByServerId("n1")
        assertEquals(titreOriginal, unchanged?.titre)
    }

    // ---- Tâche 4 : tombstone note → suppression ----

    @Test
    fun pull_tombstoneNote_suppression() = runBlocking {
        db.noteProjetDao().upsert(
            NoteProjetEntity(
                serverId = "n-delete",
                projetServerId = "p1",
                titre = "À supprimer",
                texte = "Sera supprimée.",
                auteur = "alice",
                createdAt = parseIsoToEpochMs("2026-09-10T08:00:00Z"),
            ),
        )
        api.pullResult = AilianceApiClient.ApiResult.Ok(
            SyncPullResponse(
                serverTime = "2026-09-15T10:00:00Z",
                tombstones = listOf(
                    TombstoneDto(
                        entityType = "note",
                        id = "n-delete",
                        supprimeLe = "2026-09-15T09:00:00Z",
                    ),
                ),
            ),
        )

        engine.syncNow()

        assertNull(db.noteProjetDao().getByServerId("n-delete"))
    }

    // ---- Tâche 5 : pull sans section notes (ancien serveur) → aucune erreur ----

    @Test
    fun pull_sansSectionNotes_aucuneErreur() = runBlocking {
        // SyncPullResponse sans `notes` explicite → default emptyList() (compat anciens serveurs)
        api.pullResult = AilianceApiClient.ApiResult.Ok(
            SyncPullResponse(serverTime = "2026-09-15T10:00:00Z"),
        )

        val result = engine.syncNow()

        assertEquals(true, result.success)
        assertEquals(0, db.noteProjetDao().listAll().size)
    }
}