SyncEngineTest.swift 361 lignes · 14376 octets
import XCTest
@testable import Card2vcf

final class SyncEngineTest: XCTestCase {
    private var db: InMemoryCrmDatabase!
    private var api: FakeAilianceApi!
    private var engine: SyncEngine!
    private var imagesRoot: URL!

    override func setUp() {
        super.setUp()
        db = InMemoryCrmDatabase()
        api = FakeAilianceApi()
        engine = SyncEngine(api: api, db: db)
        imagesRoot = FileManager.default.temporaryDirectory
            .appendingPathComponent("SyncEngineTest-\(UUID().uuidString)", isDirectory: true)
    }

    override func tearDown() {
        try? FileManager.default.removeItem(at: imagesRoot)
        super.tearDown()
    }

    private func makeImageStore() -> ContactImageStore {
        ContactImageStore(rootDirectory: imagesRoot)
    }

    func testCheckStatus_returnsTotalFromFake() async throws {
        api.statusResult = .ok(
            SyncStatusResponse(
                serverTime: "2026-07-22T12:00:00Z",
                changes: SyncChanges(contacts: 2, tombstones: 1),
                total: 3
            )
        )

        let result = try await engine.checkStatus()

        XCTAssertEqual(3, result.pendingRemoteChanges)
        XCTAssertEqual(3, result.response?.total)
    }

    func testSyncNow_pullAppliesNewerContact() async throws {
        var local = CrmContactEntity()
        local.serverId = "c1"
        local.fullName = "Old Name"
        local.updatedAt = 1_000
        _ = try await db.crmContactDao.insert(local)
        api.pullResult = .ok(
            SyncPullResponse(
                serverTime: "2026-07-22T12:05:00Z",
                contacts: [
                    ContactDto(
                        id: "c1",
                        prenom: "Jean",
                        nom: "Dupont",
                        creeLe: "2026-07-20T10:00:00Z",
                        misAJourLe: "2026-07-22T11:00:00Z"
                    ),
                ]
            )
        )

        _ = try await engine.syncNow()

        let updated = try await db.crmContactDao.getByServerId("c1")
        XCTAssertEqual("Jean Dupont", updated?.fullName)
    }

    func testSyncNow_pullKeepsBothAppendOnlyInteractions() async throws {
        api.pullResult = .ok(
            SyncPullResponse(
                serverTime: "2026-07-22T12:05:00Z",
                interactions: [
                    InteractionDto(id: "i1", contactId: "c1", sujet: "Appel", creeLe: "2026-07-22T09:00:00Z"),
                    InteractionDto(id: "i2", contactId: "c1", sujet: "Relance", creeLe: "2026-07-22T10:00:00Z"),
                ]
            )
        )

        _ = try await engine.syncNow()

        let stored = try await db.interactionDao.listByContactServerId("c1")
        XCTAssertEqual(2, stored.count)
        XCTAssertTrue(stored.contains { $0.serverId == "i1" })
        XCTAssertTrue(stored.contains { $0.serverId == "i2" })
    }

    func testSyncNow_tombstoneDeletesContact() async throws {
        var local = CrmContactEntity()
        local.serverId = "c-old"
        local.fullName = "À supprimer"
        local.updatedAt = 500
        _ = try await db.crmContactDao.insert(local)
        api.pullResult = .ok(
            SyncPullResponse(
                serverTime: "2026-07-22T12:05:00Z",
                tombstones: [
                    TombstoneDto(entityType: "contact", id: "c-old", supprimeLe: "2026-07-22T12:00:00Z"),
                ]
            )
        )

        _ = try await engine.syncNow()

        let stored = try await db.crmContactDao.getByServerId("c-old")
        XCTAssertNil(stored)
    }

    func testSyncNow_updatesWatermarkFromServerTime() async throws {
        api.pullResult = .ok(SyncPullResponse(serverTime: "2026-07-22T12:05:00Z"))

        let result = try await engine.syncNow()

        XCTAssertTrue(result.success)
        XCTAssertEqual("2026-07-22T12:05:00Z", result.serverTime)
        let watermark = try await db.syncMetaDao.get("watermark")
        XCTAssertEqual("2026-07-22T12:05:00Z", watermark?.value)
    }

    func testSyncNow_pushesContactCreateOpAndMapsServerId() async throws {
        var contact = CrmContactEntity()
        contact.fullName = "Nouveau"
        let localId = try await db.crmContactDao.insert(contact)
        var op = SyncOpEntity(entityType: "contact", op: "create")
        op.payloadJson = #"{"fullName":"Nouveau"}"#
        op.localId = localId
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)

        _ = try await engine.syncNow()

