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