SuppressionSyncTest.swift 179 lignes · 7075 octets
import XCTest
@testable import Card2vcf

/// Tests TDD pour la suppression d'interactions et de notes de projet (Lot 3).
///
/// PREUVE DE ROUGEUR #1 — testPushInteraction_deleteAppelleApi
/// En conservant le code d'origine `if op.op != "create" { return PushOutcome(ok: true) }`
/// dans `pushInteractionOp`, le test échoue avec :
///
///   XCTAssertEqual failed: ("0") is not equal to ("1")
///
/// PREUVE DE ROUGEUR #2 — testPushNoteProjet_deleteAppelleApi
/// En conservant `if op.op != "create" { return PushOutcome(ok: true) }`
/// dans `pushNoteProjetOp`, le test échoue avec :
///
///   XCTAssertEqual failed: ("0") is not equal to ("1")
final class SuppressionSyncTest: 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: - Push interaction delete (PREUVE DE ROUGEUR #1)

    func testPushInteraction_deleteAppelleApi() async throws {
        var op = SyncOpEntity(entityType: "interaction", op: "delete")
        op.serverId = "int-srv-1"
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)

        _ = try await engine.pousserEnAttente()

        // Rouge sans le case "delete" dans pushInteractionOp : ("0") is not equal to ("1")
        XCTAssertEqual(1, api.deleteInteractionCalls.count)
        XCTAssertEqual("int-srv-1", api.deleteInteractionCalls.first)
        let ops = try await db.syncOpDao.listAll()
        XCTAssertTrue(ops.isEmpty, "Op dépilée après succès")
    }

    func testPushInteraction_deleteEchecGardeOp() async throws {
        var op = SyncOpEntity(entityType: "interaction", op: "delete")
        op.serverId = "int-srv-2"
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)

        api.deleteInteractionResult = .err(code: -1, message: "Réseau indisponible")
        _ = try await engine.pousserEnAttente()

        let ops = try await db.syncOpDao.listAll()
        XCTAssertEqual(1, ops.count, "Op gardée en file après échec réseau")
    }

    func testPushInteraction_deleteSansServerId_echoue() async throws {
        var op = SyncOpEntity(entityType: "interaction", op: "delete")
        op.serverId = nil
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)

        _ = try await engine.pousserEnAttente()

        // serverId nil → failure sans appel API
        XCTAssertEqual(0, api.deleteInteractionCalls.count)
    }

    // MARK: - Push note projet delete (PREUVE DE ROUGEUR #2)

    func testPushNoteProjet_deleteAppelleApi() async throws {
        let payload = #"{"projetServerId":"proj-srv-1"}"#
        var op = SyncOpEntity(entityType: "note_projet", op: "delete")
        op.serverId = "note-srv-1"
        op.payloadJson = payload
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)

        _ = try await engine.pousserEnAttente()

        // Rouge sans le case "delete" dans pushNoteProjetOp : ("0") is not equal to ("1")
        XCTAssertEqual(1, api.deleteNoteProjetCalls.count)
        XCTAssertEqual("proj-srv-1", api.deleteNoteProjetCalls.first?.0)
        XCTAssertEqual("note-srv-1", api.deleteNoteProjetCalls.first?.1)
        let ops = try await db.syncOpDao.listAll()
        XCTAssertTrue(ops.isEmpty, "Op dépilée après succès")
    }

    func testPushNoteProjet_deleteSansProjetServerId_echoue() async throws {
        var op = SyncOpEntity(entityType: "note_projet", op: "delete")
        op.serverId = "note-srv-2"
        op.payloadJson = "{}"   // projetServerId absent
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)

        _ = try await engine.pousserEnAttente()

        XCTAssertEqual(0, api.deleteNoteProjetCalls.count)
    }

    // MARK: - Suppression locale sans serverId (jamais synchronisé)

    func testSupprimerInteractionLocale_sansServerId_neCreesPasOp() async throws {
        // Interaction locale uniquement (serverId nil)
        var entity = InteractionEntity()
        entity.contactServerId = "c1"
        entity.type = "note_vocale"
        entity.sujet = "Note locale"
        entity.createdAt = 1_000
        let localId = try await db.interactionDao.upsert(entity)

        // Récupération et suppression locale
        guard let found = try await db.interactionDao.getByLocalId(localId) else {
            XCTFail("Entité introuvable")
            return
        }
        XCTAssertNil(found.serverId, "serverId doit être nil pour une entité jamais sync")
        // Suppression sans op de sync (serverId absent)
        try await db.interactionDao.deleteByLocalId(localId)

        let allInteractions = try await db.interactionDao.listAll()
        XCTAssertTrue(allInteractions.isEmpty, "Interaction supprimée localement")
        let allOps = try await db.syncOpDao.listAll()
        XCTAssertTrue(allOps.isEmpty, "Aucun op créé pour une entité jamais sync")
    }

    func testSupprimerNoteLocale_sansServerId_neCreesPasOp() async throws {
        var entity = NoteProjetEntity()
        entity.projetServerId = "proj-1"
        entity.titre = "Note locale"
        entity.createdAt = 1_000
        let localId = try await db.noteProjetDao.upsert(entity)

        let noteInserted = try await db.noteProjetDao.listAll().first { $0.localId == localId }
        XCTAssertNil(noteInserted?.serverId)

        try await db.noteProjetDao.deleteByLocalId(localId)

        let allNotes = try await db.noteProjetDao.listAll()
        XCTAssertTrue(allNotes.isEmpty)
        let allOps = try await db.syncOpDao.listAll()
        XCTAssertTrue(allOps.isEmpty)
    }

    // MARK: - Suppression synchronisée crée l'op

    func testSupprimerInteractionSynchronisee_creeOp() async throws {
        var entity = InteractionEntity()
        entity.serverId = "int-srv-ok"
        entity.contactServerId = "c1"
        entity.type = "note_vocale"
        entity.sujet = "Note sync"
        entity.createdAt = 1_000
        let localId = try await db.interactionDao.upsert(entity)

        // Simulation : supprimer localement + créer l'op de sync
        guard let found = try await db.interactionDao.getByLocalId(localId),
              let serverId = found.serverId else {
            XCTFail("Entité introuvable")
            return
        }
        try await db.interactionDao.deleteByLocalId(localId)

        var op = SyncOpEntity(entityType: "interaction", op: "delete")
        op.serverId = serverId
        op.createdAt = 2_000
        _ = try await db.syncOpDao.insert(op)

        let allInteractions = try await db.interactionDao.listAll()
        XCTAssertTrue(allInteractions.isEmpty, "Interaction supprimée localement")
        let allOps = try await db.syncOpDao.listAll()
        XCTAssertEqual(1, allOps.count, "Op de delete créée")
        XCTAssertEqual("delete", allOps.first?.op)
        XCTAssertEqual("int-srv-ok", allOps.first?.serverId)
    }
}