InteractionAudioSyncTest.swift
315 lignes · 12845 octets
import XCTest @testable import Card2vcf /// Preuve de rougeur — Lot 2b (modèle + transport des notes vocales). /// /// Test 1 : testApplyInteraction_lwwUpdatesTranscriptionStatut /// En commentant `guard remoteTs > localTs else { return }` dans `SyncEngine.applyInteraction` /// (chemin de mise à jour), le test échoue avec : /// /// XCTAssertEqual failed: ("nil") is not equal to ("Optional("terminee")") /// /// Test 2 : testPousserEnAttente_uploadsInteractionAudio /// En supprimant le `case "interaction_audio"` dans `pushOps`, le test échoue avec : /// /// XCTAssertEqual failed: ("0") is not equal to ("1") /// final class InteractionAudioSyncTest: XCTestCase { private var db: InMemoryCrmDatabase! private var api: FakeAilianceApi! private var engine: SyncEngine! override func setUp() { super.setUp() db = InMemoryCrmDatabase() api = FakeAilianceApi() engine = SyncEngine(api: api, db: db) } // MARK: - InteractionDto — décodage des champs audio func testInteractionDto_decodesAudioFields() throws { let json = """ { "id": "i1", "contact_id": "c1", "type_interaction": "note_vocale", "sujet": "Note vocale", "description": "", "cree_par": "alice", "cree_le": "2026-09-16T10:00:00Z", "mis_a_jour_le": "2026-09-16T10:01:00Z", "piece_jointe": "audio.wav", "transcription": "terminee", "transcription_erreur": null } """ let dto = try SyncJson.decode(InteractionDto.self, from: json) XCTAssertEqual("note_vocale", dto.typeInteraction) XCTAssertEqual("audio.wav", dto.pieceJointe) XCTAssertEqual("terminee", dto.transcription) XCTAssertNil(dto.transcriptionErreur) } func testInteractionDto_champsOptionnelsAbsentsSansErreur() throws { // Serveur antérieur sans les nouveaux champs : décodage tolérant. let json = """ { "id": "i2", "contact_id": "c1", "cree_le": "2026-09-16T10:00:00Z" } """ let dto = try SyncJson.decode(InteractionDto.self, from: json) XCTAssertNil(dto.pieceJointe) XCTAssertNil(dto.transcription) XCTAssertNil(dto.transcriptionErreur) } // MARK: - applyInteraction — insert avec champs audio func testApplyInteraction_insertsAvecChampsAudio() async throws { api.pullResult = .ok(SyncPullResponse( serverTime: "2026-09-16T10:05:00Z", interactions: [ InteractionDto( id: "i1", contactId: "c1", typeInteraction: "note_vocale", sujet: "Note", creeLe: "2026-09-16T10:00:00Z", transcription: "en_attente" ) ] )) _ = try await engine.syncNow() let stored = try await db.interactionDao.getByServerId("i1") XCTAssertEqual("note_vocale", stored?.type) XCTAssertEqual("en_attente", stored?.transcriptionStatut) XCTAssertNil(stored?.audioPath, "audioPath doit être nil à l'insert (fichier local absent)") } // MARK: - LWW update interactions (PREUVE DE ROUGEUR #1) func testApplyInteraction_lwwUpdatesTranscriptionStatut() async throws { // Interaction connue localement (transcription en_attente, timestamp ancien). var existing = InteractionEntity() existing.serverId = "i-lww" existing.contactServerId = "c1" existing.type = "note_vocale" existing.sujet = "Note" existing.transcriptionStatut = "en_attente" existing.createdAt = 1_000 existing.updatedAt = 1_000 // très ancien : 1970-01-01T00:00:01Z _ = try await db.interactionDao.upsert(existing) // Pull : même interaction, transcription terminée, timestamp 2026 (plus récent). api.pullResult = .ok(SyncPullResponse( serverTime: "2026-09-16T12:05:00Z", interactions: [ InteractionDto( id: "i-lww", contactId: "c1", typeInteraction: "note_vocale", sujet: "Note", creeLe: "2026-09-16T10:00:00Z", misAJourLe: "2026-09-16T12:00:00Z", transcription: "terminee" ) ] )) _ = try await engine.syncNow() let updated = try await db.interactionDao.getByServerId("i-lww") // Rouge sans le chemin de MàJ : ("nil") is not equal to ("Optional("terminee")") XCTAssertEqual("terminee", updated?.transcriptionStatut) } func testApplyInteraction_remoteAncienNEcrasePasLocal() async throws { // Local très récent (2033), remote 2026 : local doit gagner. var existing = InteractionEntity() existing.serverId = "i-old" existing.contactServerId = "c1" existing.sujet = "Local récent" existing.transcriptionStatut = "terminee" existing.createdAt = 2_000_000_000_000 existing.updatedAt = 2_000_000_000_000 // ~2033 _ = try await db.interactionDao.upsert(existing) api.pullResult = .ok(SyncPullResponse( serverTime: "2026-09-16T12:05:00Z", interactions: [ InteractionDto( id: "i-old", contactId: "c1", sujet: "Ancien serveur", creeLe: "2026-09-16T09:00:00Z", misAJourLe: "2026-09-16T09:30:00Z", transcription: "en_attente" ) ] )) _ = try await engine.syncNow() let kept = try await db.interactionDao.getByServerId("i-old") XCTAssertEqual("Local récent", kept?.sujet) XCTAssertEqual("terminee", kept?.transcriptionStatut) } func testApplyInteraction_preserveAudioPathLocal() async throws { // Le pull distant (plus récent) ne doit pas écraser le chemin audio local. var existing = InteractionEntity() existing.serverId = "i-audio" existing.contactServerId = "c1" existing.sujet = "Avec audio" existing.audioPath = "/local/audio/interaction_1.wav" existing.transcriptionStatut = "en_attente" existing.createdAt = 1_000 existing.updatedAt = 1_000 _ = try await db.interactionDao.upsert(existing) api.pullResult = .ok(SyncPullResponse( serverTime: "2026-09-16T12:05:00Z", interactions: [ InteractionDto( id: "i-audio", contactId: "c1", sujet: "Avec audio", creeLe: "2026-09-16T10:00:00Z", misAJourLe: "2026-09-16T12:00:00Z", pieceJointe: "audio_serveur.wav", transcription: "terminee" ) ] )) _ = try await engine.syncNow() let updated = try await db.interactionDao.getByServerId("i-audio") XCTAssertEqual("/local/audio/interaction_1.wav", updated?.audioPath, "audioPath local préservé même si piece_jointe serveur reçu") XCTAssertEqual("terminee", updated?.transcriptionStatut) } // MARK: - Push audio interaction retry (PREUVE DE ROUGEUR #2) func testPousserEnAttente_uploadsInteractionAudio() async throws { // Créer un fichier WAV temporaire (le SyncEngine lit les bytes depuis le disque). let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let audioUrl = tempDir.appendingPathComponent("note.wav") try Data([0x52, 0x49, 0x46, 0x46, 0x00]).write(to: audioUrl) // mini RIFF var interaction = InteractionEntity() interaction.serverId = "i-srv" interaction.contactServerId = "c-srv" interaction.type = "note_vocale" interaction.sujet = "Note audio" interaction.audioPath = audioUrl.path interaction.createdAt = 1_000_000 _ = try await db.interactionDao.upsert(interaction) var op = SyncOpEntity(entityType: "interaction_audio", op: "upload") op.serverId = "i-srv" op.payloadJson = "{}" op.createdAt = 1 _ = try await db.syncOpDao.insert(op) api.uploadInteractionAudioResult = .ok("{}") _ = try await engine.pousserEnAttente() // Rouge sans le handler interaction_audio : ("0") is not equal to ("1") XCTAssertEqual(1, api.uploadInteractionAudioCalls.count) let ops1 = try await db.syncOpDao.listAll() XCTAssertTrue(ops1.isEmpty, "Op dépilée après succès") } func testPousserEnAttente_echecReseauGardeOpEnFile() async throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let audioUrl = tempDir.appendingPathComponent("note2.wav") try Data([0x00]).write(to: audioUrl) var interaction = InteractionEntity() interaction.serverId = "i-srv2" interaction.contactServerId = "c-srv" interaction.type = "note_vocale" interaction.audioPath = audioUrl.path interaction.createdAt = 1_000_000 _ = try await db.interactionDao.upsert(interaction) var op = SyncOpEntity(entityType: "interaction_audio", op: "upload") op.serverId = "i-srv2" op.payloadJson = "{}" op.createdAt = 1 _ = try await db.syncOpDao.insert(op) api.uploadInteractionAudioResult = .err(code: -1, message: "Réseau indisponible") _ = try await engine.pousserEnAttente() let remaining = try await db.syncOpDao.listAll() XCTAssertEqual(1, remaining.count, "Op reste en file après échec réseau") } // MARK: - Push note de projet func testPousserEnAttente_creeNoteProjet() async throws { var op = SyncOpEntity(entityType: "note_projet", op: "create") op.serverId = "projet-srv" op.localId = 42 op.payloadJson = #"{"titre":"Ma note","contenu":"Contenu","demande_transcription":false}"# op.createdAt = 1 _ = try await db.syncOpDao.insert(op) api.createNoteProjetResult = .ok(#"{"id":"note-srv"}"#) _ = try await engine.pousserEnAttente() XCTAssertEqual(1, api.createNoteProjetCalls.count) let ops2 = try await db.syncOpDao.listAll() XCTAssertTrue(ops2.isEmpty) } func testPousserEnAttente_noteProjetEchecReseauGardeOpEnFile() async throws { var op = SyncOpEntity(entityType: "note_projet", op: "create") op.serverId = "projet-srv" op.localId = 42 op.payloadJson = #"{"titre":"Erreur","contenu":"","demande_transcription":false}"# op.createdAt = 1 _ = try await db.syncOpDao.insert(op) api.createNoteProjetResult = .err(code: -1, message: "Réseau") _ = try await engine.pousserEnAttente() let ops3 = try await db.syncOpDao.listAll() XCTAssertEqual(1, ops3.count) } // MARK: - pousserEnAttente n'effectue pas de pull func testPousserEnAttente_nEffectuePasLePull() async throws { api.pullResult = .ok(SyncPullResponse( serverTime: "2026-09-16T10:05:00Z", contacts: [ContactDto(id: "c-never", creeLe: "2026-09-16T10:00:00Z")] )) _ = try await engine.pousserEnAttente() let contacts = try await db.crmContactDao.fetchAll() XCTAssertTrue(contacts.isEmpty, "pousserEnAttente ne doit pas effectuer de pull") } // MARK: - AudioNoteStore func testAudioNoteStore_cheminsStables() { let dir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: dir) } let store = AudioNoteStore(rootDirectory: dir) XCTAssertEqual(store.pathForInteraction(localId: 1), store.pathForInteraction(localId: 1), "Le chemin doit être stable pour un même localId") XCTAssertTrue(store.pathForInteraction(localId: 5).hasSuffix("interaction_5.wav")) XCTAssertTrue(store.pathForNote(localId: 7).hasSuffix("note_7.wav")) XCTAssertNotEqual( store.pathForInteraction(localId: 1), store.pathForNote(localId: 1), "Interactions et notes ont des chemins distincts" ) } }
GitRust