InteractionVocaleTest.kt 410 lignes · 14586 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.InteractionEntity
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
import java.io.File

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [31])
class InteractionVocaleTest {

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

    // ---- Push interaction note_vocale ----

    @Test
    fun push_interactionNoteVocale_createPuisUploadEtServerIdEnregistre() = runBlocking {
        // Prépare un fichier audio local
        val audioFile = File.createTempFile("note_vocale", ".wav")
        audioFile.writeBytes(byteArrayOf(1, 2, 3, 4, 5))

        // Interaction locale avec audioPath
        val localId = db.interactionDao().upsert(
            InteractionEntity(
                contactServerId = "contact-srv-1",
                type = "note_vocale",
                sujet = "Note vocale",
                audioPath = audioFile.absolutePath,
            ),
        )
        // Op de create dans la file de sync (serverId = contactServerId)
        db.syncOpDao().insert(
            SyncOpEntity(
                entityType = "interaction",
                op = "create",
                payloadJson = """{"sujet":"Note vocale","type_interaction":"note_vocale","demande_transcription":true}""",
                localId = localId,
                serverId = "contact-srv-1",
                createdAt = 1L,
            ),
        )

        engine.syncNow()

        // L'op create a été appelée
        assertEquals(1, api.createInteractionCalls.size)
        assertEquals("contact-srv-1", api.createInteractionCalls[0].first)

        // L'upload audio a été appelé
        assertEquals(1, api.uploadInteractionAudioCalls.size)
        assertEquals("contact-srv-1", api.uploadInteractionAudioCalls[0].first)
        assertEquals("interaction-generated", api.uploadInteractionAudioCalls[0].second)
        assertEquals(5, api.uploadInteractionAudioCalls[0].third)

        // Le serverId a été enregistré sur l'entité locale
        val updated = db.interactionDao().getByLocalId(localId)
        assertEquals("interaction-generated", updated?.serverId)

        // L'op a été dépilée (upload OK)
        assertEquals(0, db.syncOpDao().listAll().size)

        audioFile.deleteOnExit()
    }

    @Test
    fun push_interactionNoteVocale_echecUpload_opMediaEnFile() = runBlocking {
        val audioFile = File.createTempFile("note_vocale", ".wav")
        audioFile.writeBytes(byteArrayOf(1, 2, 3))
        api.uploadInteractionAudioResult = AilianceApiClient.ApiResult.Err(503, "Service indisponible")

        val localId = db.interactionDao().upsert(
            InteractionEntity(
                contactServerId = "contact-srv-2",
                type = "note_vocale",
                sujet = "Note avec erreur upload",
                audioPath = audioFile.absolutePath,
            ),
        )
        db.syncOpDao().insert(
            SyncOpEntity(
                entityType = "interaction",
                op = "create",
                payloadJson = """{"sujet":"Note avec erreur upload","type_interaction":"note_vocale","demande_transcription":true}""",
                localId = localId,
                serverId = "contact-srv-2",
                createdAt = 1L,
            ),
        )

        engine.syncNow()

        // L'op create a été dépilée (create OK)
        val remainingOps = db.syncOpDao().listAll()
        // Une op media doit être en file pour retry
        assertEquals(1, remainingOps.size)
        assertEquals("interaction_media", remainingOps[0].entityType)
        assertEquals("upload", remainingOps[0].op)
        assertEquals(localId, remainingOps[0].localId)
        assertEquals("interaction-generated", remainingOps[0].serverId)

        audioFile.deleteOnExit()
    }

    // ---- Pull interaction LWW ----

