AgendaSyncEngineTest.swift
503 lignes · 21874 octets
import XCTest @testable import Card2vcf /// Task M5 (Android): LWW RDV, reservation 409 conflict, abandon, `ressourcesQuery`, /// calendar↔store bridge. final class AgendaSyncEngineTest: 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) } private func epoch(_ iso: String) -> Int64 { parseIsoToEpochMs(iso) } // ---- LWW RDV ---- func testSyncNow_rdvPullNewerServerOverwritesCleanLocal() async throws { var local = RdvEntity() local.serverId = "rdv1" local.titre = "Ancien titre" local.updatedAt = epoch("2026-07-20T10:00:00Z") _ = try await db.rdvDao.upsert(local) api.pullResult = .ok( SyncPullResponse( serverTime: "2026-07-22T12:00:00Z", rdv: [ RendezVousDto( id: "rdv1", titre: "Nouveau titre serveur", debut: "2026-07-22T09:00:00Z", fin: "2026-07-22T10:00:00Z", creeLe: "2026-07-20T10:00:00Z", misAJourLe: "2026-07-22T10:00:00Z" ), ] ) ) _ = try await engine.syncNow() let updated = try await db.rdvDao.getByServerId("rdv1") XCTAssertEqual("Nouveau titre serveur", updated?.titre) } func testSyncNow_rdvPullDirtyLocalNewerKeepsLocal() async throws { var local = RdvEntity() local.serverId = "rdv2" local.titre = "Titre local en avance" local.updatedAt = epoch("2026-07-22T10:00:00Z") local.dirtyLocal = true _ = try await db.rdvDao.upsert(local) api.pullResult = .ok( SyncPullResponse( serverTime: "2026-07-22T12:00:00Z", rdv: [ RendezVousDto( id: "rdv2", titre: "Titre serveur périmé", debut: "2026-07-19T09:00:00Z", fin: "2026-07-19T10:00:00Z", creeLe: "2026-07-18T10:00:00Z", misAJourLe: "2026-07-20T10:00:00Z" ), ] ) ) _ = try await engine.syncNow() let kept = try await db.rdvDao.getByServerId("rdv2") XCTAssertEqual("Titre local en avance", kept?.titre) XCTAssertEqual(true, kept?.dirtyLocal) } // ---- Conflit 409 réservation ---- func testSyncNow_createReservation409SetsConflictPendingAndKeepsSyncOp() async throws { var reservation = ReservationEntity() reservation.cibleType = "salle" reservation.cibleId = "salle-1" reservation.motif = "Réunion" reservation.dirtyLocal = true let localId = try await db.reservationDao.upsert(reservation) var op = SyncOpEntity(entityType: "reservation", op: "create") op.payloadJson = #"{"debut":"2026-07-22T09:00:00Z","fin":"2026-07-22T10:00:00Z"}"# op.localId = localId op.createdAt = 1 _ = try await db.syncOpDao.insert(op) api.createReservationResult = .err(code: 409, message: "Chevauchement avec une autre réservation") let result = try await engine.syncNow() XCTAssertFalse(result.conflicts.isEmpty) XCTAssertEqual(localId, result.conflicts.first?.localReservationId) // Le 409 réservation reste arbitré par la dialog dédiée : pas d'échec générique dans le résumé. XCTAssertTrue(result.pushFailures.isEmpty) XCTAssertEqual(0, result.pushed) let stored = try await db.reservationDao.getByLocalId(localId) XCTAssertEqual(true, stored?.conflictPending) let pending = try await db.syncOpDao.listAll() XCTAssertEqual(1, pending.count) XCTAssertEqual(1, api.createReservationCalls.count) XCTAssertEqual("salle", api.createReservationCalls[0].0) XCTAssertEqual("salle-1", api.createReservationCalls[0].1) } // ---- Motif d'une réservation créée dans l'app Calendrier ---- func testAgendaEventCree_pousseLeTitreCommeMotif() async throws { let bridge = FakeCalendarBridge() let calendarId = try bridge.ensureLocalCalendar(displayName: "PicLead — Véhicule kangoo") // Cas réel : l'app Calendrier remplit le titre, pas la description. bridge.seedEvent( calendarId: calendarId, snap: CalendarEventSnapshot(title: "Course fournisseur", debutMs: 1_000, finMs: 2_000) ) let bindings = [ CalendarBinding(kind: "vehicule", serverResourceId: "v1", displayName: "Kangoo", androidCalendarId: calendarId), ] _ = try await engine.syncNow(bindings: bindings, bridge: bridge) let stored = try await db.reservationDao.listByCible(cibleType: "vehicule", cibleId: "v1") XCTAssertEqual(1, stored.count) XCTAssertEqual("Course fournisseur", stored.first?.motif) XCTAssertEqual(1, api.createReservationCalls.count) let corps = api.createReservationCalls[0].2 XCTAssertTrue(corps.contains(#""motif":"Course fournisseur""#), "motif attendu dans \(corps)") } // ---- Abandon ---- func testAbandonLocalReservation_removesEntitySyncOpsAndCalendarEvent() async throws { var reservation = ReservationEntity() reservation.cibleType = "salle" reservation.cibleId = "salle-1" reservation.motif = "Réunion" reservation.conflictPending = true reservation.calendarEventId = 42 let localId = try await db.reservationDao.upsert(reservation) var op = SyncOpEntity(entityType: "reservation", op: "create") op.localId = localId op.createdAt = 1 _ = try await db.syncOpDao.insert(op) let bridge = FakeCalendarBridge() try await engine.abandonLocalReservation(localId: localId, bridge: bridge) let stored = try await db.reservationDao.getByLocalId(localId) XCTAssertNil(stored) let pending = try await db.syncOpDao.listAll() XCTAssertTrue(pending.isEmpty) XCTAssertTrue(bridge.deletedEventIds.contains(42)) } // ---- ressourcesQuery ---- func testRessourcesQuery_buildsFromNonRdvBindingsWithServerResourceId() { let coordinator = AgendaSyncCoordinator(db: db) let bindings = [ CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: 1), CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: 2), CalendarBinding(kind: "materiel", serverResourceId: "m1", displayName: "Matériel B", androidCalendarId: 3), CalendarBinding(kind: "vehicule", serverResourceId: nil, displayName: "Sans id serveur", androidCalendarId: 4), ] XCTAssertEqual("salle:s1,materiel:m1", coordinator.ressourcesQuery(bindings)) } func testRessourcesQuery_emptyBindingsReturnsNil() { let coordinator = AgendaSyncCoordinator(db: db) XCTAssertNil(coordinator.ressourcesQuery([])) } func testSyncNow_pullUsesRessourcesQueryDerivedFromBindings() async throws { let bridge = FakeCalendarBridge() let rdvCalendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV") let salleCalendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Salle A") let bindings = [ CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: rdvCalendarId), CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: salleCalendarId), ] _ = try await engine.syncNow(bindings: bindings, bridge: bridge) XCTAssertEqual("salle:s1", api.pullQueries.last ?? nil) } func testCheckStatus_forwardsRessourcesQueryToApi() async throws { _ = try await engine.checkStatus(ressourcesQuery: "salle:s1") XCTAssertEqual("salle:s1", api.statusQueries.last ?? nil) } func testSyncNow_withoutBindingsOrBridgeOmitsRessourcesQuery() async throws { _ = try await engine.syncNow() XCTAssertNil(api.pullQueries.last ?? nil) } // ---- Agenda -> Room / Room -> Agenda ---- func testSyncNow_agendaToRoomCreatesRdvFromNewCalendarEventAndPushesIt() async throws { let bridge = FakeCalendarBridge() let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV") bridge.seedEvent( calendarId: calendarId, snap: CalendarEventSnapshot(title: "RDV client", debutMs: 1_000, finMs: 2_000, descriptionBody: "notes") ) let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)] _ = try await engine.syncNow(bindings: bindings, bridge: bridge) let stored = try await db.rdvDao.listAll() XCTAssertEqual(1, stored.count) XCTAssertEqual("RDV client", stored.first?.titre) XCTAssertEqual(1, api.createRdvCalls.count) let pending = try await db.syncOpDao.listAll() XCTAssertTrue(pending.allSatisfy { $0.entityType != "rdv" }) } func testSyncNow_roomToAgendaUpsertsRdvEventAndStoresCalendarEventId() async throws { let bridge = FakeCalendarBridge() let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV") var rdv = RdvEntity() rdv.serverId = "rdv-9" rdv.titre = "RDV serveur" rdv.debutMs = 5_000 rdv.finMs = 6_000 rdv.updatedAt = 1 let localId = try await db.rdvDao.upsert(rdv) let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)] _ = try await engine.syncNow(bindings: bindings, bridge: bridge) let updated = try await db.rdvDao.getByLocalId(localId) XCTAssertNotNil(updated?.calendarEventId) let events = try bridge.listEvents(calendarId: calendarId) XCTAssertEqual(1, events.count) XCTAssertEqual("RDV serveur", events.first?.title) } // ---- Fix HIGH : roomToAgenda deletes orphan events ---- func testSyncNow_roomToAgendaDeletesOrphanEventAfterRdvTombstone() async throws { let bridge = FakeCalendarBridge() let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV") let eventId = bridge.seedEvent( calendarId: calendarId, snap: CalendarEventSnapshot(title: "RDV serveur", debutMs: 1_000, finMs: 2_000, serverId: "rdv-old") ) var rdv = RdvEntity() rdv.serverId = "rdv-old" rdv.titre = "RDV serveur" rdv.calendarEventId = eventId rdv.updatedAt = 1 _ = try await db.rdvDao.upsert(rdv) let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)] api.pullResult = .ok( SyncPullResponse( serverTime: "2026-07-22T12:00:00Z", tombstones: [TombstoneDto(entityType: "rdv", id: "rdv-old", supprimeLe: "2026-07-22T12:00:00Z")] ) ) _ = try await engine.syncNow(bindings: bindings, bridge: bridge) let stored = try await db.rdvDao.getByServerId("rdv-old") XCTAssertNil(stored) XCTAssertTrue(bridge.deletedEventIds.contains(eventId)) XCTAssertTrue(try bridge.listEvents(calendarId: calendarId).isEmpty) } func testSyncNow_roomToAgendaDeletesOrphanEventAfterReservationBecomesInactive() async throws { let bridge = FakeCalendarBridge() let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Salle A") let eventId = bridge.seedEvent( calendarId: calendarId, snap: CalendarEventSnapshot(title: "Réunion", debutMs: 1_000, finMs: 2_000, serverId: "resa-1") ) var reservation = ReservationEntity() reservation.serverId = "resa-1" reservation.cibleType = "salle" reservation.cibleId = "s1" reservation.motif = "Réunion" reservation.calendarEventId = eventId reservation.updatedAt = 1 reservation.statut = "active" _ = try await db.reservationDao.upsert(reservation) let bindings = [CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: calendarId)] api.pullResult = .ok( SyncPullResponse( serverTime: "2026-07-22T12:00:00Z", reservations: [ ReservationDto( id: "resa-1", cible: CibleRessourceDto(type: "salle", id: "s1"), debut: "2026-07-22T09:00:00Z", fin: "2026-07-22T10:00:00Z", statut: "annulee", creeLe: "2026-07-20T10:00:00Z", misAJourLe: "2026-07-22T11:00:00Z" ), ] ) ) _ = try await engine.syncNow(bindings: bindings, bridge: bridge) let stored = try await db.reservationDao.getByServerId("resa-1") XCTAssertNil(stored) XCTAssertTrue(bridge.deletedEventIds.contains(eventId)) } func testSyncNow_roomToAgendaKeepsPendingPushEventEvenWithoutServerId() async throws { let bridge = FakeCalendarBridge() let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV") let eventId = bridge.seedEvent( calendarId: calendarId, snap: CalendarEventSnapshot(title: "Nouveau RDV", debutMs: 1_000, finMs: 2_000) ) let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)] api.createRdvResult = .err(code: -1, message: "Réseau indisponible") _ = try await engine.syncNow(bindings: bindings, bridge: bridge) let stored = try await db.rdvDao.listAll() XCTAssertEqual(1, stored.count) XCTAssertNil(stored.first?.serverId) XCTAssertTrue(bridge.deletedEventIds.isEmpty) XCTAssertTrue(try bridge.listEvents(calendarId: calendarId).contains { $0.eventId == eventId }) } // ---- Fix MEDIUM : agendaToRoom detects calendar-side deletions ---- func testAgendaToRoom_detectsDeletedRdvEventWithServerIdEnqueuesDeleteOp() async throws { let bridge = FakeCalendarBridge() let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV") var rdv = RdvEntity() rdv.serverId = "rdv-5" rdv.titre = "RDV supprimé" rdv.calendarEventId = 99 rdv.updatedAt = 1 _ = try await db.rdvDao.upsert(rdv) let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)] let coordinator = AgendaSyncCoordinator(db: db) try await coordinator.agendaToRoom(bindings: bindings, bridge: bridge) let ops = try await db.syncOpDao.listAll().filter { $0.entityType == "rdv" } XCTAssertEqual(1, ops.count) XCTAssertEqual("delete", ops[0].op) XCTAssertEqual("rdv-5", ops[0].serverId) let stored = try await db.rdvDao.getByServerId("rdv-5") XCTAssertNil(stored) } func testAgendaToRoom_detectsDeletedRdvEventWithoutServerIdRemovesLocalEntityDirectly() async throws { let bridge = FakeCalendarBridge() let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV") var rdv = RdvEntity() rdv.titre = "RDV jamais poussé" rdv.calendarEventId = 77 rdv.updatedAt = 1 let localId = try await db.rdvDao.upsert(rdv) let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)] let coordinator = AgendaSyncCoordinator(db: db) try await coordinator.agendaToRoom(bindings: bindings, bridge: bridge) let stored = try await db.rdvDao.getByLocalId(localId) XCTAssertNil(stored) let ops = try await db.syncOpDao.listAll() XCTAssertTrue(ops.allSatisfy { $0.entityType != "rdv" }) } func testAgendaToRoom_detectsDeletedReservationEventWithServerIdEnqueuesDeleteOp() async throws { let bridge = FakeCalendarBridge() let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Salle A") var reservation = ReservationEntity() reservation.serverId = "resa-9" reservation.cibleType = "salle" reservation.cibleId = "s1" reservation.motif = "Réunion supprimée" reservation.calendarEventId = 55 reservation.updatedAt = 1 _ = try await db.reservationDao.upsert(reservation) let bindings = [CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: calendarId)] let coordinator = AgendaSyncCoordinator(db: db) try await coordinator.agendaToRoom(bindings: bindings, bridge: bridge) let ops = try await db.syncOpDao.listAll().filter { $0.entityType == "reservation" } XCTAssertEqual(1, ops.count) XCTAssertEqual("delete", ops[0].op) XCTAssertEqual("resa-9", ops[0].serverId) let stored = try await db.reservationDao.getByServerId("resa-9") XCTAssertNil(stored) } func testSyncNow_pushesDeleteOpFromDeletedReservationEventUsingPayloadCibleFallback() async throws { let bridge = FakeCalendarBridge() let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Salle A") var reservation = ReservationEntity() reservation.serverId = "resa-9" reservation.cibleType = "salle" reservation.cibleId = "s1" reservation.motif = "Réunion supprimée" reservation.calendarEventId = 55 reservation.updatedAt = 1 _ = try await db.reservationDao.upsert(reservation) let bindings = [CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: calendarId)] // agendaToRoom (via syncNow) deletes the local entity; pushOps must then // resolve the target from the op payload. _ = try await engine.syncNow(bindings: bindings, bridge: bridge) XCTAssertEqual(1, api.deleteReservationCalls.count) XCTAssertEqual("salle", api.deleteReservationCalls[0].0) XCTAssertEqual("s1", api.deleteReservationCalls[0].1) XCTAssertEqual("resa-9", api.deleteReservationCalls[0].2) } func testAgendaToRoom_conflictPendingReservationNotDeletedWhenEventMissing() async throws { let bridge = FakeCalendarBridge() let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Salle A") var reservation = ReservationEntity() reservation.serverId = "resa-conflict" reservation.cibleType = "salle" reservation.cibleId = "s1" reservation.motif = "En conflit" reservation.calendarEventId = 12 reservation.updatedAt = 1 reservation.conflictPending = true _ = try await db.reservationDao.upsert(reservation) let bindings = [CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: calendarId)] let coordinator = AgendaSyncCoordinator(db: db) try await coordinator.agendaToRoom(bindings: bindings, bridge: bridge) let stored = try await db.reservationDao.getByServerId("resa-conflict") XCTAssertNotNil(stored) let ops = try await db.syncOpDao.listAll() XCTAssertTrue(ops.allSatisfy { $0.entityType != "reservation" }) } // ---- Fix MEDIUM : localAheadCount with orphan events ---- func testLocalAheadCount_withoutBridgeCountsDirtyAndPendingOpsOnly() async throws { var rdv = RdvEntity() rdv.titre = "Dirty" rdv.dirtyLocal = true _ = try await db.rdvDao.upsert(rdv) var op = SyncOpEntity(entityType: "reservation", op: "create") op.createdAt = 1 _ = try await db.syncOpDao.insert(op) let count = try await engine.localAheadCount() XCTAssertEqual(2, count) } func testLocalAheadCount_withBridgeIncludesOrphanTaggedEvents() async throws { let bridge = FakeCalendarBridge() let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV") bridge.seedEvent( calendarId: calendarId, snap: CalendarEventSnapshot(title: "Orphelin", debutMs: 1_000, finMs: 2_000, serverId: "rdv-orphan") ) let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)] let count = try await engine.localAheadCount(bindings: bindings, bridge: bridge) XCTAssertEqual(1, count) } // ---- Fix LOW : delete reservation without serverId ---- func testPushReservationDeleteWithoutServerIdDoesNotDeleteSyncOp() async throws { var reservation = ReservationEntity() reservation.cibleType = "salle" reservation.cibleId = "s1" reservation.motif = "x" let localId = try await db.reservationDao.upsert(reservation) var op = SyncOpEntity(entityType: "reservation", op: "delete") 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) } }
GitRust