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