    @Test
    fun pull_interactionAvecMisAJourLeRecent_descriptionMisAJourAudioPathPreserve() = runBlocking {
        val localAudioPath = "/data/audio/interaction_locale.wav"
        db.interactionDao().upsert(
            InteractionEntity(
                serverId = "int-srv-1",
                contactServerId = "contact-srv-1",
                type = "note_vocale",
                sujet = "Ancien sujet",
                description = "Ancienne description",
                audioPath = localAudioPath,
                createdAt = parseIsoToEpochMs("2026-09-10T08:00:00Z"),
                updatedAt = parseIsoToEpochMs("2026-09-10T08:00:00Z"),
            ),
        )

        api.pullResult = AilianceApiClient.ApiResult.Ok(
            SyncPullResponse(
                serverTime = "2026-09-15T10:00:00Z",
                interactions = listOf(
                    InteractionDto(
                        id = "int-srv-1",
                        contactId = "contact-srv-1",
                        typeInteraction = "note_vocale",
                        sujet = "Sujet mis à jour",
                        description = "Description mise à jour",
                        creeLe = "2026-09-10T08:00:00Z",
                        misAJourLe = "2026-09-15T09:00:00Z", // plus récent que updatedAt local
                        transcription = "terminee",
                    ),
                ),
            ),
        )

        engine.syncNow()

        val updated = db.interactionDao().getByServerId("int-srv-1")
        assertNotNull(updated)
        assertEquals("Sujet mis à jour", updated?.sujet)
        assertEquals("Description mise à jour", updated?.description)
        assertEquals("terminee", updated?.transcriptionStatut)
        // audioPath local préservé
        assertEquals(localAudioPath, updated?.audioPath)
    }

    @Test
    fun pull_interactionAvecMisAJourLeAncien_intact() = runBlocking {
        val sujetOriginal = "Sujet original"
        db.interactionDao().upsert(
            InteractionEntity(
                serverId = "int-srv-2",
                contactServerId = "contact-srv-1",
                type = "note",
                sujet = sujetOriginal,
                description = "Description originale",
                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",
                interactions = listOf(
                    InteractionDto(
                        id = "int-srv-2",
                        contactId = "contact-srv-1",
                        typeInteraction = "note",
                        sujet = "Sujet distant obsolète",
                        description = "Description obsolète",
                        creeLe = "2026-09-14T08:00:00Z",
                        misAJourLe = "2026-09-14T10:00:00Z", // plus ancien que updatedAt local
                    ),
                ),
            ),
        )

        engine.syncNow()

        val unchanged = db.interactionDao().getByServerId("int-srv-2")
        assertEquals(sujetOriginal, unchanged?.sujet)
    }

    // ---- Push note_projet create/update/delete ----

    @Test
    fun push_noteProjetCreate_serverIdEnregistre() = runBlocking {
        val localId = db.noteProjetDao().upsert(
            NoteProjetEntity(
                projetServerId = "projet-srv-1",
                titre = "Ma note",
                texte = "Contenu",
                auteur = "alice",
                createdAt = 1L,
            ),
        )
        db.syncOpDao().insert(
            SyncOpEntity(
                entityType = "note_projet",
                op = "create",
                payloadJson = """{"titre":"Ma note","contenu":"Contenu","demande_transcription":false}""",
                localId = localId,
                createdAt = 1L,
            ),
        )

        engine.syncNow()

        assertEquals(1, api.createNoteProjetCalls.size)
        assertEquals("projet-srv-1", api.createNoteProjetCalls[0].first)
        val updated = db.noteProjetDao().getByLocalId(localId)
        assertEquals("note-generated", updated?.serverId)
        assertEquals(0, db.syncOpDao().listAll().size)
    }

    @Test
    fun push_noteProjetUpdate_appelApi() = runBlocking {
        db.noteProjetDao().upsert(
            NoteProjetEntity(
                serverId = "note-srv-1",
                projetServerId = "projet-srv-1",
                titre = "Titre modifié",
                texte = "Contenu modifié",
                auteur = "alice",
                createdAt = 1L,
            ),
        )
        db.syncOpDao().insert(
            SyncOpEntity(
                entityType = "note_projet",
                op = "update",
                payloadJson = """{"titre":"Titre modifié","contenu":"Contenu modifié"}""",
                serverId = "note-srv-1",
                createdAt = 1L,
            ),
        )

        engine.syncNow()

        assertEquals(1, api.updateNoteProjetCalls.size)
        assertEquals("projet-srv-1", api.updateNoteProjetCalls[0].first)
        assertEquals("note-srv-1", api.updateNoteProjetCalls[0].second)
        assertEquals(0, db.syncOpDao().listAll().size)
    }