        XCTAssertEqual(1, api.createContactCalls.count)
        let pending = try await db.syncOpDao.listAll()
        XCTAssertEqual(0, pending.count)
        let stored = try await db.crmContactDao.getById(localId)
        XCTAssertEqual("srv-generated", stored?.serverId)
    }

    func testSyncNow_pushesCardImageAfterContactCreate() async throws {
        let images = makeImageStore()
        engine = SyncEngine(api: api, db: db, imageStore: images)

        var contact = CrmContactEntity()
        contact.fullName = "Avec carte"
        let localId = try await db.crmContactDao.insert(contact)
        let cardPath = try images.saveBytes(contactId: localId, name: "card.jpg", bytes: Data([1, 2, 3, 4]))
        var stored = try await db.crmContactDao.getById(localId)!
        stored.cardImagePath = cardPath
        try await db.crmContactDao.update(stored)
        var op = SyncOpEntity(entityType: "contact", op: "create")
        op.payloadJson = #"{"prenom":"A","nom":"B"}"#
        op.localId = localId
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)

        _ = try await engine.syncNow()

        XCTAssertEqual(1, api.createContactCalls.count)
        XCTAssertEqual(1, api.uploadCarteCalls.count)
        XCTAssertEqual("srv-generated", api.uploadCarteCalls[0].0)
        XCTAssertEqual(4, api.uploadCarteCalls[0].1)
        let pending = try await db.syncOpDao.listAll()
        XCTAssertEqual(0, pending.count)
    }

    func testSyncNow_enqueuesMediaRetryWhenUploadFails() async throws {
        let images = makeImageStore()
        engine = SyncEngine(api: api, db: db, imageStore: images)
        api.uploadCarteResult = .err(code: 500, message: "boom")

        var contact = CrmContactEntity()
        contact.fullName = "Retry"
        let localId = try await db.crmContactDao.insert(contact)
        let cardPath = try images.saveBytes(contactId: localId, name: "card.jpg", bytes: Data([9]))
        var stored = try await db.crmContactDao.getById(localId)!
        stored.cardImagePath = cardPath
        try await db.crmContactDao.update(stored)
        var op = SyncOpEntity(entityType: "contact", op: "create")
        op.payloadJson = #"{"prenom":"A","nom":"B"}"#
        op.localId = localId
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)

        _ = try await engine.syncNow()

        let pending = try await db.syncOpDao.listAll()
        XCTAssertEqual(1, pending.count)
        XCTAssertEqual("contact_media", pending[0].entityType)
        XCTAssertEqual("srv-generated", pending[0].serverId)
    }

    func testSyncNow_pullDownloadsMissingCardImage() async throws {
        let images = makeImageStore()
        engine = SyncEngine(api: api, db: db, imageStore: images)
        api.downloadCarteResult = .ok(Data([7, 8, 9]))
        api.pullResult = .ok(
            SyncPullResponse(
                serverTime: "2026-07-22T12:05:00Z",
                contacts: [
                    ContactDto(
                        id: "c-img",
                        prenom: "Marie",
                        nom: "Curie",
                        creeLe: "2026-07-20T10:00:00Z",
                        carteVisite: "carte.jpg"
                    ),
                ]
            )
        )

        _ = try await engine.syncNow()

        XCTAssertEqual(["c-img"], api.downloadCarteCalls)
        let stored = try await db.crmContactDao.getByServerId("c-img")
        let path = stored?.cardImagePath
        XCTAssertNotNil(path)
        XCTAssertFalse(path!.isEmpty)
        var isDirectory: ObjCBool = false
        XCTAssertTrue(FileManager.default.fileExists(atPath: path!, isDirectory: &isDirectory))
        XCTAssertFalse(isDirectory.boolValue)
    }

    func testWatermark_ignoreLorsqueLeServeurOuLeCompteChange() async throws {
        // Synchro réussie avec l'identité A : watermark posé à serverTime.
        api.pullResult = .ok(SyncPullResponse(serverTime: "2026-09-11T22:00:00Z"))
        let engineA = SyncEngine(api: api, db: db, serverIdentity: "https://prod.example|alice")
        _ = try await engineA.syncNow()
        let engineA2 = SyncEngine(api: api, db: db, serverIdentity: "https://prod.example|alice")
        _ = try await engineA2.syncNow()
        XCTAssertEqual("2026-09-11T22:00:00Z", api.pullSince.last)

        // Même base locale, mais serveur différent : le pull doit repartir de l'époque,
        // sinon les données plus anciennes que le watermark hérité ne redescendent jamais.
        let engineB = SyncEngine(api: api, db: db, serverIdentity: "https://demo.example|alice")
        _ = try await engineB.syncNow()
        XCTAssertEqual("1970-01-01T00:00:00Z", api.pullSince.last)
    }

    func testSyncNow_pushesProjetCreateOpAndMapsServerId() async throws {
        var projet = ProjetEntity()
        projet.nom = "Salon"
        projet.workflowServerId = "wf-1"
        let localId = try await db.projetDao.upsert(projet)
        var op = SyncOpEntity(entityType: "projet", op: "create")
        op.payloadJson = #"{"nom":"Salon","workflow_id":"wf-1"}"#
        op.localId = localId
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)

        _ = try await engine.syncNow()

        XCTAssertEqual(1, api.createProjetCalls.count)
        let pending = try await db.syncOpDao.listAll()
        XCTAssertEqual(0, pending.count)
        let stored = try await db.projetDao.getByLocalId(localId)
        XCTAssertEqual("projet-generated", stored?.serverId)
    }

    // ---- Retour utilisateur : compteurs et échecs de push ----

    func testSyncNow_pushRefusedKeepsOpAndReportsServerMessage() async throws {
        var rdv = RdvEntity()
        rdv.titre = "Point client"
        rdv.dirtyLocal = true
        let localId = try await db.rdvDao.upsert(rdv)
        var op = SyncOpEntity(entityType: AgendaSyncCoordinator.kindRdv, op: "create")
        op.payloadJson = #"{"titre":"Point client"}"#
        op.localId = localId
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)
        api.createRdvResult = .err(code: 409, message: "Réservation refusée : période en conflit avec Entretien")

        let result = try await engine.syncNow()

        XCTAssertTrue(result.success)
        let pending = try await db.syncOpDao.listAll()
        XCTAssertEqual(1, pending.count)
        XCTAssertEqual(0, result.pushed)
        XCTAssertEqual(1, result.pushFailures.count)
        let failure = result.pushFailures[0]
        XCTAssertEqual(AgendaSyncCoordinator.kindRdv, failure.entityType)
        XCTAssertEqual("create", failure.op)
        XCTAssertEqual(409, failure.code)
        XCTAssertEqual("Réservation refusée : période en conflit avec Entretien", failure.message)
    }

    func testSyncNow_pushFailureMarksOpWithAttemptsAndLastError() async throws {
        var contact = CrmContactEntity()
        contact.fullName = "Hors ligne"
        let localId = try await db.crmContactDao.insert(contact)
        var op = SyncOpEntity(entityType: "contact", op: "create")
        op.payloadJson = #"{"fullName":"Hors ligne"}"#
        op.localId = localId
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)
        api.createContactResult = .err(code: 500, message: "Erreur interne.")

        _ = try await engine.syncNow()

        let ops = try await db.syncOpDao.listAll()
        XCTAssertEqual(1, ops.count)
        XCTAssertEqual(1, ops[0].attempts)
        XCTAssertEqual("Erreur interne.", ops[0].lastError)

        // Le push suivant réussit : l'op est dépilée, le badge disparaît.
        api.createContactResult = .ok(#"{"id":"srv-generated"}"#)
        _ = try await engine.syncNow()
        let afterRetry = try await db.syncOpDao.listAll()
        XCTAssertEqual(0, afterRetry.count)
    }

    func testSyncNow_reportsPushedAndReceivedCounts() async throws {
        var contact = CrmContactEntity()
        contact.fullName = "Nouveau"
        let localId = try await db.crmContactDao.insert(contact)
        var op = SyncOpEntity(entityType: "contact", op: "create")
        op.payloadJson = #"{"prenom":"A","nom":"B"}"#
        op.localId = localId
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)
        api.pullResult = .ok(
            SyncPullResponse(
                serverTime: "2026-07-22T12:05:00Z",
                contacts: [
                    ContactDto(id: "c1", prenom: "Jean", nom: "Dupont", creeLe: "2026-07-20T10:00:00Z"),
                ],
                interactions: [
                    InteractionDto(id: "i1", contactId: "c1", sujet: "Appel", creeLe: "2026-07-22T09:00:00Z"),
                ],
                // Exclus du décompte utilisateur : référentiel et suppressions.
                tombstones: [
                    TombstoneDto(entityType: "contact", id: "c-old", supprimeLe: "2026-07-22T12:00:00Z"),
                ],
                workflows: [WorkflowDto(id: "w1", nom: "Défaut", creeLe: "2026-07-20T10:00:00Z")]
            )
        )

        let result = try await engine.syncNow()

        XCTAssertEqual(1, result.pushed)
        XCTAssertEqual(2, result.received)
        XCTAssertTrue(result.pushFailures.isEmpty)
    }

    func testSyncNow_skipsImageUploadWhenNoLocalFile() async throws {
        var contact = CrmContactEntity()
        contact.fullName = "Sans image"
        let localId = try await db.crmContactDao.insert(contact)
        var op = SyncOpEntity(entityType: "contact", op: "create")
        op.payloadJson = #"{"prenom":"A","nom":"B"}"#
        op.localId = localId
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)

        _ = try await engine.syncNow()

        XCTAssertEqual(0, api.uploadCarteCalls.count)
        XCTAssertEqual(0, api.uploadPhotoCalls.count)
    }
}