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