    @Test
    fun push_noteProjetDelete_appelApi() = runBlocking {
        db.noteProjetDao().upsert(
            NoteProjetEntity(
                serverId = "note-srv-2",
                projetServerId = "projet-srv-2",
                titre = "À supprimer",
                texte = "Contenu",
                auteur = "alice",
                createdAt = 1L,
            ),
        )
        db.syncOpDao().insert(
            SyncOpEntity(
                entityType = "note_projet",
                op = "delete",
                payloadJson = "{}",
                serverId = "note-srv-2",
                createdAt = 1L,
            ),
        )

        engine.syncNow()

        assertEquals(1, api.deleteNoteProjetCalls.size)
        assertEquals("projet-srv-2", api.deleteNoteProjetCalls[0].first)
        assertEquals("note-srv-2", api.deleteNoteProjetCalls[0].second)
        assertEquals(0, db.syncOpDao().listAll().size)
    }

    @Test
    fun pull_tombstoneInteraction_suppression() = runBlocking {
        db.interactionDao().upsert(
            InteractionEntity(
                serverId = "int-delete",
                contactServerId = "contact-srv-1",
                type = "note",
                sujet = "À supprimer",
                createdAt = parseIsoToEpochMs("2026-09-10T08:00:00Z"),
            ),
        )
        api.pullResult = AilianceApiClient.ApiResult.Ok(
            SyncPullResponse(
                serverTime = "2026-09-15T10:00:00Z",
                tombstones = listOf(
                    TombstoneDto(
                        entityType = "interaction",
                        id = "int-delete",
                        supprimeLe = "2026-09-15T09:00:00Z",
                    ),
                ),
            ),
        )

        engine.syncNow()

        assertNull(db.interactionDao().getByServerId("int-delete"))
    }

    // ---- pousserEnAttente ----

    @Test
    fun pousserEnAttente_modeServeur_pushDeclencheEtOpConsommee() = runBlocking {
        val localId = db.interactionDao().upsert(
            InteractionEntity(
                contactServerId = "contact-srv-1",
                type = "note_vocale",
                sujet = "Note rapide",
                transcriptionStatut = "en_attente",
                createdAt = 1L,
            ),
        )
        db.syncOpDao().insert(
            SyncOpEntity(
                entityType = "interaction",
                op = "create",
                payloadJson = """{"sujet":"Note rapide","type_interaction":"note_vocale","demande_transcription":true}""",
                localId = localId,
                serverId = "contact-srv-1",
                createdAt = 1L,
            ),
        )

        val rapport = engine.pousserEnAttente()

        assertEquals(1, api.createInteractionCalls.size)
        assertEquals(1, rapport.envoyes)
        assertEquals(0, rapport.echecs)
        assertEquals(0, db.syncOpDao().listAll().size)
    }

    @Test
    fun pousserEnAttente_echecReseau_opConserveeEnFile() = runBlocking {
        api.createInteractionResult = AilianceApiClient.ApiResult.Err(503, "Service indisponible")
        val localId = db.interactionDao().upsert(
            InteractionEntity(
                contactServerId = "contact-srv-1",
                type = "note_vocale",
                sujet = "Note réseau KO",
                transcriptionStatut = "en_attente",
                createdAt = 1L,
            ),
        )
        db.syncOpDao().insert(
            SyncOpEntity(
                entityType = "interaction",
                op = "create",
                payloadJson = """{"sujet":"Note réseau KO","type_interaction":"note_vocale","demande_transcription":true}""",
                localId = localId,
                serverId = "contact-srv-1",
                createdAt = 1L,
            ),
        )

        val rapport = engine.pousserEnAttente()

        assertEquals(0, rapport.envoyes)
        assertEquals(1, rapport.echecs)
        val ops = db.syncOpDao().listAll()
        assertEquals(1, ops.size)
        assertEquals("interaction", ops[0].entityType)
    }

    @Test
    fun pousserEnAttente_fileVide_aucunAppelApi() = runBlocking {
        val rapport = engine.pousserEnAttente()

        assertEquals(0, api.createInteractionCalls.size)
        assertEquals(0, rapport.envoyes)
        assertEquals(0, rapport.echecs)
    }
}